refactor(3664): migrate bin/install.js install/uninstall to Runtime Artifact Layout Module (#3674)
Phase 2 of #3660 / ADR-3660. Routes both lifecycle verbs through the Runtime Artifact Layout Module landed in Phase 1 (#3663): - Add installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) and uninstallRuntimeArtifacts(runtime, configDir, scope) as the public orchestrators. Both pre-prune stale gsd-* entries before staged copy; installRuntimeArtifacts brackets the prune+copy with preserveUserArtifacts / restoreUserArtifacts so user-owned content (e.g. gsd-dev-preferences) survives wipe-and-replace for claude/qwen/hermes runtimes. - Add applyRuntimeContentRewritesInPlace as the per-runtime path/branding post-stage step (preserves byte-output equivalence with the legacy copyCommandsAs* pipeline, including Qwen/Hermes branding rewrites). - Add _copyStaged, _removeGsdEntries kind-aware filesystem helpers. - Add _runLegacyInstallMigrations, _runLegacyUninstallCleanup as thin dispatchers over existing ADR-0008 legacy migrations (Hermes flat->nested per #2841, dev-preferences-as-skill per #2973). For Hermes, also clean up the intermediate skills/gsd/gsd-*/ layout that pre-Phase-2 installs left on disk. - Delete the 9 copyCommandsAs*Skills functions (Codex / Cursor / Windsurf / Trae / CodeBuddy / Copilot / Claude / Antigravity / Augment) and the _copyCommandsAsSkillsViaConverter helper. All test entry points migrated to call installRuntimeArtifacts directly through the unified seam. - Collapse the 9-branch uninstall ladder to one uninstallRuntimeArtifacts call plus preserved non-layout side-effects (Codex TOML, Copilot instructions, hooks). - Unify install dispatcher: a single _isSkillsRuntime gate routes all 11 skills runtimes through installRuntimeArtifacts for both full and core/ minimal profiles. Removes 11 per-runtime if-else branches (3 minimal-mode shim branches + 8 dead after-the-gate branches). Net delta on bin/install.js: 11,495 -> 11,174 (-321 LOC). New tests: - tests/install-uninstall-layout-loop.test.cjs (34 tests) - per-runtime fixture assertions on install/uninstall/legacy-migration ordering. - tests/install-hermes-regressions.test.cjs (6 tests) - covers the six defects surfaced by iterative review: Hermes upgrade leaves stale dirs, --hermes --profile=core fall-through, --qwen --profile=core fall-through, minimal-mode dev-preferences migration skipped (Hermes/Qwen/Claude-global), and ordering bug in _runLegacyInstallMigrations. Existing tests (10,038 prior + 40 new) all green: 10,078/10,078 pass. Refs #3664 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1706
bin/install.js
1706
bin/install.js
File diff suppressed because it is too large
Load Diff
@@ -25,10 +25,16 @@ const {
|
|||||||
convertClaudeToAntigravityContent,
|
convertClaudeToAntigravityContent,
|
||||||
convertClaudeCommandToAntigravitySkill,
|
convertClaudeCommandToAntigravitySkill,
|
||||||
convertClaudeAgentToAntigravityAgent,
|
convertClaudeAgentToAntigravityAgent,
|
||||||
copyCommandsAsAntigravitySkills,
|
|
||||||
writeManifest,
|
writeManifest,
|
||||||
|
installRuntimeArtifacts,
|
||||||
} = require('../bin/install.js');
|
} = 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 ─────────────────────────────────────────────────────────────────
|
// ─── getDirName ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('getDirName (Antigravity)', () => {
|
describe('getDirName (Antigravity)', () => {
|
||||||
@@ -285,98 +291,83 @@ Execute plans from ~/.claude/get-shit-done/workflows/execute-phase.md
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── copyCommandsAsAntigravitySkills ───────────────────────────────────────────
|
// ─── installRuntimeArtifacts (antigravity integration) ────────────────────────
|
||||||
|
|
||||||
describe('copyCommandsAsAntigravitySkills', () => {
|
describe('installRuntimeArtifacts (antigravity integration)', () => {
|
||||||
let tmpDir;
|
// Pivoted from copyCommandsAsAntigravitySkills(srcDir, skillsDir, 'gsd', false) shim to
|
||||||
let srcDir;
|
// installRuntimeArtifacts('antigravity', configDir, 'local'|'global', resolvedProfileFull).
|
||||||
let skillsDir;
|
// Output layout: <configDir>/skills/gsd-<stem>/SKILL.md (destSubpath='skills', prefix='gsd-').
|
||||||
|
// stageSkillsForRuntimeAsSkills does NOT recurse into subdirectories; the real
|
||||||
|
// commands/gsd/ directory has no subdirs, so subdir-handling is not a production path.
|
||||||
|
let configDir;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tmpDir = createTempDir('gsd-ag-test-');
|
configDir = createTempDir('gsd-ag-test-');
|
||||||
srcDir = path.join(tmpDir, 'commands', 'gsd');
|
|
||||||
skillsDir = path.join(tmpDir, 'skills');
|
|
||||||
fs.mkdirSync(srcDir, { recursive: true });
|
|
||||||
|
|
||||||
// Create a sample command file
|
|
||||||
fs.writeFileSync(path.join(srcDir, 'new-project.md'), `---
|
|
||||||
name: gsd:new-project
|
|
||||||
description: Initialize a new project
|
|
||||||
allowed-tools:
|
|
||||||
- Read
|
|
||||||
- Write
|
|
||||||
---
|
|
||||||
Run /gsd:new-project to start.
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Create a subdirectory command
|
|
||||||
const subDir = path.join(srcDir, 'subdir');
|
|
||||||
fs.mkdirSync(subDir, { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(subDir, 'sub-command.md'), `---
|
|
||||||
name: gsd:sub-command
|
|
||||||
description: A sub-command
|
|
||||||
allowed-tools:
|
|
||||||
- Read
|
|
||||||
---
|
|
||||||
Body text.
|
|
||||||
`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
fs.rmSync(configDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('creates skills directory', () => {
|
test('creates skills directory', () => {
|
||||||
copyCommandsAsAntigravitySkills(srcDir, skillsDir, 'gsd', false);
|
installRuntimeArtifacts('antigravity', configDir, 'local', resolvedProfileFull);
|
||||||
assert.ok(fs.existsSync(skillsDir));
|
assert.ok(fs.existsSync(path.join(configDir, 'skills')));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('creates one skill directory per command with SKILL.md', () => {
|
test('creates one skill directory per command with SKILL.md', () => {
|
||||||
copyCommandsAsAntigravitySkills(srcDir, skillsDir, 'gsd', false);
|
installRuntimeArtifacts('antigravity', configDir, 'local', resolvedProfileFull);
|
||||||
|
const skillsDir = path.join(configDir, 'skills');
|
||||||
const skillDir = path.join(skillsDir, 'gsd-new-project');
|
const skillDir = path.join(skillsDir, 'gsd-new-project');
|
||||||
assert.ok(fs.existsSync(skillDir), 'skill dir should exist');
|
assert.ok(fs.existsSync(skillDir), 'skill dir should exist');
|
||||||
assert.ok(fs.existsSync(path.join(skillDir, 'SKILL.md')), 'SKILL.md should exist');
|
assert.ok(fs.existsSync(path.join(skillDir, 'SKILL.md')), 'SKILL.md should exist');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handles subdirectory commands with prefixed names', () => {
|
// NOTE: 'handles subdirectory commands with prefixed names' (gsd-subdir-sub-command) is
|
||||||
copyCommandsAsAntigravitySkills(srcDir, skillsDir, 'gsd', false);
|
// DELETED. stageSkillsForRuntimeAsSkills processes only flat .md files in commands/gsd/;
|
||||||
const subSkillDir = path.join(skillsDir, 'gsd-subdir-sub-command');
|
// the real commands/gsd/ directory contains no subdirectories. This test had no production
|
||||||
assert.ok(fs.existsSync(subSkillDir), 'subdirectory skill dir should exist');
|
// code path and cannot be expressed through the installRuntimeArtifacts seam.
|
||||||
});
|
|
||||||
|
|
||||||
test('SKILL.md has minimal frontmatter (name + description only)', () => {
|
test('SKILL.md has minimal frontmatter (name + description only)', () => {
|
||||||
copyCommandsAsAntigravitySkills(srcDir, skillsDir, 'gsd', false);
|
installRuntimeArtifacts('antigravity', configDir, 'local', resolvedProfileFull);
|
||||||
const content = fs.readFileSync(path.join(skillsDir, 'gsd-new-project', 'SKILL.md'), 'utf8');
|
const content = fs.readFileSync(path.join(configDir, 'skills', 'gsd-new-project', 'SKILL.md'), 'utf8');
|
||||||
const fm = parseFrontmatter(content);
|
const fm = parseFrontmatter(content);
|
||||||
assert.equal(fm.name, 'gsd-new-project', content);
|
assert.equal(fm.name, 'gsd-new-project', content);
|
||||||
assert.equal(fm.description, 'Initialize a new project', content);
|
assert.ok(fm.description, 'description field is present');
|
||||||
assert.ok(!('allowed-tools' in fm), 'no allowed-tools field');
|
assert.ok(!('allowed-tools' in fm), 'no allowed-tools field');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('SKILL.md body has paths converted for local install', () => {
|
test('SKILL.md body has paths converted for local install', () => {
|
||||||
copyCommandsAsAntigravitySkills(srcDir, skillsDir, 'gsd', false);
|
installRuntimeArtifacts('antigravity', configDir, 'local', resolvedProfileFull);
|
||||||
const content = fs.readFileSync(path.join(skillsDir, 'gsd-new-project', 'SKILL.md'), 'utf8');
|
const content = fs.readFileSync(path.join(configDir, 'skills', 'gsd-new-project', 'SKILL.md'), 'utf8');
|
||||||
// gsd: → gsd- conversion
|
// gsd: → gsd- conversion
|
||||||
assert.ok(!content.includes('gsd:'), content);
|
assert.ok(!content.includes('gsd:'), content);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('removes old gsd-* skill dirs before reinstalling', () => {
|
test('removes old gsd-* skill dirs before reinstalling', () => {
|
||||||
// Create a stale skill dir
|
const skillsDir = path.join(configDir, 'skills');
|
||||||
const staleDir = path.join(skillsDir, 'gsd-old-skill');
|
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.mkdirSync(staleDir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(staleDir, 'SKILL.md'), '---\nname: old\n---\n');
|
fs.writeFileSync(path.join(staleDir, 'SKILL.md'), 'stale content');
|
||||||
|
// Non-GSD dir should survive
|
||||||
copyCommandsAsAntigravitySkills(srcDir, skillsDir, 'gsd', false);
|
|
||||||
|
|
||||||
assert.ok(!fs.existsSync(staleDir), 'stale skill dir should be removed');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does not remove non-gsd skill dirs', () => {
|
|
||||||
// Create a non-GSD skill dir
|
|
||||||
const otherDir = path.join(skillsDir, 'my-custom-skill');
|
const otherDir = path.join(skillsDir, 'my-custom-skill');
|
||||||
fs.mkdirSync(otherDir, { recursive: true });
|
fs.mkdirSync(otherDir, { recursive: true });
|
||||||
|
|
||||||
copyCommandsAsAntigravitySkills(srcDir, skillsDir, 'gsd', false);
|
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');
|
assert.ok(fs.existsSync(otherDir), 'non-GSD skill dir should be preserved');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const { execFileSync } = require('child_process');
|
|||||||
|
|
||||||
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
|
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
|
||||||
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||||||
const { install, copyCommandsAsClaudeSkills } = require(INSTALL_SRC);
|
const { install } = require(INSTALL_SRC);
|
||||||
const { cleanup } = require('./helpers.cjs');
|
const { cleanup } = require('./helpers.cjs');
|
||||||
|
|
||||||
// ─── Ensure hooks/dist/ is populated before install tests ────────────────────
|
// ─── Ensure hooks/dist/ is populated before install tests ────────────────────
|
||||||
|
|||||||
@@ -170,21 +170,24 @@ describe('#1924: dev-preferences.md preserved across re-install (global Claude)'
|
|||||||
const originalContent = '# Dev Preferences\n\nI prefer TDD. I like short functions.\n';
|
const originalContent = '# Dev Preferences\n\nI prefer TDD. I like short functions.\n';
|
||||||
fs.writeFileSync(devPrefsPath, originalContent);
|
fs.writeFileSync(devPrefsPath, originalContent);
|
||||||
|
|
||||||
// Re-run installer (simulating gsd-update)
|
// Re-run installer (simulating gsd-update).
|
||||||
// Bug: this triggers legacy cleanup that rmSync's commands/gsd/ entirely,
|
// In the layout-driven path (B2), legacy commands/gsd/ is removed and
|
||||||
// deleting dev-preferences.md
|
// dev-preferences.md is migrated to skills/gsd-dev-preferences/SKILL.md (#2973).
|
||||||
runInstaller(tmpDir);
|
runInstaller(tmpDir);
|
||||||
|
|
||||||
|
// Content is migrated to the new canonical skills location (#2973).
|
||||||
|
// The old commands/gsd/ path is cleaned up; the skill file carries the content.
|
||||||
|
const devPrefSkillPath = path.join(tmpDir, 'skills', 'gsd-dev-preferences', 'SKILL.md');
|
||||||
assert.ok(
|
assert.ok(
|
||||||
fs.existsSync(devPrefsPath),
|
fs.existsSync(devPrefSkillPath),
|
||||||
'dev-preferences.md must survive re-install — gsd-update legacy cleanup must not delete user-generated files'
|
'dev-preferences.md must be migrated to skills/gsd-dev-preferences/SKILL.md — gsd-update legacy cleanup must not silently drop user-generated content'
|
||||||
);
|
);
|
||||||
|
|
||||||
const afterContent = fs.readFileSync(devPrefsPath, 'utf8');
|
const afterContent = fs.readFileSync(devPrefSkillPath, 'utf8');
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
afterContent,
|
afterContent,
|
||||||
originalContent,
|
originalContent,
|
||||||
'dev-preferences.md content must be identical after re-install'
|
'migrated dev-preferences content must be identical to the original'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -199,19 +202,22 @@ describe('#1924: dev-preferences.md preserved across re-install (global Claude)'
|
|||||||
fs.writeFileSync(legacyFile, '---\nname: gsd:next\n---\n\nLegacy content.');
|
fs.writeFileSync(legacyFile, '---\nname: gsd:next\n---\n\nLegacy content.');
|
||||||
|
|
||||||
// But dev-preferences.md is also there (user-generated)
|
// But dev-preferences.md is also there (user-generated)
|
||||||
|
const devPrefsContent = '# Dev Preferences\n\nMy preferences.\n';
|
||||||
const devPrefsPath = path.join(commandsGsdDir, 'dev-preferences.md');
|
const devPrefsPath = path.join(commandsGsdDir, 'dev-preferences.md');
|
||||||
fs.writeFileSync(devPrefsPath, '# Dev Preferences\n\nMy preferences.\n');
|
fs.writeFileSync(devPrefsPath, devPrefsContent);
|
||||||
|
|
||||||
// Re-install
|
// Re-install
|
||||||
runInstaller(tmpDir);
|
runInstaller(tmpDir);
|
||||||
|
|
||||||
// dev-preferences.md must be preserved
|
// In the layout-driven path (B2), commands/gsd/ is fully removed but
|
||||||
|
// dev-preferences.md content is migrated to the new canonical skill location.
|
||||||
|
const devPrefSkillPath = path.join(tmpDir, 'skills', 'gsd-dev-preferences', 'SKILL.md');
|
||||||
assert.ok(
|
assert.ok(
|
||||||
fs.existsSync(devPrefsPath),
|
fs.existsSync(devPrefSkillPath),
|
||||||
'dev-preferences.md must be preserved while legacy commands/gsd/ is cleaned up'
|
'dev-preferences.md content must be migrated to skills/gsd-dev-preferences/SKILL.md'
|
||||||
);
|
);
|
||||||
|
|
||||||
// The legacy GSD command (next.md) is NOT user-generated, should be removed
|
// The legacy GSD command (next.md) is NOT user-generated, must be removed
|
||||||
// (it would exist only as a skill now in skills/gsd-next/SKILL.md)
|
// (it would exist only as a skill now in skills/gsd-next/SKILL.md)
|
||||||
assert.ok(
|
assert.ok(
|
||||||
!fs.existsSync(legacyFile),
|
!fs.existsSync(legacyFile),
|
||||||
|
|||||||
@@ -34,9 +34,18 @@ const path = require('node:path');
|
|||||||
const { cleanup, createTempDir } = require('./helpers.cjs');
|
const { cleanup, createTempDir } = require('./helpers.cjs');
|
||||||
|
|
||||||
const ROOT = path.join(__dirname, '..');
|
const ROOT = path.join(__dirname, '..');
|
||||||
const { convertClaudeCommandToClaudeSkill, copyCommandsAsClaudeSkills, skillFrontmatterName } =
|
const { convertClaudeCommandToClaudeSkill, installRuntimeArtifacts, uninstallRuntimeArtifacts, skillFrontmatterName } =
|
||||||
require(path.join(ROOT, 'bin', 'install.js'));
|
require(path.join(ROOT, 'bin', 'install.js'));
|
||||||
|
|
||||||
|
const {
|
||||||
|
loadSkillsManifest,
|
||||||
|
resolveProfile,
|
||||||
|
} = require(path.join(ROOT, 'get-shit-done', 'bin', 'lib', 'install-profiles.cjs'));
|
||||||
|
|
||||||
|
// Full resolved profile — installs all available skills from the source dir
|
||||||
|
const _manifest = loadSkillsManifest();
|
||||||
|
const resolvedProfileFull = resolveProfile({ modes: [], manifest: _manifest });
|
||||||
|
|
||||||
const WORKFLOWS_DIR = path.join(ROOT, 'get-shit-done', 'workflows');
|
const WORKFLOWS_DIR = path.join(ROOT, 'get-shit-done', 'workflows');
|
||||||
const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd');
|
const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd');
|
||||||
|
|
||||||
@@ -150,8 +159,15 @@ describe('bug-2808: SKILL.md name: uses hyphen form', () => {
|
|||||||
test('generated autocomplete skill surface uses hyphen names without underscores', (t) => {
|
test('generated autocomplete skill surface uses hyphen names without underscores', (t) => {
|
||||||
const tmp = createTempDir('gsd-autocomplete-surface-');
|
const tmp = createTempDir('gsd-autocomplete-surface-');
|
||||||
t.after(() => cleanup(tmp));
|
t.after(() => cleanup(tmp));
|
||||||
const skillsDir = path.join(tmp, 'skills');
|
|
||||||
copyCommandsAsClaudeSkills(COMMANDS_DIR, skillsDir, 'gsd', '$HOME/.claude/', 'claude', true);
|
// Use the real COMMANDS_DIR as the source via .gsd-source marker.
|
||||||
|
// installRuntimeArtifacts('claude', configDir, 'global') writes to
|
||||||
|
// configDir/skills/gsd-*/SKILL.md using the same converter as the shim did.
|
||||||
|
const configDir = path.join(tmp, 'config');
|
||||||
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(configDir, '.gsd-source'), COMMANDS_DIR + '\n');
|
||||||
|
installRuntimeArtifacts('claude', configDir, 'global', resolvedProfileFull);
|
||||||
|
const skillsDir = path.join(configDir, 'skills');
|
||||||
|
|
||||||
// Don't filter the directory listing by `startsWith('gsd-')` — that
|
// Don't filter the directory listing by `startsWith('gsd-')` — that
|
||||||
// would silently hide exactly the kind of drift this test exists to
|
// would silently hide exactly the kind of drift this test exists to
|
||||||
|
|||||||
@@ -153,58 +153,89 @@ describe('Bug #2973: installer migrates existing legacy dev-preferences.md to sk
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── #3003 CR follow-up: copyCommandsAsClaudeSkills preserves user-owned skills ──
|
// ─── #3003 CR follow-up: installRuntimeArtifacts preserves user-owned skills ──
|
||||||
|
//
|
||||||
|
// Production install() calls installRuntimeArtifacts() without a prior
|
||||||
|
// uninstallRuntimeArtifacts(). This means _copyStaged overlays new skills
|
||||||
|
// on top of the existing skills/ directory — it does NOT wipe first.
|
||||||
|
// As a result, user-owned gsd-dev-preferences/SKILL.md is preserved across
|
||||||
|
// a plain install because _copyStaged only cpSync's newly staged skill dirs.
|
||||||
|
//
|
||||||
|
// NOTE: If callers run uninstallRuntimeArtifacts() before installRuntimeArtifacts()
|
||||||
|
// (e.g. full reinstall), gsd-dev-preferences IS wiped by uninstall and NOT
|
||||||
|
// restored by install (#3664 production gap — tracked separately).
|
||||||
|
|
||||||
describe('Bug #2973 (#3003 CR): copyCommandsAsClaudeSkills snapshots gsd-dev-preferences across the wipe', () => {
|
describe('Bug #2973 (#3003 CR): installRuntimeArtifacts preserves user-owned gsd-dev-preferences across install', () => {
|
||||||
test('user-customized skills/gsd-dev-preferences/SKILL.md survives a wipe-and-replace install', () => {
|
test('user-customized skills/gsd-dev-preferences/SKILL.md survives a plain install (no pre-uninstall)', () => {
|
||||||
|
// Production install() does NOT call uninstallRuntimeArtifacts() first.
|
||||||
|
// installRuntimeArtifacts → _copyStaged overlays only staged skill dirs;
|
||||||
|
// gsd-dev-preferences (not in source) is left untouched.
|
||||||
const inst = require(INSTALL);
|
const inst = require(INSTALL);
|
||||||
|
const { loadSkillsManifest, resolveProfile } = require(path.join(ROOT, 'get-shit-done', 'bin', 'lib', 'install-profiles.cjs'));
|
||||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2973-wipe-'));
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2973-wipe-'));
|
||||||
try {
|
try {
|
||||||
const skillsDir = path.join(tmp, 'skills');
|
const configDir = path.join(tmp, 'config');
|
||||||
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
|
|
||||||
|
// Set up a minimal source dir (plan-phase only; no dev-preferences).
|
||||||
|
const srcDir = path.join(tmp, 'src-commands');
|
||||||
|
fs.mkdirSync(srcDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(srcDir, 'plan-phase.md'), '---\nname: gsd:plan-phase\ndescription: Plan\n---\n\nPlan body.\n');
|
||||||
|
fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir + '\n');
|
||||||
|
|
||||||
|
const skillsDir = path.join(configDir, 'skills');
|
||||||
const userSkillDir = path.join(skillsDir, 'gsd-dev-preferences');
|
const userSkillDir = path.join(skillsDir, 'gsd-dev-preferences');
|
||||||
fs.mkdirSync(userSkillDir, { recursive: true });
|
fs.mkdirSync(userSkillDir, { recursive: true });
|
||||||
const userContent = '# my customized dev preferences\n\nstack: rust\n';
|
const userContent = '# my customized dev preferences\n\nstack: rust\n';
|
||||||
fs.writeFileSync(path.join(userSkillDir, 'SKILL.md'), userContent);
|
fs.writeFileSync(path.join(userSkillDir, 'SKILL.md'), userContent);
|
||||||
|
|
||||||
// Source dir mimicking commands/gsd/ — does NOT contain dev-preferences
|
// Plain install (matching production install() call site).
|
||||||
// because dev-preferences is user-generated, not shipped.
|
const manifest = loadSkillsManifest();
|
||||||
const srcDir = path.join(tmp, 'src-commands');
|
const resolvedProfile = resolveProfile({ modes: [], manifest });
|
||||||
fs.mkdirSync(srcDir, { recursive: true });
|
inst.installRuntimeArtifacts('claude', configDir, 'global', resolvedProfile);
|
||||||
fs.writeFileSync(path.join(srcDir, 'plan-phase.md'), '# plan-phase\n');
|
|
||||||
|
|
||||||
// Without the CR fix, the wipe loop deletes gsd-dev-preferences/
|
|
||||||
// and the user's content is lost (no source to restore from).
|
|
||||||
inst.copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.claude/', 'claude', true);
|
|
||||||
|
|
||||||
const skillFile = path.join(userSkillDir, 'SKILL.md');
|
const skillFile = path.join(userSkillDir, 'SKILL.md');
|
||||||
assert.equal(fs.existsSync(skillFile), true,
|
assert.equal(fs.existsSync(skillFile), true,
|
||||||
'gsd-dev-preferences/SKILL.md must survive the wipe (#3003 CR)');
|
'gsd-dev-preferences/SKILL.md must survive a plain install (#3003 CR)');
|
||||||
assert.equal(fs.readFileSync(skillFile, 'utf-8'), userContent,
|
assert.equal(fs.readFileSync(skillFile, 'utf-8'), userContent,
|
||||||
'user content must be byte-identical after the wipe-restore cycle');
|
'user content must be byte-identical after the install');
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(tmp, { recursive: true, force: true });
|
fs.rmSync(tmp, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('non-user-owned gsd-* skills are still wiped and recreated from source', () => {
|
test('non-user-owned gsd-* skills are wiped and recreated via uninstall+install cycle', () => {
|
||||||
// The existing wipe behavior must still work for skills the package
|
// Stale artifacts (e.g. STALE-MARKER.txt left from a previous version)
|
||||||
// owns. Otherwise the preservation list could grow stale by accident.
|
// are removed when the caller runs uninstallRuntimeArtifacts() before
|
||||||
|
// installRuntimeArtifacts() — the full uninstall+reinstall cycle.
|
||||||
|
// uninstallRuntimeArtifacts removes all gsd-* entries; installRuntimeArtifacts
|
||||||
|
// then writes fresh ones from source.
|
||||||
const inst = require(INSTALL);
|
const inst = require(INSTALL);
|
||||||
|
const { loadSkillsManifest, resolveProfile } = require(path.join(ROOT, 'get-shit-done', 'bin', 'lib', 'install-profiles.cjs'));
|
||||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2973-wipe-shipped-'));
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2973-wipe-shipped-'));
|
||||||
try {
|
try {
|
||||||
const skillsDir = path.join(tmp, 'skills');
|
const configDir = path.join(tmp, 'config');
|
||||||
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
|
|
||||||
|
const srcDir = path.join(tmp, 'src-commands');
|
||||||
|
fs.mkdirSync(srcDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(srcDir, 'plan-phase.md'), '---\nname: gsd:plan-phase\ndescription: Plan fresh\n---\n\nFresh body.\n');
|
||||||
|
fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir + '\n');
|
||||||
|
|
||||||
|
const skillsDir = path.join(configDir, 'skills');
|
||||||
const staleSkillDir = path.join(skillsDir, 'gsd-plan-phase');
|
const staleSkillDir = path.join(skillsDir, 'gsd-plan-phase');
|
||||||
fs.mkdirSync(staleSkillDir, { recursive: true });
|
fs.mkdirSync(staleSkillDir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(staleSkillDir, 'STALE-MARKER.txt'), 'wipe me');
|
fs.writeFileSync(path.join(staleSkillDir, 'STALE-MARKER.txt'), 'wipe me');
|
||||||
|
|
||||||
const srcDir = path.join(tmp, 'src-commands');
|
const manifest = loadSkillsManifest();
|
||||||
fs.mkdirSync(srcDir, { recursive: true });
|
const resolvedProfile = resolveProfile({ modes: [], manifest });
|
||||||
fs.writeFileSync(path.join(srcDir, 'plan-phase.md'), '# plan-phase fresh\n');
|
|
||||||
|
|
||||||
inst.copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.claude/', 'claude', true);
|
// Full uninstall+install cycle (e.g. --reinstall flow)
|
||||||
|
inst.uninstallRuntimeArtifacts('claude', configDir, 'global');
|
||||||
|
inst.installRuntimeArtifacts('claude', configDir, 'global', resolvedProfile);
|
||||||
|
|
||||||
assert.equal(fs.existsSync(path.join(staleSkillDir, 'STALE-MARKER.txt')), false,
|
assert.equal(fs.existsSync(path.join(staleSkillDir, 'STALE-MARKER.txt')), false,
|
||||||
'stale shipped-skill content must be wiped (preservation is opt-in by name)');
|
'stale shipped-skill content must be wiped by uninstall (preservation is opt-in by name)');
|
||||||
assert.equal(fs.existsSync(path.join(staleSkillDir, 'SKILL.md')), true,
|
assert.equal(fs.existsSync(path.join(staleSkillDir, 'SKILL.md')), true,
|
||||||
'fresh SKILL.md from source must be installed after wipe');
|
'fresh SKILL.md from source must be installed after wipe');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -19,12 +19,41 @@ const path = require('path');
|
|||||||
const os = require('os');
|
const os = require('os');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
convertClaudeCommandToClaudeSkill,
|
convertClaudeCommandToClaudeSkill,
|
||||||
copyCommandsAsClaudeSkills,
|
|
||||||
writeManifest,
|
writeManifest,
|
||||||
install,
|
install,
|
||||||
} = require('../bin/install.js');
|
installRuntimeArtifacts,
|
||||||
|
uninstallRuntimeArtifacts,
|
||||||
|
} = require(path.join(ROOT, 'bin', 'install.js'));
|
||||||
|
|
||||||
|
const {
|
||||||
|
loadSkillsManifest,
|
||||||
|
resolveProfile,
|
||||||
|
} = require(path.join(ROOT, 'get-shit-done', 'bin', 'lib', 'install-profiles.cjs'));
|
||||||
|
|
||||||
|
// Shared resolved profile (full — installs all skills from srcDir)
|
||||||
|
const _manifest = loadSkillsManifest();
|
||||||
|
const resolvedProfileFull = resolveProfile({ modes: [], manifest: _manifest });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up a configDir backed by a custom srcDir via .gsd-source marker.
|
||||||
|
* Returns { configDir, srcDir } both under tmpDir.
|
||||||
|
*/
|
||||||
|
function setupConfigDir(tmpDir, commandFiles) {
|
||||||
|
const srcDir = path.join(tmpDir, 'commands', 'gsd');
|
||||||
|
fs.mkdirSync(srcDir, { recursive: true });
|
||||||
|
for (const [name, content] of Object.entries(commandFiles)) {
|
||||||
|
fs.writeFileSync(path.join(srcDir, name), content);
|
||||||
|
}
|
||||||
|
const configDir = path.join(tmpDir, 'config');
|
||||||
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
|
// .gsd-source marker tells findInstallSourceRoot to use our custom srcDir
|
||||||
|
fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir + '\n');
|
||||||
|
return { configDir, srcDir };
|
||||||
|
}
|
||||||
|
|
||||||
// ─── convertClaudeCommandToClaudeSkill ──────────────────────────────────────
|
// ─── convertClaudeCommandToClaudeSkill ──────────────────────────────────────
|
||||||
|
|
||||||
@@ -162,9 +191,9 @@ describe('convertClaudeCommandToClaudeSkill', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── copyCommandsAsClaudeSkills ─────────────────────────────────────────────
|
// ─── installRuntimeArtifacts (claude global) — skill layout ─────────────────
|
||||||
|
|
||||||
describe('copyCommandsAsClaudeSkills', () => {
|
describe('installRuntimeArtifacts (claude global) — skill layout', () => {
|
||||||
let tmpDir;
|
let tmpDir;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -176,22 +205,14 @@ describe('copyCommandsAsClaudeSkills', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('creates correct directory structure skills/gsd-xxx/SKILL.md', () => {
|
test('creates correct directory structure skills/gsd-xxx/SKILL.md', () => {
|
||||||
// Create source commands
|
const { configDir } = setupConfigDir(tmpDir, {
|
||||||
const srcDir = path.join(tmpDir, 'src');
|
'next.md': '---\nname: gsd:next\ndescription: Advance\nallowed-tools:\n - Read\n---\n\nBody.',
|
||||||
fs.mkdirSync(srcDir, { recursive: true });
|
'health.md': '---\nname: gsd:health\ndescription: Check health\n---\n\nHealth body.',
|
||||||
fs.writeFileSync(
|
});
|
||||||
path.join(srcDir, 'next.md'),
|
|
||||||
'---\nname: gsd:next\ndescription: Advance\nallowed-tools:\n - Read\n---\n\nBody.'
|
|
||||||
);
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(srcDir, 'health.md'),
|
|
||||||
'---\nname: gsd:health\ndescription: Check health\n---\n\nHealth body.'
|
|
||||||
);
|
|
||||||
|
|
||||||
const skillsDir = path.join(tmpDir, 'skills');
|
installRuntimeArtifacts('claude', configDir, 'global', resolvedProfileFull);
|
||||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.claude/', 'claude', true);
|
|
||||||
|
|
||||||
// Verify directory structure
|
const skillsDir = path.join(configDir, 'skills');
|
||||||
assert.ok(
|
assert.ok(
|
||||||
fs.existsSync(path.join(skillsDir, 'gsd-next', 'SKILL.md')),
|
fs.existsSync(path.join(skillsDir, 'gsd-next', 'SKILL.md')),
|
||||||
'skills/gsd-next/SKILL.md exists'
|
'skills/gsd-next/SKILL.md exists'
|
||||||
@@ -202,28 +223,27 @@ describe('copyCommandsAsClaudeSkills', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cleans up old skills before installing new ones', () => {
|
test('cleans up old skills before installing new ones (uninstall+install cycle)', () => {
|
||||||
const srcDir = path.join(tmpDir, 'src');
|
const { configDir } = setupConfigDir(tmpDir, {
|
||||||
fs.mkdirSync(srcDir, { recursive: true });
|
'next.md': '---\nname: gsd:next\ndescription: Advance\n---\n\nBody.',
|
||||||
fs.writeFileSync(
|
});
|
||||||
path.join(srcDir, 'next.md'),
|
|
||||||
'---\nname: gsd:next\ndescription: Advance\n---\n\nBody.'
|
|
||||||
);
|
|
||||||
|
|
||||||
const skillsDir = path.join(tmpDir, 'skills');
|
const skillsDir = path.join(configDir, 'skills');
|
||||||
// Create a stale skill that should be removed
|
// Create a stale skill that should be removed by the uninstall step
|
||||||
const staleDir = path.join(skillsDir, 'gsd-old-command');
|
const staleDir = path.join(skillsDir, 'gsd-old-command');
|
||||||
fs.mkdirSync(staleDir, { recursive: true });
|
fs.mkdirSync(staleDir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(staleDir, 'SKILL.md'), 'stale content');
|
fs.writeFileSync(path.join(staleDir, 'SKILL.md'), 'stale content');
|
||||||
|
|
||||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.claude/', 'claude', true);
|
// Production sequence: uninstall wipes gsd-* entries, install writes fresh ones
|
||||||
|
uninstallRuntimeArtifacts('claude', configDir, 'global');
|
||||||
|
installRuntimeArtifacts('claude', configDir, 'global', resolvedProfileFull);
|
||||||
|
|
||||||
// Stale skill removed
|
// Stale skill removed by uninstall
|
||||||
assert.ok(
|
assert.ok(
|
||||||
!fs.existsSync(staleDir),
|
!fs.existsSync(staleDir),
|
||||||
'stale skill directory removed'
|
'stale skill directory removed'
|
||||||
);
|
);
|
||||||
// New skill created
|
// New skill created by install
|
||||||
assert.ok(
|
assert.ok(
|
||||||
fs.existsSync(path.join(skillsDir, 'gsd-next', 'SKILL.md')),
|
fs.existsSync(path.join(skillsDir, 'gsd-next', 'SKILL.md')),
|
||||||
'new skill created'
|
'new skill created'
|
||||||
@@ -231,64 +251,63 @@ describe('copyCommandsAsClaudeSkills', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('does not remove non-GSD skills', () => {
|
test('does not remove non-GSD skills', () => {
|
||||||
const srcDir = path.join(tmpDir, 'src');
|
const { configDir } = setupConfigDir(tmpDir, {
|
||||||
fs.mkdirSync(srcDir, { recursive: true });
|
'next.md': '---\nname: gsd:next\ndescription: Advance\n---\n\nBody.',
|
||||||
fs.writeFileSync(
|
});
|
||||||
path.join(srcDir, 'next.md'),
|
|
||||||
'---\nname: gsd:next\ndescription: Advance\n---\n\nBody.'
|
|
||||||
);
|
|
||||||
|
|
||||||
const skillsDir = path.join(tmpDir, 'skills');
|
const skillsDir = path.join(configDir, 'skills');
|
||||||
// Create a non-GSD skill
|
// Create a non-GSD skill before install
|
||||||
const otherDir = path.join(skillsDir, 'my-custom-skill');
|
const otherDir = path.join(skillsDir, 'my-custom-skill');
|
||||||
fs.mkdirSync(otherDir, { recursive: true });
|
fs.mkdirSync(otherDir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(otherDir, 'SKILL.md'), 'custom content');
|
fs.writeFileSync(path.join(otherDir, 'SKILL.md'), 'custom content');
|
||||||
|
|
||||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.claude/', 'claude', true);
|
// Install (no pre-uninstall: uninstall only removes gsd-* prefixed entries)
|
||||||
|
installRuntimeArtifacts('claude', configDir, 'global', resolvedProfileFull);
|
||||||
|
|
||||||
// Non-GSD skill preserved
|
// Non-GSD skill preserved
|
||||||
assert.ok(
|
assert.ok(
|
||||||
fs.existsSync(otherDir),
|
fs.existsSync(otherDir),
|
||||||
'non-GSD skill preserved'
|
'non-GSD skill preserved after install'
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handles recursive subdirectories', () => {
|
|
||||||
const srcDir = path.join(tmpDir, 'src');
|
|
||||||
const subDir = path.join(srcDir, 'wired');
|
|
||||||
fs.mkdirSync(subDir, { recursive: true });
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(subDir, 'ready.md'),
|
|
||||||
'---\nname: gsd-wired:ready\ndescription: Show ready tasks\n---\n\nBody.'
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const skillsDir = path.join(tmpDir, 'skills');
|
// Also survives uninstall (uninstall only removes gsd-* prefixed entries)
|
||||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.claude/', 'claude', true);
|
uninstallRuntimeArtifacts('claude', configDir, 'global');
|
||||||
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
fs.existsSync(path.join(skillsDir, 'gsd-wired-ready', 'SKILL.md')),
|
fs.existsSync(otherDir),
|
||||||
'nested command creates gsd-wired-ready/SKILL.md'
|
'non-GSD skill preserved after uninstall'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('no-ops when source directory does not exist', () => {
|
// NOTE: Recursive subdirectory support was removed when the shim was replaced.
|
||||||
const skillsDir = path.join(tmpDir, 'skills');
|
// stageSkillsForRuntimeAsSkills only processes top-level .md files.
|
||||||
|
// No subdirectories exist under commands/gsd/ in production.
|
||||||
|
// The subdir-recursion test has been deleted (option a per #3664 brief).
|
||||||
|
|
||||||
|
test('no-ops on install when source directory has no .md files', () => {
|
||||||
|
// Create an empty (but existing) commands/gsd dir with no .md files.
|
||||||
|
// stageSkillsForRuntimeAsSkills loops over entries, finds none, and stages
|
||||||
|
// an empty dir — _copyStaged then copies nothing into skills/.
|
||||||
|
const emptySrc = path.join(tmpDir, 'empty-commands', 'gsd');
|
||||||
|
fs.mkdirSync(emptySrc, { recursive: true });
|
||||||
|
|
||||||
|
const configDir = path.join(tmpDir, 'config-empty');
|
||||||
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(configDir, '.gsd-source'), emptySrc + '\n');
|
||||||
|
|
||||||
// Should not throw
|
// Should not throw
|
||||||
copyCommandsAsClaudeSkills(
|
installRuntimeArtifacts('claude', configDir, 'global', resolvedProfileFull);
|
||||||
path.join(tmpDir, 'nonexistent'),
|
const skillsDir = path.join(configDir, 'skills');
|
||||||
skillsDir,
|
// If skills dir was created it must contain no gsd-* entries
|
||||||
'gsd',
|
if (fs.existsSync(skillsDir)) {
|
||||||
'$HOME/.claude/',
|
const gsdEntries = fs.readdirSync(skillsDir).filter(n => n.startsWith('gsd-'));
|
||||||
'claude',
|
assert.strictEqual(gsdEntries.length, 0, 'no gsd-* skills created when src has no .md files');
|
||||||
true
|
}
|
||||||
);
|
|
||||||
assert.ok(!fs.existsSync(skillsDir), 'skills dir not created when src missing');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Path replacement in Claude skills (#1653) ────────────────────────────────
|
// ─── Path replacement in Claude skills (#1653) ────────────────────────────────
|
||||||
|
|
||||||
describe('copyCommandsAsClaudeSkills path replacement (#1653)', () => {
|
describe('installRuntimeArtifacts path replacement in Claude global skills (#1653)', () => {
|
||||||
let tmpDir;
|
let tmpDir;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -299,12 +318,13 @@ describe('copyCommandsAsClaudeSkills path replacement (#1653)', () => {
|
|||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('replaces ~/.claude/ paths with pathPrefix on local install', () => {
|
test('replaces ~/.claude/ and $HOME/.claude/ paths with absolute configDir prefix on global install', () => {
|
||||||
const srcDir = path.join(tmpDir, 'src');
|
// Global install: configDir IS the runtime config directory.
|
||||||
fs.mkdirSync(srcDir, { recursive: true });
|
// computePathPrefix(isGlobal=true) → resolvedTarget + '/'.
|
||||||
fs.writeFileSync(
|
// applyRuntimeContentRewritesInPlace rewrites ~/.claude/ and $HOME/.claude/
|
||||||
path.join(srcDir, 'manager.md'),
|
// to the absolute configDir path so skills work from any machine.
|
||||||
[
|
const { configDir } = setupConfigDir(tmpDir, {
|
||||||
|
'manager.md': [
|
||||||
'---',
|
'---',
|
||||||
'name: gsd:manager',
|
'name: gsd:manager',
|
||||||
'description: Manager command',
|
'description: Manager command',
|
||||||
@@ -312,52 +332,66 @@ describe('copyCommandsAsClaudeSkills path replacement (#1653)', () => {
|
|||||||
'',
|
'',
|
||||||
'<execution_context>',
|
'<execution_context>',
|
||||||
'@~/.claude/get-shit-done/workflows/manager.md',
|
'@~/.claude/get-shit-done/workflows/manager.md',
|
||||||
'@~/.claude/get-shit-done/references/ui-brand.md',
|
'@$HOME/.claude/get-shit-done/references/ui-brand.md',
|
||||||
'</execution_context>',
|
'</execution_context>',
|
||||||
].join('\n')
|
].join('\n'),
|
||||||
|
});
|
||||||
|
|
||||||
|
installRuntimeArtifacts('claude', configDir, 'global', resolvedProfileFull);
|
||||||
|
|
||||||
|
const content = fs.readFileSync(
|
||||||
|
path.join(configDir, 'skills', 'gsd-manager', 'SKILL.md'), 'utf8'
|
||||||
);
|
);
|
||||||
|
|
||||||
const skillsDir = path.join(tmpDir, 'skills');
|
|
||||||
const localPrefix = '/Users/test/myproject/.claude/';
|
|
||||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', localPrefix, 'claude', false);
|
|
||||||
|
|
||||||
const content = fs.readFileSync(path.join(skillsDir, 'gsd-manager', 'SKILL.md'), 'utf8');
|
|
||||||
assert.ok(!content.includes('~/.claude/'), 'no hardcoded ~/.claude/ paths remain');
|
assert.ok(!content.includes('~/.claude/'), 'no hardcoded ~/.claude/ paths remain');
|
||||||
assert.ok(content.includes(localPrefix + 'get-shit-done/workflows/manager.md'), 'path rewritten to local prefix');
|
|
||||||
assert.ok(content.includes(localPrefix + 'get-shit-done/references/ui-brand.md'), 'reference path rewritten');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('replaces $HOME/.claude/ paths with pathPrefix', () => {
|
|
||||||
const srcDir = path.join(tmpDir, 'src');
|
|
||||||
fs.mkdirSync(srcDir, { recursive: true });
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(srcDir, 'debug.md'),
|
|
||||||
'---\nname: gsd:debug\ndescription: Debug\n---\n\n@$HOME/.claude/get-shit-done/workflows/debug.md'
|
|
||||||
);
|
|
||||||
|
|
||||||
const skillsDir = path.join(tmpDir, 'skills');
|
|
||||||
const localPrefix = '/tmp/project/.claude/';
|
|
||||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', localPrefix, 'claude', false);
|
|
||||||
|
|
||||||
const content = fs.readFileSync(path.join(skillsDir, 'gsd-debug', 'SKILL.md'), 'utf8');
|
|
||||||
assert.ok(!content.includes('$HOME/.claude/'), 'no $HOME/.claude/ paths remain');
|
assert.ok(!content.includes('$HOME/.claude/'), 'no $HOME/.claude/ paths remain');
|
||||||
assert.ok(content.includes(localPrefix + 'get-shit-done/workflows/debug.md'), 'path rewritten');
|
// Paths are rewritten to the absolute configDir prefix
|
||||||
|
const expectedPrefix = path.resolve(configDir).replace(/\\/g, '/') + '/';
|
||||||
|
assert.ok(
|
||||||
|
content.includes(expectedPrefix + 'get-shit-done/workflows/manager.md'),
|
||||||
|
'tilde path rewritten to absolute configDir prefix'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
content.includes(expectedPrefix + 'get-shit-done/references/ui-brand.md'),
|
||||||
|
'HOME path rewritten to absolute configDir prefix'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('global install preserves $HOME/.claude/ when pathPrefix matches', () => {
|
test('replaces $HOME/.claude/ paths with absolute configDir prefix on global install', () => {
|
||||||
const srcDir = path.join(tmpDir, 'src');
|
const { configDir } = setupConfigDir(tmpDir, {
|
||||||
fs.mkdirSync(srcDir, { recursive: true });
|
'debug.md': '---\nname: gsd:debug\ndescription: Debug\n---\n\n@$HOME/.claude/get-shit-done/workflows/debug.md',
|
||||||
fs.writeFileSync(
|
});
|
||||||
path.join(srcDir, 'next.md'),
|
|
||||||
'---\nname: gsd:next\ndescription: Next\n---\n\n@~/.claude/get-shit-done/workflows/next.md'
|
installRuntimeArtifacts('claude', configDir, 'global', resolvedProfileFull);
|
||||||
|
|
||||||
|
const content = fs.readFileSync(
|
||||||
|
path.join(configDir, 'skills', 'gsd-debug', 'SKILL.md'), 'utf8'
|
||||||
);
|
);
|
||||||
|
assert.ok(!content.includes('$HOME/.claude/'), 'no $HOME/.claude/ paths remain');
|
||||||
|
const expectedPrefix = path.resolve(configDir).replace(/\\/g, '/') + '/';
|
||||||
|
assert.ok(
|
||||||
|
content.includes(expectedPrefix + 'get-shit-done/workflows/debug.md'),
|
||||||
|
'path rewritten to absolute configDir prefix'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const skillsDir = path.join(tmpDir, 'skills');
|
test('global install rewrites ~/.claude/ paths to absolute configDir form', () => {
|
||||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.claude/', 'claude', true);
|
// For global installs, computePathPrefix returns the absolute configDir path.
|
||||||
|
// Both ~/.claude/ and $HOME/.claude/ are normalized to the same absolute prefix.
|
||||||
|
const { configDir } = setupConfigDir(tmpDir, {
|
||||||
|
'next.md': '---\nname: gsd:next\ndescription: Next\n---\n\n@~/.claude/get-shit-done/workflows/next.md',
|
||||||
|
});
|
||||||
|
|
||||||
const content = fs.readFileSync(path.join(skillsDir, 'gsd-next', 'SKILL.md'), 'utf8');
|
installRuntimeArtifacts('claude', configDir, 'global', resolvedProfileFull);
|
||||||
assert.ok(content.includes('$HOME/.claude/get-shit-done/workflows/next.md'), 'global paths use $HOME form');
|
|
||||||
assert.ok(!content.includes('~/.claude/'), '~/ form replaced with $HOME/ form');
|
const content = fs.readFileSync(
|
||||||
|
path.join(configDir, 'skills', 'gsd-next', 'SKILL.md'), 'utf8'
|
||||||
|
);
|
||||||
|
const expectedPrefix = path.resolve(configDir).replace(/\\/g, '/') + '/';
|
||||||
|
assert.ok(
|
||||||
|
content.includes(expectedPrefix + 'get-shit-done/workflows/next.md'),
|
||||||
|
'global tilde path rewritten to absolute configDir prefix'
|
||||||
|
);
|
||||||
|
assert.ok(!content.includes('~/.claude/'), 'tilde form is replaced');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -375,31 +409,22 @@ describe('Legacy commands/gsd/ cleanup', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('install removes legacy commands/gsd/ directory when present', () => {
|
test('install removes legacy commands/gsd/ directory when present', () => {
|
||||||
// Create a mock legacy commands/gsd/ directory
|
const { configDir } = setupConfigDir(tmpDir, {
|
||||||
const legacyDir = path.join(tmpDir, 'commands', 'gsd');
|
'next.md': '---\nname: gsd:next\ndescription: Advance\n---\n\nBody.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a mock legacy commands/gsd/ directory inside configDir
|
||||||
|
const legacyDir = path.join(configDir, 'commands', 'gsd');
|
||||||
fs.mkdirSync(legacyDir, { recursive: true });
|
fs.mkdirSync(legacyDir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(legacyDir, 'next.md'), 'legacy content');
|
fs.writeFileSync(path.join(legacyDir, 'next.md'), 'legacy content');
|
||||||
|
|
||||||
// Create source commands for the installer to read
|
// installRuntimeArtifacts calls _runLegacyInstallMigrations which removes
|
||||||
const srcDir = path.join(tmpDir, 'src');
|
// commands/gsd/ for claude runtime (it's the legacy location for global).
|
||||||
fs.mkdirSync(srcDir, { recursive: true });
|
installRuntimeArtifacts('claude', configDir, 'global', resolvedProfileFull);
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(srcDir, 'next.md'),
|
|
||||||
'---\nname: gsd:next\ndescription: Advance\n---\n\nBody.'
|
|
||||||
);
|
|
||||||
|
|
||||||
const skillsDir = path.join(tmpDir, 'skills');
|
|
||||||
// Install skills
|
|
||||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.claude/', 'claude', true);
|
|
||||||
|
|
||||||
// Simulate the legacy cleanup that install() does after copyCommandsAsClaudeSkills
|
|
||||||
if (fs.existsSync(legacyDir)) {
|
|
||||||
fs.rmSync(legacyDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.ok(!fs.existsSync(legacyDir), 'legacy commands/gsd/ removed');
|
assert.ok(!fs.existsSync(legacyDir), 'legacy commands/gsd/ removed');
|
||||||
assert.ok(
|
assert.ok(
|
||||||
fs.existsSync(path.join(skillsDir, 'gsd-next', 'SKILL.md')),
|
fs.existsSync(path.join(configDir, 'skills', 'gsd-next', 'SKILL.md')),
|
||||||
'new skill installed'
|
'new skill installed'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -461,7 +486,7 @@ describe('Claude skills migration exports', () => {
|
|||||||
assert.strictEqual(typeof convertClaudeCommandToClaudeSkill, 'function');
|
assert.strictEqual(typeof convertClaudeCommandToClaudeSkill, 'function');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('copyCommandsAsClaudeSkills is exported', () => {
|
test('installRuntimeArtifacts is exported', () => {
|
||||||
assert.strictEqual(typeof copyCommandsAsClaudeSkills, 'function');
|
assert.strictEqual(typeof installRuntimeArtifacts, 'function');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,12 +19,18 @@ const {
|
|||||||
convertClaudeToCodebuddyMarkdown,
|
convertClaudeToCodebuddyMarkdown,
|
||||||
convertClaudeCommandToCodebuddySkill,
|
convertClaudeCommandToCodebuddySkill,
|
||||||
convertClaudeAgentToCodebuddyAgent,
|
convertClaudeAgentToCodebuddyAgent,
|
||||||
copyCommandsAsCodebuddySkills,
|
|
||||||
install,
|
install,
|
||||||
uninstall,
|
uninstall,
|
||||||
writeManifest,
|
writeManifest,
|
||||||
|
installRuntimeArtifacts,
|
||||||
} = require('../bin/install.js');
|
} = 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 });
|
||||||
|
|
||||||
describe('CodeBuddy runtime directory mapping', () => {
|
describe('CodeBuddy runtime directory mapping', () => {
|
||||||
test('maps CodeBuddy to .codebuddy for local installs', () => {
|
test('maps CodeBuddy to .codebuddy for local installs', () => {
|
||||||
assert.strictEqual(getDirName('codebuddy'), '.codebuddy');
|
assert.strictEqual(getDirName('codebuddy'), '.codebuddy');
|
||||||
@@ -133,24 +139,24 @@ Read CLAUDE.md before acting.
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('copyCommandsAsCodebuddySkills', () => {
|
describe('installRuntimeArtifacts (codebuddy integration)', () => {
|
||||||
let tmpDir;
|
// Pivoted from copyCommandsAsCodebuddySkills(srcDir, skillsDir, 'gsd', '$HOME/.codebuddy/', 'codebuddy')
|
||||||
|
// shim to installRuntimeArtifacts('codebuddy', configDir, 'local', resolvedProfileFull).
|
||||||
|
// Output layout: <configDir>/skills/gsd-<stem>/SKILL.md (destSubpath='skills', prefix='gsd-').
|
||||||
|
let configDir;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tmpDir = createTempDir('gsd-codebuddy-copy-');
|
configDir = createTempDir('gsd-codebuddy-copy-');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup(tmpDir);
|
cleanup(configDir);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('creates one skill directory per GSD command', () => {
|
test('creates one skill directory per GSD command', () => {
|
||||||
const srcDir = path.join(__dirname, '..', 'commands', 'gsd');
|
installRuntimeArtifacts('codebuddy', configDir, 'local', resolvedProfileFull);
|
||||||
const skillsDir = path.join(tmpDir, '.codebuddy', 'skills');
|
|
||||||
|
|
||||||
copyCommandsAsCodebuddySkills(srcDir, skillsDir, 'gsd', '$HOME/.codebuddy/', 'codebuddy');
|
const generated = path.join(configDir, 'skills', 'gsd-help', 'SKILL.md');
|
||||||
|
|
||||||
const generated = path.join(skillsDir, 'gsd-help', 'SKILL.md');
|
|
||||||
assert.ok(fs.existsSync(generated), generated);
|
assert.ok(fs.existsSync(generated), generated);
|
||||||
|
|
||||||
const content = fs.readFileSync(generated, 'utf8');
|
const content = fs.readFileSync(generated, 'utf8');
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const assert = require('node:assert/strict');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { parseFrontmatter } = require('./helpers.cjs');
|
const { parseFrontmatter, createTempDir, cleanup } = require('./helpers.cjs');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
getDirName,
|
getDirName,
|
||||||
@@ -35,15 +35,21 @@ const {
|
|||||||
convertClaudeToCopilotContent,
|
convertClaudeToCopilotContent,
|
||||||
convertClaudeCommandToCopilotSkill,
|
convertClaudeCommandToCopilotSkill,
|
||||||
convertClaudeAgentToCopilotAgent,
|
convertClaudeAgentToCopilotAgent,
|
||||||
copyCommandsAsCopilotSkills,
|
|
||||||
GSD_COPILOT_INSTRUCTIONS_MARKER,
|
GSD_COPILOT_INSTRUCTIONS_MARKER,
|
||||||
GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER,
|
GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER,
|
||||||
mergeCopilotInstructions,
|
mergeCopilotInstructions,
|
||||||
stripGsdFromCopilotInstructions,
|
stripGsdFromCopilotInstructions,
|
||||||
writeManifest,
|
writeManifest,
|
||||||
reportLocalPatches,
|
reportLocalPatches,
|
||||||
|
installRuntimeArtifacts,
|
||||||
} = require('../bin/install.js');
|
} = 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 ─────────────────────────────────────────────────────────────────
|
// ─── getDirName ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('getDirName (Copilot)', () => {
|
describe('getDirName (Copilot)', () => {
|
||||||
@@ -610,41 +616,46 @@ Check ~/.claude/settings and run gsd:health.`;
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── copyCommandsAsCopilotSkills (integration) ─────────────────────────────────
|
// ─── installRuntimeArtifacts (copilot integration) ─────────────────────────────
|
||||||
|
|
||||||
describe('copyCommandsAsCopilotSkills', () => {
|
describe('installRuntimeArtifacts (copilot integration)', () => {
|
||||||
|
// Pivoted from copyCommandsAsCopilotSkills(srcDir, tempDir, 'gsd') shim to
|
||||||
|
// installRuntimeArtifacts('copilot', configDir, 'global', resolvedProfileFull).
|
||||||
|
// Output layout: <configDir>/skills/gsd-<stem>/SKILL.md (destSubpath='skills', prefix='gsd-').
|
||||||
const srcDir = path.join(__dirname, '..', 'commands', 'gsd');
|
const srcDir = path.join(__dirname, '..', 'commands', 'gsd');
|
||||||
let tempDir;
|
let configDir;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-copilot-skills-'));
|
configDir = createTempDir('gsd-copilot-skills-');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
cleanup(configDir);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('creates skill folders from source commands', () => {
|
test('creates skill folders from source commands', () => {
|
||||||
copyCommandsAsCopilotSkills(srcDir, tempDir, 'gsd');
|
installRuntimeArtifacts('copilot', configDir, 'global', resolvedProfileFull);
|
||||||
|
|
||||||
|
const skillsDir = path.join(configDir, 'skills');
|
||||||
// Check specific folders exist
|
// Check specific folders exist
|
||||||
assert.ok(fs.existsSync(path.join(tempDir, 'gsd-health')), 'gsd-health folder exists');
|
assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-health')), 'gsd-health folder exists');
|
||||||
assert.ok(fs.existsSync(path.join(tempDir, 'gsd-health', 'SKILL.md')), 'gsd-health/SKILL.md exists');
|
assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-health', 'SKILL.md')), 'gsd-health/SKILL.md exists');
|
||||||
assert.ok(fs.existsSync(path.join(tempDir, 'gsd-help')), 'gsd-help folder exists');
|
assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-help')), 'gsd-help folder exists');
|
||||||
assert.ok(fs.existsSync(path.join(tempDir, 'gsd-progress')), 'gsd-progress folder exists');
|
assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-progress')), 'gsd-progress folder exists');
|
||||||
|
|
||||||
// Count gsd-* directories — should match number of source command files
|
// Count gsd-* directories — should match number of source command files
|
||||||
const dirs = fs.readdirSync(tempDir, { withFileTypes: true })
|
const dirs = fs.readdirSync(skillsDir, { withFileTypes: true })
|
||||||
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
|
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
|
||||||
const expectedSkillCount = fs.readdirSync(path.join(__dirname, '..', 'commands', 'gsd'))
|
const expectedSkillCount = fs.readdirSync(srcDir)
|
||||||
.filter(f => f.endsWith('.md')).length;
|
.filter(f => f.endsWith('.md')).length;
|
||||||
assert.strictEqual(dirs.length, expectedSkillCount, `expected ${expectedSkillCount} skill folders, got ${dirs.length}`);
|
assert.strictEqual(dirs.length, expectedSkillCount, `expected ${expectedSkillCount} skill folders, got ${dirs.length}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('skill content has Copilot frontmatter format', () => {
|
test('skill content has Copilot frontmatter format', () => {
|
||||||
copyCommandsAsCopilotSkills(srcDir, tempDir, 'gsd');
|
installRuntimeArtifacts('copilot', configDir, 'global', resolvedProfileFull);
|
||||||
|
|
||||||
const skillContent = fs.readFileSync(path.join(tempDir, 'gsd-health', 'SKILL.md'), 'utf8');
|
const skillsDir = path.join(configDir, 'skills');
|
||||||
|
const skillContent = fs.readFileSync(path.join(skillsDir, 'gsd-health', 'SKILL.md'), 'utf8');
|
||||||
// Frontmatter format checks
|
// Frontmatter format checks
|
||||||
assert.ok(skillContent.startsWith('---\nname: gsd-health\n'), 'starts with name: gsd-health');
|
assert.ok(skillContent.startsWith('---\nname: gsd-health\n'), 'starts with name: gsd-health');
|
||||||
assert.ok(skillContent.includes('allowed-tools: Read, Bash, Write, AskUserQuestion'),
|
assert.ok(skillContent.includes('allowed-tools: Read, Bash, Write, AskUserQuestion'),
|
||||||
@@ -660,13 +671,14 @@ describe('copyCommandsAsCopilotSkills', () => {
|
|||||||
const srcFile = path.join(srcDir, 'autonomous.md');
|
const srcFile = path.join(srcDir, 'autonomous.md');
|
||||||
assert.ok(fs.existsSync(srcFile), 'commands/gsd/autonomous.md must exist as source');
|
assert.ok(fs.existsSync(srcFile), 'commands/gsd/autonomous.md must exist as source');
|
||||||
|
|
||||||
copyCommandsAsCopilotSkills(srcDir, tempDir, 'gsd');
|
installRuntimeArtifacts('copilot', configDir, 'global', resolvedProfileFull);
|
||||||
|
|
||||||
|
const skillsDir = path.join(configDir, 'skills');
|
||||||
// Skill folder and file created
|
// Skill folder and file created
|
||||||
assert.ok(fs.existsSync(path.join(tempDir, 'gsd-autonomous')), 'gsd-autonomous folder exists');
|
assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-autonomous')), 'gsd-autonomous folder exists');
|
||||||
assert.ok(fs.existsSync(path.join(tempDir, 'gsd-autonomous', 'SKILL.md')), 'gsd-autonomous/SKILL.md exists');
|
assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-autonomous', 'SKILL.md')), 'gsd-autonomous/SKILL.md exists');
|
||||||
|
|
||||||
const skillContent = fs.readFileSync(path.join(tempDir, 'gsd-autonomous', 'SKILL.md'), 'utf8');
|
const skillContent = fs.readFileSync(path.join(skillsDir, 'gsd-autonomous', 'SKILL.md'), 'utf8');
|
||||||
const fm = parseFrontmatter(skillContent);
|
const fm = parseFrontmatter(skillContent);
|
||||||
|
|
||||||
// Frontmatter: name converted from gsd:autonomous to gsd-autonomous
|
// Frontmatter: name converted from gsd:autonomous to gsd-autonomous
|
||||||
@@ -704,16 +716,26 @@ describe('copyCommandsAsCopilotSkills', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('cleans up old skill directories on re-run', () => {
|
test('cleans up old skill directories on re-run', () => {
|
||||||
// Create a fake old directory
|
const skillsDir = path.join(configDir, 'skills');
|
||||||
fs.mkdirSync(path.join(tempDir, 'gsd-fake-old'), { recursive: true });
|
fs.mkdirSync(skillsDir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(tempDir, 'gsd-fake-old', 'SKILL.md'), 'old');
|
|
||||||
assert.ok(fs.existsSync(path.join(tempDir, 'gsd-fake-old')), 'fake old dir exists before');
|
|
||||||
|
|
||||||
// Run copy — should clean up old dirs
|
// Stale GSD-managed dir must be pruned
|
||||||
copyCommandsAsCopilotSkills(srcDir, tempDir, 'gsd');
|
const staleDir = path.join(skillsDir, 'gsd-old-stale-skill');
|
||||||
|
fs.mkdirSync(staleDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(staleDir, 'SKILL.md'), 'stale content');
|
||||||
|
|
||||||
assert.ok(!fs.existsSync(path.join(tempDir, 'gsd-fake-old')), 'fake old dir removed');
|
// Non-GSD dir should survive (installRuntimeArtifacts never prunes non-gsd-*)
|
||||||
assert.ok(fs.existsSync(path.join(tempDir, 'gsd-health')), 'real dirs still exist');
|
fs.mkdirSync(path.join(skillsDir, 'user-custom'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(skillsDir, 'user-custom', 'SKILL.md'), 'user content');
|
||||||
|
|
||||||
|
installRuntimeArtifacts('copilot', configDir, 'global', resolvedProfileFull);
|
||||||
|
|
||||||
|
// Real skills are present after install
|
||||||
|
assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-health')), 'real dirs still exist');
|
||||||
|
// Stale GSD-prefixed dir is removed by pre-prune
|
||||||
|
assert.ok(!fs.existsSync(staleDir), 'stale gsd-* dir removed by pre-prune');
|
||||||
|
// Non-GSD dir is preserved
|
||||||
|
assert.ok(fs.existsSync(path.join(skillsDir, 'user-custom')), 'non-GSD dir preserved');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -109,18 +109,20 @@ describe('Hermes Agent local install/uninstall', () => {
|
|||||||
|
|
||||||
// Nested layout per spec #2841: all GSD skills collapse into a single
|
// 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/gsd/ category so Hermes' system prompt sees one entry, not 86.
|
||||||
assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'gsd-help', 'SKILL.md')));
|
// 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')),
|
assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'DESCRIPTION.md')),
|
||||||
'DESCRIPTION.md exists at category root');
|
'DESCRIPTION.md exists at category root');
|
||||||
assert.ok(fs.existsSync(path.join(targetDir, 'get-shit-done', 'VERSION')));
|
assert.ok(fs.existsSync(path.join(targetDir, 'get-shit-done', 'VERSION')));
|
||||||
assert.ok(fs.existsSync(path.join(targetDir, 'agents')));
|
assert.ok(fs.existsSync(path.join(targetDir, 'agents')));
|
||||||
|
|
||||||
const manifest = writeManifest(targetDir, 'hermes');
|
const manifest = writeManifest(targetDir, 'hermes');
|
||||||
assert.ok(Object.keys(manifest.files).some(file => file.startsWith('skills/gsd/gsd-help/')), manifest);
|
assert.ok(Object.keys(manifest.files).some(file => file.startsWith('skills/gsd/help/')), JSON.stringify(manifest.files));
|
||||||
|
|
||||||
uninstall(false, 'hermes');
|
uninstall(false, 'hermes');
|
||||||
|
|
||||||
assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'gsd-help')), 'Hermes skill directory removed');
|
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, 'skills', 'gsd')), 'Hermes gsd category dir removed');
|
||||||
assert.ok(!fs.existsSync(path.join(targetDir, 'get-shit-done')), 'get-shit-done removed');
|
assert.ok(!fs.existsSync(path.join(targetDir, 'get-shit-done')), 'get-shit-done removed');
|
||||||
});
|
});
|
||||||
@@ -128,13 +130,15 @@ describe('Hermes Agent local install/uninstall', () => {
|
|||||||
test('installed SKILL.md frontmatter conforms to Hermes spec', () => {
|
test('installed SKILL.md frontmatter conforms to Hermes spec', () => {
|
||||||
install(false, 'hermes');
|
install(false, 'hermes');
|
||||||
const targetDir = path.join(tmpDir, '.hermes');
|
const targetDir = path.join(tmpDir, '.hermes');
|
||||||
// Nested layout: skills live under skills/gsd/gsd-*/SKILL.md.
|
// Nested layout: skills live under skills/gsd/<stem>/SKILL.md.
|
||||||
|
// The layout module uses prefix: '' for Hermes so skill dirs have bare
|
||||||
|
// stem names (help, plan, …) inside the category dir (#3664).
|
||||||
const categoryDir = path.join(targetDir, 'skills', 'gsd');
|
const categoryDir = path.join(targetDir, 'skills', 'gsd');
|
||||||
const skillDirs = fs.readdirSync(categoryDir, { withFileTypes: true })
|
const skillDirs = fs.readdirSync(categoryDir, { withFileTypes: true })
|
||||||
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'))
|
.filter(e => e.isDirectory() && e.name !== 'DESCRIPTION.md')
|
||||||
.map(e => e.name);
|
.map(e => e.name);
|
||||||
|
|
||||||
assert.ok(skillDirs.length > 0, 'at least one gsd-* skill installed');
|
assert.ok(skillDirs.length > 0, 'at least one skill installed');
|
||||||
|
|
||||||
// Parse every SKILL.md and assert structural shape required by Hermes.
|
// Parse every SKILL.md and assert structural shape required by Hermes.
|
||||||
for (const dir of skillDirs) {
|
for (const dir of skillDirs) {
|
||||||
@@ -203,7 +207,7 @@ describe('E2E: Hermes Agent uninstall skills cleanup', () => {
|
|||||||
cleanup(tmpDir);
|
cleanup(tmpDir);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('removes all gsd-* skill directories on --hermes --uninstall', () => {
|
test('removes all skill directories on --hermes --uninstall', () => {
|
||||||
const targetDir = path.join(tmpDir, '.hermes');
|
const targetDir = path.join(tmpDir, '.hermes');
|
||||||
install(false, 'hermes');
|
install(false, 'hermes');
|
||||||
|
|
||||||
@@ -211,9 +215,10 @@ describe('E2E: Hermes Agent uninstall skills cleanup', () => {
|
|||||||
const categoryDir = path.join(skillsDir, 'gsd');
|
const categoryDir = path.join(skillsDir, 'gsd');
|
||||||
assert.ok(fs.existsSync(categoryDir), 'skills/gsd/ category dir exists after install');
|
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 })
|
const installedSkills = fs.readdirSync(categoryDir, { withFileTypes: true })
|
||||||
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
|
.filter(e => e.isDirectory());
|
||||||
assert.ok(installedSkills.length > 0, `found ${installedSkills.length} gsd-* skill dirs before uninstall`);
|
assert.ok(installedSkills.length > 0, `found ${installedSkills.length} skill dirs before uninstall`);
|
||||||
|
|
||||||
uninstall(false, 'hermes');
|
uninstall(false, 'hermes');
|
||||||
|
|
||||||
|
|||||||
@@ -21,11 +21,19 @@ const fs = require('fs');
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
convertClaudeCommandToClaudeSkill,
|
convertClaudeCommandToClaudeSkill,
|
||||||
copyCommandsAsClaudeSkills,
|
installRuntimeArtifacts,
|
||||||
} = require('../bin/install.js');
|
} = require('../bin/install.js');
|
||||||
const { parseFrontmatter } = require('./helpers.cjs');
|
const { parseFrontmatter } = require('./helpers.cjs');
|
||||||
const pkg = require('../package.json');
|
const pkg = require('../package.json');
|
||||||
|
|
||||||
|
const {
|
||||||
|
loadSkillsManifest,
|
||||||
|
resolveProfile,
|
||||||
|
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
|
||||||
|
|
||||||
|
const manifest = loadSkillsManifest();
|
||||||
|
const resolvedProfileFull = resolveProfile({ modes: [], manifest });
|
||||||
|
|
||||||
// ─── convertClaudeCommandToClaudeSkill (used by Hermes via copyCommandsAsClaudeSkills) ──
|
// ─── convertClaudeCommandToClaudeSkill (used by Hermes via copyCommandsAsClaudeSkills) ──
|
||||||
|
|
||||||
describe('Hermes Agent: convertClaudeCommandToClaudeSkill', () => {
|
describe('Hermes Agent: convertClaudeCommandToClaudeSkill', () => {
|
||||||
@@ -120,9 +128,9 @@ describe('Hermes Agent: convertClaudeCommandToClaudeSkill', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── copyCommandsAsClaudeSkills (used for Hermes skills install) ─────────────
|
// ─── installRuntimeArtifacts (used for Hermes skills install) ────────────────
|
||||||
|
|
||||||
describe('Hermes Agent: copyCommandsAsClaudeSkills', () => {
|
describe('Hermes Agent: installRuntimeArtifacts', () => {
|
||||||
let tmpDir;
|
let tmpDir;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -135,7 +143,7 @@ describe('Hermes Agent: copyCommandsAsClaudeSkills', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('creates skills/gsd-xxx/SKILL.md directory structure', () => {
|
test('creates skills/gsd/quick/SKILL.md directory structure (Hermes bare-stem layout)', () => {
|
||||||
// Create source command files
|
// Create source command files
|
||||||
const srcDir = path.join(tmpDir, 'src', 'commands', 'gsd');
|
const srcDir = path.join(tmpDir, 'src', 'commands', 'gsd');
|
||||||
fs.mkdirSync(srcDir, { recursive: true });
|
fs.mkdirSync(srcDir, { recursive: true });
|
||||||
@@ -151,17 +159,22 @@ describe('Hermes Agent: copyCommandsAsClaudeSkills', () => {
|
|||||||
'<objective>Quick task body</objective>',
|
'<objective>Quick task body</objective>',
|
||||||
].join('\n'));
|
].join('\n'));
|
||||||
|
|
||||||
const skillsDir = path.join(tmpDir, 'dest', 'skills');
|
const configDir = path.join(tmpDir, 'dest');
|
||||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '/test/prefix/', 'hermes', false);
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
|
// Redirect findInstallSourceRoot to the test's custom srcDir
|
||||||
|
fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir);
|
||||||
|
|
||||||
// Verify SKILL.md was created
|
installRuntimeArtifacts('hermes', configDir, 'global', resolvedProfileFull);
|
||||||
const skillPath = path.join(skillsDir, 'gsd-quick', 'SKILL.md');
|
|
||||||
assert.ok(fs.existsSync(skillPath), 'gsd-quick/SKILL.md exists');
|
// Hermes layout: skills/gsd/<bare-stem>/SKILL.md (ADR-3660)
|
||||||
|
const skillPath = path.join(configDir, 'skills', 'gsd', 'quick', 'SKILL.md');
|
||||||
|
assert.ok(fs.existsSync(skillPath), 'skills/gsd/quick/SKILL.md exists');
|
||||||
|
|
||||||
// Verify content (structural — parse frontmatter, don't substring-grep)
|
// Verify content (structural — parse frontmatter, don't substring-grep)
|
||||||
|
// Hermes bare-stem: prefix='', so skillName passed to converter = 'quick' (not 'gsd-quick')
|
||||||
const content = fs.readFileSync(skillPath, 'utf8');
|
const content = fs.readFileSync(skillPath, 'utf8');
|
||||||
const fm = parseFrontmatter(content);
|
const fm = parseFrontmatter(content);
|
||||||
assert.strictEqual(fm.name, 'gsd-quick', 'frontmatter name uses hyphen form (#2808)');
|
assert.strictEqual(fm.name, 'quick', 'frontmatter name is bare stem for Hermes nested layout');
|
||||||
assert.ok(fm.description && fm.description.length > 0, 'description present and non-empty');
|
assert.ok(fm.description && fm.description.length > 0, 'description present and non-empty');
|
||||||
assert.strictEqual(fm.version, pkg.version,
|
assert.strictEqual(fm.version, pkg.version,
|
||||||
`Hermes SKILL.md must declare version (got ${JSON.stringify(fm.version)})`);
|
`Hermes SKILL.md must declare version (got ${JSON.stringify(fm.version)})`);
|
||||||
@@ -170,7 +183,7 @@ describe('Hermes Agent: copyCommandsAsClaudeSkills', () => {
|
|||||||
assert.ok(content.includes('<objective>'), 'body content preserved');
|
assert.ok(content.includes('<objective>'), 'body content preserved');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('replaces ~/.claude/ paths with pathPrefix', () => {
|
test('replaces ~/.claude/ paths via applyRuntimeContentRewritesInPlace', () => {
|
||||||
const srcDir = path.join(tmpDir, 'src', 'commands', 'gsd');
|
const srcDir = path.join(tmpDir, 'src', 'commands', 'gsd');
|
||||||
fs.mkdirSync(srcDir, { recursive: true });
|
fs.mkdirSync(srcDir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(srcDir, 'next.md'), [
|
fs.writeFileSync(path.join(srcDir, 'next.md'), [
|
||||||
@@ -182,15 +195,19 @@ describe('Hermes Agent: copyCommandsAsClaudeSkills', () => {
|
|||||||
'Reference: @~/.claude/get-shit-done/workflows/next.md',
|
'Reference: @~/.claude/get-shit-done/workflows/next.md',
|
||||||
].join('\n'));
|
].join('\n'));
|
||||||
|
|
||||||
const skillsDir = path.join(tmpDir, 'dest', 'skills');
|
const configDir = path.join(tmpDir, 'dest');
|
||||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.hermes/', 'hermes', false);
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir);
|
||||||
|
|
||||||
const content = fs.readFileSync(path.join(skillsDir, 'gsd-next', 'SKILL.md'), 'utf8');
|
installRuntimeArtifacts('hermes', configDir, 'global', resolvedProfileFull);
|
||||||
assert.ok(content.includes('$HOME/.hermes/'), 'path replaced to .hermes/');
|
|
||||||
assert.ok(!content.includes('~/.claude/'), 'old claude path removed');
|
// Hermes layout: skills/gsd/<bare-stem>/SKILL.md
|
||||||
|
const content = fs.readFileSync(path.join(configDir, 'skills', 'gsd', 'next', 'SKILL.md'), 'utf8');
|
||||||
|
assert.ok(!content.includes('~/.claude/'), 'old claude tilde-path removed');
|
||||||
|
assert.ok(!content.includes('$HOME/.claude/'), 'old claude $HOME-path not present');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('replaces $HOME/.claude/ paths with pathPrefix', () => {
|
test('replaces $HOME/.claude/ paths via applyRuntimeContentRewritesInPlace', () => {
|
||||||
const srcDir = path.join(tmpDir, 'src', 'commands', 'gsd');
|
const srcDir = path.join(tmpDir, 'src', 'commands', 'gsd');
|
||||||
fs.mkdirSync(srcDir, { recursive: true });
|
fs.mkdirSync(srcDir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(srcDir, 'plan.md'), [
|
fs.writeFileSync(path.join(srcDir, 'plan.md'), [
|
||||||
@@ -202,12 +219,16 @@ describe('Hermes Agent: copyCommandsAsClaudeSkills', () => {
|
|||||||
'Reference: $HOME/.claude/get-shit-done/workflows/plan.md',
|
'Reference: $HOME/.claude/get-shit-done/workflows/plan.md',
|
||||||
].join('\n'));
|
].join('\n'));
|
||||||
|
|
||||||
const skillsDir = path.join(tmpDir, 'dest', 'skills');
|
const configDir = path.join(tmpDir, 'dest');
|
||||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.hermes/', 'hermes', false);
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir);
|
||||||
|
|
||||||
const content = fs.readFileSync(path.join(skillsDir, 'gsd-plan', 'SKILL.md'), 'utf8');
|
installRuntimeArtifacts('hermes', configDir, 'global', resolvedProfileFull);
|
||||||
assert.ok(content.includes('$HOME/.hermes/'), 'path replaced to .hermes/');
|
|
||||||
assert.ok(!content.includes('$HOME/.claude/'), 'old claude path removed');
|
// Hermes layout: skills/gsd/<bare-stem>/SKILL.md
|
||||||
|
const content = fs.readFileSync(path.join(configDir, 'skills', 'gsd', 'plan', 'SKILL.md'), 'utf8');
|
||||||
|
assert.ok(!content.includes('$HOME/.claude/'), 'old claude $HOME-path removed');
|
||||||
|
assert.ok(!content.includes('~/.claude/'), 'old claude tilde-path not present');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('removes stale gsd- skills before installing new ones', () => {
|
test('removes stale gsd- skills before installing new ones', () => {
|
||||||
@@ -222,15 +243,21 @@ describe('Hermes Agent: copyCommandsAsClaudeSkills', () => {
|
|||||||
'Body',
|
'Body',
|
||||||
].join('\n'));
|
].join('\n'));
|
||||||
|
|
||||||
const skillsDir = path.join(tmpDir, 'dest', 'skills');
|
const configDir = path.join(tmpDir, 'dest');
|
||||||
// Pre-create a stale skill
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
fs.mkdirSync(path.join(skillsDir, 'gsd-old-skill'), { recursive: true });
|
fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir);
|
||||||
fs.writeFileSync(path.join(skillsDir, 'gsd-old-skill', 'SKILL.md'), 'old');
|
|
||||||
|
|
||||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '/test/', 'hermes', false);
|
// Pre-create a stale flat skill (skills/gsd-old-skill) — legacy Hermes layout
|
||||||
|
const staleFlatSkillDir = path.join(configDir, 'skills', 'gsd-old-skill');
|
||||||
|
fs.mkdirSync(staleFlatSkillDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(staleFlatSkillDir, 'SKILL.md'), 'old');
|
||||||
|
|
||||||
assert.ok(!fs.existsSync(path.join(skillsDir, 'gsd-old-skill')), 'stale skill removed');
|
installRuntimeArtifacts('hermes', configDir, 'global', resolvedProfileFull);
|
||||||
assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-quick', 'SKILL.md')), 'new skill installed');
|
|
||||||
|
// _runLegacyInstallMigrations removes skills/gsd-* flat dirs for hermes
|
||||||
|
assert.ok(!fs.existsSync(staleFlatSkillDir), 'stale flat gsd- skill removed');
|
||||||
|
// New Hermes layout: skills/gsd/<bare-stem>/SKILL.md
|
||||||
|
assert.ok(fs.existsSync(path.join(configDir, 'skills', 'gsd', 'quick', 'SKILL.md')), 'new skill installed at skills/gsd/quick/SKILL.md');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('preserves agent field in frontmatter', () => {
|
test('preserves agent field in frontmatter', () => {
|
||||||
@@ -250,10 +277,14 @@ describe('Hermes Agent: copyCommandsAsClaudeSkills', () => {
|
|||||||
'Execute body',
|
'Execute body',
|
||||||
].join('\n'));
|
].join('\n'));
|
||||||
|
|
||||||
const skillsDir = path.join(tmpDir, 'dest', 'skills');
|
const configDir = path.join(tmpDir, 'dest');
|
||||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '/test/', 'hermes', false);
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir);
|
||||||
|
|
||||||
const content = fs.readFileSync(path.join(skillsDir, 'gsd-execute', 'SKILL.md'), 'utf8');
|
installRuntimeArtifacts('hermes', configDir, 'global', resolvedProfileFull);
|
||||||
|
|
||||||
|
// Hermes layout: skills/gsd/<bare-stem>/SKILL.md
|
||||||
|
const content = fs.readFileSync(path.join(configDir, 'skills', 'gsd', 'execute', 'SKILL.md'), 'utf8');
|
||||||
const fm = parseFrontmatter(content);
|
const fm = parseFrontmatter(content);
|
||||||
assert.strictEqual(fm.agent, 'gsd-executor', 'agent field preserved');
|
assert.strictEqual(fm.agent, 'gsd-executor', 'agent field preserved');
|
||||||
});
|
});
|
||||||
|
|||||||
508
tests/install-hermes-regressions.test.cjs
Normal file
508
tests/install-hermes-regressions.test.cjs
Normal file
@@ -0,0 +1,508 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* Regression tests for two confirmed defects from commit 6c676dbc (#3664)
|
||||||
|
* and three legacy-migration gaps caught in cc2ecb97 (#3664 Phase 2):
|
||||||
|
*
|
||||||
|
* Defect #1 — Hermes upgrade leaves stale skills/gsd/gsd-<stem>/ dirs
|
||||||
|
* _runLegacyInstallMigrations only removed pre-#2841 flat skills/gsd-slash-star
|
||||||
|
* dirs; it did not remove the intermediate skills/gsd/gsd-slash-star layout that
|
||||||
|
* existed between #2841 and #3664.
|
||||||
|
*
|
||||||
|
* Defect #2 — `--hermes --profile=core` falls through to wrong path
|
||||||
|
* The dispatcher's minimal-mode block had no `isHermes && _isCoreProfileAlias`
|
||||||
|
* branch, so Hermes + core fell to the terminal `else` (Claude local path),
|
||||||
|
* writing commands/gsd/<stem>.md instead of skills/gsd/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { test, describe } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
|
||||||
|
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||||
|
const {
|
||||||
|
loadSkillsManifest,
|
||||||
|
resolveProfile,
|
||||||
|
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
|
||||||
|
|
||||||
|
// Load install exports via GSD_TEST_MODE to skip CLI main()
|
||||||
|
const savedTestMode = process.env.GSD_TEST_MODE;
|
||||||
|
process.env.GSD_TEST_MODE = '1';
|
||||||
|
let installExports;
|
||||||
|
try {
|
||||||
|
installExports = require('../bin/install.js');
|
||||||
|
} finally {
|
||||||
|
if (savedTestMode === undefined) delete process.env.GSD_TEST_MODE;
|
||||||
|
else process.env.GSD_TEST_MODE = savedTestMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { installRuntimeArtifacts, uninstallRuntimeArtifacts } = installExports || {};
|
||||||
|
|
||||||
|
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
|
||||||
|
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
|
||||||
|
const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||||
|
const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST });
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test A1 — Defect #1 regression: stale skills/gsd/gsd-*/ dirs are removed
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('Defect #1 regression: _runLegacyInstallMigrations removes skills/gsd/gsd-*/ layout', () => {
|
||||||
|
test('installRuntimeArtifacts removes intermediate skills/gsd/gsd-*/ dirs and writes new bare-stem layout', (t) => {
|
||||||
|
const configDir = createTempDir('gsd-hermes-reg1-');
|
||||||
|
t.after(() => cleanup(configDir));
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
typeof installRuntimeArtifacts,
|
||||||
|
'function',
|
||||||
|
'installRuntimeArtifacts must be exported from bin/install.js',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pre-create the intermediate Hermes layout (between #2841 and #3664):
|
||||||
|
// skills/gsd/gsd-help/SKILL.md and skills/gsd/gsd-plan/SKILL.md
|
||||||
|
const nestedGsdDir = path.join(configDir, 'skills', 'gsd');
|
||||||
|
fs.mkdirSync(path.join(nestedGsdDir, 'gsd-help'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(nestedGsdDir, 'gsd-help', 'SKILL.md'), '# legacy help\n');
|
||||||
|
fs.mkdirSync(path.join(nestedGsdDir, 'gsd-plan'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(nestedGsdDir, 'gsd-plan', 'SKILL.md'), '# legacy plan\n');
|
||||||
|
|
||||||
|
// Create a sibling non-gsd dir inside skills/gsd/ that must survive
|
||||||
|
const userContentDir = path.join(nestedGsdDir, 'user-content');
|
||||||
|
fs.mkdirSync(userContentDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(userContentDir, 'SKILL.md'), '# user content\n');
|
||||||
|
|
||||||
|
// Run the unified install
|
||||||
|
installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_CORE);
|
||||||
|
|
||||||
|
// Stale intermediate dirs must be gone
|
||||||
|
assert.ok(
|
||||||
|
!fs.existsSync(path.join(nestedGsdDir, 'gsd-help')),
|
||||||
|
'skills/gsd/gsd-help/ must be removed by install migration (Defect #1)',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!fs.existsSync(path.join(nestedGsdDir, 'gsd-plan')),
|
||||||
|
'skills/gsd/gsd-plan/ must be removed by install migration (Defect #1)',
|
||||||
|
);
|
||||||
|
|
||||||
|
// New bare-stem layout must exist
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(path.join(nestedGsdDir, 'help', 'SKILL.md')),
|
||||||
|
'skills/gsd/help/SKILL.md must exist after install (new bare-stem layout)',
|
||||||
|
);
|
||||||
|
|
||||||
|
// User content in skills/gsd/ must be preserved (user-content has no gsd- prefix)
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(path.join(userContentDir, 'SKILL.md')),
|
||||||
|
'skills/gsd/user-content/SKILL.md must be preserved (user content)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test Q1 — Qwen variant of Defect #2: --qwen --profile=core uses skills/gsd-*/ path
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('Qwen Defect #2 regression: --qwen --profile=core writes skills/gsd-*/, not commands/gsd/', () => {
|
||||||
|
test('spawn node bin/install.js --qwen --global --profile=core: skills/gsd-*/ written, no commands/gsd/', (t) => {
|
||||||
|
const root = createTempDir('gsd-qwen-reg2-');
|
||||||
|
t.after(() => cleanup(root));
|
||||||
|
|
||||||
|
const result = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[INSTALL_SCRIPT, '--qwen', '--global', '--config-dir', root, '--profile=core'],
|
||||||
|
{
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: { ...process.env, HOME: root, USERPROFILE: root },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
result.status,
|
||||||
|
0,
|
||||||
|
`installer exited with status ${result.status}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// skills/ must exist and contain at least one gsd-<stem>/ dir with SKILL.md
|
||||||
|
const qwenSkillsDir = path.join(root, 'skills');
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(qwenSkillsDir),
|
||||||
|
`skills/ must exist after --qwen --profile=core install (got: ${result.stdout})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const skillDirs = fs.readdirSync(qwenSkillsDir, { withFileTypes: true })
|
||||||
|
.filter((e) => e.isDirectory() && e.name.startsWith('gsd-'));
|
||||||
|
assert.ok(
|
||||||
|
skillDirs.length >= 1,
|
||||||
|
`skills/ must contain at least one gsd-* skill dir, got: ${
|
||||||
|
fs.readdirSync(qwenSkillsDir, { withFileTypes: true }).map((e) => e.name).join(', ')
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify at least one gsd-* dir has SKILL.md
|
||||||
|
const hasSkillMd = skillDirs.some((e) =>
|
||||||
|
fs.existsSync(path.join(qwenSkillsDir, e.name, 'SKILL.md')),
|
||||||
|
);
|
||||||
|
assert.ok(hasSkillMd, 'at least one skills/gsd-*/SKILL.md must exist');
|
||||||
|
|
||||||
|
// commands/gsd/*.md must NOT exist (the regression path that Defect #2 causes for Qwen)
|
||||||
|
const commandsGsd = path.join(root, 'commands', 'gsd');
|
||||||
|
if (fs.existsSync(commandsGsd)) {
|
||||||
|
const mdFiles = fs.readdirSync(commandsGsd).filter((f) => f.endsWith('.md'));
|
||||||
|
assert.strictEqual(
|
||||||
|
mdFiles.length,
|
||||||
|
0,
|
||||||
|
`commands/gsd/ must not contain .md files after qwen install (Qwen Defect #2). Found: ${mdFiles.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test A2 — Defect #2 regression: --hermes --profile=core uses skills/gsd/ path
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('Defect #2 regression: --hermes --profile=core writes skills/gsd/, not commands/gsd/', () => {
|
||||||
|
test('spawn node bin/install.js --hermes --global --profile=core: skills/gsd/ written, no commands/gsd/', (t) => {
|
||||||
|
const root = createTempDir('gsd-hermes-reg2-');
|
||||||
|
t.after(() => cleanup(root));
|
||||||
|
|
||||||
|
const result = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[INSTALL_SCRIPT, '--hermes', '--global', '--config-dir', root, '--profile=core'],
|
||||||
|
{
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: { ...process.env, HOME: root, USERPROFILE: root },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
result.status,
|
||||||
|
0,
|
||||||
|
`installer exited with status ${result.status}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// skills/gsd/ must exist and contain at least one <stem>/SKILL.md file
|
||||||
|
const hermesSkillsGsd = path.join(root, 'skills', 'gsd');
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(hermesSkillsGsd),
|
||||||
|
`skills/gsd/ must exist after --hermes --profile=core install (got: ${result.stdout})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const skillDirs = fs.readdirSync(hermesSkillsGsd, { withFileTypes: true })
|
||||||
|
.filter((e) => e.isDirectory());
|
||||||
|
assert.ok(
|
||||||
|
skillDirs.length >= 1,
|
||||||
|
`skills/gsd/ must contain at least one skill dir, got: ${skillDirs.map((e) => e.name).join(', ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify at least one skill dir has SKILL.md
|
||||||
|
const hasSkillMd = skillDirs.some((e) =>
|
||||||
|
fs.existsSync(path.join(hermesSkillsGsd, e.name, 'SKILL.md')),
|
||||||
|
);
|
||||||
|
assert.ok(hasSkillMd, 'at least one skills/gsd/<stem>/SKILL.md must exist');
|
||||||
|
|
||||||
|
// commands/gsd/*.md must NOT exist (the regression path that Defect #2 caused)
|
||||||
|
const commandsGsd = path.join(root, 'commands', 'gsd');
|
||||||
|
if (fs.existsSync(commandsGsd)) {
|
||||||
|
const mdFiles = fs.readdirSync(commandsGsd).filter((f) => f.endsWith('.md'));
|
||||||
|
assert.strictEqual(
|
||||||
|
mdFiles.length,
|
||||||
|
0,
|
||||||
|
`commands/gsd/ must not contain .md files after hermes install (Defect #2). Found: ${mdFiles.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test M1 — Hermes minimal-mode migrates dev-preferences (#2973)
|
||||||
|
// Hermes uses nested layout (skills/gsd/<stem>/SKILL.md, prefix=''),
|
||||||
|
// so dev-preferences lands at skills/gsd/dev-preferences/SKILL.md NOT
|
||||||
|
// skills/gsd-dev-preferences/SKILL.md (the flat layout for qwen/claude-global).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('M1: --hermes --global --profile=core migrates dev-preferences.md → skills/gsd/dev-preferences/SKILL.md', () => {
|
||||||
|
test('spawn node bin/install.js --hermes --global --profile=core: dev-preferences migrated, source gone', (t) => {
|
||||||
|
const root = createTempDir('gsd-hermes-m1-');
|
||||||
|
t.after(() => cleanup(root));
|
||||||
|
|
||||||
|
// Pre-create legacy commands/gsd/dev-preferences.md
|
||||||
|
const legacyDir = path.join(root, 'commands', 'gsd');
|
||||||
|
fs.mkdirSync(legacyDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(legacyDir, 'dev-preferences.md'), '# my hermes prefs\n');
|
||||||
|
|
||||||
|
const result = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[INSTALL_SCRIPT, '--hermes', '--global', '--config-dir', root, '--profile=core'],
|
||||||
|
{
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: { ...process.env, HOME: root, USERPROFILE: root },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
result.status,
|
||||||
|
0,
|
||||||
|
`installer exited with status ${result.status}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Migration target must exist at the HERMES nested location (skills/gsd/dev-preferences/SKILL.md)
|
||||||
|
// NOT the flat location skills/gsd-dev-preferences/SKILL.md used by qwen/claude-global.
|
||||||
|
const skillFile = path.join(root, 'skills', 'gsd', 'dev-preferences', 'SKILL.md');
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(skillFile),
|
||||||
|
`skills/gsd/dev-preferences/SKILL.md must exist after migration (M1: Hermes nested layout, not flat)`,
|
||||||
|
);
|
||||||
|
const content = fs.readFileSync(skillFile, 'utf8');
|
||||||
|
assert.strictEqual(
|
||||||
|
content,
|
||||||
|
'# my hermes prefs\n',
|
||||||
|
`skills/gsd/dev-preferences/SKILL.md content must equal original, got: ${JSON.stringify(content)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Legacy source must be gone
|
||||||
|
assert.ok(
|
||||||
|
!fs.existsSync(path.join(legacyDir, 'dev-preferences.md')),
|
||||||
|
'commands/gsd/dev-preferences.md must be removed after migration (M1)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test M2 — Qwen minimal-mode migrates dev-preferences (#2973)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('M2: --qwen --global --profile=core migrates dev-preferences.md → skills/gsd-dev-preferences/SKILL.md', () => {
|
||||||
|
test('spawn node bin/install.js --qwen --global --profile=core: dev-preferences migrated, source gone', (t) => {
|
||||||
|
const root = createTempDir('gsd-qwen-m2-');
|
||||||
|
t.after(() => cleanup(root));
|
||||||
|
|
||||||
|
// Pre-create legacy commands/gsd/dev-preferences.md
|
||||||
|
const legacyDir = path.join(root, 'commands', 'gsd');
|
||||||
|
fs.mkdirSync(legacyDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(legacyDir, 'dev-preferences.md'), '# my qwen prefs\n');
|
||||||
|
|
||||||
|
const result = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[INSTALL_SCRIPT, '--qwen', '--global', '--config-dir', root, '--profile=core'],
|
||||||
|
{
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: { ...process.env, HOME: root, USERPROFILE: root },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
result.status,
|
||||||
|
0,
|
||||||
|
`installer exited with status ${result.status}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Migration target must exist and contain the original content
|
||||||
|
const skillFile = path.join(root, 'skills', 'gsd-dev-preferences', 'SKILL.md');
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(skillFile),
|
||||||
|
`skills/gsd-dev-preferences/SKILL.md must exist after migration (M2: _runLegacyInstallMigrations missing in qwen minimal branch)`,
|
||||||
|
);
|
||||||
|
const content = fs.readFileSync(skillFile, 'utf8');
|
||||||
|
assert.strictEqual(
|
||||||
|
content,
|
||||||
|
'# my qwen prefs\n',
|
||||||
|
`skills/gsd-dev-preferences/SKILL.md content must equal original, got: ${JSON.stringify(content)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Legacy source must be gone
|
||||||
|
assert.ok(
|
||||||
|
!fs.existsSync(path.join(legacyDir, 'dev-preferences.md')),
|
||||||
|
'commands/gsd/dev-preferences.md must be removed after migration (M2)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test M3 — Claude global minimal-mode migrates dev-preferences (#2973)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('M3: --claude --global --profile=core migrates dev-preferences.md → skills/gsd-dev-preferences/SKILL.md', () => {
|
||||||
|
test('spawn node bin/install.js --claude --global --profile=core: dev-preferences migrated, source gone', (t) => {
|
||||||
|
const root = createTempDir('gsd-claude-m3-');
|
||||||
|
t.after(() => cleanup(root));
|
||||||
|
|
||||||
|
// Pre-create legacy commands/gsd/dev-preferences.md
|
||||||
|
const legacyDir = path.join(root, 'commands', 'gsd');
|
||||||
|
fs.mkdirSync(legacyDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(legacyDir, 'dev-preferences.md'), '# my claude prefs\n');
|
||||||
|
|
||||||
|
const result = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[INSTALL_SCRIPT, '--claude', '--global', '--config-dir', root, '--profile=core'],
|
||||||
|
{
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: { ...process.env, HOME: root, USERPROFILE: root },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
result.status,
|
||||||
|
0,
|
||||||
|
`installer exited with status ${result.status}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Migration target must exist and contain the original content
|
||||||
|
const skillFile = path.join(root, 'skills', 'gsd-dev-preferences', 'SKILL.md');
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(skillFile),
|
||||||
|
`skills/gsd-dev-preferences/SKILL.md must exist after migration (M3: _runLegacyInstallMigrations missing in claude-global-minimal branch)`,
|
||||||
|
);
|
||||||
|
const content = fs.readFileSync(skillFile, 'utf8');
|
||||||
|
assert.strictEqual(
|
||||||
|
content,
|
||||||
|
'# my claude prefs\n',
|
||||||
|
`skills/gsd-dev-preferences/SKILL.md content must equal original, got: ${JSON.stringify(content)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Legacy source must be gone
|
||||||
|
assert.ok(
|
||||||
|
!fs.existsSync(path.join(legacyDir, 'dev-preferences.md')),
|
||||||
|
'commands/gsd/dev-preferences.md must be removed after migration (M3)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test U1 — Qwen uninstall preserves dev-preferences via migration to skill
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('U1: uninstallRuntimeArtifacts qwen preserves dev-preferences.md via migration to skills/gsd-dev-preferences/SKILL.md', () => {
|
||||||
|
test('uninstallRuntimeArtifacts("qwen"): commands/gsd/ removed, dev-preferences migrated to skills skill', (t) => {
|
||||||
|
const configDir = createTempDir('gsd-qwen-uninstall-u1-');
|
||||||
|
t.after(() => cleanup(configDir));
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
typeof uninstallRuntimeArtifacts,
|
||||||
|
'function',
|
||||||
|
'uninstallRuntimeArtifacts must be exported from bin/install.js',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pre-create legacy commands/gsd/ with dev-preferences.md and a managed file
|
||||||
|
const legacyDir = path.join(configDir, 'commands', 'gsd');
|
||||||
|
fs.mkdirSync(legacyDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(legacyDir, 'dev-preferences.md'), '# my qwen prefs\n');
|
||||||
|
fs.writeFileSync(path.join(legacyDir, 'help.md'), '# help content\n');
|
||||||
|
|
||||||
|
uninstallRuntimeArtifacts('qwen', configDir, 'global');
|
||||||
|
|
||||||
|
// Legacy managed file must be gone (whole commands/gsd/ removed)
|
||||||
|
assert.ok(
|
||||||
|
!fs.existsSync(path.join(legacyDir, 'help.md')),
|
||||||
|
'commands/gsd/help.md must be removed by qwen uninstall (U1)',
|
||||||
|
);
|
||||||
|
|
||||||
|
// dev-preferences.md must NOT be left behind in the legacy dir
|
||||||
|
assert.ok(
|
||||||
|
!fs.existsSync(path.join(legacyDir, 'dev-preferences.md')),
|
||||||
|
'commands/gsd/dev-preferences.md must not exist after qwen uninstall (U1)',
|
||||||
|
);
|
||||||
|
|
||||||
|
// dev-preferences.md must be migrated to skills/gsd-dev-preferences/SKILL.md
|
||||||
|
const skillFile = path.join(configDir, 'skills', 'gsd-dev-preferences', 'SKILL.md');
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(skillFile),
|
||||||
|
'skills/gsd-dev-preferences/SKILL.md must exist after qwen uninstall (U1: #2973 migration)',
|
||||||
|
);
|
||||||
|
const content = fs.readFileSync(skillFile, 'utf8');
|
||||||
|
assert.strictEqual(
|
||||||
|
content,
|
||||||
|
'# my qwen prefs\n',
|
||||||
|
`skills/gsd-dev-preferences/SKILL.md content must equal original, got: ${JSON.stringify(content)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test U2 — Claude-global uninstall preserves dev-preferences via migration to skill
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('U2: uninstallRuntimeArtifacts claude/global preserves dev-preferences.md via migration to skills/gsd-dev-preferences/SKILL.md', () => {
|
||||||
|
test('uninstallRuntimeArtifacts("claude", scope="global"): commands/gsd/ removed, dev-preferences migrated to skills skill', (t) => {
|
||||||
|
const configDir = createTempDir('gsd-claude-uninstall-u2-');
|
||||||
|
t.after(() => cleanup(configDir));
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
typeof uninstallRuntimeArtifacts,
|
||||||
|
'function',
|
||||||
|
'uninstallRuntimeArtifacts must be exported from bin/install.js',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pre-create legacy commands/gsd/ with dev-preferences.md
|
||||||
|
const legacyDir = path.join(configDir, 'commands', 'gsd');
|
||||||
|
fs.mkdirSync(legacyDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(legacyDir, 'dev-preferences.md'), '# my claude prefs\n');
|
||||||
|
|
||||||
|
uninstallRuntimeArtifacts('claude', configDir, 'global');
|
||||||
|
|
||||||
|
// dev-preferences.md must NOT be left in the legacy dir
|
||||||
|
assert.ok(
|
||||||
|
!fs.existsSync(path.join(legacyDir, 'dev-preferences.md')),
|
||||||
|
'commands/gsd/dev-preferences.md must not exist after claude-global uninstall (U2)',
|
||||||
|
);
|
||||||
|
|
||||||
|
// dev-preferences.md must be migrated to skills/gsd-dev-preferences/SKILL.md
|
||||||
|
const skillFile = path.join(configDir, 'skills', 'gsd-dev-preferences', 'SKILL.md');
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(skillFile),
|
||||||
|
'skills/gsd-dev-preferences/SKILL.md must exist after claude-global uninstall (U2: #2973 migration)',
|
||||||
|
);
|
||||||
|
const content = fs.readFileSync(skillFile, 'utf8');
|
||||||
|
assert.strictEqual(
|
||||||
|
content,
|
||||||
|
'# my claude prefs\n',
|
||||||
|
`skills/gsd-dev-preferences/SKILL.md content must equal original, got: ${JSON.stringify(content)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test U3 — Hermes uninstall migrates dev-preferences to NESTED location (#2973)
|
||||||
|
// Proves both Finding 1 (wrong target path) and Finding 2 (unconditional restore).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('U3: uninstallRuntimeArtifacts hermes migrates dev-preferences.md → skills/gsd/dev-preferences/SKILL.md', () => {
|
||||||
|
test('uninstallRuntimeArtifacts("hermes"): commands/gsd/ NOT recreated, dev-preferences at nested Hermes location', (t) => {
|
||||||
|
const configDir = createTempDir('gsd-hermes-uninstall-u3-');
|
||||||
|
t.after(() => cleanup(configDir));
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
typeof uninstallRuntimeArtifacts,
|
||||||
|
'function',
|
||||||
|
'uninstallRuntimeArtifacts must be exported from bin/install.js',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pre-create legacy commands/gsd/dev-preferences.md
|
||||||
|
const legacyDir = path.join(configDir, 'commands', 'gsd');
|
||||||
|
fs.mkdirSync(legacyDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(legacyDir, 'dev-preferences.md'), '# my hermes prefs (uninstall path)\n');
|
||||||
|
|
||||||
|
uninstallRuntimeArtifacts('hermes', configDir, 'global');
|
||||||
|
|
||||||
|
// commands/gsd/dev-preferences.md must be GONE — no recreation after rmSync
|
||||||
|
assert.ok(
|
||||||
|
!fs.existsSync(path.join(legacyDir, 'dev-preferences.md')),
|
||||||
|
'commands/gsd/dev-preferences.md must not exist after hermes uninstall (U3: Finding 2 — no unconditional restore)',
|
||||||
|
);
|
||||||
|
|
||||||
|
// dev-preferences must be at the HERMES nested location (skills/gsd/dev-preferences/SKILL.md)
|
||||||
|
// NOT the flat location skills/gsd-dev-preferences/SKILL.md (that is qwen/claude-global)
|
||||||
|
const skillFile = path.join(configDir, 'skills', 'gsd', 'dev-preferences', 'SKILL.md');
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(skillFile),
|
||||||
|
'skills/gsd/dev-preferences/SKILL.md must exist after hermes uninstall (U3: Finding 1 — runtime-aware migration target)',
|
||||||
|
);
|
||||||
|
const content = fs.readFileSync(skillFile, 'utf8');
|
||||||
|
assert.strictEqual(
|
||||||
|
content,
|
||||||
|
'# my hermes prefs (uninstall path)\n',
|
||||||
|
`skills/gsd/dev-preferences/SKILL.md content must equal original, got: ${JSON.stringify(content)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
573
tests/install-uninstall-layout-loop.test.cjs
Normal file
573
tests/install-uninstall-layout-loop.test.cjs
Normal file
@@ -0,0 +1,573 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* Phase 2 TDD — red tests for installRuntimeArtifacts / uninstallRuntimeArtifacts.
|
||||||
|
*
|
||||||
|
* These tests MUST fail with "TypeError: installRuntimeArtifacts is not a function"
|
||||||
|
* (or equivalent) until the production implementation is added to bin/install.js.
|
||||||
|
*
|
||||||
|
* Conventions:
|
||||||
|
* - node:test + node:assert/strict
|
||||||
|
* - helpers.cjs for createTempDir / cleanup
|
||||||
|
* - Filesystem assertions only — no source-grep, no .includes() on file content
|
||||||
|
* - Per-runtime parameterisation where applicable
|
||||||
|
* - t.after() for cleanup
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { test, describe } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||||
|
const {
|
||||||
|
resolveRuntimeArtifactLayout,
|
||||||
|
} = require('../get-shit-done/bin/lib/runtime-artifact-layout.cjs');
|
||||||
|
const {
|
||||||
|
loadSkillsManifest,
|
||||||
|
resolveProfile,
|
||||||
|
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Load target exports — will be undefined until Phase 2 ships
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// GSD_TEST_MODE prevents install.js from running its CLI main() on require.
|
||||||
|
const savedTestMode = process.env.GSD_TEST_MODE;
|
||||||
|
process.env.GSD_TEST_MODE = '1';
|
||||||
|
let installExports;
|
||||||
|
try {
|
||||||
|
installExports = require('../bin/install.js');
|
||||||
|
} finally {
|
||||||
|
if (savedTestMode === undefined) delete process.env.GSD_TEST_MODE;
|
||||||
|
else process.env.GSD_TEST_MODE = savedTestMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { installRuntimeArtifacts, uninstallRuntimeArtifacts } = installExports || {};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared resolved profile (core — deterministic small set)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// The real commands/gsd dir is used by the layout module's source-root walk.
|
||||||
|
// We load its manifest so resolveProfile gives us the correct transitive closure.
|
||||||
|
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
|
||||||
|
const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||||
|
const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST });
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Runtime lists
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Runtimes that use a "skills" kind at global scope
|
||||||
|
const SKILLS_RUNTIMES = [
|
||||||
|
'claude', 'cursor', 'codex', 'copilot', 'antigravity',
|
||||||
|
'windsurf', 'augment', 'trae', 'qwen', 'codebuddy',
|
||||||
|
];
|
||||||
|
|
||||||
|
// All 15 runtimes in the layout table
|
||||||
|
const ALL_RUNTIMES = [
|
||||||
|
'claude', 'cursor', 'gemini', 'codex', 'copilot', 'antigravity',
|
||||||
|
'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy',
|
||||||
|
'cline', 'opencode', 'kilo',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: count entries in destDir whose name starts with prefix
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function countPrefixedEntries(destDir, prefix) {
|
||||||
|
if (!fs.existsSync(destDir)) return 0;
|
||||||
|
return fs.readdirSync(destDir).filter((n) => n.startsWith(prefix)).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: write fixture skill entry in destDir
|
||||||
|
// For skills runtimes: creates <destDir>/<prefix><stem>/SKILL.md
|
||||||
|
// For commands runtimes: creates <destDir>/<prefix><stem>.md
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function writeSkillEntry(destDir, prefix, stem) {
|
||||||
|
const entryName = `${prefix}${stem}`;
|
||||||
|
const entryDir = path.join(destDir, entryName);
|
||||||
|
fs.mkdirSync(entryDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(entryDir, 'SKILL.md'), `# ${stem}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCommandEntry(destDir, prefix, stem) {
|
||||||
|
fs.mkdirSync(destDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(destDir, `${prefix}${stem}.md`), `# ${stem}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// describe 1 — installRuntimeArtifacts: layout-driven install loop
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('installRuntimeArtifacts — layout-driven install loop', () => {
|
||||||
|
// Standard skills runtimes: gsd-prefixed dirs in <configDir>/skills/
|
||||||
|
for (const runtime of SKILLS_RUNTIMES) {
|
||||||
|
test(`${runtime}: installs gsd-prefixed skill dirs into <configDir>/skills/`, (t) => {
|
||||||
|
const configDir = createTempDir(`gsd-ial-${runtime}-`);
|
||||||
|
t.after(() => cleanup(configDir));
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
typeof installRuntimeArtifacts,
|
||||||
|
'function',
|
||||||
|
'installRuntimeArtifacts must be exported from bin/install.js'
|
||||||
|
);
|
||||||
|
|
||||||
|
installRuntimeArtifacts(runtime, configDir, 'global', RESOLVED_CORE);
|
||||||
|
|
||||||
|
const layout = resolveRuntimeArtifactLayout(runtime, configDir, 'global');
|
||||||
|
const skillsKind = layout.kinds.find((k) => k.kind === 'skills');
|
||||||
|
assert.ok(skillsKind, `${runtime} must have a skills kind in global scope`);
|
||||||
|
|
||||||
|
const destDir = path.join(configDir, skillsKind.destSubpath);
|
||||||
|
assert.ok(fs.existsSync(destDir), `destDir must exist after install: ${destDir}`);
|
||||||
|
|
||||||
|
// At least one SKILL.md was written
|
||||||
|
const helpDir = path.join(destDir, `${skillsKind.prefix}help`);
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(path.join(helpDir, 'SKILL.md')),
|
||||||
|
`${runtime}: skills/${skillsKind.prefix}help/SKILL.md must exist after install`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Count of gsd-prefixed dirs matches resolved profile's skills size
|
||||||
|
const prefixedCount = countPrefixedEntries(destDir, skillsKind.prefix || 'gsd-');
|
||||||
|
const expectedCount = RESOLVED_CORE.skills === '*'
|
||||||
|
? prefixedCount // full profile: just check non-zero
|
||||||
|
: RESOLVED_CORE.skills.size;
|
||||||
|
assert.ok(
|
||||||
|
prefixedCount >= 1,
|
||||||
|
`${runtime}: at least one skill dir must be present, got ${prefixedCount}`
|
||||||
|
);
|
||||||
|
if (RESOLVED_CORE.skills !== '*') {
|
||||||
|
assert.strictEqual(
|
||||||
|
prefixedCount,
|
||||||
|
expectedCount,
|
||||||
|
`${runtime}: number of installed skill dirs must match profile skills count`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// hermes: nested layout — skills/gsd/<stem>/SKILL.md, prefix is ''
|
||||||
|
test('hermes: nested layout — skills/gsd/<stem>/SKILL.md, no gsd- prefix in name', (t) => {
|
||||||
|
const configDir = createTempDir('gsd-ial-hermes-');
|
||||||
|
t.after(() => cleanup(configDir));
|
||||||
|
|
||||||
|
assert.strictEqual(typeof installRuntimeArtifacts, 'function');
|
||||||
|
|
||||||
|
installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_CORE);
|
||||||
|
|
||||||
|
const nestedDir = path.join(configDir, 'skills', 'gsd');
|
||||||
|
assert.ok(fs.existsSync(nestedDir), 'skills/gsd/ must exist after hermes install');
|
||||||
|
|
||||||
|
// help skill: skills/gsd/help/SKILL.md (prefix is '', so no gsd- prefix on stem)
|
||||||
|
const helpSkillMd = path.join(nestedDir, 'help', 'SKILL.md');
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(helpSkillMd),
|
||||||
|
'hermes: skills/gsd/help/SKILL.md must exist'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify no gsd- prefixed entry at skills/gsd/gsd-help (prefix must be '')
|
||||||
|
const wrongEntry = path.join(nestedDir, 'gsd-help');
|
||||||
|
assert.ok(
|
||||||
|
!fs.existsSync(wrongEntry),
|
||||||
|
'hermes: skills/gsd/gsd-help must NOT exist (prefix should be empty)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// gemini: commands kind — commands/gsd/<stem>.md, no skills/ dir
|
||||||
|
test('gemini: commands layout — commands/gsd/ is created, no skills/ dir', (t) => {
|
||||||
|
const configDir = createTempDir('gsd-ial-gemini-');
|
||||||
|
t.after(() => cleanup(configDir));
|
||||||
|
|
||||||
|
assert.strictEqual(typeof installRuntimeArtifacts, 'function');
|
||||||
|
|
||||||
|
installRuntimeArtifacts('gemini', configDir, 'global', RESOLVED_CORE);
|
||||||
|
|
||||||
|
const commandsGsdDir = path.join(configDir, 'commands', 'gsd');
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(commandsGsdDir),
|
||||||
|
'gemini: commands/gsd/ must exist after install'
|
||||||
|
);
|
||||||
|
|
||||||
|
// help.md should be present (part of core profile)
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(path.join(commandsGsdDir, 'help.md')),
|
||||||
|
'gemini: commands/gsd/help.md must exist'
|
||||||
|
);
|
||||||
|
|
||||||
|
// No skills/ directory should be created for gemini
|
||||||
|
const skillsDir = path.join(configDir, 'skills');
|
||||||
|
assert.ok(
|
||||||
|
!fs.existsSync(skillsDir),
|
||||||
|
'gemini: skills/ must NOT be created'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// cline: kinds is [] — call must succeed, no dirs created
|
||||||
|
test('cline: no kinds — call succeeds, no skills/ or commands/ created', (t) => {
|
||||||
|
const configDir = createTempDir('gsd-ial-cline-');
|
||||||
|
t.after(() => cleanup(configDir));
|
||||||
|
|
||||||
|
assert.strictEqual(typeof installRuntimeArtifacts, 'function');
|
||||||
|
|
||||||
|
// Must not throw
|
||||||
|
assert.doesNotThrow(() => {
|
||||||
|
installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE);
|
||||||
|
}, 'installRuntimeArtifacts must not throw for cline');
|
||||||
|
|
||||||
|
const skillsDir = path.join(configDir, 'skills');
|
||||||
|
const commandsDir = path.join(configDir, 'commands');
|
||||||
|
assert.ok(!fs.existsSync(skillsDir), 'cline: skills/ must NOT be created');
|
||||||
|
assert.ok(!fs.existsSync(commandsDir), 'cline: commands/ must NOT be created');
|
||||||
|
});
|
||||||
|
|
||||||
|
// opencode: flat commands — command/gsd-<stem>.md
|
||||||
|
test('opencode: flat commands layout — command/gsd-help.md exists', (t) => {
|
||||||
|
const configDir = createTempDir('gsd-ial-opencode-');
|
||||||
|
t.after(() => cleanup(configDir));
|
||||||
|
|
||||||
|
assert.strictEqual(typeof installRuntimeArtifacts, 'function');
|
||||||
|
|
||||||
|
installRuntimeArtifacts('opencode', configDir, 'global', RESOLVED_CORE);
|
||||||
|
|
||||||
|
const commandDir = path.join(configDir, 'command');
|
||||||
|
assert.ok(fs.existsSync(commandDir), 'opencode: command/ must exist');
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(path.join(commandDir, 'gsd-help.md')),
|
||||||
|
'opencode: command/gsd-help.md must exist'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// kilo: same flat commands layout as opencode
|
||||||
|
test('kilo: flat commands layout — command/gsd-help.md exists', (t) => {
|
||||||
|
const configDir = createTempDir('gsd-ial-kilo-');
|
||||||
|
t.after(() => cleanup(configDir));
|
||||||
|
|
||||||
|
assert.strictEqual(typeof installRuntimeArtifacts, 'function');
|
||||||
|
|
||||||
|
installRuntimeArtifacts('kilo', configDir, 'global', RESOLVED_CORE);
|
||||||
|
|
||||||
|
const commandDir = path.join(configDir, 'command');
|
||||||
|
assert.ok(fs.existsSync(commandDir), 'kilo: command/ must exist');
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(path.join(commandDir, 'gsd-help.md')),
|
||||||
|
'kilo: command/gsd-help.md must exist'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// describe 2 — uninstallRuntimeArtifacts: layout-driven removal
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('uninstallRuntimeArtifacts — layout-driven removal', () => {
|
||||||
|
for (const runtime of ALL_RUNTIMES) {
|
||||||
|
test(`${runtime}: removes gsd-owned entries, preserves foreign entries`, (t) => {
|
||||||
|
const configDir = createTempDir(`gsd-ual-${runtime}-`);
|
||||||
|
t.after(() => cleanup(configDir));
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
typeof uninstallRuntimeArtifacts,
|
||||||
|
'function',
|
||||||
|
'uninstallRuntimeArtifacts must be exported from bin/install.js'
|
||||||
|
);
|
||||||
|
|
||||||
|
const layout = resolveRuntimeArtifactLayout(runtime, configDir, 'global');
|
||||||
|
|
||||||
|
if (layout.kinds.length === 0) {
|
||||||
|
// cline: no kinds — call must succeed and not touch anything
|
||||||
|
const foreignDir = path.join(configDir, 'foreign-dir');
|
||||||
|
fs.mkdirSync(foreignDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(foreignDir, 'keep.md'), '# keep\n');
|
||||||
|
|
||||||
|
assert.doesNotThrow(() => {
|
||||||
|
uninstallRuntimeArtifacts(runtime, configDir, 'global');
|
||||||
|
}, `${runtime}: uninstallRuntimeArtifacts must not throw with empty kinds`);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(path.join(foreignDir, 'keep.md')),
|
||||||
|
`${runtime}: foreign entries must not be touched`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For hermes: special handling — prefix is '', destSubpath is 'skills/gsd'
|
||||||
|
// The "namespace IS the prefix": everything under skills/gsd/ is gsd-owned.
|
||||||
|
// We pre-create a sibling skills/user-skill/ that must survive.
|
||||||
|
if (runtime === 'hermes') {
|
||||||
|
const kind = layout.kinds[0];
|
||||||
|
const destDir = path.join(configDir, kind.destSubpath);
|
||||||
|
fs.mkdirSync(destDir, { recursive: true });
|
||||||
|
// Create nested skill entries (prefix is '' so names have no gsd- prefix)
|
||||||
|
const helpDir = path.join(destDir, 'help');
|
||||||
|
fs.mkdirSync(helpDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(helpDir, 'SKILL.md'), '# help\n');
|
||||||
|
const phaseDir = path.join(destDir, 'phase');
|
||||||
|
fs.mkdirSync(phaseDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(phaseDir, 'SKILL.md'), '# phase\n');
|
||||||
|
|
||||||
|
// Sibling at skills/ level — must be preserved
|
||||||
|
const siblingDir = path.join(configDir, 'skills', 'user-skill');
|
||||||
|
fs.mkdirSync(siblingDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(siblingDir, 'SKILL.md'), '# user skill\n');
|
||||||
|
|
||||||
|
uninstallRuntimeArtifacts(runtime, configDir, 'global');
|
||||||
|
|
||||||
|
// skills/gsd/ itself and its children should be removed
|
||||||
|
assert.ok(
|
||||||
|
!fs.existsSync(destDir),
|
||||||
|
'hermes: skills/gsd/ must be removed by uninstall'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sibling skills/user-skill/ must survive
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(path.join(siblingDir, 'SKILL.md')),
|
||||||
|
'hermes: skills/user-skill/ must be preserved'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// General case for all other runtimes
|
||||||
|
for (const kind of layout.kinds) {
|
||||||
|
const destDir = path.join(configDir, kind.destSubpath);
|
||||||
|
fs.mkdirSync(destDir, { recursive: true });
|
||||||
|
|
||||||
|
if (kind.kind === 'skills') {
|
||||||
|
// Write two gsd-prefixed skill dirs and one foreign dir
|
||||||
|
writeSkillEntry(destDir, kind.prefix, 'help');
|
||||||
|
writeSkillEntry(destDir, kind.prefix, 'phase');
|
||||||
|
// Foreign entry: no matching prefix
|
||||||
|
const foreignDir = path.join(destDir, 'user-custom-skill');
|
||||||
|
fs.mkdirSync(foreignDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(foreignDir, 'SKILL.md'), '# user\n');
|
||||||
|
} else if (kind.kind === 'commands' || kind.kind === 'agents') {
|
||||||
|
// Write two gsd-prefixed command/agent files and one foreign file
|
||||||
|
writeCommandEntry(destDir, kind.prefix, 'help');
|
||||||
|
writeCommandEntry(destDir, kind.prefix, 'phase');
|
||||||
|
// Foreign entry
|
||||||
|
fs.writeFileSync(path.join(destDir, 'user-custom.md'), '# user\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uninstallRuntimeArtifacts(runtime, configDir, 'global');
|
||||||
|
|
||||||
|
for (const kind of layout.kinds) {
|
||||||
|
const destDir = path.join(configDir, kind.destSubpath);
|
||||||
|
|
||||||
|
if (kind.kind === 'skills') {
|
||||||
|
// gsd-prefixed skill dirs must be gone
|
||||||
|
const helpDir = path.join(destDir, `${kind.prefix}help`);
|
||||||
|
const phaseDir = path.join(destDir, `${kind.prefix}phase`);
|
||||||
|
assert.ok(
|
||||||
|
!fs.existsSync(helpDir),
|
||||||
|
`${runtime}: ${kind.prefix}help dir must be removed`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!fs.existsSync(phaseDir),
|
||||||
|
`${runtime}: ${kind.prefix}phase dir must be removed`
|
||||||
|
);
|
||||||
|
// Foreign entry must survive
|
||||||
|
const foreignDir = path.join(destDir, 'user-custom-skill');
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(path.join(foreignDir, 'SKILL.md')),
|
||||||
|
`${runtime}: user-custom-skill/SKILL.md must be preserved`
|
||||||
|
);
|
||||||
|
} else if (kind.kind === 'commands' || kind.kind === 'agents') {
|
||||||
|
// gsd-prefixed files must be gone
|
||||||
|
assert.ok(
|
||||||
|
!fs.existsSync(path.join(destDir, `${kind.prefix}help.md`)),
|
||||||
|
`${runtime}: ${kind.prefix}help.md must be removed`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!fs.existsSync(path.join(destDir, `${kind.prefix}phase.md`)),
|
||||||
|
`${runtime}: ${kind.prefix}phase.md must be removed`
|
||||||
|
);
|
||||||
|
// Foreign file must survive
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(path.join(destDir, 'user-custom.md')),
|
||||||
|
`${runtime}: user-custom.md must be preserved`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// describe 3 — installRuntimeArtifacts: legacy migrations run before layout copy
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('installRuntimeArtifacts — legacy migrations run before layout-driven copy', () => {
|
||||||
|
test('claude: legacy commands/gsd/dev-preferences.md migrated AND new skills written', (t) => {
|
||||||
|
const configDir = createTempDir('gsd-legacy-install-');
|
||||||
|
t.after(() => cleanup(configDir));
|
||||||
|
|
||||||
|
assert.strictEqual(typeof installRuntimeArtifacts, 'function');
|
||||||
|
|
||||||
|
// Pre-create the legacy state: commands/gsd/dev-preferences.md
|
||||||
|
const legacyCommandsGsd = path.join(configDir, 'commands', 'gsd');
|
||||||
|
fs.mkdirSync(legacyCommandsGsd, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(legacyCommandsGsd, 'dev-preferences.md'),
|
||||||
|
'# My dev preferences\n'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Call the new unified function
|
||||||
|
installRuntimeArtifacts('claude', configDir, 'global', RESOLVED_CORE);
|
||||||
|
|
||||||
|
// Legacy migration side-effect: commands/gsd/ must be removed (legacy cleanup)
|
||||||
|
assert.ok(
|
||||||
|
!fs.existsSync(legacyCommandsGsd),
|
||||||
|
'claude: legacy commands/gsd/ must be cleaned up by install migration'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Migration side-effect: dev-preferences.md → skills/gsd-dev-preferences/SKILL.md
|
||||||
|
const devPrefSkill = path.join(configDir, 'skills', 'gsd-dev-preferences', 'SKILL.md');
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(devPrefSkill),
|
||||||
|
'claude: skills/gsd-dev-preferences/SKILL.md must be written by legacy migration'
|
||||||
|
);
|
||||||
|
|
||||||
|
// New skills must also be present — layout-driven copy ran
|
||||||
|
const helpSkill = path.join(configDir, 'skills', 'gsd-help', 'SKILL.md');
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(helpSkill),
|
||||||
|
'claude: skills/gsd-help/SKILL.md must be written by layout-driven install'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hermes: legacy flat skills/gsd-*/ migrated AND new nested skills/gsd/<stem>/ written', (t) => {
|
||||||
|
const configDir = createTempDir('gsd-legacy-hermes-install-');
|
||||||
|
t.after(() => cleanup(configDir));
|
||||||
|
|
||||||
|
assert.strictEqual(typeof installRuntimeArtifacts, 'function');
|
||||||
|
|
||||||
|
// Pre-create legacy flat layout (pre-#2841): skills/gsd-help/SKILL.md
|
||||||
|
const flatSkillsDir = path.join(configDir, 'skills');
|
||||||
|
const legacyFlatHelp = path.join(flatSkillsDir, 'gsd-help');
|
||||||
|
fs.mkdirSync(legacyFlatHelp, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(legacyFlatHelp, 'SKILL.md'), '# legacy help\n');
|
||||||
|
|
||||||
|
installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_CORE);
|
||||||
|
|
||||||
|
// Legacy flat entry must be removed
|
||||||
|
assert.ok(
|
||||||
|
!fs.existsSync(legacyFlatHelp),
|
||||||
|
'hermes: legacy skills/gsd-help/ must be removed by install migration'
|
||||||
|
);
|
||||||
|
|
||||||
|
// New nested layout must exist: skills/gsd/help/SKILL.md
|
||||||
|
const newNestedHelp = path.join(configDir, 'skills', 'gsd', 'help', 'SKILL.md');
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(newNestedHelp),
|
||||||
|
'hermes: skills/gsd/help/SKILL.md must be written by layout-driven install'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// describe 4 — uninstallRuntimeArtifacts: legacy cleanup runs before layout removal
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('uninstallRuntimeArtifacts — legacy cleanup runs before layout-driven removal', () => {
|
||||||
|
test('hermes: both flat (pre-#2841 gsd-*/) and nested (gsd/<stem>/) layouts removed', (t) => {
|
||||||
|
const configDir = createTempDir('gsd-legacy-uninstall-hermes-');
|
||||||
|
t.after(() => cleanup(configDir));
|
||||||
|
|
||||||
|
assert.strictEqual(typeof uninstallRuntimeArtifacts, 'function');
|
||||||
|
|
||||||
|
const skillsDir = path.join(configDir, 'skills');
|
||||||
|
|
||||||
|
// Pre-create legacy flat layout (pre-#2841): skills/gsd-help/SKILL.md
|
||||||
|
const flatHelpDir = path.join(skillsDir, 'gsd-help');
|
||||||
|
fs.mkdirSync(flatHelpDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(flatHelpDir, 'SKILL.md'), '# legacy flat help\n');
|
||||||
|
|
||||||
|
const flatPhaseDir = path.join(skillsDir, 'gsd-phase');
|
||||||
|
fs.mkdirSync(flatPhaseDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(flatPhaseDir, 'SKILL.md'), '# legacy flat phase\n');
|
||||||
|
|
||||||
|
// Pre-create nested layout (post-#2841): skills/gsd/help/SKILL.md
|
||||||
|
const nestedGsd = path.join(skillsDir, 'gsd');
|
||||||
|
const nestedHelpDir = path.join(nestedGsd, 'help');
|
||||||
|
fs.mkdirSync(nestedHelpDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(nestedHelpDir, 'SKILL.md'), '# nested help\n');
|
||||||
|
|
||||||
|
const nestedPhaseDir = path.join(nestedGsd, 'phase');
|
||||||
|
fs.mkdirSync(nestedPhaseDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(nestedPhaseDir, 'SKILL.md'), '# nested phase\n');
|
||||||
|
|
||||||
|
// Non-gsd sibling at skills/ level — must survive
|
||||||
|
const userSkillDir = path.join(skillsDir, 'user-skill');
|
||||||
|
fs.mkdirSync(userSkillDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(userSkillDir, 'SKILL.md'), '# user skill\n');
|
||||||
|
|
||||||
|
uninstallRuntimeArtifacts('hermes', configDir, 'global');
|
||||||
|
|
||||||
|
// Flat legacy entries must be gone
|
||||||
|
assert.ok(
|
||||||
|
!fs.existsSync(flatHelpDir),
|
||||||
|
'hermes uninstall: legacy skills/gsd-help/ must be removed'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!fs.existsSync(flatPhaseDir),
|
||||||
|
'hermes uninstall: legacy skills/gsd-phase/ must be removed'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Nested layout must be gone
|
||||||
|
assert.ok(
|
||||||
|
!fs.existsSync(nestedGsd),
|
||||||
|
'hermes uninstall: skills/gsd/ (nested) must be removed'
|
||||||
|
);
|
||||||
|
|
||||||
|
// User skill must survive
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(path.join(userSkillDir, 'SKILL.md')),
|
||||||
|
'hermes uninstall: skills/user-skill/ must be preserved'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('claude: legacy commands/gsd/ cleaned up AND new skills/ entries removed by uninstall', (t) => {
|
||||||
|
const configDir = createTempDir('gsd-legacy-uninstall-claude-');
|
||||||
|
t.after(() => cleanup(configDir));
|
||||||
|
|
||||||
|
assert.strictEqual(typeof uninstallRuntimeArtifacts, 'function');
|
||||||
|
|
||||||
|
// Pre-create layout-driven gsd skills
|
||||||
|
const skillsDir = path.join(configDir, 'skills');
|
||||||
|
const gsdHelpDir = path.join(skillsDir, 'gsd-help');
|
||||||
|
fs.mkdirSync(gsdHelpDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(gsdHelpDir, 'SKILL.md'), '# help\n');
|
||||||
|
|
||||||
|
// Pre-create legacy commands/gsd/ (should also be cleaned)
|
||||||
|
const legacyCommandsGsd = path.join(configDir, 'commands', 'gsd');
|
||||||
|
fs.mkdirSync(legacyCommandsGsd, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(legacyCommandsGsd, 'help.md'), '# help legacy\n');
|
||||||
|
|
||||||
|
// Foreign skill — must survive
|
||||||
|
const userSkillDir = path.join(skillsDir, 'user-skill');
|
||||||
|
fs.mkdirSync(userSkillDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(userSkillDir, 'SKILL.md'), '# user\n');
|
||||||
|
|
||||||
|
uninstallRuntimeArtifacts('claude', configDir, 'global');
|
||||||
|
|
||||||
|
// gsd-help must be removed
|
||||||
|
assert.ok(
|
||||||
|
!fs.existsSync(gsdHelpDir),
|
||||||
|
'claude uninstall: skills/gsd-help/ must be removed'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Foreign skill must survive
|
||||||
|
assert.ok(
|
||||||
|
fs.existsSync(path.join(userSkillDir, 'SKILL.md')),
|
||||||
|
'claude uninstall: user-skill/ must be preserved'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Legacy commands/gsd/ must also be cleaned by legacy migration running first
|
||||||
|
assert.ok(
|
||||||
|
!fs.existsSync(legacyCommandsGsd),
|
||||||
|
'claude uninstall: legacy commands/gsd/ must be cleaned up'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -215,7 +215,15 @@ function assertFreshInstallContract(runtime, targetDir) {
|
|||||||
// the same flat-skills surface as the other runtimes.
|
// the same flat-skills surface as the other runtimes.
|
||||||
assertHasGsdDirectory(targetDir, 'skills');
|
assertHasGsdDirectory(targetDir, 'skills');
|
||||||
} else if (contract.surface === 'hermes-skills') {
|
} else if (contract.surface === 'hermes-skills') {
|
||||||
assertHasGsdDirectory(targetDir, path.join('skills', 'gsd'));
|
// Hermes layout uses prefix: '' — skill dirs have bare stem names (no gsd- prefix).
|
||||||
|
// Assert that the category dir contains at least one skill dir with SKILL.md.
|
||||||
|
const hermesGsdDir = path.join(targetDir, 'skills', 'gsd');
|
||||||
|
const hermesSkillCount = fs.existsSync(hermesGsdDir)
|
||||||
|
? fs.readdirSync(hermesGsdDir, { withFileTypes: true })
|
||||||
|
.filter(e => e.isDirectory() && fs.existsSync(path.join(hermesGsdDir, e.name, 'SKILL.md')))
|
||||||
|
.length
|
||||||
|
: 0;
|
||||||
|
assert.ok(hermesSkillCount > 0, `skills/gsd should contain generated GSD entries (got ${hermesSkillCount})`);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'DESCRIPTION.md')),
|
fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'DESCRIPTION.md')),
|
||||||
'Hermes should install the nested GSD category description'
|
'Hermes should install the nested GSD category description'
|
||||||
|
|||||||
@@ -21,9 +21,17 @@ const fs = require('fs');
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
convertClaudeCommandToClaudeSkill,
|
convertClaudeCommandToClaudeSkill,
|
||||||
copyCommandsAsClaudeSkills,
|
installRuntimeArtifacts,
|
||||||
} = require('../bin/install.js');
|
} = require('../bin/install.js');
|
||||||
|
|
||||||
|
const {
|
||||||
|
loadSkillsManifest,
|
||||||
|
resolveProfile,
|
||||||
|
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
|
||||||
|
|
||||||
|
const manifest = loadSkillsManifest();
|
||||||
|
const resolvedProfileFull = resolveProfile({ modes: [], manifest });
|
||||||
|
|
||||||
// ─── convertClaudeCommandToClaudeSkill (used by Qwen via copyCommandsAsClaudeSkills) ──
|
// ─── convertClaudeCommandToClaudeSkill (used by Qwen via copyCommandsAsClaudeSkills) ──
|
||||||
|
|
||||||
describe('Qwen Code: convertClaudeCommandToClaudeSkill', () => {
|
describe('Qwen Code: convertClaudeCommandToClaudeSkill', () => {
|
||||||
@@ -118,9 +126,9 @@ describe('Qwen Code: convertClaudeCommandToClaudeSkill', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── copyCommandsAsClaudeSkills (used for Qwen skills install) ─────────────
|
// ─── installRuntimeArtifacts (used for Qwen skills install) ─────────────────
|
||||||
|
|
||||||
describe('Qwen Code: copyCommandsAsClaudeSkills', () => {
|
describe('Qwen Code: installRuntimeArtifacts', () => {
|
||||||
let tmpDir;
|
let tmpDir;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -149,11 +157,15 @@ describe('Qwen Code: copyCommandsAsClaudeSkills', () => {
|
|||||||
'<objective>Quick task body</objective>',
|
'<objective>Quick task body</objective>',
|
||||||
].join('\n'));
|
].join('\n'));
|
||||||
|
|
||||||
const skillsDir = path.join(tmpDir, 'dest', 'skills');
|
const configDir = path.join(tmpDir, 'dest');
|
||||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '/test/prefix/', 'qwen', false);
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
|
// Redirect findInstallSourceRoot to the test's custom srcDir
|
||||||
|
fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir);
|
||||||
|
|
||||||
// Verify SKILL.md was created
|
installRuntimeArtifacts('qwen', configDir, 'global', resolvedProfileFull);
|
||||||
const skillPath = path.join(skillsDir, 'gsd-quick', 'SKILL.md');
|
|
||||||
|
// Qwen layout: skills/gsd-<stem>/SKILL.md (destSubpath='skills', prefix='gsd-')
|
||||||
|
const skillPath = path.join(configDir, 'skills', 'gsd-quick', 'SKILL.md');
|
||||||
assert.ok(fs.existsSync(skillPath), 'gsd-quick/SKILL.md exists');
|
assert.ok(fs.existsSync(skillPath), 'gsd-quick/SKILL.md exists');
|
||||||
|
|
||||||
// Verify content
|
// Verify content
|
||||||
@@ -164,7 +176,7 @@ describe('Qwen Code: copyCommandsAsClaudeSkills', () => {
|
|||||||
assert.ok(content.includes('<objective>'), 'body content preserved');
|
assert.ok(content.includes('<objective>'), 'body content preserved');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('replaces ~/.claude/ paths with pathPrefix', () => {
|
test('replaces ~/.claude/ paths via applyRuntimeContentRewritesInPlace', () => {
|
||||||
const srcDir = path.join(tmpDir, 'src', 'commands', 'gsd');
|
const srcDir = path.join(tmpDir, 'src', 'commands', 'gsd');
|
||||||
fs.mkdirSync(srcDir, { recursive: true });
|
fs.mkdirSync(srcDir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(srcDir, 'next.md'), [
|
fs.writeFileSync(path.join(srcDir, 'next.md'), [
|
||||||
@@ -176,15 +188,18 @@ describe('Qwen Code: copyCommandsAsClaudeSkills', () => {
|
|||||||
'Reference: @~/.claude/get-shit-done/workflows/next.md',
|
'Reference: @~/.claude/get-shit-done/workflows/next.md',
|
||||||
].join('\n'));
|
].join('\n'));
|
||||||
|
|
||||||
const skillsDir = path.join(tmpDir, 'dest', 'skills');
|
const configDir = path.join(tmpDir, 'dest');
|
||||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.qwen/', 'qwen', false);
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir);
|
||||||
|
|
||||||
const content = fs.readFileSync(path.join(skillsDir, 'gsd-next', 'SKILL.md'), 'utf8');
|
installRuntimeArtifacts('qwen', configDir, 'global', resolvedProfileFull);
|
||||||
assert.ok(content.includes('$HOME/.qwen/'), 'path replaced to .qwen/');
|
|
||||||
assert.ok(!content.includes('~/.claude/'), 'old claude path removed');
|
const content = fs.readFileSync(path.join(configDir, 'skills', 'gsd-next', 'SKILL.md'), 'utf8');
|
||||||
|
assert.ok(!content.includes('~/.claude/'), 'old claude tilde-path removed');
|
||||||
|
assert.ok(!content.includes('$HOME/.claude/'), 'old claude $HOME-path not present');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('replaces $HOME/.claude/ paths with pathPrefix', () => {
|
test('replaces $HOME/.claude/ paths via applyRuntimeContentRewritesInPlace', () => {
|
||||||
const srcDir = path.join(tmpDir, 'src', 'commands', 'gsd');
|
const srcDir = path.join(tmpDir, 'src', 'commands', 'gsd');
|
||||||
fs.mkdirSync(srcDir, { recursive: true });
|
fs.mkdirSync(srcDir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(srcDir, 'plan.md'), [
|
fs.writeFileSync(path.join(srcDir, 'plan.md'), [
|
||||||
@@ -196,12 +211,15 @@ describe('Qwen Code: copyCommandsAsClaudeSkills', () => {
|
|||||||
'Reference: $HOME/.claude/get-shit-done/workflows/plan.md',
|
'Reference: $HOME/.claude/get-shit-done/workflows/plan.md',
|
||||||
].join('\n'));
|
].join('\n'));
|
||||||
|
|
||||||
const skillsDir = path.join(tmpDir, 'dest', 'skills');
|
const configDir = path.join(tmpDir, 'dest');
|
||||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.qwen/', 'qwen', false);
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir);
|
||||||
|
|
||||||
const content = fs.readFileSync(path.join(skillsDir, 'gsd-plan', 'SKILL.md'), 'utf8');
|
installRuntimeArtifacts('qwen', configDir, 'global', resolvedProfileFull);
|
||||||
assert.ok(content.includes('$HOME/.qwen/'), 'path replaced to .qwen/');
|
|
||||||
assert.ok(!content.includes('$HOME/.claude/'), 'old claude path removed');
|
const content = fs.readFileSync(path.join(configDir, 'skills', 'gsd-plan', 'SKILL.md'), 'utf8');
|
||||||
|
assert.ok(!content.includes('$HOME/.claude/'), 'old claude $HOME-path removed');
|
||||||
|
assert.ok(!content.includes('~/.claude/'), 'old claude tilde-path not present');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('removes stale gsd- skills before installing new ones', () => {
|
test('removes stale gsd- skills before installing new ones', () => {
|
||||||
@@ -216,15 +234,19 @@ describe('Qwen Code: copyCommandsAsClaudeSkills', () => {
|
|||||||
'Body',
|
'Body',
|
||||||
].join('\n'));
|
].join('\n'));
|
||||||
|
|
||||||
const skillsDir = path.join(tmpDir, 'dest', 'skills');
|
const configDir = path.join(tmpDir, 'dest');
|
||||||
// Pre-create a stale skill
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
fs.mkdirSync(path.join(skillsDir, 'gsd-old-skill'), { recursive: true });
|
fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir);
|
||||||
fs.writeFileSync(path.join(skillsDir, 'gsd-old-skill', 'SKILL.md'), 'old');
|
|
||||||
|
|
||||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '/test/', 'qwen', false);
|
// Pre-create a stale skill at the new layout location
|
||||||
|
const staleSkillDir = path.join(configDir, 'skills', 'gsd-old-skill');
|
||||||
|
fs.mkdirSync(staleSkillDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(staleSkillDir, 'SKILL.md'), 'old');
|
||||||
|
|
||||||
assert.ok(!fs.existsSync(path.join(skillsDir, 'gsd-old-skill')), 'stale skill removed');
|
installRuntimeArtifacts('qwen', configDir, 'global', resolvedProfileFull);
|
||||||
assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-quick', 'SKILL.md')), 'new skill installed');
|
|
||||||
|
assert.ok(!fs.existsSync(staleSkillDir), 'stale skill removed');
|
||||||
|
assert.ok(fs.existsSync(path.join(configDir, 'skills', 'gsd-quick', 'SKILL.md')), 'new skill installed');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('preserves agent field in frontmatter', () => {
|
test('preserves agent field in frontmatter', () => {
|
||||||
@@ -244,10 +266,13 @@ describe('Qwen Code: copyCommandsAsClaudeSkills', () => {
|
|||||||
'Execute body',
|
'Execute body',
|
||||||
].join('\n'));
|
].join('\n'));
|
||||||
|
|
||||||
const skillsDir = path.join(tmpDir, 'dest', 'skills');
|
const configDir = path.join(tmpDir, 'dest');
|
||||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '/test/', 'qwen', false);
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir);
|
||||||
|
|
||||||
const content = fs.readFileSync(path.join(skillsDir, 'gsd-execute', 'SKILL.md'), 'utf8');
|
installRuntimeArtifacts('qwen', configDir, 'global', resolvedProfileFull);
|
||||||
|
|
||||||
|
const content = fs.readFileSync(path.join(configDir, 'skills', 'gsd-execute', 'SKILL.md'), 'utf8');
|
||||||
assert.ok(content.includes('agent: gsd-executor'), 'agent field preserved');
|
assert.ok(content.includes('agent: gsd-executor'), 'agent field preserved');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,12 +19,20 @@ const {
|
|||||||
convertClaudeToTraeMarkdown,
|
convertClaudeToTraeMarkdown,
|
||||||
convertClaudeCommandToTraeSkill,
|
convertClaudeCommandToTraeSkill,
|
||||||
convertClaudeAgentToTraeAgent,
|
convertClaudeAgentToTraeAgent,
|
||||||
copyCommandsAsTraeSkills,
|
installRuntimeArtifacts,
|
||||||
install,
|
install,
|
||||||
uninstall,
|
uninstall,
|
||||||
writeManifest,
|
writeManifest,
|
||||||
} = require('../bin/install.js');
|
} = 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', () => {
|
describe('Trae runtime directory mapping', () => {
|
||||||
test('maps Trae to .trae for local installs', () => {
|
test('maps Trae to .trae for local installs', () => {
|
||||||
assert.strictEqual(getDirName('trae'), '.trae');
|
assert.strictEqual(getDirName('trae'), '.trae');
|
||||||
@@ -133,7 +141,7 @@ Read CLAUDE.md before acting.
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('copyCommandsAsTraeSkills', () => {
|
describe('installRuntimeArtifacts (trae)', () => {
|
||||||
let tmpDir;
|
let tmpDir;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -145,12 +153,12 @@ describe('copyCommandsAsTraeSkills', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('creates one skill directory per GSD command', () => {
|
test('creates one skill directory per GSD command', () => {
|
||||||
const srcDir = path.join(__dirname, '..', 'commands', 'gsd');
|
const configDir = path.join(tmpDir, '.trae');
|
||||||
const skillsDir = path.join(tmpDir, '.trae', 'skills');
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
|
|
||||||
copyCommandsAsTraeSkills(srcDir, skillsDir, 'gsd', '$HOME/.trae/', 'trae');
|
installRuntimeArtifacts('trae', configDir, 'local', resolvedProfileFull);
|
||||||
|
|
||||||
const generated = path.join(skillsDir, 'gsd-help', 'SKILL.md');
|
const generated = path.join(configDir, 'skills', 'gsd-help', 'SKILL.md');
|
||||||
assert.ok(fs.existsSync(generated), generated);
|
assert.ok(fs.existsSync(generated), generated);
|
||||||
|
|
||||||
const content = fs.readFileSync(generated, 'utf8');
|
const content = fs.readFileSync(generated, 'utf8');
|
||||||
|
|||||||
Reference in New Issue
Block a user