Compare commits

...
Author SHA1 Message Date
Codeman maintainer d6226385c0 fix(cli): a running server notices a CLI the installer just added
A running Codeman remembers a missing CLI for up to 5 minutes, so a status
poll never spawns a login shell, and its Run button reads that same memory.
So "Run fails, add the agent, Run again" kept saying the CLI was not
installed, right after the app had said it was ready.

install.sh now touches `cli-installed` in the data dir once a CLI has landed,
and resolve() leaves the backoff when that file's mtime changes. It is one
stat, inside the backoff branch, and buys one run of the chain, so a resolved
CLI never pays for it and a CLI that is still missing goes back to waiting.
Re-walking PATH during the backoff would have found hand installs too, but it
would put a synchronous stat of every PATH entry on the request path, where
one dead network mount freezes the server (#516), and execute candidates that
were already refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:25:34 +01:00
Codeman maintainer 1157aa9ecd fix(install): say why an added agent failed on a Mac with no other agent
After "Skip for now" there is no other agent, so a failed add hit
install_offered_cli's gate first, and the app showed its first-install text
("re-run the installer") instead of naming the agent and its command. That
is the main path of adding an agent.

run_offered_cli now runs a trusted command and looks again; the gate stays in
install_offered_cli for the install, and `agent` judges its own run. The one
line that executes a trusted command is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:46:31 +01:00
Codeman maintainer dd07126dd8 feat(desktop): add a coding agent once Codeman is installed
The agent chooser only appeared during the install, and only on a Mac with
no agent. After "Skip for now", or with Codeman already installed, there was
no screen for it.

The same chooser now works after the install: "Add a coding agent" on the
This Mac tab, "Add a Coding Agent to This Mac..." in the app menu (it also
works from a Codeman page), and after a Skip the app says Codeman has no
agent yet before it opens Codeman.

`install.sh agent <id>` installs one agent and nothing else. It checks that
THAT agent landed: install_offered_cli is fatal only when no CLI is left, so
with another one present a failed install would have reported success.

The app runs it as a run of its own (`gui_install::Run::Agent`), in its own
temporary folder, so `ps` never reads it as "Codeman is being installed". It
may run next to a live server, which an install must refuse, and stops only
for another run. `--no-admin` goes to both `preflight` and `agent`: it decides
what counts as installable, so the two must agree.

CI: the bash 3.2 preflight check never tested anything. Its child run
inherited CODEMAN_INSTALL_SH_LIB and stopped at the sourcing guard, and a
false [[ ]] does not trip set -e on bash 3.2. Both are fixed for that check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:23:56 +01:00
Codeman maintainer b51632e54b feat(desktop): install Codeman from the app, with no Terminal and no password
The This Mac tab runs install.sh --no-admin itself. The two questions only a
person can answer move into the app: which coding agent to install, shown only
when this Mac has none, and who may reach Codeman. The installer's output goes
to a file, so a running install survives the app quitting and is picked up
again when it reopens.

install.sh gains --ai-cli, --progress and a preflight subcommand, which is what
the app reads its choices from, so it keeps no copy of the CLI catalogue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 02:51:00 +01:00
Codeman maintainer ed74ae41bc merge feat/no-admin-install into the graphical installer branch
The graphical install runs install.sh --no-admin, so this branch needs both
PR #763 (the app) and PR #768 (the route that needs no password).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 02:01:55 +01:00
Codeman maintainer ae28534406 feat(install): install without administrator rights (--no-admin)
Codeman itself always lived under $HOME; administrator rights were only
spent on what it needs first (system packages for Node.js, tmux and git,
Homebrew on a Mac). An account without those rights could not install it:
the installer died in ensure_sudo, or in Homebrew's own installer.

install.sh now has a no-admin route, taken by itself when the account has
no administrator rights (no sudo binary; outside sudo/wheel/admin and
`sudo -n true` fails; a refused sudo password; a Mac account outside the
admin group with no writable Homebrew) and forced with --no-admin or
CODEMAN_NO_ADMIN=1:

- Node.js: the official tarball from nodejs.org, checked against the
  SHASUMS256.txt published beside it, into ~/.codeman/runtime/node.
- tmux: the tmux project's own build (tmux/tmux-builds 3.7c), checked
  against a SHA-256 pinned in the installer, into ~/.codeman/runtime/bin.
- Both are linked from ~/.local/bin (never over a file that is not ours),
  and the runtime is on PATH and on the LaunchAgent's PATH.
- git is never installed. Without a working git the source comes as an
  archive, the install is marked with .install-source, and it updates by
  re-running the installer: the new tree is built beside the old one and
  swapped in only after the build succeeded. A later re-run on a machine
  that has gained git turns it back into a clone.
- Nothing on this route runs sudo, Homebrew or `xcode-select --install`,
  and the route is never decided by `sudo -v`.

What it cannot supply is checked before the first download: a C/C++
toolchain on Linux (node-pty 1.1.0 compiles there), musl, and macOS below
15 (the tmux build's deployment target).

Along the way:

- check_git no longer trusts `command -v git`. On a Mac without the Xcode
  Command Line Tools /usr/bin/git is a stub that opens Apple's install
  dialog; it is judged by `xcode-select -p` and never executed. Any other
  git is asked for its version.
- scripts/git-hooks.mjs answers "not a checkout" without running git, for
  the same reason: postinstall calls it on every npm install.
- The in-app updater knows the install kind `archive` and points such an
  install at the installer instead of `npm i -g aicodeman@latest`.
- download() and download_to_stdout() resolve DOWNLOADER themselves. Only
  main() used to set it, so `install.sh cloudflared` died on `set -u` with
  the message swallowed by the step log.
- The AI CLI menu leaves out a Homebrew-only install command where there
  is no Homebrew.

Verified with the real installer: a bare Ubuntu 24.04 container as an
unprivileged user (install, a PTY round trip through the API, update by
re-run, cloudflared, uninstall), the same without a compiler (stops before
any download), with a broken then working git, with sudo present but not
usable, and on macOS 26 (arm64) in an isolated HOME with sudo, brew, git,
the compilers, launchctl and tailscale replaced by tripwires. Not verified
on a real standard macOS account: that account was an administrator.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-05 18:50:58 +01:00
Codeman maintainer 46b85fa6d0 chore: bump Playwright to 1.64.1 and unpin the browser suite
Playwright supports Ubuntu 26.04 from 1.61.0, so the browser suite can go
back to ubuntu-latest. Raises the floor in package.json as well, because
the old ^1.58.0 still admitted versions that refuse to install on the
image every workflow now runs on. Bundled Chromium moves from 148 to 156.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:22:41 +01:00
Codeman maintainer aa463d9d28 ci: pin the browser suite to ubuntu-24.04
ubuntu-latest moved to 26.04 and the locked Playwright 1.60.0 refuses to
install chromium there (support starts at 1.61.0), so the nightly stopped
before running a test. Pin to the image of the last green run, so a failure
now points at our code rather than at a new OS and Chromium. Unpin when
Playwright is bumped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:15:43 +01:00
Codeman maintainer 9eac5ecf01 test: wait for the first read before starting the other edit history sessions
The gate belonged to whichever read arrived first, and sessions load their stores
independently, so under CI timing another session took it, the loop never reached
release() and the test timed out (master, CI run 41930703100). The code cannot block
one session on another: sync() only waits on its own slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 00:21:12 +01:00
26 changed files with 5229 additions and 179 deletions

No files matched your search

+84
View File
@@ -80,6 +80,35 @@ jobs:
CLI_DETECT_DONE=""; detect_all_clis
offer_ai_cli_install >/dev/null 2>&1
echo "bash $BASH_VERSION: skipping the AI CLI install menu continues"
# --ai-cli answers the same menu with no terminal: `none` carries on, and a
# name runs that one entry (bash 3.2 cannot expand an empty offer list under
# set -u, which is the guard the loop over it has to get right).
has_tty() { return 1; }
AI_CLI_CHOICE=none
offer_ai_cli_install >/dev/null 2>&1
AI_CLI_CHOICE=codex
k=0; while [[ $k -lt ${#CLI_INSTALL_CMD_TRUSTED[@]} ]]; do
CLI_INSTALL_CMD_TRUSTED[$k]=""
k=$((k + 1))
done
offer_ai_cli_install >/dev/null 2>&1
echo "bash $BASH_VERSION: --ai-cli answers the menu, also with nothing to offer"
# What the desktop app runs: preflight on stdout, and only JSON there. These
# child runs must not inherit CODEMAN_INSTALL_SH_LIB, or they stop at the
# sourcing guard and print nothing. Each check ends in `|| exit 1`, because
# bash 3.2 does not let a false [[ ]] trip set -e.
out=$(env -u CODEMAN_INSTALL_SH_LIB bash /w/install.sh preflight --no-admin)
[[ "$out" == "{\"schema\":1,"* && "$out" == *"]}" ]] || { echo "preflight printed: $out"; exit 1; }
json_string "a\"b\\c" >/tmp/json-out
[[ "$(cat /tmp/json-out)" == "\"a\\\"b\\\\c\"" ]] || { echo "json_string printed: $(cat /tmp/json-out)"; exit 1; }
echo "bash $BASH_VERSION: preflight prints JSON"
# How the desktop app adds an agent: a real run of the subcommand, which stops
# on the missing npm before it could install anything.
if out=$(env -u CODEMAN_INSTALL_SH_LIB bash /w/install.sh agent codex --no-admin --progress 2>&1); then
echo "agent ran with no npm to run"; exit 1
fi
[[ "$out" == *"@@codeman phase agent"* && "$out" != *"@@codeman phase done"* ]] || { echo "agent printed: $out"; exit 1; }
echo "bash $BASH_VERSION: agent stops without saying done"
'
# Issue #382: the dsh identity probe builds an OPTIONAL `timeout` prefix as an
# array, and on stock macOS there is no `timeout`, so the array is empty and the
@@ -151,6 +180,61 @@ jobs:
RECONFIGURE=0; parse_flags --port 4001; [[ "$RECONFIGURE" == "1" ]]
echo "bash $BASH_VERSION: question phase (flags, launch default, occupied :443, rename opt-in, kept password, start line) ok"
'
# No-admin route: the helpers that decide what gets downloaded and whether it is
# trusted (the Node.js checksum pick, the tmux digest table, the verify step), the
# stop-before-download check, and an archive install plus its replacement, all
# in bash 3.2. This image is Alpine, so it is also the one place the musl branch
# runs for real: a machine the route cannot serve must stop BEFORE a download.
docker run --rm -v "$PWD":/w -w /w -e CODEMAN_INSTALL_SH_LIB=1 -e HOME=/tmp/h bash:3.2 bash -c '
set -euo pipefail
mkdir -p /tmp/h /tmp/src/Codeman-1/src
. /w/install.sh
sha=$(printf "%064d" 7)
sums=$(printf "%s node-v22.1.0-linux-x64.tar.gz\nnothex node-v22.1.0-linux-arm64.tar.gz\n" "$sha")
[[ "$(printf "%s\n" "$sums" | pick_node_tarball linux-x64)" == "$sha node-v22.1.0-linux-x64.tar.gz" ]]
if printf "%s\n" "$sums" | pick_node_tarball linux-arm64 >/dev/null; then
echo "accepted a checksum that is not 64 hex digits" >&2; exit 1
fi
for p in linux-x86_64 linux-arm64 macos-arm64 macos-x86_64; do
d=$(tmux_build_sha256 "$p"); [[ ${#d} -eq 64 ]]
done
printf codeman > /tmp/f; good=$(sha256_file /tmp/f); [[ ${#good} -eq 64 ]]
verify_sha256 /tmp/f "$good"
if verify_sha256 /tmp/f "${good%?}x" || verify_sha256 /tmp/f ""; then
echo "accepted a wrong or empty checksum" >&2; exit 1
fi
is_musl_libc
NO_ADMIN=1; NONINTERACTIVE=1
download() { echo DOWNLOADED; }; download_to_stdout() { echo DOWNLOADED; }
check_node() { return 1; }; check_tmux() { return 1; }; check_git() { return 1; }
check_build_tools() { return 0; }
if out=$( (ask_dependencies_no_admin linux) 2>&1 ); then
echo "did not stop on a musl system" >&2; exit 1
fi
case "$out" in
*DOWNLOADED*) echo "downloaded before stopping: $out" >&2; exit 1 ;;
*musl*) ;;
*) echo "stopped for the wrong reason: $out" >&2; exit 1 ;;
esac
# An archive install, then its replacement beside it (busybox tar, bash 3.2).
printf "{ \"version\": \"1.0.0\" }\n" > /tmp/src/Codeman-1/package.json
tar -czf /tmp/one.tar.gz -C /tmp/src Codeman-1
printf "{ \"version\": \"2.0.0\" }\n" > /tmp/src/Codeman-1/package.json
tar -czf /tmp/two.tar.gz -C /tmp/src Codeman-1
INSTALL_DIR=/tmp/h/app; LOG_FILE=/dev/null
download() { cp "${1#file://}" "$2"; }; run_step() { shift; "$@"; }
CODEMAN_TARBALL_URL=file:///tmp/one.tar.gz; install_or_update_repo >/dev/null 2>&1; finish_source_swap
[[ -f "$INSTALL_DIR/$SOURCE_MARKER" && -z "$SOURCE_STAGING" ]]
grep -q 1.0.0 "$INSTALL_DIR/package.json"
touch "$INSTALL_DIR/from-the-old-build"
CODEMAN_TARBALL_URL=file:///tmp/two.tar.gz; install_or_update_repo >/dev/null 2>&1
[[ "$SOURCE_STAGING" == "$INSTALL_DIR.new" ]] && grep -q 1.0.0 "$INSTALL_DIR/package.json"
finish_source_swap
grep -q 2.0.0 "$INSTALL_DIR/package.json"
[[ ! -e "$INSTALL_DIR/from-the-old-build" && ! -e "$INSTALL_DIR.new" && ! -e "$INSTALL_DIR.old" ]]
NO_ADMIN=""; resolve_admin_mode; [[ "$NO_ADMIN" == "0" ]]
echo "bash $BASH_VERSION: no-admin route (checksums, stop before download, archive install and swap) ok"
'
- name: CLI catalogue artifacts are in sync with stock.ts
run: npm run generate:cli-catalog -- --check
+4 -4
View File
@@ -103,7 +103,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
- **Default bind is loopback-only; non-loopback without a password starts but warns** — the server defaults to `--host 127.0.0.1`. Binding non-loopback (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` starts anyway but prints a loud warning; `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges it. ⚠️ The production systemd unit passes no `--host`, so prod binds **localhost only**: reach it via `tailscale serve`/tunnel to `127.0.0.1`. A loopback bind is reachable through a same-host tunnel but NOT by a browser hitting the box's LAN IP. `install.sh` is separate and prompts for the binding (defaulting to LAN + a password), and preserves the existing binding on re-runs. → [architecture-invariants#default-bind-and-the-non-loopback-warning-path](docs/architecture-invariants.md#default-bind-and-the-non-loopback-warning-path), `docs/security-architecture.md`
- **Instance isolation / multi-instance attach danger** — the data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts`. ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions**, resizing and mutating them. `$HOME` isolation is NOT enough because tmux is system-global. To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes dir + socket together), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually; `scripts/run-beta.sh` does this for a beta alongside prod. **Any new `~/.codeman/...` path MUST go through `dataPath()`**, never `join(homedir(), '.codeman', …)`, and **any new `tmux -L` caller through `resolveTmuxSocketName()`** (both in `config/instance.ts`): the TUI shells out to tmux from a second process, and a hardcoded `codeman` there would point a beta instance at prod's panes. → [architecture-invariants#instance-isolation-and-the-multi-instance-attach-danger](docs/architecture-invariants.md#instance-isolation-and-the-multi-instance-attach-danger)
- **The prefix table on Codeman's socket holds only `<prefix> send-prefix`** — browser tabs are tmux attach clients, and tmux's stock bindings let Ctrl+B `)`/`s` move a tab onto another session. `createSession` resets the table, and `reconcileSessions()` at boot for sessions that survived an upgrade (`src/tmux-prefix-table.ts`); `root` and `C-<letter> detach-client` (the TUI attach's way out) are left alone, so F1 and that alias reach browser tabs for the length of an attach. Alt+1..9 are `if-shell` bindings that `switch-client` only for the TUI's own client (`client_tty` plus a one-shot pid hook), so a browser tab's Esc-then-digit does not move it. Docker and owned-remote nested servers get the same reduction (`C-q send-prefix` only) in their create chain. ⚠️ So `Ctrl+B d` does not exist there: anything attaching a user's terminal claims its own detach key (F1 in `root`, as `codeman tui` and `scripts/tmux-manager.sh` do). → [architecture-invariants#the-prefix-table-on-codemans-socket-holds-only-send-prefix](docs/architecture-invariants.md#the-prefix-table-on-codemans-socket-holds-only-send-prefix)
- **node-pty's macOS `spawn-helper` ships without `+x`** (issues #6, #204): `node-pty@1.1.0` publishes `prebuilds/darwin-<arch>/spawn-helper` as mode 0644, and macOS launches every PTY through it, so a stock macOS install fails every session start with `Error: posix_spawnp failed.` **Linux can never reproduce it**: `spawn-helper` is an `OS=="mac"` gyp target and node-pty ships no Linux prebuild, so node-gyp always emits an executable helper there. ⚠️ The flip side of that: since Linux has no prebuild, `npm install` **needs a C/C++ toolchain there** (`make`, `g++`, `python3`), so `install.sh` checks for and installs one alongside Node/tmux/git — a stock Ubuntu 24 server has none and died inside node-gyp with `not found: make`. Do not drop that step. ⚠️ Look in **`prebuilds/<platform>-<arch>/`**, not just `build/Release/`, which does not exist on macOS. Repair is a chmod, never a mandatory rebuild (that would require Xcode CLI tools and deletes `prebuilds/` before compiling): `npm run fix:node-pty` chmods every helper then proves it by really opening a PTY. `spawnPtyWithHelperRepair()` (`utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts` and self-heals a broken install on the first failure. → [architecture-invariants#node-ptys-macos-spawn-helper-must-be-executable](docs/architecture-invariants.md#node-ptys-macos-spawn-helper-must-be-executable)
- **node-pty's macOS `spawn-helper` ships without `+x`** (issues #6, #204): `node-pty@1.1.0` publishes `prebuilds/darwin-<arch>/spawn-helper` as mode 0644, and macOS launches every PTY through it, so a stock macOS install fails every session start with `Error: posix_spawnp failed.` **Linux can never reproduce it**: `spawn-helper` is an `OS=="mac"` gyp target and node-pty ships no Linux prebuild, so node-gyp always emits an executable helper there. ⚠️ The flip side of that: since Linux has no prebuild, `npm install` **needs a C/C++ toolchain there** (`make`, `g++`, `python3`), so `install.sh` checks for and installs one alongside Node/tmux/git — a stock Ubuntu 24 server has none and died inside node-gyp with `not found: make`. Do not drop that step. (The installer's no-admin route cannot install one, so there it stops before downloading anything and names what is missing.) ⚠️ Look in **`prebuilds/<platform>-<arch>/`**, not just `build/Release/`, which does not exist on macOS. Repair is a chmod, never a mandatory rebuild (that would require Xcode CLI tools and deletes `prebuilds/` before compiling): `npm run fix:node-pty` chmods every helper then proves it by really opening a PTY. `spawnPtyWithHelperRepair()` (`utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts` and self-heals a broken install on the first failure. → [architecture-invariants#node-ptys-macos-spawn-helper-must-be-executable](docs/architecture-invariants.md#node-ptys-macos-spawn-helper-must-be-executable)
- **User-chosen paths are probed BOUNDED, never with `existsSync`/`statSync`** (#516): a linked case or a `workingDir` can sit on a network mount that stopped answering. A synchronous check there freezes the whole server, and an unbounded async `stat`/`lstat`/`readFile` holds one of libuv's threadpool workers (4 by default, shared with every `fs`, `dns.lookup` and `crypto` call) until the mount returns. On a request or spawn path use `probePath()`/`probePathKind()` (`utils/bounded-path-probe.ts`, read its `@fileoverview`). ⚠️ `unknown` is NEVER `absent`: nothing is created, scaffolded or 404'd on it. Bulk scans keep the stall cap; a request for ONE path the user named may pass `{ pastCap: true }`, which still stops at the ceiling that keeps one worker free; and a helper that would otherwise touch the path skips whatever is still `unknown`. User-facing errors for it go through `describeUnknownPath()`, so a refused probe is not reported as a broken folder.
- **Headless screenshots: `deviceScaleFactor` MUST be 1, and write unique filenames** — under DSF=2 xterm's WebGL renderer draws glyphs at ~2× nominal size while still *reporting* nominal cell dims, so only the pixels reveal it and only the terminal font looks wrong. And overwriting a fixed output path leaves OS image viewers showing the old render, which reads as "the fix didn't work"; `scripts/capture-real-overview.mjs` mints a timestamped filename per run. Seed the per-device `localStorage` keys (`codeman:skin`, `codeman-font-size`, `codeman-app-settings`) so the capture matches a real device. → [architecture-invariants#headless-screenshot-capture](docs/architecture-invariants.md#headless-screenshot-capture)
@@ -220,7 +220,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode 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 `<base href>` + injecting `window.__CODEMAN_BASE__`, and `CodemanBase.url()` (constants.js) for runtime URLs. ⚠️ Keep template asset refs RELATIVE, and route every root-absolute frontend URL (EventSource/WebSocket/`window.open`/src) through `CodemanBase.url()`. ⚠️ Web-tab proxy egress goes through `proxyPrefixFor(cap, basePath)`; ingress parsers stay base-agnostic. ⚠️ `--base-url` must ride `buildWebArgs` and `resolveServicePlan`. Tests: `test/base-path.test.ts`. → [architecture-invariants#reverse-proxy-base-path](docs/architecture-invariants.md#reverse-proxy-base-path)
@@ -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/<id>.json` (Edit history panel, one store per session, every diff an agent wrote, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `discord.json` (Discord bridge: bot token, a bearer secret, so mode 0600, never returned by `/api/discord`, admin-only in multi-user mode) + `discord-bridge.json` (sessionId to Discord thread id), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `edit-history/<id>.json` (Edit history panel, one store per session, every diff an agent wrote, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `discord.json` (Discord bridge: bot token, a bearer secret, so mode 0600, never returned by `/api/discord`, admin-only in multi-user mode) + `discord-bridge.json` (sessionId to Discord thread id), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back), `cli-installed` (empty, touched by `install.sh` when it installs a CLI: a running server reads only its mtime, and a change ends that CLI's negative-cache backoff) and `runtime/` (the Node.js and tmux `install.sh` downloaded on its no-admin route, linked from `~/.local/bin`; all four installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
**Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored.
@@ -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`). ⚠️ Codeman Desktop drives it with no terminal (`--no-admin --progress --ai-cli <id>|none`, `install.sh preflight`, `install.sh agent <id>` to add one agent next to a running Codeman, `packages/desktop/src/gui_install.rs`): `cli_offerable` is the one rule for what can be installed, `preflight` stdout is JSON only, `agent` says `done` only when THAT agent landed, and the `progress_phase` order is pinned against `connect.html`, with `done` last. → [architecture-invariants#installsh-the-public-installer](docs/architecture-invariants.md#installsh-the-public-installer)
Other key scripts: `scripts/tmux-manager.sh` (safe tmux mgmt), `scripts/tunnel.sh [quick|named] start|stop|status|url` (quick = random trycloudflare URL, default; `named setup|enable` = fixed-hostname tunnel via `scripts/codeman-tunnel-named.service`; bare `start|stop|url` still means quick), `scripts/run-beta.sh` (isolated beta instance), `scripts/build-agent-image.mjs` (docker base image), `scripts/self-update.sh` (detached updater). Production services: `scripts/codeman-web.service`, `scripts/codeman-tunnel.service`. **Always set `CODEMAN_PASSWORD`** before exposing via tunnel.
+1
View File
@@ -69,6 +69,7 @@ This installs Node.js, tmux and a build toolchain if missing (node-pty ships no
- **How it's reachable, your choice.** **Tailscale** (loopback bind fronted by `tailscale serve`, so you get `https://<machine>.<tailnet>.ts.net` with a real certificate and your tailnet as the login, no password needed), **any device on your network** (`0.0.0.0`, with a strongly recommended password prompt), or **this machine only** (`127.0.0.1`, safest). Skipping the password on a network bind requires an explicit confirmation and ends with a loud warning. The highlighted default reflects what is already on the machine (Tailscale when it is already connected, your existing binding on a re-run), and a bare Enter never pulls in new software. If another app already owns `:443` on your node, Codeman goes under `https://<machine>.<tailnet>.ts.net/codeman` or on a second port instead of replacing it. A bare `codeman web` started by hand still defaults to loopback.
- **The name is yours to choose.** By default the URL uses the machine's existing tailnet name. Answering yes to the second question renames the machine to `codeman-<hostname>` (which also renames it for SSH, so the default is no); `install.sh name` does it later.
- **Re-run to update.** The same one-liner updates a finished install in place: local changes in `~/.codeman/app` are stashed (never discarded), and a running service is restarted and verified. If a first install was interrupted, re-running resumes the full setup instead. `install.sh status` prints the URLs and the QR code again; `install.sh update`, `install.sh tailscale` and `install.sh uninstall` also exist.
- **No administrator rights needed.** On an account without `sudo` (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 <n>`, `--port <n>`, `--yes` too). **CI / headless:** without a terminal attached, steps that would change your system abort with instructions instead of running silently; set `CODEMAN_NONINTERACTIVE=1` to approve them for automation.
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), [OMP](https://github.com/can1357/oh-my-pi), or [GitHub Copilot CLI](https://github.com/github/copilot-cli) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the ten is present; if none is found, it offers to install any of them from a menu (DeepSeek excepted, since its npm package installs only a launcher with no runnable profile), or you can skip and install one yourself later. After install:
+10
View File
@@ -1297,6 +1297,16 @@ 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<major>.x/SHASUMS256.txt` names the tarball AND carries its checksum, one fetch, no JSON parsing) into `~/.codeman/runtime/node`, and tmux from the tmux project's `tmux-builds` release into `~/.codeman/runtime/bin`. ⚠️ `tmux-builds` publishes no checksum file, so `tmux_build_sha256` pins all four digests of `TMUX_BUILD_VERSION`; bump the version and the digests together (the GitHub release API returns them as `digest`). ⚠️ A download is verified BEFORE it is unpacked or run, and an empty expected digest never passes. `node`, `npm`, `npx` and `tmux` are linked from `~/.local/bin` (`link_into_local_bin` refuses to replace a file that does not already point into the runtime, and uninstall removes only such links): the `codeman` launcher is `#!/usr/bin/env node`, and a non-interactive shell never reads the PATH line written to an interactive profile. The runtime's `node/bin` also goes on PATH and on the LaunchAgent's `svc_path`, since `npm install -g` puts AI CLIs beside that Node.js.
⚠️ `no_admin_blocker` runs before the first download and stops for what this route cannot supply: a C/C++ toolchain on Linux (node-pty 1.1.0 compiles there; a node-pty with Linux prebuilds is what would lift this, and it is a dependency decision, not an installer one), musl, a platform nodejs.org or tmux-builds has no build for, and macOS below 15 (the tmux build's deployment target). ⚠️ Nothing between the "No-admin route" header and "End of the no-admin route" may run `sudo`, `ensure_sudo`, `run_as_root`, Homebrew or `xcode-select --install`; `run_as_root` itself refuses while `NO_ADMIN=1`, so a missed call site fails instead of prompting.
**Driving the installer from a program** (`--ai-cli`, `--progress`, `install.sh preflight`, 2026-11-06). Codeman Desktop installs with no terminal: it runs `install.sh --no-admin --service --progress --ai-cli <id>|none` with `CODEMAN_NONINTERACTIVE=1` and its stdin closed, and asks the two questions in its own window (`packages/desktop/src/gui_install.rs`). `--ai-cli` answers the agent menu, so the one thing `CODEMAN_NONINTERACTIVE` used to decide by itself (the first entry of the catalogue) is the user's. ⚠️ `cli_offerable` is the ONE rule for what can be installed here: the menu, `--ai-cli` and `preflight`'s `installable` all read it, so the app's list and the menu cannot disagree. A name that is not offerable here warns and carries on, and never falls back to another CLI; a typo dies in `parse_flags` before anything runs. `install.sh agent <id>` installs one CLI and nothing else, for Codeman Desktop's *Add a coding agent*: no service, no source, no build, so it may run next to a live server, and an agent already there is a success that runs nothing. ⚠️ It calls `run_offered_cli`, checks `CLI_FOUND_PATH` of that one entry itself and only then prints `done`: `install_offered_cli`'s gate is fatal only when NO CLI is left, which is right for a first install and would report success here, where another CLI is usually present. ⚠️ The app passes `--no-admin` to both `preflight` and `agent`, because that flag decides what `cli_offerable` allows; pinned in `gui_install.rs`. ⚠️ The app runs it from a folder of its own (`Run::Agent`), so `ps` never reads an agent run as "Codeman is being installed". A running server remembers a missing CLI for up to 5 minutes (`cliResolveRetryDelayMs`), and Run reads that memory, so `mark_cli_installed` touches `cli-installed` in the data dir and `resolve()` leaves the backoff when that file's mtime changes. ⚠️ That check is one `stat` inside the backoff branch and buys one run of the chain. Never replace it with a re-walk of PATH during the backoff: that puts a synchronous stat of every PATH entry on the request path (#516) and executes candidates already refused. The app never restarts the service for this. Only `install.sh` writes the marker, always under `~/.codeman`, so a CLI installed by hand, or another `CODEMAN_INSTANCE`, waits out the backoff as before, and so does a server older than this. ⚠️ `preflight` prints only JSON on stdout and changes nothing; `schema` moves only when a field changes meaning, because the app ships on its own and refuses a number it does not know. Fields may be added freely. ⚠️ `progress_phase` writes to stderr, not stdout, because a function can run inside a `$( )` that captures stdout. The phases must stay in the order of `PHASES` in `connect.html` (pinned by test), `done` must stay the last thing `main()` reports, since a run that ends without it failed, and an unknown phase is ignored by the app, so a new phase needs no new app. ⚠️ `error()` carries the text as `@@codeman error`, and the app shows the FIRST one: the ones after it are consequences (`Installation failed. Partial...`).
⚠️ **git is judged by whether it works, never by `command -v`.** On a Mac without the Xcode Command Line Tools `/usr/bin/git` is a stub that opens Apple's install dialog, so `check_git` judges that path by `xcode-select -p` and never executes it; any other git is asked for `--version`. `scripts/git-hooks.mjs` has the matching guard (no `.git`, no `git` call), because postinstall runs it on every `npm install`. Without a working git the source is an archive (`fetch_source_archive`, GitHub's `archive/<ref>.tar.gz` or `CODEMAN_TARBALL_URL`) and the install directory carries the marker `.install-source`, which `detectInstallKind` reads as `archive` (the name is pinned across both files by `test/install-sh-invariants.test.ts`). ⚠️ An archive install is replaced as a WHOLE, never patched in place: nothing says which files a newer version dropped, and a stale `src/*.ts` would be compiled. The new tree is fetched and built in `<install dir>.new` and `finish_source_swap` moves it into place only after `npm run build` succeeded, so a failed update leaves the running install untouched; a re-run on a machine that has gained a working git stages a clone instead, which turns the install back into a git one. ⚠️ `download`/`download_to_stdout` resolve `DOWNLOADER` themselves (`require_downloader`): only `main()` used to set it, so a subcommand that downloads died on `set -u` with the message swallowed by the step log.
## Agent inspection and guarded submission
These three operations are experimental across both CLI and HTTP, including the
+59 -2
View File
@@ -78,6 +78,8 @@ install.sh uninstall # remove (offers to undo a rename it performed)
install.sh tailscale # retrofit Tailscale access onto an existing install
install.sh name [<n>] # rename this machine on your tailnet (default codeman-<hostname>)
install.sh cloudflared # install cloudflared for the in-app Cloudflare tunnel
install.sh preflight # print what is on this machine as JSON; changes nothing
install.sh agent <id> # install one agent CLI and nothing else, also next to a running Codeman
```
**Flags** answer the questions from the command line and pipe through `bash -s --`:
@@ -94,14 +96,65 @@ the name, `--service` / `--run` / `--no-start` the last one. `--yes` takes every
`--port <n>` moves Codeman off 3000; the service file and the serve mapping follow it. On an
existing install, `--port` and `--password` re-run the setup so the service file picks them up,
and a re-run with `--lan` or `--tailscale` keeps the password the service already has.
`--ai-cli <id>` answers the agent menu that appears when no agent CLI is found, with no
terminal needed (`--ai-cli claude`, `--ai-cli codex`, ...), and `--ai-cli none` skips it.
To add an agent later, `install.sh agent codex` installs that one and changes nothing else:
it does not touch Codeman or its service, and an agent that is already there is left alone.
Codeman Desktop does the same from *Add a coding agent*. A running Codeman notices the new
agent at once; reload an open page to see it.
**Automation and CI**: with no terminal attached, any step that would change the system
aborts with instructions instead of running silently. Set `CODEMAN_NONINTERACTIVE=1` to
approve those steps. `CODEMAN_TAILSCALE=1` preselects the Tailscale answer, and never
approve those steps. With nothing else said, that also installs the first agent CLI of the
catalogue when none is found; `--ai-cli` (or `CODEMAN_AI_CLI`) says which one, or none.
`CODEMAN_TAILSCALE=1` preselects the Tailscale answer, and never
installs Tailscale itself non-interactively; a non-interactive run never renames the
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 +247,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 +271,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 +287,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.
+1100 -77
View File
File diff suppressed because it is too large. Load diff
+10 -28
View File
@@ -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": {
+1 -1
View File
@@ -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",
+80 -14
View File
@@ -28,7 +28,7 @@ cannot do.
| | |
| --- | --- |
| **This Mac** | The connect screen's first tab looks for Codeman on this Mac and offers what fits: *Open Codeman* when it runs, *Start Codeman* when it is installed but stopped, *Install Codeman on this Mac* when it is not there. The address comes from Codeman's LaunchAgent (port, sub-path, HTTPS). When nothing answers there but one of your own Codeman servers runs (found with `ps` and `lsof`: a `codeman web --port 4000` in a terminal, say), the tab opens that one, or says where it listens; it never offers to start a second server, which would take over the first one's sessions, and Start refuses while one runs. Start asks launchd to run the LaunchAgent (`kickstart`, or `bootstrap` when it is not loaded), so the server keeps its own settings and supervision; without a LaunchAgent it runs `codeman web -d`. The login comes from where Codeman keeps it: the LaunchAgent's `CODEMAN_USERNAME`/`CODEMAN_PASSWORD`, else `~/.codeman/.env` (what Codeman's command-line clients read), `admin` by default. It goes only to `127.0.0.1` on that Codeman's port, only while one of your own Codeman processes holds that port, is never saved to the Keychain, and never reaches the page. Whether Codeman is installed is looked up in its files and processes, then the login shell; the interactive profile (nvm in `.zshrc`, say) only on *Check again*. Install opens Terminal with Codeman's installer (`install.sh --local --service --port N`): it may ask for the Mac password (Homebrew, Node.js, tmux) and which AI coding tools to set up, then Codeman runs as a LaunchAgent, so it keeps running when the app quits and starts at login. *Also reach it from my phone and other computers* drops `--local`, and the installer asks how (Tailscale, or the local network with a password). Port 3000 is used unless something else answers there, then the next free one. While the installer runs, the app looks every 3 seconds and opens Codeman as soon as it answers; it stops looking after the first answer, after 30 minutes, or 90 seconds after a finished install whose server never answered (then it offers Start). An install still running in Terminal is recognized by its process, so the tab waits for it instead of offering Install twice. |
| **This Mac** | The connect screen's first tab looks for Codeman on this Mac and offers what fits: *Open Codeman* when it runs, *Start Codeman* when it is installed but stopped, *Install Codeman on this Mac* when it is not there. The address comes from Codeman's LaunchAgent (port, sub-path, HTTPS). When nothing answers there but one of your own Codeman servers runs (found with `ps` and `lsof`: a `codeman web --port 4000` in a terminal, say), the tab opens that one, or says where it listens; it never offers to start a second server, which would take over the first one's sessions, and Start refuses while one runs. Start asks launchd to run the LaunchAgent (`kickstart`, or `bootstrap` when it is not loaded), so the server keeps its own settings and supervision; without a LaunchAgent it runs `codeman web -d`. The login comes from where Codeman keeps it: the LaunchAgent's `CODEMAN_USERNAME`/`CODEMAN_PASSWORD`, else `~/.codeman/.env` (what Codeman's command-line clients read), `admin` by default. It goes only to `127.0.0.1` on that Codeman's port, only while one of your own Codeman processes holds that port, is never saved to the Keychain, and never reaches the page. Whether Codeman is installed is looked up in its files and processes, then the login shell; the interactive profile (nvm in `.zshrc`, say) only on *Check again*. Install sets Codeman up from the app itself, with no Terminal and no password (see Decisions). It fetches the installer, asks it what is on this Mac, then asks two things: which coding agent to install, only when this Mac has none (the list comes from the installer, and *Skip for now* is there), and who can reach Codeman (this Mac only, or the local network with a password). A checklist follows the install, the installer's output is one click away, and Cancel stops it; installing again picks up where it stopped. Codeman runs as a LaunchAgent, so it keeps running when the app quits and starts at login. Port 3000 is used unless something else answers there, then the next free one. Closing the app does not stop an install: it runs on its own, and the app picks it up when it opens again. When it is done the app looks every 3 seconds and opens Codeman as soon as it answers; it stops looking after the first answer, after 30 minutes, or 90 seconds after a finished install whose server never answered (then it offers Start). *Install in Terminal instead* runs the installer in a Terminal window, for what the app cannot do (see Known limits): it may ask for the Mac password (Homebrew) and asks its own questions there. The app offers it by itself on a Mac where the graphical install cannot finish. An install still running in Terminal is recognized by its process, so the tab waits for it instead of offering Install twice. *Add a coding agent*, on this tab and in the app menu whenever Codeman is on this Mac, is the same chooser after the install: pick one agent, watch it install, open Codeman. It needs no password, leaves a running Codeman alone, and can be cancelled or left running like an install. After *Skip for now* the app says Codeman has no agent yet and offers this before it opens Codeman. |
| **Another machine** | The second tab connects to a server by address (below). *Codeman is not installed there yet?* opens Terminal, connects with `ssh -t` and runs Codeman's installer on that machine, which asks its questions there; the app then suggests `http://<host>:3000` and the installer prints the real address at the end. The SSH login is checked to the characters of a user and host name (no option can be slipped in) and may carry a `:port`. The remote command runs under `bash -o pipefail`, so a failed download fails the install instead of reporting success. The same tab shows the one-line install command, with a Copy button, to run on a machine by hand. |
| **Connect screen** | Server address, recent servers, a login for password-protected servers (password kept in the macOS Keychain), auto-connect at launch. It explains each failure: nothing listening; wrong password; too many failed logins (the password field stays open, since a single-user Codeman accepts the right password even then); unknown host name; an address this Mac cannot reach (with a pointer to the Local Network setting); an `http://` address on a port that only speaks HTTPS; an untrusted certificate; a redirect, with a button for the suggested `https://` address; not a Codeman server. A multi-user account that must change its password connects, and Codeman's own dialog asks for the new one. If the server page has not started showing within 15 seconds, or you press Cancel while it opens, the connect screen comes back with a notice. On a Mac whose WebKit is too old for Codeman it says so and Connect stays off (see Known limits). |
| **Start Codeman** | On the This Mac tab, and when `http://127.0.0.1:<port>` or `http://localhost:<port>` answers nothing, one click runs `codeman web -d` (or `~/.codeman/app`) with your login shell's environment, so the server gets your real PATH (Homebrew, nvm) for the agents it spawns. zsh, bash and other POSIX shells run the start themselves; fish, nu, xonsh, tcsh and other shells only report their environment, and the start runs under `/bin/sh` with it. Offered for those two addresses only: `codeman web -d` listens on 127.0.0.1. |
@@ -54,6 +54,11 @@ cargo test # unit tests
open dist/Codeman.app
```
To try a change to `install.sh` before it is merged, build with
`CODEMAN_DESKTOP_INSTALLER_URL` (where to get the installer) and
`CODEMAN_DESKTOP_INSTALL_BRANCH` (the branch it installs from) set. Both are
read at compile time: a shipped app cannot be pointed at another installer.
The script builds with `--locked` against the committed `Cargo.lock`, targets
the macOS version in `macos/Info.plist` (`LSMinimumSystemVersion`, 11.3), and
takes the binary from where cargo says it wrote it, so `CARGO_TARGET_DIR` and a
@@ -133,19 +138,52 @@ classes of wry's own delegates, so wry stays an unmodified crates.io dependency.
- `codeman web -d` gets the port and base path as separate arguments, never as
shell text.
**The installer runs in Terminal, not inside the app.** `install.sh` asks for
the administrator password on a terminal (Homebrew, its sudo keep-alive), the
Xcode tools arrive as a system dialog, and ssh asks about host keys and
passwords on a terminal too, so an install without one fails on the first
fresh Mac. The app writes a short script to the per-user temporary folder
(mode 0700, created fresh, deleted by the script as it starts) and opens it
with `open -b com.apple.Terminal`, which needs no permission; scripting
Terminal with osascript would ask for Automation access. The script prints
what it runs. A This Mac install downloads the installer to a temporary file
and runs it from there, so the installer's input is the terminal. Everything in
the scripts is built and unit-tested in `src/setup.rs`; the page sends only
which install it wants, whether other devices should reach it, and the SSH
login, which is checked before it is quoted.
**The app runs the installer itself; Terminal is the fallback.** `install.sh
--no-admin` puts Node.js and tmux in the home folder, so a This Mac install needs
no password and therefore no terminal, and the two questions only a person can
answer arrive as flags (`src/gui_install.rs`). The app downloads the installer
once to a private folder, asks it what is on this Mac (`install.sh preflight`,
which prints JSON and changes nothing) and draws its choices from that answer.
So the app has no list of coding agents of its own: a new one appears without a
new app, and the list always matches what the installer can really install
here. The installer runs with the login shell's environment, so it finds what
this person already has. The page sends only the agent's id, whether other
devices may reach Codeman, and that password; the password reaches the
installer through its environment, never its command line, where `ps` shows it.
**The install outlives the app.** Its output goes to a file, never a pipe, and
it runs in a process group of its own. With a pipe, quitting the app would kill
the installer at its next write; in the app's group, launchd would kill it with
the app. The file holds the whole run, so a page that was reloaded, or an app
that was opened again, finds the process in `ps` and replays the file from the
first line. That is why the app keeps no state about a run. A run that ends
without the installer's last `done` line failed, and the first error it reported
is the one shown: the ones after it are consequences.
**Adding an agent is a run of its own.** `install.sh agent <id>` installs one
agent and touches neither Codeman nor its service, so it may run next to a live
server, which an install must refuse: the installer's service step would replace
that server under its sessions. It has its own temporary folder, so `ps` tells the
two apart: everything that reads a running install takes it to mean "Codeman is
being installed", which an agent run does not. One run goes at a time, since both
can write to the runtime folder. It asks `preflight` for the list like an install
does, and both pass `--no-admin`: that flag decides which agents count as
installable, so without it on both the page could offer one the run refuses. The
menu item is on the connect page because that is where the app may run things:
from a Codeman page it leaves the page the way *Switch Server* does.
**Terminal stays for what the app cannot do.** The route with Homebrew asks for
the administrator password on a terminal, the Xcode tools arrive as a system
dialog, and ssh asks about host keys and passwords on a terminal too. The app
writes a short script to the per-user temporary folder (mode 0700, created
fresh, deleted by the script as it starts) and opens it with `open -b
com.apple.Terminal`, which needs no permission; scripting Terminal with
osascript would ask for Automation access. The script prints what it runs. A
This Mac install downloads the installer to a temporary file and runs it from
there, so the installer's input is the terminal. Everything in the scripts is
built and unit-tested in `src/setup.rs`; the page sends only which install it
wants, whether other devices should reach it, and the SSH login, which is
checked before it is quoted.
**This Mac's own login comes from where Codeman keeps it.** A Codeman
installed with a password keeps it in its LaunchAgent (`install.sh`) or in
@@ -182,6 +220,20 @@ and the Dock icon bounces.
## Known limits
- **A new agent can take a few minutes to show up in an older Codeman.** The server
remembers that a CLI is missing, for up to 5 minutes after it last looked
(`cliResolveRetryDelayMs`), so that a status poll never spawns a login shell. The
installer's `cli-installed` marker ends that at once, but only for a server that
knows the marker, so the screen after *Add a coding agent* still mentions it. An
open Codeman page always needs a reload, because the list of installed agents is
part of the page.
- **Agents the app can add are the ones `--no-admin` can install.** OMP's macOS
install is Homebrew, so it is not offered, also on a Mac that has Homebrew.
- **Agents that install with npm use whichever Node.js the login shell finds.**
With the Node.js the installer downloaded, or Homebrew's, that folder is yours.
The nodejs.org installer leaves `/usr/local/lib/node_modules` to root, so those
agents would be expected to fail there, with npm's permission error shown, while
the ones with their own installer still work. This has not been tried.
- **Self-signed HTTPS is refused.** A server started with `codeman web --https`
uses a self-signed certificate, and WKWebView rejects it (wry has no trust
override). Use `http://127.0.0.1:3000` for a server on this Mac. For another
@@ -249,6 +301,20 @@ and the Dock icon bounces.
- **No desktop notifications yet.** WKWebView has no Web Notification API.
Native notifications are the first item on the list below.
- **No service worker.** Codeman works without it; it is only an offline cache.
- **The graphical install needs macOS 15, or a tmux that is already there.**
tmux comes from the tmux project's own builds, which need macOS 15. On an
older Mac without tmux the app says so and offers Terminal, where Homebrew
installs it (that takes an administrator once).
- **Who can reach it stops at the local network.** Tailscale needs its own
login and setup, which the installer does at a terminal. Choose *Install in
Terminal instead*, or run `install.sh tailscale` later.
- **The agent is chosen during the install.** With Codeman already installed,
or after *Skip for now*, an agent is added by hand: its commands are in the
installer's closing notes and in [Installing an agent CLI](../../docs/wiki/Installation.md#installing-an-agent-cli).
- **An agent only your interactive profile finds counts as missing.** The app
reads the login shell's environment, without `.zshrc`, so a CLI on an nvm
PATH set up there is offered again as if it were not installed. Installing
it again is harmless; *Skip for now* leaves it as it is.
- **Installing from the app is macOS only so far**, and installing on another
machine needs ssh access to it and `curl` or `wget` there. The app does not
know when a remote install is done or which address it chose: the installer
+713 -18
View File
@@ -25,10 +25,12 @@
background: radial-gradient(1200px 600px at 50% -10%, #111a2b 0%, var(--bg) 60%) fixed, var(--bg);
color: var(--text);
font: 14px/1.45 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif;
display: flex; align-items: center; justify-content: center;
display: flex; justify-content: center;
-webkit-user-select: none; user-select: none;
}
main { width: min(440px, calc(100vw - 48px)); }
/* margin:auto, not align-items:center: a flex child taller than the window
then starts at the top instead of being cut off above it. */
main { width: min(440px, calc(100vw - 48px)); margin: auto; padding: 24px 0; }
header { text-align: center; margin-bottom: 26px; }
header img { width: 76px; height: 76px; display: block; margin: 0 auto 12px; }
h1 { font-size: 24px; font-weight: 650; letter-spacing: -0.01em; margin: 0; }
@@ -102,7 +104,6 @@
@media (prefers-reduced-motion: reduce) { .dot.busy { animation: none; } }
#local-title { font-weight: 600; }
.sub { color: var(--muted); font-size: 12px; margin-top: 3px; -webkit-user-select: text; user-select: text; overflow-wrap: anywhere; }
#local-network-row[hidden] { display: none; }
#elsewhere { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--line); }
#elsewhere summary { color: var(--muted); font-size: 13px; cursor: default; list-style: none; }
#elsewhere summary::-webkit-details-marker { display: none; }
@@ -118,6 +119,82 @@
.statusrow { display: flex; gap: 10px; align-items: flex-start; }
.statusrow #status { flex: 1; }
.statusrow #cancel { margin-top: 10px; }
/* The graphical install takes over the card, and its steps differ in height:
anchored from the top, so the logo does not jump from one to the next. */
main:has(.card.wizard-on) { margin-top: max(0px, calc(14vh - 60px)); margin-bottom: auto; }
main:has(.card.wizard-on) header p { display: none; }
.card.wizard-on > .tabs, .card.wizard-on > #pane-local, .card.wizard-on > #form,
.card.wizard-on > .statusrow, .card.wizard-on > #suggest, .card.wizard-on > .prefs { display: none; }
#wizard[hidden], #wizard [hidden] { display: none; }
.wiz-head { display: flex; align-items: center; gap: 9px; }
.wiz-head .spinner { margin-top: 0; }
#wiz-title { font-size: 17px; font-weight: 650; letter-spacing: -0.01em; margin: 0; }
#wiz-title:focus { outline: none; }
#wiz-lead { margin: 6px 0 0; color: var(--muted); font-size: 13px; }
#wiz-message {
margin: 12px 0 0; padding: 10px 12px; border: 1px solid rgba(248,113,113,0.4); border-radius: 9px;
background: rgba(248,113,113,0.08); color: var(--bad); font-size: 13px; white-space: pre-wrap;
overflow-wrap: anywhere; -webkit-user-select: text; user-select: text;
}
#wiz-message.warn { border-color: rgba(251,191,36,0.4); background: rgba(251,191,36,0.08); color: var(--warn); }
.wiz-group { margin-top: 14px; }
#wiz-agents { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.choice {
display: flex; gap: 9px; align-items: flex-start; padding: 9px 11px; border-radius: 9px;
border: 1px solid var(--line-strong); background: #0f1013; cursor: default;
}
.choice:hover { border-color: #454a55; }
.choice:has(input:checked) { border-color: var(--accent-2); background: rgba(59,130,246,0.1); }
.choice:has(input:focus-visible) { outline: 2px solid var(--accent-2); outline-offset: 1px; }
.choice input { accent-color: var(--accent-2); margin: 2px 0 0; flex: none; }
.choice b { display: block; font-weight: 600; }
.choice small { display: block; margin-top: 1px; color: var(--muted); font-size: 12px; }
.choice:has(input:disabled) { border-color: var(--line); background: none; color: var(--muted); }
#wiz-reach .choice + .choice { margin-top: 6px; }
#wiz-password { margin-top: 10px; }
#wiz-agent-note { margin: 10px 0 0; color: var(--muted); font-size: 12px; }
#wiz-agent-note a, .wiz-link { all: unset; color: #7c8491; cursor: default; }
#wiz-agent-note a { color: var(--accent); white-space: nowrap; }
#wiz-agent-note a:hover, .wiz-link:hover { color: var(--text); }
.wiz-link:focus-visible, #wiz-agent-note a:focus-visible { outline: 2px solid var(--accent-2); outline-offset: 2px; border-radius: 3px; }
.wiz-link { display: block; margin: 12px auto 0; font-size: 12px; text-align: center; }
#wiz-summary { margin: 14px 0 0; padding: 12px 0 0; border-top: 1px solid var(--line); list-style: none; color: var(--muted); font-size: 12px; }
#wiz-summary li { padding: 2px 0 2px 14px; position: relative; }
#wiz-summary li::before { content: ''; position: absolute; left: 2px; top: 9px; width: 4px; height: 4px; border-radius: 50%; background: #5d626b; }
#wiz-summary li.warn { color: var(--warn); }
/* The install's rail: one node per phase, joined by a line that fills in as
the phases finish. The running node carries the phase's own clock, because
the longest phases print nothing while they work. */
#wiz-rail { margin: 16px 0 0; padding: 0; list-style: none; }
#wiz-rail li { position: relative; display: flex; align-items: baseline; gap: 10px; padding: 0 0 13px 24px; color: #6b717c; }
#wiz-rail li:last-child { padding-bottom: 0; }
#wiz-rail li::before {
content: ''; position: absolute; left: 4px; top: 6px; width: 8px; height: 8px; box-sizing: content-box;
border-radius: 50%; background: var(--panel); border: 1px solid #454a55; z-index: 1;
}
#wiz-rail li::after { content: ''; position: absolute; left: 8.5px; top: 12px; bottom: -6px; width: 1px; background: var(--line-strong); }
#wiz-rail li:last-child::after { display: none; }
#wiz-rail li.done { color: var(--text); }
#wiz-rail li.done::before { background: var(--ok); border-color: var(--ok); }
#wiz-rail li.done::after { background: rgba(74,222,128,0.45); }
#wiz-rail li.now { color: var(--text); font-weight: 600; }
#wiz-rail li.now::before { background: var(--accent); border-color: var(--accent); box-shadow: 0 0 0 4px rgba(96,165,250,0.18); animation: pulse 1.2s ease-in-out infinite; }
#wiz-rail li.failed { color: var(--bad); font-weight: 600; }
#wiz-rail li.failed::before { background: var(--bad); border-color: var(--bad); }
@media (prefers-reduced-motion: reduce) { #wiz-rail li.now::before { animation: none; } }
#wiz-rail .clock { margin-left: auto; color: var(--muted); font-size: 12px; font-weight: 400; font-variant-numeric: tabular-nums; }
#wiz-details { margin-top: 14px; }
#wiz-details summary { color: var(--muted); font-size: 12px; cursor: default; list-style: none; }
#wiz-details summary::-webkit-details-marker { display: none; }
#wiz-details summary::before { content: '\25B8'; display: inline-block; width: 13px; transition: transform 0.15s; }
#wiz-details[open] summary::before { transform: rotate(90deg); }
#wiz-details summary:hover { color: var(--text); }
#wiz-log {
margin: 8px 0 0; height: 124px; overflow: auto; padding: 8px 10px; border-radius: 8px; background: #0c0d10;
border: 1px solid var(--line); color: #aab1bb; font: 11px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace;
white-space: pre-wrap; overflow-wrap: anywhere; -webkit-user-select: text; user-select: text;
}
#wizard .actions .wiz-busy { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-size: 13px; }
footer { margin-top: 18px; text-align: center; color: #5d626b; font-size: 12px; }
footer a { color: #7c8491; text-decoration: none; cursor: default; }
footer a:hover { color: var(--accent); }
@@ -138,6 +215,45 @@
<button type="button" role="tab" id="tab-remote" aria-controls="form" aria-selected="false" tabindex="-1">Another machine</button>
</div>
<section id="wizard" aria-labelledby="wiz-title" hidden>
<div class="wiz-head"><span class="spinner" id="wiz-spinner" hidden></span><h2 id="wiz-title" tabindex="-1"></h2></div>
<p id="wiz-lead"></p>
<div id="wiz-message" role="alert" hidden></div>
<div class="wiz-group" id="wiz-agent-step" hidden>
<div id="wiz-agents" role="radiogroup" aria-labelledby="wiz-title"></div>
<p id="wiz-agent-note"></p>
</div>
<div class="wiz-group" id="wiz-reach-step" hidden>
<div id="wiz-reach" role="radiogroup" aria-labelledby="wiz-title">
<label class="choice"><input type="radio" name="reach" value="local" checked>
<span><b>Only this Mac</b><small>The safest choice. You can open it to other devices later.</small></span></label>
<label class="choice"><input type="radio" name="reach" value="network">
<span><b>This Mac and my local network</b><small>Phones and laptops on the same Wi-Fi sign in with a password.</small></span></label>
</div>
<div id="wiz-password" class="row" hidden>
<div><label class="field" for="wiz-pw">Password</label><input id="wiz-pw" type="password" autocomplete="new-password"></div>
<div><label class="field" for="wiz-pw2">Repeat it</label><input id="wiz-pw2" type="password" autocomplete="new-password"></div>
</div>
<ul id="wiz-summary"></ul>
</div>
<ol id="wiz-rail" hidden></ol>
<details id="wiz-details" hidden>
<summary>Show the installer’s output</summary>
<pre id="wiz-log"></pre>
</details>
<div class="actions">
<button class="secondary" id="wiz-back" type="button" hidden>Back</button>
<button class="secondary" id="wiz-skip" type="button" hidden>Skip for now</button>
<button class="primary" id="wiz-next" type="button" hidden></button>
<button class="secondary" id="wiz-cancel" type="button" hidden>Cancel install</button>
</div>
<button class="wiz-link" id="wiz-terminal" type="button" hidden>Install in Terminal instead (asks for your Mac password)</button>
</section>
<p id="webkit" role="alert" hidden>Codeman needs a newer WebKit than this Mac has: macOS 13.3 or later, or Safari 16.4 or later on macOS 11 and 12.</p>
<section id="pane-local" role="tabpanel" aria-labelledby="tab-local">
@@ -148,7 +264,6 @@
<div class="sub" id="local-detail"></div>
</div>
</div>
<label class="check" id="local-network-row" hidden><input id="local-network" type="checkbox"> Also reach it from my phone and other computers (the installer asks how)</label>
<div class="actions">
<button class="primary" id="local-open" type="button" hidden>Open Codeman</button>
<button class="primary" id="local-start" type="button" hidden>Start Codeman</button>
@@ -156,6 +271,7 @@
<button class="secondary" id="local-stop" type="button" hidden>Stop waiting</button>
<button class="secondary" id="local-check" type="button" hidden>Check again</button>
</div>
<button class="wiz-link" id="local-agents" type="button" hidden>Add a coding agent</button>
</section>
<form id="form" role="tabpanel" aria-labelledby="tab-remote" autocomplete="off" novalidate hidden>
@@ -236,9 +352,15 @@
auto: $('auto'), fullscreen: $('fullscreen'), version: $('version'), webkit: $('webkit'),
tabLocal: $('tab-local'), tabRemote: $('tab-remote'), paneLocal: $('pane-local'),
localDot: $('local-dot'), localTitle: $('local-title'), localDetail: $('local-detail'),
localNetworkRow: $('local-network-row'), localNetwork: $('local-network'),
card: document.querySelector('.card'),
wizard: $('wizard'), wizSpinner: $('wiz-spinner'), wizTitle: $('wiz-title'), wizLead: $('wiz-lead'), wizMessage: $('wiz-message'),
wizAgentStep: $('wiz-agent-step'), wizAgents: $('wiz-agents'), wizAgentNote: $('wiz-agent-note'),
wizReachStep: $('wiz-reach-step'), wizPassword: $('wiz-password'), wizPw: $('wiz-pw'), wizPw2: $('wiz-pw2'),
wizSummary: $('wiz-summary'), wizRail: $('wiz-rail'), wizDetails: $('wiz-details'), wizLog: $('wiz-log'),
wizBack: $('wiz-back'), wizSkip: $('wiz-skip'), wizNext: $('wiz-next'), wizCancel: $('wiz-cancel'),
wizTerminal: $('wiz-terminal'),
localOpen: $('local-open'), localStart: $('local-start'), localInstall: $('local-install'),
localStop: $('local-stop'), localCheck: $('local-check'),
localStop: $('local-stop'), localCheck: $('local-check'), localAgents: $('local-agents'),
elsewhere: $('elsewhere'), ssh: $('ssh'), sshInstall: $('ssh-install'), copy: $('copy')
};
var manual = /[?&]manual=1/.test(location.search);
@@ -247,17 +369,21 @@
// suggestion: the address the suggest button connects to.
// tab: 'local' (This Mac) or 'remote' (Another machine). local: the last
// This Mac status from the app. wait: set while an install runs in
// Terminal and this page polls for the server it brings up.
// Terminal and this page polls for the server it brings up. gui: the
// graphical install while its screens are up (newWizard), null otherwise.
var state = {
servers: [], busy: false, lastUrl: '', startUrl: '', suggestion: '',
tab: 'local', local: null, localChecking: false, opening: false, wait: null, waitTimer: 0,
stoppedWaiting: false
stoppedWaiting: false, gui: null, wantAgents: false
};
// While an install runs: how often to look, how long a finished install may
// take to answer before Start is offered, and when to stop looking.
var WAIT_POLL_MS = 3000;
var WAIT_START_GRACE_MS = 90 * 1000;
var WAIT_MAX_MS = 30 * 60 * 1000;
// The phases `install.sh --progress` reports, in its order.
var PHASES = ['runtime', 'agent', 'source', 'packages', 'build', 'service'];
var MAX_LOG_LINES = 600;
function post(msg) { try { window.ipc.postMessage(JSON.stringify(msg)); } catch (e) { /* not in the app */ } }
@@ -536,12 +662,13 @@
els.localStart.disabled = state.busy || tooOld;
els.localInstall.disabled = state.busy || tooOld || state.opening;
els.localCheck.disabled = state.busy || state.localChecking;
els.localAgents.disabled = state.busy || tooOld || state.opening;
els.sshInstall.disabled = state.busy || state.opening;
}
function renderLocal() {
var s = state.local;
var show = { open: false, start: false, install: false, stop: false, check: false, network: false };
var show = { open: false, start: false, install: false, stop: false, check: false };
var dot = '';
var title = '';
var detail = '';
@@ -589,19 +716,17 @@
show.check = true;
} else if (s.state === 'notInstalled') {
title = 'Codeman is not installed on this Mac.';
detail = 'Install runs Codeman’s installer in Terminal: a few minutes, and it may ask for your Mac password. ' +
detail = 'Install sets it up here in a few minutes, with no password and no Terminal. ' +
'Codeman then runs in the background and starts when you log in, so your sessions keep running when this app is closed.';
show.install = true;
show.network = true;
show.check = true;
} else if (!s.installed) {
// Something else holds Codeman's port; a fresh install takes the next free one.
var other = s.outcome || {};
title = 'Codeman is not installed on this Mac.';
detail = 'Something else answers at ' + s.url + (other.kind === 'notCodeman' ? ' (HTTP ' + other.status + ')' : '') +
', so Install sets Codeman up on the next free port. It runs Codeman’s installer in Terminal: a few minutes, and it may ask for your Mac password.';
', so Install sets Codeman up on the next free port: a few minutes, with no password and no Terminal.';
show.install = true;
show.network = true;
show.check = true;
} else {
var o = s.outcome || {};
@@ -624,16 +749,28 @@
els.localInstall.hidden = !show.install;
els.localStop.hidden = !show.stop;
els.localCheck.hidden = !show.check;
els.localNetworkRow.hidden = !show.network;
// Codeman is here, in whatever state: adding an agent asks nothing of it.
els.localAgents.hidden = !s || !s.installed || !!state.wait || state.localChecking || (s.installRunning && !show.open);
syncLocalButtons();
}
function onLocalStatus(s) {
var wanted = state.wantAgents;
state.wantAgents = false;
state.localChecking = false;
state.local = s || null;
// A graphical run is going without this page (it was reloaded, or the app
// was quit and opened again): show it from its first line.
if (s && (s.installInApp || s.agentInstallInApp) && !state.gui) {
state.gui = newWizard('installing', s.installInApp ? 'install' : 'agent');
state.gui.port = s.installPort || 3000;
post({ cmd: s.installInApp ? 'installAttach' : 'agentAttach' });
renderWizard();
return;
}
// An install from this app is still running (this page was shown again
// while it ran): wait for it, unless the user stopped waiting.
if (!state.wait && s && s.installRunning && !state.stoppedWaiting && s.state !== 'running' && s.state !== 'needsLogin') {
if (!state.wait && s && s.installRunning && !s.installInApp && !state.stoppedWaiting && s.state !== 'running' && s.state !== 'needsLogin') {
state.wait = { since: Date.now(), finishedAt: 0, port: s.installPort || 3000 };
renderLocal();
scheduleWait();
@@ -643,6 +780,8 @@
return;
}
renderLocal();
if (wanted && s && s.installed && !state.gui) openAgentChooser();
else if (state.gui && state.gui.step === 'added') renderWizard();
}
// Point the Another machine form at `url` with only the login saved for it:
@@ -670,12 +809,502 @@
post({ cmd: 'startServer', url: state.startUrl });
}
function installLocal() {
// The Terminal route: what the graphical install cannot do, and what anyone
// may prefer. The installer asks its own questions there.
function installInTerminal() {
if (state.busy || tooOld || state.opening) return;
var network = !!state.gui && state.gui.reach === 'network';
closeWizard();
selectTab('local');
state.opening = true;
syncLocalButtons();
setStatus('busy', 'Opening Terminal…');
post({ cmd: 'installLocal', network: els.localNetwork.checked });
post({ cmd: 'installLocal', network: network });
}
// mode 'install': Codeman itself. mode 'agent': one coding agent for a Mac
// that has Codeman, on the same screens, with no question about Codeman.
function newWizard(step, mode) {
return {
step: step, mode: mode || 'install', plan: null, agent: '', reach: 'local', port: 3000, message: '', phase: '',
phaseSince: 0, seen: {}, log: [], ok: false, noAgent: false
};
}
function openWizard() {
if (state.busy || tooOld || state.opening) return;
state.gui = newWizard('preparing');
post({ cmd: 'installPrepare' });
renderWizard();
}
function openAgentChooser() {
if (state.busy || tooOld || state.opening) return;
state.gui = newWizard('preparing', 'agent');
post({ cmd: 'agentPrepare' });
renderWizard();
}
// The menu's way in. It can arrive before the first look at this Mac, and a
// Mac with no Codeman gets This Mac's Install instead.
function addAgent() {
selectTab('local');
if (state.gui) return;
if (!state.local || state.localChecking) state.wantAgents = true;
else if (state.local.installed) openAgentChooser();
}
function closeWizard() {
clearInterval(state.clock);
state.gui = null;
renderWizard();
renderLocal();
}
function retryWizard() {
var reach = state.gui ? state.gui.reach : 'local';
var agent = state.gui ? state.gui.agent : '';
var mode = state.gui ? state.gui.mode : 'install';
state.gui = newWizard('preparing', mode);
state.gui.reach = reach;
state.gui.agent = agent;
post({ cmd: mode === 'agent' ? 'agentPrepare' : 'installPrepare' });
renderWizard();
}
function installedAgents() {
var g = state.gui;
return g && g.plan ? g.plan.clis.filter(function (c) { return c.installed; }) : [];
}
function offeredAgents() {
var g = state.gui;
return g && g.plan ? g.plan.clis.filter(function (c) { return c.installable; }) : [];
}
function agentById(id) {
return offeredAgents().filter(function (c) { return c.id === id; })[0] || null;
}
// A run this page did not start has no plan, so no name for the agent.
function agentName() {
var c = agentById(state.gui.agent);
return c ? c.label : 'a coding agent';
}
// The first step the installer's answer calls for. In an install the agent
// step exists only when this Mac has no coding agent at all.
function onInstallPlan(plan) {
var g = state.gui;
if (!g || g.step !== 'preparing') return;
g.plan = plan;
if (g.mode === 'agent') {
// `blocker` says why Codeman cannot be installed, which is not the question.
if (!agentById(g.agent)) g.agent = offeredAgents().length ? offeredAgents()[0].id : '';
g.step = 'agent';
} else if (plan.blocker) {
g.step = 'blocked';
} else if (!installedAgents().length && offeredAgents().length) {
// Kept across Try again; otherwise the first of the catalogue's order.
if (!agentById(g.agent)) g.agent = offeredAgents()[0].id;
g.step = 'agent';
} else {
g.step = 'reach';
}
renderWizard();
}
function renderAgents() {
var g = state.gui;
els.wizAgents.textContent = '';
var rows = g.mode === 'agent' ? offeredAgents().concat(installedAgents()) : offeredAgents();
rows.forEach(function (c) {
var label = document.createElement('label');
label.className = 'choice';
var input = document.createElement('input');
input.type = 'radio';
input.name = 'agent';
input.value = c.id;
input.checked = c.id === g.agent;
input.disabled = c.installed;
input.addEventListener('change', function () {
g.agent = c.id;
renderAgentNote();
renderWizardButtons();
});
var name = document.createElement('b');
name.textContent = c.label;
var text = document.createElement('span');
text.appendChild(name);
if (c.installed) {
var here = document.createElement('small');
here.textContent = 'Installed';
text.appendChild(here);
}
label.appendChild(input);
label.appendChild(text);
els.wizAgents.appendChild(label);
});
renderAgentNote();
}
function renderAgentNote() {
var c = agentById(state.gui.agent);
els.wizAgentNote.textContent = '';
if (!c) return;
els.wizAgentNote.appendChild(document.createTextNode('You sign in to ' + c.label + ' the first time you start it. '));
if (/^https:\/\//.test(c.docs)) {
var a = document.createElement('a');
a.href = '#';
a.textContent = 'About ' + c.label;
a.addEventListener('click', function (ev) {
ev.preventDefault();
post({ cmd: 'openExternal', url: c.docs });
});
els.wizAgentNote.appendChild(a);
}
}
function renderSummary() {
var g = state.gui;
var plan = g.plan;
var lines = [{ text: 'Installs into your home folder (about 1 GB), with no administrator password' }];
var needs = [];
if (plan.needs.node) needs.push('Node.js');
if (plan.needs.tmux) needs.push('tmux');
if (needs.length) lines.push({ text: 'Downloads ' + needs.join(' and ') });
var found = installedAgents();
var chosen = agentById(g.agent);
if (found.length) {
lines.push({ text: 'Uses ' + found.map(function (c) { return c.label; }).join(', ') + ', already on this Mac' });
} else if (chosen) {
lines.push({ text: 'Installs ' + chosen.label });
} else {
lines.push({ text: 'No coding agent yet: Codeman opens, but sessions need one to run', warn: true });
}
lines.push({ text: 'Runs in the background and starts when you log in' });
if (plan.needs.git) lines.push({ text: 'This Mac has no git, so you update Codeman by running this install again' });
els.wizSummary.textContent = '';
lines.forEach(function (line) {
var li = document.createElement('li');
li.textContent = line.text;
if (line.warn) li.className = 'warn';
els.wizSummary.appendChild(li);
});
}
// Which rows the rail has. A run this page did not start has no plan, so it
// shows what the installer reported instead.
function railRows() {
var g = state.gui;
var plan = g.plan;
var chosen = agentById(g.agent);
if (g.mode === 'agent') return [{ id: 'agent', label: 'Installing ' + agentName() }];
var rows = [];
PHASES.forEach(function (id) {
var label = {
runtime: 'Getting Node.js and tmux',
agent: 'Installing ' + (chosen ? chosen.label : 'a coding agent'),
source: 'Downloading Codeman',
packages: 'Installing packages',
build: 'Building Codeman',
service: 'Starting Codeman'
}[id];
if (id === 'runtime' && plan) {
if (!plan.needs.node && !plan.needs.tmux) return;
if (!plan.needs.tmux) label = 'Getting Node.js';
if (!plan.needs.node) label = 'Getting tmux';
}
if (id === 'agent' && (plan ? !chosen : !g.seen.agent)) return;
rows.push({ id: id, label: label });
});
return rows;
}
function clockText(since) {
var s = Math.max(0, Math.floor((Date.now() - since) / 1000));
return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2);
}
function renderRail() {
var g = state.gui;
var current = PHASES.indexOf(g.phase);
els.wizRail.textContent = '';
railRows().forEach(function (row) {
var at = PHASES.indexOf(row.id);
var li = document.createElement('li');
var name = document.createElement('span');
name.textContent = row.label;
li.appendChild(name);
if (g.ok || at < current) {
li.className = 'done';
} else if (at === current && g.step === 'failed') {
li.className = 'failed';
} else if (at === current) {
li.className = 'now';
li.setAttribute('aria-current', 'step');
var clock = document.createElement('span');
clock.className = 'clock';
clock.id = 'wiz-clock';
clock.textContent = clockText(g.phaseSince);
li.appendChild(clock);
}
els.wizRail.appendChild(li);
});
}
function renderLog() {
var log = els.wizLog;
var atEnd = log.scrollTop + log.clientHeight >= log.scrollHeight - 8;
log.textContent = state.gui.log.join('\n');
if (atEnd) log.scrollTop = log.scrollHeight;
}
function appendLog(lines) {
var g = state.gui;
if (!lines || !lines.length) return;
g.log = g.log.concat(lines).slice(-MAX_LOG_LINES);
renderLog();
}
function setPhase(phase) {
var g = state.gui;
if (!phase || phase === g.phase || PHASES.indexOf(phase) < 0) return;
g.phase = phase;
g.phaseSince = Date.now();
g.seen[phase] = true;
renderRail();
}
function onInstallUpdate(msg) {
var g = state.gui;
if (!g || g.step !== 'installing') return;
setPhase(msg.phase);
appendLog(msg.log);
}
function onInstallFinished(msg) {
var g = state.gui;
if (!g) return;
appendLog(msg.log);
clearInterval(state.clock);
if (msg.ok && g.mode === 'agent') {
g.ok = true;
g.step = 'added';
renderWizard();
// Whether there is a Codeman to open from here.
checkLocal('quick');
return;
}
if (msg.ok) {
g.ok = true;
g.step = 'finishing';
renderWizard();
// The same watch as a Terminal install: it opens Codeman at the first
// answer, and offers Start when none comes.
state.wait = { since: Date.now(), finishedAt: 0, port: g.port };
checkLocal('quick');
return;
}
if (msg.cancelled) {
var stopped = g.mode === 'agent' ? 'Stopped installing ' + agentName() + '.' : 'Install cancelled.';
var again = g.mode === 'agent' ? '' : 'Install again to pick up where it stopped.';
closeWizard();
checkLocal('login');
setStatus('', stopped, again);
return;
}
g.step = 'failed';
g.message = msg.error || 'The installer stopped without saying why.';
renderWizard();
els.wizDetails.open = true;
renderLog();
}
// Each screen of the graphical install, drawn from state.gui alone.
function renderWizard() {
var g = state.gui;
els.card.classList.toggle('wizard-on', !!g);
els.wizard.hidden = !g;
if (!g) return;
var step = g.step;
var name = agentName();
var installingLead = 'You can close this window. The install keeps going, and the app picks it up when you open it again.';
var copy = g.mode === 'agent' ? {
preparing: ['Looking at this Mac', 'Getting the list of coding agents and checking which ones are here.'],
prepareFailed: ['Could not get the list of coding agents', ''],
agent: ['Add a coding agent', offeredAgents().length
? 'Pick one to install on this Mac. It needs no password, and Codeman keeps running.'
: installedAgents().length
? 'Every coding agent this app can install is already on this Mac.'
: 'This app cannot install a coding agent on this Mac.'],
installing: ['Installing ' + name, installingLead],
added: [name === 'a coding agent' ? 'The coding agent is installed' : name + ' is installed',
'If Codeman does not list it yet, reload it, or give it a few minutes. You sign in the first time you start it.'],
failed: ['The install stopped', 'Its output is below. Try again, or follow the instructions on the agent’s own site.']
}[step] : {
preparing: ['Looking at this Mac', 'Getting the installer and checking what is already here.'],
blocked: ['This Mac needs something first', ''],
prepareFailed: ['Could not get the installer', ''],
agent: ['Choose a coding agent', 'Codeman runs coding agents, and this Mac has none yet. Pick one to install with Codeman. You can add more later.'],
reach: ['Who can reach Codeman?', ''],
installing: ['Installing Codeman', installingLead],
finishing: ['Starting Codeman', 'Codeman opens here as soon as it answers.'],
ready: ['Codeman is ready', 'It has no coding agent yet, and sessions need one to run. You can add one now, or later from the Codeman menu.'],
failed: ['The install stopped', 'Its full log is in ~/.codeman/install.log. Try again picks up where it stopped.']
}[step];
els.wizSpinner.hidden = !(step === 'preparing' || step === 'finishing');
els.wizTitle.textContent = copy[0];
els.wizLead.textContent = copy[1];
els.wizLead.hidden = !copy[1];
var message = step === 'blocked' ? g.plan.blocker : (step === 'prepareFailed' || step === 'failed') ? g.message : '';
els.wizMessage.textContent = message;
els.wizMessage.hidden = !message;
els.wizMessage.className = step === 'blocked' ? 'warn' : '';
els.wizAgentStep.hidden = step !== 'agent';
els.wizReachStep.hidden = step !== 'reach';
var running = step === 'installing' || step === 'finishing' || step === 'failed' || step === 'added';
els.wizRail.hidden = !running;
els.wizDetails.hidden = !running;
if (step === 'agent') renderAgents();
if (step === 'reach') {
renderSummary();
syncReach();
}
if (running) renderRail();
clearInterval(state.clock);
if (step === 'installing') {
state.clock = setInterval(function () {
var clock = $('wiz-clock');
if (clock) clock.textContent = clockText(state.gui.phaseSince);
}, 1000);
}
renderWizardButtons();
els.wizCancel.disabled = false;
var checked = step === 'agent' && els.wizAgents.querySelector('input:checked');
if (checked) checked.focus();
else if (!els.wizNext.hidden) els.wizNext.focus();
else if (!els.wizBack.hidden) els.wizBack.focus();
else els.wizTitle.focus();
}
function localAnswers() {
return !!state.local && (state.local.state === 'running' || state.local.state === 'needsLogin');
}
function renderWizardButtons() {
var g = state.gui;
var step = g.step;
var agentMode = g.mode === 'agent';
var chosen = agentById(g.agent);
var next = {
agent: agentMode ? (chosen ? 'Install ' + chosen.label : '') : 'Continue',
reach: 'Install Codeman', failed: 'Try again', prepareFailed: 'Try again', blocked: 'Install in Terminal',
ready: 'Open Codeman', added: localAnswers() ? 'Open Codeman' : ''
}[step];
els.wizNext.hidden = !next;
els.wizNext.textContent = next || '';
// Not while preparing: a second prepare would clear the folder of the first.
els.wizBack.hidden = !(step === 'blocked' || step === 'prepareFailed' || step === 'agent' || step === 'reach' ||
(agentMode && (step === 'failed' || step === 'added')));
els.wizBack.textContent = step === 'added' ? 'Done' : 'Back';
var aside = step === 'ready' ? 'Add a coding agent'
: step === 'added' && offeredAgents().length > 1 ? 'Add another'
: step === 'agent' && !agentMode ? 'Skip for now' : '';
els.wizSkip.hidden = !aside;
els.wizSkip.textContent = aside;
els.wizCancel.hidden = step !== 'installing';
els.wizTerminal.hidden = agentMode || !(step === 'reach' || step === 'failed' || step === 'prepareFailed');
}
function syncReach() {
var g = state.gui;
var network = g.reach === 'network';
els.wizPassword.hidden = !network;
Array.prototype.forEach.call(document.getElementsByName('reach'), function (r) { r.checked = r.value === g.reach; });
}
function startInstall() {
var g = state.gui;
var password = '';
if (g.reach === 'network') {
password = els.wizPw.value;
if (!password) {
els.wizMessage.textContent = 'Set a password for the other devices to sign in with.';
els.wizMessage.hidden = false;
els.wizPw.focus();
return;
}
if (password !== els.wizPw2.value) {
els.wizMessage.textContent = 'The two passwords are different.';
els.wizMessage.hidden = false;
els.wizPw2.focus();
return;
}
}
var msg = { cmd: 'installStart' };
if (password) msg.networkPassword = password;
if (!installedAgents().length && agentById(g.agent)) msg.aiCli = g.agent;
els.wizPw.value = '';
els.wizPw2.value = '';
// Skipped: the first thing to say once Codeman answers.
g.noAgent = !installedAgents().length && !msg.aiCli && offeredAgents().length > 0;
beginRun();
post(msg);
}
function startAgent() {
var g = state.gui;
if (!agentById(g.agent)) return;
beginRun();
post({ cmd: 'agentStart', aiCli: g.agent });
}
function beginRun() {
var g = state.gui;
g.step = 'installing';
g.phase = '';
g.seen = {};
g.log = [];
renderLog();
renderWizard();
}
function wizardNext() {
var g = state.gui;
if (!g) return;
if (g.step === 'agent' && g.mode === 'agent') {
startAgent();
} else if (g.step === 'agent') {
g.step = 'reach';
renderWizard();
} else if (g.step === 'reach') {
startInstall();
} else if (g.step === 'ready' || g.step === 'added') {
closeWizard();
selectTab('local');
openLocal();
} else if (g.step === 'failed' || g.step === 'prepareFailed') {
retryWizard();
} else if (g.step === 'blocked') {
installInTerminal();
}
}
function wizardBack() {
var g = state.gui;
if (!g) return;
if (g.step === 'reach' && !installedAgents().length && offeredAgents().length && !g.plan.blocker) {
g.step = 'agent';
renderWizard();
} else {
closeWizard();
}
}
function scheduleWait() {
@@ -686,6 +1315,7 @@
function endWait() {
clearTimeout(state.waitTimer);
state.wait = null;
if (state.gui && state.gui.step === 'finishing') closeWizard();
}
// One answer while an install runs. It stops at the first answer from a
@@ -706,6 +1336,14 @@
// where something else may answer when the install took another port.
var ours = portOf(s.url) === w.port;
if (ours && (s.state === 'running' || s.state === 'needsLogin')) {
var g = state.gui;
if (g && g.step === 'finishing' && g.noAgent) {
clearTimeout(state.waitTimer);
state.wait = null;
g.step = 'ready';
renderWizard();
return;
}
endWait();
selectTab('local');
renderLocal();
@@ -776,6 +1414,7 @@
}
if (msg.autoConnect && !manual && !tooOld && !msg.starting && !msg.notice && state.servers[0]) connect();
else if (state.tab === 'remote') els.url.focus();
if (msg.addAgent) addAgent();
break;
case 'servers':
state.servers = msg.servers || [];
@@ -822,6 +1461,35 @@
renderLocal();
scheduleWait();
break;
case 'addAgent':
addAgent();
break;
case 'installPlan':
onInstallPlan(msg.plan);
break;
case 'installPrepareFailed':
if (state.gui && state.gui.step === 'preparing') {
state.gui.step = 'prepareFailed';
state.gui.message = msg.message || 'Could not get the installer.';
renderWizard();
}
break;
case 'installRunning':
if (state.gui) state.gui.port = Number(msg.port) || 3000;
break;
case 'installStartFailed':
if (state.gui) {
state.gui.step = 'failed';
state.gui.message = msg.message || 'Could not start the installer.';
renderWizard();
}
break;
case 'installUpdate':
onInstallUpdate(msg);
break;
case 'installFinished':
onInstallFinished(msg);
break;
case 'remoteInstallStarted':
state.opening = false;
syncLocalButtons();
@@ -875,7 +1543,34 @@
});
els.localOpen.addEventListener('click', openLocal);
els.localStart.addEventListener('click', startLocal);
els.localInstall.addEventListener('click', installLocal);
els.localInstall.addEventListener('click', openWizard);
els.localAgents.addEventListener('click', openAgentChooser);
els.wizNext.addEventListener('click', wizardNext);
els.wizBack.addEventListener('click', wizardBack);
els.wizSkip.addEventListener('click', function () {
var g = state.gui;
if (!g) return;
if (g.step === 'ready' || g.step === 'added') {
openAgentChooser();
return;
}
g.agent = '';
g.step = 'reach';
renderWizard();
});
els.wizCancel.addEventListener('click', function () {
els.wizCancel.disabled = true;
post({ cmd: 'installCancel' });
});
els.wizTerminal.addEventListener('click', installInTerminal);
Array.prototype.forEach.call(document.getElementsByName('reach'), function (radio) {
radio.addEventListener('change', function () {
if (!state.gui) return;
state.gui.reach = radio.value;
els.wizMessage.hidden = true;
syncReach();
});
});
els.localStop.addEventListener('click', function () { state.stoppedWaiting = true; endWait(); checkLocal('login'); });
els.localCheck.addEventListener('click', function () { if (!state.busy && !state.localChecking) checkLocal('deep'); });
els.sshInstall.addEventListener('click', installRemote);
File diff suppressed because it is too large. Load diff
+28 -7
View File
@@ -91,10 +91,10 @@ fn flavor(shell: &Path) -> Flavor {
}
/// What one process printed, stdout and stderr kept apart.
struct Output {
code: Option<i32>,
stdout: String,
stderr: String,
pub struct Output {
pub code: Option<i32>,
pub stdout: String,
pub stderr: String,
}
/// What `codeman` (or `SCRIPT` failing to find it) said, for the connect page.
@@ -139,8 +139,18 @@ fn pump<R: Read + Send + 'static>(pipe: Option<R>, stream: usize, tx: mpsc::Send
});
}
/// Run `cmd` to completion with stdin closed, or kill it at `deadline`.
fn exec(mut cmd: Command, deadline: Instant) -> Result<Output, String> {
/// `exec_bounded` for a start.
fn exec(cmd: Command, deadline: Instant) -> Result<Output, String> {
exec_bounded(
cmd,
deadline,
"Starting Codeman took longer than a minute; it was stopped.",
)
}
/// Run `cmd` to completion with stdin closed, or kill it at `deadline` and
/// answer `timed_out`.
pub fn exec_bounded(mut cmd: Command, deadline: Instant, timed_out: &str) -> Result<Output, String> {
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd
.spawn()
@@ -158,7 +168,7 @@ fn exec(mut cmd: Command, deadline: Instant) -> Result<Output, String> {
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
return Err("Starting Codeman took longer than a minute; it was stopped.".into());
return Err(timed_out.to_string());
}
Err(e) => return Err(e.to_string()),
}
@@ -265,6 +275,17 @@ fn capture_env(
}))
}
/// The login shell's environment, for a process the app runs without it (the
/// graphical install). `None` when the shell reports none.
pub fn login_environment() -> Option<Vec<(String, String)>> {
let shell = login_shell();
let flags: &[&str] = match flavor(&shell) {
Flavor::Posix => &["-l", "-c"],
Flavor::Capture(attempts) => attempts.first().copied().unwrap_or(&["-c"]),
};
capture_env(&shell, flags, Instant::now() + FIND_TIMEOUT).ok()?.ok()
}
/// `script` (SCRIPT or FIND_SCRIPT) under `/bin/sh` with exactly the captured
/// environment, so the server gets the profile's PATH, API keys and CODEMAN_*
/// settings and nothing of launchd's bare one.
+119 -1
View File
@@ -22,6 +22,7 @@
mod assets;
mod config;
mod credentials;
mod gui_install;
mod local_server;
#[cfg(target_os = "macos")]
mod macos;
@@ -34,7 +35,8 @@ use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
use std::process::Command;
use std::rc::Rc;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use muda::accelerator::{Accelerator, Code, Modifiers};
@@ -92,6 +94,7 @@ const START_REFUSED: &str = "Codeman can only be started here for an http://127.
mod menu_id {
pub const SWITCH: &str = "switch-server";
pub const ADD_AGENT: &str = "add-agent";
pub const RELOAD: &str = "reload";
pub const START_FULLSCREEN: &str = "start-fullscreen";
pub const DEVTOOLS: &str = "devtools";
@@ -267,6 +270,11 @@ struct App {
menu: AppMenu,
/// The one Keychain worker; every Keychain job goes through its queue.
keychain: Keychain,
/// Which reader of the install's output is the current one.
install_reader: Arc<AtomicU64>,
/// The connect page opens the agent chooser when it next loads: the menu
/// asked for it from a Codeman page.
add_agent_on_load: bool,
/// While the main window may still have to be put into the full screen it
/// asked for at launch: when to look next, and how many retries were made.
fullscreen_retry: Option<(Instant, u8)>,
@@ -350,6 +358,8 @@ fn main() {
downloads: HashMap::new(),
menu,
keychain,
install_reader: Arc::new(AtomicU64::new(0)),
add_agent_on_load: false,
fullscreen_retry: wants_fullscreen.then(|| (Instant::now() + FULLSCREEN_FIRST_CHECK, 0)),
};
@@ -386,6 +396,7 @@ fn build_menu(config: &Config) -> muda::Result<AppMenu> {
&about,
&PredefinedMenuItem::separator(),
&switch,
&MenuItem::with_id(menu_id::ADD_AGENT, "Add a Coding Agent to This Mac\u{2026}", true, None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::services(None),
&PredefinedMenuItem::separator(),
@@ -1037,6 +1048,7 @@ impl App {
fn on_menu(&mut self, id: &str) {
match id {
menu_id::SWITCH => self.show_connect_page(None, true),
menu_id::ADD_AGENT => self.add_agent(),
menu_id::RELOAD => self.reload_focused(),
menu_id::START_FULLSCREEN => {
let value = self.menu.start_fullscreen.is_checked();
@@ -1174,6 +1186,76 @@ impl App {
});
}
}
ConnectCmd::InstallPrepare => {
if choosing {
self.in_background(|| match gui_install::prepare() {
Ok(plan) => json!({ "type": "installPlan", "plan": plan }),
Err(message) => json!({ "type": "installPrepareFailed", "message": message }),
});
}
}
ConnectCmd::InstallStart {
network_password,
ai_cli,
} => {
if choosing {
let proxy = self.proxy.clone();
let stale = self.next_install_reader();
std::thread::spawn(move || {
let plan = gui_install::Plan {
port: setup::pick_install_port(),
network_password,
ai_cli,
};
let send = |msg| {
let _ = proxy.send_event(UserEvent::ToConnectPage(msg));
};
match gui_install::start(&plan) {
Ok(()) => {
send(json!({ "type": "installRunning", "port": plan.port }));
gui_install::follow(gui_install::Run::Codeman, stale, send);
}
Err(message) => send(json!({ "type": "installStartFailed", "message": message })),
}
});
}
}
ConnectCmd::InstallAttach => {
if choosing {
self.attach_to_run(gui_install::Run::Codeman);
}
}
ConnectCmd::InstallCancel => {
std::thread::spawn(gui_install::cancel);
}
ConnectCmd::AgentPrepare => {
if choosing {
self.in_background(|| match gui_install::prepare_agent() {
Ok(plan) => json!({ "type": "installPlan", "plan": plan }),
Err(message) => json!({ "type": "installPrepareFailed", "message": message }),
});
}
}
ConnectCmd::AgentStart { ai_cli } => {
if choosing {
let proxy = self.proxy.clone();
let stale = self.next_install_reader();
std::thread::spawn(move || {
let send = |msg| {
let _ = proxy.send_event(UserEvent::ToConnectPage(msg));
};
match gui_install::start_agent(&ai_cli) {
Ok(()) => gui_install::follow(gui_install::Run::Agent, stale, send),
Err(message) => send(json!({ "type": "installStartFailed", "message": message })),
}
});
}
}
ConnectCmd::AgentAttach => {
if choosing {
self.attach_to_run(gui_install::Run::Agent);
}
}
ConnectCmd::InstallRemote { target } => {
if choosing {
match setup::SshTarget::parse(&target) {
@@ -1205,6 +1287,40 @@ impl App {
}
}
/// A new reader of the install's output replaces the one before it: the
/// answer is how that new reader learns it was replaced in turn.
/// Report a run that is going already to the page, from its first line.
fn attach_to_run(&self, run: gui_install::Run) {
let proxy = self.proxy.clone();
let stale = self.next_install_reader();
std::thread::spawn(move || {
gui_install::follow(run, stale, |msg| {
let _ = proxy.send_event(UserEvent::ToConnectPage(msg));
});
});
}
/// The agent chooser lives on the connect page: from a Codeman page, this
/// leaves it the way Switch Server does.
fn add_agent(&mut self) {
if matches!(*self.phase.borrow(), Phase::Connect) {
if let Some(main) = self.windows.get(&self.main_id) {
main.window.set_visible(true);
main.window.set_focus();
}
self.to_connect_page(json!({ "type": "addAgent" }));
} else {
self.add_agent_on_load = true;
self.show_connect_page(None, true);
}
}
fn next_install_reader(&self) -> impl Fn() -> bool + Send + 'static {
let generation = self.install_reader.clone();
let mine = generation.fetch_add(1, Ordering::SeqCst) + 1;
move || generation.load(Ordering::SeqCst) != mine
}
/// Run `work` off the event loop (it may run a login shell, plutil,
/// Terminal or a probe) and hand what it returns to the connect page.
fn in_background(&self, work: impl FnOnce() -> serde_json::Value + Send + 'static) {
@@ -1216,6 +1332,7 @@ impl App {
fn send_init(&mut self) {
let notice = self.notice.take();
let add_agent = std::mem::take(&mut self.add_agent_on_load);
self.to_connect_page(json!({
"type": "init",
"servers": self.config.servers,
@@ -1224,6 +1341,7 @@ impl App {
"version": VERSION,
"starting": self.start_running(),
"notice": notice,
"addAgent": add_agent,
}));
}
+172
View File
@@ -565,6 +565,27 @@ pub enum ConnectCmd {
InstallLocal {
network: bool,
},
/// Fetch the installer and ask it what is on this Mac (gui_install).
InstallPrepare,
/// Run the graphical install. `network_password`: reachable from other
/// devices, with this password. `ai_cli`: an id in the shape of the
/// installer's catalogue, which checks it again.
InstallStart {
network_password: Option<String>,
ai_cli: Option<String>,
},
/// Show the graphical install that is running already.
InstallAttach,
/// Stop whichever graphical run is going.
InstallCancel,
/// Fetch the installer to add a coding agent to a Mac that has Codeman.
AgentPrepare,
/// Add this agent: an id in the shape of the installer's catalogue.
AgentStart {
ai_cli: String,
},
/// Show the agent install that is running already.
AgentAttach,
/// Run the installer on another machine over SSH, in Terminal. `target`
/// is what the user typed; setup::SshTarget::parse checks it.
InstallRemote {
@@ -619,6 +640,23 @@ enum RawConnect {
#[serde(default)]
network: bool,
},
InstallPrepare,
InstallStart {
// rename_all above reaches variant names only, and deny_unknown_fields
// would refuse the page's spelling of these.
#[serde(default, rename = "networkPassword")]
network_password: Option<String>,
#[serde(default, rename = "aiCli")]
ai_cli: Option<String>,
},
InstallAttach,
InstallCancel,
AgentPrepare,
AgentStart {
#[serde(rename = "aiCli")]
ai_cli: String,
},
AgentAttach,
InstallRemote {
target: String,
},
@@ -635,6 +673,17 @@ enum RawHost {
SessionExpired,
}
/// The shape of a CLI catalogue id. It becomes one argument of `install.sh`,
/// so it could not be read as a flag even without this: it must start with a
/// letter.
fn is_cli_id(id: &str) -> bool {
id.len() <= 32
&& id.starts_with(|c: char| c.is_ascii_lowercase())
&& id
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
fn bounded(s: Option<String>, max: usize) -> Option<Option<String>> {
match s {
Some(v) if v.len() > max => None,
@@ -692,6 +741,24 @@ pub fn parse_ipc(frame_url: &str, body: &str, phase: &Phase, is_main_window: boo
RawConnect::OpenExternal { url } if url.len() <= MAX_URL_LEN => ConnectCmd::OpenExternal(url),
RawConnect::LocalStatus { quick, deep } => ConnectCmd::LocalStatus { quick, deep },
RawConnect::InstallLocal { network } => ConnectCmd::InstallLocal { network },
RawConnect::InstallPrepare => ConnectCmd::InstallPrepare,
RawConnect::InstallStart {
network_password,
ai_cli,
} => {
if ai_cli.as_deref().is_some_and(|id| !is_cli_id(id)) {
return None;
}
ConnectCmd::InstallStart {
network_password: bounded(network_password, MAX_FIELD_LEN)?.filter(|p| !p.is_empty()),
ai_cli,
}
}
RawConnect::InstallAttach => ConnectCmd::InstallAttach,
RawConnect::InstallCancel => ConnectCmd::InstallCancel,
RawConnect::AgentPrepare => ConnectCmd::AgentPrepare,
RawConnect::AgentStart { ai_cli } if is_cli_id(&ai_cli) => ConnectCmd::AgentStart { ai_cli },
RawConnect::AgentAttach => ConnectCmd::AgentAttach,
RawConnect::InstallRemote { target } if target.len() <= MAX_FIELD_LEN => {
ConnectCmd::InstallRemote { target }
}
@@ -1480,6 +1547,111 @@ mod tests {
assert_eq!(parse_ipc(page, r#"{"cmd":"installLocal"}"#, &loaded, true), None);
}
#[test]
fn ipc_graphical_install_commands() {
let page = "codemanapp://localhost/index.html";
let c = |body: &str| parse_ipc(page, body, &Phase::Connect, true);
assert_eq!(
c(r#"{"cmd":"installPrepare"}"#),
Some(Ipc::Connect(ConnectCmd::InstallPrepare))
);
assert_eq!(
c(r#"{"cmd":"installAttach"}"#),
Some(Ipc::Connect(ConnectCmd::InstallAttach))
);
assert_eq!(
c(r#"{"cmd":"installCancel"}"#),
Some(Ipc::Connect(ConnectCmd::InstallCancel))
);
// The spelling connect.html sends (test/desktop-connect-install.test.ts
// pins the page's side).
assert_eq!(
c(r#"{"cmd":"installStart","networkPassword":"s3cret","aiCli":"claude"}"#),
Some(Ipc::Connect(ConnectCmd::InstallStart {
network_password: Some("s3cret".into()),
ai_cli: Some("claude".into()),
}))
);
assert_eq!(
c(r#"{"cmd":"installStart"}"#),
Some(Ipc::Connect(ConnectCmd::InstallStart {
network_password: None,
ai_cli: None,
}))
);
// An empty password is no password: never a network install without one.
assert_eq!(
c(r#"{"cmd":"installStart","networkPassword":""}"#),
Some(Ipc::Connect(ConnectCmd::InstallStart {
network_password: None,
ai_cli: None,
}))
);
// Only the shape of an id: nothing that could read as a flag or more.
for id in ["--yes", "-y", "Claude", "a b", "a;b", "", "a/b"] {
assert_eq!(c(&format!(r#"{{"cmd":"installStart","aiCli":"{id}"}}"#)), None, "{id}");
}
let long = "a".repeat(33);
assert_eq!(c(&format!(r#"{{"cmd":"installStart","aiCli":"{long}"}}"#)), None);
let long = "a".repeat(MAX_FIELD_LEN + 1);
assert_eq!(
c(&format!(r#"{{"cmd":"installStart","networkPassword":"{long}"}}"#)),
None
);
// Nothing else rides along.
assert_eq!(c(r#"{"cmd":"installStart","args":["--yes"]}"#), None);
assert_eq!(c(r#"{"cmd":"installStart","ai_cli":"claude"}"#), None);
// Only the connect page, only in the main window, never a server page.
assert_eq!(
parse_ipc(page, r#"{"cmd":"installStart"}"#, &Phase::Connect, false),
None
);
let loaded = Phase::Loaded(u("http://127.0.0.1:3000/"));
assert_eq!(
parse_ipc("http://127.0.0.1:3000/", r#"{"cmd":"installStart"}"#, &loaded, true),
None
);
assert_eq!(parse_ipc(page, r#"{"cmd":"installCancel"}"#, &loaded, true), None);
}
#[test]
fn ipc_adding_a_coding_agent() {
let page = "codemanapp://localhost/index.html";
let c = |body: &str| parse_ipc(page, body, &Phase::Connect, true);
assert_eq!(
c(r#"{"cmd":"agentPrepare"}"#),
Some(Ipc::Connect(ConnectCmd::AgentPrepare))
);
assert_eq!(
c(r#"{"cmd":"agentAttach"}"#),
Some(Ipc::Connect(ConnectCmd::AgentAttach))
);
assert_eq!(
c(r#"{"cmd":"agentStart","aiCli":"codex"}"#),
Some(Ipc::Connect(ConnectCmd::AgentStart { ai_cli: "codex".into() }))
);
// It names one agent, in the shape of an id, and nothing else.
assert_eq!(c(r#"{"cmd":"agentStart"}"#), None);
for id in ["--service", "-y", "Codex", "a b", "a;b", "", "a/b"] {
assert_eq!(c(&format!(r#"{{"cmd":"agentStart","aiCli":"{id}"}}"#)), None, "{id}");
}
assert_eq!(c(r#"{"cmd":"agentStart","aiCli":"codex","port":3000}"#), None);
assert_eq!(
parse_ipc(page, r#"{"cmd":"agentStart","aiCli":"codex"}"#, &Phase::Connect, false),
None
);
let loaded = Phase::Loaded(u("http://127.0.0.1:3000/"));
assert_eq!(
parse_ipc(
"http://127.0.0.1:3000/",
r#"{"cmd":"agentStart","aiCli":"codex"}"#,
&loaded,
true
),
None
);
}
#[test]
fn ipc_from_connect_page() {
let page = "codemanapp://localhost/index.html";
+37 -17
View File
@@ -1,12 +1,12 @@
//! The connect screen's "This Mac" tab and its installs: where the Codeman on
//! this Mac listens, whether it is installed, its login, and the installer runs
//! for this Mac and for another machine over SSH.
//! The connect screen's "This Mac" tab and its Terminal installs: where the
//! Codeman on this Mac listens, whether it is installed, its login, and the
//! installer runs in Terminal for this Mac and for another machine over SSH.
//! The install most people get runs in the app, in `gui_install.rs`.
//!
//! The installer always runs in Terminal, never inside the app. `install.sh`
//! asks for the administrator password on a terminal (Homebrew, its sudo
//! keep-alive), the Xcode tools arrive as a system dialog, and ssh asks about
//! host keys and passwords on a terminal too: a run without one fails on the
//! first fresh Mac. So the app writes a small script that deletes itself and
//! The Terminal route is for what that one cannot do. `install.sh` asks for
//! the administrator password on a terminal (Homebrew, its sudo keep-alive),
//! the Xcode tools arrive as a system dialog, and ssh asks about host keys and
//! passwords on a terminal too. So the app writes a small script that deletes itself and
//! hands it to Terminal (`open -b com.apple.Terminal`), which needs no
//! permission, unlike scripting Terminal through osascript (an Automation
//! prompt). Everything the scripts contain is built here and unit-tested; the
@@ -24,12 +24,22 @@ use std::time::Duration;
use serde::Serialize;
use url::Url;
use crate::gui_install;
use crate::local_server;
use crate::policy;
use crate::probe::{self, ProbeOutcome};
/// Codeman's published installer (a redirect to `install.sh` on master).
pub const INSTALLER_URL: &str = "https://getcodeman.com/install";
/// Codeman's published installer (a redirect to `install.sh` on master). A test
/// build of an installer change sets `CODEMAN_DESKTOP_INSTALLER_URL` when it is
/// compiled; a shipped app has no way to point this anywhere else, at runtime
/// or otherwise.
pub const INSTALLER_URL: &str = match option_env!("CODEMAN_DESKTOP_INSTALLER_URL") {
Some(url) => url,
None => "https://getcodeman.com/install",
};
/// The branch that test build installs Codeman from (`CODEMAN_BRANCH`).
pub const INSTALL_BRANCH: Option<&str> = option_env!("CODEMAN_DESKTOP_INSTALL_BRANCH");
/// What to paste into a terminal on another machine; the SSH install runs the
/// same thing there.
@@ -289,7 +299,7 @@ pub fn local_login(url: &Url) -> Option<(String, String)> {
}
/// This user's processes, as `ps` lists them: (pid, command line).
fn list_processes() -> Vec<(u32, String)> {
pub fn list_processes() -> Vec<(u32, String)> {
let Ok(out) = Command::new("/bin/ps")
.args(["-x", "-ww", "-o", "pid=", "-o", "command="])
.stdin(Stdio::null())
@@ -329,8 +339,8 @@ pub fn is_codeman_server(command: &str) -> bool {
}
/// Whether a command line is an install the app started on this Mac (the
/// Terminal script, or the installer it downloaded), and the port it asked
/// for once the installer runs.
/// Terminal script, the installer it downloaded, or the graphical install), and
/// the port it asked for once the installer runs.
pub fn install_in_progress(command: &str) -> Option<Option<u16>> {
let words: Vec<&str> = command.split_whitespace().collect();
let name = |w: &&str| w.rsplit('/').next().unwrap_or(w).to_string();
@@ -340,7 +350,7 @@ pub fn install_in_progress(command: &str) -> Option<Option<u16>> {
}) {
return Some(None);
}
if words.iter().any(|w| name(w).starts_with("codeman-install.")) {
if words.iter().any(|w| name(w).starts_with("codeman-install.")) || gui_install::Run::Codeman.matches(command) {
let port = words
.iter()
.position(|w| *w == "--port")
@@ -465,10 +475,16 @@ pub struct LocalStatus {
pub installed: bool,
/// `install.sh` finished its work (its `.install-complete` marker).
pub install_complete: bool,
/// An install the app started is still running in Terminal, and the
/// port it asked for once that is known.
/// An install the app started is still running, and the port it asked
/// for once that is known.
pub install_running: bool,
pub install_port: Option<u16>,
/// That install is the graphical one, not a Terminal window: the page can
/// show it (gui_install::follow), also after the app was quit.
pub install_in_app: bool,
/// A coding agent is being added the same way. Not an install: it says
/// nothing about Codeman, which may well be running.
pub agent_install_in_app: bool,
/// For RunningElsewhere: the addresses that Codeman listens on.
pub elsewhere: Vec<String>,
/// The probe's outcome, for the connect page's explanations.
@@ -495,6 +511,8 @@ pub fn inspect(depth: Depth) -> LocalStatus {
let servers = codeman_servers(&procs);
let found = listeners(&servers);
let install = procs.iter().find_map(|(_, c)| install_in_progress(c));
let install_in_app = procs.iter().any(|(_, c)| gui_install::Run::Codeman.matches(c));
let agent_install_in_app = procs.iter().any(|(_, c)| gui_install::Run::Agent.matches(c));
// The login goes only to this user's own Codeman on that port.
let auth = if serves_loopback_port(&found, url.port_or_known_default().unwrap_or(0)) {
@@ -587,6 +605,8 @@ pub fn inspect(depth: Depth) -> LocalStatus {
install_complete,
install_running: install.is_some(),
install_port: install.flatten(),
install_in_app,
agent_install_in_app,
elsewhere,
outcome,
}
@@ -597,7 +617,7 @@ pub fn inspect(depth: Depth) -> LocalStatus {
/// attach to the first one's live sessions (src/config/instance.ts), and
/// `codeman web -d` only notices a server it started itself or one at the
/// same address.
fn already_running() -> Option<String> {
pub fn already_running() -> Option<String> {
let servers = codeman_servers(&list_processes());
let pid = servers.first()?;
let found = listeners(&servers);
+5
View File
@@ -218,6 +218,11 @@ function canonicalPath(path) {
* @returns {string | null}
*/
export function resolveGitHooksDir(repoRoot) {
// Not a checkout at all (a source-archive or npm install): answer without running
// git. On a Mac without the Xcode Command Line Tools `/usr/bin/git` is a stub that
// opens Apple's "install the developer tools" dialog, and this runs on every
// `npm install`. `.git` is a directory in a clone and a file in a worktree.
if (!existsSync(join(repoRoot, '.git'))) return null;
try {
const top = git(repoRoot, ['rev-parse', '--show-toplevel']);
if (!top || realpathSync(top) !== realpathSync(repoRoot)) return null;
+4 -1
View File
@@ -31,8 +31,11 @@ export type SupervisorKind = 'systemd' | 'launchd' | 'launchd-daemon' | 'docker-
* How Codeman was installed. `git` and `docker-compose` can self-update in
* place; `docker-compose` is a git checkout bind-mounted into the container, so
* the pull/build happen on the host filesystem and survive container recreation.
* `archive` is what `install.sh` leaves on a machine with no working git (its
* no-admin route): the source unpacked from an archive, with nothing for the
* in-app updater to pull into, so it updates by re-running the installer.
*/
export type InstallKind = 'git' | 'docker-compose' | 'npm' | 'unknown';
export type InstallKind = 'git' | 'docker-compose' | 'npm' | 'archive' | 'unknown';
/**
* Why an in-place container update is refused. Each is derived mechanically from
+37 -2
View File
@@ -18,6 +18,15 @@
* cache, a missing CLI re-ran the whole chain and stalled the event loop for up
* to 5s on every request, forever.
*
* One thing ends a backoff early: `install.sh` touches `CLI_INSTALLED_MARKER` in
* the data dir when it has installed a CLI, because Run reads this same answer, so
* "Run fails, add the agent, Run again" would otherwise fail twice. The check is
* one `stat` of that file, inside the backoff branch, so a resolved CLI never pays
* for it. It is deliberately not a re-walk of PATH during the backoff: that would
* put a synchronous stat of every PATH entry on the request path, where one dead
* network mount freezes the server (#516), and execute candidates that were
* already refused. A CLI installed some other way waits out the backoff as before.
*
* Test hermeticity: under vitest (`process.env.VITEST`) the production host
* short-circuits — IO primitives that were not injected become inert stubs, so
* a suite can never scan the machine's PATH or spawn login shells (the same
@@ -34,6 +43,7 @@ import { execFileSync } from 'node:child_process';
import { accessSync, constants, statSync } from 'node:fs';
import { basename, delimiter, dirname, isAbsolute, join } from 'node:path';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import { dataPath } from '../config/instance.js';
import { loginShellArgs, resolveLocalShell } from './shell-resolver.js';
const SAFE_BINARY_NAME = /^[a-z0-9][a-z0-9._-]*$/i;
@@ -42,6 +52,12 @@ const LOGIN_SHELL_END_MARKER = '__CODEMAN_CLI_RESOLVE_END__';
/** Maximum rendered length of each bounded diagnostic field, excluding its label. */
const DIAGNOSTIC_FIELD_MAX_LENGTH = 1024;
/**
* The file `install.sh` touches in the data dir once it has installed a CLI (`mark_cli_installed`).
* Its mtime is all that is read; test/install-sh-invariants.test.ts pins the name on both sides.
*/
export const CLI_INSTALLED_MARKER = 'cli-installed';
/** First retry window after a full-chain resolution miss. */
const RESOLVE_RETRY_BASE_MS = 60_000;
/**
@@ -79,6 +95,8 @@ export interface CliResolverHost {
findOnProcessPath(binary: string): string | null;
findInLoginShell(binary: string): string | null;
exists(path: string): boolean;
/** A number that changes when the installer has installed a CLI; 0 when it never did. */
installStamp?(): number;
}
export interface CandidateValidation<T> {
@@ -113,6 +131,8 @@ export interface ProductionCliResolverHostOptions {
shellArgs?: string[];
runCommand?: CliResolverCommandRunner;
isExecutableFile?: (path: string) => boolean;
/** Where the installer's marker is. Defaults to `CLI_INSTALLED_MARKER` in the data dir. */
installMarkerPath?: string;
/**
* Test-only escape hatch: keep the REAL IO primitives even under vitest.
* For tests that exercise `isExecutableRegularFile` against their own temp
@@ -202,6 +222,14 @@ export function createProductionCliResolverHost(options: ProductionCliResolverHo
return candidate && isExecutableFile(candidate) ? candidate : null;
},
exists: isExecutableFile,
installStamp: () => {
if (inert) return 0;
try {
return statSync(options.installMarkerPath ?? dataPath(CLI_INSTALLED_MARKER)).mtimeMs;
} catch {
return 0;
}
},
};
}
@@ -260,6 +288,8 @@ export function createCliExecutableResolver<T = undefined>(
let lastFailureAt = 0;
/** The invalidation generation the cached state above belongs to. */
let generation = binaryGenerations.get(options.binary) ?? 0;
/** The installer's stamp at the last run of the chain: only a CHANGE is news. */
let installStampSeen = 0;
const accept = (path: string | null, source: CliResolutionSource): CliResolution<T> | null => {
if (!path || !isAbsolute(path) || !host.exists(path)) return null;
const validation = options.validateCandidate?.(path) ?? ({ accepted: true } as CandidateValidation<T>);
@@ -285,8 +315,13 @@ export function createCliExecutableResolver<T = undefined>(
// Negative cache: a miss is remembered and the chain — whose login-shell
// tail is a synchronous 5s-bounded spawn — is not re-run until the
// backoff elapses. Without this, every status poll and Run click against
// a missing CLI froze the event loop for the full probe, forever.
if (failures > 0 && now() - lastFailureAt < cliResolveRetryDelayMs(failures)) return null;
// a missing CLI froze the event loop for the full probe, forever. A changed
// installer stamp is the one early way out (see the @fileoverview), and buys
// one run of the chain.
const installStamp = host.installStamp?.() ?? 0;
const installedSince = installStamp !== installStampSeen;
if (failures > 0 && !installedSince && now() - lastFailureAt < cliResolveRetryDelayMs(failures)) return null;
installStampSeen = installStamp;
cached = accept(host.findOnProcessPath(options.binary), 'process-path');
if (!cached) {
+5 -1
View File
@@ -1940,10 +1940,14 @@ Object.assign(CodemanApp.prototype, {
// `docker-compose` self-updates in place like `git` does — the container
// restarts itself. Anything else cannot.
if (data.installKind && data.installKind !== 'git' && data.installKind !== 'docker-compose') {
// `archive` = installed by install.sh on a machine with no working git: the
// installer replaces it with a fresh build, so point there, not at npm.
const hint =
data.supervisor === 'docker-compose'
? 'Update from the Docker host with <code>docker/Start-Codeman.sh</code>.'
: 'Update with <code>npm i -g aicodeman@latest</code>.';
: data.installKind === 'archive'
? 'It was installed without git. Re-run the installer to update: <code>curl -fsSL https://getcodeman.com/install | bash</code>'
: 'Update with <code>npm i -g aicodeman@latest</code>.';
this._setUpdateResult(`This install can't update itself (${escapeHtml(data.installKind)}). ${hint}`);
return;
}
+21 -3
View File
@@ -462,12 +462,26 @@ export function isRunningInContainer(): boolean {
return process.env.CODEMAN_IN_CONTAINER === '1' || existsSync('/.dockerenv');
}
function detectInstallKind(dir: string): InstallKind {
/**
* Marker `install.sh` writes into an install directory whose source came from an
* archive instead of a git clone (`SOURCE_MARKER` there; the two names are pinned
* to each other by `test/install-sh-invariants.test.ts`).
*/
export const ARCHIVE_INSTALL_MARKER = '.install-source';
/** How an `archive` install updates: the installer replaces it with a fresh build. */
export const ARCHIVE_UPDATE_HINT =
'This install has no git checkout, so it cannot update in place. Re-run the installer to update: ' +
'curl -fsSL https://getcodeman.com/install | bash';
export function detectInstallKind(dir: string): InstallKind {
// A container whose code is a bind-mounted checkout updates in place (the pull
// and build land on the host filesystem and survive container recreation). A
// container WITHOUT that mount runs a baked image copy — a pull there would go
// to the writable layer and vanish on the next `up`, so it is not updatable.
if (existsSync(join(dir, '.git'))) return isRunningInContainer() ? 'docker-compose' : 'git';
// install.sh on a machine with no working git: the source from an archive.
if (existsSync(join(dir, ARCHIVE_INSTALL_MARKER))) return 'archive';
// Global npm install ships only dist/ (no src/, no .git).
if (!existsSync(join(dir, 'src'))) return 'npm';
return 'unknown';
@@ -705,7 +719,9 @@ export async function checkForUpdate(): Promise<UpdateCheckResult> {
error:
info.installKind === 'unknown' && isRunningInContainer()
? 'This container runs a baked image copy with no repository mounted — self-update is unavailable. See docs/docker-self-update.md.'
: 'Not a git install — self-update is unavailable.',
: info.installKind === 'archive'
? ARCHIVE_UPDATE_HINT
: 'Not a git install — self-update is unavailable.',
};
}
@@ -829,7 +845,9 @@ export async function startUpdate(): Promise<StartUpdateResult> {
code: 'not-git',
message: isRunningInContainer()
? 'This container has no repository mounted. Update from the host with docker/Start-Codeman.sh.'
: 'This is not a git install. Update with: npm i -g aicodeman@latest',
: info.installKind === 'archive'
? ARCHIVE_UPDATE_HINT
: 'This is not a git install. Update with: npm i -g aicodeman@latest',
};
}
const existing = readCurrentUpdateStatus();
+103 -1
View File
@@ -1,9 +1,10 @@
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { chmodSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { EXEC_TIMEOUT_MS } from '../src/config/exec-timeout.js';
import {
CLI_INSTALLED_MARKER,
cliResolveRetryDelayMs,
createCliExecutableResolver,
createProductionCliResolverHost,
@@ -130,6 +131,107 @@ describe('createCliExecutableResolver', () => {
expect(findInLoginShell).toHaveBeenCalledTimes(2);
});
describe('the installer says a CLI was installed', () => {
// install.sh touches a marker when it installs a CLI, and the stamp is that file's
// mtime. A running server must see the new CLI at the next request, because Run reads
// this same answer: "Run fails, add the agent, Run again" must not fail twice.
function waiting() {
const clock = { now: 0, stamp: 1000 };
const findInLoginShell = vi.fn<() => string | null>().mockReturnValue(null);
const onPath = vi.fn<() => string | null>().mockReturnValue(null);
const installStamp = vi.fn(() => clock.stamp);
const h = host({
findInLoginShell,
findOnProcessPath: onPath,
exists: vi.fn((path) => path === '/new/bin/codex'),
installStamp,
});
const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: [], now: () => clock.now }, h);
expect(resolver.resolve()).toBeNull();
return { clock, findInLoginShell, onPath, installStamp, resolver };
}
it('ends the backoff at once when the stamp changes', () => {
const { clock, findInLoginShell, resolver } = waiting();
clock.now = 1;
findInLoginShell.mockReturnValue('/new/bin/codex');
clock.stamp = 2000;
expect(resolver.resolve()?.binaryPath).toBe('/new/bin/codex');
});
it('keeps the backoff, with nothing spawned, while the stamp stands still', () => {
const { clock, findInLoginShell, onPath, resolver } = waiting();
clock.now = cliResolveRetryDelayMs(1) - 1;
for (let i = 0; i < 50; i++) expect(resolver.resolve()).toBeNull();
expect(findInLoginShell).toHaveBeenCalledTimes(1);
expect(onPath).toHaveBeenCalledTimes(1);
});
it('a marker older than the server is not news: it does not cost a second chain', () => {
// A stamp present from the first look on must not read as a change at the second.
const { clock, findInLoginShell, resolver } = waiting();
clock.now = 1;
expect(resolver.resolve()).toBeNull();
expect(findInLoginShell).toHaveBeenCalledTimes(1);
});
it('one change buys one look: a CLI that is still missing goes back to waiting', () => {
const { clock, findInLoginShell, resolver } = waiting();
clock.now = 1;
clock.stamp = 2000;
expect(resolver.resolve()).toBeNull();
expect(resolver.resolve()).toBeNull();
expect(resolver.resolve()).toBeNull();
expect(findInLoginShell).toHaveBeenCalledTimes(2);
});
it('never looks at the marker once a CLI is resolved', () => {
const { clock, findInLoginShell, installStamp, resolver } = waiting();
findInLoginShell.mockReturnValue('/new/bin/codex');
clock.stamp = 2000;
expect(resolver.resolve()?.binaryPath).toBe('/new/bin/codex');
installStamp.mockClear();
for (let i = 0; i < 10; i++) resolver.resolve();
expect(installStamp).not.toHaveBeenCalled();
});
it('a stamp going backwards is a change too, or a restored data folder would hide one', () => {
const { clock, findInLoginShell, resolver } = waiting();
clock.now = 1;
clock.stamp = 500;
resolver.resolve();
expect(findInLoginShell).toHaveBeenCalledTimes(2);
});
it('reads the real marker file, and treats a missing one as no news', () => {
const dir = mkdtempSync(join(tmpdir(), 'codeman-cli-marker-'));
const marker = join(dir, CLI_INSTALLED_MARKER);
try {
const h = createProductionCliResolverHost({ allowRealIoUnderVitest: true, installMarkerPath: marker });
expect(h.installStamp?.()).toBe(0);
writeFileSync(marker, '');
utimesSync(marker, 1000, 1000);
const first = h.installStamp?.();
expect(first).toBeGreaterThan(0);
utimesSync(marker, 2000, 2000);
expect(h.installStamp?.()).not.toBe(first);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('reads no marker under vitest, even one that is there', () => {
const dir = mkdtempSync(join(tmpdir(), 'codeman-cli-marker-'));
const marker = join(dir, CLI_INSTALLED_MARKER);
try {
writeFileSync(marker, '');
expect(createProductionCliResolverHost({ installMarkerPath: marker }).installStamp?.()).toBe(0);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
it('invalidation drops a negative-cache backoff immediately (a CLI installed from Settings)', () => {
let now = 0;
const findInLoginShell = vi.fn<() => string | null>().mockReturnValueOnce(null).mockReturnValue('/new/bin/grokx');
+736
View File
@@ -0,0 +1,736 @@
/**
* @fileoverview The graphical install's screens in the Codeman Desktop connect page.
*
* Runs the real packages/desktop/assets/connect.html in jsdom with a fake native side:
* `window.ipc` records what the page asks the app for, and `onNative` is what the app
* calls. The Rust side of the same messages is pinned in packages/desktop/src/policy.rs
* (`ipc_graphical_install_commands`) and src/gui_install.rs, which do not build on Linux
* CI, so the spelling of every message the page sends is asserted here.
*
* Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { JSDOM } from 'jsdom';
import { afterEach, describe, expect, it } from 'vitest';
const HTML = readFileSync(resolve(import.meta.dirname, '../packages/desktop/assets/connect.html'), 'utf8');
type Sent = { cmd: string; [key: string]: unknown };
type Cli = { id: string; label: string; installed: boolean; installable: boolean; docs: string };
const cli = (id: string, label: string, over: Partial<Cli> = {}): Cli => ({
id,
label,
installed: false,
installable: true,
docs: `https://example.org/${id}`,
...over,
});
const plan = (over: Record<string, unknown> = {}) => ({
schema: 1,
os: 'macos',
arch: 'arm64',
blocker: '',
needs: { node: true, tmux: true, git: false },
clis: [cli('claude', 'Claude Code'), cli('codex', 'Codex'), cli('deepseek', 'DeepSeek', { installable: false })],
...over,
});
const NOT_INSTALLED = {
url: 'http://127.0.0.1:3000/',
state: 'notInstalled',
installed: false,
installComplete: false,
installRunning: false,
installInApp: false,
elsewhere: [],
outcome: { kind: 'refused' },
};
let dom: JSDOM | null = null;
/** `first`: what the app says to a page that just loaded, with no look at this Mac yet. */
function open(first?: Record<string, unknown>) {
const sent: Sent[] = [];
dom = new JSDOM(HTML, {
url: 'codemanapp://localhost/index.html',
runScripts: 'dangerously',
beforeParse(window) {
(window as unknown as { ipc: unknown }).ipc = { postMessage: (m: string) => sent.push(JSON.parse(m)) };
},
});
const window = dom.window as unknown as Window & {
__codemanDesktop: { onNative(m: unknown): void };
};
const doc = window.document;
const $ = (id: string) => doc.getElementById(id) as HTMLElement;
const input = (id: string) => $(id) as HTMLInputElement;
const native = (m: unknown) => window.__codemanDesktop.onNative(m);
const click = (id: string) => $(id).click();
const visible = (id: string) => {
const el = $(id);
if (el.hidden) return false;
for (let p = el.parentElement; p; p = p.parentElement) if (p.hidden) return false;
return true;
};
const rail = () =>
Array.from(doc.querySelectorAll('#wiz-rail li')).map((li) => [li.querySelector('span')?.textContent, li.className]);
const sentOf = (cmd: string) => sent.filter((m) => m.cmd === cmd);
native({ type: 'init', servers: [], autoConnect: false, startFullscreen: false, version: '0.1.0', ...first });
if (!first) native({ type: 'localStatus', status: NOT_INSTALLED });
return { sent, sentOf, doc, $, input, native, click, visible, rail };
}
/** Open the wizard and answer its first question with `p`. */
function toPlan(p = plan()) {
const t = open();
t.click('local-install');
t.native({ type: 'installPlan', plan: p });
return t;
}
afterEach(() => {
dom?.window.close();
dom = null;
});
describe('starting the graphical install', () => {
it('asks the app for the installer instead of opening Terminal', () => {
const t = open();
t.click('local-install');
expect(t.sentOf('installPrepare')).toHaveLength(1);
expect(t.sentOf('installLocal')).toHaveLength(0);
expect(t.$('wiz-title').textContent).toBe('Looking at this Mac');
expect(t.visible('wizard')).toBe(true);
});
it('hands the whole card to the install', () => {
const t = open();
t.click('local-install');
expect(t.doc.querySelector('.card')?.classList.contains('wizard-on')).toBe(true);
});
it('goes back to the tabs from any question', () => {
const t = toPlan();
t.click('wiz-back');
expect(t.visible('wizard')).toBe(false);
expect(t.doc.querySelector('.card')?.classList.contains('wizard-on')).toBe(false);
});
it('offers no way out while preparing, so two prepares never share the folder', () => {
const t = open();
t.click('local-install');
expect(t.visible('wiz-back')).toBe(false);
expect(t.visible('wiz-next')).toBe(false);
});
it('explains a failed download and offers Try again and Terminal', () => {
const t = open();
t.click('local-install');
t.native({ type: 'installPrepareFailed', message: 'Could not download the installer.' });
expect(t.$('wiz-message').textContent).toBe('Could not download the installer.');
expect(t.visible('wiz-terminal')).toBe(true);
t.click('wiz-next');
expect(t.sentOf('installPrepare')).toHaveLength(2);
});
});
describe('choosing a coding agent', () => {
it('asks when this Mac has none, listing what the installer can install, first one chosen', () => {
const t = toPlan();
expect(t.$('wiz-title').textContent).toBe('Choose a coding agent');
const radios = Array.from(t.doc.querySelectorAll<HTMLInputElement>('#wiz-agents input'));
expect(radios.map((r) => r.value)).toEqual(['claude', 'codex']);
expect(radios.map((r) => r.checked)).toEqual([true, false]);
});
it('does not ask when one is already there, and names it instead', () => {
const t = toPlan(
plan({ clis: [cli('claude', 'Claude Code', { installed: true, installable: false }), cli('codex', 'Codex')] })
);
expect(t.$('wiz-title').textContent).toBe('Who can reach Codeman?');
expect(t.$('wiz-summary').textContent).toContain('Uses Claude Code, already on this Mac');
t.click('wiz-back');
expect(t.visible('wizard')).toBe(false);
});
it('sends no agent then, so the installer never adds one', () => {
const t = toPlan(
plan({ clis: [cli('claude', 'Claude Code', { installed: true, installable: false }), cli('codex', 'Codex')] })
);
t.click('wiz-next');
expect(t.sentOf('installStart')[0].aiCli).toBeUndefined();
});
it('skips the question when nothing can be installed', () => {
const t = toPlan(plan({ clis: [cli('deepseek', 'DeepSeek', { installable: false })] }));
expect(t.$('wiz-title').textContent).toBe('Who can reach Codeman?');
expect(t.$('wiz-summary').textContent).toContain('No coding agent yet');
});
it('installs the one that was picked', () => {
const t = toPlan();
const codex = t.doc.querySelector<HTMLInputElement>('#wiz-agents input[value=codex]')!;
codex.click();
expect(t.$('wiz-agent-note').textContent).toContain('You sign in to Codex');
t.click('wiz-next');
expect(t.$('wiz-summary').textContent).toContain('Installs Codex');
t.click('wiz-next');
expect(t.sentOf('installStart')).toEqual([{ cmd: 'installStart', aiCli: 'codex' }]);
});
it('Skip for now installs none, and says what that means', () => {
const t = toPlan();
t.click('wiz-skip');
expect(t.$('wiz-summary').textContent).toContain('No coding agent yet');
t.click('wiz-next');
expect(t.sentOf('installStart')).toEqual([{ cmd: 'installStart' }]);
});
it('Back from the next question returns to the choice, and keeps it', () => {
const t = toPlan();
t.doc.querySelector<HTMLInputElement>('#wiz-agents input[value=codex]')!.click();
t.click('wiz-next');
t.click('wiz-back');
expect(t.$('wiz-title').textContent).toBe('Choose a coding agent');
expect(t.doc.querySelector<HTMLInputElement>('#wiz-agents input:checked')?.value).toBe('codex');
});
it('opens an agent docs link through the app, and only https', () => {
const t = toPlan();
t.doc.querySelector<HTMLAnchorElement>('#wiz-agent-note a')!.click();
expect(t.sentOf('openExternal')).toEqual([{ cmd: 'openExternal', url: 'https://example.org/claude' }]);
const u = toPlan(plan({ clis: [cli('x', 'X', { docs: 'javascript:alert(1)' })] }));
expect(u.doc.querySelector('#wiz-agent-note a')).toBeNull();
});
it('shows a label from the installer as text, never as markup', () => {
const t = toPlan(plan({ clis: [cli('x', '<img src=x onerror=alert(1)>')] }));
expect(t.doc.querySelector('#wiz-agents img')).toBeNull();
expect(t.$('wiz-agents').textContent).toContain('<img');
});
});
describe('choosing who can reach Codeman', () => {
const toReach = () => {
const t = toPlan();
t.click('wiz-next');
return t;
};
const network = (t: ReturnType<typeof open>) =>
t.doc.querySelector<HTMLInputElement>('input[name=reach][value=network]')!.click();
it('is this Mac alone unless changed, with no password', () => {
const t = toReach();
expect(t.visible('wiz-password')).toBe(false);
t.click('wiz-next');
expect(t.sentOf('installStart')[0].networkPassword).toBeUndefined();
});
it('refuses the network without a password', () => {
const t = toReach();
network(t);
expect(t.visible('wiz-password')).toBe(true);
t.click('wiz-next');
expect(t.sentOf('installStart')).toHaveLength(0);
expect(t.$('wiz-message').textContent).toContain('Set a password');
});
it('refuses two different passwords', () => {
const t = toReach();
network(t);
t.input('wiz-pw').value = 'one';
t.input('wiz-pw2').value = 'two';
t.click('wiz-next');
expect(t.sentOf('installStart')).toHaveLength(0);
expect(t.$('wiz-message').textContent).toContain('different');
});
it('sends the password once and clears the fields', () => {
const t = toReach();
network(t);
t.input('wiz-pw').value = 's3cret';
t.input('wiz-pw2').value = 's3cret';
t.click('wiz-next');
expect(t.sentOf('installStart')).toEqual([{ cmd: 'installStart', networkPassword: 's3cret', aiCli: 'claude' }]);
expect(t.input('wiz-pw').value).toBe('');
expect(t.input('wiz-pw2').value).toBe('');
});
it('never sends a password a person typed before going back to this Mac', () => {
const t = toReach();
network(t);
t.input('wiz-pw').value = 's3cret';
t.input('wiz-pw2').value = 's3cret';
t.doc.querySelector<HTMLInputElement>('input[name=reach][value=local]')!.click();
t.click('wiz-next');
expect(t.sentOf('installStart')[0].networkPassword).toBeUndefined();
});
it('keeps the Terminal route, carrying the choice', () => {
const t = toReach();
network(t);
t.click('wiz-terminal');
expect(t.sentOf('installLocal')).toEqual([{ cmd: 'installLocal', network: true }]);
expect(t.visible('wizard')).toBe(false);
});
});
describe('when the no-admin route cannot finish here', () => {
it('says why and sends the person to Terminal', () => {
const t = toPlan(plan({ blocker: 'The tmux project’s build needs macOS 15 or newer.' }));
expect(t.$('wiz-message').textContent).toContain('macOS 15');
expect(t.$('wiz-next').textContent).toBe('Install in Terminal');
expect(t.visible('wiz-agent-step')).toBe(false);
t.click('wiz-next');
expect(t.sentOf('installLocal')).toHaveLength(1);
expect(t.sentOf('installStart')).toHaveLength(0);
});
});
describe('following the install', () => {
const toRun = (p = plan()) => {
const t = toPlan(p);
t.click('wiz-next');
t.click('wiz-next');
t.native({ type: 'installRunning', port: 3001 });
return t;
};
it('draws only the phases this install has', () => {
const noDownloads = toRun(plan({ needs: { node: false, tmux: false, git: false } }));
expect(noDownloads.rail().map((r) => r[0])).toEqual([
'Installing Claude Code',
'Downloading Codeman',
'Installing packages',
'Building Codeman',
'Starting Codeman',
]);
const tmuxOnly = toRun(plan({ needs: { node: false, tmux: true, git: false } }));
expect(tmuxOnly.rail()[0][0]).toBe('Getting tmux');
});
it('moves the rail along as the installer reports, and ignores a phase it does not know', () => {
const t = toRun();
t.native({ type: 'installUpdate', phase: 'runtime', log: [] });
t.native({ type: 'installUpdate', phase: 'packages', log: ['==> Installing dependencies...'] });
t.native({ type: 'installUpdate', phase: 'somethingnew', log: [] });
expect(t.rail()).toEqual([
['Getting Node.js and tmux', 'done'],
['Installing Claude Code', 'done'],
['Downloading Codeman', 'done'],
['Installing packages', 'now'],
['Building Codeman', ''],
['Starting Codeman', ''],
]);
expect(t.$('wiz-log').textContent).toContain('Installing dependencies');
expect(t.$('wiz-clock')).not.toBeNull();
});
it('asks the app to cancel, once', () => {
const t = toRun();
t.click('wiz-cancel');
t.click('wiz-cancel');
expect(t.sentOf('installCancel')).toHaveLength(1);
});
it('a cancelled run goes back to the tabs and looks again', () => {
const t = toRun();
t.native({ type: 'installFinished', ok: false, cancelled: true, error: null, log: [] });
expect(t.visible('wizard')).toBe(false);
expect(t.sentOf('localStatus').length).toBeGreaterThan(0);
});
it('a failed run shows the cause on its phase, opens the output and offers Try again', () => {
const t = toRun();
t.native({ type: 'installUpdate', phase: 'build', log: [] });
t.native({
type: 'installFinished',
ok: false,
cancelled: false,
error: 'Building Codeman failed (exit 1).',
log: ['npm error'],
});
expect(t.$('wiz-title').textContent).toBe('The install stopped');
expect(t.$('wiz-message').textContent).toBe('Building Codeman failed (exit 1).');
expect(t.rail().find((r) => r[1] === 'failed')?.[0]).toBe('Building Codeman');
expect((t.$('wiz-details') as HTMLDetailsElement).open).toBe(true);
expect(t.$('wiz-log').textContent).toContain('npm error');
t.click('wiz-next');
expect(t.sentOf('installPrepare')).toHaveLength(2);
});
it('Try again keeps what was chosen', () => {
const t = toPlan();
t.doc.querySelector<HTMLInputElement>('#wiz-agents input[value=codex]')!.click();
t.click('wiz-next');
t.click('wiz-next');
t.native({ type: 'installFinished', ok: false, cancelled: false, error: 'x', log: [] });
t.click('wiz-next');
t.native({ type: 'installPlan', plan: plan() });
expect(t.doc.querySelector<HTMLInputElement>('#wiz-agents input:checked')?.value).toBe('codex');
});
it('a run with no error text still says something', () => {
const t = toRun();
t.native({ type: 'installFinished', ok: false, cancelled: false, error: null, log: [] });
expect(t.$('wiz-message').textContent).toContain('without saying why');
});
it('a start the app refused is shown as a failure', () => {
const t = toPlan();
t.click('wiz-next');
t.click('wiz-next');
t.native({ type: 'installStartFailed', message: 'Codeman is already being installed.' });
expect(t.$('wiz-message').textContent).toBe('Codeman is already being installed.');
});
it('a finished run opens Codeman at its own port, not at whatever answers on 3000', () => {
const t = toRun();
t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
expect(t.$('wiz-title').textContent).toBe('Starting Codeman');
expect(t.rail().every((r) => r[1] === 'done')).toBe(true);
expect(t.sentOf('localStatus').at(-1)?.quick).toBe(true);
const running = { ...NOT_INSTALLED, state: 'running', version: '1.48.0', installed: true };
t.native({ type: 'localStatus', status: { ...running, url: 'http://127.0.0.1:3000/' } });
expect(t.sentOf('connect')).toHaveLength(0);
t.native({ type: 'localStatus', status: { ...running, url: 'http://127.0.0.1:3001/' } });
expect(t.sentOf('connect')).toEqual([expect.objectContaining({ url: 'http://127.0.0.1:3001/' })]);
expect(t.visible('wizard')).toBe(false);
});
});
describe('an install that was already running', () => {
it('is picked up from the app, with no question asked', () => {
const t = open();
t.native({
type: 'localStatus',
status: { ...NOT_INSTALLED, installRunning: true, installInApp: true, installPort: 3002 },
});
expect(t.sentOf('installAttach')).toHaveLength(1);
expect(t.$('wiz-title').textContent).toBe('Installing Codeman');
expect(t.visible('wiz-cancel')).toBe(true);
});
it('asks only once, however many looks arrive', () => {
const t = open();
const status = { ...NOT_INSTALLED, installRunning: true, installInApp: true };
t.native({ type: 'localStatus', status });
t.native({ type: 'localStatus', status });
expect(t.sentOf('installAttach')).toHaveLength(1);
});
it('draws what the installer reports, since it has no plan', () => {
const t = open();
t.native({ type: 'localStatus', status: { ...NOT_INSTALLED, installRunning: true, installInApp: true } });
t.native({ type: 'installUpdate', phase: 'agent', log: [] });
t.native({ type: 'installUpdate', phase: 'build', log: [] });
expect(t.rail().map((r) => r[0])).toContain('Installing a coding agent');
expect(t.rail().find((r) => r[1] === 'now')?.[0]).toBe('Building Codeman');
});
it('never takes a graphical install for one in Terminal', () => {
const t = open();
t.native({ type: 'localStatus', status: { ...NOT_INSTALLED, installRunning: true, installInApp: true } });
expect(t.$('local-title').textContent).not.toContain('Terminal');
expect(t.visible('local-stop')).toBe(false);
});
it('an install still running in Terminal keeps the old wait', () => {
const t = open();
t.native({ type: 'localStatus', status: { ...NOT_INSTALLED, installRunning: true, installInApp: false } });
expect(t.sentOf('installAttach')).toHaveLength(0);
expect(t.$('local-title').textContent).toContain('Terminal');
});
});
describe('adding a coding agent to a Mac that has Codeman', () => {
const RUNNING = { ...NOT_INSTALLED, state: 'running', version: '1.50.0', installed: true, installComplete: true };
const STOPPED = { ...NOT_INSTALLED, state: 'stopped', installed: true, installComplete: true };
const AGENTS = {
clis: [
cli('claude', 'Claude Code', { installed: true, installable: false }),
cli('codex', 'Codex'),
cli('copilot', 'GitHub Copilot'),
cli('deepseek', 'DeepSeek', { installable: false }),
],
};
function chooser(status: Record<string, unknown> = RUNNING, over: Record<string, unknown> = AGENTS) {
const t = open();
t.native({ type: 'localStatus', status });
t.click('local-agents');
t.native({ type: 'installPlan', plan: plan({ needs: { node: false, tmux: false, git: false }, ...over }) });
return t;
}
function installing(status: Record<string, unknown> = RUNNING) {
const t = chooser(status);
t.click('wiz-next');
t.native({ type: 'installUpdate', phase: 'agent', log: ['Installing Codex...'] });
return t;
}
it('is offered whenever Codeman is here, and never when it is not', () => {
const t = open();
t.native({ type: 'localStatus', status: NOT_INSTALLED });
expect(t.visible('local-agents')).toBe(false);
for (const status of [
RUNNING,
STOPPED,
{ ...RUNNING, state: 'needsLogin' },
{ ...RUNNING, state: 'runningElsewhere', elsewhere: ['127.0.0.1:4000'] },
]) {
t.native({ type: 'localStatus', status });
expect(t.visible('local-agents'), String(status.state)).toBe(true);
}
});
it('is not offered while an install in Terminal still runs', () => {
const t = open();
t.native({ type: 'localStatus', status: { ...STOPPED, installRunning: true } });
expect(t.visible('local-agents')).toBe(false);
});
it('asks the app for the agent route, never the install route', () => {
const t = open();
t.native({ type: 'localStatus', status: RUNNING });
t.click('local-agents');
expect(t.sentOf('agentPrepare')).toHaveLength(1);
expect(t.sentOf('installPrepare')).toHaveLength(0);
expect(t.$('wiz-title').textContent).toBe('Looking at this Mac');
expect(t.visible('wiz-back')).toBe(false); // A second prepare would clear the first one's folder.
});
it('lists what can be added first, what is here after it, and leaves out the rest', () => {
const t = chooser();
expect(t.$('wiz-title').textContent).toBe('Add a coding agent');
const rows = [...t.$('wiz-agents').querySelectorAll('label')];
expect(rows.map((r) => r.querySelector('b')?.textContent)).toEqual(['Codex', 'GitHub Copilot', 'Claude Code']);
const inputs = rows.map((r) => r.querySelector('input') as HTMLInputElement);
expect(inputs.map((i) => i.disabled)).toEqual([false, false, true]);
expect(rows[2].textContent).toContain('Installed');
expect(inputs[0].checked).toBe(true);
});
it('asks nothing about Codeman, whatever the installer says about installing it', () => {
const t = chooser(RUNNING, { ...AGENTS, blocker: 'macOS is too old for the tmux download.' });
expect(t.$('wiz-title').textContent).toBe('Add a coding agent');
expect(t.visible('wiz-reach-step')).toBe(false);
expect(t.visible('wiz-skip')).toBe(false);
expect(t.visible('wiz-terminal')).toBe(false);
});
it('sends the one agent chosen, and names it on the button', () => {
const t = chooser();
expect(t.$('wiz-next').textContent).toBe('Install Codex');
const copilot = t.$('wiz-agents').querySelector('input[value=copilot]') as HTMLInputElement;
copilot.click();
expect(t.$('wiz-next').textContent).toBe('Install GitHub Copilot');
t.click('wiz-next');
expect(t.sentOf('agentStart')).toEqual([{ cmd: 'agentStart', aiCli: 'copilot' }]);
expect(t.sentOf('installStart')).toHaveLength(0);
expect(t.$('wiz-title').textContent).toBe('Installing GitHub Copilot');
});
it('draws one row, for that agent', () => {
const t = installing();
expect(t.rail()).toEqual([['Installing Codex', 'now']]);
expect(t.visible('wiz-cancel')).toBe(true);
});
it('ends on what happened, and opens Codeman only when asked', () => {
const t = installing();
t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
expect(t.$('wiz-title').textContent).toBe('Codex is installed');
expect(t.$('wiz-lead').textContent).toContain('reload it, or give it a few minutes');
expect(t.rail()).toEqual([['Installing Codex', 'done']]);
expect(t.sentOf('localStatus').length).toBeGreaterThan(0);
expect(t.sentOf('connect')).toHaveLength(0);
t.native({ type: 'localStatus', status: RUNNING });
expect(t.$('wiz-title').textContent).toBe('Codex is installed');
expect(t.$('wiz-next').textContent).toBe('Open Codeman');
expect(t.$('wiz-back').textContent).toBe('Done');
t.click('wiz-next');
expect(t.sentOf('connect')).toEqual([expect.objectContaining({ url: RUNNING.url })]);
expect(t.visible('wizard')).toBe(false);
});
it('has nothing to open when Codeman is stopped, and Done goes back to This Mac', () => {
const t = installing(STOPPED);
t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
t.native({ type: 'localStatus', status: STOPPED });
expect(t.visible('wiz-next')).toBe(false);
t.click('wiz-back');
expect(t.visible('wizard')).toBe(false);
expect(t.visible('local-start')).toBe(true);
expect(t.visible('local-agents')).toBe(true);
});
it('offers another only when there is another, and asks the installer again', () => {
const t = installing();
t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
expect(t.$('wiz-skip').textContent).toBe('Add another');
t.click('wiz-skip');
expect(t.sentOf('agentPrepare')).toHaveLength(2);
expect(t.$('wiz-title').textContent).toBe('Looking at this Mac');
const last = chooser(RUNNING, { clis: [cli('codex', 'Codex')] });
last.click('wiz-next');
last.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
expect(last.visible('wiz-skip')).toBe(false);
});
it('says so when there is nothing left to add', () => {
const t = chooser(RUNNING, { clis: [cli('claude', 'Claude Code', { installed: true, installable: false })] });
expect(t.$('wiz-lead').textContent).toContain('already on this Mac');
expect(t.visible('wiz-next')).toBe(false);
expect(t.visible('wiz-back')).toBe(true);
t.click('wiz-back');
expect(t.visible('wizard')).toBe(false);
});
it('a failure shows the reason, and Try again asks again with the same choice', () => {
const t = chooser();
(t.$('wiz-agents').querySelector('input[value=copilot]') as HTMLInputElement).click();
t.click('wiz-next');
t.native({
type: 'installFinished',
ok: false,
cancelled: false,
error: 'GitHub Copilot did not install.',
log: ['npm ERR! nope'],
});
expect(t.$('wiz-title').textContent).toBe('The install stopped');
expect(t.$('wiz-message').textContent).toBe('GitHub Copilot did not install.');
expect(t.$('wiz-log').textContent).toContain('npm ERR! nope');
expect(t.visible('wiz-terminal')).toBe(false);
expect(t.visible('wiz-back')).toBe(true);
t.click('wiz-next');
expect(t.sentOf('agentPrepare')).toHaveLength(2);
t.native({ type: 'installPlan', plan: plan(AGENTS) });
expect(t.$('wiz-next').textContent).toBe('Install GitHub Copilot');
});
it('a list it could not get says so, with no Terminal route to Codeman', () => {
const t = open();
t.native({ type: 'localStatus', status: RUNNING });
t.click('local-agents');
t.native({ type: 'installPrepareFailed', message: 'No network.' });
expect(t.$('wiz-title').textContent).toBe('Could not get the list of coding agents');
expect(t.$('wiz-message').textContent).toBe('No network.');
expect(t.visible('wiz-terminal')).toBe(false);
t.click('wiz-next');
expect(t.sentOf('agentPrepare')).toHaveLength(2);
});
it('Cancel goes through the same command and leaves This Mac as it was', () => {
const t = installing();
t.click('wiz-cancel');
expect(t.sentOf('installCancel')).toHaveLength(1);
t.native({ type: 'installFinished', ok: false, cancelled: true, error: null, log: [] });
expect(t.visible('wizard')).toBe(false);
expect(t.$('status-text').textContent).toContain('Stopped installing Codex.');
});
it('is picked up after the app was reopened, as an agent run and never as an install', () => {
const t = open();
t.native({ type: 'localStatus', status: { ...RUNNING, agentInstallInApp: true } });
expect(t.sentOf('agentAttach')).toHaveLength(1);
expect(t.sentOf('installAttach')).toHaveLength(0);
expect(t.$('wiz-title').textContent).toBe('Installing a coding agent');
t.native({ type: 'installUpdate', phase: 'agent', log: [] });
expect(t.rail()).toEqual([['Installing a coding agent', 'now']]);
t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
expect(t.$('wiz-title').textContent).toBe('The coding agent is installed');
});
});
describe('the menu item that adds a coding agent', () => {
const RUNNING = { ...NOT_INSTALLED, state: 'running', version: '1.50.0', installed: true, installComplete: true };
it('opens the chooser on a page that is up', () => {
const t = open();
t.native({ type: 'localStatus', status: RUNNING });
t.native({ type: 'addAgent' });
expect(t.sentOf('agentPrepare')).toHaveLength(1);
});
it('waits for the first look when it left a Codeman page to get here', () => {
const t = open({ servers: [{ url: 'http://example.org:3000/' }], addAgent: true });
expect(t.sentOf('agentPrepare')).toHaveLength(0);
t.native({ type: 'localStatus', status: RUNNING });
expect(t.sentOf('agentPrepare')).toHaveLength(1);
expect(t.$('tab-local').getAttribute('aria-selected')).toBe('true');
});
it('leaves a Mac without Codeman on This Mac, with Install', () => {
const t = open({ addAgent: true });
t.native({ type: 'localStatus', status: NOT_INSTALLED });
expect(t.sentOf('agentPrepare')).toHaveLength(0);
expect(t.visible('wizard')).toBe(false);
expect(t.visible('local-install')).toBe(true);
});
it('never replaces a run on screen', () => {
const t = open();
t.native({ type: 'localStatus', status: { ...RUNNING, agentInstallInApp: true } });
t.native({ type: 'addAgent' });
expect(t.sentOf('agentPrepare')).toHaveLength(0);
});
});
describe('after Skip for now', () => {
function skipped() {
const t = open();
t.native({ type: 'localStatus', status: NOT_INSTALLED });
t.click('local-install');
t.native({ type: 'installPlan', plan: plan() });
t.click('wiz-skip');
t.click('wiz-next');
t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
t.native({
type: 'localStatus',
status: { ...NOT_INSTALLED, state: 'running', installed: true, version: '1.50.0' },
});
return t;
}
it('stops at Codeman being ready instead of opening it with nothing to run', () => {
const t = skipped();
expect(t.$('wiz-title').textContent).toBe('Codeman is ready');
expect(t.$('wiz-lead').textContent).toContain('no coding agent');
expect(t.sentOf('connect')).toHaveLength(0);
});
it('Open Codeman opens it anyway', () => {
const t = skipped();
t.click('wiz-next');
expect(t.sentOf('connect')).toHaveLength(1);
});
it('Add a coding agent goes to the chooser', () => {
const t = skipped();
expect(t.$('wiz-skip').textContent).toBe('Add a coding agent');
t.click('wiz-skip');
expect(t.sentOf('agentPrepare')).toHaveLength(1);
});
it('an install that got an agent still opens Codeman by itself', () => {
const t = open();
t.native({ type: 'localStatus', status: NOT_INSTALLED });
t.click('local-install');
t.native({ type: 'installPlan', plan: plan() });
t.click('wiz-next');
t.click('wiz-next');
t.native({ type: 'installFinished', ok: true, cancelled: false, error: null, log: [] });
t.native({
type: 'localStatus',
status: { ...NOT_INSTALLED, state: 'running', installed: true, version: '1.50.0' },
});
expect(t.sentOf('connect')).toHaveLength(1);
});
});
+6
View File
@@ -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<void>((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' });
+21
View File
@@ -18,6 +18,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { execFileSync, spawnSync } from 'node:child_process';
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
@@ -171,6 +172,26 @@ describe('resolveGitHooksDir (temp repos)', () => {
expect(resolveGitHooksDir(dir)).toBeNull();
});
it('never runs git for a directory that has no .git', () => {
// A source-archive or npm install. On a Mac without the Xcode Command Line Tools
// /usr/bin/git is a stub that opens Apple's install dialog, and postinstall calls this
// on every `npm install`, so "not a checkout" must be answered without spawning git.
const dir = join(scratch, `plain-${++counter}`);
const bin = join(scratch, `fakebin-${counter}`);
const ran = join(scratch, `git-ran-${counter}`);
mkdirSync(dir);
mkdirSync(bin);
writeFileSync(join(bin, 'git'), `#!/bin/sh\necho ran > "${ran}"\nexit 1\n`, { mode: 0o755 });
const path = process.env.PATH;
process.env.PATH = `${bin}:${path}`;
try {
expect(resolveGitHooksDir(dir)).toBeNull();
} finally {
process.env.PATH = path;
}
expect(existsSync(ran), 'git was executed for a directory with no .git').toBe(false);
});
it('returns null when a repo-local core.hooksPath points outside the repo', () => {
const repo = newRepo();
const outside = join(scratch, `shared-hooks-${counter}`);
+802 -1
View File
@@ -12,9 +12,13 @@
import { describe, expect, it } from 'vitest';
import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, 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 { CLI_INSTALLED_MARKER } from '../src/utils/cli-executable-resolver.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');
@@ -369,6 +373,396 @@ describe('install.sh AI CLI install menu', () => {
});
});
describe('install.sh --ai-cli answers the menu up front', () => {
// The desktop app installs with no terminal, so the agent is chosen in its window and
// arrives as a flag. Everything here runs the real functions in a real bash, with
// detection pointed at nothing, every trusted command rewritten to record which entry
// ran, and no tty, which is what the app's child process has.
const DRIVER = `
set -euo pipefail
export CODEMAN_INSTALL_SH_LIB=1
. "$1"
set +e
parse_flags "\${@:2}" || exit 9
set -e
k=0; while [[ $k -lt \${#CLI_ALL_BINS[@]} ]]; do CLI_ALL_BINS[$k]="codeman-test-no-such-bin-$k"; k=$((k + 1)); done
k=0; while [[ $k -lt \${#CLI_ALL_PATHS[@]} ]]; do CLI_ALL_PATHS[$k]="/nonexistent/codeman-test/$k"; k=$((k + 1)); done
k=0; while [[ $k -lt \${#CLI_INSTALL_CMD_TRUSTED[@]} ]]; do
if [[ -n "\${CLI_INSTALL_CMD_TRUSTED[$k]}" ]]; then
if [[ "\${KEEP_BREW:-}" == "1" && "\${CLI_INSTALL_CMD_TRUSTED[$k]}" == brew\\ * ]]; then :; else
CLI_INSTALL_CMD_TRUSTED[$k]="echo RAN=\${CLI_IDS[$k]}"
fi
fi
k=$((k + 1))
done
CLI_DETECT_DONE=""
detect_all_clis
NONINTERACTIVE=\${FORCE_NONINTERACTIVE:-0}
DOWNLOADER=curl
has_tty() { return 1; }
offer_ai_cli_install
echo "REACHED THE STEP AFTER THE MENU"
`;
function drive(args: string[], env: Record<string, string> = {}) {
const result = spawnSync('bash', ['-c', DRIVER, 'bash', INSTALL_SH, ...args], {
encoding: 'utf-8',
timeout: 30_000,
env: { ...process.env, ...env },
});
const stdout = result.stdout ?? '';
// Anchored: the skip hints print each command's text, so `echo RAN=x` there ran nothing.
const ran = [...stdout.matchAll(/^RAN=(.+)$/gm)].map((m) => m[1]);
return { status: result.status, stdout, stderr: result.stderr ?? '', ran };
}
it('none installs nothing and carries on, even with CODEMAN_NONINTERACTIVE', () => {
const run = drive(['--ai-cli', 'none'], { FORCE_NONINTERACTIVE: '1' });
expect(run.stdout, run.stderr).toContain('REACHED THE STEP AFTER THE MENU');
expect(run.ran).toEqual([]);
expect(run.stderr).toContain('Skipping AI CLI install');
});
it('a named CLI runs that entry and no other, without the menu and without a terminal', () => {
const run = drive(['--ai-cli=codex']);
expect(run.ran).toEqual(['codex']);
expect(run.stdout).not.toContain('Which AI CLI');
expect(run.status).toBe(1); // The stand-in command leaves no binary: the gate below still holds.
expect(run.stderr).toContain('The selected AI CLI failed to install');
});
it('takes the same answer from CODEMAN_AI_CLI', () => {
expect(drive([], { CODEMAN_AI_CLI: 'gemini' }).ran).toEqual(['gemini']);
});
it('a CLI this host cannot install warns and carries on, never installing another', () => {
const run = drive(['--no-admin', '--ai-cli', 'omp'], { KEEP_BREW: '1' });
if (process.platform === 'darwin') {
expect(run.ran).toEqual([]);
expect(run.stderr).toContain('omp cannot be installed automatically here');
expect(run.stdout).toContain('REACHED THE STEP AFTER THE MENU');
} else {
expect(run.ran).toEqual(['omp']); // Linux installs omp with curl, not brew.
}
});
it('deepseek has no command to run, so it warns and carries on', () => {
const run = drive(['--ai-cli', 'deepseek']);
expect(run.ran).toEqual([]);
expect(run.stderr).toContain('deepseek cannot be installed automatically here');
expect(run.stdout).toContain('REACHED THE STEP AFTER THE MENU');
});
it.each([['cluade'], ['shell'], ['--yes']])('refuses %s before anything runs', (id) => {
const run = drive([`--ai-cli=${id}`]);
expect(run.status).not.toBe(0);
expect(run.stderr).toContain('Unknown AI CLI');
expect(run.ran).toEqual([]);
});
it('a bare run with no terminal and no answer is still refused by the guard', () => {
const run = drive([]);
expect(run.status).toBe(1);
expect(run.stderr).toContain('No interactive terminal');
expect(run.ran).toEqual([]);
});
});
describe('install.sh preflight', () => {
function preflight(args: string[], env: Record<string, string> = {}) {
const home = mkdtempSync(join(tmpdir(), 'codeman-preflight-'));
try {
const result = spawnSync('bash', [INSTALL_SH, 'preflight', ...args], {
encoding: 'utf-8',
timeout: 30_000,
env: { ...process.env, HOME: home, ...env },
});
expect(result.status, result.stderr).toBe(0);
return { stdout: result.stdout, home };
} finally {
rmSync(home, { recursive: true, force: true });
}
}
it('prints one JSON object in the shape the desktop app reads', () => {
const { stdout } = preflight(['--no-admin']);
const found = JSON.parse(stdout);
expect(found.schema).toBe(1);
expect(found.noAdmin).toBe(true);
expect(typeof found.blocker).toBe('string');
expect(Object.keys(found.needs).sort()).toEqual(['git', 'node', 'tmux']);
for (const key of ['node', 'tmux', 'git']) expect(typeof found.needs[key]).toBe('boolean');
// Exactly what src/gui_install.rs deserializes, or the app calls the answer unreadable.
for (const cli of found.clis) {
expect(Object.keys(cli).sort()).toEqual(['docs', 'id', 'installable', 'installed', 'label']);
}
});
it('lists every agent of the catalogue, in its order, and never the shell', () => {
const ids = JSON.parse(preflight(['--no-admin']).stdout).clis.map((c: { id: string }) => c.id);
const agents = STOCK_CLIS.filter((c) => c.discovery.binaries.length > 0).sort((a, b) => a.order - b.order);
expect(ids).toEqual(agents.map((c) => c.id));
});
it('never calls an installed CLI installable, and agrees with the menu on the rest', () => {
for (const cli of JSON.parse(preflight(['--no-admin']).stdout).clis) {
if (cli.installed) expect(cli.installable, cli.id).toBe(false);
}
const deepseek = JSON.parse(preflight(['--no-admin']).stdout).clis.find((c: { id: string }) => c.id === 'deepseek');
if (!deepseek.installed) expect(deepseek.installable).toBe(false); // A launcher alone cannot drive a pane.
});
it('writes nothing into the home folder', () => {
const home = mkdtempSync(join(tmpdir(), 'codeman-preflight-'));
try {
const result = spawnSync('bash', [INSTALL_SH, 'preflight', '--no-admin'], {
encoding: 'utf-8',
timeout: 30_000,
env: { ...process.env, HOME: home },
});
expect(result.status).toBe(0);
expect(readdirSync(home)).toEqual([]);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
it('json_string survives what a path or a message can hold', () => {
const driver = `
export CODEMAN_INSTALL_SH_LIB=1
. "$1"
set +e
json_string "$2"
`;
const text = 'quote " back\\slash tab\t line\nfeed bell\u0007 é ✓';
const result = spawnSync('bash', ['-c', driver, 'bash', INSTALL_SH, text], { encoding: 'utf-8', timeout: 30_000 });
expect(JSON.parse(result.stdout)).toBe(text.replace('\u0007', ''));
});
});
describe('install.sh agent, one AI CLI and nothing else', () => {
// The real subcommand in a real bash, with detection pointed at nothing and every
// trusted command rewritten: LEAVES names the binary a stand-in install leaves
// behind (on PATH, in the run's own folder), FOUND is a CLI already here.
const DRIVER = `
set -euo pipefail
export CODEMAN_INSTALL_SH_LIB=1
. "$1"
set +e
parse_flags "\${@:2}" || exit 9
set -e
bin=$(mktemp -d); trap 'rm -rf "$bin"' EXIT; export PATH="$bin:$PATH"
k=0; while [[ $k -lt \${#CLI_ALL_BINS[@]} ]]; do CLI_ALL_BINS[$k]="codeman-test-no-such-bin-$k"; k=$((k + 1)); done
k=0; while [[ $k -lt \${#CLI_ALL_PATHS[@]} ]]; do CLI_ALL_PATHS[$k]="/nonexistent/codeman-test/$k"; k=$((k + 1)); done
k=0; while [[ $k -lt \${#CLI_INSTALL_CMD_TRUSTED[@]} ]]; do
if [[ "\${KEEP_COMMANDS:-}" != "1" && -n "\${CLI_INSTALL_CMD_TRUSTED[$k]}" ]]; then
CLI_INSTALL_CMD_TRUSTED[$k]="echo RAN=\${CLI_IDS[$k]}; if [[ \${CLI_IDS[$k]} == \\"\${LEAVES:-}\\" ]]; then printf '#!/bin/sh\\n' > $bin/codeman-test-left-$k; chmod +x $bin/codeman-test-left-$k; fi"
fi
if [[ "\${CLI_IDS[$k]}" == "\${FOUND:-}" ]]; then
printf '#!/bin/sh\n' > "$bin/codeman-test-found"; chmod +x "$bin/codeman-test-found"
off=\${CLI_BIN_OFF[$k]}; CLI_ALL_BINS[$off]=codeman-test-found
fi
if [[ "\${CLI_IDS[$k]}" == "\${LEAVES:-}" ]]; then
off=\${CLI_BIN_OFF[$k]}; CLI_ALL_BINS[$off]=codeman-test-left-$k
fi
k=$((k + 1))
done
if [[ -n "\${NO_NPM:-}" ]]; then check_npm() { return 1; }; fi
CLI_DETECT_DONE=""
agent_subcommand
echo "REACHED THE END"
`;
function agent(args: string[], env: Record<string, string> = {}) {
const result = spawnSync('bash', ['-c', DRIVER, 'bash', INSTALL_SH, ...args], {
encoding: 'utf-8',
timeout: 30_000,
env: { ...process.env, CODEMAN_PROGRESS: '', ...env },
});
const stdout = result.stdout ?? '';
const stderr = result.stderr ?? '';
return { status: result.status, stdout, stderr, ran: [...stdout.matchAll(/^RAN=(.+)$/gm)].map((m) => m[1]) };
}
/** The same, in a home folder of its own, reporting whether the marker was written. */
function agentInHome(args: string[], env: Record<string, string> = {}) {
const home = mkdtempSync(join(tmpdir(), 'codeman-agent-marker-'));
try {
mkdirSync(join(home, '.codeman'));
const run = agent(args, { HOME: home, ...env });
return { ...run, marked: existsSync(join(home, '.codeman', CLI_INSTALLED_MARKER)) };
} finally {
rmSync(home, { recursive: true, force: true });
}
}
it('tells a running server when the agent landed, and only then', () => {
expect(agentInHome(['--ai-cli', 'codex', '--no-admin'], { LEAVES: 'codex' }).marked).toBe(true);
expect(agentInHome(['--ai-cli', 'codex', '--no-admin']).marked).toBe(false);
// Installed by hand a minute ago, and the server may still remember it as missing.
expect(agentInHome(['--ai-cli', 'codex', '--no-admin'], { FOUND: 'codex' }).marked).toBe(true);
});
it('a home folder with no Codeman in it is no failure', () => {
const home = mkdtempSync(join(tmpdir(), 'codeman-agent-marker-'));
try {
const run = agent(['--ai-cli', 'codex', '--no-admin'], { HOME: home, LEAVES: 'codex' });
expect(run.status, run.stderr).toBe(0);
expect(readdirSync(home)).toEqual([]);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
it('writes the file the server reads', () => {
expect(SOURCE).toContain(`"$HOME/.codeman/${CLI_INSTALLED_MARKER}"`);
});
it('installs the one it was told to, and says done', () => {
const run = agent(['--no-admin', '--progress', '--ai-cli', 'codex'], { LEAVES: 'codex' });
expect(run.status, run.stderr).toBe(0);
expect(run.ran).toEqual(['codex']);
expect(run.stderr).toContain('@@codeman phase agent');
expect(run.stderr).toContain('@@codeman phase done');
});
it('never says done when that agent did not land, even with another one here', () => {
const run = agent(['--no-admin', '--progress', '--ai-cli', 'codex'], { FOUND: 'copilot' });
expect(run.ran).toEqual(['codex']);
expect(run.status).toBe(1);
expect(run.stderr).toContain('@@codeman error Codex did not install.');
expect(run.stderr).not.toContain('@@codeman phase done');
expect(run.stdout).not.toContain('REACHED THE END');
});
it('says the same with no other agent here, the way a Mac is after Skip for now', () => {
const run = agent(['--no-admin', '--progress', '--ai-cli', 'codex']);
expect(run.ran).toEqual(['codex']);
expect(run.status).toBe(1);
expect(run.stderr).toContain('@@codeman error Codex did not install.');
expect(run.stderr).not.toContain('re-run the installer');
expect(run.stderr).not.toContain('@@codeman phase done');
});
it('installs nothing for an agent that is here already, and that is a success', () => {
const run = agent(['--no-admin', '--progress', '--ai-cli', 'codex'], { FOUND: 'codex' });
expect(run.status, run.stderr).toBe(0);
expect(run.ran).toEqual([]);
expect(run.stderr).toContain('@@codeman phase done');
});
it('names what is wrong when npm cannot be found, before running anything', () => {
const run = agent(['--no-admin', '--progress', '--ai-cli', 'codex'], { KEEP_COMMANDS: '1', NO_NPM: '1' });
expect(run.status).toBe(1);
expect(run.stderr).toContain('Codex installs with npm, and this run cannot find it');
expect(run.stderr).toContain('npm install -g @openai/codex');
expect(run.stderr).not.toContain('@@codeman phase done');
});
it('refuses an agent it cannot install, and installs no other in its place', () => {
const run = agent(['--no-admin', '--progress', '--ai-cli', 'deepseek'], { KEEP_COMMANDS: '1' });
expect(run.status).toBe(1);
expect(run.stderr).toContain('DeepSeek cannot be installed automatically here');
expect(run.stderr).not.toContain('@@codeman phase done');
});
it('it takes the name as a plain word, as a person would type it', () => {
const driver = `
export CODEMAN_INSTALL_SH_LIB=1
. "$1"
set +e
parse_flags "\${@:2}" || exit 9
echo "sub=$SUBCOMMAND cli=$AI_CLI_CHOICE"
`;
const parsed = (...args: string[]) =>
spawnSync('bash', ['-c', driver, 'bash', INSTALL_SH, ...args], { encoding: 'utf-8', timeout: 30_000 });
expect(parsed('agent', 'codex', '--no-admin').stdout).toContain('sub=agent cli=codex');
expect(parsed('agent', '--ai-cli', 'codex').stdout).toContain('sub=agent cli=codex');
expect(parsed('agent', 'codx').stderr).toContain('Unknown AI CLI: codx');
expect(parsed('agent', 'shell').stderr).toContain('Unknown AI CLI: shell');
expect(parsed('agent', 'codex', 'copilot').stderr).toContain('Unexpected argument: copilot');
expect(parsed('codex').stderr).toContain('Unexpected argument: codex');
});
it('wants a name, and none is not one', () => {
expect(agent([]).stderr).toContain('Name the AI CLI to install');
expect(agent(['--ai-cli', 'none']).stderr).toContain('Name the AI CLI to install');
});
it('leaves Codeman alone: nothing in it touches the service, the source or the build', () => {
const body = SOURCE.slice(
SOURCE.indexOf('\nagent_subcommand() {'),
SOURCE.indexOf('\n# Offer to install one AI CLI')
);
for (const word of ['launchctl', 'systemctl', 'setup_launchd', 'install_or_update_repo', 'npm run', 'sudo']) {
expect(body, word).not.toContain(word);
}
expect(body).toContain('run_offered_cli "$idx"');
});
});
describe('install.sh --progress, the protocol the desktop app reads', () => {
const CONNECT_HTML = readFileSync(
fileURLToPath(new URL('../packages/desktop/assets/connect.html', import.meta.url)),
'utf-8'
);
function mainBody() {
const start = SOURCE.indexOf('main() {');
const end = SOURCE.indexOf('# Phase 0: look', start);
return SOURCE.slice(start, end);
}
it('reports its phases in the order the app draws them, ending on done', () => {
const phases = [...mainBody().matchAll(/^\s*progress_phase (\w+)$/gm)].map((m) => m[1]);
const drawn = /var PHASES = \[([^\]]*)\]/.exec(CONNECT_HTML)?.[1].match(/\w+/g);
expect(phases).toEqual([...(drawn ?? []), 'done']);
});
it('reports done after the service step and before a foreground start replaces the shell', () => {
const main = mainBody();
expect(main.indexOf('progress_phase done')).toBeGreaterThan(main.indexOf('print_done_screen'));
expect(main.indexOf('progress_phase done')).toBeLessThan(main.indexOf('exec node'));
});
function driveProtocol(env: Record<string, string>) {
const driver = `
export CODEMAN_INSTALL_SH_LIB=1
. "$1"
set +e
progress_phase build
error "first
second"
`;
return spawnSync('bash', ['-c', driver, 'bash', INSTALL_SH], {
encoding: 'utf-8',
timeout: 30_000,
env: { ...process.env, ...env },
});
}
it('prints protocol lines on stderr, one line each, so a $( ) capture stays clean', () => {
const run = driveProtocol({ CODEMAN_PROGRESS: '1' });
expect(run.stdout).toBe('');
expect(run.stderr).toContain('@@codeman phase build\n');
expect(run.stderr).toContain('@@codeman error first second\n');
});
it('says nothing of it to a person at a terminal', () => {
expect(driveProtocol({ CODEMAN_PROGRESS: '0' }).stderr).not.toContain('@@codeman');
});
it('never prints the password', () => {
expect(mainBody()).not.toMatch(/progress_phase.*PASSWORD/);
expect(fnBody('error')).not.toMatch(/PASSWORD/);
});
function fnBody(name: string) {
const start = SOURCE.indexOf(`${name}() {`);
return SOURCE.slice(start, SOURCE.indexOf('\n}\n', start));
}
});
describe('install.sh detect_all_clis and a disabled entry', () => {
// No stock entry ships disabled today, so this is characterization rather than a regression
// pin on real data: it drives the real function in a real bash with entry 0 fabricated
@@ -536,3 +930,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<string, string>) => {
const r = drive(
`
NONINTERACTIVE=1; NO_ADMIN=1; NO_ADMIN_REASON=test
download() { echo "DOWNLOAD $1"; }
download_to_stdout() { echo "DOWNLOAD $1"; }
check_node() { return 1; }; check_tmux() { return 1; }; check_git() { return 0; }
detect_arch() { echo "$DRIVE_ARCH"; }
check_build_tools() { [[ "$DRIVE_TOOLS" == "1" ]]; }; missing_build_tools() { printf 'make, g++'; }
macos_major_version() { printf '%s' "$DRIVE_MACOS"; }
is_musl_libc() { [[ "$DRIVE_MUSL" == "1" ]]; }
ask_dependencies_no_admin "$DRIVE_OS"
`,
{ DRIVE_ARCH: 'x64', DRIVE_TOOLS: '1', DRIVE_MACOS: '26', DRIVE_MUSL: '0', ...env }
);
expect(r.status, 'the installer must stop').toBe(1);
expect(r.stdout + r.stderr, 'something was downloaded before the stop').not.toContain('DOWNLOAD');
return r.stderr;
};
expect(stop({ DRIVE_OS: 'linux', DRIVE_TOOLS: '0' })).toMatch(/make, g\+\+[\s\S]*administrator/);
expect(stop({ DRIVE_OS: 'macos', DRIVE_ARCH: 'arm64', DRIVE_MACOS: '14' })).toMatch(/macOS 15 or newer/);
expect(stop({ DRIVE_OS: 'linux', DRIVE_MUSL: '1' })).toMatch(/musl/);
expect(stop({ DRIVE_OS: 'linux', DRIVE_ARCH: 'armv7' })).toMatch(/no Node\.js download/);
});
it('picks the right Node.js tarball out of SHASUMS256.txt', () => {
const sha = (c: string) => c.repeat(64);
const sums = [
`${sha('a')} node-v22.23.4-aix-ppc64.tar.gz`,
`${sha('b')} node-v22.23.4-darwin-arm64.tar.gz`,
`${sha('c')} node-v22.23.4-darwin-arm64.tar.xz`,
`${sha('d')} node-v22.23.4-linux-x64-musl.tar.gz`,
`${sha('e')} node-v22.23.4-linux-x64.tar.gz`,
`nothex node-v22.23.4-linux-arm64.tar.gz`,
].join('\n');
const result = drive(
`
printf '%s\\n' "$SUMS" | pick_node_tarball linux-x64
printf '%s\\n' "$SUMS" | pick_node_tarball darwin-arm64
printf '%s\\n' "$SUMS" | pick_node_tarball linux-arm64 || echo "arm64=refused"
printf '%s\\n' "$SUMS" | pick_node_tarball linux-s390x || echo "s390x=none"
`,
{ SUMS: sums }
);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain(`${sha('e')} node-v22.23.4-linux-x64.tar.gz`);
expect(result.stdout).toContain(`${sha('b')} node-v22.23.4-darwin-arm64.tar.gz`);
expect(result.stdout).not.toContain('musl');
// A line whose checksum is not 64 hex digits is not a checksum to trust.
expect(result.stdout).toContain('arm64=refused');
expect(result.stdout).toContain('s390x=none');
});
it('refuses a download whose checksum does not match', () => {
const result = drive(`
f=$(mktemp); trap 'rm -f "$f"' EXIT
printf 'codeman' > "$f"
good=$(sha256_file "$f")
echo "len=\${#good}"
verify_sha256 "$f" "$good" && echo "match=ok"
verify_sha256 "$f" "\${good%?}x" && echo "tampered=accepted" || echo "tampered=refused"
verify_sha256 "$f" "" && echo "empty=accepted" || echo "empty=refused"
`);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain('len=64');
expect(result.stdout).toContain('match=ok');
expect(result.stdout).toContain('empty=refused');
// The last digit swapped for a character no digest contains.
expect(result.stdout).toContain('tampered=refused');
});
it('installs from an archive without git, then replaces that install beside itself', () => {
const scratch = mkdtempSync(join(tmpdir(), 'codeman-archive-install-'));
try {
const make = (version: string) => {
const tree = join(scratch, `src-${version}`, `Codeman-${version}`);
mkdirSync(join(tree, 'src'), { recursive: true });
writeFileSync(join(tree, 'package.json'), `{ "version": "${version}" }\n`);
writeFileSync(join(tree, 'src', `only-in-${version}.ts`), '');
const tarball = join(scratch, `${version}.tar.gz`);
const tar = spawnSync('tar', ['-czf', tarball, '-C', join(scratch, `src-${version}`), `Codeman-${version}`]);
expect(tar.status, String(tar.stderr)).toBe(0);
return tarball;
};
const first = make('1.0.0');
const second = make('2.0.0');
const app = join(scratch, 'app');
const script = `
LOG_FILE=/dev/null
check_git() { return 1; }
download() { cp "\${1#file://}" "$2"; }
run_step() { shift; "$@"; }
install_or_update_repo
echo "staging=[$SOURCE_STAGING] cwd=$(pwd)"
finish_source_swap
echo "after=$(pwd)"
`;
const run = (tarball: string) =>
drive(script, { CODEMAN_INSTALL_DIR: app, CODEMAN_TARBALL_URL: `file://${tarball}`, HOME: scratch });
// First install: straight into the install directory, marked as an archive install.
const fresh = run(first);
expect(fresh.status, fresh.stderr).toBe(0);
expect(fresh.stdout).toContain('staging=[]');
expect(readFileSync(join(app, 'package.json'), 'utf-8')).toContain('1.0.0');
expect(existsSync(join(app, ARCHIVE_INSTALL_MARKER))).toBe(true);
// Second run: the new tree is built BESIDE the old one, which stays whole until the swap.
writeFileSync(join(app, 'built-by-1.0.0'), '');
const update = run(second);
expect(update.status, update.stderr).toBe(0);
expect(update.stdout).toContain(`staging=[${app}.new] cwd=${app}.new`);
expect(update.stdout).toContain(`after=${app}`);
expect(readFileSync(join(app, 'package.json'), 'utf-8')).toContain('2.0.0');
// A whole replacement: what the old version had and the new one dropped is gone.
expect(existsSync(join(app, 'src', 'only-in-1.0.0.ts'))).toBe(false);
expect(existsSync(join(app, 'built-by-1.0.0'))).toBe(false);
expect(existsSync(join(app, ARCHIVE_INSTALL_MARKER))).toBe(true);
expect(existsSync(`${app}.new`)).toBe(false);
expect(existsSync(`${app}.old`)).toBe(false);
// A folder that is neither a checkout nor one of ours is never written into.
const foreign = join(scratch, 'foreign');
mkdirSync(foreign);
writeFileSync(join(foreign, 'notes.txt'), 'mine');
const refused = drive(script, {
CODEMAN_INSTALL_DIR: foreign,
CODEMAN_TARBALL_URL: `file://${first}`,
HOME: scratch,
});
expect(refused.status).toBe(1);
expect(refused.stderr).toContain('is not a Codeman install');
expect(readFileSync(join(foreign, 'notes.txt'), 'utf-8')).toBe('mine');
} finally {
rmSync(scratch, { recursive: true, force: true });
}
});
it('downloads from a subcommand that never ran the full flow', () => {
// DOWNLOADER used to be set only by main(), so `install.sh cloudflared` (and now the
// update of an archive install) died inside download() on an unbound variable.
const result = drive(`
unset DOWNLOADER
curl() { echo "FETCH $*"; }
wget() { echo "FETCH $*"; }
download https://example.invalid/a /tmp/never-written
unset DOWNLOADER
download_to_stdout https://example.invalid/b
`);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toMatch(/FETCH .*example\.invalid\/a/);
expect(result.stdout).toMatch(/FETCH .*example\.invalid\/b/);
});
it('builds the archive URL from the repository and branch, on GitHub only', () => {
const url = (env: NodeJS.ProcessEnv) => drive('source_tarball_url || echo REFUSED', env).stdout.trim();
expect(url({})).toBe('https://github.com/Ark0N/Codeman/archive/master.tar.gz');
expect(url({ CODEMAN_REPO_URL: 'https://github.com/someone/fork.git', CODEMAN_BRANCH: 'feat/x' })).toBe(
'https://github.com/someone/fork/archive/feat/x.tar.gz'
);
expect(url({ CODEMAN_REPO_URL: 'https://gitlab.com/someone/fork.git' })).toBe('REFUSED');
expect(url({ CODEMAN_REPO_URL: 'git@github.com:someone/fork.git' })).toBe('REFUSED');
expect(url({ CODEMAN_TARBALL_URL: 'https://mirror.example/codeman.tar.gz' })).toBe(
'https://mirror.example/codeman.tar.gz'
);
});
it('shares the archive marker name with the in-app updater', () => {
// install.sh writes it, src/web/self-update.ts reads it to tell such an install how to
// update. A rename on one side would send archive installs back to the npm advice.
expect(SOURCE).toContain(`SOURCE_MARKER="${ARCHIVE_INSTALL_MARKER}"`);
});
it('treats a completed archive install as an install: quiet update on a re-run', () => {
const dispatch = SOURCE.slice(SOURCE.indexOf('\nparse_flags "$@"'));
expect(dispatch).toMatch(
/-f "\$INSTALL_DIR\/\.install-complete" \]\] && \[\[ -d "\$INSTALL_DIR\/\.git" \|\| -f "\$INSTALL_DIR\/\$SOURCE_MARKER" \]\]/
);
const update = fn('update', '\nuninstall() {');
expect(update).toContain('! -f "$INSTALL_DIR/$SOURCE_MARKER"');
// The swap happens only after the build has succeeded.
expect(update.indexOf('finish_source_swap')).toBeGreaterThan(update.indexOf('npm run build'));
const main = fn(
'main',
'\n# ----------------------------------------------------------------------------\n# Phase 0'
);
expect(main.indexOf('finish_source_swap')).toBeGreaterThan(main.indexOf('npm run build'));
expect(main.indexOf('finish_source_swap')).toBeLessThan(main.indexOf('install_symlink'));
});
it('documents --no-admin where people look for flags, and resolves the route before any work', () => {
expect(fn('usage', '\n}\n')).toContain('--no-admin');
const dispatch = SOURCE.slice(SOURCE.indexOf('\nparse_flags "$@"'));
expect(dispatch.indexOf('resolve_admin_mode')).toBeGreaterThan(-1);
expect(dispatch.indexOf('resolve_admin_mode')).toBeLessThan(dispatch.lastIndexOf('case "$SUBCOMMAND" in'));
expect(dispatch.indexOf('adopt_userspace_runtime')).toBeLessThan(dispatch.indexOf('resolve_admin_mode'));
});
it('puts the downloaded runtime on the LaunchAgent PATH', () => {
// The agent's PATH is a fixed list (Homebrew, /usr/local, ~/.local/bin, the system). A
// Node.js in the home folder is outside it, and so is everything `npm install -g` puts
// beside that Node.js: the AI CLIs, whose launchers start with `#!/usr/bin/env node`.
// A machine with a real LaunchDaemon makes the function stand down, so skip there.
if (existsSync('/Library/LaunchDaemons/com.codeman.web.plist')) return;
const home = mkdtempSync(join(tmpdir(), 'codeman-agent-path-'));
const result = drive(
`
mkdir -p "$RUNTIME_DIR/node/bin" "$RUNTIME_DIR/bin" "$HOME/.local/bin"
printf '#!/bin/sh\n' > "$RUNTIME_DIR/node/bin/node"; chmod +x "$RUNTIME_DIR/node/bin/node"
printf '#!/bin/sh\n' > "$RUNTIME_DIR/bin/tmux"; chmod +x "$RUNTIME_DIR/bin/tmux"
ln -s "$RUNTIME_DIR/node/bin/node" "$HOME/.local/bin/node"
PATH="$HOME/.local/bin:$PATH"; hash -r
launchctl() { return 0; }
sleep() { :; }
BIND_HOST=127.0.0.1
setup_launchd_service >/dev/null 2>&1
cat "$HOME/Library/LaunchAgents/com.codeman.web.plist"
`,
{ HOME: home }
);
rmSync(home, { recursive: true, force: true });
expect(result.status, result.stderr).toBe(0);
const path = /<key>PATH<\/key>\s*<string>([^<]*)<\/string>/.exec(result.stdout)?.[1] ?? '';
const dirs = path.split(':');
expect(dirs).toContain(`${home}/.codeman/runtime/node/bin`);
expect(dirs).toContain(`${home}/.codeman/runtime/bin`);
expect(dirs).toContain(`${home}/.local/bin`);
expect(dirs).toContain('/usr/bin');
expect(new Set(dirs).size, `duplicate PATH entries: ${path}`).toBe(dirs.length);
expect(result.stdout).toContain(`<string>${home}/.local/bin/node</string>`);
});
it('links only into its own runtime, and removes only its own links', () => {
// ~/.local/bin is the user's folder: a tmux or node they put there is never replaced on
// install and never deleted on uninstall.
const home = mkdtempSync(join(tmpdir(), 'codeman-links-'));
const result = drive(
`
mkdir -p "$HOME/.local/bin" "$RUNTIME_DIR/bin"
touch "$RUNTIME_DIR/bin/tmux" "$RUNTIME_DIR/bin/node"
printf 'mine' > "$HOME/.local/bin/tmux"
link_into_local_bin "$RUNTIME_DIR/bin/tmux" tmux && echo "foreign=replaced" || echo "foreign=kept"
link_into_local_bin "$RUNTIME_DIR/bin/node" node && echo "fresh=linked"
link_into_local_bin "$RUNTIME_DIR/bin/node" node && echo "ours=relinked"
echo "tmux=$(cat "$HOME/.local/bin/tmux") node=$(readlink "$HOME/.local/bin/node")"
`,
{ HOME: home }
);
rmSync(home, { recursive: true, force: true });
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain('foreign=kept');
expect(result.stdout).toContain('fresh=linked');
expect(result.stdout).toContain('ours=relinked');
expect(result.stdout).toMatch(/tmux=mine node=.*\/\.codeman\/runtime\/bin\/node/);
const body = fn('uninstall', '\nusage() {');
const rm = body.indexOf('rm -f "$symlink_dir/$runtime_tool"');
expect(rm).toBeGreaterThan(-1);
expect(body.slice(body.lastIndexOf('if [[', rm), rm)).toContain('== "$RUNTIME_DIR/"*');
});
});
+45 -1
View File
@@ -6,8 +6,16 @@
* npm test -- test/self-update.test.ts
*/
import { describe, it, expect } from 'vitest';
import { describe, it, expect, afterAll } from 'vitest';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
ARCHIVE_INSTALL_MARKER,
ARCHIVE_UPDATE_HINT,
canSelfUpdateInPlace,
detectInstallKind,
isRunningInContainer,
parseVersionFromTag,
compareVersions,
isNewerStableVersion,
@@ -205,3 +213,39 @@ describe('expireStalledStatus (runtime staleness backstop)', () => {
expect(expireStalledStatus(base({ phase: 'failed', updatedAt: NOW - 60 * MIN }), NOW)).toBeNull();
});
});
describe('detectInstallKind', () => {
const scratch = mkdtempSync(join(tmpdir(), 'codeman-install-kind-'));
afterAll(() => rmSync(scratch, { recursive: true, force: true }));
let n = 0;
const tree = (entries: string[]) => {
const dir = join(scratch, `install-${++n}`);
mkdirSync(dir);
for (const entry of entries) {
if (entry.endsWith('/')) mkdirSync(join(dir, entry));
else writeFileSync(join(dir, entry), 'archive\n');
}
return dir;
};
it('reads a source-archive install (install.sh without a working git) as archive', () => {
const dir = tree(['src/', 'dist/', ARCHIVE_INSTALL_MARKER]);
expect(detectInstallKind(dir)).toBe('archive');
// Nothing to pull into, so it never self-updates in place; the hint names the installer.
expect(canSelfUpdateInPlace('archive')).toBe(false);
expect(ARCHIVE_UPDATE_HINT).toContain('getcodeman.com/install');
expect(ARCHIVE_UPDATE_HINT).not.toContain('npm i -g');
});
it('lets a real checkout win over a leftover marker', () => {
// A re-run of the installer on a machine that gained git replaces the archive tree with
// a clone; were a marker ever left beside .git, the checkout is still what counts.
const dir = tree(['.git/', 'src/', ARCHIVE_INSTALL_MARKER]);
expect(detectInstallKind(dir)).toBe(isRunningInContainer() ? 'docker-compose' : 'git');
});
it('keeps npm and unknown as they were', () => {
expect(detectInstallKind(tree(['dist/']))).toBe('npm');
expect(detectInstallKind(tree(['dist/', 'src/']))).toBe('unknown');
});
});