feat(install): ask three questions up front, then install unattended and end on the URL with a QR code

The installer used to ask about ten things, half of them after a multi-minute
build, and the question that matters most (how do I reach the dashboard) came
last. It now looks at what is on the machine, asks at most three questions
(access, an optional tailnet name, service), and does the rest unattended.

- Every step that needs a human runs before the build: one consent for all
  missing packages, one sudo prompt kept warm for the run, the AI CLI menu,
  and the Tailscale install/login/operator/HTTPS-toggle preflight (the toggle
  is polled and the admin page opened in a browser, instead of "re-check
  now?").
- The build, the service and `tailscale serve` run behind spinners with their
  output in ~/.codeman/install.log; the tail is shown on failure and a failed
  dependency install names its step.
- The done screen leads with the URL (tailnet, network, this machine) and a
  terminal QR code from the qrcode package Codeman already ships.
  `install.sh status` prints it again.
- Tailscale is two halves: tailscale_prepare (question phase) decides the
  serve SHAPE, tailscale_apply (after the build) issues the one serve command.
  When :443 already belongs to another app, Codeman goes under a sub-path
  (serve --set-path /codeman + CODEMAN_BASE_URL in the unit; serve strips the
  prefix, Codeman's ingress tolerates that, --base-url covers the URLs it
  emits) or a second port, instead of replace-or-nothing.
- Renaming the node to codeman-<hostname> is opt-in and defaults to no
  everywhere (the tailnet name is the machine's ssh identity); --name and
  `install.sh name` do it, uninstall offers the old name back. Serve config is
  keyed by the DNS name, so a rename takes our mapping down first and re-adds
  it under the new name.
- Flags pipe through `bash -s --`: --tailscale|--lan|--local, --name|--no-rename,
  --service|--run|--no-start, --yes, --password, --port. --port is now also
  written into the service file.
- npm install runs with CODEMAN_NO_AUTOSTART=1: postinstall otherwise builds
  and starts a detached `codeman web` on 127.0.0.1:3000, which made the
  service crash-loop on EADDRINUSE while the done screen reported "running"
  off the orphan (fresh Ubuntu 24 sandbox).
- The LAN address comes from the default route, not the first interface.
- A foreign /Library/LaunchDaemons/com.codeman.web.plist is left alone
  instead of being replaced by a LaunchAgent.
- The cloudflared question leaves the main flow (`install.sh cloudflared`).
- "Continue WITHOUT a password?" defaults to yes (owner decision).

Tests: the invariants test pins no `serve reset`, no funnel, no Tailscale
Service, every serve mutation through ts_cmd_serve, rename before shape,
flag/header parity, the rename default and the NO_AUTOSTART opt-out; the CI
bash 3.2 step drives the question phase with stubbed tailscale state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-09-20 21:38:11 +02:00
parent 51b4a1b758
commit af744bdb54
3 changed files with 1472 additions and 539 deletions
+32
View File
@@ -100,6 +100,38 @@ jobs:
fi
echo "bash $BASH_VERSION: dsh identity probe survives a missing timeout"
'
# Installer v2: the question phase runs before the build, and every decision it
# takes is bash logic over stubbed tailscale state. Drive the flags, the launch
# default, the occupied-:443 menu and the rename question with canned answers,
# so a bash-4 construct or a flipped default in any of them fails here, not on a
# Mac. The JSON parsers need node (absent in this image) and are stubbed; their
# own coverage is test/install-sh-invariants.test.ts plus the vitest gate.
docker run --rm -v "$PWD":/w -w /w -e CODEMAN_INSTALL_SH_LIB=1 -e HOME=/tmp/h bash:3.2 bash -c '
set -euo pipefail
mkdir -p /tmp/h
. /w/install.sh
parse_flags --tailscale --service --name Build-Box --port 4000
[[ "$CODEMAN_TAILSCALE" == "1" && "$LAUNCH_PRESET" == "2" && "$TS_NAME" == "Build-Box" && "$CODEMAN_PORT" == "4000" ]]
[[ "$(ts_sanitize_name "$TS_NAME")" == "build-box" ]]
has_tty() { return 0; }
ANSWER=""; read_reply() { eval "$1=\"\$ANSWER\""; }
systemctl() { return 0; }
LAUNCH_PRESET=""; NONINTERACTIVE=0
choose_launch_mode linux >/dev/null 2>&1
[[ "$LAUNCH_CHOICE" == "2" ]]
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_serve_443_target_port() { printf 8080; }
ts_serve_find_port_mapping() { :; }
ts_serve_port_used() { return 1; }
detect_tailscale_serve_url() { :; }
tailscale_choose_mapping >/dev/null 2>&1
[[ "$TS_SERVE_MODE" == "path" && "$BIND_BASE_URL" == "/codeman" ]]
RENAMED=""; tailscale_rename_node() { RENAMED="$1"; }
TS_NAME=""; tailscale_choose_name >/dev/null 2>&1
[[ -z "$RENAMED" ]]
echo "bash $BASH_VERSION: question phase (flags, launch default, occupied :443, rename opt-in) ok"
'
- name: CLI catalogue artifacts are in sync with stock.ts
run: npm run generate:cli-catalog -- --check
+1307 -509
View File
File diff suppressed because it is too large Load Diff
+104 -1
View File
@@ -179,8 +179,13 @@ describe('install.sh runtime safety', () => {
/if \[\[ -n "\$\{CODEMAN_INSTALL_SH_LIB:-\}" \]\]; then return 0 2>\/dev\/null \|\| exit 0; fi/
);
const guardAt = SOURCE.indexOf('CODEMAN_INSTALL_SH_LIB');
const dispatchAt = SOURCE.indexOf('case "${1:-}" in');
const dispatchAt = SOURCE.indexOf('case "$SUBCOMMAND" in');
expect(dispatchAt, 'the dispatch case must exist').toBeGreaterThan(-1);
expect(guardAt, 'the sourcing guard must precede the dispatch case').toBeLessThan(dispatchAt);
// parse_flags runs only in the dispatch tail, after the guard: a sourced copy must
// never consume the harness's own arguments.
const parseAt = SOURCE.indexOf('\nparse_flags "$@"');
expect(parseAt, 'parse_flags must be invoked after the sourcing guard').toBeGreaterThan(guardAt);
});
it('still sets the strict flags it has always run under', () => {
@@ -205,6 +210,104 @@ describe('install.sh DeepSeek identity probe', () => {
});
});
describe('install.sh owns the build and the start', () => {
it('runs npm install with CODEMAN_NO_AUTOSTART=1', () => {
// scripts/postinstall.js builds dist/ and starts a detached `codeman web` on its
// own unless told not to. Under the installer that orphan made the service
// crash-loop on EADDRINUSE while the done screen reported "running" off the
// orphan (fresh Ubuntu 24 sandbox, 2026-09-20). Every npm install here must
// carry the opt-out.
// Executed installs only: the catalogue's `npm install -g` literals and the
// failure message that quotes the command are prose here.
const installs = CODE_LINES.filter(
(line) => /\bnpm install\b/.test(line) && !/npm install -g/.test(line) && !/\b(error|warn|info|echo) "/.test(line)
);
expect(installs.length, 'expected the one npm install call').toBeGreaterThan(0);
for (const line of installs) {
expect(line, `npm install without CODEMAN_NO_AUTOSTART=1:\n ${line}`).toContain('CODEMAN_NO_AUTOSTART=1');
}
});
});
describe('install.sh Tailscale safety rules', () => {
// Every rule here protects config that is not ours. `serve reset` destroys a user's
// unrelated serve mappings (the maintainer's own node carries two); funnel is the
// public internet, a different risk class than tailnet-only serve; advertising a
// Tailscale Service requires a tagged node and admin approval and is documented
// as a hint only. All three are pinned as absences.
it('never runs `tailscale serve reset`', () => {
const offenders = CODE_LINES.filter((line) => /serve\s+reset\b/.test(line));
expect(offenders).toEqual([]);
});
it('never runs `tailscale funnel` and never advertises a Tailscale Service', () => {
// The installer's own `--service` flag (run as a service) is not Tailscale's
// `--service=svc:<name>`; the pin keys on the svc: prefix and the serve form.
const offenders = CODE_LINES.filter(
(line) => /\bfunnel\b/.test(line) || /\bsvc:/.test(line) || /\bserve\b.*--service/.test(line)
);
expect(offenders).toEqual([]);
});
it('routes every serve mutation through ts_cmd_serve (the sudo-aware wrapper)', () => {
// A bare `tailscale serve --bg` or `set --hostname` would fail for a non-operator
// user on Linux, exactly the state the wrapper exists to handle.
const mutations = CODE_LINES.filter((line) => /\bserve --(bg|https)/.test(line) || /\bset --hostname\b/.test(line));
expect(mutations.length).toBeGreaterThan(0);
for (const line of mutations) {
// Prose in warn/info strings and manual-command hints are fine; executed lines
// must start with the wrapper.
const executed = /^\s*(if\s+)?(!\s*)?(out=\$\()?ts_cmd_serve\b/.test(line);
const quoted = /(info|warn|echo -e|success) /.test(line) || /Run: /.test(line) || /Configuring: /.test(line);
expect(executed || quoted, `serve mutation outside ts_cmd_serve:\n ${line}`).toBe(true);
}
});
it('decides the rename before the serve shape, and applies serve only after the build', () => {
// Serve config is keyed by the DNS name it was written under: renaming after
// configuring would orphan the mapping (and only `serve reset` could remove the
// stale key). tailscale_prepare therefore asks the name first, chooses the shape
// second, and main() applies the shape only after the build and the service.
const prepare = SOURCE.slice(SOURCE.indexOf('tailscale_prepare() {'), SOURCE.indexOf('tailscale_apply() {'));
expect(prepare.indexOf('tailscale_choose_name')).toBeGreaterThan(-1);
expect(prepare.indexOf('tailscale_choose_name')).toBeLessThan(prepare.indexOf('tailscale_choose_mapping'));
const main = SOURCE.slice(SOURCE.indexOf('\nmain() {'), SOURCE.indexOf('\npreflight_detect() {'));
const order = [
'choose_network_binding',
'choose_launch_mode',
'install_or_update_repo',
'npm_install_deps',
'run_step "Building Codeman"',
'tailscale_apply',
'print_done_screen',
];
const positions = order.map((needle) => main.indexOf(needle));
for (let i = 0; i < positions.length; i++) {
expect(positions[i], `${order[i]} missing from main()`).toBeGreaterThan(-1);
if (i > 0) expect(positions[i], `${order[i]} must come after ${order[i - 1]}`).toBeGreaterThan(positions[i - 1]);
}
});
it('documents every flag it parses', () => {
// The header comment is the only manual most people read (it is what `curl` shows
// them if they look). A flag parse_flags accepts and the header does not mention
// is a flag nobody finds.
const header = SOURCE.slice(0, SOURCE.indexOf('set -euo pipefail'));
const parse = SOURCE.slice(SOURCE.indexOf('parse_flags() {'), SOURCE.indexOf('# Sourcing guard'));
const flags = Array.from(parse.matchAll(/^\s+(--[a-z-]+)(?:[|)=\s])/gm), (m) => m[1]);
expect(flags.length).toBeGreaterThan(5);
for (const flag of new Set(flags)) {
expect(header.includes(flag), `${flag} is parsed but not documented in the header`).toBe(true);
}
});
it('renames only as an opt-in: the question defaults to no and --yes never renames', () => {
const fn = SOURCE.slice(SOURCE.indexOf('tailscale_choose_name() {'), SOURCE.indexOf('tailscale_rename_node() {'));
expect(fn).toMatch(/prompt_yes_no "Rename this machine to \$suggested\?" "n"/);
expect(fn).toMatch(/\[\[ "\$ASSUME_YES" == "1" \]\]/);
});
});
describe('install.sh AI CLI install menu', () => {
// The menu is the one interactive path in the script, which is why it used to be the
// only part nothing exercised: choosing "s" (Skip) once fell straight into the shared