refactor(tests): consolidate Installer Module — 11 files → 2 (#3760)

* chore(tests): lint rule — cap test files per production module at 2

Adds scripts/lint-test-file-count.cjs with a ratcheted allowlist
(scripts/lint-test-file-count.allowlist.json) capturing today's
30 violating clusters as a ceiling. New entries blocked at PR time;
reductions ratchet automatically.

Wires into .github/workflows/test.yml as a new step in lint-tests.

Refs #3737

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(tests): add docs-exempt to changeset fragment

Internal CI lint rule — no user-facing docs impact.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(tests): consolidate Installer Module — 11 files → 2

Closes #3758

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(changeset): replace invalid type 'Chore' with 'Changed' — ALLOWED_TYPES only accepts Added|Changed|Deprecated|Removed|Fixed|Security

Fragment already had a docs-exempt marker; only the type value was wrong.

Refs #3758

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(tests): split install.test.cjs along section seams — keep files ≤ 800 LOC

1828-LOC monolith split into 3 files by semantic boundary:
  install.test.cjs (602 LOC) — S1-5: dir resolution, install/uninstall spot-checks, Kilo
  install-runtime-artifacts.test.cjs (347 LOC) — S6-8+12: layout loop, Contract 6, legacy migrations
  install-minimal-hooks.test.cjs (782 LOC) — S9-11+13: profiles, minimal E2E, hooks copy

Shared constants and helpers extracted to tests/helpers/install-shared.cjs (216 LOC).
Total test count unchanged: 210 tests (68+37+105).

Refs #3758

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(tests): ratchet state test-file ceiling to 9 — actual count at baseline

The allowlist was initialised with state=8 but the repo already had 9 files
matching the 'state' prefix at the time the lint rule was created, causing
lint-test-file-count.test.cjs to fail on the real repo immediately.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-20 20:38:30 -04:00
committed by GitHub
parent 6313baad63
commit f955bf872c
18 changed files with 2262 additions and 4300 deletions

View File

@@ -0,0 +1,6 @@
---
type: Changed
pr: 3758
---
<!-- docs-exempt: internal test refactor, no API/behavior change -->
**Consolidate Installer Module tests — 11 files → 2** — Merged `hermes-install`, `kilo-install`, `qwen-install`, `trae-install`, `antigravity-install`, `install-minimal`, `install-minimal-all-runtimes`, `install-minimal-backcompat`, `install-hermes-regressions`, `install-hooks-copy`, and `install-uninstall-layout-loop` test files into `tests/install.test.cjs` (parameterized over all 15 runtimes) and `tests/install-regressions.test.cjs` (date-stamped bug reproductions). Adds Contract 6 counter-test: `resolveRuntimeArtifactLayout` throws `TypeError` for unknown runtimes. Fixes latent `GSD_TEST_MODE` env-pollution bug in all spawned-installer tests. (#3758)

View File

@@ -85,6 +85,9 @@ Module owning runtime-aware global skills directory policy for SDK query surface
### Installer Migration Authoring Guard Module
Module owning validation for Installer Migration Module records and planned actions. It enforces migration metadata, explicit install scopes, ownership evidence for destructive/config actions, and runtime contract citations for runtime config rewrites before a migration can enter planning or apply.
### Installer Module
Primary installer for all runtimes. Single production file: `bin/install.js` (generated). Exports: `install(isGlobal, runtime[, configDir])` → typed result `{ runtime, configDir, settingsPath, settings, statuslineCommand, updateBannerCommand }`; `uninstall(isGlobal, runtime[, configDir])`; `installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile)`; `uninstallRuntimeArtifacts(runtime, configDir, scope)`; `writeManifest(configDir, runtime)`. Runtime enum: `allRuntimes` (15 values: claude, antigravity, augment, cline, codebuddy, codex, copilot, cursor, gemini, hermes, kilo, opencode, qwen, trae, windsurf). Directory helpers: `getDirName(runtime)` → local dir name; `getGlobalDir(runtime[, explicitDir])` → global path (env-var–aware per runtime); `getConfigDirFromHome(runtime, isGlobal)` → shell-quoted path fragment. Runtime-specific helpers: `resolveKiloConfigPath(configDir)`, `configureKiloPermissions(isGlobal[, explicitDir])`. Layout-driven artifact copy/removal delegates to `get-shit-done/bin/lib/runtime-artifact-layout.cjs:resolveRuntimeArtifactLayout` (throws `TypeError` for unknown runtimes). Hermes uses nested `skills/gsd/<stem>/` layout (prefix: ''); other skill-runtimes use flat `skills/gsd-<stem>/` layout. See Skill Surface Budget Module and Runtime Artifact Layout Module.
### Skill Surface Budget Module
Module owning which skills and agents are written to runtime config directories at install time (Phase 1) and at runtime via cluster-level toggles (Phase 2). Phase 1: `get-shit-done/bin/lib/install-profiles.cjs` defines named profiles (`core`, `standard`, `full`), computes transitive closure over `requires:` frontmatter, stages skills/agents to runtime config dirs, and persists the chosen profile in a `.gsd-profile` marker. Profile resolution precedence: explicit `--profile=` flag > `.gsd-profile` marker > `full`. `--minimal`/`--core-only` are back-compat aliases for `--profile=core`. Phase 2: `get-shit-done/bin/lib/surface.cjs` implements the `/gsd:surface` slash command for cluster-level enable/disable without reinstall; cluster definitions live in `get-shit-done/bin/lib/clusters.cjs`; per-runtime state persists in `<runtimeConfigDir>/.gsd-surface.json` independent from the `.gsd-profile` marker. See ADR-0011.

View File

@@ -1,421 +0,0 @@
// 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.
/**
* GSD Tools Tests - Antigravity Install Plumbing
*
* Tests for Antigravity runtime directory resolution, config paths,
* content conversion functions, and integration with the multi-runtime installer.
*/
process.env.GSD_TEST_MODE = '1';
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
const os = require('os');
const fs = require('fs');
const { createTempDir, cleanup, parseFrontmatter } = require('./helpers.cjs');
const {
getDirName,
getGlobalDir,
getConfigDirFromHome,
convertClaudeToAntigravityContent,
convertClaudeCommandToAntigravitySkill,
convertClaudeAgentToAntigravityAgent,
writeManifest,
installRuntimeArtifacts,
} = require('../bin/install.js');
// ─── Profile resolution for installRuntimeArtifacts tests ────────────────────
const _gsdLibDir = path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib');
const { loadSkillsManifest, resolveProfile } = require(path.join(_gsdLibDir, 'install-profiles.cjs'));
const _manifest = loadSkillsManifest();
const resolvedProfileFull = resolveProfile({ modes: [], manifest: _manifest });
// ─── getDirName ─────────────────────────────────────────────────────────────────
describe('getDirName (Antigravity)', () => {
test('returns .agent for antigravity', () => {
assert.strictEqual(getDirName('antigravity'), '.agent');
});
test('does not break existing runtimes', () => {
assert.strictEqual(getDirName('claude'), '.claude');
assert.strictEqual(getDirName('opencode'), '.opencode');
assert.strictEqual(getDirName('gemini'), '.gemini');
assert.strictEqual(getDirName('kilo'), '.kilo');
assert.strictEqual(getDirName('codex'), '.codex');
assert.strictEqual(getDirName('copilot'), '.github');
});
});
// ─── getGlobalDir ───────────────────────────────────────────────────────────────
describe('getGlobalDir (Antigravity)', () => {
let savedEnv;
beforeEach(() => {
savedEnv = process.env.ANTIGRAVITY_CONFIG_DIR;
delete process.env.ANTIGRAVITY_CONFIG_DIR;
});
afterEach(() => {
if (savedEnv !== undefined) {
process.env.ANTIGRAVITY_CONFIG_DIR = savedEnv;
} else {
delete process.env.ANTIGRAVITY_CONFIG_DIR;
}
});
test('returns ~/.gemini/antigravity by default', () => {
const result = getGlobalDir('antigravity');
assert.strictEqual(result, path.join(os.homedir(), '.gemini', 'antigravity'));
});
test('respects ANTIGRAVITY_CONFIG_DIR env var', () => {
const customDir = path.join(os.homedir(), 'custom-ag');
process.env.ANTIGRAVITY_CONFIG_DIR = customDir;
const result = getGlobalDir('antigravity');
assert.strictEqual(result, customDir);
});
test('explicit config-dir overrides env var', () => {
process.env.ANTIGRAVITY_CONFIG_DIR = path.join(os.homedir(), 'from-env');
const explicit = path.join(os.homedir(), 'explicit-ag');
const result = getGlobalDir('antigravity', explicit);
assert.strictEqual(result, explicit);
});
test('does not change Claude Code global dir', () => {
assert.strictEqual(getGlobalDir('claude'), path.join(os.homedir(), '.claude'));
});
});
// ─── getConfigDirFromHome ───────────────────────────────────────────────────────
describe('getConfigDirFromHome (Antigravity)', () => {
test('returns .agent for local installs', () => {
assert.strictEqual(getConfigDirFromHome('antigravity', false), "'.agent'");
});
test('returns .gemini, antigravity for global installs', () => {
assert.strictEqual(getConfigDirFromHome('antigravity', true), "'.gemini', 'antigravity'");
});
test('does not change other runtimes', () => {
assert.strictEqual(getConfigDirFromHome('claude', true), "'.claude'");
assert.strictEqual(getConfigDirFromHome('gemini', true), "'.gemini'");
assert.strictEqual(getConfigDirFromHome('kilo', true), "'.config', 'kilo'");
assert.strictEqual(getConfigDirFromHome('copilot', true), "'.copilot'");
});
});
// ─── convertClaudeToAntigravityContent ─────────────────────────────────────────
describe('convertClaudeToAntigravityContent', () => {
describe('global install path replacements', () => {
test('replaces ~/. claude/ with ~/.gemini/antigravity/', () => {
const input = 'See ~/.claude/get-shit-done/workflows/';
const result = convertClaudeToAntigravityContent(input, true);
assert.ok(result.includes('~/.gemini/antigravity/get-shit-done/workflows/'), result);
assert.ok(!result.includes('~/.claude/'), result);
});
test('replaces $HOME/.claude/ with $HOME/.gemini/antigravity/', () => {
const input = 'path.join($HOME/.claude/get-shit-done)';
const result = convertClaudeToAntigravityContent(input, true);
assert.ok(result.includes('$HOME/.gemini/antigravity/'), result);
assert.ok(!result.includes('$HOME/.claude/'), result);
});
});
describe('local install path replacements', () => {
test('replaces ~/.claude/ with .agent/ for local installs', () => {
const input = 'See ~/.claude/get-shit-done/';
const result = convertClaudeToAntigravityContent(input, false);
assert.ok(result.includes('.agent/get-shit-done/'), result);
assert.ok(!result.includes('~/.claude/'), result);
});
test('replaces ./.claude/ with ./.agent/', () => {
const input = 'path ./.claude/hooks/gsd-check-update.js';
const result = convertClaudeToAntigravityContent(input, false);
assert.ok(result.includes('./.agent/hooks/'), result);
assert.ok(!result.includes('./.claude/'), result);
});
test('replaces .claude/ with .agent/', () => {
const input = 'node .claude/hooks/gsd-statusline.js';
const result = convertClaudeToAntigravityContent(input, false);
assert.ok(result.includes('.agent/hooks/gsd-statusline.js'), result);
assert.ok(!result.includes('.claude/'), result);
});
});
describe('command name conversion', () => {
test('converts /gsd:command to /gsd-command', () => {
const input = 'Run /gsd:new-project to start';
const result = convertClaudeToAntigravityContent(input, true);
assert.ok(result.includes('/gsd-new-project'), result);
assert.ok(!result.includes('gsd:'), result);
});
test('converts all gsd: references', () => {
const input = '/gsd:plan-phase and /gsd:execute-phase';
const result = convertClaudeToAntigravityContent(input, false);
assert.ok(result.includes('/gsd-plan-phase'), result);
assert.ok(result.includes('/gsd-execute-phase'), result);
});
});
test('does not modify unrelated content', () => {
const input = 'This is a plain text description with no paths.';
const result = convertClaudeToAntigravityContent(input, false);
assert.strictEqual(result, input);
});
});
// ─── convertClaudeCommandToAntigravitySkill ─────────────────────────────────────
describe('convertClaudeCommandToAntigravitySkill', () => {
const claudeCommand = `---
name: gsd:new-project
description: Initialize a new GSD project with requirements and roadmap
argument-hint: "[project-name]"
allowed-tools:
- Read
- Write
- Bash
- Agent
---
Initialize new project at ~/.claude/get-shit-done/workflows/new-project.md
`;
test('produces name and description only in frontmatter', () => {
const result = convertClaudeCommandToAntigravitySkill(claudeCommand, 'gsd-new-project', false);
const fm = parseFrontmatter(result);
assert.equal(fm.name, 'gsd-new-project', result);
assert.equal(fm.description, 'Initialize a new GSD project with requirements and roadmap', result);
assert.ok(!('allowed-tools' in fm), 'no allowed-tools field');
assert.ok(!('argument-hint' in fm), 'no argument-hint field');
});
test('applies path replacement in body', () => {
const result = convertClaudeCommandToAntigravitySkill(claudeCommand, 'gsd-new-project', false);
assert.ok(result.includes('.agent/get-shit-done/'), result);
assert.ok(!result.includes('~/.claude/'), result);
});
test('uses provided skillName for name field', () => {
const result = convertClaudeCommandToAntigravitySkill(claudeCommand, 'gsd-custom-name', false);
assert.ok(result.includes('name: gsd-custom-name'), result);
});
test('converts gsd: command references in body', () => {
const content = `---
name: test
description: test skill
---
Run /gsd:new-project to get started.
`;
const result = convertClaudeCommandToAntigravitySkill(content, 'gsd-test', false);
assert.ok(result.includes('/gsd-new-project'), result);
assert.ok(!result.includes('gsd:'), result);
});
test('returns unchanged content when no frontmatter', () => {
const noFm = 'Just some text without frontmatter.';
const result = convertClaudeCommandToAntigravitySkill(noFm, 'gsd-test', false);
// Path replacements still apply, but no frontmatter transformation
assert.ok(!result.startsWith('---'), result);
});
});
// ─── convertClaudeAgentToAntigravityAgent ──────────────────────────────────────
describe('convertClaudeAgentToAntigravityAgent', () => {
const claudeAgent = `---
name: gsd-executor
description: Executes GSD plans with atomic commits
tools: Read, Write, Edit, Bash, Glob, Grep, Task
color: blue
---
Execute plans from ~/.claude/get-shit-done/workflows/execute-phase.md
`;
test('preserves name and description', () => {
const result = convertClaudeAgentToAntigravityAgent(claudeAgent, false);
const fm = parseFrontmatter(result);
assert.equal(fm.name, 'gsd-executor', result);
assert.equal(fm.description, 'Executes GSD plans with atomic commits', result);
});
test('maps Claude tools to Gemini tool names', () => {
const result = convertClaudeAgentToAntigravityAgent(claudeAgent, false);
// Read → read_file, Bash → run_shell_command
assert.ok(result.includes('read_file'), result);
assert.ok(result.includes('run_shell_command'), result);
// Original Claude names should not appear in tools line
const fmEnd = result.indexOf('---', 3);
const frontmatter = result.slice(0, fmEnd);
assert.ok(!frontmatter.includes('tools: Read,'), frontmatter);
});
test('preserves color field', () => {
const result = convertClaudeAgentToAntigravityAgent(claudeAgent, false);
assert.ok(result.includes('color: blue'), result);
});
test('applies path replacement in body', () => {
const result = convertClaudeAgentToAntigravityAgent(claudeAgent, false);
assert.ok(result.includes('.agent/get-shit-done/'), result);
assert.ok(!result.includes('~/.claude/'), result);
});
test('uses global path for global installs', () => {
const result = convertClaudeAgentToAntigravityAgent(claudeAgent, true);
assert.ok(result.includes('~/.gemini/antigravity/get-shit-done/'), result);
});
test('excludes Task tool (filtered by convertGeminiToolName)', () => {
const result = convertClaudeAgentToAntigravityAgent(claudeAgent, false);
// Task is excluded by convertGeminiToolName (returns null for Task)
const fmEnd = result.indexOf('---', 3);
const frontmatter = result.slice(0, fmEnd);
assert.ok(!frontmatter.includes('Task'), frontmatter);
});
});
// ─── installRuntimeArtifacts (antigravity integration) ────────────────────────
describe('installRuntimeArtifacts (antigravity integration)', () => {
// Pivoted from copyCommandsAsAntigravitySkills(srcDir, skillsDir, 'gsd', false) shim to
// installRuntimeArtifacts('antigravity', configDir, 'local'|'global', resolvedProfileFull).
// Output layout: <configDir>/skills/gsd-<stem>/SKILL.md (destSubpath='skills', prefix='gsd-').
// stageSkillsForRuntimeAsSkills does NOT recurse into subdirectories; the real
// commands/gsd/ directory has no subdirs, so subdir-handling is not a production path.
let configDir;
beforeEach(() => {
configDir = createTempDir('gsd-ag-test-');
});
afterEach(() => {
fs.rmSync(configDir, { recursive: true, force: true });
});
test('creates skills directory', () => {
installRuntimeArtifacts('antigravity', configDir, 'local', resolvedProfileFull);
assert.ok(fs.existsSync(path.join(configDir, 'skills')));
});
test('creates one skill directory per command with SKILL.md', () => {
installRuntimeArtifacts('antigravity', configDir, 'local', resolvedProfileFull);
const skillsDir = path.join(configDir, 'skills');
const skillDir = path.join(skillsDir, 'gsd-new-project');
assert.ok(fs.existsSync(skillDir), 'skill dir should exist');
assert.ok(fs.existsSync(path.join(skillDir, 'SKILL.md')), 'SKILL.md should exist');
});
// NOTE: 'handles subdirectory commands with prefixed names' (gsd-subdir-sub-command) is
// DELETED. stageSkillsForRuntimeAsSkills processes only flat .md files in commands/gsd/;
// the real commands/gsd/ directory contains no subdirectories. This test had no production
// code path and cannot be expressed through the installRuntimeArtifacts seam.
test('SKILL.md has minimal frontmatter (name + description only)', () => {
installRuntimeArtifacts('antigravity', configDir, 'local', resolvedProfileFull);
const content = fs.readFileSync(path.join(configDir, 'skills', 'gsd-new-project', 'SKILL.md'), 'utf8');
const fm = parseFrontmatter(content);
assert.equal(fm.name, 'gsd-new-project', content);
assert.ok(fm.description, 'description field is present');
assert.ok(!('allowed-tools' in fm), 'no allowed-tools field');
});
test('SKILL.md body has paths converted for local install', () => {
installRuntimeArtifacts('antigravity', configDir, 'local', resolvedProfileFull);
const content = fs.readFileSync(path.join(configDir, 'skills', 'gsd-new-project', 'SKILL.md'), 'utf8');
// gsd: → gsd- conversion
assert.ok(!content.includes('gsd:'), content);
});
test('removes old gsd-* skill dirs before reinstalling', () => {
const skillsDir = path.join(configDir, 'skills');
fs.mkdirSync(skillsDir, { recursive: true });
// Stale GSD-managed dir must be pruned
const staleDir = path.join(skillsDir, 'gsd-old-stale-skill');
fs.mkdirSync(staleDir, { recursive: true });
fs.writeFileSync(path.join(staleDir, 'SKILL.md'), 'stale content');
// Non-GSD dir should survive
const otherDir = path.join(skillsDir, 'my-custom-skill');
fs.mkdirSync(otherDir, { recursive: true });
installRuntimeArtifacts('antigravity', configDir, 'local', resolvedProfileFull);
assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-new-project')), 'real skill dir written');
assert.ok(!fs.existsSync(staleDir), 'stale gsd-* dir removed by pre-prune');
});
test('does not remove non-gsd skill dirs', () => {
// Create a non-GSD skill dir before install
const skillsDir = path.join(configDir, 'skills');
fs.mkdirSync(skillsDir, { recursive: true });
const otherDir = path.join(skillsDir, 'my-custom-skill');
fs.mkdirSync(otherDir, { recursive: true });
installRuntimeArtifacts('antigravity', configDir, 'local', resolvedProfileFull);
assert.ok(fs.existsSync(otherDir), 'non-GSD skill dir should be preserved');
});
});
// ─── writeManifest (Antigravity) ───────────────────────────────────────────────
describe('writeManifest (Antigravity)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-manifest-ag-');
// Create minimal structure
const skillsDir = path.join(tmpDir, 'skills', 'gsd-help');
fs.mkdirSync(skillsDir, { recursive: true });
fs.writeFileSync(path.join(skillsDir, 'SKILL.md'), '---\nname: gsd-help\ndescription: Help\n---\n');
const gsdDir = path.join(tmpDir, 'get-shit-done');
fs.mkdirSync(gsdDir, { recursive: true });
fs.writeFileSync(path.join(gsdDir, 'VERSION'), '1.0.0');
const agentsDir = path.join(tmpDir, 'agents');
fs.mkdirSync(agentsDir, { recursive: true });
fs.writeFileSync(path.join(agentsDir, 'gsd-executor.md'), '---\nname: gsd-executor\n---\n');
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('writes manifest JSON file', () => {
writeManifest(tmpDir, 'antigravity');
const manifestPath = path.join(tmpDir, 'gsd-file-manifest.json');
assert.ok(fs.existsSync(manifestPath), 'manifest file should exist');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
assert.ok(manifest.version, 'should have version');
assert.ok(manifest.files, 'should have files');
});
test('manifest includes skills in skills/ directory', () => {
writeManifest(tmpDir, 'antigravity');
const manifest = JSON.parse(fs.readFileSync(path.join(tmpDir, 'gsd-file-manifest.json'), 'utf8'));
const skillFiles = Object.keys(manifest.files).filter(f => f.startsWith('skills/'));
assert.ok(skillFiles.length > 0, 'should have skill files in manifest');
});
test('manifest includes agent files', () => {
writeManifest(tmpDir, 'antigravity');
const manifest = JSON.parse(fs.readFileSync(path.join(tmpDir, 'gsd-file-manifest.json'), 'utf8'));
const agentFiles = Object.keys(manifest.files).filter(f => f.startsWith('agents/'));
assert.ok(agentFiles.length > 0, 'should have agent files in manifest');
});
});

View File

@@ -0,0 +1,216 @@
'use strict';
/**
* Shared helpers and constants for install test suite.
* Used by install.test.cjs, install-runtime-artifacts.test.cjs,
* and install-minimal-hooks.test.cjs.
*/
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { spawnSync } = require('node:child_process');
const assert = require('node:assert/strict');
const INSTALL_SCRIPT = path.join(__dirname, '..', '..', 'bin', 'install.js');
const MANIFEST_NAME = 'gsd-file-manifest.json';
const BUILD_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'build-hooks.js');
const HOOKS_DIST = path.join(__dirname, '..', '..', 'hooks', 'dist');
const EXPECTED_SH_HOOKS = [
'gsd-session-state.sh',
'gsd-validate-commit.sh',
'gsd-phase-boundary.sh',
];
const EXPECTED_ALL_HOOKS = [
'gsd-check-update.js',
'gsd-context-monitor.js',
'gsd-prompt-guard.js',
'gsd-read-guard.js',
'gsd-read-injection-scanner.js',
'gsd-statusline.js',
'gsd-workflow-guard.js',
...EXPECTED_SH_HOOKS,
];
// ─── Runtime metadata table ───────────────────────────────────────────────────
const RUNTIME_META = {
claude: { localDir: '.claude', globalSuffix: '.claude' },
antigravity: { localDir: '.agent', globalSuffix: path.join('.gemini', 'antigravity') },
augment: { localDir: '.augment', globalSuffix: '.augment' },
cline: { localDir: '.cline', globalSuffix: '.cline' },
codebuddy: { localDir: '.codebuddy', globalSuffix: '.codebuddy' },
codex: { localDir: '.codex', globalSuffix: '.codex' },
copilot: { localDir: '.github', globalSuffix: '.copilot' },
cursor: { localDir: '.cursor', globalSuffix: '.cursor' },
gemini: { localDir: '.gemini', globalSuffix: '.gemini' },
hermes: { localDir: '.hermes', globalSuffix: '.hermes' },
kilo: { localDir: '.kilo', globalSuffix: path.join('.config', 'kilo') },
opencode: { localDir: '.opencode', globalSuffix: path.join('.config', 'opencode') },
qwen: { localDir: '.qwen', globalSuffix: '.qwen' },
trae: { localDir: '.trae', globalSuffix: '.trae' },
windsurf: { localDir: '.windsurf', globalSuffix: path.join('.codeium', 'windsurf') },
};
// Runtimes that emit per-skill files under skills/ (not rules-based or commands-based)
const SKILL_RUNTIMES = [
'claude', 'opencode', 'gemini', 'kilo', 'codex', 'copilot', 'antigravity',
'cursor', 'windsurf', 'augment', 'trae', 'qwen', 'codebuddy',
];
// ─── Helper functions ─────────────────────────────────────────────────────────
function stripAnsi(str) {
// eslint-disable-next-line no-control-regex
return str.replace(/\x1b\[[0-9;]*m/g, '');
}
function walk(dir) {
const results = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) results.push(...walk(full));
else results.push(full);
}
return results;
}
function simulateHookCopy(hooksSrc, hooksDest) {
fs.mkdirSync(hooksDest, { recursive: true });
for (const entry of fs.readdirSync(hooksSrc)) {
const srcFile = path.join(hooksSrc, entry);
if (!fs.statSync(srcFile).isFile()) continue;
const destFile = path.join(hooksDest, entry);
if (entry.endsWith('.js')) {
fs.writeFileSync(destFile, fs.readFileSync(srcFile, 'utf8'));
try { fs.chmodSync(destFile, 0o755); } catch { /* Windows */ }
} else {
fs.copyFileSync(srcFile, destFile);
if (entry.endsWith('.sh')) {
try { fs.chmodSync(destFile, 0o755); } catch { /* Windows */ }
}
}
}
}
/** Build a clean env for spawned installer processes.
* Must strip GSD_TEST_MODE so the child runs the real install, not the no-op guard. */
function installerEnv(overrides = {}) {
const env = { ...process.env, ...overrides };
delete env.GSD_TEST_MODE;
return env;
}
function runMinimalInstall({ runtime, scope, extraArgs = [] }) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-${runtime}-${scope}-`));
try {
const LOCAL_DIR_NAME = {
claude: '.claude', opencode: '.opencode', gemini: '.gemini', kilo: '.kilo',
codex: '.codex', copilot: '.github', antigravity: '.agent', cursor: '.cursor',
windsurf: '.windsurf', augment: '.augment', trae: '.trae', qwen: '.qwen',
codebuddy: '.codebuddy', cline: '.',
};
let configDir;
let cwd = process.cwd();
const args = [INSTALL_SCRIPT, `--${runtime}`];
if (scope === 'global') {
args.push('--global', '--config-dir', root);
configDir = root;
} else {
args.push('--local');
cwd = root;
configDir = runtime === 'cline' ? root : path.join(root, LOCAL_DIR_NAME[runtime]);
}
args.push(...extraArgs);
const result = spawnSync(process.execPath, args, {
cwd, encoding: 'utf8',
env: installerEnv({ HOME: root, USERPROFILE: root }),
});
assert.strictEqual(result.status, 0,
`installer exited with status ${result.status} for ${runtime} --${scope}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
const manifestPath = path.join(configDir, MANIFEST_NAME);
const manifest = fs.existsSync(manifestPath)
? JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
: null;
return { manifest, configDir, root, stdout: result.stdout, stderr: result.stderr };
} catch (err) {
fs.rmSync(root, { recursive: true, force: true });
throw err;
}
}
function manifestSkillSet(manifest) {
if (!manifest || !manifest.files) return new Set();
const out = new Set();
for (const key of Object.keys(manifest.files)) {
if (key.startsWith('skills/')) {
const seg = key.split('/')[1].replace(/^gsd-/, '').replace(/\.md$/, '');
out.add(seg);
} else if (key.startsWith('command/')) {
const file = key.split('/')[1];
out.add(file.replace(/^gsd-/, '').replace(/\.md$/, ''));
} else if (key.startsWith('commands/gsd/')) {
const file = key.split('/')[2];
out.add(file.replace(/\.(md|toml)$/, ''));
}
}
return out;
}
function manifestAgentCount(manifest) {
if (!manifest || !manifest.files) return 0;
return Object.keys(manifest.files).filter((k) => k.startsWith('agents/')).length;
}
function collectSkillBasenamesOnDisk(configDir) {
const out = new Set();
const skillsDir = path.join(configDir, 'skills');
if (fs.existsSync(skillsDir)) {
for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
out.add(entry.name.replace(/^gsd-/, ''));
} else if (entry.isFile() && entry.name.startsWith('gsd-') && entry.name.endsWith('.md')) {
out.add(entry.name.replace(/^gsd-/, '').replace(/\.md$/, ''));
}
}
}
const commandDir = path.join(configDir, 'command');
if (fs.existsSync(commandDir)) {
for (const file of fs.readdirSync(commandDir)) {
if (file.startsWith('gsd-') && file.endsWith('.md')) {
out.add(file.replace(/^gsd-/, '').replace(/\.md$/, ''));
}
}
}
const commandsGsdDir = path.join(configDir, 'commands', 'gsd');
if (fs.existsSync(commandsGsdDir)) {
for (const file of fs.readdirSync(commandsGsdDir)) {
if (file.endsWith('.md') || file.endsWith('.toml')) {
out.add(file.replace(/\.(md|toml)$/, ''));
}
}
}
return out;
}
module.exports = {
INSTALL_SCRIPT,
MANIFEST_NAME,
BUILD_SCRIPT,
HOOKS_DIST,
EXPECTED_SH_HOOKS,
EXPECTED_ALL_HOOKS,
RUNTIME_META,
SKILL_RUNTIMES,
stripAnsi,
walk,
simulateHookCopy,
installerEnv,
runMinimalInstall,
manifestSkillSet,
manifestAgentCount,
collectSkillBasenamesOnDisk,
};

View File

@@ -1,400 +0,0 @@
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, cleanup, parseFrontmatter } = require('./helpers.cjs');
const pkg = require('../package.json');
const {
getDirName,
getGlobalDir,
getConfigDirFromHome,
install,
uninstall,
writeManifest,
} = require('../bin/install.js');
describe('Hermes Agent runtime directory mapping', () => {
test('maps Hermes to .hermes for local installs', () => {
assert.strictEqual(getDirName('hermes'), '.hermes');
});
test('maps Hermes to ~/.hermes for global installs', () => {
// Isolate from any HERMES_HOME exported on the developer's machine —
// otherwise this test asserts the env-derived path, not the default.
const originalHermesHome = process.env.HERMES_HOME;
delete process.env.HERMES_HOME;
try {
assert.strictEqual(getGlobalDir('hermes'), path.join(os.homedir(), '.hermes'));
} finally {
if (originalHermesHome === undefined) delete process.env.HERMES_HOME;
else process.env.HERMES_HOME = originalHermesHome;
}
});
test('returns .hermes config fragments for local and global installs', () => {
assert.strictEqual(getConfigDirFromHome('hermes', false), "'.hermes'");
assert.strictEqual(getConfigDirFromHome('hermes', true), "'.hermes'");
});
});
describe('getGlobalDir (Hermes Agent)', () => {
let originalHermesConfigDir;
beforeEach(() => {
originalHermesConfigDir = process.env.HERMES_HOME;
});
afterEach(() => {
if (originalHermesConfigDir !== undefined) {
process.env.HERMES_HOME = originalHermesConfigDir;
} else {
delete process.env.HERMES_HOME;
}
});
test('returns ~/.hermes with no env var or explicit dir', () => {
delete process.env.HERMES_HOME;
const result = getGlobalDir('hermes');
assert.strictEqual(result, path.join(os.homedir(), '.hermes'));
});
test('returns explicit dir when provided', () => {
const result = getGlobalDir('hermes', '/custom/hermes-path');
assert.strictEqual(result, '/custom/hermes-path');
});
test('respects HERMES_HOME env var', () => {
process.env.HERMES_HOME = '~/custom-hermes';
const result = getGlobalDir('hermes');
assert.strictEqual(result, path.join(os.homedir(), 'custom-hermes'));
});
test('explicit dir takes priority over HERMES_HOME', () => {
process.env.HERMES_HOME = '~/from-env';
const result = getGlobalDir('hermes', '/explicit/path');
assert.strictEqual(result, '/explicit/path');
});
test('does not break other runtimes', () => {
assert.strictEqual(getGlobalDir('claude'), path.join(os.homedir(), '.claude'));
assert.strictEqual(getGlobalDir('codex'), path.join(os.homedir(), '.codex'));
});
});
describe('Hermes Agent local install/uninstall', () => {
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));
// Nested layout per spec #2841: all GSD skills collapse into a single
// skills/gsd/ category so Hermes' system prompt sees one entry, not 86.
// Skills use bare stem names (no gsd- prefix) inside the category dir,
// per the layout module's prefix: '' design for Hermes (#3664).
assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'help', 'SKILL.md')));
assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'DESCRIPTION.md')),
'DESCRIPTION.md exists at category root');
assert.ok(fs.existsSync(path.join(targetDir, 'get-shit-done', 'VERSION')));
assert.ok(fs.existsSync(path.join(targetDir, 'agents')));
const manifest = writeManifest(targetDir, 'hermes');
assert.ok(Object.keys(manifest.files).some(file => file.startsWith('skills/gsd/help/')), JSON.stringify(manifest.files));
uninstall(false, 'hermes');
assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'help')), 'Hermes skill directory removed');
assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd')), 'Hermes gsd category dir removed');
assert.ok(!fs.existsSync(path.join(targetDir, 'get-shit-done')), 'get-shit-done removed');
});
test('installed SKILL.md frontmatter conforms to Hermes spec', () => {
install(false, 'hermes');
const targetDir = path.join(tmpDir, '.hermes');
// Nested layout: skills live under skills/gsd/<stem>/SKILL.md.
// The layout module uses prefix: '' for Hermes so skill dirs have bare
// stem names (help, plan, …) inside the category dir (#3664).
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');
// Parse every SKILL.md and assert structural shape required by Hermes.
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 non-empty description`);
assert.strictEqual(fm.version, pkg.version,
`${dir}/SKILL.md declares version ${pkg.version} (got ${JSON.stringify(fm.version)})`);
}
// The category DESCRIPTION.md is part of the spec — verify it parses too.
const desc = fs.readFileSync(path.join(categoryDir, 'DESCRIPTION.md'), 'utf8');
const descFm = parseFrontmatter(desc);
assert.strictEqual(descFm.name, 'gsd', 'category DESCRIPTION.md name is "gsd"');
assert.ok(typeof descFm.description === 'string' && descFm.description.length > 0,
'category DESCRIPTION.md has description');
assert.strictEqual(descFm.version, pkg.version,
'category DESCRIPTION.md declares 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');
// Walk all skill files and confirm no `CLAUDE.md` token leaks; any
// skill body that referenced project context should now point at
// `HERMES.md` per the issue spec.
let referencedHermesMd = false;
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) { walk(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;
}
};
walk(skillsDir);
// Sanity: at least one skill in the GSD set references the project
// context filename, so the substitution actually exercises.
assert.ok(referencedHermesMd, 'at least one skill references HERMES.md after substitution');
uninstall(false, 'hermes');
});
});
describe('E2E: Hermes Agent uninstall skills cleanup', () => {
let tmpDir;
let previousCwd;
beforeEach(() => {
tmpDir = createTempDir('gsd-hermes-uninstall-');
previousCwd = process.cwd();
process.chdir(tmpDir);
});
afterEach(() => {
process.chdir(previousCwd);
cleanup(tmpDir);
});
test('removes all skill directories on --hermes --uninstall', () => {
const targetDir = path.join(tmpDir, '.hermes');
install(false, 'hermes');
const skillsDir = path.join(targetDir, 'skills');
const categoryDir = path.join(skillsDir, 'gsd');
assert.ok(fs.existsSync(categoryDir), 'skills/gsd/ category dir exists after install');
// Layout uses prefix: '' for Hermes — skill dirs have bare stem names (no gsd- prefix)
const installedSkills = fs.readdirSync(categoryDir, { withFileTypes: true })
.filter(e => e.isDirectory());
assert.ok(installedSkills.length > 0, `found ${installedSkills.length} skill dirs before uninstall`);
uninstall(false, 'hermes');
assert.ok(!fs.existsSync(categoryDir), 'skills/gsd/ category dir removed by uninstall');
if (fs.existsSync(skillsDir)) {
const remainingFlat = fs.readdirSync(skillsDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
assert.strictEqual(remainingFlat.length, 0,
`Expected 0 stray flat gsd-* skill dirs after uninstall, found: ${remainingFlat.map(e => e.name).join(', ')}`);
}
});
test('preserves non-GSD skill directories during --hermes --uninstall', () => {
const targetDir = path.join(tmpDir, '.hermes');
install(false, 'hermes');
const customSkillDir = path.join(targetDir, 'skills', 'my-custom-skill');
fs.mkdirSync(customSkillDir, { recursive: true });
fs.writeFileSync(path.join(customSkillDir, 'SKILL.md'), '# My Custom Skill\n');
assert.ok(fs.existsSync(path.join(customSkillDir, 'SKILL.md')), 'custom skill exists before uninstall');
uninstall(false, 'hermes');
assert.ok(fs.existsSync(path.join(customSkillDir, 'SKILL.md')),
'Non-GSD skill directory should be preserved after Hermes uninstall');
});
test('removes engine directory on --hermes --uninstall', () => {
const targetDir = path.join(tmpDir, '.hermes');
install(false, 'hermes');
assert.ok(fs.existsSync(path.join(targetDir, 'get-shit-done', 'VERSION')),
'engine exists before uninstall');
uninstall(false, 'hermes');
assert.ok(!fs.existsSync(path.join(targetDir, 'get-shit-done')),
'get-shit-done engine should be removed after Hermes uninstall');
});
});
// ─── Regression: no Claude references leak into Hermes install (parity with Qwen regression #2112) ──────────
describe('Hermes install contains no leaked Claude references (parity with Qwen regression #2112)', () => {
let tmpDir;
let previousCwd;
beforeEach(() => {
tmpDir = createTempDir('gsd-hermes-refs-');
previousCwd = process.cwd();
process.chdir(tmpDir);
install(false, 'hermes');
});
afterEach(() => {
process.chdir(previousCwd);
cleanup(tmpDir);
});
/**
* Recursively walk a directory and return all file paths.
*/
function walk(dir) {
const results = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...walk(full));
} else {
results.push(full);
}
}
return results;
}
/**
* Return files under .hermes/ that contain Claude references,
* excluding CHANGELOG.md (historical accuracy) and VERSION (no prose).
*/
function findClaudeLeaks() {
const hermesDir = path.join(tmpDir, '.hermes');
const allFiles = walk(hermesDir);
const textFiles = allFiles.filter(f =>
f.endsWith('.md') || f.endsWith('.cjs') || f.endsWith('.js')
);
const excluded = ['CHANGELOG.md'];
const candidates = textFiles.filter(f =>
!excluded.includes(path.basename(f))
);
const leaks = [];
for (const file of candidates) {
const content = fs.readFileSync(file, 'utf8');
if (/\bCLAUDE\.md\b/.test(content) ||
/\bClaude Code\b/.test(content) ||
/\.claude\//.test(content)) {
leaks.push(path.relative(tmpDir, file));
}
}
return leaks;
}
test('skills contain no CLAUDE.md or Claude Code references', () => {
const hermesDir = path.join(tmpDir, '.hermes');
const skillsDir = path.join(hermesDir, 'skills');
assert.ok(fs.existsSync(skillsDir), 'skills directory exists');
const skillFiles = walk(skillsDir).filter(f => f.endsWith('.md'));
assert.ok(skillFiles.length > 0, 'at least one skill file exists');
const leaks = [];
for (const file of skillFiles) {
const content = fs.readFileSync(file, 'utf8');
if (/\bCLAUDE\.md\b/.test(content) || /\bClaude Code\b/.test(content)) {
leaks.push(path.relative(tmpDir, file));
}
}
assert.strictEqual(leaks.length, 0,
[
'Skills should not contain Claude references after Hermes install.',
'Leaking files:',
...leaks,
].join('\n'));
});
test('agents contain no CLAUDE.md or Claude Code references', () => {
const agentsDir = path.join(tmpDir, '.hermes', 'agents');
assert.ok(fs.existsSync(agentsDir), 'agents directory exists');
const agentFiles = walk(agentsDir).filter(f => f.endsWith('.md'));
assert.ok(agentFiles.length > 0, 'at least one agent file exists');
const leaks = [];
for (const file of agentFiles) {
const content = fs.readFileSync(file, 'utf8');
if (/\bCLAUDE\.md\b/.test(content) || /\bClaude Code\b/.test(content)) {
leaks.push(path.relative(tmpDir, file));
}
}
assert.strictEqual(leaks.length, 0,
[
'Agents should not contain Claude references after Hermes install.',
'Leaking files:',
...leaks,
].join('\n'));
});
test('hooks contain no .claude/ path references', () => {
const hooksDir = path.join(tmpDir, '.hermes', 'hooks');
if (!fs.existsSync(hooksDir)) {
return; // hooks may not be present in local installs
}
const hookFiles = walk(hooksDir).filter(f => f.endsWith('.js'));
const leaks = [];
for (const file of hookFiles) {
const content = fs.readFileSync(file, 'utf8');
if (/\.claude\//.test(content)) {
leaks.push(path.relative(tmpDir, file));
}
}
assert.strictEqual(leaks.length, 0,
[
'Hooks should not contain .claude/ path references after Hermes install.',
'Leaking files:',
...leaks,
].join('\n'));
});
test('full tree scan finds zero Claude references outside CHANGELOG.md', () => {
const leaks = findClaudeLeaks();
assert.strictEqual(leaks.length, 0,
[
'No files under .hermes/ (except CHANGELOG.md) should contain Claude references.',
`Found ${leaks.length} leaking file(s):`,
...leaks,
].join('\n'));
});
});

View File

@@ -1,508 +0,0 @@
'use strict';
/**
* Regression tests for two confirmed defects from commit 6c676dbc (#3664)
* and three legacy-migration gaps caught in cc2ecb97 (#3664 Phase 2):
*
* Defect #1 — Hermes upgrade leaves stale skills/gsd/gsd-<stem>/ dirs
* _runLegacyInstallMigrations only removed pre-#2841 flat skills/gsd-slash-star
* dirs; it did not remove the intermediate skills/gsd/gsd-slash-star layout that
* existed between #2841 and #3664.
*
* Defect #2 — `--hermes --profile=core` falls through to wrong path
* The dispatcher's minimal-mode block had no `isHermes && _isCoreProfileAlias`
* branch, so Hermes + core fell to the terminal `else` (Claude local path),
* writing commands/gsd/<stem>.md instead of skills/gsd/.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { createTempDir, cleanup } = require('./helpers.cjs');
const {
loadSkillsManifest,
resolveProfile,
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
// Load install exports via GSD_TEST_MODE to skip CLI main()
const savedTestMode = process.env.GSD_TEST_MODE;
process.env.GSD_TEST_MODE = '1';
let installExports;
try {
installExports = require('../bin/install.js');
} finally {
if (savedTestMode === undefined) delete process.env.GSD_TEST_MODE;
else process.env.GSD_TEST_MODE = savedTestMode;
}
const { installRuntimeArtifacts, uninstallRuntimeArtifacts } = installExports || {};
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR);
const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST });
// ---------------------------------------------------------------------------
// Test A1 — Defect #1 regression: stale skills/gsd/gsd-*/ dirs are removed
// ---------------------------------------------------------------------------
describe('Defect #1 regression: _runLegacyInstallMigrations removes skills/gsd/gsd-*/ layout', () => {
test('installRuntimeArtifacts removes intermediate skills/gsd/gsd-*/ dirs and writes new bare-stem layout', (t) => {
const configDir = createTempDir('gsd-hermes-reg1-');
t.after(() => cleanup(configDir));
assert.strictEqual(
typeof installRuntimeArtifacts,
'function',
'installRuntimeArtifacts must be exported from bin/install.js',
);
// Pre-create the intermediate Hermes layout (between #2841 and #3664):
// skills/gsd/gsd-help/SKILL.md and skills/gsd/gsd-plan/SKILL.md
const nestedGsdDir = path.join(configDir, 'skills', 'gsd');
fs.mkdirSync(path.join(nestedGsdDir, 'gsd-help'), { recursive: true });
fs.writeFileSync(path.join(nestedGsdDir, 'gsd-help', 'SKILL.md'), '# legacy help\n');
fs.mkdirSync(path.join(nestedGsdDir, 'gsd-plan'), { recursive: true });
fs.writeFileSync(path.join(nestedGsdDir, 'gsd-plan', 'SKILL.md'), '# legacy plan\n');
// Create a sibling non-gsd dir inside skills/gsd/ that must survive
const userContentDir = path.join(nestedGsdDir, 'user-content');
fs.mkdirSync(userContentDir, { recursive: true });
fs.writeFileSync(path.join(userContentDir, 'SKILL.md'), '# user content\n');
// Run the unified install
installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_CORE);
// Stale intermediate dirs must be gone
assert.ok(
!fs.existsSync(path.join(nestedGsdDir, 'gsd-help')),
'skills/gsd/gsd-help/ must be removed by install migration (Defect #1)',
);
assert.ok(
!fs.existsSync(path.join(nestedGsdDir, 'gsd-plan')),
'skills/gsd/gsd-plan/ must be removed by install migration (Defect #1)',
);
// New bare-stem layout must exist
assert.ok(
fs.existsSync(path.join(nestedGsdDir, 'help', 'SKILL.md')),
'skills/gsd/help/SKILL.md must exist after install (new bare-stem layout)',
);
// User content in skills/gsd/ must be preserved (user-content has no gsd- prefix)
assert.ok(
fs.existsSync(path.join(userContentDir, 'SKILL.md')),
'skills/gsd/user-content/SKILL.md must be preserved (user content)',
);
});
});
// ---------------------------------------------------------------------------
// Test Q1 — Qwen variant of Defect #2: --qwen --profile=core uses skills/gsd-*/ path
// ---------------------------------------------------------------------------
describe('Qwen Defect #2 regression: --qwen --profile=core writes skills/gsd-*/, not commands/gsd/', () => {
test('spawn node bin/install.js --qwen --global --profile=core: skills/gsd-*/ written, no commands/gsd/', (t) => {
const root = createTempDir('gsd-qwen-reg2-');
t.after(() => cleanup(root));
const result = spawnSync(
process.execPath,
[INSTALL_SCRIPT, '--qwen', '--global', '--config-dir', root, '--profile=core'],
{
encoding: 'utf8',
env: { ...process.env, HOME: root, USERPROFILE: root },
},
);
assert.strictEqual(
result.status,
0,
`installer exited with status ${result.status}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`,
);
// skills/ must exist and contain at least one gsd-<stem>/ dir with SKILL.md
const qwenSkillsDir = path.join(root, 'skills');
assert.ok(
fs.existsSync(qwenSkillsDir),
`skills/ must exist after --qwen --profile=core install (got: ${result.stdout})`,
);
const skillDirs = fs.readdirSync(qwenSkillsDir, { withFileTypes: true })
.filter((e) => e.isDirectory() && e.name.startsWith('gsd-'));
assert.ok(
skillDirs.length >= 1,
`skills/ must contain at least one gsd-* skill dir, got: ${
fs.readdirSync(qwenSkillsDir, { withFileTypes: true }).map((e) => e.name).join(', ')
}`,
);
// Verify at least one gsd-* dir has SKILL.md
const hasSkillMd = skillDirs.some((e) =>
fs.existsSync(path.join(qwenSkillsDir, e.name, 'SKILL.md')),
);
assert.ok(hasSkillMd, 'at least one skills/gsd-*/SKILL.md must exist');
// commands/gsd/*.md must NOT exist (the regression path that Defect #2 causes for Qwen)
const commandsGsd = path.join(root, 'commands', 'gsd');
if (fs.existsSync(commandsGsd)) {
const mdFiles = fs.readdirSync(commandsGsd).filter((f) => f.endsWith('.md'));
assert.strictEqual(
mdFiles.length,
0,
`commands/gsd/ must not contain .md files after qwen install (Qwen Defect #2). Found: ${mdFiles.join(', ')}`,
);
}
});
});
// ---------------------------------------------------------------------------
// Test A2 — Defect #2 regression: --hermes --profile=core uses skills/gsd/ path
// ---------------------------------------------------------------------------
describe('Defect #2 regression: --hermes --profile=core writes skills/gsd/, not commands/gsd/', () => {
test('spawn node bin/install.js --hermes --global --profile=core: skills/gsd/ written, no commands/gsd/', (t) => {
const root = createTempDir('gsd-hermes-reg2-');
t.after(() => cleanup(root));
const result = spawnSync(
process.execPath,
[INSTALL_SCRIPT, '--hermes', '--global', '--config-dir', root, '--profile=core'],
{
encoding: 'utf8',
env: { ...process.env, HOME: root, USERPROFILE: root },
},
);
assert.strictEqual(
result.status,
0,
`installer exited with status ${result.status}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`,
);
// skills/gsd/ must exist and contain at least one <stem>/SKILL.md file
const hermesSkillsGsd = path.join(root, 'skills', 'gsd');
assert.ok(
fs.existsSync(hermesSkillsGsd),
`skills/gsd/ must exist after --hermes --profile=core install (got: ${result.stdout})`,
);
const skillDirs = fs.readdirSync(hermesSkillsGsd, { withFileTypes: true })
.filter((e) => e.isDirectory());
assert.ok(
skillDirs.length >= 1,
`skills/gsd/ must contain at least one skill dir, got: ${skillDirs.map((e) => e.name).join(', ')}`,
);
// Verify at least one skill dir has SKILL.md
const hasSkillMd = skillDirs.some((e) =>
fs.existsSync(path.join(hermesSkillsGsd, e.name, 'SKILL.md')),
);
assert.ok(hasSkillMd, 'at least one skills/gsd/<stem>/SKILL.md must exist');
// commands/gsd/*.md must NOT exist (the regression path that Defect #2 caused)
const commandsGsd = path.join(root, 'commands', 'gsd');
if (fs.existsSync(commandsGsd)) {
const mdFiles = fs.readdirSync(commandsGsd).filter((f) => f.endsWith('.md'));
assert.strictEqual(
mdFiles.length,
0,
`commands/gsd/ must not contain .md files after hermes install (Defect #2). Found: ${mdFiles.join(', ')}`,
);
}
});
});
// ---------------------------------------------------------------------------
// Test M1 — Hermes minimal-mode migrates dev-preferences (#2973)
// Hermes uses nested layout (skills/gsd/<stem>/SKILL.md, prefix=''),
// so dev-preferences lands at skills/gsd/dev-preferences/SKILL.md NOT
// skills/gsd-dev-preferences/SKILL.md (the flat layout for qwen/claude-global).
// ---------------------------------------------------------------------------
describe('M1: --hermes --global --profile=core migrates dev-preferences.md → skills/gsd/dev-preferences/SKILL.md', () => {
test('spawn node bin/install.js --hermes --global --profile=core: dev-preferences migrated, source gone', (t) => {
const root = createTempDir('gsd-hermes-m1-');
t.after(() => cleanup(root));
// Pre-create legacy commands/gsd/dev-preferences.md
const legacyDir = path.join(root, 'commands', 'gsd');
fs.mkdirSync(legacyDir, { recursive: true });
fs.writeFileSync(path.join(legacyDir, 'dev-preferences.md'), '# my hermes prefs\n');
const result = spawnSync(
process.execPath,
[INSTALL_SCRIPT, '--hermes', '--global', '--config-dir', root, '--profile=core'],
{
encoding: 'utf8',
env: { ...process.env, HOME: root, USERPROFILE: root },
},
);
assert.strictEqual(
result.status,
0,
`installer exited with status ${result.status}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`,
);
// Migration target must exist at the HERMES nested location (skills/gsd/dev-preferences/SKILL.md)
// NOT the flat location skills/gsd-dev-preferences/SKILL.md used by qwen/claude-global.
const skillFile = path.join(root, 'skills', 'gsd', 'dev-preferences', 'SKILL.md');
assert.ok(
fs.existsSync(skillFile),
`skills/gsd/dev-preferences/SKILL.md must exist after migration (M1: Hermes nested layout, not flat)`,
);
const content = fs.readFileSync(skillFile, 'utf8');
assert.strictEqual(
content,
'# my hermes prefs\n',
`skills/gsd/dev-preferences/SKILL.md content must equal original, got: ${JSON.stringify(content)}`,
);
// Legacy source must be gone
assert.ok(
!fs.existsSync(path.join(legacyDir, 'dev-preferences.md')),
'commands/gsd/dev-preferences.md must be removed after migration (M1)',
);
});
});
// ---------------------------------------------------------------------------
// Test M2 — Qwen minimal-mode migrates dev-preferences (#2973)
// ---------------------------------------------------------------------------
describe('M2: --qwen --global --profile=core migrates dev-preferences.md → skills/gsd-dev-preferences/SKILL.md', () => {
test('spawn node bin/install.js --qwen --global --profile=core: dev-preferences migrated, source gone', (t) => {
const root = createTempDir('gsd-qwen-m2-');
t.after(() => cleanup(root));
// Pre-create legacy commands/gsd/dev-preferences.md
const legacyDir = path.join(root, 'commands', 'gsd');
fs.mkdirSync(legacyDir, { recursive: true });
fs.writeFileSync(path.join(legacyDir, 'dev-preferences.md'), '# my qwen prefs\n');
const result = spawnSync(
process.execPath,
[INSTALL_SCRIPT, '--qwen', '--global', '--config-dir', root, '--profile=core'],
{
encoding: 'utf8',
env: { ...process.env, HOME: root, USERPROFILE: root },
},
);
assert.strictEqual(
result.status,
0,
`installer exited with status ${result.status}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`,
);
// Migration target must exist and contain the original content
const skillFile = path.join(root, 'skills', 'gsd-dev-preferences', 'SKILL.md');
assert.ok(
fs.existsSync(skillFile),
`skills/gsd-dev-preferences/SKILL.md must exist after migration (M2: _runLegacyInstallMigrations missing in qwen minimal branch)`,
);
const content = fs.readFileSync(skillFile, 'utf8');
assert.strictEqual(
content,
'# my qwen prefs\n',
`skills/gsd-dev-preferences/SKILL.md content must equal original, got: ${JSON.stringify(content)}`,
);
// Legacy source must be gone
assert.ok(
!fs.existsSync(path.join(legacyDir, 'dev-preferences.md')),
'commands/gsd/dev-preferences.md must be removed after migration (M2)',
);
});
});
// ---------------------------------------------------------------------------
// Test M3 — Claude global minimal-mode migrates dev-preferences (#2973)
// ---------------------------------------------------------------------------
describe('M3: --claude --global --profile=core migrates dev-preferences.md → skills/gsd-dev-preferences/SKILL.md', () => {
test('spawn node bin/install.js --claude --global --profile=core: dev-preferences migrated, source gone', (t) => {
const root = createTempDir('gsd-claude-m3-');
t.after(() => cleanup(root));
// Pre-create legacy commands/gsd/dev-preferences.md
const legacyDir = path.join(root, 'commands', 'gsd');
fs.mkdirSync(legacyDir, { recursive: true });
fs.writeFileSync(path.join(legacyDir, 'dev-preferences.md'), '# my claude prefs\n');
const result = spawnSync(
process.execPath,
[INSTALL_SCRIPT, '--claude', '--global', '--config-dir', root, '--profile=core'],
{
encoding: 'utf8',
env: { ...process.env, HOME: root, USERPROFILE: root },
},
);
assert.strictEqual(
result.status,
0,
`installer exited with status ${result.status}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`,
);
// Migration target must exist and contain the original content
const skillFile = path.join(root, 'skills', 'gsd-dev-preferences', 'SKILL.md');
assert.ok(
fs.existsSync(skillFile),
`skills/gsd-dev-preferences/SKILL.md must exist after migration (M3: _runLegacyInstallMigrations missing in claude-global-minimal branch)`,
);
const content = fs.readFileSync(skillFile, 'utf8');
assert.strictEqual(
content,
'# my claude prefs\n',
`skills/gsd-dev-preferences/SKILL.md content must equal original, got: ${JSON.stringify(content)}`,
);
// Legacy source must be gone
assert.ok(
!fs.existsSync(path.join(legacyDir, 'dev-preferences.md')),
'commands/gsd/dev-preferences.md must be removed after migration (M3)',
);
});
});
// ---------------------------------------------------------------------------
// Test U1 — Qwen uninstall preserves dev-preferences via migration to skill
// ---------------------------------------------------------------------------
describe('U1: uninstallRuntimeArtifacts qwen preserves dev-preferences.md via migration to skills/gsd-dev-preferences/SKILL.md', () => {
test('uninstallRuntimeArtifacts("qwen"): commands/gsd/ removed, dev-preferences migrated to skills skill', (t) => {
const configDir = createTempDir('gsd-qwen-uninstall-u1-');
t.after(() => cleanup(configDir));
assert.strictEqual(
typeof uninstallRuntimeArtifacts,
'function',
'uninstallRuntimeArtifacts must be exported from bin/install.js',
);
// Pre-create legacy commands/gsd/ with dev-preferences.md and a managed file
const legacyDir = path.join(configDir, 'commands', 'gsd');
fs.mkdirSync(legacyDir, { recursive: true });
fs.writeFileSync(path.join(legacyDir, 'dev-preferences.md'), '# my qwen prefs\n');
fs.writeFileSync(path.join(legacyDir, 'help.md'), '# help content\n');
uninstallRuntimeArtifacts('qwen', configDir, 'global');
// Legacy managed file must be gone (whole commands/gsd/ removed)
assert.ok(
!fs.existsSync(path.join(legacyDir, 'help.md')),
'commands/gsd/help.md must be removed by qwen uninstall (U1)',
);
// dev-preferences.md must NOT be left behind in the legacy dir
assert.ok(
!fs.existsSync(path.join(legacyDir, 'dev-preferences.md')),
'commands/gsd/dev-preferences.md must not exist after qwen uninstall (U1)',
);
// dev-preferences.md must be migrated to skills/gsd-dev-preferences/SKILL.md
const skillFile = path.join(configDir, 'skills', 'gsd-dev-preferences', 'SKILL.md');
assert.ok(
fs.existsSync(skillFile),
'skills/gsd-dev-preferences/SKILL.md must exist after qwen uninstall (U1: #2973 migration)',
);
const content = fs.readFileSync(skillFile, 'utf8');
assert.strictEqual(
content,
'# my qwen prefs\n',
`skills/gsd-dev-preferences/SKILL.md content must equal original, got: ${JSON.stringify(content)}`,
);
});
});
// ---------------------------------------------------------------------------
// Test U2 — Claude-global uninstall preserves dev-preferences via migration to skill
// ---------------------------------------------------------------------------
describe('U2: uninstallRuntimeArtifacts claude/global preserves dev-preferences.md via migration to skills/gsd-dev-preferences/SKILL.md', () => {
test('uninstallRuntimeArtifacts("claude", scope="global"): commands/gsd/ removed, dev-preferences migrated to skills skill', (t) => {
const configDir = createTempDir('gsd-claude-uninstall-u2-');
t.after(() => cleanup(configDir));
assert.strictEqual(
typeof uninstallRuntimeArtifacts,
'function',
'uninstallRuntimeArtifacts must be exported from bin/install.js',
);
// Pre-create legacy commands/gsd/ with dev-preferences.md
const legacyDir = path.join(configDir, 'commands', 'gsd');
fs.mkdirSync(legacyDir, { recursive: true });
fs.writeFileSync(path.join(legacyDir, 'dev-preferences.md'), '# my claude prefs\n');
uninstallRuntimeArtifacts('claude', configDir, 'global');
// dev-preferences.md must NOT be left in the legacy dir
assert.ok(
!fs.existsSync(path.join(legacyDir, 'dev-preferences.md')),
'commands/gsd/dev-preferences.md must not exist after claude-global uninstall (U2)',
);
// dev-preferences.md must be migrated to skills/gsd-dev-preferences/SKILL.md
const skillFile = path.join(configDir, 'skills', 'gsd-dev-preferences', 'SKILL.md');
assert.ok(
fs.existsSync(skillFile),
'skills/gsd-dev-preferences/SKILL.md must exist after claude-global uninstall (U2: #2973 migration)',
);
const content = fs.readFileSync(skillFile, 'utf8');
assert.strictEqual(
content,
'# my claude prefs\n',
`skills/gsd-dev-preferences/SKILL.md content must equal original, got: ${JSON.stringify(content)}`,
);
});
});
// ---------------------------------------------------------------------------
// Test U3 — Hermes uninstall migrates dev-preferences to NESTED location (#2973)
// Proves both Finding 1 (wrong target path) and Finding 2 (unconditional restore).
// ---------------------------------------------------------------------------
describe('U3: uninstallRuntimeArtifacts hermes migrates dev-preferences.md → skills/gsd/dev-preferences/SKILL.md', () => {
test('uninstallRuntimeArtifacts("hermes"): commands/gsd/ NOT recreated, dev-preferences at nested Hermes location', (t) => {
const configDir = createTempDir('gsd-hermes-uninstall-u3-');
t.after(() => cleanup(configDir));
assert.strictEqual(
typeof uninstallRuntimeArtifacts,
'function',
'uninstallRuntimeArtifacts must be exported from bin/install.js',
);
// Pre-create legacy commands/gsd/dev-preferences.md
const legacyDir = path.join(configDir, 'commands', 'gsd');
fs.mkdirSync(legacyDir, { recursive: true });
fs.writeFileSync(path.join(legacyDir, 'dev-preferences.md'), '# my hermes prefs (uninstall path)\n');
uninstallRuntimeArtifacts('hermes', configDir, 'global');
// commands/gsd/dev-preferences.md must be GONE — no recreation after rmSync
assert.ok(
!fs.existsSync(path.join(legacyDir, 'dev-preferences.md')),
'commands/gsd/dev-preferences.md must not exist after hermes uninstall (U3: Finding 2 — no unconditional restore)',
);
// dev-preferences must be at the HERMES nested location (skills/gsd/dev-preferences/SKILL.md)
// NOT the flat location skills/gsd-dev-preferences/SKILL.md (that is qwen/claude-global)
const skillFile = path.join(configDir, 'skills', 'gsd', 'dev-preferences', 'SKILL.md');
assert.ok(
fs.existsSync(skillFile),
'skills/gsd/dev-preferences/SKILL.md must exist after hermes uninstall (U3: Finding 1 — runtime-aware migration target)',
);
const content = fs.readFileSync(skillFile, 'utf8');
assert.strictEqual(
content,
'# my hermes prefs (uninstall path)\n',
`skills/gsd/dev-preferences/SKILL.md content must equal original, got: ${JSON.stringify(content)}`,
);
});
});

View File

@@ -1,461 +0,0 @@
// allow-test-rule: pending-migration-to-typed-ir [#2974]
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
// "Prohibited: Raw Text Matching on Test Outputs". Per-file review may
// reclassify some entries as source-text-is-the-product during migration.
/**
* Regression tests for install process hook copying, permissions, manifest
* tracking, uninstall cleanup, and settings.json registration.
*
* Covers: #1755, Codex hook path/filename, cache invalidation path,
* manifest .sh tracking, uninstall settings cleanup, dead code removal.
*/
'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('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const { cleanup, createTempDir } = require('./helpers.cjs');
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
const { writeManifest, validateHookFields } = require(INSTALL_SRC);
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist');
// Expected .sh community hooks
const EXPECTED_SH_HOOKS = [
'gsd-session-state.sh',
'gsd-validate-commit.sh',
'gsd-phase-boundary.sh',
];
// All hooks that should be in hooks/dist/ after build
const EXPECTED_ALL_HOOKS = [
'gsd-check-update.js',
'gsd-context-monitor.js',
'gsd-prompt-guard.js',
'gsd-read-guard.js',
'gsd-read-injection-scanner.js',
'gsd-statusline.js',
'gsd-workflow-guard.js',
...EXPECTED_SH_HOOKS,
];
const isWindows = process.platform === 'win32';
// ─── Ensure hooks/dist/ is populated ────────────────────────────────────────
before(() => {
execFileSync(process.execPath, [BUILD_SCRIPT], {
encoding: 'utf-8',
stdio: 'pipe',
});
});
// ─── Helper: simulate the hook copy loop from install.js ────────────────────
// NOTE: This helper mirrors the chmod/copy logic only. It omits the .js
// template substitution ('.claude' → runtime dir, {{GSD_VERSION}} stamping)
// since these tests focus on file presence and permissions, not content.
function simulateHookCopy(hooksSrc, hooksDest) {
fs.mkdirSync(hooksDest, { recursive: true });
const hookEntries = fs.readdirSync(hooksSrc);
for (const entry of hookEntries) {
const srcFile = path.join(hooksSrc, entry);
if (fs.statSync(srcFile).isFile()) {
const destFile = path.join(hooksDest, entry);
if (entry.endsWith('.js')) {
const content = fs.readFileSync(srcFile, 'utf8');
fs.writeFileSync(destFile, content);
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows */ }
} else {
fs.copyFileSync(srcFile, destFile);
if (entry.endsWith('.sh')) {
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows */ }
}
}
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// 1. Hook file copy and permissions (#1755)
// ─────────────────────────────────────────────────────────────────────────────
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 in target hooks dir`
);
}
});
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 after install copy`
);
}
});
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);
const jsHooks = EXPECTED_ALL_HOOKS.filter(h => h.endsWith('.js'));
for (const js of jsHooks) {
const stat = fs.statSync(path.join(hooksDest, js));
assert.ok(
(stat.mode & 0o111) !== 0,
`${js} should be executable after install copy`
);
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 2. install.js source-level correctness checks
// ─────────────────────────────────────────────────────────────────────────────
describe('install.js source correctness', () => {
let src;
before(() => {
src = fs.readFileSync(INSTALL_SRC, 'utf-8');
});
test('.sh files get chmod after copyFileSync', () => {
// The else branch for non-.js hooks should apply chmod for .sh files
assert.ok(
src.includes("if (entry.endsWith('.sh'))"),
'install.js should check for .sh extension to apply chmod'
);
});
test('Codex hook uses correct filename gsd-check-update.js (not gsd-update-check.js)', () => {
// The cache file gsd-update-check.json is legitimate (different artifact);
// check that no hook registration uses the inverted .js filename.
// Match the exact pattern: quote + gsd-update-check.js + quote
assert.ok(
!src.match(/['"]gsd-update-check\.js['"]/),
'install.js must not reference the inverted hook name gsd-update-check.js in quotes'
);
});
test('Codex hook path does not use get-shit-done/hooks/ subdirectory', () => {
// The Codex hook should resolve to targetDir/hooks/, not targetDir/get-shit-done/hooks/
assert.ok(
!src.includes("'get-shit-done', 'hooks', 'gsd-check-update"),
'Codex hook should not use get-shit-done/hooks/ path segment'
);
});
test('cache invalidation uses ~/.cache/gsd/ path', () => {
assert.ok(
src.includes("os.homedir(), '.cache', 'gsd'"),
'Cache path should use os.homedir()/.cache/gsd/'
);
});
test('manifest tracks .sh hook files', () => {
assert.ok(
src.includes("file.endsWith('.sh')"),
'writeManifest should track .sh files in addition to .js'
);
});
test('gsd-workflow-guard.js is in uninstall hook list', () => {
const gsdHooksMatch = src.match(/const gsdHooks\s*=\s*\[([^\]]+)\]/);
assert.ok(gsdHooksMatch, 'gsdHooks array should exist');
const gsdHooksContent = gsdHooksMatch[1];
assert.ok(
gsdHooksContent.includes('gsd-workflow-guard.js'),
'gsdHooks should include gsd-workflow-guard.js'
);
});
test('phantom gsd-check-update.sh is not in uninstall hook list', () => {
const gsdHooksMatch = src.match(/const gsdHooks\s*=\s*\[([^\]]+)\]/);
assert.ok(gsdHooksMatch, 'gsdHooks array should exist');
const gsdHooksContent = gsdHooksMatch[1];
assert.ok(
!gsdHooksContent.includes('gsd-check-update.sh'),
'gsdHooks should not include phantom gsd-check-update.sh'
);
});
test('isGsdHookCommand covers all GSD hook names', () => {
// The consolidated uninstall cleanup uses isGsdHookCommand — verify all hook names are present
const expectedHookNames = [
'gsd-check-update', 'gsd-statusline', 'gsd-session-state',
'gsd-context-monitor', 'gsd-phase-boundary', 'gsd-prompt-guard',
'gsd-read-guard', 'gsd-validate-commit', 'gsd-workflow-guard',
];
for (const name of expectedHookNames) {
assert.ok(
src.includes(`'${name}'`) || src.includes(`"${name}"`),
`isGsdHookCommand should match ${name}`
);
}
});
test('Codex install migrates legacy gsd-update-check entries', () => {
assert.ok(
src.includes('gsd-update-check'),
'install.js should detect legacy gsd-update-check entries for migration'
);
});
test('no duplicate isCursor or isWindsurf branches in uninstall skill removal', () => {
// The uninstall skill removal if/else chain should not have standalone
// isCursor or isWindsurf branches — they're already handled by the combined
// (isCodex || isCursor || isWindsurf || isTrae) branch
const uninstallStart = src.indexOf('function uninstall(');
const uninstallEnd = src.indexOf('function verifyInstalled(');
assert.ok(uninstallStart !== -1, 'function uninstall( must exist in install.js');
assert.ok(uninstallEnd !== -1, 'function verifyInstalled( must exist in install.js');
const uninstallBlock = src.substring(uninstallStart, uninstallEnd);
// Count occurrences of 'else if (isCursor)' in uninstall — should be 0
const cursorBranches = (uninstallBlock.match(/else if \(isCursor\)/g) || []).length;
assert.strictEqual(cursorBranches, 0, 'No standalone isCursor branch should exist in uninstall');
// Count occurrences of 'else if (isWindsurf)' in uninstall — should be 0
const windsurfBranches = (uninstallBlock.match(/else if \(isWindsurf\)/g) || []).length;
assert.strictEqual(windsurfBranches, 0, 'No standalone isWindsurf branch should exist in uninstall');
});
test('verifyInstalled warns about missing .sh hooks', () => {
assert.ok(
src.includes('Missing expected hook:'),
'install should warn about missing .sh hooks after verification'
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 3. Manifest tracks .sh hooks
// ─────────────────────────────────────────────────────────────────────────────
describe('writeManifest includes .sh hooks', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-manifest-');
// Set up minimal structure expected by writeManifest
const hooksDir = path.join(tmpDir, 'hooks');
fs.mkdirSync(hooksDir, { recursive: true });
// Copy hooks from dist to simulate install
simulateHookCopy(HOOKS_DIST, hooksDir);
});
afterEach(() => {
cleanup(tmpDir);
});
test('manifest contains .sh hook entries', () => {
writeManifest(tmpDir, 'claude');
const manifestPath = path.join(tmpDir, 'gsd-file-manifest.json');
assert.ok(fs.existsSync(manifestPath), 'manifest file should exist');
const manifest = JSON.parse(fs.readFileSync(manifestPath, '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 manifestPath = path.join(tmpDir, 'gsd-file-manifest.json');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const jsHooks = EXPECTED_ALL_HOOKS.filter(h => h.endsWith('.js'));
for (const js of jsHooks) {
assert.ok(
manifest.files['hooks/' + js],
`manifest should contain hash for ${js}`
);
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 4. Uninstall per-hook granularity (#1755 followup)
// ─────────────────────────────────────────────────────────────────────────────
describe('uninstall settings cleanup preserves user hooks', () => {
// Mirror the isGsdHookCommand logic from install.js
const isGsdHookCommand = (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'));
// Simulate the per-hook filtering logic from uninstall
function filterGsdHooks(entries) {
return entries
.map(entry => {
if (!entry.hooks || !Array.isArray(entry.hooks)) return entry;
entry.hooks = entry.hooks.filter(h => !isGsdHookCommand(h.command));
return entry.hooks.length > 0 ? entry : null;
})
.filter(Boolean);
}
test('mixed entry with GSD + user hooks preserves user hooks', () => {
const entries = [{
matcher: 'Bash',
hooks: [
{ type: 'command', command: 'node /path/to/gsd-prompt-guard.js' },
{ type: 'command', command: 'bash /my/custom-lint.sh' },
],
}];
const result = filterGsdHooks(entries);
assert.strictEqual(result.length, 1, 'entry should survive with remaining user hook');
assert.strictEqual(result[0].hooks.length, 1, 'only user hook should remain');
assert.ok(result[0].hooks[0].command.includes('custom-lint'), 'user hook preserved');
});
test('entry with only GSD hooks is fully removed', () => {
const entries = [{
hooks: [
{ type: 'command', command: 'node /path/to/gsd-check-update.js' },
{ type: 'command', command: 'node /path/to/gsd-statusline.js' },
],
}];
const result = filterGsdHooks(entries);
assert.strictEqual(result.length, 0, 'entry should be removed when all hooks are GSD');
});
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, 'entry should survive');
assert.strictEqual(result[0].hooks.length, 1, 'user hook should remain');
});
test('non-array hook entries are preserved during uninstall (#1825)', () => {
const entries = [
{ type: 'custom', command: 'echo hello' },
{ matcher: 'Bash', hooks: [{ type: 'command', command: 'node /path/to/gsd-prompt-guard.js' }] },
{ url: 'https://example.com/webhook' },
];
const result = filterGsdHooks(JSON.parse(JSON.stringify(entries)));
assert.strictEqual(result.length, 2, 'both non-array entries should survive');
assert.deepStrictEqual(result[0], { type: 'custom', command: 'echo hello' }, 'first non-array entry preserved');
assert.deepStrictEqual(result[1], { url: 'https://example.com/webhook' }, 'second non-array entry preserved');
});
test('all GSD hook names are recognized by isGsdHookCommand', () => {
const gsdCommands = [
'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 gsdCommands) {
assert.ok(isGsdHookCommand(cmd), `should recognize: ${cmd}`);
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 5. Codex legacy migration
// ─────────────────────────────────────────────────────────────────────────────
describe('Codex legacy gsd-update-check migration', () => {
test('install.js strips legacy gsd-update-check hook blocks from config', () => {
const src = fs.readFileSync(INSTALL_SRC, 'utf-8');
assert.ok(
src.includes('gsd-update-check') && src.includes('replace('),
'install.js should have migration logic to strip legacy gsd-update-check entries'
);
});
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;
content = content.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'), 'legacy hook block should be removed');
assert.ok(content.includes('[features]'), 'non-hook content should be preserved');
});
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;
content = content.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'), 'legacy CRLF hook block should be removed');
assert.ok(content.includes('[features]'), 'non-hook content should be preserved');
});
});

View File

@@ -1,354 +0,0 @@
/**
* Per-runtime regression test for `--minimal` install profile (#2923).
*
* Background: #2923 reported that `--opencode --local --minimal` silently
* installed the full surface. While auditing the central gate
* (`stageSkillsForMode` in get-shit-done/bin/lib/install-profiles.cjs),
* we found that:
* - Skills are correctly filtered for every runtime in both `--global`
* and `--local` modes (the dispatch sites in install.js all call
* stageSkillsForMode unconditionally).
* - Agents are correctly suppressed under --minimal.
* - HOWEVER, the install manifest only recorded `commands/gsd/` for
* Gemini, leaving Claude Code local installs with an incomplete
* manifest. saveLocalPatches() then couldn't detect user edits and
* a minimal-mode reinstall couldn't be verified manifest-side.
*
* This test pins per-runtime behavior end-to-end: spawn the installer
* with --minimal for each runtime in each scope, parse the resulting
* manifest JSON, assert that mode === 'minimal', the recorded skill set
* equals MINIMAL_SKILL_ALLOWLIST, and zero gsd-* agents are present.
*
* Cline is rules-based and embeds the workflow in `.clinerules` rather
* than emitting per-skill files. Asserted separately: mode === 'minimal',
* zero agents, .clinerules exists.
*
* No regex / `.includes()` against file contents — every assertion
* either parses JSON or walks a directory tree.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { spawnSync } = require('child_process');
const {
MINIMAL_SKILL_ALLOWLIST,
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
const MANIFEST_NAME = 'gsd-file-manifest.json';
// Per-runtime config dir name for local installs. Mirrors getDirName() in
// bin/install.js; kept as a fixture to avoid coupling the test to that
// internal helper.
const LOCAL_DIR_NAME = {
claude: '.claude',
opencode: '.opencode',
gemini: '.gemini',
kilo: '.kilo',
codex: '.codex',
copilot: '.github',
antigravity: '.agent',
cursor: '.cursor',
windsurf: '.windsurf',
augment: '.augment',
trae: '.trae',
qwen: '.qwen',
codebuddy: '.codebuddy',
cline: '.', // Cline writes to project root
};
// Skill-emitting runtimes (everything except Cline, which is rules-based).
const SKILL_RUNTIMES = [
'claude',
'opencode',
'gemini',
'kilo',
'codex',
'copilot',
'antigravity',
'cursor',
'windsurf',
'augment',
'trae',
'qwen',
'codebuddy',
];
const ALL_RUNTIMES = [...SKILL_RUNTIMES, 'cline'];
/**
* Run the installer in either global or local mode and return the parsed
* manifest (or null if no manifest was written).
*/
function runInstall({ runtime, scope, extraArgs = [] }) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-${runtime}-${scope}-`));
try {
let configDir;
let cwd = process.cwd();
const args = [INSTALL_SCRIPT, `--${runtime}`];
if (scope === 'global') {
args.push('--global', '--config-dir', root);
configDir = root;
} else {
args.push('--local');
cwd = root;
configDir = runtime === 'cline'
? root
: path.join(root, LOCAL_DIR_NAME[runtime]);
}
args.push(...extraArgs);
const result = spawnSync(process.execPath, args, {
cwd,
encoding: 'utf8',
// #3037: isolate HOME so the developer's real ~/.gemini/commands/gsd/
// doesn't leak into Gemini local-install conflict detection. The
// installer reads os.homedir() to detect prior global GSD installs;
// without this, the dev's existing global install causes the local
// install to skip (correct behavior for end users, wrong for tests
// that want to assert the local install path).
env: { ...process.env, HOME: root, USERPROFILE: root },
});
assert.strictEqual(
result.status,
0,
`installer exited with status ${result.status} for ${runtime} --${scope}` +
`\nstdout: ${result.stdout}\nstderr: ${result.stderr}`,
);
const manifestPath = path.join(configDir, MANIFEST_NAME);
let manifest = null;
if (fs.existsSync(manifestPath)) {
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
}
return { manifest, configDir, root, stdout: result.stdout, stderr: result.stderr };
} catch (err) {
fs.rmSync(root, { recursive: true, force: true });
throw err;
}
}
/**
* Walk the manifest's `files` keys and project them onto a per-runtime
* "skill set". Each runtime emits skills under one of three keyspaces:
* skills/<name>/... (Claude global, Codex, Copilot, Antigravity,
* Cursor, Windsurf, Augment, Trae, Qwen,
* CodeBuddy)
* command/gsd-<name>.md (OpenCode, Kilo)
* commands/gsd/<name>.md (Gemini, Claude local — fixed in #2923)
*
* Returns the unique set of skill basenames recorded in the manifest.
*/
function manifestSkillSet(manifest) {
if (!manifest || !manifest.files) return new Set();
const out = new Set();
for (const key of Object.keys(manifest.files)) {
if (key.startsWith('skills/')) {
// Strip both the optional `gsd-` prefix (used by Claude/Codex/etc as
// a per-skill subdir name) and any trailing `.md` (Codex flat layout).
const seg = key.split('/')[1].replace(/^gsd-/, '').replace(/\.md$/, '');
out.add(seg);
} else if (key.startsWith('command/')) {
const file = key.split('/')[1];
// Strip `gsd-` prefix and `.md` suffix. Subdirs flatten with `-`,
// but our minimal allowlist is flat (top-level files only) so this
// is safe here.
const base = file.replace(/^gsd-/, '').replace(/\.md$/, '');
out.add(base);
} else if (key.startsWith('commands/gsd/')) {
// Gemini transforms .md → .toml on emit; Claude local keeps .md.
const file = key.split('/')[2];
out.add(file.replace(/\.(md|toml)$/, ''));
}
}
return out;
}
function manifestAgentCount(manifest) {
if (!manifest || !manifest.files) return 0;
return Object.keys(manifest.files).filter((k) => k.startsWith('agents/')).length;
}
function expectedSkillSet() {
return new Set([...MINIMAL_SKILL_ALLOWLIST]);
}
function expectedManifestSkillSet(runtime) {
// Codex CLI 0.130.0 does not auto-discover commands from workflow / agent
// files (#3562) — it only registers commands from skills/<name>/SKILL.md.
// Codex installs therefore materialize the same minimal-allowlist skill
// surface as the other runtimes; the prior "Codex discovers official
// skills directly" assumption (which led to an empty Codex skill set
// here) does not hold in practice.
return expectedSkillSet();
}
describe('install: --minimal honoured for every runtime in --global mode', () => {
for (const runtime of SKILL_RUNTIMES) {
test(`${runtime} --global --minimal emits exactly the core skill set, zero agents`, () => {
const { manifest, root } = runInstall({
runtime,
scope: 'global',
extraArgs: ['--minimal'],
});
try {
assert.ok(manifest, `${runtime} global install must produce a manifest`);
assert.strictEqual(manifest.mode, 'minimal',
`${runtime} global manifest.mode should be "minimal"`);
assert.deepStrictEqual(
[...manifestSkillSet(manifest)].sort(),
[...expectedManifestSkillSet(runtime)].sort(),
`${runtime} global should record exactly the MINIMAL allowlist in the manifest`,
);
assert.strictEqual(manifestAgentCount(manifest), 0,
`${runtime} global --minimal should record zero gsd-* agents`);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
}
});
describe('install: --minimal honoured for every runtime in --local mode', () => {
for (const runtime of SKILL_RUNTIMES) {
test(`${runtime} --local --minimal emits exactly the core skill set, zero agents`, () => {
const { manifest, root } = runInstall({
runtime,
scope: 'local',
extraArgs: ['--minimal'],
});
try {
assert.ok(manifest, `${runtime} local install must produce a manifest`);
assert.strictEqual(manifest.mode, 'minimal',
`${runtime} local manifest.mode should be "minimal"`);
assert.deepStrictEqual(
[...manifestSkillSet(manifest)].sort(),
[...expectedManifestSkillSet(runtime)].sort(),
`${runtime} local should record exactly the MINIMAL allowlist in the manifest (regression guard for #2923)`,
);
assert.strictEqual(manifestAgentCount(manifest), 0,
`${runtime} local --minimal should record zero gsd-* agents`);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
}
});
describe('install: Cline --minimal (rules-based runtime — no skills/ dir)', () => {
for (const scope of ['global', 'local']) {
test(`cline --${scope} --minimal records mode=minimal and zero agents`, () => {
const { manifest, configDir, root } = runInstall({
runtime: 'cline',
scope,
extraArgs: ['--minimal'],
});
try {
assert.ok(manifest, `cline ${scope} install must produce a manifest`);
assert.strictEqual(manifest.mode, 'minimal');
assert.strictEqual(manifestAgentCount(manifest), 0,
`cline ${scope} --minimal should record zero gsd-* agents`);
// .clinerules exists (Cline embeds the workflow there in lieu of
// per-skill files).
const clinerules = path.join(configDir, '.clinerules');
assert.ok(fs.existsSync(clinerules),
`cline install should emit .clinerules at ${clinerules}`);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
}
});
describe('install: directory-on-disk matches manifest for --minimal', () => {
// Cross-check that the manifest isn't lying — actually walk the install
// dir and verify the gsd-* surface on disk equals what the manifest claims.
// This catches the inverse of #2923: manifest says minimal, but disk has
// full surface (or vice versa).
for (const runtime of SKILL_RUNTIMES) {
for (const scope of ['global', 'local']) {
test(`${runtime} --${scope} --minimal: on-disk skill files match manifest`, () => {
const { manifest, configDir, root } = runInstall({
runtime,
scope,
extraArgs: ['--minimal'],
});
try {
assert.ok(
manifest,
`${runtime} ${scope} --minimal: manifest must exist before parity check`,
);
const onDisk = collectSkillBasenamesOnDisk(configDir);
const inManifest = manifestSkillSet(manifest);
assert.deepStrictEqual(
[...onDisk].sort(),
[...inManifest].sort(),
`${runtime} ${scope}: on-disk skills must match manifest record`,
);
// And no gsd-*.md agent file should exist on disk either:
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, [],
`${runtime} ${scope} --minimal should not write gsd-*.md agents on disk`);
}
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
}
}
});
/**
* Walk the per-runtime install destination and return the set of skill
* basenames found on disk. Mirrors manifestSkillSet but reads the
* filesystem, not the manifest — used to verify the two agree.
*/
function collectSkillBasenamesOnDisk(configDir) {
const out = new Set();
// skills/<name>/SKILL.md (or SKILL.toml/.md depending on runtime)
const skillsDir = path.join(configDir, 'skills');
if (fs.existsSync(skillsDir)) {
for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
out.add(entry.name.replace(/^gsd-/, ''));
} else if (entry.isFile() && entry.name.startsWith('gsd-') && entry.name.endsWith('.md')) {
// Codex flat skills/ layout: skills/gsd-<name>.md
out.add(entry.name.replace(/^gsd-/, '').replace(/\.md$/, ''));
}
}
}
// command/gsd-<name>.md (OpenCode, Kilo)
const commandDir = path.join(configDir, 'command');
if (fs.existsSync(commandDir)) {
for (const file of fs.readdirSync(commandDir)) {
if (file.startsWith('gsd-') && file.endsWith('.md')) {
out.add(file.replace(/^gsd-/, '').replace(/\.md$/, ''));
}
}
}
// commands/gsd/<name>.{md,toml} (Claude local emits .md; Gemini emits .toml)
const commandsGsdDir = path.join(configDir, 'commands', 'gsd');
if (fs.existsSync(commandsGsdDir)) {
for (const file of fs.readdirSync(commandsGsdDir)) {
if (file.endsWith('.md') || file.endsWith('.toml')) {
out.add(file.replace(/\.(md|toml)$/, ''));
}
}
}
return out;
}

View File

@@ -1,87 +0,0 @@
'use strict';
/**
* Back-compat regression: --minimal still produces the same file set as before
* the profile model was introduced (modulo the phase-inclusion fix).
*
* Also verifies --profile=core is equivalent to --minimal.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { spawnSync } = require('child_process');
const {
MINIMAL_SKILL_ALLOWLIST,
PROFILES,
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
const MANIFEST_NAME = 'gsd-file-manifest.json';
describe('install-minimal-backcompat: PROFILES.core matches MINIMAL_SKILL_ALLOWLIST', () => {
test('PROFILES.core contains the same 7 skills as MINIMAL_SKILL_ALLOWLIST', () => {
assert.deepStrictEqual(
[...PROFILES.core].sort(),
[...MINIMAL_SKILL_ALLOWLIST].sort(),
'PROFILES.core must equal MINIMAL_SKILL_ALLOWLIST for back-compat',
);
});
});
describe('install-minimal-backcompat: --minimal and --profile=core produce the same manifest skill count', () => {
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' },
);
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'));
const skillCount = new Set(
Object.keys(m.files || {})
.filter((k) => k.startsWith('skills/'))
.map((k) => k.split('/')[1]),
).size;
// Read marker
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 {
fs.rmSync(targetDir, { recursive: true, force: true });
}
}
test('--minimal produces mode "minimal" with exactly 7 skills', () => {
const r = installAndGetManifest(['--minimal']);
assert.strictEqual(r.mode, 'minimal');
assert.strictEqual(r.skillCount, 7);
});
test('--minimal writes .gsd-profile marker with "core"', () => {
const r = installAndGetManifest(['--minimal']);
assert.strictEqual(r.profileMarker, 'core', '--minimal should write profile marker "core"');
});
test('default (no flags) writes .gsd-profile marker with "full"', () => {
const r = installAndGetManifest([]);
assert.strictEqual(r.profileMarker, 'full', 'default install should write profile marker "full"');
});
test('--profile=core writes .gsd-profile marker with "core"', () => {
const r = installAndGetManifest(['--profile=core']);
assert.strictEqual(r.profileMarker, 'core', '--profile=core should write profile marker "core"');
});
test('--profile=standard writes .gsd-profile marker with "standard"', () => {
const r = installAndGetManifest(['--profile=standard']);
assert.strictEqual(r.profileMarker, 'standard', '--profile=standard should write profile marker "standard"');
});
});

View File

@@ -0,0 +1,782 @@
// 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,
} = require('../bin/install.js');
const {
MINIMAL_SKILL_ALLOWLIST,
PROFILES,
isMinimalMode,
shouldInstallSkill,
stageSkillsForMode,
cleanupStagedSkills,
loadSkillsManifest,
resolveProfile,
} = require('../get-shit-done/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', '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`,
);
}
});
});
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']) {
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 {
fs.rmSync(src, { recursive: true, force: true });
}
});
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', 'update.md'],
);
} finally {
fs.rmSync(src, { recursive: true, force: true });
if (staged) fs.rmSync(staged, { recursive: true, force: true });
}
});
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 {
fs.rmSync(src, { recursive: true, force: true });
if (staged) fs.rmSync(staged, { recursive: true, force: true });
}
});
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 {
fs.rmSync(src, { recursive: true, force: true });
if (staged) fs.rmSync(staged, { recursive: true, force: true });
}
});
});
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 {
fs.rmSync(src, { recursive: true, force: true });
}
});
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 {
fs.rmSync(src, { recursive: true, force: true });
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;
fs.rmSync(src, { recursive: true, force: true });
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 {
fs.rmSync(root, { recursive: true, force: true });
}
});
}
});
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 {
fs.rmSync(root, { recursive: true, force: true });
}
});
}
});
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 {
fs.rmSync(root, { recursive: true, force: true });
}
});
}
});
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 {
fs.rmSync(root, { recursive: true, force: true });
}
});
}
}
});
// ─── 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'));
const skillCount = new Set(
Object.keys(m.files || {}).filter(k => k.startsWith('skills/')).map(k => k.split('/')[1]),
).size;
const agentCount = Object.keys(m.files || {}).filter(k => k.startsWith('agents/')).length;
return { mode: m.mode, skillCount, agentCount };
} finally {
fs.rmSync(targetDir, { recursive: true, force: true });
}
}
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 7 skills and 0 agents', () => {
const r = manifestModeAfterInstall(['--minimal']);
assert.strictEqual(r.mode, 'minimal');
assert.strictEqual(r.skillCount, 7);
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, 7);
assert.strictEqual(r.agentCount, 0);
});
});
describe('install-minimal-backcompat: PROFILES.core matches MINIMAL_SKILL_ALLOWLIST', () => {
test('PROFILES.core contains the same 7 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'));
const skillCount = new Set(
Object.keys(m.files || {}).filter(k => k.startsWith('skills/')).map(k => k.split('/')[1]),
).size;
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 {
fs.rmSync(targetDir, { recursive: true, force: true });
}
}
test('--minimal produces mode "minimal" with exactly 7 skills', () => {
const r = installAndGetManifest(['--minimal']);
assert.strictEqual(r.mode, 'minimal');
assert.strictEqual(r.skillCount, 7);
});
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 get-shit-done 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 {
fs.rmSync(targetDir, { recursive: true, force: true });
}
});
});
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 {
fs.rmSync(targetDir, { recursive: true, force: true });
}
});
});
// ─── 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`);
}
});
});
describe('install.js source correctness', () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'bin', 'install.js'), 'utf8');
test('.sh files get chmod after copyFileSync', () => {
assert.ok(src.includes("if (entry.endsWith('.sh'))"));
});
test('Codex hook uses correct filename gsd-check-update.js', () => {
assert.ok(!src.match(/['"]gsd-update-check\.js['"]/));
});
test('Codex hook path does not use get-shit-done/hooks/ subdirectory', () => {
assert.ok(!src.includes("'get-shit-done', 'hooks', 'gsd-check-update"));
});
test('cache invalidation uses ~/.cache/gsd/ path', () => {
assert.ok(src.includes("os.homedir(), '.cache', 'gsd'"));
});
test('manifest tracks .sh hook files', () => {
assert.ok(src.includes("file.endsWith('.sh')"));
});
test('gsd-workflow-guard.js is in uninstall hook list', () => {
const m = src.match(/const gsdHooks\s*=\s*\[([^\]]+)\]/);
assert.ok(m, 'gsdHooks array must exist');
assert.ok(m[1].includes('gsd-workflow-guard.js'));
});
test('phantom gsd-check-update.sh is not in uninstall hook list', () => {
const m = src.match(/const gsdHooks\s*=\s*\[([^\]]+)\]/);
assert.ok(m);
assert.ok(!m[1].includes('gsd-check-update.sh'));
});
test('isGsdHookCommand covers all GSD hook names', () => {
const names = [
'gsd-check-update', 'gsd-statusline', 'gsd-session-state',
'gsd-context-monitor', 'gsd-phase-boundary', 'gsd-prompt-guard',
'gsd-read-guard', 'gsd-validate-commit', 'gsd-workflow-guard',
];
for (const name of names) {
assert.ok(src.includes(`'${name}'`) || src.includes(`"${name}"`));
}
});
test('no duplicate isCursor or isWindsurf branches in uninstall', () => {
const uninstallStart = src.indexOf('function uninstall(');
const uninstallEnd = src.indexOf('function verifyInstalled(');
assert.ok(uninstallStart !== -1);
assert.ok(uninstallEnd !== -1);
const block = src.substring(uninstallStart, uninstallEnd);
assert.strictEqual((block.match(/else if \(isCursor\)/g) || []).length, 0);
assert.strictEqual((block.match(/else if \(isWindsurf\)/g) || []).length, 0);
});
});
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]'));
});
});

View File

@@ -1,614 +0,0 @@
// 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.
/**
* Tests for `--minimal` install profile (#2762).
*
* Verifies:
* 1. The install-profiles allowlist contains exactly the documented core
* main-loop skills.
* 2. stageSkillsForMode() filters source dir entries to the allowlist when
* mode === 'minimal' and is a no-op for mode === 'full'.
* 3. Filtering is by basename (mirrors how copyCommandsAs*Skills derives
* skill names).
* 4. shouldInstallSkill() agrees with stageSkillsForMode().
*
* Note: end-to-end install tests (spawning bin/install.js with --minimal) are
* intentionally out of scope here — they require a fully-mocked runtime config
* dir which would duplicate antigravity-install.test.cjs scaffolding. The unit
* tests below pin the allowlist contract; the dispatch sites in install.js
* call stageSkillsForMode unconditionally so any breakage there shows up as
* a stage_dir/source_dir mismatch covered by these tests.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const {
MINIMAL_SKILL_ALLOWLIST,
isMinimalMode,
shouldInstallSkill,
stageSkillsForMode,
cleanupStagedSkills,
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
describe('install-profiles: MINIMAL_SKILL_ALLOWLIST', () => {
test('contains exactly the main-loop core (no drift without test update)', () => {
assert.deepStrictEqual(
[...MINIMAL_SKILL_ALLOWLIST].sort(),
[
'discuss-phase',
'execute-phase',
'help',
'new-project',
'phase',
'plan-phase',
'update',
],
);
});
test('is frozen (mutations throw in strict mode)', () => {
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) {
const file = path.join(commandsDir, `${name}.md`);
assert.ok(
fs.existsSync(file),
`core skill ${name} is allowlisted but ${file} does not exist`,
);
}
});
});
describe('install-profiles: isMinimalMode', () => {
test('returns true only for the literal string "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 allowlist names with .md suffix (callers must strip)', () => {
assert.strictEqual(shouldInstallSkill('plan-phase.md', 'minimal'), false);
});
});
describe('install-profiles: stageSkillsForMode', () => {
function createFixtureSkillsDir() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stage-fixture-'));
fs.writeFileSync(path.join(tmp, 'plan-phase.md'), '# plan-phase\n');
fs.writeFileSync(path.join(tmp, 'execute-phase.md'), '# execute-phase\n');
fs.writeFileSync(path.join(tmp, 'autonomous.md'), '# autonomous\n');
fs.writeFileSync(path.join(tmp, 'do.md'), '# do\n');
fs.writeFileSync(path.join(tmp, 'help.md'), '# help\n');
fs.writeFileSync(path.join(tmp, 'new-project.md'), '# new-project\n');
fs.writeFileSync(path.join(tmp, 'phase.md'), '# phase\n');
fs.writeFileSync(path.join(tmp, 'discuss-phase.md'), '# discuss-phase\n');
fs.writeFileSync(path.join(tmp, 'update.md'), '# update\n');
fs.writeFileSync(path.join(tmp, 'progress.md'), '# progress\n');
return tmp;
}
test('full mode returns the original src dir unchanged', () => {
const src = createFixtureSkillsDir();
try {
const result = stageSkillsForMode(src, 'full');
assert.strictEqual(result, src);
} finally {
fs.rmSync(src, { recursive: true, force: true });
}
});
test('minimal mode returns a new dir containing only allowlisted skills', () => {
const src = createFixtureSkillsDir();
let staged;
try {
staged = stageSkillsForMode(src, 'minimal');
assert.notStrictEqual(staged, src);
const stagedFiles = fs.readdirSync(staged).sort();
assert.deepStrictEqual(stagedFiles, [
'discuss-phase.md',
'execute-phase.md',
'help.md',
'new-project.md',
'phase.md',
'plan-phase.md',
'update.md',
]);
} finally {
fs.rmSync(src, { recursive: true, force: true });
if (staged) fs.rmSync(staged, { recursive: true, force: true });
}
});
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 {
fs.rmSync(src, { recursive: true, force: true });
if (staged) fs.rmSync(staged, { recursive: true, force: true });
}
});
test('minimal mode against non-existent source returns the source path (caller handles missing)', () => {
const ghost = path.join(os.tmpdir(), 'gsd-stage-does-not-exist-' + Date.now());
const result = stageSkillsForMode(ghost, 'minimal');
assert.strictEqual(result, 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');
const stagedFiles = fs.readdirSync(staged);
assert.deepStrictEqual(stagedFiles, ['plan-phase.md']);
} finally {
fs.rmSync(src, { recursive: true, force: true });
if (staged) fs.rmSync(staged, { recursive: true, force: true });
}
});
});
describe('install-profiles: cleanupStagedSkills', () => {
test('removes every staged dir created during this 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, 'each call should mkdtemp a fresh dir');
assert.ok(fs.existsSync(a));
assert.ok(fs.existsSync(b));
cleanupStagedSkills();
assert.ok(!fs.existsSync(a), 'first staged dir should be removed');
assert.ok(!fs.existsSync(b), 'second staged dir should be removed');
} finally {
fs.rmSync(src, { recursive: true, force: true });
}
});
test('is idempotent — calling twice does not throw', () => {
cleanupStagedSkills();
cleanupStagedSkills();
});
test('full mode does not register a staged dir (no leak source for default install)', () => {
cleanupStagedSkills();
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stage-fullmode-'));
fs.writeFileSync(path.join(src, 'plan-phase.md'), '# plan\n');
const realMkdtemp = fs.mkdtempSync;
let minimalStageDirCreated = false;
fs.mkdtempSync = (prefix, ...rest) => {
if (typeof prefix === 'string' && prefix.endsWith('gsd-minimal-skills-')) {
minimalStageDirCreated = true;
}
return realMkdtemp(prefix, ...rest);
};
try {
const result = stageSkillsForMode(src, 'full');
assert.strictEqual(result, src, 'full mode returns original src unchanged');
cleanupStagedSkills();
assert.equal(
minimalStageDirCreated,
false,
'full mode should not create a gsd-minimal-skills stage dir',
);
} finally {
fs.mkdtempSync = realMkdtemp;
fs.rmSync(src, { recursive: true, force: true });
}
});
test('exit handler registers exactly once across many stageSkillsForMode 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');
// Call 5x — install.js has 13 dispatch sites, so this matters.
for (let i = 0; i < 5; i++) stageSkillsForMode(src, 'minimal');
const after = process.listenerCount('exit');
// Either 0 (handler was already registered by an earlier test) or +1.
// Never +5.
assert.ok(after - before <= 1, `expected <=1 new exit listener, got ${after - before}`);
} finally {
fs.rmSync(src, { recursive: true, force: true });
cleanupStagedSkills();
}
});
test('SIGINT triggers cleanup and re-raises the signal (Ctrl+C path)', () => {
// Run a child process that calls stageSkillsForMode then sleeps; send it
// SIGINT and assert (a) the child exits with the SIGINT-induced status
// (signal: 'SIGINT' OR exit code 130 depending on platform), and (b) the
// staged tmp dir is gone afterwards. Skipping on Windows where signal
// semantics differ — the unit test for natural `exit` covers Linux/macOS
// CI matrix, and signal handling is a Unix concern in practice.
if (process.platform === 'win32') return;
const { spawnSync } = require('child_process');
const probe = `
const { stageSkillsForMode } = require(${JSON.stringify(
path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'install-profiles.cjs'),
)});
const fs = require('fs');
const path = require('path');
const os = require('os');
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stage-sig-src-'));
fs.writeFileSync(path.join(src, 'plan-phase.md'), '# plan\\n');
const staged = stageSkillsForMode(src, 'minimal');
// Print the staged path so the parent knows what to look for, then
// signal readiness and block until SIGINT.
process.stdout.write(staged + '\\n');
setInterval(() => {}, 1000);
`;
// Spawn detached so we control the signal cleanly.
const child = require('child_process').spawn(process.execPath, ['-e', probe], {
stdio: ['ignore', 'pipe', 'pipe'],
});
let staged = '';
child.stdout.on('data', (chunk) => {
staged += chunk.toString();
if (!staged.includes('\n')) return;
// Once we have the staged path, send SIGINT and check on exit.
child.kill('SIGINT');
});
return new Promise((resolve, reject) => {
child.on('exit', (code, signal) => {
try {
const stagedPath = staged.split('\n')[0];
assert.ok(
stagedPath && stagedPath.startsWith(os.tmpdir()),
`child should have printed a staged path under tmpdir, got: ${JSON.stringify(stagedPath)}`,
);
assert.ok(
!fs.existsSync(stagedPath),
`staged dir should have been cleaned up on SIGINT, but ${stagedPath} still exists`,
);
// The child should have exited *because* of the signal, not 0.
assert.ok(
signal === 'SIGINT' || code === 130 || code === null,
`child should exit via SIGINT, got code=${code} signal=${signal}`,
);
resolve();
} catch (err) {
reject(err);
}
});
child.on('error', reject);
});
});
test('mid-copy failure removes the partial staged dir and re-throws', () => {
// The previous shape of this test compared listTmpStageDirs() snapshots
// before and after the throw. That assertion was unsound under
// `--test-concurrency=4` (scripts/run-tests.cjs:24): a parallel test
// process (notably install-minimal-all-runtimes.test.cjs, which also
// calls stageSkillsForMode) creates and removes `gsd-minimal-skills-*`
// dirs in the shared os.tmpdir() between our two snapshots, so
// deepStrictEqual failed deterministically when the parallel process
// happened to have a live stage dir during our snapshot window.
//
// Fix: observe THIS test's own stage dir directly. Stub fs.mkdtempSync
// to record the path stageSkillsForMode creates; on throw, assert that
// exact path no longer exists. No global tmpdir scan, no race with
// parallel processes.
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);
// Only track the stage dir created by stageSkillsForMode (its
// `gsd-minimal-skills-` prefix). Don't capture our own
// `gsd-stage-fail-` parent dir created above.
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,
'stageSkillsForMode should have invoked mkdtempSync before the copy throw');
assert.equal(fs.existsSync(stagedDir), false,
`partial staged dir should be removed on throw, but ${stagedDir} still exists`);
} finally {
fs.copyFileSync = realCopy;
fs.mkdtempSync = realMkdtemp;
fs.rmSync(src, { recursive: true, force: true });
cleanupStagedSkills();
}
});
});
// Helper for the cleanup tests above. Listed as a sibling so the describe
// block stays focused on the contract assertions.
function listTmpStageDirs() {
try {
return fs
.readdirSync(os.tmpdir())
.filter((n) => n.startsWith('gsd-minimal-skills-'))
.sort();
} catch {
return [];
}
}
// ─── End-to-end install regression: full → minimal Codex downgrade ─────────
//
// CodeRabbit (#2764) flagged that switching from full to minimal on Codex
// would leave stale `agents/gsd-*.toml` files plus `[agents.gsd-*]`
// sections in `config.toml`. This test simulates a previous full Codex
// install (a few stale agent files + an existing GSD-marked config.toml)
// and confirms that `--minimal` cleans them up.
describe('install: Codex full → minimal downgrade cleans stale agent state', () => {
const { spawnSync } = require('child_process');
const installScript = path.join(__dirname, '..', 'bin', 'install.js');
function makeStaleCodexInstall(targetDir) {
const agentsDir = path.join(targetDir, 'agents');
fs.mkdirSync(agentsDir, { recursive: true });
// Pretend a previous full install left these behind:
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');
// Also drop an unrelated user agent to confirm we don't touch it:
fs.writeFileSync(path.join(agentsDir, 'my-custom-agent.md'), 'user owns this\n');
// A previously-written codex config.toml with both GSD and user content,
// matching the marker format produced by installCodexConfig.
const codexConfig = [
'# user-owned setting',
'model = "gpt-5"',
'',
'# GSD Agent Configuration — managed by get-shit-done installer',
'[agents.gsd-executor]',
'cmd = "stale"',
'',
'[agents.gsd-planner]',
'cmd = "stale"',
'',
].join('\n');
fs.writeFileSync(path.join(targetDir, 'config.toml'), codexConfig);
}
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 {
makeStaleCodexInstall(targetDir);
const result = spawnSync(
process.execPath,
[installScript, '--codex', '--global', '--config-dir', targetDir, '--minimal'],
{ encoding: 'utf8' },
);
// Install may print the SDK-not-found warning at the end (the worktree
// doesn't always have sdk/dist built). That's a non-fatal post-step;
// skill/agent staging happens before it. We assert state, not exit code.
assert.ok(result.stdout || result.stderr, 'install should produce some output');
const agentsDir = path.join(targetDir, 'agents');
const remaining = fs.existsSync(agentsDir) ? fs.readdirSync(agentsDir) : [];
// Stale gsd-* files (.md AND .toml) must be gone:
assert.ok(!remaining.includes('gsd-executor.md'), 'stale gsd-executor.md should be removed');
assert.ok(!remaining.includes('gsd-planner.md'), 'stale gsd-planner.md should be removed');
assert.ok(!remaining.includes('gsd-executor.toml'), 'stale gsd-executor.toml should be removed');
assert.ok(!remaining.includes('gsd-planner.toml'), 'stale gsd-planner.toml should be removed');
// User-owned agent must survive:
assert.ok(remaining.includes('my-custom-agent.md'), 'user agent should be preserved');
// config.toml: GSD section gone, user content preserved
const configPath = path.join(targetDir, 'config.toml');
if (fs.existsSync(configPath)) {
const config = fs.readFileSync(configPath, 'utf8');
assert.ok(!config.includes('[agents.gsd-executor]'), 'gsd-executor section stripped');
assert.ok(!config.includes('[agents.gsd-planner]'), 'gsd-planner section stripped');
assert.ok(config.includes('model = "gpt-5"'), 'user setting preserved');
}
// (If config.toml was GSD-only it'd be removed entirely, which is also acceptable —
// in this fixture there's user content so the file should still exist.)
assert.ok(fs.existsSync(configPath), 'config.toml with user content should remain');
} finally {
fs.rmSync(targetDir, { recursive: true, force: true });
}
});
});
// ─── Claude full → minimal downgrade ────────────────────────────────────────
//
// Mirrors the Codex test for the most common runtime. The Codex test pins
// the .toml + config.toml cleanup; this one pins the .md-only path that
// every non-Codex runtime shares.
describe('install: Claude full → minimal downgrade removes stale agents', () => {
const { spawnSync } = require('child_process');
const installScript = path.join(__dirname, '..', 'bin', 'install.js');
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 });
// Fake a previous full install + a user-owned agent:
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,
[installScript, '--claude', '--global', '--config-dir', targetDir, '--minimal'],
{ encoding: 'utf8' },
);
const remaining = fs.existsSync(agentsDir) ? fs.readdirSync(agentsDir) : [];
assert.ok(!remaining.includes('gsd-executor.md'), 'stale gsd-executor.md removed');
assert.ok(!remaining.includes('gsd-planner.md'), 'stale gsd-planner.md removed');
assert.ok(remaining.includes('my-custom-agent.md'), 'user agent preserved');
// No `gsd-*` files at all should remain:
const stragglers = remaining.filter((f) => f.startsWith('gsd-'));
assert.deepStrictEqual(stragglers, [], 'no gsd-* files should remain in agents/');
} finally {
fs.rmSync(targetDir, { recursive: true, force: true });
}
});
});
// ─── Manifest mode field round-trip ─────────────────────────────────────────
//
// Locks in the contract that downstream tooling (uninstaller, drift detector,
// future profile-aware commands) can rely on the `mode` field being present
// and accurate after every install. Catches regressions in writeManifest's
// options threading.
describe('install: manifest records mode for both profiles', () => {
const { spawnSync } = require('child_process');
const installScript = path.join(__dirname, '..', 'bin', 'install.js');
function manifestModeAfterInstall(extraArgs) {
const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-manifest-mode-'));
try {
spawnSync(
process.execPath,
[installScript, '--claude', '--global', '--config-dir', targetDir, ...extraArgs],
{ encoding: 'utf8' },
);
const manifestPath = path.join(targetDir, 'gsd-file-manifest.json');
if (!fs.existsSync(manifestPath)) {
return { mode: '<no manifest>', skillCount: 0, agentCount: 0 };
}
const m = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const skillCount = new Set(
Object.keys(m.files || {})
.filter((k) => k.startsWith('skills/'))
.map((k) => k.split('/')[1]),
).size;
const agentCount = Object.keys(m.files || {}).filter((k) => k.startsWith('agents/')).length;
return { mode: m.mode, skillCount, agentCount };
} finally {
fs.rmSync(targetDir, { recursive: true, force: true });
}
}
test('default install records mode: "full" with the full skill+agent count', () => {
const r = manifestModeAfterInstall([]);
assert.strictEqual(r.mode, 'full');
assert.ok(r.skillCount > 7, `full install should have >7 skills, got ${r.skillCount}`);
assert.ok(r.agentCount > 0, `full install should have agents, got ${r.agentCount}`);
});
test('--minimal records mode: "minimal" with exactly 7 skills and 0 agents', () => {
const r = manifestModeAfterInstall(['--minimal']);
assert.strictEqual(r.mode, 'minimal');
assert.strictEqual(r.skillCount, 7);
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, 7);
assert.strictEqual(r.agentCount, 0);
});
});
// ─── Allowlist scope guard ─────────────────────────────────────────────────
//
// Catches drift in the opposite direction: someone adds an off-loop command
// to the allowlist, or removes a main-loop command. The first test in this
// file asserts the exact set; these add semantic guard rails so the failure
// mode is clear ("autonomous shouldn't be in core") rather than just a diff.
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'),
`main-loop command "${required}" must be in MINIMAL_SKILL_ALLOWLIST`,
);
}
});
test('off-loop convenience commands are NOT in the allowlist', () => {
// These exist in commands/gsd/ and are valid skills, but they're not part
// of the core main loop. If any of these slip into the allowlist the
// floor erodes.
for (const offLoop of [
'autonomous',
'ship',
'do',
'progress',
'next',
'fast',
'quick',
'debug',
'code-review',
'verify-work',
]) {
assert.ok(
!shouldInstallSkill(offLoop, 'minimal'),
`off-loop command "${offLoop}" must NOT be in MINIMAL_SKILL_ALLOWLIST`,
);
}
});
test('mode is required to be a known string — defensive against typos', () => {
// Any non-'minimal' mode should admit everything (full-mode behavior).
// This catches a future bug where someone adds a 'compact' or 'tier2'
// mode and forgets to wire up the predicate.
for (const unknownMode of ['compact', 'tier2', 'CORE', 'Minimal', 'mini']) {
assert.ok(
shouldInstallSkill('autonomous', unknownMode),
`unknown mode "${unknownMode}" should fall through to full behavior`,
);
}
});
});

View File

@@ -0,0 +1,306 @@
'use strict';
/**
* Installer Module — date-stamped regression tests.
*
* Consolidates install-hermes-regressions.test.cjs into a single
* regressions file for the installer module cluster.
*
* Defects covered:
* #3664 Defect #1 — stale skills/gsd/gsd-<stem>/ dirs on Hermes upgrade
* #3664 Defect #2 — --hermes --profile=core falls through to wrong path
* #2973 M1–M3 — dev-preferences migration at profile=core for hermes/qwen/claude
* #2973 U1–U3 — uninstall preserves dev-preferences via skill migration
*
* Closes #3758
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { createTempDir, cleanup } = require('./helpers.cjs');
const {
loadSkillsManifest,
resolveProfile,
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
// Load install exports via GSD_TEST_MODE to skip CLI main()
const savedTestMode = process.env.GSD_TEST_MODE;
process.env.GSD_TEST_MODE = '1';
let installExports;
try {
installExports = require('../bin/install.js');
} finally {
if (savedTestMode === undefined) delete process.env.GSD_TEST_MODE;
else process.env.GSD_TEST_MODE = savedTestMode;
}
const { installRuntimeArtifacts, uninstallRuntimeArtifacts } = installExports || {};
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR);
const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST });
// ─── Defect #1 — Hermes upgrade leaves stale skills/gsd/gsd-<stem>/ dirs ────
describe('Defect #1 regression (#3664): _runLegacyInstallMigrations removes skills/gsd/gsd-*/ layout', () => {
test('installRuntimeArtifacts removes intermediate skills/gsd/gsd-*/ dirs and writes bare-stem layout', (t) => {
const configDir = createTempDir('gsd-hermes-reg1-');
t.after(() => cleanup(configDir));
assert.strictEqual(typeof installRuntimeArtifacts, 'function',
'installRuntimeArtifacts must be exported from bin/install.js');
// Pre-create intermediate Hermes layout (between #2841 and #3664)
const nestedGsdDir = path.join(configDir, 'skills', 'gsd');
fs.mkdirSync(path.join(nestedGsdDir, 'gsd-help'), { recursive: true });
fs.writeFileSync(path.join(nestedGsdDir, 'gsd-help', 'SKILL.md'), '# legacy help\n');
fs.mkdirSync(path.join(nestedGsdDir, 'gsd-plan'), { recursive: true });
fs.writeFileSync(path.join(nestedGsdDir, 'gsd-plan', 'SKILL.md'), '# legacy plan\n');
// Sibling non-gsd dir inside skills/gsd/ must survive
const userContentDir = path.join(nestedGsdDir, 'user-content');
fs.mkdirSync(userContentDir, { recursive: true });
fs.writeFileSync(path.join(userContentDir, 'SKILL.md'), '# user content\n');
installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_CORE);
assert.ok(!fs.existsSync(path.join(nestedGsdDir, 'gsd-help')),
'skills/gsd/gsd-help/ must be removed (Defect #1)');
assert.ok(!fs.existsSync(path.join(nestedGsdDir, 'gsd-plan')),
'skills/gsd/gsd-plan/ must be removed (Defect #1)');
assert.ok(fs.existsSync(path.join(nestedGsdDir, 'help', 'SKILL.md')),
'skills/gsd/help/SKILL.md must exist after install');
assert.ok(fs.existsSync(path.join(userContentDir, 'SKILL.md')),
'user-content must be preserved');
});
});
// ─── Defect #2 — --qwen --profile=core falls through to wrong path ────────────
describe('Defect #2 regression (Qwen, #3664): --qwen --profile=core writes skills/gsd-*/, not commands/gsd/', () => {
test('spawn --qwen --global --profile=core: skills/gsd-*/ written, no commands/gsd/', (t) => {
const root = createTempDir('gsd-qwen-reg2-');
t.after(() => cleanup(root));
const result = spawnSync(
process.execPath,
[INSTALL_SCRIPT, '--qwen', '--global', '--config-dir', root, '--profile=core'],
{ encoding: 'utf8', env: { ...process.env, HOME: root, USERPROFILE: root } },
);
assert.strictEqual(result.status, 0,
`installer exited ${result.status}\n${result.stdout}\n${result.stderr}`);
const qwenSkillsDir = path.join(root, 'skills');
assert.ok(fs.existsSync(qwenSkillsDir));
const skillDirs = fs.readdirSync(qwenSkillsDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
assert.ok(skillDirs.length >= 1, 'at least one gsd-* skill dir must exist');
assert.ok(
skillDirs.some(e => fs.existsSync(path.join(qwenSkillsDir, e.name, 'SKILL.md'))),
'at least one skills/gsd-*/SKILL.md must exist'
);
const commandsGsd = path.join(root, 'commands', 'gsd');
if (fs.existsSync(commandsGsd)) {
const mdFiles = fs.readdirSync(commandsGsd).filter(f => f.endsWith('.md'));
assert.strictEqual(mdFiles.length, 0, `commands/gsd/ must not contain .md files (Defect #2). Found: ${mdFiles.join(', ')}`);
}
});
});
describe('Defect #2 regression (Hermes, #3664): --hermes --profile=core writes skills/gsd/, not commands/gsd/', () => {
test('spawn --hermes --global --profile=core: skills/gsd/ written, no commands/gsd/', (t) => {
const root = createTempDir('gsd-hermes-reg2-');
t.after(() => cleanup(root));
const result = spawnSync(
process.execPath,
[INSTALL_SCRIPT, '--hermes', '--global', '--config-dir', root, '--profile=core'],
{ encoding: 'utf8', env: { ...process.env, HOME: root, USERPROFILE: root } },
);
assert.strictEqual(result.status, 0,
`installer exited ${result.status}\n${result.stdout}\n${result.stderr}`);
const hermesSkillsGsd = path.join(root, 'skills', 'gsd');
assert.ok(fs.existsSync(hermesSkillsGsd));
const skillDirs = fs.readdirSync(hermesSkillsGsd, { withFileTypes: true })
.filter(e => e.isDirectory());
assert.ok(skillDirs.length >= 1);
assert.ok(
skillDirs.some(e => fs.existsSync(path.join(hermesSkillsGsd, e.name, 'SKILL.md'))),
);
const commandsGsd = path.join(root, 'commands', 'gsd');
if (fs.existsSync(commandsGsd)) {
const mdFiles = fs.readdirSync(commandsGsd).filter(f => f.endsWith('.md'));
assert.strictEqual(mdFiles.length, 0, `commands/gsd/ must not contain .md files (Defect #2). Found: ${mdFiles.join(', ')}`);
}
});
});
// ─── M1 — Hermes minimal-mode migrates dev-preferences (#2973) ───────────────
describe('M1 (#2973): --hermes --global --profile=core migrates dev-preferences → skills/gsd/dev-preferences/SKILL.md', () => {
test('dev-preferences migrated to nested Hermes location, legacy source removed', (t) => {
const root = createTempDir('gsd-hermes-m1-');
t.after(() => cleanup(root));
const legacyDir = path.join(root, 'commands', 'gsd');
fs.mkdirSync(legacyDir, { recursive: true });
fs.writeFileSync(path.join(legacyDir, 'dev-preferences.md'), '# my hermes prefs\n');
const result = spawnSync(
process.execPath,
[INSTALL_SCRIPT, '--hermes', '--global', '--config-dir', root, '--profile=core'],
{ encoding: 'utf8', env: { ...process.env, HOME: root, USERPROFILE: root } },
);
assert.strictEqual(result.status, 0,
`installer exited ${result.status}\n${result.stdout}\n${result.stderr}`);
const skillFile = path.join(root, 'skills', 'gsd', 'dev-preferences', 'SKILL.md');
assert.ok(fs.existsSync(skillFile),
'skills/gsd/dev-preferences/SKILL.md must exist (M1: nested, not flat)');
assert.strictEqual(fs.readFileSync(skillFile, 'utf8'), '# my hermes prefs\n');
assert.ok(!fs.existsSync(path.join(legacyDir, 'dev-preferences.md')),
'legacy source must be removed');
});
});
// ─── M2 — Qwen minimal-mode migrates dev-preferences (#2973) ────────────────
describe('M2 (#2973): --qwen --global --profile=core migrates dev-preferences → skills/gsd-dev-preferences/SKILL.md', () => {
test('dev-preferences migrated to flat Qwen location, legacy source removed', (t) => {
const root = createTempDir('gsd-qwen-m2-');
t.after(() => cleanup(root));
const legacyDir = path.join(root, 'commands', 'gsd');
fs.mkdirSync(legacyDir, { recursive: true });
fs.writeFileSync(path.join(legacyDir, 'dev-preferences.md'), '# my qwen prefs\n');
const result = spawnSync(
process.execPath,
[INSTALL_SCRIPT, '--qwen', '--global', '--config-dir', root, '--profile=core'],
{ encoding: 'utf8', env: { ...process.env, HOME: root, USERPROFILE: root } },
);
assert.strictEqual(result.status, 0,
`installer exited ${result.status}\n${result.stdout}\n${result.stderr}`);
const skillFile = path.join(root, 'skills', 'gsd-dev-preferences', 'SKILL.md');
assert.ok(fs.existsSync(skillFile),
'skills/gsd-dev-preferences/SKILL.md must exist (M2: flat Qwen layout)');
assert.strictEqual(fs.readFileSync(skillFile, 'utf8'), '# my qwen prefs\n');
assert.ok(!fs.existsSync(path.join(legacyDir, 'dev-preferences.md')));
});
});
// ─── M3 — Claude global minimal-mode migrates dev-preferences (#2973) ────────
describe('M3 (#2973): --claude --global --profile=core migrates dev-preferences → skills/gsd-dev-preferences/SKILL.md', () => {
test('dev-preferences migrated to flat Claude-global location, legacy source removed', (t) => {
const root = createTempDir('gsd-claude-m3-');
t.after(() => cleanup(root));
const legacyDir = path.join(root, 'commands', 'gsd');
fs.mkdirSync(legacyDir, { recursive: true });
fs.writeFileSync(path.join(legacyDir, 'dev-preferences.md'), '# my claude prefs\n');
const result = spawnSync(
process.execPath,
[INSTALL_SCRIPT, '--claude', '--global', '--config-dir', root, '--profile=core'],
{ encoding: 'utf8', env: { ...process.env, HOME: root, USERPROFILE: root } },
);
assert.strictEqual(result.status, 0,
`installer exited ${result.status}\n${result.stdout}\n${result.stderr}`);
const skillFile = path.join(root, 'skills', 'gsd-dev-preferences', 'SKILL.md');
assert.ok(fs.existsSync(skillFile),
'skills/gsd-dev-preferences/SKILL.md must exist (M3)');
assert.strictEqual(fs.readFileSync(skillFile, 'utf8'), '# my claude prefs\n');
assert.ok(!fs.existsSync(path.join(legacyDir, 'dev-preferences.md')));
});
});
// ─── U1 — Qwen uninstall preserves dev-preferences via migration (#2973) ─────
describe('U1 (#2973): uninstallRuntimeArtifacts qwen migrates dev-preferences → skills/gsd-dev-preferences/SKILL.md', () => {
test('commands/gsd/ removed, dev-preferences migrated to skills skill', (t) => {
const configDir = createTempDir('gsd-qwen-uninstall-u1-');
t.after(() => cleanup(configDir));
assert.strictEqual(typeof uninstallRuntimeArtifacts, 'function',
'uninstallRuntimeArtifacts must be exported from bin/install.js');
const legacyDir = path.join(configDir, 'commands', 'gsd');
fs.mkdirSync(legacyDir, { recursive: true });
fs.writeFileSync(path.join(legacyDir, 'dev-preferences.md'), '# my qwen prefs\n');
fs.writeFileSync(path.join(legacyDir, 'help.md'), '# help content\n');
uninstallRuntimeArtifacts('qwen', configDir, 'global');
assert.ok(!fs.existsSync(path.join(legacyDir, 'help.md')));
assert.ok(!fs.existsSync(path.join(legacyDir, 'dev-preferences.md')));
const skillFile = path.join(configDir, 'skills', 'gsd-dev-preferences', 'SKILL.md');
assert.ok(fs.existsSync(skillFile), 'skills/gsd-dev-preferences/SKILL.md must exist (U1)');
assert.strictEqual(fs.readFileSync(skillFile, 'utf8'), '# my qwen prefs\n');
});
});
// ─── U2 — Claude-global uninstall preserves dev-preferences (#2973) ──────────
describe('U2 (#2973): uninstallRuntimeArtifacts claude/global migrates dev-preferences → skills/gsd-dev-preferences/SKILL.md', () => {
test('commands/gsd/ removed, dev-preferences migrated to skills skill', (t) => {
const configDir = createTempDir('gsd-claude-uninstall-u2-');
t.after(() => cleanup(configDir));
assert.strictEqual(typeof uninstallRuntimeArtifacts, 'function');
const legacyDir = path.join(configDir, 'commands', 'gsd');
fs.mkdirSync(legacyDir, { recursive: true });
fs.writeFileSync(path.join(legacyDir, 'dev-preferences.md'), '# my claude prefs\n');
uninstallRuntimeArtifacts('claude', configDir, 'global');
assert.ok(!fs.existsSync(path.join(legacyDir, 'dev-preferences.md')));
const skillFile = path.join(configDir, 'skills', 'gsd-dev-preferences', 'SKILL.md');
assert.ok(fs.existsSync(skillFile), 'skills/gsd-dev-preferences/SKILL.md must exist (U2)');
assert.strictEqual(fs.readFileSync(skillFile, 'utf8'), '# my claude prefs\n');
});
});
// ─── U3 — Hermes uninstall migrates dev-preferences to NESTED location (#2973) ─
describe('U3 (#2973): uninstallRuntimeArtifacts hermes migrates dev-preferences → skills/gsd/dev-preferences/SKILL.md', () => {
test('commands/gsd/ NOT recreated, dev-preferences at nested Hermes location', (t) => {
const configDir = createTempDir('gsd-hermes-uninstall-u3-');
t.after(() => cleanup(configDir));
assert.strictEqual(typeof uninstallRuntimeArtifacts, 'function');
const legacyDir = path.join(configDir, 'commands', 'gsd');
fs.mkdirSync(legacyDir, { recursive: true });
fs.writeFileSync(path.join(legacyDir, 'dev-preferences.md'), '# my hermes prefs\n');
uninstallRuntimeArtifacts('hermes', configDir, 'global');
assert.ok(!fs.existsSync(path.join(legacyDir, 'dev-preferences.md')),
'commands/gsd/dev-preferences.md must not exist after hermes uninstall (U3)');
const skillFile = path.join(configDir, 'skills', 'gsd', 'dev-preferences', 'SKILL.md');
assert.ok(fs.existsSync(skillFile),
'skills/gsd/dev-preferences/SKILL.md must exist at HERMES nested location (U3)');
assert.strictEqual(fs.readFileSync(skillFile, 'utf8'), '# my hermes prefs\n');
});
});

View File

@@ -0,0 +1,347 @@
// 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 6–8 + 12.
*
* Covers: installRuntimeArtifacts parameterised layout loop,
* uninstallRuntimeArtifacts all runtimes, Contract 6 counter-test
* (unknown runtime rejected), and legacy migration tests.
*
* Consolidates (original sources from #3758):
* install-uninstall-layout-loop.test.cjs
*
* Closes #3758
*/
'use strict';
process.env.GSD_TEST_MODE = '1';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempDir, cleanup } = require('./helpers.cjs');
const {
installRuntimeArtifacts,
parseRuntimeInput,
allRuntimes,
} = require('../bin/install.js');
const {
resolveRuntimeArtifactLayout,
} = require('../get-shit-done/bin/lib/runtime-artifact-layout.cjs');
const {
loadSkillsManifest,
resolveProfile,
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR);
const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST });
// ─── Section 6: installRuntimeArtifacts — parameterised layout loop ──────────
const SKILLS_RUNTIMES_LAYOUT = [
'claude', 'cursor', 'codex', 'copilot', 'antigravity',
'windsurf', 'augment', 'trae', 'qwen', 'codebuddy',
];
const ALL_RUNTIMES_LAYOUT = [
'claude', 'cursor', 'gemini', 'codex', 'copilot', 'antigravity',
'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy',
'cline', 'opencode', 'kilo',
];
function countPrefixedEntries(destDir, prefix) {
if (!fs.existsSync(destDir)) return 0;
return fs.readdirSync(destDir).filter(n => n.startsWith(prefix)).length;
}
function writeSkillEntry(destDir, prefix, stem) {
const entryDir = path.join(destDir, `${prefix}${stem}`);
fs.mkdirSync(entryDir, { recursive: true });
fs.writeFileSync(path.join(entryDir, 'SKILL.md'), `# ${stem}\n`);
}
function writeCommandEntry(destDir, prefix, stem) {
fs.mkdirSync(destDir, { recursive: true });
fs.writeFileSync(path.join(destDir, `${prefix}${stem}.md`), `# ${stem}\n`);
}
describe('installRuntimeArtifacts — skills runtimes write gsd-prefixed skill dirs', () => {
for (const runtime of SKILLS_RUNTIMES_LAYOUT) {
test(`${runtime}: gsd-prefixed skill dirs in skills/`, (t) => {
const configDir = createTempDir(`gsd-ial-${runtime}-`);
t.after(() => cleanup(configDir));
assert.strictEqual(typeof installRuntimeArtifacts, 'function');
installRuntimeArtifacts(runtime, configDir, 'global', RESOLVED_CORE);
const layout = resolveRuntimeArtifactLayout(runtime, configDir, 'global');
const skillsKind = layout.kinds.find(k => k.kind === 'skills');
assert.ok(skillsKind, `${runtime} must have skills kind`);
const destDir = path.join(configDir, skillsKind.destSubpath);
assert.ok(fs.existsSync(destDir));
assert.ok(
fs.existsSync(path.join(destDir, `${skillsKind.prefix}help`, 'SKILL.md')),
`${runtime}: ${skillsKind.prefix}help/SKILL.md must exist`
);
if (RESOLVED_CORE.skills !== '*') {
const prefixedCount = countPrefixedEntries(destDir, skillsKind.prefix || 'gsd-');
assert.strictEqual(prefixedCount, RESOLVED_CORE.skills.size,
`${runtime}: installed skill count must match profile`);
}
});
}
});
describe('installRuntimeArtifacts — hermes nested layout', () => {
test('hermes: skills/gsd/<stem>/SKILL.md, no gsd- prefix in name', (t) => {
const configDir = createTempDir('gsd-ial-hermes-');
t.after(() => cleanup(configDir));
installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_CORE);
const nestedDir = path.join(configDir, 'skills', 'gsd');
assert.ok(fs.existsSync(nestedDir));
assert.ok(fs.existsSync(path.join(nestedDir, 'help', 'SKILL.md')));
assert.ok(!fs.existsSync(path.join(nestedDir, 'gsd-help')),
'hermes must NOT have gsd-help prefix');
});
});
describe('installRuntimeArtifacts — gemini commands layout', () => {
test('gemini: commands/gsd/ created, no skills/', (t) => {
const configDir = createTempDir('gsd-ial-gemini-');
t.after(() => cleanup(configDir));
installRuntimeArtifacts('gemini', configDir, 'global', RESOLVED_CORE);
assert.ok(fs.existsSync(path.join(configDir, 'commands', 'gsd')));
assert.ok(fs.existsSync(path.join(configDir, 'commands', 'gsd', 'help.md')));
assert.ok(!fs.existsSync(path.join(configDir, 'skills')));
});
});
describe('installRuntimeArtifacts — cline no-op', () => {
test('cline: no kinds — call succeeds, no dirs created', (t) => {
const configDir = createTempDir('gsd-ial-cline-');
t.after(() => cleanup(configDir));
assert.doesNotThrow(() => installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE));
assert.ok(!fs.existsSync(path.join(configDir, 'skills')));
assert.ok(!fs.existsSync(path.join(configDir, 'commands')));
});
});
describe('installRuntimeArtifacts — opencode / kilo flat commands', () => {
for (const runtime of ['opencode', 'kilo']) {
test(`${runtime}: command/gsd-help.md exists`, (t) => {
const configDir = createTempDir(`gsd-ial-${runtime}-`);
t.after(() => cleanup(configDir));
installRuntimeArtifacts(runtime, configDir, 'global', RESOLVED_CORE);
const commandDir = path.join(configDir, 'command');
assert.ok(fs.existsSync(commandDir));
assert.ok(fs.existsSync(path.join(commandDir, 'gsd-help.md')));
});
}
});
// ─── Section 7: uninstallRuntimeArtifacts — all runtimes ─────────────────────
describe('uninstallRuntimeArtifacts — removes gsd-owned entries, preserves foreign', () => {
for (const runtime of ALL_RUNTIMES_LAYOUT) {
test(`${runtime}: gsd entries removed, foreign preserved`, (t) => {
const configDir = createTempDir(`gsd-ual-${runtime}-`);
t.after(() => cleanup(configDir));
const { uninstallRuntimeArtifacts } = require('../bin/install.js');
assert.strictEqual(typeof uninstallRuntimeArtifacts, 'function');
const layout = resolveRuntimeArtifactLayout(runtime, configDir, 'global');
if (layout.kinds.length === 0) {
const foreignDir = path.join(configDir, 'foreign-dir');
fs.mkdirSync(foreignDir, { recursive: true });
fs.writeFileSync(path.join(foreignDir, 'keep.md'), '# keep\n');
assert.doesNotThrow(() => uninstallRuntimeArtifacts(runtime, configDir, 'global'));
assert.ok(fs.existsSync(path.join(foreignDir, 'keep.md')));
return;
}
if (runtime === 'hermes') {
const kind = layout.kinds[0];
const destDir = path.join(configDir, kind.destSubpath);
fs.mkdirSync(path.join(destDir, 'help'), { recursive: true });
fs.writeFileSync(path.join(destDir, 'help', 'SKILL.md'), '# help\n');
const siblingDir = path.join(configDir, 'skills', 'user-skill');
fs.mkdirSync(siblingDir, { recursive: true });
fs.writeFileSync(path.join(siblingDir, 'SKILL.md'), '# user\n');
uninstallRuntimeArtifacts(runtime, configDir, 'global');
assert.ok(!fs.existsSync(destDir));
assert.ok(fs.existsSync(path.join(siblingDir, 'SKILL.md')));
return;
}
for (const kind of layout.kinds) {
const destDir = path.join(configDir, kind.destSubpath);
fs.mkdirSync(destDir, { recursive: true });
if (kind.kind === 'skills') {
writeSkillEntry(destDir, kind.prefix, 'help');
writeSkillEntry(destDir, kind.prefix, 'phase');
const foreignDir = path.join(destDir, 'user-custom-skill');
fs.mkdirSync(foreignDir, { recursive: true });
fs.writeFileSync(path.join(foreignDir, 'SKILL.md'), '# user\n');
} else {
writeCommandEntry(destDir, kind.prefix, 'help');
writeCommandEntry(destDir, kind.prefix, 'phase');
fs.writeFileSync(path.join(destDir, 'user-custom.md'), '# user\n');
}
}
uninstallRuntimeArtifacts(runtime, configDir, 'global');
for (const kind of layout.kinds) {
const destDir = path.join(configDir, kind.destSubpath);
if (kind.kind === 'skills') {
assert.ok(!fs.existsSync(path.join(destDir, `${kind.prefix}help`)));
assert.ok(!fs.existsSync(path.join(destDir, `${kind.prefix}phase`)));
assert.ok(fs.existsSync(path.join(destDir, 'user-custom-skill', 'SKILL.md')));
} else {
assert.ok(!fs.existsSync(path.join(destDir, `${kind.prefix}help.md`)));
assert.ok(!fs.existsSync(path.join(destDir, `${kind.prefix}phase.md`)));
assert.ok(fs.existsSync(path.join(destDir, 'user-custom.md')));
}
}
});
}
});
// ─── Section 8: Counter-test — unknown runtime is rejected (Contract 6) ──────
describe('Contract 6: unknown runtime is rejected', () => {
test('resolveRuntimeArtifactLayout throws TypeError for unknown runtime', () => {
assert.throws(
() => resolveRuntimeArtifactLayout('unknown-runtime-xyz', '/tmp/test', 'global'),
(err) => {
assert.ok(err instanceof TypeError, 'must be TypeError');
assert.ok(err.message.includes('Unknown runtime'), `message: ${err.message}`);
return true;
}
);
});
test('parseRuntimeInput returns ["claude"] for unrecognised string (safe default)', () => {
// parseRuntimeInput processes menu numbers, not runtime names directly;
// an unrecognised token falls through to the default ["claude"].
const result = parseRuntimeInput('unknown-xyz');
assert.deepStrictEqual(result, ['claude']);
});
test('allRuntimes does not include any unrecognised value', () => {
// Every entry in allRuntimes must be recognised by resolveRuntimeArtifactLayout
for (const runtime of allRuntimes) {
assert.doesNotThrow(
() => resolveRuntimeArtifactLayout(runtime, '/tmp/test', 'global'),
`${runtime} must be a recognised runtime`
);
}
});
});
// ─── Section 12: Legacy migrations in installRuntimeArtifacts ────────────────
describe('installRuntimeArtifacts — legacy migrations run before layout copy', () => {
test('claude: legacy commands/gsd/dev-preferences.md migrated AND new skills written', (t) => {
const configDir = createTempDir('gsd-legacy-install-');
t.after(() => cleanup(configDir));
const legacyDir = path.join(configDir, 'commands', 'gsd');
fs.mkdirSync(legacyDir, { recursive: true });
fs.writeFileSync(path.join(legacyDir, 'dev-preferences.md'), '# My dev prefs\n');
installRuntimeArtifacts('claude', configDir, 'global', RESOLVED_CORE);
assert.ok(!fs.existsSync(legacyDir));
assert.ok(fs.existsSync(path.join(configDir, 'skills', 'gsd-dev-preferences', 'SKILL.md')));
assert.ok(fs.existsSync(path.join(configDir, 'skills', 'gsd-help', 'SKILL.md')));
});
test('hermes: legacy flat skills/gsd-*/ migrated AND new nested skills/gsd/<stem>/ written', (t) => {
const configDir = createTempDir('gsd-legacy-hermes-install-');
t.after(() => cleanup(configDir));
const legacyFlatHelp = path.join(configDir, 'skills', 'gsd-help');
fs.mkdirSync(legacyFlatHelp, { recursive: true });
fs.writeFileSync(path.join(legacyFlatHelp, 'SKILL.md'), '# legacy help\n');
installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_CORE);
assert.ok(!fs.existsSync(legacyFlatHelp));
assert.ok(fs.existsSync(path.join(configDir, 'skills', 'gsd', 'help', 'SKILL.md')));
});
});
describe('uninstallRuntimeArtifacts — legacy cleanup runs before layout removal', () => {
test('hermes: both flat and nested layouts removed', (t) => {
const { uninstallRuntimeArtifacts } = require('../bin/install.js');
const configDir = createTempDir('gsd-legacy-uninstall-hermes-');
t.after(() => cleanup(configDir));
const skillsDir = path.join(configDir, 'skills');
const flatHelp = path.join(skillsDir, 'gsd-help');
fs.mkdirSync(flatHelp, { recursive: true });
fs.writeFileSync(path.join(flatHelp, 'SKILL.md'), '# legacy flat\n');
const nestedGsd = path.join(skillsDir, 'gsd');
fs.mkdirSync(path.join(nestedGsd, 'help'), { recursive: true });
fs.writeFileSync(path.join(nestedGsd, 'help', 'SKILL.md'), '# nested help\n');
const userSkill = path.join(skillsDir, 'user-skill');
fs.mkdirSync(userSkill, { recursive: true });
fs.writeFileSync(path.join(userSkill, 'SKILL.md'), '# user\n');
uninstallRuntimeArtifacts('hermes', configDir, 'global');
assert.ok(!fs.existsSync(flatHelp));
assert.ok(!fs.existsSync(nestedGsd));
assert.ok(fs.existsSync(path.join(userSkill, 'SKILL.md')));
});
test('claude: legacy commands/gsd/ cleaned AND new skills/ entries removed', (t) => {
const { uninstallRuntimeArtifacts } = require('../bin/install.js');
const configDir = createTempDir('gsd-legacy-uninstall-claude-');
t.after(() => cleanup(configDir));
const skillsDir = path.join(configDir, 'skills');
const gsdHelp = path.join(skillsDir, 'gsd-help');
fs.mkdirSync(gsdHelp, { recursive: true });
fs.writeFileSync(path.join(gsdHelp, 'SKILL.md'), '# help\n');
const legacyDir = path.join(configDir, 'commands', 'gsd');
fs.mkdirSync(legacyDir, { recursive: true });
fs.writeFileSync(path.join(legacyDir, 'help.md'), '# legacy\n');
const userSkill = path.join(skillsDir, 'user-skill');
fs.mkdirSync(userSkill, { recursive: true });
fs.writeFileSync(path.join(userSkill, 'SKILL.md'), '# user\n');
uninstallRuntimeArtifacts('claude', configDir, 'global');
assert.ok(!fs.existsSync(gsdHelp));
assert.ok(!fs.existsSync(legacyDir));
assert.ok(fs.existsSync(path.join(userSkill, 'SKILL.md')));
});
});

View File

@@ -1,573 +0,0 @@
'use strict';
/**
* Phase 2 TDD — red tests for installRuntimeArtifacts / uninstallRuntimeArtifacts.
*
* These tests MUST fail with "TypeError: installRuntimeArtifacts is not a function"
* (or equivalent) until the production implementation is added to bin/install.js.
*
* Conventions:
* - node:test + node:assert/strict
* - helpers.cjs for createTempDir / cleanup
* - Filesystem assertions only — no source-grep, no .includes() on file content
* - Per-runtime parameterisation where applicable
* - t.after() for cleanup
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { createTempDir, cleanup } = require('./helpers.cjs');
const {
resolveRuntimeArtifactLayout,
} = require('../get-shit-done/bin/lib/runtime-artifact-layout.cjs');
const {
loadSkillsManifest,
resolveProfile,
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
// ---------------------------------------------------------------------------
// Load target exports — will be undefined until Phase 2 ships
// ---------------------------------------------------------------------------
// GSD_TEST_MODE prevents install.js from running its CLI main() on require.
const savedTestMode = process.env.GSD_TEST_MODE;
process.env.GSD_TEST_MODE = '1';
let installExports;
try {
installExports = require('../bin/install.js');
} finally {
if (savedTestMode === undefined) delete process.env.GSD_TEST_MODE;
else process.env.GSD_TEST_MODE = savedTestMode;
}
const { installRuntimeArtifacts, uninstallRuntimeArtifacts } = installExports || {};
// ---------------------------------------------------------------------------
// Shared resolved profile (core — deterministic small set)
// ---------------------------------------------------------------------------
// The real commands/gsd dir is used by the layout module's source-root walk.
// We load its manifest so resolveProfile gives us the correct transitive closure.
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR);
const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST });
// ---------------------------------------------------------------------------
// Runtime lists
// ---------------------------------------------------------------------------
// Runtimes that use a "skills" kind at global scope
const SKILLS_RUNTIMES = [
'claude', 'cursor', 'codex', 'copilot', 'antigravity',
'windsurf', 'augment', 'trae', 'qwen', 'codebuddy',
];
// All 15 runtimes in the layout table
const ALL_RUNTIMES = [
'claude', 'cursor', 'gemini', 'codex', 'copilot', 'antigravity',
'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy',
'cline', 'opencode', 'kilo',
];
// ---------------------------------------------------------------------------
// Helper: count entries in destDir whose name starts with prefix
// ---------------------------------------------------------------------------
function countPrefixedEntries(destDir, prefix) {
if (!fs.existsSync(destDir)) return 0;
return fs.readdirSync(destDir).filter((n) => n.startsWith(prefix)).length;
}
// ---------------------------------------------------------------------------
// Helper: write fixture skill entry in destDir
// For skills runtimes: creates <destDir>/<prefix><stem>/SKILL.md
// For commands runtimes: creates <destDir>/<prefix><stem>.md
// ---------------------------------------------------------------------------
function writeSkillEntry(destDir, prefix, stem) {
const entryName = `${prefix}${stem}`;
const entryDir = path.join(destDir, entryName);
fs.mkdirSync(entryDir, { recursive: true });
fs.writeFileSync(path.join(entryDir, 'SKILL.md'), `# ${stem}\n`);
}
function writeCommandEntry(destDir, prefix, stem) {
fs.mkdirSync(destDir, { recursive: true });
fs.writeFileSync(path.join(destDir, `${prefix}${stem}.md`), `# ${stem}\n`);
}
// ---------------------------------------------------------------------------
// describe 1 — installRuntimeArtifacts: layout-driven install loop
// ---------------------------------------------------------------------------
describe('installRuntimeArtifacts — layout-driven install loop', () => {
// Standard skills runtimes: gsd-prefixed dirs in <configDir>/skills/
for (const runtime of SKILLS_RUNTIMES) {
test(`${runtime}: installs gsd-prefixed skill dirs into <configDir>/skills/`, (t) => {
const configDir = createTempDir(`gsd-ial-${runtime}-`);
t.after(() => cleanup(configDir));
assert.strictEqual(
typeof installRuntimeArtifacts,
'function',
'installRuntimeArtifacts must be exported from bin/install.js'
);
installRuntimeArtifacts(runtime, configDir, 'global', RESOLVED_CORE);
const layout = resolveRuntimeArtifactLayout(runtime, configDir, 'global');
const skillsKind = layout.kinds.find((k) => k.kind === 'skills');
assert.ok(skillsKind, `${runtime} must have a skills kind in global scope`);
const destDir = path.join(configDir, skillsKind.destSubpath);
assert.ok(fs.existsSync(destDir), `destDir must exist after install: ${destDir}`);
// At least one SKILL.md was written
const helpDir = path.join(destDir, `${skillsKind.prefix}help`);
assert.ok(
fs.existsSync(path.join(helpDir, 'SKILL.md')),
`${runtime}: skills/${skillsKind.prefix}help/SKILL.md must exist after install`
);
// Count of gsd-prefixed dirs matches resolved profile's skills size
const prefixedCount = countPrefixedEntries(destDir, skillsKind.prefix || 'gsd-');
const expectedCount = RESOLVED_CORE.skills === '*'
? prefixedCount // full profile: just check non-zero
: RESOLVED_CORE.skills.size;
assert.ok(
prefixedCount >= 1,
`${runtime}: at least one skill dir must be present, got ${prefixedCount}`
);
if (RESOLVED_CORE.skills !== '*') {
assert.strictEqual(
prefixedCount,
expectedCount,
`${runtime}: number of installed skill dirs must match profile skills count`
);
}
});
}
// hermes: nested layout — skills/gsd/<stem>/SKILL.md, prefix is ''
test('hermes: nested layout — skills/gsd/<stem>/SKILL.md, no gsd- prefix in name', (t) => {
const configDir = createTempDir('gsd-ial-hermes-');
t.after(() => cleanup(configDir));
assert.strictEqual(typeof installRuntimeArtifacts, 'function');
installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_CORE);
const nestedDir = path.join(configDir, 'skills', 'gsd');
assert.ok(fs.existsSync(nestedDir), 'skills/gsd/ must exist after hermes install');
// help skill: skills/gsd/help/SKILL.md (prefix is '', so no gsd- prefix on stem)
const helpSkillMd = path.join(nestedDir, 'help', 'SKILL.md');
assert.ok(
fs.existsSync(helpSkillMd),
'hermes: skills/gsd/help/SKILL.md must exist'
);
// Verify no gsd- prefixed entry at skills/gsd/gsd-help (prefix must be '')
const wrongEntry = path.join(nestedDir, 'gsd-help');
assert.ok(
!fs.existsSync(wrongEntry),
'hermes: skills/gsd/gsd-help must NOT exist (prefix should be empty)'
);
});
// gemini: commands kind — commands/gsd/<stem>.md, no skills/ dir
test('gemini: commands layout — commands/gsd/ is created, no skills/ dir', (t) => {
const configDir = createTempDir('gsd-ial-gemini-');
t.after(() => cleanup(configDir));
assert.strictEqual(typeof installRuntimeArtifacts, 'function');
installRuntimeArtifacts('gemini', configDir, 'global', RESOLVED_CORE);
const commandsGsdDir = path.join(configDir, 'commands', 'gsd');
assert.ok(
fs.existsSync(commandsGsdDir),
'gemini: commands/gsd/ must exist after install'
);
// help.md should be present (part of core profile)
assert.ok(
fs.existsSync(path.join(commandsGsdDir, 'help.md')),
'gemini: commands/gsd/help.md must exist'
);
// No skills/ directory should be created for gemini
const skillsDir = path.join(configDir, 'skills');
assert.ok(
!fs.existsSync(skillsDir),
'gemini: skills/ must NOT be created'
);
});
// cline: kinds is [] — call must succeed, no dirs created
test('cline: no kinds — call succeeds, no skills/ or commands/ created', (t) => {
const configDir = createTempDir('gsd-ial-cline-');
t.after(() => cleanup(configDir));
assert.strictEqual(typeof installRuntimeArtifacts, 'function');
// Must not throw
assert.doesNotThrow(() => {
installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE);
}, 'installRuntimeArtifacts must not throw for cline');
const skillsDir = path.join(configDir, 'skills');
const commandsDir = path.join(configDir, 'commands');
assert.ok(!fs.existsSync(skillsDir), 'cline: skills/ must NOT be created');
assert.ok(!fs.existsSync(commandsDir), 'cline: commands/ must NOT be created');
});
// opencode: flat commands — command/gsd-<stem>.md
test('opencode: flat commands layout — command/gsd-help.md exists', (t) => {
const configDir = createTempDir('gsd-ial-opencode-');
t.after(() => cleanup(configDir));
assert.strictEqual(typeof installRuntimeArtifacts, 'function');
installRuntimeArtifacts('opencode', configDir, 'global', RESOLVED_CORE);
const commandDir = path.join(configDir, 'command');
assert.ok(fs.existsSync(commandDir), 'opencode: command/ must exist');
assert.ok(
fs.existsSync(path.join(commandDir, 'gsd-help.md')),
'opencode: command/gsd-help.md must exist'
);
});
// kilo: same flat commands layout as opencode
test('kilo: flat commands layout — command/gsd-help.md exists', (t) => {
const configDir = createTempDir('gsd-ial-kilo-');
t.after(() => cleanup(configDir));
assert.strictEqual(typeof installRuntimeArtifacts, 'function');
installRuntimeArtifacts('kilo', configDir, 'global', RESOLVED_CORE);
const commandDir = path.join(configDir, 'command');
assert.ok(fs.existsSync(commandDir), 'kilo: command/ must exist');
assert.ok(
fs.existsSync(path.join(commandDir, 'gsd-help.md')),
'kilo: command/gsd-help.md must exist'
);
});
});
// ---------------------------------------------------------------------------
// describe 2 — uninstallRuntimeArtifacts: layout-driven removal
// ---------------------------------------------------------------------------
describe('uninstallRuntimeArtifacts — layout-driven removal', () => {
for (const runtime of ALL_RUNTIMES) {
test(`${runtime}: removes gsd-owned entries, preserves foreign entries`, (t) => {
const configDir = createTempDir(`gsd-ual-${runtime}-`);
t.after(() => cleanup(configDir));
assert.strictEqual(
typeof uninstallRuntimeArtifacts,
'function',
'uninstallRuntimeArtifacts must be exported from bin/install.js'
);
const layout = resolveRuntimeArtifactLayout(runtime, configDir, 'global');
if (layout.kinds.length === 0) {
// cline: no kinds — call must succeed and not touch anything
const foreignDir = path.join(configDir, 'foreign-dir');
fs.mkdirSync(foreignDir, { recursive: true });
fs.writeFileSync(path.join(foreignDir, 'keep.md'), '# keep\n');
assert.doesNotThrow(() => {
uninstallRuntimeArtifacts(runtime, configDir, 'global');
}, `${runtime}: uninstallRuntimeArtifacts must not throw with empty kinds`);
assert.ok(
fs.existsSync(path.join(foreignDir, 'keep.md')),
`${runtime}: foreign entries must not be touched`
);
return;
}
// For hermes: special handling — prefix is '', destSubpath is 'skills/gsd'
// The "namespace IS the prefix": everything under skills/gsd/ is gsd-owned.
// We pre-create a sibling skills/user-skill/ that must survive.
if (runtime === 'hermes') {
const kind = layout.kinds[0];
const destDir = path.join(configDir, kind.destSubpath);
fs.mkdirSync(destDir, { recursive: true });
// Create nested skill entries (prefix is '' so names have no gsd- prefix)
const helpDir = path.join(destDir, 'help');
fs.mkdirSync(helpDir, { recursive: true });
fs.writeFileSync(path.join(helpDir, 'SKILL.md'), '# help\n');
const phaseDir = path.join(destDir, 'phase');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, 'SKILL.md'), '# phase\n');
// Sibling at skills/ level — must be preserved
const siblingDir = path.join(configDir, 'skills', 'user-skill');
fs.mkdirSync(siblingDir, { recursive: true });
fs.writeFileSync(path.join(siblingDir, 'SKILL.md'), '# user skill\n');
uninstallRuntimeArtifacts(runtime, configDir, 'global');
// skills/gsd/ itself and its children should be removed
assert.ok(
!fs.existsSync(destDir),
'hermes: skills/gsd/ must be removed by uninstall'
);
// Sibling skills/user-skill/ must survive
assert.ok(
fs.existsSync(path.join(siblingDir, 'SKILL.md')),
'hermes: skills/user-skill/ must be preserved'
);
return;
}
// General case for all other runtimes
for (const kind of layout.kinds) {
const destDir = path.join(configDir, kind.destSubpath);
fs.mkdirSync(destDir, { recursive: true });
if (kind.kind === 'skills') {
// Write two gsd-prefixed skill dirs and one foreign dir
writeSkillEntry(destDir, kind.prefix, 'help');
writeSkillEntry(destDir, kind.prefix, 'phase');
// Foreign entry: no matching prefix
const foreignDir = path.join(destDir, 'user-custom-skill');
fs.mkdirSync(foreignDir, { recursive: true });
fs.writeFileSync(path.join(foreignDir, 'SKILL.md'), '# user\n');
} else if (kind.kind === 'commands' || kind.kind === 'agents') {
// Write two gsd-prefixed command/agent files and one foreign file
writeCommandEntry(destDir, kind.prefix, 'help');
writeCommandEntry(destDir, kind.prefix, 'phase');
// Foreign entry
fs.writeFileSync(path.join(destDir, 'user-custom.md'), '# user\n');
}
}
uninstallRuntimeArtifacts(runtime, configDir, 'global');
for (const kind of layout.kinds) {
const destDir = path.join(configDir, kind.destSubpath);
if (kind.kind === 'skills') {
// gsd-prefixed skill dirs must be gone
const helpDir = path.join(destDir, `${kind.prefix}help`);
const phaseDir = path.join(destDir, `${kind.prefix}phase`);
assert.ok(
!fs.existsSync(helpDir),
`${runtime}: ${kind.prefix}help dir must be removed`
);
assert.ok(
!fs.existsSync(phaseDir),
`${runtime}: ${kind.prefix}phase dir must be removed`
);
// Foreign entry must survive
const foreignDir = path.join(destDir, 'user-custom-skill');
assert.ok(
fs.existsSync(path.join(foreignDir, 'SKILL.md')),
`${runtime}: user-custom-skill/SKILL.md must be preserved`
);
} else if (kind.kind === 'commands' || kind.kind === 'agents') {
// gsd-prefixed files must be gone
assert.ok(
!fs.existsSync(path.join(destDir, `${kind.prefix}help.md`)),
`${runtime}: ${kind.prefix}help.md must be removed`
);
assert.ok(
!fs.existsSync(path.join(destDir, `${kind.prefix}phase.md`)),
`${runtime}: ${kind.prefix}phase.md must be removed`
);
// Foreign file must survive
assert.ok(
fs.existsSync(path.join(destDir, 'user-custom.md')),
`${runtime}: user-custom.md must be preserved`
);
}
}
});
}
});
// ---------------------------------------------------------------------------
// describe 3 — installRuntimeArtifacts: legacy migrations run before layout copy
// ---------------------------------------------------------------------------
describe('installRuntimeArtifacts — legacy migrations run before layout-driven copy', () => {
test('claude: legacy commands/gsd/dev-preferences.md migrated AND new skills written', (t) => {
const configDir = createTempDir('gsd-legacy-install-');
t.after(() => cleanup(configDir));
assert.strictEqual(typeof installRuntimeArtifacts, 'function');
// Pre-create the legacy state: commands/gsd/dev-preferences.md
const legacyCommandsGsd = path.join(configDir, 'commands', 'gsd');
fs.mkdirSync(legacyCommandsGsd, { recursive: true });
fs.writeFileSync(
path.join(legacyCommandsGsd, 'dev-preferences.md'),
'# My dev preferences\n'
);
// Call the new unified function
installRuntimeArtifacts('claude', configDir, 'global', RESOLVED_CORE);
// Legacy migration side-effect: commands/gsd/ must be removed (legacy cleanup)
assert.ok(
!fs.existsSync(legacyCommandsGsd),
'claude: legacy commands/gsd/ must be cleaned up by install migration'
);
// Migration side-effect: dev-preferences.md → skills/gsd-dev-preferences/SKILL.md
const devPrefSkill = path.join(configDir, 'skills', 'gsd-dev-preferences', 'SKILL.md');
assert.ok(
fs.existsSync(devPrefSkill),
'claude: skills/gsd-dev-preferences/SKILL.md must be written by legacy migration'
);
// New skills must also be present — layout-driven copy ran
const helpSkill = path.join(configDir, 'skills', 'gsd-help', 'SKILL.md');
assert.ok(
fs.existsSync(helpSkill),
'claude: skills/gsd-help/SKILL.md must be written by layout-driven install'
);
});
test('hermes: legacy flat skills/gsd-*/ migrated AND new nested skills/gsd/<stem>/ written', (t) => {
const configDir = createTempDir('gsd-legacy-hermes-install-');
t.after(() => cleanup(configDir));
assert.strictEqual(typeof installRuntimeArtifacts, 'function');
// Pre-create legacy flat layout (pre-#2841): skills/gsd-help/SKILL.md
const flatSkillsDir = path.join(configDir, 'skills');
const legacyFlatHelp = path.join(flatSkillsDir, 'gsd-help');
fs.mkdirSync(legacyFlatHelp, { recursive: true });
fs.writeFileSync(path.join(legacyFlatHelp, 'SKILL.md'), '# legacy help\n');
installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_CORE);
// Legacy flat entry must be removed
assert.ok(
!fs.existsSync(legacyFlatHelp),
'hermes: legacy skills/gsd-help/ must be removed by install migration'
);
// New nested layout must exist: skills/gsd/help/SKILL.md
const newNestedHelp = path.join(configDir, 'skills', 'gsd', 'help', 'SKILL.md');
assert.ok(
fs.existsSync(newNestedHelp),
'hermes: skills/gsd/help/SKILL.md must be written by layout-driven install'
);
});
});
// ---------------------------------------------------------------------------
// describe 4 — uninstallRuntimeArtifacts: legacy cleanup runs before layout removal
// ---------------------------------------------------------------------------
describe('uninstallRuntimeArtifacts — legacy cleanup runs before layout-driven removal', () => {
test('hermes: both flat (pre-#2841 gsd-*/) and nested (gsd/<stem>/) layouts removed', (t) => {
const configDir = createTempDir('gsd-legacy-uninstall-hermes-');
t.after(() => cleanup(configDir));
assert.strictEqual(typeof uninstallRuntimeArtifacts, 'function');
const skillsDir = path.join(configDir, 'skills');
// Pre-create legacy flat layout (pre-#2841): skills/gsd-help/SKILL.md
const flatHelpDir = path.join(skillsDir, 'gsd-help');
fs.mkdirSync(flatHelpDir, { recursive: true });
fs.writeFileSync(path.join(flatHelpDir, 'SKILL.md'), '# legacy flat help\n');
const flatPhaseDir = path.join(skillsDir, 'gsd-phase');
fs.mkdirSync(flatPhaseDir, { recursive: true });
fs.writeFileSync(path.join(flatPhaseDir, 'SKILL.md'), '# legacy flat phase\n');
// Pre-create nested layout (post-#2841): skills/gsd/help/SKILL.md
const nestedGsd = path.join(skillsDir, 'gsd');
const nestedHelpDir = path.join(nestedGsd, 'help');
fs.mkdirSync(nestedHelpDir, { recursive: true });
fs.writeFileSync(path.join(nestedHelpDir, 'SKILL.md'), '# nested help\n');
const nestedPhaseDir = path.join(nestedGsd, 'phase');
fs.mkdirSync(nestedPhaseDir, { recursive: true });
fs.writeFileSync(path.join(nestedPhaseDir, 'SKILL.md'), '# nested phase\n');
// Non-gsd sibling at skills/ level — must survive
const userSkillDir = path.join(skillsDir, 'user-skill');
fs.mkdirSync(userSkillDir, { recursive: true });
fs.writeFileSync(path.join(userSkillDir, 'SKILL.md'), '# user skill\n');
uninstallRuntimeArtifacts('hermes', configDir, 'global');
// Flat legacy entries must be gone
assert.ok(
!fs.existsSync(flatHelpDir),
'hermes uninstall: legacy skills/gsd-help/ must be removed'
);
assert.ok(
!fs.existsSync(flatPhaseDir),
'hermes uninstall: legacy skills/gsd-phase/ must be removed'
);
// Nested layout must be gone
assert.ok(
!fs.existsSync(nestedGsd),
'hermes uninstall: skills/gsd/ (nested) must be removed'
);
// User skill must survive
assert.ok(
fs.existsSync(path.join(userSkillDir, 'SKILL.md')),
'hermes uninstall: skills/user-skill/ must be preserved'
);
});
test('claude: legacy commands/gsd/ cleaned up AND new skills/ entries removed by uninstall', (t) => {
const configDir = createTempDir('gsd-legacy-uninstall-claude-');
t.after(() => cleanup(configDir));
assert.strictEqual(typeof uninstallRuntimeArtifacts, 'function');
// Pre-create layout-driven gsd skills
const skillsDir = path.join(configDir, 'skills');
const gsdHelpDir = path.join(skillsDir, 'gsd-help');
fs.mkdirSync(gsdHelpDir, { recursive: true });
fs.writeFileSync(path.join(gsdHelpDir, 'SKILL.md'), '# help\n');
// Pre-create legacy commands/gsd/ (should also be cleaned)
const legacyCommandsGsd = path.join(configDir, 'commands', 'gsd');
fs.mkdirSync(legacyCommandsGsd, { recursive: true });
fs.writeFileSync(path.join(legacyCommandsGsd, 'help.md'), '# help legacy\n');
// Foreign skill — must survive
const userSkillDir = path.join(skillsDir, 'user-skill');
fs.mkdirSync(userSkillDir, { recursive: true });
fs.writeFileSync(path.join(userSkillDir, 'SKILL.md'), '# user\n');
uninstallRuntimeArtifacts('claude', configDir, 'global');
// gsd-help must be removed
assert.ok(
!fs.existsSync(gsdHelpDir),
'claude uninstall: skills/gsd-help/ must be removed'
);
// Foreign skill must survive
assert.ok(
fs.existsSync(path.join(userSkillDir, 'SKILL.md')),
'claude uninstall: user-skill/ must be preserved'
);
// Legacy commands/gsd/ must also be cleaned by legacy migration running first
assert.ok(
!fs.existsSync(legacyCommandsGsd),
'claude uninstall: legacy commands/gsd/ must be cleaned up'
);
});
});

602
tests/install.test.cjs Normal file
View File

@@ -0,0 +1,602 @@
// 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/getGlobalDir/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,
getGlobalDir,
getConfigDirFromHome,
install,
uninstall,
writeManifest,
allRuntimes,
runtimeMap,
buildRuntimePromptText,
resolveKiloConfigPath,
configureKiloPermissions,
} = require('../bin/install.js');
const {
RUNTIME_META,
SKILL_RUNTIMES,
stripAnsi,
walk,
} = require('./helpers/install-shared.cjs');
// ─── Section 1: getDirName / getGlobalDir / 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('getGlobalDir — all runtimes default paths', () => {
// Test the default (no env var, no explicit dir) for each runtime
const ENV_KEYS = [
'HERMES_HOME', 'QWEN_CONFIG_DIR', 'TRAE_CONFIG_DIR', 'ANTIGRAVITY_CONFIG_DIR',
'KILO_CONFIG_DIR', 'KILO_CONFIG', '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) {
test(`getGlobalDir('${runtime}') returns expected home-relative path`, () => {
const expected = path.join(os.homedir(), RUNTIME_META[runtime].globalSuffix);
assert.strictEqual(getGlobalDir(runtime), expected);
});
}
});
describe('getGlobalDir — 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(getGlobalDir('hermes', '/explicit/hermes'), '/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(getGlobalDir('kilo', '/explicit/kilo'), '/explicit/kilo');
} finally {
if (saved !== undefined) process.env.KILO_CONFIG_DIR = saved;
else delete process.env.KILO_CONFIG_DIR;
}
});
});
describe('getGlobalDir — 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(getGlobalDir('hermes'), path.join(os.homedir(), 'custom-hermes'));
});
});
describe('getGlobalDir — 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(getGlobalDir('kilo'), path.join(os.homedir(), 'custom-kilo'));
});
test('falls back to XDG_CONFIG_HOME/kilo', () => {
process.env.XDG_CONFIG_HOME = '~/xdg-config';
assert.strictEqual(getGlobalDir('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(getGlobalDir('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(getGlobalDir('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 .agent (local) and .gemini, antigravity (global)', () => {
assert.strictEqual(getConfigDirFromHome('antigravity', false), "'.agent'");
assert.strictEqual(getConfigDirFromHome('antigravity', true), "'.gemini', 'antigravity'");
});
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/ 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));
assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'help', 'SKILL.md')));
assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'DESCRIPTION.md')),
'DESCRIPTION.md at category root');
assert.ok(fs.existsSync(path.join(targetDir, 'get-shit-done', '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/help/')),
JSON.stringify(manifest.files));
uninstall(false, 'hermes');
assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'help')));
assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd')));
assert.ok(!fs.existsSync(path.join(targetDir, 'get-shit-done')));
});
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 (flat skills/gsd-* 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));
assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd-help', 'SKILL.md')));
assert.ok(fs.existsSync(path.join(targetDir, 'get-shit-done', '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-help/')));
uninstall(false, 'qwen');
assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd-help')));
assert.ok(!fs.existsSync(path.join(targetDir, 'get-shit-done')));
});
});
describe('install/uninstall — trae (flat skills/gsd-* 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),
});
assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd-help', 'SKILL.md')));
assert.ok(fs.existsSync(path.join(targetDir, 'get-shit-done', '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-help/')));
uninstall(false, 'trae');
assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd-help')));
assert.ok(!fs.existsSync(path.join(targetDir, 'get-shit-done')));
});
});
// ─── 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, 'get-shit-done', 'VERSION')));
uninstall(false, 'hermes');
assert.ok(!fs.existsSync(path.join(targetDir, 'get-shit-done')));
});
});
// ─── 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', () => {
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, '/')}/get-shit-done/*`;
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, '/')}/get-shit-done/*`;
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, '/')}/get-shit-done/*`;
assert.strictEqual(config.permission.read[gsdPath], 'allow');
assert.strictEqual(config.permission.external_directory[gsdPath], 'allow');
});
});
describe('Kilo source integration assertions', () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'bin', 'install.js'), 'utf8');
const updateWorkflowSrc = fs.readFileSync(
path.join(__dirname, '..', 'get-shit-done', 'workflows', 'update.md'), 'utf8');
test('--kilo flag parsing exists', () => {
assert.ok(src.includes("args.includes('--kilo')"));
});
test('runtimeMap has Kilo as option 11', () => {
assert.strictEqual(runtimeMap['11'], 'kilo');
});
test('prompt text shows Kilo above OpenCode without marketing copy', () => {
const plain = stripAnsi(buildRuntimePromptText());
assert.ok(/\b11\)\s*Kilo\b/.test(plain));
assert.ok(plain.indexOf('11) Kilo') < plain.indexOf('OpenCode'));
assert.ok(!plain.includes('the #1 AI coding platform on OpenRouter'));
});
test('finishInstall passes the actual config dir to Kilo permissions', () => {
assert.ok(src.includes('configureKiloPermissions(isGlobal, configDir);'));
});
test('uninstall cleans Kilo permissions from the resolved target dir', () => {
assert.ok(src.includes('const configPath = resolveKiloConfigPath(targetDir);'));
});
test('update workflow checks preferred custom config dirs', () => {
assert.ok(updateWorkflowSrc.includes('PREFERRED_CONFIG_DIR'));
assert.ok(updateWorkflowSrc.includes('kilo.jsonc'));
assert.ok(updateWorkflowSrc.includes('KILO_CONFIG'));
});
});

View File

@@ -1,290 +0,0 @@
// allow-test-rule: pending-migration-to-typed-ir [#2974]
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
// "Prohibited: Raw Text Matching on Test Outputs". Per-file review may
// reclassify some entries as source-text-is-the-product during migration.
/**
* GSD Tools Tests - Kilo Install Plumbing
*
* Tests for Kilo runtime directory resolution, config paths,
* permission config, and installer source integration.
*/
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 { createTempProject, cleanup } = require('./helpers.cjs');
const {
getDirName,
getGlobalDir,
getConfigDirFromHome,
resolveKiloConfigPath,
configureKiloPermissions,
} = require('../bin/install.js');
describe('getDirName (Kilo)', () => {
test('returns .kilo for kilo', () => {
assert.strictEqual(getDirName('kilo'), '.kilo');
});
});
describe('getConfigDirFromHome (Kilo)', () => {
test('returns .kilo for local installs', () => {
assert.strictEqual(getConfigDirFromHome('kilo', false), "'.kilo'");
});
test('returns .config, kilo for global installs', () => {
assert.strictEqual(getConfigDirFromHome('kilo', true), "'.config', 'kilo'");
});
});
describe('getGlobalDir (Kilo)', () => {
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(() => {
if (savedEnv.KILO_CONFIG_DIR === undefined) {
delete process.env.KILO_CONFIG_DIR;
} else {
process.env.KILO_CONFIG_DIR = savedEnv.KILO_CONFIG_DIR;
}
if (savedEnv.KILO_CONFIG === undefined) {
delete process.env.KILO_CONFIG;
} else {
process.env.KILO_CONFIG = savedEnv.KILO_CONFIG;
}
if (savedEnv.XDG_CONFIG_HOME === undefined) {
delete process.env.XDG_CONFIG_HOME;
} else {
process.env.XDG_CONFIG_HOME = savedEnv.XDG_CONFIG_HOME;
}
});
test('returns ~/.config/kilo by default', () => {
assert.strictEqual(getGlobalDir('kilo'), path.join(os.homedir(), '.config', 'kilo'));
});
test('respects KILO_CONFIG_DIR env var', () => {
process.env.KILO_CONFIG_DIR = '~/custom-kilo';
assert.strictEqual(getGlobalDir('kilo'), path.join(os.homedir(), 'custom-kilo'));
});
test('falls back to XDG_CONFIG_HOME/kilo', () => {
process.env.XDG_CONFIG_HOME = '~/xdg-config';
assert.strictEqual(getGlobalDir('kilo'), path.join(os.homedir(), 'xdg-config', 'kilo'));
});
test('uses dirname(KILO_CONFIG) when KILO_CONFIG_DIR is unset', () => {
process.env.KILO_CONFIG = '~/profiles/work/kilo.jsonc';
assert.strictEqual(getGlobalDir('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(getGlobalDir('kilo'), path.join(os.homedir(), 'custom-kilo'));
});
test('explicit config-dir overrides env vars', () => {
process.env.KILO_CONFIG_DIR = '~/from-env';
process.env.XDG_CONFIG_HOME = '~/xdg-config';
assert.strictEqual(getGlobalDir('kilo', '/explicit/kilo'), '/explicit/kilo');
});
});
describe('Kilo config file helpers', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject('gsd-kilo-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('resolveKiloConfigPath prefers kilo.jsonc when present', () => {
const configDir = path.join(tmpDir, '.kilo');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'kilo.jsonc'), '{\n // comment\n}\n');
assert.strictEqual(resolveKiloConfigPath(configDir), path.join(configDir, 'kilo.jsonc'));
});
test('resolveKiloConfigPath 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(() => {
if (savedEnv.KILO_CONFIG_DIR === undefined) {
delete process.env.KILO_CONFIG_DIR;
} else {
process.env.KILO_CONFIG_DIR = savedEnv.KILO_CONFIG_DIR;
}
if (savedEnv.XDG_CONFIG_HOME === undefined) {
delete process.env.XDG_CONFIG_HOME;
} else {
process.env.XDG_CONFIG_HOME = savedEnv.XDG_CONFIG_HOME;
}
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, '/')}/get-shit-done/*`;
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 config\n "permission": {\n "bash": "ask",\n },\n}\n');
configureKiloPermissions(true);
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
const gsdPath = `${configDir.replace(/\\/g, '/')}/get-shit-done/*`;
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, '/')}/get-shit-done/*`;
assert.strictEqual(config.permission.read[gsdPath], 'allow');
assert.strictEqual(config.permission.external_directory[gsdPath], 'allow');
});
});
describe('Source code integration (Kilo)', () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'bin', 'install.js'), 'utf8');
const updateWorkflowSrc = fs.readFileSync(path.join(__dirname, '..', 'get-shit-done', 'workflows', 'update.md'), 'utf8');
// #2790: reapply-patches.md command was absorbed into update.md --reapply.
// The Kilo-specific env-var checks (KILO_CONFIG_DIR, KILO_CONFIG, XDG_CONFIG_HOME)
// now live in the update.md workflow (which covers both --sync and --reapply paths).
const reapplyPatchesSrc = updateWorkflowSrc;
test('--kilo flag parsing exists', () => {
assert.ok(src.includes("args.includes('--kilo')"), '--kilo flag parsed');
});
test('help text includes --kilo', () => {
assert.ok(src.includes('Install for Kilo only'), 'help text includes Kilo option');
});
test('--all array includes kilo', () => {
assert.ok(src.includes("'kilo'"), '--all includes kilo runtime');
});
test('promptRuntime runtimeMap has Kilo as option 11', () => {
// Structural assertion against exported runtimeMap rather than source-grep.
process.env.GSD_TEST_MODE = '1';
delete require.cache[require.resolve(path.join(__dirname, '..', 'bin', 'install.js'))];
const { runtimeMap } = require(path.join(__dirname, '..', 'bin', 'install.js'));
assert.strictEqual(runtimeMap['11'], 'kilo', 'runtimeMap has 11 -> kilo');
});
test('prompt text shows Kilo above OpenCode without marketing copy', () => {
// Call the exported prompt builder; assert against rendered text, not raw source.
process.env.GSD_TEST_MODE = '1';
delete require.cache[require.resolve(path.join(__dirname, '..', 'bin', 'install.js'))];
const { buildRuntimePromptText } = require(path.join(__dirname, '..', 'bin', 'install.js'));
const promptText = buildRuntimePromptText();
// Strip ANSI color codes so assertions don't depend on terminal escapes.
// eslint-disable-next-line no-control-regex
const plain = promptText.replace(/\x1b\[[0-9;]*m/g, '');
assert.ok(/\b11\)\s*Kilo\b/.test(plain), 'prompt lists Kilo as option 11');
const kiloIdx = plain.indexOf('11) Kilo');
const opencodeIdx = plain.indexOf('OpenCode');
assert.ok(kiloIdx > -1 && opencodeIdx > -1 && kiloIdx < opencodeIdx,
'Kilo appears above OpenCode in prompt');
assert.ok(!plain.includes('the #1 AI coding platform on OpenRouter'),
'prompt does not include marketing tagline');
});
test('hooks are skipped for Kilo', () => {
assert.ok(src.includes('!isOpencode && !isKilo'), 'hooks skip check includes kilo');
});
test('settings.json write excludes Kilo', () => {
assert.ok(src.includes('!isCodex && !isCopilot && !isKilo && !isCursor && !isWindsurf'), 'settings write excludes kilo');
});
test('agent path replacement does not exclude Kilo', () => {
assert.ok(src.includes('if (!isCopilot && !isAntigravity)'), 'generic agent path replacement still applies');
});
test('finishInstall passes the actual config dir to Kilo permissions', () => {
assert.ok(src.includes('configureKiloPermissions(isGlobal, configDir);'), 'Kilo permission config uses actual install dir');
});
test('uninstall cleans Kilo permissions from the resolved target dir', () => {
assert.ok(src.includes('const configPath = resolveKiloConfigPath(targetDir);'), 'Kilo uninstall cleanup uses targetDir');
});
test('update workflow checks preferred custom config dirs before defaults', () => {
assert.ok(updateWorkflowSrc.includes('PREFERRED_CONFIG_DIR'), 'workflow tracks preferred config dir');
assert.ok(updateWorkflowSrc.includes('kilo.jsonc'), 'workflow infers Kilo from config files');
assert.ok(updateWorkflowSrc.includes('ENV_RUNTIME_DIRS'), 'workflow checks env-derived config dirs');
assert.ok(updateWorkflowSrc.includes('KILO_CONFIG'), 'workflow checks KILO_CONFIG');
});
test('reapply-patches checks Kilo custom config env vars first', () => {
assert.ok(reapplyPatchesSrc.includes('KILO_CONFIG_DIR'), 'reapply-patches checks KILO_CONFIG_DIR');
assert.ok(reapplyPatchesSrc.includes('KILO_CONFIG'), 'reapply-patches checks KILO_CONFIG');
assert.ok(reapplyPatchesSrc.includes('XDG_CONFIG_HOME'), 'reapply-patches checks XDG_CONFIG_HOME');
});
});

View File

@@ -1,316 +0,0 @@
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, cleanup } = require('./helpers.cjs');
const {
getDirName,
getGlobalDir,
getConfigDirFromHome,
install,
uninstall,
writeManifest,
} = require('../bin/install.js');
describe('Qwen Code runtime directory mapping', () => {
test('maps Qwen to .qwen for local installs', () => {
assert.strictEqual(getDirName('qwen'), '.qwen');
});
test('maps Qwen to ~/.qwen for global installs', () => {
assert.strictEqual(getGlobalDir('qwen'), path.join(os.homedir(), '.qwen'));
});
test('returns .qwen config fragments for local and global installs', () => {
assert.strictEqual(getConfigDirFromHome('qwen', false), "'.qwen'");
assert.strictEqual(getConfigDirFromHome('qwen', true), "'.qwen'");
});
});
describe('getGlobalDir (Qwen Code)', () => {
let originalQwenConfigDir;
beforeEach(() => {
originalQwenConfigDir = process.env.QWEN_CONFIG_DIR;
});
afterEach(() => {
if (originalQwenConfigDir !== undefined) {
process.env.QWEN_CONFIG_DIR = originalQwenConfigDir;
} else {
delete process.env.QWEN_CONFIG_DIR;
}
});
test('returns ~/.qwen with no env var or explicit dir', () => {
delete process.env.QWEN_CONFIG_DIR;
const result = getGlobalDir('qwen');
assert.strictEqual(result, path.join(os.homedir(), '.qwen'));
});
test('returns explicit dir when provided', () => {
const result = getGlobalDir('qwen', '/custom/qwen-path');
assert.strictEqual(result, '/custom/qwen-path');
});
test('respects QWEN_CONFIG_DIR env var', () => {
process.env.QWEN_CONFIG_DIR = '~/custom-qwen';
const result = getGlobalDir('qwen');
assert.strictEqual(result, path.join(os.homedir(), 'custom-qwen'));
});
test('explicit dir takes priority over QWEN_CONFIG_DIR', () => {
process.env.QWEN_CONFIG_DIR = '~/from-env';
const result = getGlobalDir('qwen', '/explicit/path');
assert.strictEqual(result, '/explicit/path');
});
test('does not break other runtimes', () => {
assert.strictEqual(getGlobalDir('claude'), path.join(os.homedir(), '.claude'));
assert.strictEqual(getGlobalDir('codex'), path.join(os.homedir(), '.codex'));
});
});
describe('Qwen Code local install/uninstall', () => {
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));
assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd-help', 'SKILL.md')));
assert.ok(fs.existsSync(path.join(targetDir, 'get-shit-done', 'VERSION')));
assert.ok(fs.existsSync(path.join(targetDir, 'agents')));
const manifest = writeManifest(targetDir, 'qwen');
assert.ok(Object.keys(manifest.files).some(file => file.startsWith('skills/gsd-help/')), manifest);
uninstall(false, 'qwen');
assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd-help')), 'Qwen skill directory removed');
assert.ok(!fs.existsSync(path.join(targetDir, 'get-shit-done')), 'get-shit-done removed');
});
});
describe('E2E: Qwen Code uninstall skills cleanup', () => {
let tmpDir;
let previousCwd;
beforeEach(() => {
tmpDir = createTempDir('gsd-qwen-uninstall-');
previousCwd = process.cwd();
process.chdir(tmpDir);
});
afterEach(() => {
process.chdir(previousCwd);
cleanup(tmpDir);
});
test('removes all gsd-* skill directories on --qwen --uninstall', () => {
const targetDir = path.join(tmpDir, '.qwen');
install(false, 'qwen');
const skillsDir = path.join(targetDir, 'skills');
assert.ok(fs.existsSync(skillsDir), 'skills dir exists after install');
const installedSkills = fs.readdirSync(skillsDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
assert.ok(installedSkills.length > 0, `found ${installedSkills.length} gsd-* skill dirs before uninstall`);
uninstall(false, 'qwen');
if (fs.existsSync(skillsDir)) {
const remainingGsd = fs.readdirSync(skillsDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
assert.strictEqual(remainingGsd.length, 0,
`Expected 0 gsd-* skill dirs after uninstall, found: ${remainingGsd.map(e => e.name).join(', ')}`);
}
});
test('preserves non-GSD skill directories during --qwen --uninstall', () => {
const targetDir = path.join(tmpDir, '.qwen');
install(false, 'qwen');
const customSkillDir = path.join(targetDir, 'skills', 'my-custom-skill');
fs.mkdirSync(customSkillDir, { recursive: true });
fs.writeFileSync(path.join(customSkillDir, 'SKILL.md'), '# My Custom Skill\n');
assert.ok(fs.existsSync(path.join(customSkillDir, 'SKILL.md')), 'custom skill exists before uninstall');
uninstall(false, 'qwen');
assert.ok(fs.existsSync(path.join(customSkillDir, 'SKILL.md')),
'Non-GSD skill directory should be preserved after Qwen uninstall');
});
test('removes engine directory on --qwen --uninstall', () => {
const targetDir = path.join(tmpDir, '.qwen');
install(false, 'qwen');
assert.ok(fs.existsSync(path.join(targetDir, 'get-shit-done', 'VERSION')),
'engine exists before uninstall');
uninstall(false, 'qwen');
assert.ok(!fs.existsSync(path.join(targetDir, 'get-shit-done')),
'get-shit-done engine should be removed after Qwen uninstall');
});
});
// ─── Regression: no Claude references leak into Qwen install (#2112) ──────────
describe('Qwen install contains no leaked Claude references (#2112)', () => {
let tmpDir;
let previousCwd;
beforeEach(() => {
tmpDir = createTempDir('gsd-qwen-refs-');
previousCwd = process.cwd();
process.chdir(tmpDir);
install(false, 'qwen');
});
afterEach(() => {
process.chdir(previousCwd);
cleanup(tmpDir);
});
/**
* Recursively walk a directory and return all file paths.
*/
function walk(dir) {
const results = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...walk(full));
} else {
results.push(full);
}
}
return results;
}
/**
* Return files under .qwen/ that contain Claude references,
* excluding CHANGELOG.md (historical accuracy) and VERSION (no prose).
*/
function findClaudeLeaks() {
const qwenDir = path.join(tmpDir, '.qwen');
const allFiles = walk(qwenDir);
const textFiles = allFiles.filter(f =>
f.endsWith('.md') || f.endsWith('.cjs') || f.endsWith('.js')
);
const excluded = ['CHANGELOG.md'];
const candidates = textFiles.filter(f =>
!excluded.includes(path.basename(f))
);
const leaks = [];
for (const file of candidates) {
const content = fs.readFileSync(file, 'utf8');
if (/\bCLAUDE\.md\b/.test(content) ||
/\bClaude Code\b/.test(content) ||
/\.claude\//.test(content)) {
leaks.push(path.relative(tmpDir, file));
}
}
return leaks;
}
test('skills contain no CLAUDE.md or Claude Code references', () => {
const qwenDir = path.join(tmpDir, '.qwen');
const skillsDir = path.join(qwenDir, 'skills');
assert.ok(fs.existsSync(skillsDir), 'skills directory exists');
const skillFiles = walk(skillsDir).filter(f => f.endsWith('.md'));
assert.ok(skillFiles.length > 0, 'at least one skill file exists');
const leaks = [];
for (const file of skillFiles) {
const content = fs.readFileSync(file, 'utf8');
if (/\bCLAUDE\.md\b/.test(content) || /\bClaude Code\b/.test(content)) {
leaks.push(path.relative(tmpDir, file));
}
}
assert.strictEqual(leaks.length, 0,
[
'Skills should not contain Claude references after Qwen install.',
'Leaking files:',
...leaks,
].join('\n'));
});
test('agents contain no CLAUDE.md or Claude Code references', () => {
const agentsDir = path.join(tmpDir, '.qwen', 'agents');
assert.ok(fs.existsSync(agentsDir), 'agents directory exists');
const agentFiles = walk(agentsDir).filter(f => f.endsWith('.md'));
assert.ok(agentFiles.length > 0, 'at least one agent file exists');
const leaks = [];
for (const file of agentFiles) {
const content = fs.readFileSync(file, 'utf8');
if (/\bCLAUDE\.md\b/.test(content) || /\bClaude Code\b/.test(content)) {
leaks.push(path.relative(tmpDir, file));
}
}
assert.strictEqual(leaks.length, 0,
[
'Agents should not contain Claude references after Qwen install.',
'Leaking files:',
...leaks,
].join('\n'));
});
test('hooks contain no .claude/ path references', () => {
const hooksDir = path.join(tmpDir, '.qwen', 'hooks');
if (!fs.existsSync(hooksDir)) {
return; // hooks may not be present in local installs
}
const hookFiles = walk(hooksDir).filter(f => f.endsWith('.js'));
const leaks = [];
for (const file of hookFiles) {
const content = fs.readFileSync(file, 'utf8');
if (/\.claude\//.test(content)) {
leaks.push(path.relative(tmpDir, file));
}
}
assert.strictEqual(leaks.length, 0,
[
'Hooks should not contain .claude/ path references after Qwen install.',
'Leaking files:',
...leaks,
].join('\n'));
});
test('full tree scan finds zero Claude references outside CHANGELOG.md', () => {
const leaks = findClaudeLeaks();
assert.strictEqual(leaks.length, 0,
[
'No files under .qwen/ (except CHANGELOG.md) should contain Claude references.',
`Found ${leaks.length} leaking file(s):`,
...leaks,
].join('\n'));
});
});

View File

@@ -1,276 +0,0 @@
// allow-test-rule: pending-migration-to-typed-ir [#2974]
// Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md
// "Prohibited: Raw Text Matching on Test Outputs". Per-file review may
// reclassify some entries as source-text-is-the-product during migration.
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, cleanup } = require('./helpers.cjs');
const {
getDirName,
getGlobalDir,
getConfigDirFromHome,
convertClaudeToTraeMarkdown,
convertClaudeCommandToTraeSkill,
convertClaudeAgentToTraeAgent,
installRuntimeArtifacts,
install,
uninstall,
writeManifest,
} = require('../bin/install.js');
const {
loadSkillsManifest,
resolveProfile,
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
const manifest = loadSkillsManifest();
const resolvedProfileFull = resolveProfile({ modes: [], manifest });
describe('Trae runtime directory mapping', () => {
test('maps Trae to .trae for local installs', () => {
assert.strictEqual(getDirName('trae'), '.trae');
});
test('maps Trae to ~/.trae for global installs', () => {
assert.strictEqual(getGlobalDir('trae'), path.join(os.homedir(), '.trae'));
});
test('returns .trae config fragments for local and global installs', () => {
assert.strictEqual(getConfigDirFromHome('trae', false), "'.trae'");
assert.strictEqual(getConfigDirFromHome('trae', true), "'.trae'");
});
});
describe('getGlobalDir (Trae)', () => {
let originalTraeConfigDir;
beforeEach(() => {
originalTraeConfigDir = process.env.TRAE_CONFIG_DIR;
});
afterEach(() => {
if (originalTraeConfigDir !== undefined) {
process.env.TRAE_CONFIG_DIR = originalTraeConfigDir;
} else {
delete process.env.TRAE_CONFIG_DIR;
}
});
test('returns ~/.trae with no env var or explicit dir', () => {
delete process.env.TRAE_CONFIG_DIR;
const result = getGlobalDir('trae');
assert.strictEqual(result, path.join(os.homedir(), '.trae'));
});
test('returns explicit dir when provided', () => {
const result = getGlobalDir('trae', '/custom/trae-path');
assert.strictEqual(result, '/custom/trae-path');
});
test('respects TRAE_CONFIG_DIR env var', () => {
process.env.TRAE_CONFIG_DIR = '~/custom-trae';
const result = getGlobalDir('trae');
assert.strictEqual(result, path.join(os.homedir(), 'custom-trae'));
});
test('explicit dir takes priority over TRAE_CONFIG_DIR', () => {
process.env.TRAE_CONFIG_DIR = '~/from-env';
const result = getGlobalDir('trae', '/explicit/path');
assert.strictEqual(result, '/explicit/path');
});
test('does not break other runtimes', () => {
assert.strictEqual(getGlobalDir('claude'), path.join(os.homedir(), '.claude'));
assert.strictEqual(getGlobalDir('codex'), path.join(os.homedir(), '.codex'));
});
});
describe('Trae markdown conversion', () => {
test('converts Claude-specific references to Trae equivalents', () => {
const input = [
'Claude Code reads CLAUDE.md before using .claude/skills/.',
'Run /gsd:plan-phase with $ARGUMENTS.',
'Use Bash(command) and Edit(file).',
].join('\n');
const result = convertClaudeToTraeMarkdown(input);
assert.ok(result.includes('Trae reads .trae/rules/ before using .trae/skills/.'), result);
assert.ok(result.includes('/gsd-plan-phase'), result);
assert.ok(result.includes('{{GSD_ARGS}}'), result);
assert.ok(result.includes('Shell('), result);
assert.ok(result.includes('StrReplace('), result);
});
test('converts commands and agents to Trae frontmatter', () => {
const command = `---
name: gsd:new-project
description: Initialize a project
---
Use .claude/skills/ and /gsd:help.
`;
const agent = `---
name: gsd-planner
description: Planner agent
tools: Read, Write
color: blue
---
Read CLAUDE.md before acting.
`;
const convertedCommand = convertClaudeCommandToTraeSkill(command, 'gsd-new-project');
const convertedAgent = convertClaudeAgentToTraeAgent(agent);
assert.ok(convertedCommand.includes('name: gsd-new-project'), convertedCommand);
assert.ok(!convertedCommand.includes('<trae_skill_adapter>'), convertedCommand);
assert.ok(convertedCommand.includes('.trae/skills/'), convertedCommand);
assert.ok(convertedCommand.includes('/gsd-help'), convertedCommand);
assert.ok(convertedAgent.includes('name: gsd-planner'), convertedAgent);
assert.ok(!convertedAgent.includes('color:'), convertedAgent);
assert.ok(convertedAgent.includes('.trae/rules/'), convertedAgent);
});
});
describe('installRuntimeArtifacts (trae)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-trae-copy-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('creates one skill directory per GSD command', () => {
const configDir = path.join(tmpDir, '.trae');
fs.mkdirSync(configDir, { recursive: true });
installRuntimeArtifacts('trae', configDir, 'local', resolvedProfileFull);
const generated = path.join(configDir, 'skills', 'gsd-help', 'SKILL.md');
assert.ok(fs.existsSync(generated), generated);
const content = fs.readFileSync(generated, 'utf8');
assert.ok(!content.includes('<trae_skill_adapter>'), content);
assert.ok(content.includes('name: gsd-help'), content);
});
});
describe('Trae local install/uninstall', () => {
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', () => {
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),
});
assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd-help', 'SKILL.md')));
assert.ok(fs.existsSync(path.join(targetDir, 'get-shit-done', 'VERSION')));
assert.ok(fs.existsSync(path.join(targetDir, 'agents')));
const manifest = writeManifest(targetDir, 'trae');
assert.ok(Object.keys(manifest.files).some(file => file.startsWith('skills/gsd-help/')), manifest);
uninstall(false, 'trae');
assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd-help')), 'Trae skill directory removed');
assert.ok(!fs.existsSync(path.join(targetDir, 'get-shit-done')), 'get-shit-done removed');
});
});
describe('E2E: Trae uninstall skills cleanup', () => {
let tmpDir;
let previousCwd;
beforeEach(() => {
tmpDir = createTempDir('gsd-trae-uninstall-');
previousCwd = process.cwd();
process.chdir(tmpDir);
});
afterEach(() => {
process.chdir(previousCwd);
cleanup(tmpDir);
});
test('removes all gsd-* skill directories on --trae --uninstall', () => {
const targetDir = path.join(tmpDir, '.trae');
install(false, 'trae');
const skillsDir = path.join(targetDir, 'skills');
assert.ok(fs.existsSync(skillsDir), 'skills dir exists after install');
const installedSkills = fs.readdirSync(skillsDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
assert.ok(installedSkills.length > 0, `found ${installedSkills.length} gsd-* skill dirs before uninstall`);
uninstall(false, 'trae');
if (fs.existsSync(skillsDir)) {
const remainingGsd = fs.readdirSync(skillsDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
assert.strictEqual(remainingGsd.length, 0,
`Expected 0 gsd-* skill dirs after uninstall, found: ${remainingGsd.map(e => e.name).join(', ')}`);
}
});
test('preserves non-GSD skill directories during --trae --uninstall', () => {
const targetDir = path.join(tmpDir, '.trae');
install(false, 'trae');
const customSkillDir = path.join(targetDir, 'skills', 'my-custom-skill');
fs.mkdirSync(customSkillDir, { recursive: true });
fs.writeFileSync(path.join(customSkillDir, 'SKILL.md'), '# My Custom Skill\n');
assert.ok(fs.existsSync(path.join(customSkillDir, 'SKILL.md')), 'custom skill exists before uninstall');
uninstall(false, 'trae');
assert.ok(fs.existsSync(path.join(customSkillDir, 'SKILL.md')),
'Non-GSD skill directory should be preserved after Trae uninstall');
});
test('removes engine directory on --trae --uninstall', () => {
const targetDir = path.join(tmpDir, '.trae');
install(false, 'trae');
assert.ok(fs.existsSync(path.join(targetDir, 'get-shit-done', 'VERSION')),
'engine exists before uninstall');
uninstall(false, 'trae');
assert.ok(!fs.existsSync(path.join(targetDir, 'get-shit-done')),
'get-shit-done engine should be removed after Trae uninstall');
});
});