Skip to content

Install the selfhost server on macOS with one script - #422

Merged
nedtwigg merged 3 commits into
mainfrom
selfhost-macos-installer
Aug 20, 2026
Merged

Install the selfhost server on macOS with one script#422
nedtwigg merged 3 commits into
mainfrom
selfhost-macos-installer

Conversation

@nedtwigg

Copy link
Copy Markdown
Member

SELF_HOST.md specifies a deploy/local/install-macos.sh that installs the coordinating server as a per-login LaunchAgent behind tailscale 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 interactive PATH. 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 --legacy poisons the workspace. It rewrites the root node_modules/.pnpm-workspace-state-v1.json to production: true / dev: false. Every later pnpm command in that checkout then decides the workspace is stale and tries to run pnpm 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 an EXIT trap, 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 link follows a symlink to a directory. Used to swap current, it deposits the temp link inside the old release and leaves current pointing 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. Now rename(2) on the link path, with a post-switch assertion that current actually 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
  • HTTPS origin serves /api/hello and the Pocket app with a real Let's Encrypt cert
  • port 3100 bound only to 127.0.0.1; refused on the Tailscale IP
  • kill -9 → launchd restarts in ~1s (KeepAlive)
  • update preserves state/ checksums and server.env byte-for-byte; current/previous advance; rollback works
  • release starts under a scrubbed PATH (no repo, no Homebrew, no pnpm)
  • bash -n on the installer and both generated scripts; pnpm lint:specs OK; server tests 143/143

Notes for review

  • macOS-only and ~1200 lines of bash, which is a lot for this repo. SELF_HOST.md specifies this exact path and contract, so it is anticipated rather than freelanced, but scoping it differently is a reasonable ask.
  • bash 3.2-compatible (macOS system bash) — no associative arrays, no mapfile.
  • No runtime code ships; nothing here is in the app bundle.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GDNJHHA95nvRdo4Cv3rAoi

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
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 20, 2026

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 627b987
Status: ✅  Deploy successful!
Preview URL: https://401c1aef.mouseterm.pages.dev
Branch Preview URL: https://selfhost-macos-installer.mouseterm.pages.dev

View logs

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 the current/previous link 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_bin can resolve to $ROOT/current/runtime/node, and it only gets there when $prev/runtime/node wasn't executable. The first atomic_symlink then points current at prev, so on the very next line $node_bin names the binary that was already rejected — atomic_symlink fails, set -e aborts the function, and you're left with current moved but previous still on prev. The release you rolled back from is then referenced by neither link and the next install's prune deletes it. Swapping the two atomic_symlink calls (update previous first, while current still 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.

Comment thread deploy/local/install-macos.sh Outdated
Comment thread deploy/local/install-macos.sh Outdated
Comment thread deploy/local/install-macos.sh Outdated
Comment thread deploy/local/install-macos.sh Outdated
Comment thread deploy/local/install-macos.sh Outdated
Comment thread deploy/local/install-macos.sh Outdated
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 dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread deploy/local/install-macos.sh Outdated
Comment thread SELF_HOST.md Outdated
`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
@nedtwigg
nedtwigg merged commit 5b76a49 into main Aug 20, 2026
11 checks passed
@nedtwigg
nedtwigg deleted the selfhost-macos-installer branch August 20, 2026 23:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants