From af744bdb54cb330639511afb1a019b174e3762ec Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 20 Sep 2026 21:38:11 +0200 Subject: [PATCH] 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- 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 --- .github/workflows/ci.yml | 32 + install.sh | 1874 ++++++++++++++++++++-------- test/install-sh-invariants.test.ts | 105 +- 3 files changed, 1472 insertions(+), 539 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b657989..0d3aebb5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/install.sh b/install.sh index 29b6a13d..23c61cab 100755 --- a/install.sh +++ b/install.sh @@ -2,7 +2,22 @@ # Codeman Universal Installer # https://github.com/Ark0N/Codeman # -# Usage: curl -fsSL https://raw.githubusercontent.com/Ark0N/Codeman/master/install.sh | bash +# Usage: curl -fsSL https://getcodeman.com/install | bash +# curl -fsSL https://getcodeman.com/install | bash -s -- [flags] [subcommand] +# +# The flow: look at what is already on the machine, ask at most three +# questions (how the dashboard is reached, optionally what to call this +# machine on your tailnet, whether to run Codeman as a service), then do all +# the work unattended and end on the URL, with a QR code for your phone. +# +# Flags (each has an environment-variable twin, listed below): +# --tailscale | --lan | --local How the dashboard is reached (question 1) +# --name | --no-rename Rename this machine on the tailnet / never ask (question 2) +# --service | --run | --no-start What to do at the end (question 3) +# --yes, -y Take every default; still waits on a Tailscale login URL +# --password

Dashboard password (visible in `ps`; prefer CODEMAN_PASSWORD) +# --port Port Codeman listens on (default 3000) +# --help, -h Print this text # # Environment variables: # CODEMAN_NONINTERACTIVE=1 - Skip all prompts and accept their defaults @@ -10,27 +25,29 @@ # that need system changes (sudo package # installs, AI CLI download); without it those # steps abort instead of running silently. +# Never installs Tailscale, never renames. # CODEMAN_INSTALL_DIR - Custom install directory (default: ~/.codeman/app) # CODEMAN_SKIP_SYSTEMD=1 - Skip systemd/launchd service setup prompt # CODEMAN_NODE_VERSION - Node.js major version to install (default: 22) # CODEMAN_REPO_URL - Custom git repository URL (default: upstream Codeman) # CODEMAN_BRANCH - Git branch to install (default: master) # CODEMAN_HOST - Preset the network binding and skip the prompt -# (e.g. 0.0.0.0 for LAN access, 127.0.0.1 for -# local-only; interactive default is 0.0.0.0, -# non-interactive default is 127.0.0.1) -# CODEMAN_PASSWORD - Preset the dashboard password (skips the -# password prompt when binding to the network) +# (0.0.0.0 for LAN access, 127.0.0.1 for local-only) +# CODEMAN_PASSWORD - Preset the dashboard password +# CODEMAN_PORT - Port Codeman listens on (default 3000); written +# into the service and used as the serve target # CODEMAN_TAILSCALE=1 - Preset the Tailscale choice: bind loopback and -# front it with `tailscale serve` HTTPS (skips -# the network prompt; never installs Tailscale -# in non-interactive runs) +# front it with `tailscale serve` HTTPS (never +# installs Tailscale in non-interactive runs) +# CODEMAN_TAILSCALE_NAME - Rename this machine on the tailnet (same as --name) # # Subcommands: -# install.sh update - Update an existing install -# install.sh uninstall - Remove services, symlinks and (optionally) data -# install.sh tailscale - Set up (or repair) Tailscale serve HTTPS access -# for an existing install +# install.sh update - Update an existing install +# install.sh uninstall - Remove services, symlinks and (optionally) data +# install.sh tailscale - Set up (or repair) Tailscale HTTPS access for an existing install +# install.sh name [] - Rename this machine on your tailnet (default: codeman-) +# install.sh status - Print the URLs, the QR code and how to manage the service +# install.sh cloudflared - Install cloudflared for the in-app Cloudflare tunnel set -euo pipefail @@ -62,12 +79,73 @@ EXISTING_PASSWORD="" EXISTING_ACK="0" # Tailscale serve URL configured or detected during this run -# (setup_tailscale_access / detect_tailscale_serve_url). Empty when the -# Tailscale path was not taken or not completed. +# (tailscale_apply / detect_tailscale_serve_url). Empty when the Tailscale path +# was not taken or not completed. TAILSCALE_SERVE_URL="" # Set to 1 when serve commands must go through sudo because granting the user # tailscale "operator" rights failed (ensure_tailscale_operator). TS_NEED_ROOT="0" +# Tailscale decisions taken in the question phase (tailscale_prepare) and +# applied after the build (tailscale_apply). TS_READY=1 means preflight passed +# (installed, logged in, HTTPS certs on) and a serve shape was chosen. +# TS_SERVE_MODE: keep (our mapping already exists), root (https://), +# path (https:///codeman, when :443 already belongs to another app), +# port (https://:), replace (take :443 over). +TS_READY="0" +TS_SERVE_MODE="" +TS_SERVE_PATH="/codeman" +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. +TS_NAME="${CODEMAN_TAILSCALE_NAME:-}" +TS_NO_RENAME="0" +# 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. +TS_MAPPING_REMOVED_BY_RENAME="0" +# Where a rename is recorded so uninstall can offer to undo it (tailscaled does +# not remember previous names). +TS_RENAME_RECORD="$HOME/.codeman/tailscale-rename" + +# Sub-path Codeman is mounted under ('' for the root). Set by +# tailscale_choose_mapping (path mode) or read back from the service file. +BIND_BASE_URL="" +EXISTING_BASE_URL="" +EXISTING_VERSION="" + +# Answer presets from flags. LAUNCH_PRESET: 1 = run now, 2 = service, 3 = do +# not start. ASSUME_YES=1 (--yes) takes every prompt's default but keeps the +# terminal (a Tailscale login URL still waits for a human), unlike +# CODEMAN_NONINTERACTIVE, which is the CI contract and never installs Tailscale. +LAUNCH_PRESET="" +ASSUME_YES="0" +# Set by parse_flags when a flag asks to change how an existing install is +# reached or run, so a bare re-run takes the full flow instead of a quiet update. +RECONFIGURE="0" +SUBCOMMAND="" +SUBCOMMAND_ARG="" +# Answers to question 3 (choose_launch_mode): LAUNCH_CHOICE 1/2/3 as above, +# SERVICE_TYPE systemd | launchd | launchd-daemon (a foreign daemon we left +# alone) | empty (no service manager here). +LAUNCH_CHOICE="3" +SERVICE_TYPE="" + +# Everything the unattended steps print goes here; the terminal gets one line +# per step and the tail of this file on failure. +LOG_FILE="$HOME/.codeman/install.log" +SUDO_KEEPALIVE_PID="" +SPINNER_PID="" +# Set once the work phase (clone/build) has begun; gates the cleanup trap's +# "partial installation may remain" advice. CURRENT_STEP names whatever the +# installer was doing when a `set -e` failure ends it (a vendor installer that +# times out otherwise leaves only its own last line on screen). +INSTALL_STARTED="0" +CURRENT_STEP="" + +# What preflight_detect found: the missing system packages as prose, and the +# Tailscale state (absent | installed | connected | serving). +MISSING_PKGS="" +TS_STATE="absent" # puppeteer is a devDependency used only by scripts/browser-comparison.mjs — its # ~150MB chrome-headless-shell download is never needed to build or run Codeman. @@ -220,9 +298,24 @@ print_security_notice() { cleanup() { local exit_code=$? - if [[ $exit_code -ne 0 ]]; then + if [[ -n "$SPINNER_PID" ]]; then + kill "$SPINNER_PID" 2>/dev/null || true + printf '\r\033[K' >&2 + fi + if [[ -n "$SUDO_KEEPALIVE_PID" ]]; then + kill "$SUDO_KEEPALIVE_PID" 2>/dev/null || true + fi + # 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. + if [[ $exit_code -ne 0 && -n "$CURRENT_STEP" ]]; then + error "Failed while: $CURRENT_STEP (see the output above). Fix the cause and re-run this installer." + fi + if [[ $exit_code -ne 0 && "$INSTALL_STARTED" == "1" ]]; then error "Installation failed. Partial installation may remain at $INSTALL_DIR" error "To retry, run the installer again or remove the directory manually." + if [[ -s "$LOG_FILE" ]]; then + error "Step output was saved to $LOG_FILE" + fi fi } @@ -383,7 +476,7 @@ missing_build_tools() { local missing="" command -v make &>/dev/null || missing="make" if ! command -v c++ &>/dev/null && ! command -v g++ &>/dev/null && ! command -v clang++ &>/dev/null; then - missing="${missing:+$missing, }a C++ compiler (g++)" + missing="${missing:+$missing, }g++" fi command -v python3 &>/dev/null || missing="${missing:+$missing, }python3" printf '%s' "$missing" @@ -1155,8 +1248,8 @@ prompt_yes_no() { local prompt="$1" local default="${2:-y}" - if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then - # Non-interactive, use default + # Non-interactive, or --yes: take the default. + if [[ "$NONINTERACTIVE" == "1" ]] || [[ "$ASSUME_YES" == "1" ]] || ! has_tty; then [[ "$default" == "y" ]] return fi @@ -1180,6 +1273,99 @@ prompt_yes_no() { done } +# Some steps need root (system packages, the Tailscale installer, `tailscale +# up`, the operator grant). Ask for the password ONCE, up front, and keep the +# sudo timestamp warm in the background for the rest of the run, instead of a +# password prompt per step scattered around a multi-minute build. +sudo_session_start() { + [[ $EUID -eq 0 ]] && return 0 + [[ -n "$SUDO_KEEPALIVE_PID" ]] && return 0 + command -v sudo &>/dev/null || return 0 + if ! sudo -n true 2>/dev/null; then + echo "" >&2 + info "Some steps need administrator rights. You will be asked for your password once." + if [[ -e /dev/tty ]]; then + sudo -v < /dev/tty || die "Failed to obtain sudo privileges." + else + sudo -v || die "Failed to obtain sudo privileges. Try running the script directly instead of piping." + fi + fi + # Refreshes the timestamp while this script lives; ends on its own once the + # installer is gone or the credential lapses. + ( + while kill -0 "$$" 2>/dev/null; do + sudo -n true 2>/dev/null || exit 0 + sleep 50 + done + ) & + SUDO_KEEPALIVE_PID=$! + return 0 +} + +# Open a URL in the user's browser when there is one to open it in (macOS, a +# desktop session, WSL). Silent no-op on a headless box: the URL is printed +# anyway. +open_in_browser() { + local url="$1" + if [[ "$(uname -s)" == "Darwin" ]]; then + open "$url" >/dev/null 2>&1 || true + elif command -v wslview &>/dev/null; then + wslview "$url" >/dev/null 2>&1 || true + elif [[ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]] && command -v xdg-open &>/dev/null; then + xdg-open "$url" >/dev/null 2>&1 & + fi + return 0 +} + +fmt_elapsed() { + local s="$1" + if [[ "$s" -ge 60 ]]; then + printf '%dm %02ds' $((s / 60)) $((s % 60)) + else + printf '%ds' "$s" + fi +} + +# run_step

Dashboard password (visible in ps; prefer CODEMAN_PASSWORD) + --port Port Codeman listens on (default 3000) + --help, -h This text + +Subcommands + update Update an existing install + uninstall Remove services, symlinks and (optionally) data + tailscale Set up (or repair) Tailscale HTTPS access for an existing install + name [] Rename this machine on your tailnet (default: codeman-) + status Print the URLs, the QR code and how to manage the service + cloudflared Install cloudflared for the in-app Cloudflare tunnel + +Environment: CODEMAN_NONINTERACTIVE=1, CODEMAN_INSTALL_DIR, CODEMAN_HOST, +CODEMAN_PASSWORD, CODEMAN_PORT, CODEMAN_TAILSCALE=1, CODEMAN_TAILSCALE_NAME, +CODEMAN_SKIP_SYSTEMD=1, CODEMAN_NODE_VERSION, CODEMAN_REPO_URL, CODEMAN_BRANCH. +EOF +} + +# Flags set the same variables their environment-variable twins do, so every +# 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 +# full flow (which re-asks nothing the flag already answered) instead of the +# quiet update. +parse_flags() { + while [[ $# -gt 0 ]]; do + case "$1" in + --tailscale) CODEMAN_TAILSCALE=1; CODEMAN_HOST=""; RECONFIGURE="1" ;; + --lan) CODEMAN_HOST="0.0.0.0"; CODEMAN_TAILSCALE=0; RECONFIGURE="1" ;; + --local) CODEMAN_HOST="127.0.0.1"; CODEMAN_TAILSCALE=0; RECONFIGURE="1" ;; + --name) + shift + [[ $# -gt 0 ]] || die "--name needs a value (e.g. --name codeman-$(hostname -s 2>/dev/null || echo box))" + TS_NAME="$1"; RECONFIGURE="1" ;; + --name=*) TS_NAME="${1#--name=}"; RECONFIGURE="1" ;; + --no-rename) TS_NO_RENAME="1" ;; + --service) LAUNCH_PRESET="2"; RECONFIGURE="1" ;; + --run) LAUNCH_PRESET="1"; RECONFIGURE="1" ;; + --no-start) LAUNCH_PRESET="3" ;; + --yes|-y) ASSUME_YES="1" ;; + --password) + shift + [[ $# -gt 0 ]] || die "--password needs a value" + CODEMAN_PASSWORD="$1" ;; + --password=*) CODEMAN_PASSWORD="${1#--password=}" ;; + --port) + shift + [[ $# -gt 0 ]] || die "--port needs a value" + CODEMAN_PORT="$1"; export CODEMAN_PORT ;; + --port=*) CODEMAN_PORT="${1#--port=}"; export CODEMAN_PORT ;; + --help|-h) usage; exit 0 ;; + update|uninstall|tailscale|name|status|cloudflared) + [[ -z "$SUBCOMMAND" ]] || die "Only one subcommand at a time ($SUBCOMMAND and $1 given)." + SUBCOMMAND="$1" ;; + -*) die "Unknown option: $1 (see --help)" ;; + *) + if [[ "$SUBCOMMAND" == "name" && -z "$SUBCOMMAND_ARG" ]]; then + SUBCOMMAND_ARG="$1" + else + die "Unexpected argument: $1 (see --help)" + fi ;; + esac + shift + done + if [[ -n "${CODEMAN_PORT:-}" ]] && ! [[ "$CODEMAN_PORT" =~ ^[0-9]+$ && "$CODEMAN_PORT" -ge 1 && "$CODEMAN_PORT" -le 65535 ]]; then + die "Invalid port: $CODEMAN_PORT" + fi + return 0 +} + # Sourcing guard: let the test harness load this file for its pure helpers # without running an install. bash 3.2 cannot be exercised any other way from # CI — see .github/workflows/ci.yml and test/install-sh-invariants.test.ts. if [[ -n "${CODEMAN_INSTALL_SH_LIB:-}" ]]; then return 0 2>/dev/null || exit 0; fi # Wrap in main to prevent partial execution on curl | bash -case "${1:-}" in - update) update ;; - uninstall) uninstall ;; - tailscale) setup_tailscale_subcommand ;; +parse_flags "$@" +case "$SUBCOMMAND" in + update) update ;; + uninstall) uninstall ;; + tailscale) setup_tailscale_subcommand ;; + name) setup_name_subcommand ;; + status) status_subcommand ;; + cloudflared) cloudflared_subcommand ;; *) # Only a COMPLETED install re-runs as a quiet update. A partial one # (clone succeeded but build/menu never finished) lacks the marker and # re-runs the full flow, so a failed first attempt can actually finish. - if [[ -z "${1:-}" && -d "$INSTALL_DIR/.git" && -f "$INSTALL_DIR/.install-complete" ]]; then + # A flag that changes the setup (--tailscale, --service, ...) also takes + # the full flow: that is what the flag is for. + if [[ "$RECONFIGURE" != "1" && -d "$INSTALL_DIR/.git" && -f "$INSTALL_DIR/.install-complete" ]]; then print_banner update else - main "$@" + main fi ;; esac diff --git a/test/install-sh-invariants.test.ts b/test/install-sh-invariants.test.ts index abab770f..175c7d4e 100644 --- a/test/install-sh-invariants.test.ts +++ b/test/install-sh-invariants.test.ts @@ -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:`; 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