Merge pull request #460 from Ark0N/feat/installer-v2

feat(install): three questions up front, an unattended build, and a URL you can scan
This commit is contained in:
Codeman maintainer
2026-09-21 04:23:15 +02:00
13 changed files with 2238 additions and 582 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
### Default bind, and the non-loopback warning path
**Default bind is loopback-only; non-loopback without a password starts but warns** — since COD-29 (PR #107) the web server defaults to `--host 127.0.0.1` (was `0.0.0.0`). As of **0.9.0** binding a non-loopback host (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` **no longer refuses to start — it starts and prints a loud warning** listing the fixes (set `CODEMAN_PASSWORD`, bind loopback + tunnel/`tailscale serve`, or `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` to acknowledge → terser note). Host classification is `isLoopbackBindHost()` in `network-auth-policy.ts`; the warn-vs-start logic is in `server.ts` `start()`; flags wired in `cli.ts`. ⚠️ Operational note: the production systemd unit runs `node dist/index.js web --https` with no `--host`, so it binds **localhost only** — reach it remotely via `tailscale serve`/tunnel to `127.0.0.1`, or add `Environment=CODEMAN_HOST=0.0.0.0` + `Environment=CODEMAN_PASSWORD=…` to `~/.config/systemd/user/codeman-web.service`. A loopback bind is reachable through a same-host tunnel (cloudflared/tailscale → `127.0.0.1`) but NOT by a browser hitting the box's LAN IP. Auth user defaults to `admin`. **Installer note** (1.8.x, `install.sh`): interactive installs now PROMPT for the binding, defaulting to LAN (`0.0.0.0`) with a required password prompt (skipping the password needs an explicit confirm and prints a loud warning); non-interactive installs keep loopback unless `CODEMAN_HOST` is preset, and re-runs/updates preserve the EXISTING binding (`read_existing_binding()` parses the current systemd unit / launchd plist). The server binary's own default is unchanged. **Full model: `docs/security-architecture.md`.**
**Default bind is loopback-only; non-loopback without a password starts but warns** — since COD-29 (PR #107) the web server defaults to `--host 127.0.0.1` (was `0.0.0.0`). As of **0.9.0** binding a non-loopback host (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` **no longer refuses to start — it starts and prints a loud warning** listing the fixes (set `CODEMAN_PASSWORD`, bind loopback + tunnel/`tailscale serve`, or `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` to acknowledge → terser note). Host classification is `isLoopbackBindHost()` in `network-auth-policy.ts`; the warn-vs-start logic is in `server.ts` `start()`; flags wired in `cli.ts`. ⚠️ Operational note: the production systemd unit runs `node dist/index.js web --https` with no `--host`, so it binds **localhost only** — reach it remotely via `tailscale serve`/tunnel to `127.0.0.1`, or add `Environment=CODEMAN_HOST=0.0.0.0` + `Environment=CODEMAN_PASSWORD=…` to `~/.config/systemd/user/codeman-web.service`. A loopback bind is reachable through a same-host tunnel (cloudflared/tailscale → `127.0.0.1`) but NOT by a browser hitting the box's LAN IP. Auth user defaults to `admin`. **Installer note** (`install.sh`, installer v2 since 2026-09-20): interactive installs PROMPT for the binding up front, defaulting to Tailscale when the node is already connected, else LAN (`0.0.0.0`), and to the existing choice on a re-run; a LAN bind asks for a password, and skipping it is a confirm that DEFAULTS TO YES (owner decision 2026-09-20) and ends on a loud red notice; non-interactive installs keep loopback unless `CODEMAN_HOST` is preset, and re-runs/updates preserve the EXISTING binding AND password (`read_existing_binding()` parses the current systemd unit / launchd plist, and the `--lan`/`--tailscale`/env preset paths read `${CODEMAN_PASSWORD:-$EXISTING_PASSWORD}`, since a flag re-run used to rewrite a password-protected unit open). The server binary's own default is unchanged. **Full model: `docs/security-architecture.md`.**
### Instance isolation and the multi-instance attach danger
+380
View File
@@ -0,0 +1,380 @@
# 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-<hostname>` 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 `<base href="/codeman/">` 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.
- **Review fixes (2026-09-21)**, from the two reviews on PR #460 (DeepSeek Harness, then
Claude): the done screen's Start line is composed from every non-default value
(`start_command_hint`, shared with the exec branch as `export_bind_env`), so "do not
start" under a sub-path or a custom port no longer prints a bare `codeman web`; the
`--lan`/`--tailscale`/env preset paths keep an existing password instead of rewriting
the unit open; `--password`/`--port` flip `RECONFIGURE` so they reach the unit;
`install.sh name` re-syncs the unit's base URL after a rename; the sudo keepalive is
ended before the `exec` into the foreground server; Ctrl+C in the HTTPS-toggle poll
skips Tailscale instead of killing the run; `uninstall` asks before removing a
LaunchDaemon it never wrote; a foreign LaunchDaemon gets a restart hint and the done
screen stops claiming the new build is running; the preflight summary reads the
Tailscale state without node; the LAN security notice uses the configured port; a
bare re-run ends on the done screen; a build failure after a rename names the
`install.sh tailscale` recovery; `TS_JOINED_HERE` is gone.
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://<name>.<tailnet>.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-<host>` (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 NO everywhere** (owner decision 2026-09-20: the machine is used for other things, so a bare Enter never renames it). The proposal was YES when the installer itself had just joined the tailnet; rejected. |
| **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 no, always)
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 <n>` / `--no-rename` | Answer 2/3: rename the node to `<n>`, 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 <p>` | Same as `CODEMAN_PASSWORD` |
| `--port <n>` | 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 (Ctrl+C
during the poll skips Tailscale for this run rather than ending the installer). The
rename default does not depend on whether this run performed the login (decided NO
everywhere), so nothing records it.
2. **Name** (`tailscale_choose_name`, question 2/3): shown only on the Tailscale route.
Default `codeman-<oshostname>` 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 <port>`. 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://<url>/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.<tailnet>.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 [<n>]` (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
question shown (default no), 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://<node>/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.<tailnet>.ts.net`.
Decisions for the maintainer:
1. **Rename default.** Decided 2026-09-20: always NO; the yes answer, `--name` and
`install.sh name` are the ways in. (The proposal was YES only when this run had joined
the tailnet, NO otherwise; rejected because the host is used for other things.)
2. **Name pattern.** `codeman-<hostname>` (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.
+9 -3
View File
@@ -270,9 +270,15 @@ tailscale serve --bg 3000 # HTTPS at https://<node>.<tailnet>.ts.net
Only devices on your tailnet can reach it; Tailscale handles identity and
terminates TLS with a real Let's Encrypt certificate (so PWA install and web
push work). No app password and no `0.0.0.0` bind required. (This is the
maintainer's production setup.) `CODEMAN_TAILSCALE=1` presets the choice for
automation; the installer never runs `tailscale serve reset` and never touches
serve mappings other than `443 -> Codeman's port`.
maintainer's production setup.) `CODEMAN_TAILSCALE=1` or `--tailscale` presets
the choice for automation. When `:443` on the node already belongs to another
app, the installer mounts Codeman under `/codeman` (`tailscale serve --set-path`
plus `--base-url`, which keeps the loopback bind and the same host guard) or on a
second port rather than replacing it. The installer never runs `tailscale serve
reset`, never touches serve mappings other than the one it created, never opens a
`tailscale funnel` (public internet, a different risk class) and never advertises
a Tailscale Service. Renaming the node (`--name`, `install.sh name`) is opt-in
and defaults to no, because the tailnet name is also the machine's SSH identity.
### B. Authenticated cloudflared tunnel + password
+5
View File
@@ -1,5 +1,10 @@
# Tailscale Setup in the Installer (Plan)
> Superseded in part by [`installer-v2-plan.md`](installer-v2-plan.md) (2026-09-20), which
> moved every human step before the build, added the sub-path / second-port answer for an
> occupied `:443`, the opt-in rename, flags, and the done screen with a QR code. The
> state machine and safety rules below still hold.
Goal: make "Codeman over Tailscale, with real HTTPS" a first-class, guided path in
`install.sh`, instead of a one-line hint pointing at the docs. Today the safest
recommended deployment (loopback bind + `tailscale serve`) is exactly what the
+47 -13
View File
@@ -24,16 +24,22 @@ This installs Node.js, tmux and a build toolchain if they are missing (node-pty
Linux prebuild, so it compiles from source), clones Codeman into `~/.codeman/app`, and
builds it.
What it asks you:
It starts by printing what it found (git, Node, tmux, build tools, agent CLIs, Tailscale,
an existing install), then asks everything it needs up front, then does the work
unattended. You can leave while it builds. What it asks you:
1. **Permission for every system change.** Package installs and agent CLI downloads are
prompted individually. Nothing is installed silently. If no agent CLI is found, a menu
offers to install any of them (DeepSeek excepted: its npm package installs only a
launcher with no runnable profile), or you skip and install one yourself later.
1. **One consent for the missing packages.** Git, Node.js, tmux and (on Linux) the build
toolchain are installed after a single yes, and sudo asks for your password once for
the whole run. Nothing is installed silently. If no agent CLI is found, a menu offers
to install any of them (DeepSeek excepted: its npm package installs only a launcher
with no runnable profile), or you skip and install one yourself later.
2. **How the dashboard should be reachable.** Three choices:
- **Tailscale** (recommended for phone access): keeps the loopback bind and walks you
through `tailscale serve`, including the tailnet HTTPS toggle, then verifies the result
end to end.
- **Tailscale** (recommended for phone access): keeps the loopback bind, installs
Tailscale if needed, logs in, enables the tailnet HTTPS toggle (it opens the admin
page for you and waits; Ctrl+C there skips Tailscale for this run), then configures `tailscale serve` after the build and
verifies the result end to end. If another app already owns `:443` on your node,
you choose between a sub-path (`https://<machine>.<tailnet>.ts.net/codeman`, the
default), a second port, replacing the other mapping, or skipping.
- **Your local network** (`0.0.0.0`): prompts for a password. Skipping the password takes
an explicit confirmation and ends on a loud warning.
- **This machine only** (`127.0.0.1`): the safest option, and the default for a bare
@@ -44,26 +50,54 @@ What it asks you:
Tailscale. An existing loopback install defaults to keeping loopback, or to Tailscale when
a serve mapping for Codeman is already there. A bare Enter never pulls in new software,
and a non-interactive run always keeps the safe loopback default.
3. **What to do when it finishes.** Run in this terminal, install as a background service
that starts on boot, or do nothing yet.
3. **What to call this machine on your tailnet** (Tailscale route only). By default the URL
uses the machine's existing name. Answer yes to rename it `codeman-<hostname>`; the
default is no, because the tailnet name is also what SSH and everything else on that
machine are reached by.
4. **Whether to run Codeman in the background.** Enter installs a systemd user service or a
macOS LaunchAgent that starts on boot; answering no offers to start it in this terminal
instead, or not at all.
It ends on a screen with the URL (your tailnet, your network, or this machine), a QR code to
scan with your phone, and the two commands you need to manage the service.
Re-running the same one-liner **updates an existing install in place**. Local changes in
`~/.codeman/app` are stashed rather than discarded, a running service is restarted and
verified, and your existing network binding is preserved. An interrupted first install
resumes instead of restarting.
Two other entry points exist:
Other entry points:
```bash
install.sh status # print the URLs, the QR code and the manage commands again
install.sh update # update only
install.sh uninstall # remove
install.sh uninstall # remove (offers to undo a rename it performed)
install.sh tailscale # retrofit Tailscale access onto an existing install
install.sh name [<n>] # rename this machine on your tailnet (default codeman-<hostname>)
install.sh cloudflared # install cloudflared for the in-app Cloudflare tunnel
```
**Flags** answer the questions from the command line 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
```
`--tailscale` / `--lan` / `--local` answer the access question, `--name <n>` / `--no-rename`
the name, `--service` / `--run` / `--no-start` the last one. `--yes` takes every default
(it still waits on a Tailscale login URL, and a network bind still asks for a password).
`--port <n>` moves Codeman off 3000; the service file and the serve mapping follow it. On an
existing install, `--port` and `--password` re-run the setup so the service file picks them up,
and a re-run with `--lan` or `--tailscale` keeps the password the service already has.
**Automation and CI**: with no terminal attached, any step that would change the system
aborts with instructions instead of running silently. Set `CODEMAN_NONINTERACTIVE=1` to
approve those steps. `CODEMAN_TAILSCALE=1` preselects the Tailscale answer, and never
installs Tailscale itself non-interactively.
installs Tailscale itself non-interactively; a non-interactive run never renames the
machine and never starts a service. Everything the unattended steps print goes to
`~/.codeman/install.log`, and the last lines of it are shown when a step fails.
## Route B: npm
+24 -3
View File
@@ -33,11 +33,12 @@ Your devices join a private network, and Codeman stays bound to loopback. Nothin
published to the internet, and you get real HTTPS with a real certificate.
The installer sets this up for you, including installing Tailscale, logging in, enabling
tailnet HTTPS, and verifying the result end to end. To retrofit it onto an existing
install:
tailnet HTTPS, and verifying the result end to end. It ends on the URL with a QR code to
scan. To retrofit it onto an existing install, or to see the URL and QR code again:
```bash
install.sh tailscale
install.sh status
```
By hand:
@@ -49,6 +50,22 @@ tailscale serve status
Then open `https://<machine>.<tailnet>.ts.net` from any device on your tailnet.
### The name in the URL
The URL is the machine's MagicDNS name, so on a machine called `tnode` it is
`https://tnode.<tailnet>.ts.net`. Three ways to influence that, from least to most work:
| You want | How |
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| The machine's existing name (default) | Nothing. This is what the installer does unless you say otherwise. |
| `https://codeman-<hostname>.<tailnet>.ts.net` | Answer yes to the installer's name question, pass `--name codeman-<hostname>`, or run `install.sh name`. This renames the machine tailnet-wide (SSH included), which is why the installer defaults to no. `install.sh uninstall` offers to rename it back. |
| `https://codeman.<tailnet>.ts.net` | A [Tailscale Service](https://tailscale.com/docs/features/tailscale-services). Only a **tagged** node can host one (a device signed in with a user account cannot), the service is defined and approved in the admin console, and the feature is in beta. The installer does not set this up; it is a `tailscale serve --service=svc:codeman --https=443 127.0.0.1:3000` on a tagged host once the service exists. |
If `:443` on your node already belongs to another app, the installer offers Codeman under
`https://<machine>.<tailnet>.ts.net/codeman` (the default, via `tailscale serve --set-path`
plus Codeman's `--base-url`), on a second port (`https://<machine>.<tailnet>.ts.net:8443`),
or replacing the other mapping. It never replaces anything without asking.
Notes:
- Keep the loopback bind. `tailscale serve` connects to `127.0.0.1:3000` locally, so
@@ -58,7 +75,11 @@ Notes:
- Codeman's Host-header allowlist already accepts `.ts.net`, so no extra configuration is
needed.
- The installer never resets or rewrites `serve` mappings other than the one pointing at
Codeman's port, so unrelated serve configuration is left alone.
Codeman's port, so unrelated serve configuration is left alone. It also never opens a
`tailscale funnel` (that is the public internet) and never advertises a Tailscale Service.
- On macOS, the App Store and standalone Tailscale apps only run once someone is logged in,
so a headless Mac needs the open-source `tailscaled` for the URL to come back after a
reboot on its own.
## Cloudflare tunnel
+8
View File
@@ -67,6 +67,14 @@ On Linux, if you want the service running while you are not logged in:
loginctl enable-linger $USER
```
On macOS, a LaunchAgent starts when you log in, not at boot. A headless Mac (no GUI login)
needs a system LaunchDaemon instead, written by hand as root. The installer recognises an
existing `/Library/LaunchDaemons/com.codeman.web.plist` and leaves it alone rather than
installing a LaunchAgent next to it, since the two would fight over the port; remove the
daemon first if you want to switch. The same login caveat applies to the App Store and
standalone Tailscale apps, so on a headless Mac the Tailscale URL only comes back after a
reboot if the open-source `tailscaled` is used.
### Writing the unit by hand
**Linux (systemd user unit):**