/** * @fileoverview Static guards over `install.sh`, the one file in this repo nothing else checks. * * There is no shellcheck, no bats, and CI is Node-only, so a bash mistake here reaches users * through `curl | bash` with nothing in between. The CI workflow now runs `bash -n` and a real * `bash:3.2` container (see `.github/workflows/ci.yml`), which catches syntax and the * `set -u` classes; this file catches the things that are perfectly valid bash and still wrong * for THIS script. * * Port: none (pure, over one source file). */ import { describe, expect, it } from 'vitest'; import { spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { STOCK_CLIS } from '../src/config/cli-registry/stock.js'; import { ARCHIVE_INSTALL_MARKER } from '../src/web/self-update.js'; const INSTALL_SH = fileURLToPath(new URL('../install.sh', import.meta.url)); const SOURCE = readFileSync(INSTALL_SH, 'utf-8'); /** Lines with the leading `#` comments removed, so prose quoting a banned form is not a hit. */ const CODE_LINES = SOURCE.split('\n').filter((line) => !/^\s*#/.test(line)); const CODE = CODE_LINES.join('\n'); describe('install.sh stays bash 3.2 compatible', () => { // macOS ships bash 3.2 (the last GPLv2 release) and the documented install is // `curl -fsSL | bash`, so a bash-4 construct is not a warning on a Mac, it is a // syntax error that kills the install mid-run. it.each([ ['associative arrays (`declare -A`)', /\b(?:declare|local|typeset)\s+-[A-Za-z]*A/], ['case-conversion expansion (`${x,,}` / `${x^^}`)', /\$\{[A-Za-z_][A-Za-z0-9_]*(?:\[[^\]]*\])?[,^]{1,2}\}/], ['`mapfile` / `readarray`', /\b(?:mapfile|readarray)\b/], ['namerefs (`declare -n`)', /\b(?:declare|local|typeset)\s+-[A-Za-z]*n\b/], ['here-strings (`<<<`)', /<< { const offenders = CODE_LINES.filter((line) => pattern.test(line)); expect(offenders, `bash 4+ construct found:\n ${offenders.join('\n ')}`).toEqual([]); }); }); describe('install.sh generated-catalogue block', () => { it('has exactly one matched marker pair', () => { expect(SOURCE.split('# >>> BEGIN GENERATED CLI CATALOGUE').length - 1).toBe(1); expect(SOURCE.split('# <<< END GENERATED CLI CATALOGUE').length - 1).toBe(1); expect(SOURCE.indexOf('# >>> BEGIN GENERATED CLI CATALOGUE')).toBeLessThan( SOURCE.indexOf('# <<< END GENERATED CLI CATALOGUE') ); }); it('declares every array install.sh actually reads', () => { for (const name of [ 'CLI_IDS', 'CLI_LABELS', 'CLI_ENABLED', 'CLI_LAUNCHER_ONLY', 'CLI_DOCS', 'CLI_CMD_LINUX', 'CLI_CMD_DARWIN', 'CLI_ALL_BINS', 'CLI_BIN_OFF', 'CLI_BIN_LEN', 'CLI_ALL_PATHS', 'CLI_PATH_OFF', 'CLI_PATH_LEN', ]) { expect(new RegExp(`^${name}=\\(`, 'm').test(SOURCE), `${name} is not declared`).toBe(true); } }); it('declares no array install.sh never reads', () => { // CLI_KIND and CLI_NPM were generated and read by nothing (the .mjs/docker-hosts.ts // producers read the JSON's `kind`/`npmPackage` fields directly; only these two bash // arrays were dead). A generated-but-unread array is a maintenance trap the generator // itself cannot warn about — it has no reader to check against — so this pins the // opposite of the test above: naming what must NOT come back rather than what must. for (const name of ['CLI_KIND', 'CLI_NPM']) { expect(new RegExp(`^${name}=\\(`, 'm').test(SOURCE), `${name} is declared but nothing reads it`).toBe(false); } }); it('keeps no hand-written per-CLI detection behind', () => { // The nine `*_SEARCH_PATHS` arrays and eighteen `check_`/`get__path` pairs are // what this change removes. One left behind would be a second source of truth that the // generator does not update — the exact shape of upstream b6d0f1fa. expect(CODE.match(/_SEARCH_PATHS=\(/g) ?? []).toEqual([]); // Keyed on the catalogue's OWN ids and binaries rather than an allowlist of the helpers // that may exist. `check_tmux` and `check_cloudflared` are legitimate and unrelated; a // `check_claude` or `get_omp_path` is the thing being removed. Deriving the ban from the // catalogue means a CLI added later is covered with no edit here. const names = new Set(); for (const arrayName of ['CLI_IDS', 'CLI_ALL_BINS']) { const m = new RegExp(`^${arrayName}=\\((.*)\\)$`, 'm').exec(SOURCE); for (const token of m?.[1].match(/'([^']*)'/g) ?? []) names.add(token.replace(/'/g, '')); } expect(names.size, 'could not read the catalogue ids/binaries').toBeGreaterThan(5); const perCliFunctions = [...names] .flatMap((name) => [`check_${name}()`, `get_${name}_path()`]) .filter((fn) => new RegExp(`^${fn.replace(/[()]/g, '\\$&')}`, 'm').test(CODE)); expect(perCliFunctions, `hand-written per-CLI detection still present:\n ${perCliFunctions.join('\n ')}`).toEqual( [] ); }); it('keeps no dead generic-lookup helpers behind', () => { // _cli_index/check_cli/get_cli_path were the ungenericized precursor to the per-CLI // helpers above: same shape, one level of indirection, called from nowhere once the // catalogue-driven menu and hints stopped needing a lookup-by-id. Unlike the per-CLI // pairs these are exact names, not derived from the catalogue. for (const fn of ['_cli_index()', 'check_cli()', 'get_cli_path()']) { expect(CODE.includes(fn), `${fn} should have been removed as dead code`).toBe(false); } }); }); describe('install.sh trust boundary', () => { // A command the installer EXECUTES must have arrived embedded in this file, over the same // TLS fetch and in the same commit as the script itself — there is no second, network-derived // copy of these commands anywhere in the script (an earlier draft that added one, and split // a TRUSTED/DISPLAY pair to keep the fetched copy display-only, was dropped before merge: // see docs/cli-registry.md). These three assertions are what is left to guard now that the // fetch path itself does not exist: everything the installer runs or shows still comes only // from the generated block, and nothing in the file eval()s. it('writes CLI_INSTALL_CMD_TRUSTED only from the generated per-platform arrays', () => { const writes = CODE_LINES.filter((line) => /CLI_INSTALL_CMD_TRUSTED\s*\[[^\]]*\]\s*=/.test(line)); expect(writes.length, 'expected exactly the two platform assignments').toBe(2); for (const line of writes) { expect(line, `TRUSTED written from something other than the generated block:\n ${line}`).toMatch( /=\s*"\$\{CLI_CMD_(?:LINUX|DARWIN)\[\$i\]\}"/ ); } }); it('fetches no CLI catalogue over the network at install time', () => { // The exact shape of the earlier, dropped design: a URL built from the repo/branch this // script came from, an opt-in env var to enable it, and a `download()` call feeding // straight into the trusted arrays. None of that exists in this file any more; this pins // the absence so it cannot quietly come back without a reviewer noticing. for (const needle of [ 'cli_catalog_refresh', 'cli_catalog_default_url', 'CODEMAN_CLI_CATALOGUE_URL', 'CODEMAN_REFRESH_CLI_CATALOGUE', 'CLI_INSTALL_CMD_DISPLAY', ]) { expect(SOURCE.includes(needle), `${needle} should not exist — the catalogue refresh was dropped`).toBe(false); } }); it('never eval()s anything', () => { // install.sh has two long-standing, legitimate evals (`eval "$(brew shellenv)"`, Homebrew's // documented idiom, and one inside a node -e that reads `tailscale serve status`), both of // which operate on output this script itself produced, never on fetched content. With no // network-derived catalogue left to eval, the word should not appear at all outside those. const offenders = CODE_LINES.filter( (line) => /\beval\b/.test(line) && !/eval "\$\(.*shellenv\)"/.test(line) && !line.includes('eval(process.argv') ); expect(offenders, `unexpected eval:\n ${offenders.join('\n ')}`).toEqual([]); }); it("redirects stdin for every command it executes on the user's behalf", () => { // Under `curl | bash` the script IS stdin, so a child that reads stdin eats the rest of // it. Every spawn of an untrusted-length vendor command must carry ` /\bbash -c "\$\{CLI_INSTALL_CMD_TRUSTED/.test(line)); expect(spawns.length, 'expected the single install-menu spawn').toBe(1); for (const line of spawns) { expect(line, `install spawn without { it('can be sourced without installing anything', () => { // The bash 3.2 CI step sources this file to exercise detect_all_clis. Without the guard // the dispatch `case` at the tail would run a real install inside the container. expect(SOURCE).toMatch( /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 "$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', () => { expect(SOURCE).toMatch(/^set -euo pipefail$/m); }); }); describe('install.sh DeepSeek identity probe', () => { it('greps for the same banner the registry identity regex demands', () => { // dsh_banner_probe is the ONE hand-written identity check left in the script (the // registry's is a JavaScript regex, deliberately not translated into grep at install // time). The two are pinned to each other here so an upstream banner change fails // this test instead of mis-detecting on one side only. const grepLine = CODE_LINES.find((line) => line.includes('grep -qi "DeepSeek Harness"')); expect(grepLine, 'the dsh banner grep is gone or its literal changed').toBeDefined(); const deepseek = STOCK_CLIS.find((entry) => entry.id === 'deepseek'); const identity = deepseek?.discovery.identity; expect(identity, 'the deepseek entry no longer declares an identity probe').toBeDefined(); expect(identity?.arg).toBe('--help'); expect(new RegExp(identity!.regex, 'i').test('DeepSeek Harness')).toBe(true); }); }); 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 // "failed to install" gate and aborted the installer before the clone. These drive the // real function (offer_ai_cli_install) in a real bash, with detection pointed at // nothing so the menu appears, and read_reply scripted. const DRIVER = ` set -euo pipefail export CODEMAN_INSTALL_SH_LIB=1 . "$1" k=0; while [[ $k -lt \${#CLI_ALL_BINS[@]} ]]; do CLI_ALL_BINS[$k]="codeman-test-no-such-bin-$k"; k=$((k + 1)); done k=0; while [[ $k -lt \${#CLI_ALL_PATHS[@]} ]]; do CLI_ALL_PATHS[$k]="/nonexistent/codeman-test/$k"; k=$((k + 1)); done if [[ -n "\${MENU_INSTALL_CMD:-}" ]]; then k=0; while [[ $k -lt \${#CLI_INSTALL_CMD_TRUSTED[@]} ]]; do CLI_INSTALL_CMD_TRUSTED[$k]="$MENU_INSTALL_CMD"; k=$((k + 1)); done fi CLI_DETECT_DONE="" detect_all_clis echo "found=$CLI_FOUND_COUNT" NONINTERACTIVE=0 DOWNLOADER=curl has_tty() { return 0; } headless_guard() { return 0; } read_reply() { eval "$1=\\"$MENU_ANSWER\\""; } offer_ai_cli_install echo "REACHED THE STEP AFTER THE MENU" `; function driveMenu(answer: string, installCommand?: string) { const result = spawnSync('bash', ['-c', DRIVER, 'bash', INSTALL_SH], { encoding: 'utf-8', timeout: 30_000, env: { ...process.env, MENU_ANSWER: answer, ...(installCommand ? { MENU_INSTALL_CMD: installCommand } : {}) }, }); // eslint-disable-next-line no-control-regex const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, ''); return { status: result.status, stdout: strip(result.stdout ?? ''), stderr: strip(result.stderr ?? '') }; } it('offers the menu only when nothing is installed', () => { const run = driveMenu('s'); expect(run.stdout).toContain('found=0'); expect(run.stderr).toContain('Choose [1-'); }); it('continues past the menu when the user skips', () => { const run = driveMenu('s'); expect(run.stderr).toContain('Skipping AI CLI install'); expect(run.stdout, run.stderr).toContain('REACHED THE STEP AFTER THE MENU'); expect(run.stderr).not.toContain('failed to install'); expect(run.status).toBe(0); }); it('still dies when the chosen install leaves nothing behind', () => { const run = driveMenu('1', 'false'); expect(run.stderr).toContain('installation failed'); expect(run.stderr).toContain('The selected AI CLI failed to install'); expect(run.stdout).not.toContain('REACHED THE STEP AFTER THE MENU'); expect(run.status).toBe(1); }); }); describe('install.sh detect_all_clis and a disabled entry', () => { // No stock entry ships disabled today, so this is characterization rather than a regression // pin on real data: it drives the real function in a real bash with entry 0 fabricated // disabled, and points its binary at `bash` — guaranteed resolvable via `command -v` — to // prove the entry is genuinely never PROBED (CLI_FOUND_PATH stays empty) rather than merely // filtered out downstream by every consumer's own `CLI_ENABLED` check. function driveDetect(disableEntry0: boolean) { const driver = ` set -euo pipefail export CODEMAN_INSTALL_SH_LIB=1 . "$1" k=0; while [[ $k -lt \${#CLI_ALL_BINS[@]} ]]; do CLI_ALL_BINS[$k]="codeman-test-no-such-bin-$k"; k=$((k + 1)); done k=0; while [[ $k -lt \${#CLI_ALL_PATHS[@]} ]]; do CLI_ALL_PATHS[$k]="/nonexistent/codeman-test/$k"; k=$((k + 1)); done # Point entry 0's first declared binary at something that WILL resolve, so a probe that # runs at all finds it. CLI_ALL_BINS[\${CLI_BIN_OFF[0]}]="bash" ${disableEntry0 ? 'CLI_ENABLED[0]="0"' : ''} CLI_DETECT_DONE="" detect_all_clis echo "path0=[\${CLI_FOUND_PATH[0]}]" echo "found=$CLI_FOUND_COUNT" `; const result = spawnSync('bash', ['-c', driver, 'bash', INSTALL_SH], { encoding: 'utf-8', timeout: 30_000 }); return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' }; } it('probes an enabled entry (control case)', () => { const run = driveDetect(false); expect(run.stdout, run.stderr).not.toContain('path0=[]'); expect(run.stdout).toContain('found=1'); }); it('never probes a disabled entry', () => { const run = driveDetect(true); expect(run.stdout, run.stderr).toContain('path0=[]'); 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='' 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'); }); }); it('install.sh never contains the unauthenticated tunnel opt-in', () => { expect(SOURCE).not.toContain('CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL'); expect(SOURCE).not.toContain('--allow-unauthenticated-tunnel'); }); describe('install.sh no-admin route', () => { // The route that installs without administrator rights: Node.js and tmux are downloaded // into the home folder, and a machine with no working git gets the source as an archive. // What can go wrong here is quiet: a sudo prompt that cannot succeed, a binary unpacked // before its checksum was checked, Apple's git stub opening a dialog. So each is pinned. 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); }; const sectionStart = SOURCE.indexOf('# No-admin route: everything into the home folder'); const sectionEnd = SOURCE.indexOf('# ---- End of the no-admin route'); const section = SOURCE.slice(sectionStart, sectionEnd); const sectionCode = section.split('\n').filter((line) => !/^\s*#/.test(line)); /** Run a snippet with install.sh sourced as a library, in a real bash. */ const drive = (script: string, env: NodeJS.ProcessEnv = {}) => { const clean = { ...process.env, ...env }; for (const key of ['CODEMAN_NO_ADMIN', 'CODEMAN_TARBALL_URL', 'CODEMAN_REPO_URL', 'CODEMAN_BRANCH']) { if (!(key in env)) delete clean[key]; } const full = `set -euo pipefail\nexport CODEMAN_INSTALL_SH_LIB=1\n. "$1"\n${script}`; return spawnSync('bash', ['-c', full, 'bash', INSTALL_SH], { encoding: 'utf-8', timeout: 30_000, env: clean }); }; it('has its section markers, in order', () => { expect(sectionStart).toBeGreaterThan(-1); expect(sectionEnd).toBeGreaterThan(sectionStart); expect(sectionCode.length).toBeGreaterThan(100); }); it('never runs sudo, Homebrew or the Xcode tools installer', () => { // Command position only: the messages in this section name `brew install tmux` and // `apt-get install` as things an administrator can run, which is prose. const banned = /(?:^|[;&|]|\b(?:if|then|else|do|while|until)\s|!\s)\s*(?:sudo|brew|ensure_sudo|run_as_root|ensure_homebrew|sudo_session_start|sudo_session_try)\b/; const offenders = sectionCode.filter((line) => banned.test(line) || /xcode-select\s+--install/.test(line)); expect(offenders, `privileged call on the no-admin route:\n ${offenders.join('\n ')}`).toEqual([]); }); it('pins a SHA-256 for every tmux build it can download', () => { const digests = fn('tmux_build_sha256', '\n}\n'); for (const platform of ['linux-x86_64', 'linux-arm64', 'macos-arm64', 'macos-x86_64']) { expect(digests, `no digest for ${platform}`).toMatch(new RegExp(`${platform}\\)\\s+printf '[0-9a-f]{64}'`)); // Every platform the name mapper can produce has a digest, and nothing else does. expect(fn('userspace_tmux_platform', '\n}\n')).toContain(`printf '${platform}'`); } expect(digests.match(/[0-9a-f]{64}/g)?.length).toBe(4); // The asset name and URL are built from the same version the digests were taken for. const install = fn('install_tmux_userspace', '\n}\n'); expect(install).toContain('name="tmux-$TMUX_BUILD_VERSION-$platform.tar.gz"'); expect(install).toContain('tmux-builds/releases/download/v$TMUX_BUILD_VERSION/$name'); }); it('checks a download before unpacking or running it', () => { for (const name of ['install_node_userspace', 'install_tmux_userspace']) { const body = fn(name, '\n}\n'); const verify = body.indexOf('verify_sha256 "$tmp/$name" "$sha"'); expect(verify, `${name} never verifies its download`).toBeGreaterThan(-1); expect(body.indexOf('tar -xzf'), `${name} unpacks before verifying`).toBeGreaterThan(verify); } // An empty expected digest must never pass (a lookup that came back with nothing). expect(fn('verify_sha256', '\n}\n')).toContain('[[ -n "$want" ]] || return 1'); }); it('never executes /usr/bin/git on a Mac before the developer tools are proven', () => { // Without the Command Line Tools that path is a stub that opens Apple's install dialog. const body = fn('check_git', '\n}\n'); const apple = body.indexOf('"$git_bin" == "/usr/bin/git"'); const gate = body.indexOf('xcode-select -p'); const run = body.indexOf('"$git_bin" --version'); expect(apple).toBeGreaterThan(-1); expect(gate).toBeGreaterThan(apple); // The Apple branch returns on its own evidence; only other gits are executed. expect(body.slice(gate, run)).toMatch(/\n\s+return 1\n\s+fi\n/); }); it('judges git by whether it runs', () => { const result = drive(` bin=$(mktemp -d); trap 'rm -rf "$bin"' EXIT printf '#!/bin/sh\\nexit 127\\n' > "$bin/git"; chmod +x "$bin/git" PATH="$bin:$PATH"; hash -r check_git && echo "broken=present" || echo "broken=missing" printf '#!/bin/sh\\nexit 0\\n' > "$bin/git" check_git && echo "working=present" || echo "working=missing" # A Mac whose only git is Apple's stub, with and without the developer tools. uname() { echo Darwin; } command() { if [[ "$1" == "-v" && "$2" == "git" ]]; then echo /usr/bin/git; else builtin command "$@"; fi; } xcode-select() { return 2; } check_git && echo "stub=present" || echo "stub=missing" mkdir -p "$bin/dev/usr/bin"; cp "$bin/git" "$bin/dev/usr/bin/git" xcode-select() { echo "$bin/dev"; } check_git && echo "tools=present" || echo "tools=missing" `); expect(result.status, result.stderr).toBe(0); expect(result.stdout).toContain('broken=missing'); expect(result.stdout).toContain('working=present'); expect(result.stdout).toContain('stub=missing'); expect(result.stdout).toContain('tools=present'); }); it('decides the route from what the account may do, without asking for a password', () => { // `sudo -v` would prompt, and a refused password is reported to the administrator. expect(fn('resolve_admin_mode', '\n}\n')).not.toMatch(/sudo -v/); const result = drive(` [[ $EUID -ne 0 ]] || { echo "skipped=root"; exit 0; } has_sudo=1 command() { if [[ "$1" == "-v" && "$2" == "sudo" ]]; then [[ "$has_sudo" == "1" ]]; elif [[ "$1" == "-v" && "$2" == "brew" ]]; then return 1; else builtin command "$@"; fi; } groups="users"; id() { echo "$groups"; } sudo_ok=1; sudo() { return "$sudo_ok"; } probe() { NO_ADMIN="$1"; NO_ADMIN_REASON=""; resolve_admin_mode; echo "$2=$NO_ADMIN"; } uname() { echo Linux; } has_sudo=0; probe "" linux_no_sudo; has_sudo=1 probe "" linux_not_in_group groups="users sudo"; probe "" linux_sudo_group groups="users wheel"; probe "" linux_wheel_group groups="users"; sudo_ok=0; probe "" linux_passwordless; sudo_ok=1 probe 0 forced_off groups="users sudo"; probe 1 forced_on uname() { echo Darwin; } groups="staff everyone"; probe "" mac_standard groups="staff admin"; probe "" mac_admin `); expect(result.status, result.stderr).toBe(0); if (result.stdout.includes('skipped=root')) return; for (const expected of [ 'linux_no_sudo=1', 'linux_not_in_group=1', 'linux_sudo_group=0', 'linux_wheel_group=0', 'linux_passwordless=0', 'forced_off=0', 'forced_on=1', 'mac_standard=1', 'mac_admin=0', ]) { expect(result.stdout, result.stdout).toContain(expected); } }); it('stops before the first download when the machine cannot finish the install', () => { // No compiler on Linux, or a Mac older than the tmux build supports: say so while it // costs nothing, not after 50 MB of Node.js. const stop = (env: Record) => { const r = drive( ` NONINTERACTIVE=1; NO_ADMIN=1; NO_ADMIN_REASON=test download() { echo "DOWNLOAD $1"; } download_to_stdout() { echo "DOWNLOAD $1"; } check_node() { return 1; }; check_tmux() { return 1; }; check_git() { return 0; } detect_arch() { echo "$DRIVE_ARCH"; } check_build_tools() { [[ "$DRIVE_TOOLS" == "1" ]]; }; missing_build_tools() { printf 'make, g++'; } macos_major_version() { printf '%s' "$DRIVE_MACOS"; } is_musl_libc() { [[ "$DRIVE_MUSL" == "1" ]]; } ask_dependencies_no_admin "$DRIVE_OS" `, { DRIVE_ARCH: 'x64', DRIVE_TOOLS: '1', DRIVE_MACOS: '26', DRIVE_MUSL: '0', ...env } ); expect(r.status, 'the installer must stop').toBe(1); expect(r.stdout + r.stderr, 'something was downloaded before the stop').not.toContain('DOWNLOAD'); return r.stderr; }; expect(stop({ DRIVE_OS: 'linux', DRIVE_TOOLS: '0' })).toMatch(/make, g\+\+[\s\S]*administrator/); expect(stop({ DRIVE_OS: 'macos', DRIVE_ARCH: 'arm64', DRIVE_MACOS: '14' })).toMatch(/macOS 15 or newer/); expect(stop({ DRIVE_OS: 'linux', DRIVE_MUSL: '1' })).toMatch(/musl/); expect(stop({ DRIVE_OS: 'linux', DRIVE_ARCH: 'armv7' })).toMatch(/no Node\.js download/); }); it('picks the right Node.js tarball out of SHASUMS256.txt', () => { const sha = (c: string) => c.repeat(64); const sums = [ `${sha('a')} node-v22.23.4-aix-ppc64.tar.gz`, `${sha('b')} node-v22.23.4-darwin-arm64.tar.gz`, `${sha('c')} node-v22.23.4-darwin-arm64.tar.xz`, `${sha('d')} node-v22.23.4-linux-x64-musl.tar.gz`, `${sha('e')} node-v22.23.4-linux-x64.tar.gz`, `nothex node-v22.23.4-linux-arm64.tar.gz`, ].join('\n'); const result = drive( ` printf '%s\\n' "$SUMS" | pick_node_tarball linux-x64 printf '%s\\n' "$SUMS" | pick_node_tarball darwin-arm64 printf '%s\\n' "$SUMS" | pick_node_tarball linux-arm64 || echo "arm64=refused" printf '%s\\n' "$SUMS" | pick_node_tarball linux-s390x || echo "s390x=none" `, { SUMS: sums } ); expect(result.status, result.stderr).toBe(0); expect(result.stdout).toContain(`${sha('e')} node-v22.23.4-linux-x64.tar.gz`); expect(result.stdout).toContain(`${sha('b')} node-v22.23.4-darwin-arm64.tar.gz`); expect(result.stdout).not.toContain('musl'); // A line whose checksum is not 64 hex digits is not a checksum to trust. expect(result.stdout).toContain('arm64=refused'); expect(result.stdout).toContain('s390x=none'); }); it('refuses a download whose checksum does not match', () => { const result = drive(` f=$(mktemp); trap 'rm -f "$f"' EXIT printf 'codeman' > "$f" good=$(sha256_file "$f") echo "len=\${#good}" verify_sha256 "$f" "$good" && echo "match=ok" verify_sha256 "$f" "\${good%?}x" && echo "tampered=accepted" || echo "tampered=refused" verify_sha256 "$f" "" && echo "empty=accepted" || echo "empty=refused" `); expect(result.status, result.stderr).toBe(0); expect(result.stdout).toContain('len=64'); expect(result.stdout).toContain('match=ok'); expect(result.stdout).toContain('empty=refused'); // The last digit swapped for a character no digest contains. expect(result.stdout).toContain('tampered=refused'); }); it('installs from an archive without git, then replaces that install beside itself', () => { const scratch = mkdtempSync(join(tmpdir(), 'codeman-archive-install-')); try { const make = (version: string) => { const tree = join(scratch, `src-${version}`, `Codeman-${version}`); mkdirSync(join(tree, 'src'), { recursive: true }); writeFileSync(join(tree, 'package.json'), `{ "version": "${version}" }\n`); writeFileSync(join(tree, 'src', `only-in-${version}.ts`), ''); const tarball = join(scratch, `${version}.tar.gz`); const tar = spawnSync('tar', ['-czf', tarball, '-C', join(scratch, `src-${version}`), `Codeman-${version}`]); expect(tar.status, String(tar.stderr)).toBe(0); return tarball; }; const first = make('1.0.0'); const second = make('2.0.0'); const app = join(scratch, 'app'); const script = ` LOG_FILE=/dev/null check_git() { return 1; } download() { cp "\${1#file://}" "$2"; } run_step() { shift; "$@"; } install_or_update_repo echo "staging=[$SOURCE_STAGING] cwd=$(pwd)" finish_source_swap echo "after=$(pwd)" `; const run = (tarball: string) => drive(script, { CODEMAN_INSTALL_DIR: app, CODEMAN_TARBALL_URL: `file://${tarball}`, HOME: scratch }); // First install: straight into the install directory, marked as an archive install. const fresh = run(first); expect(fresh.status, fresh.stderr).toBe(0); expect(fresh.stdout).toContain('staging=[]'); expect(readFileSync(join(app, 'package.json'), 'utf-8')).toContain('1.0.0'); expect(existsSync(join(app, ARCHIVE_INSTALL_MARKER))).toBe(true); // Second run: the new tree is built BESIDE the old one, which stays whole until the swap. writeFileSync(join(app, 'built-by-1.0.0'), ''); const update = run(second); expect(update.status, update.stderr).toBe(0); expect(update.stdout).toContain(`staging=[${app}.new] cwd=${app}.new`); expect(update.stdout).toContain(`after=${app}`); expect(readFileSync(join(app, 'package.json'), 'utf-8')).toContain('2.0.0'); // A whole replacement: what the old version had and the new one dropped is gone. expect(existsSync(join(app, 'src', 'only-in-1.0.0.ts'))).toBe(false); expect(existsSync(join(app, 'built-by-1.0.0'))).toBe(false); expect(existsSync(join(app, ARCHIVE_INSTALL_MARKER))).toBe(true); expect(existsSync(`${app}.new`)).toBe(false); expect(existsSync(`${app}.old`)).toBe(false); // A trailing slash on the setting must not put the staging folder inside the install. const slashed = drive(script, { CODEMAN_INSTALL_DIR: `${app}/`, CODEMAN_TARBALL_URL: `file://${first}`, HOME: scratch, }); expect(slashed.status, slashed.stderr).toBe(0); expect(slashed.stdout).toContain(`staging=[${app}.new] cwd=${app}.new`); expect(readFileSync(join(app, 'package.json'), 'utf-8')).toContain('1.0.0'); expect(existsSync(`${app}.new`)).toBe(false); expect(existsSync(`${app}.old`)).toBe(false); // A folder that is neither a checkout nor one of ours is never written into. const foreign = join(scratch, 'foreign'); mkdirSync(foreign); writeFileSync(join(foreign, 'notes.txt'), 'mine'); const refused = drive(script, { CODEMAN_INSTALL_DIR: foreign, CODEMAN_TARBALL_URL: `file://${first}`, HOME: scratch, }); expect(refused.status).toBe(1); expect(refused.stderr).toContain('is not a Codeman install'); expect(readFileSync(join(foreign, 'notes.txt'), 'utf-8')).toBe('mine'); } finally { rmSync(scratch, { recursive: true, force: true }); } }); it('downloads from a subcommand that never ran the full flow', () => { // DOWNLOADER used to be set only by main(), so `install.sh cloudflared` (and now the // update of an archive install) died inside download() on an unbound variable. const result = drive(` unset DOWNLOADER curl() { echo "FETCH $*"; } wget() { echo "FETCH $*"; } download https://example.invalid/a /tmp/never-written unset DOWNLOADER download_to_stdout https://example.invalid/b `); expect(result.status, result.stderr).toBe(0); expect(result.stdout).toMatch(/FETCH .*example\.invalid\/a/); expect(result.stdout).toMatch(/FETCH .*example\.invalid\/b/); }); it('builds the archive URL from the repository and branch, on GitHub only', () => { const url = (env: NodeJS.ProcessEnv) => drive('source_tarball_url || echo REFUSED', env).stdout.trim(); expect(url({})).toBe('https://github.com/Ark0N/Codeman/archive/master.tar.gz'); expect(url({ CODEMAN_REPO_URL: 'https://github.com/someone/fork.git', CODEMAN_BRANCH: 'feat/x' })).toBe( 'https://github.com/someone/fork/archive/feat/x.tar.gz' ); expect(url({ CODEMAN_REPO_URL: 'https://gitlab.com/someone/fork.git' })).toBe('REFUSED'); expect(url({ CODEMAN_REPO_URL: 'git@github.com:someone/fork.git' })).toBe('REFUSED'); expect(url({ CODEMAN_TARBALL_URL: 'https://mirror.example/codeman.tar.gz' })).toBe( 'https://mirror.example/codeman.tar.gz' ); }); it('shares the archive marker name with the in-app updater', () => { // install.sh writes it, src/web/self-update.ts reads it to tell such an install how to // update. A rename on one side would send archive installs back to the npm advice. expect(SOURCE).toContain(`SOURCE_MARKER="${ARCHIVE_INSTALL_MARKER}"`); }); it('treats a completed archive install as an install: quiet update on a re-run', () => { const dispatch = SOURCE.slice(SOURCE.indexOf('\nparse_flags "$@"')); expect(dispatch).toMatch( /-f "\$INSTALL_DIR\/\.install-complete" \]\] && \[\[ -d "\$INSTALL_DIR\/\.git" \|\| -f "\$INSTALL_DIR\/\$SOURCE_MARKER" \]\]/ ); const update = fn('update', '\nuninstall() {'); expect(update).toContain('! -f "$INSTALL_DIR/$SOURCE_MARKER"'); // The swap happens only after the build has succeeded. expect(update.indexOf('finish_source_swap')).toBeGreaterThan(update.indexOf('npm run build')); const main = fn( 'main', '\n# ----------------------------------------------------------------------------\n# Phase 0' ); expect(main.indexOf('finish_source_swap')).toBeGreaterThan(main.indexOf('npm run build')); expect(main.indexOf('finish_source_swap')).toBeLessThan(main.indexOf('install_symlink')); }); it('documents --no-admin where people look for flags, and resolves the route before any work', () => { expect(fn('usage', '\n}\n')).toContain('--no-admin'); const dispatch = SOURCE.slice(SOURCE.indexOf('\nparse_flags "$@"')); expect(dispatch.indexOf('resolve_admin_mode')).toBeGreaterThan(-1); expect(dispatch.indexOf('resolve_admin_mode')).toBeLessThan(dispatch.lastIndexOf('case "$SUBCOMMAND" in')); expect(dispatch.indexOf('adopt_userspace_runtime')).toBeLessThan(dispatch.indexOf('resolve_admin_mode')); }); it('puts the downloaded runtime on the LaunchAgent PATH', () => { // The agent's PATH is a fixed list (Homebrew, /usr/local, ~/.local/bin, the system). A // Node.js in the home folder is outside it, and so is everything `npm install -g` puts // beside that Node.js: the AI CLIs, whose launchers start with `#!/usr/bin/env node`. // A machine with a real LaunchDaemon makes the function stand down, so skip there. if (existsSync('/Library/LaunchDaemons/com.codeman.web.plist')) return; const home = mkdtempSync(join(tmpdir(), 'codeman-agent-path-')); const result = drive( ` mkdir -p "$RUNTIME_DIR/node/bin" "$RUNTIME_DIR/bin" "$HOME/.local/bin" printf '#!/bin/sh\n' > "$RUNTIME_DIR/node/bin/node"; chmod +x "$RUNTIME_DIR/node/bin/node" printf '#!/bin/sh\n' > "$RUNTIME_DIR/bin/tmux"; chmod +x "$RUNTIME_DIR/bin/tmux" ln -s "$RUNTIME_DIR/node/bin/node" "$HOME/.local/bin/node" PATH="$HOME/.local/bin:$PATH"; hash -r launchctl() { return 0; } sleep() { :; } BIND_HOST=127.0.0.1 setup_launchd_service >/dev/null 2>&1 cat "$HOME/Library/LaunchAgents/com.codeman.web.plist" `, { HOME: home } ); rmSync(home, { recursive: true, force: true }); expect(result.status, result.stderr).toBe(0); const path = /PATH<\/key>\s*([^<]*)<\/string>/.exec(result.stdout)?.[1] ?? ''; const dirs = path.split(':'); expect(dirs).toContain(`${home}/.codeman/runtime/node/bin`); expect(dirs).toContain(`${home}/.codeman/runtime/bin`); expect(dirs).toContain(`${home}/.local/bin`); expect(dirs).toContain('/usr/bin'); expect(new Set(dirs).size, `duplicate PATH entries: ${path}`).toBe(dirs.length); expect(result.stdout).toContain(`${home}/.local/bin/node`); }); it('links only into its own runtime, and removes only its own links', () => { // ~/.local/bin is the user's folder: a tmux or node they put there is never replaced on // install and never deleted on uninstall. const home = mkdtempSync(join(tmpdir(), 'codeman-links-')); const result = drive( ` mkdir -p "$HOME/.local/bin" "$RUNTIME_DIR/bin" touch "$RUNTIME_DIR/bin/tmux" "$RUNTIME_DIR/bin/node" printf 'mine' > "$HOME/.local/bin/tmux" link_into_local_bin "$RUNTIME_DIR/bin/tmux" tmux && echo "foreign=replaced" || echo "foreign=kept" link_into_local_bin "$RUNTIME_DIR/bin/node" node && echo "fresh=linked" link_into_local_bin "$RUNTIME_DIR/bin/node" node && echo "ours=relinked" echo "tmux=$(cat "$HOME/.local/bin/tmux") node=$(readlink "$HOME/.local/bin/node")" `, { HOME: home } ); rmSync(home, { recursive: true, force: true }); expect(result.status, result.stderr).toBe(0); expect(result.stdout).toContain('foreign=kept'); expect(result.stdout).toContain('fresh=linked'); expect(result.stdout).toContain('ours=relinked'); expect(result.stdout).toMatch(/tmux=mine node=.*\/\.codeman\/runtime\/bin\/node/); const body = fn('uninstall', '\nusage() {'); const rm = body.indexOf('rm -f "$symlink_dir/$runtime_tool"'); expect(rm).toBeGreaterThan(-1); expect(body.slice(body.lastIndexOf('if [[', rm), rm)).toContain('== "$RUNTIME_DIR/"*'); }); it('says the rights were not confirmed, and names the way back to sudo, when it chose this route itself', () => { // A sudoers entry cannot be read without a probe that reports to the administrator, so an // account listed by name, with a password, lands on this route too. It must not be told it // may not use sudo, and every place that ends or sums up the route must name the override. const stops = ` detect_arch() { echo x64; } download() { echo "DOWNLOAD $1"; }; download_to_stdout() { echo "DOWNLOAD $1"; } check_node() { return 1; }; check_tmux() { return 1; }; check_git() { return 0; } missing_build_tools() { printf 'make'; }; is_musl_libc() { return 1; } ts_cmd() { return 1; }; check_tailscale() { return 1; } CLI_IDS=(); TS_STATE=""; MISSING_PKGS=""; EXISTING_FOUND=""; EXISTING_VERSION="" section() { echo "== $1"; } NONINTERACTIVE=1 section blocker; check_build_tools() { return 1; }; (ask_dependencies_no_admin linux) 2>&1 || true check_build_tools() { return 0; } section declined; prompt_yes_no() { return 1; }; (ask_dependencies_no_admin linux) 2>&1 || true section ensure_sudo; (ensure_sudo) 2>&1 || true section operator; (ensure_tailscale_operator) 2>&1 || true section summary; (print_preflight_summary linux x64 ubuntu) 2>&1 NONINTERACTIVE=0 section tailscale; (offer_install_tailscale) 2>&1 || true `; const sections = (stdout: string) => Object.fromEntries( stdout .split(/^== /m) .slice(1) .map((part) => [part.slice(0, part.indexOf('\n')), part]) ); const detected = drive(` [[ $EUID -ne 0 ]] || { echo "skipped=root"; exit 0; } uname() { echo Linux; } command() { if [[ "$1" == "-v" && "$2" == "sudo" ]]; then return 0; else builtin command "$@"; fi; } id() { echo users; } sudo() { return 1; } NO_ADMIN=""; resolve_admin_mode echo "route=$NO_ADMIN reason=$NO_ADMIN_REASON" ${stops} `); expect(detected.status, detected.stderr).toBe(0); if (detected.stdout.includes('skipped=root')) return; expect(detected.stdout).toMatch(/route=1 reason=.*could not confirm/); expect(detected.stdout).not.toContain('may not use sudo'); const parts = sections(detected.stdout); for (const name of ['blocker', 'declined', 'ensure_sudo', 'operator', 'summary', 'tailscale']) { expect(parts[name], `${name}: ${detected.stdout}`).toContain('CODEMAN_NO_ADMIN=0'); } // Asked for, or sudo really tried: there is no way back to name. for (const reason of ['--no-admin', 'sudo is not installed', 'sudo was refused']) { const other = drive(` uname() { echo Linux; } NO_ADMIN=1; NO_ADMIN_REASON="${reason}" ${stops} `); expect(other.status, other.stderr).toBe(0); expect(other.stdout, reason).not.toContain('CODEMAN_NO_ADMIN=0'); } }); it('proves a downloaded tmux and cloudflared in the home folder, not in the temp folder', () => { // /tmp is commonly mounted noexec on the shared hosts this route is for. The stand-in // below refuses to run from TMPDIR the way such a mount does (126, not a shell error). const home = mkdtempSync(join(tmpdir(), 'codeman-noexec-')); try { const noexec = join(home, 'noexec-tmp'); const fake = join(home, 'fake'); mkdirSync(noexec); mkdirSync(fake); for (const tool of ['tmux', 'cloudflared']) { writeFileSync(join(fake, tool), `#!/bin/sh\ncase "$0" in "${noexec}"/*) exit 126 ;; esac\necho ok\n`, { mode: 0o755, }); } const tarball = join(home, 'tmux.tar.gz'); const tar = spawnSync('tar', ['-czf', tarball, '-C', fake, 'tmux']); expect(tar.status, String(tar.stderr)).toBe(0); const result = drive( ` run_step() { shift; "$@"; } ensure_on_path() { :; } tmux_build_sha256() { sha256_file "$DRIVE_FAKE/../tmux.tar.gz"; } download() { case "$1" in *tmux*) cp "$DRIVE_FAKE/../tmux.tar.gz" "$2" ;; *) cp "$DRIVE_FAKE/cloudflared" "$2" ;; esac; } (install_tmux_userspace linux x64) && echo "tmux=installed" (install_cloudflared_userspace linux x64) && echo "cloudflared=installed" ls -A "$RUNTIME_DIR" | tr '\n' ' '; echo ls -A "$HOME/.local" | tr '\n' ' '; echo ls -A "$HOME/.local/bin" | tr '\n' ' '; echo `, { HOME: home, TMPDIR: noexec, DRIVE_FAKE: fake } ); expect(result.stderr).not.toContain('does not run'); expect(result.status, result.stderr).toBe(0); expect(result.stdout).toContain('tmux=installed'); expect(result.stdout).toContain('cloudflared=installed'); expect(existsSync(join(home, '.codeman/runtime/bin/tmux'))).toBe(true); expect(existsSync(join(home, '.local/bin/cloudflared'))).toBe(true); // Nothing left beside them: the listings hold the final names and nothing else. const [runtime, local, bin] = result.stdout.trim().split('\n').slice(-3); expect(runtime.trim()).toBe('bin'); expect(local.trim()).toBe('bin'); expect(bin.trim().split(' ').sort()).toEqual(['cloudflared', 'tmux']); } finally { rmSync(home, { recursive: true, force: true }); } }); it('survives an environment with no USER', () => { // docker run -u gives a process no USER, and set -u turns a message that prints it into a // crash. The no-admin route reaches several such lines, and such a container is its first home. const result = drive( ` uname() { echo Linux; } NO_ADMIN=1; NO_ADMIN_REASON=test; NONINTERACTIVE=0 ts_cmd() { return 1; }; check_tailscale() { return 1; } (ensure_tailscale_operator) 2>&1 || true (offer_install_tailscale) 2>&1 || true BIND_HOST=0.0.0.0; BIND_PASSWORD="" (print_security_notice) 2>&1 `, { USER: undefined } ); expect(result.status, result.stderr).toBe(0); expect(result.stdout + result.stderr).not.toContain('unbound variable'); expect(result.stdout.match(/--operator=\S/g)?.length).toBe(2); }); it('drops a trailing slash from CODEMAN_INSTALL_DIR', () => { // "$INSTALL_DIR.new" must sit beside the install, and app/.new sits inside it. const dir = (value: string) => drive('echo "$INSTALL_DIR"', { CODEMAN_INSTALL_DIR: value }).stdout.trim(); expect(dir('/x/app/')).toBe('/x/app'); expect(dir('/x/app//')).toBe('/x/app'); expect(dir('/x/app')).toBe('/x/app'); expect(dir('/')).toBe('/'); }); it('warns that removing the data directory takes a kept runtime with it', () => { // The runtime lives inside ~/.codeman, so the yes to the data directory removes what the // user chose to keep one prompt earlier. Read from the source, never driven: uninstall() // calls `systemctl --user`, which HOME does not redirect, so a drive stops and disables // the real codeman-web of whoever runs this test. const body = fn('uninstall', '\nusage() {'); const prompt = body.indexOf('Remove data directory ($data_dir)?'); expect(prompt).toBeGreaterThan(-1); const note = body.lastIndexOf('[[ -d "$RUNTIME_DIR" ]]', prompt); expect(note, 'the kept runtime is not looked at before that prompt').toBeGreaterThan( body.indexOf('Kept $RUNTIME_DIR') ); expect(body.slice(note, prompt)).toMatch(/data_note=" .*\$RUNTIME_DIR/); expect(body.slice(prompt)).toMatch(/^Remove data directory \(\$data_dir\)\?\$data_note"/); }); });