Merge #768 feat(install): install without administrator rights (--no-admin)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 05:03:29 +01:00
commit 11208a153c
19 files changed
+1692 -57

No files matched your search

+55
View File
@@ -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
+4 -4
View File
@@ -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 `<prefix> 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-<letter> 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-<arch>/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/<platform>-<arch>/`**, 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-<arch>/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/<platform>-<arch>/`**, 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, copilot and pi get the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/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, tracked Copilot, or tracked Pi**. 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. Pi has no version gate either, and is fullscreen by default since 1.0.0: it enables tracking once at start, so the flag is what survives a tab switch or reload (xterm's own mode does not). ⚠️ Pi deliberately has no `transcriptPageKeys`: its `tuiMode: regular` binds those keys to the editor, so paging there moves a draft's caret. 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 `<base href>` + 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)
@@ -321,7 +321,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/<id>.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/<username>/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/<id>.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/<username>/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.
@@ -386,6 +386,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.
+1
View File
@@ -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://<machine>.<tailnet>.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://<machine>.<tailnet>.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-<hostname>` (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`, one whose `sudo` rights the installer cannot confirm (outside the `sudo`, `wheel` and `admin` groups, with a password still required) 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 and `CODEMAN_NO_ADMIN=0` turns it off. 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 <n>`, `--port <n>`, `--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:
+1
View File
@@ -71,6 +71,7 @@ curl -fsSL https://getcodeman.com/install | bash
- **怎么访问,由你决定。** 安装器提供三种到达仪表盘的方式:**Tailscale**(环回绑定,由 `tailscale serve` 代理,得到带真实证书的 `https://<机器名>.<tailnet>.ts.net`,用你的 tailnet 当登录,无需密码)、**局域网内任意设备**(`0.0.0.0`,会提示设置一个强烈推荐的密码),或**仅本机**(`127.0.0.1`,最安全)。绑定网络却跳过密码需要显式确认,并以醒目警告收尾。高亮的默认项反映机器上已有的状态(已在用 Tailscale 时默认 Tailscale,重跑时沿用现有绑定),直接回车绝不会引入新软件。如果你节点上的 `:443` 已被别的应用占用,Codeman 会改用 `https://<机器名>.<tailnet>.ts.net/codeman` 或第二个端口,而不是把它替换掉。手动运行的 `codeman web` 仍默认仅环回。
- **名字由你来定。** 默认情况下,URL 用的是这台机器在 tailnet 里现有的名字。第二个问题答「是」会把机器改名为 `codeman-<hostname>`(这也会改掉它在 SSH 里的名字,所以默认是「否」);之后也可以用 `install.sh name` 来改。
- **重跑即更新。** 再次运行同一条命令即可原地更新已完成的安装:`~/.codeman/app` 中的本地改动会被 stash(绝不丢弃),运行中的服务会自动重启并校验。若首次安装中途失败,重跑会继续完成完整的安装流程。`install.sh status` 会再次打印 URL 和二维码;另外还有 `install.sh update`、`install.sh tailscale` 和 `install.sh uninstall`。
- **无需管理员权限。** 在没有 `sudo` 的账户、安装器无法确认 `sudo` 权限的账户(不在 `sudo`、`wheel`、`admin` 组内,且 `sudo` 需要密码)或不是管理员的 Mac 账户上,安装器会把 nodejs.org 的 Node.js 和 tmux 项目自己的构建下载到 `~/.codeman/runtime`,两者都会校验 SHA-256;没有可用的 git 时,则以归档包的方式获取源码。`--no-admin` 强制走这条路线,`CODEMAN_NO_ADMIN=0` 则将其关闭。两个限制:Linux 上必须已有 make、C++ 编译器和 python3,tmux 下载需要 macOS 15 或更新版本。详见 [wiki](https://github.com/Ark0N/Codeman/wiki/Installation#without-administrator-rights)。
- **给急性子的参数。** `curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service` 直接在命令行上回答这些问题(还有 `--lan`、`--local`、`--run`、`--no-start`、`--name <n>`、`--port <n>`、`--yes`)。**CI / 无终端环境:** 没有终端时,涉及系统改动的步骤会带着说明中止,而不是静默执行;在自动化场景设置 `CODEMAN_NONINTERACTIVE=1` 即可批准这些步骤。
你至少需要安装一个 AI 编程 CLI —— [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) 或 [GitHub Copilot CLI](https://github.com/github/copilot-cli)(任意组合均可;自 Google 面向消费者停售后,Gemini CLI 仅限企业版,Antigravity 是其继任者)。安装器会自动检测这十个中已安装的任意一个;若一个都没有,会给出一个菜单让你安装其中任意一个(DeepSeek 除外,它的 npm 包只装一个启动器,没有可运行的 profile),也可以选择跳过、稍后自行安装。安装完成后:
+8
View File
@@ -1319,6 +1319,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`). ⚠️ The group test is a judgment, not a fact: no probe can read sudoers without reporting to the administrator, so an account sudoers lists by name, with a password, lands here too. `NO_ADMIN_REASON` must therefore say the rights could not be confirmed (`NO_ADMIN_UNCONFIRMED`), never that the account may not use `sudo`, and every message that ends or sums up the route carries `no_admin_override_hint`, which names `CODEMAN_NO_ADMIN=0` for that reason alone (`--no-admin` asked for the route; a `sudo` that is missing or refused has no way back).
On this route Node.js comes from nodejs.org (`latest-v<major>.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/<ref>.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 `<install dir>.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
+9
View File
@@ -483,6 +483,15 @@ transient mount blip at launch poisons tmux's long‑lived cwd and crashes
never executes. Large hand‑formatted/generated assets (`app.js`, the gesture
bundle, vendored libs) are `.prettierignore`d for the style pass, but the NUL +
syntax checks still cover them.
- **Installer downloads without a package manager:** on the no-admin route
(`docs/wiki/Installation.md`) `install.sh` fetches and runs software no system
package manager vetted, each checked before it is unpacked or run. The Node.js
tarball is checked against the `SHASUMS256.txt` fetched from nodejs.org over the
same TLS connection, which proves integrity, not independent provenance: the
signature on that file is not checked. The tmux build is checked against a
SHA-256 pinned per platform in the installer. `install.sh cloudflared` takes the
latest cloudflared release into `~/.local/bin` unverified, as the system routes
take the same release, because Cloudflare publishes no checksum file.
---
+2 -1
View File
@@ -179,12 +179,13 @@ the defaults discovers and attaches your live sessions.
| Install route | Update with |
| ------------- | --------------------------------------------------------------------------- |
| Installer | Re-run the install one-liner, or **App Settings → System → Updates**. |
| Installer, without git | Re-run the install 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 the service. |
The in-app updater covers git-clone installs supervised by systemd or launchd. It stashes a
dirty tree rather than discarding it, and streams progress across the restart. npm installs
report as non-updatable.
and installs without git report as non-updatable.
### Will updating kill my running sessions?
+59 -2
View File
@@ -102,6 +102,57 @@ 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.
The group check cannot see a sudoers entry, so an account that sudoers lists by name, with
a password, takes this route too. The summary then reads `could not confirm that this
account may use sudo`, and to use `sudo` anyway:
```bash
curl -fsSL https://getcodeman.com/install | CODEMAN_NO_ADMIN=0 bash
```
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
@@ -202,6 +253,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. |
@@ -225,6 +277,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 | `git pull`, then 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. |
@@ -240,8 +293,12 @@ 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
state and your case folders gone as well, and check `~/codeman-cases/` first: linked cases
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`. That folder is inside `~/.codeman/`, so saying yes to the data directory
question that follows removes it even if you kept it a step earlier, and the question says
so. By default neither route 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.
+3 -2
View File
@@ -145,6 +145,7 @@ log stream --predicate 'process == "node"' # macOS, noisy
| Install route | Update with |
| ------------- | ------------------------------------------------------------------------ |
| Installer | Re-run the one-liner, or **App Settings → System → Updates**. |
| 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 | The in-app updater, which restarts the container in place, or `git pull`, then re-run `Start-Codeman.sh`. |
@@ -152,8 +153,8 @@ log stream --predicate 'process == "node"' # macOS, noisy
### The in-app updater
**App Settings → System → Updates**, for git-clone installs supervised by systemd or
launchd. npm installs report as non-updatable, and an unsupervised install is told to
restart manually.
launchd. npm installs and installs without git report as non-updatable, and an unsupervised
install is told to restart manually.
The interesting part is that the update restarts the very process running it. So the real
work runs in a **detached script that outlives the restart** and writes progress to a status
+2 -2
View File
@@ -38,8 +38,8 @@ as usual and tells them that server-wide settings can only be changed by an admi
### Updates
Current version, a manual check, and the in-app updater. Covers git-clone installs
supervised by systemd or launchd; npm installs report as non-updatable. See
[Running As A Service](Running-As-A-Service).
supervised by systemd or launchd; npm installs and installs without git report as
non-updatable. See [Running As A Service](Running-As-A-Service).
### Terminal & Input
+1 -1
View File
@@ -393,7 +393,7 @@ On an HTTPS install, add `-k` to the curl commands and use the `https://` URL.
Open an [issue](https://github.com/Ark0N/Codeman/issues) with:
- OS and version.
- Install method: installer, npm, or git clone.
- Install method: installer (with or without git), npm, or git clone.
- `codeman --version`.
- Browser and version, if the problem is in the UI.
- Which CLI the session was running, and its version.
+871 -38
View File
File diff suppressed because it is too large. Load diff
+5
View File
@@ -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;
+4 -1
View File
@@ -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
+5 -1
View File
@@ -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 <code>docker/Start-Codeman.sh</code>.'
: 'Update with <code>npm i -g aicodeman@latest</code>.';
: data.installKind === 'archive'
? 'It was installed without git. Re-run the installer to update: <code>curl -fsSL https://getcodeman.com/install | bash</code>'
: 'Update with <code>npm i -g aicodeman@latest</code>.';
this._setUpdateResult(`This install can't update itself (${escapeHtml(data.installKind)}). ${hint}`);
return;
}
+21 -3
View File
@@ -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<UpdateCheckResult> {
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<StartUpdateResult> {
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();
+21
View File
@@ -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}`);
+575 -1
View File
@@ -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,574 @@ 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<string, string>) => {
const r = drive(
`
NONINTERACTIVE=1; NO_ADMIN=1; NO_ADMIN_REASON=test
download() { echo "DOWNLOAD $1"; }
download_to_stdout() { echo "DOWNLOAD $1"; }
check_node() { return 1; }; check_tmux() { return 1; }; check_git() { return 0; }
detect_arch() { echo "$DRIVE_ARCH"; }
check_build_tools() { [[ "$DRIVE_TOOLS" == "1" ]]; }; missing_build_tools() { printf 'make, g++'; }
macos_major_version() { printf '%s' "$DRIVE_MACOS"; }
is_musl_libc() { [[ "$DRIVE_MUSL" == "1" ]]; }
ask_dependencies_no_admin "$DRIVE_OS"
`,
{ DRIVE_ARCH: 'x64', DRIVE_TOOLS: '1', DRIVE_MACOS: '26', DRIVE_MUSL: '0', ...env }
);
expect(r.status, 'the installer must stop').toBe(1);
expect(r.stdout + r.stderr, 'something was downloaded before the stop').not.toContain('DOWNLOAD');
return r.stderr;
};
expect(stop({ DRIVE_OS: 'linux', DRIVE_TOOLS: '0' })).toMatch(/make, g\+\+[\s\S]*administrator/);
expect(stop({ DRIVE_OS: 'macos', DRIVE_ARCH: 'arm64', DRIVE_MACOS: '14' })).toMatch(/macOS 15 or newer/);
expect(stop({ DRIVE_OS: 'linux', DRIVE_MUSL: '1' })).toMatch(/musl/);
expect(stop({ DRIVE_OS: 'linux', DRIVE_ARCH: 'armv7' })).toMatch(/no Node\.js download/);
});
it('picks the right Node.js tarball out of SHASUMS256.txt', () => {
const sha = (c: string) => c.repeat(64);
const sums = [
`${sha('a')} node-v22.23.4-aix-ppc64.tar.gz`,
`${sha('b')} node-v22.23.4-darwin-arm64.tar.gz`,
`${sha('c')} node-v22.23.4-darwin-arm64.tar.xz`,
`${sha('d')} node-v22.23.4-linux-x64-musl.tar.gz`,
`${sha('e')} node-v22.23.4-linux-x64.tar.gz`,
`nothex node-v22.23.4-linux-arm64.tar.gz`,
].join('\n');
const result = drive(
`
printf '%s\\n' "$SUMS" | pick_node_tarball linux-x64
printf '%s\\n' "$SUMS" | pick_node_tarball darwin-arm64
printf '%s\\n' "$SUMS" | pick_node_tarball linux-arm64 || echo "arm64=refused"
printf '%s\\n' "$SUMS" | pick_node_tarball linux-s390x || echo "s390x=none"
`,
{ SUMS: sums }
);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain(`${sha('e')} node-v22.23.4-linux-x64.tar.gz`);
expect(result.stdout).toContain(`${sha('b')} node-v22.23.4-darwin-arm64.tar.gz`);
expect(result.stdout).not.toContain('musl');
// A line whose checksum is not 64 hex digits is not a checksum to trust.
expect(result.stdout).toContain('arm64=refused');
expect(result.stdout).toContain('s390x=none');
});
it('refuses a download whose checksum does not match', () => {
const result = drive(`
f=$(mktemp); trap 'rm -f "$f"' EXIT
printf 'codeman' > "$f"
good=$(sha256_file "$f")
echo "len=\${#good}"
verify_sha256 "$f" "$good" && echo "match=ok"
verify_sha256 "$f" "\${good%?}x" && echo "tampered=accepted" || echo "tampered=refused"
verify_sha256 "$f" "" && echo "empty=accepted" || echo "empty=refused"
`);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain('len=64');
expect(result.stdout).toContain('match=ok');
expect(result.stdout).toContain('empty=refused');
// The last digit swapped for a character no digest contains.
expect(result.stdout).toContain('tampered=refused');
});
it('installs from an archive without git, then replaces that install beside itself', () => {
const scratch = mkdtempSync(join(tmpdir(), 'codeman-archive-install-'));
try {
const make = (version: string) => {
const tree = join(scratch, `src-${version}`, `Codeman-${version}`);
mkdirSync(join(tree, 'src'), { recursive: true });
writeFileSync(join(tree, 'package.json'), `{ "version": "${version}" }\n`);
writeFileSync(join(tree, 'src', `only-in-${version}.ts`), '');
const tarball = join(scratch, `${version}.tar.gz`);
const tar = spawnSync('tar', ['-czf', tarball, '-C', join(scratch, `src-${version}`), `Codeman-${version}`]);
expect(tar.status, String(tar.stderr)).toBe(0);
return tarball;
};
const first = make('1.0.0');
const second = make('2.0.0');
const app = join(scratch, 'app');
const script = `
LOG_FILE=/dev/null
check_git() { return 1; }
download() { cp "\${1#file://}" "$2"; }
run_step() { shift; "$@"; }
install_or_update_repo
echo "staging=[$SOURCE_STAGING] cwd=$(pwd)"
finish_source_swap
echo "after=$(pwd)"
`;
const run = (tarball: string) =>
drive(script, { CODEMAN_INSTALL_DIR: app, CODEMAN_TARBALL_URL: `file://${tarball}`, HOME: scratch });
// First install: straight into the install directory, marked as an archive install.
const fresh = run(first);
expect(fresh.status, fresh.stderr).toBe(0);
expect(fresh.stdout).toContain('staging=[]');
expect(readFileSync(join(app, 'package.json'), 'utf-8')).toContain('1.0.0');
expect(existsSync(join(app, ARCHIVE_INSTALL_MARKER))).toBe(true);
// Second run: the new tree is built BESIDE the old one, which stays whole until the swap.
writeFileSync(join(app, 'built-by-1.0.0'), '');
const update = run(second);
expect(update.status, update.stderr).toBe(0);
expect(update.stdout).toContain(`staging=[${app}.new] cwd=${app}.new`);
expect(update.stdout).toContain(`after=${app}`);
expect(readFileSync(join(app, 'package.json'), 'utf-8')).toContain('2.0.0');
// A whole replacement: what the old version had and the new one dropped is gone.
expect(existsSync(join(app, 'src', 'only-in-1.0.0.ts'))).toBe(false);
expect(existsSync(join(app, 'built-by-1.0.0'))).toBe(false);
expect(existsSync(join(app, ARCHIVE_INSTALL_MARKER))).toBe(true);
expect(existsSync(`${app}.new`)).toBe(false);
expect(existsSync(`${app}.old`)).toBe(false);
// A trailing slash on the setting must not put the staging folder inside the install.
const slashed = drive(script, {
CODEMAN_INSTALL_DIR: `${app}/`,
CODEMAN_TARBALL_URL: `file://${first}`,
HOME: scratch,
});
expect(slashed.status, slashed.stderr).toBe(0);
expect(slashed.stdout).toContain(`staging=[${app}.new] cwd=${app}.new`);
expect(readFileSync(join(app, 'package.json'), 'utf-8')).toContain('1.0.0');
expect(existsSync(`${app}.new`)).toBe(false);
expect(existsSync(`${app}.old`)).toBe(false);
// A folder that is neither a checkout nor one of ours is never written into.
const foreign = join(scratch, 'foreign');
mkdirSync(foreign);
writeFileSync(join(foreign, 'notes.txt'), 'mine');
const refused = drive(script, {
CODEMAN_INSTALL_DIR: foreign,
CODEMAN_TARBALL_URL: `file://${first}`,
HOME: scratch,
});
expect(refused.status).toBe(1);
expect(refused.stderr).toContain('is not a Codeman install');
expect(readFileSync(join(foreign, 'notes.txt'), 'utf-8')).toBe('mine');
} finally {
rmSync(scratch, { recursive: true, force: true });
}
});
it('downloads from a subcommand that never ran the full flow', () => {
// DOWNLOADER used to be set only by main(), so `install.sh cloudflared` (and now the
// update of an archive install) died inside download() on an unbound variable.
const result = drive(`
unset DOWNLOADER
curl() { echo "FETCH $*"; }
wget() { echo "FETCH $*"; }
download https://example.invalid/a /tmp/never-written
unset DOWNLOADER
download_to_stdout https://example.invalid/b
`);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toMatch(/FETCH .*example\.invalid\/a/);
expect(result.stdout).toMatch(/FETCH .*example\.invalid\/b/);
});
it('builds the archive URL from the repository and branch, on GitHub only', () => {
const url = (env: NodeJS.ProcessEnv) => drive('source_tarball_url || echo REFUSED', env).stdout.trim();
expect(url({})).toBe('https://github.com/Ark0N/Codeman/archive/master.tar.gz');
expect(url({ CODEMAN_REPO_URL: 'https://github.com/someone/fork.git', CODEMAN_BRANCH: 'feat/x' })).toBe(
'https://github.com/someone/fork/archive/feat/x.tar.gz'
);
expect(url({ CODEMAN_REPO_URL: 'https://gitlab.com/someone/fork.git' })).toBe('REFUSED');
expect(url({ CODEMAN_REPO_URL: 'git@github.com:someone/fork.git' })).toBe('REFUSED');
expect(url({ CODEMAN_TARBALL_URL: 'https://mirror.example/codeman.tar.gz' })).toBe(
'https://mirror.example/codeman.tar.gz'
);
});
it('shares the archive marker name with the in-app updater', () => {
// install.sh writes it, src/web/self-update.ts reads it to tell such an install how to
// update. A rename on one side would send archive installs back to the npm advice.
expect(SOURCE).toContain(`SOURCE_MARKER="${ARCHIVE_INSTALL_MARKER}"`);
});
it('treats a completed archive install as an install: quiet update on a re-run', () => {
const dispatch = SOURCE.slice(SOURCE.indexOf('\nparse_flags "$@"'));
expect(dispatch).toMatch(
/-f "\$INSTALL_DIR\/\.install-complete" \]\] && \[\[ -d "\$INSTALL_DIR\/\.git" \|\| -f "\$INSTALL_DIR\/\$SOURCE_MARKER" \]\]/
);
const update = fn('update', '\nuninstall() {');
expect(update).toContain('! -f "$INSTALL_DIR/$SOURCE_MARKER"');
// The swap happens only after the build has succeeded.
expect(update.indexOf('finish_source_swap')).toBeGreaterThan(update.indexOf('npm run build'));
const main = fn(
'main',
'\n# ----------------------------------------------------------------------------\n# Phase 0'
);
expect(main.indexOf('finish_source_swap')).toBeGreaterThan(main.indexOf('npm run build'));
expect(main.indexOf('finish_source_swap')).toBeLessThan(main.indexOf('install_symlink'));
});
it('documents --no-admin where people look for flags, and resolves the route before any work', () => {
expect(fn('usage', '\n}\n')).toContain('--no-admin');
const dispatch = SOURCE.slice(SOURCE.indexOf('\nparse_flags "$@"'));
expect(dispatch.indexOf('resolve_admin_mode')).toBeGreaterThan(-1);
expect(dispatch.indexOf('resolve_admin_mode')).toBeLessThan(dispatch.lastIndexOf('case "$SUBCOMMAND" in'));
expect(dispatch.indexOf('adopt_userspace_runtime')).toBeLessThan(dispatch.indexOf('resolve_admin_mode'));
});
it('puts the downloaded runtime on the LaunchAgent PATH', () => {
// The agent's PATH is a fixed list (Homebrew, /usr/local, ~/.local/bin, the system). A
// Node.js in the home folder is outside it, and so is everything `npm install -g` puts
// beside that Node.js: the AI CLIs, whose launchers start with `#!/usr/bin/env node`.
// A machine with a real LaunchDaemon makes the function stand down, so skip there.
if (existsSync('/Library/LaunchDaemons/com.codeman.web.plist')) return;
const home = mkdtempSync(join(tmpdir(), 'codeman-agent-path-'));
const result = drive(
`
mkdir -p "$RUNTIME_DIR/node/bin" "$RUNTIME_DIR/bin" "$HOME/.local/bin"
printf '#!/bin/sh\n' > "$RUNTIME_DIR/node/bin/node"; chmod +x "$RUNTIME_DIR/node/bin/node"
printf '#!/bin/sh\n' > "$RUNTIME_DIR/bin/tmux"; chmod +x "$RUNTIME_DIR/bin/tmux"
ln -s "$RUNTIME_DIR/node/bin/node" "$HOME/.local/bin/node"
PATH="$HOME/.local/bin:$PATH"; hash -r
launchctl() { return 0; }
sleep() { :; }
BIND_HOST=127.0.0.1
setup_launchd_service >/dev/null 2>&1
cat "$HOME/Library/LaunchAgents/com.codeman.web.plist"
`,
{ HOME: home }
);
rmSync(home, { recursive: true, force: true });
expect(result.status, result.stderr).toBe(0);
const path = /<key>PATH<\/key>\s*<string>([^<]*)<\/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(`<string>${home}/.local/bin/node</string>`);
});
it('links only into its own runtime, and removes only its own links', () => {
// ~/.local/bin is the user's folder: a tmux or node they put there is never replaced on
// install and never deleted on uninstall.
const home = mkdtempSync(join(tmpdir(), 'codeman-links-'));
const result = drive(
`
mkdir -p "$HOME/.local/bin" "$RUNTIME_DIR/bin"
touch "$RUNTIME_DIR/bin/tmux" "$RUNTIME_DIR/bin/node"
printf 'mine' > "$HOME/.local/bin/tmux"
link_into_local_bin "$RUNTIME_DIR/bin/tmux" tmux && echo "foreign=replaced" || echo "foreign=kept"
link_into_local_bin "$RUNTIME_DIR/bin/node" node && echo "fresh=linked"
link_into_local_bin "$RUNTIME_DIR/bin/node" node && echo "ours=relinked"
echo "tmux=$(cat "$HOME/.local/bin/tmux") node=$(readlink "$HOME/.local/bin/node")"
`,
{ HOME: home }
);
rmSync(home, { recursive: true, force: true });
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain('foreign=kept');
expect(result.stdout).toContain('fresh=linked');
expect(result.stdout).toContain('ours=relinked');
expect(result.stdout).toMatch(/tmux=mine node=.*\/\.codeman\/runtime\/bin\/node/);
const body = fn('uninstall', '\nusage() {');
const rm = body.indexOf('rm -f "$symlink_dir/$runtime_tool"');
expect(rm).toBeGreaterThan(-1);
expect(body.slice(body.lastIndexOf('if [[', rm), rm)).toContain('== "$RUNTIME_DIR/"*');
});
it('says the rights were not confirmed, and names the way back to sudo, when it chose this route itself', () => {
// A sudoers entry cannot be read without a probe that reports to the administrator, so an
// account listed by name, with a password, lands on this route too. It must not be told it
// may not use sudo, and every place that ends or sums up the route must name the override.
const stops = `
detect_arch() { echo x64; }
download() { echo "DOWNLOAD $1"; }; download_to_stdout() { echo "DOWNLOAD $1"; }
check_node() { return 1; }; check_tmux() { return 1; }; check_git() { return 0; }
missing_build_tools() { printf 'make'; }; is_musl_libc() { return 1; }
ts_cmd() { return 1; }; check_tailscale() { return 1; }
CLI_IDS=(); TS_STATE=""; MISSING_PKGS=""; EXISTING_FOUND=""; EXISTING_VERSION=""
section() { echo "== $1"; }
NONINTERACTIVE=1
section blocker; check_build_tools() { return 1; }; (ask_dependencies_no_admin linux) 2>&1 || true
check_build_tools() { return 0; }
section declined; prompt_yes_no() { return 1; }; (ask_dependencies_no_admin linux) 2>&1 || true
section ensure_sudo; (ensure_sudo) 2>&1 || true
section operator; (ensure_tailscale_operator) 2>&1 || true
section summary; (print_preflight_summary linux x64 ubuntu) 2>&1
NONINTERACTIVE=0
section tailscale; (offer_install_tailscale) 2>&1 || true
`;
const sections = (stdout: string) =>
Object.fromEntries(
stdout
.split(/^== /m)
.slice(1)
.map((part) => [part.slice(0, part.indexOf('\n')), part])
);
const detected = drive(`
[[ $EUID -ne 0 ]] || { echo "skipped=root"; exit 0; }
uname() { echo Linux; }
command() { if [[ "$1" == "-v" && "$2" == "sudo" ]]; then return 0; else builtin command "$@"; fi; }
id() { echo users; }
sudo() { return 1; }
NO_ADMIN=""; resolve_admin_mode
echo "route=$NO_ADMIN reason=$NO_ADMIN_REASON"
${stops}
`);
expect(detected.status, detected.stderr).toBe(0);
if (detected.stdout.includes('skipped=root')) return;
expect(detected.stdout).toMatch(/route=1 reason=.*could not confirm/);
expect(detected.stdout).not.toContain('may not use sudo');
const parts = sections(detected.stdout);
for (const name of ['blocker', 'declined', 'ensure_sudo', 'operator', 'summary', 'tailscale']) {
expect(parts[name], `${name}: ${detected.stdout}`).toContain('CODEMAN_NO_ADMIN=0');
}
// Asked for, or sudo really tried: there is no way back to name.
for (const reason of ['--no-admin', 'sudo is not installed', 'sudo was refused']) {
const other = drive(`
uname() { echo Linux; }
NO_ADMIN=1; NO_ADMIN_REASON="${reason}"
${stops}
`);
expect(other.status, other.stderr).toBe(0);
expect(other.stdout, reason).not.toContain('CODEMAN_NO_ADMIN=0');
}
});
it('proves a downloaded tmux and cloudflared in the home folder, not in the temp folder', () => {
// /tmp is commonly mounted noexec on the shared hosts this route is for. The stand-in
// below refuses to run from TMPDIR the way such a mount does (126, not a shell error).
const home = mkdtempSync(join(tmpdir(), 'codeman-noexec-'));
try {
const noexec = join(home, 'noexec-tmp');
const fake = join(home, 'fake');
mkdirSync(noexec);
mkdirSync(fake);
for (const tool of ['tmux', 'cloudflared']) {
writeFileSync(join(fake, tool), `#!/bin/sh\ncase "$0" in "${noexec}"/*) exit 126 ;; esac\necho ok\n`, {
mode: 0o755,
});
}
const tarball = join(home, 'tmux.tar.gz');
const tar = spawnSync('tar', ['-czf', tarball, '-C', fake, 'tmux']);
expect(tar.status, String(tar.stderr)).toBe(0);
const result = drive(
`
run_step() { shift; "$@"; }
ensure_on_path() { :; }
tmux_build_sha256() { sha256_file "$DRIVE_FAKE/../tmux.tar.gz"; }
download() { case "$1" in *tmux*) cp "$DRIVE_FAKE/../tmux.tar.gz" "$2" ;; *) cp "$DRIVE_FAKE/cloudflared" "$2" ;; esac; }
(install_tmux_userspace linux x64) && echo "tmux=installed"
(install_cloudflared_userspace linux x64) && echo "cloudflared=installed"
ls -A "$RUNTIME_DIR" | tr '\n' ' '; echo
ls -A "$HOME/.local" | tr '\n' ' '; echo
ls -A "$HOME/.local/bin" | tr '\n' ' '; echo
`,
{ HOME: home, TMPDIR: noexec, DRIVE_FAKE: fake }
);
expect(result.stderr).not.toContain('does not run');
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain('tmux=installed');
expect(result.stdout).toContain('cloudflared=installed');
expect(existsSync(join(home, '.codeman/runtime/bin/tmux'))).toBe(true);
expect(existsSync(join(home, '.local/bin/cloudflared'))).toBe(true);
// Nothing left beside them: the listings hold the final names and nothing else.
const [runtime, local, bin] = result.stdout.trim().split('\n').slice(-3);
expect(runtime.trim()).toBe('bin');
expect(local.trim()).toBe('bin');
expect(bin.trim().split(' ').sort()).toEqual(['cloudflared', 'tmux']);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
it('survives an environment with no USER', () => {
// docker run -u gives a process no USER, and set -u turns a message that prints it into a
// crash. The no-admin route reaches several such lines, and such a container is its first home.
const result = drive(
`
uname() { echo Linux; }
NO_ADMIN=1; NO_ADMIN_REASON=test; NONINTERACTIVE=0
ts_cmd() { return 1; }; check_tailscale() { return 1; }
(ensure_tailscale_operator) 2>&1 || true
(offer_install_tailscale) 2>&1 || true
BIND_HOST=0.0.0.0; BIND_PASSWORD=""
(print_security_notice) 2>&1
`,
{ USER: undefined }
);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout + result.stderr).not.toContain('unbound variable');
expect(result.stdout.match(/--operator=\S/g)?.length).toBe(2);
});
it('drops a trailing slash from CODEMAN_INSTALL_DIR', () => {
// "$INSTALL_DIR.new" must sit beside the install, and app/.new sits inside it.
const dir = (value: string) => drive('echo "$INSTALL_DIR"', { CODEMAN_INSTALL_DIR: value }).stdout.trim();
expect(dir('/x/app/')).toBe('/x/app');
expect(dir('/x/app//')).toBe('/x/app');
expect(dir('/x/app')).toBe('/x/app');
expect(dir('/')).toBe('/');
});
it('warns that removing the data directory takes a kept runtime with it', () => {
// The runtime lives inside ~/.codeman, so the yes to the data directory removes what the
// user chose to keep one prompt earlier. Read from the source, never driven: uninstall()
// calls `systemctl --user`, which HOME does not redirect, so a drive stops and disables
// the real codeman-web of whoever runs this test.
const body = fn('uninstall', '\nusage() {');
const prompt = body.indexOf('Remove data directory ($data_dir)?');
expect(prompt).toBeGreaterThan(-1);
const note = body.lastIndexOf('[[ -d "$RUNTIME_DIR" ]]', prompt);
expect(note, 'the kept runtime is not looked at before that prompt').toBeGreaterThan(
body.indexOf('Kept $RUNTIME_DIR')
);
expect(body.slice(note, prompt)).toMatch(/data_note=" .*\$RUNTIME_DIR/);
expect(body.slice(prompt)).toMatch(/^Remove data directory \(\$data_dir\)\?\$data_note"/);
});
});
+45 -1
View File
@@ -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');
});
});