mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89a93c871f |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"aicodeman": patch
|
||||
---
|
||||
|
||||
Files panel: when a session's agent moves into a git worktree part way through (Claude Code's EnterWorktree), the panel now lists that worktree instead of the main checkout, follows the agent back out, and shows a strip that names the worktree with a button to switch to the session's folder. Only a worktree inside the session's folder is followed, and file access stays confined to that folder as before.
|
||||
@@ -151,61 +151,6 @@ 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. (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)
|
||||
- **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)
|
||||
- **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). ⚠️ 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)
|
||||
**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)
|
||||
|
||||
**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)
|
||||
|
||||
@@ -238,6 +238,8 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin
|
||||
|
||||
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
|
||||
|
||||
**Files panel in a worktree** (#765, `src/live-worktree.ts`, `root=live` on `GET /api/sessions/:id/files`): Claude's EnterWorktree moves the process, and `paneCwd` follows it (tmux reads the process cwd, measured on 2.1.318, back out on ExitWorktree too), while `workingDir` stays the session's identity. `refreshLiveWorktree()` (server.ts) turns a changed `paneCwd` into `SessionState.liveWorktree` by reading the `.git` FILE (never spawning git), walking up and stopping at `workingDir`, a repository or submodule of its own, or anything unreadable. ⚠️ Only a folder strictly below `workingDir` is ever an answer, which is the whole security argument: the walk starts there, returned `path`s stay relative to `workingDir`, and the routes keep confining to `workingDir`, so never make `liveWorktree` a confinement root (a worktree outside the folder stays unfollowed). ⚠️ A plain subfolder is deliberately no answer, or a `cd` in a shell would re-root the panel. ⚠️ A listing reports `worktree` whichever folder it walked, even when the worktree has gone: `_followFileBrowserWorktree()` compares that with the session's state, and an absent report would reload forever. Tests: `test/live-worktree.test.ts`, `test/routes/file-routes-live-worktree.test.ts`, `test/file-browser-worktree.test.ts`.
|
||||
|
||||
**Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline on its own (it never fetches; fetching is the separate path below). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate).
|
||||
|
||||
**Git fetch** (`src/git-fetch.ts`, `src/git-fetch-scheduler.ts`, `routes/git-status-routes.ts`, the panel's **Fetch** button): `POST /api/sessions/:id/git-fetch` is the ONLY git command the Git PANEL runs that WRITES (cloning and worktree management write too, but only when the user asked for them at that moment), and it is the only one that can run later by itself, on a timer, and the only one that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the automatic maintenance a fetch triggers, config commands such as `credential.helper`), which is why it is opt-in and never runs on a timer until the user turns it on. ⚠️ Fetch updates remote-tracking refs only; it must never commit, check out, merge or touch the working tree. ⚠️ It is not as harmless as a read: a fetch holds git's ref lock, so an agent's own `git pull` in the same clone can hit the same `cannot lock ref`, hence one folder is fetched by at most one run at a time (`fetchWorkspaceOnce` joins a run already going), and `FETCH_CONFIG_ARGS` (`-c core.fsmonitor=false -c maintenance.auto=false -c gc.auto=0`) keeps an unattended fetch from starting a gc in a repository the agent is using. ⚠️ `buildFetchArgs()` is `['fetch', '--quiet']` and must stay verb-invariant (`test/git-fetch.test.ts`); those `-c` overrides are added by the runner, not by `buildFetchArgs()`. ⚠️ The git timeout must reach the whole process TREE, not just the `git` parent: `execFile` signals the parent only, so `runFetch` spawns `detached` and signals the negative pid (SIGTERM, then SIGKILL `FETCH_KILL_ESCALATION_MS` later), and the caller is answered AT the timeout while the group is still dying: that is the point. `test/git-fetch.test.ts` proves it against a local server that never answers, by group membership rather than parentage. A non-zero exit must still reject the way `execFile` would, or a real failure reads as a clean run with no reason. ⚠️ The same Docker rule as above applies BEFORE any git runs (a repository at or inside a Docker case workspace is refused), and remote sessions answer `unsupported`. ⚠️ Keep it non-interactive (`gitNonInteractiveEnv()`: `GIT_TERMINAL_PROMPT=0` + BatchMode ssh) or a fetch hangs on an invisible login prompt, but that BatchMode ssh is a DEFAULT, never an override: `fetchEnvFor` must not overwrite the repository's `core.sshCommand`, the user's own `GIT_SSH_COMMAND` or a `GIT_SSH` (`readRepoSshCommand` reads it inside the same limiter slot and the same wall-clock bound as the fetch it serves, and the spawn gets only what the read left), and `StrictHostKeyChecking=accept-new` writes an unknown host key into `~/.ssh/known_hosts` without asking: BatchMode stops a prompt, not a write. Keep it bounded: `MAX_REPOS`/`MAX_REPOS_LIMIT` (12/50), `MAX_FETCH_OUTPUT_BYTES` (2 MB), the git timeout per spawn, and `FETCH_CONCURRENCY` (4) as ONE `Limiter` shared by the fetches of the whole tick: `createLimiter` keeps its count in the closure, so a limiter that delegated to `mapLimited` per call bounds each call, and 4 folders × 4 repositories would start 16 git processes. ⚠️ One semaphore cannot bound both the folders and the fetches: a folder holding a slot would then wait for a slot its own fetch needs, so the tick bounds its folders with `mapLimited` and hands the shared bound to every run. ⚠️ Failure text reaching a client is control-stripped and credential-redacted (`sanitizeGitOutput`) and the browser writes it with `textContent`. Scheduler semantics: settings are read fresh each tick (`GIT_FETCH_TICK_INTERVAL_MS`, 15 s), a per-session override wins even while the app-wide switch is off, the interval is timed from the run START, state is keyed by FOLDER (sessions sharing one folder are the same clone, so one run serves them all, the schedule is stamped at selection, and every session on a folder is remembered, a folder is due when ANY session open on it is due, so the order the sessions were read cannot decide which interval wins (both orders are tested), and the folder is probed once for the whole group rather than once per session on it: closing the one that holds the override must not stop the folder's fetching while another session is still open on it), a tick is skipped while the previous one is still going, and a user-chosen folder is probed BOUNDED with the default options before anything runs (rule #516: `unknown` is never read as `absent`). ⚠️ The two settings (`gitFetchEnabled`, `gitFetchIntervalSeconds`, off/300 by default, clamped 30–3600) are **synced**, not per-device: they must stay out of `displayKeys` and out of the strip destructure in settings-ui.js, or the scheduler reads nobody's value. Per-session state rides `SessionUpdated` (no new SSE event) as `gitFetch`, and the click route publishes the run's trigger, not the request's: `trigger: 'click'` only when the click was the run, because a click that joined a tick already going fetched nothing and cannot report itselLine truncated
|
||||
@@ -319,7 +321,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/`
|
||||
|
||||
## State Files
|
||||
|
||||
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `edit-history/<id>.json` (Edit history panel, one store per session, every diff an agent wrote, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `discord.json` (Discord bridge: bot token, a bearer secret, so mode 0600, never returned by `/api/discord`, admin-only in multi-user mode) + `discord-bridge.json` (sessionId to Discord thread id), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) `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`).
|
||||
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`).
|
||||
|
||||
**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 +386,6 @@ Two constraints worth knowing before you touch them: the env-derived PTY buffer
|
||||
|
||||
## Scripts & Tunnel
|
||||
|
||||
**`install.sh`** (repo root) is the public `curl | bash` installer: it installs Node/tmux/git/build tools, clones to `~/.codeman/app`, builds, and offers a systemd/launchd service, asking every question BEFORE the unattended build (log in `~/.codeman/install.log`). Network access is Tailscale / LAN / local-only; re-runs preserve the existing binding AND password (`read_existing_binding()`). ⚠️ Compose the hand-start env only in `start_command_hint`/`export_bind_env`, and call `stop_background_helpers` before `exec`. ⚠️ Tailscale rename is opt-in (default NO, never under `--yes`/non-interactive); NEVER `tailscale serve reset`, touch a mapping it did not create, run `tailscale funnel` or advertise a Service (`test/install-sh-invariants.test.ts`). ⚠️ **No-admin route** (`--no-admin` / `CODEMAN_NO_ADMIN`, taken by itself when the account has no administrator rights): Node.js (nodejs.org, checked against `SHASUMS256.txt`) and tmux (`tmux/tmux-builds`, digests pinned in `tmux_build_sha256`: bump `TMUX_BUILD_VERSION` and all four together) go into `~/.codeman/runtime`, and without a working git the source is an archive that updates by a re-run (built beside the old tree, swapped in after the build). Nothing in that section may run `sudo`, Homebrew or `xcode-select --install`, a download is verified before it is unpacked, the route is never decided by `sudo -v`, `/usr/bin/git` on a Mac is never executed before `xcode-select -p` proves the developer tools, and `no_admin_blocker` stops BEFORE the first download for what it cannot supply (a Linux toolchain, macOS below 15). ⚠️ Stay **bash 3.2** clean (no `declare -A`, `mapfile`, namerefs, `${x,,}`, here-strings, empty-array expansion under `set -u`). ⚠️ Execute only commands from the generated CLI block (`CLI_INSTALL_CMD_TRUSTED`, `npm run generate:cli-catalog`). → [architecture-invariants#installsh-the-public-installer](docs/architecture-invariants.md#installsh-the-public-installer)
|
||||
**`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)
|
||||
|
||||
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,7 +69,6 @@ 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,14 +1297,6 @@ 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.
|
||||
|
||||
⚠️ **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
|
||||
|
||||
@@ -39,6 +39,19 @@ name.
|
||||
|
||||
**Open** on a worktree's row starts another session in it, with the CLI chosen above.
|
||||
|
||||
## When an agent moves into a worktree
|
||||
|
||||
Claude Code can move a running session into a worktree part way through (its EnterWorktree tool),
|
||||
and the session's tab stays on the case. While the session's pane is in a worktree inside the
|
||||
session's folder, the **Files** panel lists that worktree, so you see the files the agent is
|
||||
editing. A strip under the panel's title names the worktree, and its **Session folder** button
|
||||
switches to the session's own folder (**Back to worktree** returns). The panel follows the agent
|
||||
in and back out on its own while it is open.
|
||||
|
||||
A worktree outside the session's folder (`git worktree add ../feature`) is not followed: Codeman
|
||||
only serves files inside the session's folder. Open such a worktree as a case of its own with
|
||||
Add Case > Link Existing.
|
||||
|
||||
## Removing one
|
||||
|
||||
Two ways, and both use `git worktree remove` **without** `--force`:
|
||||
|
||||
@@ -102,49 +102,6 @@ installs Tailscale itself non-interactively; a non-interactive run never renames
|
||||
machine and never starts a service. Everything the unattended steps print goes to
|
||||
`~/.codeman/install.log`, and the last lines of it are shown when a step fails.
|
||||
|
||||
### Without administrator rights
|
||||
|
||||
Codeman itself always lived in your home directory. What used `sudo` (or Homebrew on a
|
||||
Mac) were the things it needs first. On an account that has no administrator rights the
|
||||
installer says so in its summary (`Admin not used (...)`) and gets those another way:
|
||||
|
||||
| Needed | With administrator rights | Without |
|
||||
| ----------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Node.js 22 | system package or Homebrew | The official tarball from nodejs.org, checked against the `SHASUMS256.txt` published beside it, unpacked to `~/.codeman/runtime/node`. |
|
||||
| tmux | system package or Homebrew | The tmux project's own build ([tmux-builds](https://github.com/tmux/tmux-builds)), checked against a SHA-256 pinned in the installer, in `~/.codeman/runtime/bin`. |
|
||||
| git | system package or Xcode tools | Not installed. Without a working git, the source is downloaded as an archive instead of cloned. |
|
||||
| Build toolchain (Linux) | system package | Cannot be supplied. See below. |
|
||||
|
||||
This route is taken by itself when `sudo` is not installed, when the account is outside the
|
||||
`sudo`, `wheel` and `admin` groups and `sudo` will not run without a password, when the
|
||||
`sudo` password is refused, and on a Mac for an account that is not an administrator.
|
||||
`--no-admin` (or `CODEMAN_NO_ADMIN=1`) forces it on an account that does have the rights;
|
||||
`CODEMAN_NO_ADMIN=0` switches the detection off. Nothing on this route runs `sudo`,
|
||||
Homebrew or the Xcode tools installer.
|
||||
|
||||
What to know about it:
|
||||
|
||||
- **Linux needs make, a C++ compiler and python3 to be there already.** node-pty compiles
|
||||
from source on Linux, and a compiler is a system package. The installer checks this
|
||||
before it downloads anything and names what is missing. macOS needs no compiler.
|
||||
- **The tmux download needs macOS 15 or newer** (and x86_64 or arm64 on either system).
|
||||
On an older Mac an administrator has to install tmux once (`brew install tmux`); the
|
||||
rest then works without one.
|
||||
- **An install without git updates by re-running the install line**, not from App
|
||||
Settings. The re-run builds the new version beside the old one and swaps it in only
|
||||
after the build succeeded, so a failed download or build leaves the install as it was.
|
||||
Once the machine has a working git, the next re-run turns the install into a normal
|
||||
clone and in-app updates work again. On a Mac, "a working git" means the Xcode Command
|
||||
Line Tools are installed: without them `/usr/bin/git` only opens Apple's install dialog,
|
||||
and the installer never runs it.
|
||||
- **Tailscale works where it is already installed and your account may configure it.** On
|
||||
Linux, installing it, logging in and granting serve rights each need an administrator
|
||||
(`sudo tailscale set --operator=$USER`, once). Local network and this-machine access
|
||||
need nothing, and `install.sh cloudflared` puts `cloudflared` in `~/.local/bin`.
|
||||
- `node`, `npm`, `npx` and `tmux` are linked from `~/.local/bin`, and
|
||||
`~/.codeman/runtime/node/bin` is added to your PATH, so a CLI installed with
|
||||
`npm install -g` is found as well.
|
||||
|
||||
## Route B: npm
|
||||
|
||||
```bash
|
||||
@@ -237,7 +194,6 @@ 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. |
|
||||
@@ -261,7 +217,6 @@ 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. |
|
||||
@@ -277,9 +232,7 @@ install.sh uninstall # installer route
|
||||
npm uninstall -g aicodeman # npm route
|
||||
```
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -6,8 +6,9 @@ until you get home.
|
||||
|
||||
## The File Viewer
|
||||
|
||||
A panel that browses the active session's working directory. Its header button is on by
|
||||
default; if it is missing, re-enable it in **App Settings → Header & Panels**.
|
||||
A panel that browses the active session's working directory, or the git worktree its agent
|
||||
moved into ([Git Worktrees](Git-Worktrees#when-an-agent-moves-into-a-worktree)). Its header
|
||||
button is on by default; if it is missing, re-enable it in **App Settings → Header & Panels**.
|
||||
|
||||
It renders what it can:
|
||||
|
||||
|
||||
+37
-821
File diff suppressed because it is too large.
Load diff
@@ -218,11 +218,6 @@ 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;
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @fileoverview Which linked git worktree a session's pane is in.
|
||||
*
|
||||
* Claude Code's worktree feature moves the running `claude` process into
|
||||
* `<repo>/.claude/worktrees/<name>` part way through a session. `Session.workingDir` is the
|
||||
* session's identity and stays on the main checkout, so the Files panel kept listing a tree the
|
||||
* agent had left (#765). `Session.paneCwd` does follow: tmux reads the process cwd, measured with
|
||||
* Claude Code 2.1.318 going into a worktree on EnterWorktree and back on ExitWorktree. It also
|
||||
* follows every `cd` in a shell session, so it cannot be the panel's root by itself. This module
|
||||
* answers the narrower question: is the pane inside a linked worktree, and which one.
|
||||
*
|
||||
* A linked worktree is recognised by its `.git` FILE (`gitdir: <repo>/.git/worktrees/<id>`) rather
|
||||
* than by running `git worktree list`: this runs from the pane watcher whenever a directory
|
||||
* changes, and a file read starts no process, no repository config and no filter.
|
||||
*
|
||||
* Only a folder at or below `workingDir` is ever an answer, and that is the whole security
|
||||
* argument: the file routes already serve everything under `workingDir`, so following a pane
|
||||
* there widens nothing, and a planted `.git` file can only move where the listing STARTS inside
|
||||
* that same folder. A worktree outside it (`git worktree add ../feature`) stays unfollowed, since
|
||||
* listing it would need a second confinement root. Nothing here is a confinement decision: the
|
||||
* routes keep judging every path against `workingDir`.
|
||||
*
|
||||
* Probes are bounded (#516). The folder can sit on a mount that stopped answering, and an
|
||||
* `unknown` answer stops the walk rather than being read as "no `.git` here".
|
||||
*
|
||||
* @module live-worktree
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import { probePathKind } from './utils/bounded-path-probe.js';
|
||||
|
||||
/** A linked worktree a session's pane is in. */
|
||||
export interface LiveWorktree {
|
||||
/** The worktree folder's name, for display. */
|
||||
name: string;
|
||||
/** Absolute path, spelled like the session's `workingDir`. */
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** More than any real `.git` file. It is read only to recognise the first line. */
|
||||
const MAX_DOT_GIT_FILE_BYTES = 4096;
|
||||
|
||||
/**
|
||||
* Whether the text of a `.git` file names a linked worktree's git dir
|
||||
* (`<common dir>/worktrees/<id>`), as opposed to a submodule's (`<common dir>/modules/<name>`).
|
||||
* The path may be relative: `git worktree add --relative-paths` writes it that way.
|
||||
*/
|
||||
export function isLinkedWorktreeGitFile(text: string): boolean {
|
||||
const gitDir = /^gitdir:[ \t]*(.+?)[ \t]*$/m.exec(text)?.[1];
|
||||
if (!gitDir) return false;
|
||||
const segments = gitDir.split('/').filter(Boolean);
|
||||
return segments.length >= 2 && segments[segments.length - 2] === 'worktrees';
|
||||
}
|
||||
|
||||
/** What `<dir>/.git` says: a linked worktree's root, a boundary the walk must stop at, or nothing. */
|
||||
async function classifyDotGit(dir: string): Promise<'worktree' | 'stop' | 'none'> {
|
||||
const dotGit = join(dir, '.git');
|
||||
const kind = await probePathKind(dotGit);
|
||||
if (kind === 'absent') return 'none';
|
||||
// A `.git` directory is a repository of its own, and `unknown` is never read as absent.
|
||||
if (kind !== 'file') return 'stop';
|
||||
let handle: fs.FileHandle | undefined;
|
||||
try {
|
||||
// lstat, so a `.git` that is a symlink or a FIFO is refused before anything opens it:
|
||||
// opening a FIFO waits for a writer and would hold a threadpool worker for good.
|
||||
if (!(await fs.lstat(dotGit)).isFile()) return 'stop';
|
||||
handle = await fs.open(dotGit, 'r');
|
||||
const { buffer, bytesRead } = await handle.read(Buffer.alloc(MAX_DOT_GIT_FILE_BYTES), 0, MAX_DOT_GIT_FILE_BYTES, 0);
|
||||
return isLinkedWorktreeGitFile(buffer.toString('utf8', 0, bytesRead)) ? 'worktree' : 'stop';
|
||||
} catch {
|
||||
return 'stop';
|
||||
} finally {
|
||||
await handle?.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The linked worktree strictly below `workingDir` that holds `cwd`, if any. Walks up from `cwd`
|
||||
* and stops at `workingDir`, at a repository or submodule of its own, or at anything it cannot
|
||||
* read. `workingDir` itself is never an answer: a session started in a worktree is already
|
||||
* listing it. Never throws.
|
||||
*/
|
||||
export async function findLiveWorktree(workingDir: string, cwd: string | undefined): Promise<LiveWorktree | undefined> {
|
||||
if (!cwd?.startsWith('/')) return undefined;
|
||||
const root = workingDir.length > 1 ? workingDir.replace(/\/+$/, '') : workingDir;
|
||||
const under = root === '/' ? '/' : `${root}/`;
|
||||
for (let dir = cwd; dir !== root && dir.startsWith(under); dir = dirname(dir)) {
|
||||
const found = await classifyDotGit(dir);
|
||||
if (found === 'worktree') return { name: basename(dir), path: dir };
|
||||
if (found === 'stop') return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -73,6 +73,7 @@ import type { GitFetchStatus } from './git-fetch.js';
|
||||
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
|
||||
import { claudeTranscriptExists } from './utils/claude-transcript.js';
|
||||
import { probePathKind } from './utils/bounded-path-probe.js';
|
||||
import type { LiveWorktree } from './live-worktree.js';
|
||||
import { PATH_PROBE_TIMEOUT_MS, WORKSPACE_REALPATH_RETRY_MS } from './config/path-probe.js';
|
||||
import { matchesPattern } from './config/cli-registry/patterns.js';
|
||||
import { probeDockerCliVersion } from './docker-hosts.js';
|
||||
@@ -766,6 +767,8 @@ export class Session extends EventEmitter {
|
||||
* serialized by toState() but never restored by the constructor.
|
||||
*/
|
||||
private _paneCwd: string | undefined;
|
||||
/** See {@link liveWorktree}. */
|
||||
private _liveWorktree: LiveWorktree | undefined;
|
||||
/**
|
||||
* `workingDir` with symlinks resolved, so a pane reading (tmux reads the
|
||||
* process cwd, always the resolved path) can be put back into `workingDir`'s
|
||||
@@ -1576,6 +1579,22 @@ export class Session extends EventEmitter {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The linked git worktree inside `workingDir` that {@link paneCwd} is in, when there is one
|
||||
* (`live-worktree.ts`). The Files panel lists it instead of the main checkout. Runtime-only and
|
||||
* never a confinement root, like `paneCwd`: it is always a folder the file routes already serve.
|
||||
*/
|
||||
get liveWorktree(): LiveWorktree | undefined {
|
||||
return this._liveWorktree;
|
||||
}
|
||||
|
||||
/** Record the result of a worktree lookup. Returns whether it CHANGED, so the caller broadcasts only then. */
|
||||
setLiveWorktree(next: LiveWorktree | undefined): boolean {
|
||||
if (next?.path === this._liveWorktree?.path) return false;
|
||||
this._liveWorktree = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** `workingDir` without a trailing separator (the root stays `/`). */
|
||||
private _workspaceSpelling(): string {
|
||||
return this.workingDir.length > 1 ? this.workingDir.replace(/\/+$/, '') : this.workingDir;
|
||||
@@ -2300,6 +2319,7 @@ export class Session extends EventEmitter {
|
||||
status: this._status,
|
||||
workingDir: this.workingDir,
|
||||
paneCwd: this._paneCwd,
|
||||
liveWorktree: this._liveWorktree,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
owner: this._owner,
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
import type { RespawnConfig } from './respawn.js';
|
||||
import type { AttachmentDetectedType } from './tools.js';
|
||||
import type { GitFetchStatus } from '../git-fetch.js';
|
||||
import type { LiveWorktree } from '../live-worktree.js';
|
||||
|
||||
/** Status of a Claude session */
|
||||
export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
|
||||
@@ -847,6 +848,15 @@ export interface SessionState {
|
||||
* routes like any other out-of-workspace path.
|
||||
*/
|
||||
paneCwd?: string;
|
||||
/**
|
||||
* The linked git worktree inside `workingDir` that the pane is in NOW, when there is one:
|
||||
* Claude Code's `.claude/worktrees/<name>`, or any other worktree of the repository under
|
||||
* the session's folder. Derived from {@link paneCwd} by `live-worktree.ts`, so it is
|
||||
* runtime-only and absent wherever `paneCwd` is, and absent for a worktree outside
|
||||
* `workingDir`: the file routes never serve those. NOT a confinement root; the Files panel
|
||||
* only starts its listing here.
|
||||
*/
|
||||
liveWorktree?: LiveWorktree;
|
||||
/** Remote execution metadata, present when this session runs over SSH through local tmux */
|
||||
remote?: SessionRemote;
|
||||
/** Docker execution metadata, present when this session runs inside a container via local tmux + docker exec */
|
||||
|
||||
+1
-4
@@ -31,11 +31,8 @@ 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' | 'archive' | 'unknown';
|
||||
export type InstallKind = 'git' | 'docker-compose' | 'npm' | 'unknown';
|
||||
|
||||
/**
|
||||
* Why an in-place container update is refused. Each is derived mechanically from
|
||||
|
||||
@@ -2458,6 +2458,8 @@ class CodemanApp {
|
||||
}
|
||||
// cliMouseTracking (Claude's fullscreen mode) ends a keyboard row hold.
|
||||
if (session.id === this.activeSessionId) this._reconcileKeyboardHold?.();
|
||||
// The agent moved into or out of a git worktree while the Files panel is open (#765).
|
||||
if (session.id === this.activeSessionId) this._followFileBrowserWorktree?.(session);
|
||||
// Update parentSessionName for any subagents belonging to this session
|
||||
// (fixes stale name display after session rename)
|
||||
this.updateSubagentParentNames(session.id);
|
||||
|
||||
@@ -1133,6 +1133,9 @@
|
||||
'Filter by date range': '按日期范围筛选',
|
||||
'Filter by session status': '按会话状态筛选',
|
||||
'Filter files...': '筛选文件…',
|
||||
Worktree: '工作树',
|
||||
'Session folder': '会话文件夹',
|
||||
'Back to worktree': '返回工作树',
|
||||
'Any status': '任意状态',
|
||||
'Any time': '任意时间',
|
||||
'Last hour': '最近一小时',
|
||||
|
||||
@@ -617,6 +617,11 @@
|
||||
<button class="btn-icon-sm" onclick="app.closeFileBrowserPanel()" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-browser-root" id="fileBrowserRoot" hidden>
|
||||
<span class="file-browser-root-label" id="fileBrowserRootLabel"></span>
|
||||
<span class="file-browser-root-name" id="fileBrowserRootName" data-i18n-skip></span>
|
||||
<button type="button" class="file-browser-root-switch" id="fileBrowserRootSwitch" onclick="app.toggleFileBrowserRoot()"></button>
|
||||
</div>
|
||||
<div class="file-browser-search">
|
||||
<input type="text" id="fileBrowserSearch" placeholder="Filter files..." oninput="app.filterFileBrowser(this.value)">
|
||||
</div>
|
||||
|
||||
@@ -3282,6 +3282,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
matches: [],
|
||||
deferredDirectoryTarget: null,
|
||||
filter: typeof this.fileBrowserFilter === 'string' ? this.fileBrowserFilter : '',
|
||||
// The person asked for the session folder while the agent is in a worktree (#765).
|
||||
sessionRootPinned: false,
|
||||
};
|
||||
}
|
||||
return this._fileBrowserState;
|
||||
@@ -3304,6 +3306,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
state.deferredDirectoryTarget = null;
|
||||
state.filter = '';
|
||||
state.view = 'normal';
|
||||
state.sessionRootPinned = false;
|
||||
this.fileBrowserData = null;
|
||||
this.fileBrowserFilter = '';
|
||||
this.fileBrowserExpandedDirs?.clear?.();
|
||||
@@ -3314,6 +3317,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._syncFileBrowserExpandBtn();
|
||||
const expandBtn = this.$?.('fileBrowserExpandBtn');
|
||||
if (expandBtn) expandBtn.innerHTML = '\u229E';
|
||||
this._renderFileBrowserRoot(null);
|
||||
|
||||
const panel = this.$?.('fileBrowserPanel');
|
||||
const treeEl = this.$?.('fileBrowserTree');
|
||||
@@ -3346,6 +3350,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
state.deferredDirectoryTarget = null;
|
||||
state.filter = '';
|
||||
state.view = 'normal';
|
||||
state.sessionRootPinned = false;
|
||||
this.fileBrowserData = null;
|
||||
this.fileBrowserFilter = '';
|
||||
this.fileBrowserExpandedDirs?.clear?.();
|
||||
@@ -3356,6 +3361,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._syncFileBrowserExpandBtn();
|
||||
const expandBtn = this.$?.('fileBrowserExpandBtn');
|
||||
if (expandBtn) expandBtn.innerHTML = '\u229E';
|
||||
this._renderFileBrowserRoot(null);
|
||||
const treeEl = this.$?.('fileBrowserTree');
|
||||
if (treeEl) treeEl.innerHTML = '';
|
||||
const statusEl = this.$?.('fileBrowserStatus');
|
||||
@@ -3448,6 +3454,60 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._syncFileBrowserExpandBtn();
|
||||
this.renderFileBrowserTree(normalState.sessionId);
|
||||
this._renderFileBrowserNormalStatus(normalState.data, normalState.showHidden);
|
||||
this._renderFileBrowserRoot(normalState.data);
|
||||
},
|
||||
|
||||
// The strip under the panel header, present only while the session's pane is in a linked
|
||||
// worktree (#765): it says which of the two folders is listed and switches between them.
|
||||
// Everything here comes from GET .../files, so the strip describes the tree on screen rather
|
||||
// than the session's state a moment later.
|
||||
_renderFileBrowserRoot(data) {
|
||||
const strip = this.$?.('fileBrowserRoot');
|
||||
if (!strip) return;
|
||||
const worktree = data?.worktree;
|
||||
if (typeof worktree?.path !== 'string' || typeof worktree?.name !== 'string') {
|
||||
strip.hidden = true;
|
||||
return;
|
||||
}
|
||||
const inWorktree = data.root === worktree.path;
|
||||
const label = this.$?.('fileBrowserRootLabel');
|
||||
const name = this.$?.('fileBrowserRootName');
|
||||
const button = this.$?.('fileBrowserRootSwitch');
|
||||
if (label) label.textContent = inWorktree ? 'Worktree' : 'Session folder';
|
||||
if (name) name.textContent = inWorktree ? worktree.name : '';
|
||||
if (button) button.textContent = inWorktree ? 'Session folder' : 'Back to worktree';
|
||||
strip.title = typeof data.root === 'string' ? data.root : '';
|
||||
strip.hidden = false;
|
||||
},
|
||||
|
||||
// The `root` parameter of a listing or search. The server ignores it while the pane is not in
|
||||
// a worktree, so the panel always asks unless the person chose the session folder.
|
||||
_fileBrowserRootParam() {
|
||||
return this._ensureFileBrowserState().sessionRootPinned ? '' : '&root=live';
|
||||
},
|
||||
|
||||
toggleFileBrowserRoot() {
|
||||
const state = this._ensureFileBrowserState();
|
||||
state.sessionRootPinned = !state.sessionRootPinned;
|
||||
return this._reloadFileBrowserKeepingQuery();
|
||||
},
|
||||
|
||||
// Called on every session update of the active session. Compares with what the listing on
|
||||
// screen reported rather than with the previous update, so a load already in flight when the
|
||||
// pane moved is corrected by the next update, and an answer that matches ends it: a listing
|
||||
// always reports the session's worktree, whichever folder it walked, so this cannot loop.
|
||||
_followFileBrowserWorktree(session) {
|
||||
const state = this._fileBrowserState;
|
||||
if (!state || state.ownerSessionId !== session.id) return;
|
||||
if (this.$?.('fileBrowserPanel')?.classList.contains('visible') !== true) return;
|
||||
const normal = state.normalState;
|
||||
if (normal?.phase !== 'ready' || normal.sessionId !== session.id) return;
|
||||
const live = session.liveWorktree?.path ?? null;
|
||||
const listed = normal.data?.worktree?.path ?? null;
|
||||
if (live === listed) return;
|
||||
// The pin was about the worktree the agent has just left.
|
||||
state.sessionRootPinned = false;
|
||||
this._reloadFileBrowserKeepingQuery();
|
||||
},
|
||||
|
||||
_validateFileBrowserTreeEnvelope(result) {
|
||||
@@ -3552,14 +3612,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
async toggleFileBrowserHidden() {
|
||||
const state = this._ensureFileBrowserState();
|
||||
const rawInput = typeof state.filter === 'string' ? state.filter : '';
|
||||
const query = rawInput.trim();
|
||||
this.fileBrowserShowHidden = !this.fileBrowserShowHidden;
|
||||
try {
|
||||
localStorage.setItem(FILE_BROWSER_SHOW_HIDDEN_KEY, this.fileBrowserShowHidden ? '1' : '0');
|
||||
} catch {}
|
||||
this._syncFileBrowserHiddenBtn();
|
||||
return this._reloadFileBrowserKeepingQuery();
|
||||
},
|
||||
|
||||
// Re-fetch the tree after something the server filters on changed (hidden files, the listing's
|
||||
// root), then re-run the search in the box against the new tree.
|
||||
async _reloadFileBrowserKeepingQuery() {
|
||||
const state = this._ensureFileBrowserState();
|
||||
const rawInput = typeof state.filter === 'string' ? state.filter : '';
|
||||
const query = rawInput.trim();
|
||||
|
||||
if (state.inFlight?.timer !== undefined && state.inFlight?.timer !== null) {
|
||||
clearTimeout(state.inFlight.timer);
|
||||
@@ -3633,7 +3699,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const request = (async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/sessions/${encodeURIComponent(sessionId)}/files?depth=5&showHidden=${showHidden}`,
|
||||
`/api/sessions/${encodeURIComponent(sessionId)}/files?depth=5&showHidden=${showHidden}${this._fileBrowserRootParam()}`,
|
||||
);
|
||||
if (!res.ok) throw new Error('Failed to load files');
|
||||
const result = await res.json();
|
||||
@@ -3820,6 +3886,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showHidden: this.fileBrowserShowHidden === true,
|
||||
rawInput,
|
||||
query,
|
||||
rootParam: this._fileBrowserRootParam(),
|
||||
timer: null,
|
||||
};
|
||||
state.view = 'search-pending';
|
||||
@@ -3834,7 +3901,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
request.timer = null;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/sessions/${encodeURIComponent(ownerSessionId)}/files?depth=5&showHidden=${request.showHidden}&q=${encodeURIComponent(query)}`,
|
||||
`/api/sessions/${encodeURIComponent(ownerSessionId)}/files?depth=5&showHidden=${request.showHidden}&q=${encodeURIComponent(query)}${request.rootParam}`,
|
||||
);
|
||||
if (!res.ok) throw new Error('Search failed');
|
||||
const result = await res.json();
|
||||
|
||||
@@ -1940,14 +1940,10 @@ 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>.'
|
||||
: 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>.';
|
||||
: 'Update with <code>npm i -g aicodeman@latest</code>.';
|
||||
this._setUpdateResult(`This install can't update itself (${escapeHtml(data.installKind)}). ${hint}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -12207,6 +12207,45 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.file-browser-root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.file-browser-root[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-browser-root-name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.file-browser-root-switch {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-browser-root-switch:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--text-dim);
|
||||
}
|
||||
|
||||
.file-browser-search {
|
||||
padding: 0.4rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
||||
@@ -1595,12 +1595,27 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// File tree listing
|
||||
app.get('/api/sessions/:id/files', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { depth, showHidden, q } = req.query as { depth?: string; showHidden?: string; q?: string };
|
||||
const { depth, showHidden, q, root } = req.query as {
|
||||
depth?: string;
|
||||
showHidden?: string;
|
||||
q?: string;
|
||||
root?: string;
|
||||
};
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
const maxDepth = Math.min(parseInt(depth || '5', 10), 10);
|
||||
const includeHidden = showHidden === 'true';
|
||||
const workingDir = session.workingDir;
|
||||
// `root=live` starts the walk at the linked worktree the pane is in (#765). That folder is
|
||||
// inside `workingDir` (`live-worktree.ts`), and every `path` below stays relative to
|
||||
// `workingDir`, so file-content, file-raw and the edit route need nothing new and judge these
|
||||
// paths against the same root as any other. Only the search predicate reads a path relative
|
||||
// to the listing, since that is the tree the person is looking at.
|
||||
const liveWorktree = session.liveWorktree;
|
||||
const walkRoot =
|
||||
root === 'live' && liveWorktree && (await probePathKind(liveWorktree.path, { pastCap: true })) === 'directory'
|
||||
? liveWorktree.path
|
||||
: workingDir;
|
||||
// null for an empty/whitespace query, which is what keeps the default
|
||||
// tree response byte-identical when no search is requested.
|
||||
const matcher = compileFileQuery(q ?? '');
|
||||
@@ -1676,17 +1691,18 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
|
||||
const fullPath = join(dirPath, entry.name);
|
||||
const relativePath = relative(workingDir, fullPath);
|
||||
const listedPath = relative(walkRoot, fullPath);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
totalDirectories++;
|
||||
if (matcher(entry.name, relativePath)) {
|
||||
if (matcher(entry.name, listedPath)) {
|
||||
matches.push({ name: entry.name, path: relativePath, type: 'directory' });
|
||||
}
|
||||
// Always recurse, even when this directory does not match.
|
||||
await searchDirectory(fullPath, currentDepth + 1);
|
||||
} else {
|
||||
totalFiles++;
|
||||
if (matcher(entry.name, relativePath)) {
|
||||
if (matcher(entry.name, listedPath)) {
|
||||
let size: number | undefined;
|
||||
try {
|
||||
size = (await fs.stat(fullPath)).size;
|
||||
@@ -1705,12 +1721,13 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
}
|
||||
};
|
||||
|
||||
await searchDirectory(workingDir, 1);
|
||||
await searchDirectory(walkRoot, 1);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
root: workingDir,
|
||||
root: walkRoot,
|
||||
...(liveWorktree && { worktree: liveWorktree }),
|
||||
tree: [],
|
||||
matches,
|
||||
totalFiles,
|
||||
@@ -1791,12 +1808,13 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
}
|
||||
};
|
||||
|
||||
const tree = await scanDirectory(workingDir, 1);
|
||||
const tree = await scanDirectory(walkRoot, 1);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
root: workingDir,
|
||||
root: walkRoot,
|
||||
...(liveWorktree && { worktree: liveWorktree }),
|
||||
tree,
|
||||
totalFiles,
|
||||
totalDirectories,
|
||||
|
||||
+3
-21
@@ -462,26 +462,12 @@ export function isRunningInContainer(): boolean {
|
||||
return process.env.CODEMAN_IN_CONTAINER === '1' || existsSync('/.dockerenv');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
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';
|
||||
@@ -719,9 +705,7 @@ 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.'
|
||||
: info.installKind === 'archive'
|
||||
? ARCHIVE_UPDATE_HINT
|
||||
: 'Not a git install — self-update is unavailable.',
|
||||
: 'Not a git install — self-update is unavailable.',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -845,9 +829,7 @@ 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.'
|
||||
: info.installKind === 'archive'
|
||||
? ARCHIVE_UPDATE_HINT
|
||||
: 'This is not a git install. Update with: npm i -g aicodeman@latest',
|
||||
: 'This is not a git install. Update with: npm i -g aicodeman@latest',
|
||||
};
|
||||
}
|
||||
const existing = readCurrentUpdateStatus();
|
||||
|
||||
@@ -35,6 +35,7 @@ import fastifyWebsocket from '@fastify/websocket';
|
||||
import fastifyMultipart from '@fastify/multipart';
|
||||
import { pasteImageDirInUseByOtherSession, startPasteImageGc, uploadDirs } from './paste-image-gc.js';
|
||||
import { CLEAN_EXIT_CLOSE_REASON, shouldCloseCleanlyExitedSession } from '../pane-exit-sweep.js';
|
||||
import { findLiveWorktree } from '../live-worktree.js';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { existsSync, mkdirSync, readFileSync, chmodSync, statSync } from 'node:fs';
|
||||
@@ -2967,6 +2968,7 @@ export class WebServer extends EventEmitter {
|
||||
// `undefined` here means an authoritative read listed no single directory
|
||||
// for the session (split into several panes, or gone from the socket).
|
||||
const paneCwdChanged = getPaneCurrentPath ? session.setPaneCwd(getPaneCurrentPath(muxName)) : false;
|
||||
if (paneCwdChanged) void this.refreshLiveWorktree(session);
|
||||
if (session.setPaneExit(getPaneExit(muxName))) {
|
||||
this.persistSessionState(session);
|
||||
} else if (!paneCwdChanged) {
|
||||
@@ -2977,6 +2979,18 @@ export class WebServer extends EventEmitter {
|
||||
this.closeCleanlyExitedSessions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Work out which linked worktree a session's pane moved into (#765). In the background, since
|
||||
* it reads the folder, and only after the directory changed, so an idle pane costs nothing.
|
||||
* A reading newer than the one this lookup started from owns the answer.
|
||||
*/
|
||||
private async refreshLiveWorktree(session: Session): Promise<void> {
|
||||
const cwd = session.paneCwd;
|
||||
const worktree = await findLiveWorktree(session.workingDir, cwd);
|
||||
if (session.paneCwd !== cwd) return;
|
||||
if (session.setLiveWorktree(worktree)) this.broadcastSessionStateDebounced(session.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close every session whose agent exited cleanly, through the same
|
||||
* `cleanupSession()` the X button uses (Ark0N/Codeman#446). A pinned session
|
||||
|
||||
@@ -387,7 +387,7 @@ describe('File Viewer server search', () => {
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=src%20%26%20docs');
|
||||
expect(pending[0].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=src%20%26%20docs&root=live');
|
||||
});
|
||||
|
||||
it('is a safe no-op without a panel or session and can lazily adopt a later active owner', async () => {
|
||||
@@ -399,7 +399,7 @@ describe('File Viewer server search', () => {
|
||||
app.activeSessionId = 'session/B';
|
||||
app.filterFileBrowser('after-session');
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
expect(pending[0].url).toBe('/api/sessions/session%2FB/files?depth=5&showHidden=false&q=after-session');
|
||||
expect(pending[0].url).toBe('/api/sessions/session%2FB/files?depth=5&showHidden=false&q=after-session&root=live');
|
||||
|
||||
pending[0].reply.resolve(response(successfulData()));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
@@ -450,7 +450,7 @@ describe('File Viewer server search', () => {
|
||||
expect(pending).toHaveLength(0);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(pending.map(({ url }) => url)).toEqual([
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&q=second%20query',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&q=second%20query&root=live',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -659,7 +659,7 @@ describe('File Viewer server search', () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0].url.endsWith(`&q=${'x'.repeat(256)}`)).toBe(true);
|
||||
expect(pending[0].url.endsWith(`&q=${'x'.repeat(256)}&root=live`)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects 257 trimmed characters without a request', async () => {
|
||||
@@ -786,7 +786,7 @@ describe('File Viewer server search', () => {
|
||||
expect(elements.fileBrowserTree.innerHTML).toContain('Loading files');
|
||||
expect(elements.fileBrowserTree.innerHTML).not.toContain('old-result.ts');
|
||||
expect(elements.fileBrowserTree.innerHTML).not.toContain('old-preference.ts');
|
||||
expect(pending[1].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true');
|
||||
expect(pending[1].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&root=live');
|
||||
|
||||
pending[1].reply.resolve(response(successfulTree('new-preference.ts')));
|
||||
await toggle;
|
||||
@@ -794,7 +794,7 @@ describe('File Viewer server search', () => {
|
||||
expect(app._fileBrowserState.view).toBe('search-pending');
|
||||
expect(elements.fileBrowserTree.innerHTML).toContain('Searching');
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
expect(pending[2].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&q=widget');
|
||||
expect(pending[2].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&q=widget&root=live');
|
||||
pending[2].reply.resolve(
|
||||
response(
|
||||
successfulData({
|
||||
@@ -877,8 +877,8 @@ describe('File Viewer server search', () => {
|
||||
const firstToggle = app.toggleFileBrowserHidden();
|
||||
const secondToggle = app.toggleFileBrowserHidden();
|
||||
expect(pending.slice(1).map(({ url }) => url)).toEqual([
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=true',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=true&root=live',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live',
|
||||
]);
|
||||
|
||||
pending[1].reply.resolve(response(successfulTree('stale-hidden.ts')));
|
||||
@@ -891,7 +891,7 @@ describe('File Viewer server search', () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
expect(pending).toHaveLength(4);
|
||||
expect(pending[3].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=widget');
|
||||
expect(pending[3].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=widget&root=live');
|
||||
expect(app.fileBrowserShowHidden).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1674,7 +1674,7 @@ describe('File Viewer server search', () => {
|
||||
expect(immediateState.activeSessionId).toBe('session/B');
|
||||
expect(immediateState.ownerSessionId).toBe('session/B');
|
||||
expect(immediateState.resizeCalls).toBe(1);
|
||||
expect(immediateState.fileUrls).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false']);
|
||||
expect(immediateState.fileUrls).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live']);
|
||||
|
||||
expect(filePending).toHaveLength(1);
|
||||
expect(app._fileBrowserState.ownerSessionId).toBe('session/B');
|
||||
@@ -1755,7 +1755,7 @@ describe('File Viewer server search', () => {
|
||||
expect(elements.fileBrowserExpandBtn.innerHTML).toBe('\u229E');
|
||||
expect(elements.fileBrowserTree.querySelectorAll('.file-tree-item')).toHaveLength(0);
|
||||
expect(elements.fileBrowserTree.innerHTML).toContain('Loading files');
|
||||
expect(pending.map(({ url }) => url)).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false']);
|
||||
expect(pending.map(({ url }) => url)).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live']);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
expect(pending).toHaveLength(1);
|
||||
@@ -1797,8 +1797,8 @@ describe('File Viewer server search', () => {
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
expect(pending.map(({ url }) => url)).toEqual([
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&q=during%20load',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&q=during%20load&root=live',
|
||||
]);
|
||||
expect(pending.every(({ url }) => !url.includes('session%2FA'))).toBe(true);
|
||||
});
|
||||
@@ -1815,9 +1815,9 @@ describe('File Viewer server search', () => {
|
||||
|
||||
expect(secondA).not.toBe(firstA);
|
||||
expect(pending.map(({ url }) => url)).toEqual([
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live',
|
||||
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live',
|
||||
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live',
|
||||
]);
|
||||
|
||||
pending[0].reply.resolve(response(successfulTree('stale-A.ts')));
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* @fileoverview Files panel following the agent into a git worktree (#765).
|
||||
*
|
||||
* The server does the walking, so what can go wrong here is the client: asking for the wrong root,
|
||||
* showing a strip that does not describe the tree on screen, or reloading forever. The last one is
|
||||
* why the follow compares with what the listing reported rather than with the previous update.
|
||||
*
|
||||
* Loaded via `vm` with a stubbed context, like file-browser-hidden.test.ts.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
const panelsJs = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8');
|
||||
const appJs = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||
const indexHtml = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
|
||||
const stylesCss = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
|
||||
|
||||
const WORKTREE = { name: 'probe', path: '/repo/.claude/worktrees/probe' };
|
||||
|
||||
function element() {
|
||||
const classes = new Set<string>();
|
||||
return {
|
||||
innerHTML: '',
|
||||
textContent: '',
|
||||
title: '',
|
||||
hidden: true,
|
||||
value: '',
|
||||
classList: {
|
||||
toggle: (name: string, on: boolean) => (on ? classes.add(name) : classes.delete(name)),
|
||||
contains: (name: string) => classes.has(name),
|
||||
},
|
||||
setAttribute: () => {},
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
}
|
||||
|
||||
function loadPanel() {
|
||||
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
console,
|
||||
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
escapeHtml: (s: string) => String(s),
|
||||
document: { getElementById: () => null, addEventListener: vi.fn() },
|
||||
window: { addEventListener: vi.fn() },
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
fetch: undefined as unknown,
|
||||
CodemanBase: { url: (p: string) => p },
|
||||
});
|
||||
vm.runInContext(panelsJs, context, { filename: 'panels-ui.js' });
|
||||
|
||||
const ids = [
|
||||
'fileBrowserPanel',
|
||||
'fileBrowserTree',
|
||||
'fileBrowserStatus',
|
||||
'fileBrowserHiddenBtn',
|
||||
'fileBrowserSearch',
|
||||
'fileBrowserRoot',
|
||||
'fileBrowserRootLabel',
|
||||
'fileBrowserRootName',
|
||||
'fileBrowserRootSwitch',
|
||||
];
|
||||
const elements = Object.fromEntries(ids.map((id) => [id, element()]));
|
||||
elements.fileBrowserPanel.classList.toggle('visible', true);
|
||||
|
||||
const app = new CodemanApp();
|
||||
app.$ = (id: string) => elements[id] ?? null;
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.fileBrowserData = null;
|
||||
app.fileBrowserExpandedDirs = new Set<string>();
|
||||
app.fileBrowserFilter = '';
|
||||
app.fileBrowserShowHidden = false;
|
||||
app.getFileIcon = () => '';
|
||||
app.formatFileSize = () => '';
|
||||
|
||||
// A stand-in for the route: the session's worktree, listed when the request asks for it.
|
||||
const server = { worktree: null as typeof WORKTREE | null, requests: [] as string[] };
|
||||
context.fetch = async (url: string) => {
|
||||
server.requests.push(url);
|
||||
const live = url.includes('root=live') && server.worktree;
|
||||
const data = {
|
||||
root: live ? server.worktree!.path : '/repo',
|
||||
...(server.worktree && { worktree: server.worktree }),
|
||||
tree: [],
|
||||
totalFiles: 0,
|
||||
totalDirectories: 0,
|
||||
truncated: false,
|
||||
...(url.includes('q=') && { mode: 'search', matches: [], matchCount: 0 }),
|
||||
};
|
||||
return { ok: true, json: async () => ({ success: true, data }) };
|
||||
};
|
||||
return { app, elements, server };
|
||||
}
|
||||
|
||||
const strip = (elements: Record<string, ReturnType<typeof element>>) => ({
|
||||
hidden: elements.fileBrowserRoot.hidden,
|
||||
label: elements.fileBrowserRootLabel.textContent,
|
||||
name: elements.fileBrowserRootName.textContent,
|
||||
button: elements.fileBrowserRootSwitch.textContent,
|
||||
title: elements.fileBrowserRoot.title,
|
||||
});
|
||||
|
||||
describe('Files panel in a git worktree', () => {
|
||||
it('asks for the live root by default', async () => {
|
||||
const { app, server } = loadPanel();
|
||||
await app.loadFileBrowser('sess-1');
|
||||
expect(server.requests[0]).toContain('root=live');
|
||||
});
|
||||
|
||||
it('shows no strip for a session in no worktree', async () => {
|
||||
const { app, elements } = loadPanel();
|
||||
await app.loadFileBrowser('sess-1');
|
||||
expect(elements.fileBrowserRoot.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('says which worktree is listed, and offers the session folder', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
server.worktree = WORKTREE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
expect(strip(elements)).toEqual({
|
||||
hidden: false,
|
||||
label: 'Worktree',
|
||||
name: 'probe',
|
||||
button: 'Session folder',
|
||||
title: WORKTREE.path,
|
||||
});
|
||||
});
|
||||
|
||||
it('switches to the session folder and back', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
server.worktree = WORKTREE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
|
||||
await app.toggleFileBrowserRoot();
|
||||
expect(server.requests[1]).not.toContain('root=live');
|
||||
expect(strip(elements)).toEqual({
|
||||
hidden: false,
|
||||
label: 'Session folder',
|
||||
name: '',
|
||||
button: 'Back to worktree',
|
||||
title: '/repo',
|
||||
});
|
||||
|
||||
await app.toggleFileBrowserRoot();
|
||||
expect(server.requests[2]).toContain('root=live');
|
||||
expect(elements.fileBrowserRootName.textContent).toBe('probe');
|
||||
});
|
||||
|
||||
it('keeps the query in the box across the switch, and searches the new root', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
server.worktree = WORKTREE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
app._fileBrowserState.filter = 'main';
|
||||
elements.fileBrowserSearch.value = 'main';
|
||||
|
||||
await app.toggleFileBrowserRoot();
|
||||
await vi.waitFor(() => expect(server.requests.some((url) => url.includes('q=main'))).toBe(true));
|
||||
|
||||
const search = server.requests.find((url) => url.includes('q=main'))!;
|
||||
expect(search).not.toContain('root=live');
|
||||
});
|
||||
|
||||
it('follows the agent into a worktree while the panel is open', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
await app.loadFileBrowser('sess-1');
|
||||
expect(server.requests).toHaveLength(1);
|
||||
|
||||
server.worktree = WORKTREE;
|
||||
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
|
||||
await vi.waitFor(() => expect(server.requests).toHaveLength(2));
|
||||
await vi.waitFor(() => expect(elements.fileBrowserRoot.hidden).toBe(false));
|
||||
expect(elements.fileBrowserRootName.textContent).toBe('probe');
|
||||
});
|
||||
|
||||
it('follows the agent back out, and a pin on the old worktree does not outlive it', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
server.worktree = WORKTREE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
await app.toggleFileBrowserRoot();
|
||||
|
||||
server.worktree = null;
|
||||
app._followFileBrowserWorktree({ id: 'sess-1' });
|
||||
await vi.waitFor(() => expect(elements.fileBrowserRoot.hidden).toBe(true));
|
||||
expect(app._fileBrowserState.sessionRootPinned).toBe(false);
|
||||
});
|
||||
|
||||
it('settles: updates that agree with the listing reload nothing', async () => {
|
||||
const { app, server } = loadPanel();
|
||||
server.worktree = WORKTREE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
|
||||
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
|
||||
expect(server.requests).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('leaves another session and a closed panel alone', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
await app.loadFileBrowser('sess-1');
|
||||
app._followFileBrowserWorktree({ id: 'other', liveWorktree: WORKTREE });
|
||||
elements.fileBrowserPanel.classList.toggle('visible', false);
|
||||
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
|
||||
expect(server.requests).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('drops the pin and the strip on a session switch', async () => {
|
||||
const { app, elements, server } = loadPanel();
|
||||
server.worktree = WORKTREE;
|
||||
await app.loadFileBrowser('sess-1');
|
||||
await app.toggleFileBrowserRoot();
|
||||
|
||||
app._activateFileBrowserSession('sess-2');
|
||||
expect(app._fileBrowserState.sessionRootPinned).toBe(false);
|
||||
expect(elements.fileBrowserRoot.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('is wired up in the page', () => {
|
||||
expect(appJs).toContain('this._followFileBrowserWorktree?.(session)');
|
||||
expect(indexHtml).toContain('id="fileBrowserRoot" hidden');
|
||||
expect(indexHtml).toContain('onclick="app.toggleFileBrowserRoot()"');
|
||||
// The strip sets `display`, so `hidden` needs its own rule or it never hides.
|
||||
expect(stylesCss).toMatch(/\.file-browser-root\[hidden\]\s*\{\s*display: none;/);
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,6 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
@@ -172,26 +171,6 @@ 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,12 +12,9 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { readFileSync } from 'node:fs';
|
||||
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');
|
||||
@@ -539,410 +536,3 @@ 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/"*');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* @fileoverview `findLiveWorktree()` (#765): which linked worktree a session's pane is in.
|
||||
*
|
||||
* The answer moves the Files panel's listing, so what matters is what it must NOT answer: the
|
||||
* session folder itself, a folder outside it, a plain subfolder (a `cd` in a shell must not re-root
|
||||
* the panel), a repository or submodule nested in the folder, and a `.git` that is not a regular
|
||||
* file. Runs against a real filesystem, and one case against a worktree real git made.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { findLiveWorktree, isLinkedWorktreeGitFile } from '../src/live-worktree.js';
|
||||
|
||||
describe('isLinkedWorktreeGitFile', () => {
|
||||
it('accepts what git writes for a linked worktree, absolute or relative', () => {
|
||||
expect(isLinkedWorktreeGitFile('gitdir: /home/me/repo/.git/worktrees/probe\n')).toBe(true);
|
||||
expect(isLinkedWorktreeGitFile('gitdir: ../../../.git/worktrees/probe\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a submodule, a bare path and text that is not a gitdir line', () => {
|
||||
expect(isLinkedWorktreeGitFile('gitdir: ../.git/modules/lib\n')).toBe(false);
|
||||
expect(isLinkedWorktreeGitFile('gitdir: worktrees\n')).toBe(false);
|
||||
expect(isLinkedWorktreeGitFile('/home/me/repo/.git/worktrees/probe\n')).toBe(false);
|
||||
expect(isLinkedWorktreeGitFile('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findLiveWorktree', () => {
|
||||
let base: string;
|
||||
let repo: string;
|
||||
let worktree: string;
|
||||
|
||||
beforeEach(() => {
|
||||
base = mkdtempSync(join(tmpdir(), 'live-worktree-'));
|
||||
repo = join(base, 'repo');
|
||||
worktree = join(repo, '.claude', 'worktrees', 'probe');
|
||||
mkdirSync(join(repo, '.git'), { recursive: true });
|
||||
mkdirSync(join(worktree, 'src', 'deep'), { recursive: true });
|
||||
writeFileSync(join(worktree, '.git'), `gitdir: ${repo}/.git/worktrees/probe\n`);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('finds the worktree from its own root and from below it', async () => {
|
||||
const expected = { name: 'probe', path: worktree };
|
||||
expect(await findLiveWorktree(repo, worktree)).toEqual(expected);
|
||||
expect(await findLiveWorktree(repo, join(worktree, 'src', 'deep'))).toEqual(expected);
|
||||
});
|
||||
|
||||
it('answers the same for a workingDir spelled with a trailing slash', async () => {
|
||||
expect(await findLiveWorktree(`${repo}/`, worktree)).toEqual({ name: 'probe', path: worktree });
|
||||
});
|
||||
|
||||
it('does not treat the session folder as a worktree of itself', async () => {
|
||||
writeFileSync(join(worktree, 'marker'), '');
|
||||
expect(await findLiveWorktree(worktree, worktree)).toBeUndefined();
|
||||
expect(await findLiveWorktree(worktree, join(worktree, 'src'))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stays on the session folder for a plain subfolder, so a shell cd moves nothing', async () => {
|
||||
mkdirSync(join(repo, 'src'));
|
||||
expect(await findLiveWorktree(repo, repo)).toBeUndefined();
|
||||
expect(await findLiveWorktree(repo, join(repo, 'src'))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never answers outside the session folder', async () => {
|
||||
const outside = join(base, 'repo-feature');
|
||||
mkdirSync(outside);
|
||||
writeFileSync(join(outside, '.git'), `gitdir: ${repo}/.git/worktrees/feature\n`);
|
||||
// A sibling sharing the folder's name as a prefix is the case a bare startsWith gets wrong.
|
||||
expect(await findLiveWorktree(repo, outside)).toBeUndefined();
|
||||
expect(await findLiveWorktree(repo, '/')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stops at a submodule and at a repository of its own', async () => {
|
||||
const submodule = join(repo, 'lib');
|
||||
mkdirSync(submodule);
|
||||
writeFileSync(join(submodule, '.git'), 'gitdir: ../.git/modules/lib\n');
|
||||
expect(await findLiveWorktree(repo, submodule)).toBeUndefined();
|
||||
|
||||
const nested = join(worktree, 'vendored');
|
||||
mkdirSync(join(nested, '.git'), { recursive: true });
|
||||
expect(await findLiveWorktree(repo, nested)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses a .git that is not a regular file', async () => {
|
||||
const linked = join(repo, 'linked');
|
||||
mkdirSync(linked);
|
||||
symlinkSync(join(worktree, '.git'), join(linked, '.git'));
|
||||
expect(await findLiveWorktree(repo, linked)).toBeUndefined();
|
||||
|
||||
// A FIFO would block open() for good: the test would time out rather than fail.
|
||||
const piped = join(repo, 'piped');
|
||||
mkdirSync(piped);
|
||||
execFileSync('mkfifo', [join(piped, '.git')]);
|
||||
expect(await findLiveWorktree(repo, piped)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('answers nothing without an absolute reading', async () => {
|
||||
expect(await findLiveWorktree(repo, undefined)).toBeUndefined();
|
||||
expect(await findLiveWorktree(repo, '')).toBeUndefined();
|
||||
expect(await findLiveWorktree(repo, '.claude/worktrees/probe')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('recognises a worktree real git made', async () => {
|
||||
const real = join(base, 'real');
|
||||
const git = (...args: string[]) =>
|
||||
execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@example.com', ...args], {
|
||||
cwd: real,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
mkdirSync(real);
|
||||
git('init', '-q');
|
||||
git('commit', '-q', '--allow-empty', '-m', 'init');
|
||||
git('worktree', 'add', '-q', '.claude/worktrees/made', '-b', 'worktree-made');
|
||||
expect(await findLiveWorktree(real, join(real, '.claude', 'worktrees', 'made'))).toEqual({
|
||||
name: 'made',
|
||||
path: join(real, '.claude', 'worktrees', 'made'),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -29,11 +29,11 @@
|
||||
*
|
||||
* Port: ephemeral
|
||||
*/
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync } from 'node:fs';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Session, rebasePath } from '../src/session.js';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import type { SessionDocker, SessionRemote } from '../src/types.js';
|
||||
@@ -278,6 +278,77 @@ describe('a pane read reaching the session record', () => {
|
||||
expect(session.toState().workingDir).toBe('/work/proj');
|
||||
});
|
||||
|
||||
describe('liveWorktree (#765)', () => {
|
||||
let base: string;
|
||||
let repo: string;
|
||||
let worktree: string;
|
||||
|
||||
beforeEach(() => {
|
||||
base = mkdtempSync(join(tmpdir(), 'pane-live-worktree-'));
|
||||
repo = join(base, 'repo');
|
||||
worktree = join(repo, '.claude', 'worktrees', 'probe');
|
||||
mkdirSync(worktree, { recursive: true });
|
||||
writeFileSync(join(worktree, '.git'), `gitdir: ${repo}/.git/worktrees/probe\n`);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const inRepo = () => {
|
||||
const { mux, sessions, web } = build();
|
||||
const session = localMuxSession({ workingDir: repo });
|
||||
sessions.set(session.id, session);
|
||||
const broadcast = vi.fn();
|
||||
(web as unknown as { broadcastSessionStateDebounced: (id: string) => void }).broadcastSessionStateDebounced =
|
||||
broadcast;
|
||||
mux.getPaneExit = () => undefined;
|
||||
return { mux, session, web, broadcast };
|
||||
};
|
||||
|
||||
it('publishes the worktree the pane moved into, and clears it when the pane leaves', async () => {
|
||||
const { mux, session, web, broadcast } = inRepo();
|
||||
let dir = worktree;
|
||||
mux.getPaneCurrentPath = () => dir;
|
||||
mux.emit('paneExitsUpdated');
|
||||
await vi.waitFor(() => expect(session.liveWorktree).toEqual({ name: 'probe', path: worktree }));
|
||||
// What the browser receives, and what the file routes read: never the confinement root.
|
||||
const state = (
|
||||
web as unknown as { getSessionStateWithRespawn: (s: Session) => { liveWorktree?: unknown } }
|
||||
).getSessionStateWithRespawn(session);
|
||||
expect(state.liveWorktree).toEqual({ name: 'probe', path: worktree });
|
||||
expect(session.toState().workingDir).toBe(repo);
|
||||
|
||||
const broadcasts = broadcast.mock.calls.length;
|
||||
dir = repo;
|
||||
mux.emit('paneExitsUpdated');
|
||||
await vi.waitFor(() => expect(session.liveWorktree).toBeUndefined());
|
||||
expect(broadcast.mock.calls.length).toBeGreaterThan(broadcasts);
|
||||
});
|
||||
|
||||
it('does nothing for a pane that has not moved', async () => {
|
||||
const { mux, session } = inRepo();
|
||||
mux.getPaneCurrentPath = () => repo;
|
||||
const lookup = vi.spyOn(session, 'setLiveWorktree');
|
||||
mux.emit('paneExitsUpdated');
|
||||
await vi.waitFor(() => expect(lookup).toHaveBeenCalledTimes(1));
|
||||
mux.emit('paneExitsUpdated');
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(lookup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('drops an answer for a directory the pane has already left', async () => {
|
||||
const { web, session } = inRepo();
|
||||
session.setPaneCwd(worktree);
|
||||
const refresh = (
|
||||
web as unknown as { refreshLiveWorktree: (s: Session) => Promise<void> }
|
||||
).refreshLiveWorktree.call(web, session);
|
||||
session.setPaneCwd(repo);
|
||||
await refresh;
|
||||
expect(session.liveWorktree).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts a directory change once, and nothing on an unchanged tick', () => {
|
||||
const { mux, sessions } = build();
|
||||
const session = localMuxSession();
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* @fileoverview `GET /api/sessions/:id/files?root=live` (#765).
|
||||
*
|
||||
* The listing starts at the worktree the pane is in, and every `path` stays relative to
|
||||
* `workingDir`: that is what keeps the preview, raw and edit routes working unchanged, inside the
|
||||
* same confinement. Real filesystem on purpose, since the walk is the thing under test.
|
||||
*/
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
|
||||
|
||||
const PREFIX = '.claude/worktrees/probe';
|
||||
|
||||
describe('Files panel listing of a live worktree', () => {
|
||||
let harness: RouteTestHarness;
|
||||
let repo: string;
|
||||
let worktree: string;
|
||||
|
||||
const list = async (query = '') => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/files?depth=5${query}`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
return res.json().data;
|
||||
};
|
||||
const names = (nodes: Array<{ name: string }>) => nodes.map((node) => node.name);
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerFileRoutes);
|
||||
repo = mkdtempSync(join(homedir(), 'live-worktree-route-'));
|
||||
worktree = join(repo, PREFIX);
|
||||
mkdirSync(join(repo, 'src'), { recursive: true });
|
||||
mkdirSync(join(worktree, 'src'), { recursive: true });
|
||||
writeFileSync(join(repo, 'src', 'main-only.ts'), 'main');
|
||||
writeFileSync(join(worktree, 'src', 'worktree-only.ts'), 'worktree');
|
||||
Object.assign(harness.ctx._session, {
|
||||
workingDir: repo,
|
||||
liveWorktree: { name: 'probe', path: worktree },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('lists the worktree with paths relative to the session folder', async () => {
|
||||
const data = await list('&root=live');
|
||||
expect(data.root).toBe(worktree);
|
||||
expect(data.worktree).toEqual({ name: 'probe', path: worktree });
|
||||
expect(names(data.tree)).toEqual(['src']);
|
||||
expect(data.tree[0].path).toBe(`${PREFIX}/src`);
|
||||
expect(data.tree[0].children[0].path).toBe(`${PREFIX}/src/worktree-only.ts`);
|
||||
});
|
||||
|
||||
it('does not need hidden files on, though the worktree lives under a dot folder', async () => {
|
||||
const data = await list('&root=live&showHidden=false');
|
||||
expect(data.totalFiles).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps the session folder without the parameter, and still reports the worktree', async () => {
|
||||
const data = await list();
|
||||
expect(data.root).toBe(repo);
|
||||
expect(data.worktree).toEqual({ name: 'probe', path: worktree });
|
||||
expect(names(data.tree)).toEqual(['src']);
|
||||
expect(data.tree[0].children[0].path).toBe('src/main-only.ts');
|
||||
});
|
||||
|
||||
it('answers exactly as before for a session in no worktree', async () => {
|
||||
Object.assign(harness.ctx._session, { liveWorktree: undefined });
|
||||
const data = await list('&root=live');
|
||||
expect(data).toEqual(await list());
|
||||
expect(data.root).toBe(repo);
|
||||
expect('worktree' in data).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to the session folder when the worktree has gone, keeping the report', async () => {
|
||||
rmSync(worktree, { recursive: true });
|
||||
const data = await list('&root=live');
|
||||
expect(data.root).toBe(repo);
|
||||
// Kept so the panel's comparison with the session's state settles instead of reloading forever.
|
||||
expect(data.worktree).toEqual({ name: 'probe', path: worktree });
|
||||
});
|
||||
|
||||
it('searches the worktree: the query reads paths in the listing, the result in the session folder', async () => {
|
||||
const data = await list(`&root=live&q=${encodeURIComponent('src/*.ts')}`);
|
||||
expect(data.root).toBe(worktree);
|
||||
expect(data.matches.map((match: { path: string }) => match.path)).toEqual([`${PREFIX}/src/worktree-only.ts`]);
|
||||
expect(data.worktree).toEqual({ name: 'probe', path: worktree });
|
||||
});
|
||||
|
||||
it('serves a listed path through the unchanged preview route', async () => {
|
||||
const path = `${PREFIX}/src/worktree-only.ts`;
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=${encodeURIComponent(path)}`,
|
||||
});
|
||||
expect(res.json().success).toBe(true);
|
||||
expect(res.json().data.content).toBe('worktree');
|
||||
});
|
||||
});
|
||||
@@ -6,16 +6,8 @@
|
||||
* npm test -- test/self-update.test.ts
|
||||
*/
|
||||
|
||||
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 { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
ARCHIVE_INSTALL_MARKER,
|
||||
ARCHIVE_UPDATE_HINT,
|
||||
canSelfUpdateInPlace,
|
||||
detectInstallKind,
|
||||
isRunningInContainer,
|
||||
parseVersionFromTag,
|
||||
compareVersions,
|
||||
isNewerStableVersion,
|
||||
@@ -213,39 +205,3 @@ 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