mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-29 09:20:39 +02:00
Merge remote-tracking branch 'origin/main' into prompt-token-load-reduction
This commit is contained in:
@@ -314,3 +314,96 @@ describe('gstack-evidence check', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-evidence run — bun dotenv autoload must not reach the child', () => {
|
||||
// bun auto-loads .env / .env.<NODE_ENV> / .env.local from the cwd into
|
||||
// process.env, and this binary has a bun shebang, so without scrubbing every
|
||||
// spawned command inherits them. That leaks production credentials into a child
|
||||
// that would not otherwise have them AND changes the behaviour of the command
|
||||
// being certified, which is the worse half: the ledger would vouch for a run
|
||||
// that differs from the one CI performs.
|
||||
//
|
||||
// ⚠️ `bun test` runs with NODE_ENV=test, and bun SKIPS .env.local in test mode
|
||||
// (verified on bun 1.3.11: NODE_ENV=test loads .env but not .env.local). A
|
||||
// .env.local fixture here therefore proves nothing unless NODE_ENV is cleared
|
||||
// for the spawn — the first version of these tests passed for exactly that
|
||||
// wrong reason. Every leak test below asserts the scrub WARNING fired, so a
|
||||
// fixture bun never loaded fails instead of passing silently.
|
||||
|
||||
function runWith(env: Record<string, string | undefined>, cmd: string) {
|
||||
return spawnSync(EVIDENCE, ['run', '--label', 'envprobe', '--', cmd], {
|
||||
cwd: repoDir,
|
||||
env: { ...process.env, GSTACK_HOME: gstackHome, ...env },
|
||||
encoding: 'utf-8',
|
||||
timeout: 60000,
|
||||
});
|
||||
}
|
||||
|
||||
test('a .env value is scrubbed, and the warning names the key but never the value', () => {
|
||||
fs.writeFileSync(path.join(repoDir, '.env'), 'ZZ_TOKEN_PROBE="s3cret-value"\n');
|
||||
const r = run(['run', '--label', 'envprobe', '--', 'echo "saw=[${ZZ_TOKEN_PROBE:-absent}]"']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr).toContain('ZZ_TOKEN_PROBE'); // positive control: the scrub ran
|
||||
expect(r.stdout).toContain('saw=[absent]');
|
||||
// The diagnostic must not become the leak it prevents.
|
||||
expect(r.stderr).not.toContain('s3cret-value');
|
||||
expect(r.stdout).not.toContain('s3cret-value');
|
||||
});
|
||||
|
||||
test('a .env.local value is scrubbed when bun actually loads it (NODE_ENV cleared)', () => {
|
||||
fs.writeFileSync(path.join(repoDir, '.env.local'), 'ZZ_LOCAL_PROBE=leaked\n');
|
||||
const r = runWith({ NODE_ENV: undefined }, 'echo "saw=[${ZZ_LOCAL_PROBE:-absent}]"');
|
||||
expect(r.stderr ?? '').toContain('ZZ_LOCAL_PROBE'); // positive control
|
||||
expect(r.stdout ?? '').toContain('saw=[absent]');
|
||||
expect(r.stdout ?? '').not.toContain('leaked');
|
||||
});
|
||||
|
||||
test('.env.local is left alone under NODE_ENV=test, because bun never loaded it', () => {
|
||||
// Mirrors bun's own precedence. Scrubbing a key bun did not inject would strip
|
||||
// a variable the caller's shell legitimately provided.
|
||||
fs.writeFileSync(path.join(repoDir, '.env.local'), 'ZZ_TESTMODE_PROBE=from_file\n');
|
||||
const r = runWith({ NODE_ENV: 'test', ZZ_TESTMODE_PROBE: 'from_shell' },
|
||||
'echo "saw=[${ZZ_TESTMODE_PROBE:-absent}]"');
|
||||
expect(r.stdout ?? '').toContain('saw=[from_shell]');
|
||||
});
|
||||
|
||||
test('CONTROL — a var the shell exported with a different value SURVIVES', () => {
|
||||
// bun does not override an already-exported var (verified on bun 1.3.11), so a
|
||||
// live value that differs from the file is genuinely the user's environment.
|
||||
fs.writeFileSync(path.join(repoDir, '.env'), 'ZZ_KEEP_PROBE=from_file\n');
|
||||
const r = runWith({ ZZ_KEEP_PROBE: 'from_shell' }, 'echo "saw=[${ZZ_KEEP_PROBE:-absent}]"');
|
||||
expect(r.stdout ?? '').toContain('saw=[from_shell]');
|
||||
expect(r.stderr ?? '').not.toContain('ZZ_KEEP_PROBE');
|
||||
});
|
||||
|
||||
test('GSTACK_EVIDENCE_KEEP_DOTENV=1 restores the old pass-through behaviour', () => {
|
||||
fs.writeFileSync(path.join(repoDir, '.env'), 'ZZ_OPTOUT_PROBE=kept\n');
|
||||
const r = runWith({ GSTACK_EVIDENCE_KEEP_DOTENV: '1' }, 'echo "saw=[${ZZ_OPTOUT_PROBE:-absent}]"');
|
||||
expect(r.stdout ?? '').toContain('saw=[kept]');
|
||||
expect(r.stderr ?? '').not.toContain('scrubbed');
|
||||
});
|
||||
|
||||
test('no dotenv file means no scrub warning at all', () => {
|
||||
const r = run(['run', '--label', 'envprobe', '--', 'echo hi']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr).not.toContain('scrubbed');
|
||||
});
|
||||
|
||||
test('an UNREADABLE .env fails open: evidence still runs, nothing scrubbed', () => {
|
||||
const envPath = path.join(repoDir, '.env');
|
||||
fs.writeFileSync(envPath, 'ZZ_DENIED_PROBE=hidden\n');
|
||||
fs.chmodSync(envPath, 0o000);
|
||||
// chmod 000 cannot create unreadability for root or CAP_DAC_OVERRIDE
|
||||
// environments (reads succeed regardless) — probe functionally and skip
|
||||
// rather than assert a condition the fixture couldn't create.
|
||||
try { fs.readFileSync(envPath); fs.chmodSync(envPath, 0o644); return; } catch {}
|
||||
try {
|
||||
const r = run(['run', '--label', 'envprobe', '--', 'echo hi']);
|
||||
// The scrub must skip the unreadable file and the run must still be recorded.
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr ?? '').not.toContain('ZZ_DENIED_PROBE');
|
||||
} finally {
|
||||
fs.chmodSync(envPath, 0o644); // let afterEach rmSync succeed
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* mkdirpSync + install-prepush-hook under bun-on-Windows EEXIST semantics
|
||||
* (#2635).
|
||||
*
|
||||
* bun on Windows throws EEXIST from fs.mkdirSync(dir, { recursive: true })
|
||||
* when dir already exists - Node treats it as a no-op - which crashed
|
||||
* `gstack-redact install-prepush-hook` on any repo whose .git/hooks already
|
||||
* existed. The CLI regression test below emulates those Windows semantics via
|
||||
* a `bun --preload` fixture (test/helpers/emulate-bun-windows-eexist.ts), so
|
||||
* the crash path runs on any platform, including CI Linux.
|
||||
*/
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import { spawnSync } from "child_process";
|
||||
import { mkdirpSync } from "../lib/fs-utils";
|
||||
|
||||
const REDACT = path.resolve(import.meta.dir, "..", "bin", "gstack-redact");
|
||||
const EEXIST_PRELOAD = path.resolve(
|
||||
import.meta.dir,
|
||||
"helpers",
|
||||
"emulate-bun-windows-eexist.ts",
|
||||
);
|
||||
|
||||
function tmpdir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "fs-utils-"));
|
||||
}
|
||||
|
||||
describe("mkdirpSync", () => {
|
||||
test("creates missing nested directories", () => {
|
||||
const base = tmpdir();
|
||||
try {
|
||||
const dir = path.join(base, "a", "b", "c");
|
||||
mkdirpSync(dir);
|
||||
expect(fs.statSync(dir).isDirectory()).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("tolerates the directory already existing", () => {
|
||||
const base = tmpdir();
|
||||
try {
|
||||
mkdirpSync(base); // exists -> must be a no-op, not EEXIST
|
||||
mkdirpSync(base); // and idempotent on repeat calls
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("still throws EEXIST when a regular file occupies the path", () => {
|
||||
const base = tmpdir();
|
||||
try {
|
||||
const file = path.join(base, "occupied");
|
||||
fs.writeFileSync(file, "x");
|
||||
expect(() => mkdirpSync(file)).toThrow(/EEXIST/);
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("swept mkdirp sites under bun-on-Windows EEXIST semantics (#2635)", () => {
|
||||
const DECISION_LOG = path.resolve(import.meta.dir, "..", "bin", "gstack-decision-log");
|
||||
|
||||
test("decision-log still writes when its projects dir already exists", () => {
|
||||
// Proves the sweep WIRING, not just the helper: the first call creates
|
||||
// ~/.gstack/projects/<slug>/, the second hits the emulated Windows EEXIST
|
||||
// on that pre-existing dir — bare mkdirSync crashed here before the sweep.
|
||||
const base = tmpdir();
|
||||
try {
|
||||
const work = path.join(base, "work");
|
||||
fs.mkdirSync(work, { recursive: true });
|
||||
const payload = '{"decision":"eexist probe","rationale":"r","scope":"repo","source":"user"}';
|
||||
const env = { ...process.env, HOME: base };
|
||||
const first = spawnSync("bun", [DECISION_LOG, payload], { cwd: work, encoding: "utf8", env });
|
||||
expect(first.status).toBe(0);
|
||||
const second = spawnSync(
|
||||
"bun", ["--preload", EEXIST_PRELOAD, DECISION_LOG, payload],
|
||||
{ cwd: work, encoding: "utf8", env },
|
||||
);
|
||||
expect(second.status).toBe(0);
|
||||
expect(second.stderr ?? "").not.toContain("EEXIST");
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("install-prepush-hook under bun-on-Windows EEXIST semantics (#2635)", () => {
|
||||
test("install succeeds when .git/hooks already exists, existing hook preserved", () => {
|
||||
const base = tmpdir();
|
||||
try {
|
||||
const repo = path.join(base, "repo");
|
||||
spawnSync("git", ["init", "-q", repo]);
|
||||
const hookDir = path.join(repo, ".git", "hooks");
|
||||
fs.mkdirSync(hookDir, { recursive: true });
|
||||
const hookPath = path.join(hookDir, "pre-push");
|
||||
fs.writeFileSync(hookPath, "#!/usr/bin/env bash\necho mine\n", { mode: 0o755 });
|
||||
|
||||
// Under the emulated bun-on-Windows fs, the bare
|
||||
// fs.mkdirSync(dir, { recursive: true }) in installPrepushHook() throws
|
||||
// EEXIST (the #2635 crash). With mkdirpSync it must install cleanly.
|
||||
const r = spawnSync("bun", ["--preload", EEXIST_PRELOAD, REDACT, "install-prepush-hook"], {
|
||||
cwd: repo,
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr ?? "").not.toContain("EEXIST");
|
||||
expect(fs.readFileSync(hookPath, "utf8")).toContain("gstack-redact pre-push (managed)");
|
||||
expect(fs.readFileSync(path.join(hookDir, "pre-push.local"), "utf8")).toContain("echo mine");
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2515,9 +2515,12 @@ describe('setup script validation', () => {
|
||||
expect(claudeSection).toContain('link_claude_root_skill_alias "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"');
|
||||
});
|
||||
|
||||
test('setup supports --host auto|claude|codex|kiro|opencode|cursor|slate', () => {
|
||||
test('setup supports --host auto|claude|codex|kiro|opencode|cursor; slate is informational', () => {
|
||||
expect(setupContent).toContain('--host');
|
||||
expect(setupContent).toContain('claude|codex|kiro|factory|opencode|cursor|slate|auto');
|
||||
// #2361: slate moved OUT of the install accept-list (it was accepted but
|
||||
// never dispatched — a silent exit-0 no-op) into an informational arm.
|
||||
expect(setupContent).toContain('claude|codex|kiro|factory|opencode|cursor|auto');
|
||||
expect(setupContent).toMatch(/^ {2}slate\)/m);
|
||||
});
|
||||
|
||||
test('auto mode detects claude, codex, kiro, and opencode binaries', () => {
|
||||
|
||||
@@ -20,6 +20,18 @@ const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const BIN_DIR = path.join(ROOT, 'bin');
|
||||
const WIREUP_BIN = path.join(BIN_DIR, 'gstack-gbrain-source-wireup');
|
||||
|
||||
// Hermetic PATH base (#2255). The missing-gbrain fixtures must not see a
|
||||
// user-installed gbrain on the host (e.g. macOS /opt/homebrew/bin), or the
|
||||
// "missing" case exits 0 instead of 2. Base is root-owned OS dirs only
|
||||
// (/usr/bin:/bin:/usr/sbin:/sbin — where git/python3/jq/coreutils resolve on
|
||||
// macOS and Linux) plus BUN_ONLY_DIR, so no user-installed gbrain can be
|
||||
// present. The scratch dir holds only a bun symlink, mirroring
|
||||
// gbrain-detect-install.test.ts so spawned children can resolve bun on CI
|
||||
// regardless of install dir.
|
||||
const BUN_ONLY_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'wireup-bun-only-'));
|
||||
fs.symlinkSync(process.execPath, path.join(BUN_ONLY_DIR, 'bun'));
|
||||
const HERMETIC_PATH = `/usr/bin:/bin:/usr/sbin:/sbin:${BUN_ONLY_DIR}`;
|
||||
|
||||
let tmpHome: string;
|
||||
let gstackHome: string;
|
||||
let worktreeDir: string;
|
||||
@@ -30,10 +42,12 @@ let gbrainStateFile: string;
|
||||
function makeFakeGbrain(opts: {
|
||||
version?: string | null; // null = "binary missing" (don't write the file)
|
||||
syncFails?: boolean;
|
||||
syncHelpNoSource?: boolean; // simulate an older gbrain whose sync lacks --source
|
||||
}) {
|
||||
const version = opts.version ?? '0.18.2';
|
||||
if (version === null) return; // simulate missing binary by NOT writing one
|
||||
const syncFails = opts.syncFails ?? false;
|
||||
const syncHelpNoSource = opts.syncHelpNoSource ?? false;
|
||||
|
||||
// Stub gbrain reads/writes state from a JSON file. Fields:
|
||||
// sources: [{id, local_path, federated}]
|
||||
@@ -97,6 +111,13 @@ json.dump(state, open('$STATE','w'), indent=2)
|
||||
fi
|
||||
|
||||
# sync --repo <p> → records, optionally fails
|
||||
# sync --help → advertise flags (the wireup probes this before choosing the
|
||||
# sync form; the default fake mirrors a current gbrain, which HAS --source)
|
||||
if [ "$1" = "sync" ] && [ "$2" = "--help" ]; then
|
||||
echo "Usage: gbrain sync [--repo <path>]${syncHelpNoSource ? '' : ' [--source <id>]'}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$1" = "sync" ]; then
|
||||
${syncFails ? 'echo "sync failed: connection error" >&2; exit 1' : 'echo "1 page imported"; exit 0'}
|
||||
fi
|
||||
@@ -113,7 +134,7 @@ function run(
|
||||
opts: { env?: Record<string, string> } = {}
|
||||
) {
|
||||
const env = {
|
||||
PATH: `${fakeBinDir}:${process.env.PATH || '/usr/bin:/bin:/opt/homebrew/bin'}`,
|
||||
PATH: `${fakeBinDir}:${HERMETIC_PATH}`,
|
||||
HOME: tmpHome,
|
||||
GSTACK_HOME: gstackHome,
|
||||
GSTACK_BRAIN_WORKTREE: worktreeDir,
|
||||
@@ -181,6 +202,31 @@ describe('gstack-gbrain-source-wireup — wireup mode', () => {
|
||||
expect(state.sources[0].federated).toBe(true);
|
||||
});
|
||||
|
||||
test('the real sync targets the REGISTERED source, never --repo (#2662)', () => {
|
||||
// `sync --repo <path>` resolves against the brain's DEFAULT source and can
|
||||
// silently repoint its local_path anchor at our worktree. This case runs
|
||||
// WITHOUT GSTACK_BRAIN_NO_SYNC — the skip-mode cases never reach the sync,
|
||||
// so asserting the sync argv there would be vacuous.
|
||||
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
||||
makeFakeGbrain({});
|
||||
const r = run([]);
|
||||
expect(r.status).toBe(0);
|
||||
const calls = gbrainCalls();
|
||||
expect(calls.some((c) => c.startsWith('gbrain sync --source gstack-brain-user'))).toBe(true);
|
||||
expect(calls.some((c) => c.includes('sync --repo'))).toBe(false);
|
||||
});
|
||||
|
||||
test('older gbrain without sync --source: falls back to --repo with an upgrade warning', () => {
|
||||
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
||||
makeFakeGbrain({ syncHelpNoSource: true });
|
||||
const r = run([]);
|
||||
expect(r.status).toBe(0);
|
||||
const calls = gbrainCalls();
|
||||
expect(calls.some((c) => c.startsWith('gbrain sync --repo'))).toBe(true);
|
||||
expect(calls.some((c) => c.includes('sync --source '))).toBe(false);
|
||||
expect(r.stderr).toContain('#2662');
|
||||
});
|
||||
|
||||
test('idempotent re-run after success: no new sources add call', () => {
|
||||
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
||||
makeFakeGbrain({});
|
||||
@@ -229,14 +275,52 @@ describe('gstack-gbrain-source-wireup — wireup mode', () => {
|
||||
|
||||
test('--strict + gbrain missing on PATH: exits 2', () => {
|
||||
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
||||
// Don't make a fake gbrain — fakeBinDir is empty. Keep system dirs on PATH
|
||||
// so basic commands (git, awk, sed, etc.) work; only `gbrain` is absent.
|
||||
const r = run(['--strict'], {
|
||||
env: { PATH: `${fakeBinDir}:/usr/bin:/bin:/opt/homebrew/bin` },
|
||||
});
|
||||
// Don't make a fake gbrain — fakeBinDir is empty. run() applies the
|
||||
// hermetic PATH base; only `gbrain` is absent.
|
||||
const r = run(['--strict']);
|
||||
expect(r.status).toBe(2);
|
||||
});
|
||||
|
||||
test('--strict + gbrain present in controlled dir: exits 0 (positive control)', () => {
|
||||
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
||||
// Positive control for hermeticity: a gbrain stub in the test-controlled
|
||||
// fakeBinDir (first on the hermetic PATH) IS found, so --strict proceeds
|
||||
// (exit 0). This proves the fixture CAN supply gbrain when present; the
|
||||
// determinism test below proves the host cannot leak one in (#2255).
|
||||
makeFakeGbrain({});
|
||||
const r = run(['--strict'], { env: { GSTACK_BRAIN_NO_SYNC: '1' } });
|
||||
expect(r.status).toBe(0);
|
||||
expect(gbrainCalls().some((c) => c.startsWith('gbrain sources add'))).toBe(true);
|
||||
});
|
||||
|
||||
test('--strict + gbrain present in a host-like dir: still exits 2 (determinism)', () => {
|
||||
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
||||
// Determinism check (#2255, plan TS2): plant a real-looking gbrain stub in
|
||||
// a dir that the OLD fixture would have leaked via process.env.PATH or the
|
||||
// hardcoded /opt/homebrew/bin list. The root-owned-only hermetic base
|
||||
// excludes user-writable dirs, so the child never sees the stub and the
|
||||
// missing case stays deterministic across dev machines. This test fails on
|
||||
// the unpatched fixture (stub found -> exit 0) and passes on the fixed one.
|
||||
const hostLikeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wireup-host-like-'));
|
||||
fs.writeFileSync(
|
||||
path.join(hostLikeDir, 'gbrain'),
|
||||
'#!/bin/bash\necho "gbrain 0.18.2"\n',
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
// No env PATH override: run() applies the hermetic base. The stub exists
|
||||
// only in a user-writable dir the base excludes.
|
||||
const r = run(['--strict']);
|
||||
expect(r.status).toBe(2);
|
||||
// Sanity: the stub IS visible to a shell using the host-like PATH, so this
|
||||
// test would catch the old leak if the base ever regressed.
|
||||
const check = spawnSync('bash', ['-c', `command -v gbrain && gbrain --version`], {
|
||||
env: { PATH: `${hostLikeDir}:${process.env.PATH || '/usr/bin:/bin'}` },
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
expect(check.status).toBe(0);
|
||||
expect(check.stdout).toContain('gbrain 0.18.2');
|
||||
});
|
||||
|
||||
test('source-id derived from origin URL', () => {
|
||||
setupGstackRepo('git@github.com:user/gstack-brain-alice.git');
|
||||
makeFakeGbrain({});
|
||||
@@ -291,6 +375,46 @@ describe('gstack-gbrain-source-wireup — wireup mode', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-gbrain-source-wireup — ZeroEntropy sunset advisory (#2365)', () => {
|
||||
// The hosted ZeroEntropy API dies Sept 4, 2026; a gbrain on the zeroentropyai
|
||||
// recipe keeps importing but stops embedding SILENTLY. Detection is a
|
||||
// fail-open grep of ~/.gbrain/config.json — missing/other-provider configs
|
||||
// must stay silent and never block the wireup.
|
||||
|
||||
test('config naming zeroentropyai → sunset warning, wireup still succeeds', () => {
|
||||
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
||||
makeFakeGbrain({});
|
||||
fs.mkdirSync(path.join(tmpHome, '.gbrain'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, '.gbrain', 'config.json'),
|
||||
JSON.stringify({ embedding: { recipe: 'zeroentropyai' } }),
|
||||
);
|
||||
const r = run([], { env: { GSTACK_BRAIN_NO_SYNC: '1' } });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr).toContain('ZeroEntropy');
|
||||
expect(r.stderr).toContain('2365');
|
||||
});
|
||||
|
||||
test('config on another provider → no warning (fail-open, no false positive)', () => {
|
||||
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
||||
makeFakeGbrain({});
|
||||
fs.mkdirSync(path.join(tmpHome, '.gbrain'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, '.gbrain', 'config.json'),
|
||||
JSON.stringify({ embedding: { recipe: 'voyage:voyage-code-3' } }),
|
||||
);
|
||||
const r = run([], { env: { GSTACK_BRAIN_NO_SYNC: '1' } });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr).not.toContain('ZeroEntropy');
|
||||
});
|
||||
|
||||
test('advisory docs entry exists (USING_GBRAIN_WITH_GSTACK.md content pin)', () => {
|
||||
const doc = fs.readFileSync(path.join(ROOT, 'USING_GBRAIN_WITH_GSTACK.md'), 'utf-8');
|
||||
expect(doc).toContain('September 4, 2026');
|
||||
expect(doc).toContain('#2365');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-gbrain-source-wireup — --database-url lock (defends against external config rewrites)', () => {
|
||||
test('--database-url flag is exported as GBRAIN_DATABASE_URL to child gbrain calls', () => {
|
||||
setupGstackRepo('git@github.com:user/gstack-brain-user.git');
|
||||
@@ -388,9 +512,7 @@ describe('gstack-gbrain-source-wireup — uninstall mode', () => {
|
||||
expect(fs.existsSync(worktreeDir)).toBe(true);
|
||||
// Now remove the fake gbrain so uninstall sees gbrain missing
|
||||
fs.rmSync(path.join(fakeBinDir, 'gbrain'), { force: true });
|
||||
const r = run(['--uninstall'], {
|
||||
env: { PATH: `${fakeBinDir}:/usr/bin:/bin:/opt/homebrew/bin` },
|
||||
});
|
||||
const r = run(['--uninstall']);
|
||||
expect(r.status).toBe(0); // best-effort, never fails on gbrain absence
|
||||
expect(fs.existsSync(worktreeDir)).toBe(false); // worktree still cleaned up
|
||||
});
|
||||
|
||||
@@ -484,6 +484,44 @@ describe("gstack-memory-ingest writer (gbrain v0.20+ batch `import` interface)",
|
||||
expect(stagedList).toMatch(/^\.\/transcripts\/claude-code\/.+\.md$/m);
|
||||
});
|
||||
|
||||
// #2353: buildTranscriptPage stored the RAW resolved remote ("" when
|
||||
// unresolvable) while the frontmatter wrote the normalized "_unattributed".
|
||||
// The policy filter fast-paths !p.git_remote, so under --include-unattributed
|
||||
// a `_unattributed → deny` policy never applied to exactly the pages it
|
||||
// names. Uses the REAL bin/gstack-gbrain-repo-policy (resolved by the client
|
||||
// relative to lib/, and seeded here through its own `set` verb) — a fake
|
||||
// echoing tiers would pass on both sides of the fix.
|
||||
it("a deny policy keyed _unattributed applies to unattributable transcripts (#2353)", () => {
|
||||
const home = makeTestHome();
|
||||
const gstackHome = join(home, ".gstack");
|
||||
mkdirSync(gstackHome, { recursive: true });
|
||||
const { binDir, logFile } = installFakeGbrain(home);
|
||||
|
||||
const POLICY = join(import.meta.dir, "..", "bin", "gstack-gbrain-repo-policy");
|
||||
const seeded = spawnSync("bash", [POLICY, "set", "_unattributed", "deny"], {
|
||||
encoding: "utf-8",
|
||||
env: { ...process.env, HOME: home, GSTACK_HOME: gstackHome },
|
||||
});
|
||||
expect(seeded.status).toBe(0);
|
||||
expect(existsSync(join(gstackHome, "gbrain-repo-policy.json"))).toBe(true);
|
||||
|
||||
const session =
|
||||
`{"type":"user","message":{"role":"user","content":"hi"},"timestamp":"2026-05-01T00:00:00Z","cwd":"/tmp/foo"}\n` +
|
||||
`{"type":"assistant","message":{"role":"assistant","content":"hello"},"timestamp":"2026-05-01T00:00:01Z"}\n`;
|
||||
writeClaudeCodeSession(home, "tmp-foo", "abc123", session);
|
||||
|
||||
const r = runScript(["--bulk", "--include-unattributed", "--quiet"], {
|
||||
HOME: home,
|
||||
GSTACK_HOME: gstackHome,
|
||||
PATH: `${binDir}:${process.env.PATH || ""}`,
|
||||
});
|
||||
|
||||
// The only candidate page is policy-denied, so nothing may reach gbrain:
|
||||
// pre-fix, the "" remote bypassed the filter and gbrain import ran.
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(existsSync(logFile)).toBe(false);
|
||||
});
|
||||
|
||||
// Silent-data-loss regression: gbrain accepts the import call, exits 0, and
|
||||
// reports imported=0 because collect_files found nothing in the staging dir
|
||||
// (real-world cause: gstack-artifacts-init writes `.gitignore = "*"` into
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Bun --preload fixture that emulates bun-on-Windows fs.mkdirSync semantics
|
||||
* (see #2635): a recursive mkdir on an already-existing directory throws
|
||||
* EEXIST, where Node (and bun on Linux/macOS) treat it as a no-op success.
|
||||
*
|
||||
* Loaded into a child process with `bun --preload <this file> <script>`, it
|
||||
* lets the #2635 regression test exercise the exact Windows crash path on any
|
||||
* platform. The patch is deliberately transparent - it changes nothing except
|
||||
* throwing EEXIST where Windows bun would.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const orig = fs.mkdirSync;
|
||||
fs.mkdirSync = (p: string, opts: any) => {
|
||||
if (opts?.recursive && fs.existsSync(p) && fs.statSync(p).isDirectory()) {
|
||||
const e = new Error(`EEXIST: file already exists, mkdir '${p}'`);
|
||||
(e as any).code = "EEXIST";
|
||||
throw e;
|
||||
}
|
||||
return orig(p, opts);
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { execSync, spawnSync } from 'child_process';
|
||||
|
||||
/**
|
||||
* bash 5.2+ delivers a heredoc body of 64KiB or less through a pipe: the
|
||||
* forked child writes the whole body before exec, and nothing reads the other
|
||||
* end until the command starts. On macOS under pipe-KVA pressure the kernel
|
||||
* hands a fresh pipe a 512-byte buffer, so any body of 512 bytes or more
|
||||
* blocks write() forever — the script hangs at startup, silently, with no
|
||||
* output and no error. The runtime capacity check bash would need
|
||||
* (F_GETPIPE_SZ) is Linux-only.
|
||||
*
|
||||
* Compat level 50 restores the pre-5.2 tempfile path. Every script that ships
|
||||
* an in-window heredoc must set it, and this scanner fails the suite when a
|
||||
* new one appears without the guard.
|
||||
*
|
||||
* The guard is deliberately not a `#!/bin/bash` shebang swap: that pins the
|
||||
* script to whatever bash lives at /bin (3.2 on macOS, absent on some Linux
|
||||
* distributions) and is bypassed entirely by `bash script.sh` call sites.
|
||||
*/
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
|
||||
// Inclusive byte window where the pipe path is taken AND a starved pipe can
|
||||
// block. Bodies over 64KiB fall back to a tempfile on their own.
|
||||
const MIN_BODY = 512;
|
||||
const MAX_BODY = 64 * 1024;
|
||||
|
||||
const GUARD_RE = /^\s*(?::\s*"\$\{)?BASH_COMPAT(?:[:=]|\}")/m;
|
||||
|
||||
function trackedShellScripts(): string[] {
|
||||
const out = execSync('git ls-files', { cwd: ROOT, encoding: 'utf-8', maxBuffer: 32 * 1024 * 1024 });
|
||||
return out
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.filter((f) => {
|
||||
const abs = path.join(ROOT, f);
|
||||
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return false;
|
||||
if (f.endsWith('.sh')) return true;
|
||||
const head = fs.readFileSync(abs).subarray(0, 64).toString('utf-8');
|
||||
return /^#!.*\b(bash|sh)\b/.test(head);
|
||||
});
|
||||
}
|
||||
|
||||
/** Heredocs in `content` whose body lands inside the deadlock window. */
|
||||
function inWindowHeredocs(content: string): { line: number; tag: string; bytes: number }[] {
|
||||
const lines = content.split('\n');
|
||||
const hits: { line: number; tag: string; bytes: number }[] = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const m = /<<-?\s*'?([A-Za-z_][A-Za-z0-9_]*)'?/.exec(lines[i]);
|
||||
if (!m) continue;
|
||||
const tag = m[1];
|
||||
let j = i + 1;
|
||||
const body: string[] = [];
|
||||
while (j < lines.length && lines[j].trim() !== tag) body.push(lines[j++]);
|
||||
const bytes = Buffer.byteLength(body.join('\n')) + 1;
|
||||
if (bytes >= MIN_BODY && bytes <= MAX_BODY) hits.push({ line: i + 1, tag, bytes });
|
||||
i = j;
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
describe('heredoc pipe-deadlock guard', () => {
|
||||
test('every script with an in-window heredoc sets BASH_COMPAT', () => {
|
||||
const violations: string[] = [];
|
||||
for (const rel of trackedShellScripts()) {
|
||||
const content = fs.readFileSync(path.join(ROOT, rel), 'utf-8');
|
||||
const hits = inWindowHeredocs(content);
|
||||
if (hits.length === 0) continue;
|
||||
if (GUARD_RE.test(content)) continue;
|
||||
for (const h of hits) violations.push(`${rel}:${h.line} <<${h.tag} body=${h.bytes}B`);
|
||||
}
|
||||
if (violations.length > 0) {
|
||||
throw new Error(
|
||||
`Heredoc bodies in the ${MIN_BODY}-${MAX_BODY}B pipe window without a BASH_COMPAT guard:\n ` +
|
||||
violations.join('\n ') +
|
||||
`\n\nFix: add \`BASH_COMPAT=50\` near the top of the script (below any ` +
|
||||
`\`--help\` sed range that reads $0), or shrink the body under ${MIN_BODY}B, ` +
|
||||
`or pipe it in with printf so a live reader exists.`,
|
||||
);
|
||||
}
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
test('the guard actually moves the body off the pipe', () => {
|
||||
const bash = spawnSync('bash', ['-c', 'echo "${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]}"'], {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
const version = (bash.stdout ?? '').trim();
|
||||
const [maj, min] = version.split('.').map((n) => parseInt(n, 10));
|
||||
// Only 5.2+ takes the pipe path at all; older bash is already on tempfiles.
|
||||
if (!(maj > 5 || (maj === 5 && min >= 2))) {
|
||||
expect(version).toBeTruthy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Some sandboxes/containers ship a minimal /dev without /dev/stdin — the
|
||||
// probe medium itself is absent there, so -p/-f both report false and the
|
||||
// probe would answer OTHER for an unobservable fd. Skip rather than fail.
|
||||
const devStdin = spawnSync('bash', ['-c', '[ -e /dev/stdin ] && echo yes || echo no'], {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
if ((devStdin.stdout ?? '').trim() !== 'yes') return;
|
||||
|
||||
const probe = (guard: string) => `#!/usr/bin/env bash
|
||||
${guard}
|
||||
body=$(printf 'x%.0s' $(seq 1 1000))
|
||||
probe() { if [ -p /dev/stdin ]; then echo PIPE; elif [ -f /dev/stdin ]; then echo TEMPFILE; else echo OTHER; fi; }
|
||||
probe <<EOF
|
||||
$body
|
||||
EOF
|
||||
`;
|
||||
const run = (guard: string) =>
|
||||
(spawnSync('bash', ['-c', probe(guard)], { encoding: 'utf-8' }).stdout ?? '').trim();
|
||||
|
||||
expect(run('')).toBe('PIPE');
|
||||
expect(run('BASH_COMPAT=50')).toBe('TEMPFILE');
|
||||
});
|
||||
});
|
||||
@@ -87,6 +87,26 @@ describe("PR #1620 §4a-postfail in land-and-deploy template", () => {
|
||||
expect(body).toMatch(/continue to §4a/);
|
||||
});
|
||||
|
||||
// #2656: the failed merge carried --delete-branch; the recovery path must
|
||||
// reconcile the remote branch instead of silently dropping that half.
|
||||
test("MERGED branch reconciles the remote branch (ls-remote, confirm-first delete)", () => {
|
||||
const body = readTmpl();
|
||||
expect(body).toMatch(/git ls-remote --heads origin "\$BRANCH"/);
|
||||
expect(body).toMatch(/gh pr view --json headRefName -q \.headRefName/);
|
||||
expect(body).toMatch(/git push origin --delete "\$BRANCH"/);
|
||||
// Confirm-first: deletion is offered, never unilateral.
|
||||
expect(body).toMatch(/Delete it\?/);
|
||||
});
|
||||
|
||||
test("MERGED branch reconciliation distinguishes branch-absent from check-failed", () => {
|
||||
const body = readTmpl();
|
||||
// exit 0 + empty output = already clean (idempotent re-runs)...
|
||||
expect(body).toMatch(/already been cleaned up/);
|
||||
// ...non-zero exit = unknown state, never read as a clean branch.
|
||||
expect(body).toMatch(/Couldn't verify remote branch state/);
|
||||
expect(body).toMatch(/never read a failed check as a clean branch/);
|
||||
});
|
||||
|
||||
test("OPEN branch checks autoMergeRequest before treating as failure", () => {
|
||||
const body = readTmpl();
|
||||
expect(body).toMatch(/gh pr view --json autoMergeRequest/);
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* cleanup_old_claude_symlinks destination scan (#2204).
|
||||
*
|
||||
* The helper used to iterate the payload skill dirs. When the payload is
|
||||
* gone the glob matches nothing, so leftover flat skill dirs in $skills_dir
|
||||
* stay forever. This suite extracts the REAL function from setup and drives
|
||||
* it against a temp skills tree — payload-missing orphans must go, user
|
||||
* skills must stay.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const SETUP_SRC = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
|
||||
|
||||
function extractFn(name: string): string {
|
||||
const start = SETUP_SRC.indexOf(`${name}() {`);
|
||||
const end = SETUP_SRC.indexOf('\n}\n', start);
|
||||
if (start < 0 || end < 0) throw new Error(`Could not locate ${name}() in setup`);
|
||||
return SETUP_SRC.slice(start, end + 2);
|
||||
}
|
||||
|
||||
function cleanupBody(): string {
|
||||
return extractFn('cleanup_old_claude_symlinks');
|
||||
}
|
||||
|
||||
describe('setup: cleanup_old_claude_symlinks — static (#2204)', () => {
|
||||
test('scans the skills dir, not only the payload', () => {
|
||||
const body = cleanupBody();
|
||||
expect(body).toContain('for old_target in "$skills_dir"/*');
|
||||
expect(body).toContain('[ "$skill_name" = "gstack" ] && continue');
|
||||
expect(body).toContain('readlink');
|
||||
expect(body).toContain('gstack/*');
|
||||
expect(body).toContain('gstack-*) continue');
|
||||
expect(body).toContain('-d "$old_target"');
|
||||
expect(body).toContain('-L "$old_target/SKILL.md"');
|
||||
expect(body).toContain('rm -rf "$old_target"');
|
||||
// SKILL.md arm must use path-segment provenance, not a bare substring.
|
||||
expect(body).toContain('gstack/*|*/gstack/*|*/.gstack/render/claude/*');
|
||||
expect(body).not.toMatch(/\*gstack\*\)/);
|
||||
});
|
||||
|
||||
test('Windows real-file reap still requires a live payload name list', () => {
|
||||
const body = cleanupBody();
|
||||
expect(body).toContain('for skill_dir in "$gstack_dir"/*/');
|
||||
expect(body).toContain('[ "${IS_WINDOWS:-0}" -eq 1 ] && [ -d "$gstack_dir" ]');
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('setup: cleanup_old_claude_symlinks — behavior (#2204)', () => {
|
||||
function runCleanup(opts: {
|
||||
isWindows?: '0' | '1';
|
||||
payload?: boolean;
|
||||
plant: (skills: string, payload: string) => void;
|
||||
}): { status: number; stdout: string; stderr: string; names: string[]; tmp: string } {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cleanup-orphans-'));
|
||||
const skills = path.join(tmp, 'skills');
|
||||
const payload = path.join(skills, 'gstack');
|
||||
fs.mkdirSync(skills, { recursive: true });
|
||||
if (opts.payload) {
|
||||
fs.mkdirSync(payload, { recursive: true });
|
||||
}
|
||||
opts.plant(skills, payload);
|
||||
const gstackArg = opts.payload ? payload : path.join(skills, 'missing-payload');
|
||||
const script = [
|
||||
'set -e',
|
||||
`IS_WINDOWS=${opts.isWindows ?? '0'}`,
|
||||
extractFn('cleanup_old_claude_symlinks'),
|
||||
`cleanup_old_claude_symlinks "${gstackArg}" "${skills}"`,
|
||||
].join('\n');
|
||||
const result = spawnSync('bash', ['-c', script], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
});
|
||||
const names = fs.existsSync(skills)
|
||||
? fs.readdirSync(skills).sort()
|
||||
: [];
|
||||
return {
|
||||
status: result.status ?? -1,
|
||||
stdout: result.stdout ?? '',
|
||||
stderr: result.stderr ?? '',
|
||||
names,
|
||||
tmp,
|
||||
};
|
||||
}
|
||||
|
||||
function plantDanglingSkillMd(skills: string, name: string) {
|
||||
const dir = path.join(skills, name);
|
||||
fs.mkdirSync(dir);
|
||||
fs.symlinkSync(`gstack/${name}/SKILL.md`, path.join(dir, 'SKILL.md'));
|
||||
}
|
||||
|
||||
function plantUserSkill(skills: string, name: string) {
|
||||
const dir = path.join(skills, name);
|
||||
fs.mkdirSync(dir);
|
||||
fs.writeFileSync(path.join(dir, 'SKILL.md'), '---\nname: user-owned\n---\n');
|
||||
}
|
||||
|
||||
test('payload gone: dangling SKILL.md orphan is removed, user skill stays', () => {
|
||||
const r = runCleanup({
|
||||
payload: false,
|
||||
plant(skills) {
|
||||
plantDanglingSkillMd(skills, 'qa');
|
||||
plantUserSkill(skills, 'my-own');
|
||||
},
|
||||
});
|
||||
try {
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr).toBe('');
|
||||
expect(r.stdout).toContain('cleaned up old entries: qa');
|
||||
expect(r.names).toEqual(['my-own']);
|
||||
} finally {
|
||||
fs.rmSync(r.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('payload gone: whole-dir symlink into gstack/ is removed', () => {
|
||||
const r = runCleanup({
|
||||
payload: false,
|
||||
plant(skills) {
|
||||
fs.symlinkSync('gstack/qa', path.join(skills, 'qa'));
|
||||
plantUserSkill(skills, 'my-own');
|
||||
},
|
||||
});
|
||||
try {
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.names).toEqual(['my-own']);
|
||||
} finally {
|
||||
fs.rmSync(r.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('payload present: leftover flat name pointing at gstack is still removed', () => {
|
||||
const r = runCleanup({
|
||||
payload: true,
|
||||
plant(skills, payload) {
|
||||
const src = path.join(payload, 'qa');
|
||||
fs.mkdirSync(src);
|
||||
fs.writeFileSync(path.join(src, 'SKILL.md'), '---\nname: qa\n---\n');
|
||||
plantDanglingSkillMd(skills, 'qa');
|
||||
},
|
||||
});
|
||||
try {
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.names).toEqual(['gstack']);
|
||||
} finally {
|
||||
fs.rmSync(r.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('payload present: dangling name absent from the payload is still removed', () => {
|
||||
// Unique dest-scan win: the old "$gstack_dir"/*/ loop only considered
|
||||
// names that still exist in the payload. A retired leftover must go.
|
||||
const r = runCleanup({
|
||||
payload: true,
|
||||
plant(skills, payload) {
|
||||
const src = path.join(payload, 'ship');
|
||||
fs.mkdirSync(src);
|
||||
fs.writeFileSync(path.join(src, 'SKILL.md'), '---\nname: ship\n---\n');
|
||||
plantDanglingSkillMd(skills, 'qa');
|
||||
},
|
||||
});
|
||||
try {
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.names).toEqual(['gstack']);
|
||||
} finally {
|
||||
fs.rmSync(r.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('does not remove a SKILL.md symlink that does not point at gstack', () => {
|
||||
const r = runCleanup({
|
||||
payload: false,
|
||||
plant(skills) {
|
||||
const dir = path.join(skills, 'elsewhere');
|
||||
fs.mkdirSync(dir);
|
||||
fs.symlinkSync('other/SKILL.md', path.join(dir, 'SKILL.md'));
|
||||
},
|
||||
});
|
||||
try {
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toBe('');
|
||||
expect(r.names).toEqual(['elsewhere']);
|
||||
} finally {
|
||||
fs.rmSync(r.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('does not remove a SKILL.md whose target merely contains the substring gstack', () => {
|
||||
const r = runCleanup({
|
||||
payload: false,
|
||||
plant(skills) {
|
||||
const dir = path.join(skills, 'notes');
|
||||
fs.mkdirSync(dir);
|
||||
fs.symlinkSync('../../archive/my-gstack-backup/SKILL.md', path.join(dir, 'SKILL.md'));
|
||||
},
|
||||
});
|
||||
try {
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toBe('');
|
||||
expect(r.names).toEqual(['notes']);
|
||||
} finally {
|
||||
fs.rmSync(r.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('reaps a leftover whose SKILL.md points at the user render dir', () => {
|
||||
const r = runCleanup({
|
||||
payload: false,
|
||||
plant(skills) {
|
||||
const dir = path.join(skills, 'qa');
|
||||
fs.mkdirSync(dir);
|
||||
fs.symlinkSync('../../.gstack/render/claude/qa/SKILL.md', path.join(dir, 'SKILL.md'));
|
||||
},
|
||||
});
|
||||
try {
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('cleaned up old entries: qa');
|
||||
expect(r.names).toEqual([]);
|
||||
} finally {
|
||||
fs.rmSync(r.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('does not remove prefixed gstack-* names or the payload dir', () => {
|
||||
const r = runCleanup({
|
||||
payload: true,
|
||||
plant(skills, payload) {
|
||||
fs.writeFileSync(path.join(payload, 'SKILL.md'), '---\nname: gstack\n---\n');
|
||||
const prefixed = path.join(skills, 'gstack-qa');
|
||||
fs.mkdirSync(prefixed);
|
||||
fs.symlinkSync('gstack/qa/SKILL.md', path.join(prefixed, 'SKILL.md'));
|
||||
},
|
||||
});
|
||||
try {
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.names).toEqual(['gstack', 'gstack-qa']);
|
||||
} finally {
|
||||
fs.rmSync(r.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('Windows real-file orphan is left alone when the payload is gone', () => {
|
||||
const r = runCleanup({
|
||||
isWindows: '1',
|
||||
payload: false,
|
||||
plant(skills) {
|
||||
plantUserSkill(skills, 'qa');
|
||||
},
|
||||
});
|
||||
try {
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.names).toEqual(['qa']);
|
||||
} finally {
|
||||
fs.rmSync(r.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('Windows real-file leftover is removed when the payload still names it', () => {
|
||||
const r = runCleanup({
|
||||
isWindows: '1',
|
||||
payload: true,
|
||||
plant(skills, payload) {
|
||||
const src = path.join(payload, 'qa');
|
||||
fs.mkdirSync(src);
|
||||
fs.writeFileSync(path.join(src, 'SKILL.md'), '---\nname: qa\n---\n');
|
||||
plantUserSkill(skills, 'qa');
|
||||
plantUserSkill(skills, 'my-own');
|
||||
},
|
||||
});
|
||||
try {
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.names).toEqual(['gstack', 'my-own']);
|
||||
} finally {
|
||||
fs.rmSync(r.tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -62,3 +62,67 @@ describe('setup: --help flag (#1133)', () => {
|
||||
expect(res.stdout).toContain('Usage:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setup: host accept-list ↔ hosts/index.ts registry cross-check (#2361)', () => {
|
||||
// The #2361 failure class: a host passes --host validation but has no
|
||||
// install arm, so `./setup --host <it>` configures nothing and exits 0.
|
||||
// This cross-check derives BOTH sides — the registry from hosts/index.ts
|
||||
// and the case arms from setup — so adding a host to either place without
|
||||
// the other goes red at the moment of the drift, not in a user report.
|
||||
|
||||
const content = fs.readFileSync(SETUP_SCRIPT, 'utf-8');
|
||||
|
||||
function hostCaseArms(): { installTargets: string[]; namedArms: string[] } {
|
||||
const start = content.indexOf('case "$HOST" in');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const block = content.slice(start, content.indexOf('\nesac', start));
|
||||
// The pipe list is the install accept-list; single-name arms are informational.
|
||||
const installTargets: string[] = [];
|
||||
const namedArms: string[] = [];
|
||||
for (const m of block.matchAll(/^ {2}([a-z|]+)\)/gm)) {
|
||||
const names = m[1].split('|');
|
||||
if (names.length > 1) installTargets.push(...names.filter((n) => n !== 'auto'));
|
||||
else if (names[0] !== 'auto') namedArms.push(names[0]);
|
||||
}
|
||||
return { installTargets, namedArms };
|
||||
}
|
||||
|
||||
test('registry names == accept-list (minus auto) + informational arms', async () => {
|
||||
const { ALL_HOST_CONFIGS } = await import('../hosts/index');
|
||||
const registered = ALL_HOST_CONFIGS.map((c: { name: string }) => c.name).sort();
|
||||
const { installTargets, namedArms } = hostCaseArms();
|
||||
const covered = [...new Set([...installTargets, ...namedArms])].sort();
|
||||
expect(covered).toEqual(registered);
|
||||
});
|
||||
|
||||
test('every accept-listed install target has a dispatch arm (the exact #2361 hole)', () => {
|
||||
// Set-membership alone would have passed while slate sat accepted-but-
|
||||
// unwired: the invariant that bites is accept-list ⊆ dispatch arms.
|
||||
const { installTargets } = hostCaseArms();
|
||||
expect(installTargets.length).toBeGreaterThan(0);
|
||||
for (const host of installTargets) {
|
||||
expect(content).toMatch(new RegExp(`\\[ "\\$HOST" = "${host}" \\]`));
|
||||
}
|
||||
});
|
||||
|
||||
test('slate informational arm: explains itself, points at --host claude, exit 0', () => {
|
||||
const res = spawnSync('bash', [SETUP_SCRIPT, '--host', 'slate'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
});
|
||||
expect(res.status).toBe(0);
|
||||
expect(res.stdout).toContain('./setup --host claude');
|
||||
expect(res.stdout).toContain('.claude/skills');
|
||||
// It must not fall through into the installer.
|
||||
expect(res.stdout).not.toMatch(/Installing|bun install|Building/);
|
||||
});
|
||||
|
||||
test('zero-dispatch guard exists: unwired host errors loudly instead of exit-0 no-op', () => {
|
||||
// The guard is only reachable when a future host is accepted but unwired,
|
||||
// so pin its presence and shape statically: it must name the host, call
|
||||
// itself a setup bug, and exit 1.
|
||||
const guard = content.match(/no install arm exists for host[^\n]*\n\s*exit 1/);
|
||||
expect(guard).toBeTruthy();
|
||||
expect(content).toContain("[ \"$HOST\" != \"auto\" ]");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user