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/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", 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/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' }); 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'); + }); +});