fix(install): fold in both reviews of #460

The two reviews on the PR (DeepSeek Harness, then Claude) found one class of
bug twice and a list of smaller ones; all of them land here, each pinned in
test/install-sh-invariants.test.ts and, where it is bash logic, driven in the
bash:3.2 CI step as well.

The Start line the done screen prints is now composed in one place
(start_command_hint) from every non-default value, the same five the exec
branch exports through 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 read ${CODEMAN_PASSWORD:-$EXISTING_PASSWORD}: a flag re-run on
a unit that carried a password used to rewrite it without the password and
with the unauthenticated ack. --password and --port flip RECONFIGURE so they
reach the unit instead of taking the quiet update path, and `install.sh name`
re-syncs the unit's base URL after the mapping is re-added.

Also: the sudo keepalive is ended before the exec into the foreground server
(exec skips the EXIT trap, and the loop keys on $$); Ctrl+C in the HTTPS-toggle
poll is trapped for the poll only and skips Tailscale for the run instead of
killing the installer; uninstall asks before removing a LaunchDaemon this
installer never wrote; a foreign daemon gets a launchctl kickstart hint and the
done screen stops claiming the new build is running; the preflight summary
reads the Tailscale state with a line grep when node is not installed yet; 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 (written, never read) is gone; the plan doc and
architecture-invariants say what the code does. A minor changeset is included.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-09-21 03:03:31 +02:00
parent 1ba0684438
commit 72d437ab63
8 changed files with 351 additions and 65 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'aicodeman': minor
---
Installer v2. `curl -fsSL https://getcodeman.com/install | bash` now looks at the machine first, asks at most three questions up front (how the dashboard is reached, optionally what to call the machine on your tailnet, whether to run Codeman as a background service), does the install unattended behind progress spinners with the output in `~/.codeman/install.log`, and ends on the URL with a QR code to scan. One consent covers every missing package and sudo asks for your password once. Flags pipe through `bash -s --` (`--tailscale | --lan | --local`, `--name <n> | --no-rename`, `--service | --run | --no-start`, `--yes`, `--password`, `--port`), `install.sh status` prints the URL and the QR code again, and the cloudflared question moved out of the main flow into `install.sh cloudflared`. On the Tailscale route, a `:443` that already belongs to another app gets Codeman under `https://<node>/codeman` (or on a second port) instead of a dead end, the node can be renamed opt-in (`--name`, `install.sh name`, undone by uninstall), and the HTTPS-certificates toggle is polled with the admin page opened for you. Also fixed on the way: the installer's own `npm install` no longer lets the postinstall start a stray server on port 3000 (the service crash-looped on EADDRINUSE while the done screen said "running"), the LAN address comes from the default route rather than the first interface, a hand-written LaunchDaemon on a headless Mac is left alone, a flag re-run keeps an existing dashboard password, and the done screen's start command carries the sub-path and port it was installed with.
+13 -1
View File
@@ -121,6 +121,8 @@ jobs:
[[ "$LAUNCH_CHOICE" == "2" ]] [[ "$LAUNCH_CHOICE" == "2" ]]
check_tailscale() { return 0; } check_tailscale() { return 0; }
ts_status_field() { case "$1" in "s.BackendState") printf Running ;; "s.Self && s.Self.DNSName") printf "box.tail.ts.net." ;; esac; } ts_status_field() { case "$1" in "s.BackendState") printf Running ;; "s.Self && s.Self.DNSName") printf "box.tail.ts.net." ;; esac; }
ts_backend_state() { printf Running; }
ts_dns_name() { printf box.tail.ts.net; }
ts_serve_443_target_port() { printf 8080; } ts_serve_443_target_port() { printf 8080; }
ts_serve_find_port_mapping() { :; } ts_serve_find_port_mapping() { :; }
ts_serve_port_used() { return 1; } ts_serve_port_used() { return 1; }
@@ -130,7 +132,17 @@ jobs:
RENAMED=""; tailscale_rename_node() { RENAMED="$1"; } RENAMED=""; tailscale_rename_node() { RENAMED="$1"; }
TS_NAME=""; tailscale_choose_name >/dev/null 2>&1 TS_NAME=""; tailscale_choose_name >/dev/null 2>&1
[[ -z "$RENAMED" ]] [[ -z "$RENAMED" ]]
echo "bash $BASH_VERSION: question phase (flags, launch default, occupied :443, rename opt-in) ok" # A flag re-run keeps the password the unit already carries (and so
# never writes the unauthenticated ack), and the hand-start line the
# done screen prints carries every non-default value.
read_existing_binding() { EXISTING_FOUND=1; EXISTING_HOST=0.0.0.0; EXISTING_PASSWORD=s3cret; EXISTING_ACK=0; EXISTING_BASE_URL=""; }
CODEMAN_HOST=0.0.0.0; CODEMAN_TAILSCALE=0; unset CODEMAN_PASSWORD; BIND_ACK=0
choose_network_binding >/dev/null 2>&1
[[ "$BIND_PASSWORD" == "s3cret" && "$BIND_ACK" == "0" ]]
BIND_HOST=0.0.0.0; BIND_PASSWORD=x; BIND_ACK=0; BIND_BASE_URL=/codeman; CODEMAN_PORT=4000
[[ "$(start_command_hint)" == "CODEMAN_HOST=0.0.0.0 CODEMAN_PASSWORD="*" CODEMAN_BASE_URL=/codeman CODEMAN_PORT=4000 codeman web" ]]
RECONFIGURE=0; parse_flags --port 4001; [[ "$RECONFIGURE" == "1" ]]
echo "bash $BASH_VERSION: question phase (flags, launch default, occupied :443, rename opt-in, kept password, start line) ok"
' '
- name: CLI catalogue artifacts are in sync with stock.ts - name: CLI catalogue artifacts are in sync with stock.ts
+1 -1
View File
File diff suppressed because one or more lines are too long
+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, 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 ### Instance isolation and the multi-instance attach danger
+24 -8
View File
@@ -37,6 +37,20 @@ Verification record for phase 1 (all on the maintainer's box, 2026-09-20):
absent / logged out / HTTPS toggle off, the rename against a real node (the 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 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. 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 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 away during the build, and comes back to `https://<name>.<tailnet>.ts.net` printed with a
@@ -88,7 +102,7 @@ unit, so they cannot simply be merged. Left as-is in this plan (see section 9).
| Option | Resulting URL | What it needs | Side effects | Verdict | | 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. | | **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 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. | | **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. | | **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. | | **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. | | **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. |
@@ -129,7 +143,7 @@ re-add**.
Choose [1/2/3] (default 1): Choose [1/2/3] (default 1):
2/3 Name this machine "codeman-tnode" on your tailnet? [y/N] 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) (only shown for option 1; default no, always)
3/3 Run Codeman as a background service that starts on boot? [Y/n] 3/3 Run Codeman as a background service that starts on boot? [Y/n]
@@ -226,8 +240,10 @@ The state machine from the previous plan stays; these are the changes.
1. **Preflight, before the build** (`tailscale_preflight`): installed? -> install 1. **Preflight, before the build** (`tailscale_preflight`): installed? -> install
(Linux: official script; macOS: brew cask, else download link and wait). Logged in? -> (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): `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 grant once under the single sudo session. HTTPS certs: poll instead of ask (Ctrl+C
`TS_JOINED_HERE=1` when this run performed the login: it drives the rename default. 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. 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 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 `ts_cmd_serve set --hostname`, then poll `.Self.DNSName` until it carries the new name
@@ -325,7 +341,7 @@ items never ran on a fresh machine:
1. Tailscale absent, declined -> local-only, done screen shows the retrofit command. 1. Tailscale absent, declined -> local-only, done screen shows the retrofit command.
2. Tailscale absent, accepted -> install, login URL, operator, certs toggle polled, rename 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. 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 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. node name, `serve status` gains exactly one entry.
4. `:443` root occupied -> path option -> `https://<node>/codeman` answers, hooks still 4. `:443` root occupied -> path option -> `https://<node>/codeman` answers, hooks still
@@ -352,9 +368,9 @@ Services flow if a tagged-fleet user asks for `codeman.<tailnet>.ts.net`.
Decisions for the maintainer: Decisions for the maintainer:
1. **Rename default.** Proposed: default YES only when this run joined the tailnet, 1. **Rename default.** Decided 2026-09-20: always NO; the yes answer, `--name` and
default NO otherwise, never on re-runs. The alternative is always NO with `--name` as `install.sh name` are the ways in. (The proposal was YES only when this run had joined
the only way in. 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 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 on one tailnet stay distinguishable) versus plain `codeman` (nicer once, collides on the
second install, Tailscale silently appends `-1`). second install, Tailscale silently appends `-1`).
+4 -2
View File
@@ -36,7 +36,7 @@ unattended. You can leave while it builds. What it asks you:
2. **How the dashboard should be reachable.** Three choices: 2. **How the dashboard should be reachable.** Three choices:
- **Tailscale** (recommended for phone access): keeps the loopback bind, installs - **Tailscale** (recommended for phone access): keeps the loopback bind, installs
Tailscale if needed, logs in, enables the tailnet HTTPS toggle (it opens the admin Tailscale if needed, logs in, enables the tailnet HTTPS toggle (it opens the admin
page for you and waits), then configures `tailscale serve` after the build and 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, 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 you choose between a sub-path (`https://<machine>.<tailnet>.ts.net/codeman`, the
default), a second port, replacing the other mapping, or skipping. default), a second port, replacing the other mapping, or skipping.
@@ -88,7 +88,9 @@ curl -fsSL https://getcodeman.com/install | bash -s -- --local --run
`--tailscale` / `--lan` / `--local` answer the access question, `--name <n>` / `--no-rename` `--tailscale` / `--lan` / `--local` answer the access question, `--name <n>` / `--no-rename`
the name, `--service` / `--run` / `--no-start` the last one. `--yes` takes every default 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). (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. `--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 **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 aborts with instructions instead of running silently. Set `CODEMAN_NONINTERACTIVE=1` to
+175 -48
View File
@@ -95,14 +95,14 @@ TS_READY="0"
TS_SERVE_MODE="" TS_SERVE_MODE=""
TS_SERVE_PATH="/codeman" TS_SERVE_PATH="/codeman"
TS_SERVE_PORT="8443" TS_SERVE_PORT="8443"
# Set to 1 when THIS run performed the `tailscale up` login.
TS_JOINED_HERE="0"
# --name / CODEMAN_TAILSCALE_NAME, and --no-rename. # --name / CODEMAN_TAILSCALE_NAME, and --no-rename.
TS_NAME="${CODEMAN_TAILSCALE_NAME:-}" TS_NAME="${CODEMAN_TAILSCALE_NAME:-}"
TS_NO_RENAME="0" TS_NO_RENAME="0"
# Set to 1 when a rename took our serve mapping down (it is keyed by the old # Set to 1 when a rename took our serve mapping down (it is keyed by the old
# name), so the caller knows to re-add it and never adds one that was not there. # name), so the caller knows to re-add it and never adds one that was not there.
TS_MAPPING_REMOVED_BY_RENAME="0" TS_MAPPING_REMOVED_BY_RENAME="0"
# Set by the INT trap that is armed only while ensure_tailnet_https polls.
TS_HTTPS_POLL_INTERRUPTED="0"
# Where a rename is recorded so uninstall can offer to undo it (tailscaled does # Where a rename is recorded so uninstall can offer to undo it (tailscaled does
# not remember previous names). # not remember previous names).
TS_RENAME_RECORD="$HOME/.codeman/tailscale-rename" TS_RENAME_RECORD="$HOME/.codeman/tailscale-rename"
@@ -256,7 +256,7 @@ print_security_notice() {
echo -e " ${DIM}Details: docs/security-architecture.md${NC}" echo -e " ${DIM}Details: docs/security-architecture.md${NC}"
elif [[ "$BIND_HOST" == "0.0.0.0" ]]; then elif [[ "$BIND_HOST" == "0.0.0.0" ]]; then
echo -e " ${YELLOW}${BOLD}Security:${NC}" echo -e " ${YELLOW}${BOLD}Security:${NC}"
echo -e " The dashboard is reachable from your network at port 3000 and is" echo -e " The dashboard is reachable from your network at port ${CODEMAN_PORT:-3000} and is"
echo -e " password-protected (user ${BOLD}admin${NC}). Keep that password strong:" echo -e " password-protected (user ${BOLD}admin${NC}). Keep that password strong:"
echo -e " whoever logs in can run commands through your agents." echo -e " whoever logs in can run commands through your agents."
echo -e " For access from OUTSIDE your network, prefer Tailscale or a tunnel." echo -e " For access from OUTSIDE your network, prefer Tailscale or a tunnel."
@@ -296,15 +296,26 @@ print_security_notice() {
# Cleanup on Failure # Cleanup on Failure
# ============================================================================ # ============================================================================
cleanup() { # End the spinner and the sudo keepalive. Called from the EXIT trap, and by
local exit_code=$? # hand right before `exec` in main(): exec replaces this shell WITHOUT running
# the trap, and the keepalive keys on $$, which is then the server's pid, so
# it would refresh the sudo timestamp for the whole life of the server.
stop_background_helpers() {
if [[ -n "$SPINNER_PID" ]]; then if [[ -n "$SPINNER_PID" ]]; then
kill "$SPINNER_PID" 2>/dev/null || true kill "$SPINNER_PID" 2>/dev/null || true
SPINNER_PID=""
printf '\r\033[K' >&2 printf '\r\033[K' >&2
fi fi
if [[ -n "$SUDO_KEEPALIVE_PID" ]]; then if [[ -n "$SUDO_KEEPALIVE_PID" ]]; then
kill "$SUDO_KEEPALIVE_PID" 2>/dev/null || true kill "$SUDO_KEEPALIVE_PID" 2>/dev/null || true
SUDO_KEEPALIVE_PID=""
fi fi
return 0
}
cleanup() {
local exit_code=$?
stop_background_helpers
# The "partial install" advice is only true once the work phase has begun: # The "partial install" advice is only true once the work phase has begun:
# a bad flag or a refused question exits before anything was written. # a bad flag or a refused question exits before anything was written.
if [[ $exit_code -ne 0 && -n "$CURRENT_STEP" ]]; then if [[ $exit_code -ne 0 && -n "$CURRENT_STEP" ]]; then
@@ -317,6 +328,12 @@ cleanup() {
error "Step output was saved to $LOG_FILE" error "Step output was saved to $LOG_FILE"
fi fi
fi fi
# A rename takes our serve mapping down in the question phase and the
# after-the-build half puts it back; a failure in between leaves nothing
# fronting Codeman, and `install.sh tailscale` is what restores it.
if [[ $exit_code -ne 0 && "$TS_MAPPING_REMOVED_BY_RENAME" == "1" && -z "$TAILSCALE_SERVE_URL" ]]; then
error "The Tailscale serve mapping was taken down for the rename and not re-added. Restore it with: bash $INSTALL_DIR/install.sh tailscale"
fi
} }
trap cleanup EXIT trap cleanup EXIT
@@ -1566,14 +1583,25 @@ read_existing_binding() {
# CODEMAN_HOST is preset. The server binary itself still defaults to 127.0.0.1 # CODEMAN_HOST is preset. The server binary itself still defaults to 127.0.0.1
# either way. # either way.
choose_network_binding() { choose_network_binding() {
# A previous install's choice is the baseline: re-installing must never
# silently loosen it. That holds for the preset paths below too: a flag
# re-run (`--lan --service` on a unit that carried a password) used to
# rewrite the unit without the password AND with the unauthenticated
# ack, and `--tailscale` dropped the password the same way (found in
# review, 2026-09-21). The caller's own CODEMAN_PASSWORD still wins.
read_existing_binding
# Preset via environment or flag: honor it and skip the prompt entirely. # Preset via environment or flag: honor it and skip the prompt entirely.
# CODEMAN_TAILSCALE=1 composes with a loopback (or absent) CODEMAN_HOST. # CODEMAN_TAILSCALE=1 composes with a loopback (or absent) CODEMAN_HOST.
if [[ -n "${CODEMAN_HOST:-}" ]]; then if [[ -n "${CODEMAN_HOST:-}" ]]; then
BIND_HOST="$CODEMAN_HOST" BIND_HOST="$CODEMAN_HOST"
BIND_PASSWORD="${CODEMAN_PASSWORD:-}" BIND_PASSWORD="${CODEMAN_PASSWORD:-$EXISTING_PASSWORD}"
if [[ "$BIND_HOST" != "127.0.0.1" && -z "$BIND_PASSWORD" ]]; then if [[ "$BIND_HOST" != "127.0.0.1" && -z "$BIND_PASSWORD" ]]; then
BIND_ACK="1" BIND_ACK="1"
fi fi
if [[ -n "$EXISTING_PASSWORD" && -z "${CODEMAN_PASSWORD:-}" ]]; then
info "Keeping the existing dashboard password"
fi
info "Network binding preset: $BIND_HOST" info "Network binding preset: $BIND_HOST"
if [[ "${CODEMAN_TAILSCALE:-0}" == "1" ]]; then if [[ "${CODEMAN_TAILSCALE:-0}" == "1" ]]; then
if [[ "$BIND_HOST" == "127.0.0.1" ]]; then if [[ "$BIND_HOST" == "127.0.0.1" ]]; then
@@ -1586,16 +1614,15 @@ choose_network_binding() {
fi fi
if [[ "${CODEMAN_TAILSCALE:-0}" == "1" ]]; then if [[ "${CODEMAN_TAILSCALE:-0}" == "1" ]]; then
BIND_HOST="127.0.0.1" BIND_HOST="127.0.0.1"
BIND_PASSWORD="${CODEMAN_PASSWORD:-}" BIND_PASSWORD="${CODEMAN_PASSWORD:-$EXISTING_PASSWORD}"
if [[ -n "$EXISTING_PASSWORD" && -z "${CODEMAN_PASSWORD:-}" ]]; then
info "Keeping the existing dashboard password"
fi
info "Tailscale access preset" info "Tailscale access preset"
tailscale_prepare || true tailscale_prepare || true
return 0 return 0
fi fi
# A previous install's choice is the baseline: re-installing must never
# silently loosen it.
read_existing_binding
if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then
if [[ "$EXISTING_FOUND" == "1" ]]; then if [[ "$EXISTING_FOUND" == "1" ]]; then
BIND_HOST="$EXISTING_HOST" BIND_HOST="$EXISTING_HOST"
@@ -1615,7 +1642,7 @@ choose_network_binding() {
local ts_hint="will be installed for you" ts_ready="0" ts_detected_url="" ts_preview="" local ts_hint="will be installed for you" ts_ready="0" ts_detected_url="" ts_preview=""
if check_tailscale; then if check_tailscale; then
ts_hint="installed, needs login" ts_hint="installed, needs login"
if command -v node &>/dev/null && [[ "$(ts_status_field 's.BackendState')" == "Running" ]]; then if command -v node &>/dev/null && [[ "$(ts_backend_state)" == "Running" ]]; then
ts_ready="1" ts_ready="1"
ts_hint="already connected" ts_hint="already connected"
ts_preview="https://$(ts_dns_name)" ts_preview="https://$(ts_dns_name)"
@@ -1916,11 +1943,30 @@ ts_serve_port_used() {
' "$1" 2>/dev/null | grep -q 1 ' "$1" 2>/dev/null | grep -q 1
} }
# This node's MagicDNS name (no trailing dot), its short name, and the tailnet # The daemon state (Running, NeedsLogin, Stopped, ...) and this node's
# suffix. Empty when tailscale is not running. # MagicDNS name (no trailing dot), plus its short name and the tailnet suffix.
# Both are read through node when it is there and with a line grep when it is
# not: `tailscale status --json` is printed one key per line and Self precedes
# Peer, so the first match is this node. The preflight summary is the one
# reader that runs before node is installed, and it used to report a
# logged-in node as "not logged in" on exactly the fresh box this installer
# is for; everything else runs after ask_dependencies. Empty when tailscale
# is not running.
ts_backend_state() {
if command -v node &>/dev/null; then
ts_status_field 's.BackendState'
return 0
fi
ts_cmd status --json 2>/dev/null | sed -n 's/^ *"BackendState": *"\([^"]*\)".*/\1/p' | head -1 || true
}
ts_dns_name() { ts_dns_name() {
local dns local dns
if command -v node &>/dev/null; then
dns=$(ts_status_field 's.Self && s.Self.DNSName') dns=$(ts_status_field 's.Self && s.Self.DNSName')
else
dns=$(ts_cmd status --json 2>/dev/null | sed -n 's/^ *"DNSName": *"\([^"]*\)".*/\1/p' | head -1 || true)
fi
printf '%s' "${dns%.}" printf '%s' "${dns%.}"
} }
@@ -1949,7 +1995,7 @@ ts_sanitize_name() {
detect_tailscale_serve_url() { detect_tailscale_serve_url() {
check_tailscale || return 0 check_tailscale || return 0
command -v node &>/dev/null || return 0 command -v node &>/dev/null || return 0
[[ "$(ts_status_field 's.BackendState')" == "Running" ]] || return 0 [[ "$(ts_backend_state)" == "Running" ]] || return 0
local mapping local mapping
mapping=$(ts_serve_find_port_mapping "${CODEMAN_PORT:-3000}") mapping=$(ts_serve_find_port_mapping "${CODEMAN_PORT:-3000}")
[[ -n "$mapping" ]] || return 0 [[ -n "$mapping" ]] || return 0
@@ -2032,7 +2078,7 @@ offer_install_tailscale() {
ensure_tailscale_login() { ensure_tailscale_login() {
local state local state
state=$(ts_status_field 's.BackendState') state=$(ts_backend_state)
if [[ "$state" == "Running" ]]; then if [[ "$state" == "Running" ]]; then
return 0 return 0
fi fi
@@ -2063,8 +2109,7 @@ ensure_tailscale_login() {
fi fi
return 1 return 1
fi fi
if [[ "$(ts_status_field 's.BackendState')" == "Running" ]]; then if [[ "$(ts_backend_state)" == "Running" ]]; then
TS_JOINED_HERE="1"
success "Connected to your tailnet as $(ts_node_name)" success "Connected to your tailnet as $(ts_node_name)"
return 0 return 0
fi fi
@@ -2106,8 +2151,27 @@ ensure_tailscale_operator() {
# browser, and the old "re-check now?" question was one more thing to answer); # browser, and the old "re-check now?" question was one more thing to answer);
# opens the admin page where there is a browser to open it in. # opens the admin page where there is a browser to open it in.
ensure_tailnet_https() { ensure_tailnet_https() {
# Ctrl+C during the poll used to end the whole installer (the only trap
# was EXIT) while the prompt said "give up", and the user re-answered every
# question on the retry. The INT trap is armed for the poll only and
# restored on every way out; the poll then returns 1, which lands on the
# retrofit hint like any other Tailscale fallback.
local rc=0
TS_HTTPS_POLL_INTERRUPTED="0"
trap 'TS_HTTPS_POLL_INTERRUPTED=1' INT
tailnet_https_poll || rc=$?
trap - INT
return "$rc"
}
tailnet_https_poll() {
local waited=0 printed="0" magic cert local waited=0 printed="0" magic cert
while true; do while true; do
if [[ "$TS_HTTPS_POLL_INTERRUPTED" == "1" ]]; then
echo "" >&2
warn "Interrupted; skipping Tailscale for this run."
return 1
fi
magic=$(ts_status_field 's.CurrentTailnet && s.CurrentTailnet.MagicDNSEnabled ? "1" : ""') magic=$(ts_status_field 's.CurrentTailnet && s.CurrentTailnet.MagicDNSEnabled ? "1" : ""')
cert=$(ts_status_field 'Array.isArray(s.CertDomains) && s.CertDomains.length > 0 ? "1" : ""') cert=$(ts_status_field 'Array.isArray(s.CertDomains) && s.CertDomains.length > 0 ? "1" : ""')
if [[ "$magic" == "1" && "$cert" == "1" ]]; then if [[ "$magic" == "1" && "$cert" == "1" ]]; then
@@ -2135,7 +2199,7 @@ ensure_tailnet_https() {
return 1 return 1
fi fi
open_in_browser "https://login.tailscale.com/admin/dns" open_in_browser "https://login.tailscale.com/admin/dns"
echo -e " ${DIM}Waiting for the toggle (checking every 5 s; Ctrl+C to give up)${NC}" >&2 echo -e " ${DIM}Waiting for the toggle (checking every 5 s; Ctrl+C skips Tailscale for this run)${NC}" >&2
fi fi
if [[ "$waited" -ge 300 ]]; then if [[ "$waited" -ge 300 ]]; then
echo "" >&2 echo "" >&2
@@ -2144,7 +2208,8 @@ ensure_tailnet_https() {
fi fi
waited=0 waited=0
fi fi
sleep 5 # A Ctrl+C lands in this sleep; the trap only records it.
sleep 5 || true
waited=$((waited + 5)) waited=$((waited + 5))
printf '.' >&2 printf '.' >&2
done done
@@ -2450,11 +2515,16 @@ sync_service_base_url() {
BIND_PASSWORD="$EXISTING_PASSWORD" BIND_PASSWORD="$EXISTING_PASSWORD"
BIND_ACK="$EXISTING_ACK" BIND_ACK="$EXISTING_ACK"
info "Updating the service to run under ${BIND_BASE_URL:-/} ..." info "Updating the service to run under ${BIND_BASE_URL:-/} ..."
local rc=0
if [[ "$(uname -s)" == "Darwin" ]]; then if [[ "$(uname -s)" == "Darwin" ]]; then
setup_launchd_service setup_launchd_service || rc=$?
else else
setup_systemd_service setup_systemd_service || rc=$?
fi fi
# The unit now carries the new sub-path: that is what the done screen and
# the next re-run read back.
[[ "$rc" -ne 0 ]] || EXISTING_BASE_URL="$BIND_BASE_URL"
return "$rc"
} }
# A loopback install with Tailscale already connected but nothing fronting # A loopback install with Tailscale already connected but nothing fronting
@@ -2471,7 +2541,7 @@ maybe_offer_tailscale_repair() {
fi fi
check_tailscale || return 0 check_tailscale || return 0
command -v node &>/dev/null || return 0 command -v node &>/dev/null || return 0
[[ "$(ts_status_field 's.BackendState')" == "Running" ]] || return 0 [[ "$(ts_backend_state)" == "Running" ]] || return 0
# Already fronting Codeman: nothing to repair. # Already fronting Codeman: nothing to repair.
[[ -z "$(detect_tailscale_serve_url)" ]] || return 0 [[ -z "$(detect_tailscale_serve_url)" ]] || return 0
@@ -2547,7 +2617,7 @@ setup_name_subcommand() {
die "node is required. Install Codeman first (run the installer without arguments)." die "node is required. Install Codeman first (run the installer without arguments)."
fi fi
check_tailscale || die "Tailscale is not installed. Run: bash $INSTALL_DIR/install.sh tailscale" check_tailscale || die "Tailscale is not installed. Run: bash $INSTALL_DIR/install.sh tailscale"
if [[ "$(ts_status_field 's.BackendState')" != "Running" ]]; then if [[ "$(ts_backend_state)" != "Running" ]]; then
die "Tailscale is not connected. Run: bash $INSTALL_DIR/install.sh tailscale" die "Tailscale is not connected. Run: bash $INSTALL_DIR/install.sh tailscale"
fi fi
read_existing_binding read_existing_binding
@@ -2564,6 +2634,9 @@ setup_name_subcommand() {
fi fi
if [[ "$TS_MAPPING_REMOVED_BY_RENAME" == "1" ]]; then if [[ "$TS_MAPPING_REMOVED_BY_RENAME" == "1" ]]; then
if tailscale_choose_mapping; then if tailscale_choose_mapping; then
# The shape can change across a rename (a root mapping freed :443,
# or the user picks a port this time), and the unit must follow.
sync_service_base_url || true
TS_READY="1" TS_READY="1"
tailscale_apply || true tailscale_apply || true
if codeman_answers_locally; then if codeman_answers_locally; then
@@ -2575,7 +2648,8 @@ setup_name_subcommand() {
BIND_HOST="${EXISTING_HOST:-127.0.0.1}" BIND_HOST="${EXISTING_HOST:-127.0.0.1}"
BIND_PASSWORD="$EXISTING_PASSWORD" BIND_PASSWORD="$EXISTING_PASSWORD"
BIND_ACK="$EXISTING_ACK" BIND_ACK="$EXISTING_ACK"
BIND_BASE_URL="$EXISTING_BASE_URL" # With no service on disk the sub-path lives only in this run's choice.
[[ -n "$BIND_BASE_URL" ]] || BIND_BASE_URL="$EXISTING_BASE_URL"
print_done_screen "" "" print_done_screen "" ""
print_security_notice print_security_notice
} }
@@ -3013,13 +3087,10 @@ main() {
# Source profile to pick up PATH changes, then exec codeman # Source profile to pick up PATH changes, then exec codeman
# shellcheck disable=SC1090 # shellcheck disable=SC1090
source "$profile" 2>/dev/null || true source "$profile" 2>/dev/null || true
if [[ -n "$BIND_HOST" ]]; then export_bind_env
export CODEMAN_HOST="$BIND_HOST" # exec skips the EXIT trap: end the sudo keepalive here, or it keeps
[[ -n "$BIND_PASSWORD" ]] && export CODEMAN_PASSWORD="$BIND_PASSWORD" # refreshing the sudo timestamp for as long as the server runs.
[[ "$BIND_ACK" == "1" ]] && export CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1 stop_background_helpers
fi
[[ -n "$BIND_BASE_URL" ]] && export CODEMAN_BASE_URL="$BIND_BASE_URL"
[[ -n "${CODEMAN_PORT:-}" ]] && export CODEMAN_PORT
exec node "$INSTALL_DIR/dist/index.js" web exec node "$INSTALL_DIR/dist/index.js" web
fi fi
} }
@@ -3045,8 +3116,11 @@ preflight_detect() {
TS_STATE="absent" TS_STATE="absent"
if check_tailscale; then if check_tailscale; then
TS_STATE="installed" TS_STATE="installed"
if command -v node &>/dev/null && [[ "$(ts_status_field 's.BackendState')" == "Running" ]]; then if [[ "$(ts_backend_state)" == "Running" ]]; then
TS_STATE="connected" TS_STATE="connected"
# "serving" needs the serve-status parser, and that needs node
# (installed by ask_dependencies when missing); the menu hint in
# choose_network_binding re-checks once it is there.
if [[ -n "$(detect_tailscale_serve_url)" ]]; then if [[ -n "$(detect_tailscale_serve_url)" ]]; then
TS_STATE="serving" TS_STATE="serving"
fi fi
@@ -3252,6 +3326,37 @@ install_symlink() {
# Phase 3: done # Phase 3: done
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
# The environment a hand-started `codeman web` needs in order to match what
# this run chose: every non-default value, composed in ONE place so the done
# screen's Start line and the exec branch of main() cannot disagree (the Start
# line used to print a bare `codeman web` under a URL that carried a sub-path
# and a port). start_command_hint prints the line with a placeholder for the
# password; export_bind_env exports the real values for the exec.
start_command_hint() {
local env=""
if [[ -n "$BIND_HOST" && "$BIND_HOST" != "127.0.0.1" ]]; then
env="CODEMAN_HOST=$BIND_HOST"
[[ -n "$BIND_PASSWORD" ]] && env="$env CODEMAN_PASSWORD='<your-password>'"
[[ "$BIND_ACK" == "1" ]] && env="$env CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1"
fi
[[ -n "$BIND_BASE_URL" ]] && env="${env:+$env }CODEMAN_BASE_URL=$BIND_BASE_URL"
if [[ -n "${CODEMAN_PORT:-}" && "$CODEMAN_PORT" != "3000" ]]; then
env="${env:+$env }CODEMAN_PORT=$CODEMAN_PORT"
fi
printf '%s' "${env:+$env }codeman web"
}
export_bind_env() {
if [[ -n "$BIND_HOST" ]]; then
export CODEMAN_HOST="$BIND_HOST"
[[ -n "$BIND_PASSWORD" ]] && export CODEMAN_PASSWORD="$BIND_PASSWORD"
[[ "$BIND_ACK" == "1" ]] && export CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1
fi
[[ -n "$BIND_BASE_URL" ]] && export CODEMAN_BASE_URL="$BIND_BASE_URL"
[[ -n "${CODEMAN_PORT:-}" ]] && export CODEMAN_PORT
return 0
}
# A QR code of the URL, for the phone in the user's hand. Uses the qrcode # A QR code of the URL, for the phone in the user's hand. Uses the qrcode
# package Codeman itself depends on, so nothing extra is installed; skipped on # package Codeman itself depends on, so nothing extra is installed; skipped on
# a terminal without color support or too narrow to draw it. # a terminal without color support or too narrow to draw it.
@@ -3291,7 +3396,7 @@ print_done_screen() {
codeman_answers_locally && running="1" codeman_answers_locally && running="1"
# Which supervisor, when this run did not decide: whatever is on disk. # Which supervisor, when this run did not decide: whatever is on disk.
local svc="$SERVICE_TYPE" local svc="$SERVICE_TYPE" this_run="$launch"
if [[ -z "$launch" ]]; then if [[ -z "$launch" ]]; then
svc="" svc=""
if [[ -f "$unit" ]]; then svc="systemd" if [[ -f "$unit" ]]; then svc="systemd"
@@ -3310,7 +3415,11 @@ print_done_screen() {
echo "" echo ""
echo -e "${GREEN}${BOLD}============================================================${NC}" echo -e "${GREEN}${BOLD}============================================================${NC}"
if [[ "$running" == "1" ]]; then if [[ "$running" == "1" && -n "$this_run" && "$svc" == "launchd-daemon" ]]; then
# This run built a new dist/ but left the daemon alone, so what answers
# on the port is still the previous build.
echo -e "${GREEN}${BOLD} Codeman${version:+ $version} is built${NC} ${DIM}(the LaunchDaemon still runs the previous build until restarted)${NC}"
elif [[ "$running" == "1" ]]; then
echo -e "${GREEN}${BOLD} Codeman${version:+ $version} is running${NC}" echo -e "${GREEN}${BOLD} Codeman${version:+ $version} is running${NC}"
else else
echo -e "${GREEN}${BOLD} Codeman${version:+ $version} is installed${NC} ${DIM}(not running yet)${NC}" echo -e "${GREEN}${BOLD} Codeman${version:+ $version} is installed${NC} ${DIM}(not running yet)${NC}"
@@ -3344,7 +3453,8 @@ print_done_screen() {
echo -e " ${DIM}A LaunchAgent starts after you log in to this Mac. For a headless Mac see the wiki (Running As A Service).${NC}" echo -e " ${DIM}A LaunchAgent starts after you log in to this Mac. For a headless Mac see the wiki (Running As A Service).${NC}"
;; ;;
launchd-daemon) launchd-daemon)
echo -e " ${BOLD}Manage${NC} ${CYAN}sudo launchctl print system/com.codeman.web${NC} # the LaunchDaemon that supervises it" echo -e " ${BOLD}Manage${NC} ${CYAN}sudo launchctl kickstart -k system/com.codeman.web${NC} # restart (picks up a new build)"
echo -e " ${CYAN}sudo launchctl print system/com.codeman.web${NC} # the LaunchDaemon that supervises it"
;; ;;
*) *)
echo -e " ${BOLD}Manage${NC} ${CYAN}systemctl --user restart codeman-web${NC} ${CYAN}journalctl --user -u codeman-web -f${NC}" echo -e " ${BOLD}Manage${NC} ${CYAN}systemctl --user restart codeman-web${NC} ${CYAN}journalctl --user -u codeman-web -f${NC}"
@@ -3352,17 +3462,15 @@ print_done_screen() {
esac esac
elif [[ "$launch" == "2" ]]; then elif [[ "$launch" == "2" ]]; then
echo -e " ${YELLOW}${BOLD}The service was set up but is not running yet${NC} (see the warnings above)." echo -e " ${YELLOW}${BOLD}The service was set up but is not running yet${NC} (see the warnings above)."
echo -e " ${DIM}You can always run it directly:${NC} ${CYAN}codeman web${NC}" echo -e " ${DIM}You can always run it directly:${NC} ${CYAN}$(start_command_hint)${NC}"
elif [[ "$launch" == "3" ]]; then elif [[ "$launch" == "3" ]]; then
echo -e " ${BOLD}Start${NC} ${CYAN}$(start_command_hint)${NC}"
if [[ "$BIND_HOST" == "0.0.0.0" ]]; then if [[ "$BIND_HOST" == "0.0.0.0" ]]; then
if [[ -n "$BIND_PASSWORD" ]]; then
echo -e " ${BOLD}Start${NC} ${CYAN}CODEMAN_HOST=0.0.0.0 CODEMAN_PASSWORD='<your-password>' codeman web${NC}"
else
echo -e " ${BOLD}Start${NC} ${CYAN}CODEMAN_HOST=0.0.0.0 codeman web${NC}"
fi
echo -e " ${DIM}(a bare 'codeman web' binds 127.0.0.1, this machine only)${NC}" echo -e " ${DIM}(a bare 'codeman web' binds 127.0.0.1, this machine only)${NC}"
elif [[ "$(start_command_hint)" == "codeman web" ]]; then
echo -e " ${DIM}(or: codeman web -d to detach; codeman service install for boot)${NC}"
else else
echo -e " ${BOLD}Start${NC} ${CYAN}codeman web${NC} ${DIM}(or: codeman web -d to detach; codeman service install for boot)${NC}" echo -e " ${DIM}(the same variables apply to: codeman web -d)${NC}"
fi fi
fi fi
echo -e " ${BOLD}Update${NC} re-run the install line, or App Settings -> System -> Updates" echo -e " ${BOLD}Update${NC} re-run the install line, or App Settings -> System -> Updates"
@@ -3433,6 +3541,11 @@ update() {
launchctl unload "$agent_plist" 2>/dev/null || true launchctl unload "$agent_plist" 2>/dev/null || true
launchctl load "$agent_plist" 2>/dev/null || true launchctl load "$agent_plist" 2>/dev/null || true
success "LaunchAgent restarted" success "LaunchAgent restarted"
elif [[ -f "/Library/LaunchDaemons/com.codeman.web.plist" ]]; then
# Left alone on purpose (see setup_launchd_service); it keeps running
# the previous build until its owner restarts it.
info "A system LaunchDaemon supervises Codeman; restart it to run the new build:"
echo -e " ${CYAN}sudo launchctl kickstart -k system/com.codeman.web${NC}"
else else
echo -e " ${DIM}Restart codeman web to use the new version:${NC}" echo -e " ${DIM}Restart codeman web to use the new version:${NC}"
echo -e " ${CYAN}codeman web --stop; codeman web -d${NC}" echo -e " ${CYAN}codeman web --stop; codeman web -d${NC}"
@@ -3453,6 +3566,9 @@ update() {
# chance at remote access; the fresh-install path asks outright. # chance at remote access; the fresh-install path asks outright.
maybe_offer_tailscale_repair maybe_offer_tailscale_repair
# The re-run is how everyone updates, and the first thing people try when
# they want the URL back: end on the same screen the install ends on.
print_done_screen "" ""
print_security_notice print_security_notice
} }
@@ -3488,9 +3604,18 @@ uninstall() {
success "Removed LaunchAgent" success "Removed LaunchAgent"
fi fi
if [[ -f "$daemon_plist" ]]; then if [[ -f "$daemon_plist" ]]; then
# This installer never writes a LaunchDaemon (setup_launchd_service
# leaves one alone), so this one is the user's own headless-Mac setup:
# ask before touching it. The default stays yes, because a daemon left
# pointing at a removed install restarts into failure every 10 s.
warn "A system LaunchDaemon supervises Codeman ($daemon_plist); this installer did not write it."
if prompt_yes_no "Remove that LaunchDaemon too (needs sudo)?" "y"; then
sudo launchctl unload "$daemon_plist" 2>/dev/null || true sudo launchctl unload "$daemon_plist" 2>/dev/null || true
sudo rm -f "$daemon_plist" sudo rm -f "$daemon_plist"
success "Removed LaunchDaemon" success "Removed LaunchDaemon"
else
info "Kept $daemon_plist. Remove it later with: sudo launchctl unload $daemon_plist && sudo rm $daemon_plist"
fi
fi fi
# Remove OUR tailscale serve mapping (whatever shape it has) only. Other # Remove OUR tailscale serve mapping (whatever shape it has) only. Other
@@ -3607,7 +3732,9 @@ EOF
# function below reads one source of truth. A flag that changes how an existing # function below reads one source of truth. A flag that changes how an existing
# install is reached or run also flips RECONFIGURE, so a bare re-run takes the # install is reached or run also flips RECONFIGURE, so a bare re-run takes the
# full flow (which re-asks nothing the flag already answered) instead of the # full flow (which re-asks nothing the flag already answered) instead of the
# quiet update. # quiet update. A password or a port lives in the unit, so those two reconfigure
# as well: the quiet update never rewrites the unit and used to drop them
# silently.
parse_flags() { parse_flags() {
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
@@ -3627,13 +3754,13 @@ parse_flags() {
--password) --password)
shift shift
[[ $# -gt 0 ]] || die "--password needs a value" [[ $# -gt 0 ]] || die "--password needs a value"
CODEMAN_PASSWORD="$1" ;; CODEMAN_PASSWORD="$1"; RECONFIGURE="1" ;;
--password=*) CODEMAN_PASSWORD="${1#--password=}" ;; --password=*) CODEMAN_PASSWORD="${1#--password=}"; RECONFIGURE="1" ;;
--port) --port)
shift shift
[[ $# -gt 0 ]] || die "--port needs a value" [[ $# -gt 0 ]] || die "--port needs a value"
CODEMAN_PORT="$1"; export CODEMAN_PORT ;; CODEMAN_PORT="$1"; export CODEMAN_PORT; RECONFIGURE="1" ;;
--port=*) CODEMAN_PORT="${1#--port=}"; export CODEMAN_PORT ;; --port=*) CODEMAN_PORT="${1#--port=}"; export CODEMAN_PORT; RECONFIGURE="1" ;;
--help|-h) usage; exit 0 ;; --help|-h) usage; exit 0 ;;
update|uninstall|tailscale|name|status|cloudflared) update|uninstall|tailscale|name|status|cloudflared)
[[ -z "$SUBCOMMAND" ]] || die "Only one subcommand at a time ($SUBCOMMAND and $1 given)." [[ -z "$SUBCOMMAND" ]] || die "Only one subcommand at a time ($SUBCOMMAND and $1 given)."
+124
View File
@@ -407,3 +407,127 @@ describe('install.sh detect_all_clis and a disabled entry', () => {
expect(run.stdout).toContain('found=0'); expect(run.stdout).toContain('found=0');
}); });
}); });
describe('install.sh review fixes for #460', () => {
// Each pin here is a finding from the two reviews of PR #460 (the DeepSeek Harness
// pass, then the Claude pass), kept as a static guard so the fix cannot quietly rot.
const fn = (name: string, until: string) => {
const start = SOURCE.indexOf(`${name}() {`);
expect(start, `${name}() missing`).toBeGreaterThan(-1);
const end = SOURCE.indexOf(until, start);
expect(end, `${until} missing after ${name}()`).toBeGreaterThan(start);
return SOURCE.slice(start, end);
};
it('keeps an existing password on the flag and env preset paths', () => {
// `--lan --service` on a unit that carried a password used to rewrite it without the
// password and with the unauthenticated ack; `--tailscale` dropped it the same way.
const body = fn('choose_network_binding', 'get_tailscale_path() {');
expect(body.match(/BIND_PASSWORD="\$\{CODEMAN_PASSWORD:-\$EXISTING_PASSWORD\}"/g)?.length).toBe(2);
expect(body).not.toMatch(/BIND_PASSWORD="\$\{CODEMAN_PASSWORD:-\}"/);
// The presets can only keep what was read, so the read comes first.
expect(body.indexOf('read_existing_binding')).toBeLessThan(body.indexOf('CODEMAN_HOST:-'));
});
it('composes the hand-start environment in one place', () => {
// "Do not start" under a sub-path or a custom port used to print a bare `codeman web`
// under URLs that carried both.
const hint = fn('start_command_hint', 'export_bind_env() {');
const exported = fn('export_bind_env', '# A QR code of the URL');
for (const key of [
'CODEMAN_HOST',
'CODEMAN_PASSWORD',
'CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK',
'CODEMAN_BASE_URL',
'CODEMAN_PORT',
]) {
expect(hint, `${key} missing from start_command_hint`).toContain(key);
expect(exported, `${key} missing from export_bind_env`).toContain(key);
}
const done = fn('print_done_screen', '\nupdate() {');
expect(done).toContain('$(start_command_hint)');
expect(done).not.toMatch(/CODEMAN_HOST=0\.0\.0\.0 codeman web/);
});
it('flips RECONFIGURE for --password and --port', () => {
// Neither used to, so on a completed install both took the quiet update path, which
// never rewrites the unit: the password never landed and the port stayed at 3000.
const parse = fn('parse_flags', '# Sourcing guard');
for (const label of ['--password)', '--password=*)', '--port)', '--port=*)']) {
const at = parse.indexOf(label);
expect(at, `${label} missing`).toBeGreaterThan(-1);
expect(parse.slice(at, parse.indexOf(';;', at)), `${label} does not reconfigure`).toContain('RECONFIGURE="1"');
}
});
it('ends the sudo keepalive and exports the binding before the exec', () => {
// exec skips the EXIT trap, and the keepalive keys on $$, which becomes the server's
// pid: it refreshed the sudo timestamp for the server's whole life.
const execAt = SOURCE.indexOf('exec node "$INSTALL_DIR/dist/index.js" web');
expect(execAt).toBeGreaterThan(-1);
const before = SOURCE.slice(SOURCE.lastIndexOf('source "$profile"', execAt), execAt);
expect(before).toContain('export_bind_env');
expect(before).toContain('stop_background_helpers');
});
it('lets Ctrl+C skip the HTTPS-toggle poll instead of ending the run', () => {
const body = fn('ensure_tailnet_https', 'tailnet_https_poll() {');
expect(body).toMatch(/trap '[^']*' INT/);
expect(body).toContain('trap - INT');
expect(fn('tailnet_https_poll', '# Everything Tailscale that needs a human')).toContain('sleep 5 || true');
});
it('asks before removing a LaunchDaemon it never wrote', () => {
const body = fn('uninstall', '\nusage() {');
const ask = body.indexOf('prompt_yes_no "Remove that LaunchDaemon too');
expect(ask).toBeGreaterThan(-1);
expect(body.indexOf('sudo rm -f "$daemon_plist"')).toBeGreaterThan(ask);
});
it('re-syncs the unit after `install.sh name` re-adds the mapping, and ends an update on the done screen', () => {
const name = fn('setup_name_subcommand', '\nstatus_subcommand() {');
expect(name.indexOf('sync_service_base_url')).toBeGreaterThan(name.indexOf('tailscale_choose_mapping'));
expect(name.indexOf('sync_service_base_url')).toBeLessThan(name.indexOf('tailscale_apply'));
expect(fn('update', '\nuninstall() {')).toContain('print_done_screen "" ""');
});
it('reads the Tailscale state in the preflight without node, and no longer records TS_JOINED_HERE', () => {
const preflight = fn('preflight_detect', '\nprint_preflight_summary() {');
expect(preflight).toContain('ts_backend_state');
expect(preflight).not.toContain('command -v node');
expect(fn('ts_backend_state', '\nts_dns_name() {')).toContain('sed -n');
expect(SOURCE).not.toContain('TS_JOINED_HERE');
expect(CODE).not.toContain('at port 3000');
});
it('drives the kept password and the start line in a real bash', () => {
const DRIVER = `
set -euo pipefail
export CODEMAN_INSTALL_SH_LIB=1
. "$1"
read_existing_binding() { EXISTING_FOUND=1; EXISTING_HOST=0.0.0.0; EXISTING_PASSWORD=s3cret; EXISTING_ACK=0; EXISTING_BASE_URL=""; }
tailscale_prepare() { return 0; }
parse_flags $DRIVE_FLAGS
choose_network_binding >/dev/null 2>&1
echo "host=$BIND_HOST pw=$BIND_PASSWORD ack=$BIND_ACK"
BIND_HOST=0.0.0.0; BIND_PASSWORD=x; BIND_ACK=0; BIND_BASE_URL=/codeman; CODEMAN_PORT=4000
echo "hint=$(start_command_hint)"
BIND_HOST=127.0.0.1; BIND_PASSWORD=""; BIND_BASE_URL=""; CODEMAN_PORT=""
echo "bare=$(start_command_hint)"
`;
const drive = (flags: string) => {
const env = { ...process.env, DRIVE_FLAGS: flags };
delete env.CODEMAN_PASSWORD;
const result = spawnSync('bash', ['-c', DRIVER, 'bash', INSTALL_SH], { encoding: 'utf-8', timeout: 30_000, env });
expect(result.status, result.stderr).toBe(0);
return result.stdout;
};
const lan = drive('--lan');
expect(lan).toContain('host=0.0.0.0 pw=s3cret ack=0');
expect(lan).toContain(
"hint=CODEMAN_HOST=0.0.0.0 CODEMAN_PASSWORD='<your-password>' CODEMAN_BASE_URL=/codeman CODEMAN_PORT=4000 codeman web"
);
expect(lan).toContain('bare=codeman web');
expect(drive('--tailscale')).toContain('host=127.0.0.1 pw=s3cret ack=0');
});
});