diff --git a/CHANGELOG.md b/CHANGELOG.md index 32f13e9dc..1d6a19495 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [1.91.85.0] - 2026-10-11 + +**`/cso` works on Windows Git 2.56 and gets Docker on macOS, `/test-audit` and `/ship` loop correctly under zsh, and the deploy skills stop at the honest point (Wave 9 Lane D).** + +- **`/cso` on Git for Windows 2.56.** Every CSO Git child, and the eval fixture and producer identity reads, now set `GIT_CONFIG_GLOBAL=/dev/null` on every platform. Git 2.56 rejects `NUL` there ("unable to access 'NUL'"), so `/cso` could not read any repository on Windows and `doctor` reported `static-snapshot: missing`. `GIT_CONFIG_NOSYSTEM=1` stays, POSIX is unchanged, and a new free test that also runs in the Windows CI shards proves `git config --global --list` is empty under the CSO environment. Thanks @Alfabit7 (#3082). +- **`/cso` admits the local Docker socket on macOS.** Bun's `realpathSync` opens its target to resolve it, and a Unix socket throws `EOPNOTSUPP`, so every macOS run rejected Docker and fell back to static-only. On exactly that error the socket path is now resolved without opening it (canonical parent plus `lstat`/`readlink`); the endpoint must still be a local `unix://` socket pinned by device and inode, TCP and SSH endpoints are still rejected, and Linux takes the unchanged path. The registry-broker socket mount uses the same resolver. Thanks @nathanshox (#3094). +- **zsh-safe file loops.** `/test-audit`'s near-duplicate seed and `/ship`'s base-control fixture copy looped with `for f in $VAR`, which zsh (the Bash tool's shell on macOS) runs once with the whole list. Both now split the list explicitly, and a static test rejects any generated bash block that loops over an unquoted variable. Thanks @vilano93 (#3109). +- **`/setup-deploy` stops when nothing is live yet.** The deploy-trigger question separates "nothing is deployed yet" from "this project never deploys". The first stops and writes nothing, telling you to bring the site up with the platform's own tool and re-run `/setup-deploy`; the second still records the `none` configuration. Thanks @mikehasa (#3095). +- **`/land-and-deploy` runs a recorded command trigger only with approval.** When Deploy Configuration records a command as the deploy trigger, Step 5 says production has not moved, names the command and asks you to run it, or runs it only with your explicit approval, before polling for a deployment tied to the merge; a zero exit is not revision proof. Thanks @mikehasa (#3096). +- **TODOS.md** records the Wave 9 triage: the remaining open P2/P3 bugs by area with their root-cause files and sizes, the parked items (#2118, #2623, #3061, #3084's legacy Cursor folders, #1315's session wording, the `gbrain put` stdin pipe from #1675) and the 73 feature requests grouped into twelve themes with counted demand. + ## [1.91.84.0] - 2026-10-11 **A paid CSO baseline can be scored, and it measures what audits found. Three producer/scorer mismatches made gstack's own collect reject every real producer receipt from the first full baseline. Collect and score now accept real receipts of every kind, a test feeds them through, and findings count from every finished report, with completeness kept as its own measured rate.** diff --git a/TODOS.md b/TODOS.md index c1572d075..b2aadc566 100644 --- a/TODOS.md +++ b/TODOS.md @@ -2,6 +2,72 @@ ## NEXT PRIORITY +### P2/P3: open bugs by area after the Wave 9 triage (filed 2026-10-11) + +The 158 older open issues were re-checked against main 5cb5e1c (v1.91.83.0). Of the 30 still-valid bugs, Wave 9 lanes take #915, #1080, #1081, #1150, #1315 and #1675 and park #2118 and #2623 (next section). The rest stay open here: issue, root-cause file, size. Bugs already tracked elsewhere in this file point there instead of repeating it. + +- **Plan reviews and planning artifacts** + - #280: plan-eng-review never reads the CEO plan. Its Step 0 discovery has no `ceo-plans/` lookup (only `design-html/SKILL.md.tmpl` and `bin/gstack-context-recovery` read it). Add a `gstack-ceo-plan-find` sibling of `bin/gstack-design-doc-find`; leave the per-branch review log alone. **Effort:** S. **Priority:** P3. + - #542: an office-hours design doc written in host plan mode lands in `~/.claude/plans/`, which `bin/gstack-design-doc-find` never reads, so the two copies drift. Pick one authority and warn on a stale copy. **Effort:** M. **Priority:** P2. + - #1048: "smallest clear diff" in `plan-eng-review/SKILL.md.tmpl` and `plan-ceo-review/SKILL.md.tmpl` has no schema-design carve-out, so it biases reviews toward JSON blobs. Add one tradeoff bullet, with a paid before/after case. **Effort:** S. **Priority:** P3. + - #1791: every plan-eng-review run writes a fresh `tasks-eng-review-.jsonl` with no branch in the name and no reconciliation (`scripts/resolvers/tasks-section.ts`). Put the branch in the name and supersede earlier sets. **Effort:** M. **Priority:** P3. + - #1957: /autoplan Phase 0 prints no matched-terms line for UI scope and keeps UI and DX terms in two places (`bin/gstack-autoplan-snapshot.ts`, `lib/autoplan-prompts.ts`). One shared terms module, and a `borderline` flag in the printed line. **Effort:** M. **Priority:** P3. +- **Review** + - #356: `/review` has no repo-convention check, so a `.bat` added to a bash-and-Make repo passes. Add it to `review/specialists/maintainability.md`. **Effort:** S. **Priority:** P3. +- **Office hours and design** + - #340: the approved sketch stays in a purgeable `mktemp` dir and no downstream design skill reads it (`scripts/resolvers/design.ts`). Copy it next to the design doc and have plan-design-review and design-review read it. **Effort:** S. **Priority:** P3. +- **Questions** + - #1319: the AskUserQuestion format (`scripts/resolvers/preamble/generate-ask-user-format.ts`) has no layout or length rule, so long briefs render as a wall of text. Add block separation and per-block caps, with a free test that includes a CJK case. **Effort:** M. **Priority:** P2. +- **Setup and upgrade** + - #954: vendored installs get absolute symlinks from `_link_or_copy` in `setup`. Use relative links when the source sits in the same skills dir. **Effort:** S. **Priority:** P3. + - #1022: a parent repo that tracks the install dir silently reverts VERSION after each upgrade. Detect it with `git ls-files --error-unmatch` in `setup` and `gstack-doctor`, and print the `git rm --cached` commands. **Effort:** S. **Priority:** P3. + - #2120: `bin/gstack-patch-names` and setup's per-skill link loops fork about four times per skill, which deadlocks x64-emulated Git Bash on Windows ARM64. Make the read path fork-free. Unverified without an ARM64 Windows host. **Effort:** M. **Priority:** P2. + - #2378: update-check compares VERSION while `/gstack-upgrade` installs HEAD. Tracked under the v1.67 fix-wave deferrals ("#2378 release-tag upgrade semantics"). **Priority:** P3. +- **Build** + - #407: `bun build --compile` in `scripts/build.sh` writes straight to `dist/`, and Bun's rename fails on virtiofs devcontainer mounts. Compile to a local temp dir and move. Repro unconfirmed on current Bun. **Effort:** S. **Priority:** P2. +- **Windows binaries** + - #2124, #2595: Smart App Control blocks the locally compiled browse.exe. Tracked under "Windows follow-ups from the October fix wave"; blocked on Garry's signing account. **Priority:** P2. +- **Safety hooks** + - #1060: tracked as "Quote-aware masking in `check-careful.sh`" under the Oct 8 safety hooks follow-ups. **Priority:** P3. +- **Sessions** + - #1651 part B: tracked under the office-hours and timeline follow-ups above. **Priority:** P3. +- **/cso** + - #2993: tracked under the /cso capacity follow-ups above. **Priority:** P2. +- **Dependency security ledgers** (not code defects; keep open until the trigger) + - #2753: `.osv-scanner.toml` ignores image-size (via html-to-docx) until 2026-11-30. Bump it once a fixed release exists, or re-justify at expiry. **Effort:** S. **Priority:** P3. + - #2754: `.osv-scanner.toml` ignores `@anthropic-ai/sdk` 0.81.0 under the pinned claude-agent-sdk until 2026-11-30. Drop it with the next harness bump. **Effort:** S. **Priority:** P3. +- **Tests and CI** + - #2756: the periodic lane. Tracked under "Periodic-lane stabilization (#2756)". **Priority:** P2. + - #2967: `test/redact-prepush-scan-range.test.ts` (and its rebase twin) run fixture pushes with the inherited global config, so a developer's `core.hooksPath` pre-push runs inside the fixture. Set `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_NOSYSTEM=1` in the helper. **Effort:** S. **Priority:** P3. + +### P3: parked in Wave 9 (filed 2026-10-11) + +Each item was decided in the Wave 9 plan and parked with its reason. Credit the reporter or contributor when it is picked up. + +- **Fold `triggers:` into skill descriptions (#2118)** — `scripts/gen-skill-docs.ts` folds only `voice-triggers:`, and the trimmed description keeps one sentence, so natural-language triggers never reach the listing. Parked because it conflicts with the catalog-trim design and would regenerate every skill. Mechanism when picked up: a `when_to_use:` frontmatter key for the Claude host, built from `triggers:`, deduped, and kept inside the catalog byte budget (Claude Code's 1,536-character combined listing limit); hosts that allowlist name and description get a bounded "Use when…" sentence or nothing. **Effort:** M. **Priority:** P2. +- **Plan-tune signals stuck at 0.5 (#2623)** — `hosts/claude/hooks/question-log-hook.ts` logs the UI label, while `scripts/psychographic-signals.ts` looks up stable option keys, so no row ever matches. The two-line key mapping is capture plumbing only and does not fix saturation. Acceptance criteria: labels map to stable option keys at log time; `profile.inferred` records contributing events per dimension; per-event and per-session accumulation is bounded so a dimension cannot pin at 1.0; `{skill}-{slug}` ids get a hinted fallback. Needs a design call on the key source. **Effort:** M. **Priority:** P2. +- **Stateless, instruction-only host mode (#3061, @m-check1B)** — hosted multi-tenant runtimes have no install, state root or helper binaries, but skill bodies still read and write `$GSTACK_STATE_ROOT`, call `bin/gstack-*` and log telemetry. Deferred to the new-hosts theme below. Requirements kept on record: tenant-owned state (a `capabilities.state: 'none'` host renders host-provided hooks instead of shell steps), named artifact hooks (`ARTIFACT_SAVE`/`ARTIFACT_FIND` per kind, `WEB_RESEARCH`, `SECRET_SCAN`, `USER_PREF`) mapped the way the gbrain resolvers already are, a generator check that fails a stateless render containing `~/.gstack`, `$GSTACK_`, `bin/gstack-*` or `--event-type`, and the `claude` host's output byte-identical. The contributor offered an office-hours overlay plus the generator check as a first PR. **Effort:** L. **Priority:** P3. +- **Legacy unprefixed Cursor skill folders (#3084)** — 1.31 Cursor installs created `~/.cursor/skills//SKILL.md` links; 1.91 links `gstack-*` folders and `_prune_stale_generated` in `setup` only looks at `gstack-*` names, so Cursor can load two skills with the same name. `setup` already removes old unprefixed Claude entries; Cursor needs the same owned-link cleanup (remove only links that point into a gstack checkout). Split from #3084's data-loss fix, which Wave 9 Lane A0 ships. **Effort:** S. **Priority:** P3. +- **`/retro global` says what a "session" is (#1315, third problem)** — `bin/gstack-global-discover.ts` counts transcript files and rollouts, so cron- or agent-driven repos show hundreds of "sessions" with no interactive work. Annotate the retro output that sessions are tool invocations or files, not distinct human working sessions. Problems 1 and 2 are fixed (v1.91.66.0 and Wave 9 Lane F). **Effort:** S. **Priority:** P3. +- **`gbrain put` stdin pipe in `lib/code-intelligence/gbrain-adapter.ts` (#1675)** — `put` sends the document body on stdin (`#runInput(["put", slug], body)`), the same shape #1675 hit on Windows in setup-gbrain's smoke test, which Wave 9 Lane G changes to a temp file. Check gbrain's stdin handling on Windows at the pinned version first; if it is still broken, pass the body through a private temp file or the MCP `put_page` path. **Effort:** S. **Priority:** P3. + +### P3: feature themes from the Wave 9 triage (filed 2026-10-11) + +The 73 open feature requests, grouped by theme with counted demand (issues in the theme). Each issue closes with a link to its theme here; the three largest cross-cutting themes (new hosts, provider pluralism, install footprint) stay open as tracking issues. Pick a theme up as a whole and credit every linked reporter. + +- **New hosts (8 issues, plus #3061 above and 9 PRs parked on Oct 8)** — Kimi CLI (#1072), Gemini CLI (#1247), Pi Agent (#1288), Claude Desktop Cowork (#1345), Letta Code (#2117), team mode for non-Claude hosts (#890), `/retro global` discovery of Antigravity (#1977) and Kimi Code / OMP sessions (#2917). Waits for the host-config work; see "Parked contributor ideas (Oct 8 triage)" for the parked host PRs. **Priority:** P3. +- **Provider pluralism (7 issues, plus #3122)** — Gemini CLI as an outside-voice backend (#619), non-OpenAI image providers in `design` (#990), Codex-native `image_gen` in the `design` binary's own generate, evolve and prototype calls (#2174; design-shotgun itself already uses `$imagegen` on Codex since #2175), GPT-6 Sol/Luna tiers in Codex call routing (#2943), a `/grok` review port (#2947), Codex review token usage (#2948), Codex refresh-token recovery docs (#1542), and a configurable PR review bot instead of hard-coded Greptile in /review, /ship and /retro (#3122, folded here by decision D4). One provider seam serves all of them. **Priority:** P3. +- **Install footprint and layout (7 issues)** — skills.sh distribution (#113), `~/.gstack` as the global install path (#753), a repo-local install that keeps state in the project (#1235), five compiled binaries each bundling Bun (#1245), Windows junctions instead of copies (#1877), the 1.1 GB `~/.claude/skills` footprint (#2199), and a skill-only mode (#2528). Decision D6: setup keeps the staged copy on Windows, because it works today and links change what every cleanup deletes; #1943 (@JonasFocus, junctions) and #2129 (@TTmo123, symlinks with the privilege check) are closed with credit and their designs stay here as the starting point. See also "install footprint follow-ups from the October fix wave". **Priority:** P3. +- **New skills (12 issues)** — GUI kanban for plan reviews (#247), journey maps (#729), UI-doc review (#851), assumption verification `/avl` (#1058), `/a11y-check` (#1104), `/env-check` (#1106), native plan writing (#1170), `/plan-experiment` and `/review-experiment` (#1195), `/plan-status` (#1343), language-specific reviewers (#1753), `/benchmark-mobile` (#1754), session start/end skills (#2088). **Priority:** P3. +- **Ship, review and safety (9 issues)** — `/review` shadowing the native command (#422), a silent-deploy-failures checklist section (#2089), Conductor TODO proposals and a /ship block on unresolved ones (#2090), a light-ship mode (#2188), auditing an issue before filing it (#2642), maintainer-gated CI versus failures in landing-report (#2766), Flutter pubspec versions with build metadata (#2833), a deterministic fact-consistency check for document skills (#2887), a dual-engine /careful guard (#2888). **Priority:** P3. +- **Planning reviews (8 issues)** — marketplace-aware skill recommendations (#419), founder-signal reasoning in the YC recommendation (#513), grounded verification of paths, math and flags in spec reviews (#973), a substrate-neutral existing-capability check (#1685), `/autoplan --tier` (#1955), autoplan workspace safeguards (#1959), splitting one-way architecture forks from two-way defects (#2490), an evidence ledger for "is demand real?" (#2350). **Priority:** P3. +- **State root and project identity (6 issues)** — repo-local portable context artifacts (#1161), design-doc archive and prune (#1536), umbrella directories over several repos (#1923), reclaiming per-repo `.gstack` state (#2627), nested repos declaring themselves projects (#2649), references and disposition on decision events (#2678). **Priority:** P3. +- **Browser and mobile QA (6 issues)** — a remote browser via `BROWSER_WS_ENDPOINT` (#974), per-session tab isolation (#1961), an opt-in User-Agent suffix and default headers (#2459), skipping the Aside probe on Linux and Windows (#2864), activity-ring copies on caught-up reads (#2973), touch input and mobile Safari (#3058). **Priority:** P3. +- **Prompt cost and skill shape (4 issues)** — a response-header template (#1376), caching preamble output within a session (#1572), the Fable 5 prompting-guide audit (#1994), per-skill benefit in `gstack-context-bill` (#2889). **Priority:** P3. +- **Customization and localization (3 issues)** — locale-aware skills (#481), a tutorial on new customization dimensions (#489), a `SKILL.local.md` overlay (#1759). **Priority:** P3. +- **Retro (2 issues)** — a team-scoped multi-repo retro (#402), ADR and issue awareness in the narrative (#2284). **Priority:** P3. +- **CI supply chain (1 issue)** — artifact attestations for the GHCR CI images (#2003). **Priority:** P3. + ### P2/P3: office-hours and timeline follow-ups (filed 2026-10-08, v1.91.54.0) Left open by the office-hours tier fix (#2801, #879, #1049, #1958, #1723, #1651 part A). diff --git a/VERSION b/VERSION index 2118139f9..70e0abf62 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.84.0 +1.91.85.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 9611a81e5..64c333f7a 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.84.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.85.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/land-and-deploy/SKILL.md b/land-and-deploy/SKILL.md index 5501c50b1..b9781fc12 100644 --- a/land-and-deploy/SKILL.md +++ b/land-and-deploy/SKILL.md @@ -811,6 +811,10 @@ record for `DEPLOY_SHA`, then Step 7. No record means deployment UNVERIFIED, not Run only the configured read-only status command. Check its exit code and revision output; a generic health check cannot certify a new deployment. +A recorded command trigger follows Step 5 route 2: it runs from a checkout of the default +branch, by the user or by you only with their explicit approval. A zero exit or a reachable +URL is not revision proof on its own. + ### Common: Timing and failure handling Record deploy start time. Show progress every 2 minutes: "Deploy is still running... ({X}m so far). This is normal for most platforms." diff --git a/land-and-deploy/SKILL.md.tmpl b/land-and-deploy/SKILL.md.tmpl index 18b9a37c3..f3c68b354 100644 --- a/land-and-deploy/SKILL.md.tmpl +++ b/land-and-deploy/SKILL.md.tmpl @@ -284,6 +284,10 @@ record for `DEPLOY_SHA`, then Step 7. No record means deployment UNVERIFIED, not Run only the configured read-only status command. Check its exit code and revision output; a generic health check cannot certify a new deployment. +A recorded command trigger follows Step 5 route 2: it runs from a checkout of the default +branch, by the user or by you only with their explicit approval. A zero exit or a reachable +URL is not revision proof on its own. + ### Common: Timing and failure handling Record deploy start time. Show progress every 2 minutes: "Deploy is still running... ({X}m so far). This is normal for most platforms." diff --git a/land-and-deploy/sections/merge-and-deploy.md b/land-and-deploy/sections/merge-and-deploy.md index 273cd780a..46bd99a10 100644 --- a/land-and-deploy/sections/merge-and-deploy.md +++ b/land-and-deploy/sections/merge-and-deploy.md @@ -205,14 +205,18 @@ Select the production route below, then complete Step 5a **before executing that route**. The docs-only no-deploy route can finish immediately; it needs no staging offer. 1. Matching deploy run/platform release: monitor it in Step 6, even for docs-only - (it may be a docs site). A configured trigger whose run has not appeared remains - pending; poll for the matching revision within Step 6's deadline, not another run. -2. Explicit `VERIFY_URL`: run Step 7 even for docs-only. Without deployment-revision + (it may be a docs site). A configured workflow or platform trigger whose run has not + appeared remains pending; poll for the matching revision within Step 6's deadline, not another run. +2. Recorded command trigger (Deploy Configuration records a command as the deploy + trigger): nothing deploys until it runs. Tell the user production has not moved yet, + name the command, and ask them to run it, or run it yourself only with their explicit + approval. Then poll Step 6 status for `MERGE_SHA`; a zero exit is not revision proof. +3. Explicit `VERIFY_URL`: run Step 7 even for docs-only. Without deployment-revision evidence, report site health separately from whether this change is live. -3. `DOCS_ONLY=true`, no explicit URL, and no triggered/expected deployment: record +4. `DOCS_ONLY=true`, no explicit URL, and no triggered/expected deployment: record SKIPPED (docs-only), then Step 9 with MERGED — NO DEPLOY NEEDED. Unknown deployment detection is not proof that nothing was triggered; use the question below instead. -4. Otherwise use configured production URL/status checks in Steps 6-7. If neither +5. Otherwise use configured production URL/status checks in Steps 6-7. If neither a usable URL nor deploy status exists, ask once. Also ask when Step 6 finishes without a production URL needed for canary: - **Re-ground:** "PR #NNN is merged. {Known deploy state}. I need a URL to check diff --git a/land-and-deploy/sections/merge-and-deploy.md.tmpl b/land-and-deploy/sections/merge-and-deploy.md.tmpl index d76e854fe..c3c69f717 100644 --- a/land-and-deploy/sections/merge-and-deploy.md.tmpl +++ b/land-and-deploy/sections/merge-and-deploy.md.tmpl @@ -203,14 +203,18 @@ Select the production route below, then complete Step 5a **before executing that route**. The docs-only no-deploy route can finish immediately; it needs no staging offer. 1. Matching deploy run/platform release: monitor it in Step 6, even for docs-only - (it may be a docs site). A configured trigger whose run has not appeared remains - pending; poll for the matching revision within Step 6's deadline, not another run. -2. Explicit `VERIFY_URL`: run Step 7 even for docs-only. Without deployment-revision + (it may be a docs site). A configured workflow or platform trigger whose run has not + appeared remains pending; poll for the matching revision within Step 6's deadline, not another run. +2. Recorded command trigger (Deploy Configuration records a command as the deploy + trigger): nothing deploys until it runs. Tell the user production has not moved yet, + name the command, and ask them to run it, or run it yourself only with their explicit + approval. Then poll Step 6 status for `MERGE_SHA`; a zero exit is not revision proof. +3. Explicit `VERIFY_URL`: run Step 7 even for docs-only. Without deployment-revision evidence, report site health separately from whether this change is live. -3. `DOCS_ONLY=true`, no explicit URL, and no triggered/expected deployment: record +4. `DOCS_ONLY=true`, no explicit URL, and no triggered/expected deployment: record SKIPPED (docs-only), then Step 9 with MERGED — NO DEPLOY NEEDED. Unknown deployment detection is not proof that nothing was triggered; use the question below instead. -4. Otherwise use configured production URL/status checks in Steps 6-7. If neither +5. Otherwise use configured production URL/status checks in Steps 6-7. If neither a usable URL nor deploy status exists, ask once. Also ask when Step 6 finishes without a production URL needed for canary: - **Re-ground:** "PR #NNN is merged. {Known deploy state}. I need a URL to check diff --git a/lib/cso/docker.ts b/lib/cso/docker.ts index 55857ae22..8519d5ca1 100644 --- a/lib/cso/docker.ts +++ b/lib/cso/docker.ts @@ -1,5 +1,5 @@ import * as fs from 'node:fs'; -import { join } from 'node:path'; +import { basename, isAbsolute, join } from 'node:path'; import { canonical, CsoError, MAX_OUTPUT, sha256 } from './contracts'; import { spawn } from 'node:child_process'; import { dirname } from 'node:path'; @@ -24,6 +24,28 @@ export const ISOLATION_POLICY_HASH = sha256( }), ); +// Bun resolves realpath on macOS by opening the target and asking the kernel +// for its path (F_GETPATH). A Unix socket cannot be opened that way and the +// call throws EOPNOTSUPP even though the socket exists, which would reject +// every local Docker endpoint on macOS. Resolve the leaf by hand instead: +// canonicalize the parent directory, then follow symbolic links on the final +// component without ever opening it. +export function realpathSocket(path: string): string { + try { + return fs.realpathSync(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EOPNOTSUPP') throw error; + } + let current = path; + for (let hops = 0; hops < 40; hops++) { + const parent = fs.realpathSync(dirname(current)); + const resolved = join(parent, basename(current)); + if (!fs.lstatSync(resolved).isSymbolicLink()) return resolved; + const target = fs.readlinkSync(resolved); + current = isAbsolute(target) ? target : join(parent, target); + } + throw new Error('Too many symbolic links while resolving a Unix socket path'); +} export interface DockerEndpoint { uri: string; socket: string; @@ -106,7 +128,7 @@ export async function dockerEndpoint( const requestedSocket = uri.slice('unix://'.length); let socket = ''; try { - socket = fs.realpathSync(requestedSocket); + socket = realpathSocket(requestedSocket); } catch { throw new CsoError('ISOLATION_FAILED', 'Pinned local Docker socket is unavailable'); } @@ -799,7 +821,7 @@ export class DockerGroup { if (spec.readonlyArchiveDirectory) directoryMount(spec.readonlyArchiveDirectory, '/archives', true); if (spec.registrySocket) { const stat = fs.lstatSync(spec.registrySocket), - real = fs.realpathSync(spec.registrySocket); + real = realpathSocket(spec.registrySocket); if ( !stat.isSocket() || stat.isSymbolicLink() || diff --git a/lib/cso/process.ts b/lib/cso/process.ts index 618184756..e24824a2d 100644 --- a/lib/cso/process.ts +++ b/lib/cso/process.ts @@ -62,6 +62,9 @@ export function executable(name: string): string { } throw new CsoError('TOOL_UNAVAILABLE', `${name} is not installed in a trusted system executable directory`); } +// Git for Windows 2.56 rejects GIT_CONFIG_GLOBAL=NUL ("unable to access 'NUL'"); its MSYS +// path layer maps /dev/null, so one spelling empties the global config on every platform. +export const EMPTY_GLOBAL_GIT_CONFIG = '/dev/null'; export function childEnvironment(home: string): Record { return { PATH: TRUSTED_PATH, @@ -70,7 +73,7 @@ export function childEnvironment(home: string): Record { LC_ALL: 'C.UTF-8', TZ: 'UTC', GIT_CONFIG_NOSYSTEM: '1', - GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null', + GIT_CONFIG_GLOBAL: EMPTY_GLOBAL_GIT_CONFIG, GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', GIT_ATTR_NOSYSTEM: '1', diff --git a/package.json b/package.json index 26421cedb..d88d09008 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.84", + "version": "1.91.85", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/scripts/cso-eval-producer.ts b/scripts/cso-eval-producer.ts index 908951ad2..58e9978ff 100644 --- a/scripts/cso-eval-producer.ts +++ b/scripts/cso-eval-producer.ts @@ -6,7 +6,7 @@ import { execFileSync } from 'node:child_process'; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { readBoundedStable } from '../lib/cso/bounded-file'; import { CsoError } from '../lib/cso/contracts'; -import { executable, redact } from '../lib/cso/process'; +import { EMPTY_GLOBAL_GIT_CONFIG, executable, redact } from '../lib/cso/process'; import { atomicWriteSync } from '../lib/fs-atomic'; import { redactProducerText, sourceCredentialValues, type OutputRedaction } from './cso-eval-output-redaction'; import { resolveClaudeCommand } from '../lib/claude-bin'; @@ -241,7 +241,7 @@ export function inventoryProducerArtifacts(helperHome: string): ProducerArtifact function repositoryIdentity(root: string): string { const nullPath = process.platform === 'win32' ? 'NUL' : '/dev/null'; - const env = { PATH: process.env.PATH ?? '', LANG: 'C', LC_ALL: 'C', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: nullPath, GIT_ATTR_NOSYSTEM: '1', GIT_OPTIONAL_LOCKS: '0' }; + const env = { PATH: process.env.PATH ?? '', LANG: 'C', LC_ALL: 'C', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: EMPTY_GLOBAL_GIT_CONFIG, GIT_ATTR_NOSYSTEM: '1', GIT_OPTIONAL_LOCKS: '0' }; const git = executable('git'); const config = readBoundedStable(join(root, '.git', 'config'), 1024 * 1024, 'Producer Git configuration').toString('utf8'); if (/^\s*\[\s*include(?:if)?(?=[\s."\]])/im.test(config)) throw sourceError('.git/config', 'contains an include directive'); diff --git a/scripts/cso-eval.ts b/scripts/cso-eval.ts index 67f6e7b1a..8c19ae1a2 100644 --- a/scripts/cso-eval.ts +++ b/scripts/cso-eval.ts @@ -7,7 +7,7 @@ import { dirname, join, resolve } from 'node:path'; import { readBoundedStable } from '../lib/cso/bounded-file'; import { canonical } from '../lib/cso/contracts'; import { validateRuntimeCatalog, type RuntimeCatalog } from '../lib/cso/runtime-catalog'; -import { executable } from '../lib/cso/process'; +import { EMPTY_GLOBAL_GIT_CONFIG, executable } from '../lib/cso/process'; import { atomicWriteSync } from '../lib/fs-atomic'; import { loadCorpusManifest, materializeCase, publicCorpus, STACKS, validateCorpusManifest, type CorpusManifest, type EvalCorpus, type EvalStack, type EvalVariant } from '../test/fixtures/cso-eval/materialize'; import { @@ -317,7 +317,7 @@ function initializeFixtureRepository(path: string): void { const nullPath = process.platform === 'win32' ? 'NUL' : '/dev/null'; const env = { PATH: process.env.PATH ?? '', LANG: 'C', LC_ALL: 'C', - GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: nullPath, GIT_ATTR_NOSYSTEM: '1', + GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: EMPTY_GLOBAL_GIT_CONFIG, GIT_ATTR_NOSYSTEM: '1', GIT_AUTHOR_NAME: 'CSO Eval', GIT_AUTHOR_EMAIL: 'cso-eval@invalid', GIT_COMMITTER_NAME: 'CSO Eval', GIT_COMMITTER_EMAIL: 'cso-eval@invalid', }; diff --git a/scripts/resolvers/testing.ts b/scripts/resolvers/testing.ts index 3146a8e4c..eb43ff804 100644 --- a/scripts/resolvers/testing.ts +++ b/scripts/resolvers/testing.ts @@ -229,7 +229,7 @@ trap cleanup EXIT INT TERM git -C "$ROOT" remote get-url origin >/dev/null 2>&1 || { echo "BASE_CONTROL: unavailable (no base remote)"; exit 0; } timeout 30 git -C "$ROOT" fetch --quiet origin "$BASE" || { echo "BASE_CONTROL: unavailable (base not fetched)"; exit 0; } git -C "$ROOT" worktree add --quiet --detach "$CTL_TMP/wt" "origin/$BASE" >/dev/null 2>&1 || { echo "BASE_CONTROL: unavailable (worktree add failed)"; exit 0; } - for f in $TEST $FIXTURES; do mkdir -p "$CTL_TMP/wt/$(dirname "$f")" && cp "$ROOT/$f" "$CTL_TMP/wt/$f"; done + for f in $(echo "$TEST $FIXTURES"); do mkdir -p "$CTL_TMP/wt/$(dirname "$f")" && cp "$ROOT/$f" "$CTL_TMP/wt/$f"; done [ -d "$ROOT/node_modules" ] && ln -s "$ROOT/node_modules" "$CTL_TMP/wt/node_modules" cd "$CTL_TMP/wt" && timeout "\${BUDGET:-90}" sh -c "$RUN" > "$CTL_TMP/out" 2>&1; rc=$? tail -n 40 "$CTL_TMP/out" diff --git a/scripts/ubicloud/setup-free-suite.sh b/scripts/ubicloud/setup-free-suite.sh index 2bb17fae6..d725e41b3 100755 --- a/scripts/ubicloud/setup-free-suite.sh +++ b/scripts/ubicloud/setup-free-suite.sh @@ -10,7 +10,7 @@ NODE_VERSION=22.20.0 export DEBIAN_FRONTEND=noninteractive sudo -E apt-get update -qq sudo -E apt-get install -y -qq --no-install-recommends \ - git curl unzip xz-utils ca-certificates build-essential clang python3-venv jq \ + git curl unzip zip xz-utils ca-certificates build-essential clang python3-venv jq \ xvfb x11-utils poppler-utils fonts-noto-color-emoji zsh >/dev/null # Ubuntu 24.04 blocks unprivileged user namespaces, which Chromium's sandbox diff --git a/setup-deploy/SKILL.md b/setup-deploy/SKILL.md index ec5a9e342..10d681ad0 100644 --- a/setup-deploy/SKILL.md +++ b/setup-deploy/SKILL.md @@ -525,7 +525,10 @@ Use AskUserQuestion to gather the information: - B) Via GitHub Actions workflow - C) Via a deploy script or CLI command (describe it) - D) Manually (SSH, dashboard, etc.) - - E) This project doesn't deploy (library, CLI, tool) + - E) Nothing is deployed yet (the first production deploy is still to come) + - F) This project never deploys (library, CLI, tool) + + On E, stop here and write nothing: there is no production URL and no deploy trigger to record yet. Tell the user that this skill configures how `/land-and-deploy` verifies a deploy, and the first bring-up is its own step, done with the platform's own CLI or whatever bring-up tool they choose. Bring the project up first, then re-run `/setup-deploy` after the first production deploy exists. On F, Step 4 writes the `none` configuration. 2. **What's the production URL?** (Free text — the URL where the app runs) diff --git a/setup-deploy/SKILL.md.tmpl b/setup-deploy/SKILL.md.tmpl index 2a718d518..d0005dd06 100644 --- a/setup-deploy/SKILL.md.tmpl +++ b/setup-deploy/SKILL.md.tmpl @@ -160,7 +160,10 @@ Use AskUserQuestion to gather the information: - B) Via GitHub Actions workflow - C) Via a deploy script or CLI command (describe it) - D) Manually (SSH, dashboard, etc.) - - E) This project doesn't deploy (library, CLI, tool) + - E) Nothing is deployed yet (the first production deploy is still to come) + - F) This project never deploys (library, CLI, tool) + + On E, stop here and write nothing: there is no production URL and no deploy trigger to record yet. Tell the user that this skill configures how `/land-and-deploy` verifies a deploy, and the first bring-up is its own step, done with the platform's own CLI or whatever bring-up tool they choose. Bring the project up first, then re-run `/setup-deploy` after the first production deploy exists. On F, Step 4 writes the `none` configuration. 2. **What's the production URL?** (Free text — the URL where the app runs) diff --git a/ship/sections/test-coverage.md b/ship/sections/test-coverage.md index e81cd0d52..ef8859a58 100644 --- a/ship/sections/test-coverage.md +++ b/ship/sections/test-coverage.md @@ -202,7 +202,7 @@ trap cleanup EXIT INT TERM git -C "$ROOT" remote get-url origin >/dev/null 2>&1 || { echo "BASE_CONTROL: unavailable (no base remote)"; exit 0; } timeout 30 git -C "$ROOT" fetch --quiet origin "$BASE" || { echo "BASE_CONTROL: unavailable (base not fetched)"; exit 0; } git -C "$ROOT" worktree add --quiet --detach "$CTL_TMP/wt" "origin/$BASE" >/dev/null 2>&1 || { echo "BASE_CONTROL: unavailable (worktree add failed)"; exit 0; } - for f in $TEST $FIXTURES; do mkdir -p "$CTL_TMP/wt/$(dirname "$f")" && cp "$ROOT/$f" "$CTL_TMP/wt/$f"; done + for f in $(echo "$TEST $FIXTURES"); do mkdir -p "$CTL_TMP/wt/$(dirname "$f")" && cp "$ROOT/$f" "$CTL_TMP/wt/$f"; done [ -d "$ROOT/node_modules" ] && ln -s "$ROOT/node_modules" "$CTL_TMP/wt/node_modules" cd "$CTL_TMP/wt" && timeout "${BUDGET:-90}" sh -c "$RUN" > "$CTL_TMP/out" 2>&1; rc=$? tail -n 40 "$CTL_TMP/out" diff --git a/test-audit/SKILL.md b/test-audit/SKILL.md index d57706a77..a385dbd29 100644 --- a/test-audit/SKILL.md +++ b/test-audit/SKILL.md @@ -442,7 +442,7 @@ echo "$FILES" | xargs grep -L -E 'expect|assert|should|t\.(Error|Fatal|Fail)|ref echo "$FILES" | xargs grep -l -E 'readFileSync\([^)]*\.(ts|js|py|rb|go|tmpl)|toContain\(.(import|export|function) ' 2>/dev/null | sed 's/^/SOURCE_GREP:/' echo "$FILES" | xargs grep -l -E 'Object\.keys\(|export list|exports\)\.toEqual' 2>/dev/null | sed 's/^/EXPORT_LIST:/' echo "$FILES" | xargs grep -l -F 'gstack:test-value keep' 2>/dev/null | sed 's/^/SUPPRESSED:/' -for f in $FILES; do printf '%s %s\n' "$(tr -d '[:space:]' < "$f" | cksum | cut -d' ' -f1)" "$f"; done | sort | awk '$(1)==p{print "NEAR_DUPLICATE:" pf " " $(2)} {p=$(1); pf=$(2)}' +printf '%s\n' "$FILES" | while IFS= read -r f; do [ -n "$f" ] || continue; printf '%s %s\n' "$(tr -d '[:space:]' < "$f" | cksum | cut -d' ' -f1)" "$f"; done | sort | awk '$(1)==p{print "NEAR_DUPLICATE:" pf " " $(2)} {p=$(1); pf=$(2)}' ``` Add seed candidates to the shortlist. A `SUPPRESSED` test is never a candidate: record diff --git a/test-audit/SKILL.md.tmpl b/test-audit/SKILL.md.tmpl index 905c1cfb7..b063cd16d 100644 --- a/test-audit/SKILL.md.tmpl +++ b/test-audit/SKILL.md.tmpl @@ -81,7 +81,7 @@ echo "$FILES" | xargs grep -L -E 'expect|assert|should|t\.(Error|Fatal|Fail)|ref echo "$FILES" | xargs grep -l -E 'readFileSync\([^)]*\.(ts|js|py|rb|go|tmpl)|toContain\(.(import|export|function) ' 2>/dev/null | sed 's/^/SOURCE_GREP:/' echo "$FILES" | xargs grep -l -E 'Object\.keys\(|export list|exports\)\.toEqual' 2>/dev/null | sed 's/^/EXPORT_LIST:/' echo "$FILES" | xargs grep -l -F 'gstack:test-value keep' 2>/dev/null | sed 's/^/SUPPRESSED:/' -for f in $FILES; do printf '%s %s\n' "$(tr -d '[:space:]' < "$f" | cksum | cut -d' ' -f1)" "$f"; done | sort | awk '$(1)==p{print "NEAR_DUPLICATE:" pf " " $(2)} {p=$(1); pf=$(2)}' +printf '%s\n' "$FILES" | while IFS= read -r f; do [ -n "$f" ] || continue; printf '%s %s\n' "$(tr -d '[:space:]' < "$f" | cksum | cut -d' ' -f1)" "$f"; done | sort | awk '$(1)==p{print "NEAR_DUPLICATE:" pf " " $(2)} {p=$(1); pf=$(2)}' ``` Add seed candidates to the shortlist. A `SUPPRESSED` test is never a candidate: record diff --git a/test/cso-docker-socket-realpath.test.ts b/test/cso-docker-socket-realpath.test.ts new file mode 100644 index 000000000..315d1f3f4 --- /dev/null +++ b/test/cso-docker-socket-realpath.test.ts @@ -0,0 +1,118 @@ +import { afterAll, afterEach, beforeAll, describe, expect, spyOn, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as net from 'node:net'; +import * as os from 'node:os'; +import { join } from 'node:path'; +import { realpathSocket } from '../lib/cso/docker'; + +describe.skipIf(process.platform === 'win32')('CSO Docker socket path resolution', () => { + let root = ''; + let socketPath = ''; + let server: net.Server; + beforeAll(async () => { + root = fs.mkdtempSync(join(os.tmpdir(), 'cso-sock-')); + socketPath = join(root, 'docker.sock'); + server = net.createServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(socketPath, () => resolve()); + }); + }); + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + fs.rmSync(root, { recursive: true, force: true }); + }); + + test('resolves a Unix socket that realpath cannot open', () => { + const real = realpathSocket(socketPath); + expect(fs.statSync(real).isSocket()).toBe(true); + expect(real).toBe(join(fs.realpathSync(root), 'docker.sock')); + }); + + test('follows absolute and relative symbolic links down to the socket', () => { + const absoluteLink = join(root, 'absolute.sock'); + fs.symlinkSync(socketPath, absoluteLink); + const relativeLink = join(root, 'relative.sock'); + fs.symlinkSync('absolute.sock', relativeLink); + const expected = realpathSocket(socketPath); + expect(realpathSocket(absoluteLink)).toBe(expected); + expect(realpathSocket(relativeLink)).toBe(expected); + }); + + test('resolves a socket through a symlinked parent directory', () => { + const linkedDirectory = join(root, 'run'); + fs.symlinkSync(root, linkedDirectory); + expect(realpathSocket(join(linkedDirectory, 'docker.sock'))).toBe(realpathSocket(socketPath)); + }); + + test('still reports a missing socket as an error', () => { + expect(() => realpathSocket(join(root, 'missing.sock'))).toThrow(); + }); + + test('matches realpath for ordinary files', () => { + const file = join(root, 'plain'); + fs.writeFileSync(file, 'x'); + expect(realpathSocket(file)).toBe(fs.realpathSync(file)); + }); +}); + +// On macOS Bun's own realpath fails on a socket, so the suite above already +// exercises the fallback there. Linux resolves sockets natively, so this block +// forces the failure to keep the fallback covered on every CI platform. +describe.skipIf(process.platform === 'win32')( + 'CSO Docker socket path resolution when realpath refuses sockets', + () => { + let root = ''; + let socketPath = ''; + let server: net.Server; + const realpath = fs.realpathSync; + const refusing = (code: string) => + ((path: fs.PathLike, ...rest: any[]) => { + if (fs.statSync(path).isSocket()) { + const error = new Error(`${code}: operation not supported on socket`) as NodeJS.ErrnoException; + error.code = code; + throw error; + } + return realpath(path, ...rest); + }) as typeof fs.realpathSync; + beforeAll(async () => { + root = fs.mkdtempSync(join(os.tmpdir(), 'cso-sock-stub-')); + socketPath = join(root, 'docker.sock'); + server = net.createServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(socketPath, () => resolve()); + }); + }); + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + fs.rmSync(root, { recursive: true, force: true }); + }); + afterEach(() => { + (fs.realpathSync as any).mockRestore?.(); + }); + + test('falls back to resolving the leaf by hand on EOPNOTSUPP', () => { + const stub = spyOn(fs, 'realpathSync').mockImplementation(refusing('EOPNOTSUPP')); + expect(realpathSocket(socketPath)).toBe(join(realpath(root), 'docker.sock')); + expect(stub).toHaveBeenCalledWith(socketPath); + expect(stub.mock.calls.length).toBeGreaterThan(1); + }); + + test('still follows symbolic links to the socket on EOPNOTSUPP', () => { + spyOn(fs, 'realpathSync').mockImplementation(refusing('EOPNOTSUPP')); + const link = join(root, 'link.sock'); + fs.symlinkSync('docker.sock', link); + const directory = join(root, 'run'); + fs.symlinkSync(root, directory); + const expected = join(realpath(root), 'docker.sock'); + expect(realpathSocket(link)).toBe(expected); + expect(realpathSocket(join(directory, 'docker.sock'))).toBe(expected); + }); + + test('propagates any other realpath error unchanged', () => { + spyOn(fs, 'realpathSync').mockImplementation(refusing('EACCES')); + expect(() => realpathSocket(socketPath)).toThrow(/EACCES/); + }); + }, +); diff --git a/test/cso-git-global-config.test.ts b/test/cso-git-global-config.test.ts new file mode 100644 index 000000000..d33eadf66 --- /dev/null +++ b/test/cso-git-global-config.test.ts @@ -0,0 +1,56 @@ +import { afterAll, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { childEnvironment, EMPTY_GLOBAL_GIT_CONFIG, executable } from '../lib/cso/process'; + +const roots: string[] = []; +afterAll(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +function homeWithGlobalConfig(): string { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cso-git-global-')); + roots.push(home); + fs.writeFileSync(path.join(home, '.gitconfig'), '[user]\n\tname = leaked-global\n'); + fs.mkdirSync(path.join(home, '.config', 'git'), { recursive: true }); + fs.writeFileSync(path.join(home, '.config', 'git', 'config'), '[user]\n\temail = leaked@xdg.invalid\n'); + return home; +} + +function gitConfig(home: string, env: Record, args: string[]) { + return spawnSync(executable('git'), ['config', ...args], { cwd: home, env, encoding: 'utf8', timeout: 30_000 }); +} + +describe('CSO child Git reads no global or system config (#3082)', () => { + test('uses the /dev/null spelling on every platform and keeps NOSYSTEM', () => { + const env = childEnvironment(os.tmpdir()); + expect(EMPTY_GLOBAL_GIT_CONFIG).toBe('/dev/null'); + expect(env.GIT_CONFIG_GLOBAL).toBe('/dev/null'); + expect(env.GIT_CONFIG_NOSYSTEM).toBe('1'); + }); + + test('the fixture home is read when the override is absent', () => { + const home = homeWithGlobalConfig(); + const { GIT_CONFIG_GLOBAL: _ignored, ...env } = childEnvironment(home); + const result = gitConfig(home, env, ['--global', '--list']); + expect(result.status).toBe(0); + expect(result.stdout).toContain('user.name=leaked-global'); + }); + + test('git config --global --list is empty and exits 0 under the CSO environment', () => { + const home = homeWithGlobalConfig(); + const result = gitConfig(home, childEnvironment(home), ['--global', '--list']); + expect(result.stderr).toBe(''); + expect(result.status).toBe(0); + expect(result.stdout).toBe(''); + }); + + test('neither the home nor the XDG config reaches the merged view', () => { + const home = homeWithGlobalConfig(); + const result = gitConfig(home, childEnvironment(home), ['--list']); + expect(result.status).toBe(0); + expect(result.stdout).not.toContain('leaked'); + }); +}); diff --git a/test/cso-snapshot-disappearance-race.test.ts b/test/cso-snapshot-disappearance-race.test.ts index 866488790..8e2c4b0b3 100644 --- a/test/cso-snapshot-disappearance-race.test.ts +++ b/test/cso-snapshot-disappearance-race.test.ts @@ -14,7 +14,7 @@ function trustedGit():string{ return executable; } function gitEnvironment(home:string):NodeJS.ProcessEnv{ - return{...process.env,HOME:home,GIT_CONFIG_NOSYSTEM:'1',GIT_CONFIG_GLOBAL:process.platform==='win32'?'NUL':'/dev/null',GIT_TERMINAL_PROMPT:'0'}; + return{...process.env,HOME:home,GIT_CONFIG_NOSYSTEM:'1',GIT_CONFIG_GLOBAL:'/dev/null',GIT_TERMINAL_PROMPT:'0'}; } type FileIdentity={path:string;dev:bigint;ino:bigint}; function canonicalFile(file:string):string{ diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index 291351e4c..3b7f4f0f3 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -565,6 +565,25 @@ describe('gen-skill-docs', () => { } }); + test('bash blocks never loop over an unquoted variable (zsh does not word-split it)', () => { + // `for f in $LIST` runs once with the whole list under zsh (no SH_WORD_SPLIT), + // which is the Bash tool's shell on macOS. Iterate with + // `printf '%s\n' "$LIST" | while IFS= read -r f` or `$(echo "$LIST")`; + // zsh still splits an unquoted command substitution. + const violations: string[] = []; + for (const skill of CLAUDE_GENERATED_SKILLS) { + const content = readSkillUnion(skill.dir); + for (const block of [...content.matchAll(/```bash\n([\s\S]*?)```/g)].map(m => m[1])) { + for (const loop of block.matchAll(/\bfor\s+\w+\s+in\s+([^;\n]*)/g)) { + if (loop[1].split(/\s+/).some(word => /^\$\{?\w+\}?$/.test(word))) { + violations.push(`${skill.dir}: ${loop[0].trim()}`); + } + } + } + } + expect(violations).toEqual([]); + }); + test('preamble-using skills have correct skill name in telemetry', () => { const PREAMBLE_SKILLS = [ { dir: '.', name: 'gstack' }, diff --git a/test/land-and-deploy-command-trigger.test.ts b/test/land-and-deploy-command-trigger.test.ts new file mode 100644 index 000000000..84239322e --- /dev/null +++ b/test/land-and-deploy-command-trigger.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const root = join(import.meta.dir, '..'); +const source = (name: string) => readFileSync(join(root, 'land-and-deploy', name), 'utf8'); + +describe('land-and-deploy handles a recorded command deploy trigger', () => { + test('Step 5 routes a recorded command trigger before URL polling', () => { + const merge = source('sections/merge-and-deploy.md'); + const route = merge.indexOf('2. Recorded command trigger'); + expect(route).toBeGreaterThanOrEqual(0); + expect(route).toBeLessThan(merge.indexOf('Explicit `VERIFY_URL`')); + expect(route).toBeLessThan(merge.indexOf('Otherwise use configured production URL/status checks')); + const body = merge.slice(route, merge.indexOf('3. Explicit `VERIFY_URL`')).replace(/\s+/g, ' '); + expect(body).toContain('production has not moved yet'); + expect(body).toContain('only with their explicit approval'); + expect(body).toContain('a zero exit is not revision proof'); + }); + + test('a pending workflow or platform trigger is not mistaken for a command trigger', () => { + const merge = source('sections/merge-and-deploy.md'); + expect(merge.replace(/\s+/g, ' ')).toContain('A configured workflow or platform trigger whose run has not appeared remains pending'); + }); + + test('Strategy D points at the route and still demands revision proof', () => { + const main = source('SKILL.md'); + const start = main.indexOf('### Strategy D: Custom deploy hooks'); + expect(start).toBeGreaterThanOrEqual(0); + const body = main.slice(start, main.indexOf('### Common: Timing and failure handling')).replace(/\s+/g, ' '); + expect(body).toContain('A recorded command trigger follows Step 5 route 2'); + expect(body).toContain('only with their explicit approval'); + expect(body).toContain('not revision proof on its own'); + }); +}); diff --git a/test/setup-deploy-not-deployed.test.ts b/test/setup-deploy-not-deployed.test.ts new file mode 100644 index 000000000..0920cf708 --- /dev/null +++ b/test/setup-deploy-not-deployed.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; + +const SKILL = fs.readFileSync(path.resolve(import.meta.dir, '..', 'setup-deploy', 'SKILL.md'), 'utf-8'); + +function section(heading: string, next: string): string { + const start = SKILL.indexOf(heading); + expect(start).toBeGreaterThanOrEqual(0); + const end = SKILL.indexOf(next, start + heading.length); + expect(end).toBeGreaterThan(start); + return SKILL.slice(start, end); +} + +describe('setup-deploy handles a project with nothing live yet', () => { + const question = section('1. **How are deploys triggered?**', "2. **What's the production URL?**"); + + test('not deployed yet and never deploys are separate answers', () => { + expect(question).toContain('E) Nothing is deployed yet'); + expect(question).toContain('F) This project never deploys (library, CLI, tool)'); + }); + + test('nothing deployed yet stops before the URL question and writes nothing', () => { + const onE = question.slice(question.indexOf('On E,')); + expect(onE).toContain('stop here and write nothing'); + expect(onE).toContain('no production URL and no deploy trigger to record'); + expect(onE).toContain('re-run `/setup-deploy` after the first production deploy exists'); + }); + + test('a project that never deploys still gets the none configuration', () => { + expect(question).toContain('On F, Step 4 writes the `none` configuration'); + expect(SKILL).toContain('If the project does not deploy, set platform/URL/workflow/status/health/trigger to `none`'); + }); +});