Install the selfhost server on macOS with one script - #422
Conversation
One idempotent script installs the coordinating server as a per-login LaunchAgent fronted by `tailscale serve` on the node's own MagicDNS name. Re-running it updates the installed release from the current checkout; it never pulls, switches branches, or installs an updater. Each release is self-contained — production server tree, lib/dist-pocket, and a copy of the exact Node binary the build ran under — so the service depends on neither the source checkout, nor Homebrew/nvm, nor pnpm's store, nor the user's interactive PATH. State and config live outside the releases and survive updates, prunes, and uninstall. Two silent-failure traps are encoded because testing hit both: - `pnpm deploy --prod --legacy` rewrites the *root* workspace state file to production:true/dev:false. Every later pnpm command in that checkout then decides the workspace is stale and tries `pnpm install --production`, which would strip the developer's devDependencies. Snapshot and restore it from an EXIT trap, so even a failed install leaves the checkout as it found it. - `mv -f tmp link` follows a symlink to a directory: swapping `current` this way deposits the temp link inside the old release and leaves `current` unmoved, making every update a silent no-op whose prune then deletes the new release. Use rename(2) on the link path and assert the switch landed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GDNJHHA95nvRdo4Cv3rAoi
Deploying mouseterm with
|
| Latest commit: |
627b987
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://401c1aef.mouseterm.pages.dev |
| Branch Preview URL: | https://selfhost-macos-installer.mouseterm.pages.dev |
dormouse-bot
left a comment
There was a problem hiding this comment.
Read the whole script rather than the diff, since it's all new. The build/stage/probe/switch/rollback spine holds up — the candidate probe on an ephemeral port with a throwaway state dir before current moves, the post-switch assertion that current actually advanced, the workspace-state EXIT trap, and the origin-mismatch stop are all doing real work, and the two traps you called out in the description are genuinely encoded rather than just described. Findings are in manage's operator surface and a couple of failure paths, not the install spine.
The one I'd fix before merging is manage uninstall: with no controlling terminal the [ -t 0 ] block is skipped entirely and it proceeds straight to launchctl bootout + rm -rf. manage uninstall </dev/null, or the same call from a script or a CI step, tears down the LaunchAgent and deletes releases/, current, previous and bin/ with no prompt. cmd_purge gets this right by accident (no [ -t 0 ] guard, so EOF yields an empty reply that fails the phrase check and aborts), and the installer's own confirm() takes the explicit position that no terminal means refuse — "refusing to assume an answer with no terminal". SELF_HOST.md §2 also asks for "remove LaunchAgent and installed code only after confirmation". Inline suggestion below.
Two smaller ones I didn't post inline:
RELEASES_TO_KEEP=2(line 31) is never read — the prune keeps releases by matching thecurrent/previouslink names, not by count. Worth deleting so it can't drift out of agreement with the prune it looks like it configures.cmd_rollback:node_bincan resolve to$ROOT/current/runtime/node, and it only gets there when$prev/runtime/nodewasn't executable. The firstatomic_symlinkthen pointscurrentatprev, so on the very next line$node_binnames the binary that was already rejected —atomic_symlinkfails,set -eaborts the function, and you're left withcurrentmoved butpreviousstill onprev. The release you rolled back from is then referenced by neither link and the next install's prune deletes it. Swapping the twoatomic_symlinkcalls (updatepreviousfirst, whilecurrentstill resolves) fixes it without touching the fallback.
Also worth a look, though it's your call and outside this diff: SELF_HOST.md still reads as a build plan for a script that now exists ("### 1: author the local installer", "Create and review: deploy/local/install-macos.sh"). The runbook sections below it are fine, but §1–§3 describe work this PR completes.
The installer findings, all of which fail quietly: - `manage uninstall` skipped its confirmation entirely with no controlling terminal and fell straight through to `launchctl bootout` + `rm -rf`. Any non-interactive call uninstalled silently. No terminal is now a refusal, which is the stance the installer's own `confirm()` already takes. - Uninstall's `tailscale serve reset` fallback clears the node's entire Serve config, not the one mapping this installer owns — so on a node where `serve --bg off` failed, uninstalling Dormouse would take an unrelated app's Serve path down with it. It now says what it could not do and leaves the config alone. - `manage status` parsed `launchctl print` with `\|` alternation, a GNU sed extension that BSD sed treats as a literal — on macOS, the only platform this script runs on, the LaunchAgent section printed nothing at all. Replaced with awk anchored to the single-tab top-level fields, so the nested endpoint dictionaries no longer contribute stray `state =` lines. - `manage rollback` swapped `current` before `previous`, and its runtime fallback can be `$ROOT/current/runtime/node` — reached only when the previous release's runtime was rejected. Moving `current` first repointed that path at the rejected binary, so the second swap died under `set -e` with `current` moved and `previous` stale, orphaning the release just rolled back from. `previous` now goes first, while `current` still resolves. - `rollback_release` in the installer used `$OLD_RELEASE/runtime/node`, which is never checked, on the one path where the health check has already failed. It now uses `$STAGE/runtime/node`, verified executable and version/arch-matched earlier in the same run. - The usage header advertised an overridden `HOME` — the thing the code deliberately refuses because pnpm's store lives there — and never mentioned `DORMOUSE_INSTALL_ROOT`, which is the knob that exists. `--help` also ran two lines past the header, printing `set -euo pipefail` at the user. - Dropped the unread `RELEASES_TO_KEEP`: the prune keeps releases by matching the `current`/`previous` link names, not by count. SELF_HOST.md's first three sections still read as a build plan for a script that now ships — "author the local installer", "Create and review", a 17-item contract, "test before installing". Following it literally meant rewriting an existing file. The contract now lives in docs/specs/server.md → "Installing it", so those sections are replaced by six checkpoints that run the installer: preflight (naming the one thing the script does not check — that port 3100 is free), install, verify, first-run setup, updating/rollback/uninstall, and backup. First-run setup previously said "enroll a Host" with no mechanism; it now gives the `window.dormouseRemoteHost.enroll(...)` call, the pairing sequence, and the iOS Home-Screen-before-signing-in rule for push. Two comments in lib cited SELF_HOST.md as documenting the console hook, which it never did. They point at docs/specs/server.md now. Verified: `bash -n` on the installer and both generated scripts; `--help` output; the awk against real `launchctl print` output (and the old sed producing nothing); `manage uninstall` refusing at EOF and still accepting `y` under a pty, with config and state surviving both; and the rollback ordering in a throwaway root with an unusable previous runtime, which lands `current`/`previous` correctly where the old order orphaned a release. `pnpm lint:specs` OK. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016MVfFyGRbQyNKJAFEF33wW
dormouse-bot
left a comment
There was a problem hiding this comment.
All six inline findings and the two body notes from the last pass are addressed, and the SELF_HOST.md rewrite does what §1–3 needed — it now reads as "run the shipped installer and verify it" instead of a build plan for a script that already exists. I've resolved those threads.
One new finding, and it's the same shape as the uninstall fix in this push: cmd_show_password still takes the skip-on-no-TTY branch, and what it skips there is the gate on a bearer credential. manage show-password </dev/null — or the assistant driving this runbook, whose shell has no controlling terminal — prints the setup password straight to stdout with no confirmation. That is the one command SELF_HOST.md instruction 6 and checkpoint 4 step 1 both single out as the user's to run in their own terminal, so the failure mode is the password landing in a chat transcript. [ -t 0 ] tests stdin, so manage show-password | pbcopy still gets its prompt; only genuinely non-interactive callers would be refused. Inline suggestion below.
A smaller doc/code mismatch in the Definition of done, also inline.
Outside the diff, and your call: AGENTS.md's index entry for docs/specs/server.md doesn't mention this work. The description ends at "the testing harness, and instructions for running it end to end", and the touch points are server/src/, lib/src/remote/host/enrollment.ts, scripts/csp-defaults.mjs, and the dev:pocket-server flow — none of which cover the new "Installing it (macOS, behind Tailscale)" section or deploy/local/install-macos.sh, which that section names as its source of truth. Touch points are documented as "the load-bearing areas only", and an installer that owns a whole spec section seems to qualify. Happy to push that one-line AGENTS.md edit if you want it.
`cmd_show_password` had the same skip-on-no-TTY shape the last push removed from `cmd_uninstall`, and what it skipped was the gate on a bearer credential: `manage show-password </dev/null`, a CI step, or an assistant's non-interactive shell all printed the setup password to stdout unprompted. SELF_HOST.md carves this command out as the user's to run in their own terminal — instruction 6 and checkpoint 4 step 1 — so the script enforces it now rather than the prose alone. `[ -t 0 ]` tests stdin, so `show-password | pbcopy` still gets its prompt. Two doc corrections that came with it: - The Definition of done listed the retained `previous` release among the checks `manage verify` fails on. It warns there instead, and a first install never has one — so the reader following checkpoint 3 right after checkpoint 2 would see a `!` line the runbook told them not to expect. - AGENTS.md's index entry for server.md stopped at "running it end to end" and named no installer touch point, though that spec's new section names `deploy/local/install-macos.sh` as its source of truth. Verified `show-password` refusing at EOF without printing, and still printing after `y` under a pty. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016MVfFyGRbQyNKJAFEF33wW
SELF_HOST.mdspecifies adeploy/local/install-macos.shthat installs the coordinating server as a per-login LaunchAgent behindtailscale serve. This is that script, plus the spec section describing what it installs.One idempotent command. Re-running it updates the release from the current checkout; it never pulls, switches branches, or installs an updater. Each release is self-contained — production server tree,
lib/dist-pocket, and a copy of the exact Node binary the build ran under — so the service depends on neither the source checkout, nor Homebrew/nvm, nor pnpm's store, nor the user's interactivePATH. launchd reads none of those.State and config live outside
releases/and survive updates, prunes, and uninstall. Purging is a separate, explicitly-confirmed operation.Two silent-failure traps this encodes
Both were found by testing, and both fail quietly rather than loudly:
pnpm deploy --prod --legacypoisons the workspace. It rewrites the rootnode_modules/.pnpm-workspace-state-v1.jsontoproduction: true/dev: false. Every later pnpm command in that checkout then decides the workspace is stale and tries to runpnpm install --production— which would strip the developer's devDependencies. It only stopped here because there was no TTY to confirm the purge. The installer snapshots that file and restores it from anEXITtrap, so even a failed install leaves the checkout as it found it. This looks like a pnpm bug worth reporting upstream, independent of this PR.mv -f tmp linkfollows a symlink to a directory. Used to swapcurrent, it deposits the temp link inside the old release and leavescurrentpointing where it was — so every update became a silent no-op, and the prune then deleted the release nothing pointed at. It reported success while doing this. Nowrename(2)on the link path, with a post-switch assertion thatcurrentactually advanced.Verification
Exercised end to end against a real tailnet, and in a throwaway install root (including one whose path contains spaces, since the real one is
Application Support/Dormouse Server):manage verify— all checks pass, exit 0/api/helloand the Pocket app with a real Let's Encrypt cert127.0.0.1; refused on the Tailscale IPkill -9→ launchd restarts in ~1s (KeepAlive)state/checksums andserver.envbyte-for-byte;current/previousadvance; rollback worksPATH(no repo, no Homebrew, no pnpm)bash -non the installer and both generated scripts;pnpm lint:specsOK; server tests 143/143Notes for review
SELF_HOST.mdspecifies this exact path and contract, so it is anticipated rather than freelanced, but scoping it differently is a reasonable ask.mapfile.🤖 Generated with Claude Code
https://claude.ai/code/session_01GDNJHHA95nvRdo4Cv3rAoi