mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-06 07:29:33 +01:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b51632e54b | ||
|
|
ed74ae41bc | ||
|
|
ae28534406 | ||
|
|
46b85fa6d0 | ||
|
|
aa463d9d28 | ||
|
|
8ba5134174 | ||
|
|
9eac5ecf01 |
No files matched your search
@@ -80,6 +80,25 @@ 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.
|
||||
out=$(bash /w/install.sh preflight --no-admin)
|
||||
[[ "$out" == "{\"schema\":1,"* && "$out" == *"]}" ]]
|
||||
json_string "a\"b\\c" >/tmp/json-out
|
||||
[[ "$(cat /tmp/json-out)" == "\"a\\\"b\\\\c\"" ]]
|
||||
echo "bash $BASH_VERSION: preflight prints JSON"
|
||||
'
|
||||
# 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 +170,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
|
||||
|
||||
@@ -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) and `runtime/` (the Node.js and tmux `install.sh` downloaded on its no-admin route, linked from `~/.local/bin`; all three installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
|
||||
|
||||
**Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored.
|
||||
|
||||
@@ -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`, `packages/desktop/src/gui_install.rs`): `cli_offerable` is the one rule for what can be installed, `preflight` stdout is JSON only, 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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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. ⚠️ `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
|
||||
|
||||
@@ -78,6 +78,7 @@ 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
|
||||
```
|
||||
|
||||
**Flags** answer the questions from the command line and pipe through `bash -s --`:
|
||||
@@ -94,14 +95,61 @@ 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.
|
||||
|
||||
**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 +242,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 +266,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 +282,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.
|
||||
|
||||
+1039
-77
File diff suppressed because it is too large.
Load diff
Generated
+10
-28
@@ -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
@@ -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",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
target/
|
||||
dist/
|
||||
Generated
+3134
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,87 @@
|
||||
[package]
|
||||
name = "codeman-desktop"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.90"
|
||||
description = "Codeman Desktop: a small native window for a Codeman server"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "codeman-desktop"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# Window + event loop (tao), the system web view (wry: WKWebView on macOS,
|
||||
# WebView2 on Windows, WebKitGTK on Linux) and native menus (muda). Tauri is
|
||||
# built on these three; using them directly keeps the app small and gives us
|
||||
# `load_url_with_headers`, which Tauri's navigate() does not have (see README).
|
||||
tao = "0.37"
|
||||
wry = { version = "0.57", features = ["devtools"] }
|
||||
muda = "0.21"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
url = "2"
|
||||
base64 = "0.22"
|
||||
dirs = "6"
|
||||
# The reachability probe. platform-verifier = the OS trust store, the same one
|
||||
# WKWebView uses, so the probe and the window agree on which certificates pass.
|
||||
ureq = { version = "3", features = ["platform-verifier"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
security-framework = "3"
|
||||
# JavaScript dialogs, HTTP Basic challenges, clearing cookies and the window's
|
||||
# content size (src/macos.rs). The versions wry already pulls in, so no new
|
||||
# crate is added.
|
||||
objc2 = { version = "0.6.5", default-features = false, features = ["std"] }
|
||||
block2 = { version = "0.6.2", default-features = false, features = ["std"] }
|
||||
objc2-foundation = { version = "0.3.2", default-features = false, features = [
|
||||
"std",
|
||||
"objc2-core-foundation",
|
||||
"block2",
|
||||
"NSDate",
|
||||
"NSGeometry",
|
||||
"NSObject",
|
||||
"NSOperation",
|
||||
"NSRange",
|
||||
"NSSet",
|
||||
"NSString",
|
||||
"NSURL",
|
||||
"NSURLAuthenticationChallenge",
|
||||
"NSURLCredential",
|
||||
"NSURLProtectionSpace",
|
||||
"NSURLRequest",
|
||||
"NSURLResponse",
|
||||
"NSURLSession",
|
||||
] }
|
||||
objc2-app-kit = { version = "0.3.2", default-features = false, features = [
|
||||
"std",
|
||||
"objc2-core-foundation",
|
||||
"NSAlert",
|
||||
"NSApplication",
|
||||
"NSButton",
|
||||
"NSControl",
|
||||
"NSResponder",
|
||||
"NSTextField",
|
||||
"NSView",
|
||||
"NSWindow",
|
||||
] }
|
||||
objc2-web-kit = { version = "0.3.2", default-features = false, features = [
|
||||
"std",
|
||||
"block2",
|
||||
"objc2-app-kit",
|
||||
"WKFrameInfo",
|
||||
"WKNavigationDelegate",
|
||||
"WKSecurityOrigin",
|
||||
"WKUIDelegate",
|
||||
"WKWebView",
|
||||
"WKWebsiteDataRecord",
|
||||
"WKWebsiteDataStore",
|
||||
] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
panic = "abort"
|
||||
@@ -0,0 +1,312 @@
|
||||
# Codeman Desktop
|
||||
|
||||
A small native app that opens Codeman in its own window. Double-click it, and
|
||||
it runs Codeman on this Mac (installing it first if it is not there) or
|
||||
connects to Codeman on another machine (installing it there over SSH if
|
||||
needed), and Codeman fills the window. `⌃⌘F` puts it in full screen.
|
||||
|
||||
macOS first: the app runs on 11.3 or later, and Codeman's own page needs the
|
||||
WebKit of Safari 16.4 (see Known limits). The same code is meant to build for
|
||||
Windows and Linux later.
|
||||
|
||||
- **Size:** 4.6 MB for the universal `Codeman.app` (Apple Silicon + Intel), 3 MB as a `.dmg` (measured on the first build).
|
||||
- **Stack:** Rust with [tao](https://crates.io/crates/tao) (window and event loop),
|
||||
[wry](https://crates.io/crates/wry) (the system web view: WKWebView on macOS) and
|
||||
[muda](https://crates.io/crates/muda) (native menus), plus [objc2](https://crates.io/crates/objc2)
|
||||
for the two things wry leaves out on macOS (JavaScript dialogs, HTTP Basic login).
|
||||
No bundled browser and no Node in the app.
|
||||
- **Server:** unchanged. The app is a client of Codeman's web UI, like a browser tab.
|
||||
|
||||
## Why it is cheap
|
||||
|
||||
Codeman is already a web app, so the desktop app is a native window around the
|
||||
operating system's own web view. An Electron build of the same thing would ship
|
||||
a whole Chromium (around 150 MB). The native side only adds what a browser tab
|
||||
cannot do.
|
||||
|
||||
## What it does (v0.1)
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **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. |
|
||||
| **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. |
|
||||
| **Login** | The app answers the server's HTTP Basic login requests itself, with the login you connected with, so the page keeps working after the server restarts (a restart forgets every session cookie). It answers only for Codeman's own pages under the server's base path: a web tab or a rendered HTML file that asks for a Basic login of its own is left to WebKit and never gets your Codeman password. WebKit keeps no copy of the login, so every request for it gets the current one. Each connect first clears the app's cookies, so connecting to the same server as another user really switches users (local storage, where Codeman keeps its per-device settings, stays; a dashboard in a web tab that keeps its own login in a cookie may ask for it again). A page that still gets a 401 reloads once. If the server refuses the login (the password was changed, say), the app does not keep sending it: it checks it with a single probe, and only if the server refuses that too does the connect screen come back with a notice. That does not take focus from the app you are in: if Codeman is in the background, its Dock icon bounces. |
|
||||
| **Dialogs** | Codeman's `confirm()`, `prompt()` and `alert()` (Delete case, Kill all sessions, Update Codeman, discarding unsaved edits, ...) show as native macOS dialogs. wry implements none of them, and without them WKWebView answers Cancel without showing anything. |
|
||||
| **Full screen** | `⌃⌘F` or the green button. The last state is restored at launch; *Always Open in Full Screen* forces it. Leaving full screen returns to the saved window size and position. |
|
||||
| **Menus** | A real Edit menu (without one, `⌘C`/`⌘V` do nothing in a WKWebView), Reload `⌘R`, Switch Server `⇧⌘O`, Developer Tools `⌥⌘I`. The page sees every key first, so Codeman's own shortcuts keep working. If the main window ends up showing a raw file instead of Codeman, `⌘R` takes it back to Codeman. |
|
||||
| **Pop-outs** | Implements Codeman's existing native-wrapper bridge, `window.CodemanHost` (`openInHostWindow` in `src/web/public/app.js`), so popping out a session or a file preview opens a real app window, and the pop-out icon is on by default. Session and rendered-HTML pop-outs come only from Codeman's own pop-out button. A `window.open` from page script opens only Codeman's file and download windows, without taking keyboard focus, at most 3 at once and then 1 per second. Only Codeman's own pop-out pages become app windows (see Decisions); when the app refuses one, Codeman shows a toast. A web tab's *Open in new tab* opens the dashboard in your default browser, as it would from a browser tab. |
|
||||
| **Links** | On Codeman's own pages (the dashboard and session windows), a link to another site opens in your default browser, also on `⌘`-click or a middle click. Pages can send at most 3 links to the browser at once, then 1 per second. Links to other sites inside rendered HTML previews and web tabs do not open (see Known limits). |
|
||||
| **Downloads** | Codeman's Download links, and downloads Codeman opens in a new window, save the file to `~/Downloads` whatever its type, and a toast in Codeman names the file. Files the web view cannot display are saved the same way. |
|
||||
| **Window** | Size, position and full-screen state are remembered. A saved window that hangs off the screen it opens on (it was last on a larger display that is gone, say) is pulled back on and shrunk to fit; one whose title bar is on no screen at all opens at the default size instead. Clicking the Dock icon brings back a minimized window. |
|
||||
|
||||
## Build (on a Mac)
|
||||
|
||||
Needs Rust 1.90 or newer ([rustup](https://rustup.rs); `rustup update` for an older
|
||||
toolchain) and the Xcode Command Line Tools (`xcode-select --install`).
|
||||
|
||||
```bash
|
||||
cd packages/desktop
|
||||
cargo test # unit tests
|
||||
./scripts/bundle-macos.sh # dist/Codeman.app for this Mac's architecture
|
||||
./scripts/bundle-macos.sh --universal --dmg # Apple Silicon + Intel, plus dist/Codeman-<version>.dmg
|
||||
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
|
||||
cargo config's `build.target-dir` work.
|
||||
|
||||
Settings live in `~/Library/Application Support/Codeman/desktop.json`; passwords
|
||||
in the Keychain under *Codeman Desktop*, one item per username and server. A
|
||||
`desktop.json` the app cannot read or parse is moved aside, never overwritten:
|
||||
to `desktop.json.corrupt`, or to the first free name of `desktop.json.corrupt.1`
|
||||
to `.corrupt.9` when that one is taken, so an earlier copy is kept. If all ten
|
||||
names are taken, or the file cannot be moved, the app starts with default
|
||||
settings and saves nothing, which leaves the file where it is. When a server's
|
||||
username changes (a change of letter case counts), or a server drops off the
|
||||
recent list (it keeps 8), the Keychain item of the old username is deleted.
|
||||
|
||||
## Decisions, and why
|
||||
|
||||
**tao + wry + muda directly, not Tauri.** Tauri is built on the same three crates.
|
||||
The deciding detail is login: Codeman's password protection is HTTP Basic, and
|
||||
a WKWebView shows no login dialog of its own (wry 0.57 implements no
|
||||
authentication-challenge handler). So the app sends the `Authorization` header
|
||||
itself on the first load, with wry's `load_url_with_headers` (Tauri 2.12's
|
||||
`navigate()` takes only a URL), and from then on answers the HTTP Basic
|
||||
challenges of Codeman's own pages with the current login. Codeman keeps its
|
||||
session cookies in memory, so after a server restart that answer is what keeps
|
||||
the page logged in. Using the crates directly is also smaller.
|
||||
|
||||
**What wry leaves out is added at runtime, not patched in.** `src/macos.rs` adds
|
||||
the JavaScript dialog methods and the authentication-challenge method to the
|
||||
classes of wry's own delegates, so wry stays an unmodified crates.io dependency.
|
||||
|
||||
**What a page may ask of the app** (all in `src/policy.rs`, unit-tested):
|
||||
|
||||
- Codeman shows content it did not write on its own origin: rendered HTML files
|
||||
(`/html-view/`) and proxied dashboards (`/webview/`). Only Codeman's own app
|
||||
shell (the dashboard and a session pop-out) may use `window.CodemanHost`.
|
||||
That is an allowlist on the raw path, and a path holding any `%` fails it, so
|
||||
an encoded spelling or a route Codeman adds later stays out.
|
||||
- An app window opens only for Codeman's own pop-out pages under the server's
|
||||
base path, checked after decoding the path once, and which ones depends on
|
||||
who asks. `window.CodemanHost` (the app shell only) may open sessions,
|
||||
rendered HTML views, the raw, preview, attachment and folder file routes, and
|
||||
`api/download`. A `window.open` or `target=_blank` link, which WebKit passes
|
||||
on from any frame without saying which, gets only the file routes and
|
||||
`api/download`, the pages Codeman itself opens that way; otherwise a rendered
|
||||
HTML file could open copies of itself as top-level windows that pass for the
|
||||
app's own. Such a window does not take keyboard focus, and these windows
|
||||
have a budget of their own (3 at once, then 1 per second), apart from the
|
||||
one links draw on. Any other page on the server's origin is refused rather
|
||||
than sent to the browser: a top-level `/webview/` load would escape the web
|
||||
tab's iframe sandbox in either place, so it never opens anywhere.
|
||||
`window.open('')` is refused too, since WebKit's blank window carries none of
|
||||
the app's handlers.
|
||||
- wry's navigation handler sees every frame and does not say which one. So a
|
||||
navigation to another site is blocked rather than opened in the browser;
|
||||
otherwise a page full of external iframes could open browser tabs. A real
|
||||
link click is sent out by the injected script in the main frame only.
|
||||
- Only `http(s)` URLs ever reach the default browser, at most 3 at once and
|
||||
then 1 per second. macOS lets a page call `window.open` without a click, so
|
||||
this keeps a script from opening a browser tab for every call.
|
||||
- The `Authorization` header is only sent to URLs under the server's base path.
|
||||
The login answers a Basic challenge only when it is Codeman's own: the
|
||||
server's scheme, host and port (never a proxy's), a URL below the base path,
|
||||
and not a web tab (`webview/`) or a rendered HTML file (`html-view/`), which
|
||||
relay 401s from content Codeman did not write. Only such a challenge can
|
||||
send the user back to the connect screen. WebKit is handed the login without
|
||||
persistence, so it keeps no copy: every challenge gets the current login,
|
||||
and one from before a Switch Server is never replayed.
|
||||
- Each connect starts by clearing the app's cookies (never local storage).
|
||||
Codeman checks its session cookie before the `Authorization` header, so an
|
||||
earlier user's cookie would otherwise win over the new login.
|
||||
- Microphone and camera requests prompt per page. wry's default without a
|
||||
handler is to grant them to every frame, sandboxed previews included.
|
||||
- The bundled connect page keeps working until a server page commits in the
|
||||
main window (it is still the live page while the server loads), and only
|
||||
the main window may talk to it.
|
||||
- `codeman web -d` gets the port and base path as separate arguments, never as
|
||||
shell text.
|
||||
|
||||
**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.
|
||||
|
||||
**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
|
||||
`~/.codeman/.env` (what `codeman attach`, `codeman tui` and `codeman agent`
|
||||
read). The app reads the same places, so a password-protected Codeman on this
|
||||
Mac opens without typing the password. The username and password always come
|
||||
from the same place, since the server reads its username from its own
|
||||
environment. The login goes only to `127.0.0.1` on that Codeman's port, and
|
||||
only while `lsof` shows one of your own Codeman processes holding that port:
|
||||
with Codeman stopped, whatever else listens there never gets it.
|
||||
|
||||
**Never a second server on the same sessions.** Two Codeman servers on one
|
||||
data directory and tmux socket attach to each other's live sessions, and
|
||||
`codeman web -d` only notices a server it started itself or one at the same
|
||||
address. So the app looks for running Codeman servers of this user (`ps`)
|
||||
before it offers or runs a start, and opens the one it finds instead.
|
||||
|
||||
**Closing the main window quits the app.** Codeman's sessions keep running on
|
||||
the server, so nothing is lost, and reopening takes about a second.
|
||||
|
||||
**One reachability probe per user action, and one to check a refused login.**
|
||||
Codeman counts a request without valid credentials against its login rate
|
||||
limit (10 per IP, 15 minutes; a successful login resets the count), so the app
|
||||
never retries in a loop. Once the server refuses the login in a challenge, the
|
||||
app sends it only once more, in the single probe that checks the refusal. If
|
||||
that probe is refused or rate limited, the connect screen comes back; the
|
||||
app does not send the refused login again. Keeping no copy of the login in WebKit has a cost here: after a server
|
||||
restart, each request that arrives without a cookie gets a 401 before the
|
||||
login goes out, and those count until one succeeds. Many windows reconnecting
|
||||
at once can push a request past 10, and that request then fails with a 429
|
||||
instead of being answered. A refusal that lands while you are in another app
|
||||
does not bring Codeman forward: the connect screen loads where the window is,
|
||||
and the Dock icon bounces.
|
||||
|
||||
## Known limits
|
||||
|
||||
- **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
|
||||
machine, serve Codeman through `tailscale serve`
|
||||
(`https://<machine>.<tailnet>.ts.net` has a real certificate) or a reverse
|
||||
proxy with a real certificate. Marking the certificate as trusted in
|
||||
Keychain Access can only help at `https://localhost` or `https://127.0.0.1`,
|
||||
the only names Codeman's certificate carries, and may not work even there.
|
||||
The connect screen says so when it happens.
|
||||
- **Keychain prompt after every rebuild of an ad-hoc build.** The Keychain
|
||||
knows the app that saved a password by its code signature, and an ad-hoc
|
||||
signature changes with every build and update. So each new build asks once
|
||||
for access to the saved passwords: choose *Always Allow*. All Keychain work
|
||||
runs on one background thread, so the prompt does not freeze the window
|
||||
while it waits for an answer. To keep one identity across builds, sign with
|
||||
`SIGN_IDENTITY` (a self-made code-signing certificate from Keychain Access
|
||||
is enough). Microphone and camera permissions are asked again for the same
|
||||
reason.
|
||||
- **Gatekeeper.** Without a Developer ID the app is signed ad hoc. A build you
|
||||
made yourself opens normally; a copy that arrived by download or AirDrop is
|
||||
quarantined. On macOS 15 and later, try to open it once, then click *Open
|
||||
Anyway* in System Settings > Privacy & Security (it asks for an administrator
|
||||
password). On macOS 14 and earlier, right-click it and choose *Open* once.
|
||||
Either way, `xattr -dr com.apple.quarantine /Applications/Codeman.app` on the
|
||||
copy in Applications works too. Shipping it to other people needs a Developer
|
||||
ID signature and notarization:
|
||||
`SIGN_IDENTITY="Developer ID Application: ..." ./scripts/bundle-macos.sh --dmg`,
|
||||
then `xcrun notarytool submit` and `xcrun stapler staple`.
|
||||
- **Local Network permission (macOS 15 and later).** The first connection to an
|
||||
address on the local network (a LAN IP, a `.local` name) makes macOS ask
|
||||
whether Codeman may use the local network, and that first attempt can fail
|
||||
while the question is open: connect again after answering. If it was denied,
|
||||
turn Codeman on in System Settings > Privacy & Security > Local Network. A
|
||||
server started with *Start Codeman on this Mac* counts as part of the app
|
||||
here, so the same answer covers what it reaches on the local network (ssh to
|
||||
LAN hosts, Wake-on-LAN, web tabs pointing at LAN dashboards), also after the
|
||||
app quits.
|
||||
- **Subagent transcript windows do not open in the app.** Codeman writes that
|
||||
view into a blank `window.open('')` window, which the app refuses (see
|
||||
Decisions), and shows a toast instead. A Codeman without the null check in
|
||||
`viewSubagentTranscript` (`src/web/public/panels-ui.js`) reports *Failed to
|
||||
load transcript* there. Open the transcript from a browser tab.
|
||||
- **Dialogs are app-modal.** While a JavaScript dialog is open, every Codeman
|
||||
window waits for it. A page that calls `alert()` in a loop (a rendered HTML
|
||||
preview or a web tab can) leaves no way out but Force Quit (`⌥⌘⎋`).
|
||||
- **Codeman needs the WebKit of Safari 16.4.** The app itself runs on macOS
|
||||
11.3 or later (downloads go through WKDownload, which arrived in 11.3), but
|
||||
Codeman's terminal (xterm.js 6 uses class static blocks) needs the WebKit
|
||||
that came with Safari 16.4: macOS 13.3, or macOS 11 or 12 with Safari 16.4
|
||||
or later installed. On an older WebKit the connect screen says so and does
|
||||
not connect. Session pop-outs also need BroadcastChannel (Safari 15.4),
|
||||
which the same requirement covers.
|
||||
- **Links in rendered HTML and web tabs do not open.** A link to another site
|
||||
inside a rendered HTML preview or a web tab does nothing, in the preview
|
||||
overlay, and for a rendered HTML file in its pop-out window too. The app
|
||||
sends links out only from Codeman's own pages, and blocks navigation to
|
||||
other sites in every frame (see Decisions).
|
||||
- **No login dialog for a web tab's own HTTP Basic login.** The app answers
|
||||
only Codeman's own login, and WebKit shows no dialog for any other, so a
|
||||
dashboard in a web tab that asks for HTTP Basic shows its 401 page.
|
||||
- **Downloads from previews and web tabs are saved without asking.** Rendered
|
||||
HTML files and web tabs are allowed to start downloads, and the app saves
|
||||
every download to `~/Downloads` without a prompt. Asking first for those
|
||||
frames is not done yet.
|
||||
- **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
|
||||
prints the address, and you connect to it under Another machine.
|
||||
- **A This Mac install always sets Codeman up as a LaunchAgent** that starts
|
||||
at login. To stop it: `launchctl unload ~/Library/LaunchAgents/com.codeman.web.plist`;
|
||||
to remove Codeman: `curl -fsSL https://getcodeman.com/install | bash -s -- uninstall`.
|
||||
- **Windows and Linux** need their menu hookup (`init_for_hwnd`, GTK), a
|
||||
credential store (Credential Manager, Secret Service) and installers. The
|
||||
rest of the code is platform-neutral.
|
||||
|
||||
## Ideas for next
|
||||
|
||||
- Native notifications and a Dock badge with the number of sessions waiting
|
||||
for you (tao has `set_badge_label`; Codeman already tracks which sessions need input).
|
||||
- A global hotkey that brings Codeman to the front from anywhere.
|
||||
- A menu bar icon showing session states.
|
||||
- Trusting a self-signed server certificate by its fingerprint.
|
||||
- Asking before a download that a rendered HTML file or a web tab starts.
|
||||
- A way out of a page that keeps opening dialogs (a "block further dialogs" option).
|
||||
- Windows and Linux builds, a CI job that builds the `.dmg`, notarization, auto-update.
|
||||
File diff suppressed because it is too large.
Load diff
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
@@ -0,0 +1,59 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key>
|
||||
<string>Codeman</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Codeman</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.getcodeman.desktop</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>__VERSION__</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>__VERSION__</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>codeman-desktop</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>Codeman</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<!-- 11.3 is where WKDownload arrived; wry saves files through it. Below
|
||||
that a Download link replaces the window or saves nothing.
|
||||
scripts/bundle-macos.sh builds for this version too. -->
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>11.3</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.developer-tools</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSSupportsAutomaticGraphicsSwitching</key>
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>MIT License, Codeman Contributors</string>
|
||||
<!-- Voice dictation and gesture control ask for these; without the
|
||||
strings macOS kills the app the first time a page requests them. -->
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Codeman uses the microphone for voice dictation when you turn it on.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Codeman uses the camera for hand-gesture control when you turn it on.</string>
|
||||
<!-- macOS 15 asks before an app reaches the local network (a LAN address,
|
||||
a .local name), and shows this text in that question. A process the
|
||||
app starts counts as the app (Apple TN3179), so a server started with
|
||||
"Start Codeman on this Mac" runs under the same answer. -->
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Codeman connects to Codeman servers on your local network. A Codeman server you start from this app uses the same permission for what it reaches on the network.</string>
|
||||
<!-- App Transport Security would block plain http:// to a host NAME
|
||||
(my-mac.local, a LAN box). IP literals are exempt, which is why
|
||||
http://127.0.0.1 works without this. -->
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Only used with a Developer ID (SIGN_IDENTITY=...), which turns on the
|
||||
hardened runtime; there, microphone and camera need these. -->
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1 @@
|
||||
max_width = 120
|
||||
Executable
+110
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build Codeman.app (and optionally a .dmg) from packages/desktop, on macOS.
|
||||
#
|
||||
# packages/desktop/scripts/bundle-macos.sh # this Mac's architecture
|
||||
# packages/desktop/scripts/bundle-macos.sh --universal --dmg
|
||||
# SIGN_IDENTITY="Developer ID Application: ..." packages/desktop/scripts/bundle-macos.sh --dmg
|
||||
#
|
||||
# Without SIGN_IDENTITY the app is signed ad hoc: it runs on the Mac that built
|
||||
# it, and a copy downloaded on another Mac is stopped by Gatekeeper. On macOS 15
|
||||
# and later, try to open it once, then click Open Anyway in System Settings >
|
||||
# Privacy & Security; on macOS 14 and earlier, right-click it and choose Open.
|
||||
# Or run `xattr -dr com.apple.quarantine /Applications/Codeman.app` on the copy
|
||||
# in /Applications. A Developer ID signature + notarization avoids all of this.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
[ "$(uname -s)" = Darwin ] || { echo "bundle-macos.sh runs on macOS only" >&2; exit 1; }
|
||||
|
||||
UNIVERSAL=0
|
||||
DMG=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--universal) UNIVERSAL=1 ;;
|
||||
--dmg) DMG=1 ;;
|
||||
*) echo "unknown option: $arg" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
VERSION=$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -1)
|
||||
[ -n "$VERSION" ] || { echo "could not read the version from Cargo.toml" >&2; exit 1; }
|
||||
|
||||
# The binary targets the same macOS as LSMinimumSystemVersion in Info.plist,
|
||||
# so the two cannot disagree.
|
||||
MACOSX_DEPLOYMENT_TARGET=$(/usr/libexec/PlistBuddy -c 'Print :LSMinimumSystemVersion' macos/Info.plist)
|
||||
[ -n "$MACOSX_DEPLOYMENT_TARGET" ] || { echo "could not read LSMinimumSystemVersion from macos/Info.plist" >&2; exit 1; }
|
||||
export MACOSX_DEPLOYMENT_TARGET
|
||||
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
# Build with cargo and set BUILT to the executable cargo reports for
|
||||
# codeman-desktop. Asking cargo, rather than assuming ./target, keeps
|
||||
# CARGO_TARGET_DIR, build.target-dir and build.target from a cargo config
|
||||
# working, and a stale binary from an older build is never bundled.
|
||||
cargo_build() {
|
||||
local json
|
||||
json=$(cargo build --locked --release "$@" --message-format=json-render-diagnostics)
|
||||
BUILT=$(printf '%s\n' "$json" | awk '
|
||||
index($0, "\"reason\":\"compiler-artifact\"") &&
|
||||
index($0, "\"kind\":[\"bin\"]") &&
|
||||
index($0, "\"name\":\"codeman-desktop\"") &&
|
||||
match($0, /"executable":"[^"]*"/) { path = substr($0, RSTART + 14, RLENGTH - 15) }
|
||||
END { print path }')
|
||||
if [ -z "$BUILT" ] || [ ! -x "$BUILT" ]; then
|
||||
echo "cargo did not report a codeman-desktop executable${BUILT:+ (it named $BUILT)}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$UNIVERSAL" = 1 ]; then
|
||||
rustup target add aarch64-apple-darwin x86_64-apple-darwin >/dev/null
|
||||
cargo_build --target aarch64-apple-darwin
|
||||
ARM_BIN=$BUILT
|
||||
cargo_build --target x86_64-apple-darwin
|
||||
INTEL_BIN=$BUILT
|
||||
BIN="$WORK/codeman-desktop-universal"
|
||||
lipo -create -output "$BIN" "$ARM_BIN" "$INTEL_BIN"
|
||||
else
|
||||
cargo_build
|
||||
BIN=$BUILT
|
||||
fi
|
||||
|
||||
APP=dist/Codeman.app
|
||||
rm -rf "$APP"
|
||||
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
|
||||
cp "$BIN" "$APP/Contents/MacOS/codeman-desktop"
|
||||
sed "s/__VERSION__/$VERSION/g" macos/Info.plist > "$APP/Contents/Info.plist"
|
||||
plutil -lint "$APP/Contents/Info.plist" >/dev/null
|
||||
printf 'APPL????' > "$APP/Contents/PkgInfo"
|
||||
|
||||
# assets/icon.png (1024x1024, already on Apple's icon grid) -> Codeman.icns
|
||||
ICONSET="$WORK/Codeman.iconset"
|
||||
mkdir -p "$ICONSET"
|
||||
for size in 16 32 128 256 512; do
|
||||
sips -z "$size" "$size" assets/icon.png --out "$ICONSET/icon_${size}x${size}.png" >/dev/null
|
||||
double=$((size * 2))
|
||||
sips -z "$double" "$double" assets/icon.png --out "$ICONSET/icon_${size}x${size}@2x.png" >/dev/null
|
||||
done
|
||||
iconutil -c icns "$ICONSET" -o "$APP/Contents/Resources/Codeman.icns"
|
||||
|
||||
if [ -n "${SIGN_IDENTITY:-}" ]; then
|
||||
codesign --force --options runtime --timestamp \
|
||||
--entitlements macos/entitlements.plist --sign "$SIGN_IDENTITY" "$APP"
|
||||
else
|
||||
codesign --force --sign - "$APP"
|
||||
fi
|
||||
codesign --verify --strict "$APP"
|
||||
|
||||
echo "Built $(pwd)/$APP ($(du -sh "$APP" | cut -f1), version $VERSION, macOS $MACOSX_DEPLOYMENT_TARGET or later)"
|
||||
|
||||
if [ "$DMG" = 1 ]; then
|
||||
STAGE="$WORK/dmg"
|
||||
mkdir -p "$STAGE"
|
||||
cp -R "$APP" "$STAGE/"
|
||||
ln -s /Applications "$STAGE/Applications"
|
||||
DMG_PATH="dist/Codeman-$VERSION.dmg"
|
||||
rm -f "$DMG_PATH"
|
||||
hdiutil create -quiet -volname "Codeman" -srcfolder "$STAGE" -ov -format UDZO "$DMG_PATH"
|
||||
echo "Built $(pwd)/$DMG_PATH ($(du -sh "$DMG_PATH" | cut -f1))"
|
||||
fi
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Files bundled into the binary: the connect page, its icon, and the script
|
||||
//! injected into every Codeman page (`window.CodemanHost`).
|
||||
|
||||
use std::borrow::Cow;
|
||||
use wry::http::{header::CONTENT_TYPE, Request, Response, StatusCode};
|
||||
|
||||
const CONNECT_HTML: &[u8] = include_bytes!("../assets/connect.html");
|
||||
const ICON_PNG: &[u8] = include_bytes!("../assets/icon.png");
|
||||
|
||||
/// The connect page loads nothing from outside the app.
|
||||
const CSP: &str = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src codemanapp:";
|
||||
|
||||
/// Serve `codemanapp://localhost/<path>`.
|
||||
pub fn respond(request: Request<Vec<u8>>) -> Response<Cow<'static, [u8]>> {
|
||||
let (body, mime): (&'static [u8], &str) = match request.uri().path() {
|
||||
"/" | "/index.html" => (CONNECT_HTML, "text/html; charset=utf-8"),
|
||||
"/icon.png" => (ICON_PNG, "image/png"),
|
||||
_ => {
|
||||
return Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Cow::Borrowed(&b"not found"[..]))
|
||||
.expect("static response");
|
||||
}
|
||||
};
|
||||
Response::builder()
|
||||
.header(CONTENT_TYPE, mime)
|
||||
.header("Content-Security-Policy", CSP)
|
||||
.header("Cache-Control", "no-store")
|
||||
.body(Cow::Borrowed(body))
|
||||
.expect("static response")
|
||||
}
|
||||
|
||||
/// Injected at document start into the MAIN frame of every page (wry's
|
||||
/// default), where it does nothing unless the page is http(s). It provides the
|
||||
/// native-wrapper bridge Codeman's frontend already looks for
|
||||
/// (`window.CodemanHost.openWindow/closeWindow/focusWindow`, see
|
||||
/// `openInHostWindow` in src/web/public/app.js), so pop-outs become real app
|
||||
/// windows. `openWindow` answers false, posting nothing, for a URL the native
|
||||
/// side would drop (not http(s), or longer than its 4096-byte cap), so
|
||||
/// Codeman shows its own error instead of marking a session detached with no
|
||||
/// window behind it. Besides that it:
|
||||
///
|
||||
/// - turns a click on a same-origin download link (`download=true`, or a path
|
||||
/// ending in `/download`) into a real download by giving the link a
|
||||
/// `download` attribute. Codeman's file tree and attachment cards link to
|
||||
/// `file-raw?download=true` without one, and the server sends a type WebKit
|
||||
/// can show (png, pdf, txt), so WebKit would otherwise replace the Codeman
|
||||
/// window with the file, with no way back. This runs in the capture phase,
|
||||
/// before the file tree's own `stopPropagation`.
|
||||
/// - does the same for `window.open` of such a `download=true` URL (the
|
||||
/// attachment history), returning null instead of a window.
|
||||
/// - sends clicks on links to other sites to the default browser, after
|
||||
/// Codeman's own handlers have had their turn (bubble phase, skipped when
|
||||
/// one of them called preventDefault). Middle clicks and modifier clicks
|
||||
/// count too: a bare WKWebView has no new-tab gesture of its own, so they
|
||||
/// would do nothing.
|
||||
/// - reports the first same-origin 401 a `fetch` gets (`sessionExpired`),
|
||||
/// once per page load: the session cookie is gone, usually because the
|
||||
/// server restarted, and nothing in the page asks for the login again.
|
||||
/// - defines `__codemanDesktopRefused(id)` (not enumerable) for
|
||||
/// [`refused_script`].
|
||||
pub const HOST_SHIM: &str = r#"(function () {
|
||||
'use strict';
|
||||
if (!/^https?:$/.test(location.protocol) || window.CodemanHost) return;
|
||||
var MAX_URL = 4096;
|
||||
var post = function (msg) {
|
||||
try { window.ipc.postMessage(JSON.stringify(msg)); return true; } catch (e) { return false; }
|
||||
};
|
||||
// An http(s) URL object, or null. fetch and window.open resolve against
|
||||
// the document base (Codeman sets <base href>), so those pass it.
|
||||
var httpUrl = function (raw, base) {
|
||||
try {
|
||||
var u = new URL(String(raw), base || location.href);
|
||||
return /^https?:$/.test(u.protocol) ? u : null;
|
||||
} catch (e) { return null; }
|
||||
};
|
||||
var sameOrigin = function (u) { return !!u && u.origin === location.origin; };
|
||||
var link = function (ev) {
|
||||
var a = ev.target && ev.target.closest ? ev.target.closest('a[href]') : null;
|
||||
return a && typeof a.href === 'string' && !a.hasAttribute('download') ? a : null;
|
||||
};
|
||||
|
||||
Object.defineProperty(window, 'CodemanHost', {
|
||||
value: Object.freeze({
|
||||
desktop: true,
|
||||
openWindow: function (url) {
|
||||
var u = httpUrl(url);
|
||||
return u && u.href.length <= MAX_URL ? post({ cmd: 'openWindow', url: u.href }) : false;
|
||||
},
|
||||
closeWindow: function () { post({ cmd: 'closeWindow' }); },
|
||||
focusWindow: function () { post({ cmd: 'focusWindow' }); }
|
||||
})
|
||||
});
|
||||
|
||||
// The native side could not open a window: say so. `id` is a string only
|
||||
// when CodemanHost.openWindow asked for that session's pop-out; that session
|
||||
// goes back to the dashboard (a fresh channel object, so this window's own
|
||||
// channel hears it too). Anything else, null included, only shows the toast.
|
||||
Object.defineProperty(window, '__codemanDesktopRefused', {
|
||||
value: function (id) {
|
||||
try {
|
||||
if (window.app && typeof window.app.showToast === 'function') {
|
||||
window.app.showToast('Could not open a new window', 'error');
|
||||
}
|
||||
} catch (e) {}
|
||||
try {
|
||||
if (typeof id === 'string' && id !== '' && typeof BroadcastChannel === 'function') {
|
||||
var ch = new BroadcastChannel('codeman-windows');
|
||||
ch.postMessage({ type: 'redocked', id: id });
|
||||
setTimeout(function () { try { ch.close(); } catch (e) {} }, 2000);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
});
|
||||
|
||||
var markDownload = function (ev) {
|
||||
if (ev.button > 1) return;
|
||||
var a = link(ev);
|
||||
var u = a && httpUrl(a.href);
|
||||
if (sameOrigin(u) && (u.searchParams.get('download') === 'true' || /\/download$/.test(u.pathname))) {
|
||||
a.setAttribute('download', '');
|
||||
}
|
||||
};
|
||||
window.addEventListener('click', markDownload, true);
|
||||
window.addEventListener('auxclick', markDownload, true);
|
||||
|
||||
var lastMiddle = null;
|
||||
var openOutside = function (ev) {
|
||||
if (ev.defaultPrevented || ev.button > 1) return;
|
||||
var a = link(ev);
|
||||
var u = a && httpUrl(a.href);
|
||||
if (!u || sameOrigin(u)) return;
|
||||
ev.preventDefault();
|
||||
// Older WebKit sends a middle click as 'click', newer as 'auxclick'; one
|
||||
// that sent both must still open the link once.
|
||||
if (ev.button === 1) {
|
||||
if (lastMiddle && lastMiddle.a === a && ev.timeStamp - lastMiddle.t < 500) return;
|
||||
lastMiddle = { a: a, t: ev.timeStamp };
|
||||
}
|
||||
post({ cmd: 'openExternal', url: u.href });
|
||||
};
|
||||
window.addEventListener('click', openOutside, false);
|
||||
window.addEventListener('auxclick', openOutside, false);
|
||||
|
||||
var nativeOpen = window.open;
|
||||
if (typeof nativeOpen === 'function') {
|
||||
window.open = function open(url) {
|
||||
try {
|
||||
var u = arguments.length && url !== undefined ? httpUrl(url, document.baseURI) : null;
|
||||
if (sameOrigin(u) && u.searchParams.get('download') === 'true') {
|
||||
var a = document.createElement('a');
|
||||
a.href = u.href;
|
||||
a.setAttribute('download', '');
|
||||
a.click();
|
||||
return null;
|
||||
}
|
||||
} catch (e) {}
|
||||
return nativeOpen.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
|
||||
// Returns the promise fetch itself returned, so callers see exactly what
|
||||
// they saw before; the 401 check hangs off a side branch that swallows its
|
||||
// own errors and never reads the body.
|
||||
var expired = false;
|
||||
var nativeFetch = window.fetch;
|
||||
if (typeof nativeFetch === 'function') {
|
||||
var check = function (input, r) {
|
||||
if (expired || !r || r.status !== 401) return;
|
||||
var raw = r.url || (input && typeof input === 'object' && typeof input.url === 'string' ? input.url : input);
|
||||
if (!sameOrigin(httpUrl(raw, document.baseURI))) return;
|
||||
expired = true;
|
||||
post({ cmd: 'sessionExpired' });
|
||||
};
|
||||
window.fetch = function fetch(input) {
|
||||
var p = nativeFetch.apply(this, arguments);
|
||||
try {
|
||||
if (!expired && p && typeof p.then === 'function') {
|
||||
p.then(function (r) { try { check(input, r); } catch (e) {} }, function () {});
|
||||
}
|
||||
} catch (e) {}
|
||||
return p;
|
||||
};
|
||||
}
|
||||
})();"#;
|
||||
|
||||
/// A toast inside Codeman when a download finished (Codeman's own
|
||||
/// `showToast`; nothing happens on a page without it).
|
||||
pub fn download_toast_script(file_name: &str, ok: bool) -> String {
|
||||
let text = if ok {
|
||||
format!("Downloaded {file_name} to Downloads")
|
||||
} else {
|
||||
format!("Download of {file_name} failed")
|
||||
};
|
||||
let text = serde_json::to_string(&text).unwrap_or_else(|_| "\"Download finished\"".into());
|
||||
let kind = if ok { "\"success\"" } else { "\"error\"" };
|
||||
format!("try {{ window.app && window.app.showToast && window.app.showToast({text}, {kind}); }} catch (e) {{}}")
|
||||
}
|
||||
|
||||
/// Runs in the window that asked for a window the native side did not open
|
||||
/// (pop-out limit reached, policy refused it, window or web view failed to
|
||||
/// build): the page shows an error toast. Does nothing on a page without the
|
||||
/// shim.
|
||||
///
|
||||
/// `session_id` is the decoded id of a session pop-out
|
||||
/// (`policy::session_id_of`), and only for a `CodemanHost.openWindow` request,
|
||||
/// which comes from a trusted frame: that session then gets its tab back in
|
||||
/// the dashboard. Pass `None` for every other refusal, above all the ones a
|
||||
/// `window.open` or `target=_blank` from any frame caused, so a page cannot
|
||||
/// make the dashboard drop the detached state of a session that is still
|
||||
/// popped out.
|
||||
pub fn refused_script(session_id: Option<&str>) -> String {
|
||||
let id = serde_json::to_string(&session_id).unwrap_or_else(|_| "null".into());
|
||||
format!(
|
||||
"try {{ if (typeof window.__codemanDesktopRefused === 'function') window.__codemanDesktopRefused({id}); }} catch (e) {{}}"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn get(path: &str) -> Response<Cow<'static, [u8]>> {
|
||||
respond(
|
||||
Request::builder()
|
||||
.uri(format!("codemanapp://localhost{path}"))
|
||||
.body(Vec::new())
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serves_page_and_icon_only() {
|
||||
let page = get("/index.html");
|
||||
assert_eq!(page.status(), StatusCode::OK);
|
||||
assert!(page.headers()["content-type"]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.starts_with("text/html"));
|
||||
assert!(page.headers().contains_key("content-security-policy"));
|
||||
assert!(String::from_utf8_lossy(page.body()).contains("window.__codemanDesktop"));
|
||||
assert_eq!(get("/icon.png").status(), StatusCode::OK);
|
||||
assert_eq!(get("/../etc/passwd").status(), StatusCode::NOT_FOUND);
|
||||
assert_eq!(get("/other.js").status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refused_script_escapes_the_session_id() {
|
||||
let js = refused_script(Some("a'\"); alert(1); //\u{2028}\\"));
|
||||
assert!(js.starts_with("try { if (typeof window.__codemanDesktopRefused === 'function') "));
|
||||
assert!(js.contains(r#"window.__codemanDesktopRefused("a'\"); alert(1); //"#));
|
||||
assert!(js.contains("\u{2028}\\\\\"); } catch (e) {}"));
|
||||
assert!(js.ends_with(" } catch (e) {}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refused_script_without_a_session_passes_null() {
|
||||
assert_eq!(
|
||||
refused_script(None),
|
||||
"try { if (typeof window.__codemanDesktopRefused === 'function') window.__codemanDesktopRefused(null); } catch (e) {}"
|
||||
);
|
||||
assert!(refused_script(Some("")).contains("__codemanDesktopRefused(\"\")"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shim_keeps_its_contract() {
|
||||
for needle in [
|
||||
"Object.freeze",
|
||||
"openWindow",
|
||||
"closeWindow",
|
||||
"focusWindow",
|
||||
"__codemanDesktopRefused",
|
||||
"sessionExpired",
|
||||
"'auxclick'",
|
||||
"MAX_URL = 4096",
|
||||
"typeof id === 'string' && id !== ''",
|
||||
] {
|
||||
assert!(HOST_SHIM.contains(needle), "{needle}");
|
||||
}
|
||||
// The refused hook takes the session id the native side resolved; it
|
||||
// never parses or decodes a URL to find one.
|
||||
assert!(!HOST_SHIM.contains("decodeURIComponent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toast_escapes_file_names() {
|
||||
let js = download_toast_script("a\"); alert(1); (\".txt", true);
|
||||
assert!(js.contains(r#"Downloaded a\"); alert(1); (\".txt to Downloads"#));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,722 @@
|
||||
//! The app's own settings: recent servers, two preferences and the window
|
||||
//! frame. Stored as JSON next to other per-app data
|
||||
//! (`~/Library/Application Support/Codeman/desktop.json` on macOS). Passwords
|
||||
//! never go here; they live in the Keychain (see `credentials`).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const MAX_SERVERS: usize = 8;
|
||||
/// How many recovery copies of an unreadable config can sit side by side:
|
||||
/// `desktop.json.corrupt`, then `desktop.json.corrupt.1` to `.9`.
|
||||
const ASIDE_SLOTS: usize = 10;
|
||||
const MIN_WIDTH: f64 = 480.0;
|
||||
const MIN_HEIGHT: f64 = 360.0;
|
||||
const MAX_EDGE: f64 = 20_000.0;
|
||||
|
||||
/// Height of a standard title bar, in points.
|
||||
const TITLE_BAR: f64 = 28.0;
|
||||
/// How far the outer top of the window lies above `WindowFrame::y`. On macOS
|
||||
/// `y` is the content area's top (tao's `with_position` puts the content rect
|
||||
/// there and the title bar above it); elsewhere `y` is already the outer top.
|
||||
const TITLE_ABOVE_Y: f64 = if cfg!(target_os = "macos") { TITLE_BAR } else { 0.0 };
|
||||
/// How much of the title bar must be on a screen to grab it.
|
||||
const MIN_GRIP: f64 = 80.0;
|
||||
/// Room `fitted_to` leaves when it shrinks a window. Screens are full display
|
||||
/// bounds, so the menu bar and the Dock are inside them; these are the margins
|
||||
/// main.rs uses for the centred default frame.
|
||||
const FIT_MARGIN_X: f64 = 40.0;
|
||||
const FIT_MARGIN_Y: f64 = 80.0;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Config {
|
||||
/// Most recent first.
|
||||
pub servers: Vec<ServerEntry>,
|
||||
/// Connect to the most recent server at launch.
|
||||
pub auto_connect: bool,
|
||||
/// Always open in full screen (otherwise the last state is restored).
|
||||
pub start_fullscreen: bool,
|
||||
pub window: Option<WindowFrame>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Config {
|
||||
servers: Vec::new(),
|
||||
auto_connect: true,
|
||||
start_fullscreen: false,
|
||||
window: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServerEntry {
|
||||
pub url: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub has_saved_password: bool,
|
||||
}
|
||||
|
||||
/// The main window's position and inner size, in logical (point)
|
||||
/// coordinates. `x`/`y` are where tao's `with_position` wants them: on macOS
|
||||
/// the top-left of the CONTENT area (the title bar, about 28 pt, sits above
|
||||
/// it), elsewhere the window's outer top-left. Saving the outer corner on
|
||||
/// macOS and restoring it as the content corner made the window creep up by
|
||||
/// a title bar on every launch.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WindowFrame {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
#[serde(default)]
|
||||
pub fullscreen: bool,
|
||||
}
|
||||
|
||||
/// A display's full bounds in logical coordinates, as tao reports them. This
|
||||
/// is the whole display, not the work area: the menu bar and the Dock are
|
||||
/// inside it.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Rect {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
}
|
||||
|
||||
impl WindowFrame {
|
||||
/// The frame clamped to sane sizes, or None when it holds junk.
|
||||
pub fn sanitized(self) -> Option<WindowFrame> {
|
||||
let vals = [self.x, self.y, self.width, self.height];
|
||||
if vals.iter().any(|v| !v.is_finite()) || self.x.abs() > MAX_EDGE || self.y.abs() > MAX_EDGE {
|
||||
return None;
|
||||
}
|
||||
Some(WindowFrame {
|
||||
width: self.width.clamp(MIN_WIDTH, MAX_EDGE),
|
||||
height: self.height.clamp(MIN_HEIGHT, MAX_EDGE),
|
||||
..self
|
||||
})
|
||||
}
|
||||
|
||||
/// The frame made to fit the screens it will open on, or None when its
|
||||
/// title bar is on none of them (the caller then falls back to a default
|
||||
/// frame). An edge that no screen covers is pulled onto the screen that
|
||||
/// holds the title bar, shrinking the window when it is too big for it:
|
||||
/// a frame saved on an external display that is gone would otherwise
|
||||
/// open with its right edge and resize corner far off screen. An edge
|
||||
/// that lands on some screen stays put, so a window spanning two
|
||||
/// displays keeps its span. `min` is the window's minimum inner size.
|
||||
pub fn fitted_to(&self, screens: &[Rect], min: (f64, f64)) -> Option<WindowFrame> {
|
||||
let home = *self.title_screen(screens)?;
|
||||
let top = self.outer_top();
|
||||
let outer_height = TITLE_BAR + self.height;
|
||||
let (x, width) = fit_axis(
|
||||
self.x,
|
||||
self.width,
|
||||
(
|
||||
covers_x(screens, self.x, top, top + outer_height),
|
||||
covers_x(screens, self.x + self.width, top, top + outer_height),
|
||||
),
|
||||
(home.x, home.x + home.width),
|
||||
FIT_MARGIN_X,
|
||||
min.0,
|
||||
);
|
||||
let (top, outer_height) = fit_axis(
|
||||
top,
|
||||
outer_height,
|
||||
(
|
||||
covers_y(screens, top, x, x + width),
|
||||
covers_y(screens, top + outer_height, x, x + width),
|
||||
),
|
||||
(home.y, home.y + home.height),
|
||||
FIT_MARGIN_Y - TITLE_BAR,
|
||||
min.1 + TITLE_BAR,
|
||||
);
|
||||
Some(WindowFrame {
|
||||
x,
|
||||
y: top + TITLE_ABOVE_Y,
|
||||
width,
|
||||
height: outer_height - TITLE_BAR,
|
||||
..*self
|
||||
})
|
||||
}
|
||||
|
||||
fn outer_top(&self) -> f64 {
|
||||
self.y - TITLE_ABOVE_Y
|
||||
}
|
||||
|
||||
/// The screen the title bar is on, or None when no screen has enough of
|
||||
/// it to grab (a display unplugged since the last run leaves the frame off
|
||||
/// screen). The middle of the title bar must be inside the screen and at
|
||||
/// least `MIN_GRIP` of the bar's width must overlap it; when several
|
||||
/// qualify, the one holding most of the bar wins.
|
||||
fn title_screen<'a>(&self, screens: &'a [Rect]) -> Option<&'a Rect> {
|
||||
let title_y = self.outer_top() + TITLE_BAR / 2.0;
|
||||
screens
|
||||
.iter()
|
||||
.filter(|m| title_y >= m.y && title_y <= m.y + m.height)
|
||||
.map(|m| {
|
||||
let grip = (self.x + self.width).min(m.x + m.width) - self.x.max(m.x);
|
||||
(m, grip)
|
||||
})
|
||||
.filter(|(_, grip)| *grip >= MIN_GRIP)
|
||||
.max_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|(m, _)| m)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether some screen holds part of the vertical edge at `x` that runs from
|
||||
/// `top` to `bottom`.
|
||||
fn covers_x(screens: &[Rect], x: f64, top: f64, bottom: f64) -> bool {
|
||||
screens
|
||||
.iter()
|
||||
.any(|m| x >= m.x && x <= m.x + m.width && top < m.y + m.height && bottom > m.y)
|
||||
}
|
||||
|
||||
/// Whether some screen holds part of the horizontal edge at `y` that runs
|
||||
/// from `left` to `right`.
|
||||
fn covers_y(screens: &[Rect], y: f64, left: f64, right: f64) -> bool {
|
||||
screens
|
||||
.iter()
|
||||
.any(|m| y >= m.y && y <= m.y + m.height && left < m.x + m.width && right > m.x)
|
||||
}
|
||||
|
||||
/// One axis of `WindowFrame::fitted_to`. The window runs from `start` for
|
||||
/// `len`; `covered` says whether some screen holds each end; `home` is the
|
||||
/// title bar screen's range on this axis. An uncovered end is pulled onto
|
||||
/// `home`, and the window shrinks (leaving `margin`, never below `min`) when
|
||||
/// it does not fit; a covered end may stay where it is, on another display.
|
||||
fn fit_axis(start: f64, len: f64, covered: (bool, bool), home: (f64, f64), margin: f64, min: f64) -> (f64, f64) {
|
||||
if covered.0 && covered.1 {
|
||||
return (start, len);
|
||||
}
|
||||
let lo = if covered.0 { start.min(home.0) } else { home.0 };
|
||||
let hi = if covered.1 { (start + len).max(home.1) } else { home.1 };
|
||||
let len = len.min(hi - lo - margin).max(min);
|
||||
// `max` last: a window still too big for the screen keeps its start (the
|
||||
// left edge, or the title bar) on screen rather than its end.
|
||||
let start = start.min(hi - len).max(lo);
|
||||
(start, len)
|
||||
}
|
||||
|
||||
/// The first name an unreadable `desktop.json` can be moved to without
|
||||
/// replacing an earlier recovery copy: `desktop.json.corrupt`, then
|
||||
/// `desktop.json.corrupt.1` to `.9`, next to it.
|
||||
/// A name is free only when nothing at all is there: a file, a directory or
|
||||
/// a dangling symlink left at it is taken, and so is a name that cannot be
|
||||
/// checked. None when every name is taken.
|
||||
fn free_aside_name(path: &Path) -> Option<PathBuf> {
|
||||
(0..ASIDE_SLOTS)
|
||||
.map(|n| match n {
|
||||
0 => path.with_extension("json.corrupt"),
|
||||
n => path.with_extension(format!("json.corrupt.{n}")),
|
||||
})
|
||||
.find(|aside| matches!(fs::symlink_metadata(aside), Err(e) if e.kind() == io::ErrorKind::NotFound))
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Where the config lives, or None if the OS has no config directory.
|
||||
pub fn default_path() -> Option<PathBuf> {
|
||||
dirs::config_dir().map(|d| d.join("Codeman").join("desktop.json"))
|
||||
}
|
||||
|
||||
/// Read the config, and say whether saving back to `path` is safe this
|
||||
/// run. A missing file is the default. A file that is there but cannot
|
||||
/// be read (not UTF-8, no permission, a directory, an I/O error) or does
|
||||
/// not parse is moved aside for recovery, and the defaults are used; it
|
||||
/// is never silently overwritten with them. It goes to the first free
|
||||
/// name of `desktop.json.corrupt`, `desktop.json.corrupt.1` to `.9`, so
|
||||
/// a second unreadable file never replaces the copy kept from the first.
|
||||
/// If every name is taken or the move fails, the answer is false: the
|
||||
/// original is still at `path`, and the save at exit must not replace it.
|
||||
pub fn load_from(path: &Path) -> (Config, bool) {
|
||||
let text = match fs::read_to_string(path) {
|
||||
Ok(text) => Some(text),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => return (Config::default(), true),
|
||||
Err(_) => None,
|
||||
};
|
||||
if let Some(mut config) = text.and_then(|t| serde_json::from_str::<Config>(&t).ok()) {
|
||||
config.sanitize();
|
||||
return (config, true);
|
||||
}
|
||||
let Some(aside) = free_aside_name(path) else {
|
||||
eprintln!(
|
||||
"codeman-desktop: {} could not be read, and {} to .{} are all taken; settings will not be saved this run",
|
||||
path.display(),
|
||||
path.with_extension("json.corrupt").display(),
|
||||
ASIDE_SLOTS - 1
|
||||
);
|
||||
return (Config::default(), false);
|
||||
};
|
||||
match fs::rename(path, &aside) {
|
||||
Ok(()) => {
|
||||
eprintln!(
|
||||
"codeman-desktop: {} could not be read; moved it to {} and started with defaults",
|
||||
path.display(),
|
||||
aside.display()
|
||||
);
|
||||
(Config::default(), true)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"codeman-desktop: {} could not be read or moved aside ({e}); settings will not be saved this run",
|
||||
path.display()
|
||||
);
|
||||
(Config::default(), false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write via a temp file and rename, so a crash never leaves half a file.
|
||||
pub fn save_to(&self, path: &Path) -> io::Result<()> {
|
||||
if let Some(dir) = path.parent() {
|
||||
fs::create_dir_all(dir)?;
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
let text = serde_json::to_string_pretty(self).map_err(io::Error::other)?;
|
||||
fs::write(&tmp, text)?;
|
||||
fs::rename(&tmp, path)
|
||||
}
|
||||
|
||||
fn sanitize(&mut self) {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
self.servers.retain(|s| !s.url.is_empty() && seen.insert(s.url.clone()));
|
||||
self.servers.truncate(MAX_SERVERS);
|
||||
self.window = self.window.and_then(WindowFrame::sanitized);
|
||||
}
|
||||
|
||||
/// Move (or add) a server to the top of the recent list. Returns the
|
||||
/// Keychain accounts, as (url, username), of the entries with a username
|
||||
/// that fall off the end of the list (it keeps `MAX_SERVERS`), for the
|
||||
/// caller to delete. A different username on `url` itself is not
|
||||
/// reported: the caller deletes that account before it saves the new
|
||||
/// one, comparing the names exactly as the Keychain account string
|
||||
/// does. A delete made after the save could take the new password with
|
||||
/// it on a Keychain that treats two spellings of a name as one item.
|
||||
#[must_use = "the returned Keychain accounts must be deleted"]
|
||||
pub fn remember(&mut self, url: &str, username: Option<&str>, saved: bool) -> Vec<(String, String)> {
|
||||
self.servers.retain(|s| s.url != url);
|
||||
self.servers.insert(
|
||||
0,
|
||||
ServerEntry {
|
||||
url: url.to_string(),
|
||||
username: username.map(str::to_string),
|
||||
has_saved_password: saved,
|
||||
},
|
||||
);
|
||||
let evicted = self.servers.split_off(self.servers.len().min(MAX_SERVERS));
|
||||
evicted.into_iter().filter_map(|s| Some((s.url, s.username?))).collect()
|
||||
}
|
||||
|
||||
pub fn forget(&mut self, url: &str) -> Option<ServerEntry> {
|
||||
let idx = self.servers.iter().position(|s| s.url == url)?;
|
||||
Some(self.servers.remove(idx))
|
||||
}
|
||||
|
||||
pub fn find(&self, url: &str) -> Option<&ServerEntry> {
|
||||
self.servers.iter().find(|s| s.url == url)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tmp_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("codeman-desktop-test-{}-{name}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
fn frame(x: f64, y: f64, width: f64, height: f64) -> WindowFrame {
|
||||
WindowFrame {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
fullscreen: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn rect(x: f64, y: f64, width: f64, height: f64) -> Rect {
|
||||
Rect { x, y, width, height }
|
||||
}
|
||||
|
||||
const MIN: (f64, f64) = (MIN_WIDTH, MIN_HEIGHT);
|
||||
|
||||
fn reachable(f: &WindowFrame, screens: &[Rect]) -> bool {
|
||||
f.title_screen(screens).is_some()
|
||||
}
|
||||
|
||||
/// The outer frame (title bar included) as left, top, right, bottom.
|
||||
fn outer(f: &WindowFrame) -> (f64, f64, f64, f64) {
|
||||
let top = f.y - TITLE_ABOVE_Y;
|
||||
(f.x, top, f.x + f.width, top + TITLE_BAR + f.height)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_is_default() {
|
||||
let dir = tmp_dir("missing");
|
||||
let (c, can_save) = Config::load_from(&dir.join("desktop.json"));
|
||||
assert_eq!(c, Config::default());
|
||||
assert!(c.auto_connect);
|
||||
assert!(can_save);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_and_atomic_save() {
|
||||
let dir = tmp_dir("roundtrip");
|
||||
let path = dir.join("nested").join("desktop.json");
|
||||
let mut c = Config::default();
|
||||
assert!(c.remember("http://127.0.0.1:3000/", None, false).is_empty());
|
||||
assert!(c.remember("https://box.ts.net/", Some("admin"), true).is_empty());
|
||||
c.start_fullscreen = true;
|
||||
c.window = Some(WindowFrame {
|
||||
x: 10.0,
|
||||
y: 20.0,
|
||||
width: 1200.0,
|
||||
height: 800.0,
|
||||
fullscreen: true,
|
||||
});
|
||||
c.save_to(&path).unwrap();
|
||||
assert!(!path.with_extension("json.tmp").exists());
|
||||
let (back, can_save) = Config::load_from(&path);
|
||||
assert!(can_save);
|
||||
assert_eq!(back, c);
|
||||
assert_eq!(back.servers[0].url, "https://box.ts.net/");
|
||||
let text = fs::read_to_string(&path).unwrap();
|
||||
assert!(
|
||||
!text.to_lowercase().contains("password\":\""),
|
||||
"no password values on disk"
|
||||
);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_file_is_moved_aside() {
|
||||
let dir = tmp_dir("corrupt");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("desktop.json");
|
||||
fs::write(&path, "{ not json").unwrap();
|
||||
let (c, can_save) = Config::load_from(&path);
|
||||
assert_eq!(c, Config::default());
|
||||
assert!(can_save);
|
||||
assert!(!path.exists());
|
||||
assert!(dir.join("desktop.json.corrupt").exists());
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_utf8_file_is_moved_aside() {
|
||||
let dir = tmp_dir("non-utf8");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("desktop.json");
|
||||
// Valid JSON apart from a Latin-1 "ü", which is not UTF-8.
|
||||
let bytes = b"{\"servers\":[{\"url\":\"http://a/\",\"username\":\"J\xfcrg\"}]}";
|
||||
fs::write(&path, bytes).unwrap();
|
||||
let (c, can_save) = Config::load_from(&path);
|
||||
assert_eq!(c, Config::default());
|
||||
assert!(can_save);
|
||||
assert!(!path.exists());
|
||||
assert_eq!(fs::read(dir.join("desktop.json.corrupt")).unwrap(), bytes);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_path_is_moved_aside() {
|
||||
let dir = tmp_dir("unreadable");
|
||||
let path = dir.join("desktop.json");
|
||||
// A directory where the file should be: it exists but cannot be read.
|
||||
fs::create_dir_all(path.join("inside")).unwrap();
|
||||
let (c, can_save) = Config::load_from(&path);
|
||||
assert_eq!(c, Config::default());
|
||||
assert!(can_save);
|
||||
assert!(!path.exists());
|
||||
assert!(dir.join("desktop.json.corrupt").join("inside").is_dir());
|
||||
c.save_to(&path).unwrap();
|
||||
assert_eq!(Config::load_from(&path), (Config::default(), true));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_corruption_keeps_the_first_copy() {
|
||||
let dir = tmp_dir("corrupt-twice");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("desktop.json");
|
||||
fs::write(&path, "{ first").unwrap();
|
||||
assert_eq!(Config::load_from(&path), (Config::default(), true));
|
||||
fs::write(&path, "{ second").unwrap();
|
||||
assert_eq!(Config::load_from(&path), (Config::default(), true));
|
||||
assert!(!path.exists());
|
||||
assert_eq!(fs::read_to_string(dir.join("desktop.json.corrupt")).unwrap(), "{ first");
|
||||
assert_eq!(
|
||||
fs::read_to_string(dir.join("desktop.json.corrupt.1")).unwrap(),
|
||||
"{ second"
|
||||
);
|
||||
// A good file written afterwards loads normally and moves nothing.
|
||||
Config::default().save_to(&path).unwrap();
|
||||
assert_eq!(Config::load_from(&path), (Config::default(), true));
|
||||
assert!(!dir.join("desktop.json.corrupt.2").exists());
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn taken_aside_names_are_skipped() {
|
||||
let dir = tmp_dir("aside-taken");
|
||||
// A directory left at the first name and a file at the second: the
|
||||
// third is the first free one.
|
||||
fs::create_dir_all(dir.join("desktop.json.corrupt")).unwrap();
|
||||
fs::write(dir.join("desktop.json.corrupt.1"), "kept").unwrap();
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(dir.join("nowhere"), dir.join("desktop.json.corrupt.2")).unwrap();
|
||||
let next = if cfg!(unix) {
|
||||
"desktop.json.corrupt.3"
|
||||
} else {
|
||||
"desktop.json.corrupt.2"
|
||||
};
|
||||
let path = dir.join("desktop.json");
|
||||
fs::write(&path, "{ not json").unwrap();
|
||||
assert_eq!(Config::load_from(&path), (Config::default(), true));
|
||||
assert!(!path.exists());
|
||||
assert!(dir.join("desktop.json.corrupt").is_dir());
|
||||
assert_eq!(fs::read_to_string(dir.join("desktop.json.corrupt.1")).unwrap(), "kept");
|
||||
// The dangling symlink still points nowhere and still counts as taken.
|
||||
#[cfg(unix)]
|
||||
assert!(fs::symlink_metadata(dir.join("desktop.json.corrupt.2"))
|
||||
.unwrap()
|
||||
.is_symlink());
|
||||
assert_eq!(fs::read_to_string(dir.join(next)).unwrap(), "{ not json");
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_free_aside_name_disables_saving() {
|
||||
let dir = tmp_dir("aside-full");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("desktop.json");
|
||||
let names: Vec<PathBuf> = std::iter::once(dir.join("desktop.json.corrupt"))
|
||||
.chain((1..ASIDE_SLOTS).map(|n| dir.join(format!("desktop.json.corrupt.{n}"))))
|
||||
.collect();
|
||||
assert_eq!(names.len(), 10);
|
||||
for (n, name) in names.iter().enumerate() {
|
||||
fs::write(name, format!("copy {n}")).unwrap();
|
||||
}
|
||||
fs::write(&path, "{ not json").unwrap();
|
||||
assert_eq!(Config::load_from(&path), (Config::default(), false));
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "{ not json");
|
||||
for (n, name) in names.iter().enumerate() {
|
||||
assert_eq!(fs::read_to_string(name).unwrap(), format!("copy {n}"));
|
||||
}
|
||||
assert!(!dir.join("desktop.json.corrupt.10").exists());
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn failed_move_aside_disables_saving() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tmp_dir("stuck");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("desktop.json");
|
||||
fs::write(&path, "{ not json").unwrap();
|
||||
fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap();
|
||||
// Root ignores the read-only directory, so the rename cannot fail.
|
||||
if fs::write(dir.join("probe"), "").is_ok() {
|
||||
eprintln!("skipped: the directory is still writable (running as root?)");
|
||||
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
return;
|
||||
}
|
||||
let (c, can_save) = Config::load_from(&path);
|
||||
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert_eq!(c, Config::default());
|
||||
assert!(!can_save);
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "{ not json");
|
||||
assert!(!dir.join("desktop.json.corrupt").exists());
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_list_is_deduped_and_capped() {
|
||||
let mut c = Config::default();
|
||||
for i in 0..12 {
|
||||
assert!(c.remember(&format!("http://h{i}/"), None, false).is_empty());
|
||||
}
|
||||
assert!(c.remember("http://h5/", Some("me"), false).is_empty());
|
||||
assert_eq!(c.servers.len(), MAX_SERVERS);
|
||||
assert_eq!(c.servers[0].url, "http://h5/");
|
||||
assert_eq!(c.servers.iter().filter(|s| s.url == "http://h5/").count(), 1);
|
||||
assert_eq!(c.forget("http://h5/").unwrap().username.as_deref(), Some("me"));
|
||||
assert!(c.find("http://h5/").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remember_leaves_a_username_change_to_the_caller() {
|
||||
let url = "https://box.ts.net/";
|
||||
let mut c = Config::default();
|
||||
assert!(c.remember(url, Some("Alice"), true).is_empty());
|
||||
// Every change of the username on the same server, a case-only one,
|
||||
// another user, or none at all, returns nothing: the caller deletes
|
||||
// the old account before it saves the new one.
|
||||
for (user, saved) in [
|
||||
(Some("alice"), true),
|
||||
(Some(" alice "), true),
|
||||
(Some("bob"), true),
|
||||
(None, false),
|
||||
(None, false),
|
||||
(Some("dave"), true),
|
||||
] {
|
||||
assert!(c.remember(url, user, saved).is_empty(), "{user:?}");
|
||||
let entry = c.find(url).unwrap();
|
||||
assert_eq!((entry.username.as_deref(), entry.has_saved_password), (user, saved));
|
||||
assert_eq!(c.servers.iter().filter(|s| s.url == url).count(), 1);
|
||||
}
|
||||
assert!(c.remember("http://other/", Some("carol"), true).is_empty());
|
||||
assert!(c.remember(url, Some("erin"), true).is_empty());
|
||||
assert_eq!(c.servers.len(), 2);
|
||||
assert_eq!(c.servers[0].url, url);
|
||||
assert_eq!(c.find("http://other/").unwrap().username.as_deref(), Some("carol"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remember_returns_evicted_accounts() {
|
||||
let mut c = Config::default();
|
||||
for i in 0..MAX_SERVERS {
|
||||
let user = (i % 2 == 0).then(|| format!("u{i}"));
|
||||
assert!(c.remember(&format!("http://h{i}/"), user.as_deref(), true).is_empty());
|
||||
}
|
||||
// Moving a listed server to the top evicts nothing.
|
||||
assert!(c.remember("http://h3/", None, false).is_empty());
|
||||
assert_eq!(c.servers.len(), MAX_SERVERS);
|
||||
// A new server pushes the oldest (h0, with a username) off the end.
|
||||
assert_eq!(
|
||||
c.remember("http://new/", None, false),
|
||||
vec![("http://h0/".to_string(), "u0".to_string())]
|
||||
);
|
||||
// The next oldest is h1, which has no username: nothing to delete.
|
||||
assert!(c.remember("http://newer/", Some("x"), true).is_empty());
|
||||
assert_eq!(c.servers.len(), MAX_SERVERS);
|
||||
assert!(c.find("http://h0/").is_none() && c.find("http://h1/").is_none());
|
||||
// A username change on a listed server returns nothing; the next new
|
||||
// server evicts the oldest entry, and only that account comes back.
|
||||
let mut c = Config::default();
|
||||
for i in 0..MAX_SERVERS {
|
||||
assert!(c.remember(&format!("http://h{i}/"), Some("u"), true).is_empty());
|
||||
}
|
||||
assert!(c.remember("http://h7/", Some("v"), true).is_empty());
|
||||
assert_eq!(
|
||||
c.remember("http://h8/", Some("w"), true),
|
||||
vec![("http://h0/".to_string(), "u".to_string())]
|
||||
);
|
||||
assert_eq!(c.find("http://h7/").unwrap().username.as_deref(), Some("v"));
|
||||
assert_eq!(c.servers.len(), MAX_SERVERS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_and_missing_fields_tolerated() {
|
||||
let c: Config = serde_json::from_str(r#"{"servers":[{"url":"http://a/"}],"future":1}"#).unwrap();
|
||||
assert!(c.auto_connect);
|
||||
assert_eq!(c.servers[0].username, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_sanitizing_and_reachability() {
|
||||
assert!(frame(f64::NAN, 0.0, 800.0, 600.0).sanitized().is_none());
|
||||
let small = frame(0.0, 0.0, 10.0, 10.0).sanitized().unwrap();
|
||||
assert_eq!((small.width, small.height), (MIN_WIDTH, MIN_HEIGHT));
|
||||
let screen = [rect(0.0, 0.0, 1512.0, 982.0)];
|
||||
let on = frame(100.0, 50.0, 1200.0, 800.0);
|
||||
assert!(reachable(&on, &screen));
|
||||
assert_eq!(on.fitted_to(&screen, MIN), Some(on));
|
||||
let off = frame(3000.0, 50.0, 1200.0, 800.0);
|
||||
assert!(!reachable(&off, &screen));
|
||||
assert_eq!(off.fitted_to(&screen, MIN), None);
|
||||
let above = frame(100.0, -900.0, 1200.0, 800.0);
|
||||
assert!(!reachable(&above, &screen));
|
||||
assert_eq!(above.fitted_to(&screen, MIN), None);
|
||||
// Only a sliver of the title bar on screen is not enough to grab.
|
||||
let sliver = frame(-1150.0, 50.0, 1200.0, 800.0);
|
||||
assert!(!reachable(&sliver, &screen));
|
||||
assert_eq!(sliver.fitted_to(&screen, MIN), None);
|
||||
// No screens at all (none reported): nothing is reachable.
|
||||
assert_eq!(on.fitted_to(&[], MIN), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_bar_is_above_y_on_macos() {
|
||||
let screen = [rect(0.0, 0.0, 1512.0, 982.0)];
|
||||
// On macOS the title bar of a content top at y = 10 is at -18..10,
|
||||
// mostly above the screen; elsewhere it is at 10..38.
|
||||
let high = frame(100.0, 10.0, 1200.0, 800.0);
|
||||
assert_eq!(reachable(&high, &screen), !cfg!(target_os = "macos"));
|
||||
// Half the bar above the top edge: still grabbable, and fitting
|
||||
// brings all of it onto the screen.
|
||||
let straddling = frame(100.0, TITLE_ABOVE_Y - 10.0, 1200.0, 800.0);
|
||||
assert!(reachable(&straddling, &screen));
|
||||
let fitted = straddling.fitted_to(&screen, MIN).unwrap();
|
||||
assert_eq!(outer(&fitted).1, 0.0);
|
||||
assert_eq!((fitted.x, fitted.width, fitted.height), (100.0, 1200.0, 800.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_from_an_unplugged_ultrawide_is_fitted() {
|
||||
// Saved on a 3440x1440 display at the origin, restored on a laptop.
|
||||
let laptop = [rect(0.0, 0.0, 1512.0, 982.0)];
|
||||
let wide = frame(0.0, 30.0, 3300.0, 1300.0);
|
||||
let fitted = wide.fitted_to(&laptop, MIN).unwrap();
|
||||
assert_eq!(
|
||||
(fitted.x, fitted.y, fitted.width, fitted.height),
|
||||
(0.0, 30.0, 1512.0 - FIT_MARGIN_X, 982.0 - FIT_MARGIN_Y)
|
||||
);
|
||||
let (left, top, right, bottom) = outer(&fitted);
|
||||
assert!(left >= 0.0 && top >= 0.0 && right <= 1512.0 && bottom <= 982.0);
|
||||
// Hanging off the right of the laptop by a bit: shifted, not shrunk.
|
||||
let fitted = frame(500.0, 100.0, 1200.0, 700.0).fitted_to(&laptop, MIN).unwrap();
|
||||
assert_eq!(
|
||||
(fitted.x, fitted.y, fitted.width, fitted.height),
|
||||
(312.0, 100.0, 1200.0, 700.0)
|
||||
);
|
||||
// Hanging off the left: shifted right.
|
||||
let fitted = frame(-1000.0, 100.0, 1200.0, 700.0).fitted_to(&laptop, MIN).unwrap();
|
||||
assert_eq!((fitted.x, fitted.width), (0.0, 1200.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_spanning_two_displays_is_left_alone() {
|
||||
let screens = [rect(0.0, 0.0, 1512.0, 982.0), rect(1512.0, -200.0, 2560.0, 1440.0)];
|
||||
let span = frame(1000.0, 100.0, 1500.0, 700.0);
|
||||
assert_eq!(span.fitted_to(&screens, MIN), Some(span));
|
||||
// Only the edge that lands on no display moves: the left edge stays
|
||||
// on the laptop, the right edge comes back onto the external one.
|
||||
let past = frame(1000.0, 100.0, 3500.0, 700.0);
|
||||
let fitted = past.fitted_to(&screens, MIN).unwrap();
|
||||
assert_eq!(fitted.x, 1000.0);
|
||||
assert_eq!(fitted.x + fitted.width, 1512.0 + 2560.0 - FIT_MARGIN_X);
|
||||
assert_eq!((fitted.y, fitted.height), (100.0, 700.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fitted_frame_keeps_the_minimum_size() {
|
||||
let tiny = [rect(0.0, 0.0, 400.0, 300.0)];
|
||||
let fitted = frame(0.0, 40.0, 1200.0, 800.0).fitted_to(&tiny, MIN).unwrap();
|
||||
assert_eq!((fitted.width, fitted.height), MIN);
|
||||
// Too big for the screen even at the minimum: the left edge and the
|
||||
// title bar stay on it.
|
||||
let (left, top, _, _) = outer(&fitted);
|
||||
assert_eq!((left, top), (0.0, 0.0));
|
||||
assert!(reachable(&fitted, &tiny));
|
||||
// The fullscreen flag rides along.
|
||||
let full = WindowFrame {
|
||||
fullscreen: true,
|
||||
..frame(0.0, 40.0, 1200.0, 800.0)
|
||||
};
|
||||
assert!(full.fitted_to(&tiny, MIN).unwrap().fullscreen);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
//! Saved passwords, in the macOS Keychain (service "Codeman Desktop", one item
|
||||
//! per username + server). Nothing secret is ever written to the config file.
|
||||
//! Windows and Linux get their stores when those builds land.
|
||||
//!
|
||||
//! Every Keychain call runs on ONE long-lived worker thread, never on the
|
||||
//! event loop: any of them can bring up the system's access prompt (an
|
||||
//! ad-hoc build's items name the previous build), and the window must keep
|
||||
//! running while it is up. One serial worker rather than a thread per call,
|
||||
//! because jobs must run in the order they were asked for: a delete from
|
||||
//! Forget that ran after a later save of the same account would remove the
|
||||
//! password that was just saved.
|
||||
|
||||
use std::sync::mpsc::{self, Receiver, Sender};
|
||||
use std::thread::{self, JoinHandle};
|
||||
|
||||
pub const SERVICE: &str = "Codeman Desktop";
|
||||
|
||||
/// What a job reports when the worker thread is gone.
|
||||
const WORKER_GONE: &str = "The Keychain could not be reached.";
|
||||
|
||||
/// The Keychain account name for a username on a server.
|
||||
pub fn account(server_url: &str, username: &str) -> String {
|
||||
format!("{username} @ {server_url}")
|
||||
}
|
||||
|
||||
/// The password store the worker runs jobs against: the Keychain on macOS,
|
||||
/// a stub elsewhere, an in-memory map in the tests.
|
||||
trait Backend: Send {
|
||||
fn load(&mut self, account: &str) -> Option<String>;
|
||||
fn save(&mut self, account: &str, password: &str) -> Result<(), String>;
|
||||
fn delete(&mut self, account: &str);
|
||||
}
|
||||
|
||||
enum Job {
|
||||
Load {
|
||||
account: String,
|
||||
reply: Sender<Option<String>>,
|
||||
},
|
||||
Save {
|
||||
account: String,
|
||||
password: String,
|
||||
reply: Sender<Result<(), String>>,
|
||||
},
|
||||
Delete {
|
||||
account: String,
|
||||
},
|
||||
/// Ends the worker, so the tests can check what callers see after it.
|
||||
#[cfg(test)]
|
||||
Stop,
|
||||
}
|
||||
|
||||
/// A handle on the Keychain worker. Start it once with [`Keychain::spawn`]
|
||||
/// and clone the handle: every clone feeds the same queue, which is what
|
||||
/// keeps jobs in order. The worker ends when the last handle is dropped.
|
||||
#[derive(Clone)]
|
||||
pub struct Keychain {
|
||||
jobs: Sender<Job>,
|
||||
}
|
||||
|
||||
impl Keychain {
|
||||
/// Starts the worker. If the thread cannot be started, the handle still
|
||||
/// works and every call fails soft (no password, save refused).
|
||||
pub fn spawn() -> Keychain {
|
||||
start(Box::new(imp::System)).0
|
||||
}
|
||||
|
||||
/// The saved password for an account. Blocks until every job queued
|
||||
/// before it has run and the Keychain has answered, which can mean
|
||||
/// waiting on the access prompt, so call it from a worker thread.
|
||||
/// `None` when nothing is saved, the item is unreadable or the worker
|
||||
/// is gone.
|
||||
pub fn load_blocking(&self, account: &str) -> Option<String> {
|
||||
let (reply, answer) = mpsc::channel();
|
||||
let job = Job::Load {
|
||||
account: account.to_owned(),
|
||||
reply,
|
||||
};
|
||||
self.jobs.send(job).ok()?;
|
||||
answer.recv().ok().flatten()
|
||||
}
|
||||
|
||||
/// Saves a password, replacing any saved before. Blocks like
|
||||
/// [`Keychain::load_blocking`]; the result is the Keychain's own.
|
||||
pub fn save_blocking(&self, account: &str, password: &str) -> Result<(), String> {
|
||||
let (reply, answer) = mpsc::channel();
|
||||
let job = Job::Save {
|
||||
account: account.to_owned(),
|
||||
password: password.to_owned(),
|
||||
reply,
|
||||
};
|
||||
if self.jobs.send(job).is_err() {
|
||||
return Err(WORKER_GONE.into());
|
||||
}
|
||||
answer.recv().unwrap_or_else(|_| Err(WORKER_GONE.into()))
|
||||
}
|
||||
|
||||
/// Queues the removal of an account's password and returns at once.
|
||||
/// Jobs queued after it see the account gone. Nothing happens when the
|
||||
/// worker is gone.
|
||||
pub fn delete(&self, account: &str) {
|
||||
let _ = self.jobs.send(Job::Delete {
|
||||
account: account.to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a worker on `backend`. The join handle is for the tests; `None`
|
||||
/// when the thread could not be started, in which case the receiver went
|
||||
/// down with the closure and every send fails.
|
||||
fn start(backend: Box<dyn Backend>) -> (Keychain, Option<JoinHandle<()>>) {
|
||||
let (jobs, queue) = mpsc::channel();
|
||||
let worker = thread::Builder::new()
|
||||
.name("keychain".into())
|
||||
.spawn(move || run(backend, queue))
|
||||
.ok();
|
||||
(Keychain { jobs }, worker)
|
||||
}
|
||||
|
||||
/// The worker loop: one job at a time, in the order they were sent, until
|
||||
/// every handle is gone. A reply nobody waits for any more is dropped.
|
||||
fn run(mut backend: Box<dyn Backend>, queue: Receiver<Job>) {
|
||||
for job in queue {
|
||||
match job {
|
||||
Job::Load { account, reply } => {
|
||||
let _ = reply.send(backend.load(&account));
|
||||
}
|
||||
Job::Save {
|
||||
account,
|
||||
password,
|
||||
reply,
|
||||
} => {
|
||||
let _ = reply.send(backend.save(&account, &password));
|
||||
}
|
||||
Job::Delete { account } => backend.delete(&account),
|
||||
#[cfg(test)]
|
||||
Job::Stop => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod imp {
|
||||
use super::{Backend, SERVICE};
|
||||
use objc2::rc::autoreleasepool;
|
||||
use security_framework::passwords::{delete_generic_password, get_generic_password, set_generic_password};
|
||||
|
||||
/// The login Keychain, through security-framework. The worker lives as
|
||||
/// long as the app and a thread's implicit autorelease pool drains only
|
||||
/// when the thread ends, so each call gets its own pool and whatever
|
||||
/// Security autoreleases is freed with the job.
|
||||
pub struct System;
|
||||
|
||||
impl Backend for System {
|
||||
fn load(&mut self, account: &str) -> Option<String> {
|
||||
autoreleasepool(|_| {
|
||||
get_generic_password(SERVICE, account)
|
||||
.ok()
|
||||
.and_then(|b| String::from_utf8(b).ok())
|
||||
})
|
||||
}
|
||||
|
||||
fn save(&mut self, account: &str, password: &str) -> Result<(), String> {
|
||||
autoreleasepool(|_| set_generic_password(SERVICE, account, password.as_bytes()).map_err(|e| e.to_string()))
|
||||
}
|
||||
|
||||
fn delete(&mut self, account: &str) {
|
||||
autoreleasepool(|_| {
|
||||
let _ = delete_generic_password(SERVICE, account);
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
mod imp {
|
||||
use super::Backend;
|
||||
|
||||
/// No password store yet on this platform.
|
||||
pub struct System;
|
||||
|
||||
impl Backend for System {
|
||||
fn load(&mut self, _account: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn save(&mut self, _account: &str, _password: &str) -> Result<(), String> {
|
||||
Err("Saving passwords is not supported on this platform yet.".into())
|
||||
}
|
||||
fn delete(&mut self, _account: &str) {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{start, Backend, Job, Keychain};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
/// An in-memory store that logs every job it runs. Its delete is slow on
|
||||
/// purpose: a queue that did not keep order would let the next job
|
||||
/// overtake it, and the tests would see that.
|
||||
#[derive(Clone, Default)]
|
||||
struct Memory {
|
||||
items: Arc<Mutex<HashMap<String, String>>>,
|
||||
log: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl Memory {
|
||||
fn note(&self, entry: String) {
|
||||
self.log.lock().unwrap().push(entry);
|
||||
}
|
||||
fn log(&self) -> Vec<String> {
|
||||
self.log.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for Memory {
|
||||
fn load(&mut self, account: &str) -> Option<String> {
|
||||
self.note(format!("load {account}"));
|
||||
self.items.lock().unwrap().get(account).cloned()
|
||||
}
|
||||
fn save(&mut self, account: &str, password: &str) -> Result<(), String> {
|
||||
self.note(format!("save {account}"));
|
||||
self.items
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(account.to_owned(), password.to_owned());
|
||||
Ok(())
|
||||
}
|
||||
fn delete(&mut self, account: &str) {
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
self.note(format!("delete {account}"));
|
||||
self.items.lock().unwrap().remove(account);
|
||||
}
|
||||
}
|
||||
|
||||
/// A store whose load fails the way a crashed worker would.
|
||||
struct Crashes;
|
||||
|
||||
impl Backend for Crashes {
|
||||
fn load(&mut self, _account: &str) -> Option<String> {
|
||||
panic!("keychain worker crashed");
|
||||
}
|
||||
fn save(&mut self, _account: &str, _password: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
fn delete(&mut self, _account: &str) {}
|
||||
}
|
||||
|
||||
fn keychain() -> (Keychain, Memory) {
|
||||
let memory = Memory::default();
|
||||
let (keychain, worker) = start(Box::new(memory.clone()));
|
||||
assert!(worker.is_some(), "the worker thread starts");
|
||||
(keychain, memory)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_names_are_per_user_and_server() {
|
||||
assert_eq!(
|
||||
super::account("http://127.0.0.1:3000/", "admin"),
|
||||
"admin @ http://127.0.0.1:3000/"
|
||||
);
|
||||
assert_ne!(
|
||||
super::account("http://a/", "admin"),
|
||||
super::account("http://b/", "admin")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_load_reads_what_a_save_wrote() {
|
||||
let (keychain, _) = keychain();
|
||||
assert_eq!(keychain.load_blocking("a"), None);
|
||||
assert_eq!(keychain.save_blocking("a", "secret"), Ok(()));
|
||||
assert_eq!(keychain.load_blocking("a").as_deref(), Some("secret"));
|
||||
assert_eq!(keychain.save_blocking("a", "changed"), Ok(()));
|
||||
assert_eq!(keychain.load_blocking("a").as_deref(), Some("changed"));
|
||||
assert_eq!(keychain.load_blocking("b"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_delete_queued_before_a_save_never_removes_the_saved_password() {
|
||||
let (keychain, memory) = keychain();
|
||||
assert_eq!(keychain.save_blocking("a", "old"), Ok(()));
|
||||
keychain.delete("a");
|
||||
assert_eq!(keychain.save_blocking("a", "new"), Ok(()));
|
||||
assert_eq!(keychain.load_blocking("a").as_deref(), Some("new"));
|
||||
assert_eq!(memory.log(), ["save a", "delete a", "save a", "load a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_delete_queued_after_a_save_removes_it_before_the_next_job() {
|
||||
let (keychain, memory) = keychain();
|
||||
assert_eq!(keychain.save_blocking("a", "secret"), Ok(()));
|
||||
keychain.delete("a");
|
||||
// No wait between the two: the load is queued behind the delete.
|
||||
assert_eq!(keychain.load_blocking("a"), None);
|
||||
assert_eq!(memory.log(), ["save a", "delete a", "load a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jobs_from_clones_on_other_threads_share_one_queue() {
|
||||
let (keychain, memory) = keychain();
|
||||
let other = keychain.clone();
|
||||
std::thread::spawn(move || {
|
||||
other.save_blocking("a", "secret").unwrap();
|
||||
other.delete("a");
|
||||
})
|
||||
.join()
|
||||
.unwrap();
|
||||
assert_eq!(keychain.load_blocking("a"), None);
|
||||
assert_eq!(memory.log(), ["save a", "delete a", "load a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_call_fails_soft_once_the_worker_is_gone() {
|
||||
let memory = Memory::default();
|
||||
let (keychain, worker) = start(Box::new(memory.clone()));
|
||||
assert_eq!(keychain.save_blocking("a", "secret"), Ok(()));
|
||||
keychain.jobs.send(Job::Stop).unwrap();
|
||||
worker.unwrap().join().unwrap();
|
||||
|
||||
assert_eq!(keychain.load_blocking("a"), None);
|
||||
assert!(keychain.save_blocking("a", "secret").is_err());
|
||||
keychain.delete("a");
|
||||
keychain.clone().delete("a");
|
||||
assert_eq!(memory.log(), ["save a"]);
|
||||
}
|
||||
|
||||
#[cfg(panic = "unwind")]
|
||||
#[test]
|
||||
fn a_worker_that_dies_mid_job_answers_nothing_and_takes_no_more_jobs() {
|
||||
let (keychain, worker) = start(Box::new(Crashes));
|
||||
assert_eq!(keychain.load_blocking("a"), None);
|
||||
assert!(worker.unwrap().join().is_err(), "the worker panicked");
|
||||
assert!(keychain.save_blocking("a", "secret").is_err());
|
||||
keychain.delete("a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_handle_is_send_and_sync() {
|
||||
fn send_sync_clone<T: Send + Sync + Clone + 'static>() {}
|
||||
send_sync_clone::<Keychain>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,855 @@
|
||||
//! The graphical install: the app runs Codeman's installer itself and the
|
||||
//! connect page is its interface, so a fresh Mac needs neither Terminal nor an
|
||||
//! administrator password.
|
||||
//!
|
||||
//! `install.sh --no-admin` is what makes that possible: it puts Node.js and
|
||||
//! tmux in the home folder, so nothing in it asks for a password, and the two
|
||||
//! questions only a person can answer (which coding agent, who may reach
|
||||
//! Codeman) are asked by the page and passed as flags. The Terminal route in
|
||||
//! `setup.rs` stays for what this one cannot do, a Mac older than macOS 15 with
|
||||
//! no tmux first of all.
|
||||
//!
|
||||
//! `prepare` downloads the installer once into a private folder and asks it
|
||||
//! what is on this Mac (`install.sh preflight`); `start` runs the same file with
|
||||
//! every answer given; `follow` reports what it prints.
|
||||
//!
|
||||
//! **Its output goes to a file, never a pipe.** With a pipe, quitting the app
|
||||
//! would kill the installer at its next write, half way through an install. The
|
||||
//! file also holds the whole run, so `follow` can start over from the top: a
|
||||
//! connect page that was reloaded, or an app that was quit and opened again,
|
||||
//! finds the running install in `ps` and replays it. That is why this module
|
||||
//! keeps no state about a run but the file and the process.
|
||||
|
||||
use std::fs::{self, DirBuilder, File, OpenOptions};
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::local_server;
|
||||
use crate::setup;
|
||||
|
||||
/// The folder under the per-user temporary folder that holds a run. A fixed
|
||||
/// name, so an app that was opened again finds it.
|
||||
const RUN_DIR: &str = "codeman-install";
|
||||
const SCRIPT_FILE: &str = "install.sh";
|
||||
const OUTPUT_FILE: &str = "install.out";
|
||||
|
||||
/// The `install.sh preflight` schema this app understands.
|
||||
const PREFLIGHT_SCHEMA: u32 = 1;
|
||||
|
||||
const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
const PREFLIGHT_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// How often `follow` looks at the output file and the process.
|
||||
const POLL: Duration = Duration::from_millis(500);
|
||||
|
||||
/// How long the installer gets to end after Cancel before it is killed.
|
||||
const CANCEL_GRACE: Duration = Duration::from_secs(5);
|
||||
|
||||
/// One line of output, for the page's details view.
|
||||
const MAX_LOG_LINE: usize = 500;
|
||||
|
||||
/// When the login shell's PATH cannot be read.
|
||||
const FALLBACK_PATH: &str = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";
|
||||
|
||||
/// Lines of the installer's output protocol (`progress_phase` in install.sh).
|
||||
const PROTOCOL_PREFIX: &str = "@@codeman ";
|
||||
|
||||
/// Set by Cancel, so `follow` can tell a cancelled run from a failed one.
|
||||
static CANCELLED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// What `install.sh preflight` found. Fields the installer adds later are
|
||||
/// ignored here, and `schema` changes when one changes meaning.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Preflight {
|
||||
pub schema: u32,
|
||||
pub os: String,
|
||||
pub arch: String,
|
||||
/// Why `--no-admin` cannot finish on this Mac; empty when it can.
|
||||
pub blocker: String,
|
||||
pub needs: Needs,
|
||||
pub clis: Vec<CliEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Needs {
|
||||
pub node: bool,
|
||||
pub tmux: bool,
|
||||
pub git: bool,
|
||||
}
|
||||
|
||||
/// One coding agent of the installer's catalogue. The app has no list of its
|
||||
/// own, so a new agent appears here without a new app.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CliEntry {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub installed: bool,
|
||||
pub installable: bool,
|
||||
pub docs: String,
|
||||
}
|
||||
|
||||
/// What the person chose on the connect page.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Plan {
|
||||
pub port: u16,
|
||||
/// Reachable from other devices, with this login password. `None`: this
|
||||
/// Mac only.
|
||||
pub network_password: Option<String>,
|
||||
/// The catalogue id of the agent to install. `None`: none.
|
||||
pub ai_cli: Option<String>,
|
||||
}
|
||||
|
||||
/// `install.sh`'s flags for a graphical install. The password is not among
|
||||
/// them: anything here can be read in `ps`. Always `--service`, like the
|
||||
/// Terminal route: a LaunchAgent runs Codeman, so it keeps running when the app
|
||||
/// quits. Always `--ai-cli`: without it `CODEMAN_NONINTERACTIVE` would install
|
||||
/// the first agent of the catalogue by itself.
|
||||
pub fn install_args(plan: &Plan) -> Vec<String> {
|
||||
let mut args: Vec<String> = ["--no-admin", "--progress", "--service"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
args.push(
|
||||
if plan.network_password.is_some() {
|
||||
"--lan"
|
||||
} else {
|
||||
"--local"
|
||||
}
|
||||
.to_string(),
|
||||
);
|
||||
args.push("--port".to_string());
|
||||
args.push(plan.port.to_string());
|
||||
args.push("--ai-cli".to_string());
|
||||
args.push(plan.ai_cli.clone().unwrap_or_else(|| "none".to_string()));
|
||||
args
|
||||
}
|
||||
|
||||
/// What a graphical install adds to the environment. There is no terminal, so
|
||||
/// `CODEMAN_NONINTERACTIVE` is what lets `headless_guard` go ahead.
|
||||
pub fn install_env(plan: &Plan) -> Vec<(String, String)> {
|
||||
let mut env = vec![("CODEMAN_NONINTERACTIVE".to_string(), "1".to_string())];
|
||||
if let Some(password) = &plan.network_password {
|
||||
env.push(("CODEMAN_PASSWORD".to_string(), password.clone()));
|
||||
}
|
||||
if let Some(branch) = setup::INSTALL_BRANCH {
|
||||
env.push(("CODEMAN_BRANCH".to_string(), branch.to_string()));
|
||||
}
|
||||
env
|
||||
}
|
||||
|
||||
/// Read what `install.sh preflight` printed. An installer without that
|
||||
/// subcommand ends before this; one that speaks another schema stops here.
|
||||
pub fn parse_preflight(stdout: &str) -> Result<Preflight, String> {
|
||||
let found: Preflight = serde_json::from_str(stdout.trim())
|
||||
.map_err(|_| "The installer gave an answer this app cannot read.".to_string())?;
|
||||
if found.schema != PREFLIGHT_SCHEMA {
|
||||
return Err(format!(
|
||||
"This installer speaks a newer language than this app (schema {}). Update Codeman Desktop, or install in Terminal.",
|
||||
found.schema
|
||||
));
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// Whether a command line is an install this module started. `preflight` from
|
||||
/// the same file is not one: it has no `--progress`.
|
||||
pub fn is_install_run(command: &str) -> bool {
|
||||
let suffix = format!("/{RUN_DIR}/{SCRIPT_FILE}");
|
||||
let mut words = command.split_whitespace();
|
||||
words.any(|w| w.ends_with(&suffix)) && command.split_whitespace().any(|w| w == "--progress")
|
||||
}
|
||||
|
||||
fn run_dir() -> PathBuf {
|
||||
std::env::temp_dir().join(RUN_DIR)
|
||||
}
|
||||
|
||||
/// A fresh folder for a run, for this user only. The per-user temporary folder
|
||||
/// of macOS is private already; the mode covers a `TMPDIR` that is not.
|
||||
fn fresh_run_dir() -> Result<PathBuf, String> {
|
||||
let dir = run_dir();
|
||||
match fs::remove_dir_all(&dir) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(format!("Could not clear {}: {e}", dir.display())),
|
||||
}
|
||||
DirBuilder::new()
|
||||
.mode(0o700)
|
||||
.create(&dir)
|
||||
.map_err(|e| format!("Could not create {}: {e}", dir.display()))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// The login shell's environment, so the installer finds the Node.js, git and
|
||||
/// agents this person already has, wherever their profile puts them on PATH. A
|
||||
/// double-clicked app gets launchd's bare PATH instead (see local_server.rs).
|
||||
fn installer_env() -> Vec<(String, String)> {
|
||||
with_path(local_server::login_environment().unwrap_or_default())
|
||||
}
|
||||
|
||||
fn with_path(mut env: Vec<(String, String)>) -> Vec<(String, String)> {
|
||||
if !env.iter().any(|(k, _)| k == "PATH") {
|
||||
env.push(("PATH".to_string(), FALLBACK_PATH.to_string()));
|
||||
}
|
||||
env
|
||||
}
|
||||
|
||||
fn home() -> PathBuf {
|
||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
|
||||
}
|
||||
|
||||
fn download_installer(script: &Path) -> Result<(), String> {
|
||||
let mut cmd = Command::new("/usr/bin/curl");
|
||||
cmd.args(["-fsSL", "--connect-timeout", "15", "-o"])
|
||||
.arg(script)
|
||||
.arg(setup::INSTALLER_URL);
|
||||
let out = local_server::exec_bounded(
|
||||
cmd,
|
||||
Instant::now() + DOWNLOAD_TIMEOUT,
|
||||
"Downloading the installer took too long, so it was stopped.",
|
||||
)?;
|
||||
if out.code == Some(0) && script.is_file() {
|
||||
return Ok(());
|
||||
}
|
||||
let said = out.stderr.trim();
|
||||
Err(if said.is_empty() {
|
||||
"Could not download the installer. Check the internet connection, then try again.".to_string()
|
||||
} else {
|
||||
format!("Could not download the installer. Check the internet connection, then try again.\n{said}")
|
||||
})
|
||||
}
|
||||
|
||||
fn ask_installer(script: &Path, env: &[(String, String)]) -> Result<Preflight, String> {
|
||||
let mut cmd = Command::new("/bin/bash");
|
||||
cmd.arg(script)
|
||||
.args(["preflight", "--no-admin"])
|
||||
.envs(env.iter().map(|(k, v)| (k, v)))
|
||||
.current_dir(home());
|
||||
let out = local_server::exec_bounded(
|
||||
cmd,
|
||||
Instant::now() + PREFLIGHT_TIMEOUT,
|
||||
"The installer took too long to look at this Mac, so it was stopped.",
|
||||
)?;
|
||||
if out.code != Some(0) {
|
||||
return Err("This installer cannot run from the app yet. Install in Terminal instead.".to_string());
|
||||
}
|
||||
parse_preflight(&out.stdout)
|
||||
}
|
||||
|
||||
/// Get the installer and ask it what is on this Mac.
|
||||
pub fn prepare() -> Result<Preflight, String> {
|
||||
if let Some(why) = refuse_start() {
|
||||
return Err(why);
|
||||
}
|
||||
fetch_and_ask()
|
||||
}
|
||||
|
||||
fn fetch_and_ask() -> Result<Preflight, String> {
|
||||
let dir = fresh_run_dir()?;
|
||||
let script = dir.join(SCRIPT_FILE);
|
||||
download_installer(&script)?;
|
||||
ask_installer(&script, &installer_env())
|
||||
}
|
||||
|
||||
/// Why no install may start now: one runs already, or a Codeman server does and
|
||||
/// the installer's service step would replace it under its sessions.
|
||||
fn refuse_start() -> Option<String> {
|
||||
if setup::install_in_app().is_some() {
|
||||
return Some("Codeman is already being installed.".to_string());
|
||||
}
|
||||
setup::already_running()
|
||||
}
|
||||
|
||||
/// Run the installer `prepare` fetched. It outlives the app (see the module
|
||||
/// docs): when an app's job ends, launchd kills what is left in that job's
|
||||
/// process group, so the installer gets a group of its own. A thread reaps it
|
||||
/// if it ends first.
|
||||
pub fn start(plan: &Plan) -> Result<(), String> {
|
||||
if let Some(why) = refuse_start() {
|
||||
return Err(why);
|
||||
}
|
||||
spawn_run(plan)
|
||||
}
|
||||
|
||||
fn spawn_run(plan: &Plan) -> Result<(), String> {
|
||||
let dir = run_dir();
|
||||
let script = dir.join(SCRIPT_FILE);
|
||||
if !script.is_file() {
|
||||
return Err("The installer is gone from the temporary folder. Try again.".to_string());
|
||||
}
|
||||
let output = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(dir.join(OUTPUT_FILE))
|
||||
.map_err(|e| format!("Could not start the installer: {e}"))?;
|
||||
let stderr = output
|
||||
.try_clone()
|
||||
.map_err(|e| format!("Could not start the installer: {e}"))?;
|
||||
CANCELLED.store(false, Ordering::SeqCst);
|
||||
let mut child = Command::new("/bin/bash")
|
||||
.arg(&script)
|
||||
.args(install_args(plan))
|
||||
.envs(installer_env())
|
||||
.envs(install_env(plan))
|
||||
.current_dir(home())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(output)
|
||||
.stderr(stderr)
|
||||
.process_group(0)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Could not start the installer: {e}"))?;
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The process group of `pid`. `setup::install_in_app` may answer with one of
|
||||
/// the installer's subshells, which `ps` shows with the installer's own command
|
||||
/// line, so its pid is not always the group's.
|
||||
fn group_of(pid: u32) -> Option<u32> {
|
||||
let out = Command::new("/bin/ps")
|
||||
.args(["-o", "pgid=", "-p", &pid.to_string()])
|
||||
.stdin(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.output()
|
||||
.ok()?;
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.trim()
|
||||
.parse()
|
||||
.ok()
|
||||
.filter(|g| *g > 1)
|
||||
}
|
||||
|
||||
fn signal_group(group: u32, signal: &str) {
|
||||
let _ = Command::new("/bin/kill")
|
||||
.arg(format!("-{signal}"))
|
||||
.arg("--")
|
||||
.arg(format!("-{group}"))
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
}
|
||||
|
||||
/// Stop the running install and whatever it started. What it left behind is
|
||||
/// picked up by the next run: `install.sh` re-runs a partial install in full.
|
||||
pub fn cancel() {
|
||||
let Some(group) = setup::install_in_app().and_then(group_of) else {
|
||||
return;
|
||||
};
|
||||
CANCELLED.store(true, Ordering::SeqCst);
|
||||
signal_group(group, "TERM");
|
||||
let deadline = Instant::now() + CANCEL_GRACE;
|
||||
while Instant::now() < deadline {
|
||||
if setup::install_in_app().is_none() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
signal_group(group, "KILL");
|
||||
}
|
||||
|
||||
/// Drop terminal colour and cursor sequences.
|
||||
fn strip_ansi(line: &str) -> String {
|
||||
let mut out = String::with_capacity(line.len());
|
||||
let mut chars = line.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c != '\u{1b}' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
if chars.peek() == Some(&'[') {
|
||||
chars.next();
|
||||
for next in chars.by_ref() {
|
||||
if ('@'..='~').contains(&next) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One line of the installer's output.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Line {
|
||||
Phase(String),
|
||||
Error(String),
|
||||
Text(String),
|
||||
}
|
||||
|
||||
fn parse_line(raw: &str) -> Line {
|
||||
let line = strip_ansi(raw.trim_end_matches('\r'));
|
||||
if let Some(rest) = line.strip_prefix(PROTOCOL_PREFIX) {
|
||||
let (word, text) = rest.split_once(' ').unwrap_or((rest, ""));
|
||||
match word {
|
||||
"phase" if !text.is_empty() && text.chars().all(|c| c.is_ascii_lowercase()) => {
|
||||
return Line::Phase(text.to_string())
|
||||
}
|
||||
"error" if !text.is_empty() => return Line::Error(text.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Line::Text(line)
|
||||
}
|
||||
|
||||
/// What a run has said so far.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Progress {
|
||||
partial: Vec<u8>,
|
||||
phase: Option<String>,
|
||||
/// The first error: the cause. The ones after it are its consequences.
|
||||
error: Option<String>,
|
||||
done: bool,
|
||||
}
|
||||
|
||||
impl Progress {
|
||||
fn take_line(&mut self, raw: &str, log: &mut Vec<String>) {
|
||||
match parse_line(raw) {
|
||||
Line::Phase(phase) => {
|
||||
self.done = phase == "done";
|
||||
self.phase = Some(phase);
|
||||
}
|
||||
Line::Error(text) => {
|
||||
self.error.get_or_insert(text);
|
||||
}
|
||||
Line::Text(text) if text.trim().is_empty() => {}
|
||||
Line::Text(text) => log.push(text.chars().take(MAX_LOG_LINE).collect()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Take bytes of output; returns the complete lines to show.
|
||||
pub fn feed(&mut self, bytes: &[u8]) -> Vec<String> {
|
||||
self.partial.extend_from_slice(bytes);
|
||||
let mut log = Vec::new();
|
||||
while let Some(end) = self.partial.iter().position(|b| *b == b'\n') {
|
||||
let rest = self.partial.split_off(end + 1);
|
||||
let line = std::mem::replace(&mut self.partial, rest);
|
||||
self.take_line(String::from_utf8_lossy(&line[..end]).as_ref(), &mut log);
|
||||
}
|
||||
log
|
||||
}
|
||||
|
||||
/// The output ended: a last line without a newline is complete now.
|
||||
pub fn finish(&mut self) -> Vec<String> {
|
||||
let line = std::mem::take(&mut self.partial);
|
||||
let mut log = Vec::new();
|
||||
self.take_line(String::from_utf8_lossy(&line).as_ref(), &mut log);
|
||||
log
|
||||
}
|
||||
|
||||
pub fn update_message(&self, log: Vec<String>) -> Value {
|
||||
json!({ "type": "installUpdate", "phase": self.phase, "log": log })
|
||||
}
|
||||
|
||||
pub fn finished_message(&self, cancelled: bool, log: Vec<String>) -> Value {
|
||||
json!({
|
||||
"type": "installFinished",
|
||||
"ok": self.done,
|
||||
"cancelled": cancelled && !self.done,
|
||||
"error": self.error,
|
||||
"log": log,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn read_more(file: &mut Option<File>, path: &Path, offset: &mut u64) -> Vec<u8> {
|
||||
if file.is_none() {
|
||||
*file = File::open(path).ok();
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
if let Some(f) = file {
|
||||
if f.seek(SeekFrom::Start(*offset)).is_ok() && f.read_to_end(&mut bytes).is_ok() {
|
||||
*offset += bytes.len() as u64;
|
||||
}
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Report a run from its first line to its end. `stale` ends this reader
|
||||
/// (the page asked for another). Every look at the process comes BEFORE the read
|
||||
/// of the file, so what it wrote just before it ended is never missed. `done`
|
||||
/// ends the run by itself: a subshell of the installer that lingers looks just
|
||||
/// like the installer, and must not keep the page waiting.
|
||||
pub fn follow(stale: impl Fn() -> bool, send: impl Fn(Value)) {
|
||||
let path = run_dir().join(OUTPUT_FILE);
|
||||
let mut file = None;
|
||||
let mut offset = 0;
|
||||
let mut progress = Progress::default();
|
||||
let mut reported = None;
|
||||
loop {
|
||||
if stale() {
|
||||
return;
|
||||
}
|
||||
let running = setup::install_in_app().is_some();
|
||||
let log = progress.feed(&read_more(&mut file, &path, &mut offset));
|
||||
if !running || progress.done {
|
||||
let mut log = log;
|
||||
log.extend(progress.finish());
|
||||
let cancelled = CANCELLED.load(Ordering::SeqCst);
|
||||
if progress.done {
|
||||
let _ = fs::remove_dir_all(run_dir());
|
||||
}
|
||||
send(progress.finished_message(cancelled, log));
|
||||
return;
|
||||
}
|
||||
if !log.is_empty() || reported != progress.phase {
|
||||
reported = progress.phase.clone();
|
||||
send(progress.update_message(log));
|
||||
}
|
||||
std::thread::sleep(POLL);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn plan() -> Plan {
|
||||
Plan {
|
||||
port: 3000,
|
||||
network_password: None,
|
||||
ai_cli: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_this_mac_install_never_waits_for_a_person() {
|
||||
let args = install_args(&plan());
|
||||
for flag in ["--no-admin", "--progress", "--service", "--local"] {
|
||||
assert!(args.iter().any(|a| a == flag), "{flag}");
|
||||
}
|
||||
assert!(!args.iter().any(|a| a == "--lan"));
|
||||
let env = install_env(&plan());
|
||||
assert!(env.contains(&("CODEMAN_NONINTERACTIVE".to_string(), "1".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_always_names_the_agent_so_the_installer_never_picks_one() {
|
||||
let at = |args: &[String]| args[args.iter().position(|a| a == "--ai-cli").unwrap() + 1].clone();
|
||||
assert_eq!(at(&install_args(&plan())), "none");
|
||||
let mut p = plan();
|
||||
p.ai_cli = Some("codex".to_string());
|
||||
assert_eq!(at(&install_args(&p)), "codex");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_password_goes_in_the_environment_and_never_in_the_arguments() {
|
||||
let mut p = plan();
|
||||
p.network_password = Some("hunter2".to_string());
|
||||
let args = install_args(&p);
|
||||
assert!(args.iter().any(|a| a == "--lan"));
|
||||
assert!(!args.iter().any(|a| a == "--local"));
|
||||
assert!(!args.iter().any(|a| a.contains("hunter2")));
|
||||
assert!(!args.iter().any(|a| a.starts_with("--password")));
|
||||
assert!(install_env(&p).contains(&("CODEMAN_PASSWORD".to_string(), "hunter2".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_reads_the_preflight_answer() {
|
||||
let text = r#"{"schema":1,"os":"macos","arch":"arm64","noAdmin":true,"blocker":"",
|
||||
"needs":{"node":true,"tmux":true,"git":false},"later":"ignored",
|
||||
"clis":[{"id":"claude","label":"Claude Code","installed":false,"installable":true,"docs":"https://x"}]}"#;
|
||||
let found = parse_preflight(text).unwrap();
|
||||
assert_eq!(found.arch, "arm64");
|
||||
assert!(found.needs.node && !found.needs.git);
|
||||
assert_eq!(found.clis[0].label, "Claude Code");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_refuses_an_answer_it_cannot_trust() {
|
||||
assert!(parse_preflight("Unknown option").is_err());
|
||||
let newer = r#"{"schema":2,"os":"macos","arch":"arm64","blocker":"","needs":{"node":true,"tmux":true,"git":true},"clis":[]}"#;
|
||||
assert!(parse_preflight(newer).unwrap_err().contains("schema 2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_page_gets_the_preflight_in_its_own_spelling() {
|
||||
let found = Preflight {
|
||||
schema: 1,
|
||||
os: "macos".into(),
|
||||
arch: "arm64".into(),
|
||||
blocker: String::new(),
|
||||
needs: Needs {
|
||||
node: true,
|
||||
tmux: false,
|
||||
git: true,
|
||||
},
|
||||
clis: vec![],
|
||||
};
|
||||
let text = serde_json::to_value(&found).unwrap();
|
||||
assert_eq!(text["needs"]["node"], json!(true));
|
||||
assert_eq!(text["blocker"], json!(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_real_run_counts_as_an_install() {
|
||||
let script = "/var/folders/ab/T/codeman-install/install.sh";
|
||||
assert!(is_install_run(&format!(
|
||||
"/bin/bash {script} --no-admin --progress --service --local --port 3000"
|
||||
)));
|
||||
assert!(!is_install_run(&format!("/bin/bash {script} preflight --no-admin")));
|
||||
assert!(!is_install_run("/bin/bash /tmp/other/install.sh --progress"));
|
||||
assert!(!is_install_run("vim /tmp/codeman-install/notes.txt --progress"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_follows_the_installer_protocol() {
|
||||
assert_eq!(parse_line("@@codeman phase build"), Line::Phase("build".into()));
|
||||
assert_eq!(
|
||||
parse_line("@@codeman error Building Codeman failed (exit 1)"),
|
||||
Line::Error("Building Codeman failed (exit 1)".into())
|
||||
);
|
||||
assert_eq!(parse_line("plain text\r"), Line::Text("plain text".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_is_not_the_protocol_stays_output() {
|
||||
for line in [
|
||||
"@@codeman phase",
|
||||
"@@codeman phase Not-A-Phase",
|
||||
"@@codeman nonsense here",
|
||||
"@@codeman",
|
||||
] {
|
||||
assert_eq!(parse_line(line), Line::Text(line.into()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colours_do_not_reach_the_page() {
|
||||
assert_eq!(
|
||||
parse_line("\u{1b}[0;34m==>\u{1b}[0m \u{1b}[1mDone\u{1b}[0m"),
|
||||
Line::Text("==> Done".into())
|
||||
);
|
||||
assert_eq!(parse_line("\u{1b}[2K\u{1b}[1Gnpm"), Line::Text("npm".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_split_across_reads_is_read_whole() {
|
||||
let mut p = Progress::default();
|
||||
assert!(p.feed(b"@@codeman pha").is_empty());
|
||||
assert!(p.feed(b"se packages\nHello ").is_empty());
|
||||
assert_eq!(p.phase.as_deref(), Some("packages"));
|
||||
assert_eq!(p.feed(b"world\n"), vec!["Hello world".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_without_a_last_newline_is_kept() {
|
||||
let mut p = Progress::default();
|
||||
p.feed(b"@@codeman phase done");
|
||||
assert!(!p.done);
|
||||
p.finish();
|
||||
assert!(p.done);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_utf8_byte_costs_one_character_not_the_line() {
|
||||
let mut p = Progress::default();
|
||||
let log = p.feed(b"caf\xff bar\n");
|
||||
assert_eq!(log, vec!["caf\u{fffd} bar".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_very_long_line_is_cut() {
|
||||
let mut p = Progress::default();
|
||||
let line = format!("{}\n", "x".repeat(MAX_LOG_LINE * 2));
|
||||
assert_eq!(p.feed(line.as_bytes())[0].chars().count(), MAX_LOG_LINE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_error_is_the_one_reported() {
|
||||
let mut p = Progress::default();
|
||||
p.feed(b"@@codeman error Building Codeman failed\n@@codeman error Installation failed. Partial\n");
|
||||
let done = p.finished_message(false, vec![]);
|
||||
assert_eq!(done["error"], json!("Building Codeman failed"));
|
||||
assert_eq!(done["ok"], json!(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_done_is_success() {
|
||||
let mut p = Progress::default();
|
||||
p.feed(b"@@codeman phase service\n");
|
||||
assert_eq!(p.finished_message(false, vec![])["ok"], json!(false));
|
||||
p.feed(b"@@codeman phase done\n");
|
||||
assert_eq!(p.finished_message(false, vec![])["ok"], json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_that_finished_before_cancel_landed_is_not_cancelled() {
|
||||
let mut p = Progress::default();
|
||||
p.feed(b"@@codeman phase done\n");
|
||||
assert_eq!(p.finished_message(true, vec![])["cancelled"], json!(false));
|
||||
let mut q = Progress::default();
|
||||
q.feed(b"@@codeman phase build\n");
|
||||
assert_eq!(q.finished_message(true, vec![])["cancelled"], json!(true));
|
||||
}
|
||||
|
||||
/// The process side, against a stand-in installer: `cargo test` on a Mac
|
||||
/// runs it. One test, because a run is one folder and one process.
|
||||
#[test]
|
||||
fn a_run_outlives_its_reader_and_ends_as_reported() {
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
let plan = Plan {
|
||||
port: 3999,
|
||||
network_password: Some("not-in-ps".to_string()),
|
||||
ai_cli: None,
|
||||
};
|
||||
let stand_in = |body: &str| {
|
||||
let dir = fresh_run_dir().unwrap();
|
||||
fs::write(dir.join(SCRIPT_FILE), format!("#!/bin/bash\n{body}\n")).unwrap();
|
||||
spawn_run(&plan).unwrap();
|
||||
dir
|
||||
};
|
||||
let collect = |stop_after: Option<usize>| {
|
||||
let got = Arc::new(Mutex::new(Vec::<Value>::new()));
|
||||
let (seen, sink) = (got.clone(), got.clone());
|
||||
let calls = AtomicUsize::new(0);
|
||||
follow(
|
||||
move || {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
stop_after.is_some_and(|n| seen.lock().unwrap().len() >= n)
|
||||
},
|
||||
move |msg| sink.lock().unwrap().push(msg),
|
||||
);
|
||||
let out = got.lock().unwrap().clone();
|
||||
out
|
||||
};
|
||||
|
||||
// A reader that goes away (the page reloaded, the app quit) leaves the
|
||||
// install running, and the next reader gets it from the first line.
|
||||
let dir = stand_in(
|
||||
r#"echo "==> first"
|
||||
echo "@@codeman phase source" >&2
|
||||
while [ ! -f "$(dirname "$0")/go" ]; do sleep 0.1; done
|
||||
echo "==> second"
|
||||
echo "@@codeman phase done" >&2"#,
|
||||
);
|
||||
let first = collect(Some(1));
|
||||
assert_eq!(first[0]["phase"], json!("source"));
|
||||
assert_eq!(first[0]["log"], json!(["==> first"]));
|
||||
assert!(setup::install_in_app().is_some(), "the install stopped with its reader");
|
||||
|
||||
fs::write(dir.join("go"), "").unwrap();
|
||||
let replay = collect(None);
|
||||
let last = replay.last().unwrap();
|
||||
assert_eq!(last["type"], json!("installFinished"));
|
||||
assert_eq!(last["ok"], json!(true));
|
||||
let lines: Vec<&Value> = replay.iter().flat_map(|m| m["log"].as_array().unwrap()).collect();
|
||||
assert_eq!(lines, vec!["==> first", "==> second"]);
|
||||
assert!(!dir.exists(), "a finished run leaves nothing behind");
|
||||
|
||||
// Cancel reaches what the installer started, not only the installer.
|
||||
let marker = "31337";
|
||||
stand_in(&format!(
|
||||
r#"echo "@@codeman phase build" >&2
|
||||
sleep {marker} &
|
||||
( while true; do sleep {marker}; done ) &
|
||||
wait"#
|
||||
));
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
let sleeper = |procs: Vec<(u32, String)>| procs.iter().any(|(_, c)| c == &format!("sleep {marker}"));
|
||||
assert!(sleeper(setup::list_processes()), "the stand-in did not start");
|
||||
cancel();
|
||||
assert!(setup::install_in_app().is_none());
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
assert!(!sleeper(setup::list_processes()), "a child outlived Cancel");
|
||||
let done = collect(None);
|
||||
assert_eq!(done.last().unwrap()["cancelled"], json!(true));
|
||||
assert_eq!(done.last().unwrap()["ok"], json!(false));
|
||||
|
||||
// A subshell that outlives `done` does not keep the page waiting.
|
||||
let dir = stand_in(
|
||||
r#"( while true; do sleep 1; done ) &
|
||||
echo "@@codeman phase done" >&2
|
||||
wait"#,
|
||||
);
|
||||
assert_eq!(collect(None).last().unwrap()["ok"], json!(true));
|
||||
assert!(!dir.exists());
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
cancel();
|
||||
|
||||
// A failure reports its first error, and keeps the output for a look.
|
||||
let dir = stand_in(
|
||||
r#"echo "@@codeman phase packages" >&2
|
||||
echo "@@codeman error npm said no" >&2
|
||||
echo "@@codeman error Installation failed." >&2
|
||||
exit 1"#,
|
||||
);
|
||||
let done = collect(None);
|
||||
let last = done.last().unwrap();
|
||||
assert_eq!(last["ok"], json!(false));
|
||||
assert_eq!(last["cancelled"], json!(false));
|
||||
assert_eq!(last["error"], json!("npm said no"));
|
||||
assert!(dir.join(OUTPUT_FILE).is_file());
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A whole real install, driven down the same path as the page: build with
|
||||
/// `CODEMAN_DESKTOP_INSTALLER_URL` and run under a throwaway HOME with
|
||||
/// `CODEMAN_E2E_AI_CLI` set. Skips `refuse_start`, which a machine with a
|
||||
/// live Codeman would answer, and that machine is where this runs. With
|
||||
/// `CODEMAN_E2E_CANCEL_AT=<phase>` it cancels there instead of finishing.
|
||||
#[test]
|
||||
#[ignore = "installs Codeman: see the comment"]
|
||||
fn a_real_install_under_a_throwaway_home() {
|
||||
let found = fetch_and_ask().unwrap();
|
||||
println!("preflight: {}", serde_json::to_string(&found).unwrap());
|
||||
let ai_cli = std::env::var("CODEMAN_E2E_AI_CLI").ok();
|
||||
let port = std::env::var("CODEMAN_E2E_PORT").unwrap().parse().unwrap();
|
||||
spawn_run(&Plan {
|
||||
port,
|
||||
network_password: None,
|
||||
ai_cli,
|
||||
})
|
||||
.unwrap();
|
||||
let ok = std::sync::Arc::new(AtomicBool::new(false));
|
||||
let result = ok.clone();
|
||||
let started = Instant::now();
|
||||
let cancel_at = std::env::var("CODEMAN_E2E_CANCEL_AT").ok();
|
||||
let cancelling = cancel_at.is_some();
|
||||
let asked = AtomicBool::new(false);
|
||||
follow(
|
||||
|| false,
|
||||
move |msg| {
|
||||
let at = started.elapsed().as_secs();
|
||||
if cancel_at.as_deref().is_some_and(|p| msg["phase"] == json!(p)) && !asked.swap(true, Ordering::SeqCst)
|
||||
{
|
||||
println!("[{at}s] cancelling");
|
||||
std::thread::spawn(cancel);
|
||||
}
|
||||
if msg["type"] == "installFinished" {
|
||||
result.store(msg["ok"] == json!(true), Ordering::SeqCst);
|
||||
println!("[{at}s] {}", serde_json::to_string(&msg).unwrap());
|
||||
} else if !msg["log"].as_array().unwrap().is_empty() {
|
||||
println!("[{at}s] {} {}", msg["phase"], msg["log"]);
|
||||
}
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
ok.load(Ordering::SeqCst),
|
||||
!cancelling,
|
||||
"the install did not end as asked"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_path_is_filled_in() {
|
||||
let env = with_path(vec![("HOME".to_string(), "/Users/a".to_string())]);
|
||||
assert!(env.iter().any(|(k, v)| k == "PATH" && v == FALLBACK_PATH));
|
||||
let own = with_path(vec![("PATH".to_string(), "/mine".to_string())]);
|
||||
assert_eq!(own, vec![("PATH".to_string(), "/mine".to_string())]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,806 @@
|
||||
//! "Start Codeman on this Mac": `codeman web -d` through the user's login shell.
|
||||
//!
|
||||
//! A double-clicked app gets launchd's bare PATH (`/usr/bin:/bin:...`), which
|
||||
//! finds neither `codeman` nor `node`, and the server inherits whatever
|
||||
//! environment it is started with: every session it spawns looks for
|
||||
//! claude/codex/tmux on that PATH and reads provider keys from it. So the
|
||||
//! command runs with the login shell's environment, picking up Homebrew and the
|
||||
//! user's profile, and falls back to the interactive profile (`-i`) for setups
|
||||
//! that only extend PATH there (nvm in `.zshrc`). The URL's port and base path
|
||||
//! are passed as separate arguments (`"$@"`), never spliced into the script text.
|
||||
//!
|
||||
//! How depends on the shell, chosen by its basename as Codeman's own
|
||||
//! `src/utils/shell-resolver.ts` does:
|
||||
//! - A POSIX shell (zsh, bash, sh, ...) runs `SCRIPT` itself:
|
||||
//! `$SHELL -l [-i] -c SCRIPT codeman-desktop <args>`.
|
||||
//! - Any other shell (fish, nu, xonsh, elvish, pwsh, tcsh) cannot parse
|
||||
//! `SCRIPT`: fish has no `then`/`fi` and refuses `$@`. It is only asked to
|
||||
//! print its environment between two markers, and `SCRIPT` then runs under
|
||||
//! `/bin/sh` with exactly that environment.
|
||||
//!
|
||||
//! `codeman web -d` refuses to start a second server on the same data dir and
|
||||
//! returns once the new one answers `/api/status`, so this is safe to call
|
||||
//! only after the probe found nothing listening.
|
||||
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
use url::Url;
|
||||
|
||||
use crate::policy;
|
||||
|
||||
/// For the whole start, every shell run and retry included.
|
||||
const TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// How long the pipes may stay open after the shell exited. Something the
|
||||
/// profile started in the background can inherit them and keep them open for
|
||||
/// as long as it runs; whatever arrived by then is used.
|
||||
const PIPE_GRACE: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Found on PATH first, then the `install.sh` clone at `~/.codeman/app`.
|
||||
const SCRIPT: &str = r#"if command -v codeman >/dev/null 2>&1; then exec codeman "$@"; fi
|
||||
if [ -f "$HOME/.codeman/app/dist/index.js" ] && command -v node >/dev/null 2>&1; then
|
||||
exec node "$HOME/.codeman/app/dist/index.js" "$@"
|
||||
fi
|
||||
echo "Could not find the codeman command or ~/.codeman/app. Install Codeman first: https://github.com/Ark0N/Codeman#install" >&2
|
||||
exit 127"#;
|
||||
|
||||
/// Bracket the environment a non-POSIX login shell prints, so whatever its
|
||||
/// profile writes to stdout before or after cannot be read as a variable.
|
||||
const ENV_BEGIN: &str = "__CODEMAN_DESKTOP_ENV_BEGIN__";
|
||||
const ENV_END: &str = "__CODEMAN_DESKTOP_ENV_END__";
|
||||
|
||||
/// What a non-POSIX login shell runs to report its environment. The shell
|
||||
/// itself only parses a command path and one single-quoted word with no `;`,
|
||||
/// `$`, backslash or `!` in it, so fish, nu, xonsh, elvish, pwsh and tcsh have
|
||||
/// no syntax of their own to disagree on. The markers and `env` run under
|
||||
/// `/bin/sh`, which inherits everything the profile exported.
|
||||
fn capture_command() -> String {
|
||||
format!("/bin/sh -c 'echo {ENV_BEGIN}; /usr/bin/env; echo {ENV_END}'")
|
||||
}
|
||||
|
||||
fn login_shell() -> PathBuf {
|
||||
std::env::var_os("SHELL")
|
||||
.map(PathBuf::from)
|
||||
.filter(|p| p.is_absolute() && p.exists())
|
||||
.unwrap_or_else(|| PathBuf::from("/bin/zsh"))
|
||||
}
|
||||
|
||||
/// How the login shell takes part in a start.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Flavor {
|
||||
/// Runs `SCRIPT` itself.
|
||||
Posix,
|
||||
/// Only reports its environment. One entry per attempt, in order: a later
|
||||
/// one runs when the earlier one printed no environment or its environment
|
||||
/// had no `codeman` (exit 127).
|
||||
Capture(&'static [&'static [&'static str]]),
|
||||
}
|
||||
|
||||
fn flavor(shell: &Path) -> Flavor {
|
||||
match shell.file_name().and_then(|n| n.to_str()).unwrap_or("") {
|
||||
"sh" | "bash" | "zsh" | "dash" | "ksh" | "ash" | "ksh93" | "mksh" | "pdksh" => Flavor::Posix,
|
||||
// tcsh honors -l only when it is the ONLY flag, so a login shell that
|
||||
// also runs a command is impossible; -i at least reads all of .tcshrc
|
||||
// (csh: .cshrc), including the parts guarded by `$?prompt`.
|
||||
"csh" | "tcsh" => Flavor::Capture(&[&["-ic"]]),
|
||||
_ => Flavor::Capture(&[&["-l", "-c"], &["-l", "-i", "-c"]]),
|
||||
}
|
||||
}
|
||||
|
||||
/// What one process printed, stdout and stderr kept apart.
|
||||
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.
|
||||
struct Run {
|
||||
code: Option<i32>,
|
||||
output: String,
|
||||
}
|
||||
|
||||
impl From<Output> for Run {
|
||||
fn from(out: Output) -> Self {
|
||||
let mut output = out.stdout;
|
||||
if !out.stderr.trim().is_empty() {
|
||||
output.push_str(&out.stderr);
|
||||
}
|
||||
Run {
|
||||
code: out.code,
|
||||
output: last_lines(&output, 6),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy `pipe` into the channel as it arrives, tagged with `stream`, then send
|
||||
/// `None` once it closes.
|
||||
fn pump<R: Read + Send + 'static>(pipe: Option<R>, stream: usize, tx: mpsc::Sender<(usize, Option<Vec<u8>>)>) {
|
||||
std::thread::spawn(move || {
|
||||
if let Some(mut pipe) = pipe {
|
||||
let mut buf = [0u8; 8192];
|
||||
loop {
|
||||
match pipe.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
if tx.send((stream, Some(buf[..n].to_vec()))).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = tx.send((stream, None));
|
||||
});
|
||||
}
|
||||
|
||||
/// `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()
|
||||
.map_err(|e| format!("Could not run {}: {e}", Path::new(cmd.get_program()).display()))?;
|
||||
|
||||
// Read both pipes on threads so a chatty profile cannot fill one and stall.
|
||||
let (tx, rx) = mpsc::channel();
|
||||
pump(child.stdout.take(), 0, tx.clone());
|
||||
pump(child.stderr.take(), 1, tx);
|
||||
|
||||
let status = loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => break status,
|
||||
Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(100)),
|
||||
Ok(None) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(timed_out.to_string());
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
}
|
||||
};
|
||||
|
||||
let mut bufs = [Vec::new(), Vec::new()];
|
||||
let settle = (Instant::now() + PIPE_GRACE).min(deadline);
|
||||
let mut open = 2;
|
||||
while open > 0 {
|
||||
match rx.recv_timeout(settle.saturating_duration_since(Instant::now())) {
|
||||
Ok((stream, Some(bytes))) => bufs[stream].extend_from_slice(&bytes),
|
||||
Ok((_, None)) => open -= 1,
|
||||
Err(_) => {
|
||||
// Still held open past the grace: keep what already arrived.
|
||||
for (stream, bytes) in rx.try_iter() {
|
||||
if let Some(bytes) = bytes {
|
||||
bufs[stream].extend_from_slice(&bytes);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Lossy rather than strict: one stray non-UTF-8 byte in a profile's output
|
||||
// must not cost the whole environment.
|
||||
let [out, err] = bufs;
|
||||
Ok(Output {
|
||||
code: status.code(),
|
||||
stdout: String::from_utf8_lossy(&out).into_owned(),
|
||||
stderr: String::from_utf8_lossy(&err).into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The tail of what the command printed, for the connect page.
|
||||
fn last_lines(text: &str, n: usize) -> String {
|
||||
let lines: Vec<&str> = text
|
||||
.lines()
|
||||
.map(str::trim_end)
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.collect();
|
||||
lines[lines.len().saturating_sub(n)..].join("\n")
|
||||
}
|
||||
|
||||
/// The variables `env` printed between the markers, or None when the output
|
||||
/// does not hold both. A line that does not start with a portable variable
|
||||
/// name followed by `=` continues the previous value (a value with a newline
|
||||
/// in it); one that does starts the next variable.
|
||||
fn parse_env(output: &str) -> Option<Vec<(String, String)>> {
|
||||
let mut lines = output.split('\n');
|
||||
// ends_with: a profile line printed without its newline sits in front.
|
||||
lines.by_ref().find(|l| l.trim_end().ends_with(ENV_BEGIN))?;
|
||||
let mut vars: Vec<(String, String)> = Vec::new();
|
||||
for line in lines {
|
||||
if line.trim() == ENV_END {
|
||||
return Some(vars);
|
||||
}
|
||||
match env_entry(line) {
|
||||
Some((name, value)) => vars.push((name.to_string(), value.to_string())),
|
||||
None => {
|
||||
if let Some((_, value)) = vars.last_mut() {
|
||||
value.push('\n');
|
||||
value.push_str(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn env_entry(line: &str) -> Option<(&str, &str)> {
|
||||
let (name, value) = line.split_once('=')?;
|
||||
let mut chars = name.chars();
|
||||
let first = chars.next()?;
|
||||
let portable =
|
||||
(first.is_ascii_alphabetic() || first == '_') && chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
|
||||
portable.then_some((name, value))
|
||||
}
|
||||
|
||||
/// Ask `shell` for its environment with `flags`. The outer error ends the
|
||||
/// start (the shell could not be run, or time is up); the inner one means this
|
||||
/// form printed no environment and the next may be tried.
|
||||
fn capture_env(
|
||||
shell: &Path,
|
||||
flags: &[&str],
|
||||
deadline: Instant,
|
||||
) -> Result<Result<Vec<(String, String)>, String>, String> {
|
||||
let mut cmd = Command::new(shell);
|
||||
cmd.args(flags).arg(capture_command());
|
||||
let out = exec(cmd, deadline)?;
|
||||
Ok(parse_env(&out.stdout).ok_or_else(|| {
|
||||
// stderr only: stdout can hold part of the environment, API keys included.
|
||||
let said = last_lines(&out.stderr, 6);
|
||||
let mut message = format!("Could not read the environment of your login shell {}", shell.display());
|
||||
if said.is_empty() {
|
||||
let code = out
|
||||
.code
|
||||
.map_or_else(|| "a signal".to_string(), |c| format!("exit code {c}"));
|
||||
message.push_str(&format!(" (it ended with {code})."));
|
||||
} else {
|
||||
message.push_str(":\n");
|
||||
message.push_str(&said);
|
||||
}
|
||||
message
|
||||
}))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn run_with_env(script: &str, env: &[(String, String)], args: &[String], deadline: Instant) -> Result<Run, String> {
|
||||
let mut cmd = Command::new("/bin/sh");
|
||||
cmd.arg("-c").arg(script).arg("codeman-desktop").args(args);
|
||||
cmd.env_clear().envs(env.iter().map(|(k, v)| (k, v)));
|
||||
exec(cmd, deadline).map(Run::from)
|
||||
}
|
||||
|
||||
/// `script` run by the POSIX login shell itself.
|
||||
fn run_in_shell(
|
||||
script: &str,
|
||||
shell: &Path,
|
||||
interactive: bool,
|
||||
args: &[String],
|
||||
deadline: Instant,
|
||||
) -> Result<Run, String> {
|
||||
let mut cmd = Command::new(shell);
|
||||
cmd.arg("-l");
|
||||
if interactive {
|
||||
cmd.arg("-i");
|
||||
}
|
||||
cmd.arg("-c").arg(script).arg("codeman-desktop").args(args);
|
||||
exec(cmd, deadline).map(Run::from)
|
||||
}
|
||||
|
||||
/// Run `script` the way the login shell allows (see the module docs). Exit
|
||||
/// 127 means "no codeman here" and moves on to the next way of asking; the
|
||||
/// interactive profile is asked only when `interactive` allows it.
|
||||
fn launch(script: &str, shell: &Path, args: &[String], deadline: Instant, interactive: bool) -> Result<Run, String> {
|
||||
let attempts = match flavor(shell) {
|
||||
Flavor::Posix => {
|
||||
let result = run_in_shell(script, shell, false, args, deadline)?;
|
||||
if result.code != Some(127) || !interactive {
|
||||
return Ok(result);
|
||||
}
|
||||
// Not on the login PATH; try the interactive profile too.
|
||||
return run_in_shell(script, shell, true, args, deadline);
|
||||
}
|
||||
Flavor::Capture(attempts) => attempts,
|
||||
};
|
||||
let mut last_run = None;
|
||||
let mut capture_error = None;
|
||||
let interactive_flag = |flags: &&&[&str]| {
|
||||
flags
|
||||
.iter()
|
||||
.any(|f| f.starts_with('-') && !f.starts_with("--") && f.contains('i'))
|
||||
};
|
||||
for flags in attempts.iter().filter(|f| interactive || !interactive_flag(f)) {
|
||||
match capture_env(shell, flags, deadline)? {
|
||||
Ok(env) => {
|
||||
let result = run_with_env(script, &env, args, deadline)?;
|
||||
if result.code != Some(127) {
|
||||
return Ok(result);
|
||||
}
|
||||
last_run = Some(result);
|
||||
}
|
||||
Err(e) => capture_error = Some(e),
|
||||
}
|
||||
}
|
||||
if let Some(result) = last_run {
|
||||
return Ok(result);
|
||||
}
|
||||
// No form printed an environment: a shell that knows neither -l nor -i
|
||||
// may still run a bare -c.
|
||||
match capture_env(shell, &["-c"], deadline)? {
|
||||
Ok(env) => run_with_env(script, &env, args, deadline),
|
||||
Err(e) => Err(capture_error.unwrap_or(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Exit 0 when SCRIPT would find Codeman (the `codeman` command or the
|
||||
/// `install.sh` clone), 127 when it would not. Same lookup, nothing started.
|
||||
const FIND_SCRIPT: &str = r#"if command -v codeman >/dev/null 2>&1; then exit 0; fi
|
||||
if [ -f "$HOME/.codeman/app/dist/index.js" ] && command -v node >/dev/null 2>&1; then exit 0; fi
|
||||
exit 127"#;
|
||||
|
||||
/// How long the This Mac tab waits for the login shell to answer whether
|
||||
/// Codeman is installed.
|
||||
const FIND_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// `installed` with the shell given, so tests need not touch `$SHELL`.
|
||||
fn installed_with(shell: &Path, interactive: bool) -> bool {
|
||||
matches!(
|
||||
launch(FIND_SCRIPT, shell, &[], Instant::now() + FIND_TIMEOUT, interactive),
|
||||
Ok(Run { code: Some(0), .. })
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether "Start Codeman on this Mac" would find Codeman: the `codeman`
|
||||
/// command on the login shell's PATH, or `~/.codeman/app` with `node`. Runs
|
||||
/// the login shell, so never on the event loop. `interactive` also asks the
|
||||
/// interactive profile (nvm in `.zshrc`, say), whose side effects only a
|
||||
/// user's own click should set off.
|
||||
pub fn installed(interactive: bool) -> bool {
|
||||
installed_with(&login_shell(), interactive)
|
||||
}
|
||||
|
||||
/// `start` with the shell given, so tests need not touch `$SHELL`.
|
||||
fn start_with(shell: &Path, args: &[String]) -> Result<String, String> {
|
||||
let result = launch(SCRIPT, shell, args, Instant::now() + TIMEOUT, true)?;
|
||||
match result.code {
|
||||
Some(0) => Ok(result.output),
|
||||
_ if result.output.is_empty() => Err("codeman web -d failed without saying why.".into()),
|
||||
_ => Err(result.output),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a detached server for `url`. Ok carries what `codeman` printed.
|
||||
pub fn start(url: &Url) -> Result<String, String> {
|
||||
if !policy::can_start_locally(url) {
|
||||
return Err("Codeman can only be started here for http://127.0.0.1 or http://localhost on this Mac.".into());
|
||||
}
|
||||
start_with(&login_shell(), &policy::start_args(url))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// One spawning test at a time. A test writes a fake executable and then
|
||||
/// runs it; a process forked by a parallel test in between can still hold
|
||||
/// the write descriptor, and the exec then fails with ETXTBSY.
|
||||
fn serial() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
LOCK.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tail_of_output() {
|
||||
assert_eq!(last_lines("a\n\nb\nc\n", 2), "b\nc");
|
||||
assert_eq!(last_lines("", 3), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_non_local_or_https() {
|
||||
assert!(start(&Url::parse("https://127.0.0.1:3000/").unwrap()).is_err());
|
||||
assert!(start(&Url::parse("http://192.168.1.4:3000/").unwrap()).is_err());
|
||||
}
|
||||
|
||||
/// The script passes its arguments through untouched and reports a missing
|
||||
/// `codeman` with exit 127 (run with an empty PATH and HOME so nothing is found).
|
||||
#[test]
|
||||
fn script_reports_missing_codeman() {
|
||||
let _serial = serial();
|
||||
let out = Command::new("/bin/sh")
|
||||
.arg("-c")
|
||||
.arg(SCRIPT)
|
||||
.arg("codeman-desktop")
|
||||
.args(["web", "-d", "--port", "3000"])
|
||||
.env("PATH", "/nonexistent")
|
||||
.env("HOME", "/nonexistent")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(out.status.code(), Some(127));
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("Could not find the codeman command"));
|
||||
}
|
||||
|
||||
/// Arguments are data: a hostile-looking value is passed as one argv entry
|
||||
/// to a fake `codeman`, never run by the shell.
|
||||
#[test]
|
||||
fn arguments_are_not_shell_text() {
|
||||
let _serial = serial();
|
||||
let dir = std::env::temp_dir().join(format!("codeman-desktop-fake-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let fake = dir.join("codeman");
|
||||
std::fs::write(&fake, "#!/bin/sh\nfor a in \"$@\"; do printf '[%s]' \"$a\"; done\n").unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
let out = Command::new("/bin/sh")
|
||||
.arg("-c")
|
||||
.arg(SCRIPT)
|
||||
.arg("codeman-desktop")
|
||||
.args(["web", "--base-url", "/x; touch /tmp/pwned"])
|
||||
.env("PATH", format!("{}:/usr/bin:/bin", dir.display()))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
"[web][--base-url][/x; touch /tmp/pwned]"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn pairs(vars: &[(&str, &str)]) -> Vec<(String, String)> {
|
||||
vars.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_capture_parser() {
|
||||
let out = format!(
|
||||
"Welcome to fish, the friendly interactive shell\nno newline here{ENV_BEGIN}\n\
|
||||
PATH=/opt/homebrew/bin:/usr/bin:/bin\n\
|
||||
MULTI=line one\nline two\n three = 3\n\
|
||||
EMPTY=\n\
|
||||
TRAIL=a \n\n\
|
||||
_=/usr/bin/env\n\
|
||||
{ENV_END} \nbye from the profile\nNOT_CAPTURED=1\n"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_env(&out),
|
||||
Some(pairs(&[
|
||||
("PATH", "/opt/homebrew/bin:/usr/bin:/bin"),
|
||||
("MULTI", "line one\nline two\n three = 3"),
|
||||
("EMPTY", ""),
|
||||
("TRAIL", "a \n"),
|
||||
("_", "/usr/bin/env"),
|
||||
]))
|
||||
);
|
||||
// A value may itself contain `=`; only the first one splits.
|
||||
assert_eq!(
|
||||
parse_env(&format!("{ENV_BEGIN}\nOPTS=a=b\n{ENV_END}\n")),
|
||||
Some(pairs(&[("OPTS", "a=b")]))
|
||||
);
|
||||
assert_eq!(parse_env(&format!("{ENV_BEGIN}\n{ENV_END}\n")), Some(vec![]));
|
||||
assert_eq!(parse_env(&format!("{ENV_BEGIN}\nPATH=/usr/bin\n")), None);
|
||||
assert_eq!(parse_env(&format!("PATH=/usr/bin\n{ENV_END}\n")), None);
|
||||
assert_eq!(parse_env("fish: Unknown command: then\n"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_flavor_by_basename() {
|
||||
for posix in [
|
||||
"/bin/zsh",
|
||||
"/bin/bash",
|
||||
"/bin/sh",
|
||||
"/bin/dash",
|
||||
"/bin/ksh",
|
||||
"/opt/homebrew/bin/bash",
|
||||
] {
|
||||
assert_eq!(flavor(Path::new(posix)), Flavor::Posix, "{posix}");
|
||||
}
|
||||
assert_eq!(flavor(Path::new("/bin/tcsh")), Flavor::Capture(&[&["-ic"]]));
|
||||
assert_eq!(flavor(Path::new("/bin/csh")), Flavor::Capture(&[&["-ic"]]));
|
||||
for other in [
|
||||
"/opt/homebrew/bin/fish",
|
||||
"/usr/local/bin/nu",
|
||||
"/opt/homebrew/bin/xonsh",
|
||||
"/usr/local/bin/pwsh",
|
||||
] {
|
||||
assert_eq!(
|
||||
flavor(Path::new(other)),
|
||||
Flavor::Capture(&[&["-l", "-c"], &["-l", "-i", "-c"]]),
|
||||
"{other}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The login shell's part avoids everything only POSIX shells read.
|
||||
#[test]
|
||||
fn capture_command_is_shell_neutral() {
|
||||
let _serial = serial();
|
||||
let cmd = capture_command();
|
||||
for bad in ["then", "fi", "$", "\\", "!", "\"", "&&"] {
|
||||
assert!(!cmd.contains(bad), "{bad} in {cmd}");
|
||||
}
|
||||
assert_eq!(cmd.matches('\'').count(), 2);
|
||||
assert!(cmd.starts_with("/bin/sh -c '"));
|
||||
// /bin/sh, as the capture runs it, prints a parsable environment.
|
||||
let out = Command::new("/bin/sh")
|
||||
.arg("-c")
|
||||
.arg(&cmd)
|
||||
.env("CODEMAN_DESKTOP_PARSER_PROBE", "two\nlines")
|
||||
.output()
|
||||
.unwrap();
|
||||
let env = parse_env(&String::from_utf8_lossy(&out.stdout)).expect("markers");
|
||||
assert!(env
|
||||
.iter()
|
||||
.any(|(k, v)| k == "CODEMAN_DESKTOP_PARSER_PROBE" && v == "two\nlines"));
|
||||
assert!(env.iter().any(|(k, _)| k == "PATH"));
|
||||
}
|
||||
|
||||
/// A scratch directory with a fake `codeman` in `bin/` that prints its
|
||||
/// arguments, the profile's marker variable and the names of everything in
|
||||
/// its environment.
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new(name: &str) -> Self {
|
||||
let dir = std::env::temp_dir().join(format!("codeman-desktop-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("bin")).unwrap();
|
||||
std::fs::create_dir_all(dir.join("home")).unwrap();
|
||||
let scratch = Scratch(dir);
|
||||
scratch.exe(
|
||||
"bin/codeman",
|
||||
"#!/bin/sh\nprintf '[%s]' \"$@\"; echo\necho \"profile: $CODEMAN_DESKTOP_TEST\"\n\
|
||||
printf 'names:'; env | cut -d= -f1 | tr '\\n' ' '; echo\n",
|
||||
);
|
||||
scratch
|
||||
}
|
||||
|
||||
/// Write an executable at `rel`, with `DIR` in `body` replaced by the scratch path.
|
||||
fn exe(&self, rel: &str, body: &str) -> PathBuf {
|
||||
let path = self.0.join(rel);
|
||||
std::fs::write(&path, body.replace("DIR", &self.0.display().to_string())).unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
fn argv_log(&self) -> String {
|
||||
std::fs::read_to_string(self.0.join("argv.log")).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
const HOSTILE_ARGS: [&str; 3] = ["web", "--base-url", "/x; touch /tmp/pwned"];
|
||||
|
||||
fn hostile_args() -> Vec<String> {
|
||||
HOSTILE_ARGS.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
/// A stand-in for fish: it refuses POSIX `then` and `$@` the way fish
|
||||
/// does, logs its flags, prints profile noise around whatever it runs, and
|
||||
/// its "profile" replaces the environment. On its interactive form only
|
||||
/// when `INTERACTIVE_ONLY` is yes, PATH reaches the fake `codeman`.
|
||||
const FAKE_FISH: &str = r#"#!/bin/sh
|
||||
args=""
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
*then*|*'$@'*) echo "fish: Unknown command: then" >&2; exit 127;;
|
||||
-*) args="$args$a ";;
|
||||
esac
|
||||
cmd="$a"
|
||||
done
|
||||
echo "$args" >> "DIR/argv.log"
|
||||
bin="DIR/bin"
|
||||
case "INTERACTIVE_ONLY:$args" in yes:*-i*|no:*) ;; *) bin=/nonexistent;; esac
|
||||
echo "Welcome to fish, the friendly interactive shell"
|
||||
/usr/bin/env -i PATH="$bin:/usr/bin:/bin" HOME="DIR/home" CODEMAN_DESKTOP_TEST="from the profile" /bin/sh -c "$cmd"
|
||||
echo "bye from the profile"
|
||||
"#;
|
||||
|
||||
/// SCRIPT never reaches the non-POSIX shell: it runs under /bin/sh with the
|
||||
/// environment the shell printed, and with nothing else.
|
||||
#[test]
|
||||
fn non_posix_shell_only_reports_its_environment() {
|
||||
let _serial = serial();
|
||||
let s = Scratch::new("fish");
|
||||
let fish = s.exe("fish", &FAKE_FISH.replace("INTERACTIVE_ONLY", "no"));
|
||||
let out = start_with(&fish, &hostile_args()).expect("started");
|
||||
let lines: Vec<&str> = out.lines().collect();
|
||||
assert_eq!(lines[0], "[web][--base-url][/x; touch /tmp/pwned]");
|
||||
assert_eq!(lines[1], "profile: from the profile");
|
||||
assert_eq!(s.argv_log(), "-l -c \n");
|
||||
|
||||
// env_clear: a variable this process has, but the profile did not
|
||||
// export, must not reach codeman.
|
||||
let names: Vec<&str> = lines[2].trim_start_matches("names:").split_whitespace().collect();
|
||||
assert!(names.contains(&"PATH") && names.contains(&"CODEMAN_DESKTOP_TEST"));
|
||||
let captured = ["PATH", "HOME", "CODEMAN_DESKTOP_TEST", "PWD", "OLDPWD", "SHLVL", "_"];
|
||||
let leaked: Vec<String> = std::env::vars_os()
|
||||
.filter_map(|(k, _)| k.into_string().ok())
|
||||
.filter(|k| !captured.contains(&k.as_str()) && !k.starts_with("__CF"))
|
||||
.filter(|k| names.contains(&k.as_str()))
|
||||
.collect();
|
||||
assert!(leaked.is_empty(), "leaked from the app: {leaked:?}");
|
||||
}
|
||||
|
||||
/// No codeman on the login PATH (exit 127): the environment is captured
|
||||
/// again from the interactive profile, and that run finds it.
|
||||
#[test]
|
||||
fn non_posix_shell_retries_with_the_interactive_profile() {
|
||||
let _serial = serial();
|
||||
let s = Scratch::new("fish-retry");
|
||||
let fish = s.exe("fish", &FAKE_FISH.replace("INTERACTIVE_ONLY", "yes"));
|
||||
let out = start_with(&fish, &hostile_args()).expect("started");
|
||||
assert!(out.starts_with("[web][--base-url][/x; touch /tmp/pwned]\n"), "{out}");
|
||||
assert_eq!(s.argv_log(), "-l -c \n-l -i -c \n");
|
||||
}
|
||||
|
||||
/// tcsh takes -l only as its only flag, so it is asked with -ic alone.
|
||||
#[test]
|
||||
fn tcsh_gets_ic_and_never_l() {
|
||||
let _serial = serial();
|
||||
let s = Scratch::new("tcsh");
|
||||
let tcsh = s.exe(
|
||||
"tcsh",
|
||||
r#"#!/bin/sh
|
||||
echo "$*" >> "DIR/argv.log"
|
||||
if [ "$#" != 2 ] || [ "$1" != -ic ]; then echo "tcsh: Unknown option: $1" >&2; exit 1; fi
|
||||
/usr/bin/env -i PATH="DIR/bin:/usr/bin:/bin" HOME="DIR/home" CODEMAN_DESKTOP_TEST=tcsh /bin/sh -c "$2"
|
||||
"#,
|
||||
);
|
||||
let out = start_with(&tcsh, &hostile_args()).expect("started");
|
||||
assert!(out.contains("profile: tcsh"), "{out}");
|
||||
assert_eq!(s.argv_log().lines().count(), 1);
|
||||
assert!(s.argv_log().starts_with("-ic /bin/sh -c '"));
|
||||
}
|
||||
|
||||
/// A shell that takes neither -l nor -i still gets a bare -c.
|
||||
#[test]
|
||||
fn shell_without_login_flags_falls_back_to_plain_c() {
|
||||
let _serial = serial();
|
||||
let s = Scratch::new("nolflag");
|
||||
let shell = s.exe(
|
||||
"elvish",
|
||||
r#"#!/bin/sh
|
||||
echo "$1" >> "DIR/argv.log"
|
||||
case "$1" in -l|-i) echo "flag provided but not defined: $1" >&2; exit 2;; esac
|
||||
/usr/bin/env -i PATH="DIR/bin:/usr/bin:/bin" HOME="DIR/home" CODEMAN_DESKTOP_TEST=plain /bin/sh -c "$2"
|
||||
"#,
|
||||
);
|
||||
let out = start_with(&shell, &hostile_args()).expect("started");
|
||||
assert!(out.contains("profile: plain"), "{out}");
|
||||
assert_eq!(s.argv_log(), "-l\n-l\n-c\n");
|
||||
}
|
||||
|
||||
/// A shell that never prints its environment is reported by name with what
|
||||
/// it said on stderr, never with stdout (which can carry secrets).
|
||||
#[test]
|
||||
fn capture_failure_names_the_shell() {
|
||||
let _serial = serial();
|
||||
let s = Scratch::new("broken");
|
||||
let shell = s.exe(
|
||||
"weirdsh",
|
||||
"#!/bin/sh\necho 'API_KEY=sk-secret'\necho 'weirdsh: cannot start' >&2\nexit 3\n",
|
||||
);
|
||||
let err = start_with(&shell, &hostile_args()).unwrap_err();
|
||||
assert!(err.contains("Could not read the environment"), "{err}");
|
||||
assert!(err.contains("weirdsh: cannot start"), "{err}");
|
||||
assert!(!err.contains("sk-secret"), "{err}");
|
||||
}
|
||||
|
||||
/// POSIX shells keep running SCRIPT themselves: `-l -c SCRIPT codeman-desktop
|
||||
/// args`, then `-l -i -c` when the login PATH had no codeman.
|
||||
#[test]
|
||||
fn posix_shell_runs_script_itself_and_retries_interactive() {
|
||||
let _serial = serial();
|
||||
let s = Scratch::new("zsh");
|
||||
let zsh = s.exe(
|
||||
"zsh",
|
||||
r#"#!/bin/sh
|
||||
[ "$1" = -l ] || exit 90
|
||||
shift
|
||||
PATH=/usr/bin:/bin
|
||||
if [ "$1" = -i ]; then shift; PATH="DIR/bin:/usr/bin:/bin"; fi
|
||||
[ "$1" = -c ] || exit 91
|
||||
shift
|
||||
echo "$2" >> "DIR/argv.log"
|
||||
HOME="DIR/home"
|
||||
export PATH HOME
|
||||
exec /bin/sh -c "$@"
|
||||
"#,
|
||||
);
|
||||
let out = start_with(&zsh, &hostile_args()).expect("started");
|
||||
assert!(out.starts_with("[web][--base-url][/x; touch /tmp/pwned]\n"), "{out}");
|
||||
// $0 of the script, once per run: login, then interactive.
|
||||
assert_eq!(s.argv_log(), "codeman-desktop\ncodeman-desktop\n");
|
||||
}
|
||||
|
||||
/// The installed check asks the same shells the same way and runs nothing:
|
||||
/// found on the interactive PATH here, after the login PATH had none.
|
||||
#[test]
|
||||
fn installed_check_finds_codeman_without_running_it() {
|
||||
let _serial = serial();
|
||||
let s = Scratch::new("find-zsh");
|
||||
let zsh = s.exe(
|
||||
"zsh",
|
||||
r#"#!/bin/sh
|
||||
[ "$1" = -l ] || exit 90
|
||||
shift
|
||||
PATH=/usr/bin:/bin
|
||||
if [ "$1" = -i ]; then shift; PATH="DIR/bin:/usr/bin:/bin"; fi
|
||||
[ "$1" = -c ] || exit 91
|
||||
shift
|
||||
echo "$2" >> "DIR/argv.log"
|
||||
HOME="DIR/home"
|
||||
export PATH HOME
|
||||
exec /bin/sh -c "$@"
|
||||
"#,
|
||||
);
|
||||
// A codeman that would leave a mark if it ran.
|
||||
s.exe("bin/codeman", "#!/bin/sh\ntouch DIR/ran\n");
|
||||
// Without the interactive profile: one login-shell look, not found.
|
||||
assert!(!installed_with(&zsh, false));
|
||||
assert_eq!(s.argv_log(), "codeman-desktop\n");
|
||||
assert!(installed_with(&zsh, true));
|
||||
assert_eq!(s.argv_log(), "codeman-desktop\ncodeman-desktop\ncodeman-desktop\n");
|
||||
assert!(!s.0.join("ran").exists(), "the installed check ran codeman");
|
||||
}
|
||||
|
||||
/// Nothing on any PATH and no ~/.codeman/app: not installed. Through a
|
||||
/// non-POSIX shell too, which only reports its environment.
|
||||
#[test]
|
||||
fn installed_check_reports_missing_codeman() {
|
||||
let _serial = serial();
|
||||
let s = Scratch::new("find-fish");
|
||||
std::fs::remove_file(s.0.join("bin/codeman")).unwrap();
|
||||
let fish = s.exe("fish", &FAKE_FISH.replace("INTERACTIVE_ONLY", "no"));
|
||||
assert!(!installed_with(&fish, true));
|
||||
// The login-only look asks fish once, with -l and never -i.
|
||||
let before = s.argv_log();
|
||||
assert!(!installed_with(&fish, false));
|
||||
assert_eq!(&s.argv_log()[before.len()..], "-l -c \n");
|
||||
|
||||
// The install.sh clone counts once node is there.
|
||||
let app = s.0.join("home/.codeman/app/dist");
|
||||
std::fs::create_dir_all(&app).unwrap();
|
||||
std::fs::write(app.join("index.js"), "").unwrap();
|
||||
s.exe("bin/node", "#!/bin/sh\nexit 0\n");
|
||||
assert!(installed_with(&fish, true));
|
||||
assert!(installed_with(&fish, false));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,727 @@
|
||||
//! "Is a Codeman server answering at this address, and does it want a login?"
|
||||
//!
|
||||
//! One `GET <server>/api/status`, five seconds at most, run off the UI thread.
|
||||
//! The answer is classified into something the connect page can explain. It is
|
||||
//! asked once per user action, never in a loop: Codeman counts a request
|
||||
//! without valid credentials against the login rate limit (10 per IP).
|
||||
|
||||
use serde::Serialize;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const MAX_BODY: u64 = 8 * 1024 * 1024;
|
||||
|
||||
/// The path the probe asks for, relative to the server's base URL.
|
||||
const STATUS_PATH: &str = "api/status";
|
||||
|
||||
/// rustls' text for `Error::InvalidCertificate`, which is what a handshake
|
||||
/// with a certificate the OS does not trust fails with. ureq hands it over as
|
||||
/// an `io::Error` of kind `InvalidData`, so the text is all there is to match
|
||||
/// without a direct rustls dependency.
|
||||
const CERT_REJECTED_PREFIX: &str = "invalid peer certificate";
|
||||
|
||||
/// std's text for a failed `getaddrinfo`. Its kind is `Uncategorized`, which
|
||||
/// cannot be matched on stable Rust, so the prefix is the only handle. The
|
||||
/// prefix is the same on macOS and Linux; only the resolver's reason differs.
|
||||
const LOOKUP_FAILED_PREFIX: &str = "failed to lookup address information";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum ProbeOutcome {
|
||||
Ok {
|
||||
version: String,
|
||||
},
|
||||
/// 401 and we sent no credentials.
|
||||
NeedsLogin,
|
||||
/// 401 although we sent credentials.
|
||||
BadCredentials,
|
||||
/// 429: too many failed logins from this address. A single-user Codeman
|
||||
/// still accepts the right password, so the page needs to know whether
|
||||
/// this probe carried one. `retry_after_secs` is the server's
|
||||
/// `Retry-After`, when it gave one in seconds.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
RateLimited {
|
||||
sent_credentials: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
retry_after_secs: Option<u64>,
|
||||
},
|
||||
/// 403 `PASSWORD_CHANGE_REQUIRED`: the credentials are valid, but a
|
||||
/// multi-user Codeman wants this account's password changed first. Page
|
||||
/// loads are exempt, so the web UI can show its change-password dialog.
|
||||
PasswordChangeRequired,
|
||||
/// 403 from Codeman's Host guard (a name it does not know, e.g. a custom domain).
|
||||
HostNotAllowed,
|
||||
/// 3xx. `location` is the target resolved against the probed URL.
|
||||
/// `suggested` is a server address to use instead, set only when the
|
||||
/// redirect stays on the same host, only moves to https or another port,
|
||||
/// and still points at the status API. Never followed: a second request
|
||||
/// would carry the credentials to an address the user did not type.
|
||||
Redirect {
|
||||
location: String,
|
||||
suggested: Option<String>,
|
||||
},
|
||||
/// Something answered, but not Codeman's status API.
|
||||
NotCodeman {
|
||||
status: u16,
|
||||
},
|
||||
Refused,
|
||||
/// No route to the address: the machine is off or on another network,
|
||||
/// or macOS's Local Network privacy setting blocked the app.
|
||||
Unreachable,
|
||||
/// An http:// probe whose connection was closed without an answer. A
|
||||
/// Codeman started with `--https` does exactly that to plain HTTP.
|
||||
MaybeHttps,
|
||||
Timeout,
|
||||
TlsUntrusted {
|
||||
detail: String,
|
||||
},
|
||||
Unresolvable,
|
||||
Failed {
|
||||
detail: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Classify an HTTP answer to `<server>/api/status`. `request` is the URL that
|
||||
/// was asked, `location` and `retry_after` are those response headers.
|
||||
pub fn classify_response(
|
||||
request: &Url,
|
||||
status: u16,
|
||||
location: Option<&str>,
|
||||
retry_after: Option<&str>,
|
||||
sent_credentials: bool,
|
||||
body: &str,
|
||||
) -> ProbeOutcome {
|
||||
match status {
|
||||
200 => {
|
||||
let parsed: Option<serde_json::Value> = serde_json::from_str(body).ok();
|
||||
let version = parsed.as_ref().and_then(|v| {
|
||||
(v.get("success")? == &serde_json::Value::Bool(true))
|
||||
.then(|| v.get("data")?.get("version")?.as_str().map(str::to_string))
|
||||
.flatten()
|
||||
});
|
||||
match version {
|
||||
Some(version) => ProbeOutcome::Ok { version },
|
||||
None => ProbeOutcome::NotCodeman { status },
|
||||
}
|
||||
}
|
||||
300..=399 => {
|
||||
// A 3xx without a usable Location (304, a garbled header) is not a
|
||||
// redirect anyone can act on.
|
||||
match location.and_then(|l| request.join(l.trim()).ok()) {
|
||||
Some(target) => ProbeOutcome::Redirect {
|
||||
suggested: suggested_server(request, &target),
|
||||
location: target.into(),
|
||||
},
|
||||
None => ProbeOutcome::NotCodeman { status },
|
||||
}
|
||||
}
|
||||
401 if sent_credentials => ProbeOutcome::BadCredentials,
|
||||
401 => ProbeOutcome::NeedsLogin,
|
||||
403 if body.contains("host not allowed") => ProbeOutcome::HostNotAllowed,
|
||||
// Codeman only says this after the Basic credentials verified (the
|
||||
// probe sends no cookie), so without credentials it is not Codeman's
|
||||
// answer to us, and loading the page without them would not help.
|
||||
403 if sent_credentials && error_code(body).as_deref() == Some("PASSWORD_CHANGE_REQUIRED") => {
|
||||
ProbeOutcome::PasswordChangeRequired
|
||||
}
|
||||
429 => ProbeOutcome::RateLimited {
|
||||
sent_credentials,
|
||||
retry_after_secs: retry_after.and_then(|v| v.trim().parse::<u64>().ok()),
|
||||
},
|
||||
other => ProbeOutcome::NotCodeman { status: other },
|
||||
}
|
||||
}
|
||||
|
||||
/// `errorCode` of a Codeman `ApiResponse` error body.
|
||||
fn error_code(body: &str) -> Option<String> {
|
||||
let parsed: serde_json::Value = serde_json::from_str(body).ok()?;
|
||||
Some(parsed.get("errorCode")?.as_str()?.to_string())
|
||||
}
|
||||
|
||||
/// The server base URL a redirect points to, when it is the same server
|
||||
/// moved to https or another port (a reverse proxy upgrading plain HTTP, the
|
||||
/// usual case). Anything else, such as another host (a login gateway the
|
||||
/// window could not pass anyway) or a downgrade to http, gets no suggestion.
|
||||
fn suggested_server(request: &Url, target: &Url) -> Option<String> {
|
||||
let upgrade = request.scheme() == "http" && target.scheme() == "https";
|
||||
let port_change =
|
||||
request.scheme() == target.scheme() && request.port_or_known_default() != target.port_or_known_default();
|
||||
if !(upgrade || port_change) || target.host() != request.host() {
|
||||
return None;
|
||||
}
|
||||
if !target.username().is_empty() || target.password().is_some() {
|
||||
return None;
|
||||
}
|
||||
// The redirect must keep pointing at the status API (a proxy may add a
|
||||
// base path in front of it); the base is what comes before it.
|
||||
if !target.path().ends_with(request.path()) || !request.path().ends_with(STATUS_PATH) {
|
||||
return None;
|
||||
}
|
||||
let base_path = &target.path()[..target.path().len() - STATUS_PATH.len()];
|
||||
let mut base = target.clone();
|
||||
base.set_path(base_path);
|
||||
base.set_query(None);
|
||||
base.set_fragment(None);
|
||||
Some(base.into())
|
||||
}
|
||||
|
||||
/// Transport failures, reduced to what the connect page explains differently.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TransportError {
|
||||
Refused,
|
||||
Unreachable,
|
||||
ClosedWithoutAnswer,
|
||||
Timeout,
|
||||
Tls(String),
|
||||
Dns,
|
||||
Other(String),
|
||||
}
|
||||
|
||||
pub fn classify_transport(err: TransportError) -> ProbeOutcome {
|
||||
match err {
|
||||
TransportError::Refused => ProbeOutcome::Refused,
|
||||
TransportError::Unreachable => ProbeOutcome::Unreachable,
|
||||
TransportError::ClosedWithoutAnswer => ProbeOutcome::MaybeHttps,
|
||||
TransportError::Timeout => ProbeOutcome::Timeout,
|
||||
TransportError::Tls(detail) => ProbeOutcome::TlsUntrusted { detail },
|
||||
TransportError::Dns => ProbeOutcome::Unresolvable,
|
||||
TransportError::Other(detail) => ProbeOutcome::Failed { detail },
|
||||
}
|
||||
}
|
||||
|
||||
/// `plain_http`: the probe went to an http:// URL. Only there does a closed
|
||||
/// connection hint at an https-only server; over https the same close is a
|
||||
/// failed handshake with nothing more to say about it.
|
||||
fn transport_error(err: &ureq::Error, plain_http: bool) -> TransportError {
|
||||
use std::io::ErrorKind;
|
||||
match err {
|
||||
ureq::Error::Timeout(_) => TransportError::Timeout,
|
||||
ureq::Error::HostNotFound => TransportError::Dns,
|
||||
ureq::Error::ConnectionFailed => TransportError::Refused,
|
||||
// ureq passes both a rejected certificate (rustls wraps it as
|
||||
// InvalidData during the handshake) and a failed name lookup (std's
|
||||
// own error) through as plain io errors, so they are told apart here
|
||||
// before the kinds below swallow them.
|
||||
ureq::Error::Io(e) if e.kind() == ErrorKind::InvalidData && e.to_string().starts_with(CERT_REJECTED_PREFIX) => {
|
||||
TransportError::Tls(e.to_string())
|
||||
}
|
||||
ureq::Error::Io(e) if e.raw_os_error().is_none() && e.to_string().starts_with(LOOKUP_FAILED_PREFIX) => {
|
||||
TransportError::Dns
|
||||
}
|
||||
ureq::Error::Io(e) => match e.kind() {
|
||||
ErrorKind::ConnectionRefused | ErrorKind::ConnectionReset | ErrorKind::AddrNotAvailable => {
|
||||
TransportError::Refused
|
||||
}
|
||||
ErrorKind::HostUnreachable | ErrorKind::NetworkUnreachable => TransportError::Unreachable,
|
||||
ErrorKind::UnexpectedEof if plain_http => TransportError::ClosedWithoutAnswer,
|
||||
ErrorKind::TimedOut | ErrorKind::WouldBlock => TransportError::Timeout,
|
||||
_ => TransportError::Other(e.to_string()),
|
||||
},
|
||||
// ureq's own rustls errors come from setting up the TLS client (the
|
||||
// handshake's arrive as Io above); only a certificate rejection is
|
||||
// "untrusted", anything else is reported as it is.
|
||||
ureq::Error::Rustls(e) if e.to_string().starts_with(CERT_REJECTED_PREFIX) => TransportError::Tls(e.to_string()),
|
||||
other => TransportError::Other(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the server. Blocking: call it from a worker thread.
|
||||
pub fn probe(server: &Url, authorization: Option<&str>) -> ProbeOutcome {
|
||||
let status_url = match server.join(STATUS_PATH) {
|
||||
Ok(u) => u,
|
||||
Err(e) => return ProbeOutcome::Failed { detail: e.to_string() },
|
||||
};
|
||||
let tls = ureq::tls::TlsConfig::builder()
|
||||
.root_certs(ureq::tls::RootCerts::PlatformVerifier)
|
||||
.build();
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||
.timeout_global(Some(TIMEOUT))
|
||||
.http_status_as_error(false)
|
||||
// Never follow: a 3xx comes back as an answer and is explained.
|
||||
.max_redirects(0)
|
||||
.tls_config(tls)
|
||||
.user_agent(concat!("CodemanDesktop/", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
.into();
|
||||
let mut req = agent.get(status_url.as_str()).header("Accept", "application/json");
|
||||
if let Some(auth) = authorization {
|
||||
req = req.header("Authorization", auth);
|
||||
}
|
||||
match req.call() {
|
||||
Ok(mut resp) => {
|
||||
let status = resp.status().as_u16();
|
||||
let header = |name: &str| {
|
||||
resp.headers()
|
||||
.get(name)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_string)
|
||||
};
|
||||
let location = header("location");
|
||||
let retry_after = header("retry-after");
|
||||
let body = resp
|
||||
.body_mut()
|
||||
.with_config()
|
||||
.limit(MAX_BODY)
|
||||
.read_to_string()
|
||||
.unwrap_or_default();
|
||||
classify_response(
|
||||
&status_url,
|
||||
status,
|
||||
location.as_deref(),
|
||||
retry_after.as_deref(),
|
||||
authorization.is_some(),
|
||||
&body,
|
||||
)
|
||||
}
|
||||
Err(e) => classify_transport(transport_error(&e, status_url.scheme() == "http")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io;
|
||||
|
||||
fn status_url() -> Url {
|
||||
Url::parse("http://studio.lan:3000/api/status").unwrap()
|
||||
}
|
||||
|
||||
/// `classify_response` for the cases that need no headers.
|
||||
fn classify(status: u16, sent_credentials: bool, body: &str) -> ProbeOutcome {
|
||||
classify_response(&status_url(), status, None, None, sent_credentials, body)
|
||||
}
|
||||
|
||||
fn redirect(request: &str, location: &str) -> ProbeOutcome {
|
||||
classify_response(&Url::parse(request).unwrap(), 301, Some(location), None, false, "")
|
||||
}
|
||||
|
||||
fn io_outcome(kind: io::ErrorKind, message: &str, plain_http: bool) -> ProbeOutcome {
|
||||
let err = ureq::Error::Io(io::Error::new(kind, message.to_string()));
|
||||
classify_transport(transport_error(&err, plain_http))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ok_needs_success_and_version() {
|
||||
assert_eq!(
|
||||
classify(
|
||||
200,
|
||||
false,
|
||||
r#"{"success":true,"data":{"version":"1.47.0","sessions":[]}}"#
|
||||
),
|
||||
ProbeOutcome::Ok {
|
||||
version: "1.47.0".into()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
classify(200, false, "<html>hello</html>"),
|
||||
ProbeOutcome::NotCodeman { status: 200 }
|
||||
);
|
||||
assert_eq!(
|
||||
classify(200, false, r#"{"success":false,"data":{"version":"1"}}"#),
|
||||
ProbeOutcome::NotCodeman { status: 200 }
|
||||
);
|
||||
assert_eq!(
|
||||
classify(200, false, r#"{"success":true}"#),
|
||||
ProbeOutcome::NotCodeman { status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_and_guards() {
|
||||
assert_eq!(classify(401, false, "Unauthorized"), ProbeOutcome::NeedsLogin);
|
||||
assert_eq!(classify(401, true, "Unauthorized"), ProbeOutcome::BadCredentials);
|
||||
assert_eq!(
|
||||
classify(403, false, "Forbidden: host not allowed"),
|
||||
ProbeOutcome::HostNotAllowed
|
||||
);
|
||||
// The Host guard runs before authentication, so it wins even with credentials.
|
||||
assert_eq!(
|
||||
classify(403, true, "Forbidden: host not allowed"),
|
||||
ProbeOutcome::HostNotAllowed
|
||||
);
|
||||
assert_eq!(classify(403, false, "nope"), ProbeOutcome::NotCodeman { status: 403 });
|
||||
assert_eq!(classify(502, false, ""), ProbeOutcome::NotCodeman { status: 502 });
|
||||
}
|
||||
|
||||
/// The exact body auth.ts sends: `createErrorResponse(PASSWORD_CHANGE_REQUIRED)`.
|
||||
#[test]
|
||||
fn password_change_required_only_after_sent_credentials() {
|
||||
let body = r#"{"success":false,"error":"You must change your password before continuing","errorCode":"PASSWORD_CHANGE_REQUIRED"}"#;
|
||||
assert_eq!(classify(403, true, body), ProbeOutcome::PasswordChangeRequired);
|
||||
assert_eq!(classify(403, false, body), ProbeOutcome::NotCodeman { status: 403 });
|
||||
// Another Codeman error code, or the text without the JSON field, is not it.
|
||||
assert_eq!(
|
||||
classify(403, true, r#"{"success":false,"errorCode":"FORBIDDEN"}"#),
|
||||
ProbeOutcome::NotCodeman { status: 403 }
|
||||
);
|
||||
assert_eq!(
|
||||
classify(403, true, "PASSWORD_CHANGE_REQUIRED"),
|
||||
ProbeOutcome::NotCodeman { status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limited_says_whether_credentials_went_along() {
|
||||
let body = "Too Many Requests, try again later";
|
||||
assert_eq!(
|
||||
classify(429, false, body),
|
||||
ProbeOutcome::RateLimited {
|
||||
sent_credentials: false,
|
||||
retry_after_secs: None
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
classify(429, true, body),
|
||||
ProbeOutcome::RateLimited {
|
||||
sent_credentials: true,
|
||||
retry_after_secs: None
|
||||
}
|
||||
);
|
||||
// auth.ts sends Retry-After in whole seconds; an HTTP date is not parsed.
|
||||
assert_eq!(
|
||||
classify_response(&status_url(), 429, None, Some(" 742 "), true, body),
|
||||
ProbeOutcome::RateLimited {
|
||||
sent_credentials: true,
|
||||
retry_after_secs: Some(742)
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
classify_response(
|
||||
&status_url(),
|
||||
429,
|
||||
None,
|
||||
Some("Wed, 21 Oct 2026 07:28:00 GMT"),
|
||||
false,
|
||||
body
|
||||
),
|
||||
ProbeOutcome::RateLimited {
|
||||
sent_credentials: false,
|
||||
retry_after_secs: None
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redirect_to_https_on_the_same_host_is_suggested() {
|
||||
assert_eq!(
|
||||
redirect("http://studio.lan/api/status", "https://studio.lan/api/status"),
|
||||
ProbeOutcome::Redirect {
|
||||
location: "https://studio.lan/api/status".into(),
|
||||
suggested: Some("https://studio.lan/".into())
|
||||
}
|
||||
);
|
||||
// A base path the proxy adds stays part of the suggestion; a query does not.
|
||||
assert_eq!(
|
||||
redirect(
|
||||
"http://studio.lan:3000/api/status",
|
||||
"https://studio.lan/codeman/api/status?from=http"
|
||||
),
|
||||
ProbeOutcome::Redirect {
|
||||
location: "https://studio.lan/codeman/api/status?from=http".into(),
|
||||
suggested: Some("https://studio.lan/codeman/".into())
|
||||
}
|
||||
);
|
||||
// A relative Location resolves against the probed URL; another port is fine.
|
||||
assert_eq!(
|
||||
redirect(
|
||||
"http://studio.lan:3000/base/api/status",
|
||||
"//studio.lan:3001/base/api/status"
|
||||
),
|
||||
ProbeOutcome::Redirect {
|
||||
location: "http://studio.lan:3001/base/api/status".into(),
|
||||
suggested: Some("http://studio.lan:3001/base/".into())
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_redirects_are_reported_without_a_suggestion() {
|
||||
// Another host (a login gateway, say).
|
||||
assert_eq!(
|
||||
redirect("http://studio.lan/api/status", "https://sso.example.com/login?next=x"),
|
||||
ProbeOutcome::Redirect {
|
||||
location: "https://sso.example.com/login?next=x".into(),
|
||||
suggested: None
|
||||
}
|
||||
);
|
||||
// Same host, but no longer the status API.
|
||||
assert_eq!(
|
||||
redirect("http://studio.lan/api/status", "/login"),
|
||||
ProbeOutcome::Redirect {
|
||||
location: "http://studio.lan/login".into(),
|
||||
suggested: None
|
||||
}
|
||||
);
|
||||
// A downgrade to plain http.
|
||||
assert_eq!(
|
||||
redirect("https://studio.lan/api/status", "http://studio.lan/api/status"),
|
||||
ProbeOutcome::Redirect {
|
||||
location: "http://studio.lan/api/status".into(),
|
||||
suggested: None
|
||||
}
|
||||
);
|
||||
// Same scheme and port, only the path moved.
|
||||
assert_eq!(
|
||||
redirect("http://studio.lan/api/status", "/x/api/status"),
|
||||
ProbeOutcome::Redirect {
|
||||
location: "http://studio.lan/x/api/status".into(),
|
||||
suggested: None
|
||||
}
|
||||
);
|
||||
// Credentials in the target are never suggested.
|
||||
assert_eq!(
|
||||
redirect("http://studio.lan/api/status", "https://u:p@studio.lan/api/status"),
|
||||
ProbeOutcome::Redirect {
|
||||
location: "https://u:p@studio.lan/api/status".into(),
|
||||
suggested: None
|
||||
}
|
||||
);
|
||||
// No Location (304), or one that does not parse: nothing to act on.
|
||||
assert_eq!(
|
||||
classify_response(&status_url(), 304, None, None, false, ""),
|
||||
ProbeOutcome::NotCodeman { status: 304 }
|
||||
);
|
||||
assert_eq!(
|
||||
classify_response(&status_url(), 302, Some("http://[::1"), None, false, ""),
|
||||
ProbeOutcome::NotCodeman { status: 302 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_classes() {
|
||||
assert_eq!(classify_transport(TransportError::Refused), ProbeOutcome::Refused);
|
||||
assert_eq!(classify_transport(TransportError::Timeout), ProbeOutcome::Timeout);
|
||||
assert_eq!(classify_transport(TransportError::Dns), ProbeOutcome::Unresolvable);
|
||||
assert_eq!(
|
||||
classify_transport(TransportError::Tls("bad cert".into())),
|
||||
ProbeOutcome::TlsUntrusted {
|
||||
detail: "bad cert".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// The io errors exactly as ureq hands them over (`Error::Io`).
|
||||
#[test]
|
||||
fn io_errors_are_told_apart() {
|
||||
use io::ErrorKind;
|
||||
// rustls' certificate rejection from the handshake.
|
||||
let cert = "invalid peer certificate: UnknownIssuer";
|
||||
assert_eq!(
|
||||
io_outcome(ErrorKind::InvalidData, cert, false),
|
||||
ProbeOutcome::TlsUntrusted { detail: cert.into() }
|
||||
);
|
||||
// Any other InvalidData (a port that does not speak TLS, say) is not a certificate problem.
|
||||
assert_eq!(
|
||||
io_outcome(
|
||||
ErrorKind::InvalidData,
|
||||
"received corrupt message of type InvalidContentType",
|
||||
false
|
||||
),
|
||||
ProbeOutcome::Failed {
|
||||
detail: "received corrupt message of type InvalidContentType".into()
|
||||
}
|
||||
);
|
||||
// std's getaddrinfo failure (its real kind, Uncategorized, cannot be built on stable).
|
||||
assert_eq!(
|
||||
io_outcome(
|
||||
ErrorKind::Other,
|
||||
"failed to lookup address information: nodename nor servname provided, or not known",
|
||||
true
|
||||
),
|
||||
ProbeOutcome::Unresolvable
|
||||
);
|
||||
// The same text from the OS (an errno) is not a lookup failure.
|
||||
let os = ureq::Error::Io(io::Error::from_raw_os_error(1));
|
||||
assert!(!matches!(
|
||||
classify_transport(transport_error(&os, true)),
|
||||
ProbeOutcome::Unresolvable
|
||||
));
|
||||
// ureq's "Peer disconnected": over http a hint at https, over https just a failure.
|
||||
assert_eq!(
|
||||
io_outcome(ErrorKind::UnexpectedEof, "Peer disconnected", true),
|
||||
ProbeOutcome::MaybeHttps
|
||||
);
|
||||
assert_eq!(
|
||||
io_outcome(ErrorKind::UnexpectedEof, "Peer disconnected", false),
|
||||
ProbeOutcome::Failed {
|
||||
detail: "Peer disconnected".into()
|
||||
}
|
||||
);
|
||||
// No route: Local Network privacy, or a machine that is off.
|
||||
assert_eq!(
|
||||
io_outcome(ErrorKind::HostUnreachable, "No route to host", true),
|
||||
ProbeOutcome::Unreachable
|
||||
);
|
||||
assert_eq!(
|
||||
io_outcome(ErrorKind::NetworkUnreachable, "Network is unreachable", false),
|
||||
ProbeOutcome::Unreachable
|
||||
);
|
||||
assert_eq!(
|
||||
io_outcome(ErrorKind::ConnectionRefused, "Connection refused", true),
|
||||
ProbeOutcome::Refused
|
||||
);
|
||||
assert_eq!(
|
||||
classify_transport(transport_error(&ureq::Error::HostNotFound, true)),
|
||||
ProbeOutcome::Unresolvable
|
||||
);
|
||||
// A ureq TLS setup message is not an untrusted certificate.
|
||||
assert_eq!(
|
||||
classify_transport(transport_error(
|
||||
&ureq::Error::Tls("Rustls invalid dns name error"),
|
||||
false
|
||||
)),
|
||||
ProbeOutcome::Failed {
|
||||
detail: "Rustls invalid dns name error".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outcome_serializes_for_the_page() {
|
||||
let json = |o: ProbeOutcome| serde_json::to_value(o).unwrap();
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::NotCodeman { status: 404 }),
|
||||
serde_json::json!({"kind":"notCodeman","status":404})
|
||||
);
|
||||
assert_eq!(json(ProbeOutcome::NeedsLogin), serde_json::json!({"kind":"needsLogin"}));
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::RateLimited {
|
||||
sent_credentials: true,
|
||||
retry_after_secs: None
|
||||
}),
|
||||
serde_json::json!({"kind":"rateLimited","sentCredentials":true})
|
||||
);
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::RateLimited {
|
||||
sent_credentials: false,
|
||||
retry_after_secs: Some(60)
|
||||
}),
|
||||
serde_json::json!({"kind":"rateLimited","sentCredentials":false,"retryAfterSecs":60})
|
||||
);
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::PasswordChangeRequired),
|
||||
serde_json::json!({"kind":"passwordChangeRequired"})
|
||||
);
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::Redirect {
|
||||
location: "https://studio.lan/api/status".into(),
|
||||
suggested: Some("https://studio.lan/".into())
|
||||
}),
|
||||
serde_json::json!({"kind":"redirect","location":"https://studio.lan/api/status","suggested":"https://studio.lan/"})
|
||||
);
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::Redirect {
|
||||
location: "https://sso.example.com/".into(),
|
||||
suggested: None
|
||||
}),
|
||||
serde_json::json!({"kind":"redirect","location":"https://sso.example.com/","suggested":null})
|
||||
);
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::Unreachable),
|
||||
serde_json::json!({"kind":"unreachable"})
|
||||
);
|
||||
assert_eq!(json(ProbeOutcome::MaybeHttps), serde_json::json!({"kind":"maybeHttps"}));
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::TlsUntrusted { detail: "x".into() }),
|
||||
serde_json::json!({"kind":"tlsUntrusted","detail":"x"})
|
||||
);
|
||||
assert_eq!(
|
||||
json(ProbeOutcome::Unresolvable),
|
||||
serde_json::json!({"kind":"unresolvable"})
|
||||
);
|
||||
}
|
||||
|
||||
/// Against real sockets: a closed port is `Refused`, a listener that
|
||||
/// speaks HTTP but not Codeman is `NotCodeman`, a fake Codeman is `Ok`,
|
||||
/// a 401 is `NeedsLogin`, a redirect is reported without being followed,
|
||||
/// and a listener that hangs up without answering is `MaybeHttps`.
|
||||
#[test]
|
||||
fn probe_against_local_sockets() {
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
|
||||
let closed = {
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
l.local_addr().unwrap().port()
|
||||
};
|
||||
let url = Url::parse(&format!("http://127.0.0.1:{closed}/")).unwrap();
|
||||
assert_eq!(probe(&url, None), ProbeOutcome::Refused);
|
||||
|
||||
/// Accept one connection, read the request and send `response`
|
||||
/// (nothing, then close, for an empty one).
|
||||
fn serve_raw(response: String) -> u16 {
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = l.local_addr().unwrap().port();
|
||||
std::thread::spawn(move || {
|
||||
if let Ok((mut s, _)) = l.accept() {
|
||||
// Read the whole request head (a GET has no body) so that
|
||||
// closing sends a FIN, not a reset for unread bytes.
|
||||
let mut head = Vec::new();
|
||||
let mut buf = [0u8; 4096];
|
||||
while !head.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
match s.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => head.extend_from_slice(&buf[..n]),
|
||||
}
|
||||
}
|
||||
let _ = s.write_all(response.as_bytes());
|
||||
}
|
||||
});
|
||||
port
|
||||
}
|
||||
fn serve_once(status_line: &str, extra_headers: &str, body: &str) -> u16 {
|
||||
serve_raw(format!(
|
||||
"HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n{extra_headers}Connection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
))
|
||||
}
|
||||
|
||||
let p = serve_once("200 OK", "", r#"{"success":true,"data":{"version":"9.9.9"}}"#);
|
||||
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
|
||||
assert_eq!(
|
||||
probe(&url, None),
|
||||
ProbeOutcome::Ok {
|
||||
version: "9.9.9".into()
|
||||
}
|
||||
);
|
||||
|
||||
let p = serve_once("404 Not Found", "", "nope");
|
||||
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
|
||||
assert_eq!(probe(&url, None), ProbeOutcome::NotCodeman { status: 404 });
|
||||
|
||||
let p = serve_once("401 Unauthorized", "", "Unauthorized");
|
||||
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
|
||||
assert_eq!(probe(&url, None), ProbeOutcome::NeedsLogin);
|
||||
|
||||
let p = serve_once("429 Too Many Requests", "Retry-After: 30\r\n", "Too Many Requests");
|
||||
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
|
||||
assert_eq!(
|
||||
probe(&url, Some("Basic YWRtaW46eA==")),
|
||||
ProbeOutcome::RateLimited {
|
||||
sent_credentials: true,
|
||||
retry_after_secs: Some(30)
|
||||
}
|
||||
);
|
||||
|
||||
// Nothing listens on the redirect target: had it been followed, the
|
||||
// answer would be Refused instead.
|
||||
let p = serve_once(
|
||||
"301 Moved Permanently",
|
||||
&format!("Location: https://127.0.0.1:{closed}/api/status\r\n"),
|
||||
"",
|
||||
);
|
||||
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
|
||||
assert_eq!(
|
||||
probe(&url, None),
|
||||
ProbeOutcome::Redirect {
|
||||
location: format!("https://127.0.0.1:{closed}/api/status"),
|
||||
suggested: Some(format!("https://127.0.0.1:{closed}/"))
|
||||
}
|
||||
);
|
||||
|
||||
let p = serve_raw(String::new());
|
||||
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
|
||||
assert_eq!(probe(&url, None), ProbeOutcome::MaybeHttps);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
|
||||
|
||||
@@ -983,6 +983,14 @@
|
||||
'Could not open a new window for this session': '无法在新窗口中打开此会话',
|
||||
'Could not open a new window for this preview': '无法在新窗口中打开此预览',
|
||||
'Could not open a new window for this dashboard': '无法在新窗口中打开此网页标签',
|
||||
// The Codeman desktop app's shim when the app would not open the window a
|
||||
// page asked for (packages/desktop/src/assets.rs).
|
||||
'Could not open a new window': '无法打开新窗口',
|
||||
// A subagent transcript or a detached preview whose window.open came back
|
||||
// null (panels-ui.js viewSubagentTranscript / detachFilePreview).
|
||||
'Could not open the transcript window: pop-up windows are blocked here': '无法打开对话记录窗口:此处已阻止弹出窗口',
|
||||
'Pop-up blocked: allow pop-ups for this site to detach previews':
|
||||
'弹出窗口已被阻止:请允许此网站的弹出窗口以分离预览',
|
||||
// Dictation whose session closed before the text was sent (voice-input.js).
|
||||
'That session has closed; dictation not sent': '该会话已关闭,语音输入未发送',
|
||||
// Terminal touch-selection bar (long-press to select). The bar is a sibling of
|
||||
@@ -1457,6 +1465,10 @@
|
||||
[/^Worktree (\S+) was kept: ([\s\S]+)$/, (_m, wt, why) => `工作树 ${wt} 已保留:${why}`],
|
||||
// A pick, drop or paste that mixed admitted files with refused ones (image-input.js).
|
||||
[/^(\d+) files? skipped: unsupported type$/, (_m, n) => `已跳过 ${n} 个不支持类型的文件`],
|
||||
// The Codeman desktop app's download toasts (packages/desktop/src/assets.rs).
|
||||
// The file name is user text: it passes through untranslated.
|
||||
[/^Downloaded (.+) to Downloads$/, (_m, name) => `已将 ${name} 下载到“下载”文件夹`],
|
||||
[/^Download of (.+) failed$/, (_m, name) => `下载 ${name} 失败`],
|
||||
// Group names are user text: they pass through untranslated.
|
||||
[/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`],
|
||||
// Session names are user text too.
|
||||
|
||||
@@ -1963,7 +1963,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Show in a modal or new window
|
||||
const content = data.data.formatted.join('\n');
|
||||
const win = window.open('', '_blank', 'width=800,height=600');
|
||||
// This open runs after the fetch above, so a pop-up blocker that wants a
|
||||
// click can refuse it, and the Codeman desktop app always refuses an
|
||||
// empty window. Both return null; say so instead of throwing into the
|
||||
// catch below, whose 'Failed to load transcript' would be wrong.
|
||||
let win = null;
|
||||
try { win = window.open('', '_blank', 'width=800,height=600'); } catch {}
|
||||
if (!win) {
|
||||
this.showToast('Could not open the transcript window: pop-up windows are blocked here', 'error');
|
||||
return;
|
||||
}
|
||||
win.document.write(`
|
||||
<html>
|
||||
<head>
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* @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;
|
||||
|
||||
function open() {
|
||||
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' });
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @fileoverview zh-CN coverage for the toasts shown when a window could not be
|
||||
* opened, and for the Codeman desktop app's own toasts.
|
||||
*
|
||||
* The literals are read from their sources: the transcript and detached
|
||||
* preview toasts from panels-ui.js, and the desktop shim's toasts from
|
||||
* packages/desktop/src/assets.rs (a Rust file whose strings reach Codeman's
|
||||
* `showToast`), so a reworded toast on either side without an entry fails
|
||||
* here. The download toasts carry a file name and go through the pattern
|
||||
* rule, which must keep the name exactly as written.
|
||||
*
|
||||
* Port: N/A.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { afterAll, describe, expect, it } from 'vitest';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
const read = (f: string) => readFileSync(resolve(PUBLIC, f), 'utf8');
|
||||
const I18N = read('i18n.js');
|
||||
const PANELS_UI = read('panels-ui.js');
|
||||
const ASSETS_RS = readFileSync(resolve(import.meta.dirname, '../packages/desktop/src/assets.rs'), 'utf8');
|
||||
|
||||
type Api = { t(s: string): string; configure(o: Record<string, string>): void };
|
||||
function translator(language: string) {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
|
||||
runScripts: 'outside-only',
|
||||
url: 'http://localhost/',
|
||||
});
|
||||
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
|
||||
const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n;
|
||||
api.configure({ language });
|
||||
return { api, dom };
|
||||
}
|
||||
const zh = translator('zh-CN');
|
||||
const en = translator('en');
|
||||
afterAll(() => {
|
||||
zh.dom.window.close();
|
||||
en.dom.window.close();
|
||||
});
|
||||
|
||||
const panelToasts = [
|
||||
...PANELS_UI.matchAll(/this\.showToast\('(Could not open the transcript window[^']*|Pop-up blocked[^']*)'/g),
|
||||
].map((m) => m[1]);
|
||||
// The shim's JavaScript may quote a literal either way.
|
||||
const shimToasts = [...ASSETS_RS.matchAll(/showToast\((['"])([^'"]+)\1/g)].map((m) => m[2]);
|
||||
const downloadTemplates = [...ASSETS_RS.matchAll(/format!\("([^"]*\{file_name\}[^"]*)"\)/g)].map((m) => m[1]);
|
||||
|
||||
// File names with spaces, dots, brackets and English words, one of them a
|
||||
// dictionary word on its own ("Settings saved"), which must not be translated.
|
||||
const FILE_NAMES = ['report v2.pdf', 'Settings saved', 'a (1).tar.gz', '数据.csv'];
|
||||
|
||||
describe('the window and desktop toasts in zh-CN', () => {
|
||||
it('finds the strings it checks (the check is not vacuous)', () => {
|
||||
expect(new Set(panelToasts)).toEqual(
|
||||
new Set([
|
||||
'Could not open the transcript window: pop-up windows are blocked here',
|
||||
'Pop-up blocked: allow pop-ups for this site to detach previews',
|
||||
])
|
||||
);
|
||||
expect(new Set(shimToasts)).toEqual(new Set(['Could not open a new window']));
|
||||
expect(new Set(downloadTemplates)).toEqual(
|
||||
new Set(['Downloaded {file_name} to Downloads', 'Download of {file_name} failed'])
|
||||
);
|
||||
});
|
||||
|
||||
it('every literal toast translates, and reads the same in English', () => {
|
||||
const bad = [...panelToasts, ...shimToasts].filter((l) => {
|
||||
const text = zh.api.t(l);
|
||||
return text === l || /[A-Za-z]/.test(text) || en.api.t(l) !== l;
|
||||
});
|
||||
expect(bad).toEqual([]);
|
||||
});
|
||||
|
||||
it('the download toasts translate around the file name, which stays as written', () => {
|
||||
for (const template of downloadTemplates) {
|
||||
for (const name of FILE_NAMES) {
|
||||
const source = template.replace('{file_name}', name);
|
||||
const text = zh.api.t(source);
|
||||
expect(text, source).not.toBe(source);
|
||||
expect(text.split(name), source).toHaveLength(2);
|
||||
expect(text.replace(name, ''), source).not.toMatch(/[A-Za-z]/);
|
||||
expect(en.api.t(source)).toBe(source);
|
||||
}
|
||||
}
|
||||
expect(zh.api.t('Downloaded report v2.pdf to Downloads')).toBe('已将 report v2.pdf 下载到“下载”文件夹');
|
||||
expect(zh.api.t('Download of report v2.pdf failed')).toBe('下载 report v2.pdf 失败');
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -12,9 +12,12 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, 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 { 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 +372,235 @@ 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 --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 +768,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/"*');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user