From 9eac5ecf01e4d26b769403e30188064e0c669857 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 5 Nov 2026 00:21:12 +0100 Subject: [PATCH 1/4] test: wait for the first read before starting the other edit history sessions The gate belonged to whichever read arrived first, and sessions load their stores independently, so under CI timing another session took it, the loop never reached release() and the test timed out (master, CI run 41930703100). The code cannot block one session on another: sync() only waits on its own slot. Co-Authored-By: Claude Opus 5.5 --- test/edit-history-sync.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/edit-history-sync.test.ts b/test/edit-history-sync.test.ts index 4a9f6266..60cd745b 100644 --- a/test/edit-history-sync.test.ts +++ b/test/edit-history-sync.test.ts @@ -428,14 +428,20 @@ describe('EditHistorySync', () => { host.append('main', editLine('t1', T0, '/w/a.ts')); const orig = host.read; let first = true; + let inRead!: () => void; + const reachedRead = new Promise((r) => (inRead = r)); host.read = async (...a) => { if (first) { first = false; + inRead(); await gate; } return orig(...a); }; const slow = sync.sync(local); + // Only once s1 holds the gate: the sessions load their stores independently, so a later one + // could reach the read first, take the gate and hang the loop below before `release()`. + await reachedRead; for (let i = 1; i <= MAX_LOADED_STORES + 1; i++) await sync.sync({ ...local, id: `s1-${i}` }); release(); expect(await slow).toEqual({ state: 'ok' }); From aa463d9d28a4be9b5d07eecadd210c186a6afdb3 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 5 Nov 2026 14:15:43 +0100 Subject: [PATCH 2/4] ci: pin the browser suite to ubuntu-24.04 ubuntu-latest moved to 26.04 and the locked Playwright 1.60.0 refuses to install chromium there (support starts at 1.61.0), so the nightly stopped before running a test. Pin to the image of the last green run, so a failure now points at our code rather than at a new OS and Chromium. Unpin when Playwright is bumped. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/browser-suite.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/browser-suite.yml b/.github/workflows/browser-suite.yml index caadba59..9dea6cbc 100644 --- a/.github/workflows/browser-suite.yml +++ b/.github/workflows/browser-suite.yml @@ -21,7 +21,9 @@ permissions: jobs: browser: name: Playwright browser suite - runs-on: ubuntu-latest + # Pinned, not ubuntu-latest: that moved to 26.04 on 2026-11-05 and the locked Playwright 1.60.0 + # refuses to install there (support starts at 1.61.0). Unpin when Playwright is bumped. + runs-on: ubuntu-24.04 timeout-minutes: 60 steps: From 46b85fa6d0c4892873453d75dd60a2306609c500 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 5 Nov 2026 14:22:41 +0100 Subject: [PATCH 3/4] chore: bump Playwright to 1.64.1 and unpin the browser suite Playwright supports Ubuntu 26.04 from 1.61.0, so the browser suite can go back to ubuntu-latest. Raises the floor in package.json as well, because the old ^1.58.0 still admitted versions that refuse to install on the image every workflow now runs on. Bundled Chromium moves from 148 to 156. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/browser-suite.yml | 4 +-- package-lock.json | 38 ++++++++--------------------- package.json | 2 +- 3 files changed, 12 insertions(+), 32 deletions(-) diff --git a/.github/workflows/browser-suite.yml b/.github/workflows/browser-suite.yml index 9dea6cbc..caadba59 100644 --- a/.github/workflows/browser-suite.yml +++ b/.github/workflows/browser-suite.yml @@ -21,9 +21,7 @@ permissions: jobs: browser: name: Playwright browser suite - # Pinned, not ubuntu-latest: that moved to 26.04 on 2026-11-05 and the locked Playwright 1.60.0 - # refuses to install there (support starts at 1.61.0). Unpin when Playwright is bumped. - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest timeout-minutes: 60 steps: diff --git a/package-lock.json b/package-lock.json index 56a2e9bb..ff1b96da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,7 @@ "exceljs": "4.4.0", "fflate": "0.8.3", "pixelmatch": "^6.0.0", - "playwright": "^1.58.0", + "playwright": "^1.64.1", "pngjs": "^7.0.0", "postcss": "^8.5.15", "prettier": "^3.4.0", @@ -9736,50 +9736,32 @@ } }, "node_modules/playwright": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", - "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.60.0" + "playwright-core": "1.64.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" + "node": ">=20" } }, "node_modules/playwright-core": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", - "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=18" - } - }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=20" } }, "node_modules/pngjs": { diff --git a/package.json b/package.json index 653b835c..d468ef30 100644 --- a/package.json +++ b/package.json @@ -133,7 +133,7 @@ "exceljs": "4.4.0", "fflate": "0.8.3", "pixelmatch": "^6.0.0", - "playwright": "^1.58.0", + "playwright": "^1.64.1", "pngjs": "^7.0.0", "postcss": "^8.5.15", "prettier": "^3.4.0", From ae28534406ebbea8a28c8799c70fdca19db25a89 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 5 Nov 2026 18:50:58 +0100 Subject: [PATCH 4/4] feat(install): install without administrator rights (--no-admin) Codeman itself always lived under $HOME; administrator rights were only spent on what it needs first (system packages for Node.js, tmux and git, Homebrew on a Mac). An account without those rights could not install it: the installer died in ensure_sudo, or in Homebrew's own installer. install.sh now has a no-admin route, taken by itself when the account has no administrator rights (no sudo binary; outside sudo/wheel/admin and `sudo -n true` fails; a refused sudo password; a Mac account outside the admin group with no writable Homebrew) and forced with --no-admin or CODEMAN_NO_ADMIN=1: - Node.js: the official tarball from nodejs.org, checked against the SHASUMS256.txt published beside it, into ~/.codeman/runtime/node. - tmux: the tmux project's own build (tmux/tmux-builds 3.7c), checked against a SHA-256 pinned in the installer, into ~/.codeman/runtime/bin. - Both are linked from ~/.local/bin (never over a file that is not ours), and the runtime is on PATH and on the LaunchAgent's PATH. - git is never installed. Without a working git the source comes as an archive, the install is marked with .install-source, and it updates by re-running the installer: the new tree is built beside the old one and swapped in only after the build succeeded. A later re-run on a machine that has gained git turns it back into a clone. - Nothing on this route runs sudo, Homebrew or `xcode-select --install`, and the route is never decided by `sudo -v`. What it cannot supply is checked before the first download: a C/C++ toolchain on Linux (node-pty 1.1.0 compiles there), musl, and macOS below 15 (the tmux build's deployment target). Along the way: - check_git no longer trusts `command -v git`. On a Mac without the Xcode Command Line Tools /usr/bin/git is a stub that opens Apple's install dialog; it is judged by `xcode-select -p` and never executed. Any other git is asked for its version. - scripts/git-hooks.mjs answers "not a checkout" without running git, for the same reason: postinstall calls it on every npm install. - The in-app updater knows the install kind `archive` and points such an install at the installer instead of `npm i -g aicodeman@latest`. - download() and download_to_stdout() resolve DOWNLOADER themselves. Only main() used to set it, so `install.sh cloudflared` died on `set -u` with the message swallowed by the step log. - The AI CLI menu leaves out a Homebrew-only install command where there is no Homebrew. Verified with the real installer: a bare Ubuntu 24.04 container as an unprivileged user (install, a PTY round trip through the API, update by re-run, cloudflared, uninstall), the same without a compiler (stops before any download), with a broken then working git, with sudo present but not usable, and on macOS 26 (arm64) in an isolated HOME with sudo, brew, git, the compilers, launchctl and tailscale replaced by tripwires. Not verified on a real standard macOS account: that account was an administrator. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 55 ++ CLAUDE.md | 8 +- README.md | 1 + docs/architecture-invariants.md | 8 + docs/wiki/Installation.md | 49 +- install.sh | 858 +++++++++++++++++++++++++++-- scripts/git-hooks.mjs | 5 + src/types/update.ts | 5 +- src/web/public/settings-ui.js | 6 +- src/web/self-update.ts | 24 +- test/git-hooks.test.ts | 21 + test/install-sh-invariants.test.ts | 412 +++++++++++++- test/self-update.test.ts | 46 +- 13 files changed, 1449 insertions(+), 49 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4df79374..7bea9d6d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -151,6 +151,61 @@ jobs: 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" ' + # No-admin route: the helpers that decide what gets downloaded and whether it is + # trusted (the Node.js checksum pick, the tmux digest table, the verify step), the + # stop-before-download check, and an archive install plus its replacement, all + # in bash 3.2. This image is Alpine, so it is also the one place the musl branch + # runs for real: a machine the route cannot serve must stop BEFORE a download. + 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 /tmp/src/Codeman-1/src + . /w/install.sh + sha=$(printf "%064d" 7) + sums=$(printf "%s node-v22.1.0-linux-x64.tar.gz\nnothex node-v22.1.0-linux-arm64.tar.gz\n" "$sha") + [[ "$(printf "%s\n" "$sums" | pick_node_tarball linux-x64)" == "$sha node-v22.1.0-linux-x64.tar.gz" ]] + if printf "%s\n" "$sums" | pick_node_tarball linux-arm64 >/dev/null; then + echo "accepted a checksum that is not 64 hex digits" >&2; exit 1 + fi + for p in linux-x86_64 linux-arm64 macos-arm64 macos-x86_64; do + d=$(tmux_build_sha256 "$p"); [[ ${#d} -eq 64 ]] + done + printf codeman > /tmp/f; good=$(sha256_file /tmp/f); [[ ${#good} -eq 64 ]] + verify_sha256 /tmp/f "$good" + if verify_sha256 /tmp/f "${good%?}x" || verify_sha256 /tmp/f ""; then + echo "accepted a wrong or empty checksum" >&2; exit 1 + fi + is_musl_libc + NO_ADMIN=1; NONINTERACTIVE=1 + download() { echo DOWNLOADED; }; download_to_stdout() { echo DOWNLOADED; } + check_node() { return 1; }; check_tmux() { return 1; }; check_git() { return 1; } + check_build_tools() { return 0; } + if out=$( (ask_dependencies_no_admin linux) 2>&1 ); then + echo "did not stop on a musl system" >&2; exit 1 + fi + case "$out" in + *DOWNLOADED*) echo "downloaded before stopping: $out" >&2; exit 1 ;; + *musl*) ;; + *) echo "stopped for the wrong reason: $out" >&2; exit 1 ;; + esac + # An archive install, then its replacement beside it (busybox tar, bash 3.2). + printf "{ \"version\": \"1.0.0\" }\n" > /tmp/src/Codeman-1/package.json + tar -czf /tmp/one.tar.gz -C /tmp/src Codeman-1 + printf "{ \"version\": \"2.0.0\" }\n" > /tmp/src/Codeman-1/package.json + tar -czf /tmp/two.tar.gz -C /tmp/src Codeman-1 + INSTALL_DIR=/tmp/h/app; LOG_FILE=/dev/null + download() { cp "${1#file://}" "$2"; }; run_step() { shift; "$@"; } + CODEMAN_TARBALL_URL=file:///tmp/one.tar.gz; install_or_update_repo >/dev/null 2>&1; finish_source_swap + [[ -f "$INSTALL_DIR/$SOURCE_MARKER" && -z "$SOURCE_STAGING" ]] + grep -q 1.0.0 "$INSTALL_DIR/package.json" + touch "$INSTALL_DIR/from-the-old-build" + CODEMAN_TARBALL_URL=file:///tmp/two.tar.gz; install_or_update_repo >/dev/null 2>&1 + [[ "$SOURCE_STAGING" == "$INSTALL_DIR.new" ]] && grep -q 1.0.0 "$INSTALL_DIR/package.json" + finish_source_swap + grep -q 2.0.0 "$INSTALL_DIR/package.json" + [[ ! -e "$INSTALL_DIR/from-the-old-build" && ! -e "$INSTALL_DIR.new" && ! -e "$INSTALL_DIR.old" ]] + NO_ADMIN=""; resolve_admin_mode; [[ "$NO_ADMIN" == "0" ]] + echo "bash $BASH_VERSION: no-admin route (checksums, stop before download, archive install and swap) ok" + ' - name: CLI catalogue artifacts are in sync with stock.ts run: npm run generate:cli-catalog -- --check diff --git a/CLAUDE.md b/CLAUDE.md index 4f350fdb..305a5844 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,7 +103,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph - **Default bind is loopback-only; non-loopback without a password starts but warns** — the server defaults to `--host 127.0.0.1`. Binding non-loopback (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` starts anyway but prints a loud warning; `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges it. ⚠️ The production systemd unit passes no `--host`, so prod binds **localhost only**: reach it via `tailscale serve`/tunnel to `127.0.0.1`. A loopback bind is reachable through a same-host tunnel but NOT by a browser hitting the box's LAN IP. `install.sh` is separate and prompts for the binding (defaulting to LAN + a password), and preserves the existing binding on re-runs. → [architecture-invariants#default-bind-and-the-non-loopback-warning-path](docs/architecture-invariants.md#default-bind-and-the-non-loopback-warning-path), `docs/security-architecture.md` - **Instance isolation / multi-instance attach danger** — the data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts`. ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions**, resizing and mutating them. `$HOME` isolation is NOT enough because tmux is system-global. To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes dir + socket together), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually; `scripts/run-beta.sh` does this for a beta alongside prod. **Any new `~/.codeman/...` path MUST go through `dataPath()`**, never `join(homedir(), '.codeman', …)`, and **any new `tmux -L` caller through `resolveTmuxSocketName()`** (both in `config/instance.ts`): the TUI shells out to tmux from a second process, and a hardcoded `codeman` there would point a beta instance at prod's panes. → [architecture-invariants#instance-isolation-and-the-multi-instance-attach-danger](docs/architecture-invariants.md#instance-isolation-and-the-multi-instance-attach-danger) - **The prefix table on Codeman's socket holds only ` send-prefix`** — browser tabs are tmux attach clients, and tmux's stock bindings let Ctrl+B `)`/`s` move a tab onto another session. `createSession` resets the table, and `reconcileSessions()` at boot for sessions that survived an upgrade (`src/tmux-prefix-table.ts`); `root` and `C- detach-client` (the TUI attach's way out) are left alone, so F1 and that alias reach browser tabs for the length of an attach. Alt+1..9 are `if-shell` bindings that `switch-client` only for the TUI's own client (`client_tty` plus a one-shot pid hook), so a browser tab's Esc-then-digit does not move it. Docker and owned-remote nested servers get the same reduction (`C-q send-prefix` only) in their create chain. ⚠️ So `Ctrl+B d` does not exist there: anything attaching a user's terminal claims its own detach key (F1 in `root`, as `codeman tui` and `scripts/tmux-manager.sh` do). → [architecture-invariants#the-prefix-table-on-codemans-socket-holds-only-send-prefix](docs/architecture-invariants.md#the-prefix-table-on-codemans-socket-holds-only-send-prefix) -- **node-pty's macOS `spawn-helper` ships without `+x`** (issues #6, #204): `node-pty@1.1.0` publishes `prebuilds/darwin-/spawn-helper` as mode 0644, and macOS launches every PTY through it, so a stock macOS install fails every session start with `Error: posix_spawnp failed.` **Linux can never reproduce it**: `spawn-helper` is an `OS=="mac"` gyp target and node-pty ships no Linux prebuild, so node-gyp always emits an executable helper there. ⚠️ The flip side of that: since Linux has no prebuild, `npm install` **needs a C/C++ toolchain there** (`make`, `g++`, `python3`), so `install.sh` checks for and installs one alongside Node/tmux/git — a stock Ubuntu 24 server has none and died inside node-gyp with `not found: make`. Do not drop that step. ⚠️ Look in **`prebuilds/-/`**, not just `build/Release/`, which does not exist on macOS. Repair is a chmod, never a mandatory rebuild (that would require Xcode CLI tools and deletes `prebuilds/` before compiling): `npm run fix:node-pty` chmods every helper then proves it by really opening a PTY. `spawnPtyWithHelperRepair()` (`utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts` and self-heals a broken install on the first failure. → [architecture-invariants#node-ptys-macos-spawn-helper-must-be-executable](docs/architecture-invariants.md#node-ptys-macos-spawn-helper-must-be-executable) +- **node-pty's macOS `spawn-helper` ships without `+x`** (issues #6, #204): `node-pty@1.1.0` publishes `prebuilds/darwin-/spawn-helper` as mode 0644, and macOS launches every PTY through it, so a stock macOS install fails every session start with `Error: posix_spawnp failed.` **Linux can never reproduce it**: `spawn-helper` is an `OS=="mac"` gyp target and node-pty ships no Linux prebuild, so node-gyp always emits an executable helper there. ⚠️ The flip side of that: since Linux has no prebuild, `npm install` **needs a C/C++ toolchain there** (`make`, `g++`, `python3`), so `install.sh` checks for and installs one alongside Node/tmux/git — a stock Ubuntu 24 server has none and died inside node-gyp with `not found: make`. Do not drop that step. (The installer's no-admin route cannot install one, so there it stops before downloading anything and names what is missing.) ⚠️ Look in **`prebuilds/-/`**, not just `build/Release/`, which does not exist on macOS. Repair is a chmod, never a mandatory rebuild (that would require Xcode CLI tools and deletes `prebuilds/` before compiling): `npm run fix:node-pty` chmods every helper then proves it by really opening a PTY. `spawnPtyWithHelperRepair()` (`utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts` and self-heals a broken install on the first failure. → [architecture-invariants#node-ptys-macos-spawn-helper-must-be-executable](docs/architecture-invariants.md#node-ptys-macos-spawn-helper-must-be-executable) - **User-chosen paths are probed BOUNDED, never with `existsSync`/`statSync`** (#516): a linked case or a `workingDir` can sit on a network mount that stopped answering. A synchronous check there freezes the whole server, and an unbounded async `stat`/`lstat`/`readFile` holds one of libuv's threadpool workers (4 by default, shared with every `fs`, `dns.lookup` and `crypto` call) until the mount returns. On a request or spawn path use `probePath()`/`probePathKind()` (`utils/bounded-path-probe.ts`, read its `@fileoverview`). ⚠️ `unknown` is NEVER `absent`: nothing is created, scaffolded or 404'd on it. Bulk scans keep the stall cap; a request for ONE path the user named may pass `{ pastCap: true }`, which still stops at the ceiling that keeps one worker free; and a helper that would otherwise touch the path skips whatever is still `unknown`. User-facing errors for it go through `describeUnknownPath()`, so a refused probe is not reported as a broken folder. - **Headless screenshots: `deviceScaleFactor` MUST be 1, and write unique filenames** — under DSF=2 xterm's WebGL renderer draws glyphs at ~2× nominal size while still *reporting* nominal cell dims, so only the pixels reveal it and only the terminal font looks wrong. And overwriting a fixed output path leaves OS image viewers showing the old render, which reads as "the fix didn't work"; `scripts/capture-real-overview.mjs` mints a timestamped filename per run. Seed the per-device `localStorage` keys (`codeman:skin`, `codeman-font-size`, `codeman-app-settings`) so the capture matches a real device. → [architecture-invariants#headless-screenshot-capture](docs/architecture-invariants.md#headless-screenshot-capture) @@ -220,7 +220,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode and copilot get the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward only with observed `cliMouseTracking`: **Claude ≥ 2.1.187, tracked Codex, or tracked Copilot**. Codex 0.163.0 fullscreen was measured with `alternate_on=1`, `mouse_any_flag=1`: one SGR report moves one transcript row; see the fresh measurement in architecture-invariants. Inline/untracked Codex retains local history or the capability-gated paging fallback. Copilot has no version gate; a stale/false tracking flag falls back to PageUp/PageDown. Touch alone has one more branch (`_shouldForwardTouchToApp`): in any mode, a drag also forwards as SGR wheel reports while xterm ITSELF tracks the mouse with SGR encoding (an app in the pane asked for it; a tracking mode other than `x10` and `coreMouseService.activeEncoding === 'SGR'`, so DEFAULT, X10 and SGR-pixel apps fail closed), because xterm encodes a wheel there but the touch listeners consume the drag. ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. ⚠️ The hollow-buffer PageUp/PageDown fallback (`_maybePageCliTranscript`) stands down when the last capture reported tmux scrollback (`paneHistoryLines` > 0: a tail replayed after a server restart carries only the attach repaint), so the gesture scrolls locally and the scroll-to-top pull loads `full=1`; 0 and unknown keep paging. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding) **Detached start + service install**: `codeman web -d` relaunches the same entry script `detached:true` (setsid); `nohup` is not what makes it survive. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile + `/api/status` probe), or a second instance attaches to the first one's live sessions. ⚠️ Never report success not observed: poll `/api/status` until the child answers or dies. `--stop` must verify the pid still looks like Codeman (`ps -o command=`) before signalling. Unit/label names live only in `config/service-names.ts`. `service install` bakes the installing shell's PATH into the unit and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install) -**Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update) +**Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ An install with no git checkout (`install.sh` on a machine without a working git leaves the marker `.install-source`) is kind `archive`: it cannot update in place, and both the check and the UI tell it to re-run the installer, never the npm line. ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update) **Reverse-proxy base path** (`--base-url` / `CODEMAN_BASE_URL`, default `/`; pure single source `src/config/base-path.ts`, normalized to `''` or `/foo`): mounts Codeman under a sub-path behind a proxy that forwards the prefix unchanged. Few choke points: `stripBasePath()` in Fastify's `rewriteUrl` (routes stay prefix-agnostic; unprefixed requests still answer), one `onSend` hook rebasing `Location`, `renderIndexHtml` rewriting `` + injecting `window.__CODEMAN_BASE__`, and `CodemanBase.url()` (constants.js) for runtime URLs. ⚠️ Keep template asset refs RELATIVE, and route every root-absolute frontend URL (EventSource/WebSocket/`window.open`/src) through `CodemanBase.url()`. ⚠️ Web-tab proxy egress goes through `proxyPrefixFor(cap, basePath)`; ingress parsers stay base-agnostic. ⚠️ `--base-url` must ride `buildWebArgs` and `resolveServicePlan`. Tests: `test/base-path.test.ts`. → [architecture-invariants#reverse-proxy-base-path](docs/architecture-invariants.md#reverse-proxy-base-path) @@ -319,7 +319,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/` ## State Files -All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `edit-history/.json` (Edit history panel, one store per session, every diff an agent wrote, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `discord.json` (Discord bridge: bot token, a bearer secret, so mode 0600, never returned by `/api/discord`, admin-only in multi-user mode) + `discord-bridge.json` (sessionId to Discord thread id), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users//cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`). +All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `edit-history/.json` (Edit history panel, one store per session, every diff an agent wrote, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `discord.json` (Discord bridge: bot token, a bearer secret, so mode 0600, never returned by `/api/discord`, admin-only in multi-user mode) + `discord-bridge.json` (sessionId to Discord thread id), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back) and `runtime/` (the Node.js and tmux `install.sh` downloaded on its no-admin route, linked from `~/.local/bin`; all three installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users//cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`). **Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored. @@ -384,6 +384,6 @@ Two constraints worth knowing before you touch them: the env-derived PTY buffer ## Scripts & Tunnel -**`install.sh`** (repo root) is the public `curl | bash` installer: it installs Node/tmux/git/build tools, clones to `~/.codeman/app`, builds, and offers a systemd/launchd service, asking every question BEFORE the unattended build (log in `~/.codeman/install.log`). Network access is Tailscale / LAN / local-only; re-runs preserve the existing binding AND password (`read_existing_binding()`). ⚠️ Compose the hand-start env only in `start_command_hint`/`export_bind_env`, and call `stop_background_helpers` before `exec`. ⚠️ Tailscale rename is opt-in (default NO, never under `--yes`/non-interactive); NEVER `tailscale serve reset`, touch a mapping it did not create, run `tailscale funnel` or advertise a Service (`test/install-sh-invariants.test.ts`). ⚠️ Stay **bash 3.2** clean (no `declare -A`, `mapfile`, namerefs, `${x,,}`, here-strings, empty-array expansion under `set -u`). ⚠️ Execute only commands from the generated CLI block (`CLI_INSTALL_CMD_TRUSTED`, `npm run generate:cli-catalog`). → [architecture-invariants#installsh-the-public-installer](docs/architecture-invariants.md#installsh-the-public-installer) +**`install.sh`** (repo root) is the public `curl | bash` installer: it installs Node/tmux/git/build tools, clones to `~/.codeman/app`, builds, and offers a systemd/launchd service, asking every question BEFORE the unattended build (log in `~/.codeman/install.log`). Network access is Tailscale / LAN / local-only; re-runs preserve the existing binding AND password (`read_existing_binding()`). ⚠️ Compose the hand-start env only in `start_command_hint`/`export_bind_env`, and call `stop_background_helpers` before `exec`. ⚠️ Tailscale rename is opt-in (default NO, never under `--yes`/non-interactive); NEVER `tailscale serve reset`, touch a mapping it did not create, run `tailscale funnel` or advertise a Service (`test/install-sh-invariants.test.ts`). ⚠️ **No-admin route** (`--no-admin` / `CODEMAN_NO_ADMIN`, taken by itself when the account has no administrator rights): Node.js (nodejs.org, checked against `SHASUMS256.txt`) and tmux (`tmux/tmux-builds`, digests pinned in `tmux_build_sha256`: bump `TMUX_BUILD_VERSION` and all four together) go into `~/.codeman/runtime`, and without a working git the source is an archive that updates by a re-run (built beside the old tree, swapped in after the build). Nothing in that section may run `sudo`, Homebrew or `xcode-select --install`, a download is verified before it is unpacked, the route is never decided by `sudo -v`, `/usr/bin/git` on a Mac is never executed before `xcode-select -p` proves the developer tools, and `no_admin_blocker` stops BEFORE the first download for what it cannot supply (a Linux toolchain, macOS below 15). ⚠️ Stay **bash 3.2** clean (no `declare -A`, `mapfile`, namerefs, `${x,,}`, here-strings, empty-array expansion under `set -u`). ⚠️ Execute only commands from the generated CLI block (`CLI_INSTALL_CMD_TRUSTED`, `npm run generate:cli-catalog`). → [architecture-invariants#installsh-the-public-installer](docs/architecture-invariants.md#installsh-the-public-installer) Other key scripts: `scripts/tmux-manager.sh` (safe tmux mgmt), `scripts/tunnel.sh [quick|named] start|stop|status|url` (quick = random trycloudflare URL, default; `named setup|enable` = fixed-hostname tunnel via `scripts/codeman-tunnel-named.service`; bare `start|stop|url` still means quick), `scripts/run-beta.sh` (isolated beta instance), `scripts/build-agent-image.mjs` (docker base image), `scripts/self-update.sh` (detached updater). Production services: `scripts/codeman-web.service`, `scripts/codeman-tunnel.service`. **Always set `CODEMAN_PASSWORD`** before exposing via tunnel. diff --git a/README.md b/README.md index 33fb90ee..0b3107db 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ This installs Node.js, tmux and a build toolchain if missing (node-pty ships no - **How it's reachable, your choice.** **Tailscale** (loopback bind fronted by `tailscale serve`, so you get `https://..ts.net` with a real certificate and your tailnet as the login, no password needed), **any device on your network** (`0.0.0.0`, with a strongly recommended password prompt), or **this machine only** (`127.0.0.1`, safest). Skipping the password on a network bind requires an explicit confirmation and ends with a loud warning. The highlighted default reflects what is already on the machine (Tailscale when it is already connected, your existing binding on a re-run), and a bare Enter never pulls in new software. If another app already owns `:443` on your node, Codeman goes under `https://..ts.net/codeman` or on a second port instead of replacing it. A bare `codeman web` started by hand still defaults to loopback. - **The name is yours to choose.** By default the URL uses the machine's existing tailnet name. Answering yes to the second question renames the machine to `codeman-` (which also renames it for SSH, so the default is no); `install.sh name` does it later. - **Re-run to update.** The same one-liner updates a finished install in place: local changes in `~/.codeman/app` are stashed (never discarded), and a running service is restarted and verified. If a first install was interrupted, re-running resumes the full setup instead. `install.sh status` prints the URLs and the QR code again; `install.sh update`, `install.sh tailscale` and `install.sh uninstall` also exist. +- **No administrator rights needed.** On an account without `sudo` (or a Mac account that is not an administrator), the installer downloads Node.js from nodejs.org and the tmux project's own build into `~/.codeman/runtime`, each checked against its SHA-256, and takes the source as an archive when there is no working git. `--no-admin` forces that route. Two limits: Linux needs make, a C++ compiler and python3 to be there already, and the tmux download needs macOS 15 or newer. Details in the [wiki](https://github.com/Ark0N/Codeman/wiki/Installation#without-administrator-rights). - **Flags for the impatient.** `curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service` answers the questions from the command line (`--lan`, `--local`, `--run`, `--no-start`, `--name `, `--port `, `--yes` too). **CI / headless:** without a terminal attached, steps that would change your system abort with instructions instead of running silently; set `CODEMAN_NONINTERACTIVE=1` to approve them for automation. You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), [OMP](https://github.com/can1357/oh-my-pi), or [GitHub Copilot CLI](https://github.com/github/copilot-cli) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the ten is present; if none is found, it offers to install any of them from a menu (DeepSeek excepted, since its npm package installs only a launcher with no runnable profile), or you can skip and install one yourself later. After install: diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 479d279b..7b6115e0 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1297,6 +1297,14 @@ Its CLI knowledge is a GENERATED block (`npm run generate:cli-catalog`, markers ⚠️ It executes ONLY commands from the embedded block (`CLI_INSTALL_CMD_TRUSTED`) — there is no network fetch of the catalogue at install time to worry about at all. +**No-admin route** (`--no-admin` / `CODEMAN_NO_ADMIN`, 2026-11-05). Everything Codeman writes was always under `$HOME`; administrator rights were only ever spent on prerequisites. `resolve_admin_mode` decides once per run whether those rights may be asked for: root yes; a Mac account outside the `admin` group no (unless a Homebrew it can write to exists); on Linux no without a `sudo` binary, or when the account is outside `sudo`/`wheel`/`admin` AND `sudo -n true` fails. ⚠️ It is never decided by `sudo -v`: that prompts, and a refused password from an account outside sudoers is reported to the administrator. A refused password on the system route offers this route instead of ending the run (`sudo_session_try`). + +On this route Node.js comes from nodejs.org (`latest-v.x/SHASUMS256.txt` names the tarball AND carries its checksum, one fetch, no JSON parsing) into `~/.codeman/runtime/node`, and tmux from the tmux project's `tmux-builds` release into `~/.codeman/runtime/bin`. ⚠️ `tmux-builds` publishes no checksum file, so `tmux_build_sha256` pins all four digests of `TMUX_BUILD_VERSION`; bump the version and the digests together (the GitHub release API returns them as `digest`). ⚠️ A download is verified BEFORE it is unpacked or run, and an empty expected digest never passes. `node`, `npm`, `npx` and `tmux` are linked from `~/.local/bin` (`link_into_local_bin` refuses to replace a file that does not already point into the runtime, and uninstall removes only such links): the `codeman` launcher is `#!/usr/bin/env node`, and a non-interactive shell never reads the PATH line written to an interactive profile. The runtime's `node/bin` also goes on PATH and on the LaunchAgent's `svc_path`, since `npm install -g` puts AI CLIs beside that Node.js. + +⚠️ `no_admin_blocker` runs before the first download and stops for what this route cannot supply: a C/C++ toolchain on Linux (node-pty 1.1.0 compiles there; a node-pty with Linux prebuilds is what would lift this, and it is a dependency decision, not an installer one), musl, a platform nodejs.org or tmux-builds has no build for, and macOS below 15 (the tmux build's deployment target). ⚠️ Nothing between the "No-admin route" header and "End of the no-admin route" may run `sudo`, `ensure_sudo`, `run_as_root`, Homebrew or `xcode-select --install`; `run_as_root` itself refuses while `NO_ADMIN=1`, so a missed call site fails instead of prompting. + +⚠️ **git is judged by whether it works, never by `command -v`.** On a Mac without the Xcode Command Line Tools `/usr/bin/git` is a stub that opens Apple's install dialog, so `check_git` judges that path by `xcode-select -p` and never executes it; any other git is asked for `--version`. `scripts/git-hooks.mjs` has the matching guard (no `.git`, no `git` call), because postinstall runs it on every `npm install`. Without a working git the source is an archive (`fetch_source_archive`, GitHub's `archive/.tar.gz` or `CODEMAN_TARBALL_URL`) and the install directory carries the marker `.install-source`, which `detectInstallKind` reads as `archive` (the name is pinned across both files by `test/install-sh-invariants.test.ts`). ⚠️ An archive install is replaced as a WHOLE, never patched in place: nothing says which files a newer version dropped, and a stale `src/*.ts` would be compiled. The new tree is fetched and built in `.new` and `finish_source_swap` moves it into place only after `npm run build` succeeded, so a failed update leaves the running install untouched; a re-run on a machine that has gained a working git stages a clone instead, which turns the install back into a git one. ⚠️ `download`/`download_to_stdout` resolve `DOWNLOADER` themselves (`require_downloader`): only `main()` used to set it, so a subcommand that downloads died on `set -u` with the message swallowed by the step log. + ## Agent inspection and guarded submission These three operations are experimental across both CLI and HTTP, including the diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index ef0585ca..2542c36a 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -102,6 +102,49 @@ installs Tailscale itself non-interactively; a non-interactive run never renames machine and never starts a service. Everything the unattended steps print goes to `~/.codeman/install.log`, and the last lines of it are shown when a step fails. +### Without administrator rights + +Codeman itself always lived in your home directory. What used `sudo` (or Homebrew on a +Mac) were the things it needs first. On an account that has no administrator rights the +installer says so in its summary (`Admin not used (...)`) and gets those another way: + +| Needed | With administrator rights | Without | +| ----------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| Node.js 22 | system package or Homebrew | The official tarball from nodejs.org, checked against the `SHASUMS256.txt` published beside it, unpacked to `~/.codeman/runtime/node`. | +| tmux | system package or Homebrew | The tmux project's own build ([tmux-builds](https://github.com/tmux/tmux-builds)), checked against a SHA-256 pinned in the installer, in `~/.codeman/runtime/bin`. | +| git | system package or Xcode tools | Not installed. Without a working git, the source is downloaded as an archive instead of cloned. | +| Build toolchain (Linux) | system package | Cannot be supplied. See below. | + +This route is taken by itself when `sudo` is not installed, when the account is outside the +`sudo`, `wheel` and `admin` groups and `sudo` will not run without a password, when the +`sudo` password is refused, and on a Mac for an account that is not an administrator. +`--no-admin` (or `CODEMAN_NO_ADMIN=1`) forces it on an account that does have the rights; +`CODEMAN_NO_ADMIN=0` switches the detection off. Nothing on this route runs `sudo`, +Homebrew or the Xcode tools installer. + +What to know about it: + +- **Linux needs make, a C++ compiler and python3 to be there already.** node-pty compiles + from source on Linux, and a compiler is a system package. The installer checks this + before it downloads anything and names what is missing. macOS needs no compiler. +- **The tmux download needs macOS 15 or newer** (and x86_64 or arm64 on either system). + On an older Mac an administrator has to install tmux once (`brew install tmux`); the + rest then works without one. +- **An install without git updates by re-running the install line**, not from App + Settings. The re-run builds the new version beside the old one and swaps it in only + after the build succeeded, so a failed download or build leaves the install as it was. + Once the machine has a working git, the next re-run turns the install into a normal + clone and in-app updates work again. On a Mac, "a working git" means the Xcode Command + Line Tools are installed: without them `/usr/bin/git` only opens Apple's install dialog, + and the installer never runs it. +- **Tailscale works where it is already installed and your account may configure it.** On + Linux, installing it, logging in and granting serve rights each need an administrator + (`sudo tailscale set --operator=$USER`, once). Local network and this-machine access + need nothing, and `install.sh cloudflared` puts `cloudflared` in `~/.local/bin`. +- `node`, `npm`, `npx` and `tmux` are linked from `~/.local/bin`, and + `~/.codeman/runtime/node/bin` is added to your PATH, so a CLI installed with + `npm install -g` is found as well. + ## Route B: npm ```bash @@ -194,6 +237,7 @@ If the dashboard loads and **+ New Session** opens, you are done. Continue to | Path | What | | ----------------------- | ------------------------------------------------------------------------------------------------ | | `~/.codeman/app` | The installed code (installer route only). | +| `~/.codeman/runtime` | Node.js and tmux, when the installer downloaded them instead of using system packages ([no administrator rights](#without-administrator-rights)). | | `~/.codeman/` | All state: `state.json`, settings, session history, push keys, TLS certs. See [Core Concepts](Core-Concepts). | | `~/codeman-cases/` | Cases created from scratch. Linked cases stay wherever they already are. | | `~/.codeman/web.log` | Log for a detached (`-d`) server. | @@ -217,6 +261,7 @@ Full detail, including logs and the self-updater, is in | Install route | How to update | | ------------- | ----------------------------------------------------------------- | | Installer | Re-run the one-liner, or **App Settings → System → Updates** in the UI. | +| Installer, without git | Re-run the one-liner. The in-app updater says so instead of offering an update. | | npm | `npm update -g aicodeman` | | git clone | `git pull && npm install && npm run build`, then restart. | | Docker Compose | Re-run `Start-Codeman.sh`. The in-app updater works too, and refuses a release that changes the container definition until you re-run the script. | @@ -232,7 +277,9 @@ install.sh uninstall # installer route npm uninstall -g aicodeman # npm route ``` -Neither removes `~/.codeman/` or `~/codeman-cases/`. Delete those by hand if you want the +The installer route asks before it removes `~/.codeman/runtime` (the Node.js and tmux it +downloaded, if any), and keeps it by default when you installed CLIs there with +`npm install -g`. Neither removes `~/.codeman/` or `~/codeman-cases/`. Delete those by hand if you want the state and your case folders gone as well, and check `~/codeman-cases/` first: linked cases point at directories you already had, but cases created from scratch have their only copy there. diff --git a/install.sh b/install.sh index 446d64c4..bb5b4c92 100755 --- a/install.sh +++ b/install.sh @@ -17,6 +17,9 @@ # --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) +# --no-admin Never use sudo or Homebrew: download Node.js and tmux into +# the home folder instead (chosen by itself when this account +# has no administrator rights) # --help, -h Print this text # # Environment variables: @@ -40,6 +43,12 @@ # 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) +# CODEMAN_NO_ADMIN - 1: install without administrator rights (same as +# --no-admin); 0: always use system packages, even +# when this account looks like it has no such rights +# CODEMAN_TARBALL_URL - Source archive (.tar.gz) to install from when no +# working git is available (default: the GitHub +# archive of CODEMAN_REPO_URL at CODEMAN_BRANCH) # # Subcommands: # install.sh update - Update an existing install @@ -63,6 +72,29 @@ TARGET_NODE_VERSION="${CODEMAN_NODE_VERSION:-22}" NONINTERACTIVE="${CODEMAN_NONINTERACTIVE:-0}" SKIP_SYSTEMD="${CODEMAN_SKIP_SYSTEMD:-0}" +# Install without administrator rights (see "No-admin route" below): 1 = on +# (--no-admin), 0 = never, empty = decided from what this account may do +# (resolve_admin_mode). NO_ADMIN_REASON is the one-line "why" shown to the user. +NO_ADMIN="${CODEMAN_NO_ADMIN:-}" +NO_ADMIN_REASON="" +# Where the no-admin route keeps what it downloads (Node.js, tmux). Inside the +# data directory, so one folder holds everything Codeman put on the machine. +RUNTIME_DIR="$HOME/.codeman/runtime" +# The tmux project's own binaries (https://github.com/tmux/tmux-builds). It +# publishes no checksum file, so the SHA-256 of every asset of this version is +# pinned in tmux_build_sha256 and a download that does not match is refused. +# Bump the version and all four digests together. +TMUX_BUILD_VERSION="3.7c" +# Written into the install directory when the source came from an archive +# instead of a git clone. The in-app updater reads the same file name +# (ARCHIVE_INSTALL_MARKER in src/web/self-update.ts) to say how such an install +# updates; test/install-sh-invariants.test.ts pins the two to each other. +SOURCE_MARKER=".install-source" +# Set while a fresh source tree is being built beside an existing archive +# install (install_or_update_repo); finish_source_swap moves it into place once +# the build has succeeded. +SOURCE_STAGING="" + # Network binding chosen during install (choose_network_binding). Empty # BIND_HOST means "not chosen" (e.g. the update path) and falls back to the # server's own loopback default. @@ -322,8 +354,14 @@ cleanup() { 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 [[ -n "$SOURCE_STAGING" ]]; then + # The new tree was being built beside the old one and never swapped in. + error "Update failed. The existing install at $INSTALL_DIR is unchanged." + error "The unfinished new copy is in $SOURCE_STAGING; the next run replaces it." + else + error "Installation failed. Partial installation may remain at $INSTALL_DIR" + error "To retry, run the installer again or remove the directory manually." + fi if [[ -s "$LOG_FILE" ]]; then error "Step output was saved to $LOG_FILE" fi @@ -431,10 +469,22 @@ check_curl_or_wget() { return 1 } +# DOWNLOADER is set by check_curl_or_wget, which only main() used to call: a +# subcommand that downloads (`update` of an archive install, `cloudflared`, +# `tailscale`) reached these two with it unset and died on `set -u` with the +# message swallowed by the step log. So they resolve it themselves. +require_downloader() { + if [[ -n "${DOWNLOADER:-}" ]] || check_curl_or_wget; then + return 0 + fi + die "curl or wget is required but neither is installed. Please install one first." +} + download() { local url="$1" local output="$2" + require_downloader if [[ "$DOWNLOADER" == "curl" ]]; then curl -fsSL "$url" -o "$output" else @@ -445,6 +495,7 @@ download() { download_to_stdout() { local url="$1" + require_downloader if [[ "$DOWNLOADER" == "curl" ]]; then curl -fsSL "$url" else @@ -474,8 +525,23 @@ check_npm() { command -v npm &>/dev/null } +# A git on PATH is not proof of a working git. On a Mac without the Xcode +# Command Line Tools, /usr/bin/git is Apple's stub: running it opens the +# "install the developer tools" dialog and exits non-zero, so that one is +# judged by `xcode-select -p` and NEVER executed. Any other git is asked for +# its version, which also rules out a broken one. check_git() { - command -v git &>/dev/null + local git_bin + git_bin=$(command -v git 2>/dev/null) || return 1 + if [[ "$(uname -s)" == "Darwin" && "$git_bin" == "/usr/bin/git" ]]; then + local dev_dir + dev_dir=$(xcode-select -p 2>/dev/null) || return 1 + if [[ -n "$dev_dir" && -x "$dev_dir/usr/bin/git" ]]; then + return 0 + fi + return 1 + fi + "$git_bin" --version >/dev/null 2>&1 < /dev/null } check_tmux() { @@ -755,6 +821,11 @@ offer_ai_cli_install() { curl_only_skipped=$((curl_only_skipped + 1)) continue fi + # Same reasoning for a Homebrew command: it cannot run where there is no + # Homebrew, and the no-admin route never installs one. + if [[ "${CLI_INSTALL_CMD_TRUSTED[$i]}" == brew\ * ]] && { [[ "$NO_ADMIN" == "1" ]] || ! command -v brew &>/dev/null; }; then + continue + fi offer_idx[${#offer_idx[@]}]=$i done @@ -857,10 +928,68 @@ get_cloudflared_path() { # Dependency Installation # ============================================================================ +# True when this account is in a group that normally carries administrator +# rights: admin on macOS, sudo or wheel (or admin) on Linux. +user_in_admin_group() { + local group + for group in $(id -Gn 2>/dev/null); do + case "$group" in + admin|sudo|wheel) return 0 ;; + esac + done + return 1 +} + +# Decide once whether this run may ask for administrator rights, and switch to +# the no-admin route (below) when it may not. An explicit --no-admin or +# CODEMAN_NO_ADMIN=0/1 wins. Otherwise root has them; a Mac account outside the +# admin group does not (unless a Homebrew it can write to is already there); +# on Linux an account has none without sudo, or when it is outside the admin +# groups AND sudo will not run without a password. +# +# Never decided by trying `sudo -v`: that asks for a password, and a wrong +# answer from an account that may not use sudo is reported to the machine's +# administrator. `sudo -n` never asks. +resolve_admin_mode() { + case "$NO_ADMIN" in + 1) [[ -n "$NO_ADMIN_REASON" ]] || NO_ADMIN_REASON="--no-admin"; return 0 ;; + 0) return 0 ;; + esac + NO_ADMIN="0" + if [[ $EUID -eq 0 ]]; then + return 0 + fi + if [[ "$(uname -s)" == "Darwin" ]]; then + if user_in_admin_group; then + return 0 + fi + if command -v brew &>/dev/null && [[ -w "$(brew --prefix 2>/dev/null)" ]]; then + return 0 + fi + NO_ADMIN="1" + NO_ADMIN_REASON="this account is not an administrator of this Mac" + return 0 + fi + if ! command -v sudo &>/dev/null; then + NO_ADMIN="1" + NO_ADMIN_REASON="sudo is not installed" + return 0 + fi + if user_in_admin_group || sudo -n true 2>/dev/null; then + return 0 + fi + NO_ADMIN="1" + NO_ADMIN_REASON="this account may not use sudo" + return 0 +} + ensure_sudo() { if [[ $EUID -eq 0 ]]; then return 0 fi + if [[ "$NO_ADMIN" == "1" ]]; then + die "This step needs administrator rights, and this run installs without them${NO_ADMIN_REASON:+ ($NO_ADMIN_REASON)}." + fi if ! command -v sudo &>/dev/null; then die "sudo is required but not installed. Please install packages manually or run as root." fi @@ -880,6 +1009,11 @@ ensure_sudo() { run_as_root() { if [[ $EUID -eq 0 ]]; then "$@" + elif [[ "$NO_ADMIN" == "1" ]]; then + # Every caller checks NO_ADMIN first; refusing here keeps a call site + # that was missed from opening a sudo password prompt. + error "Not running '$1' as root: this run installs without administrator rights." + return 1 else sudo "$@" fi @@ -1214,6 +1348,473 @@ install_cloudflared_suse() { rm -f "$tmp" } +# ============================================================================ +# No-admin route: everything into the home folder, no administrator rights +# ============================================================================ +# +# What Codeman itself writes was always under $HOME: the app, the `codeman` +# symlink, the user-level service. Administrator rights were only ever spent on +# what it needs first (system packages for Node.js, tmux and git; Homebrew on a +# Mac). This route supplies those without them: +# +# Node.js the official tarball from nodejs.org, checked against the +# SHASUMS256.txt published beside it, unpacked to $RUNTIME_DIR/node +# tmux the tmux project's own build, checked against the digest pinned in +# tmux_build_sha256, in $RUNTIME_DIR/bin (linked from ~/.local/bin) +# git never installed: where no git works, the source comes as an +# archive (fetch_source_archive) and such an install updates by +# re-running this installer +# +# Two things it cannot supply, both checked BEFORE anything is downloaded +# (no_admin_blocker): a C/C++ toolchain on Linux, where node-pty compiles from +# source, and a tmux for a Mac older than macOS 15, the deployment target of +# the official build. +# +# Rule for every function between this header and "End of the no-admin route": +# none of them runs sudo, ensure_sudo, run_as_root, sudo_session_*, Homebrew or +# `xcode-select --install` (pinned by test/install-sh-invariants.test.ts). + +# sha256_file : the file's SHA-256 in lowercase hex. Fails when the +# machine has no tool for it; a download is never unpacked unverified. +sha256_file() { + local file="$1" out="" + if command -v sha256sum &>/dev/null; then + out=$(sha256sum "$file" 2>/dev/null) || return 1 + elif command -v shasum &>/dev/null; then + out=$(shasum -a 256 "$file" 2>/dev/null) || return 1 + elif command -v openssl &>/dev/null; then + out=$(openssl dgst -sha256 -r "$file" 2>/dev/null) || return 1 + else + return 1 + fi + printf '%s' "${out%% *}" | tr 'A-F' 'a-f' +} + +# verify_sha256 +verify_sha256() { + local file="$1" want="$2" got="" + [[ -n "$want" ]] || return 1 + got=$(sha256_file "$file") || return 1 + [[ "$got" == "$want" ]] +} + +# The platform part of a Node.js tarball name; fails where nodejs.org ships +# none that this route supports. +userspace_node_platform() { + case "$1-$2" in + linux-x64) printf 'linux-x64' ;; + linux-arm64) printf 'linux-arm64' ;; + macos-x64) printf 'darwin-x64' ;; + macos-arm64) printf 'darwin-arm64' ;; + *) return 1 ;; + esac +} + +# The platform part of a tmux-builds asset name. +userspace_tmux_platform() { + case "$1-$2" in + linux-x64) printf 'linux-x86_64' ;; + linux-arm64) printf 'linux-arm64' ;; + macos-x64) printf 'macos-x86_64' ;; + macos-arm64) printf 'macos-arm64' ;; + *) return 1 ;; + esac +} + +# SHA-256 of tmux-$TMUX_BUILD_VERSION-.tar.gz, as published on the +# release page (the `digest` field of the GitHub release API) and re-hashed +# from a fresh download when this version was pinned. +tmux_build_sha256() { + case "$1" in + linux-x86_64) printf 'cc56bd1cc873eb6089c615f0496b072385bda8a6d944069f38564a5d49c128aa' ;; + linux-arm64) printf '29dcb978da2a4b0cf6790f9e004865dac239a3ba03bbf776dddacce23d03831b' ;; + macos-arm64) printf '0a763dd0380aa980d239509654da1bc7455843706a3c050f6709c8cd2e13d12d' ;; + macos-x86_64) printf '1c21f9ade964e4a539be05ffe3a4e95854ab76a0a9c4c05d0ffbe7fc6c4d783f' ;; + *) return 1 ;; + esac +} + +# Alpine and friends: the official Node.js tarballs are linked against glibc. +is_musl_libc() { + [[ "$(uname -s)" == "Linux" ]] || return 1 + if ls /lib/ld-musl-* >/dev/null 2>&1; then + return 0 + fi + command -v ldd &>/dev/null || return 1 + case "$(ldd --version 2>&1 || true)" in + *musl*) return 0 ;; + esac + return 1 +} + +macos_major_version() { + local version + version=$(sw_vers -productVersion 2>/dev/null) || return 1 + printf '%s' "${version%%.*}" +} + +# pick_node_tarball : reads a SHASUMS256.txt on stdin and prints +# " " for that platform's .tar.gz. Pure (the test suite +# feeds it a fixture), and it reads all of stdin so the writer never sees a +# closed pipe under `set -o pipefail`. +pick_node_tarball() { + local platform="$1" sha="" name="" found="" hex='^[0-9a-f]{64}$' + while read -r sha name; do + [[ -z "$found" ]] || continue + case "$name" in + node-v*-"$platform".tar.gz) + if [[ "$sha" =~ $hex ]]; then + found="$sha $name" + fi + ;; + esac + done + [[ -n "$found" ]] || return 1 + printf '%s\n' "$found" +} + +# ensure_on_path

: first on PATH for the rest of this run, and in the +# shell profile for later ones. add_to_path alone is not enough: it returns +# before exporting when the profile already carries the line (a re-run from a +# shell that has not loaded its profile). +ensure_on_path() { + local dir="$1" + if [[ ":$PATH:" == *":$dir:"* ]]; then + return 0 + fi + add_to_path "$dir" + if [[ ":$PATH:" != *":$dir:"* ]]; then + export PATH="$dir:$PATH" + fi + return 0 +} + +# link_into_local_bin : make ~/.local/bin/ point at +# something this route unpacked, so it is found wherever `codeman` itself is. +# That matters for node: the `codeman` launcher starts with +# `#!/usr/bin/env node`, and a non-interactive shell (ssh host 'codeman ...', +# cron) never reads the PATH line this installer adds to an interactive +# profile. Returns 1, changing nothing, when a file or link that is not ours +# is already there. +link_into_local_bin() { + local target="$1" link="$HOME/.local/bin/$2" + if [[ -e "$link" || -L "$link" ]] && [[ "$(readlink "$link" 2>/dev/null)" != "$RUNTIME_DIR/"* ]]; then + return 1 + fi + mkdir -p "$HOME/.local/bin" + ln -sf "$target" "$link" +} + +# A re-run often starts in a shell that has not loaded the profile an earlier +# no-admin run wrote (a fresh `curl | bash`, a non-login shell), so what that +# run unpacked would look missing and be downloaded again. Put it back on PATH +# for this run. Runs for every subcommand: `update` needs node and npm too. +adopt_userspace_runtime() { + if [[ -x "$RUNTIME_DIR/node/bin/node" && ":$PATH:" != *":$RUNTIME_DIR/node/bin:"* ]]; then + export PATH="$RUNTIME_DIR/node/bin:$PATH" + fi + if [[ -x "$RUNTIME_DIR/bin/tmux" ]] && ! command -v tmux &>/dev/null; then + export PATH="$RUNTIME_DIR/bin:$PATH" + fi + return 0 +} + +# no_admin_blocker : prints why +# this route cannot finish on this machine, or nothing when it can. Checks +# only, so it runs before the first download: nobody should wait for 50 MB of +# Node.js to then learn that a compiler is missing. +no_admin_blocker() { + local os="$1" arch="$2" need_node="$3" need_tmux="$4" + if [[ "$os" == "linux" ]] && ! check_build_tools; then + printf '%s' "Codeman's terminal module (node-pty) is compiled during the install on Linux, which needs make, a C++ compiler and python3. Missing here: $(missing_build_tools). Installing them takes an administrator (Debian/Ubuntu: apt-get install -y build-essential python3; Fedora/RHEL: dnf install -y gcc gcc-c++ make python3). Once they are there, re-run this installer: the rest needs no administrator rights." + return 0 + fi + if [[ "$need_node" == "1" ]]; then + if ! userspace_node_platform "$os" "$arch" >/dev/null; then + printf '%s' "nodejs.org has no Node.js download this installer can use on $os $arch. Install Node.js $TARGET_NODE_VERSION yourself, then re-run this installer." + return 0 + fi + if is_musl_libc; then + printf '%s' "The official Node.js downloads need glibc, and this system uses musl (Alpine). Install Node.js $TARGET_NODE_VERSION yourself (an administrator can run: apk add nodejs npm), then re-run this installer." + return 0 + fi + fi + if [[ "$need_tmux" == "1" ]]; then + if ! userspace_tmux_platform "$os" "$arch" >/dev/null; then + printf '%s' "The tmux project publishes no build for $os $arch. Ask an administrator to install tmux, then re-run this installer." + return 0 + fi + if [[ "$os" == "macos" ]]; then + local major="" digits='^[0-9]+$' + major=$(macos_major_version) || major="" + if ! [[ "$major" =~ $digits ]] || [[ "$major" -lt 15 ]]; then + printf '%s' "The tmux project's build needs macOS 15 or newer, and this Mac runs ${major:-an unknown version}. Ask an administrator to install tmux once (brew install tmux), then re-run this installer: the rest needs no administrator rights." + return 0 + fi + fi + fi + if [[ "$need_node" == "1" || "$need_tmux" == "1" ]]; then + if ! command -v tar &>/dev/null; then + printf '%s' "tar is needed to unpack the downloads and is not installed." + return 0 + fi + if ! command -v sha256sum &>/dev/null && ! command -v shasum &>/dev/null && ! command -v openssl &>/dev/null; then + printf '%s' "No SHA-256 tool (sha256sum, shasum or openssl) is installed, so the downloads could not be verified." + return 0 + fi + fi + return 0 +} + +# Node.js $TARGET_NODE_VERSION (latest patch release) into $RUNTIME_DIR/node. +# Unpacked beside the old copy and proven to run before it replaces it. +install_node_userspace() { + local os="$1" arch="$2" platform="" sums="" line="" sha="" name="" version="" tmp="" + local dest="$RUNTIME_DIR/node" base="https://nodejs.org/dist" + platform=$(userspace_node_platform "$os" "$arch") || die "No Node.js download for $os $arch." + + info "Looking up the latest Node.js $TARGET_NODE_VERSION..." + sums=$(download_to_stdout "$base/latest-v${TARGET_NODE_VERSION}.x/SHASUMS256.txt" < /dev/null) \ + || die "Could not reach nodejs.org. Check the network (or a proxy: https_proxy) and re-run." + line=$(printf '%s\n' "$sums" | pick_node_tarball "$platform") \ + || die "nodejs.org lists no Node.js $TARGET_NODE_VERSION build for $platform." + sha="${line%% *}" + name="${line#* }" + version="${name#node-}" + version="${version%%-*}" + + tmp=$(mktemp -d "${TMPDIR:-/tmp}/codeman-node.XXXXXX") || die "Could not create a temporary folder." + if ! run_step "Downloading Node.js $version" download "$base/$version/$name" "$tmp/$name"; then + rm -rf "$tmp" + exit 1 + fi + if ! verify_sha256 "$tmp/$name" "$sha"; then + rm -rf "$tmp" + die "The Node.js download does not match the checksum nodejs.org publishes for it. Nothing was installed; re-run to try again." + fi + + mkdir -p "$RUNTIME_DIR" + rm -rf "$dest.new" + mkdir -p "$dest.new" + if ! tar -xzf "$tmp/$name" -C "$dest.new" --strip-components=1 < /dev/null; then + rm -rf "$tmp" "$dest.new" + die "Could not unpack the Node.js download." + fi + rm -rf "$tmp" + if ! "$dest.new/bin/node" --version >/dev/null 2>&1 < /dev/null; then + rm -rf "$dest.new" + die "The downloaded Node.js does not run here (on Linux the official builds need glibc 2.28 or newer). Install Node.js $TARGET_NODE_VERSION yourself, then re-run this installer." + fi + rm -rf "$dest.old" + if [[ -e "$dest" ]]; then + mv "$dest" "$dest.old" + fi + mv "$dest.new" "$dest" + rm -rf "$dest.old" + + # On PATH as a folder (what `npm install -g` puts beside it is found too), + # and linked from ~/.local/bin for shells that never read the profile. + ensure_on_path "$dest/bin" + local tool + for tool in node npm npx; do + link_into_local_bin "$dest/bin/$tool" "$tool" || true + done + # An older Node.js earlier on PATH would still win for this run. + if [[ "$(command -v node 2>/dev/null)" != "$dest/bin/node" ]]; then + export PATH="$dest/bin:$PATH" + fi + hash -r 2>/dev/null || true + success "Node.js $version installed in $dest" + return 0 +} + +# tmux $TMUX_BUILD_VERSION into $RUNTIME_DIR/bin, linked from ~/.local/bin +# (already on the PATH of the service this installer writes). +install_tmux_userspace() { + local os="$1" arch="$2" platform="" sha="" name="" tmp="" + local bin_dir="$RUNTIME_DIR/bin" link_dir="$HOME/.local/bin" + platform=$(userspace_tmux_platform "$os" "$arch") || die "No tmux build for $os $arch." + sha=$(tmux_build_sha256 "$platform") || die "No pinned checksum for the $platform tmux build." + name="tmux-$TMUX_BUILD_VERSION-$platform.tar.gz" + + tmp=$(mktemp -d "${TMPDIR:-/tmp}/codeman-tmux.XXXXXX") || die "Could not create a temporary folder." + if ! run_step "Downloading tmux $TMUX_BUILD_VERSION" download \ + "https://github.com/tmux/tmux-builds/releases/download/v$TMUX_BUILD_VERSION/$name" "$tmp/$name"; then + rm -rf "$tmp" + exit 1 + fi + if ! verify_sha256 "$tmp/$name" "$sha"; then + rm -rf "$tmp" + die "The tmux download does not match the checksum pinned in this installer. Nothing was installed; re-run to try again." + fi + if ! tar -xzf "$tmp/$name" -C "$tmp" < /dev/null || [[ ! -f "$tmp/tmux" ]]; then + rm -rf "$tmp" + die "Could not unpack the tmux download." + fi + chmod 755 "$tmp/tmux" + if ! "$tmp/tmux" -V >/dev/null 2>&1 < /dev/null; then + rm -rf "$tmp" + die "The downloaded tmux does not run on this system. Ask an administrator to install tmux, then re-run this installer." + fi + mkdir -p "$bin_dir" + mv -f "$tmp/tmux" "$bin_dir/tmux" + rm -rf "$tmp" + + # A tmux someone else put at ~/.local/bin/tmux is not ours to replace: put + # the runtime folder itself on PATH in that case. + if link_into_local_bin "$bin_dir/tmux" tmux; then + ensure_on_path "$link_dir" + else + ensure_on_path "$bin_dir" + fi + hash -r 2>/dev/null || true + success "tmux $TMUX_BUILD_VERSION installed in $bin_dir" + return 0 +} + +# cloudflared into ~/.local/bin, where the server looks for it first. Cloudflare +# publishes no checksum file; this takes the release asset over TLS, exactly as +# the system routes above take the same release's packages and binaries. +install_cloudflared_userspace() { + local os="$1" arch="$2" asset="" tmp="" src="" dest="$HOME/.local/bin/cloudflared" + case "$os-$arch" in + linux-x64) asset="cloudflared-linux-amd64" ;; + linux-arm64) asset="cloudflared-linux-arm64" ;; + linux-armv7) asset="cloudflared-linux-arm" ;; + macos-x64) asset="cloudflared-darwin-amd64.tgz" ;; + macos-arm64) asset="cloudflared-darwin-arm64.tgz" ;; + *) die "Cloudflare publishes no cloudflared build for $os $arch." ;; + esac + tmp=$(mktemp -d "${TMPDIR:-/tmp}/codeman-cloudflared.XXXXXX") || die "Could not create a temporary folder." + if ! run_step "Downloading cloudflared" download \ + "https://github.com/cloudflare/cloudflared/releases/latest/download/$asset" "$tmp/$asset"; then + rm -rf "$tmp" + exit 1 + fi + src="$tmp/$asset" + if [[ "$asset" == *.tgz ]]; then + if ! tar -xzf "$tmp/$asset" -C "$tmp" < /dev/null || [[ ! -f "$tmp/cloudflared" ]]; then + rm -rf "$tmp" + die "Could not unpack the cloudflared download." + fi + src="$tmp/cloudflared" + fi + chmod 755 "$src" + if ! "$src" --version >/dev/null 2>&1 < /dev/null; then + rm -rf "$tmp" + die "The downloaded cloudflared does not run on this system." + fi + mkdir -p "$HOME/.local/bin" + mv -f "$src" "$dest" + rm -rf "$tmp" + return 0 +} + +# The no-admin half of ask_dependencies: say what stands in the way before +# downloading anything, ask once, fetch what is missing, then prove it runs. +# $2 = "consented" when the user already said yes to installing the missing +# pieces (the fallback after a refused sudo password), so they are not asked +# the same thing twice. +ask_dependencies_no_admin() { + local os="$1" consented="${2:-}" arch="" blocker="" what="" + local need_node="0" need_tmux="0" need_git="0" + arch=$(detect_arch) + check_node || need_node="1" + check_tmux || need_tmux="1" + check_git || need_git="1" + + blocker=$(no_admin_blocker "$os" "$arch" "$need_node" "$need_tmux") + if [[ -n "$blocker" ]]; then + die "$blocker" + fi + if [[ "$need_git" == "1" ]]; then + if [[ -d "$INSTALL_DIR/.git" ]]; then + die "$INSTALL_DIR is a git checkout, and no working git is available here to update it. Ask an administrator for git, or remove that folder for a fresh install, then re-run." + fi + if ! source_tarball_url >/dev/null; then + die "No working git is available, and $REPO_URL is not a GitHub repository this installer can fetch as an archive. Set CODEMAN_TARBALL_URL to a .tar.gz of the source, then re-run." + fi + fi + + info "Installing without administrator rights${NO_ADMIN_REASON:+ ($NO_ADMIN_REASON)}." + if [[ "$need_git" == "1" ]]; then + info "No working git here: Codeman is installed from a source archive, and updates by re-running this installer." + fi + if [[ "$need_node" == "1" ]]; then + what="Node.js $TARGET_NODE_VERSION (from nodejs.org)" + fi + if [[ "$need_tmux" == "1" ]]; then + what="${what:+$what and }tmux $TMUX_BUILD_VERSION (from github.com/tmux/tmux-builds)" + fi + if [[ -n "$what" ]]; then + headless_guard "download $what into $RUNTIME_DIR" + if [[ "$consented" != "consented" ]] && ! prompt_yes_no "Download $what into your home folder ($RUNTIME_DIR)?" "y"; then + die "Codeman needs $what. Install them and re-run this installer." + fi + if [[ "$need_node" == "1" ]]; then + CURRENT_STEP="installing Node.js $TARGET_NODE_VERSION into $RUNTIME_DIR" + install_node_userspace "$os" "$arch" + fi + if [[ "$need_tmux" == "1" ]]; then + CURRENT_STEP="installing tmux into $RUNTIME_DIR" + install_tmux_userspace "$os" "$arch" + fi + CURRENT_STEP="" + hash -r 2>/dev/null || true + fi + + # Prove it, never assume it. git is deliberately absent from this list. + check_node || die "Node.js $MIN_NODE_VERSION+ is still missing after the install." + check_npm || die "npm is not available next to that Node.js." + check_tmux || die "tmux is still missing after the install (sessions live inside tmux)." + if [[ -n "$what" ]]; then + success "Dependencies installed without administrator rights" + fi + return 0 +} + +# Where the source archive comes from when there is no git to clone with: +# CODEMAN_TARBALL_URL, else GitHub's archive of the configured repository at +# the configured branch or tag. Fails for a repository that is not on GitHub. +source_tarball_url() { + if [[ -n "${CODEMAN_TARBALL_URL:-}" ]]; then + printf '%s' "$CODEMAN_TARBALL_URL" + return 0 + fi + local base="${REPO_URL%/}" + base="${base%.git}" + case "$base" in + https://github.com/*/*) printf '%s/archive/%s.tar.gz' "$base" "$BRANCH" ;; + *) return 1 ;; + esac +} + +# fetch_source_archive : unpack the source into , which must be +# missing or empty. The marker goes in FIRST, so a run that dies half way +# through leaves something the next run recognises as its own and replaces. +fetch_source_archive() { + local dest="$1" url="" tmp="" + url=$(source_tarball_url) || die "No source archive is configured for $REPO_URL (set CODEMAN_TARBALL_URL)." + tmp=$(mktemp -d "${TMPDIR:-/tmp}/codeman-source.XXXXXX") || die "Could not create a temporary folder." + if ! run_step "Downloading Codeman" download "$url" "$tmp/source.tar.gz"; then + rm -rf "$tmp" + exit 1 + fi + mkdir -p "$dest" + printf 'archive\n' > "$dest/$SOURCE_MARKER" + if ! tar -xzf "$tmp/source.tar.gz" -C "$dest" --strip-components=1 < /dev/null; then + rm -rf "$tmp" + die "Could not unpack the Codeman source archive from $url." + fi + rm -rf "$tmp" + if [[ ! -f "$dest/package.json" ]]; then + die "The archive at $url does not look like the Codeman source (no package.json at its top level)." + fi + return 0 +} + +# ---- End of the no-admin route --------------------------------------------- + # ============================================================================ # Interactive Prompts # ============================================================================ @@ -1294,17 +1895,21 @@ prompt_yes_no() { # 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() { +# +# sudo_session_try returns 1 when the password is refused, so a caller can fall +# back to the no-admin route; sudo_session_start ends the run instead. +sudo_session_try() { [[ $EUID -eq 0 ]] && return 0 [[ -n "$SUDO_KEEPALIVE_PID" ]] && return 0 + [[ "$NO_ADMIN" == "1" ]] && 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." + sudo -v < /dev/tty || return 1 else - sudo -v || die "Failed to obtain sudo privileges. Try running the script directly instead of piping." + sudo -v || return 1 fi fi # Refreshes the timestamp while this script lives; ends on its own once the @@ -1319,6 +1924,16 @@ sudo_session_start() { return 0 } +sudo_session_start() { + if sudo_session_try; then + return 0 + fi + if [[ -e /dev/tty ]]; then + die "Failed to obtain sudo privileges." + fi + die "Failed to obtain sudo privileges. Try running the script directly instead of piping." +} + # 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. @@ -1640,6 +2255,9 @@ choose_network_binding() { # Tailscale state, for the menu hint and the default choice. Detection # only; never installs, logs in, or prompts for sudo here. local ts_hint="will be installed for you" ts_ready="0" ts_detected_url="" ts_preview="" + if [[ "$NO_ADMIN" == "1" && "$(uname -s)" != "Darwin" ]]; then + ts_hint="not installed, and installing it takes an administrator" + fi if check_tailscale; then ts_hint="installed, needs login" if command -v node &>/dev/null && [[ "$(ts_backend_state)" == "Running" ]]; then @@ -2027,10 +2645,17 @@ offer_install_tailscale() { info "Tailscale is not installed; skipping (non-interactive runs never install it)." return 1 fi + # Linux: the package install, `tailscale up` and the operator grant all + # need root. Say so instead of opening a sudo prompt that cannot succeed. + if [[ "$NO_ADMIN" == "1" && "$(uname -s)" != "Darwin" ]]; then + info "Tailscale is not installed, and installing it takes an administrator." + echo -e " ${DIM}Once one has installed it and run${NC} ${CYAN}sudo tailscale set --operator=$USER${NC}${DIM}, finish here with:${NC} ${CYAN}bash $INSTALL_DIR/install.sh tailscale${NC}" >&2 + return 1 + fi headless_guard "install Tailscale (curl | sh from tailscale.com)" if [[ "$(uname -s)" == "Darwin" ]]; then - if command -v brew &>/dev/null; then + if [[ "$NO_ADMIN" != "1" ]] && command -v brew &>/dev/null; then if ! prompt_yes_no "Tailscale is not installed. Install it now with Homebrew?" "y"; then return 1 fi @@ -2094,6 +2719,10 @@ ensure_tailscale_login() { if [[ "$(uname -s)" == "Darwin" ]]; then # The GUI app's CLI runs as the user; no root needed. if "$ts_bin" up --timeout=300s; then up_ok="1"; fi + elif [[ "$NO_ADMIN" == "1" ]]; then + # Works for an account an administrator made the Tailscale operator; + # for any other it fails right here, without a password prompt. + if "$ts_bin" up --timeout=300s; then up_ok="1"; fi else sudo_session_start if [[ -e /dev/tty ]]; then @@ -2126,6 +2755,11 @@ ensure_tailscale_operator() { if ts_cmd serve status &>/dev/null; then return 0 fi + if [[ "$NO_ADMIN" == "1" ]]; then + warn "tailscale serve needs root or Tailscale 'operator' rights, and this account has neither." + echo -e " ${DIM}An administrator can grant them once with:${NC} ${CYAN}sudo tailscale set --operator=$USER${NC}" >&2 + return 1 + fi if ! command -v sudo &>/dev/null; then warn "No sudo available; tailscale serve configuration may fail without root." TS_NEED_ROOT="1" @@ -2234,7 +2868,13 @@ tailscale_preflight() { tailscale_retrofit_hint "Tailscale is not connected" return 1 fi - ensure_tailscale_operator + # Fails only on the no-admin route (everywhere else it falls back to sudo + # per command). Checked explicitly: this function runs inside `||` and `if` + # conditions, where `set -e` does not stop on a failing line. + if ! ensure_tailscale_operator; then + tailscale_retrofit_hint "tailscale serve is not available to this account" + return 1 + fi if ! ensure_tailnet_https; then tailscale_retrofit_hint "HTTPS certificates are not enabled for your tailnet" return 1 @@ -2621,7 +3261,7 @@ setup_name_subcommand() { die "Tailscale is not connected. Run: bash $INSTALL_DIR/install.sh tailscale" fi read_existing_binding - ensure_tailscale_operator + ensure_tailscale_operator || exit 1 [[ -z "$SUBCOMMAND_ARG" ]] || TS_NAME="$SUBCOMMAND_ARG" TS_NO_RENAME="0" @@ -2677,11 +3317,19 @@ cloudflared_subcommand() { if check_cloudflared; then success "cloudflared is already installed at $(get_cloudflared_path)" else - headless_guard "install cloudflared (system package via sudo)" - if ! prompt_yes_no "Install cloudflared now?" "y"; then - exit 0 + if [[ "$NO_ADMIN" == "1" ]]; then + headless_guard "download cloudflared into $HOME/.local/bin" + if ! prompt_yes_no "Download cloudflared into $HOME/.local/bin now?" "y"; then + exit 0 + fi + install_cloudflared_userspace "$os" "$(detect_arch)" + else + headless_guard "install cloudflared (system package via sudo)" + if ! prompt_yes_no "Install cloudflared now?" "y"; then + exit 0 + fi + install_dependency "cloudflared" "$os" "$distro" fi - install_dependency "cloudflared" "$os" "$distro" hash -r 2>/dev/null || true check_cloudflared || die "cloudflared installation failed. See https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/" success "cloudflared installed at $(get_cloudflared_path)" @@ -2749,6 +3397,22 @@ setup_launchd_service() { local node_path node_path=$(command -v node) + # A Node.js outside those directories (the no-admin runtime, nvm, fnm) has + # to be on the agent's PATH as well: `npm install -g` puts AI CLIs beside + # it, and their `#!/usr/bin/env node` launchers look node up on PATH. Same + # for the tmux the no-admin route unpacked. + local node_dir + node_dir=$(dirname "$node_path") + if [[ ":$svc_path:" != *":$node_dir:"* ]]; then + svc_path="$node_dir:$svc_path" + fi + if [[ -x "$RUNTIME_DIR/node/bin/node" && ":$svc_path:" != *":$RUNTIME_DIR/node/bin:"* ]]; then + svc_path="$RUNTIME_DIR/node/bin:$svc_path" + fi + if [[ -x "$RUNTIME_DIR/bin/tmux" ]]; then + svc_path="$RUNTIME_DIR/bin:$svc_path" + fi + # Binding chosen during install (empty on paths that never asked), the # sub-path and the port when they differ from the defaults. local bind_plist="" @@ -3042,6 +3706,7 @@ main() { install_or_update_repo npm_install_deps run_step "Building Codeman" npm run build || exit 1 + finish_source_swap install_symlink # The dispatcher at the bottom only routes a bare re-run to the quiet @@ -3148,8 +3813,11 @@ print_preflight_summary() { installed) ts_line="installed, not logged in" ;; *) ts_line="not installed" ;; esac - if [[ -d "$INSTALL_DIR/.git" ]]; then + if [[ -d "$INSTALL_DIR/.git" || -f "$INSTALL_DIR/$SOURCE_MARKER" ]]; then existing="$INSTALL_DIR${EXISTING_VERSION:+ (v$EXISTING_VERSION)}" + if [[ ! -d "$INSTALL_DIR/.git" ]]; then + existing="$existing, from a source archive" + fi if [[ "$EXISTING_FOUND" == "1" ]]; then existing="$existing, service binds $EXISTING_HOST${EXISTING_BASE_URL:+ under $EXISTING_BASE_URL}" fi @@ -3160,6 +3828,9 @@ print_preflight_summary() { echo -e " ${BOLD}AI CLIs${NC} ${clis:-none found}" echo -e " ${BOLD}Tailscale${NC} $ts_line" echo -e " ${BOLD}Existing${NC} $existing" + if [[ "$NO_ADMIN" == "1" ]]; then + echo -e " ${BOLD}Admin${NC} not used${NO_ADMIN_REASON:+ ($NO_ADMIN_REASON)}: Node.js and tmux, where missing, go into your home folder" + fi echo "" return 0 } @@ -3175,12 +3846,27 @@ print_preflight_summary() { ask_dependencies() { local os="$1" distro="$2" [[ -n "$MISSING_PKGS" ]] || return 0 + if [[ "$NO_ADMIN" == "1" ]]; then + ask_dependencies_no_admin "$os" + return 0 + fi headless_guard "install $MISSING_PKGS (system packages via sudo)" if ! prompt_yes_no "Install the missing pieces now ($MISSING_PKGS)?" "y"; then die "Codeman needs $MISSING_PKGS. Install them and re-run this installer." fi if [[ "$os" == "linux" ]]; then - sudo_session_start + if ! sudo_session_try; then + # The password was refused, or this account may not use sudo after + # all. Offer the route that needs neither before giving up. + warn "Could not get administrator rights." + if prompt_yes_no "Install without them instead? (Node.js and tmux go into your home folder)" "y"; then + NO_ADMIN="1" + NO_ADMIN_REASON="sudo was refused" + ask_dependencies_no_admin "$os" "consented" + return 0 + fi + die "Failed to obtain sudo privileges." + fi fi if ! check_git; then CURRENT_STEP="installing git" @@ -3266,8 +3952,43 @@ choose_launch_mode() { # Phase 2: work # ---------------------------------------------------------------------------- +# fetch_source : a shallow clone where git works, the source archive +# where it does not (only the no-admin route gets here without a git: every +# other route installed one in ask_dependencies). +fetch_source() { + local dest="$1" + if check_git; then + run_step "Cloning Codeman" git clone --quiet --depth 1 --branch "$BRANCH" "$REPO_URL" "$dest" || exit 1 + else + fetch_source_archive "$dest" + fi + return 0 +} + +# The second half of replacing an archive install (install_or_update_repo set +# SOURCE_STAGING): the freshly built tree takes the old one's place. Called +# only after `npm run build` succeeded, so until this line the running install +# was never touched. +finish_source_swap() { + [[ -n "$SOURCE_STAGING" ]] || return 0 + local old="$INSTALL_DIR.old" + rm -rf "$old" + cd "$HOME" 2>/dev/null || cd / + mv "$INSTALL_DIR" "$old" + if ! mv "$SOURCE_STAGING" "$INSTALL_DIR"; then + mv "$old" "$INSTALL_DIR" + die "Could not move the new build into place. The previous install is unchanged." + fi + rm -rf "$old" + SOURCE_STAGING="" + cd "$INSTALL_DIR" + return 0 +} + install_or_update_repo() { + SOURCE_STAGING="" if [[ -d "$INSTALL_DIR/.git" ]]; then + check_git || die "$INSTALL_DIR is a git checkout, and no working git is available to update it. Install git (or remove that folder for a fresh install), then re-run." info "Existing installation found at $INSTALL_DIR, updating it" cd "$INSTALL_DIR" git remote set-url origin "$REPO_URL" 2>/dev/null || true @@ -3285,12 +4006,27 @@ install_or_update_repo() { run_step "Fetching the latest Codeman" git fetch --quiet origin || exit 1 git reset --hard "origin/$BRANCH" --quiet fi + elif [[ -f "$INSTALL_DIR/$SOURCE_MARKER" ]]; then + # An archive install is replaced as a whole: with no git there is + # nothing to say which files a newer version dropped. The fresh tree + # is fetched and built BESIDE the running one, and finish_source_swap + # moves it into place once the build has succeeded, so a failed + # download or build leaves the install exactly as it was. Where git + # works by now, the fresh tree is a clone and in-app updates return. + info "Existing installation found at $INSTALL_DIR, building its replacement beside it" + SOURCE_STAGING="$INSTALL_DIR.new" + rm -rf "$SOURCE_STAGING" + fetch_source "$SOURCE_STAGING" + cd "$SOURCE_STAGING" else + if [[ -e "$INSTALL_DIR" ]] && [[ -n "$(ls -A "$INSTALL_DIR" 2>/dev/null)" ]]; then + die "$INSTALL_DIR exists and is not a Codeman install. Move it away (or set CODEMAN_INSTALL_DIR), then re-run." + fi # Create parent directory mkdir -p "$(dirname "$INSTALL_DIR")" - # Clone repository (shallow for speed) - run_step "Cloning Codeman" git clone --quiet --depth 1 --branch "$BRANCH" "$REPO_URL" "$INSTALL_DIR" || exit 1 + # Shallow clone, or the source archive where no git works + fetch_source "$INSTALL_DIR" cd "$INSTALL_DIR" fi return 0 @@ -3473,7 +4209,11 @@ print_done_screen() { echo -e " ${DIM}(the same variables apply to: codeman web -d)${NC}" fi fi - echo -e " ${BOLD}Update${NC} re-run the install line, or App Settings -> System -> Updates" + if [[ -f "$INSTALL_DIR/$SOURCE_MARKER" && ! -d "$INSTALL_DIR/.git" ]]; then + echo -e " ${BOLD}Update${NC} re-run the install line ${DIM}(installed without git, so the in-app updater is off)${NC}" + else + echo -e " ${BOLD}Update${NC} re-run the install line, or App Settings -> System -> Updates" + fi echo -e " ${BOLD}Terminal${NC} ${CYAN}codeman tui${NC} ${DIM}(session dashboard over SSH, e.g. from Termius)${NC}" echo -e " ${BOLD}Docs${NC} https://github.com/Ark0N/Codeman/wiki" if [[ -z "$ts_url" ]] && [[ "$BIND_HOST" != "0.0.0.0" ]]; then @@ -3494,33 +4234,41 @@ print_done_screen() { } update() { - if [[ ! -d "$INSTALL_DIR/.git" ]]; then + if [[ ! -d "$INSTALL_DIR/.git" && ! -f "$INSTALL_DIR/$SOURCE_MARKER" ]]; then die "Codeman is not installed at $INSTALL_DIR. Run the installer first." fi info "Updating Codeman..." INSTALL_STARTED="1" - cd "$INSTALL_DIR" - git remote set-url origin "$REPO_URL" 2>/dev/null || true + if [[ -d "$INSTALL_DIR/.git" ]]; then + check_git || die "$INSTALL_DIR is a git checkout, and no working git is available to update it. Install git, then re-run." + cd "$INSTALL_DIR" + git remote set-url origin "$REPO_URL" 2>/dev/null || true - # Never blow away local changes silently (this used to be an unconditional - # reset --hard). Interactive users get a choice; headless runs auto-stash - # so the changes stay recoverable, the same policy as scripts/self-update.sh. - if ! git diff --quiet 2>/dev/null || ! git diff --staged --quiet 2>/dev/null; then - warn "Local changes detected in $INSTALL_DIR" - if prompt_yes_no "Stash local changes and update? (recover with: git stash pop)"; then - git stash push --quiet -m "codeman-installer auto-stash $(date -u +%Y-%m-%dT%H:%M:%SZ)" - info "Local changes stashed (see 'git stash list' in $INSTALL_DIR)" - else - info "Keeping local changes; update skipped." - return 0 + # Never blow away local changes silently (this used to be an unconditional + # reset --hard). Interactive users get a choice; headless runs auto-stash + # so the changes stay recoverable, the same policy as scripts/self-update.sh. + if ! git diff --quiet 2>/dev/null || ! git diff --staged --quiet 2>/dev/null; then + warn "Local changes detected in $INSTALL_DIR" + if prompt_yes_no "Stash local changes and update? (recover with: git stash pop)"; then + git stash push --quiet -m "codeman-installer auto-stash $(date -u +%Y-%m-%dT%H:%M:%SZ)" + info "Local changes stashed (see 'git stash list' in $INSTALL_DIR)" + else + info "Keeping local changes; update skipped." + return 0 + fi fi - fi - run_step "Fetching the latest Codeman" git fetch --quiet origin || exit 1 - git reset --hard "origin/$BRANCH" --quiet + run_step "Fetching the latest Codeman" git fetch --quiet origin || exit 1 + git reset --hard "origin/$BRANCH" --quiet + else + # Installed from a source archive (no git): a fresh tree is fetched and + # built beside the running one, and swapped in below after the build. + install_or_update_repo + fi npm_install_deps run_step "Building Codeman" npm run build || exit 1 + finish_source_swap date -u +%Y-%m-%dT%H:%M:%SZ > "$INSTALL_DIR/.install-complete" success "Updated to $(node -e "console.log(require('./package.json').version)")" echo "" @@ -3678,6 +4426,31 @@ uninstall() { fi fi + # What a no-admin install downloaded. A link in ~/.local/bin goes only + # while it still points into that folder: a tmux or node the user put + # there themselves is not ours. `npm install -g` under that Node.js put its + # CLIs in the same folder, so say so and default to keeping it then. + if [[ -d "$RUNTIME_DIR" ]]; then + local runtime_default="y" runtime_note="" runtime_globals="" + runtime_globals=$(ls "$RUNTIME_DIR/node/lib/node_modules" 2>/dev/null | grep -v -E '^(npm|corepack)$' | tr '\n' ' ' || true) + if [[ -n "$runtime_globals" ]]; then + runtime_default="n" + runtime_note=" It also holds what you installed with its npm -g: $runtime_globals" + fi + if prompt_yes_no "Remove the Node.js and tmux this installer downloaded ($RUNTIME_DIR)?$runtime_note" "$runtime_default"; then + local runtime_tool + for runtime_tool in tmux node npm npx; do + if [[ -L "$symlink_dir/$runtime_tool" ]] && [[ "$(readlink "$symlink_dir/$runtime_tool" 2>/dev/null)" == "$RUNTIME_DIR/"* ]]; then + rm -f "$symlink_dir/$runtime_tool" + fi + done + rm -rf "$RUNTIME_DIR" + success "Removed $RUNTIME_DIR" + else + info "Kept $RUNTIME_DIR" + fi + fi + # Ask about data directory local data_dir="$HOME/.codeman" if [[ -d "$data_dir" ]]; then @@ -3712,6 +4485,8 @@ Flags --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) + --no-admin Never use sudo or Homebrew: Node.js and tmux go into the + home folder (chosen by itself without administrator rights) --help, -h This text Subcommands @@ -3724,7 +4499,8 @@ Subcommands 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. +CODEMAN_SKIP_SYSTEMD=1, CODEMAN_NODE_VERSION, CODEMAN_REPO_URL, CODEMAN_BRANCH, +CODEMAN_NO_ADMIN=1, CODEMAN_TARBALL_URL. EOF } @@ -3751,6 +4527,7 @@ parse_flags() { --run) LAUNCH_PRESET="1"; RECONFIGURE="1" ;; --no-start) LAUNCH_PRESET="3" ;; --yes|-y) ASSUME_YES="1" ;; + --no-admin) NO_ADMIN="1" ;; --password) shift [[ $# -gt 0 ]] || die "--password needs a value" @@ -3788,6 +4565,13 @@ if [[ -n "${CODEMAN_INSTALL_SH_LIB:-}" ]]; then return 0 2>/dev/null || exit 0; # Wrap in main to prevent partial execution on curl | bash parse_flags "$@" +adopt_userspace_runtime +# Whether this run may ask for administrator rights. `status` and `uninstall` +# never install anything, so they skip the question (and its `sudo -n`). +case "$SUBCOMMAND" in + status|uninstall) ;; + *) resolve_admin_mode ;; +esac case "$SUBCOMMAND" in update) update ;; uninstall) uninstall ;; @@ -3801,7 +4585,7 @@ case "$SUBCOMMAND" in # re-runs the full flow, so a failed first attempt can actually finish. # 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 + if [[ "$RECONFIGURE" != "1" && -f "$INSTALL_DIR/.install-complete" ]] && [[ -d "$INSTALL_DIR/.git" || -f "$INSTALL_DIR/$SOURCE_MARKER" ]]; then print_banner update else diff --git a/scripts/git-hooks.mjs b/scripts/git-hooks.mjs index 4727a5fe..a9784cc3 100644 --- a/scripts/git-hooks.mjs +++ b/scripts/git-hooks.mjs @@ -218,6 +218,11 @@ function canonicalPath(path) { * @returns {string | null} */ export function resolveGitHooksDir(repoRoot) { + // Not a checkout at all (a source-archive or npm install): answer without running + // git. On a Mac without the Xcode Command Line Tools `/usr/bin/git` is a stub that + // opens Apple's "install the developer tools" dialog, and this runs on every + // `npm install`. `.git` is a directory in a clone and a file in a worktree. + if (!existsSync(join(repoRoot, '.git'))) return null; try { const top = git(repoRoot, ['rev-parse', '--show-toplevel']); if (!top || realpathSync(top) !== realpathSync(repoRoot)) return null; diff --git a/src/types/update.ts b/src/types/update.ts index 696d2cdf..97441113 100644 --- a/src/types/update.ts +++ b/src/types/update.ts @@ -31,8 +31,11 @@ export type SupervisorKind = 'systemd' | 'launchd' | 'launchd-daemon' | 'docker- * How Codeman was installed. `git` and `docker-compose` can self-update in * place; `docker-compose` is a git checkout bind-mounted into the container, so * the pull/build happen on the host filesystem and survive container recreation. + * `archive` is what `install.sh` leaves on a machine with no working git (its + * no-admin route): the source unpacked from an archive, with nothing for the + * in-app updater to pull into, so it updates by re-running the installer. */ -export type InstallKind = 'git' | 'docker-compose' | 'npm' | 'unknown'; +export type InstallKind = 'git' | 'docker-compose' | 'npm' | 'archive' | 'unknown'; /** * Why an in-place container update is refused. Each is derived mechanically from diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index fd4c41af..14bb9f69 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -1940,10 +1940,14 @@ Object.assign(CodemanApp.prototype, { // `docker-compose` self-updates in place like `git` does — the container // restarts itself. Anything else cannot. if (data.installKind && data.installKind !== 'git' && data.installKind !== 'docker-compose') { + // `archive` = installed by install.sh on a machine with no working git: the + // installer replaces it with a fresh build, so point there, not at npm. const hint = data.supervisor === 'docker-compose' ? 'Update from the Docker host with docker/Start-Codeman.sh.' - : 'Update with npm i -g aicodeman@latest.'; + : data.installKind === 'archive' + ? 'It was installed without git. Re-run the installer to update: curl -fsSL https://getcodeman.com/install | bash' + : 'Update with npm i -g aicodeman@latest.'; this._setUpdateResult(`This install can't update itself (${escapeHtml(data.installKind)}). ${hint}`); return; } diff --git a/src/web/self-update.ts b/src/web/self-update.ts index ac7c97a2..e01920a7 100644 --- a/src/web/self-update.ts +++ b/src/web/self-update.ts @@ -462,12 +462,26 @@ export function isRunningInContainer(): boolean { return process.env.CODEMAN_IN_CONTAINER === '1' || existsSync('/.dockerenv'); } -function detectInstallKind(dir: string): InstallKind { +/** + * Marker `install.sh` writes into an install directory whose source came from an + * archive instead of a git clone (`SOURCE_MARKER` there; the two names are pinned + * to each other by `test/install-sh-invariants.test.ts`). + */ +export const ARCHIVE_INSTALL_MARKER = '.install-source'; + +/** How an `archive` install updates: the installer replaces it with a fresh build. */ +export const ARCHIVE_UPDATE_HINT = + 'This install has no git checkout, so it cannot update in place. Re-run the installer to update: ' + + 'curl -fsSL https://getcodeman.com/install | bash'; + +export function detectInstallKind(dir: string): InstallKind { // A container whose code is a bind-mounted checkout updates in place (the pull // and build land on the host filesystem and survive container recreation). A // container WITHOUT that mount runs a baked image copy — a pull there would go // to the writable layer and vanish on the next `up`, so it is not updatable. if (existsSync(join(dir, '.git'))) return isRunningInContainer() ? 'docker-compose' : 'git'; + // install.sh on a machine with no working git: the source from an archive. + if (existsSync(join(dir, ARCHIVE_INSTALL_MARKER))) return 'archive'; // Global npm install ships only dist/ (no src/, no .git). if (!existsSync(join(dir, 'src'))) return 'npm'; return 'unknown'; @@ -705,7 +719,9 @@ export async function checkForUpdate(): Promise { error: info.installKind === 'unknown' && isRunningInContainer() ? 'This container runs a baked image copy with no repository mounted — self-update is unavailable. See docs/docker-self-update.md.' - : 'Not a git install — self-update is unavailable.', + : info.installKind === 'archive' + ? ARCHIVE_UPDATE_HINT + : 'Not a git install — self-update is unavailable.', }; } @@ -829,7 +845,9 @@ export async function startUpdate(): Promise { code: 'not-git', message: isRunningInContainer() ? 'This container has no repository mounted. Update from the host with docker/Start-Codeman.sh.' - : 'This is not a git install. Update with: npm i -g aicodeman@latest', + : info.installKind === 'archive' + ? ARCHIVE_UPDATE_HINT + : 'This is not a git install. Update with: npm i -g aicodeman@latest', }; } const existing = readCurrentUpdateStatus(); diff --git a/test/git-hooks.test.ts b/test/git-hooks.test.ts index 3e00bc1e..425a56d3 100644 --- a/test/git-hooks.test.ts +++ b/test/git-hooks.test.ts @@ -18,6 +18,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { execFileSync, spawnSync } from 'node:child_process'; import { chmodSync, + existsSync, mkdirSync, mkdtempSync, readFileSync, @@ -171,6 +172,26 @@ describe('resolveGitHooksDir (temp repos)', () => { expect(resolveGitHooksDir(dir)).toBeNull(); }); + it('never runs git for a directory that has no .git', () => { + // A source-archive or npm install. On a Mac without the Xcode Command Line Tools + // /usr/bin/git is a stub that opens Apple's install dialog, and postinstall calls this + // on every `npm install`, so "not a checkout" must be answered without spawning git. + const dir = join(scratch, `plain-${++counter}`); + const bin = join(scratch, `fakebin-${counter}`); + const ran = join(scratch, `git-ran-${counter}`); + mkdirSync(dir); + mkdirSync(bin); + writeFileSync(join(bin, 'git'), `#!/bin/sh\necho ran > "${ran}"\nexit 1\n`, { mode: 0o755 }); + const path = process.env.PATH; + process.env.PATH = `${bin}:${path}`; + try { + expect(resolveGitHooksDir(dir)).toBeNull(); + } finally { + process.env.PATH = path; + } + expect(existsSync(ran), 'git was executed for a directory with no .git').toBe(false); + }); + it('returns null when a repo-local core.hooksPath points outside the repo', () => { const repo = newRepo(); const outside = join(scratch, `shared-hooks-${counter}`); diff --git a/test/install-sh-invariants.test.ts b/test/install-sh-invariants.test.ts index 0fd588be..384937dd 100644 --- a/test/install-sh-invariants.test.ts +++ b/test/install-sh-invariants.test.ts @@ -12,9 +12,12 @@ import { describe, expect, it } from 'vitest'; import { spawnSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; +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'); @@ -536,3 +539,410 @@ 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 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/"*'); + }); +}); diff --git a/test/self-update.test.ts b/test/self-update.test.ts index 6a41b48c..6f3f5393 100644 --- a/test/self-update.test.ts +++ b/test/self-update.test.ts @@ -6,8 +6,16 @@ * npm test -- test/self-update.test.ts */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterAll } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { + ARCHIVE_INSTALL_MARKER, + ARCHIVE_UPDATE_HINT, + canSelfUpdateInPlace, + detectInstallKind, + isRunningInContainer, parseVersionFromTag, compareVersions, isNewerStableVersion, @@ -205,3 +213,39 @@ describe('expireStalledStatus (runtime staleness backstop)', () => { expect(expireStalledStatus(base({ phase: 'failed', updatedAt: NOW - 60 * MIN }), NOW)).toBeNull(); }); }); + +describe('detectInstallKind', () => { + const scratch = mkdtempSync(join(tmpdir(), 'codeman-install-kind-')); + afterAll(() => rmSync(scratch, { recursive: true, force: true })); + let n = 0; + const tree = (entries: string[]) => { + const dir = join(scratch, `install-${++n}`); + mkdirSync(dir); + for (const entry of entries) { + if (entry.endsWith('/')) mkdirSync(join(dir, entry)); + else writeFileSync(join(dir, entry), 'archive\n'); + } + return dir; + }; + + it('reads a source-archive install (install.sh without a working git) as archive', () => { + const dir = tree(['src/', 'dist/', ARCHIVE_INSTALL_MARKER]); + expect(detectInstallKind(dir)).toBe('archive'); + // Nothing to pull into, so it never self-updates in place; the hint names the installer. + expect(canSelfUpdateInPlace('archive')).toBe(false); + expect(ARCHIVE_UPDATE_HINT).toContain('getcodeman.com/install'); + expect(ARCHIVE_UPDATE_HINT).not.toContain('npm i -g'); + }); + + it('lets a real checkout win over a leftover marker', () => { + // A re-run of the installer on a machine that gained git replaces the archive tree with + // a clone; were a marker ever left beside .git, the checkout is still what counts. + const dir = tree(['.git/', 'src/', ARCHIVE_INSTALL_MARKER]); + expect(detectInstallKind(dir)).toBe(isRunningInContainer() ? 'docker-compose' : 'git'); + }); + + it('keeps npm and unknown as they were', () => { + expect(detectInstallKind(tree(['dist/']))).toBe('npm'); + expect(detectInstallKind(tree(['dist/', 'src/']))).toBe('unknown'); + }); +});