mirror of https://github.com/garrytan/gstack.git
Merge 55c2eb6a0f into a3259400a3
This commit is contained in:
commit
a7febeb6a1
37
CHANGELOG.md
37
CHANGELOG.md
|
|
@ -1,5 +1,42 @@
|
|||
# Changelog
|
||||
|
||||
## [1.60.2.0] - 2026-07-15
|
||||
|
||||
## **Windows Codex installs now ship the runtime modules their GBrain tools import.**
|
||||
## **Worktree sync preserves path boundaries and refuses to widen maintenance across the whole brain.**
|
||||
|
||||
The Windows Codex runtime could install executable GStack helpers without the sibling `lib` modules they import. Once the helpers ran, source sync had three more sharp edges: equivalent Windows path spellings looked like drift, paths containing spaces passed through shell parsing, and a source-scoped maintenance request could assume a CLI capability the installed GBrain did not expose. This release closes all four defects as one boundary-hardening patch.
|
||||
|
||||
### The numbers that matter
|
||||
|
||||
Source: the DEV-206 regression suite and adjacent GBrain orchestration tests (`bun test test/dev-206-windows-gbrain.test.ts test/gbrain-cycle-completed.test.ts test/gbrain-dream-stage.test.ts test/gbrain-exec-invariant.test.ts test/gbrain-sources.test.ts test/gbrain-spawn-windows-shell.test.ts test/gstack-gbrain-sync.test.ts`).
|
||||
|
||||
| Windows worktree-sync boundary | Before | After |
|
||||
|---|---:|---:|
|
||||
| Required runtime module trees installed with Codex helpers | 0 | 1 (`lib`) |
|
||||
| Equivalent slash/case path forms that trigger re-registration | possible | 0 in regression coverage |
|
||||
| Shell parsing layers around GBrain argv | 1 | 0 |
|
||||
| Brain-wide maintenance fallbacks from a source-scoped request | possible | 0 (fails closed) |
|
||||
| Focused regression and orchestration tests | — | 92 passing |
|
||||
|
||||
### What this means for you
|
||||
|
||||
Codex-hosted GStack tools keep their imports after both setup and upgrade. A Windows worktree path keeps its spaces as one argument, case or slash differences do not cause a destructive source remove/re-add, and `/sync-gbrain` reports an actionable compatibility error when the installed CLI cannot perform source-scoped maintenance. It never substitutes a brain-wide cycle silently.
|
||||
|
||||
### Itemized changes
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Codex runtime metadata and `./setup` now install `lib` beside shipped `bin` helpers in both the global runtime and project sidecar.
|
||||
- Source drift checks resolve Windows drive/UNC paths, normalize separators, and compare case-insensitively before considering destructive re-registration or hostname-fold migration.
|
||||
- GBrain subprocesses resolve a direct executable and use structural argv with `shell: false`; Windows `.cmd`, `.bat`, and `.ps1` launchers are rejected with setup remediation instead of rebuilding a shell command.
|
||||
- The dream stage probes `gbrain dream --help` for `--source` support before acquiring its maintenance marker. Missing capability or an unresolvable worktree returns an error and never falls back to bare `gbrain dream`.
|
||||
|
||||
#### For contributors
|
||||
|
||||
- Cross-platform fake GBrain harnesses now compile executable test shims, so spaced-path and direct-spawn behavior is exercised without shell quoting artifacts.
|
||||
- DEV-206 coverage simulates Codex install/upgrade, verifies normalized drift, rejects unsafe Windows launchers, and runs the real wrapper against a brain-wide-only fake CLI to prove no maintenance dispatch occurs.
|
||||
|
||||
## [1.60.1.0] - 2026-07-09
|
||||
|
||||
## **The /autoplan dual-voice eval is back on the board, catching real regressions.**
|
||||
|
|
|
|||
|
|
@ -230,7 +230,7 @@ Each skill feeds into the next. `/office-hours` writes a design doc that `/plan-
|
|||
| `/open-gstack-browser` | **GStack Browser** — launch GStack Browser with sidebar, anti-bot stealth, auto model routing (Sonnet for actions, Opus for analysis), one-click cookie import, and Claude Code integration. Clean up pages, take smart screenshots, edit CSS, and pass info back to your terminal. |
|
||||
| `/setup-deploy` | **Deploy Configurator** — one-time setup for `/land-and-deploy`. Detects your platform, production URL, and deploy commands. |
|
||||
| `/setup-gbrain` | **GBrain Onboarding** — from zero to running gbrain in under 5 minutes. PGLite local, Supabase existing URL, or auto-provision a new Supabase project via Management API. MCP registration for Claude Code + per-repo trust triad (read-write/read-only/deny). [Full guide](USING_GBRAIN_WITH_GSTACK.md). |
|
||||
| `/sync-gbrain` | **Keep Brain Current** — re-index this repo's code into gbrain via `gbrain sources add` + `gbrain sync --strategy code`, refresh the `## GBrain Search Guidance` block in CLAUDE.md, and auto-remove guidance when the capability check fails. `--incremental` (default), `--full`, `--dry-run`. Idempotent; safe to re-run. |
|
||||
| `/sync-gbrain` | **Keep Brain Current** — re-index this repo's code into gbrain via `gbrain sources add` + `gbrain sync --strategy code`, refresh the `## GBrain Search Guidance` block in CLAUDE.md, and auto-remove guidance when the capability check fails. `--incremental` (default), `--full`, `--dry-run`. Source-scoped maintenance fails closed when the installed GBrain only supports a brain-wide cycle. Idempotent; safe to re-run. |
|
||||
| `/gstack-upgrade` | **Self-Updater** — upgrade gstack to latest. Detects global vs vendored install, syncs both, shows what changed. |
|
||||
| `/ios-qa` | **iOS Live-Device QA (v1.43.0.0+)** — drive a real iPhone over USB CoreDevice via an embedded `StateServer` in the app. Read Swift source, codegen typed `@Observable` accessors, run the agent loop. Optional `--tailnet` flag exposes the device to OpenClaw or any HTTP-capable agent on your Tailscale tailnet so remote agents can run iOS QA without ever touching the hardware. Capability-tier allowlist (observe/interact/mutate/restore), per-device session lock, audit log. |
|
||||
| `/ios-fix`, `/ios-design-review`, `/ios-clean`, `/ios-sync` | iOS bug-fix loop, designer's-eye HIG audit, debug-bridge cleanup, and accessor resync. See `docs/skills.md`. End-to-end walkthrough: [docs/howto-ios-testing-with-gstack.md](docs/howto-ios-testing-with-gstack.md). |
|
||||
|
|
@ -405,7 +405,7 @@ Four paths, pick one:
|
|||
|
||||
After init, the skill offers to register gbrain as an MCP server for Claude Code (`claude mcp add gbrain -- gbrain serve`) so `gbrain search`, `gbrain put`, etc. show up as first-class typed tools — not bash shell-outs.
|
||||
|
||||
**Keeping the brain current.** Run `/sync-gbrain` from any repo to re-index its code into gbrain (incremental by default, `--full` for a full reindex, `--dry-run` to preview). The skill registers the cwd as a federated source via `gbrain sources add`, runs `gbrain sync --strategy code`, and writes a `## GBrain Search Guidance` block to your project's CLAUDE.md so the agent prefers `gbrain search`/`code-def`/`code-refs` over Grep. The block is removed automatically if the capability check fails — no stale guidance pointing at tools that aren't installed.
|
||||
**Keeping the brain current.** Run `/sync-gbrain` from any repo to re-index its code into gbrain (incremental by default, `--full` for a full reindex, `--dry-run` to preview). The skill registers the cwd as a federated source via `gbrain sources add`, runs `gbrain sync --strategy code`, and writes a `## GBrain Search Guidance` block to your project's CLAUDE.md so the agent prefers `gbrain search`/`code-def`/`code-refs` over Grep. The block is removed automatically if the capability check fails — no stale guidance pointing at tools that aren't installed. If a full sync needs call-graph maintenance, GStack requires the installed CLI to support `gbrain dream --source`; it will not silently widen that request to a brain-wide cycle.
|
||||
|
||||
**Per-remote trust policy.** Each repo on your machine gets one of three tiers:
|
||||
|
||||
|
|
|
|||
|
|
@ -135,11 +135,13 @@ The skill runs three stages — code, memory, brain-sync — independently. A fa
|
|||
**What it does on a fresh worktree:**
|
||||
|
||||
1. **Pre-flight.** Checks `gbrain_local_status` (the local engine's health). If the engine is `broken-db` or `broken-config`, the skill STOPs with a remediation menu — it refuses to silently degrade. If the local engine is missing and you're in remote-MCP mode (Path 4), the code stage SKIPs cleanly and only brain-sync runs.
|
||||
2. **Code stage.** Registers the cwd as a federated source via `gbrain sources add`, writes a `.gbrain-source` pin file in the repo root (kubectl-style context — every worktree gets its own pin, so Conductor sibling worktrees don't collide), runs `gbrain sync --strategy code`.
|
||||
2. **Code stage.** Registers the cwd as a federated source via `gbrain sources add`, writes a `.gbrain-source` pin file in the repo root (kubectl-style context — every worktree gets its own pin, so Conductor sibling worktrees don't collide), runs `gbrain sync --strategy code`, and normalizes Windows slash/case differences before deciding that a registered path has drifted.
|
||||
3. **Memory stage.** Stages your `~/.gstack/` transcripts + curated memory. In local-stdio MCP mode, ingests into the local engine. In remote-http MCP mode, persists staged markdown to `~/.gstack/transcripts/run-<pid>-<ts>/` for the remote brain admin's pull pipeline. The ingest timeout is 30 minutes by default; raise it for a big brain with `GSTACK_INGEST_TIMEOUT_MS` (accepts 1 min–24h). On timeout the gbrain import checkpoint is preserved, so the next `/sync-gbrain` resumes instead of starting over.
|
||||
4. **Brain-sync stage.** Pushes curated artifacts (plans, designs, retros) to your private artifacts repo if you have one configured.
|
||||
5. **CLAUDE.md guidance.** Capability-checks the round-trip (write a page → search → find it). If green, writes the `## GBrain Search Guidance` block to your project's CLAUDE.md. If red, REMOVES the block — the agent should never be told to use a tool that isn't installed.
|
||||
|
||||
`--full` may also need a call-graph maintenance cycle for this worktree. GStack first checks whether the installed GBrain exposes `gbrain dream --source`; if it does not, sync reports an error and leaves maintenance to the operator. It never substitutes a brain-wide `gbrain dream` for a worktree-scoped request.
|
||||
|
||||
**The watermark.** Sync state advances by commit hash. If gbrain hits a file it can't index (5 MB hard limit per file, or a file vanished mid-sync), the watermark stays put and subsequent syncs retry. To acknowledge an unfixable failure and move past it:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ import { createHash } from "crypto";
|
|||
|
||||
import "../lib/conductor-env-shim";
|
||||
import { detectEngineTier, withErrorContext, canonicalizeRemote } from "../lib/gstack-memory-helpers";
|
||||
import { ensureSourceRegistered, sourcePageCount, parseSourcesList, cycleCompleted, type CycleStatus } from "../lib/gbrain-sources";
|
||||
import { ensureSourceRegistered, sourcePageCount, parseSourcesList, cycleCompleted, sourcePathsEqual, type CycleStatus } from "../lib/gbrain-sources";
|
||||
import { detectAutopilot, decideSourceRemove, decideCodeSync } from "../lib/gbrain-guards";
|
||||
import { localEngineStatus, type LocalEngineStatus } from "../lib/gbrain-local-status";
|
||||
import { buildGbrainEnv, spawnGbrain, execGbrainJson, NEEDS_SHELL_ON_WINDOWS } from "../lib/gbrain-exec";
|
||||
|
|
@ -499,7 +499,7 @@ export function planHostnameFoldMigration(
|
|||
if (oldPath === null) {
|
||||
return { kind: "none", reason: "no-legacy-source" };
|
||||
}
|
||||
if (oldPath !== currentRoot) {
|
||||
if (!sourcePathsEqual(oldPath, currentRoot)) {
|
||||
return {
|
||||
kind: "skipped-path-drift",
|
||||
oldId: legacyPathHashId,
|
||||
|
|
@ -1188,11 +1188,43 @@ export function shouldRunDream(args: CliArgs, cycle: CycleStatus | null): boolea
|
|||
return false;
|
||||
}
|
||||
|
||||
export type SourceScopedDreamPlan =
|
||||
| { ok: true; args: string[] }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Run `gbrain dream` — the brain-global maintenance cycle whose
|
||||
* resolve_symbol_edges phase builds the call graph. Runs LOCK-FREE (called
|
||||
* after the sync lock releases) so it never freezes sibling worktrees; the
|
||||
* `.dream-in-progress` marker dedupes concurrent dreams instead.
|
||||
* Turn `gbrain dream --help` into a source-scoped command or a fail-closed
|
||||
* compatibility error. A brain-wide `dream` is never an implicit fallback for
|
||||
* a worktree-scoped sync request.
|
||||
*/
|
||||
export function planSourceScopedDream(
|
||||
sourceId: string,
|
||||
helpStatus: number | null,
|
||||
helpOutput: string,
|
||||
): SourceScopedDreamPlan {
|
||||
if (helpStatus !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
`could not verify source-scoped gbrain dream support (help exited ${helpStatus}); ` +
|
||||
"upgrade gbrain or review a brain-wide maintenance run manually; refusing to widen scope",
|
||||
};
|
||||
}
|
||||
if (!/--source(?:\s|=|<|\[|$)/m.test(helpOutput)) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
"installed gbrain dream does not support --source; upgrade gbrain or review a " +
|
||||
"brain-wide maintenance run manually; refusing to widen scope",
|
||||
};
|
||||
}
|
||||
return { ok: true, args: ["dream", "--source", sourceId] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a proven source-scoped `gbrain dream --source <id>` maintenance cycle.
|
||||
* Runs LOCK-FREE (called after the sync lock releases) so it never freezes
|
||||
* sibling worktrees; the `.dream-in-progress` marker dedupes concurrent dreams.
|
||||
*
|
||||
* Returns a StageResult (never throws). SKIP (ran:false, ok:true) for: dry-run
|
||||
* preview, local engine not ok, or a fresh marker present. ERR (ran:true,
|
||||
|
|
@ -1213,7 +1245,7 @@ export async function runDream(args: CliArgs): Promise<StageResult> {
|
|||
duration_ms: 0,
|
||||
summary: sourceId
|
||||
? `would: gbrain dream --source ${sourceId} (build this source's call graph)`
|
||||
: "would: gbrain dream (call-graph build)",
|
||||
: "would refuse: cannot derive a worktree source id (no brain-wide fallback)",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1224,6 +1256,45 @@ export async function runDream(args: CliArgs): Promise<StageResult> {
|
|||
return skipStageForLocalStatus("dream", localStatus, t0);
|
||||
}
|
||||
|
||||
const root = repoRoot();
|
||||
if (!root) {
|
||||
return {
|
||||
name: "dream",
|
||||
ran: true,
|
||||
ok: false,
|
||||
duration_ms: Date.now() - t0,
|
||||
summary: "cannot derive a worktree source id; refusing to run brain-wide gbrain dream",
|
||||
};
|
||||
}
|
||||
const sourceId = deriveCodeSourceId(root);
|
||||
let dreamPlan: SourceScopedDreamPlan;
|
||||
try {
|
||||
const help = spawnGbrain(["dream", "--help"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeout: 10_000,
|
||||
baseEnv: process.env,
|
||||
});
|
||||
dreamPlan = planSourceScopedDream(
|
||||
sourceId,
|
||||
help.status,
|
||||
`${help.stdout || ""}${help.stderr || ""}`,
|
||||
);
|
||||
} catch (err) {
|
||||
dreamPlan = {
|
||||
ok: false,
|
||||
error: `could not inspect gbrain dream capability: ${(err as Error).message}; refusing to widen scope`,
|
||||
};
|
||||
}
|
||||
if (!dreamPlan.ok) {
|
||||
return {
|
||||
name: "dream",
|
||||
ran: true,
|
||||
ok: false,
|
||||
duration_ms: Date.now() - t0,
|
||||
summary: dreamPlan.error,
|
||||
};
|
||||
}
|
||||
|
||||
// Dedupe concurrent dreams across worktrees (lock-free path).
|
||||
if (!acquireDreamMarker()) {
|
||||
const pid = dreamMarkerPid();
|
||||
|
|
@ -1243,18 +1314,6 @@ export async function runDream(args: CliArgs): Promise<StageResult> {
|
|||
DEFAULT_DREAM_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
// Scope the cycle to THIS worktree's code source: `gbrain dream --source <id>`.
|
||||
// Verified empirically (not just from `gbrain --help`): plain `gbrain dream`
|
||||
// cycles the brain's default source and never runs the source-scoped `extract`
|
||||
// phase for our code source, so the call graph for the pinned source stays
|
||||
// empty. `gbrain dream --source <id>` runs the per-source cycle (the form
|
||||
// `gbrain doctor` recommends for stale sources) and is what actually populates
|
||||
// code-callers/code-callees for this worktree. Falls back to plain `dream`
|
||||
// only when we can't derive the source id (not in a git repo).
|
||||
const root = repoRoot();
|
||||
const sourceId = root ? deriveCodeSourceId(root) : null;
|
||||
const dreamArgs = sourceId ? ["dream", "--source", sourceId] : ["dream"];
|
||||
|
||||
// spawnGbrain seeds DATABASE_URL from gbrain's config via buildGbrainEnv.
|
||||
//
|
||||
// We CAPTURE output (pipe) rather than inherit because `gbrain dream` exits 0
|
||||
|
|
@ -1268,7 +1327,7 @@ export async function runDream(args: CliArgs): Promise<StageResult> {
|
|||
}
|
||||
let result: ReturnType<typeof spawnGbrain>;
|
||||
try {
|
||||
result = spawnGbrain(dreamArgs, {
|
||||
result = spawnGbrain(dreamPlan.args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeout: dreamTimeoutMs,
|
||||
baseEnv: process.env,
|
||||
|
|
|
|||
|
|
@ -42,14 +42,14 @@ const codex: HostConfig = {
|
|||
],
|
||||
|
||||
runtimeRoot: {
|
||||
globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'],
|
||||
globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'],
|
||||
globalFiles: {
|
||||
'review': ['checklist.md', 'TODOS-format.md'],
|
||||
},
|
||||
},
|
||||
sidecar: {
|
||||
path: '.agents/skills/gstack',
|
||||
symlinks: ['bin', 'browse', 'review', 'qa', 'ETHOS.md'],
|
||||
symlinks: ['bin', 'lib', 'browse', 'review', 'qa', 'ETHOS.md'],
|
||||
},
|
||||
|
||||
install: {
|
||||
|
|
|
|||
|
|
@ -125,17 +125,57 @@ export function buildGbrainEnv(opts: BuildGbrainEnvOptions = {}): NodeJS.Process
|
|||
}
|
||||
|
||||
/**
|
||||
* Windows can't directly spawn the `gbrain` launcher (bun/npm install it as a
|
||||
* `gbrain.cmd`/`.ps1` shim) or a shebang script like the bash `gstack-brain-sync`
|
||||
* — `spawnSync`/`spawn` resolve those only through a shell's PATHEXT + interpreter
|
||||
* lookup. Without `shell: true` the child spawn fails ENOENT, which on the sync
|
||||
* orchestrator surfaced as "brain-sync exited undefined" (#1731). Gate on platform
|
||||
* so POSIX keeps the cheaper no-shell path. Exported so the static-grep tripwire
|
||||
* (test/gbrain-spawn-windows-shell.test.ts) can assert every gbrain/brain-sync
|
||||
* spawn carries it.
|
||||
* Windows still needs a shell for bash helpers such as `gstack-brain-sync`.
|
||||
* Direct gbrain calls deliberately do NOT use this flag: planGbrainSpawn resolves
|
||||
* Bun's executable shim and preserves argv structurally (DEV-206). Exported for
|
||||
* the remaining shebang-script spawns and legacy literal-argument probes.
|
||||
*/
|
||||
export const NEEDS_SHELL_ON_WINDOWS = process.platform === "win32";
|
||||
|
||||
export interface GbrainSpawnPlan {
|
||||
command: string;
|
||||
args: string[];
|
||||
shell: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a structural gbrain invocation. Supported Windows installs use Bun's
|
||||
* direct `.exe` shim, so argv values (especially worktree paths containing
|
||||
* spaces) never pass through `cmd.exe` tokenization.
|
||||
*
|
||||
* A `.cmd`/`.bat`/`.ps1` launcher would require rebuilding the command as a
|
||||
* shell string. Fail closed instead of risking argument splitting or command
|
||||
* injection; `bin/gstack-gbrain-install` uses `bun link`, which installs the
|
||||
* directly executable shim this contract requires.
|
||||
*/
|
||||
export function planGbrainSpawn(
|
||||
args: string[],
|
||||
resolvedExecutable: string | null,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): GbrainSpawnPlan {
|
||||
const command = resolvedExecutable || "gbrain";
|
||||
if (platform === "win32" && resolvedExecutable && !/\.(?:exe|com)$/i.test(resolvedExecutable)) {
|
||||
throw new Error(
|
||||
`unsafe Windows gbrain launcher at ${resolvedExecutable}; ` +
|
||||
"re-run /setup-gbrain so bun link installs a direct gbrain.exe shim",
|
||||
);
|
||||
}
|
||||
return { command, args: [...args], shell: false };
|
||||
}
|
||||
|
||||
/** Resolve gbrain against the exact PATH that will be passed to the child. */
|
||||
export function resolveGbrainExecutable(env: NodeJS.ProcessEnv = process.env): string | null {
|
||||
try {
|
||||
return Bun.which("gbrain", { PATH: env.PATH });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function gbrainSpawnPlan(args: string[], env: NodeJS.ProcessEnv): GbrainSpawnPlan {
|
||||
return planGbrainSpawn(args, resolveGbrainExecutable(env));
|
||||
}
|
||||
|
||||
export interface SpawnGbrainOptions {
|
||||
/** Timeout in milliseconds. Defaults to 30s. */
|
||||
timeout?: number;
|
||||
|
|
@ -159,13 +199,15 @@ export interface SpawnGbrainOptions {
|
|||
* `stderr` exactly as they would with `spawnSync` directly.
|
||||
*/
|
||||
export function spawnGbrain(args: string[], opts: SpawnGbrainOptions = {}): SpawnSyncReturns<string> {
|
||||
return spawnSync("gbrain", args, {
|
||||
const env = buildGbrainEnv({ baseEnv: opts.baseEnv, announce: opts.announce });
|
||||
const plan = gbrainSpawnPlan(args, env);
|
||||
return spawnSync(plan.command, plan.args, {
|
||||
encoding: "utf-8",
|
||||
timeout: opts.timeout ?? 30_000,
|
||||
cwd: opts.cwd,
|
||||
stdio: opts.stdio || ["ignore", "pipe", "pipe"],
|
||||
env: buildGbrainEnv({ baseEnv: opts.baseEnv, announce: opts.announce }),
|
||||
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
|
||||
env,
|
||||
shell: plan.shell,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -194,11 +236,13 @@ export function spawnGbrainAsync(
|
|||
args: string[],
|
||||
opts: { stdio?: SpawnOptions["stdio"]; cwd?: string; baseEnv?: NodeJS.ProcessEnv } = {},
|
||||
): ChildProcess {
|
||||
return spawn("gbrain", args, {
|
||||
const env = buildGbrainEnv({ baseEnv: opts.baseEnv, announce: false });
|
||||
const plan = gbrainSpawnPlan(args, env);
|
||||
return spawn(plan.command, plan.args, {
|
||||
stdio: opts.stdio || ["ignore", "pipe", "pipe"],
|
||||
cwd: opts.cwd,
|
||||
env: buildGbrainEnv({ baseEnv: opts.baseEnv, announce: false }),
|
||||
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
|
||||
env,
|
||||
shell: plan.shell,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -207,12 +251,14 @@ export function spawnGbrainAsync(
|
|||
* for callers that want to surface gbrain's stderr as the error message.
|
||||
*/
|
||||
export function execGbrainText(args: string[], opts: SpawnGbrainOptions = {}): string {
|
||||
return execFileSync("gbrain", args, {
|
||||
const env = buildGbrainEnv({ baseEnv: opts.baseEnv, announce: opts.announce });
|
||||
const plan = gbrainSpawnPlan(args, env);
|
||||
return execFileSync(plan.command, plan.args, {
|
||||
encoding: "utf-8",
|
||||
timeout: opts.timeout ?? 30_000,
|
||||
cwd: opts.cwd,
|
||||
stdio: opts.stdio || ["ignore", "pipe", "pipe"],
|
||||
env: buildGbrainEnv({ baseEnv: opts.baseEnv, announce: opts.announce }),
|
||||
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
|
||||
env,
|
||||
shell: plan.shell,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@
|
|||
* Per /plan-eng-review D3 (DRY extraction).
|
||||
*/
|
||||
|
||||
import { execFileSync, spawnSync } from "child_process";
|
||||
import { resolve, win32 } from "path";
|
||||
import { withErrorContext } from "./gstack-memory-helpers";
|
||||
import { execGbrainJson, NEEDS_SHELL_ON_WINDOWS } from "./gbrain-exec";
|
||||
import { execGbrainJson, execGbrainText, spawnGbrain } from "./gbrain-exec";
|
||||
|
||||
export interface SourceState {
|
||||
/** "absent" — id not registered. "match" — id at expected path. "drift" — id at different path. */
|
||||
|
|
@ -72,6 +72,34 @@ export interface EnsureOptions {
|
|||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
function looksLikeWindowsPath(value: string): boolean {
|
||||
return /^[a-z]:[\\/]/i.test(value) || /^\\\\/.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare source paths using the host filesystem's path semantics. Windows
|
||||
* drive/UNC paths are resolved, separator-normalized, and case-folded before
|
||||
* comparison so `C:\\Repo` and `c:/repo` cannot trigger destructive drift.
|
||||
* POSIX paths remain case-sensitive.
|
||||
*/
|
||||
export function sourcePathsEqual(
|
||||
left: string,
|
||||
right: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): boolean {
|
||||
const useWindowsSemantics =
|
||||
platform === "win32" || looksLikeWindowsPath(left) || looksLikeWindowsPath(right);
|
||||
if (useWindowsSemantics) {
|
||||
const normalizeWindows = (value: string): string =>
|
||||
win32
|
||||
.resolve(value.replace(/\//g, "\\"))
|
||||
.replace(/^\\\\\?\\/, "")
|
||||
.toLowerCase();
|
||||
return normalizeWindows(left) === normalizeWindows(right);
|
||||
}
|
||||
return resolve(left) === resolve(right);
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the registration state of a source by id.
|
||||
*
|
||||
|
|
@ -83,13 +111,7 @@ export interface EnsureOptions {
|
|||
export function probeSource(id: string, env?: NodeJS.ProcessEnv): SourceState {
|
||||
let stdout: string;
|
||||
try {
|
||||
stdout = execFileSync("gbrain", ["sources", "list", "--json"], {
|
||||
encoding: "utf-8",
|
||||
timeout: 30_000,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
|
||||
});
|
||||
stdout = execGbrainText(["sources", "list", "--json"], { baseEnv: env });
|
||||
} catch (err) {
|
||||
const e = err as NodeJS.ErrnoException & { stderr?: Buffer };
|
||||
const stderr = e.stderr?.toString() || "";
|
||||
|
|
@ -144,7 +166,10 @@ export async function ensureSourceRegistered(
|
|||
|
||||
// Disambiguate match-but-different-path
|
||||
let state: SourceState = probed;
|
||||
if (probed.status === "match" && probed.registered_path !== path) {
|
||||
if (
|
||||
probed.status === "match" &&
|
||||
(!probed.registered_path || !sourcePathsEqual(probed.registered_path, path))
|
||||
) {
|
||||
state = { status: "drift", registered_path: probed.registered_path };
|
||||
}
|
||||
|
||||
|
|
@ -158,12 +183,7 @@ export async function ensureSourceRegistered(
|
|||
|
||||
// For drift, remove first.
|
||||
if (state.status === "drift") {
|
||||
const rm = spawnSync("gbrain", ["sources", "remove", id, "--yes"], {
|
||||
encoding: "utf-8",
|
||||
timeout: 30_000,
|
||||
env,
|
||||
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
|
||||
});
|
||||
const rm = spawnGbrain(["sources", "remove", id, "--yes"], { baseEnv: env });
|
||||
if (rm.status !== 0) {
|
||||
throw new Error(`gbrain sources remove ${id} failed: ${rm.stderr || rm.stdout || `exit ${rm.status}`}`);
|
||||
}
|
||||
|
|
@ -172,12 +192,7 @@ export async function ensureSourceRegistered(
|
|||
// Add.
|
||||
const addArgs = ["sources", "add", id, "--path", path];
|
||||
if (federated) addArgs.push("--federated");
|
||||
const add = spawnSync("gbrain", addArgs, {
|
||||
encoding: "utf-8",
|
||||
timeout: 30_000,
|
||||
env,
|
||||
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
|
||||
});
|
||||
const add = spawnGbrain(addArgs, { baseEnv: env });
|
||||
if (add.status !== 0) {
|
||||
throw new Error(`gbrain sources add ${id} failed: ${add.stderr || add.stdout || `exit ${add.status}`}`);
|
||||
}
|
||||
|
|
@ -195,27 +210,11 @@ export async function ensureSourceRegistered(
|
|||
* variant selection.
|
||||
*/
|
||||
export function sourcePageCount(id: string, env?: NodeJS.ProcessEnv): number | null {
|
||||
let stdout: string;
|
||||
try {
|
||||
stdout = execFileSync("gbrain", ["sources", "list", "--json"], {
|
||||
encoding: "utf-8",
|
||||
timeout: 30_000,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const match = parseSourcesList(JSON.parse(stdout)).find((s) => s.id === id);
|
||||
if (!match) return null;
|
||||
if (typeof match.page_count !== "number") return null;
|
||||
return match.page_count;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const raw = execGbrainJson<unknown>(["sources", "list", "--json"], { baseEnv: env });
|
||||
if (!raw) return null;
|
||||
const match = parseSourcesList(raw).find((s) => s.id === id);
|
||||
if (!match || typeof match.page_count !== "number") return null;
|
||||
return match.page_count;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "gstack",
|
||||
"version": "1.60.1.0",
|
||||
"version": "1.60.2.0",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
5
setup
5
setup
|
|
@ -749,7 +749,7 @@ create_agents_sidecar() {
|
|||
mkdir -p "$agents_gstack"
|
||||
|
||||
# Sidecar directories that skills reference at runtime
|
||||
for asset in bin browse review qa; do
|
||||
for asset in bin lib browse review qa; do
|
||||
local src="$SOURCE_GSTACK_DIR/$asset"
|
||||
local dst="$agents_gstack/$asset"
|
||||
if [ -d "$src" ] || [ -f "$src" ]; then
|
||||
|
|
@ -796,6 +796,9 @@ create_codex_runtime_root() {
|
|||
if [ -d "$gstack_dir/bin" ]; then
|
||||
_link_or_copy "$gstack_dir/bin" "$codex_gstack/bin"
|
||||
fi
|
||||
if [ -d "$gstack_dir/lib" ]; then
|
||||
_link_or_copy "$gstack_dir/lib" "$codex_gstack/lib"
|
||||
fi
|
||||
if [ -d "$gstack_dir/browse/dist" ]; then
|
||||
_link_or_copy "$gstack_dir/browse/dist" "$codex_gstack/browse/dist"
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -0,0 +1,221 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { delimiter, dirname, join, resolve } from "path";
|
||||
import { spawnSync } from "child_process";
|
||||
|
||||
import codex from "../hosts/codex";
|
||||
import { planGbrainSpawn } from "../lib/gbrain-exec";
|
||||
import { sourcePathsEqual } from "../lib/gbrain-sources";
|
||||
import { planSourceScopedDream } from "../bin/gstack-gbrain-sync";
|
||||
|
||||
const ROOT = join(import.meta.dir, "..");
|
||||
const SETUP = readFileSync(join(ROOT, "setup"), "utf-8");
|
||||
|
||||
function setupFunction(name: string): string {
|
||||
const start = SETUP.indexOf(`${name}() {`);
|
||||
const end = SETUP.indexOf("\n}\n", start);
|
||||
if (start < 0 || end < 0) throw new Error(`Could not extract ${name} from setup`);
|
||||
return SETUP.slice(start, end + 2);
|
||||
}
|
||||
|
||||
function bashExecutable(): string {
|
||||
if (process.platform === "win32") {
|
||||
const git = Bun.which("git");
|
||||
if (git) {
|
||||
const gitBash = resolve(dirname(git), "..", "bin", "bash.exe");
|
||||
if (existsSync(gitBash)) return gitBash;
|
||||
}
|
||||
}
|
||||
return Bun.which("bash") || "bash";
|
||||
}
|
||||
|
||||
describe("DEV-206 Windows Codex runtime and GBrain regressions", () => {
|
||||
test("Codex runtime metadata and setup include lib beside imported bin scripts", () => {
|
||||
expect(codex.runtimeRoot.globalSymlinks).toContain("lib");
|
||||
expect(codex.sidecar?.symlinks).toContain("lib");
|
||||
|
||||
const runtimeStart = SETUP.indexOf("create_codex_runtime_root() {");
|
||||
const runtimeEnd = SETUP.indexOf("\n}\n", runtimeStart);
|
||||
const runtimeBody = SETUP.slice(runtimeStart, runtimeEnd);
|
||||
expect(runtimeBody).toContain('_link_or_copy "$gstack_dir/lib" "$codex_gstack/lib"');
|
||||
|
||||
const sidecarStart = SETUP.indexOf("create_agents_sidecar() {");
|
||||
const sidecarEnd = SETUP.indexOf("\n}\n", sidecarStart);
|
||||
const sidecarBody = SETUP.slice(sidecarStart, sidecarEnd);
|
||||
expect(sidecarBody).toMatch(/for asset in[^\n]*\blib\b/);
|
||||
});
|
||||
|
||||
test("simulated Windows Codex install and upgrade preserve lib imports", () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "gstack-dev-206-runtime-"));
|
||||
try {
|
||||
const source = join(tmp, "source");
|
||||
const runtime = join(tmp, "codex runtime");
|
||||
mkdirSync(join(source, ".agents", "skills", "gstack"), { recursive: true });
|
||||
mkdirSync(join(source, "bin"), { recursive: true });
|
||||
mkdirSync(join(source, "lib"), { recursive: true });
|
||||
writeFileSync(join(source, ".agents", "skills", "gstack", "SKILL.md"), "---\nname: gstack\n---\n");
|
||||
writeFileSync(join(source, "lib", "runtime-marker.ts"), 'export const marker = "runtime-ok";\n');
|
||||
const detector = join(source, "bin", "gstack-gbrain-detect");
|
||||
writeFileSync(
|
||||
detector,
|
||||
'#!/usr/bin/env bun\nimport { marker } from "../lib/runtime-marker.ts";\nconsole.log(marker);\n',
|
||||
);
|
||||
chmodSync(detector, 0o755);
|
||||
|
||||
const shell = [
|
||||
"set -e",
|
||||
"IS_WINDOWS=1",
|
||||
setupFunction("_link_or_copy"),
|
||||
setupFunction("create_codex_runtime_root"),
|
||||
'create_codex_runtime_root "$1" "$2"',
|
||||
].join("\n");
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const install = spawnSync(bashExecutable(), ["-c", shell, "dev-206", source, runtime], {
|
||||
encoding: "utf-8",
|
||||
timeout: 15_000,
|
||||
});
|
||||
expect(install.status).toBe(0);
|
||||
expect(install.stderr).toBe("");
|
||||
}
|
||||
|
||||
const run = spawnSync(process.execPath, [join(runtime, "bin", "gstack-gbrain-detect")], {
|
||||
encoding: "utf-8",
|
||||
timeout: 15_000,
|
||||
});
|
||||
expect(run.status).toBe(0);
|
||||
expect(run.stdout.trim()).toBe("runtime-ok");
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("equivalent Windows slash and case forms compare as one resolved source", () => {
|
||||
expect(sourcePathsEqual("C:\\Work Trees\\Example Repo", "c:/work trees/example repo", "win32")).toBe(true);
|
||||
expect(sourcePathsEqual("C:\\Work Trees\\Example Repo", "D:/work trees/example repo", "win32")).toBe(false);
|
||||
});
|
||||
|
||||
test("Windows source registration keeps a path containing spaces as one argv value", () => {
|
||||
const sourcePath = "C:\\Work Trees\\Example Repo";
|
||||
const args = ["sources", "add", "gstack-code-test", "--path", sourcePath, "--federated"];
|
||||
const plan = planGbrainSpawn(args, "C:\\Program Files\\Bun\\gbrain.exe", "win32");
|
||||
|
||||
expect(plan.command).toBe("C:\\Program Files\\Bun\\gbrain.exe");
|
||||
expect(plan.args).toEqual(args);
|
||||
expect(plan.args[4]).toBe(sourcePath);
|
||||
expect(plan.shell).toBe(false);
|
||||
});
|
||||
|
||||
test("Windows source registration rejects shell-only launchers", () => {
|
||||
for (const extension of ["cmd", "bat", "ps1"]) {
|
||||
expect(() =>
|
||||
planGbrainSpawn(
|
||||
["sources", "list", "--json"],
|
||||
`C:\\Program Files\\GBrain\\gbrain.${extension}`,
|
||||
"win32",
|
||||
),
|
||||
).toThrow("unsafe Windows gbrain launcher");
|
||||
}
|
||||
});
|
||||
|
||||
test("dream capability planning refuses a brain-wide fallback", () => {
|
||||
const supported = planSourceScopedDream(
|
||||
"gstack-code-test",
|
||||
0,
|
||||
"Usage: gbrain dream [--source <id>]",
|
||||
);
|
||||
expect(supported).toEqual({ ok: true, args: ["dream", "--source", "gstack-code-test"] });
|
||||
|
||||
const brainWideOnly = planSourceScopedDream(
|
||||
"gstack-code-test",
|
||||
0,
|
||||
"Usage: gbrain dream",
|
||||
);
|
||||
expect(brainWideOnly.ok).toBe(false);
|
||||
if (!brainWideOnly.ok) {
|
||||
expect(brainWideOnly.error).toContain("does not support --source");
|
||||
expect(brainWideOnly.error).toContain("refusing to widen scope");
|
||||
}
|
||||
});
|
||||
|
||||
test("dream execution probes capability and never launches a brain-wide fallback", () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "gstack-dev-206-dream-"));
|
||||
try {
|
||||
const binDir = join(tmp, "bin");
|
||||
const gbrainHome = join(tmp, ".gbrain");
|
||||
const gstackHome = join(tmp, ".gstack");
|
||||
const logPath = join(tmp, "gbrain-calls.jsonl");
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
mkdirSync(gbrainHome, { recursive: true });
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
writeFileSync(
|
||||
join(gbrainHome, "config.json"),
|
||||
JSON.stringify({ engine: "pglite", database_url: "pglite:///synthetic" }),
|
||||
);
|
||||
|
||||
const fakeSource = join(tmp, "fake-gbrain.ts");
|
||||
const fakeBinary = join(binDir, process.platform === "win32" ? "gbrain.exe" : "gbrain");
|
||||
writeFileSync(
|
||||
fakeSource,
|
||||
`import { appendFileSync } from "fs";
|
||||
const args = process.argv.slice(2);
|
||||
appendFileSync(process.env.FAKE_GBRAIN_LOG!, JSON.stringify(args) + "\\n");
|
||||
if (args.join(" ") === "--version") { console.log("gbrain 0.41.0"); process.exit(0); }
|
||||
if (args.join(" ") === "sources list --json") { console.log('{"sources":[]}'); process.exit(0); }
|
||||
if (args.join(" ") === "dream --help") { console.log("Usage: gbrain dream"); process.exit(0); }
|
||||
process.exit(91);
|
||||
`,
|
||||
);
|
||||
const built = spawnSync(
|
||||
process.execPath,
|
||||
["build", fakeSource, "--compile", "--outfile", fakeBinary],
|
||||
{ encoding: "utf-8", timeout: 30_000 },
|
||||
);
|
||||
expect(built.status).toBe(0);
|
||||
chmodSync(fakeBinary, 0o755);
|
||||
|
||||
const childEnv: NodeJS.ProcessEnv = { ...process.env };
|
||||
for (const key of Object.keys(childEnv)) {
|
||||
if (key.toUpperCase() === "PATH") delete childEnv[key];
|
||||
}
|
||||
Object.assign(childEnv, {
|
||||
HOME: tmp,
|
||||
GBRAIN_HOME: gbrainHome,
|
||||
GSTACK_HOME: gstackHome,
|
||||
PATH: `${binDir}${delimiter}${process.env.PATH || ""}`,
|
||||
FAKE_GBRAIN_LOG: logPath,
|
||||
});
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
join(ROOT, "bin", "gstack-gbrain-sync.ts"),
|
||||
"--dream",
|
||||
"--no-code",
|
||||
"--no-memory",
|
||||
"--no-brain-sync",
|
||||
],
|
||||
{
|
||||
cwd: ROOT,
|
||||
encoding: "utf-8",
|
||||
timeout: 15_000,
|
||||
env: childEnv,
|
||||
},
|
||||
);
|
||||
const output = `${result.stdout || ""}${result.stderr || ""}`;
|
||||
expect(result.status).toBe(1);
|
||||
expect(output).toContain("does not support --source");
|
||||
expect(output).toContain("refusing to widen scope");
|
||||
|
||||
const calls = readFileSync(logPath, "utf-8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as string[]);
|
||||
expect(calls).toContainEqual(["dream", "--help"]);
|
||||
expect(calls).not.toContainEqual(["dream"]);
|
||||
expect(calls.some((call) => call[0] === "dream" && call[1] !== "--help")).toBe(false);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -9,13 +9,39 @@
|
|||
* start; explicit env is the only reliable redirect).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, chmodSync } from "fs";
|
||||
import { afterAll, describe, it, expect } from "bun:test";
|
||||
import { mkdtempSync, writeFileSync, rmSync, chmodSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { delimiter, join } from "path";
|
||||
import { spawnSync } from "child_process";
|
||||
|
||||
import { cycleCompleted } from "../lib/gbrain-sources";
|
||||
|
||||
const FAKE_BUILD_ROOT = mkdtempSync(join(tmpdir(), "gbrain-cycle-fake-bin-"));
|
||||
const FAKE_SOURCE = join(FAKE_BUILD_ROOT, "fake-gbrain.ts");
|
||||
const FAKE_BINARY = join(FAKE_BUILD_ROOT, process.platform === "win32" ? "gbrain.exe" : "gbrain");
|
||||
|
||||
writeFileSync(
|
||||
FAKE_SOURCE,
|
||||
`const args = process.argv.slice(2);
|
||||
if (args.join(" ") !== "doctor --json --fast") process.exit(1);
|
||||
const exitCode = Number(process.env.FAKE_GBRAIN_DOCTOR_EXIT || "0");
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
process.stdout.write(process.env.FAKE_GBRAIN_DOCTOR_JSON || "");
|
||||
`,
|
||||
);
|
||||
const fakeBuild = spawnSync(
|
||||
process.execPath,
|
||||
["build", FAKE_SOURCE, "--compile", "--outfile", FAKE_BINARY],
|
||||
{ encoding: "utf-8", timeout: 30_000 },
|
||||
);
|
||||
if (fakeBuild.status !== 0) {
|
||||
throw new Error(`Could not build fake gbrain: ${fakeBuild.stderr || fakeBuild.stdout}`);
|
||||
}
|
||||
chmodSync(FAKE_BINARY, 0o755);
|
||||
|
||||
afterAll(() => rmSync(FAKE_BUILD_ROOT, { recursive: true, force: true }));
|
||||
|
||||
interface FakeSetup {
|
||||
env: NodeJS.ProcessEnv;
|
||||
cleanup: () => void;
|
||||
|
|
@ -25,33 +51,17 @@ interface FakeSetup {
|
|||
* Fake `gbrain`:
|
||||
* doctor --json --fast → echo $DOCTOR_JSON (or exit $DOCTOR_EXIT if set)
|
||||
* anything else → exit 1
|
||||
* The doctor payload is baked into the script so each test gets its own shim.
|
||||
* Each test supplies its doctor result through the child environment while all
|
||||
* tests reuse one compiled, cross-platform executable shim.
|
||||
*/
|
||||
function makeFakeGbrain(opts: { doctorJson?: string; doctorExit?: number }): FakeSetup {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "gbrain-cycle-test-"));
|
||||
const bindir = join(tmp, "bin");
|
||||
mkdirSync(bindir, { recursive: true });
|
||||
|
||||
const exit = opts.doctorExit ?? 0;
|
||||
// Single-quote the JSON for the heredoc-free echo; escape embedded single quotes.
|
||||
const payload = (opts.doctorJson ?? "").replace(/'/g, "'\\''");
|
||||
const fake = `#!/bin/sh
|
||||
case "$1 $2 $3" in
|
||||
"doctor --json --fast")
|
||||
if [ ${exit} -ne 0 ]; then exit ${exit}; fi
|
||||
printf '%s' '${payload}'
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
echo "fake gbrain: unknown command: $@" >&2
|
||||
exit 1
|
||||
`;
|
||||
const fakePath = join(bindir, "gbrain");
|
||||
writeFileSync(fakePath, fake);
|
||||
chmodSync(fakePath, 0o755);
|
||||
|
||||
const env: NodeJS.ProcessEnv = { ...process.env, PATH: `${bindir}:${process.env.PATH || ""}` };
|
||||
return { env, cleanup: () => rmSync(tmp, { recursive: true, force: true }) };
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
PATH: `${FAKE_BUILD_ROOT}${delimiter}${process.env.PATH || ""}`,
|
||||
FAKE_GBRAIN_DOCTOR_EXIT: String(opts.doctorExit ?? 0),
|
||||
FAKE_GBRAIN_DOCTOR_JSON: opts.doctorJson ?? "",
|
||||
};
|
||||
return { env, cleanup: () => undefined };
|
||||
}
|
||||
|
||||
const SRC = "gstack-code-gstack-c5994d95";
|
||||
|
|
|
|||
|
|
@ -7,13 +7,60 @@
|
|||
* invocations. The same trick `test/gstack-gbrain-source-wireup.test.ts` uses.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { mkdtempSync, writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, chmodSync } from "fs";
|
||||
import { afterAll, describe, it, expect } from "bun:test";
|
||||
import { mkdtempSync, writeFileSync, readFileSync, rmSync, chmodSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { delimiter, join } from "path";
|
||||
import { spawnSync } from "child_process";
|
||||
|
||||
import { ensureSourceRegistered, probeSource, sourcePageCount } from "../lib/gbrain-sources";
|
||||
|
||||
const FAKE_BUILD_ROOT = mkdtempSync(join(tmpdir(), "gbrain-sources-fake-bin-"));
|
||||
const FAKE_SOURCE = join(FAKE_BUILD_ROOT, "fake-gbrain.ts");
|
||||
const FAKE_BINARY = join(FAKE_BUILD_ROOT, process.platform === "win32" ? "gbrain.exe" : "gbrain");
|
||||
|
||||
writeFileSync(
|
||||
FAKE_SOURCE,
|
||||
`import { appendFileSync, readFileSync, writeFileSync } from "fs";
|
||||
const args = process.argv.slice(2);
|
||||
const statePath = process.env.FAKE_GBRAIN_STATE_PATH!;
|
||||
const logPath = process.env.FAKE_GBRAIN_LOG_PATH!;
|
||||
appendFileSync(logPath, args.join(" ") + "\\n");
|
||||
if (args[0] === "--version") { console.log("gbrain 0.25.1"); process.exit(0); }
|
||||
if (args[0] !== "sources") process.exit(1);
|
||||
const state = JSON.parse(readFileSync(statePath, "utf-8"));
|
||||
if (args[1] === "list") { console.log(JSON.stringify(state)); process.exit(0); }
|
||||
if (args[1] === "add") {
|
||||
const pathIndex = args.indexOf("--path");
|
||||
state.sources.push({
|
||||
id: args[2],
|
||||
local_path: pathIndex >= 0 ? args[pathIndex + 1] : "",
|
||||
federated: args.includes("--federated"),
|
||||
page_count: 0,
|
||||
});
|
||||
writeFileSync(statePath, JSON.stringify(state));
|
||||
process.exit(0);
|
||||
}
|
||||
if (args[1] === "remove") {
|
||||
state.sources = state.sources.filter((source: { id?: string }) => source.id !== args[2]);
|
||||
writeFileSync(statePath, JSON.stringify(state));
|
||||
process.exit(0);
|
||||
}
|
||||
process.exit(1);
|
||||
`,
|
||||
);
|
||||
const fakeBuild = spawnSync(
|
||||
process.execPath,
|
||||
["build", FAKE_SOURCE, "--compile", "--outfile", FAKE_BINARY],
|
||||
{ encoding: "utf-8", timeout: 30_000 },
|
||||
);
|
||||
if (fakeBuild.status !== 0) {
|
||||
throw new Error(`Could not build fake gbrain: ${fakeBuild.stderr || fakeBuild.stdout}`);
|
||||
}
|
||||
chmodSync(FAKE_BINARY, 0o755);
|
||||
|
||||
afterAll(() => rmSync(FAKE_BUILD_ROOT, { recursive: true, force: true }));
|
||||
|
||||
interface FakeGbrainSetup {
|
||||
bindir: string;
|
||||
statePath: string;
|
||||
|
|
@ -38,59 +85,21 @@ interface FakeGbrainSetup {
|
|||
*/
|
||||
function makeFakeGbrain(initialState: { sources: Array<{ id: string; local_path: string; federated?: boolean; page_count?: number }> }): FakeGbrainSetup {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "gbrain-sources-test-"));
|
||||
const bindir = join(tmp, "bin");
|
||||
mkdirSync(bindir, { recursive: true });
|
||||
const bindir = FAKE_BUILD_ROOT;
|
||||
const statePath = join(tmp, "state.json");
|
||||
const logPath = join(tmp, "calls.log");
|
||||
writeFileSync(statePath, JSON.stringify(initialState));
|
||||
writeFileSync(logPath, "");
|
||||
|
||||
const fake = `#!/bin/sh
|
||||
echo "$@" >> "${logPath}"
|
||||
case "$1 $2" in
|
||||
"--version ")
|
||||
echo "gbrain 0.25.1"
|
||||
exit 0
|
||||
;;
|
||||
"sources list")
|
||||
cat "${statePath}"
|
||||
exit 0
|
||||
;;
|
||||
"sources add")
|
||||
ID="$3"
|
||||
shift 3
|
||||
PATH_VAL=""
|
||||
FED="false"
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--path) PATH_VAL="$2"; shift 2 ;;
|
||||
--federated) FED="true"; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
NEW=$(jq --arg id "$ID" --arg path "$PATH_VAL" --argjson fed "$FED" \
|
||||
'.sources += [{id: $id, local_path: $path, federated: $fed, page_count: 0}]' "${statePath}")
|
||||
echo "$NEW" > "${statePath}"
|
||||
exit 0
|
||||
;;
|
||||
"sources remove")
|
||||
ID="$3"
|
||||
NEW=$(jq --arg id "$ID" '.sources = (.sources | map(select(.id != $id)))' "${statePath}")
|
||||
echo "$NEW" > "${statePath}"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
echo "fake gbrain: unknown command: $@" >&2
|
||||
exit 1
|
||||
`;
|
||||
const fakePath = join(bindir, "gbrain");
|
||||
writeFileSync(fakePath, fake);
|
||||
chmodSync(fakePath, 0o755);
|
||||
|
||||
// Build the env override we'll pass to helper calls. We do NOT mutate
|
||||
// process.env globally because Bun's execFileSync caches PATH at process
|
||||
// start; explicit env is the only reliable way to redirect spawn-time PATH.
|
||||
const env: NodeJS.ProcessEnv = { ...process.env, PATH: `${bindir}:${process.env.PATH || ""}` };
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
PATH: `${bindir}${delimiter}${process.env.PATH || ""}`,
|
||||
FAKE_GBRAIN_STATE_PATH: statePath,
|
||||
FAKE_GBRAIN_LOG_PATH: logPath,
|
||||
};
|
||||
|
||||
return {
|
||||
bindir,
|
||||
|
|
@ -190,6 +199,40 @@ describe("ensureSourceRegistered", () => {
|
|||
expect(log).not.toContain("sources add");
|
||||
fake.cleanup();
|
||||
});
|
||||
|
||||
it("reuses equivalent Windows slash/case paths without destructive re-registration", async () => {
|
||||
const fake = makeFakeGbrain({
|
||||
sources: [{ id: "gstack-code-foo", local_path: "C:\\Work Trees\\Example Repo" }],
|
||||
});
|
||||
const result = await ensureSourceRegistered(
|
||||
"gstack-code-foo",
|
||||
"c:/work trees/example repo",
|
||||
{ env: fake.env },
|
||||
);
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.state.status).toBe("match");
|
||||
|
||||
const log = readFileSync(fake.logPath, "utf-8");
|
||||
expect(log).not.toContain("sources remove");
|
||||
expect(log).not.toContain("sources add");
|
||||
fake.cleanup();
|
||||
});
|
||||
|
||||
it("registers a Windows worktree path containing spaces as one argument", async () => {
|
||||
const fake = makeFakeGbrain({ sources: [] });
|
||||
const sourcePath = "C:\\Work Trees\\Example Repo";
|
||||
const result = await ensureSourceRegistered("gstack-code-foo", sourcePath, {
|
||||
federated: true,
|
||||
env: fake.env,
|
||||
});
|
||||
expect(result.changed).toBe(true);
|
||||
|
||||
const state = JSON.parse(readFileSync(fake.statePath, "utf-8")) as {
|
||||
sources: Array<{ local_path?: string }>;
|
||||
};
|
||||
expect(state.sources[0]?.local_path).toBe(sourcePath);
|
||||
fake.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sourcePageCount", () => {
|
||||
|
|
|
|||
|
|
@ -5,34 +5,29 @@ import * as path from "path";
|
|||
const ROOT = path.resolve(import.meta.dir, "..");
|
||||
const read = (rel: string) => fs.readFileSync(path.join(ROOT, rel), "utf-8");
|
||||
|
||||
// #1731 tripwire. Windows can't spawn the `gbrain` shim (gbrain.cmd) or the bash
|
||||
// shebang script gstack-brain-sync without a shell; the fix gates `shell: true`
|
||||
// behind NEEDS_SHELL_ON_WINDOWS. These static checks fail CI if a refactor adds
|
||||
// a gbrain/brain-sync child spawn without the Windows shell flag, since macOS/
|
||||
// Linux CI can't exercise the Windows path at runtime.
|
||||
describe("#1731 gbrain spawns carry the Windows shell flag", () => {
|
||||
// #1731 + DEV-206 tripwire. Bash helpers still need a shell on Windows, but
|
||||
// gbrain itself must resolve to Bun's direct executable shim so argv values
|
||||
// (notably worktree paths containing spaces) never pass through cmd.exe.
|
||||
describe("Windows gbrain spawns preserve structural argv", () => {
|
||||
test("NEEDS_SHELL_ON_WINDOWS is platform-gated in gbrain-exec.ts", () => {
|
||||
const src = read("lib/gbrain-exec.ts");
|
||||
expect(src).toMatch(/export const NEEDS_SHELL_ON_WINDOWS\s*=\s*process\.platform === "win32"/);
|
||||
});
|
||||
|
||||
// Every direct `gbrain` child spawn in these files must be matched by a
|
||||
// shell:NEEDS_SHELL_ON_WINDOWS flag. Count openers vs flags as a cheap,
|
||||
// refactor-resistant invariant.
|
||||
const gbrainSpawnFiles = [
|
||||
"lib/gbrain-exec.ts",
|
||||
"lib/gbrain-sources.ts",
|
||||
"lib/gbrain-local-status.ts",
|
||||
];
|
||||
for (const rel of gbrainSpawnFiles) {
|
||||
test(`${rel}: every gbrain spawn has shell:NEEDS_SHELL_ON_WINDOWS`, () => {
|
||||
const src = read(rel);
|
||||
const spawnOpeners = src.match(/(spawnSync|spawn|execFileSync)\("gbrain"/g)?.length ?? 0;
|
||||
const shellFlags = src.match(/shell:\s*NEEDS_SHELL_ON_WINDOWS/g)?.length ?? 0;
|
||||
expect(spawnOpeners).toBeGreaterThan(0);
|
||||
expect(shellFlags).toBeGreaterThanOrEqual(spawnOpeners);
|
||||
});
|
||||
}
|
||||
test("gbrain-exec resolves a direct executable and disables shell parsing", () => {
|
||||
const src = read("lib/gbrain-exec.ts");
|
||||
expect(src).toContain('Bun.which("gbrain"');
|
||||
expect(src).toContain("planGbrainSpawn");
|
||||
expect(src).toContain("return { command, args: [...args], shell: false }");
|
||||
expect(src).not.toMatch(/(spawnSync|spawn|execFileSync)\("gbrain"/);
|
||||
});
|
||||
|
||||
test("gbrain-sources routes registration through the structural helper", () => {
|
||||
const src = read("lib/gbrain-sources.ts");
|
||||
expect(src).toContain("spawnGbrain(addArgs");
|
||||
expect(src).toContain('spawnGbrain(["sources", "remove"');
|
||||
expect(src).not.toContain('spawnSync("gbrain"');
|
||||
});
|
||||
|
||||
test("orchestrator brain-sync spawns carry the Windows shell flag", () => {
|
||||
const src = read("bin/gstack-gbrain-sync.ts");
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, mkdirSync, chmodSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { delimiter, join } from "path";
|
||||
import { spawnSync } from "child_process";
|
||||
|
||||
import {
|
||||
|
|
@ -247,10 +247,8 @@ describe("gstack-gbrain-sync CLI", () => {
|
|||
// shim on PATH so the stage runs (we only assert the preview line, never
|
||||
// invoke gbrain itself).
|
||||
const bindir = mkdtempSync(join(tmpdir(), "gstack-host-collide-bin-"));
|
||||
const shim = join(bindir, "gbrain");
|
||||
writeFileSync(shim, "#!/bin/sh\nexit 0\n");
|
||||
chmodSync(shim, 0o755);
|
||||
const PATH = `${bindir}:${process.env.PATH || ""}`;
|
||||
makeShim(bindir, { "--help": { stdout: "gbrain\n" } });
|
||||
const PATH = `${bindir}${delimiter}${process.env.PATH || ""}`;
|
||||
|
||||
const runAs = (host: string) =>
|
||||
spawnSync("bun", [SCRIPT, "--dry-run", "--code-only", "--quiet"], {
|
||||
|
|
@ -563,19 +561,27 @@ describe("gstack-gbrain-sync CLI", () => {
|
|||
* (which spawn `gbrain` from PATH) without a real gbrain CLI.
|
||||
*/
|
||||
function makeShim(bindir: string, responses: Record<string, { stdout?: string; stderr?: string; exit?: number }>): string {
|
||||
const shim = join(bindir, "gbrain");
|
||||
const cases = Object.entries(responses).map(([key, r]) => {
|
||||
const exit = r.exit ?? 0;
|
||||
const stdout = (r.stdout || "").replace(/'/g, "'\\''");
|
||||
const stderr = (r.stderr || "").replace(/'/g, "'\\''");
|
||||
// Patterns with spaces MUST be double-quoted in sh case statements,
|
||||
// otherwise the shell parses the second word as the start of the next
|
||||
// pattern and errors out.
|
||||
return ` "${key}") printf '%s' '${stdout}'; printf '%s' '${stderr}' >&2; exit ${exit} ;;`;
|
||||
}).join("\n");
|
||||
// Match on the full argument string, joined with literal spaces.
|
||||
const script = `#!/bin/sh\nARGS="$*"\ncase "$ARGS" in\n${cases}\n *) echo "shim: no match for [$ARGS]" >&2; exit 1 ;;\nesac\n`;
|
||||
writeFileSync(shim, script);
|
||||
const source = join(bindir, "gbrain-shim.ts");
|
||||
const shim = join(bindir, process.platform === "win32" ? "gbrain.exe" : "gbrain");
|
||||
const script = `
|
||||
const responses = ${JSON.stringify(responses)} as Record<string, { stdout?: string; stderr?: string; exit?: number }>;
|
||||
const args = Bun.argv.slice(2).join(" ");
|
||||
const response = responses[args];
|
||||
if (!response) {
|
||||
process.stderr.write(\`shim: no match for [\${args}]\\n\`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (response.stdout) process.stdout.write(response.stdout);
|
||||
if (response.stderr) process.stderr.write(response.stderr);
|
||||
process.exit(response.exit ?? 0);
|
||||
`;
|
||||
writeFileSync(source, script);
|
||||
const built = spawnSync(process.execPath, ["build", source, "--compile", "--outfile", shim], {
|
||||
encoding: "utf-8",
|
||||
});
|
||||
if (built.status !== 0) {
|
||||
throw new Error(`failed to compile gbrain shim: ${built.stderr || built.stdout}`);
|
||||
}
|
||||
chmodSync(shim, 0o755);
|
||||
return shim;
|
||||
}
|
||||
|
|
@ -631,7 +637,7 @@ describe("derivePathOnlyHashLegacyId", () => {
|
|||
encoding: "utf-8",
|
||||
timeout: 60000,
|
||||
cwd: repo,
|
||||
env: { ...process.env, HOME: home, GSTACK_HOME: gstackHome, GSTACK_HOSTNAME: "machine-x", PATH: `${bindir}:${process.env.PATH || ""}` },
|
||||
env: { ...process.env, HOME: home, GSTACK_HOME: gstackHome, GSTACK_HOSTNAME: "machine-x", PATH: `${bindir}${delimiter}${process.env.PATH || ""}` },
|
||||
});
|
||||
const newId = (r.stdout || "").match(/gbrain sources add (\S+)/)?.[1];
|
||||
expect(newId).toBeTruthy();
|
||||
|
|
@ -652,7 +658,7 @@ describe("derivePathOnlyHashLegacyId", () => {
|
|||
* explicitly to each spawn for the override to take effect.
|
||||
*/
|
||||
function envWithBindir(bindir: string): NodeJS.ProcessEnv {
|
||||
return { ...process.env, PATH: `${bindir}:${process.env.PATH || ""}` };
|
||||
return { ...process.env, PATH: `${bindir}${delimiter}${process.env.PATH || ""}` };
|
||||
}
|
||||
|
||||
describe("planHostnameFoldMigration", () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue