Emit the 6 gsd-ns-* routers as the only top-level skill bundles and nest the ~61 concrete skills under <router>/skills/<name>/SKILL.md on runtimes with confirmed non-recursive skill loaders (claude global, cline, qwen, hermes, augment, trae, antigravity). Router bodies rewrite their routing tables from Skill-tool dispatch to a Read skills/<name>/SKILL.md pattern. Recursive/unconfirmed loaders (cursor, codex, copilot, windsurf, codebuddy, opencode, kilo) keep the flat layout. Completes the v1.40 namespace architecture (#2792) so the eager skill listing drops to ~6 entries. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
818 lines
31 KiB
JavaScript
818 lines
31 KiB
JavaScript
// 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 9–11 + 13.
|
||
*
|
||
* Covers: install-profiles unit tests (MINIMAL_SKILL_ALLOWLIST, isMinimalMode,
|
||
* shouldInstallSkill, stageSkillsForMode, cleanupStagedSkills),
|
||
* --minimal per-runtime E2E (spawned), --minimal manifest mode + downgrade,
|
||
* and hooks copy / manifest / uninstall settings cleanup.
|
||
*
|
||
* Consolidates (original sources from #3758):
|
||
* install-minimal.test.cjs
|
||
* install-minimal-all-runtimes.test.cjs
|
||
* install-minimal-backcompat.test.cjs
|
||
* install-hooks-copy.test.cjs
|
||
*
|
||
* Closes #3758
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
process.env.GSD_TEST_MODE = '1';
|
||
|
||
const { test, describe, beforeEach, afterEach, before } = 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 { spawnSync, execFileSync } = require('node:child_process');
|
||
|
||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||
|
||
const {
|
||
writeManifest,
|
||
GSD_UNINSTALL_HOOKS,
|
||
} = require('../bin/install.js');
|
||
|
||
const {
|
||
MINIMAL_SKILL_ALLOWLIST,
|
||
PROFILES,
|
||
isMinimalMode,
|
||
shouldInstallSkill,
|
||
stageSkillsForMode,
|
||
cleanupStagedSkills,
|
||
} = require('../gsd-core/bin/lib/install-profiles.cjs');
|
||
|
||
const {
|
||
INSTALL_SCRIPT,
|
||
MANIFEST_NAME,
|
||
BUILD_SCRIPT,
|
||
HOOKS_DIST,
|
||
EXPECTED_SH_HOOKS,
|
||
EXPECTED_ALL_HOOKS,
|
||
SKILL_RUNTIMES,
|
||
simulateHookCopy,
|
||
installerEnv,
|
||
runMinimalInstall,
|
||
manifestSkillSet,
|
||
manifestAgentCount,
|
||
collectSkillBasenamesOnDisk,
|
||
} = require('./helpers/install-shared.cjs');
|
||
|
||
// ─── Section 9: install-profiles — MINIMAL_SKILL_ALLOWLIST ───────────────────
|
||
|
||
describe('install-profiles: MINIMAL_SKILL_ALLOWLIST', () => {
|
||
test('contains exactly the main-loop core (frozen)', () => {
|
||
assert.deepStrictEqual(
|
||
[...MINIMAL_SKILL_ALLOWLIST].sort(),
|
||
['discuss-phase', 'execute-phase', 'help', 'new-project', 'phase', 'plan-phase', 'surface', 'update'],
|
||
);
|
||
assert.ok(Object.isFrozen(MINIMAL_SKILL_ALLOWLIST));
|
||
});
|
||
|
||
test('every allowlisted skill exists in commands/gsd/', () => {
|
||
const commandsDir = path.join(__dirname, '..', 'commands', 'gsd');
|
||
for (const name of MINIMAL_SKILL_ALLOWLIST) {
|
||
assert.ok(
|
||
fs.existsSync(path.join(commandsDir, `${name}.md`)),
|
||
`${name} is allowlisted but commands/gsd/${name}.md does not exist`,
|
||
);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ─── #834: --help profile skill counts must track PROFILES ───────────────────
|
||
|
||
describe('install: --help profile counts match PROFILES (#834)', () => {
|
||
function helpText() {
|
||
return execFileSync(process.execPath, [INSTALL_SCRIPT, '--help'], {
|
||
encoding: 'utf8',
|
||
env: installerEnv(),
|
||
});
|
||
}
|
||
|
||
test('core line advertises PROFILES.core.length main-loop skills', () => {
|
||
const out = helpText();
|
||
const m = out.match(/core\s+—\s+~?(\d+)\s+main-loop skills/);
|
||
assert.ok(m, `--help must advertise a core profile skill count; got:\n${out}`);
|
||
assert.strictEqual(
|
||
Number(m[1]),
|
||
PROFILES.core.length,
|
||
`--help core count (${m[1]}) must equal PROFILES.core.length (${PROFILES.core.length})`,
|
||
);
|
||
});
|
||
|
||
test('standard line advertises PROFILES.standard.length skills', () => {
|
||
const out = helpText();
|
||
const m = out.match(/standard\s+—\s+~?(\d+)\s+skills/);
|
||
assert.ok(m, `--help must advertise a standard profile skill count; got:\n${out}`);
|
||
assert.strictEqual(
|
||
Number(m[1]),
|
||
PROFILES.standard.length,
|
||
`--help standard count (${m[1]}) must equal PROFILES.standard.length (${PROFILES.standard.length})`,
|
||
);
|
||
});
|
||
|
||
test('full line does not hardcode a drift-prone skill count', () => {
|
||
const out = helpText();
|
||
const m = out.match(/full\s+—\s+([^\n]*?)\s+\(default\)/);
|
||
assert.ok(m, `--help must advertise a full profile line; got:\n${out}`);
|
||
assert.doesNotMatch(
|
||
m[1],
|
||
/\d/,
|
||
`--help full line must not hardcode a numeric skill count (drifts); got: "${m[1]}"`,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('install-profiles: isMinimalMode', () => {
|
||
test('returns true only for "minimal"', () => {
|
||
assert.strictEqual(isMinimalMode('minimal'), true);
|
||
assert.strictEqual(isMinimalMode('full'), false);
|
||
assert.strictEqual(isMinimalMode(''), false);
|
||
assert.strictEqual(isMinimalMode(undefined), false);
|
||
assert.strictEqual(isMinimalMode(null), false);
|
||
assert.strictEqual(isMinimalMode('MINIMAL'), false);
|
||
});
|
||
});
|
||
|
||
describe('install-profiles: shouldInstallSkill', () => {
|
||
test('full mode admits every skill', () => {
|
||
assert.strictEqual(shouldInstallSkill('plan-phase', 'full'), true);
|
||
assert.strictEqual(shouldInstallSkill('autonomous', 'full'), true);
|
||
assert.strictEqual(shouldInstallSkill('arbitrary-future-name', 'full'), true);
|
||
});
|
||
|
||
test('minimal mode admits only allowlisted skills', () => {
|
||
for (const name of MINIMAL_SKILL_ALLOWLIST) {
|
||
assert.strictEqual(shouldInstallSkill(name, 'minimal'), true, name);
|
||
}
|
||
for (const denied of ['autonomous', 'do', 'progress', 'next', 'fast', 'quick']) {
|
||
assert.strictEqual(shouldInstallSkill(denied, 'minimal'), false, denied);
|
||
}
|
||
});
|
||
|
||
test('minimal mode rejects .md-suffixed names (callers must strip)', () => {
|
||
assert.strictEqual(shouldInstallSkill('plan-phase.md', 'minimal'), false);
|
||
});
|
||
|
||
test('unknown mode falls through to full behavior', () => {
|
||
for (const unknownMode of ['compact', 'tier2', 'CORE', 'Minimal', 'mini']) {
|
||
assert.ok(shouldInstallSkill('autonomous', unknownMode),
|
||
`unknown mode "${unknownMode}" should admit all skills`);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('install-profiles: stageSkillsForMode', () => {
|
||
function createFixtureSkillsDir() {
|
||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stage-fixture-'));
|
||
for (const name of ['plan-phase', 'execute-phase', 'autonomous', 'do', 'help',
|
||
'new-project', 'phase', 'discuss-phase', 'update', 'progress', 'surface']) {
|
||
fs.writeFileSync(path.join(tmp, `${name}.md`), `# ${name}\n`);
|
||
}
|
||
return tmp;
|
||
}
|
||
|
||
test('full mode returns original src dir unchanged', () => {
|
||
const src = createFixtureSkillsDir();
|
||
try {
|
||
assert.strictEqual(stageSkillsForMode(src, 'full'), src);
|
||
} finally {
|
||
cleanup(src);
|
||
}
|
||
});
|
||
|
||
test('minimal mode returns new dir with only allowlisted skills', () => {
|
||
const src = createFixtureSkillsDir();
|
||
let staged;
|
||
try {
|
||
staged = stageSkillsForMode(src, 'minimal');
|
||
assert.notStrictEqual(staged, src);
|
||
assert.deepStrictEqual(
|
||
fs.readdirSync(staged).sort(),
|
||
['discuss-phase.md', 'execute-phase.md', 'help.md', 'new-project.md',
|
||
'phase.md', 'plan-phase.md', 'surface.md', 'update.md'],
|
||
);
|
||
} finally {
|
||
cleanup(src);
|
||
cleanup(staged);
|
||
}
|
||
});
|
||
|
||
test('minimal mode preserves file content byte-for-byte', () => {
|
||
const src = createFixtureSkillsDir();
|
||
let staged;
|
||
try {
|
||
staged = stageSkillsForMode(src, 'minimal');
|
||
const original = fs.readFileSync(path.join(src, 'plan-phase.md'), 'utf8');
|
||
const copied = fs.readFileSync(path.join(staged, 'plan-phase.md'), 'utf8');
|
||
assert.strictEqual(copied, original);
|
||
} finally {
|
||
cleanup(src);
|
||
cleanup(staged);
|
||
}
|
||
});
|
||
|
||
test('minimal mode against non-existent source returns source path', () => {
|
||
const ghost = path.join(os.tmpdir(), 'gsd-stage-does-not-exist-' + Date.now());
|
||
assert.strictEqual(stageSkillsForMode(ghost, 'minimal'), ghost);
|
||
});
|
||
|
||
test('minimal mode skips non-md files and subdirectories', () => {
|
||
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stage-mixed-'));
|
||
let staged;
|
||
try {
|
||
fs.writeFileSync(path.join(src, 'plan-phase.md'), '# plan\n');
|
||
fs.writeFileSync(path.join(src, 'README.txt'), 'not a skill\n');
|
||
fs.mkdirSync(path.join(src, 'nested-dir'));
|
||
fs.writeFileSync(path.join(src, 'nested-dir', 'plan-phase.md'), '# nested\n');
|
||
staged = stageSkillsForMode(src, 'minimal');
|
||
assert.deepStrictEqual(fs.readdirSync(staged), ['plan-phase.md']);
|
||
} finally {
|
||
cleanup(src);
|
||
cleanup(staged);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('install-profiles: cleanupStagedSkills', () => {
|
||
test('removes staged dirs created during process', () => {
|
||
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stage-cleanup-'));
|
||
fs.writeFileSync(path.join(src, 'plan-phase.md'), '# plan\n');
|
||
try {
|
||
const a = stageSkillsForMode(src, 'minimal');
|
||
const b = stageSkillsForMode(src, 'minimal');
|
||
assert.notStrictEqual(a, b);
|
||
assert.ok(fs.existsSync(a));
|
||
assert.ok(fs.existsSync(b));
|
||
cleanupStagedSkills();
|
||
assert.ok(!fs.existsSync(a));
|
||
assert.ok(!fs.existsSync(b));
|
||
} finally {
|
||
cleanup(src);
|
||
}
|
||
});
|
||
|
||
test('is idempotent', () => {
|
||
cleanupStagedSkills();
|
||
cleanupStagedSkills();
|
||
});
|
||
|
||
test('exit handler registers at most once across many calls', () => {
|
||
cleanupStagedSkills();
|
||
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stage-exit-handler-'));
|
||
fs.writeFileSync(path.join(src, 'plan-phase.md'), '# plan\n');
|
||
try {
|
||
const before = process.listenerCount('exit');
|
||
for (let i = 0; i < 5; i++) stageSkillsForMode(src, 'minimal');
|
||
const after = process.listenerCount('exit');
|
||
assert.ok(after - before <= 1, `expected <=1 new exit listener, got ${after - before}`);
|
||
} finally {
|
||
cleanup(src);
|
||
cleanupStagedSkills();
|
||
}
|
||
});
|
||
|
||
test('mid-copy failure removes partial staged dir and re-throws', () => {
|
||
cleanupStagedSkills();
|
||
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stage-fail-'));
|
||
fs.writeFileSync(path.join(src, 'plan-phase.md'), '# plan\n');
|
||
fs.writeFileSync(path.join(src, 'execute-phase.md'), '# x\n');
|
||
const realCopy = fs.copyFileSync;
|
||
const realMkdtemp = fs.mkdtempSync;
|
||
let stagedDir = null;
|
||
fs.mkdtempSync = (prefix, ...rest) => {
|
||
const out = realMkdtemp(prefix, ...rest);
|
||
if (typeof prefix === 'string' && prefix.endsWith('gsd-minimal-skills-')) stagedDir = out;
|
||
return out;
|
||
};
|
||
let copyCount = 0;
|
||
fs.copyFileSync = (s, d) => {
|
||
copyCount++;
|
||
if (copyCount === 2) throw new Error('synthetic disk full');
|
||
return realCopy(s, d);
|
||
};
|
||
try {
|
||
assert.throws(() => stageSkillsForMode(src, 'minimal'), /synthetic disk full/);
|
||
assert.notStrictEqual(stagedDir, null);
|
||
assert.equal(fs.existsSync(stagedDir), false);
|
||
} finally {
|
||
fs.copyFileSync = realCopy;
|
||
fs.mkdtempSync = realMkdtemp;
|
||
cleanup(src);
|
||
cleanupStagedSkills();
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('install-profiles: allowlist scope guards', () => {
|
||
test('every main-loop command is in the allowlist', () => {
|
||
for (const required of ['new-project', 'discuss-phase', 'plan-phase', 'execute-phase']) {
|
||
assert.ok(shouldInstallSkill(required, 'minimal'), `"${required}" must be in allowlist`);
|
||
}
|
||
});
|
||
|
||
test('off-loop commands are NOT in the allowlist', () => {
|
||
for (const offLoop of ['autonomous', 'ship', 'do', 'progress', 'next', 'fast', 'quick', 'debug', 'code-review', 'verify-work']) {
|
||
assert.ok(!shouldInstallSkill(offLoop, 'minimal'), `"${offLoop}" must NOT be in allowlist`);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ─── Section 10: --minimal install — per-runtime E2E (spawned) ───────────────
|
||
|
||
describe('install: --minimal honoured for every runtime in --global mode', () => {
|
||
for (const runtime of SKILL_RUNTIMES) {
|
||
test(`${runtime} --global --minimal: mode=minimal, correct skills, zero agents`, () => {
|
||
const { manifest, root } = runMinimalInstall({ runtime, scope: 'global', extraArgs: ['--minimal'] });
|
||
try {
|
||
assert.ok(manifest, `${runtime} global must produce manifest`);
|
||
assert.strictEqual(manifest.mode, 'minimal');
|
||
assert.deepStrictEqual(
|
||
[...manifestSkillSet(manifest)].sort(),
|
||
[...MINIMAL_SKILL_ALLOWLIST].sort(),
|
||
);
|
||
assert.strictEqual(manifestAgentCount(manifest), 0);
|
||
} finally {
|
||
cleanup(root);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
describe('install: --minimal honoured for every runtime in --local mode', () => {
|
||
for (const runtime of SKILL_RUNTIMES) {
|
||
test(`${runtime} --local --minimal: mode=minimal, correct skills, zero agents`, () => {
|
||
const { manifest, root } = runMinimalInstall({ runtime, scope: 'local', extraArgs: ['--minimal'] });
|
||
try {
|
||
assert.ok(manifest, `${runtime} local must produce manifest`);
|
||
assert.strictEqual(manifest.mode, 'minimal');
|
||
assert.deepStrictEqual(
|
||
[...manifestSkillSet(manifest)].sort(),
|
||
[...MINIMAL_SKILL_ALLOWLIST].sort(),
|
||
);
|
||
assert.strictEqual(manifestAgentCount(manifest), 0);
|
||
} finally {
|
||
cleanup(root);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
describe('install: Cline --minimal (rules-based, no skills/ dir)', () => {
|
||
for (const scope of ['global', 'local']) {
|
||
test(`cline --${scope} --minimal: mode=minimal, zero agents, .clinerules present`, () => {
|
||
const { manifest, configDir, root } = runMinimalInstall({
|
||
runtime: 'cline', scope, extraArgs: ['--minimal'],
|
||
});
|
||
try {
|
||
assert.ok(manifest, 'cline must produce manifest');
|
||
assert.strictEqual(manifest.mode, 'minimal');
|
||
assert.strictEqual(manifestAgentCount(manifest), 0);
|
||
assert.ok(fs.existsSync(path.join(configDir, '.clinerules')));
|
||
} finally {
|
||
cleanup(root);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
describe('install: on-disk skill files match manifest for --minimal', () => {
|
||
for (const runtime of SKILL_RUNTIMES) {
|
||
for (const scope of ['global', 'local']) {
|
||
test(`${runtime} --${scope} --minimal: on-disk matches manifest`, () => {
|
||
const { manifest, configDir, root } = runMinimalInstall({
|
||
runtime, scope, extraArgs: ['--minimal'],
|
||
});
|
||
try {
|
||
assert.ok(manifest);
|
||
const onDisk = collectSkillBasenamesOnDisk(configDir);
|
||
const inManifest = manifestSkillSet(manifest);
|
||
assert.deepStrictEqual([...onDisk].sort(), [...inManifest].sort());
|
||
const agentsDir = path.join(configDir, 'agents');
|
||
if (fs.existsSync(agentsDir)) {
|
||
const gsdAgents = fs.readdirSync(agentsDir)
|
||
.filter(f => f.startsWith('gsd-') && f.endsWith('.md'));
|
||
assert.deepStrictEqual(gsdAgents, []);
|
||
}
|
||
} finally {
|
||
cleanup(root);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
// ─── Section 11: --minimal manifest mode + downgrade ─────────────────────────
|
||
|
||
describe('install: manifest records mode for both profiles', () => {
|
||
function manifestModeAfterInstall(extraArgs) {
|
||
const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-manifest-mode-'));
|
||
try {
|
||
spawnSync(
|
||
process.execPath,
|
||
[INSTALL_SCRIPT, '--claude', '--global', '--config-dir', targetDir, ...extraArgs],
|
||
{ encoding: 'utf8', env: installerEnv() },
|
||
);
|
||
const manifestPath = path.join(targetDir, MANIFEST_NAME);
|
||
if (!fs.existsSync(manifestPath)) return { mode: '<no manifest>', skillCount: 0, agentCount: 0 };
|
||
const m = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||
// Count SKILL.md files under skills/ (works for both flat and ns-nested layouts).
|
||
const skillCount = Object.keys(m.files || {}).filter(
|
||
k => k.startsWith('skills/') && k.endsWith('/SKILL.md'),
|
||
).length;
|
||
const agentCount = Object.keys(m.files || {}).filter(k => k.startsWith('agents/')).length;
|
||
return { mode: m.mode, skillCount, agentCount };
|
||
} finally {
|
||
cleanup(targetDir);
|
||
}
|
||
}
|
||
|
||
test('default install records mode: "full" with full skill+agent count', () => {
|
||
const r = manifestModeAfterInstall([]);
|
||
assert.strictEqual(r.mode, 'full');
|
||
assert.ok(r.skillCount > 7);
|
||
assert.ok(r.agentCount > 0);
|
||
});
|
||
|
||
test('--minimal records mode: "minimal" with exactly 8 skills and 0 agents', () => {
|
||
const r = manifestModeAfterInstall(['--minimal']);
|
||
assert.strictEqual(r.mode, 'minimal');
|
||
assert.strictEqual(r.skillCount, 8);
|
||
assert.strictEqual(r.agentCount, 0);
|
||
});
|
||
|
||
test('--core-only is an alias for --minimal', () => {
|
||
const r = manifestModeAfterInstall(['--core-only']);
|
||
assert.strictEqual(r.mode, 'minimal');
|
||
assert.strictEqual(r.skillCount, 8);
|
||
assert.strictEqual(r.agentCount, 0);
|
||
});
|
||
});
|
||
|
||
describe('install-minimal-backcompat: PROFILES.core matches MINIMAL_SKILL_ALLOWLIST', () => {
|
||
test('PROFILES.core contains the same 8 skills as MINIMAL_SKILL_ALLOWLIST', () => {
|
||
assert.deepStrictEqual(
|
||
[...PROFILES.core].sort(),
|
||
[...MINIMAL_SKILL_ALLOWLIST].sort(),
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('install-minimal-backcompat: --minimal and --profile=core produce same manifest', () => {
|
||
function installAndGetManifest(extraArgs) {
|
||
const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-backcompat-'));
|
||
try {
|
||
spawnSync(
|
||
process.execPath,
|
||
[INSTALL_SCRIPT, '--claude', '--global', '--config-dir', targetDir, ...extraArgs],
|
||
{ encoding: 'utf8', env: installerEnv() },
|
||
);
|
||
const manifestPath = path.join(targetDir, MANIFEST_NAME);
|
||
if (!fs.existsSync(manifestPath)) return { mode: null, skillCount: 0, profileMarker: null };
|
||
const m = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||
// Count SKILL.md files under skills/ (works for both flat and ns-nested layouts).
|
||
const skillCount = Object.keys(m.files || {}).filter(
|
||
k => k.startsWith('skills/') && k.endsWith('/SKILL.md'),
|
||
).length;
|
||
const markerPath = path.join(targetDir, '.gsd-profile');
|
||
const profileMarker = fs.existsSync(markerPath) ? fs.readFileSync(markerPath, 'utf8').trim() : null;
|
||
return { mode: m.mode, skillCount, profileMarker };
|
||
} finally {
|
||
cleanup(targetDir);
|
||
}
|
||
}
|
||
|
||
test('--minimal produces mode "minimal" with exactly 8 skills', () => {
|
||
const r = installAndGetManifest(['--minimal']);
|
||
assert.strictEqual(r.mode, 'minimal');
|
||
assert.strictEqual(r.skillCount, 8);
|
||
});
|
||
|
||
test('--minimal writes .gsd-profile marker "core"', () => {
|
||
const r = installAndGetManifest(['--minimal']);
|
||
assert.strictEqual(r.profileMarker, 'core');
|
||
});
|
||
|
||
test('default install writes .gsd-profile marker "full"', () => {
|
||
const r = installAndGetManifest([]);
|
||
assert.strictEqual(r.profileMarker, 'full');
|
||
});
|
||
|
||
test('--profile=core writes .gsd-profile marker "core"', () => {
|
||
const r = installAndGetManifest(['--profile=core']);
|
||
assert.strictEqual(r.profileMarker, 'core');
|
||
});
|
||
|
||
test('--profile=standard writes .gsd-profile marker "standard"', () => {
|
||
const r = installAndGetManifest(['--profile=standard']);
|
||
assert.strictEqual(r.profileMarker, 'standard');
|
||
});
|
||
});
|
||
|
||
describe('install: Codex full → minimal downgrade cleans stale agent state', () => {
|
||
test('--minimal removes stale .toml agents and strips [agents.gsd-*] from config.toml', () => {
|
||
const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-codex-downgrade-'));
|
||
try {
|
||
const agentsDir = path.join(targetDir, 'agents');
|
||
fs.mkdirSync(agentsDir, { recursive: true });
|
||
fs.writeFileSync(path.join(agentsDir, 'gsd-executor.md'), 'stale\n');
|
||
fs.writeFileSync(path.join(agentsDir, 'gsd-planner.md'), 'stale\n');
|
||
fs.writeFileSync(path.join(agentsDir, 'gsd-executor.toml'), 'name = "gsd-executor"\n');
|
||
fs.writeFileSync(path.join(agentsDir, 'gsd-planner.toml'), 'name = "gsd-planner"\n');
|
||
fs.writeFileSync(path.join(agentsDir, 'my-custom-agent.md'), 'user owns this\n');
|
||
const codexConfig = [
|
||
'# user-owned setting',
|
||
'model = "gpt-5"',
|
||
'',
|
||
'# GSD Agent Configuration — managed by gsd-core installer',
|
||
'[agents.gsd-executor]',
|
||
'cmd = "stale"',
|
||
'',
|
||
'[agents.gsd-planner]',
|
||
'cmd = "stale"',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(path.join(targetDir, 'config.toml'), codexConfig);
|
||
|
||
const result = spawnSync(
|
||
process.execPath,
|
||
[INSTALL_SCRIPT, '--codex', '--global', '--config-dir', targetDir, '--minimal'],
|
||
{ encoding: 'utf8', env: installerEnv() },
|
||
);
|
||
assert.ok(result.stdout || result.stderr);
|
||
|
||
const remaining = fs.existsSync(agentsDir) ? fs.readdirSync(agentsDir) : [];
|
||
assert.ok(!remaining.includes('gsd-executor.md'));
|
||
assert.ok(!remaining.includes('gsd-planner.md'));
|
||
assert.ok(!remaining.includes('gsd-executor.toml'));
|
||
assert.ok(!remaining.includes('gsd-planner.toml'));
|
||
assert.ok(remaining.includes('my-custom-agent.md'));
|
||
|
||
const configPath = path.join(targetDir, 'config.toml');
|
||
if (fs.existsSync(configPath)) {
|
||
const config = fs.readFileSync(configPath, 'utf8');
|
||
assert.ok(!config.includes('[agents.gsd-executor]'));
|
||
assert.ok(!config.includes('[agents.gsd-planner]'));
|
||
assert.ok(config.includes('model = "gpt-5"'));
|
||
}
|
||
assert.ok(fs.existsSync(configPath));
|
||
} finally {
|
||
cleanup(targetDir);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('install: Claude full → minimal downgrade removes stale agents', () => {
|
||
test('--minimal removes stale gsd-*.md agents but preserves user-owned agents', () => {
|
||
const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-claude-downgrade-'));
|
||
try {
|
||
const agentsDir = path.join(targetDir, 'agents');
|
||
fs.mkdirSync(agentsDir, { recursive: true });
|
||
fs.writeFileSync(path.join(agentsDir, 'gsd-executor.md'), 'stale\n');
|
||
fs.writeFileSync(path.join(agentsDir, 'gsd-planner.md'), 'stale\n');
|
||
fs.writeFileSync(path.join(agentsDir, 'my-custom-agent.md'), 'user owns this\n');
|
||
|
||
spawnSync(
|
||
process.execPath,
|
||
[INSTALL_SCRIPT, '--claude', '--global', '--config-dir', targetDir, '--minimal'],
|
||
{ encoding: 'utf8', env: installerEnv() },
|
||
);
|
||
|
||
const remaining = fs.existsSync(agentsDir) ? fs.readdirSync(agentsDir) : [];
|
||
assert.ok(!remaining.includes('gsd-executor.md'));
|
||
assert.ok(!remaining.includes('gsd-planner.md'));
|
||
assert.ok(remaining.includes('my-custom-agent.md'));
|
||
assert.deepStrictEqual(remaining.filter(f => f.startsWith('gsd-')), []);
|
||
} finally {
|
||
cleanup(targetDir);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ─── Section 13: Hooks copy, manifest, uninstall settings cleanup ─────────────
|
||
|
||
before(() => {
|
||
execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' });
|
||
});
|
||
|
||
const isWindows = process.platform === 'win32';
|
||
|
||
describe('#1755: .sh hooks are copied and executable after install', () => {
|
||
let tmpDir;
|
||
beforeEach(() => { tmpDir = createTempDir('gsd-hook-copy-'); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
test('all expected hooks are copied from hooks/dist/ to target', () => {
|
||
const hooksDest = path.join(tmpDir, 'hooks');
|
||
simulateHookCopy(HOOKS_DIST, hooksDest);
|
||
for (const hook of EXPECTED_ALL_HOOKS) {
|
||
assert.ok(fs.existsSync(path.join(hooksDest, hook)), `${hook} should exist`);
|
||
}
|
||
});
|
||
|
||
test('.sh hooks are executable after copy', {
|
||
skip: isWindows ? 'Windows has no POSIX file permissions' : false,
|
||
}, () => {
|
||
const hooksDest = path.join(tmpDir, 'hooks');
|
||
simulateHookCopy(HOOKS_DIST, hooksDest);
|
||
for (const sh of EXPECTED_SH_HOOKS) {
|
||
const stat = fs.statSync(path.join(hooksDest, sh));
|
||
assert.ok((stat.mode & 0o111) !== 0, `${sh} should be executable`);
|
||
}
|
||
});
|
||
|
||
test('.js hooks are executable after copy', {
|
||
skip: isWindows ? 'Windows has no POSIX file permissions' : false,
|
||
}, () => {
|
||
const hooksDest = path.join(tmpDir, 'hooks');
|
||
simulateHookCopy(HOOKS_DIST, hooksDest);
|
||
for (const js of EXPECTED_ALL_HOOKS.filter(h => h.endsWith('.js'))) {
|
||
const stat = fs.statSync(path.join(hooksDest, js));
|
||
assert.ok((stat.mode & 0o111) !== 0, `${js} should be executable`);
|
||
}
|
||
});
|
||
});
|
||
|
||
// Migrated (#455): uses typed export GSD_UNINSTALL_HOOKS instead of
|
||
// source-grep assertions on bin/install.js for the uninstall hook list tests.
|
||
describe('install.js uninstall hooks registry (typed assertions)', () => {
|
||
test('GSD_UNINSTALL_HOOKS is a non-empty array', () => {
|
||
assert.ok(Array.isArray(GSD_UNINSTALL_HOOKS), 'GSD_UNINSTALL_HOOKS must be an array');
|
||
assert.ok(GSD_UNINSTALL_HOOKS.length > 0, 'GSD_UNINSTALL_HOOKS must not be empty');
|
||
});
|
||
|
||
test('gsd-workflow-guard.js is in GSD_UNINSTALL_HOOKS', () => {
|
||
assert.ok(
|
||
GSD_UNINSTALL_HOOKS.includes('gsd-workflow-guard.js'),
|
||
'GSD_UNINSTALL_HOOKS must include gsd-workflow-guard.js'
|
||
);
|
||
});
|
||
|
||
test('phantom gsd-check-update.sh is NOT in GSD_UNINSTALL_HOOKS', () => {
|
||
assert.ok(
|
||
!GSD_UNINSTALL_HOOKS.includes('gsd-check-update.sh'),
|
||
'GSD_UNINSTALL_HOOKS must not include the phantom gsd-check-update.sh entry'
|
||
);
|
||
});
|
||
|
||
test('GSD_UNINSTALL_HOOKS covers all 3 opt-in bash hooks', () => {
|
||
const required = ['gsd-session-state.sh', 'gsd-validate-commit.sh', 'gsd-phase-boundary.sh'];
|
||
for (const hook of required) {
|
||
assert.ok(
|
||
GSD_UNINSTALL_HOOKS.includes(hook),
|
||
`GSD_UNINSTALL_HOOKS must include ${hook}`
|
||
);
|
||
}
|
||
});
|
||
|
||
test('GSD_UNINSTALL_HOOKS covers core JS hooks', () => {
|
||
const coreJsHooks = [
|
||
'gsd-check-update.js', 'gsd-statusline.js', 'gsd-session-state.sh',
|
||
'gsd-context-monitor.js', 'gsd-phase-boundary.sh', 'gsd-prompt-guard.js',
|
||
'gsd-read-guard.js', 'gsd-validate-commit.sh', 'gsd-workflow-guard.js',
|
||
];
|
||
for (const hook of coreJsHooks) {
|
||
assert.ok(
|
||
GSD_UNINSTALL_HOOKS.includes(hook),
|
||
`GSD_UNINSTALL_HOOKS must include ${hook}`
|
||
);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('writeManifest includes .sh hooks', () => {
|
||
let tmpDir;
|
||
beforeEach(() => {
|
||
tmpDir = createTempDir('gsd-manifest-');
|
||
const hooksDir = path.join(tmpDir, 'hooks');
|
||
simulateHookCopy(HOOKS_DIST, hooksDir);
|
||
});
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
test('manifest contains .sh hook entries', () => {
|
||
writeManifest(tmpDir, 'claude');
|
||
const manifest = JSON.parse(fs.readFileSync(path.join(tmpDir, 'gsd-file-manifest.json'), 'utf8'));
|
||
for (const sh of EXPECTED_SH_HOOKS) {
|
||
assert.ok(manifest.files['hooks/' + sh], `manifest should contain hash for ${sh}`);
|
||
}
|
||
});
|
||
|
||
test('manifest contains .js hook entries', () => {
|
||
writeManifest(tmpDir, 'claude');
|
||
const manifest = JSON.parse(fs.readFileSync(path.join(tmpDir, 'gsd-file-manifest.json'), 'utf8'));
|
||
for (const js of EXPECTED_ALL_HOOKS.filter(h => h.endsWith('.js'))) {
|
||
assert.ok(manifest.files['hooks/' + js], `manifest should contain hash for ${js}`);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('uninstall settings cleanup preserves user hooks', () => {
|
||
const isGsdHook = (cmd) =>
|
||
cmd && (cmd.includes('gsd-check-update') || cmd.includes('gsd-statusline') ||
|
||
cmd.includes('gsd-session-state') || cmd.includes('gsd-context-monitor') ||
|
||
cmd.includes('gsd-phase-boundary') || cmd.includes('gsd-prompt-guard') ||
|
||
cmd.includes('gsd-read-guard') || cmd.includes('gsd-validate-commit') ||
|
||
cmd.includes('gsd-workflow-guard'));
|
||
|
||
function filterGsdHooks(entries) {
|
||
return entries
|
||
.map(e => {
|
||
if (!e.hooks || !Array.isArray(e.hooks)) return e;
|
||
e.hooks = e.hooks.filter(h => !isGsdHook(h.command));
|
||
return e.hooks.length > 0 ? e : null;
|
||
})
|
||
.filter(Boolean);
|
||
}
|
||
|
||
test('mixed entry preserves user hooks', () => {
|
||
const entries = [{
|
||
matcher: 'Bash',
|
||
hooks: [
|
||
{ type: 'command', command: 'node /path/gsd-prompt-guard.js' },
|
||
{ type: 'command', command: 'bash /my/custom-lint.sh' },
|
||
],
|
||
}];
|
||
const result = filterGsdHooks(entries);
|
||
assert.strictEqual(result.length, 1);
|
||
assert.strictEqual(result[0].hooks.length, 1);
|
||
assert.ok(result[0].hooks[0].command.includes('custom-lint'));
|
||
});
|
||
|
||
test('entry with only GSD hooks is fully removed', () => {
|
||
const entries = [{
|
||
hooks: [
|
||
{ type: 'command', command: 'node /path/gsd-check-update.js' },
|
||
{ type: 'command', command: 'node /path/gsd-statusline.js' },
|
||
],
|
||
}];
|
||
assert.strictEqual(filterGsdHooks(entries).length, 0);
|
||
});
|
||
|
||
test('entry with only user hooks is untouched', () => {
|
||
const entries = [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'bash /my/pre-check.sh' }] }];
|
||
const result = filterGsdHooks(entries);
|
||
assert.strictEqual(result.length, 1);
|
||
assert.strictEqual(result[0].hooks.length, 1);
|
||
});
|
||
|
||
test('non-array hook entries are preserved (#1825)', () => {
|
||
const entries = [
|
||
{ type: 'custom', command: 'echo hello' },
|
||
{ matcher: 'Bash', hooks: [{ type: 'command', command: 'node /path/gsd-prompt-guard.js' }] },
|
||
{ url: 'https://example.com/webhook' },
|
||
];
|
||
const result = filterGsdHooks(JSON.parse(JSON.stringify(entries)));
|
||
assert.strictEqual(result.length, 2);
|
||
assert.deepStrictEqual(result[0], { type: 'custom', command: 'echo hello' });
|
||
assert.deepStrictEqual(result[1], { url: 'https://example.com/webhook' });
|
||
});
|
||
|
||
test('all GSD hook names are recognised', () => {
|
||
const cmds = [
|
||
'node /path/gsd-check-update.js', 'node /path/gsd-statusline.js',
|
||
'bash /path/gsd-session-state.sh', 'node /path/gsd-context-monitor.js',
|
||
'bash /path/gsd-phase-boundary.sh', 'node /path/gsd-prompt-guard.js',
|
||
'node /path/gsd-read-guard.js', 'bash /path/gsd-validate-commit.sh',
|
||
'node /path/gsd-workflow-guard.js',
|
||
];
|
||
for (const cmd of cmds) {
|
||
assert.ok(isGsdHook(cmd), `should recognise: ${cmd}`);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('Codex legacy gsd-update-check migration', () => {
|
||
const src = fs.readFileSync(path.join(__dirname, '..', 'bin', 'install.js'), 'utf8');
|
||
|
||
test('install.js strips legacy gsd-update-check hook blocks', () => {
|
||
assert.ok(src.includes('gsd-update-check') && src.includes('replace('));
|
||
});
|
||
|
||
test('migration regex removes LF legacy hook block', () => {
|
||
const legacyBlock = ['[features]', 'codex_hooks = true', '',
|
||
'# GSD Hooks', '[[hooks]]', 'event = "SessionStart"',
|
||
'command = "node /old/path/gsd-update-check.js"', ''].join('\n');
|
||
let content = legacyBlock.replace(
|
||
/\n# GSD Hooks\n\[\[hooks\]\]\nevent = "SessionStart"\ncommand = "node [^\n]*gsd-update-check\.js"\n/g, '\n',
|
||
);
|
||
assert.ok(!content.includes('gsd-update-check'));
|
||
assert.ok(content.includes('[features]'));
|
||
});
|
||
|
||
test('migration regex removes CRLF legacy hook block', () => {
|
||
const legacyBlock = ['[features]', 'codex_hooks = true', '',
|
||
'# GSD Hooks', '[[hooks]]', 'event = "SessionStart"',
|
||
'command = "node /old/path/gsd-update-check.js"', ''].join('\r\n');
|
||
let content = legacyBlock.replace(
|
||
/\r\n# GSD Hooks\r\n\[\[hooks\]\]\r\nevent = "SessionStart"\r\ncommand = "node [^\r\n]*gsd-update-check\.js"\r\n/g, '\r\n',
|
||
);
|
||
assert.ok(!content.includes('gsd-update-check'));
|
||
assert.ok(content.includes('[features]'));
|
||
});
|
||
});
|