From f955bf872ca8e46aa81d9f4d72683a1aaf04e8b9 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 20 May 2026 20:38:30 -0400 Subject: [PATCH] =?UTF-8?q?refactor(tests):=20consolidate=20Installer=20Mo?= =?UTF-8?q?dule=20=E2=80=94=2011=20files=20=E2=86=92=202=20(#3760)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * chore(tests): add docs-exempt to changeset fragment Internal CI lint rule — no user-facing docs impact. Co-Authored-By: Claude Sonnet 4.6 * refactor(tests): consolidate Installer Module — 11 files → 2 Closes #3758 Co-Authored-By: Claude Sonnet 4.6 * 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 * 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 * 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 --------- Co-authored-by: Claude Sonnet 4.6 --- .../3758-consolidate-installer-tests.md | 6 + CONTEXT.md | 3 + tests/antigravity-install.test.cjs | 421 ---------- tests/helpers/install-shared.cjs | 216 +++++ tests/hermes-install.test.cjs | 400 --------- tests/install-hermes-regressions.test.cjs | 508 ------------ tests/install-hooks-copy.test.cjs | 461 ----------- tests/install-minimal-all-runtimes.test.cjs | 354 -------- tests/install-minimal-backcompat.test.cjs | 87 -- tests/install-minimal-hooks.test.cjs | 782 ++++++++++++++++++ tests/install-minimal.test.cjs | 614 -------------- tests/install-regressions.test.cjs | 306 +++++++ tests/install-runtime-artifacts.test.cjs | 347 ++++++++ tests/install-uninstall-layout-loop.test.cjs | 573 ------------- tests/install.test.cjs | 602 ++++++++++++++ tests/kilo-install.test.cjs | 290 ------- tests/qwen-install.test.cjs | 316 ------- tests/trae-install.test.cjs | 276 ------- 18 files changed, 2262 insertions(+), 4300 deletions(-) create mode 100644 .changeset/3758-consolidate-installer-tests.md delete mode 100644 tests/antigravity-install.test.cjs create mode 100644 tests/helpers/install-shared.cjs delete mode 100644 tests/hermes-install.test.cjs delete mode 100644 tests/install-hermes-regressions.test.cjs delete mode 100644 tests/install-hooks-copy.test.cjs delete mode 100644 tests/install-minimal-all-runtimes.test.cjs delete mode 100644 tests/install-minimal-backcompat.test.cjs create mode 100644 tests/install-minimal-hooks.test.cjs delete mode 100644 tests/install-minimal.test.cjs create mode 100644 tests/install-regressions.test.cjs create mode 100644 tests/install-runtime-artifacts.test.cjs delete mode 100644 tests/install-uninstall-layout-loop.test.cjs create mode 100644 tests/install.test.cjs delete mode 100644 tests/kilo-install.test.cjs delete mode 100644 tests/qwen-install.test.cjs delete mode 100644 tests/trae-install.test.cjs diff --git a/.changeset/3758-consolidate-installer-tests.md b/.changeset/3758-consolidate-installer-tests.md new file mode 100644 index 000000000..17a4a22ef --- /dev/null +++ b/.changeset/3758-consolidate-installer-tests.md @@ -0,0 +1,6 @@ +--- +type: Changed +pr: 3758 +--- + +**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) diff --git a/CONTEXT.md b/CONTEXT.md index 41ca245fe..eb0232b1c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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//` layout (prefix: ''); other skill-runtimes use flat `skills/gsd-/` 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 `/.gsd-surface.json` independent from the `.gsd-profile` marker. See ADR-0011. diff --git a/tests/antigravity-install.test.cjs b/tests/antigravity-install.test.cjs deleted file mode 100644 index 8bbcc5039..000000000 --- a/tests/antigravity-install.test.cjs +++ /dev/null @@ -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: /skills/gsd-/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'); - }); -}); diff --git a/tests/helpers/install-shared.cjs b/tests/helpers/install-shared.cjs new file mode 100644 index 000000000..d9f671d30 --- /dev/null +++ b/tests/helpers/install-shared.cjs @@ -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, +}; diff --git a/tests/hermes-install.test.cjs b/tests/hermes-install.test.cjs deleted file mode 100644 index 6d7fc7469..000000000 --- a/tests/hermes-install.test.cjs +++ /dev/null @@ -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//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')); - }); -}); diff --git a/tests/install-hermes-regressions.test.cjs b/tests/install-hermes-regressions.test.cjs deleted file mode 100644 index bd36810e9..000000000 --- a/tests/install-hermes-regressions.test.cjs +++ /dev/null @@ -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-/ 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/.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-/ 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 /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//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//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)}`, - ); - }); -}); diff --git a/tests/install-hooks-copy.test.cjs b/tests/install-hooks-copy.test.cjs deleted file mode 100644 index 741fee103..000000000 --- a/tests/install-hooks-copy.test.cjs +++ /dev/null @@ -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'); - }); -}); diff --git a/tests/install-minimal-all-runtimes.test.cjs b/tests/install-minimal-all-runtimes.test.cjs deleted file mode 100644 index 5e5495ad0..000000000 --- a/tests/install-minimal-all-runtimes.test.cjs +++ /dev/null @@ -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//... (Claude global, Codex, Copilot, Antigravity, - * Cursor, Windsurf, Augment, Trae, Qwen, - * CodeBuddy) - * command/gsd-.md (OpenCode, Kilo) - * commands/gsd/.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//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//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-.md - out.add(entry.name.replace(/^gsd-/, '').replace(/\.md$/, '')); - } - } - } - - // command/gsd-.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/.{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; -} diff --git a/tests/install-minimal-backcompat.test.cjs b/tests/install-minimal-backcompat.test.cjs deleted file mode 100644 index 8a3196f25..000000000 --- a/tests/install-minimal-backcompat.test.cjs +++ /dev/null @@ -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"'); - }); -}); diff --git a/tests/install-minimal-hooks.test.cjs b/tests/install-minimal-hooks.test.cjs new file mode 100644 index 000000000..729a5f4e0 --- /dev/null +++ b/tests/install-minimal-hooks.test.cjs @@ -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: '', 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]')); + }); +}); diff --git a/tests/install-minimal.test.cjs b/tests/install-minimal.test.cjs deleted file mode 100644 index 2c110a922..000000000 --- a/tests/install-minimal.test.cjs +++ /dev/null @@ -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: '', 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`, - ); - } - }); -}); diff --git a/tests/install-regressions.test.cjs b/tests/install-regressions.test.cjs new file mode 100644 index 000000000..d7f640c2d --- /dev/null +++ b/tests/install-regressions.test.cjs @@ -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-/ 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-/ 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'); + }); +}); diff --git a/tests/install-runtime-artifacts.test.cjs b/tests/install-runtime-artifacts.test.cjs new file mode 100644 index 000000000..897637393 --- /dev/null +++ b/tests/install-runtime-artifacts.test.cjs @@ -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//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// 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'))); + }); +}); diff --git a/tests/install-uninstall-layout-loop.test.cjs b/tests/install-uninstall-layout-loop.test.cjs deleted file mode 100644 index 31e30b5ea..000000000 --- a/tests/install-uninstall-layout-loop.test.cjs +++ /dev/null @@ -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 //SKILL.md -// For commands runtimes: creates /.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 /skills/ - for (const runtime of SKILLS_RUNTIMES) { - test(`${runtime}: installs gsd-prefixed skill dirs into /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//SKILL.md, prefix is '' - test('hermes: nested layout — skills/gsd//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/.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-.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// 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//) 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' - ); - }); -}); diff --git a/tests/install.test.cjs b/tests/install.test.cjs new file mode 100644 index 000000000..2548a13c7 --- /dev/null +++ b/tests/install.test.cjs @@ -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')); + }); +}); diff --git a/tests/kilo-install.test.cjs b/tests/kilo-install.test.cjs deleted file mode 100644 index bf4bb2537..000000000 --- a/tests/kilo-install.test.cjs +++ /dev/null @@ -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'); - }); -}); diff --git a/tests/qwen-install.test.cjs b/tests/qwen-install.test.cjs deleted file mode 100644 index 721288058..000000000 --- a/tests/qwen-install.test.cjs +++ /dev/null @@ -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')); - }); -}); diff --git a/tests/trae-install.test.cjs b/tests/trae-install.test.cjs deleted file mode 100644 index bb2cb7971..000000000 --- a/tests/trae-install.test.cjs +++ /dev/null @@ -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(''), 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(''), 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'); - }); -});