# Installer v2: three questions, then a URL you can open on your phone (Plan) Status: **Phase 1 IMPLEMENTED (2026-09-20)**, phases 2 and 3 open. It builds on `docs/tailscale-installer-plan.md` (implemented 2026-08-04), which made Tailscale a guided option; this round makes it the thing the install ENDS on, and makes the whole installer shorter to sit through. Owner decisions taken before implementation: rename is opt-in and **defaults to no everywhere** (the machine name is used for other things); the URL keeps the node name unless asked; `codeman-` is the suggested name; sub-path is the default for an occupied `:443`. Verification record for phase 1 (all on the maintainer's box, 2026-09-20): - `test/install-sh-invariants.test.ts` (28 tests, incl. the new Tailscale safety pins) and the detection-parity test pass; `bash -n` passes. - Every new decision function driven with stubbed tailscale state under **bash 5.2 and bash 3.2** (the `bash:3.2` container CI uses): flags, the launch default, the serve shape for free / ours / occupied `:443` (all four answers plus the non-interactive default), the three serve commands, the rename question (Enter keeps the name; `--yes` and non-interactive never rename; `codeman-*` nodes are skipped; `--name` is sanitized), `run_step` success/failure/stdin, the unit round-trip of `CODEMAN_BASE_URL`/`CODEMAN_PORT`/an escaped password, and the done screen. - A full non-interactive install into a sandboxed `HOME` with `CODEMAN_TAILSCALE=1`: preflight summary, kept the existing prod mapping (no serve mutation), clone 2 s, `npm install` 18 s, build 23 s, symlink, done screen; `install.sh status` on a pty renders the QR code. Nothing on the real system changed. - **Sub-path mode end to end over the real tailnet**: an isolated Codeman (`CODEMAN_INSTANCE`, port 3999, `--base-url /codeman`) behind `tailscale serve --https=8445 --set-path /codeman 3999` answered `/codeman/api/status`, `/codeman/` (with `` and `__CODEMAN_BASE__="/codeman"`), the hashed CSS/JS, `/codeman` without a slash, and the SSE stream; mapping and server removed afterwards. **Correction to section 2**: serve STRIPS the mount prefix before proxying (a direct `/codeman/api/status` on the server is 404 while the same path through serve is 200). That is fine because Codeman's ingress tolerates unprefixed requests; `--base-url` is needed for the URLs Codeman EMITS, not for what it receives. - Not yet exercised on a fresh machine (unchanged from the previous plan): Tailscale absent / logged out / HTTPS toggle off, the rename against a real node (the off-rename-re-add order is implemented but only unit-driven), macOS, uninstall. The Mac mini and a throwaway VM are the venues; see section 8. Goal, in one sentence: a user runs the one-liner, answers at most three questions, walks away during the build, and comes back to `https://..ts.net` printed with a QR code, already answering, on every device in their tailnet. That is exactly the maintainer's own production setup (`tnode.tailf80371.ts.net` fronting `127.0.0.1:3000`), and the installer should produce it without the user knowing what `tailscale serve` is. ## 1. Where the installer is today Facts from reading `install.sh` (2886 lines, 19 `prompt_yes_no` sites) and the live Tailscale state on the maintainer's box (tailscale 1.102.2, user-owned node, MagicDNS + HTTPS certs on, serve mapping `443 -> https+insecure://localhost:3000`). **The order is backwards for a human.** The flow is: detect -> ask about git -> ask about node -> ask about tmux -> ask about build tools -> AI CLI menu -> ask about cloudflared -> clone -> `npm install` -> build (minutes) -> **then** the network-access question -> the Tailscale sub-steps (install? login URL, sudo for operator, admin-console toggle loop) -> the launch menu (no default; a bare Enter re-prompts) -> tunnel-service question. A fresh Ubuntu server taking the Tailscale route answers roughly ten prompts plus two to four sudo password prompts, split around a multi-minute build. The user cannot walk away at any point, and the question that matters most (how do I reach it) comes last. **The Tailscale flow works but was never exercised on a fresh machine.** The previous plan's manual matrix still lists items 1-4, 7 and 10-12 (Tailscale absent, logged out, HTTPS toggle off, port 443 occupied, macOS, uninstall, phone PWA) as untested. The maintainer's own verification was the idempotent "kept as-is" path. **The URL is the machine's name, full stop.** `setup_tailscale_serve` derives it from `.Self.DNSName`, and nothing lets the user influence it. A second Codeman on the same tailnet is `macminis-mac-mini.tailf80371.ts.net`, which tells you nothing about Codeman. **Port 443 taken means give up or clobber.** If another app already owns the root of `:443`, the only offer is "replace it?" (default no), and declining falls back to local-only. Codeman already supports running under a sub-path (`--base-url`), and Tailscale serve supports mounting a path (`--set-path`), so there is a third answer nobody is offered. **The result is invisible afterwards.** Once the terminal scrolls away, nothing in the app or the CLI tells the user their Tailscale URL again. `codeman doctor` does not probe Tailscale; App Settings -> Remote access shows only the Cloudflare tunnel. **Two service writers exist.** `install.sh` carries its own plist/unit generator (~180 lines) next to `codeman service install` (`src/service-installer.ts`). They agree on the job name by design, but the bash copy is the one that writes `CODEMAN_PASSWORD` into the unit, so they cannot simply be merged. Left as-is in this plan (see section 9). ## 2. What Tailscale makes possible for the name (researched 2026-09-20) | Option | Resulting URL | What it needs | Side effects | Verdict | | ------ | ------------- | ------------- | ------------ | ------- | | **A. Node name** (today) | `https://tnode.tailf80371.ts.net` | `tailscale serve --bg 3000` | none | **Default.** Zero admin-console work, matches the maintainer's prod. | | **B. Rename the node** | `https://codeman-tnode.tailf80371.ts.net` | `tailscale set --hostname codeman-` (operator or root) | Renames the machine tailnet-wide: ssh targets, other serve URLs, the admin console entry. Tailscale de-dups a clash as `-1`. The cert follows the new name. | **Opt-in**, default YES only when the installer itself just joined this machine to the tailnet (nobody depends on the old name yet), default NO on a pre-existing node. | | **C. Tailscale Service** | `https://codeman.tailf80371.ts.net` | tailscale >= 1.86 on the host; the host must have a **tag-based identity** ("You cannot use a device authenticated with a user account as a Service host"); the service is defined in the admin console first; the host is then approved there (or via `autoApprovers.services`). Public beta since 2025-10-28, all plans. | Re-authenticating a personal machine as a tagged node changes its identity (SSH ACLs, user attribution). Known daemon quirk: approval is not picked up until `serve clear` + re-advertise (tailscale/tailscale#18821). | **Detect and hint only** in this round. The maintainer's own node has `Self.Tags: null`, so it could not host one without re-tagging. Worth a real flow once someone with a tagged fleet asks. | | **D. Sub-path** | `https://tnode.tailf80371.ts.net/codeman` | `tailscale serve --bg --set-path /codeman 3000` plus `--base-url /codeman` on the server | Codeman runs under a prefix. Hooks are unaffected (they hit the raw port with no prefix, which `rewriteUrl` already tolerates). Serve forwards the prefix unchanged, which is exactly the shape `--base-url` was built for. | **The answer when `:443` root is already taken.** Replaces today's replace-or-nothing prompt. | | **E. Second port** | `https://tnode.tailf80371.ts.net:8443` | `tailscale serve --bg --https=8443 3000` | Port in the URL; the beta-preview recipe already uses this. | Fallback when the user rejects D. | | Funnel (public internet) | `https://tnode.tailf80371.ts.net` from anywhere | `tailscale funnel` | Public exposure; different risk class. | **Out of scope**, as before. Docs only, with the password warning. | Sources: Tailscale Services docs (`tailscale.com/docs/features/tailscale-services`), the Services beta announcement (`tailscale.com/blog/services-beta`), machine names (`tailscale.com/kb/1098/machine-names`), the serve CLI reference (`tailscale.com/docs/reference/tailscale-cli/serve`), the macOS variants page (`tailscale.com/docs/concepts/macos-variants`), and `tailscale serve --help` on 1.102.2 (which lists `--service`, `--set-path`, `--yes`, `advertise`, `get-config`/`set-config`). **Trap for option B (verify on the Mac mini before shipping):** the serve config is keyed by `host:port` using the DNS name at configuration time (`"Web": {"tnode.tailf80371.ts.net:443": ...}` in `serve status --json`). Renaming a node after serve is configured most likely orphans that entry: the handler lookup uses the current name and never matches the old key, and the only tool that removes a stale key is `serve reset`, which this installer must never run. So the order is **rename first, then configure serve** on a fresh install, and on a retrofit (`install.sh name`) **turn our mapping off, rename, wait for `.Self.DNSName` to change, re-add**. ## 3. Target UX ### 3.1 Three questions, then walk away ``` Codeman installer Found: git, Node 22.14, tmux 3.4, build tools Missing: nothing AI CLIs: Claude Code (~/.local/bin/claude) Tailscale: connected as tnode (tailf80371.ts.net) Existing: none 1/3 How should the dashboard be reachable? 1) Tailscale https://tnode.tailf80371.ts.net (recommended, already connected) 2) Any device on your network (0.0.0.0, password required) 3) This machine only (127.0.0.1) Choose [1/2/3] (default 1): 2/3 Name this machine "codeman-tnode" on your tailnet? [y/N] (only shown for option 1; default Y when the installer just joined the tailnet) 3/3 Run Codeman as a background service that starts on boot? [Y/n] Installing… this takes a few minutes. You can leave this running. ✓ dependencies ✓ clone ✓ build (2m 41s) ✓ service ✓ tailscale serve ``` Rules that make this work: - **Every step that needs a human runs BEFORE the build.** The dependency consent, the AI CLI menu, the Tailscale install consent, the `tailscale up` login URL, the operator grant, and the tailnet HTTPS toggle all move into the question phase. The build, the service, `tailscale serve` and the verification are unattended. - **One consent for all missing system packages.** "Install git, Node 22 and build tools now? [Y/n]" replaces four separate prompts. Each package still runs its own distro-specific installer. - **One sudo prompt.** When anything needs root (packages, the Tailscale installer, `tailscale up`, the operator grant), the installer says so once, runs `sudo -v`, and keeps the timestamp alive in a background loop until it exits. macOS needs no sudo for the Tailscale GUI-app CLI and the pattern still holds for Homebrew packages. - **Service is the default.** Enter on the last question installs the service; "run in this terminal" and "don't start" stay reachable by answering, and by flag. - **The cloudflared question is gone from the main flow.** It is optional, defaults to no, and has an in-app toggle (App Settings -> Remote access). The done screen mentions it only when `cloudflared` is already installed. The Linux tunnel-service prompt goes with it. - **The HTTPS-certificates toggle no longer asks "re-check now?"** The installer prints the admin URL, opens it in a browser when one is available (`xdg-open` / `open`, never on a headless box), and polls `tailscale status --json` every 5 s for up to 5 minutes. Ctrl+C or the timeout falls back exactly as today. - **Progress, not silence.** `npm install` and `npm run build` run behind one line each with elapsed time; their output goes to `~/.codeman/install.log` and is printed only on failure, with the exact retry command. ### 3.2 The done screen One block, the URL first, a QR code the phone can scan, and nothing the user does not need right now. ``` ✓ Codeman 1.31.0 is running Your tailnet: https://codeman-tnode.tailf80371.ts.net (HTTPS, any of your devices) This machine: http://localhost:3000 ▄▄▄▄▄▄▄ ▄ ▄▄ ▄▄▄▄▄▄▄ █ ▄▄▄ █ ▄▄▀ ▄ █ ▄▄▄ █ scan with your phone █ ███ █ ███▀▀ █ ███ █ █▄▄▄▄▄█ █ ▄ █ █▄▄▄▄▄█ Manage systemctl --user restart codeman-web · journalctl --user -u codeman-web -f Update re-run the install line, or App Settings → System → Updates Docs https://github.com/Ark0N/Codeman/wiki Security: Codeman binds 127.0.0.1. Tailscale authenticates every device before a packet reaches it. Details: docs/security-architecture.md ``` The QR comes from the `qrcode` package Codeman already depends on (`node -e "require('qrcode').toString(url, {type:'terminal', small:true}, …)"` from `$INSTALL_DIR`, verified locally: 17 rows by 45 columns). Skipped when the terminal has no color support or fewer than 50 columns. The QR encodes the plain URL, not an auth token: the tailnet is the login. ### 3.3 Express mode and flags Env vars stay (`CODEMAN_TAILSCALE=1`, `CODEMAN_HOST`, `CODEMAN_PASSWORD`, `CODEMAN_NONINTERACTIVE=1`, `CODEMAN_PORT`). Flags are added because they are discoverable from the one-liner and pipe through `bash -s --`: ```bash curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service curl -fsSL https://getcodeman.com/install | bash -s -- --lan --password 'x' --service curl -fsSL https://getcodeman.com/install | bash -s -- --local --run curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --name codeman-build --yes ``` | Flag | Meaning | | ---- | ------- | | `--tailscale` / `--lan` / `--local` | Answer 1/3 (same semantics as `CODEMAN_TAILSCALE=1`, `CODEMAN_HOST=0.0.0.0`, `CODEMAN_HOST=127.0.0.1`) | | `--name ` / `--no-rename` | Answer 2/3: rename the node to ``, or never ask | | `--service` / `--run` / `--no-start` | Answer 3/3 | | `--yes` | Accept every default, still prompt for a login URL (a human must open it) | | `--password

` | Same as `CODEMAN_PASSWORD` | | `--port ` | Same as `CODEMAN_PORT`; the serve target follows it | `--yes` differs from `CODEMAN_NONINTERACTIVE=1`: it is the interactive user saying "I trust the defaults", so it may install software and may wait on a login URL. Non-interactive stays the CI contract and never installs Tailscale. ## 4. The Tailscale flow, v2 The state machine from the previous plan stays; these are the changes. 1. **Preflight, before the build** (`tailscale_preflight`): installed? -> install (Linux: official script; macOS: brew cask, else download link and wait). Logged in? -> `tailscale up` with the URL printed prominently and a 5-minute poll. Operator (Linux): grant once under the single sudo session. HTTPS certs: poll instead of ask. Record `TS_JOINED_HERE=1` when this run performed the login: it drives the rename default. 2. **Name** (`tailscale_choose_name`, question 2/3): shown only on the Tailscale route. Default `codeman-` sanitized to `[a-z0-9-]`, max 63. Applied with `ts_cmd_serve set --hostname`, then poll `.Self.DNSName` until it carries the new name (up to 60 s). Order matters: this runs before any serve mutation (section 2 trap). Declining keeps the node name. On a re-run against a node already named `codeman-*`, the question is skipped. 3. **Serve, after the service is up** (`setup_tailscale_serve`): unchanged idempotent "kept as-is" path first. When `:443` root belongs to another target, the new prompt is: ``` tailscale serve already sends https://tnode.tailf80371.ts.net to port 8080. 1) Add Codeman under a path: https://tnode.tailf80371.ts.net/codeman (default) 2) Use another port: https://tnode.tailf80371.ts.net:8443 3) Replace the existing mapping with Codeman 4) Skip Tailscale for now ``` Option 1 writes `--base-url /codeman` into the service unit (it is a `WebLaunchOptions` field already, and `buildWebArgs` carries it) and runs `tailscale serve --bg --set-path /codeman `. Option 2 runs `--https=8443`. `detect_tailscale_serve_url` learns to recognize all three shapes (root, path, port) so uninstall, the security notice and the re-run default keep working. 4. **Warm the certificate.** Right after serve is configured, fire one background `curl -sk https:///api/status` so Let's Encrypt issuance overlaps the rest of the install instead of adding 30 s to the verify step. 5. **Verify** as today (200 or 401 on `/api/status`), with the path-aware URL. 6. **Services hint** (option C): when `.Self.Tags` is non-empty and `serve --help` lists `--service`, the done screen adds one line: "This is a tagged node, so it can also host `https://codeman..ts.net` as a Tailscale Service: see Remote Access in the wiki." No flow, no prompt. 7. **macOS**: the App Store and Standalone variants cannot run before login, so a LaunchAgent plus serve only comes back after someone logs in. The done screen says so on macOS. The Mac mini (`arbbot`, headless, system LaunchDaemon) is the reference for the "headless Mac" caveat, and `install.sh` must keep refusing to replace a LaunchDaemon it did not write (today it removes one; that is a bug for the Mac mini and is fixed here: detect `UserName` in the daemon plist and leave it alone with a message). 8. **Uninstall** additionally offers to restore the original node name when this installer renamed it (the original is recorded in `~/.codeman/install.json`, the one marker file this feature adds, because tailscaled does not remember previous names). 9. **Subcommands**: `install.sh tailscale` (unchanged purpose, now runs the v2 flow), `install.sh name []` (rename with the off/rename/re-add dance), `install.sh status` (prints the done screen again, URL and QR included, for the "what was my URL" moment). ## 5. In-app: the URL stays discoverable Small, read-only, and the first server-side code this feature has ever needed. - **`GET /api/system/remote-access`** returns `{ tailscale: { installed, connected, dnsName, url, mode: 'root'|'path'|'port'|null } }` by running `tailscale status --json` and `tailscale serve status --json` through `execFile` with the existing exec timeout, cached 30 s, resolved through the same `get_tailscale_path` search as the installer (PATH, then the macOS app bundle), and a no-op under `VITEST` like every other IO probe. Never mutates serve config. - **App Settings -> Remote access** gains a **Tailscale** row above the Cloudflare toggle: the URL as a copy chip, a QR button reusing `showTunnelQR`'s modal, and when nothing is configured a one-line hint with `bash ~/.codeman/app/install.sh tailscale`. The welcome screen's "open on your phone" affordance shows the same QR. - **`codeman doctor`** grows a `tailscale` entry under `other` in `config/dependency-registry.ts`: installed, connected, serving Codeman (URL). Pure engine, injectable probe host, like the existing rows. - No new SSE event, no settings key, no state.json change. ## 6. Security posture Nothing widens. The bind stays loopback; the tailnet is the authentication boundary; `.ts.net` is already in `DEFAULT_TRUSTED_HOST_SUFFIXES`. New surfaces are read-only probes. `install.sh` still never runs `tailscale serve reset`, still touches only the mapping it created, and gains one more never: it never advertises a Tailscale Service or runs `tailscale funnel`. The sudo keep-alive loop is killed by the existing `cleanup` trap. The rename records the previous name locally and offers the reversal at uninstall. ## 7. Implementation inventory | File | Change | | ---- | ------ | | `install.sh` | New `parse_flags`, `preflight_summary`, `ask_everything` (the three questions), `sudo_session`, `run_step` (spinner + log), `tailscale_preflight`, `tailscale_choose_name`, `tailscale_rename_node`, `print_done_screen`, `print_qr`, `status` subcommand, `name` subcommand. Modified: `main` (reordered into ask -> work -> done), `choose_network_binding` (question 1/3, same defaults), `setup_tailscale_serve` (path/port options), `detect_tailscale_serve_url` (three shapes), `setup_systemd_service`/`setup_launchd_service` (`--base-url`, LaunchDaemon guard), `uninstall` (rename reversal), header docs (flags). Removed from the main flow: the cloudflared prompt, the tunnel-service prompt. bash 3.2 rules unchanged. | | `src/web/routes/system-routes.ts` | `GET /api/system/remote-access` | | `src/tailscale-status.ts` (new) | Pure parser for the two JSON shapes + the IO wrapper; unit-tested against captured `serve status --json` fixtures (root, path, port, foreign target, none) | | `src/config/dependency-registry.ts`, `src/utils/dependency-checker.ts` | `tailscale` doctor row | | `src/web/public/index.html`, `settings-ui.js`, `panels-ui.js` | Tailscale row + QR, welcome-screen QR | | `test/install-sh-invariants.test.ts` | Extend: flags documented in the header, no `serve reset`, no `funnel`, no `--service` advertise, every serve mutation goes through `ts_cmd_serve`, rename happens before serve in `main` (static order check) | | `.github/workflows/ci.yml` | The bash 3.2 step additionally sources the script with stubbed `ts_cmd`/`ts_cmd_serve`/`read_reply` and drives `ask_everything` through all three answers and the 443-occupied menu | | `test/tailscale-status.test.ts`, `test/routes/system-routes-remote-access.test.ts` | Parser + route | | Docs | README install + remote-access sections, `docs/wiki/Installation.md`, `Remote-Access.md` (naming options table, Services caveat, path/port variants), `Mobile-Guide.md`, `Running-As-A-Service.md` (macOS login caveat), `FAQ.md`, `docs/security-architecture.md` §A, CLAUDE.md Scripts & Tunnel paragraph, `docs/tailscale-installer-plan.md` gets a pointer here. getcodeman.com copy lives outside the repo (maintainer handbook). | Changeset: `minor` (new flags, new subcommands, new API route). ## 8. Test plan Automated (the gate): the static invariants above, the bash 3.2 container drive of the question phase, the JSON parser fixtures, the route test. Manual matrix, on a fresh Ubuntu 24 VM and on the Mac mini, since the previous plan's items never ran on a fresh machine: 1. Tailscale absent, declined -> local-only, done screen shows the retrofit command. 2. Tailscale absent, accepted -> install, login URL, operator, certs toggle polled, rename default YES, service, serve, URL verified, QR scans on a phone, PWA installs. 3. Tailscale present and logged in on a pre-existing node -> rename default NO, URL is the node name, `serve status` gains exactly one entry. 4. `:443` root occupied -> path option -> `https:///codeman` answers, hooks still fire (raw port), `install.sh status` prints the path URL. 5. Rename on a node that already has our serve mapping (`install.sh name`) -> off, rename, re-add, `serve status` has no stale key. 6. Re-run the one-liner -> quiet update, binding and name preserved, no prompts. 7. `--yes` end to end; `CODEMAN_NONINTERACTIVE=1` end to end (no software installed). 8. Uninstall -> mapping removed, other mappings intact, rename reversal offered. 9. Mac mini: LaunchDaemon left alone with the message; done screen carries the login caveat. ## 9. Phasing and open decisions **Phase 1 (this round):** the reorder, the three questions, one consent + one sudo, flags, the done screen with QR, Tailscale preflight-before-build, the path/port answer for an occupied 443, the rename step, `status` and `name` subcommands, docs. **Phase 2:** the in-app Tailscale row + QR, `codeman doctor` row, the `remote-access` route. Independent of phase 1 and useful on its own for existing installs. **Phase 3 (optional):** replace the bash service writers with `codeman service install` once that command can carry `CODEMAN_PASSWORD` behind an explicit flag; and a Tailscale Services flow if a tagged-fleet user asks for `codeman..ts.net`. Decisions for the maintainer: 1. **Rename default.** Proposed: default YES only when this run joined the tailnet, default NO otherwise, never on re-runs. The alternative is always NO with `--name` as the only way in. 2. **Name pattern.** `codeman-` (proposed; unique per machine, and two Codemans on one tailnet stay distinguishable) versus plain `codeman` (nicer once, collides on the second install, Tailscale silently appends `-1`). 3. **Path versus port** as the default answer for an occupied 443. Proposed: path, because the URL has no port and `--base-url` already exists for exactly this proxy shape. 4. **Whether Phase 2 ships in the same release.** It is the part that helps people who installed months ago.