diff --git a/bootstrap.sh b/bootstrap.sh index 11322e1..9a5f470 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -101,31 +101,9 @@ remote() { ssh "${SSH_OPTS[@]}" "$FLIT_USER@$HOST" "$@"; } FLIT_LAPTOP_HOME=$HOME export FLIT_LAPTOP_HOME -# flit_remote_env — the FLIT_NAME/FLIT_USER/FLIT_SERVER/FLIT_LAPTOP_HOME -# assignment list every ssh call that runs on a stripped environment (`sudo` -# strips it; so does a non-login remote command) must carry explicitly. ONE -# function rather than five hand-written copies, so a variable added here -# reaches every call site instead of whichever ones happened to get edited — -# the failure this exists to prevent (§13.2). Callers append whatever else -# that particular remote script needs (NO_EXTERNAL_STATE, STORAGEBOX_USER, -# ...) after this. -flit_remote_env() { - # FLIT_GIT_NAME/FLIT_GIT_EMAIL are identifying (CLAUDE.md), so they live - # only in the gitignored local.conf, which phase_2_deliver() excludes from - # the rsync push by design — forwarding them here is the only route by - # which a value set there reaches the server at all. - # - # %q, not '%s'. A '%s' format is safe only while no value can contain a - # single quote, and FLIT_GIT_NAME breaks that assumption: an apostrophe in a - # personal name would close the quoting and hand the remainder of the - # assignment list to the far shell as code. %q quotes for a shell to read - # back, exists in bash 3.2 as well as 5, and renders an empty value as '' - # rather than as nothing — which is what stops an unset FLIT_GIT_EMAIL from - # swallowing the word after it. - printf "FLIT_NAME=%q FLIT_USER=%q FLIT_SERVER=%q FLIT_LAPTOP_HOME=%q FLIT_IN_DRILL=%q FLIT_GIT_NAME=%q FLIT_GIT_EMAIL=%q" \ - "$FLIT_NAME" "$FLIT_USER" "$FLIT_SERVER" "$FLIT_LAPTOP_HOME" "${FLIT_IN_DRILL:-}" \ - "${FLIT_GIT_NAME:-}" "${FLIT_GIT_EMAIL:-}" -} +# flit_remote_env() lives in lib/common.sh: laptop/sync-config.sh runs the same +# server-side script this file does, and a second hand-written copy of the +# assignment list is exactly the drift §13.2 keeps counting. # --------------------------------------------------------------- preflight @@ -555,7 +533,28 @@ phase_6_terminal() { fi } -phase_7_sshconfig() { log "phase 7: server ssh config"; run_server_lib 45-ssh-config.sh; } +# The key is installed BEFORE the ssh config that names it, so a run never +# leaves a config pointing at a path with nothing at it — ssh treats a missing +# IdentityFile as no identity at all and falls through to "Permission denied +# (publickey)", which reads like an unregistered key rather than a missing one. +# +# Skipped under --no-external-state: registering a key against the Tangled +# account is state shared with production, and a drill must not touch it. The +# drill box therefore has no Tangled key, which is why verify.sh's assertion is +# one the drills are expected to fail on rather than one they assert. +phase_7_sshconfig() { + log "phase 7: server ssh config" + if [[ $NO_EXTERNAL_STATE -eq 1 ]]; then + info "skipping the Tangled key: --no-external-state" + else + # EXIT_DEFERRED is success at the script's real job (§5.8), not failure: + # the key is installed and only its registration with the Tangled account + # is left to the operator, which no script here can do. + laptop/tangled-key.sh "$HOST" \ + || { [[ $? -eq $EXIT_DEFERRED ]] || die "tangled-key.sh failed"; } + fi + run_server_lib 45-ssh-config.sh +} phase_8_alerting() { log "phase 8: alerting (laptop half)" diff --git a/laptop/sync-config.sh b/laptop/sync-config.sh new file mode 100755 index 0000000..b529578 --- /dev/null +++ b/laptop/sync-config.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# laptop/sync-config.sh — push the WORKING TREE of the global agent +# configuration to the box, without a full provisioning run. +# +# laptop/sync-config.sh [host] +# +# WHY THIS EXISTS: bootstrap.sh phase_2_deliver() moves ~/.claude and +# ~/misc/chook with `git archive HEAD`, so the box runs whatever was committed +# at the last provisioning run and drifts behind every edit since — and +# check_clean() refuses a dirty tree outright, because git archive ships the +# last commit and silently omits the rest. That guard is correct for an archive +# and pointless here: this script ships the working tree, so an uncommitted +# tweak is the case it exists to serve rather than the case it must refuse. +# +# WHY THIS IS IN laptop/: it is a push, and the box never fetches its own +# configuration (§5.1). The direction IS the design. + +set -uo pipefail +cd "$(dirname "$0")/.." || exit 1 +# shellcheck source=lib/common.sh +source lib/common.sh +load_laptop_conf + +HOST=${1:-$FLIT_SERVER} + +# Set here for the same reason bootstrap.sh sets it: flit_remote_env() forwards +# it, and no server-side default could guess this laptop's home. +FLIT_LAPTOP_HOME=$HOME +export FLIT_LAPTOP_HOME + +SSH_OPTS=(-o BatchMode=yes -o StrictHostKeyChecking=accept-new) +remote() { ssh "${SSH_OPTS[@]}" "$FLIT_USER@$HOST" "$@"; } + +need_cmd rsync +need_cmd git + +# sync_tree