Files
msd-core/tests/install.test.cjs
Tom Boucher 0cc7a1a426 test(#1974): consolidate 27 installer/hooks remainder tests into module suites
Fold 27 issue-named installer/hooks/statusline/migration/reapply regression files
into their canonical module suites (installer-migrations, installer-migration-report,
gsd-statusline, reapply-verify-hunks, install-*, gsd-check-update-worker-platform-gate,
etc.). Verbatim block-scoped describe wrappers; 276 subtests conserved 1:1. No new files.

The one subdir origin (tests/installer-migrations/001-legacy-orphan-files) moved up one
level into installer-migrations.test.cjs; its single ../../ module require corrected to
../ so it resolves from tests/ root (verified). Host-env pre-check: no CLI-receiving host
sets a redirecting GSD_WORKSTREAM/GSD_PROJECT value.

Regenerates regression-name allowlist (222->205), ratchets file-count allowlist (verify
11->8, validate entry removed), makes 16 relocated allow-test-rule exemptions issue-ref-
compliant (ADR-456; prunes stale ids). Repoints 15 tests/ references across state-md.md
(EN + ja/ko/pt/zh). lint:ci green.

Part of epic #1969. Closes #1974.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 09:34:39 -04:00

5821 lines
234 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// allow-test-rule: source-text-is-the-product
// Reads .md/.json/.yml product files whose deployed text IS what the
// runtime loads — testing text content tests the deployed contract.
/**
* Installer Module — Sections 1–5.
*
* Covers: getDirName/getGlobalConfigDir/getConfigDirFromHome, per-runtime
* install/uninstall spot-checks (hermes/qwen/trae), uninstall skills
* cleanup, Claude-reference leak tests, and Kilo-specific helpers.
*
* Consolidates (original sources from #3758):
* hermes-install.test.cjs
* kilo-install.test.cjs
* qwen-install.test.cjs
* trae-install.test.cjs
* antigravity-install.test.cjs
*
* Closes #3758
*/
'use strict';
process.env.GSD_TEST_MODE = '1';
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { createTempDir, createTempProject, cleanup, parseFrontmatter } = require('./helpers.cjs');
const pkg = require('../package.json');
const {
getDirName,
getConfigDirFromHome,
install,
uninstall,
writeManifest,
allRuntimes,
runtimeMap,
buildRuntimePromptText,
resolveKiloConfigPath,
configureKiloPermissions,
selectRuntimesFromArgs,
normalizeNodePath,
} = require('../bin/install.js');
const { getGlobalConfigDir } = require('../gsd-core/bin/lib/runtime-homes.cjs');
const {
RUNTIME_META,
stripAnsi,
walk,
} = require('./helpers/install-shared.cjs');
const { CHILD_ROUTER, nestedSkillPath } = require('./helpers/nested-layout.cjs');
// ─── Section 1: getDirName / getGlobalConfigDir / getConfigDirFromHome ──────────
describe('getDirName — all runtimes', () => {
for (const runtime of allRuntimes) {
test(`getDirName('${runtime}') returns expected local directory name`, () => {
const expected = RUNTIME_META[runtime].localDir;
assert.strictEqual(getDirName(runtime), expected,
`getDirName('${runtime}') should return '${expected}'`);
});
}
});
describe('getGlobalConfigDir — all runtimes default paths', () => {
// Derive env-var list from the registry so it stays auto-correct when new
// runtimes are added. GROK_AGENTS_HOME is kept explicitly because grok has
// no registry entry.
const { runtimes: _registryRuntimes } = require('../gsd-core/bin/lib/capability-registry.cjs');
const _registryEnvKeys = Object.values(_registryRuntimes).flatMap((r) => {
const ch = r.runtime?.configHome;
if (!ch) return [];
const envs = Array.isArray(ch.env) ? ch.env : [];
const skillsEnvs = ch.skillsHome && Array.isArray(ch.skillsHome.env) ? ch.skillsHome.env : [];
return [...envs, ...skillsEnvs];
});
const ENV_KEYS = [...new Set([..._registryEnvKeys, 'GROK_AGENTS_HOME', 'XDG_CONFIG_HOME'])];
let savedEnv = {};
beforeEach(() => {
for (const key of ENV_KEYS) {
savedEnv[key] = process.env[key];
delete process.env[key];
}
});
afterEach(() => {
for (const key of ENV_KEYS) {
if (savedEnv[key] !== undefined) process.env[key] = savedEnv[key];
else delete process.env[key];
}
});
for (const runtime of allRuntimes.filter(runtime => runtime !== 'kimi')) {
test(`getGlobalConfigDir('${runtime}') returns expected home-relative path`, () => {
const expected = path.join(os.homedir(), RUNTIME_META[runtime].globalSuffix);
assert.strictEqual(getGlobalConfigDir(runtime), expected);
});
}
});
describe('getGlobalConfigDir/getConfigDirFromHome — antigravity 2.x layout detection', () => {
const saved = {};
beforeEach(() => {
saved.HOME = process.env.HOME;
saved.USERPROFILE = process.env.USERPROFILE;
saved.ANTIGRAVITY_CONFIG_DIR = process.env.ANTIGRAVITY_CONFIG_DIR;
delete process.env.ANTIGRAVITY_CONFIG_DIR;
});
afterEach(() => {
if (saved.HOME !== undefined) process.env.HOME = saved.HOME;
else delete process.env.HOME;
if (saved.USERPROFILE !== undefined) process.env.USERPROFILE = saved.USERPROFILE;
else delete process.env.USERPROFILE;
if (saved.ANTIGRAVITY_CONFIG_DIR !== undefined) process.env.ANTIGRAVITY_CONFIG_DIR = saved.ANTIGRAVITY_CONFIG_DIR;
else delete process.env.ANTIGRAVITY_CONFIG_DIR;
});
test('uses ~/.gemini/antigravity-ide when legacy dir is absent and ide dir exists', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-antigravity-ide-'));
try {
fs.mkdirSync(path.join(home, '.gemini', 'antigravity-ide'), { recursive: true });
process.env.HOME = home;
process.env.USERPROFILE = home;
assert.strictEqual(
getGlobalConfigDir('antigravity'),
path.join(home, '.gemini', 'antigravity-ide'),
);
assert.strictEqual(
getConfigDirFromHome('antigravity', true),
"'.gemini', 'antigravity-ide'",
);
} finally {
cleanup(home);
}
});
test('uses ~/.gemini/antigravity-cli when only cli dir exists', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-antigravity-cli-'));
try {
fs.mkdirSync(path.join(home, '.gemini', 'antigravity-cli'), { recursive: true });
process.env.HOME = home;
process.env.USERPROFILE = home;
assert.strictEqual(
getGlobalConfigDir('antigravity'),
path.join(home, '.gemini', 'antigravity-cli'),
);
assert.strictEqual(
getConfigDirFromHome('antigravity', true),
"'.gemini', 'antigravity-cli'",
);
} finally {
cleanup(home);
}
});
// #213/#217 coexistence regression (end-to-end through the registry descriptor).
// A CLI user who ALSO has the Antigravity-IDE's ~/.gemini/antigravity dir was
// previously shadowed to the legacy dir because it is probed first. The
// The probeExists marker (gsd-core/VERSION) makes the dir GSD installed into win.
test('coexistence: legacy antigravity + GSD-marked antigravity-cli both present → resolves to antigravity-cli', (t) => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-antigravity-coexist-'));
t.after(() => cleanup(home));
// Both dirs exist on disk...
fs.mkdirSync(path.join(home, '.gemini', 'antigravity'), { recursive: true });
fs.mkdirSync(path.join(home, '.gemini', 'antigravity-cli', 'gsd-core'), { recursive: true });
// ...but only the cli dir carries the GSD marker.
fs.writeFileSync(path.join(home, '.gemini', 'antigravity-cli', 'gsd-core', 'VERSION'), '1.6.0\n');
process.env.HOME = home;
process.env.USERPROFILE = home;
assert.strictEqual(
getGlobalConfigDir('antigravity'),
path.join(home, '.gemini', 'antigravity-cli'),
'GSD-marked antigravity-cli must win over the bare-existing legacy antigravity dir',
);
assert.strictEqual(
getConfigDirFromHome('antigravity', true),
"'.gemini', 'antigravity-cli'",
);
});
test('coexistence: legacy antigravity carries the GSD marker (real 1.x install) → resolves to legacy even when cli dir exists bare', (t) => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-antigravity-legacy-marked-'));
t.after(() => cleanup(home));
fs.mkdirSync(path.join(home, '.gemini', 'antigravity', 'gsd-core'), { recursive: true });
fs.writeFileSync(path.join(home, '.gemini', 'antigravity', 'gsd-core', 'VERSION'), '1.5.0\n');
fs.mkdirSync(path.join(home, '.gemini', 'antigravity-cli'), { recursive: true });
process.env.HOME = home;
process.env.USERPROFILE = home;
assert.strictEqual(
getGlobalConfigDir('antigravity'),
path.join(home, '.gemini', 'antigravity'),
'a genuine GSD install in the legacy dir must not be abandoned for a bare sibling',
);
});
});
describe('getGlobalConfigDir — explicit configDir overrides env for all runtimes', () => {
test('explicit dir overrides any env var for hermes', () => {
const savedHome = process.env.HERMES_HOME;
process.env.HERMES_HOME = '~/from-env';
try {
assert.strictEqual(String(getGlobalConfigDir('hermes', '/explicit/hermes')).replace(/\\/g, '/'), '/explicit/hermes');
} finally {
if (savedHome !== undefined) process.env.HERMES_HOME = savedHome;
else delete process.env.HERMES_HOME;
}
});
test('explicit dir overrides KILO_CONFIG_DIR', () => {
const saved = process.env.KILO_CONFIG_DIR;
process.env.KILO_CONFIG_DIR = '~/from-env';
try {
assert.strictEqual(String(getGlobalConfigDir('kilo', '/explicit/kilo')).replace(/\\/g, '/'), '/explicit/kilo');
} finally {
if (saved !== undefined) process.env.KILO_CONFIG_DIR = saved;
else delete process.env.KILO_CONFIG_DIR;
}
});
});
describe('getGlobalConfigDir — HERMES_HOME env var', () => {
let saved;
beforeEach(() => { saved = process.env.HERMES_HOME; });
afterEach(() => {
if (saved !== undefined) process.env.HERMES_HOME = saved;
else delete process.env.HERMES_HOME;
});
test('respects HERMES_HOME env var (tilde-expanded)', () => {
process.env.HERMES_HOME = '~/custom-hermes';
assert.strictEqual(getGlobalConfigDir('hermes'), path.join(os.homedir(), 'custom-hermes'));
});
});
describe('getGlobalConfigDir — Kilo env var priority', () => {
let savedEnv;
beforeEach(() => {
savedEnv = {
KILO_CONFIG_DIR: process.env.KILO_CONFIG_DIR,
KILO_CONFIG: process.env.KILO_CONFIG,
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
};
delete process.env.KILO_CONFIG_DIR;
delete process.env.KILO_CONFIG;
delete process.env.XDG_CONFIG_HOME;
});
afterEach(() => {
for (const [k, v] of Object.entries(savedEnv)) {
if (v !== undefined) process.env[k] = v;
else delete process.env[k];
}
});
test('respects KILO_CONFIG_DIR', () => {
process.env.KILO_CONFIG_DIR = '~/custom-kilo';
assert.strictEqual(getGlobalConfigDir('kilo'), path.join(os.homedir(), 'custom-kilo'));
});
test('falls back to XDG_CONFIG_HOME/kilo', () => {
process.env.XDG_CONFIG_HOME = '~/xdg-config';
assert.strictEqual(getGlobalConfigDir('kilo'), path.join(os.homedir(), 'xdg-config', 'kilo'));
});
test('uses dirname(KILO_CONFIG) when KILO_CONFIG_DIR unset', () => {
process.env.KILO_CONFIG = '~/profiles/work/kilo.jsonc';
assert.strictEqual(getGlobalConfigDir('kilo'), path.join(os.homedir(), 'profiles', 'work'));
});
test('KILO_CONFIG_DIR takes precedence over KILO_CONFIG', () => {
process.env.KILO_CONFIG_DIR = '~/custom-kilo';
process.env.KILO_CONFIG = '~/profiles/work/kilo.jsonc';
assert.strictEqual(getGlobalConfigDir('kilo'), path.join(os.homedir(), 'custom-kilo'));
});
});
describe('getConfigDirFromHome — spot-checks', () => {
test('claude returns .claude for both scopes', () => {
assert.strictEqual(getConfigDirFromHome('claude', false), "'.claude'");
assert.strictEqual(getConfigDirFromHome('claude', true), "'.claude'");
});
test('hermes returns .hermes for both scopes', () => {
assert.strictEqual(getConfigDirFromHome('hermes', false), "'.hermes'");
assert.strictEqual(getConfigDirFromHome('hermes', true), "'.hermes'");
});
test('qwen returns .qwen for both scopes', () => {
assert.strictEqual(getConfigDirFromHome('qwen', false), "'.qwen'");
assert.strictEqual(getConfigDirFromHome('qwen', true), "'.qwen'");
});
test('trae returns .trae for both scopes', () => {
assert.strictEqual(getConfigDirFromHome('trae', false), "'.trae'");
assert.strictEqual(getConfigDirFromHome('trae', true), "'.trae'");
});
test('antigravity returns .agents (local) and legacy fallback global path when no 2.x dirs exist', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-antigravity-empty-'));
const savedHome = process.env.HOME;
const savedUserProfile = process.env.USERPROFILE;
const savedAntigravityConfig = process.env.ANTIGRAVITY_CONFIG_DIR;
delete process.env.ANTIGRAVITY_CONFIG_DIR;
process.env.HOME = home;
process.env.USERPROFILE = home;
try {
assert.strictEqual(getConfigDirFromHome('antigravity', false), "'.agents'");
assert.strictEqual(getConfigDirFromHome('antigravity', true), "'.gemini', 'antigravity'");
} finally {
if (savedHome === undefined) delete process.env.HOME;
else process.env.HOME = savedHome;
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = savedUserProfile;
if (savedAntigravityConfig === undefined) delete process.env.ANTIGRAVITY_CONFIG_DIR;
else process.env.ANTIGRAVITY_CONFIG_DIR = savedAntigravityConfig;
cleanup(home);
}
});
test('kilo returns .kilo (local) and .config, kilo (global)', () => {
assert.strictEqual(getConfigDirFromHome('kilo', false), "'.kilo'");
assert.strictEqual(getConfigDirFromHome('kilo', true), "'.config', 'kilo'");
});
});
// ─── Section 2: Local install / uninstall for subset of runtimes ─────────────
// Full E2E for runtimes that have distinct install paths (hermes nested layout,
// qwen flat layout, trae flat layout). Others are covered by layout-loop tests.
describe('install/uninstall — hermes (nested skills/gsd/<router>/skills/<stem>/ layout)', () => {
let tmpDir;
let previousCwd;
beforeEach(() => {
tmpDir = createTempDir('gsd-hermes-install-');
previousCwd = process.cwd();
process.chdir(tmpDir);
});
afterEach(() => {
process.chdir(previousCwd);
cleanup(tmpDir);
});
test('installs GSD into ./.hermes and removes it cleanly', () => {
const result = install(false, 'hermes');
const targetDir = path.join(tmpDir, '.hermes');
assert.strictEqual(result.runtime, 'hermes');
assert.strictEqual(result.configDir, fs.realpathSync(targetDir));
// hermes nests: skills/gsd/gsd-<router>/skills/<stem>/SKILL.md (#947 — canonical gsd- prefix)
const hermesHelpPath = nestedSkillPath(path.join(targetDir, 'skills', 'gsd'), 'gsd-', 'help');
assert.ok(fs.existsSync(hermesHelpPath),
`help SKILL.md must exist at nested path: ${path.relative(targetDir, hermesHelpPath)}`);
assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'DESCRIPTION.md')),
'DESCRIPTION.md at category root');
assert.ok(fs.existsSync(path.join(targetDir, 'gsd-core', 'VERSION')));
assert.ok(fs.existsSync(path.join(targetDir, 'agents')));
const manifest = writeManifest(targetDir, 'hermes');
assert.ok(
Object.keys(manifest.files).some(f =>
f.startsWith('skills/gsd/gsd-' + CHILD_ROUTER['help'] + '/skills/help/')
),
JSON.stringify(manifest.files)
);
uninstall(false, 'hermes');
assert.ok(!fs.existsSync(hermesHelpPath));
assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd')));
assert.ok(!fs.existsSync(path.join(targetDir, 'gsd-core')));
});
test('installed SKILL.md frontmatter conforms to Hermes spec', () => {
install(false, 'hermes');
const targetDir = path.join(tmpDir, '.hermes');
const categoryDir = path.join(targetDir, 'skills', 'gsd');
const skillDirs = fs.readdirSync(categoryDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name !== 'DESCRIPTION.md')
.map(e => e.name);
assert.ok(skillDirs.length > 0, 'at least one skill installed');
for (const dir of skillDirs) {
const content = fs.readFileSync(path.join(categoryDir, dir, 'SKILL.md'), 'utf8');
const fm = parseFrontmatter(content);
assert.strictEqual(fm.name, dir, `${dir}/SKILL.md name matches dir`);
assert.ok(typeof fm.description === 'string' && fm.description.length > 0,
`${dir}/SKILL.md has description`);
assert.strictEqual(fm.version, pkg.version,
`${dir}/SKILL.md declares version ${pkg.version}`);
}
const desc = fs.readFileSync(path.join(categoryDir, 'DESCRIPTION.md'), 'utf8');
const descFm = parseFrontmatter(desc);
assert.strictEqual(descFm.name, 'gsd');
assert.ok(typeof descFm.description === 'string' && descFm.description.length > 0);
assert.strictEqual(descFm.version, pkg.version);
uninstall(false, 'hermes');
});
test('replaces CLAUDE.md references with HERMES.md', () => {
install(false, 'hermes');
const targetDir = path.join(tmpDir, '.hermes');
const skillsDir = path.join(targetDir, 'skills');
let referencedHermesMd = false;
const checkWalk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) { checkWalk(full); continue; }
if (!entry.name.endsWith('.md')) continue;
const content = fs.readFileSync(full, 'utf8');
assert.ok(!/\bCLAUDE\.md\b/.test(content),
`${path.relative(targetDir, full)} still references CLAUDE.md`);
if (/\bHERMES\.md\b/.test(content)) referencedHermesMd = true;
}
};
checkWalk(skillsDir);
assert.ok(referencedHermesMd, 'at least one skill references HERMES.md');
uninstall(false, 'hermes');
});
});
describe('install/uninstall — qwen (nested skills/gsd-<router>/skills/<stem>/ layout)', () => {
let tmpDir;
let previousCwd;
beforeEach(() => {
tmpDir = createTempDir('gsd-qwen-install-');
previousCwd = process.cwd();
process.chdir(tmpDir);
});
afterEach(() => {
process.chdir(previousCwd);
cleanup(tmpDir);
});
test('installs GSD into ./.qwen and removes it cleanly', () => {
const result = install(false, 'qwen');
const targetDir = path.join(tmpDir, '.qwen');
assert.strictEqual(result.runtime, 'qwen');
assert.strictEqual(result.configDir, fs.realpathSync(targetDir));
// qwen nests: skills/gsd-<router>/skills/<stem>/SKILL.md
const qwenHelpPath = nestedSkillPath(path.join(targetDir, 'skills'), 'gsd-', 'help');
assert.ok(fs.existsSync(qwenHelpPath),
`help SKILL.md must exist at nested path: ${path.relative(targetDir, qwenHelpPath)}`);
assert.ok(fs.existsSync(path.join(targetDir, 'gsd-core', 'VERSION')));
assert.ok(fs.existsSync(path.join(targetDir, 'agents')));
const manifest = writeManifest(targetDir, 'qwen');
assert.ok(
Object.keys(manifest.files).some(f =>
f.startsWith('skills/gsd-' + CHILD_ROUTER['help'] + '/skills/help/')
)
);
uninstall(false, 'qwen');
assert.ok(!fs.existsSync(qwenHelpPath));
assert.ok(!fs.existsSync(path.join(targetDir, 'gsd-core')));
});
});
describe('install/uninstall — trae (nested skills/gsd-<router>/skills/<stem>/ layout)', () => {
let tmpDir;
let previousCwd;
beforeEach(() => {
tmpDir = createTempDir('gsd-trae-install-');
previousCwd = process.cwd();
process.chdir(tmpDir);
});
afterEach(() => {
process.chdir(previousCwd);
cleanup(tmpDir);
});
test('installs GSD into ./.trae and removes it cleanly (typed IR result)', () => {
const result = install(false, 'trae');
const targetDir = path.join(tmpDir, '.trae');
assert.deepStrictEqual(result, {
settingsPath: null,
settings: null,
statuslineCommand: null,
updateBannerCommand: null,
runtime: 'trae',
configDir: fs.realpathSync(targetDir),
});
// trae nests: skills/gsd-<router>/skills/<stem>/SKILL.md
const traeHelpPath = nestedSkillPath(path.join(targetDir, 'skills'), 'gsd-', 'help');
assert.ok(fs.existsSync(traeHelpPath),
`help SKILL.md must exist at nested path: ${path.relative(targetDir, traeHelpPath)}`);
assert.ok(fs.existsSync(path.join(targetDir, 'gsd-core', 'VERSION')));
assert.ok(fs.existsSync(path.join(targetDir, 'agents')));
const manifest = writeManifest(targetDir, 'trae');
assert.ok(
Object.keys(manifest.files).some(f =>
f.startsWith('skills/gsd-' + CHILD_ROUTER['help'] + '/skills/help/')
)
);
uninstall(false, 'trae');
assert.ok(!fs.existsSync(traeHelpPath));
assert.ok(!fs.existsSync(path.join(targetDir, 'gsd-core')));
});
});
// ─── Section 3: Uninstall skills cleanup — parameterised ─────────────────────
describe('uninstall skills cleanup — hermes', () => {
let tmpDir;
let previousCwd;
beforeEach(() => {
tmpDir = createTempDir('gsd-hermes-uninstall-');
previousCwd = process.cwd();
process.chdir(tmpDir);
});
afterEach(() => {
process.chdir(previousCwd);
cleanup(tmpDir);
});
test('removes skills/gsd/ category dir', () => {
install(false, 'hermes');
const targetDir = path.join(tmpDir, '.hermes');
const categoryDir = path.join(targetDir, 'skills', 'gsd');
assert.ok(fs.existsSync(categoryDir));
const skills = fs.readdirSync(categoryDir, { withFileTypes: true }).filter(e => e.isDirectory());
assert.ok(skills.length > 0);
uninstall(false, 'hermes');
assert.ok(!fs.existsSync(categoryDir));
});
test('preserves non-GSD skill directories', () => {
install(false, 'hermes');
const targetDir = path.join(tmpDir, '.hermes');
const custom = path.join(targetDir, 'skills', 'my-custom-skill');
fs.mkdirSync(custom, { recursive: true });
fs.writeFileSync(path.join(custom, 'SKILL.md'), '# custom\n');
uninstall(false, 'hermes');
assert.ok(fs.existsSync(path.join(custom, 'SKILL.md')));
});
test('removes engine directory', () => {
install(false, 'hermes');
const targetDir = path.join(tmpDir, '.hermes');
assert.ok(fs.existsSync(path.join(targetDir, 'gsd-core', 'VERSION')));
uninstall(false, 'hermes');
assert.ok(!fs.existsSync(path.join(targetDir, 'gsd-core')));
});
});
// ─── Section 4: No Claude references leak into non-Claude runtimes ────────────
for (const runtime of ['hermes', 'qwen']) {
describe(`no Claude references leak into ${runtime} install`, () => {
let tmpDir;
let previousCwd;
beforeEach(() => {
tmpDir = createTempDir(`gsd-${runtime}-refs-`);
previousCwd = process.cwd();
process.chdir(tmpDir);
install(false, runtime);
});
afterEach(() => {
process.chdir(previousCwd);
cleanup(tmpDir);
});
test('skills contain no CLAUDE.md or Claude Code references', () => {
const rtDir = path.join(tmpDir, getDirName(runtime));
const skillsDir = path.join(rtDir, 'skills');
assert.ok(fs.existsSync(skillsDir));
const skillFiles = walk(skillsDir).filter(f => f.endsWith('.md'));
assert.ok(skillFiles.length > 0);
const leaks = skillFiles.filter(f => {
const c = fs.readFileSync(f, 'utf8');
return /\bCLAUDE\.md\b/.test(c) || /\bClaude Code\b/.test(c);
}).map(f => path.relative(tmpDir, f));
assert.strictEqual(leaks.length, 0, `Leaking: ${leaks.join(', ')}`);
});
test('agents contain no CLAUDE.md or Claude Code references', () => {
// Not the shared listAgentFiles() helper: walks the INSTALLED dest dir
// and returns absolute paths (for leak scanning), not the source roster.
const agentsDir = path.join(tmpDir, getDirName(runtime), 'agents');
assert.ok(fs.existsSync(agentsDir));
const agentFiles = walk(agentsDir).filter(f => f.endsWith('.md'));
assert.ok(agentFiles.length > 0);
const leaks = agentFiles.filter(f => {
const c = fs.readFileSync(f, 'utf8');
return /\bCLAUDE\.md\b/.test(c) || /\bClaude Code\b/.test(c);
}).map(f => path.relative(tmpDir, f));
assert.strictEqual(leaks.length, 0, `Leaking: ${leaks.join(', ')}`);
});
test('full tree scan finds zero Claude references outside CHANGELOG.md', () => {
const rtDir = path.join(tmpDir, getDirName(runtime));
const allFiles = walk(rtDir).filter(f =>
(f.endsWith('.md') || f.endsWith('.cjs') || f.endsWith('.js')) &&
path.basename(f) !== 'CHANGELOG.md'
);
const leaks = allFiles.filter(f => {
const c = fs.readFileSync(f, 'utf8');
return /\bCLAUDE\.md\b/.test(c) || /\bClaude Code\b/.test(c) || /\.claude\//.test(c);
}).map(f => path.relative(tmpDir, f));
assert.strictEqual(leaks.length, 0, `Leaking: ${leaks.join(', ')}`);
});
});
}
// ─── Section 5: Kilo-specific helpers ────────────────────────────────────────
describe('resolveKiloConfigPath', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempProject('gsd-kilo-'); });
afterEach(() => { cleanup(tmpDir); });
test('prefers kilo.jsonc when present', () => {
const configDir = path.join(tmpDir, '.kilo');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'kilo.jsonc'), '{\n}\n');
assert.strictEqual(resolveKiloConfigPath(configDir), path.join(configDir, 'kilo.jsonc'));
});
test('falls back to kilo.json', () => {
const configDir = path.join(tmpDir, '.kilo');
fs.mkdirSync(configDir, { recursive: true });
assert.strictEqual(resolveKiloConfigPath(configDir), path.join(configDir, 'kilo.json'));
});
});
describe('configureKiloPermissions', () => {
let tmpDir;
let configDir;
let savedEnv;
beforeEach(() => {
tmpDir = createTempProject('gsd-kilo-perms-');
configDir = path.join(tmpDir, '.config', 'kilo');
savedEnv = {
KILO_CONFIG_DIR: process.env.KILO_CONFIG_DIR,
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
};
process.env.KILO_CONFIG_DIR = configDir;
delete process.env.XDG_CONFIG_HOME;
});
afterEach(() => {
for (const [k, v] of Object.entries(savedEnv)) {
if (v !== undefined) process.env[k] = v;
else delete process.env[k];
}
cleanup(tmpDir);
});
test('writes GSD permissions to kilo.json when config is missing', () => {
configureKiloPermissions(true);
const configPath = path.join(configDir, 'kilo.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
const gsdPath = `${configDir.replace(/\\/g, '/')}/gsd-core/*`;
assert.strictEqual(config.permission.read[gsdPath], 'allow');
assert.strictEqual(config.permission.external_directory[gsdPath], 'allow');
});
test('updates existing kilo.jsonc configs via JSONC parsing', () => {
fs.mkdirSync(configDir, { recursive: true });
const configPath = path.join(configDir, 'kilo.jsonc');
fs.writeFileSync(configPath, '{\n // existing\n "permission": {\n "bash": "ask",\n },\n}\n');
configureKiloPermissions(true);
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
const gsdPath = `${configDir.replace(/\\/g, '/')}/gsd-core/*`;
assert.strictEqual(config.permission.bash, 'ask');
assert.strictEqual(config.permission.read[gsdPath], 'allow');
assert.strictEqual(config.permission.external_directory[gsdPath], 'allow');
});
test('writes permissions to an explicit config dir argument', () => {
const explicitDir = path.join(tmpDir, 'custom-kilo-config');
configureKiloPermissions(true, explicitDir);
const configPath = path.join(explicitDir, 'kilo.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
const gsdPath = `${explicitDir.replace(/\\/g, '/')}/gsd-core/*`;
assert.strictEqual(config.permission.read[gsdPath], 'allow');
assert.strictEqual(config.permission.external_directory[gsdPath], 'allow');
});
});
describe('Kilo integration — install/uninstall behaviour', () => {
// Product-text reads for test 6 only — update.md and update-context.cjs
// are deployed artifacts whose text IS the runtime contract (allow-test-rule).
const updateWorkflowSrc = fs.readFileSync(
path.join(__dirname, '..', 'gsd-core', 'workflows', 'update.md'), 'utf8');
// #498: update.md's runtime/scope/config-dir resolution moved into the tested
// projection gsd-core/bin/lib/update-context.cjs. Custom-config-dir
// detection (kilo.jsonc, KILO_CONFIG) is now asserted there.
const updateContextSrc = fs.readFileSync(
path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'update-context.cjs'), 'utf8');
let tmpDir;
let previousCwd;
let savedKiloConfigDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-kilo-integration-');
previousCwd = process.cwd();
process.chdir(tmpDir);
savedKiloConfigDir = process.env.KILO_CONFIG_DIR;
// Point KILO_CONFIG_DIR at the install target so configureKiloPermissions
// and uninstall resolve to the same dir without needing the real ~/.config/kilo.
process.env.KILO_CONFIG_DIR = path.join(tmpDir, '.kilo');
});
afterEach(() => {
process.chdir(previousCwd);
if (savedKiloConfigDir !== undefined) process.env.KILO_CONFIG_DIR = savedKiloConfigDir;
else delete process.env.KILO_CONFIG_DIR;
cleanup(tmpDir);
});
test('--kilo flag routes to kilo runtime via selectRuntimesFromArgs', () => {
// Behavioural replacement for source-grep on runtimeArgs.includes('--kilo').
// The flag must produce ['kilo'] — a rename or deletion of the flag branch
// would make this go red.
assert.deepStrictEqual(selectRuntimesFromArgs(['--kilo']), ['kilo']);
});
test('runtimeMap has Kilo as option 12 after Kimi', () => {
assert.strictEqual(runtimeMap['12'], 'kilo');
});
test('prompt text shows Kilo above OpenCode without marketing copy', () => {
const plain = stripAnsi(buildRuntimePromptText());
assert.ok(/\b12\)\s*Kilo\b/.test(plain));
assert.ok(plain.indexOf('12) Kilo') < plain.indexOf('OpenCode'));
assert.ok(!plain.includes('the #1 AI coding platform on OpenRouter'));
});
test('install() for kilo writes artifacts to the configDir it returns', () => {
// Behavioural replacement for source-grep on the kilo install branch.
//
// IMPORTANT: GSD_TEST_MODE=1 (set at the top of this file) suppresses the
// configureKiloPermissions() call inside install() to avoid mutating the real
// ~/.config/kilo during unit tests. Asserting on kilo.json permissions here
// would require manually calling configureKiloPermissions(), which only tests
// that helper — not install()'s wiring of it.
//
// Instead we assert on what install() ITSELF produces on disk, which is the
// correct target:
// 1. The returned configDir exists and is the KILO_CONFIG_DIR we set.
// 2. install() wrote kilo artifacts (skills/, agents/) into that dir.
// 3. The configDir returned by install() matches what resolveKiloConfigPath
// resolves for the same env, proving the dir-resolution path is correct.
//
// If someone breaks the kilo install branch (wrong configDir, wrong skill
// target, removed case) these assertions go red immediately.
const result = install(false, 'kilo');
const configDir = result.configDir;
// (1) install() returned the expected configDir (respects KILO_CONFIG_DIR env).
assert.strictEqual(
result.runtime,
'kilo',
'install() must return runtime: "kilo"',
);
assert.ok(
fs.existsSync(configDir),
`install() must create the configDir it returns: ${configDir}`,
);
// (2) Kilo-specific artifacts were written by install() into configDir.
const skillsDir = path.join(configDir, 'skills');
assert.ok(
fs.existsSync(skillsDir),
`install() must create skills/ under the kilo configDir: ${skillsDir}`,
);
const agentsDir = path.join(configDir, 'agents');
assert.ok(
fs.existsSync(agentsDir),
`install() must create agents/ under the kilo configDir: ${agentsDir}`,
);
// (3) The configDir is consistent with resolveKiloConfigPath, proving the
// path-resolution wiring between install() and configureKiloPermissions is
// stable: both read from the same env (KILO_CONFIG_DIR).
const kiloConfigPath = resolveKiloConfigPath(configDir);
assert.ok(
typeof kiloConfigPath === 'string' && kiloConfigPath.length > 0,
`resolveKiloConfigPath must return a valid path for configDir: ${configDir}`,
);
assert.ok(
kiloConfigPath.startsWith(configDir),
`resolveKiloConfigPath must return a path inside the install configDir.\n` +
`Expected prefix: ${configDir}\n` +
`Got: ${kiloConfigPath}`,
);
});
test('uninstall removes GSD permissions from the resolved kilo config path', () => {
// Behavioural replacement for source-grep on
// "const configPath = resolveKiloConfigPath(targetDir)".
// The contract: after install + configureKiloPermissions, an uninstall must
// strip the GSD permission entries from kilo.json at the resolved path.
const result = install(false, 'kilo');
const configDir = result.configDir;
configureKiloPermissions(true, configDir);
const kiloJsonPath = resolveKiloConfigPath(configDir);
const beforeConfig = JSON.parse(fs.readFileSync(kiloJsonPath, 'utf8'));
const gsdGlob = `${configDir.replace(/\\/g, '/')}/gsd-core/*`;
assert.ok(
beforeConfig.permission.read[gsdGlob] === 'allow',
'pre-condition: GSD read permission must exist before uninstall',
);
uninstall(false, 'kilo');
// After uninstall the GSD permission keys must be absent. The file may
// still exist (Kilo preserves user settings) but the gsd-core/* entries
// must be gone.
const afterConfig = JSON.parse(fs.readFileSync(kiloJsonPath, 'utf8'));
assert.ok(
!(afterConfig.permission && afterConfig.permission.read && afterConfig.permission.read[gsdGlob]),
`GSD read permission must be removed from ${kiloJsonPath} after uninstall`,
);
assert.ok(
!(afterConfig.permission && afterConfig.permission.external_directory &&
afterConfig.permission.external_directory[gsdGlob]),
`GSD external_directory permission must be removed from ${kiloJsonPath} after uninstall`,
);
});
test('update workflow checks preferred custom config dirs', () => {
// update.md still derives the preferred config dir from execution_context…
assert.ok(updateWorkflowSrc.includes('PREFERRED_CONFIG_DIR'));
// …and the custom-dir detection (kilo.jsonc config marker, KILO_CONFIG env)
// now lives in the tested update-context projection (#498).
assert.ok(updateContextSrc.includes('kilo.jsonc'));
assert.ok(updateContextSrc.includes('KILO_CONFIG'));
});
});
// ─── Section N: changeset CLI install regression (#935) ──────────────────────
describe('install — changeset CLI lands at scripts/changeset/cli.cjs (#935)', () => {
// Regression guard: the changeset CLI must be copied into the runtime config dir
// by the installer so $GSD_DIR/scripts/changeset/cli.cjs resolves at runtime.
// Before this fix, scripts/ was never copied and /gsd-update changelog preview
// silently failed on every real install.
let tmpDir;
let previousCwd;
beforeEach(() => {
tmpDir = createTempDir('gsd-changeset-install-');
previousCwd = process.cwd();
process.chdir(tmpDir);
});
afterEach(() => {
process.chdir(previousCwd);
cleanup(tmpDir);
});
test('install() copies scripts/changeset/cli.cjs to <configDir>/scripts/changeset/cli.cjs', () => {
install(false, 'claude');
const claudeDir = path.join(tmpDir, '.claude');
const cliPath = path.join(claudeDir, 'scripts', 'changeset', 'cli.cjs');
assert.ok(
fs.existsSync(cliPath),
`scripts/changeset/cli.cjs must exist at ${path.relative(tmpDir, cliPath)} after install (#935)`,
);
});
test('install() copies scripts/lib/cli-exit.cjs to <configDir>/scripts/lib/cli-exit.cjs', () => {
install(false, 'claude');
const claudeDir = path.join(tmpDir, '.claude');
const cliExitPath = path.join(claudeDir, 'scripts', 'lib', 'cli-exit.cjs');
assert.ok(
fs.existsSync(cliExitPath),
`scripts/lib/cli-exit.cjs must exist at ${path.relative(tmpDir, cliExitPath)} after install (#935)`,
);
});
test('installed cli.cjs executes without module-resolution errors', () => {
// Smoke test: node can load the installed changeset CLI without crashing.
// This catches path mismatches in require('../lib/cli-exit.cjs') etc.
install(false, 'claude');
const claudeDir = path.join(tmpDir, '.claude');
const cliPath = path.join(claudeDir, 'scripts', 'changeset', 'cli.cjs');
const { spawnSync } = require('node:child_process');
const result = spawnSync(process.execPath, [cliPath, '--help'], { encoding: 'utf8' });
// --help exits with code 1 (usage shown), but must NOT throw a MODULE_NOT_FOUND error
assert.ok(
!result.stderr.includes('MODULE_NOT_FOUND'),
`cli.cjs must not produce MODULE_NOT_FOUND errors; stderr=${result.stderr}`,
);
assert.ok(
!result.stderr.includes('Cannot find module'),
`cli.cjs must resolve all modules; stderr=${result.stderr}`,
);
});
test('installed cli.cjs can run extract subcommand end-to-end (#935)', () => {
// Integration smoke test: the installed CLI's extract path (invoked by update.md)
// must actually work — this catches require() path issues that --help wouldn't surface.
install(false, 'claude');
const claudeDir = path.join(tmpDir, '.claude');
const cliPath = path.join(claudeDir, 'scripts', 'changeset', 'cli.cjs');
// Use the CHANGELOG.md that was installed into gsd-core/ (installed by the installer)
const changelogPath = path.join(claudeDir, 'gsd-core', 'CHANGELOG.md');
assert.ok(fs.existsSync(changelogPath), 'CHANGELOG.md must be installed under gsd-core/');
const { spawnSync } = require('node:child_process');
const result = spawnSync(
process.execPath,
[cliPath, 'extract', '--from', '0.0.0', '--to', '9999.0.0', '--changelog', changelogPath, '--json'],
{ encoding: 'utf8' },
);
// extract must NOT throw a MODULE_NOT_FOUND or Cannot find module error
assert.ok(
!result.stderr.includes('MODULE_NOT_FOUND') && !result.stderr.includes('Cannot find module'),
`installed cli.cjs extract must resolve all modules; stderr=${result.stderr}`,
);
// extract exit code 0 (found entries) or 2 (no entries in range) are both valid;
// any other exit code is an error
assert.ok(
result.status === 0 || result.status === 2,
`installed cli.cjs extract must exit 0 or 2; got ${result.status}; stderr=${result.stderr}`,
);
});
test('writeManifest() tracks scripts/changeset/ and scripts/lib/ files', () => {
install(false, 'claude');
const claudeDir = path.join(tmpDir, '.claude');
const manifest = writeManifest(claudeDir, 'claude');
const changesetKeys = Object.keys(manifest.files).filter(k => k.startsWith('scripts/changeset/'));
const libKeys = Object.keys(manifest.files).filter(k => k.startsWith('scripts/lib/'));
assert.ok(changesetKeys.length > 0, 'manifest must track scripts/changeset/ files');
assert.ok(libKeys.length > 0, 'manifest must track scripts/lib/ files');
assert.ok(
changesetKeys.includes('scripts/changeset/cli.cjs'),
'manifest must include scripts/changeset/cli.cjs',
);
assert.ok(
libKeys.includes('scripts/lib/cli-exit.cjs'),
'manifest must include scripts/lib/cli-exit.cjs',
);
});
test('uninstall() removes scripts/changeset/ and scripts/lib/', () => {
install(false, 'claude');
const claudeDir = path.join(tmpDir, '.claude');
assert.ok(fs.existsSync(path.join(claudeDir, 'scripts', 'changeset', 'cli.cjs')),
'pre-condition: cli.cjs must be installed before uninstall');
uninstall(false, 'claude');
assert.ok(
!fs.existsSync(path.join(claudeDir, 'scripts', 'changeset')),
'scripts/changeset/ must be removed on uninstall',
);
assert.ok(
!fs.existsSync(path.join(claudeDir, 'scripts', 'lib')),
'scripts/lib/ must be removed on uninstall',
);
});
});
// ─── Section N: fix-slash-commands.cjs install regression (#1223) ───────────────
describe('install — fix-slash-commands.cjs lands at scripts/fix-slash-commands.cjs (#1223)', () => {
// Regression guard: scripts/fix-slash-commands.cjs must be copied into the runtime
// config dir by the installer so gsd-core/bin/lib/command-roster.cjs can require it
// via '../../../scripts/fix-slash-commands.cjs'. Before this fix, the file was never
// installed and every gsd-tools command crashed with MODULE_NOT_FOUND (#1223).
let tmpDir;
let previousCwd;
beforeEach(() => {
tmpDir = createTempDir('gsd-fix-slash-install-');
previousCwd = process.cwd();
process.chdir(tmpDir);
});
afterEach(() => {
process.chdir(previousCwd);
cleanup(tmpDir);
});
test('install() copies scripts/fix-slash-commands.cjs to <configDir>/scripts/fix-slash-commands.cjs', () => {
install(false, 'claude');
const claudeDir = path.join(tmpDir, '.claude');
const fixSlashPath = path.join(claudeDir, 'scripts', 'fix-slash-commands.cjs');
assert.ok(
fs.existsSync(fixSlashPath),
`scripts/fix-slash-commands.cjs must exist at ${path.relative(tmpDir, fixSlashPath)} after install (#1223)`,
);
});
test('installed gsd-tools.cjs query loads without MODULE_NOT_FOUND (#1223)', () => {
// End-to-end smoke: spawning gsd-tools.cjs must not crash with MODULE_NOT_FOUND.
// This directly exercises the command-roster → fix-slash-commands require chain.
install(false, 'claude');
const claudeDir = path.join(tmpDir, '.claude');
const gsdToolsPath = path.join(claudeDir, 'gsd-core', 'bin', 'gsd-tools.cjs');
assert.ok(fs.existsSync(gsdToolsPath), 'pre-condition: gsd-tools.cjs must be installed');
const { spawnSync } = require('node:child_process');
const result = spawnSync(
process.execPath,
[gsdToolsPath, 'query', 'init.new-project'],
{ encoding: 'utf8', timeout: 15000 },
);
assert.ok(
!result.stderr.includes('MODULE_NOT_FOUND'),
`gsd-tools.cjs must not crash with MODULE_NOT_FOUND; stderr=${result.stderr}`,
);
assert.ok(
!result.stderr.includes('Cannot find module'),
`gsd-tools.cjs must resolve all modules; stderr=${result.stderr}`,
);
});
test('writeManifest() tracks scripts/fix-slash-commands.cjs', () => {
install(false, 'claude');
const claudeDir = path.join(tmpDir, '.claude');
const manifest = writeManifest(claudeDir, 'claude');
assert.ok(
'scripts/fix-slash-commands.cjs' in manifest.files,
'manifest must track scripts/fix-slash-commands.cjs',
);
});
test('uninstall() removes scripts/fix-slash-commands.cjs', () => {
install(false, 'claude');
const claudeDir = path.join(tmpDir, '.claude');
const fixSlashPath = path.join(claudeDir, 'scripts', 'fix-slash-commands.cjs');
assert.ok(fs.existsSync(fixSlashPath),
'pre-condition: fix-slash-commands.cjs must be installed before uninstall');
uninstall(false, 'claude');
assert.ok(
!fs.existsSync(fixSlashPath),
'scripts/fix-slash-commands.cjs must be removed on uninstall',
);
});
});
// ─── Section N: readCmdNames() tolerates absent commands/gsd/ dir (#1223) ────────
describe('readCmdNames() — tolerates missing commands/gsd directory (#1223)', () => {
// Regression guard: on installs where commands/gsd/ does not exist (e.g. skill-based
// or global Claude installs) readCmdNames() must return [] rather than throwing ENOENT.
test('readCmdNames() returns an array (does not throw)', () => {
// Verify the guard contract: readCmdNames() must always return an array regardless
// of whether COMMANDS_DIR exists. The spawn-based test below covers the absent-dir
// scenario; this inline test asserts the basic export shape.
const fixSlashModule = require('../scripts/fix-slash-commands.cjs');
const result = fixSlashModule.readCmdNames();
assert.ok(Array.isArray(result), 'readCmdNames() must return an array');
});
test('readCmdNames() returns [] from a context where commands/gsd/ is absent', () => {
// Genuine absent-dir test: copy fix-slash-commands.cjs into a fresh temp directory
// under a scripts/ subdirectory so that __dirname inside the copy points to
// <tmpRoot>/scripts/ — making COMMANDS_DIR = path.join(__dirname,'..','commands','gsd')
// resolve to <tmpRoot>/commands/gsd, which does NOT exist. Requiring the copy (not
// the repo original) exercises the real ENOENT guard rather than silently hitting
// the repo's live 69-command registry.
//
// This test MUST fail on a pre-fix build (unguarded readdirSync throws ENOENT) and
// pass after (ENOENT-specific catch returns []).
const { spawnSync } = require('node:child_process');
const absScriptsSrc = path.resolve(__dirname, '..', 'scripts', 'fix-slash-commands.cjs');
// Build a clean tmpRoot: <tmpRoot>/scripts/fix-slash-commands.cjs
// No commands/gsd/ exists anywhere under or adjacent to tmpRoot.
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-readcmdnames-absentdir-'));
try {
const tmpScriptsDir = path.join(tmpRoot, 'scripts');
fs.mkdirSync(tmpScriptsDir, { recursive: true });
const tmpCopyPath = path.join(tmpScriptsDir, 'fix-slash-commands.cjs');
fs.copyFileSync(absScriptsSrc, tmpCopyPath);
// Script: require the COPY (not the repo original) so __dirname === tmpScriptsDir
// → COMMANDS_DIR = path.join(tmpScriptsDir, '..', 'commands', 'gsd') = <tmpRoot>/commands/gsd
// which does not exist → must return [] without throwing.
const script = [
`'use strict';`,
`const mod = require(${JSON.stringify(tmpCopyPath)});`,
`let result;`,
`try { result = mod.readCmdNames(); } catch(e) { process.stderr.write('THREW:' + e.code + ':' + e.message); process.exit(2); }`,
`if (!Array.isArray(result)) { process.stderr.write('NOT_ARRAY:' + JSON.stringify(result)); process.exit(3); }`,
`if (result.length !== 0) { process.stderr.write('EXPECTED_EMPTY:got ' + result.length + ' entries'); process.exit(4); }`,
`// readCmdNames() returned [] as required — success`,
`process.exit(0);`,
].join('\n');
const spawnResult = spawnSync(process.execPath, ['-e', script], {
encoding: 'utf8',
timeout: 10000,
env: { ...process.env, GSD_TEST_MODE: '1' },
});
assert.ok(
!spawnResult.stderr.includes('THREW:'),
`readCmdNames() must not throw when commands/gsd/ is absent; stderr=${spawnResult.stderr}`,
);
assert.strictEqual(spawnResult.status, 0,
`readCmdNames() must return [] (exit 0) when commands/gsd/ is absent; ` +
`status=${spawnResult.status} stderr=${spawnResult.stderr}`);
} finally {
cleanup(tmpRoot);
}
});
});
// ─── Section N: Antigravity .agents canonical workspace dir (#791) ─────────────
// allow-test-rule: runtime-contract-is-the-product
// Reads deployed agent .md files whose text IS the product surface the
// Antigravity runtime loads at startup (path references, command names).
describe('antigravity local install writes to .agents/ canonical dir (#791)', () => {
let tmpDir;
let previousCwd;
beforeEach(() => {
tmpDir = createTempDir('gsd-antigravity-791-');
previousCwd = process.cwd();
process.chdir(tmpDir);
});
afterEach(() => {
process.chdir(previousCwd);
cleanup(tmpDir);
});
test('install writes workspace skills under .agents/skills/', () => {
const result = install(false, 'antigravity');
const agentsDir = path.join(tmpDir, '.agents');
assert.strictEqual(result.runtime, 'antigravity');
assert.ok(fs.existsSync(agentsDir), '.agents/ must be created for local antigravity install');
const skillsDir = path.join(agentsDir, 'skills');
assert.ok(fs.existsSync(skillsDir), '.agents/skills/ must exist after install');
const skillEntries = fs.readdirSync(skillsDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
assert.ok(skillEntries.length > 0, 'at least one gsd-* skill must be installed under .agents/skills/');
const firstSkill = path.join(skillsDir, skillEntries[0].name, 'SKILL.md');
assert.ok(fs.existsSync(firstSkill), `SKILL.md must exist at ${firstSkill}`);
});
test('install writes concrete skills at the immediate level AGY scans', () => {
install(false, 'antigravity');
const skillsDir = path.join(tmpDir, '.agents', 'skills');
assert.ok(
fs.existsSync(path.join(skillsDir, 'gsd-progress', 'SKILL.md')),
'AGY scans immediate skill folders, so /gsd-progress must be installed at .agents/skills/gsd-progress/SKILL.md',
);
assert.ok(
fs.existsSync(path.join(skillsDir, 'gsd-verify-work', 'SKILL.md')),
'AGY scans immediate skill folders, so /gsd-verify-work must be installed at .agents/skills/gsd-verify-work/SKILL.md',
);
assert.ok(
!fs.existsSync(path.join(skillsDir, 'gsd-ns-workflow', 'skills', 'progress', 'SKILL.md')),
'Antigravity must not rely on router-nested concrete skills that AGY does not discover',
);
});
test('installed agent files reference .agents/ not ~/.claude/ or bare .agent/', () => {
// NOTE: skill content is intentionally NOT asserted here. The installer calls
// convertClaudeCommandToAntigravitySkill(content, skillName, runtime, cmdNames)
// where the 3rd arg is the string "antigravity" (truthy), routing local skills
// through the global content branch — a pre-existing quirk tracked separately.
// Agent files are NOT affected: convertClaudeAgentToAntigravityAgent(content, isGlobal)
// receives the boolean isGlobal correctly, so local agents use the local (.agents/) branch.
install(false, 'antigravity');
const agentsDest = path.join(tmpDir, '.agents', 'agents');
assert.ok(fs.existsSync(agentsDest), '.agents/agents/ must exist after local install');
const agentFiles = fs.readdirSync(agentsDest)
.filter(f => f.startsWith('gsd-') && f.endsWith('.md'));
assert.ok(agentFiles.length > 0, 'pre-condition: at least one gsd-* agent must be installed');
for (const file of agentFiles) {
const content = fs.readFileSync(path.join(agentsDest, file), 'utf8');
// Local agent content must not contain global home-dir paths (should be .agents/)
assert.ok(
!content.includes('~/.claude/') && !content.includes('$HOME/.claude/'),
`${file} must not contain ~/.claude/ or $HOME/.claude/ in a local install; content uses .agents/`,
);
// Local agent content must not reference the legacy singular .agent/ path
const bareAgentRefs = content.match(/(?<!\w)\.agent(?!s)\//g) || [];
assert.strictEqual(
bareAgentRefs.length, 0,
`${file} must not reference legacy .agent/ path; found: ${bareAgentRefs.join(', ')}`,
);
}
});
test('legacy .agent/ is NOT written on a fresh local install', () => {
install(false, 'antigravity');
const legacyDir = path.join(tmpDir, '.agent');
assert.ok(!fs.existsSync(legacyDir),
'.agent/ must not be created by a fresh install (new installs use .agents/)');
});
test('global antigravity install still writes to ~/.gemini/antigravity (unchanged)', () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ag-global-'));
const savedHome = process.env.HOME;
const savedUserProfile = process.env.USERPROFILE;
const savedAntigravityConfig = process.env.ANTIGRAVITY_CONFIG_DIR;
delete process.env.ANTIGRAVITY_CONFIG_DIR;
process.env.HOME = homeDir;
process.env.USERPROFILE = homeDir;
try {
const result = install(true, 'antigravity');
assert.strictEqual(result.runtime, 'antigravity');
assert.ok(
result.configDir.startsWith(homeDir),
`global antigravity install must go under HOME, got: ${result.configDir}`,
);
assert.ok(
fs.existsSync(path.join(result.configDir, 'skills')),
'global antigravity install must create skills/ under ~/.gemini/antigravity',
);
assert.ok(
!fs.existsSync(path.join(homeDir, '.agents')),
'.agents/ must NOT be created by a global install (global path is ~/.gemini/antigravity)',
);
} finally {
if (savedHome === undefined) delete process.env.HOME;
else process.env.HOME = savedHome;
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = savedUserProfile;
if (savedAntigravityConfig === undefined) delete process.env.ANTIGRAVITY_CONFIG_DIR;
else process.env.ANTIGRAVITY_CONFIG_DIR = savedAntigravityConfig;
cleanup(homeDir);
}
});
});
// ─── Section 6: Windsurf / devin-desktop alias (#792) ───────────────────────
describe('install — --devin-desktop CLI flag routes to windsurf runtime (#792)', () => {
test('--devin-desktop resolves to ["windsurf"] via selectRuntimesFromArgs', () => {
const runtimes = selectRuntimesFromArgs(['--devin-desktop']);
assert.deepStrictEqual(runtimes, ['windsurf'],
'--devin-desktop must resolve to ["windsurf"] via selectRuntimesFromArgs');
});
test('--windsurf and --devin-desktop both resolve to ["windsurf"]', () => {
assert.deepStrictEqual(selectRuntimesFromArgs(['--windsurf']), ['windsurf']);
assert.deepStrictEqual(selectRuntimesFromArgs(['--devin-desktop']), ['windsurf']);
});
});
// ─── Section N: Windsurf workflow slash-command install (#1615) ─────────────
// allow-test-rule: runtime-contract-is-the-product
// Reads deployed workflow .md files whose text IS the product surface the
// Windsurf runtime loads at startup (path references, command names).
describe('windsurf local install writes workflow slash commands (#1615)', () => {
let tmpDir;
let previousCwd;
beforeEach(() => {
tmpDir = createTempDir('gsd-windsurf-1085-');
previousCwd = process.cwd();
process.chdir(tmpDir);
});
afterEach(() => {
process.chdir(previousCwd);
cleanup(tmpDir);
});
test('install writes workspace workflows under .windsurf/workflows/', () => {
const result = install(false, 'windsurf');
const windsurfDir = path.join(tmpDir, '.windsurf');
assert.strictEqual(result.runtime, 'windsurf');
assert.ok(fs.existsSync(windsurfDir), '.windsurf/ must be created for local windsurf install');
const workflowsDir = path.join(windsurfDir, 'workflows');
assert.ok(fs.existsSync(workflowsDir), '.windsurf/workflows/ must exist after install');
const workflowEntries = fs.readdirSync(workflowsDir, { withFileTypes: true })
.filter(e => e.isFile() && e.name.startsWith('gsd-') && e.name.endsWith('.md'));
assert.ok(workflowEntries.length > 0, 'at least one gsd-* workflow must be installed under .windsurf/workflows/');
assert.ok(fs.existsSync(path.join(workflowsDir, 'gsd-help.md')), 'gsd-help.md workflow must exist');
});
test('legacy .devin/skills is NOT written on a fresh local install', () => {
install(false, 'windsurf');
assert.ok(!fs.existsSync(path.join(tmpDir, '.devin', 'skills')),
'.devin/skills must not be created by a fresh install (new installs use .windsurf/workflows)');
assert.ok(!fs.existsSync(path.join(tmpDir, '.windsurf', 'skills')),
'.windsurf/skills must not be created for slash commands');
});
test('installed workflow content references command body, not skill locations', () => {
install(false, 'windsurf');
const workflowsDir = path.join(tmpDir, '.windsurf', 'workflows');
const workflowEntries = fs.readdirSync(workflowsDir, { withFileTypes: true })
.filter(e => e.isFile() && e.name.startsWith('gsd-') && e.name.endsWith('.md'));
assert.ok(workflowEntries.length > 0, 'pre-condition: at least one gsd-* workflow must be installed');
for (const workflowEntry of workflowEntries) {
const workflowFile = path.join(workflowsDir, workflowEntry.name);
const content = fs.readFileSync(workflowFile, 'utf8');
assert.ok(
content.includes(`${tmpDir}/.windsurf/gsd-core/commands/gsd/`.replace(/\\/g, '/')),
`${workflowEntry.name} must reference the installed canonical command body`,
);
assert.ok(
!content.includes('/skills/') && !content.includes('SKILL.md'),
`${workflowEntry.name} must not reference Windsurf skill locations`,
);
}
});
// #1629 Finding A: every workflow's @-reference target must exist on disk
// after install. Pre-fix, gsd-core/ was copied AFTER workflows were written;
// a throw or kill in that window left workflows pointing at missing files.
// Post-fix, gsd-core/ is copied first. This behavioral invariant catches
// any ordering regression that leaves a workflow target absent.
test('every workflow @-reference target exists on disk after install (#1629 Finding A)', () => {
install(false, 'windsurf');
const workflowsDir = path.join(tmpDir, '.windsurf', 'workflows');
const workflowEntries = fs.readdirSync(workflowsDir, { withFileTypes: true })
.filter(e => e.isFile() && e.name.startsWith('gsd-') && e.name.endsWith('.md'));
assert.ok(workflowEntries.length > 0, 'pre-condition: at least one gsd-* workflow must be installed');
const commandsGsdDir = path.join(tmpDir, '.windsurf', 'gsd-core', 'commands', 'gsd');
assert.ok(fs.existsSync(commandsGsdDir),
`gsd-core/commands/gsd/ must exist at ${commandsGsdDir} so workflows can delegate to it`);
for (const workflowEntry of workflowEntries) {
// Workflow naming convention: gsd-<stem>.md → delegates to commands/gsd/<stem>.md
const stem = workflowEntry.name.replace(/^gsd-/, '').replace(/\.md$/, '');
const targetFile = path.join(commandsGsdDir, `${stem}.md`);
assert.ok(
fs.existsSync(targetFile),
`${workflowEntry.name} delegates to commands/gsd/${stem}.md, but that file does not exist at ${targetFile}`,
);
}
});
test('global windsurf install does not write unsupported workflows or skills', () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ws-global-'));
const savedHome = process.env.HOME;
const savedUserProfile = process.env.USERPROFILE;
const savedWindsurfConfig = process.env.WINDSURF_CONFIG_DIR;
delete process.env.WINDSURF_CONFIG_DIR;
process.env.HOME = homeDir;
process.env.USERPROFILE = homeDir;
try {
const result = install(true, 'windsurf');
assert.strictEqual(result.runtime, 'windsurf');
assert.ok(
result.configDir.includes('codeium') || result.configDir.includes('windsurf'),
`global windsurf install must go to codeium/windsurf path, got: ${result.configDir}`,
);
assert.ok(
!fs.existsSync(path.join(result.configDir, 'workflows')),
'global windsurf install must not create unsupported workflows/ under ~/.codeium/windsurf',
);
assert.ok(
!fs.existsSync(path.join(result.configDir, 'skills')),
'global windsurf install must not create dead skills/ artifacts',
);
} finally {
if (savedHome === undefined) delete process.env.HOME;
else process.env.HOME = savedHome;
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = savedUserProfile;
if (savedWindsurfConfig === undefined) delete process.env.WINDSURF_CONFIG_DIR;
else process.env.WINDSURF_CONFIG_DIR = savedWindsurfConfig;
cleanup(homeDir);
}
});
test('global windsurf install still installs shared workflow assets', () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ws-global-c-'));
const savedHome = process.env.HOME;
const savedUserProfile = process.env.USERPROFILE;
const savedWindsurfConfig = process.env.WINDSURF_CONFIG_DIR;
delete process.env.WINDSURF_CONFIG_DIR;
process.env.HOME = homeDir;
process.env.USERPROFILE = homeDir;
try {
const result = install(true, 'windsurf');
assert.ok(fs.existsSync(path.join(result.configDir, 'gsd-core', 'workflows', 'update.md')),
'global windsurf install should still copy shared gsd-core workflow assets');
assert.ok(!fs.existsSync(path.join(result.configDir, 'workflows')),
'global windsurf install must not write workflow files into an undocumented global path');
} finally {
if (savedHome === undefined) delete process.env.HOME;
else process.env.HOME = savedHome;
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = savedUserProfile;
if (savedWindsurfConfig === undefined) delete process.env.WINDSURF_CONFIG_DIR;
else process.env.WINDSURF_CONFIG_DIR = savedWindsurfConfig;
cleanup(homeDir);
}
});
});
// ─── #1629 Finding B: legacy .devin/skills/gsd-* cleanup on Windsurf reinstall ─
describe('cleanupWindsurfLegacyDevinSkills — removes pre-#1615 skill artifacts (#1629)', () => {
const { cleanupWindsurfLegacyDevinSkills } = require('../bin/install.js');
test('removes GSD-managed gsd-* dirs under .devin/skills/', (t) => {
const tmpDir = createTempDir('gsd-1629b-cleanup-');
t.after(() => cleanup(tmpDir));
// Stage legacy .devin/skills/gsd-*/ artifacts (pre-#1615 layout)
const legacySkillsDir = path.join(tmpDir, '.devin', 'skills');
for (const skill of ['gsd-help', 'gsd-plan-phase', 'gsd-ship']) {
const skillDir = path.join(legacySkillsDir, skill);
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Legacy skill\n');
}
const removed = cleanupWindsurfLegacyDevinSkills(tmpDir);
assert.strictEqual(removed, 3, 'should remove exactly 3 gsd-* dirs');
for (const skill of ['gsd-help', 'gsd-plan-phase', 'gsd-ship']) {
assert.ok(
!fs.existsSync(path.join(legacySkillsDir, skill)),
`${skill} should be removed from .devin/skills/`,
);
}
// Empty container dirs should also be pruned
assert.ok(!fs.existsSync(legacySkillsDir), '.devin/skills/ should be pruned when empty');
assert.ok(!fs.existsSync(path.join(tmpDir, '.devin')), '.devin/ should be pruned when empty');
});
test('preserves user-owned non-gsd- content under .devin/skills/', (t) => {
const tmpDir = createTempDir('gsd-1629b-preserve-');
t.after(() => cleanup(tmpDir));
const legacySkillsDir = path.join(tmpDir, '.devin', 'skills');
// Stage mixed content: legacy GSD + user-authored + user-owned gsd-dev-preferences
fs.mkdirSync(path.join(legacySkillsDir, 'gsd-help'), { recursive: true });
fs.writeFileSync(path.join(legacySkillsDir, 'gsd-help', 'SKILL.md'), '# legacy\n');
fs.mkdirSync(path.join(legacySkillsDir, 'my-custom-skill'), { recursive: true });
fs.writeFileSync(path.join(legacySkillsDir, 'my-custom-skill', 'SKILL.md'), '# user\n');
fs.mkdirSync(path.join(legacySkillsDir, 'gsd-dev-preferences'), { recursive: true });
fs.writeFileSync(path.join(legacySkillsDir, 'gsd-dev-preferences', 'SKILL.md'), '# prefs\n');
const removed = cleanupWindsurfLegacyDevinSkills(tmpDir);
assert.strictEqual(removed, 1, 'only gsd-help should be removed (gsd-dev-preferences is user-owned)');
assert.ok(!fs.existsSync(path.join(legacySkillsDir, 'gsd-help')), 'legacy gsd-help removed');
assert.ok(
fs.existsSync(path.join(legacySkillsDir, 'my-custom-skill')),
'user-authored my-custom-skill must be preserved',
);
assert.ok(
fs.existsSync(path.join(legacySkillsDir, 'gsd-dev-preferences')),
'user-owned gsd-dev-preferences must be preserved (#2973)',
);
// Container NOT pruned because it still has user content
assert.ok(fs.existsSync(legacySkillsDir), '.devin/skills/ preserved when user content remains');
assert.ok(fs.existsSync(path.join(tmpDir, '.devin')), '.devin/ preserved when user content remains');
});
test('skips symlinks pointing outside the .devin tree (escape guard)', (t) => {
const tmpDir = createTempDir('gsd-1629b-symlink-');
t.after(() => cleanup(tmpDir));
const legacySkillsDir = path.join(tmpDir, '.devin', 'skills');
fs.mkdirSync(legacySkillsDir, { recursive: true });
// Create a symlink that points outside the tree
const outsideTarget = path.join(tmpDir, 'secret');
fs.mkdirSync(outsideTarget);
fs.writeFileSync(path.join(outsideTarget, 'secret.txt'), 'secret\n');
fs.symlinkSync(outsideTarget, path.join(legacySkillsDir, 'gsd-symlinked'));
const removed = cleanupWindsurfLegacyDevinSkills(tmpDir);
assert.strictEqual(removed, 0, 'symlinked gsd-* dir must not be removed');
assert.ok(
fs.existsSync(path.join(legacySkillsDir, 'gsd-symlinked')),
'symlink must be preserved (escape guard)',
);
assert.ok(
fs.existsSync(path.join(outsideTarget, 'secret.txt')),
'out-of-tree target must not be touched',
);
});
test('no-op when .devin/skills/ does not exist', () => {
const tmpDir = createTempDir('gsd-1629b-noop-');
const removed = cleanupWindsurfLegacyDevinSkills(tmpDir);
assert.strictEqual(removed, 0, 'should return 0 when .devin/skills/ is absent');
cleanup(tmpDir);
});
test('install(false, "windsurf") removes pre-existing .devin/skills/gsd-* on reinstall', (t) => {
// End-to-end: stage legacy artifacts, run a fresh Windsurf install,
// verify the old layout is cleaned up while the new .windsurf/ layout is written.
const tmpDir = createTempDir('gsd-1629b-e2e-');
t.after(() => cleanup(tmpDir));
const previousCwd = process.cwd();
process.chdir(tmpDir);
try {
// Stage pre-#1615 artifacts
const legacyDir = path.join(tmpDir, '.devin', 'skills', 'gsd-help');
fs.mkdirSync(legacyDir, { recursive: true });
fs.writeFileSync(path.join(legacyDir, 'SKILL.md'), '# legacy\n');
// Fresh Windsurf install
install(false, 'windsurf');
// Legacy layout should be cleaned up
assert.ok(
!fs.existsSync(path.join(tmpDir, '.devin', 'skills', 'gsd-help')),
'pre-existing .devin/skills/gsd-help should be removed by fresh windsurf install',
);
// New layout should be present
assert.ok(
fs.existsSync(path.join(tmpDir, '.windsurf', 'workflows')),
'.windsurf/workflows/ should exist after fresh install',
);
} finally {
process.chdir(previousCwd);
}
});
});
// ─── Section N+1: #767 — disallowedTools injection for read-only agents ──────
//
// Verifies (installer-behavioral test — drives install() to a temp dir):
// 1. Claude install: Group A agents have disallowedTools == {Write, Edit, MultiEdit} exactly.
// 2. Claude install: Group B agents have disallowedTools == {Edit, MultiEdit} exactly.
// 3. Negative: gsd-nyquist-auditor has NO disallowedTools key (legitimately writes+edits).
// 4. Cross-runtime no-leak: Gemini-installed read-only agents do NOT contain disallowedTools.
// 5. Source purity: source agents/*.md must not contain disallowedTools (inject-only).
// 6. Parity: docs/AGENTS.md "Disallowed Tools" rows match READONLY_AGENT_DISALLOWED_TOOLS.
// (DEFECT.GENERATIVE-FIX guard)
// #767 — must mirror READONLY_AGENT_DISALLOWED_TOOLS in bin/install.js.
// If you change the map there, update this too (the parity test will catch drift).
const READONLY_AGENT_DISALLOWED_TOOLS_767 = {
'gsd-plan-checker': 'Write, Edit, MultiEdit',
'gsd-integration-checker': 'Write, Edit, MultiEdit',
'gsd-ui-checker': 'Write, Edit, MultiEdit',
'gsd-verifier': 'Edit, MultiEdit',
'gsd-doc-verifier': 'Edit, MultiEdit',
'gsd-eval-auditor': 'Edit, MultiEdit',
'gsd-ui-auditor': 'Edit, MultiEdit',
};
const GROUP_A_767 = ['gsd-plan-checker', 'gsd-integration-checker', 'gsd-ui-checker'];
const GROUP_B_767 = ['gsd-verifier', 'gsd-doc-verifier', 'gsd-eval-auditor', 'gsd-ui-auditor'];
const REPO_ROOT_767 = path.resolve(__dirname, '..');
const SOURCE_AGENTS_DIR_767 = path.join(REPO_ROOT_767, 'agents');
const AGENTS_DOC_PATH_767 = path.join(REPO_ROOT_767, 'docs', 'AGENTS.md');
function readFrontmatterText(mdPath) {
const content = fs.readFileSync(mdPath, 'utf8');
if (!content.startsWith('---')) return '';
const end = content.indexOf('---', 3);
if (end === -1) return '';
return content.substring(3, end);
}
function parseDisallowedToolsSet(fm) {
const match = fm.match(/^disallowedTools:\s*(.+)$/m);
if (!match) return null;
return new Set(match[1].split(',').map((t) => t.trim()).filter(Boolean));
}
/**
* Run a global install for the given runtime, redirecting its home dir to
* tmpHome. Stubs both HOME and USERPROFILE for Windows parity, and
* suppresses the stale-SDK npm subprocess.
*/
function runGlobalInstall767(runtime, tmpHome) {
const envVarMap = {
claude: 'CLAUDE_CONFIG_DIR',
gemini: 'GEMINI_CONFIG_DIR',
qwen: 'QWEN_CONFIG_DIR',
};
const envVar = envVarMap[runtime];
if (!envVar) throw new Error(`Unsupported runtime in #767 test: ${runtime}`);
const isolatedHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-767-home-'));
const prevEnvVar = process.env[envVar];
const prevCwd = process.cwd();
const prevHome = process.env.HOME;
const prevUserProfile = process.env.USERPROFILE;
const prevSkipStale = process.env.GSD_SKIP_STALE_SDK_CHECK;
process.env[envVar] = tmpHome;
process.env.HOME = isolatedHome;
process.env.USERPROFILE = isolatedHome;
process.env.GSD_SKIP_STALE_SDK_CHECK = '1';
process.chdir(REPO_ROOT_767);
try {
install(true, runtime);
} finally {
process.chdir(prevCwd);
if (prevEnvVar === undefined) delete process.env[envVar];
else process.env[envVar] = prevEnvVar;
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
if (prevUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = prevUserProfile;
if (prevSkipStale === undefined) delete process.env.GSD_SKIP_STALE_SDK_CHECK;
else process.env.GSD_SKIP_STALE_SDK_CHECK = prevSkipStale;
cleanup(isolatedHome);
}
return tmpHome;
}
describe('#767 Claude install: Group A agents have disallowedTools = {Write, Edit, MultiEdit}', () => {
let tmpDir;
let claudeHome;
beforeEach(() => {
tmpDir = createTempDir('gsd-767-claude-a-');
claudeHome = path.join(tmpDir, 'claude-home');
fs.mkdirSync(claudeHome, { recursive: true });
runGlobalInstall767('claude', claudeHome);
});
afterEach(() => {
cleanup(tmpDir);
});
const EXPECTED_A_767 = new Set(['Write', 'Edit', 'MultiEdit']);
for (const agent of GROUP_A_767) {
test(`${agent}: disallowedTools is exactly {Write, Edit, MultiEdit}`, () => {
const fm = readFrontmatterText(path.join(claudeHome, 'agents', `${agent}.md`));
const tools = parseDisallowedToolsSet(fm);
assert.ok(tools !== null,
`${agent} must have a disallowedTools key in Claude frontmatter\nFrontmatter:\n${fm}`);
assert.deepEqual(tools, EXPECTED_A_767,
`${agent} disallowedTools must be exactly {Write, Edit, MultiEdit}\nGot: ${[...tools].join(', ')}`);
});
}
});
describe('#767 Claude install: Group B agents have disallowedTools = {Edit, MultiEdit}', () => {
let tmpDir;
let claudeHome;
beforeEach(() => {
tmpDir = createTempDir('gsd-767-claude-b-');
claudeHome = path.join(tmpDir, 'claude-home');
fs.mkdirSync(claudeHome, { recursive: true });
runGlobalInstall767('claude', claudeHome);
});
afterEach(() => {
cleanup(tmpDir);
});
const EXPECTED_B_767 = new Set(['Edit', 'MultiEdit']);
for (const agent of GROUP_B_767) {
test(`${agent}: disallowedTools is exactly {Edit, MultiEdit}`, () => {
const fm = readFrontmatterText(path.join(claudeHome, 'agents', `${agent}.md`));
const tools = parseDisallowedToolsSet(fm);
assert.ok(tools !== null,
`${agent} must have a disallowedTools key in Claude frontmatter\nFrontmatter:\n${fm}`);
assert.deepEqual(tools, EXPECTED_B_767,
`${agent} disallowedTools must be exactly {Edit, MultiEdit}\nGot: ${[...tools].join(', ')}`);
});
}
});
describe('#767 Claude install: gsd-nyquist-auditor has no disallowedTools (legitimately writes+edits)', () => {
let tmpDir;
let claudeHome;
beforeEach(() => {
tmpDir = createTempDir('gsd-767-claude-nyquist-');
claudeHome = path.join(tmpDir, 'claude-home');
fs.mkdirSync(claudeHome, { recursive: true });
runGlobalInstall767('claude', claudeHome);
});
afterEach(() => {
cleanup(tmpDir);
});
test('gsd-nyquist-auditor.md has NO disallowedTools key', () => {
const fm = readFrontmatterText(path.join(claudeHome, 'agents', 'gsd-nyquist-auditor.md'));
const tools = parseDisallowedToolsSet(fm);
assert.equal(tools, null,
`gsd-nyquist-auditor must NOT have disallowedTools in Claude frontmatter\nFrontmatter:\n${fm}`);
});
});
describe('#767 Gemini install: read-only agents do NOT contain disallowedTools (cross-runtime leak guard)', () => {
let tmpDir;
let geminiHome;
beforeEach(() => {
tmpDir = createTempDir('gsd-767-gemini-');
geminiHome = path.join(tmpDir, 'gemini-home');
fs.mkdirSync(geminiHome, { recursive: true });
runGlobalInstall767('gemini', geminiHome);
});
afterEach(() => {
cleanup(tmpDir);
});
for (const agent of [...GROUP_A_767, ...GROUP_B_767]) {
test(`Gemini ${agent}.md has no disallowedTools`, () => {
const agentPath = path.join(geminiHome, 'agents', `${agent}.md`);
const content = fs.readFileSync(agentPath, 'utf8');
assert.ok(!content.includes('disallowedTools'),
`${agent} (Gemini) must NOT contain disallowedTools\nContent excerpt:\n${content.slice(0, 400)}`);
});
}
});
describe('#767 Source purity: source agents/*.md must not contain disallowedTools (inject-only)', () => {
for (const agent of [...GROUP_A_767, ...GROUP_B_767]) {
test(`source agents/${agent}.md has no disallowedTools`, () => {
const content = fs.readFileSync(path.join(SOURCE_AGENTS_DIR_767, `${agent}.md`), 'utf8');
assert.ok(!content.includes('disallowedTools'),
`Source agents/${agent}.md must NOT contain disallowedTools (injection is install-time only, source must stay runtime-neutral)`);
});
}
});
describe('#767 Parity: docs/AGENTS.md "Disallowed Tools" rows match READONLY_AGENT_DISALLOWED_TOOLS', () => {
const agentsDoc = fs.readFileSync(AGENTS_DOC_PATH_767, 'utf8');
for (const [agent, expectedTools] of Object.entries(READONLY_AGENT_DISALLOWED_TOOLS_767)) {
test(`docs/AGENTS.md has matching Disallowed Tools row for ${agent}`, () => {
const agentHeaderIdx = agentsDoc.indexOf(`### ${agent}`);
assert.ok(agentHeaderIdx !== -1,
`docs/AGENTS.md must contain a ### ${agent} section`);
const nextSectionIdx = agentsDoc.indexOf('\n### ', agentHeaderIdx + 1);
const sectionEnd = nextSectionIdx === -1 ? agentsDoc.length : nextSectionIdx;
const section = agentsDoc.slice(agentHeaderIdx, sectionEnd);
const disallowedMatch = section.match(/\|\s*\*\*Disallowed Tools\*\*\s*\|\s*([^|]+)\|/);
assert.ok(disallowedMatch,
`docs/AGENTS.md section for ${agent} must have a "Disallowed Tools" table row`);
const docTools = disallowedMatch[1].trim();
assert.equal(docTools, expectedTools,
`docs/AGENTS.md "Disallowed Tools" for ${agent} must be "${expectedTools}" but got "${docTools}"`);
});
}
});
// ─── normalizeNodePath — mise versioned install path → stable shim (#1619) ────
//
// Bug #1619: `resolveNodeRunner()` bakes process.execPath into managed hook
// commands. Node realpaths execPath, so under mise it resolves to
// `<data>/installs/node/<ver>/bin/node` — a concrete version mise prunes on
// `mise up`, after which every managed hook 404s (same class as #977 fnm /
// #3181 Homebrew). normalizeNodePath now rewrites it to the stable sibling
// shim `<data>/shims/node` when that shim exists, deriving <data> from the
// path so a custom MISE_DATA_DIR works, and falling back to execPath otherwise.
// Folded into install.test.cjs (not a new bug-NNNN file) per the regression
// test-name lint. Assertions go against the exported function's return values.
describe('normalizeNodePath — mise versioned path → sibling shim (#1619)', () => {
const MISE_DATA = '/Users/u/.local/share/mise';
const MISE_NODE_PINNED = `${MISE_DATA}/installs/node/26.3.0/bin/node`;
const MISE_SHIM = `${MISE_DATA}/shims/node`;
const MISE_WIN_DATA = 'C:/Users/u/AppData/Local/mise';
const MISE_WIN_NODE = `${MISE_WIN_DATA}/installs/node/22.1.0/node.exe`; // no bin/ on Windows
const MISE_WIN_SHIM = `${MISE_WIN_DATA}/shims/node.exe`;
const MISE_CUSTOM_DATA = '/opt/mise-data';
const MISE_CUSTOM_NODE = `${MISE_CUSTOM_DATA}/installs/node/20.0.0/bin/node`;
const MISE_CUSTOM_SHIM = `${MISE_CUSTOM_DATA}/shims/node`;
test('POSIX pinned install path + shim exists → sibling shim', () => {
assert.equal(
normalizeNodePath(MISE_NODE_PINNED, { existsSync: p => p === MISE_SHIM }),
MISE_SHIM);
});
test('Windows node.exe + shim exists → shims/node.exe (.exe preserved)', () => {
assert.equal(
normalizeNodePath(MISE_WIN_NODE, { existsSync: p => p === MISE_WIN_SHIM }),
MISE_WIN_SHIM);
});
test('backslash Windows path normalizes the same as forward-slash', () => {
assert.equal(
normalizeNodePath(MISE_WIN_NODE.replace(/\//g, '\\'),
{ existsSync: p => p === MISE_WIN_SHIM }),
MISE_WIN_SHIM);
});
test('custom MISE_DATA_DIR layout → shim derived from execPath, not env', () => {
assert.equal(
normalizeNodePath(MISE_CUSTOM_NODE, { existsSync: p => p === MISE_CUSTOM_SHIM }),
MISE_CUSTOM_SHIM);
});
test('no regression: shim absent → falls back to raw execPath unchanged', () => {
assert.equal(
normalizeNodePath(MISE_NODE_PINNED, { existsSync: () => false }),
MISE_NODE_PINNED);
});
test('non-mise path (Homebrew symlink) is left unchanged here', () => {
assert.equal(
normalizeNodePath('/opt/homebrew/bin/node', { existsSync: () => true }),
'/opt/homebrew/bin/node');
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2256-model-overrides-transport.test.cjs — consolidation epic #1969 (B1 #1970)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-2256-model-overrides-transport (consolidation epic #1969 B1 #1970)", () => {
/**
* Regression tests for issue #2256 — per-agent model_overrides transport
* for Codex and OpenCode runtimes.
*
* The bug: model_overrides set in per-project `.planning/config.json` were
* never read by the Codex / OpenCode install paths, which only probed
* `~/.gsd/defaults.json`. As a result, the configured per-agent model was
* dropped and child agents inherited the runtime's default model.
*
* These tests lock in the fix: per-project overrides must be honored, and
* per-project keys must win over global when both are present.
*/
process.env.GSD_TEST_MODE = '1';
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const isWindows = process.platform === 'win32';
const {
readGsdEffectiveModelOverrides,
generateCodexAgentToml,
convertClaudeToOpencodeFrontmatter,
getCodexSkillAdapterHeader,
} = require('../bin/install.js');
const { createTempDir, cleanup } = require('./helpers.cjs');
const makeTmp = (prefix) => createTempDir(`gsd-2256-${prefix}-`);
function writeJson(p, obj) {
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(obj, null, 2));
}
describe('bug #2256 — readGsdEffectiveModelOverrides', () => {
let projectDir;
let homeDir;
let origHome;
let origUserProfile;
beforeEach(() => {
projectDir = makeTmp('proj');
homeDir = makeTmp('home');
origHome = process.env.HOME;
// On Windows, os.homedir() reads USERPROFILE (not HOME). Tests that
// need to redirect ~ must override both — otherwise the SUT reads
// the real user's home and the fixture is invisible.
origUserProfile = process.env.USERPROFILE;
process.env.HOME = homeDir;
if (isWindows) process.env.USERPROFILE = homeDir;
});
afterEach(() => {
if (origHome === undefined) delete process.env.HOME;
else process.env.HOME = origHome;
if (isWindows) {
if (origUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = origUserProfile;
}
cleanup(projectDir);
cleanup(homeDir);
});
test('returns null when neither source defines model_overrides', () => {
const result = readGsdEffectiveModelOverrides(projectDir);
assert.strictEqual(result, null);
});
test('reads overrides from ~/.gsd/defaults.json (global only)', () => {
writeJson(path.join(homeDir, '.gsd', 'defaults.json'), {
model_overrides: { 'gsd-codebase-mapper': 'gpt-5-mini' },
});
const result = readGsdEffectiveModelOverrides(projectDir);
assert.deepStrictEqual(result, { 'gsd-codebase-mapper': 'gpt-5-mini' });
});
test('reads overrides from per-project .planning/config.json', () => {
writeJson(path.join(projectDir, '.planning', 'config.json'), {
model_overrides: { 'gsd-codebase-mapper': 'claude-haiku-4-5' },
});
const result = readGsdEffectiveModelOverrides(projectDir);
assert.deepStrictEqual(result, { 'gsd-codebase-mapper': 'claude-haiku-4-5' });
});
test('per-project overrides win over global on conflict', () => {
writeJson(path.join(homeDir, '.gsd', 'defaults.json'), {
model_overrides: { 'gsd-codebase-mapper': 'global-model', 'gsd-planner': 'opus' },
});
writeJson(path.join(projectDir, '.planning', 'config.json'), {
model_overrides: { 'gsd-codebase-mapper': 'project-model' },
});
const result = readGsdEffectiveModelOverrides(projectDir);
// Per-project wins on conflict; non-conflicting global keys are preserved.
assert.deepStrictEqual(result, {
'gsd-codebase-mapper': 'project-model',
'gsd-planner': 'opus',
});
});
test('walks up from nested targetDir to find .planning/', () => {
writeJson(path.join(projectDir, '.planning', 'config.json'), {
model_overrides: { 'gsd-planner': 'project-opus' },
});
const nested = path.join(projectDir, '.codex');
fs.mkdirSync(nested, { recursive: true });
const result = readGsdEffectiveModelOverrides(nested);
assert.deepStrictEqual(result, { 'gsd-planner': 'project-opus' });
});
});
describe('bug #2256 — Codex adapter embeds per-project override', () => {
const agentContent = `---\nname: gsd-codebase-mapper\ndescription: Maps codebase\n---\n\nbody\n`;
test('generateCodexAgentToml embeds model when override provided', () => {
const toml = generateCodexAgentToml(
'gsd-codebase-mapper',
agentContent,
{ 'gsd-codebase-mapper': 'gpt-5-mini' },
);
assert.match(toml, /^model = "gpt-5-mini"$/m);
});
test('generateCodexAgentToml omits model when no override', () => {
const toml = generateCodexAgentToml('gsd-codebase-mapper', agentContent, null);
assert.doesNotMatch(toml, /^model\s*=/m);
});
});
describe('bug #2256 — OpenCode adapter embeds per-project override', () => {
test('convertClaudeToOpencodeFrontmatter embeds model on agent frontmatter', () => {
const input = `---\nname: gsd-codebase-mapper\ndescription: Maps codebase\n---\n\nbody\n`;
const out = convertClaudeToOpencodeFrontmatter(input, {
isAgent: true,
modelOverride: 'claude-haiku-4-5',
});
assert.match(out, /^model: claude-haiku-4-5$/m);
assert.match(out, /^mode: subagent$/m);
});
test('convertClaudeToOpencodeFrontmatter omits model when override absent', () => {
const input = `---\nname: gsd-codebase-mapper\ndescription: Maps codebase\n---\n\nbody\n`;
const out = convertClaudeToOpencodeFrontmatter(input, { isAgent: true, modelOverride: null });
assert.doesNotMatch(out, /^model:/m);
});
});
describe('bug #2256 — Codex skill adapter header documents transport', () => {
test('Task(model=...) line no longer says "omit" without explanation', () => {
const header = getCodexSkillAdapterHeader('gsd-plan-phase');
// Header must mention that per-agent model_overrides are embedded in agent
// TOML so spawn_agent picks them up automatically — the old text said
// "Codex uses per-role config, not inline model selection" which left
// users thinking their model_overrides were silently ignored.
assert.match(header, /model_overrides/);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3181-node-cellar-path.test.cjs — consolidation epic #1969 (B1 #1970)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3181-node-cellar-path (consolidation epic #1969 B1 #1970)", () => {
'use strict';
process.env.GSD_TEST_MODE = '1';
/**
* Bug #3181: `resolveNodeRunner()` bakes versioned Homebrew Cellar paths
* (e.g. `/usr/local/Cellar/node/25.8.1/bin/node`) into hook commands in
* `~/.claude/settings.json`. After `brew upgrade node` the Cellar binary
* fails with `dyld: Library not loaded` because shared libraries have
* changed SOVERSION.
*
* Fix: prefer the stable Homebrew symlinks (`/usr/local/bin/node` for Intel
* Macs, `/opt/homebrew/bin/node` for Apple Silicon) when a Cellar path is
* detected. Non-Homebrew paths (NVM, system node, Windows, etc.) are
* returned unchanged.
*
* Also: `rewriteLegacyManagedNodeHookCommands()` must normalize Cellar paths
* baked into existing hook commands so reinstall doesn't re-bake them.
*
* All assertions go against exported function return values — no source-grep.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js'));
const { normalizeNodePath, resolveNodeRunner, rewriteLegacyManagedNodeHookCommands } = INSTALL;
// ─── normalizeNodePath ────────────────────────────────────────────────────────
describe('Bug #3181: normalizeNodePath — exported as a function', () => {
test('normalizeNodePath is exported', () => {
assert.equal(typeof normalizeNodePath, 'function');
});
});
describe('Bug #3181: normalizeNodePath — Intel Homebrew Cellar paths → /usr/local/bin/node', () => {
test('simple versioned Intel Cellar path', () => {
const result = normalizeNodePath('/usr/local/Cellar/node/25.8.1/bin/node');
assert.equal(result, '/usr/local/bin/node');
});
test('Intel Cellar path with long semver', () => {
const result = normalizeNodePath('/usr/local/Cellar/node/20.11.0/bin/node');
assert.equal(result, '/usr/local/bin/node');
});
test('Intel Cellar path with prerelease version segment', () => {
const result = normalizeNodePath('/usr/local/Cellar/node/22.0.0-rc.1/bin/node');
assert.equal(result, '/usr/local/bin/node');
});
test('Intel versioned formula Cellar path (node@20) maps to stable symlink', () => {
const result = normalizeNodePath('/usr/local/Cellar/node@20/20.11.0/bin/node');
assert.equal(result, '/usr/local/bin/node');
});
});
describe('Bug #3181: normalizeNodePath — Apple Silicon Homebrew Cellar paths → /opt/homebrew/bin/node', () => {
test('simple versioned Apple Silicon Cellar path', () => {
const result = normalizeNodePath('/opt/homebrew/Cellar/node/25.8.1/bin/node');
assert.equal(result, '/opt/homebrew/bin/node');
});
test('Apple Silicon Cellar path with another version', () => {
const result = normalizeNodePath('/opt/homebrew/Cellar/node/18.20.4/bin/node');
assert.equal(result, '/opt/homebrew/bin/node');
});
test('Apple Silicon versioned formula Cellar path (node@18) maps to stable symlink', () => {
const result = normalizeNodePath('/opt/homebrew/Cellar/node@18/18.20.4/bin/node');
assert.equal(result, '/opt/homebrew/bin/node');
});
});
describe('Bug #3181: normalizeNodePath — non-Homebrew paths are returned unchanged', () => {
test('NVM path is unchanged', () => {
const nvm = '/Users/dev/.nvm/versions/node/v20.11.0/bin/node';
assert.equal(normalizeNodePath(nvm), nvm);
});
test('already-stable Intel Homebrew symlink is unchanged', () => {
assert.equal(normalizeNodePath('/usr/local/bin/node'), '/usr/local/bin/node');
});
test('already-stable Apple Silicon Homebrew symlink is unchanged', () => {
assert.equal(normalizeNodePath('/opt/homebrew/bin/node'), '/opt/homebrew/bin/node');
});
test('system node (/usr/bin/node) is unchanged', () => {
assert.equal(normalizeNodePath('/usr/bin/node'), '/usr/bin/node');
});
test('Windows path is unchanged', () => {
const win = 'C:\\Program Files\\nodejs\\node.exe';
assert.equal(normalizeNodePath(win), win);
});
test('empty string is returned as-is', () => {
assert.equal(normalizeNodePath(''), '');
});
test('null is returned as-is', () => {
assert.equal(normalizeNodePath(null), null);
});
});
// ─── resolveNodeRunner ────────────────────────────────────────────────────────
describe('Bug #3181: resolveNodeRunner — maps Cellar execPath to stable symlink', () => {
test('Intel Cellar execPath → stable symlink quoted token', () => {
const orig = process.execPath;
try {
Object.defineProperty(process, 'execPath', {
value: '/usr/local/Cellar/node/25.8.1/bin/node',
configurable: true,
});
const runner = resolveNodeRunner();
assert.equal(runner, '"/usr/local/bin/node"',
`expected stable Intel symlink, got: ${runner}`);
} finally {
Object.defineProperty(process, 'execPath', { value: orig, configurable: true });
}
});
test('Apple Silicon Cellar execPath → stable symlink quoted token', () => {
const orig = process.execPath;
try {
Object.defineProperty(process, 'execPath', {
value: '/opt/homebrew/Cellar/node/25.8.1/bin/node',
configurable: true,
});
const runner = resolveNodeRunner();
assert.equal(runner, '"/opt/homebrew/bin/node"',
`expected stable Apple Silicon symlink, got: ${runner}`);
} finally {
Object.defineProperty(process, 'execPath', { value: orig, configurable: true });
}
});
test('non-Homebrew execPath is returned as a quoted absolute path unchanged', () => {
const orig = process.execPath;
const nvmPath = '/Users/dev/.nvm/versions/node/v20.11.0/bin/node';
try {
Object.defineProperty(process, 'execPath', { value: nvmPath, configurable: true });
const runner = resolveNodeRunner();
assert.equal(runner, JSON.stringify(nvmPath));
} finally {
Object.defineProperty(process, 'execPath', { value: orig, configurable: true });
}
});
test('returns null when execPath is empty (existing null-guard is preserved)', () => {
const orig = process.execPath;
try {
Object.defineProperty(process, 'execPath', { value: '', configurable: true });
assert.equal(resolveNodeRunner(), null);
} finally {
Object.defineProperty(process, 'execPath', { value: orig, configurable: true });
}
});
});
// ─── rewriteLegacyManagedNodeHookCommands — Cellar runner rewrite ─────────────
describe('Bug #3181: rewriteLegacyManagedNodeHookCommands — rewrites baked Cellar runner to stable symlink', () => {
test('Intel Cellar runner in a managed hook is rewritten to the stable symlink', () => {
const settings = {
hooks: {
SessionStart: [{
hooks: [{
type: 'command',
command: '"/usr/local/Cellar/node/25.8.1/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"',
}],
}],
},
};
const runner = '"/usr/local/bin/node"';
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner);
assert.equal(changed, true, 'expected rewrite to occur');
assert.equal(
settings.hooks.SessionStart[0].hooks[0].command,
'"/usr/local/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"',
);
});
test('Apple Silicon Cellar runner in a managed hook is rewritten to the stable symlink', () => {
const settings = {
hooks: {
SessionStart: [{
hooks: [{
type: 'command',
command: '"/opt/homebrew/Cellar/node/25.8.1/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"',
}],
}],
},
};
const runner = '"/opt/homebrew/bin/node"';
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner);
assert.equal(changed, true, 'expected rewrite to occur');
assert.equal(
settings.hooks.SessionStart[0].hooks[0].command,
'"/opt/homebrew/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"',
);
});
test('a hook already using the stable runner is NOT rewritten (no churn)', () => {
const settings = {
hooks: {
SessionStart: [{
hooks: [{
type: 'command',
command: '"/usr/local/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"',
}],
}],
},
};
const runner = '"/usr/local/bin/node"';
const before = settings.hooks.SessionStart[0].hooks[0].command;
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner);
assert.equal(changed, false, 'already-stable entry must not be touched');
assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before);
});
test('a user hook using a Cellar runner but an unmanaged filename is NOT rewritten', () => {
const settings = {
hooks: {
SessionStart: [{
hooks: [{
type: 'command',
command: '"/usr/local/Cellar/node/25.8.1/bin/node" "/Users/x/my-custom-hook.js"',
}],
}],
},
};
const runner = '"/usr/local/bin/node"';
const before = settings.hooks.SessionStart[0].hooks[0].command;
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner);
assert.equal(changed, false, 'unmanaged hooks with Cellar runner must not be touched');
assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before);
});
// Existing bare-node rewrite still works alongside the new Cellar rewrite
test('bare `node` managed hook is still rewritten (existing #2979 behaviour preserved)', () => {
const settings = {
hooks: {
SessionStart: [{
hooks: [{
type: 'command',
command: 'node "/Users/x/.gemini/hooks/gsd-check-update.js"',
}],
}],
},
};
const runner = '"/usr/local/bin/node"';
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner);
assert.equal(changed, true);
assert.equal(
settings.hooks.SessionStart[0].hooks[0].command,
'"/usr/local/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"',
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-977-fnm-multishell-path.test.cjs — consolidation epic #1969 (B1 #1970)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-977-fnm-multishell-path (consolidation epic #1969 B1 #1970)", () => {
'use strict';
process.env.GSD_TEST_MODE = '1';
/**
* Bug #977: `resolveNodeRunner()` bakes an ephemeral fnm multishell shim path
* (e.g. `C:/Users/u/AppData/Local/fnm_multishells/<pid>_<ts>/node.exe`) into
* managed `.js` hook commands. fnm cleans up these per-shell-session directories
* when the shell exits, so the captured path later points at nothing — every
* managed hook fails to spawn until reinstall.
*
* Fix: when `normalizeNodePath` detects a path matching the fnm multishell
* directory pattern (`fnm_multishells/<id>/node(\.exe)?$`), it probes a stable
* alias path derived from `FNM_DIR` or `APPDATA` env vars (with injected
* `existsSync` for testability) and returns the first that exists. Falls back to
* the raw execPath if no stable alias is found.
*
* All assertions go against exported function return values — no source-grep.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js'));
const { normalizeNodePath, resolveNodeRunner } = INSTALL;
// ─── Synthetic paths used across tests ───────────────────────────────────────
const EPHEMERAL_FNM_WIN = 'C:/Users/u/AppData/Local/fnm_multishells/15600_1781041703752/node.exe';
const EPHEMERAL_FNM_WIN_BACKSLASH = 'C:\\Users\\u\\AppData\\Local\\fnm_multishells\\15600_1781041703752\\node.exe';
const FNM_DIR_WIN = 'C:/Users/u/AppData/Roaming/fnm';
const APPDATA_WIN = 'C:/Users/u/AppData/Roaming';
const STABLE_FNM_DIR_NODE = `${FNM_DIR_WIN}/aliases/default/node.exe`;
const STABLE_APPDATA_NODE = `${APPDATA_WIN}/fnm/aliases/default/node.exe`;
// ─── normalizeNodePath — fnm multishell ephemeral path → stable alias ────────
describe('Bug #977: normalizeNodePath — fnm multishell path with FNM_DIR → stable alias', () => {
test('forward-slash Windows ephemeral path + FNM_DIR set + alias exists → stable FNM_DIR alias', () => {
const result = normalizeNodePath(EPHEMERAL_FNM_WIN, {
env: { FNM_DIR: FNM_DIR_WIN },
existsSync: p => p === STABLE_FNM_DIR_NODE,
});
assert.equal(
result,
STABLE_FNM_DIR_NODE,
`expected stable FNM_DIR alias, got: ${result}`,
);
});
test('backslash Windows ephemeral path + FNM_DIR set + alias exists → stable FNM_DIR alias', () => {
const result = normalizeNodePath(EPHEMERAL_FNM_WIN_BACKSLASH, {
env: { FNM_DIR: FNM_DIR_WIN },
existsSync: p => p === STABLE_FNM_DIR_NODE,
});
assert.equal(
result,
STABLE_FNM_DIR_NODE,
`expected stable FNM_DIR alias, got: ${result}`,
);
});
test('FNM_DIR alias does not exist → falls through to APPDATA alias → returns APPDATA alias', () => {
const result = normalizeNodePath(EPHEMERAL_FNM_WIN, {
env: { FNM_DIR: FNM_DIR_WIN, APPDATA: APPDATA_WIN },
existsSync: p => p === STABLE_APPDATA_NODE, // FNM_DIR alias absent, APPDATA alias present
});
assert.equal(
result,
STABLE_APPDATA_NODE,
`expected stable APPDATA alias, got: ${result}`,
);
});
test('no alias exists → returns raw execPath unchanged (graceful fallback)', () => {
const result = normalizeNodePath(EPHEMERAL_FNM_WIN, {
env: { FNM_DIR: FNM_DIR_WIN, APPDATA: APPDATA_WIN },
existsSync: () => false, // nothing exists
});
assert.equal(
result,
EPHEMERAL_FNM_WIN,
`expected raw execPath fallback, got: ${result}`,
);
});
test('no FNM_DIR or APPDATA in env → returns raw execPath unchanged', () => {
const result = normalizeNodePath(EPHEMERAL_FNM_WIN, {
env: {},
existsSync: () => false,
});
assert.equal(
result,
EPHEMERAL_FNM_WIN,
`expected raw execPath fallback, got: ${result}`,
);
});
});
// ─── normalizeNodePath — non-fnm paths are NOT affected by the new branch ────
describe('Bug #977: normalizeNodePath — non-fnm paths are unaffected (no regression to existing behavior)', () => {
test('NVM path is unchanged', () => {
const nvm = '/Users/dev/.nvm/versions/node/v20.11.0/bin/node';
assert.equal(normalizeNodePath(nvm), nvm);
});
test('Intel Homebrew Cellar path still maps to stable symlink', () => {
assert.equal(
normalizeNodePath('/usr/local/Cellar/node/25.8.1/bin/node'),
'/usr/local/bin/node',
);
});
test('Apple Silicon Homebrew Cellar path still maps to stable symlink', () => {
assert.equal(
normalizeNodePath('/opt/homebrew/Cellar/node/25.8.1/bin/node'),
'/opt/homebrew/bin/node',
);
});
test('regular Windows nodejs path is unchanged', () => {
const win = 'C:\\Program Files\\nodejs\\node.exe';
assert.equal(normalizeNodePath(win), win);
});
test('empty string is returned as-is', () => {
assert.equal(normalizeNodePath(''), '');
});
test('null is returned as-is', () => {
assert.equal(normalizeNodePath(null), null);
});
});
// ─── normalizeNodePath — already-stable fnm alias path is not re-processed ───
describe('Bug #977: normalizeNodePath — already-stable fnm alias path passes through unchanged', () => {
test('stable FNM_DIR alias path is returned as-is', () => {
assert.equal(
normalizeNodePath(STABLE_FNM_DIR_NODE),
STABLE_FNM_DIR_NODE,
);
});
});
// ─── normalizeNodePath — false-positive guard: non-numeric id must NOT remap ──
describe('Bug #977: normalizeNodePath — non-ephemeral fnm_multishells path is not remapped', () => {
test('non-numeric id segment (e.g. custom-dir) returns raw execPath unchanged even when alias exists', () => {
const nonEphemeral = 'C:/Users/u/AppData/Local/fnm_multishells/custom-dir/node.exe';
const stableAlias = 'C:/Users/u/AppData/Roaming/fnm/aliases/default/node.exe';
const result = normalizeNodePath(nonEphemeral, {
env: { FNM_DIR: 'C:/Users/u/AppData/Roaming/fnm' },
// existsSync returns true for the alias to prove the regex — not the existsSync — is the guard
existsSync: p => p === stableAlias,
});
assert.equal(
result,
nonEphemeral,
`expected raw execPath (non-ephemeral id must not be remapped), got: ${result}`,
);
});
});
// ─── resolveNodeRunner — opts pass-through ────────────────────────────────────
describe('Bug #977: resolveNodeRunner — passes opts through to normalizeNodePath', () => {
test('fnm multishell execPath is resolved to stable alias via injected opts', () => {
const orig = process.execPath;
try {
Object.defineProperty(process, 'execPath', {
value: EPHEMERAL_FNM_WIN,
configurable: true,
});
const runner = resolveNodeRunner({
env: { FNM_DIR: FNM_DIR_WIN },
existsSync: p => p === STABLE_FNM_DIR_NODE,
});
assert.equal(
runner,
JSON.stringify(STABLE_FNM_DIR_NODE),
`expected stable FNM_DIR alias quoted, got: ${runner}`,
);
} finally {
Object.defineProperty(process, 'execPath', { value: orig, configurable: true });
}
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2979-hook-absolute-node.test.cjs — consolidation epic #1969 (B1 #1970)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-2979-hook-absolute-node (consolidation epic #1969 B1 #1970)", () => {
'use strict';
process.env.GSD_TEST_MODE = '1';
/**
* Bug #2979: Managed JS hooks fail in GUI/minimal-PATH runtimes because
* the installer emits bare `node`.
*
* Reporter evidence: in a stripped PATH like /usr/bin:/bin:/usr/sbin:/sbin
* (the default for Finder-launched/Antigravity-spawned processes on macOS),
* `node` is not resolvable. Hook commands like
* `node "<HOME>/.gemini/hooks/gsd-check-update.js"`
* fail with `/bin/sh: node: command not found` (exit 127).
*
* Fix: emit the absolute node path (`process.execPath`, the binary
* running the installer itself) as the runner. Forward-slash-normalized
* and double-quoted so it works on POSIX and Windows.
*
* This test exercises the public buildHookCommand surface plus the
* resolveNodeRunner helper, asserting on structured records:
* - the runner field is an absolute path (not bare 'node')
* - it ends with /node or \\node (or .exe on Windows simulation)
* - .sh hooks still use bare 'bash' (PATH-resolved; portable across
* distros that don't ship /bin/bash, like NixOS)
*
* No source-grep on install.js content — assertions go against the
* value returned by the exported function and the parsed structure of
* the emitted hook command (split into runner + args).
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js'));
const { buildHookCommand, resolveNodeRunner } = INSTALL;
/**
* Parse a hook command string into { runner, hookPath } structured
* record. The shape is `<runner> "<hookPath>"` where <runner> may itself
* be a quoted absolute path (containing spaces), so we split on the
* trailing quoted-path token rather than the first space.
*/
function parseHookCommand(cmd) {
// Trailing token: a double-quoted string ending the command.
const m = cmd.match(/^(.+?)\s+"([^"]+)"\s*$/);
if (!m) {
return { runner: null, hookPath: null, raw: cmd };
}
return { runner: m[1], hookPath: m[2], raw: cmd };
}
describe('Bug #2979: resolveNodeRunner returns absolute, quoted, forward-slash node path', () => {
test('exported as a function', () => {
assert.equal(typeof resolveNodeRunner, 'function');
});
test('returns a double-quoted absolute path', () => {
const runner = resolveNodeRunner();
assert.ok(runner.startsWith('"'), `expected leading double-quote, got: ${runner}`);
assert.ok(runner.endsWith('"'), `expected trailing double-quote, got: ${runner}`);
const inner = runner.slice(1, -1);
assert.ok(path.isAbsolute(inner.replace(/\//g, path.sep)), `expected absolute path, got: ${inner}`);
});
test('uses forward slashes (Windows-safe, matches buildHookCommand convention)', () => {
const runner = resolveNodeRunner();
assert.ok(!runner.includes('\\'), `expected forward slashes, got: ${runner}`);
});
test('points at a node binary (basename starts with "node")', () => {
const runner = resolveNodeRunner();
const inner = runner.slice(1, -1);
const base = path.posix.basename(inner);
assert.ok(/^node(\.exe)?$/i.test(base), `expected basename node or node.exe, got: ${base}`);
});
});
describe('Bug #2979: buildHookCommand for .js hooks emits absolute node runner', () => {
test('global install: .js hook uses absolute node path, not bare "node"', () => {
const cmd = buildHookCommand('/tmp/.claude', 'gsd-check-update.js');
const parsed = parseHookCommand(cmd);
assert.notEqual(parsed.runner, null, `failed to parse: ${cmd}`);
assert.notEqual(parsed.runner, 'node', `must not emit bare node (#2979): ${cmd}`);
// The runner should be a quoted absolute path.
assert.ok(parsed.runner.startsWith('"') && parsed.runner.endsWith('"'),
`runner must be quoted absolute path, got: ${parsed.runner}`);
});
test('global install: .js hook command parses with hookPath at expected location', () => {
const cmd = buildHookCommand('/tmp/.gemini', 'gsd-statusline.js');
const parsed = parseHookCommand(cmd);
assert.equal(parsed.hookPath, '/tmp/.gemini/hooks/gsd-statusline.js');
});
test('portableHooks global install: .js hook still uses absolute node (only the path is $HOME-relative)', () => {
const home = require('node:os').homedir().replace(/\\/g, '/');
const configDir = home + '/.gemini';
const cmd = buildHookCommand(configDir, 'gsd-check-update.js', { portableHooks: true });
const parsed = parseHookCommand(cmd);
assert.notEqual(parsed.runner, 'node', `portableHooks must also use absolute node (#2979): ${cmd}`);
assert.equal(parsed.hookPath, '$HOME/.gemini/hooks/gsd-check-update.js');
});
});
describe('Bug #3362 / #3413: Windows hook commands are runtime-aware', () => {
test('Gemini global install: .js hook command starts with & so quoted runners execute in PowerShell', () => {
const cmd = buildHookCommand('C:/Program Files/Gemini/.gemini', 'gsd-check-update.js', {
platform: 'win32',
runtime: 'gemini',
});
assert.ok(cmd.startsWith('& '), `Gemini PowerShell commands need call operator, got: ${cmd}`);
assert.ok(cmd.includes('"C:/Program Files/Gemini/.gemini/hooks/gsd-check-update.js"'));
});
test('Gemini portable install: .js hook command also uses & on Windows PowerShell', () => {
const home = require('node:os').homedir().replace(/\\/g, '/');
const cmd = buildHookCommand(`${home}/.gemini`, 'gsd-check-update.js', {
portableHooks: true,
platform: 'win32',
runtime: 'gemini',
});
assert.ok(cmd.startsWith('& '), `Gemini PowerShell commands need call operator, got: ${cmd}`);
assert.equal(parseHookCommand(cmd.slice(2)).hookPath, '$HOME/.gemini/hooks/gsd-check-update.js');
});
test('Claude global install: .js hook command stays shell-neutral on Windows Git Bash', () => {
const cmd = buildHookCommand('C:/Users/me/.claude', 'gsd-check-update.js', {
platform: 'win32',
runtime: 'claude',
});
assert.ok(!cmd.startsWith('& '), `Claude hook command must not use PowerShell call operator: ${cmd}`);
assert.equal(parseHookCommand(cmd).hookPath, 'C:/Users/me/.claude/hooks/gsd-check-update.js');
});
test('Windows .js hook with no runtime stays shell-neutral', () => {
const cmd = buildHookCommand('C:/Users/me/.claude', 'gsd-check-update.js', {
platform: 'win32',
});
assert.ok(!cmd.startsWith('& '), `Missing runtime must not imply PowerShell syntax: ${cmd}`);
assert.equal(parseHookCommand(cmd).hookPath, 'C:/Users/me/.claude/hooks/gsd-check-update.js');
});
test('Gemini runtime on non-Windows platform does not get PowerShell syntax', () => {
const cmd = buildHookCommand('/home/me/.claude', 'gsd-check-update.js', {
platform: 'linux',
runtime: 'gemini',
});
assert.ok(!cmd.startsWith('& '), `Non-Windows Gemini hook must stay shell-neutral: ${cmd}`);
assert.equal(parseHookCommand(cmd).hookPath, '/home/me/.claude/hooks/gsd-check-update.js');
});
});
describe('Bug #2979: buildHookCommand for .sh hooks still uses bare "bash" (POSIX std PATH always has /bin)', () => {
test('.sh hook runner is exactly "bash" — bash is in /usr/bin:/bin and resolves under minimal PATH', () => {
const cmd = buildHookCommand('/tmp/.claude', 'gsd-session-state.sh', { platform: 'linux' });
const parsed = parseHookCommand(cmd);
assert.equal(parsed.runner, 'bash');
});
test('Windows .sh hook uses resolved Git Bash path instead of bare bash (#3393)', () => {
const cmd = buildHookCommand('C:/Users/me/.codex', 'gsd-validate-commit.sh', {
platform: 'win32',
env: { ProgramFiles: 'C:\\Program Files' },
existsSync: (candidate) => candidate === 'C:\\Program Files\\Git\\bin\\bash.exe',
});
assert.equal(
cmd,
'"C:/Program Files/Git/bin/bash.exe" "C:/Users/me/.codex/hooks/gsd-validate-commit.sh"',
);
});
test('Windows .sh hook returns null when no supported Bash runner is found (#3393)', () => {
const cmd = buildHookCommand('C:/Users/me/.codex', 'gsd-phase-boundary.sh', {
platform: 'win32',
env: {},
existsSync: () => false,
});
assert.equal(cmd, null);
});
test('Windows Claude .sh hook omits explicit bash.exe wrapper (#166)', () => {
const cmd = buildHookCommand('C:/Users/me/.claude', 'gsd-session-state.sh', {
platform: 'win32',
runtime: 'claude',
env: { ProgramFiles: 'C:\\Program Files' },
existsSync: (candidate) => candidate === 'C:\\Program Files\\Git\\bin\\bash.exe',
});
assert.equal(
cmd,
'"C:/Users/me/.claude/hooks/gsd-session-state.sh"',
'Claude win32 .sh hooks should serialize as script-only commands'
);
});
});
// ─── #3002 CR follow-up: legacy-bare-node migration ─────────────────────────
const { rewriteLegacyManagedNodeHookCommands } = INSTALL;
describe('Bug #2979 (#3002 CR): rewriteLegacyManagedNodeHookCommands rewrites bare-node managed hooks on reinstall', () => {
test('exported as a function', () => {
assert.equal(typeof rewriteLegacyManagedNodeHookCommands, 'function');
});
test('rewrites a managed hook entry that uses bare `node ` to the absolute runner', () => {
const settings = {
hooks: {
SessionStart: [{
hooks: [
{ type: 'command', command: 'node "/Users/x/.gemini/hooks/gsd-check-update.js"' },
],
}],
},
};
const runner = '"/usr/local/bin/node"';
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner);
assert.equal(changed, true);
assert.equal(
settings.hooks.SessionStart[0].hooks[0].command,
'"/usr/local/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"',
);
});
test('does NOT touch entries that already use a quoted absolute runner', () => {
const settings = {
hooks: {
SessionStart: [{
hooks: [{ type: 'command', command: '"/usr/local/bin/node" "/x/hooks/gsd-statusline.js"' }],
}],
},
};
const runner = '"/usr/local/bin/node"';
const before = settings.hooks.SessionStart[0].hooks[0].command;
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner);
assert.equal(changed, false);
assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before);
});
test('Gemini on Windows adds PowerShell call operator to existing quoted managed hooks', () => {
const settings = {
hooks: {
SessionStart: [{
hooks: [{ type: 'command', command: '"/usr/local/bin/node" "C:/Program Files/Gemini/.gemini/hooks/gsd-check-update.js"' }],
}],
},
};
const runner = '"/usr/local/bin/node"';
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32', runtime: 'gemini' });
assert.equal(changed, true);
assert.equal(
settings.hooks.SessionStart[0].hooks[0].command,
'& "/usr/local/bin/node" "C:/Program Files/Gemini/.gemini/hooks/gsd-check-update.js"',
);
});
test('Gemini on Windows does NOT double-prefix managed hooks that already use the PowerShell call operator', () => {
const settings = {
hooks: {
SessionStart: [{
hooks: [{ type: 'command', command: '& "/usr/local/bin/node" "C:/Program Files/Gemini/.gemini/hooks/gsd-check-update.js"' }],
}],
},
};
const runner = '"/usr/local/bin/node"';
const before = settings.hooks.SessionStart[0].hooks[0].command;
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32', runtime: 'gemini' });
assert.equal(changed, false);
assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before);
});
test('Gemini on Windows rewrites PowerShell bare-node managed hooks to absolute runner without dropping &', () => {
const settings = {
hooks: {
SessionStart: [{
hooks: [{ type: 'command', command: '& node "C:/Users/me/.gemini/hooks/gsd-check-update.js"' }],
}],
},
};
const runner = '"/usr/local/bin/node"';
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32', runtime: 'gemini' });
assert.equal(changed, true);
assert.equal(
settings.hooks.SessionStart[0].hooks[0].command,
'& "/usr/local/bin/node" "C:/Users/me/.gemini/hooks/gsd-check-update.js"',
);
});
test('Claude on Windows strips stale PowerShell prefix from managed hooks on reinstall (#3413)', () => {
const settings = {
hooks: {
SessionStart: [{
hooks: [{ type: 'command', command: '& "/usr/local/bin/node" "C:/Users/me/.claude/hooks/gsd-check-update.js"' }],
}],
},
};
const runner = '"/usr/local/bin/node"';
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32', runtime: 'claude' });
assert.equal(changed, true);
assert.equal(
settings.hooks.SessionStart[0].hooks[0].command,
'"/usr/local/bin/node" "C:/Users/me/.claude/hooks/gsd-check-update.js"',
);
});
test('does NOT touch user-authored bare-node hooks (filename not in managed allowlist)', () => {
const settings = {
hooks: {
SessionStart: [{
hooks: [{ type: 'command', command: 'node /home/me/my-custom-hook.js' }],
}],
},
};
const runner = '"/usr/local/bin/node"';
const before = settings.hooks.SessionStart[0].hooks[0].command;
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner);
assert.equal(changed, false);
assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before);
});
test('does NOT touch .sh hooks (they correctly use bare bash)', () => {
const settings = {
hooks: {
SessionStart: [{
hooks: [{ type: 'command', command: 'bash "/x/hooks/gsd-session-state.sh"' }],
}],
},
};
const runner = '"/usr/local/bin/node"';
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner);
assert.equal(changed, false);
});
test('is a no-op when absoluteRunner is null (resolveNodeRunner failed)', () => {
const settings = {
hooks: {
SessionStart: [{
hooks: [{ type: 'command', command: 'node "/x/hooks/gsd-check-update.js"' }],
}],
},
};
const before = settings.hooks.SessionStart[0].hooks[0].command;
const changed = rewriteLegacyManagedNodeHookCommands(settings, null);
assert.equal(changed, false);
assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before);
});
// #3002 CR: substring containment was a false-positive vector.
// User-authored hooks whose path happened to CONTAIN a managed filename
// as a substring would get unconditionally rewritten with the GSD runner.
// The fix matches by basename equality.
test('does NOT rewrite a user hook whose path contains a managed filename as a substring', () => {
const settings = {
hooks: {
SessionStart: [{
hooks: [{
type: 'command',
// Path contains gsd-check-update.js as substring of a longer
// filename, but is NOT actually that file.
command: 'node /home/me/scripts/wraps-gsd-check-update.js-helper.js',
}],
}],
},
};
const runner = '"/usr/local/bin/node"';
const before = settings.hooks.SessionStart[0].hooks[0].command;
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner);
assert.equal(changed, false, 'must not rewrite user hooks with managed-filename-as-substring paths');
assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before);
});
test('rewrites a managed entry whose path is quoted with single quotes', () => {
const settings = {
hooks: {
SessionStart: [{
hooks: [{ type: 'command', command: "node '/x/hooks/gsd-statusline.js'" }],
}],
},
};
const runner = '"/usr/local/bin/node"';
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'linux' });
assert.equal(changed, true);
assert.equal(
settings.hooks.SessionStart[0].hooks[0].command,
`"/usr/local/bin/node" '/x/hooks/gsd-statusline.js'`,
);
});
test('rewrites a managed entry with no path quoting (bareword)', () => {
const settings = {
hooks: {
SessionStart: [{
hooks: [{ type: 'command', command: 'node /x/hooks/gsd-context-monitor.js' }],
}],
},
};
const runner = '"/usr/local/bin/node"';
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'linux' });
assert.equal(changed, true);
assert.equal(
settings.hooks.SessionStart[0].hooks[0].command,
'"/usr/local/bin/node" /x/hooks/gsd-context-monitor.js',
);
});
test('handles Windows-style backslash path separators when extracting basename', () => {
const settings = {
hooks: {
SessionStart: [{
hooks: [{ type: 'command', command: 'node "C:\\\\Users\\\\me\\\\.claude\\\\hooks\\\\gsd-prompt-guard.js"' }],
}],
},
};
const runner = '"/usr/local/bin/node"';
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner);
assert.equal(changed, true);
});
test('Gemini on Windows normalizes single-quoted managed hook paths to double-quoted forward-slash paths (#3392)', () => {
const settings = {
hooks: {
PreToolUse: [{
hooks: [{
type: 'command',
command: "node 'C:\\Users\\me\\.gemini\\hooks\\gsd-prompt-guard.js'",
}],
}],
},
};
const runner = '"C:/nvm4w/nodejs/node.exe"';
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32', runtime: 'gemini' });
assert.equal(changed, true);
assert.equal(
settings.hooks.PreToolUse[0].hooks[0].command,
'& "C:/nvm4w/nodejs/node.exe" "C:/Users/me/.gemini/hooks/gsd-prompt-guard.js"',
);
});
});
describe('Bug #2979 (#3002 CR): resolveNodeRunner returns null when execPath unavailable', () => {
test('returns null instead of bare "node" when process.execPath is empty', () => {
const orig = process.execPath;
try {
Object.defineProperty(process, 'execPath', { value: '', configurable: true });
const r = resolveNodeRunner();
assert.equal(r, null, 'expected null, not bare "node"');
} finally {
Object.defineProperty(process, 'execPath', { value: orig, configurable: true });
}
});
test('buildHookCommand returns null when execPath is unavailable (caller skips registration)', () => {
const orig = process.execPath;
try {
Object.defineProperty(process, 'execPath', { value: '', configurable: true });
const cmd = buildHookCommand('/tmp/.claude', 'gsd-statusline.js');
assert.equal(cmd, null);
} finally {
Object.defineProperty(process, 'execPath', { value: orig, configurable: true });
}
});
});
// ─── #3002 CR follow-up #2: null-command guards in settings.json ──────────
const { validateHookFields } = INSTALL;
describe('Bug #2979 (#3002 CR follow-up): no command:null hook entries survive serialization', () => {
// CR feedback: assert structurally on the resulting settings object, not by
// grepping bin/install.js source. The push-site guards (each `if` clause's
// `&& <command>` token) skip null-command pushes at the source. As a
// backstop, install.js now runs validateHookFields(settings) right before
// writeSettings; this test exercises that backstop directly.
//
// Construct a settings object that contains exactly the kind of null-command
// entries that the registration code would have written if my push-site
// guards regressed. Run validateHookFields on it. Assert the null entries
// are gone and the well-formed entries survive.
function nullCommandEntry(matcher) {
const entry = { hooks: [{ type: 'command', command: null }] };
if (matcher) entry.matcher = matcher;
return entry;
}
function realCommandEntry(matcher, command) {
const entry = { hooks: [{ type: 'command', command }] };
if (matcher) entry.matcher = matcher;
return entry;
}
const MANAGED_JS_HOOKS = [
{ event: 'SessionStart', matcher: undefined, label: 'gsd-check-update.js' },
{ event: 'PostToolUse', matcher: 'Bash|Edit|Write|MultiEdit|Agent|Task', label: 'gsd-context-monitor.js' },
{ event: 'PreToolUse', matcher: 'Write|Edit', label: 'gsd-prompt-guard.js' },
{ event: 'PreToolUse', matcher: 'Write|Edit', label: 'gsd-read-guard.js' },
{ event: 'PostToolUse', matcher: 'Read', label: 'gsd-read-injection-scanner.js' },
{ event: 'PreToolUse', matcher: 'Bash|Edit|Write|MultiEdit', label: 'gsd-workflow-guard.js' },
];
for (const { event, matcher, label } of MANAGED_JS_HOOKS) {
test(`validateHookFields strips a null-command ${label} entry from settings.hooks.${event}`, () => {
const settings = {
hooks: {
[event]: [
nullCommandEntry(matcher),
realCommandEntry(matcher, '"/usr/local/bin/node" "/x/hooks/other.js"'),
],
},
};
const out = validateHookFields(settings);
const survivors = out.hooks[event] || [];
// The well-formed entry must remain.
assert.equal(survivors.length, 1, `expected the real-command entry to survive`);
// No survivor entry contains a hook with command === null.
for (const e of survivors) {
for (const h of e.hooks || []) {
assert.notEqual(h.command, null, 'no surviving hook should have command:null');
}
}
});
}
test('validateHookFields drops the entry entirely when all its hooks have null commands', () => {
const settings = {
hooks: {
SessionStart: [nullCommandEntry()],
},
};
const out = validateHookFields(settings);
// Empty event arrays should be cleaned up (the entire SessionStart key
// gets removed when nothing valid remains).
assert.ok(
!out.hooks.SessionStart || out.hooks.SessionStart.length === 0,
'expected SessionStart to be empty/removed after the only entry was dropped',
);
});
test('validateHookFields preserves agent-type hooks while stripping command:null sibling hooks', () => {
const settings = {
hooks: {
SessionStart: [{
hooks: [
{ type: 'command', command: null },
{ type: 'agent', prompt: 'analyze the session' },
{ type: 'command', command: '"/usr/local/bin/node" "/x/hooks/y.js"' },
],
}],
},
};
const out = validateHookFields(settings);
const survivors = out.hooks.SessionStart[0].hooks;
assert.equal(survivors.length, 2, 'expected 2 of 3 hooks to survive (the null-command one is stripped)');
assert.equal(survivors.find(h => h.command === null), undefined, 'no surviving hook should have command:null');
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-442-config-dir-equals-in-path.test.cjs — consolidation epic #1969 (B1 #1970)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-442-config-dir-equals-in-path (consolidation epic #1969 B1 #1970)", () => {
'use strict';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
// parseConfigDirArg is not exported directly from bin/install.js (it closes
// over the module-level `args` array). We expose a pure seam here:
// parseConfigDirFromArgs(args) that mirrors the function's logic so we can
// test the equals-form parsing without spawning a child process.
//
// The implementation under test is inlined below (RED: before the fix it will
// reproduce the truncation bug). Once the fix lands, we swap in the real
// implementation via require.
/**
* Pure seam that replicates the equals-form parse logic from bin/install.js.
* We import it via a thin wrapper so that the function can be tested without
* executing the entire install script.
*
* During RED the bug is: `split('=')[1]` drops everything after the second `=`.
*/
const { parseConfigDirFromArgs } = require('../bin/install.js');
describe('bug-442: --config-dir= equals-form path parsing', () => {
// ── Happy-path: single = in path ─────────────────────────────────────────
test('--config-dir=<path> with one = in value returns full value', () => {
const result = parseConfigDirFromArgs(['--config-dir=/tmp/gsd=a']);
assert.equal(result, '/tmp/gsd=a');
});
// ── Happy-path: multiple = in path ───────────────────────────────────────
test('--config-dir=<path> with multiple = in value returns full value', () => {
const result = parseConfigDirFromArgs(['--config-dir=/tmp/a=b=c']);
assert.equal(result, '/tmp/a=b=c');
});
// ── Short form -c= ────────────────────────────────────────────────────────
test('-c=<path> with = in value returns full value', () => {
const result = parseConfigDirFromArgs(['-c=/tmp/gsd=a']);
assert.equal(result, '/tmp/gsd=a');
});
test('-c=<path> with multiple = in value returns full value', () => {
const result = parseConfigDirFromArgs(['-c=/tmp/a=b=c']);
assert.equal(result, '/tmp/a=b=c');
});
// ── Contract: empty value ─────────────────────────────────────────────────
// --config-dir= (no value after the =) → returns empty string ''.
// The caller (parseConfigDirArg) treats '' as missing and errors; the seam
// itself should faithfully return '' rather than null/undefined so the
// caller can make the error decision.
test('--config-dir= with no value returns empty string', () => {
const result = parseConfigDirFromArgs(['--config-dir=']);
assert.equal(result, '');
});
test('-c= with no value returns empty string', () => {
const result = parseConfigDirFromArgs(['-c=']);
assert.equal(result, '');
});
// ── Space-separated form is unaffected (regression guard) ─────────────────
test('--config-dir <path> space-separated still returns the path', () => {
const result = parseConfigDirFromArgs(['--config-dir', '/tmp/gsd=a']);
assert.equal(result, '/tmp/gsd=a');
});
test('-c <path> space-separated still returns the path', () => {
const result = parseConfigDirFromArgs(['-c', '/tmp/gsd=a']);
assert.equal(result, '/tmp/gsd=a');
});
// ── No config-dir flag → null ─────────────────────────────────────────────
test('returns null when no --config-dir flag is present', () => {
const result = parseConfigDirFromArgs(['--global', '--claude']);
assert.equal(result, null);
});
// ── Negative matrix (CLI edge cases) ─────────────────────────────────────
// Flag-looking value after space form: next arg starts with - → null (no
// valid value; the real function would process.exit but the seam returns null
// so tests stay in-process).
test('space form with next arg being a flag returns null (flag-looking value)', () => {
const result = parseConfigDirFromArgs(['--config-dir', '--other-flag']);
assert.equal(result, null);
});
// Equals form where value is a path with no = (plain path, no regression)
test('--config-dir=<plain-path> without any = in path still works', () => {
const result = parseConfigDirFromArgs(['--config-dir=/tmp/plain']);
assert.equal(result, '/tmp/plain');
});
// Flag appears after other args (positional ordering should not matter)
test('--config-dir= flag after other args is parsed correctly', () => {
const result = parseConfigDirFromArgs(['--global', '--config-dir=/tmp/a=b', '--claude']);
assert.equal(result, '/tmp/a=b');
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/enh-1559-installer-export-audit.test.cjs — consolidation epic #1969 (B1 #1970)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:enh-1559-installer-export-audit (consolidation epic #1969 B1 #1970)", () => {
'use strict';
const { describe, test, before } = require('node:test');
const assert = require('node:assert/strict');
let installer;
let conversion;
before(() => {
process.env['GSD_TEST_MODE'] = '1';
installer = require('../bin/install.js');
conversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs');
});
describe('bin/install.js compatibility export audit (#1559)', () => {
test('retains audited compatibility relays for shared rewrite helpers', () => {
assert.strictEqual(installer.processAttribution, conversion.processAttribution);
assert.strictEqual(
installer.applyRuntimeContentRewritesForCommandsInPlace,
conversion.applyRuntimeContentRewritesForCommandsInPlace,
);
});
test('does not leak unaudited conversion-module helpers through the installer', () => {
for (const name of [
'yamlQuote',
'toSingleLine',
'extractFrontmatterAndBody',
'extractFrontmatterField',
'convertClaudeToCursorMarkdown',
'convertClaudeToCodexMarkdown',
'transformContentToHyphen',
'claudeToGeminiTools',
'convertGeminiToolName',
'rewriteStagedSkillBodies',
'rewriteStagedCommandBodies',
'_computePathPrefix',
'_stampNonClaudeRuntimeDefaults',
'NON_CLAUDE_RUNTIMES',
]) {
assert.ok(name in conversion, `${name} remains available from the conversion module`);
assert.equal(installer[name], undefined, `${name} is not an installer compatibility export`);
}
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-1908-uninstall-manifest.test.cjs — consolidation epic #1969 (B1 #1970)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-1908-uninstall-manifest (consolidation epic #1969 B1 #1970)", () => {
/**
* Regression test for bug #1908
*
* `--uninstall` did not remove `gsd-file-manifest.json` from the target
* directory, leaving a stale metadata file after uninstall.
*
* Fix: `uninstall()` must call
* fs.rmSync(path.join(targetDir, MANIFEST_NAME), { force: true })
* after cleaning up the rest of the GSD artefacts.
*/
'use strict';
process.env.GSD_TEST_MODE = '1';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { uninstall } = require('../bin/install.js');
const MANIFEST_NAME = 'gsd-file-manifest.json';
// ─── helpers ──────────────────────────────────────────────────────────────────
function createFakeInstall(prefix = 'gsd-uninstall-test-') {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
// Simulate the minimum directory/file layout produced by the installer:
// gsd-core/ directory, agents/ directory, and the manifest file.
fs.mkdirSync(path.join(dir, 'gsd-core', 'workflows'), { recursive: true });
fs.writeFileSync(path.join(dir, 'gsd-core', 'workflows', 'execute-phase.md'), '# stub');
fs.mkdirSync(path.join(dir, 'agents'), { recursive: true });
fs.writeFileSync(path.join(dir, 'agents', 'gsd-executor.md'), '# stub');
const manifest = {
version: '1.34.0',
timestamp: new Date().toISOString(),
files: {
'gsd-core/workflows/execute-phase.md': 'abc123',
'agents/gsd-executor.md': 'def456',
},
};
fs.writeFileSync(path.join(dir, MANIFEST_NAME), JSON.stringify(manifest, null, 2));
return dir;
}
function cleanup(dir) {
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- local teardown helper predates helpers.cjs; renaming would collide with the imported cleanup
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
}
// ─── tests ────────────────────────────────────────────────────────────────────
describe('uninstall — manifest cleanup (#1908)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createFakeInstall();
});
afterEach(() => {
cleanup(tmpDir);
});
test('gsd-file-manifest.json is removed after global uninstall', () => {
const manifestPath = path.join(tmpDir, MANIFEST_NAME);
// Pre-condition: manifest exists before uninstall
assert.ok(
fs.existsSync(manifestPath),
'Test setup failure: manifest file should exist before uninstall'
);
// Run uninstall against tmpDir (pass it via CLAUDE_CONFIG_DIR so getGlobalDir()
// resolves to our temp directory; pass isGlobal=true)
const savedEnv = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = tmpDir;
try {
uninstall(true, 'claude');
} finally {
if (savedEnv === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = savedEnv;
}
}
assert.ok(
!fs.existsSync(manifestPath),
[
`${MANIFEST_NAME} must be removed by uninstall() but still exists at`,
manifestPath,
].join(' ')
);
});
test('gsd-file-manifest.json is removed after local uninstall', () => {
const manifestPath = path.join(tmpDir, MANIFEST_NAME);
assert.ok(
fs.existsSync(manifestPath),
'Test setup failure: manifest file should exist before uninstall'
);
// For a local install, getGlobalDir is not called — targetDir = cwd + dirName.
// Simulate by creating .claude/ inside tmpDir and placing artefacts there.
const localDir = path.join(tmpDir, '.claude');
fs.mkdirSync(path.join(localDir, 'gsd-core', 'workflows'), { recursive: true });
fs.writeFileSync(path.join(localDir, 'gsd-core', 'workflows', 'execute-phase.md'), '# stub');
const localManifestPath = path.join(localDir, MANIFEST_NAME);
fs.writeFileSync(localManifestPath, JSON.stringify({ version: '1.34.0', files: {} }, null, 2));
const savedCwd = process.cwd();
process.chdir(tmpDir);
try {
uninstall(false, 'claude');
} finally {
process.chdir(savedCwd);
}
assert.ok(
!fs.existsSync(localManifestPath),
[
`${MANIFEST_NAME} must be removed by uninstall() (local) but still exists at`,
localManifestPath,
].join(' ')
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2771-user-profile-manifest.test.cjs — consolidation epic #1969 (B1 #1970)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-2771-user-profile-manifest (consolidation epic #1969 B1 #1970)", () => {
/**
* Regression tests for bug #2771: USER-PROFILE.md tracked in install manifest
*
* USER-PROFILE.md is a user-owned artifact created/refreshed by /gsd-profile-user.
* preserveUserArtifacts() correctly preserves it across reinstalls. But writeManifest()
* also records it under "gsd-core/USER-PROFILE.md" with a SHA-256 of whatever was
* on disk at install time. On the next install, saveLocalPatches() compares the on-disk
* (refreshed) hash to the manifest hash, finds them different, and emits the spurious
* "Found N locally modified GSD file(s) — backed up to gsd-local-patches/" warning.
*
* Invariant: a file is either distribution (manifest-tracked, diff'd against manifest)
* or user artifact (preserved across installs, never diff'd). It cannot be both. The
* shared truth source must be a single USER_OWNED_ARTIFACTS list referenced by both
* preserveUserArtifacts callers and writeManifest.
*
* Closes: #2771
*/
'use strict';
const { describe, test, beforeEach, afterEach, before } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { execFileSync } = require('child_process');
const { createTempDir, cleanup } = require('./helpers.cjs');
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
const MANIFEST_NAME = 'gsd-file-manifest.json';
const PATCHES_DIR_NAME = 'gsd-local-patches';
before(() => {
execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' });
});
function runInstaller(configDir) {
const env = { ...process.env, CLAUDE_CONFIG_DIR: configDir };
delete env.GSD_TEST_MODE;
return execFileSync(
process.execPath,
[INSTALL_SCRIPT, '--claude', '--global', '--yes', '--no-sdk'],
{ encoding: 'utf-8', stdio: 'pipe', env }
);
}
// ─── Test 1: writeManifest must NOT record USER-PROFILE.md ────────────────────
describe('#2771: USER-PROFILE.md is excluded from gsd-file-manifest.json', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempDir('gsd-2771-manifest-'); });
afterEach(() => { cleanup(tmpDir); });
test('writeManifest excludes gsd-core/USER-PROFILE.md even when present on disk', () => {
runInstaller(tmpDir);
// Simulate /gsd-profile-user creating USER-PROFILE.md
const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md');
fs.writeFileSync(profilePath, '# My Profile\n\nFirst version.\n');
// Re-install: writeManifest runs again with USER-PROFILE.md present on disk
runInstaller(tmpDir);
const manifestPath = path.join(tmpDir, MANIFEST_NAME);
assert.ok(fs.existsSync(manifestPath), 'manifest must be written');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
assert.ok(
!Object.prototype.hasOwnProperty.call(manifest.files, 'gsd-core/USER-PROFILE.md'),
'manifest.files must NOT contain gsd-core/USER-PROFILE.md — it is a user artifact, not distribution'
);
});
});
// ─── Test 2: preserveUserArtifacts still preserves USER-PROFILE.md ────────────
describe('#2771: USER-PROFILE.md is still preserved across reinstall', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempDir('gsd-2771-preserve-'); });
afterEach(() => { cleanup(tmpDir); });
test('USER-PROFILE.md content survives reinstall (preservation regression guard)', () => {
runInstaller(tmpDir);
const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md');
const content = '# Profile\n\nUser content from /gsd-profile-user.\n';
fs.writeFileSync(profilePath, content);
runInstaller(tmpDir);
assert.ok(fs.existsSync(profilePath), 'USER-PROFILE.md must survive reinstall');
assert.strictEqual(fs.readFileSync(profilePath, 'utf8'), content);
});
});
// ─── Test 3: no spurious "local patches" hit for USER-PROFILE.md refresh ──────
describe('#2771: refreshed USER-PROFILE.md does not trigger local-patches warning', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempDir('gsd-2771-patches-'); });
afterEach(() => { cleanup(tmpDir); });
test('saveLocalPatches does not classify a refreshed USER-PROFILE.md as a local patch', () => {
// Initial install
runInstaller(tmpDir);
// /gsd-profile-user creates USER-PROFILE.md (v1)
const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md');
fs.writeFileSync(profilePath, '# Profile v1\n');
// Reinstall — manifest written with v1 contents (under buggy code) or excluded (under fix)
runInstaller(tmpDir);
// /gsd-profile-user --refresh rewrites USER-PROFILE.md (v2 != v1)
fs.writeFileSync(profilePath, '# Profile v2 — refreshed\n');
// Reinstall — saveLocalPatches scans manifest. Under bug, v2 hash != v1 manifest
// hash → patch detected. Under fix, file is not in manifest → no patch.
const output = runInstaller(tmpDir);
const patchesDir = path.join(tmpDir, PATCHES_DIR_NAME);
const patchFile = path.join(patchesDir, 'gsd-core', 'USER-PROFILE.md');
assert.ok(
!fs.existsSync(patchFile),
'USER-PROFILE.md must NOT appear in gsd-local-patches/ — it is a user artifact, not a modified distribution file'
);
const offendingLine = output
.split('\n')
.find((line) => /locally modified GSD file/.test(line) && /USER-PROFILE/.test(line));
assert.strictEqual(
offendingLine,
undefined,
'installer output must not report USER-PROFILE.md as a locally modified GSD file on any single line. Output was:\n' + output
);
});
});
// ─── Test 5: legacy manifest with USER-PROFILE.md entry is normalized ─────────
describe('#2771: legacy manifest entries for USER_OWNED_ARTIFACTS are normalized', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempDir('gsd-2771-legacy-'); });
afterEach(() => { cleanup(tmpDir); });
test('pre-existing manifest entry for USER-PROFILE.md does not trigger patches warning', () => {
// Initial install
runInstaller(tmpDir);
const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md');
fs.writeFileSync(profilePath, '# Profile v1\n');
// Reinstall to populate manifest under the (now-fixed) writer
runInstaller(tmpDir);
// Inject a stale manifest entry simulating a pre-#2771 install: a hash for
// USER-PROFILE.md that does NOT match current content.
const manifestPath = path.join(tmpDir, MANIFEST_NAME);
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
manifest.files = manifest.files || {};
manifest.files['gsd-core/USER-PROFILE.md'] = 'deadbeef'.repeat(8); // stale hash
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
// /gsd-profile-user --refresh rewrites USER-PROFILE.md
fs.writeFileSync(profilePath, '# Profile v2 — refreshed\n');
// Reinstall — saveLocalPatches must strip the legacy entry before scanning
const output = runInstaller(tmpDir);
const patchesDir = path.join(tmpDir, PATCHES_DIR_NAME);
const patchFile = path.join(patchesDir, 'gsd-core', 'USER-PROFILE.md');
assert.ok(
!fs.existsSync(patchFile),
'legacy USER-PROFILE.md manifest entry must be normalized away — not backed up as a patch'
);
const offendingLine = output
.split('\n')
.find((line) => /locally modified GSD file/.test(line) && /USER-PROFILE/.test(line));
assert.strictEqual(
offendingLine,
undefined,
'legacy manifest entry must not surface a USER-PROFILE.md patches warning. Output was:\n' + output
);
});
});
// ─── Test 4: shared constant exists and is used by both call sites ────────────
describe('#2771: USER_OWNED_ARTIFACTS is a single source of truth', () => {
test('install.js exports USER_OWNED_ARTIFACTS containing USER-PROFILE.md', () => {
const origMode = process.env.GSD_TEST_MODE;
process.env.GSD_TEST_MODE = '1';
let mod;
try {
delete require.cache[require.resolve(INSTALL_SCRIPT)];
mod = require(INSTALL_SCRIPT);
} finally {
if (origMode === undefined) delete process.env.GSD_TEST_MODE;
else process.env.GSD_TEST_MODE = origMode;
}
assert.ok(
Array.isArray(mod.USER_OWNED_ARTIFACTS) || mod.USER_OWNED_ARTIFACTS instanceof Set,
'install.js must export USER_OWNED_ARTIFACTS as a single source of truth'
);
const list = Array.isArray(mod.USER_OWNED_ARTIFACTS)
? mod.USER_OWNED_ARTIFACTS
: Array.from(mod.USER_OWNED_ARTIFACTS);
assert.ok(
list.includes('USER-PROFILE.md'),
'USER_OWNED_ARTIFACTS must include USER-PROFILE.md'
);
});
});
describe('manifest path safety', () => {
let tmpDir;
let outside;
beforeEach(() => {
tmpDir = createTempDir('gsd-manifest-path-safety-');
outside = path.join(tmpDir, '..', `outside-managed-file-${path.basename(tmpDir)}.txt`);
});
afterEach(() => {
cleanup(outside);
cleanup(tmpDir);
});
test('saveLocalPatches ignores manifest entries that escape the install root', () => {
const origMode = process.env.GSD_TEST_MODE;
process.env.GSD_TEST_MODE = '1';
let mod;
try {
delete require.cache[require.resolve(INSTALL_SCRIPT)];
mod = require(INSTALL_SCRIPT);
} finally {
if (origMode === undefined) delete process.env.GSD_TEST_MODE;
else process.env.GSD_TEST_MODE = origMode;
}
fs.writeFileSync(outside, 'outside user data\n', 'utf8');
fs.writeFileSync(
path.join(tmpDir, MANIFEST_NAME),
JSON.stringify({
version: 'legacy',
timestamp: '2026-05-11T00:00:00.000Z',
files: {
'../outside-managed-file.txt': 'deadbeef',
},
}, null, 2),
'utf8'
);
const modified = mod.saveLocalPatches(tmpDir);
assert.deepEqual(modified, []);
assert.equal(fs.readFileSync(outside, 'utf8'), 'outside user data\n');
assert.equal(fs.existsSync(path.join(tmpDir, PATCHES_DIR_NAME, '..', path.basename(outside))), false);
});
test('saveLocalPatches does not follow symlinked patch directories outside the install root', () => {
const origMode = process.env.GSD_TEST_MODE;
process.env.GSD_TEST_MODE = '1';
let mod;
try {
delete require.cache[require.resolve(INSTALL_SCRIPT)];
mod = require(INSTALL_SCRIPT);
} finally {
if (origMode === undefined) delete process.env.GSD_TEST_MODE;
else process.env.GSD_TEST_MODE = origMode;
}
const hookPath = path.join(tmpDir, 'hooks', 'managed.js');
fs.mkdirSync(path.dirname(hookPath), { recursive: true });
fs.writeFileSync(hookPath, 'user edited hook\n', 'utf8');
fs.writeFileSync(
path.join(tmpDir, MANIFEST_NAME),
JSON.stringify({
version: 'legacy',
timestamp: '2026-05-11T00:00:00.000Z',
files: {
'hooks/managed.js': crypto.createHash('sha256').update('managed hook\n').digest('hex'),
},
}, null, 2),
'utf8'
);
fs.mkdirSync(outside, { recursive: true });
try {
fs.symlinkSync(outside, path.join(tmpDir, PATCHES_DIR_NAME), 'dir');
} catch {
return;
}
const modified = mod.saveLocalPatches(tmpDir);
assert.deepEqual(modified, []);
assert.equal(fs.existsSync(path.join(outside, 'hooks', 'managed.js')), false);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3571-configuration-manifest-install-path.test.cjs — consolidation epic #1969 (B1 #1970)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3571-configuration-manifest-install-path (consolidation epic #1969 B1 #1970)", () => {
/**
* Regression test for #3571: configuration.cjs used the source
* checkout sdk/shared path only, which breaks installed gsd-tools.cjs because
* runtime installs copy gsd-core/ but not sdk/.
*/
'use strict';
process.env.GSD_TEST_MODE = '1';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const REPO_ROOT = path.join(__dirname, '..');
const CONFIGURATION_CJS = path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'configuration.cjs');
const SHARED_DIR = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared');
const { install } = require('../bin/install.js');
const { createTempDir, cleanup } = require('./helpers.cjs');
const makeTmpDir = () => createTempDir('gsd-3571-');
function silenceConsole(fn) {
const original = {
log: console.log,
warn: console.warn,
error: console.error,
};
console.log = () => {};
console.warn = () => {};
console.error = () => {};
try {
return fn();
} finally {
console.log = original.log;
console.warn = original.warn;
console.error = original.error;
}
}
describe('bug #3571: configuration generated manifests resolve in install layout', () => {
let tmpRoot;
let savedHome;
let savedUserProfile;
let savedExplicitConfigDir;
beforeEach(() => {
tmpRoot = makeTmpDir();
savedHome = process.env.HOME;
// On Windows, os.homedir() reads USERPROFILE; install() resolves via it.
savedUserProfile = process.env.USERPROFILE;
savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR;
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
});
afterEach(() => {
process.env.HOME = savedHome;
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = savedUserProfile;
if (savedExplicitConfigDir === undefined) {
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
} else {
process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir;
}
cleanup(tmpRoot);
});
test('co-located bin/shared manifests let configuration.cjs load without sdk/shared', () => {
const gsdBinDir = path.join(tmpRoot, '.codex', 'gsd-core', 'bin');
const gsdLibDir = path.join(gsdBinDir, 'lib');
const gsdSharedDir = path.join(gsdBinDir, 'shared');
fs.mkdirSync(gsdLibDir, { recursive: true });
fs.mkdirSync(gsdSharedDir, { recursive: true });
const installedCjs = path.join(gsdLibDir, 'configuration.cjs');
fs.copyFileSync(CONFIGURATION_CJS, installedCjs);
fs.copyFileSync(
path.join(SHARED_DIR, 'config-defaults.manifest.json'),
path.join(gsdSharedDir, 'config-defaults.manifest.json')
);
fs.copyFileSync(
path.join(SHARED_DIR, 'config-schema.manifest.json'),
path.join(gsdSharedDir, 'config-schema.manifest.json')
);
delete require.cache[installedCjs];
let mod;
assert.doesNotThrow(() => {
mod = require(installedCjs);
}, 'installed configuration.cjs must not require ~/.codex/sdk/shared');
assert.ok(mod.VALID_CONFIG_KEYS.has('workflow.plan_review_convergence'));
});
test('post-install: install() copies configuration manifests to co-located bin/shared', () => {
process.env.HOME = tmpRoot;
process.env.USERPROFILE = tmpRoot;
silenceConsole(() => {
install(true, 'codex');
});
const sharedDir = path.join(tmpRoot, '.codex', 'gsd-core', 'bin', 'shared');
for (const fileName of ['config-defaults.manifest.json', 'config-schema.manifest.json']) {
const installedManifest = path.join(sharedDir, fileName);
assert.ok(fs.existsSync(installedManifest), `${fileName} must be copied to ${sharedDir}`);
assert.doesNotThrow(() => {
JSON.parse(fs.readFileSync(installedManifest, 'utf8'));
}, `${fileName} must be valid JSON`);
}
const installedCjs = path.join(
tmpRoot,
'.codex',
'gsd-core',
'bin',
'lib',
'configuration.cjs'
);
delete require.cache[installedCjs];
assert.doesNotThrow(() => {
require(installedCjs);
}, 'post-install configuration.cjs must load from co-located manifests');
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3288-model-catalog-install-path.test.cjs — consolidation epic #1969 (B1 #1970)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3288-model-catalog-install-path (consolidation epic #1969 B1 #1970)", () => {
/**
* Regression test for #3288: model-catalog.cjs uses brittle relative path
* that breaks after install.
*
* Repro:
* After `node bin/install.js --global --claude`, the installed
* `~/.claude/gsd-core/bin/lib/model-catalog.cjs` tries:
* require(path.join(__dirname, '..', '..', '..', 'sdk', 'shared', 'model-catalog.json'))
* which resolves to `~/.claude/sdk/shared/model-catalog.json`.
* The installer copies `gsd-core/` but never copies `sdk/shared/`,
* so the require throws MODULE_NOT_FOUND.
*
* Fix contract:
* 1. model-catalog.cjs must use a resolve-chain that checks a co-located
* path first (bin/shared/model-catalog.json) before the legacy
* source-repo path.
* 2. bin/install.js must copy shared model-catalog.json into
* gsd-core/bin/shared/model-catalog.json (co-located inside the
* gsd-core/ payload).
*
* Both halves must be true for the install layout to work.
*/
'use strict';
process.env.GSD_TEST_MODE = '1';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const REPO_ROOT = path.join(__dirname, '..');
const MODEL_CATALOG_CJS = path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'model-catalog.cjs');
const MODEL_CATALOG_JSON = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared', 'model-catalog.json');
const { install } = require('../bin/install.js');
// ─── helpers ─────────────────────────────────────────────────────────────────
const { createTempDir, cleanup } = require('./helpers.cjs');
const makeTmpDir = createTempDir;
const rmTmpDir = cleanup;
/**
* Silence console output during install to avoid noise in test output.
*/
function silenceConsole(fn) {
const orig = {
log: console.log,
warn: console.warn,
error: console.error,
};
console.log = () => {};
console.warn = () => {};
console.error = () => {};
try {
return fn();
} finally {
console.log = orig.log;
console.warn = orig.warn;
console.error = orig.error;
}
}
// ─── test 1: fake-install layout reproduces MODULE_NOT_FOUND ────────────────
//
// Build a fake post-install layout that mirrors what the OLD install did:
// <tmp>/.claude/gsd-core/bin/lib/model-catalog.cjs (copy of real file)
// <tmp>/.claude/sdk/shared/model-catalog.json ABSENT
//
// Then attempt to require model-catalog.cjs from that layout.
// Under the old path scheme (3 levels up → sdk/shared/) this should throw.
// After the fix, if we DON'T also copy the json, it should still throw — this
// confirms the co-located path IS required.
describe('bug #3288: model-catalog.cjs install-layout resolution', () => {
let tmpRoot;
let savedHome;
let savedUserProfile;
let savedExplicitConfigDir;
beforeEach(() => {
tmpRoot = makeTmpDir('gsd-3288-');
savedHome = process.env.HOME;
// On Windows, os.homedir() reads USERPROFILE (and HOMEDRIVE+HOMEPATH), NOT
// HOME. install() resolves the install destination via os.homedir(), so the
// tests must also redirect USERPROFILE → tmpRoot on win32 to keep the
// installer writing inside the fixture.
savedUserProfile = process.env.USERPROFILE;
// Stash and clear explicitConfigDir via env so install() picks up our tmp dir.
// Must delete (not just save) so any CI-set value doesn't leak into install()
// and target a different directory than tmpRoot (CR finding, PR #3293).
savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR;
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
});
afterEach(() => {
process.env.HOME = savedHome;
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = savedUserProfile;
if (savedExplicitConfigDir === undefined) {
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
} else {
process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir;
}
rmTmpDir(tmpRoot);
});
// ── test A ──────────────────────────────────────────────────────────────────
test('OLD layout (3-level __dirname, no co-located json) fails to require', () => {
// Build the old install layout manually:
// <tmpRoot>/.claude/gsd-core/bin/lib/model-catalog.cjs (copy of the real CJS)
// sdk/shared/model-catalog.json ABSENT
const gsdLibDir = path.join(tmpRoot, '.claude', 'gsd-core', 'bin', 'lib');
fs.mkdirSync(gsdLibDir, { recursive: true });
// Write a minimal model-catalog.cjs that uses ONLY the 3-level path (the old/broken path).
const oldCjsContent = `'use strict';
const path = require('node:path');
// This is the BRITTLE path: 3 levels up from bin/lib → sdk/shared/
const catalog = require(path.join(__dirname, '..', '..', '..', 'sdk', 'shared', 'model-catalog.json'));
module.exports = { catalog };
`;
const catalogCjsPath = path.join(gsdLibDir, 'model-catalog.cjs');
fs.writeFileSync(catalogCjsPath, oldCjsContent);
// Deliberately do NOT create sdk/shared/model-catalog.json (simulates missing file post-install).
// Require must fail with MODULE_NOT_FOUND.
assert.throws(
() => {
// Delete from require cache to force a fresh load.
delete require.cache[catalogCjsPath];
require(catalogCjsPath);
},
(err) => {
assert.ok(
err.code === 'MODULE_NOT_FOUND' || err.message.includes('model-catalog.json'),
`Expected MODULE_NOT_FOUND or model-catalog.json error, got: ${err.message}`,
);
return true;
},
'OLD 3-level path must fail when sdk/shared/model-catalog.json is not present (install layout)',
);
});
// ── test B ──────────────────────────────────────────────────────────────────
test('NEW layout (co-located bin/shared/model-catalog.json) resolves correctly', () => {
// Build the new install layout:
// <tmpRoot>/.claude/gsd-core/bin/lib/model-catalog.cjs (copy of real CJS)
// <tmpRoot>/.claude/gsd-core/bin/shared/model-catalog.json (co-located copy)
const gsdBinDir = path.join(tmpRoot, '.claude', 'gsd-core', 'bin');
const gsdLibDir = path.join(gsdBinDir, 'lib');
const gsdSharedDir = path.join(gsdBinDir, 'shared');
fs.mkdirSync(gsdLibDir, { recursive: true });
fs.mkdirSync(gsdSharedDir, { recursive: true });
// Copy the real model-catalog.cjs into the fake install.
const catalogCjsPath = path.join(gsdLibDir, 'model-catalog.cjs');
fs.copyFileSync(MODEL_CATALOG_CJS, catalogCjsPath);
// Copy the real model-catalog.json to the co-located path.
fs.copyFileSync(MODEL_CATALOG_JSON, path.join(gsdSharedDir, 'model-catalog.json'));
// Require must succeed and expose catalog with expected shape.
delete require.cache[catalogCjsPath];
let mod;
assert.doesNotThrow(() => {
mod = require(catalogCjsPath);
}, 'NEW co-located layout must not throw MODULE_NOT_FOUND');
assert.ok(mod.catalog, 'module must export catalog');
assert.ok(Array.isArray(mod.VALID_PROFILES), 'module must export VALID_PROFILES');
assert.ok(mod.VALID_PROFILES.length > 0, 'VALID_PROFILES must not be empty');
});
// ── test C ──────────────────────────────────────────────────────────────────
test('post-install: install() copies model-catalog.json to co-located path', () => {
// Run the real installer against a tmp target dir, then assert the co-located
// json is present and parseable.
const claudeDir = path.join(tmpRoot, '.claude');
fs.mkdirSync(claudeDir, { recursive: true });
process.env.HOME = tmpRoot;
process.env.USERPROFILE = tmpRoot;
// Capture process.exit to prevent the test from being killed.
const origExit = process.exit;
let exitCalled = false;
process.exit = (code) => {
exitCalled = true;
throw new Error(`process.exit(${code}) during install — should not happen`);
};
try {
silenceConsole(() => {
install(true /* isGlobal */, 'claude');
});
} catch (e) {
if (exitCalled) {
assert.fail(`install() called process.exit — unexpected: ${e.message}`);
}
throw e;
} finally {
process.exit = origExit;
}
// The co-located json must be present after install.
const colocatedJson = path.join(
claudeDir,
'gsd-core',
'bin',
'shared',
'model-catalog.json',
);
assert.ok(
fs.existsSync(colocatedJson),
`model-catalog.json must be present at co-located path post-install: ${colocatedJson}`,
);
// The json must be valid and have expected shape.
let parsed;
assert.doesNotThrow(() => {
parsed = JSON.parse(fs.readFileSync(colocatedJson, 'utf8'));
}, 'co-located model-catalog.json must be valid JSON');
assert.ok(Array.isArray(parsed.profiles), 'catalog.profiles must be an array');
assert.ok(parsed.profiles.length > 0, 'catalog.profiles must not be empty');
// And the installed model-catalog.cjs must be requireable from its install location.
const installedCjs = path.join(
claudeDir,
'gsd-core',
'bin',
'lib',
'model-catalog.cjs',
);
assert.ok(fs.existsSync(installedCjs), `model-catalog.cjs must be installed at: ${installedCjs}`);
delete require.cache[installedCjs];
let installedMod;
assert.doesNotThrow(() => {
installedMod = require(installedCjs);
}, 'installed model-catalog.cjs must not throw MODULE_NOT_FOUND after install');
assert.ok(installedMod.catalog, 'installed module must export catalog');
assert.ok(installedMod.VALID_PROFILES.length > 0, 'installed module must have valid profiles');
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-130-finishinstall-opencode-testmode.test.cjs — consolidation epic #1969 (B1 #1970)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-130-finishinstall-opencode-testmode (consolidation epic #1969 B1 #1970)", () => {
'use strict';
process.env.GSD_TEST_MODE = '1';
/**
* Bug #130: finishInstall calls configureOpencodePermissions unconditionally,
* violating the GSD_TEST_MODE side-effect-free contract.
*
* configureOpencodePermissions does fs.mkdirSync + fs.writeFileSync, which
* must NOT run under GSD_TEST_MODE='1'. This test asserts that the opencode
* config file (opencode.json) is NOT created when GSD_TEST_MODE is set.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const os = require('node:os');
const fs = require('node:fs');
const ROOT = path.join(__dirname, '..');
// Point HOME at a temp dir so configureOpencodePermissions can't write to
// the real ~/.config/opencode/ even if the guard is missing.
const FAKE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-130-test-'));
// Consolidation #1969: scope the HOME/USERPROFILE mutation to before/after so it
// does not leak into sibling folded suites (was process-isolated when standalone).
const { before: __foldBefore, after: __foldAfter } = require('node:test');
const __savedHome = process.env.HOME;
const __savedUserProfile = process.env.USERPROFILE;
__foldBefore(() => {
process.env.HOME = FAKE_HOME;
process.env.USERPROFILE = FAKE_HOME;
});
__foldAfter(() => {
if (__savedHome === undefined) delete process.env.HOME;
else process.env.HOME = __savedHome;
if (__savedUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = __savedUserProfile;
});
// The opencode config dir that configureOpencodePermissions would use for a
// global install when configDir=null: <HOME>/.config/opencode/
// The file it writes is opencode.json (or opencode.jsonc if pre-existing).
const OPENCODE_CONFIG_DIR = path.join(FAKE_HOME, '.config', 'opencode');
const OPENCODE_CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'opencode.json');
// configDir is passed explicitly so the function targets our FAKE_HOME dir
// regardless of how getGlobalDir resolves.
const installModule = require(path.join(ROOT, 'bin', 'install.js'));
const SETTINGS_PATH = path.join(FAKE_HOME, `gsd-test-settings-${process.pid}.json`);
function callFinishInstall() {
const original = console.log;
console.log = () => {};
try {
installModule.finishInstall(
SETTINGS_PATH,
{},
null,
false,
'opencode',
true,
OPENCODE_CONFIG_DIR, // pass explicit configDir pointing at our temp dir
);
} finally {
console.log = original;
}
}
describe('Bug #130: finishInstall opencode + GSD_TEST_MODE side-effect guard', () => {
test('configureOpencodePermissions does NOT write opencode.json under GSD_TEST_MODE', () => {
// Confirm the file does not exist before the call
assert.equal(
fs.existsSync(OPENCODE_CONFIG_FILE),
false,
'opencode.json should not exist before finishInstall call',
);
callFinishInstall();
// Assert the file was NOT created — the side-effect must be suppressed
assert.equal(
fs.existsSync(OPENCODE_CONFIG_FILE),
false,
`opencode.json must NOT be created under GSD_TEST_MODE; found at ${OPENCODE_CONFIG_FILE}`,
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-410-install-defaults-test-mode-guard.test.cjs — consolidation epic #1969 (B1 #1970)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-410-install-defaults-test-mode-guard (consolidation epic #1969 B1 #1970)", () => {
'use strict';
/**
* Bug #410: finishInstall writes ~/.gsd/defaults.json for non-Claude runtimes
* without a GSD_TEST_MODE guard, polluting the real developer home directory
* during test runs.
*
* The opencode permission-config write a few lines above already carries the
* GSD_TEST_MODE guard (added for #130) — this test covers the un-fixed sibling
* (the resolve_model_ids: "omit" write).
*/
const { test, describe } = require('node:test');
const { cleanup } = require('./helpers.cjs');
const assert = require('node:assert/strict');
const path = require('node:path');
const os = require('node:os');
const fs = require('node:fs');
const ROOT = path.join(__dirname, '..');
// Point HOME at a temp dir so the defaults.json write can't reach the real
// ~/.gsd/ even if the guard is missing.
// On Windows, os.homedir() reads USERPROFILE (not HOME). Set both so
// finishInstall's path.join(os.homedir(), '.gsd') resolves into FAKE_HOME
// on every platform. Node docs: https://nodejs.org/docs/latest-v22.x/api/os.html#oshomedir
const FAKE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-410-test-'));
// Consolidation #1969: scope the HOME/USERPROFILE mutation to before/after so it
// does not leak into sibling folded suites (was process-isolated when standalone).
const { before: __foldBefore, after: __foldAfter } = require('node:test');
const __savedHome = process.env.HOME;
const __savedUserProfile = process.env.USERPROFILE;
__foldBefore(() => {
process.env.HOME = FAKE_HOME;
process.env.USERPROFILE = FAKE_HOME;
});
__foldAfter(() => {
if (__savedHome === undefined) delete process.env.HOME;
else process.env.HOME = __savedHome;
if (__savedUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = __savedUserProfile;
});
// The path that finishInstall would write to for a non-Claude runtime.
const GSD_DIR = path.join(FAKE_HOME, '.gsd');
const DEFAULTS_PATH = path.join(GSD_DIR, 'defaults.json');
// Set GSD_TEST_MODE before requiring install.js so any module-level guards
// also see the flag.
process.env.GSD_TEST_MODE = '1';
const installModule = require(path.join(ROOT, 'bin', 'install.js'));
// A synthetic settingsPath that won't exist — finishInstall should cope.
const SETTINGS_PATH = path.join(FAKE_HOME, `gsd-test-settings-${process.pid}.json`);
function callFinishInstallForRuntime(runtime) {
const original = console.log;
console.log = () => {};
try {
installModule.finishInstall(
SETTINGS_PATH,
{}, // empty settings
null, // statuslineCommand
false, // shouldInstallStatusline
runtime,
true, // isGlobal
null, // configDir
);
} finally {
console.log = original;
}
}
describe('Bug #410: finishInstall non-Claude runtime + GSD_TEST_MODE side-effect guard', () => {
test('defaults.json is NOT written for opencode runtime under GSD_TEST_MODE', () => {
assert.equal(
fs.existsSync(DEFAULTS_PATH),
false,
'defaults.json should not exist before finishInstall call',
);
callFinishInstallForRuntime('opencode');
assert.equal(
fs.existsSync(DEFAULTS_PATH),
false,
`defaults.json must NOT be created under GSD_TEST_MODE; found at ${DEFAULTS_PATH}`,
);
});
test('defaults.json is NOT written for gemini runtime under GSD_TEST_MODE', () => {
// Reset in case previous test left artifacts (it shouldn't).
assert.equal(
fs.existsSync(DEFAULTS_PATH),
false,
'defaults.json should not exist before gemini test',
);
callFinishInstallForRuntime('gemini');
assert.equal(
fs.existsSync(DEFAULTS_PATH),
false,
`defaults.json must NOT be created under GSD_TEST_MODE for gemini; found at ${DEFAULTS_PATH}`,
);
});
test('defaults.json IS written for opencode runtime when GSD_TEST_MODE is unset', () => {
// Temporarily unset GSD_TEST_MODE to verify the user-facing path still works.
const saved = process.env.GSD_TEST_MODE;
delete process.env.GSD_TEST_MODE;
try {
callFinishInstallForRuntime('opencode');
assert.equal(
fs.existsSync(DEFAULTS_PATH),
true,
`defaults.json must be written for non-Claude runtime when GSD_TEST_MODE is unset`,
);
// Verify the written content is correct.
const contents = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8'));
assert.equal(contents.resolve_model_ids, 'omit', 'resolve_model_ids must be "omit"');
} finally {
// Restore GSD_TEST_MODE and clean up the written file.
process.env.GSD_TEST_MODE = saved;
cleanup(DEFAULTS_PATH);
try { fs.rmdirSync(GSD_DIR); } catch { /* not empty or already gone */ }
}
});
});
// Bug #1569 folded here (sibling on the SAME finishInstall resolve_model_ids block):
// the #1156 default-to-"omit" step keyed its write on `!== "omit"`, so an explicit
// `resolve_model_ids: true` opt-in (resolveModelInternal returns full materialized
// model IDs) was silently clobbered across all 14 non-Claude runtimes. The fix
// preserves `true` and only defaults absent/falsy → "omit". Reuses the #410 harness.
describe('Bug #1569: non-Claude finishInstall preserves explicit resolve_model_ids:true', () => {
function seedDefaults(obj) {
fs.mkdirSync(GSD_DIR, { recursive: true });
fs.writeFileSync(DEFAULTS_PATH, JSON.stringify(obj, null, 2) + '\n', 'utf8');
}
function withUserPath(fn) {
const saved = process.env.GSD_TEST_MODE;
delete process.env.GSD_TEST_MODE;
try {
return fn();
} finally {
process.env.GSD_TEST_MODE = saved;
}
}
test('explicit resolve_model_ids:true survives a codex global install (the reported case)', () => {
withUserPath(() => {
seedDefaults({ runtime: 'codex', model_profile: 'balanced', resolve_model_ids: true });
callFinishInstallForRuntime('codex');
const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8'));
assert.equal(
after.resolve_model_ids,
true,
'explicit resolve_model_ids:true must be preserved across a codex install, not clobbered to "omit"',
);
});
});
// The clobber guard is runtime-agnostic (`runtime !== 'claude'`); parameterize
// across a representative slice of non-Claude runtimes.
for (const runtime of ['codex', 'opencode', 'gemini']) {
test(`explicit resolve_model_ids:true survives a ${runtime} global install`, () => {
withUserPath(() => {
seedDefaults({ runtime, resolve_model_ids: true });
callFinishInstallForRuntime(runtime);
const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8'));
assert.equal(
after.resolve_model_ids,
true,
`explicit resolve_model_ids:true must be preserved for ${runtime}`,
);
});
});
}
test('absent resolve_model_ids still defaults to "omit" (preserves #1156 intent)', () => {
withUserPath(() => {
seedDefaults({ runtime: 'codex' });
callFinishInstallForRuntime('codex');
const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8'));
assert.equal(
after.resolve_model_ids,
'omit',
'absent resolve_model_ids must still default to "omit" for non-Claude runtimes',
);
});
});
test('explicit resolve_model_ids:false still defaults to "omit"', () => {
withUserPath(() => {
seedDefaults({ runtime: 'codex', resolve_model_ids: false });
callFinishInstallForRuntime('codex');
const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8'));
assert.equal(after.resolve_model_ids, 'omit', 'false must still be normalized to "omit"');
});
});
test('non-canonical resolve_model_ids values (0, "", "yes", {}) default to "omit" — no Claude alias leak (#1569 codex review)', () => {
// The domain is true/false/"omit"/absent. Any OTHER value is malformed; the safe
// non-Claude default is "omit" (don't leak Claude aliases the runtime can't resolve).
withUserPath(() => {
for (const bad of [0, '', 'yes', {}]) {
seedDefaults({ runtime: 'codex', resolve_model_ids: bad });
callFinishInstallForRuntime('codex');
const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8'));
assert.equal(
after.resolve_model_ids,
'omit',
`non-canonical resolve_model_ids:${JSON.stringify(bad)} must default to "omit", not pass through`,
);
}
});
});
test('already-"omit" is left unchanged (idempotent, no rewrite churn)', () => {
withUserPath(() => {
seedDefaults({ runtime: 'codex', resolve_model_ids: 'omit' });
const beforeMtime = fs.statSync(DEFAULTS_PATH).mtimeMs;
// fs mtime resolution can be coarse; wait briefly so an accidental rewrite is detectable.
const start = Date.now();
while (Date.now() - start < 20) { /* spin briefly */ }
callFinishInstallForRuntime('codex');
const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8'));
const afterMtime = fs.statSync(DEFAULTS_PATH).mtimeMs;
assert.equal(after.resolve_model_ids, 'omit');
assert.equal(
afterMtime,
beforeMtime,
'defaults.json must not be rewritten when resolve_model_ids is already "omit" (idempotent)',
);
});
});
test('claude runtime never touches resolve_model_ids (cross-runtime parity)', () => {
withUserPath(() => {
seedDefaults({ runtime: 'claude', resolve_model_ids: true });
callFinishInstallForRuntime('claude');
const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8'));
assert.equal(
after.resolve_model_ids,
true,
'claude install must never rewrite resolve_model_ids',
);
});
});
test('malformed defaults.json does not crash — still defaults to "omit"', () => {
withUserPath(() => {
fs.mkdirSync(GSD_DIR, { recursive: true });
fs.writeFileSync(DEFAULTS_PATH, '{ not valid json }', 'utf8');
// Must not throw.
callFinishInstallForRuntime('codex');
const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8'));
assert.equal(
after.resolve_model_ids,
'omit',
'malformed defaults.json must be recovered to a valid state with resolve_model_ids:omit',
);
});
});
});
// Bug #1657 — finishInstall reads ~/.gsd/defaults.json with JSON.parse but did not
// validate the result is a plain object. A valid-JSON-but-non-object value (null, [],
// 42, "str") bypassed the catch and flowed through, leaving the malformed file on disk
// unrecovered (and, for null, throwing a TypeError swallowed by the outer try/catch).
// Folded into the owning install-defaults test (no new top-level bug-NNNN file).
describe('Bug #1657: finishInstall recovers a malformed (non-object) defaults.json', () => {
function seedDefaultsRaw(raw) {
fs.mkdirSync(GSD_DIR, { recursive: true });
fs.writeFileSync(DEFAULTS_PATH, raw, 'utf8');
}
function runAndRead(runtime) {
const saved = process.env.GSD_TEST_MODE;
delete process.env.GSD_TEST_MODE;
const log = console.log; console.log = () => {};
let threw = null;
try {
installModule.finishInstall(SETTINGS_PATH, {}, null, false, runtime, true, null);
} catch (e) { threw = e.message; } finally { console.log = log; process.env.GSD_TEST_MODE = saved; }
let after = null;
try { after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); } catch (e) { after = 'UNPARSEABLE: ' + e.message; }
return { threw, after };
}
for (const [label, raw] of [['null', 'null'], ['array', '[]'], ['number', '42'], ['string', '"oops"']]) {
test(`seed ${label} (${raw}) recovers to a valid object with resolve_model_ids:omit`, () => {
seedDefaultsRaw(raw);
const { threw, after } = runAndRead('codex');
assert.equal(threw, null, `must not throw for seed ${label} (got: ${threw})`);
assert.equal(
after !== null && typeof after === 'object' && !Array.isArray(after) && after.resolve_model_ids === 'omit',
true,
`seed ${label} must recover to { resolve_model_ids: 'omit' }, got: ${JSON.stringify(after)}`,
);
});
}
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-1736-local-install-commands.test.cjs — consolidation epic #1969 (B1 #1970)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-1736-local-install-commands (consolidation epic #1969 B1 #1970)", () => {
/**
* Regression test for #1736: local Claude install missing commands/gsd/
*
* After a fresh local install (`--claude --local`), all /gsd-* commands
* except /gsd-help return "Unknown skill: gsd-quick" because
* .claude/commands/gsd/ was not populated. Claude Code reads local project
* commands from .claude/commands/ (one level up) using the file stem as the
* command name.
*
* #1367 follow-up: the fix changed the layout from the old commands/gsd/<cmd>.md
* (which caused /gsd:<cmd> colon namespace) to flat commands/gsd-<cmd>.md
* (which produces /gsd-<cmd> hyphen form). This test has been updated to assert
* the new flat layout while preserving the core invariant from #1736: commands
* must be present and usable after a local install.
*/
'use strict';
process.env.GSD_TEST_MODE = '1';
const { describe, test, before, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execFileSync } = require('child_process');
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
const { install } = require(INSTALL_SRC);
const { cleanup } = require('./helpers.cjs');
// ─── Ensure hooks/dist/ is populated before install tests ────────────────────
// With --test-concurrency=4, other install tests (bug-1834, bug-1924) run
// build-hooks.js concurrently. That script creates hooks/dist/ empty first,
// then copies files — creating a window where this test sees an empty dir and
// install() fails with "directory is empty" → process.exit(1).
before(() => {
execFileSync(process.execPath, [BUILD_SCRIPT], {
encoding: 'utf-8',
stdio: 'pipe',
});
});
// ─── #1736 + #1367: local install deploys commands in flat gsd-<cmd>.md layout ───
describe('#1736: local Claude install deploys slash commands (flat gsd-<cmd>.md layout, #1367)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-local-install-1736-'));
});
afterEach(() => {
// Use the shared helper which has a 5s Windows-EBUSY retry budget
// (20×250ms). The inline 1s budget here was insufficient on cold runners.
cleanup(tmpDir);
});
test('local install creates .claude/commands/ directory with flat gsd-*.md files (#1367)', (t) => {
// #1736 invariant: commands must be deployed.
// #1367 fix: commands land as flat gsd-<cmd>.md at commands/ (not commands/gsd/<cmd>.md).
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
install(false, 'claude');
const commandsDir = path.join(tmpDir, '.claude', 'commands');
assert.ok(
fs.existsSync(commandsDir),
'.claude/commands/ directory must exist after local install'
);
const flatFiles = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md'));
assert.ok(
flatFiles.length > 0,
`.claude/commands/ must have flat gsd-*.md files (e.g. gsd-help.md). Found: ${JSON.stringify(flatFiles)}`
);
// The old commands/gsd/ subdirectory must NOT exist (#1367)
const oldSubdir = path.join(commandsDir, 'gsd');
assert.ok(
!fs.existsSync(oldSubdir),
'.claude/commands/gsd/ subdir must NOT exist — flat gsd-<cmd>.md layout required (#1367)'
);
});
test('local install deploys at least one .md command file to .claude/commands/ (#1736 invariant)', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
install(false, 'claude');
const commandsDir = path.join(tmpDir, '.claude', 'commands');
assert.ok(
fs.existsSync(commandsDir),
'.claude/commands/ must exist'
);
const files = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md'));
assert.ok(
files.length > 0,
`.claude/commands/ must contain at least one gsd-*.md file, found: ${JSON.stringify(files)}`
);
});
test('local install deploys gsd-quick.md to .claude/commands/ (#1367: flat hyphen form)', (t) => {
// Was: .claude/commands/gsd/quick.md (caused /gsd:quick colon form).
// Now: .claude/commands/gsd-quick.md (produces /gsd-quick hyphen form).
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
install(false, 'claude');
const quickCmd = path.join(tmpDir, '.claude', 'commands', 'gsd-quick.md');
assert.ok(
fs.existsSync(quickCmd),
'.claude/commands/gsd-quick.md must exist after local install (#1367 flat layout)'
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2248-local-install-statusline.test.cjs — consolidation epic #1969 (B1 #1970)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-2248-local-install-statusline (consolidation epic #1969 B1 #1970)", () => {
/**
* Regression test for #2248: local Claude install clobbers profile-level statusLine
*
* When installing with `--claude --local`, the repo-level `.claude/settings.json`
* takes precedence over the user's profile-level `~/.claude/settings.json` in
* Claude Code. Writing `statusLine` to repo settings during a local install
* silently overrides any profile-level statusLine the user configured.
*
* Fix: local installs skip writing `statusLine` to settings.json unless
* `--force-statusline` is passed.
*
* Note: `install()` only copies files. `finishInstall()` writes settings.json.
* The production code calls both from `installAllRuntimes()`. Tests must mirror
* that two-phase pattern.
*/
'use strict';
process.env.GSD_TEST_MODE = '1';
const { describe, test, before, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execFileSync } = require('child_process');
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
const { install, finishInstall } = require(INSTALL_SRC);
const { cleanup, captureConsole } = require('./helpers.cjs');
// ─── Ensure hooks/dist/ is populated before install tests ────────────────────
before(() => {
execFileSync(process.execPath, [BUILD_SCRIPT], {
encoding: 'utf-8',
stdio: 'pipe',
});
});
// ─── #2248: local install must NOT write statusLine to repo settings.json ────
describe('#2248: local Claude install does not clobber profile-level statusLine', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-local-install-2248-'));
});
afterEach(() => {
// Use the shared 5s Windows-EBUSY retry budget instead of inline 1s.
cleanup(tmpDir);
});
test('local install writes hooks to .claude/settings.local.json and does not write statusLine', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
// Phase 1: copy files (mirrors installAllRuntimes)
const result = install(false, 'claude');
// Phase 2: configure settings.local.json (mirrors installAllRuntimes → finalize)
// #338: local Claude installs now write to settings.local.json, not settings.json.
// shouldInstallStatusline=true mirrors what handleStatusline picks for a fresh install
const { stdout } = captureConsole(() => {
finishInstall(
result.settingsPath,
result.settings,
result.statuslineCommand,
true, // shouldInstallStatusline
'claude',
false // isGlobal=false -> local install
);
});
assert.match(
stdout,
/Skipping statusLine for local install/,
'Local install must explain that it skipped statusLine unless --force-statusline is passed'
);
// #338: local installs write to settings.local.json, not settings.json
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
assert.ok(
fs.existsSync(localSettingsPath),
'.claude/settings.local.json must exist after local Claude install (#338)'
);
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
assert.strictEqual(
settings.statusLine,
undefined,
'Local install must not write statusLine to settings.local.json — it would clobber profile-level settings (#2248)'
);
// settings.json must not be touched by a fresh local install
const sharedSettingsPath = path.join(tmpDir, '.claude', 'settings.json');
assert.strictEqual(
fs.existsSync(sharedSettingsPath),
false,
'.claude/settings.json must NOT be created by a fresh local Claude install (#338)'
);
});
test('global install still writes statusLine to settings.json', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
// Global install writes to CLAUDE_CONFIG_DIR; point it at our tmpDir
const configDir = path.join(tmpDir, '.claude');
fs.mkdirSync(configDir, { recursive: true });
const origEnv = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = configDir;
t.after(() => {
if (origEnv === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = origEnv;
}
});
// Phase 1: copy files
const result = install(true, 'claude');
// Phase 2: configure settings.json
finishInstall(
result.settingsPath,
result.settings,
result.statuslineCommand,
true, // shouldInstallStatusline
'claude',
true // isGlobal=true
);
const settingsPath = path.join(configDir, 'settings.json');
assert.ok(
fs.existsSync(settingsPath),
'~/.claude/settings.json must exist after global install'
);
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
assert.ok(
settings.statusLine !== undefined,
'Global install should write statusLine to settings.json'
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-338-local-install-settings-local-json.test.cjs — consolidation epic #1969 (B1 #1970)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-338-local-install-settings-local-json (consolidation epic #1969 B1 #1970)", () => {
/**
* Regression tests for #338: Claude --local installs must write hook wiring to
* `.claude/settings.local.json` (Claude Code's per-user gitignored slot) instead
* of the repo-shared `.claude/settings.json`.
*
* Three cases:
* 1. Fresh local install: settings.local.json is created with hook block;
* settings.json is not touched.
* 2. Global install (regression guard): continues to write to settings.json.
* 3. Migration: if a prior local install wrote GSD entries to settings.json,
* re-running local install moves them to settings.local.json and removes
* them from settings.json in the same run.
*
* Note: `install()` only copies files. `finishInstall()` writes settings.
* The production code calls both from `installAllRuntimes()`. Tests mirror
* that two-phase pattern.
*/
'use strict';
process.env.GSD_TEST_MODE = '1';
const { describe, test, before, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execFileSync } = require('child_process');
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
const { install, finishInstall } = require(INSTALL_SRC);
const { cleanup } = require('./helpers.cjs');
// ─── Ensure hooks/dist/ is populated before install tests ────────────────────
before(() => {
execFileSync(process.execPath, [BUILD_SCRIPT], {
encoding: 'utf-8',
stdio: 'pipe',
});
});
// ─── Helper: run both install phases ─────────────────────────────────────────
/**
* Run install + finishInstall (mirrors installAllRuntimes two-phase pattern).
* @param {boolean} isGlobal
* @param {object} [opts]
* @param {boolean} [opts.shouldInstallStatusline]
* @returns {{ result: object }}
*/
function runInstall(isGlobal, opts = {}) {
const { shouldInstallStatusline = false } = opts;
const result = install(isGlobal, 'claude');
finishInstall(
result.settingsPath,
result.settings,
result.statuslineCommand,
shouldInstallStatusline,
'claude',
isGlobal
);
return { result };
}
// ─── Case 1: fresh local install → settings.local.json, not settings.json ───
describe('#338 case 1: fresh local Claude install writes to settings.local.json', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-338-local-'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('settings.local.json is created with hook block', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
runInstall(false);
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
assert.ok(
fs.existsSync(localSettingsPath),
'.claude/settings.local.json must exist after local Claude install (#338)'
);
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
assert.ok(
settings && typeof settings === 'object',
'settings.local.json must be a valid JSON object'
);
// Hook block must be present (hooks key or at minimum the file was written)
assert.ok(
settings.hooks !== undefined || Object.keys(settings).length >= 0,
'settings.local.json must contain the hook block'
);
});
test('settings.json is NOT created by a fresh local install', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
runInstall(false);
const sharedSettingsPath = path.join(tmpDir, '.claude', 'settings.json');
assert.strictEqual(
fs.existsSync(sharedSettingsPath),
false,
'.claude/settings.json must NOT be created by a fresh local Claude install (#338) — ' +
'engineer-specific absolute paths must not leak into the repo-shared file'
);
});
test('install() returns settingsPath pointing to settings.local.json', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
const result = install(false, 'claude');
assert.ok(
result.settingsPath.endsWith('settings.local.json'),
`install() must return settingsPath ending in settings.local.json for local Claude installs; got: ${result.settingsPath}`
);
});
});
// ─── Case 2: global Claude install (regression guard) ────────────────────────
describe('#338 case 2: global Claude install continues to write to settings.json', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-338-global-'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('global install writes hook block to settings.json', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
// Point CLAUDE_CONFIG_DIR at a subdir of tmpDir to avoid polluting ~/.claude
const configDir = path.join(tmpDir, '.claude');
fs.mkdirSync(configDir, { recursive: true });
const origEnv = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = configDir;
t.after(() => {
if (origEnv === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = origEnv;
}
});
runInstall(true);
const settingsPath = path.join(configDir, 'settings.json');
assert.ok(
fs.existsSync(settingsPath),
'~/.claude/settings.json must exist after global Claude install (regression guard for #338)'
);
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
assert.ok(
settings && typeof settings === 'object',
'settings.json must be a valid JSON object after global install'
);
});
test('global install does NOT create settings.local.json', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
const configDir = path.join(tmpDir, '.claude');
fs.mkdirSync(configDir, { recursive: true });
const origEnv = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = configDir;
t.after(() => {
if (origEnv === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = origEnv;
}
});
runInstall(true);
const localSettingsPath = path.join(configDir, 'settings.local.json');
assert.strictEqual(
fs.existsSync(localSettingsPath),
false,
'~/.claude/settings.local.json must NOT be created by a global Claude install'
);
});
});
// ─── Case 3: migration — prior local install wrote GSD entries to settings.json ─
describe('#338 case 3: migration of prior local install GSD entries from settings.json to settings.local.json', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-338-migrate-'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('GSD hook entries are moved from settings.json to settings.local.json', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
// Pre-populate .claude/settings.json with a GSD-shaped hook block (simulating
// a prior local install that wrote to the wrong file).
const claudeDir = path.join(tmpDir, '.claude');
fs.mkdirSync(claudeDir, { recursive: true });
const sharedSettingsPath = path.join(claudeDir, 'settings.json');
const priorSettings = {
hooks: {
SessionStart: [
{
hooks: [
{
type: 'command',
command: `${process.execPath} ${path.join(claudeDir, 'hooks', 'gsd-check-update.js')}`,
}
]
}
],
PostToolUse: [
{
matcher: 'Bash|Edit|Write|MultiEdit|Agent|Task',
hooks: [
{
type: 'command',
command: `${process.execPath} ${path.join(claudeDir, 'hooks', 'gsd-context-monitor.js')}`,
timeout: 10,
}
]
}
]
}
};
fs.writeFileSync(sharedSettingsPath, JSON.stringify(priorSettings, null, 2) + '\n');
// Run a fresh local install — this should trigger migration
runInstall(false);
// Verify GSD entries are now in settings.local.json
const localSettingsPath = path.join(claudeDir, 'settings.local.json');
assert.ok(
fs.existsSync(localSettingsPath),
'.claude/settings.local.json must exist after migration run'
);
const localSettings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
const sessionStartHooks = (localSettings.hooks && localSettings.hooks.SessionStart) || [];
const hasGsdUpdateHook = sessionStartHooks.some(
entry => entry && entry.hooks && Array.isArray(entry.hooks) &&
entry.hooks.some(h => h && h.command && h.command.includes('gsd-check-update'))
);
assert.ok(
hasGsdUpdateHook,
'settings.local.json must contain the migrated gsd-check-update hook after migration'
);
});
test('GSD hook entries are removed from settings.json after migration', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
const claudeDir = path.join(tmpDir, '.claude');
fs.mkdirSync(claudeDir, { recursive: true });
const sharedSettingsPath = path.join(claudeDir, 'settings.json');
const priorSettings = {
// Include a non-GSD key to verify user content is preserved
myCustomKey: 'keep-me',
hooks: {
SessionStart: [
{
hooks: [
{
type: 'command',
command: `${process.execPath} ${path.join(claudeDir, 'hooks', 'gsd-check-update.js')}`,
}
]
}
]
}
};
fs.writeFileSync(sharedSettingsPath, JSON.stringify(priorSettings, null, 2) + '\n');
runInstall(false);
// settings.json must exist (we don't delete it — user may have other content)
assert.ok(
fs.existsSync(sharedSettingsPath),
'.claude/settings.json must still exist after migration (may have non-GSD user content)'
);
const sharedSettings = JSON.parse(fs.readFileSync(sharedSettingsPath, 'utf-8'));
// GSD hooks must be gone from settings.json
const sessionStartHooks = (sharedSettings.hooks && sharedSettings.hooks.SessionStart) || [];
const hasGsdHook = sessionStartHooks.some(
entry => entry && entry.hooks && Array.isArray(entry.hooks) &&
entry.hooks.some(h => h && h.command && h.command.includes('gsd-check-update'))
);
assert.strictEqual(
hasGsdHook,
false,
'GSD hook entries must be removed from settings.json after migration to settings.local.json'
);
// Non-GSD user content must be preserved
assert.strictEqual(
sharedSettings.myCustomKey,
'keep-me',
'Non-GSD user content in settings.json must be preserved during migration'
);
});
test('settings.json with no GSD entries is left unchanged', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
const claudeDir = path.join(tmpDir, '.claude');
fs.mkdirSync(claudeDir, { recursive: true });
const sharedSettingsPath = path.join(claudeDir, 'settings.json');
const userOnlySettings = {
userKey: 'user-value',
hooks: {
SessionStart: [
{
hooks: [
{
type: 'command',
command: '/usr/local/bin/my-own-hook.sh',
}
]
}
]
}
};
const originalContent = JSON.stringify(userOnlySettings, null, 2) + '\n';
fs.writeFileSync(sharedSettingsPath, originalContent);
runInstall(false);
// settings.json must be unchanged (no GSD entries to migrate)
const afterContent = fs.readFileSync(sharedSettingsPath, 'utf-8');
const afterSettings = JSON.parse(afterContent);
assert.strictEqual(
afterSettings.userKey,
'user-value',
'Non-GSD settings.json must be untouched when no GSD entries are present'
);
// User hook must still be there
const sessionStart = (afterSettings.hooks && afterSettings.hooks.SessionStart) || [];
const hasUserHook = sessionStart.some(
entry => entry && entry.hooks && entry.hooks.some(h => h && h.command === '/usr/local/bin/my-own-hook.sh')
);
assert.ok(
hasUserHook,
'User hook in settings.json must be preserved when no migration occurs'
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2957-claude-global-postinstall-message.test.cjs — consolidation epic #1969 (B1 #1970)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-2957-claude-global-postinstall-message (consolidation epic #1969 B1 #1970)", () => {
'use strict';
process.env.GSD_TEST_MODE = '1';
/**
* Bug #2957: post-install message for `--claude --global` must instruct
* users to restart Claude Code and offer the skill-name fallback, since
* the skills-only install layout (CC 2.1.88+) leaves nothing in
* commands/gsd/ for the slash menu to read on older configurations.
*
* Captures the call to finishInstall(runtime='claude', isGlobal=true) and
* asserts the printed message contains both invocation paths.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const os = require('node:os');
const ROOT = path.join(__dirname, '..');
const SETTINGS_PATH = path.join(os.tmpdir(), `gsd-test-settings-${process.pid}.json`);
const installModule = require(path.join(ROOT, 'bin', 'install.js'));
function captureFinishInstallOutput(runtime, isGlobal) {
const original = console.log;
const lines = [];
console.log = (...args) => { lines.push(args.join(' ')); };
try {
installModule.finishInstall(
SETTINGS_PATH,
{},
null,
false,
runtime,
isGlobal,
null,
);
} finally {
console.log = original;
}
// Strip ANSI color escapes so message-content assertions don't couple to colors.
// eslint-disable-next-line no-control-regex -- \x1b (ESC) is the required leading byte of ANSI SGR color sequences; matching it is the purpose of stripping ANSI codes from captured CLI/console output
return lines.join('\n').replace(/\x1B\[[0-9;]*m/g, '');
}
describe('Bug #2957: claude+global post-install message', () => {
test('claude+global message tells the user to restart and offers skill-name fallback', () => {
const output = captureFinishInstallOutput('claude', true);
assert.match(output, /restart claude code/i, 'should mention restart');
assert.match(output, /\/gsd-new-project/, 'should still mention /gsd-new-project');
assert.match(output, /gsd-new-project skill/i, 'should mention the skill name fallback');
assert.doesNotMatch(
output,
/open a blank directory/i,
'global claude install should replace, not extend, the legacy generic instruction',
);
});
test('claude+local message keeps the original /gsd-new-project instruction', () => {
const output = captureFinishInstallOutput('claude', false);
assert.match(output, /\/gsd-new-project/, 'should still mention /gsd-new-project');
assert.doesNotMatch(output, /restart claude code/i, 'local install does not require the skills restart note');
});
test('non-claude runtimes keep their original message format', () => {
const output = captureFinishInstallOutput('opencode', true);
assert.match(output, /Open a blank directory/, 'opencode message should be unchanged');
assert.doesNotMatch(output, /restart/i, 'opencode message should not have the claude-specific restart note');
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-505-remove-dead-sdk-verification.test.cjs — consolidation epic #1969 (B1 #1970)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-505-remove-dead-sdk-verification (consolidation epic #1969 B1 #1970)", () => {
/**
* Regression guard for #505: dead SDK-shim verification subsystem removed.
*
* Post-ADR-0174 the `@opengsd/gsd-sdk` package was retired; `sdk/` no longer
* ships. `installSdkIfNeeded` and all functions it transitively called are
* dead code with no live callers. This test asserts:
*
* 1. All removed symbols are NO LONGER exported from bin/install.js.
* 2. The two live stale-standalone-SDK helpers (detectStaleStandaloneSdk,
* formatStaleStandaloneSdkWarning) are STILL exported as functions — they
* handle a real user-facing condition (#3406) and MUST NOT be removed.
*/
'use strict';
process.env.GSD_TEST_MODE = '1';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const inst = require('../bin/install.js');
describe('bug #505: dead SDK verification subsystem removed from bin/install.js', () => {
// ----------------------------------------------------------------
// Dead symbols — must NOT be exported after removal
// ----------------------------------------------------------------
const deadSymbols = [
'installSdkIfNeeded',
'classifySdkInstall',
'buildSdkFailFastReport',
'renderSdkFailFastReport',
'buildGsdSdkVersionMismatchReport',
'renderGsdSdkVersionMismatchReport',
'readGsdSdkVersion',
'parseGsdSdkVersion',
'findGsdSdkOnPath',
'isGsdSdkOnPath',
'isLegacyGsdSdkShim',
'trySelfLinkGsdSdk',
'trySelfLinkGsdSdkWindows',
'filterNpxFromPath',
'getUserShellPath',
'getUserShellWindowsPersistentPath',
];
for (const sym of deadSymbols) {
test(`dead symbol '${sym}' is not exported`, () => {
assert.equal(
typeof inst[sym],
'undefined',
`'${sym}' should have been removed (post #505 dead-code removal) but is still exported as ${typeof inst[sym]}`,
);
});
}
// ----------------------------------------------------------------
// The stale-standalone-SDK helpers (detectStaleStandaloneSdk,
// formatStaleStandaloneSdkWarning) and the gsd-sdk shim contract surface
// (buildWindowsShimTriple, formatSdkPathDiagnostic) that #505 kept were
// removed when the gsd-sdk shim itself was retired (#191). Their absence is
// covered by the dead-symbol assertions above.
// ----------------------------------------------------------------
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-376-claude-js-hook-gsd-rewriter.test.cjs — consolidation epic #1969 (B1 #1970)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-376-claude-js-hook-gsd-rewriter (consolidation epic #1969 B1 #1970)", () => {
'use strict';
/**
* Regression for bug #376 — Claude-installed hook JS files ship with raw
* /gsd:<cmd> command literals because the hook-copy loop in install.js had
* no /gsd: → /gsd- rewrite for the claude runtime.
*
* Fix: the `.js` branch of the hook-copy loop now applies
* `content.replace(/gsd:/gi, 'gsd-')` when
* `shouldNormalizeHyphenNamespaceInAgentBody(runtime)` is true (covers
* claude, qwen, hermes).
*
* Test plan:
* 1. Claude install to tmp prefix — installed .js hook files must contain
* no user-facing /gsd: literals (// comment occurrences exempted).
* 2. Cursor install regression — still rewrites correctly (pre-existing
* branch must remain intact).
* 3. Source files in hooks/ must be byte-identical before and after both
* installs (install-time rewrite only, no in-tree mutation).
*/
process.env.GSD_TEST_MODE = '1';
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const REPO_ROOT = path.resolve(__dirname, '..');
const INSTALL_PATH = path.join(REPO_ROOT, 'bin', 'install.js');
const HOOKS_DIST_DIR = path.join(REPO_ROOT, 'hooks', 'dist');
const BUILD_HOOKS_SCRIPT = path.join(REPO_ROOT, 'scripts', 'build-hooks.js');
/**
* Ensure hooks/dist is populated before any suite that reads it.
* hooks/dist/ is gitignored and only produced by `npm run build:hooks`.
* In CI the scoped/windows test jobs do NOT run build:hooks before running
* tests, so the first test that needs hooks/dist would fail. This mirrors
* the pattern used in bug-3357-codex-legacy-hooks-json-migration.test.cjs.
*/
function ensureHooksDist() {
if (!fs.existsSync(HOOKS_DIST_DIR) || fs.readdirSync(HOOKS_DIST_DIR).filter(f => f.endsWith('.js')).length === 0) {
execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' });
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Run `node install.js <...args>` from cwd.
* GSD_TEST_MODE is cleared so the install() main block executes.
*/
function runInstall(cwd, args) {
const env = { ...process.env };
delete env.GSD_TEST_MODE;
execFileSync(process.execPath, [INSTALL_PATH, ...args], {
cwd,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env,
timeout: 60000,
});
}
/**
* Return an array of { rel, path } for all .js files under dir.
*/
function findJsFiles(dir) {
const results = [];
function walk(d) {
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
const full = path.join(d, entry.name);
if (entry.isDirectory()) walk(full);
else if (entry.name.endsWith('.js') || entry.name.endsWith('.cjs')) {
results.push({ rel: path.relative(dir, full), full });
}
}
}
walk(dir);
return results;
}
/**
* Split a JS file's lines into comment and non-comment buckets.
* A line is treated as a comment if it starts with optional whitespace
* followed by // (single-line comment). Block comments are not checked
* since none of the hook files use them for command refs.
*/
function nonCommentLines(content) {
return content.split('\n').filter(line => !/^\s*\/\//.test(line));
}
/**
* Return lines (from nonCommentLines) that contain a user-facing /gsd: ref.
*/
function colonRefs(content) {
return nonCommentLines(content).filter(line => /\/gsd:/.test(line));
}
// ---------------------------------------------------------------------------
// Prerequisite: hooks/dist must exist (built by `npm run build:hooks`)
// ---------------------------------------------------------------------------
describe('bug #376 — prerequisite: hooks/dist is present', () => {
before(() => {
// hooks/dist is gitignored; build it on demand so this test is
// deterministic in CI scoped/windows jobs that don't pre-run build:hooks.
ensureHooksDist();
});
test('hooks/dist directory exists (run npm run build:hooks if missing)', () => {
assert.ok(
fs.existsSync(HOOKS_DIST_DIR),
`hooks/dist not found at ${HOOKS_DIST_DIR}. Run: npm run build:hooks`,
);
});
test('hooks/dist contains at least one .js hook file with a /gsd: literal', () => {
const jsFiles = findJsFiles(HOOKS_DIST_DIR);
assert.ok(jsFiles.length > 0, 'hooks/dist must contain .js files');
const withColonRef = jsFiles.filter(({ full }) => {
const content = fs.readFileSync(full, 'utf-8');
return colonRefs(content).length > 0;
});
assert.ok(
withColonRef.length > 0,
'Expected at least one hooks/dist .js file with a non-comment /gsd: literal ' +
'— this confirms the test is guarding a real regression surface. ' +
`Files checked: ${jsFiles.map(f => f.rel).join(', ')}`,
);
});
});
// ---------------------------------------------------------------------------
// Suite 1 — Claude install: no /gsd: colon refs in installed .js hook files
// ---------------------------------------------------------------------------
describe('bug #376 — Suite 1: Claude install rewrites /gsd: → /gsd- in hook .js files', () => {
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-376-claude-'));
runInstall(tmpDir, ['--claude', '--local', '--no-sdk']);
});
after(() => {
cleanup(tmpDir);
});
test('1a: hooks/ directory is created by the Claude local install', () => {
const hooksDir = path.join(tmpDir, '.claude', 'hooks');
assert.ok(
fs.existsSync(hooksDir),
`hooks/ must be created at ${hooksDir} by Claude local install`,
);
});
test('1b: installed .js hook files contain no user-facing /gsd: colon refs', () => {
const hooksDir = path.join(tmpDir, '.claude', 'hooks');
if (!fs.existsSync(hooksDir)) {
// If hooks/ wasn't created (hooks/dist missing at install time), skip gracefully
return;
}
const jsFiles = findJsFiles(hooksDir);
assert.ok(jsFiles.length > 0, 'At least one .js hook file must be installed');
const offenders = [];
for (const { rel, full } of jsFiles) {
const content = fs.readFileSync(full, 'utf-8');
const badLines = colonRefs(content);
if (badLines.length > 0) {
offenders.push({ rel, lines: badLines });
}
}
assert.deepEqual(
offenders,
[],
'Installed Claude hook .js files must not contain /gsd:<cmd> colon refs ' +
'(non-comment occurrences). The install-time rewriter must replace these with /gsd-<cmd>. ' +
'Offenders: ' + JSON.stringify(offenders, null, 2),
);
});
test('1c: installed .js hook files DO contain the hyphen form /gsd- (rewrite happened)', () => {
const hooksDir = path.join(tmpDir, '.claude', 'hooks');
if (!fs.existsSync(hooksDir)) return;
const jsFiles = findJsFiles(hooksDir);
const withHyphen = jsFiles.filter(({ full }) => {
const content = fs.readFileSync(full, 'utf-8');
return /\/gsd-/.test(content);
});
assert.ok(
withHyphen.length > 0,
'At least one installed .js hook file must contain /gsd- (confirming rewrite ran). ' +
`Files checked: ${jsFiles.map(f => f.rel).join(', ')}`,
);
});
});
// ---------------------------------------------------------------------------
// Suite 2 — Cursor install regression: /gsd: → /gsd- still works (pre-existing)
//
// Note: Cursor installs its own hooks (gsd-cursor-session-start.js and
// gsd-cursor-post-tool.js) via the cursor-hooks-json installSurface (issue #777).
// It does NOT install the bundled Claude-style hooks/dist files (no gsd-session-state.sh
// etc.). The Cursor /gsd: rewrite applies in `copyWithPathReplacement` to JS files
// under the agent/skill tree (.cursor/gsd-core/*.js etc). We verify that Cursor's
// installed .js files under .cursor/ have no /gsd: colon refs, and that the hooks/
// directory contains only the Cursor-specific managed hooks.
// ---------------------------------------------------------------------------
describe('bug #376 — Suite 2: Cursor install still rewrites /gsd: → /gsd- (regression)', () => {
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-376-cursor-'));
runInstall(tmpDir, ['--cursor', '--local', '--no-sdk']);
});
after(() => {
cleanup(tmpDir);
});
test('2a: .cursor/ directory is created by the Cursor local install', () => {
const cursorDir = path.join(tmpDir, '.cursor');
assert.ok(
fs.existsSync(cursorDir),
`Cursor install must create .cursor/ directory at ${cursorDir}`,
);
});
test('2b: Cursor-installed .js files contain no user-facing /gsd: colon refs', () => {
const cursorDir = path.join(tmpDir, '.cursor');
if (!fs.existsSync(cursorDir)) return;
// Infrastructure files whose /gsd: occurrences are intentional implementation
// details — NOT user-facing command references that Cursor would invoke.
//
// scripts/fix-slash-commands.cjs is the slash-command rewriter engine, required
// by gsd-core/bin/lib/command-roster.cjs on ALL runtimes (including Cursor).
// It must be installed verbatim and must NOT be content-rewritten: it needs to
// emit `/gsd:${cmd}` for non-Cursor runtimes, and its /gsd: strings are internal
// implementation/docs (transform patterns, regex literals, template literals),
// not commands a Cursor user would type. Rewriting it would corrupt the transformer.
const INFRA_BASENAMES = new Set(['fix-slash-commands.cjs']);
const jsFiles = findJsFiles(cursorDir);
// Cursor may not install any .js files depending on what agent/skill content exists;
// if none, skip gracefully.
if (jsFiles.length === 0) return;
const offenders = [];
for (const { rel, full } of jsFiles) {
// Skip infrastructure files whose /gsd: strings are intentional (see above).
if (INFRA_BASENAMES.has(path.basename(full))) continue;
const content = fs.readFileSync(full, 'utf-8');
const badLines = colonRefs(content);
if (badLines.length > 0) {
offenders.push({ rel, lines: badLines });
}
}
assert.deepEqual(
offenders,
[],
'Cursor-installed .js files must not contain /gsd:<cmd> colon refs. ' +
'The existing Cursor branch in copyWithPathReplacement must still apply /gsd:/gi → gsd- rewrite. ' +
'Offenders: ' + JSON.stringify(offenders, null, 2),
);
});
test('2c: Cursor install creates a hooks/ directory with only Cursor-specific managed hooks', () => {
// Since issue #777, Cursor installs gsd-cursor-session-start.js and
// gsd-cursor-post-tool.js into <configDir>/hooks/. These are Cursor-native
// hooks — NOT the bundled Claude-style hooks (no gsd-session-state.sh etc.).
// Verify: hooks/ exists AND does NOT contain any Claude-bundled hooks.
const hooksDir = path.join(tmpDir, '.cursor', 'hooks');
assert.ok(
fs.existsSync(hooksDir),
'Cursor install must create a hooks/ directory for its managed hook scripts (#777)',
);
const CLAUDE_BUNDLED_HOOKS = ['gsd-session-state.sh', 'gsd-context-monitor.js', 'gsd-statusline.js'];
for (const hook of CLAUDE_BUNDLED_HOOKS) {
assert.strictEqual(
fs.existsSync(path.join(hooksDir, hook)),
false,
`Cursor hooks/ must NOT contain Claude-bundled hook ${hook} — only Cursor-native hooks are installed`,
);
}
// The two Cursor-specific managed hooks must be present.
assert.ok(
fs.existsSync(path.join(hooksDir, 'gsd-cursor-session-start.js')),
'gsd-cursor-session-start.js must be installed in .cursor/hooks/ (#777)',
);
assert.ok(
fs.existsSync(path.join(hooksDir, 'gsd-cursor-post-tool.js')),
'gsd-cursor-post-tool.js must be installed in .cursor/hooks/ (#777)',
);
});
});
// ---------------------------------------------------------------------------
// Suite 3 — Source files in hooks/ are untouched
// ---------------------------------------------------------------------------
describe('bug #376 — Suite 3: hooks/ source files are unchanged by install', () => {
let snapshotBefore;
before(() => {
// Ensure hooks/dist is built before snapshotting; it may be absent in CI
// scoped/windows jobs that don't pre-run build:hooks (#777 fix).
ensureHooksDist();
// Snapshot hooks/dist JS files before any install in this suite
snapshotBefore = {};
if (fs.existsSync(HOOKS_DIST_DIR)) {
for (const { rel, full } of findJsFiles(HOOKS_DIST_DIR)) {
snapshotBefore[rel] = fs.readFileSync(full, 'utf-8');
}
}
});
test('3a: hooks/dist .js source files still contain /gsd: literals (not mutated)', () => {
// The source must remain in colon form — the rewrite is install-time only
const jsFiles = findJsFiles(HOOKS_DIST_DIR);
const withColonRef = jsFiles.filter(({ full }) => {
const content = fs.readFileSync(full, 'utf-8');
return colonRefs(content).length > 0;
});
// We know from the prerequisite suite that at least one file had a colon ref;
// if the source was mutated by install, this would now be zero.
assert.ok(
withColonRef.length > 0,
'hooks/dist .js files must still contain /gsd: literals after install — ' +
'the install-time rewrite must NOT modify the source tree. ' +
`Files that still have colon refs: ${withColonRef.map(f => f.rel).join(', ')}`,
);
});
test('3b: hooks/dist .js source file contents match pre-test snapshot (byte-identical)', () => {
if (Object.keys(snapshotBefore).length === 0) {
// hooks/dist was absent before; skip
return;
}
for (const [rel, before] of Object.entries(snapshotBefore)) {
const full = path.join(HOOKS_DIST_DIR, rel);
const after = fs.readFileSync(full, 'utf-8');
assert.strictEqual(
after,
before,
`hooks/dist/${rel} was mutated by install — install must only rewrite the installed copy, not the source`,
);
}
});
});
// ---------------------------------------------------------------------------
// Suite 4 — Pure-function: shouldNormalizeHyphenNamespaceInAgentBody covers claude
// ---------------------------------------------------------------------------
describe('bug #376 — Suite 4: shouldNormalizeHyphenNamespaceInAgentBody covers claude', () => {
const install = require(INSTALL_PATH);
test('4a: shouldNormalizeHyphenNamespaceInAgentBody is exported', () => {
assert.strictEqual(
typeof install.shouldNormalizeHyphenNamespaceInAgentBody,
'function',
'install.js must export shouldNormalizeHyphenNamespaceInAgentBody',
);
});
test('4b: claude is in the hyphen-namespace set', () => {
assert.strictEqual(
install.shouldNormalizeHyphenNamespaceInAgentBody('claude'),
true,
'claude must be a hyphen-namespace runtime',
);
});
test('4c: qwen is in the hyphen-namespace set', () => {
assert.strictEqual(
install.shouldNormalizeHyphenNamespaceInAgentBody('qwen'),
true,
);
});
test('4d: hermes is in the hyphen-namespace set', () => {
assert.strictEqual(
install.shouldNormalizeHyphenNamespaceInAgentBody('hermes'),
true,
);
});
test('4e: gemini is NOT in the hyphen-namespace set', () => {
assert.strictEqual(
install.shouldNormalizeHyphenNamespaceInAgentBody('gemini'),
false,
'gemini intentionally keeps colon namespace and must not be in the hyphen set',
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/feat-443-effort-defaults-drift.test.cjs — consolidation epic #1969 (B5 #1974)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:feat-443-effort-defaults-drift (consolidation epic #1969 B5 #1974)", () => {
'use strict';
/**
* feat-443-effort-defaults-drift.test.cjs
*
* Drift-guard: asserts that install.js's resolved baseline effort defaults
* equal config-defaults.manifest.json's effort block. Any future divergence
* (someone edits the manifest without updating install.js or vice-versa) fails
* CI immediately rather than silently injecting stale effort values.
*
* Real assertions on runtime values — no source-grep.
*/
// MUST be set before require('bin/install.js') so the main install block
// (guarded by !GSD_TEST_MODE) does not execute and perform a real global
// install into $HOME/.claude/ — which would leak gsd-tools.cjs into the
// ambient HOME and break runtime-launcher-parity test (D) in the same
// node --test run (all unit tests share the same HOME on CI).
process.env.GSD_TEST_MODE = '1';
const assert = require('assert');
const path = require('path');
const { test } = require('node:test');
// Load the manifest directly (JSON, not a .cjs source file — allowed by lint rule)
const manifestPath = path.join(
__dirname,
'..',
'gsd-core',
'bin',
'shared',
'config-defaults.manifest.json'
);
const manifest = require(manifestPath);
// Load install.js exported values (executes the module, not text inspection)
const installPath = path.join(__dirname, '..', 'bin', 'install.js');
const {
_GSD_EFFORT_MANIFEST_TIER_DEFAULTS,
_GSD_EFFORT_MANIFEST_DEFAULT,
} = require(installPath);
test('install.js _GSD_EFFORT_MANIFEST_TIER_DEFAULTS.light matches manifest effort.routing_tier_defaults.light', () => {
assert.strictEqual(
_GSD_EFFORT_MANIFEST_TIER_DEFAULTS.light,
manifest.effort.routing_tier_defaults.light,
`install.js tier default for "light" (${_GSD_EFFORT_MANIFEST_TIER_DEFAULTS.light}) differs from manifest (${manifest.effort.routing_tier_defaults.light})`
);
});
test('install.js _GSD_EFFORT_MANIFEST_TIER_DEFAULTS.standard matches manifest effort.routing_tier_defaults.standard', () => {
assert.strictEqual(
_GSD_EFFORT_MANIFEST_TIER_DEFAULTS.standard,
manifest.effort.routing_tier_defaults.standard,
`install.js tier default for "standard" (${_GSD_EFFORT_MANIFEST_TIER_DEFAULTS.standard}) differs from manifest (${manifest.effort.routing_tier_defaults.standard})`
);
});
test('install.js _GSD_EFFORT_MANIFEST_TIER_DEFAULTS.heavy matches manifest effort.routing_tier_defaults.heavy', () => {
assert.strictEqual(
_GSD_EFFORT_MANIFEST_TIER_DEFAULTS.heavy,
manifest.effort.routing_tier_defaults.heavy,
`install.js tier default for "heavy" (${_GSD_EFFORT_MANIFEST_TIER_DEFAULTS.heavy}) differs from manifest (${manifest.effort.routing_tier_defaults.heavy})`
);
});
test('install.js _GSD_EFFORT_MANIFEST_DEFAULT matches manifest effort.default', () => {
assert.strictEqual(
_GSD_EFFORT_MANIFEST_DEFAULT,
manifest.effort.default,
`install.js effort default (${_GSD_EFFORT_MANIFEST_DEFAULT}) differs from manifest (${manifest.effort.default})`
);
});
test('install.js tier-defaults object has exactly the same keys as manifest effort.routing_tier_defaults', () => {
const installKeys = Object.keys(_GSD_EFFORT_MANIFEST_TIER_DEFAULTS).sort();
const manifestKeys = Object.keys(manifest.effort.routing_tier_defaults).sort();
assert.deepStrictEqual(
installKeys,
manifestKeys,
`Key mismatch — install.js: [${installKeys.join(', ')}], manifest: [${manifestKeys.join(', ')}]`
);
});
});
}