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:
6
.changeset/3758-consolidate-installer-tests.md
Normal file
6
.changeset/3758-consolidate-installer-tests.md
Normal 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)
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
216
tests/helpers/install-shared.cjs
Normal file
216
tests/helpers/install-shared.cjs
Normal 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,
|
||||
};
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
782
tests/install-minimal-hooks.test.cjs
Normal file
782
tests/install-minimal-hooks.test.cjs
Normal 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]'));
|
||||
});
|
||||
});
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
306
tests/install-regressions.test.cjs
Normal file
306
tests/install-regressions.test.cjs
Normal 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');
|
||||
});
|
||||
});
|
||||
347
tests/install-runtime-artifacts.test.cjs
Normal file
347
tests/install-runtime-artifacts.test.cjs
Normal 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')));
|
||||
});
|
||||
});
|
||||
@@ -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
602
tests/install.test.cjs
Normal 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'));
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user