mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-07 07:59:33 +01:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91cea50d9c | ||
|
|
e9b2ecd552 | ||
|
|
f66a9a574d | ||
|
|
a651616907 |
No files matched your search
@@ -0,0 +1,85 @@
|
||||
name: Publish to the MCP registry
|
||||
|
||||
# The official MCP registry does not follow npm, so every version is published to it.
|
||||
# Release calls this after npm took a release. To publish the version on master by hand,
|
||||
# after a failed run for example: gh workflow run publish-mcp.yml
|
||||
#
|
||||
# No secret and no browser login: the registry takes this repository's GitHub Actions OIDC
|
||||
# token as proof of the io.github.Ark0N/ namespace. That id-token right is why this job
|
||||
# runs nothing from the repository: it reads server.json and runs one pinned binary.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
# Bump both together, the digest from the release's asset. Never releases/latest.
|
||||
MCP_PUBLISHER_VERSION: v1.8.7
|
||||
MCP_PUBLISHER_SHA256: 00b21004149a4ce66f0782508ae4a2bce67d1bea395a8da0bd8028159fb59579
|
||||
REGISTRY_VERSIONS_URL: https://registry.modelcontextprotocol.io/v0/servers/io.github.Ark0N%2Fcodeman/versions
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Also what ends a run for a release that only moved xterm-zerolag-input.
|
||||
- name: Skip a version the registry already has
|
||||
id: check
|
||||
run: |
|
||||
VERSION=$(jq -r .version config/mcp-registry/server.json)
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
STATUS=$(curl -sS -o /dev/null -w '%{http_code}' "$REGISTRY_VERSIONS_URL/$VERSION")
|
||||
case "$STATUS" in
|
||||
200) echo "::notice::$VERSION is already in the MCP registry" ;;
|
||||
404) echo "needed=true" >> "$GITHUB_OUTPUT" ;;
|
||||
*) echo "::error::The MCP registry answered $STATUS for $VERSION"; exit 1 ;;
|
||||
esac
|
||||
|
||||
# The registry refuses a version npm does not serve yet, and reads mcpName from it.
|
||||
- name: Wait until npm serves this version
|
||||
if: steps.check.outputs.needed == 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.check.outputs.version }}
|
||||
run: |
|
||||
EXPECTED=$(jq -r .name config/mcp-registry/server.json)
|
||||
for _ in $(seq 1 60); do
|
||||
FOUND=$(npm view "aicodeman@$VERSION" mcpName 2>/dev/null || true)
|
||||
[ "$FOUND" = "$EXPECTED" ] && exit 0
|
||||
sleep 20
|
||||
done
|
||||
echo "::error::npm did not serve aicodeman@$VERSION with mcpName $EXPECTED within 20 minutes"
|
||||
exit 1
|
||||
|
||||
- name: Download mcp-publisher
|
||||
if: steps.check.outputs.needed == 'true'
|
||||
run: |
|
||||
ARCHIVE="$RUNNER_TEMP/mcp-publisher.tar.gz"
|
||||
curl -fsSL -o "$ARCHIVE" "https://github.com/modelcontextprotocol/registry/releases/download/$MCP_PUBLISHER_VERSION/mcp-publisher_linux_amd64.tar.gz"
|
||||
echo "$MCP_PUBLISHER_SHA256 $ARCHIVE" | sha256sum -c -
|
||||
tar xzf "$ARCHIVE" -C "$RUNNER_TEMP" mcp-publisher
|
||||
|
||||
- name: Publish
|
||||
if: steps.check.outputs.needed == 'true'
|
||||
working-directory: config/mcp-registry
|
||||
run: |
|
||||
"$RUNNER_TEMP/mcp-publisher" login github-oidc
|
||||
"$RUNNER_TEMP/mcp-publisher" publish
|
||||
|
||||
- name: Confirm the registry lists it as latest
|
||||
if: steps.check.outputs.needed == 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.check.outputs.version }}
|
||||
run: |
|
||||
curl -fsS --retry 4 --retry-all-errors "$REGISTRY_VERSIONS_URL/$VERSION" \
|
||||
| jq -e '._meta["io.modelcontextprotocol.registry/official"].isLatest == true'
|
||||
@@ -14,6 +14,8 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
outputs:
|
||||
published: ${{ steps.changesets.outputs.published }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v6
|
||||
@@ -60,15 +62,40 @@ jobs:
|
||||
# 1.9.4 lost it to xterm-zerolag-input@0.1.7 by two seconds. All package
|
||||
# releases already exist by the time this step runs, so setting it here
|
||||
# is deterministic.
|
||||
#
|
||||
# The title and body are the page people land on from the update toast.
|
||||
# changesets copied the body from CHANGELOG.md as one long bullet, so it is
|
||||
# reshaped here (scripts/lib/release-notes.mjs). npm has already published by
|
||||
# now, so a failure in that must not stop the retag below: without the body
|
||||
# field the release keeps the text changesets wrote.
|
||||
RELEASE_ID=$(gh release view "$OLD_TAG" --json databaseId -q .databaseId 2>/dev/null || true)
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
NOTES_FILE="$RUNNER_TEMP/release-notes.md"
|
||||
BODY_ARGS=()
|
||||
if node scripts/generate-release-notes.mjs "$VERSION" > "$NOTES_FILE"; then
|
||||
BODY_ARGS=(-F "body=@$NOTES_FILE")
|
||||
else
|
||||
echo "::warning::Keeping the changesets release notes for $VERSION"
|
||||
fi
|
||||
gh api -X PATCH "repos/${{ github.repository }}/releases/${RELEASE_ID}" \
|
||||
-f tag_name="$NEW_TAG" \
|
||||
-f name="$NEW_TAG" \
|
||||
-f make_latest=true
|
||||
-f name="Codeman ${VERSION}" \
|
||||
-f make_latest=true \
|
||||
"${BODY_ARGS[@]}"
|
||||
fi
|
||||
|
||||
# Retag
|
||||
git tag "$NEW_TAG" "$OLD_TAG" 2>/dev/null || true
|
||||
git tag -d "$OLD_TAG" 2>/dev/null || true
|
||||
git push origin "$NEW_TAG" ":refs/tags/$OLD_TAG" 2>/dev/null || true
|
||||
|
||||
# Its own job: this one alone holds id-token, and a registry failure shows as itself
|
||||
# without touching the npm publish or the tag above.
|
||||
mcp-registry:
|
||||
name: MCP registry
|
||||
needs: release
|
||||
if: needs.release.outputs.published == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
uses: ./.github/workflows/publish-mcp.yml
|
||||
@@ -104,6 +104,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
- **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)
|
||||
- **Never start `git` without `gitSpawnAllowed()`** (`utils/git-availability.ts`): on a Mac without the developer tools `/usr/bin/git` is Apple's placeholder, and running it, even as `git --version`, opens macOS's "install the command line developer tools" window. The server did that on the first page load after a no-admin install (`isGitAvailable()` from `renderIndexHtml`) and would have again at every start. The gate resolves `git` on the PATH itself and, for the placeholder, proves the tools first with `xcode-select -p`, which never opens a window; a refusal is answered as ENOENT (`gitMissingError()`), which every caller already reads as "git is not installed". `test/git-spawn-gate.test.ts` pins the list of spawn sites, so a new one fails until it is gated. `install.sh` follows the same rule for the same reason.
|
||||
- **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)
|
||||
|
||||
|
||||
@@ -662,7 +662,7 @@ Adding the header changed the preamble, so `CODEMAN_PREAMBLE` was bumped (1.22.0
|
||||
|
||||
⚠️ The actual per-CLI redirect is `capabilities.customModelInjection` on the CLI registry (four kinds: `env` for claude/gemini/copilot, `configContentEnv` reusing opencode's existing `OPENCODE_CONFIG_CONTENT`, `configDir` for codex/pi/grok/omp/deepseek, writes an isolated per-session config file, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config/dsh home; deepseek's redirect is a per-session `DSH_HOME` holding only the generated `cordis.patch.yml`, whose `linkFromRealHome` recipe field symlinks `profiles`/`sessions`/`settings.yaml` back out of the real dsh home (resolved session-`DSH_HOME`-first, lstat-guarded so a dangling link survives re-apply) instead of stranding them, and `unsupported` for antigravity, which has no known mechanism), computed by the pure `custom-model-injection.ts` (mirrors `session-cli-builder.ts`'s no-IO discipline). ⚠️ `PI_CONFIG_DIR` does NOTHING for pi or omp (grepped pi's entire bundled JS source, the string appears nowhere); both hardcode `~/.pi/agent/models.json` / `~/.omp/agent/models.yml` with no dedicated override, so the real redirect for both is the child process's own **`HOME`**, and both need `models` as an ARRAY of `{id}` objects (an object keyed by id silently loads zero models). Grok's real mechanism turned out to be a `config.toml` `[model.<name>]` block redirected via `GROK_HOME`, its original env-var-based recipe was flat-out wrong (produced "Not signed in" against a real binary), not just unverified.
|
||||
|
||||
⚠️ **Two launch paths, chosen by mechanism, not preference**: opencode/codex/gemini/pi/grok/deepseek/omp apply the selection ONE-SHOT, before the session/process ever exists, with no restart at all; claude alone still applies a selection by **restarting the session's CLI process in place** via `Session.restartCli()`, a de-restricted `reattachRemote()` reusing the same `respawn-pane -k` primitive local/remote respawns already share, because every one of these harnesses reads its endpoint config at process start, never per-turn, so there is no live hot-swap; `Session.setCustomModel()` undoes the PREVIOUS selection's env keys (and deletes its old `configDir`) before merging the new ones in, so switching endpoints or clearing back to native cloud never leaves a stale key behind. It also puts back what the session had set itself under an injected key (`CustomModelBookkeeping.ownEnv`, read after the undo so a switch carries it across): an own `DSH_HOME` or `CLAUDE_CONFIG_DIR` used to be deleted with the injected value, which moved the session to the server's tree or the default Claude account. ⚠️ `ownEnv` can hold a credential in memory, so `getCustomModelForPersist()` writes only its `homeEnvVar` entry, and only as an absolute path (the registry types that name as any env var, so a `clis.json` override can point it at a credential), which it must write because the selection overwrote the tmux `setenv` that was the only other copy. `_restoreCustomModel()` always states `ownEnv` (empty for an older record) after `clampEnvOverridesForOwner()`: after a restart `_envOverrides` holds what the previous run INJECTED, and reading that as the session's own would pin it to a directory the clear deletes. The re-apply's real-home lookup has the same rule: the route reads `Session.ownEnvValue()` and recovery that clamped record (probed under the bulk cap, since it walks every saved session), never `envOverrideValue()`, so an entry the own home lacks is skipped rather than filled from the server's home. Quick-start hands the `Session` constructor the caller's overrides alone so `setCustomModel()` can see them. ⚠️ Deleting a key from `_envOverrides` is NOT enough on its own: `tmux setenv` persists at the tmux-session level and is inherited by `respawn-pane` (measured: `setenv FOO bar` survived two successive `respawn-pane -k`), so the retired keys are queued (`_pendingEnvUnsets`) and ride `RespawnPaneOptions.unsetEnvKeys` into `applyEnvOverrides()`, which `setenv -u`s them BEFORE re-applying the live overrides. ⚠️ `restartCli()` relaunches a CLI in an existing pane, so a CLI whose launch declares a `fallback` chain (claude) gets a conversation pinned as `resumeSessionId` for that one respawn: `--session-id <id>` refuses an id that already has a transcript (`Session ID ... is already in use`), and without the `--resume <id> || --session-id <id>` shape the docker/remote pane commands already use, applying a model killed the pane and lost the session. ⚠️ The pin looks in `<isolated>/projects`, so `linkSharedProjectsDir()` links it from the session's own `CLAUDE_CONFIG_DIR` when it has one (`guardSessionConfigDirHome()` covers `configDirVar` too): the server's tree would hide the conversation on apply and leave the endpoint's transcript behind on clear. The dead-pane respawn shares this pin, see [Dead-pane respawn: the resume pin](#dead-pane-respawn-the-resume-pin). ⚠️ pi, omp and grok need the config file AND a `model` launch param (`custom/<id>` for pi/omp, grok's `[model.codeman-custom]` block name), and codex gets one too (`--model <bare modelId>`, its pattern changed to `model-plain-colon`, which admits qwen3-style colon ids and requires a letter or digit first, for the native App Settings Codex model too): that is the registry's `customModelInjection.launchModel` template, applied onto the respawn options through `legacyConfigField` by `_withCustomModelLaunchModel()`, never by id, and a model id the CLI's `model` token pattern cannot carry is refused with a 400 rather than silently dropped by the argv engine. ⚠️ Remote (SSH) and Docker sessions are REFUSED (400): their `restartCli()` reattaches a durable tmux rather than restarting the agent and the env lands on the local pane, so they used to report `restarted:true` and change nothing. The selection survives a Codeman restart as the disk-only `__customModel` (bookkeeping: env KEYS, config dir, launch model, `homeEnvVar` and the session's own home path; never an injected value, which carries the API key and is re-derived from the endpoint store on recovery, and never any other own value), the config dir is removed with the session, and every secret-bearing file (`custom-model-hosts.json`, the per-session config dir) is written 0600.
|
||||
⚠️ **Two launch paths, chosen by mechanism, not preference**: opencode/codex/gemini/pi/grok/deepseek/omp apply the selection ONE-SHOT, before the session/process ever exists, with no restart at all; claude alone still applies a selection by **restarting the session's CLI process in place** via `Session.restartCli()`, a de-restricted `reattachRemote()` reusing the same `respawn-pane -k` primitive local/remote respawns already share, because every one of these harnesses reads its endpoint config at process start, never per-turn, so there is no live hot-swap; `Session.setCustomModel()` undoes the PREVIOUS selection's env keys (and deletes its old `configDir`) before merging the new ones in, so switching endpoints or clearing back to native cloud never leaves a stale key behind. It also puts back what the session had set itself under an injected key (`CustomModelBookkeeping.ownEnv`, read after the undo so a switch carries it across): an own `DSH_HOME` or `CLAUDE_CONFIG_DIR` used to be deleted with the injected value, which moved the session to the server's tree or the default Claude account. ⚠️ `ownEnv` can hold a credential in memory, so `getCustomModelForPersist()` writes only its `homeEnvVar` entry, and only as an absolute path (the registry types that name as any env var, so a `clis.json` override can point it at a credential), which it must write because the selection overwrote the tmux `setenv` that was the only other copy. `_restoreCustomModel()` always states `ownEnv` (empty for an older record) after `clampEnvOverridesForOwner()`: after a restart `_envOverrides` holds what the previous run INJECTED, and reading that as the session's own would pin it to a directory the clear deletes. The re-apply's real-home lookup has the same rule: the route reads `Session.ownEnvValue()` and recovery that clamped record (probed under the bulk cap, since it walks every saved session), never `envOverrideValue()`, so an entry the own home lacks is skipped rather than filled from the server's home. Quick-start hands the `Session` constructor the caller's overrides alone so `setCustomModel()` can see them. ⚠️ Deleting a key from `_envOverrides` is NOT enough on its own: `tmux setenv` persists at the tmux-session level and is inherited by `respawn-pane` (measured: `setenv FOO bar` survived two successive `respawn-pane -k`), so the retired keys are queued (`_pendingEnvUnsets`) and ride `RespawnPaneOptions.unsetEnvKeys` into `applyEnvOverrides()`, which `setenv -u`s them BEFORE re-applying the live overrides. ⚠️ `restartCli()` relaunches a CLI in an existing pane, so a CLI whose launch declares a `fallback` chain (claude) gets a conversation pinned as `resumeSessionId` for that one respawn: `--session-id <id>` refuses an id that already has a transcript (`Session ID ... is already in use`), and without the `--resume <id> || --session-id <id>` shape the docker/remote pane commands already use, applying a model killed the pane and lost the session. ⚠️ The pin looks in `<isolated>/projects`, so `linkSharedProjectsDir()` links it from the session's own `CLAUDE_CONFIG_DIR` when it has one (`guardSessionConfigDirHome()` covers `configDirVar` too): the server's tree would hide the conversation on apply and leave the endpoint's transcript behind on clear. The dead-pane respawn shares this pin, see [Dead-pane respawn: the resume pin](#dead-pane-respawn-the-resume-pin). ⚠️ pi, omp and grok need the config file AND a `model` launch param (`custom/<id>` for pi/omp, grok's `[model.codeman-custom]` block name), and codex gets one too (`--model <bare modelId>`, its pattern changed to `model-plain-colon`, which admits qwen3-style colon ids and requires a letter or digit first, for the native App Settings Codex model too): that is the registry's `customModelInjection.launchModel` template, applied onto the create and respawn options through `legacyConfigField` by `_withCustomModelLaunchModel()`, never by id and never onto the stored `<Mode>Config` (that config is what a clear falls back to; the quick-start route used to write the launch model there, so a clear relaunched the endpoint's id on the native backend), and a model id the CLI's `model` token pattern cannot carry is refused with a 400 rather than silently dropped by the argv engine. ⚠️ Remote (SSH) and Docker sessions are REFUSED (400): their `restartCli()` reattaches a durable tmux rather than restarting the agent and the env lands on the local pane, so they used to report `restarted:true` and change nothing. The selection survives a Codeman restart as the disk-only `__customModel` (bookkeeping: env KEYS, config dir, launch model, `homeEnvVar` and the session's own home path; never an injected value, which carries the API key and is re-derived from the endpoint store on recovery, and never any other own value), the config dir is removed with the session, and every secret-bearing file (`custom-model-hosts.json`, the per-session config dir) is written 0600.
|
||||
|
||||
⚠️ **Security**: every env var this feature can redirect (`ANTHROPIC_BASE_URL`, `GOOGLE_GEMINI_BASE_URL`, `CODEX_HOME`, `GROK_HOME`, `HOME` for pi/omp, `OPENCODE_CONFIG_CONTENT`, etc.) is in that CLI's `privilegedEnvKeys` — several of these were reachable via the generic `envOverrides` field's prefix allowlist BEFORE this feature existed (the env allowlist is global and prefix-based, not per-CLI-scoped), so building this surfaced and closed a pre-existing gap rather than opening a new one. `ANTHROPIC_*` is deliberately NOT in claude's `allowedPrefixes` at all — Anthropic-traffic redirection can only happen through this feature's own admin-configured, SSRF-guarded route, never a plain client-supplied `envOverrides`.
|
||||
|
||||
|
||||
@@ -543,6 +543,11 @@ selection goes, on a clear or a switch to another endpoint. That is what
|
||||
keeps a session on its own `DSH_HOME`, `CODEX_HOME` or `GROK_HOME`, and on
|
||||
its own account through `CLAUDE_CONFIG_DIR` (the per-client-account case).
|
||||
|
||||
The launch model goes the same way: a clear relaunches on the model the session
|
||||
was created with, or with none if it was created without one. A session an
|
||||
older build launched straight onto an endpoint saved that endpoint's model in
|
||||
its config, so its clear still relaunches on that id.
|
||||
|
||||
⚠️ Across a Codeman restart only the home directory comes back, and only as
|
||||
an absolute path. The selection overwrote the tmux `setenv` that held it, so
|
||||
its record in `state.json` keeps that one path (`ownEnv`), and a non-granted
|
||||
|
||||
+20
-2
@@ -19,14 +19,32 @@ When user says "COM":
|
||||
|
||||
Replace `patch` with `minor` or `major` as needed. Include `"xterm-zerolag-input": patch` on a separate line if that package changed too.
|
||||
|
||||
**Write it in the shape the release page is built from.** `CHANGELOG.md` keeps changesets' format, and the `Release` workflow rebuilds the GitHub release from that section (`scripts/generate-release-notes.mjs`, which also titles it `Codeman <x.y.z>`). The in-app update toast reads the same bold lead-ins:
|
||||
|
||||
```markdown
|
||||
### Thanks
|
||||
- @someone for what they did (#123).
|
||||
|
||||
**What the change does for the user (#123).** Two or three plain sentences.
|
||||
|
||||
**Fixes.**
|
||||
- **What works now (#124).** What was wrong. Behaviour change: what a user has to do differently.
|
||||
|
||||
**Fixes applied while landing.** One sentence each.
|
||||
|
||||
**Docs and tests.** One sentence.
|
||||
```
|
||||
|
||||
`### Thanks` stays first and opens the page. A bold lead-in that ends in punctuation becomes a heading under What's new, so make it a short headline. `**Fixes.**` and its list become a Fixes section, and the last two labels share an Also in this release section (the labels are `SECTION_LABELS` in `scripts/lib/whats-new.mjs`). `Behaviour change:` and `Breaking:` are put in bold wherever they appear, so use those words for anything a user has to act on. Keep each change short: how it works belongs in the docs, and a long paragraph is a wall of text even under a heading. Text the script does not recognise stays on the page as plain paragraphs. After step 3, `node scripts/generate-release-notes.mjs` prints the page. If the workflow warns that it kept the changesets notes, fix the changelog and run `gh release edit codeman@<x.y.z> --notes-file <file>`.
|
||||
|
||||
3. **Consume the changeset**: `npm run version-packages` (auto-bumps `package.json` files, updates `CHANGELOG.md`, runs `npm install --package-lock-only`, and verifies lockfile sync via `scripts/check-lockfile-sync.mjs` — all in one command; never hand-edit `CHANGELOG.md` or `package-lock.json` versions. It also writes `src/web/public/whats-new.json` (`scripts/generate-whats-new.mjs`): the bold lead-in of each change in the new `CHANGELOG.md` section, which the browser announces once per version in a toast. Commit it with the release; `test/whats-new.test.ts` fails when its version differs from `package.json`, so a hand bump needs `node scripts/generate-whats-new.mjs && npx prettier --write src/web/public/whats-new.json` too. A release whose notes have no bold lead-ins ships an empty list and shows no toast)
|
||||
4. **Sync CLAUDE.md version**: Update the `**Version**` line in the root `CLAUDE.md` (under COM Shorthand) to match the new version from `package.json`
|
||||
5. **Commit and deploy**: verify the branch first (`git branch --show-current`), then stage EXPLICIT paths — never `git add -A`, which has swept another session's WIP into a release. `git status --short` and account for every line before committing:
|
||||
`git add <paths> && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
|
||||
6. **Refresh the getcodeman.com version badge**: the landing page's status bar carries the release version (`v<x.y.z> · getcodeman.com · MIT`), so it goes stale on every release if nobody bumps it. The site source and its deploy script are maintained outside this repository, on the maintainer's machine only; follow the local site handbook there, which also covers the numbers strip and `sitemap.xml` refresh that belong in the same pass. Poll production (`curl -s https://getcodeman.com/ | grep v<x.y.z>`) before calling it done, since the edge lags a deploy by up to a minute. Not applicable to contributor clones — skip it and say so.
|
||||
7. **Wait for CI**: after `git push`, TWO workflows fire per master push — `CI` and `Release` (the npm publish + GitHub release). List both runs for the pushed commit with `gh run list --commit $(git rev-parse HEAD) --json databaseId,workflowName` and watch EACH with `gh run watch <id> --exit-status`. Confirm both pass before considering the release done (`gh run list -L 1` returns only one of the two).
|
||||
7. **Wait for CI**: after `git push`, TWO workflows fire per master push: `CI` and `Release` (the npm publish, the GitHub release and the MCP registry publish). List both runs for the pushed commit with `gh run list --commit $(git rev-parse HEAD) --json databaseId,workflowName` and watch EACH with `gh run watch <id> --exit-status`. Confirm both pass before considering the release done (`gh run list -L 1` returns only one of the two).
|
||||
|
||||
8. **Publish the new version to the MCP registry**: `codeman mcp` is listed in the official MCP registry as `io.github.Ark0N/codeman`, and the registry does not follow npm, so every release is published there by hand or the listing stays on the old version. `npm run version-packages` has already written the new version into `config/mcp-registry/server.json` (`scripts/sync-plugin.mjs`). Wait until npm serves it (`npm view aicodeman version`; it can lag the green `Release` run by several minutes), because the registry checks that the npm package exists and carries `mcpName: io.github.Ark0N/codeman`. Then run `cd config/mcp-registry && mcp-publisher publish`, preceded by `mcp-publisher login github` when the saved login has expired, signed in as the account that owns the `io.github.Ark0N/` namespace. Use mcp-publisher 1.8.1 or newer (`mcp-publisher --version`), the release binary from https://github.com/modelcontextprotocol/registry/releases: the Snap Store package is stuck at 1.1.0 and rejects the current `$schema` with a false "deprecated schema detected", so never edit `server.json` for that error. Older publishers also save their login as `.mcpregistry_github_token` and `.mcpregistry_registry_token` (live tokens) in the current directory; move those out of the repo, never commit them. Confirm with `curl -s 'https://registry.modelcontextprotocol.io/v0/servers?search=io.github.Ark0N/codeman' | jq -r '.servers[] | "\(.server.version) latest=\(._meta["io.modelcontextprotocol.registry/official"].isLatest)"'`: the new version must be the one with `latest=true`. Maintainer only (it needs that GitHub account); contributor clones skip it and say so.
|
||||
8. **Check the MCP registry**: `codeman mcp` is listed in the official MCP registry as `io.github.Ark0N/codeman`, and the registry does not follow npm, so every release is published there. The `Release` workflow does this itself in its `MCP registry` job (`.github/workflows/publish-mcp.yml`): it waits until npm serves the version, because the registry checks that the npm package exists and carries `mcpName: io.github.Ark0N/codeman`, signs in with the workflow's GitHub OIDC token (no secret, no browser) and publishes the `config/mcp-registry/server.json` that `npm run version-packages` wrote (`scripts/sync-plugin.mjs`). A version the registry already has is skipped. Confirm with `curl -s 'https://registry.modelcontextprotocol.io/v0/servers?search=io.github.Ark0N/codeman' | jq -r '.servers[] | "\(.server.version) latest=\(._meta["io.modelcontextprotocol.registry/official"].isLatest)"'`: the new version must be the one with `latest=true`. If the job failed, `gh workflow run publish-mcp.yml` publishes the version on master again. Its publisher is pinned: bump `MCP_PUBLISHER_VERSION` and `MCP_PUBLISHER_SHA256` together, the digest from the release's asset. The local fallback is `cd config/mcp-registry && ~/.local/bin/mcp-publisher login github && ~/.local/bin/mcp-publisher publish`, signed in as the account that owns the `io.github.Ark0N/` namespace, with mcp-publisher 1.8.1 or newer, the release binary from https://github.com/modelcontextprotocol/registry/releases: the Snap Store package is stuck at 1.1.0 and rejects the current `$schema` with a false "deprecated schema detected", so never edit `server.json` for that error. Older publishers also save their login as `.mcpregistry_github_token` and `.mcpregistry_registry_token` (live tokens) in the current directory; move those out of the repo, never commit them.
|
||||
|
||||
9. **Announce it in Discussions**: every release gets a post in the [Announcements](https://github.com/Ark0N/Codeman/discussions/categories/announcements) category, shaped like #418 and #302: the short version first (one bold lead-in per theme, features before fixes, what it does for the user rather than how it works), contributor @-mentions inline where their work is described (a mention notifies them, and a contributor reposting is the cheapest reach this project has), a link to the release, and the Thanks names at the end. Casual first-person voice, no em-dashes, humanizer pass when the skill is available. A same-day follow-on patch (1.28.1 after 1.28.0) is folded into the previous post as an edit, never a second thread. Post it with `gh api graphql -F body=@<file> -f title='Codeman <x.y.z>: <hook>' -f repo=R_kgDOQ-SMDg -f cat=DIC_kwDOQ-SMDs4DCHZE -f query='mutation($repo:ID!,$cat:ID!,$title:String!,$body:String!){createDiscussion(input:{repositoryId:$repo,categoryId:$cat,title:$title,body:$body}){discussion{number url}}}'` (the repo's node id and its Announcements category id; `pinDiscussion` does not exist in the API, so pinning stays a click in the UI). This step exists because announcements stopped at 1.18 (#302) while ten releases shipped with nobody notified; #418 is the backfill covering 1.19.0 to 1.28.1, and release notes only count as content once they reach a surface people are subscribed to.
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env node
|
||||
// Prints the GitHub release notes for one version, built from its section of CHANGELOG.md.
|
||||
// The Release workflow sends this to the release it just published. Run it by hand to preview:
|
||||
//
|
||||
// node scripts/generate-release-notes.mjs [version] default: the version in package.json
|
||||
//
|
||||
// Exits 1 with nothing on stdout when the changelog has no section for that version, so the
|
||||
// workflow keeps the text changesets wrote rather than blanking a published release.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { buildReleaseNotes } from './lib/release-notes.mjs';
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const version = process.argv[2] ?? JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')).version;
|
||||
const notes = buildReleaseNotes(readFileSync(resolve(root, 'CHANGELOG.md'), 'utf8'), version);
|
||||
if (!notes) {
|
||||
console.error(`release-notes: CHANGELOG.md has no notes for ${version}`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(notes);
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Turns one version's section of CHANGELOG.md into the body of its GitHub release.
|
||||
*
|
||||
* changesets writes a changeset as one list item, `- <hash>: <first line>`, with the rest
|
||||
* indented under it, and changesets/action copies that section into the release as it stands.
|
||||
* So a whole release rendered as a single bullet, and the `### Thanks` that opens a changeset
|
||||
* sat behind the hash as literal text. The shape below cannot be written into CHANGELOG.md
|
||||
* instead: `### <Type> Changes` is hardcoded upstream, and a `## ` line would end the section
|
||||
* for both changesets/action and versionSection(). So the changelog keeps changesets' format,
|
||||
* and the Release workflow applies this to what comes out of it.
|
||||
*
|
||||
* Only the commit prefix and the `### <Type> Changes` lines are removed. A block this does not
|
||||
* recognise stays as text; test/release-notes.test.ts checks that over every past release.
|
||||
*/
|
||||
import { SECTION_LABELS, cleanLead, versionSection } from './whats-new.mjs';
|
||||
|
||||
const REPO_URL = 'https://github.com/Ark0N/Codeman';
|
||||
|
||||
const TYPE_HEADING = /^### (?:Major|Minor|Patch) Changes\s*$/;
|
||||
const ITEM_START = /^- (?:[0-9a-f]{7,40}: )?(.*)$/;
|
||||
const HEADING = /^#{1,6}\s+(.+?)\s*$/;
|
||||
const BOLD_START = /^\*\*(.+?)\*\*(?:\s+(.*))?$/;
|
||||
const LIST_ITEM = /^\s*[-*] /;
|
||||
const VERSION_HEADING = /^## (\d+\.\d+\.\d+\S*)\s*$/;
|
||||
const BEHAVIOUR_CHANGE = /(?<![*\w])(Behaviou?r changes?|Breaking):/g;
|
||||
|
||||
/** The section's changesets, each as its own lines with the prefix and indent removed. */
|
||||
function changesets(section) {
|
||||
const items = [];
|
||||
for (const line of section.split('\n')) {
|
||||
if (TYPE_HEADING.test(line)) continue;
|
||||
const start = ITEM_START.exec(line);
|
||||
if (start) items.push([start[1]]);
|
||||
else if (items.length > 0) items[items.length - 1].push(line.replace(/^ {2}/, ''));
|
||||
else if (line.trim()) items.push([line]);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Splits lines on blank ones, keeping a fenced code block whole. */
|
||||
function splitBlocks(lines) {
|
||||
const blocks = [];
|
||||
let current = [];
|
||||
let inFence = false;
|
||||
for (const line of lines) {
|
||||
if (/^\s*(?:```|~~~)/.test(line)) inFence = !inFence;
|
||||
if (inFence || line.trim()) {
|
||||
current.push(line);
|
||||
} else if (current.length > 0) {
|
||||
blocks.push(current);
|
||||
current = [];
|
||||
}
|
||||
}
|
||||
if (current.length > 0) blocks.push(current);
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* The title a block opens with, or null. Bold counts only as a lead-in when it ends in
|
||||
* punctuation or stands alone: "**Allow network access** is a new setting" is a sentence.
|
||||
*/
|
||||
function blockTitle(firstLine) {
|
||||
const heading = HEADING.exec(firstLine);
|
||||
if (heading) return { title: heading[1], text: '', isHeading: true };
|
||||
const bold = BOLD_START.exec(firstLine);
|
||||
if (bold && (!bold[2] || /[.:?!]$/.test(bold[1]))) return { title: bold[1], text: bold[2] ?? '', isHeading: false };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Which part of the page a group label heads, or null for a change's own headline. */
|
||||
function groupFor(label) {
|
||||
if (!SECTION_LABELS.test(label)) return null;
|
||||
if (/^thanks\b/i.test(label)) return 'thanks';
|
||||
if (/^fixes$/i.test(label)) return 'fixes';
|
||||
return /^new features$/i.test(label) ? null : 'also';
|
||||
}
|
||||
|
||||
/** Sorts every block of the section into the part of the page it belongs to. */
|
||||
function sortBlocks(section) {
|
||||
const parts = { thanks: [], intro: [], news: [], fixes: [], also: [] };
|
||||
const items = changesets(section);
|
||||
for (const item of items) {
|
||||
const blocks = splitBlocks(item);
|
||||
// Text with no headline leads What's new, or it would read as part of the last feature.
|
||||
let target = 'intro';
|
||||
let thanksIsEmpty = false;
|
||||
for (const block of blocks) {
|
||||
const [first, ...rest] = block;
|
||||
const opened = blockTitle(first);
|
||||
if (opened) {
|
||||
const label = cleanLead(opened.title);
|
||||
const group = groupFor(label);
|
||||
const body = [opened.text, ...rest].filter(Boolean).join('\n');
|
||||
target = group ?? 'news';
|
||||
thanksIsEmpty = group === 'thanks' && !body;
|
||||
if (group === 'also') {
|
||||
// These two are a sentence or two each, so they keep their bold lead-in: a heading
|
||||
// over every one of them weighs more than the text under it.
|
||||
parts.also.push(opened.isHeading ? [`**${label}.**`, ...rest].join('\n') : block.join('\n'));
|
||||
} else if (group) {
|
||||
if (body) parts[group].push(body);
|
||||
} else {
|
||||
parts.news.push(`### ${label}`);
|
||||
if (body) parts.news.push(body);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Only credit stays under Thanks: what opens it, its list, and a line ending in a colon
|
||||
// that leads into one. A paragraph after that is the release talking again.
|
||||
if (target === 'thanks' && !thanksIsEmpty && !LIST_ITEM.test(first) && !first.endsWith(':')) target = 'intro';
|
||||
thanksIsEmpty = false;
|
||||
const text = block.join('\n');
|
||||
// A one-line changeset was a bullet in the changelog; beside others it stays one.
|
||||
parts[target].push(blocks.length === 1 && items.length > 1 ? `- ${text}` : text);
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** The release before `version`, or null for the first one or a version not in the changelog. */
|
||||
export function previousVersion(changelog, version) {
|
||||
const versions = changelog.split(/\r?\n/).flatMap((line) => VERSION_HEADING.exec(line)?.[1] ?? []);
|
||||
const index = versions.indexOf(version);
|
||||
return index === -1 ? null : (versions[index + 1] ?? null);
|
||||
}
|
||||
|
||||
/** The markdown for the release page, or '' when the changelog has nothing for `version`. */
|
||||
export function buildReleaseNotes(changelog, version) {
|
||||
const { thanks, intro, news, fixes, also } = sortBlocks(versionSection(changelog, version));
|
||||
const page = [];
|
||||
// Thanks stays first on purpose: contributors are the first thing a visitor to Latest sees.
|
||||
if (thanks.length > 0) page.push('## Thanks', ...thanks);
|
||||
if (intro.length + news.length > 0) page.push("## What's new", ...intro, ...news);
|
||||
if (fixes.length > 0) page.push('## Fixes', ...fixes);
|
||||
if (also.length > 0) page.push('## Also in this release', ...also);
|
||||
if (page.length === 0) return '';
|
||||
|
||||
const blocks = page.map((block) => (block.startsWith('#') ? block : block.replace(BEHAVIOUR_CHANGE, '**$1:**')));
|
||||
const previous = previousVersion(changelog, version);
|
||||
if (previous) {
|
||||
blocks.push('---', `**Full Changelog**: ${REPO_URL}/compare/codeman@${previous}...codeman@${version}`);
|
||||
}
|
||||
return `${blocks.join('\n\n')}\n`;
|
||||
}
|
||||
@@ -19,9 +19,13 @@ export function versionSection(changelog, version) {
|
||||
|
||||
// Bold phrases the release notes use as group labels rather than as the headline of a change:
|
||||
// "**Fixes.**" heads the list of fixes, "**Fixes applied while landing.**" the review fixes.
|
||||
const SECTION_LABELS =
|
||||
// Shared with release-notes.mjs, so the toast and the release page agree on what is a headline.
|
||||
export const SECTION_LABELS =
|
||||
/^(?:thanks\b.*|fixes|fixes applied while landing|docs|docs and tests|new features|also in this release)$/i;
|
||||
|
||||
/** A bold lead-in without its closing punctuation. */
|
||||
export const cleanLead = (text) => text.replace(/[\s.:]+$/, '').trim();
|
||||
|
||||
/** Bold lead-ins at the start of a paragraph or bullet, cleaned for display. */
|
||||
export function extractHighlights(section) {
|
||||
const out = [];
|
||||
@@ -30,7 +34,7 @@ export function extractHighlights(section) {
|
||||
// ("- d18deed: **Lead.** text"), which is the shape every changesets-bot release has.
|
||||
const m = /^\s*(?:[-*]\s+)?(?:[0-9a-f]{7,40}:\s+)?\*\*([^*]{3,120})\*\*/.exec(line);
|
||||
if (!m) continue;
|
||||
const text = m[1].replace(/[\s.:]+$/, '').trim();
|
||||
const text = cleanLead(m[1]);
|
||||
if (!text || SECTION_LABELS.test(text)) continue;
|
||||
if (!out.includes(text)) out.push(text);
|
||||
}
|
||||
|
||||
+20
-1
@@ -54,6 +54,7 @@ import { lstat, readdir, rename, rm } from 'node:fs/promises';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||
import { probePathKind } from './utils/bounded-path-probe.js';
|
||||
import { gitMissingError, gitSpawnAllowed } from './utils/git-availability.js';
|
||||
|
||||
// ─── Tunables ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -728,6 +729,17 @@ interface GitRun {
|
||||
* signal is used rather than `child.kill()`.
|
||||
*/
|
||||
async function runGit(args: string[], timeoutMs: number, maxStdoutBytes: number): Promise<GitRun> {
|
||||
// Never Apple's placeholder git on a Mac without developer tools: answered as a missing git.
|
||||
if (!gitSpawnAllowed()) {
|
||||
return {
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
code: null,
|
||||
timedOut: false,
|
||||
started: false,
|
||||
spawnError: String(gitMissingError()),
|
||||
};
|
||||
}
|
||||
// The queue wait spends the SAME deadline as the operation: `timeoutMs` is a
|
||||
// promise about the whole call, not about git's runtime after some unbounded
|
||||
// wait. A full queue is refused outright rather than queued.
|
||||
@@ -814,10 +826,17 @@ async function runGit(args: string[], timeoutMs: number, maxStdoutBytes: number)
|
||||
}
|
||||
}
|
||||
|
||||
/** Is a usable `git` on this machine? Memoized: the answer cannot change without a restart. */
|
||||
/**
|
||||
* Is a usable `git` on this machine? Memoized: the answer cannot change without a restart. The one
|
||||
* exception is a Mac whose only git is Apple's placeholder with no developer tools behind it: running
|
||||
* that, even as `git --version`, opens macOS's "install the developer tools" window, so it is never
|
||||
* run, and that refusal is not memoized here (the gate keeps its own short memory), so the page finds
|
||||
* git once the tools are installed.
|
||||
*/
|
||||
let gitAvailable: boolean | null = null;
|
||||
export function isGitAvailable(): boolean {
|
||||
if (gitAvailable !== null) return gitAvailable;
|
||||
if (!gitSpawnAllowed()) return false;
|
||||
try {
|
||||
execFileSync('git', ['--version'], {
|
||||
encoding: 'utf-8',
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
resolveWorkspaceRepos,
|
||||
type GitRunner,
|
||||
} from './git-workspace-status.js';
|
||||
import { gitMissingError, gitSpawnAllowed } from './utils/git-availability.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -205,6 +206,7 @@ export function fetchEnvFor(base: NodeJS.ProcessEnv, repoSshCommand: string | nu
|
||||
* add a fifth git process or push a fetch past the user's timeout: it spends part of it.
|
||||
*/
|
||||
export async function readRepoSshCommand(cwd: string, timeoutMs: number): Promise<string | null> {
|
||||
if (!gitSpawnAllowed()) return null;
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['config', '--get', 'core.sshCommand'], {
|
||||
cwd,
|
||||
@@ -240,6 +242,8 @@ export async function readRepoSshCommand(cwd: string, timeoutMs: number): Promis
|
||||
* as a timeout without starting the fetch.
|
||||
*/
|
||||
export const runFetch: GitRunner = async (cwd, args, opts) => {
|
||||
// Never Apple's placeholder git on a Mac without developer tools: answered as GIT_MISSING.
|
||||
if (!gitSpawnAllowed()) throw gitMissingError();
|
||||
const timeoutMs = opts?.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS;
|
||||
const startedAt = Date.now();
|
||||
const env = fetchEnvFor(process.env, await readRepoSshCommand(cwd, timeoutMs));
|
||||
|
||||
@@ -50,6 +50,7 @@ import { homedir } from 'node:os';
|
||||
import { basename, join, relative, sep } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { gitNonInteractiveEnv, redactGitCredentials } from './git-clone.js';
|
||||
import { gitMissingError, gitSpawnAllowed } from './utils/git-availability.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -265,6 +266,8 @@ export function parseCommitLog(text: string): GitCommitEntry[] {
|
||||
export type GitRunner = (cwd: string, args: string[], opts?: { timeoutMs?: number }) => Promise<string>;
|
||||
|
||||
export const runGit: GitRunner = async (cwd, args, opts) => {
|
||||
// Never Apple's placeholder git on a Mac without developer tools (utils/git-availability.ts).
|
||||
if (!gitSpawnAllowed()) throw gitMissingError();
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
// --no-optional-locks: never touch the index just to look. core.fsmonitor=false: do not start or
|
||||
|
||||
@@ -62,6 +62,7 @@ import { homedir } from 'node:os';
|
||||
import { join, resolve, sep } from 'node:path';
|
||||
import { gitNonInteractiveEnv, isSafeGitRef, sanitizeGitOutput } from './git-clone.js';
|
||||
import { isInsideAny, isUnrelatedAncestor } from './git-workspace-status.js';
|
||||
import { gitMissingError, gitSpawnAllowed } from './utils/git-availability.js';
|
||||
|
||||
// ─── Limits ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -371,6 +372,11 @@ const READ_ONLY_GIT_OPTIONS = ['--no-optional-locks', '-c', 'core.fsmonitor=fals
|
||||
*/
|
||||
export const runWorktreeGit: WorktreeGitRunner = (cwd, args, opts) =>
|
||||
new Promise<WorktreeGitResult>((resolveRun) => {
|
||||
// Never Apple's placeholder git on a Mac without developer tools: answered as "git is not installed".
|
||||
if (!gitSpawnAllowed()) {
|
||||
resolveRun({ code: null, stdout: '', stderr: '', timedOut: false, spawnError: String(gitMissingError()) });
|
||||
return;
|
||||
}
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawn('git', args, {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { open, readdir, stat } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import type { PredictionToolCall, WorkspaceSignals } from './readmymind-context.js';
|
||||
import { gitMissingError, gitSpawnAllowed } from './utils/git-availability.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -150,6 +151,8 @@ const MAX_STATUS_LINES = 30;
|
||||
*/
|
||||
export async function collectWorkspaceSignals(workingDir: string): Promise<WorkspaceSignals | null> {
|
||||
const git = async (args: string[]): Promise<string> => {
|
||||
// Never Apple's placeholder git on a Mac without developer tools (utils/git-availability.ts).
|
||||
if (!gitSpawnAllowed()) throw gitMissingError();
|
||||
const { stdout } = await execFileAsync('git', args, {
|
||||
cwd: workingDir,
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
|
||||
+8
-8
@@ -2990,13 +2990,13 @@ export class Session extends EventEmitter {
|
||||
* launch param. Where that param
|
||||
* lives is registry DATA — the entry's `legacyConfigField` (`piConfig`, `grokConfig`,
|
||||
* ...) or the top-level `model` for an entry that declares none — so this stays a
|
||||
* generic reader rather than a branch per CLI. Applied on the OPTIONS only: the stored
|
||||
* `<Mode>Config` keeps whatever model the user chose at create, which is exactly what a
|
||||
* later clear must fall back to.
|
||||
* generic reader rather than a branch per CLI. Applied on the OPTIONS only, on both the
|
||||
* respawn and the create path: the stored `<Mode>Config` keeps whatever model the user
|
||||
* chose at create, which is exactly what a later clear must fall back to.
|
||||
*/
|
||||
private _withCustomModelLaunchModel(
|
||||
options: import('./mux-interface.js').RespawnPaneOptions
|
||||
): import('./mux-interface.js').RespawnPaneOptions {
|
||||
private _withCustomModelLaunchModel<
|
||||
T extends import('./mux-interface.js').RespawnPaneOptions | import('./mux-interface.js').CreateSessionOptions,
|
||||
>(options: T): T {
|
||||
const launchModel = this._customModel?.launchModel;
|
||||
if (!launchModel) return options;
|
||||
const entry = getCli(this.mode);
|
||||
@@ -3348,7 +3348,7 @@ export class Session extends EventEmitter {
|
||||
const { isRestored, respawnedResumeId, respawnedDeadPane } = await this._setupOrAttachMuxSession({
|
||||
// Single source of truth shared with reattachRemote() (COD-108).
|
||||
respawnPaneOptions: this._buildRespawnPaneOptions(),
|
||||
createSessionOptions: {
|
||||
createSessionOptions: this._withCustomModelLaunchModel({
|
||||
sessionId: this.id,
|
||||
workingDir: this.workingDir,
|
||||
mode: this.mode,
|
||||
@@ -3378,7 +3378,7 @@ export class Session extends EventEmitter {
|
||||
docker: this._docker,
|
||||
adopt: this._adopt,
|
||||
owner: this._owner,
|
||||
},
|
||||
}),
|
||||
spawnErrLabel: 'mux attachment',
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @fileoverview The ONE gate in front of every `git` the server starts.
|
||||
*
|
||||
* macOS ships `/usr/bin/git` as a placeholder. On a Mac without the developer tools (Xcode or the
|
||||
* Command Line Tools), running it, even as `git --version`, makes macOS open its "install the command
|
||||
* line developer tools" window and exit non-zero. The server used to ask "is git here?" that way on the
|
||||
* first page load, so a user who had just installed Codeman without git and without an administrator
|
||||
* password got that window one second after the install finished, and again at every login.
|
||||
* `install.sh` never runs the placeholder for the same reason.
|
||||
*
|
||||
* So on macOS, when `git` on the server's PATH resolves to that placeholder, the developer tools are
|
||||
* proven first: `xcode-select -p` (a real binary that prints and never opens a window) must name a
|
||||
* folder that holds a git of its own. Otherwise git counts as absent and is never started.
|
||||
*
|
||||
* Every site that starts git calls `gitSpawnAllowed()` first and answers a refusal the way it already
|
||||
* answers a machine with no git (`gitMissingError()` carries `code: 'ENOENT'` for that).
|
||||
* `test/git-spawn-gate.test.ts` fails on a git spawn in a file that does not go through this gate.
|
||||
*
|
||||
* The decision is pure (`gitSpawnWouldPrompt`, `findOnPath`); the IO around it is two bounded steps, a
|
||||
* PATH scan and one `xcode-select -p` (a few milliseconds, capped at 3 s), and only on macOS. A "yes" is
|
||||
* kept for the life of the process; a "no" is asked again after `GIT_GATE_NEGATIVE_TTL_MS`, so
|
||||
* installing the developer tools later needs no server restart for the git features (the page's own
|
||||
* "git is available" flag stays memoized per process, like every other availability flag there).
|
||||
*
|
||||
* @module utils/git-availability
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import { delimiter, isAbsolute, join } from 'node:path';
|
||||
import { isExecutableRegularFile } from './cli-executable-resolver.js';
|
||||
|
||||
/** Apple's placeholder. A real git (Homebrew, MacPorts, Nix) sits elsewhere on the PATH. */
|
||||
export const APPLE_GIT_PLACEHOLDER = '/usr/bin/git';
|
||||
|
||||
const XCODE_SELECT = '/usr/bin/xcode-select';
|
||||
const XCODE_SELECT_TIMEOUT_MS = 3_000;
|
||||
|
||||
/** How long a refusal is kept before the machine is looked at again. */
|
||||
export const GIT_GATE_NEGATIVE_TTL_MS = 60_000;
|
||||
|
||||
/**
|
||||
* The first `name` on a PATH-style list that `isExecutable` accepts, or null. Pure.
|
||||
* Empty and relative entries are skipped: they mean "the current directory" of whoever runs the
|
||||
* command, which this lookup cannot know.
|
||||
*/
|
||||
export function findOnPath(
|
||||
name: string,
|
||||
pathEnv: string | undefined,
|
||||
isExecutable: (path: string) => boolean
|
||||
): string | null {
|
||||
for (const dir of (pathEnv ?? '').split(delimiter)) {
|
||||
if (!dir || !isAbsolute(dir)) continue;
|
||||
const candidate = join(dir, name);
|
||||
if (isExecutable(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface GitGateFacts {
|
||||
platform: NodeJS.Platform;
|
||||
/** What `git` resolves to on the server's PATH (symlinks followed), or null when there is none. */
|
||||
gitPath: string | null;
|
||||
/** Asked only when it decides the answer, so other machines never pay for it. */
|
||||
developerToolsPresent: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Would starting `git` open macOS's "install the developer tools" window? Pure.
|
||||
* Only Apple's placeholder does that, and only while no developer tools are installed. No git on the
|
||||
* PATH at all is not this function's case: the spawn then fails with ENOENT, which every caller handles.
|
||||
*/
|
||||
export function gitSpawnWouldPrompt(facts: GitGateFacts): boolean {
|
||||
if (facts.platform !== 'darwin') return false;
|
||||
if (facts.gitPath !== APPLE_GIT_PLACEHOLDER) return false;
|
||||
return !facts.developerToolsPresent();
|
||||
}
|
||||
|
||||
/**
|
||||
* The developer folder `xcode-select -p` names must hold a git of its own (`usr/bin/git`, true for both
|
||||
* Xcode and the Command Line Tools). A selected folder that has since been deleted would otherwise read
|
||||
* as "tools present" and the placeholder would open its window after all.
|
||||
*/
|
||||
export function developerDirHoldsGit(developerDir: string, isExecutable: (path: string) => boolean): boolean {
|
||||
const dir = developerDir.trim();
|
||||
if (!dir || !isAbsolute(dir)) return false;
|
||||
return isExecutable(join(dir, 'usr', 'bin', 'git'));
|
||||
}
|
||||
|
||||
function resolveGitOnPath(): string | null {
|
||||
const found = findOnPath('git', process.env.PATH, isExecutableRegularFile);
|
||||
if (!found) return null;
|
||||
try {
|
||||
return realpathSync(found);
|
||||
} catch {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
function developerToolsPresent(): boolean {
|
||||
try {
|
||||
const out = execFileSync(XCODE_SELECT, ['-p'], {
|
||||
encoding: 'utf8',
|
||||
timeout: XCODE_SELECT_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
return developerDirHoldsGit(out, isExecutableRegularFile);
|
||||
} catch {
|
||||
return false; // exit 2: no developer folder. Never the placeholder's window.
|
||||
}
|
||||
}
|
||||
|
||||
let allowed: boolean | null = null;
|
||||
let refusedAt = 0;
|
||||
|
||||
/**
|
||||
* May the server start `git` right now? False only on a Mac whose only git is Apple's placeholder with
|
||||
* no developer tools behind it. Callers answer false as "git is not installed".
|
||||
*/
|
||||
export function gitSpawnAllowed(): boolean {
|
||||
if (allowed === true) return true;
|
||||
if (allowed === false && Date.now() - refusedAt < GIT_GATE_NEGATIVE_TTL_MS) return false;
|
||||
const platform = process.platform;
|
||||
allowed = !gitSpawnWouldPrompt({
|
||||
platform,
|
||||
gitPath: platform === 'darwin' ? resolveGitOnPath() : null,
|
||||
developerToolsPresent,
|
||||
});
|
||||
if (!allowed) refusedAt = Date.now();
|
||||
return allowed;
|
||||
}
|
||||
|
||||
/** The error a refused spawn stands in for: what Node raises when there is no `git` to start. */
|
||||
export function gitMissingError(): NodeJS.ErrnoException {
|
||||
const err: NodeJS.ErrnoException = new Error('spawn git ENOENT');
|
||||
err.code = 'ENOENT';
|
||||
err.errno = -2;
|
||||
err.syscall = 'spawn git';
|
||||
err.path = 'git';
|
||||
return err;
|
||||
}
|
||||
|
||||
/** Test seam: forget the remembered answer. */
|
||||
export function resetGitGateForTests(): void {
|
||||
allowed = null;
|
||||
refusedAt = 0;
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import { createRequire } from 'node:module';
|
||||
import { promisify } from 'node:util';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import { gitNonInteractiveEnv, redactGitCredentials } from '../git-clone.js';
|
||||
import { gitSpawnAllowed } from '../utils/git-availability.js';
|
||||
import { getInstallInfo } from './self-update.js';
|
||||
import type { RepoIncomingCommit, RepoRemoteRole, RepoRemoteStatus, RepositoryStatusResult } from '../types/update.js';
|
||||
|
||||
@@ -242,6 +243,8 @@ interface GitResult {
|
||||
* spawn would block the event loop for the whole wait.
|
||||
*/
|
||||
async function runGit(args: string[], cwd: string, timeout = EXEC_TIMEOUT_MS): Promise<GitResult> {
|
||||
// Never Apple's placeholder git on a Mac without developer tools (utils/git-availability.ts).
|
||||
if (!gitSpawnAllowed()) return { ok: false, stdout: '', stderr: 'git is not installed' };
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', args, {
|
||||
cwd,
|
||||
|
||||
@@ -4750,7 +4750,6 @@ export function registerSessionRoutes(
|
||||
// would hide them, and giving setCustomModel() that set would put
|
||||
// CLAUDE_CODE_EFFORT_LEVEL back after the constructor stripped it.
|
||||
let qsCustomModelAppliedEnvOverrides: Record<string, string> | undefined;
|
||||
let qsCustomModelLaunchModel: string | undefined;
|
||||
let qsCustomModelSessionId: string | undefined;
|
||||
let qsCustomModelSwapInProgress = false;
|
||||
let qsCustomModelBookkeeping:
|
||||
@@ -4866,7 +4865,6 @@ export function registerSessionRoutes(
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${mode} has no known custom-model mechanism`);
|
||||
}
|
||||
qsCustomModelAppliedEnvOverrides = cmApplied.envOverrides;
|
||||
qsCustomModelLaunchModel = cmApplied.launchModel;
|
||||
qsCustomModelBookkeeping = {
|
||||
endpointId: cmEndpoint.id,
|
||||
modelId: customModel.modelId,
|
||||
@@ -4916,30 +4914,15 @@ export function registerSessionRoutes(
|
||||
tmuxHistoryLimit: qsTerminalHistoryConfig.tmuxHistoryLimit,
|
||||
parentSessionId: qsParentSessionId,
|
||||
};
|
||||
// Force the custom-model selection's launchModel (pi/omp `custom/<id>`, grok's
|
||||
// `[model.<name>]` block name) onto whichever config field the registry says the
|
||||
// CLI's `model` launch param lives in — mirrors Session._withCustomModelLaunchModel,
|
||||
// which the restart-in-place path already uses, rather than a hardcoded per-CLI
|
||||
// branch here that a CLI landing its injection recipe later would silently miss.
|
||||
if (qsCustomModelLaunchModel !== undefined) {
|
||||
const qsCustomModelField = getCli(mode)?.launch.legacyConfigField;
|
||||
if (qsCustomModelField) {
|
||||
const qsSessionOptionsBag = qsSessionOptions as unknown as Record<string, unknown>;
|
||||
qsSessionOptionsBag[qsCustomModelField] = {
|
||||
...((qsSessionOptionsBag[qsCustomModelField] as Record<string, unknown>) ?? {}),
|
||||
model: qsCustomModelLaunchModel,
|
||||
};
|
||||
} else {
|
||||
qsSessionOptions.model = qsCustomModelLaunchModel;
|
||||
}
|
||||
}
|
||||
const session = new Session(qsSessionOptions);
|
||||
|
||||
// Records the selection for session.customModel/getCustomModelForPersist() and future
|
||||
// clear/switch calls, and merges the injected env, which the launch below reads along
|
||||
// with the launch-model config set above (piConfig/grokConfig/ompConfig.model). Nothing
|
||||
// has started yet, so this is never a restart: setCustomModel() is synchronous state,
|
||||
// no tmux IO of its own (see its own doc comment in session.ts).
|
||||
// clear/switch calls, and merges the injected env. The launch below reads both: the
|
||||
// selection's launchModel is forced onto the launch options, never onto the stored
|
||||
// config above, because that config is what a later clear falls back to
|
||||
// (Session._withCustomModelLaunchModel). Nothing has started yet, so this is never a
|
||||
// restart: setCustomModel() is synchronous state, no tmux IO of its own (see its own
|
||||
// doc comment in session.ts).
|
||||
if (qsCustomModelBookkeeping) {
|
||||
session.setCustomModel(qsCustomModelBookkeeping, qsCustomModelAppliedEnvOverrides);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ import { createRequire } from 'node:module';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
import { LAUNCHD_LABEL, SYSTEMD_UNIT } from '../config/service-names.js';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import { gitSpawnAllowed } from '../utils/git-availability.js';
|
||||
import type {
|
||||
EnvironmentBlocker,
|
||||
EnvironmentGate,
|
||||
@@ -429,6 +430,8 @@ export function reconcileUpdateOnBoot(now = Date.now()): void {
|
||||
|
||||
/** Run a command, returning trimmed stdout, or null on any error. */
|
||||
function tryExec(cmd: string, args: string[], cwd?: string, timeout = EXEC_TIMEOUT_MS): string | null {
|
||||
// Never Apple's placeholder git on a Mac without developer tools (utils/git-availability.ts).
|
||||
if (cmd === 'git' && !gitSpawnAllowed()) return null;
|
||||
try {
|
||||
return execFileSync(cmd, args, { cwd, encoding: 'utf-8', timeout, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* @fileoverview The gate in front of every `git` the server starts (src/utils/git-availability.ts).
|
||||
*
|
||||
* On a Mac without the developer tools, `/usr/bin/git` is Apple's placeholder: running it, even as
|
||||
* `git --version`, opens the system's "install the command line developer tools" window. The server did
|
||||
* exactly that on the first page load after a no-admin install (found 2026-11-06 on a new MacBook: the
|
||||
* system log named the Codeman server as the parent of that `git`).
|
||||
*
|
||||
* Two halves: the pure decision, and a scan of `src/` that fails when a file starts git without going
|
||||
* through the gate, so the next git caller cannot bring the window back.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join, relative, sep } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
APPLE_GIT_PLACEHOLDER,
|
||||
developerDirHoldsGit,
|
||||
findOnPath,
|
||||
gitMissingError,
|
||||
gitSpawnAllowed,
|
||||
gitSpawnWouldPrompt,
|
||||
resetGitGateForTests,
|
||||
} from '../src/utils/git-availability.js';
|
||||
|
||||
const never = (): boolean => {
|
||||
throw new Error('must not be asked');
|
||||
};
|
||||
|
||||
describe('gitSpawnWouldPrompt', () => {
|
||||
it('is true only for Apple’s placeholder on a Mac without developer tools', () => {
|
||||
expect(
|
||||
gitSpawnWouldPrompt({ platform: 'darwin', gitPath: APPLE_GIT_PLACEHOLDER, developerToolsPresent: () => false })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('lets the placeholder run once the developer tools are there', () => {
|
||||
expect(
|
||||
gitSpawnWouldPrompt({ platform: 'darwin', gitPath: APPLE_GIT_PLACEHOLDER, developerToolsPresent: () => true })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('never asks about developer tools for a git that is not the placeholder', () => {
|
||||
for (const gitPath of ['/opt/homebrew/bin/git', '/usr/local/bin/git', '/nix/store/x/bin/git']) {
|
||||
expect(gitSpawnWouldPrompt({ platform: 'darwin', gitPath, developerToolsPresent: never })).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves a Mac with no git on the PATH to the spawn’s own ENOENT', () => {
|
||||
expect(gitSpawnWouldPrompt({ platform: 'darwin', gitPath: null, developerToolsPresent: never })).toBe(false);
|
||||
});
|
||||
|
||||
it('is never true off macOS, where /usr/bin/git is a real git', () => {
|
||||
for (const platform of ['linux', 'win32', 'freebsd'] as const) {
|
||||
expect(gitSpawnWouldPrompt({ platform, gitPath: APPLE_GIT_PLACEHOLDER, developerToolsPresent: never })).toBe(
|
||||
false
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOnPath', () => {
|
||||
const has =
|
||||
(...paths: string[]) =>
|
||||
(p: string) =>
|
||||
paths.includes(p);
|
||||
|
||||
it('returns the first match in PATH order', () => {
|
||||
const exists = has('/opt/homebrew/bin/git', '/usr/bin/git');
|
||||
expect(findOnPath('git', '/opt/homebrew/bin:/usr/bin', exists)).toBe('/opt/homebrew/bin/git');
|
||||
expect(findOnPath('git', '/usr/bin:/opt/homebrew/bin', exists)).toBe('/usr/bin/git');
|
||||
});
|
||||
|
||||
it('skips empty and relative entries, which mean the current directory', () => {
|
||||
const exists = has('git', 'bin/git', '/usr/bin/git');
|
||||
expect(findOnPath('git', ':.:bin:/usr/bin', exists)).toBe('/usr/bin/git');
|
||||
});
|
||||
|
||||
it('is null when nothing matches or there is no PATH', () => {
|
||||
expect(findOnPath('git', '/a:/b', () => false)).toBeNull();
|
||||
expect(findOnPath('git', undefined, () => true)).toBeNull();
|
||||
expect(findOnPath('git', '', () => true)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('developerDirHoldsGit', () => {
|
||||
it('needs a git inside the folder xcode-select names', () => {
|
||||
const exists = (p: string) => p === '/Library/Developer/CommandLineTools/usr/bin/git';
|
||||
expect(developerDirHoldsGit('/Library/Developer/CommandLineTools\n', exists)).toBe(true);
|
||||
// A selected Xcode that was deleted since: the placeholder would open its window after all.
|
||||
expect(developerDirHoldsGit('/Applications/Xcode.app/Contents/Developer\n', exists)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses empty and relative answers', () => {
|
||||
expect(developerDirHoldsGit('', () => true)).toBe(false);
|
||||
expect(developerDirHoldsGit(' \n', () => true)).toBe(false);
|
||||
expect(developerDirHoldsGit('relative/dir', () => true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gitMissingError', () => {
|
||||
it('reads as the ENOENT every caller already answers with "git is not installed"', () => {
|
||||
const err = gitMissingError();
|
||||
expect(err.code).toBe('ENOENT');
|
||||
expect(err.message).toBe('spawn git ENOENT');
|
||||
expect(String(err)).toMatch(/enoent/i); // git-worktrees.ts and git-clone.ts match the string form
|
||||
});
|
||||
});
|
||||
|
||||
describe('gitSpawnAllowed', () => {
|
||||
it.skipIf(process.platform === 'darwin')('is always true off macOS and does no IO there', () => {
|
||||
resetGitGateForTests();
|
||||
expect(gitSpawnAllowed()).toBe(true);
|
||||
expect(gitSpawnAllowed()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Scan: no git spawn outside the gate ─────────────────────────────────────
|
||||
|
||||
const SRC = join(__dirname, '..', 'src');
|
||||
|
||||
/** A call that starts `git` as the program (argv form; the server never builds a git shell string). */
|
||||
const GIT_SPAWN = /\b(?:spawn|spawnSync|execFile|execFileSync|execFileAsync|exec|execSync|tryExec)\(\s*['"`]git['"`]/g;
|
||||
|
||||
/**
|
||||
* Files that start git, with how many call sites each has. A new site in one of them, or a new file,
|
||||
* fails here on purpose: add `gitSpawnAllowed()` in front of it (see the module's fileoverview), then
|
||||
* update this table.
|
||||
*/
|
||||
const GATED: Record<string, number> = {
|
||||
'git-clone.ts': 2,
|
||||
'git-fetch.ts': 2,
|
||||
'git-workspace-status.ts': 1,
|
||||
'git-worktrees.ts': 1,
|
||||
'readmymind-collectors.ts': 1,
|
||||
'web/repo-status.ts': 1,
|
||||
'web/self-update.ts': 8,
|
||||
};
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === 'public' || entry.name === 'node_modules') continue;
|
||||
walk(full, out);
|
||||
} else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('every git the server starts goes through the gate', () => {
|
||||
const found: Record<string, number> = {};
|
||||
const ungated: string[] = [];
|
||||
for (const file of walk(SRC)) {
|
||||
const text = readFileSync(file, 'utf8');
|
||||
const sites = text.match(GIT_SPAWN)?.length ?? 0;
|
||||
if (!sites) continue;
|
||||
const rel = relative(SRC, file).split(sep).join('/');
|
||||
found[rel] = sites;
|
||||
if (!/\bgitSpawnAllowed\(\)/.test(text)) ungated.push(rel);
|
||||
}
|
||||
|
||||
it('finds the spawn sites at all (the pattern still matches the code)', () => {
|
||||
expect(Object.keys(found).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('has no file that starts git without calling gitSpawnAllowed()', () => {
|
||||
expect(ungated).toEqual([]);
|
||||
});
|
||||
|
||||
it('matches the reviewed list of sites, so a new one is looked at', () => {
|
||||
expect(found).toEqual(GATED);
|
||||
});
|
||||
|
||||
it('keeps the gate itself free of git', () => {
|
||||
const gate = readFileSync(join(SRC, 'utils', 'git-availability.ts'), 'utf8');
|
||||
expect(gate.match(GIT_SPAWN)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
// @ts-expect-error plain .mjs helper shared with scripts/generate-release-notes.mjs
|
||||
import { buildReleaseNotes, previousVersion } from '../scripts/lib/release-notes.mjs';
|
||||
// @ts-expect-error plain .mjs helper shared with scripts/generate-whats-new.mjs
|
||||
import { versionSection, extractHighlights } from '../scripts/lib/whats-new.mjs';
|
||||
|
||||
const root = resolve(import.meta.dirname, '..');
|
||||
const realChangelog = readFileSync(resolve(root, 'CHANGELOG.md'), 'utf8');
|
||||
|
||||
/** A release the way changesets writes it: one hashed bullet, the rest nested under it. */
|
||||
const CHANGELOG = `# aicodeman
|
||||
|
||||
## 2.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 12024cc: ### Thanks
|
||||
- @someone for the tile grid (#10).
|
||||
- @other for reporting the wheel bug (#11).
|
||||
|
||||
**The tile grid (#10).** Six sessions side by side. Behaviour change: \`Ctrl+G\` opens it now.
|
||||
|
||||
It remembers your layout.
|
||||
|
||||
**Install without admin rights.** Nothing lands outside your home folder.
|
||||
|
||||
**Fixes.**
|
||||
- **The wheel works (#11).** It survives a reload.
|
||||
- **A second fix.** Text.
|
||||
|
||||
**Fixes applied while landing.** One review fix (#10).
|
||||
|
||||
**Docs and tests.** A new guide.
|
||||
|
||||
## 2.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- abcdef1: One line.
|
||||
- A bullet with no hash.
|
||||
|
||||
## 1.9.0
|
||||
|
||||
- **The oldest entry.** Text.
|
||||
`;
|
||||
|
||||
const notes = (version: string, changelog = CHANGELOG): string => buildReleaseNotes(changelog, version);
|
||||
|
||||
describe('release notes', () => {
|
||||
it('drops what only changesets needs: the commit prefix, the type heading and the nesting', () => {
|
||||
const body = notes('2.1.0');
|
||||
expect(body).not.toMatch(/12024cc/);
|
||||
expect(body).not.toMatch(/Minor Changes/);
|
||||
expect(body.split('\n').filter((line) => line.startsWith(' '))).toEqual([]);
|
||||
});
|
||||
|
||||
it('lays the page out in order, with Thanks first', () => {
|
||||
const headings = notes('2.1.0')
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('#'));
|
||||
expect(headings).toEqual([
|
||||
'## Thanks',
|
||||
"## What's new",
|
||||
'### The tile grid (#10)',
|
||||
'### Install without admin rights',
|
||||
'## Fixes',
|
||||
'## Also in this release',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a paragraph with the feature it follows', () => {
|
||||
const body = notes('2.1.0');
|
||||
expect(body.indexOf('It remembers your layout.')).toBeLessThan(body.indexOf('### Install without admin rights'));
|
||||
expect(body.indexOf('It remembers your layout.')).toBeGreaterThan(body.indexOf('### The tile grid'));
|
||||
});
|
||||
|
||||
it('keeps the fixes as a list and the two short sections as run-ins', () => {
|
||||
const body = notes('2.1.0');
|
||||
expect(body).toContain('## Fixes\n\n- **The wheel works (#11).** It survives a reload.\n- **A second fix.** Text.');
|
||||
expect(body).toContain('**Fixes applied while landing.** One review fix (#10).');
|
||||
expect(body).toContain('**Docs and tests.** A new guide.');
|
||||
});
|
||||
|
||||
it('makes a behaviour change stand out, without doubling bold that is already there', () => {
|
||||
expect(notes('2.1.0')).toContain('**Behaviour change:** `Ctrl+G` opens it now.');
|
||||
const bold = '## 1.0.0\n\n- **Fixes.**\n - **Clone.** Text. Behaviour changes: a. **Breaking:** b.\n';
|
||||
const body = notes('1.0.0', bold);
|
||||
expect(body).toContain('**Behaviour changes:** a. **Breaking:** b.');
|
||||
expect(body).not.toContain('****');
|
||||
});
|
||||
|
||||
it('links the comparison with the previous release, and only when there is one', () => {
|
||||
expect(previousVersion(CHANGELOG, '2.1.0')).toBe('2.0.0');
|
||||
expect(notes('2.1.0')).toContain('compare/codeman@2.0.0...codeman@2.1.0');
|
||||
expect(previousVersion(CHANGELOG, '1.9.0')).toBeNull();
|
||||
expect(notes('1.9.0')).not.toContain('Full Changelog');
|
||||
});
|
||||
|
||||
it('keeps one-line changesets as a list, hashed or not', () => {
|
||||
expect(notes('2.0.0')).toContain("## What's new\n\n- One line.\n\n- A bullet with no hash.");
|
||||
});
|
||||
|
||||
it('merges several changesets into one page instead of repeating sections', () => {
|
||||
const changelog = [
|
||||
'## 3.0.0',
|
||||
'### Minor Changes',
|
||||
'- aaaaaaa: ### Thanks\n - @a for one.\n\n **Feature A.** Text.\n\n **Fixes.**\n - **Fix A.** Text.',
|
||||
'### Patch Changes',
|
||||
'- bbbbbbb: ### Thanks\n - @b for two.\n\n **Feature B.** Text.\n\n **Fixes.**\n - **Fix B.** Text.',
|
||||
].join('\n\n');
|
||||
const body = notes('3.0.0', changelog);
|
||||
expect(body.match(/^## Thanks$/gm)).toHaveLength(1);
|
||||
expect(body.match(/^## Fixes$/gm)).toHaveLength(1);
|
||||
expect(body).toContain('### Feature A');
|
||||
expect(body).toContain('### Feature B');
|
||||
expect(body).toContain('- @b for two.');
|
||||
expect(body).toContain('- **Fix B.** Text.');
|
||||
});
|
||||
|
||||
it('does not take a sentence that opens in bold for a headline', () => {
|
||||
const changelog = '## 1.0.0\n\n- **Allow network access** is a new setting, off by default.\n';
|
||||
const body = notes('1.0.0', changelog);
|
||||
expect(body).toContain('**Allow network access** is a new setting, off by default.');
|
||||
expect(body).not.toContain('###');
|
||||
});
|
||||
|
||||
it('keeps text after the Thanks list out of Thanks, and a lead-in line in it', () => {
|
||||
const changelog = [
|
||||
'## 1.0.0',
|
||||
'- ### Thanks',
|
||||
' 1.0.0 follows 0.9.0, so the thanks belong here too:',
|
||||
'',
|
||||
' - @a for a fix.',
|
||||
'',
|
||||
' ',
|
||||
'',
|
||||
' **A feature.** Text.',
|
||||
].join('\n');
|
||||
const body = notes('1.0.0', changelog);
|
||||
expect(body).toMatch(/## Thanks\n\n1\.0\.0 follows 0\.9\.0.*:\n\n- @a for a fix\.\n\n## What's new\n\n!\[a demo\]/);
|
||||
});
|
||||
|
||||
it('keeps credit written as a paragraph under Thanks, where it opened (1.21.0 does this)', () => {
|
||||
const changelog =
|
||||
'## 1.0.0\n\n- ### Thanks\n\n Seven rounds of beta testing by **@someone**.\n\n **A feature.** Text.\n';
|
||||
expect(notes('1.0.0', changelog)).toMatch(
|
||||
/^## Thanks\n\nSeven rounds of beta testing by \*\*@someone\*\*\.\n\n## What's new/
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a fenced code block alone', () => {
|
||||
const changelog = '## 1.0.0\n\n- **A feature.** Text.\n\n ```md\n ### Not a heading\n\n **Fixes.**\n ```\n';
|
||||
const body = notes('1.0.0', changelog);
|
||||
expect(body).toContain('```md\n### Not a heading\n\n**Fixes.**\n```');
|
||||
expect(body).not.toContain('## Fixes');
|
||||
});
|
||||
|
||||
it('returns nothing for a version the changelog does not have', () => {
|
||||
expect(notes('9.9.9')).toBe('');
|
||||
expect(notes('1.0.0', '## 1.0.0\n\n### Patch Changes\n')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('release notes over every release in CHANGELOG.md', () => {
|
||||
const versions: string[] = [...realChangelog.matchAll(/^## (\d+\.\d+\.\d+\S*)\s*$/gm)].map((m) => m[1]);
|
||||
|
||||
/** The words of a text, ignoring only what the page legitimately changes around them. */
|
||||
const words = (text: string): string[] =>
|
||||
text
|
||||
.replace(/^### (?:Major|Minor|Patch) Changes\s*$/gm, '')
|
||||
.replace(/^- [0-9a-f]{7,40}: /gm, '')
|
||||
.replace(/[*#]/g, '')
|
||||
.split(/\s+/)
|
||||
.map((word) => word.replace(/[.:]+$/, ''))
|
||||
.filter((word) => word && word !== '-');
|
||||
|
||||
it('reads the whole changelog', () => {
|
||||
expect(versions.length).toBeGreaterThan(200);
|
||||
});
|
||||
|
||||
it('never loses a word', () => {
|
||||
const lost: string[] = [];
|
||||
for (const version of versions) {
|
||||
const available = new Map<string, number>();
|
||||
for (const word of words(buildReleaseNotes(realChangelog, version))) {
|
||||
available.set(word, (available.get(word) ?? 0) + 1);
|
||||
}
|
||||
for (const word of words(versionSection(realChangelog, version))) {
|
||||
const left = available.get(word) ?? 0;
|
||||
if (left === 0) lost.push(`${version}: ${word}`);
|
||||
available.set(word, left - 1);
|
||||
}
|
||||
}
|
||||
expect(lost).toEqual([]);
|
||||
});
|
||||
|
||||
it('leaves no changesets scaffolding on any page, and opens with Thanks wherever there is one', () => {
|
||||
for (const version of versions) {
|
||||
const body: string = buildReleaseNotes(realChangelog, version);
|
||||
expect(body, version).not.toMatch(/^- [0-9a-f]{7,40}: /m);
|
||||
expect(body, version).not.toMatch(/^#+ (?:Major|Minor|Patch) Changes/m);
|
||||
if (/^ *### Thanks|: ### Thanks/m.test(versionSection(realChangelog, version))) {
|
||||
expect(body.startsWith('## Thanks'), version).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('gives a page every headline the in-app toast announces for the same release', () => {
|
||||
const body: string = buildReleaseNotes(realChangelog, '1.49.0');
|
||||
const highlights: string[] = extractHighlights(versionSection(realChangelog, '1.49.0'));
|
||||
expect(highlights.length).toBeGreaterThan(0);
|
||||
for (const highlight of highlights) expect(body).toContain(highlight);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generate-release-notes.mjs', () => {
|
||||
const script = resolve(root, 'scripts/generate-release-notes.mjs');
|
||||
|
||||
it('prints the page for a version', () => {
|
||||
const out = execFileSync(process.execPath, [script, '1.49.0'], { encoding: 'utf8' });
|
||||
expect(out).toBe(buildReleaseNotes(realChangelog, '1.49.0'));
|
||||
});
|
||||
|
||||
it('prints nothing and fails for an unknown version, so the workflow keeps the changesets text', () => {
|
||||
const run = spawnSync(process.execPath, [script, '0.0.1-nope'], { encoding: 'utf8' });
|
||||
expect(run.status).toBe(1);
|
||||
expect(run.stdout).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Release workflow', () => {
|
||||
const workflow = readFileSync(resolve(root, '.github/workflows/release.yml'), 'utf8');
|
||||
|
||||
it('titles the release after the product and keeps the tag the updater parses', () => {
|
||||
expect(workflow).toContain('-f name="Codeman ${VERSION}"');
|
||||
expect(workflow).toContain('-f tag_name="$NEW_TAG"');
|
||||
});
|
||||
|
||||
it('cannot lose the retag to a failed format: the script is guarded and the PATCH is not', () => {
|
||||
const guard = workflow.indexOf('if node scripts/generate-release-notes.mjs "$VERSION"');
|
||||
const patch = workflow.indexOf('gh api -X PATCH');
|
||||
expect(guard).toBeGreaterThan(-1);
|
||||
expect(guard).toBeLessThan(patch);
|
||||
expect(workflow.slice(guard, patch)).toContain('fi\n');
|
||||
expect(workflow.slice(patch)).toContain('"${BODY_ARGS[@]}"');
|
||||
});
|
||||
});
|
||||
@@ -204,6 +204,34 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', ()
|
||||
expect(restartSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves the caller's own model in the stored config, which is what a later clear falls back to", async () => {
|
||||
const res = await quickStart({
|
||||
caseName: 'cm-pi-own-model',
|
||||
mode: 'pi',
|
||||
piConfig: { model: 'anthropic/claude-x' },
|
||||
customModel: { endpointId: 'ep1', modelId: 'qwen3' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const { sessionId } = res.json();
|
||||
const session = ctx.sessions.get(sessionId) as unknown as Session;
|
||||
expect(session.getCustomModelForPersist()?.launchModel).toBe('custom/qwen3');
|
||||
expect(session.toState().piConfig?.model).toBe('anthropic/claude-x');
|
||||
});
|
||||
|
||||
it('stores no model at all for a caller that chose none', async () => {
|
||||
const res = await quickStart({
|
||||
caseName: 'cm-codex-no-model',
|
||||
mode: 'codex',
|
||||
customModel: { endpointId: 'ep1', modelId: 'qwen3' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const session = ctx.sessions.get(res.json().sessionId) as unknown as Session;
|
||||
expect(session.getCustomModelForPersist()?.launchModel).toBe('qwen3');
|
||||
expect(session.toState().codexConfig?.model).toBeUndefined();
|
||||
});
|
||||
|
||||
it('404s for an unknown endpoint id', async () => {
|
||||
const res = await quickStart({
|
||||
caseName: 'cm-ghost',
|
||||
|
||||
@@ -195,6 +195,53 @@ describe('restartCli() must not kill a working pane', () => {
|
||||
});
|
||||
|
||||
describe('launchModel reaches the CLI through its own launch param', () => {
|
||||
/** A session the route just built: no pane yet, so its launch is `createSession`. */
|
||||
function freshSession(mode: SessionMode, extra: Record<string, unknown> = {}) {
|
||||
mkdirSync(workingDir, { recursive: true });
|
||||
const mux = new TmuxManager();
|
||||
const create = vi.spyOn(mux, 'createSession');
|
||||
vi.spyOn(mux, 'muxSessionExists').mockReturnValue(true);
|
||||
const respawn = vi.spyOn(mux, 'respawnPane').mockResolvedValue(4242);
|
||||
const session = new Session({ workingDir, mode, mux, useMux: true, ...extra });
|
||||
sessions.push(session);
|
||||
return { session, create, respawn };
|
||||
}
|
||||
|
||||
it('the first launch carries it too, and a clear relaunches on the model the session was created with', async () => {
|
||||
const { session, create, respawn } = freshSession('pi', { piConfig: { model: 'anthropic/claude-x' } });
|
||||
session.setCustomModel(
|
||||
{ endpointId: 'ep', modelId: 'qwen3', envKeys: ['HOME'], configDir: '/tmp/cfg', launchModel: 'custom/qwen3' },
|
||||
{ HOME: '/tmp/cfg' }
|
||||
);
|
||||
await session.startInteractive();
|
||||
expect(create.mock.calls[0][0].piConfig?.model).toBe('custom/qwen3');
|
||||
expect(session.toState().piConfig?.model).toBe('anthropic/claude-x');
|
||||
|
||||
session.setCustomModel(undefined);
|
||||
expect(await session.restartCli()).toBe(true);
|
||||
expect(respawn.mock.calls[0][0].piConfig?.model).toBe('anthropic/claude-x');
|
||||
});
|
||||
|
||||
it('a clear on a session created with no model relaunches with none', async () => {
|
||||
const { session, create, respawn } = freshSession('codex');
|
||||
session.setCustomModel(
|
||||
{ endpointId: 'ep', modelId: 'qwen3', envKeys: ['CODEX_HOME'], configDir: '/tmp/cfg', launchModel: 'qwen3' },
|
||||
{ CODEX_HOME: '/tmp/cfg' }
|
||||
);
|
||||
await session.startInteractive();
|
||||
expect(create.mock.calls[0][0].codexConfig?.model).toBe('qwen3');
|
||||
|
||||
session.setCustomModel(undefined);
|
||||
expect(await session.restartCli()).toBe(true);
|
||||
expect(respawn.mock.calls[0][0].codexConfig?.model).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves the launch untouched without a selection', async () => {
|
||||
const { session, create } = freshSession('pi', { piConfig: { model: 'anthropic/claude-x', thinking: 'low' } });
|
||||
await session.startInteractive();
|
||||
expect(create.mock.calls[0][0].piConfig).toEqual({ model: 'anthropic/claude-x', thinking: 'low' });
|
||||
});
|
||||
|
||||
it('pi: the selection forces piConfig.model on the respawn options, leaving the stored config alone', async () => {
|
||||
const { session, respawn } = liveSession('pi', { piConfig: { model: 'anthropic/claude-x', thinking: 'low' } });
|
||||
session.setCustomModel(
|
||||
|
||||
Reference in new issue
Block a user