diff --git a/tests/surface-apply.test.cjs b/tests/surface-apply.test.cjs index 1bcac5a72..dd1f07629 100644 --- a/tests/surface-apply.test.cjs +++ b/tests/surface-apply.test.cjs @@ -7,20 +7,16 @@ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); -const os = require('os'); const { writeSurface, applySurface } = require('../get-shit-done/bin/lib/surface.cjs'); const { loadSkillsManifest, writeActiveProfile } = require('../get-shit-done/bin/lib/install-profiles.cjs'); const { CLUSTERS } = require('../get-shit-done/bin/lib/clusters.cjs'); const { resolveRuntimeArtifactLayout } = require('../get-shit-done/bin/lib/runtime-artifact-layout.cjs'); +const { createTempDir, cleanup } = require('./helpers.cjs'); const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); const REAL_AGENTS_DIR = path.join(__dirname, '..', 'agents'); -function tmpDir() { - return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-surface-apply-')); -} - /** * Create a minimal fixture install dir structure for claude/local layout. * runtimeConfigDir is the layout configDir. @@ -28,7 +24,7 @@ function tmpDir() { * agentsDir = runtimeConfigDir/agents */ function createFixtureRuntime() { - const base = tmpDir(); + const base = createTempDir('gsd-surface-apply-'); const runtimeConfigDir = base; const commandsDir = path.join(runtimeConfigDir, 'commands', 'gsd'); const agentsDir = path.join(runtimeConfigDir, 'agents'); @@ -38,135 +34,123 @@ function createFixtureRuntime() { } describe('applySurface', () => { - test('core profile: only core skills appear in commandsDir', () => { + test('core profile: only core skills appear in commandsDir', (t) => { const { base, runtimeConfigDir, commandsDir, agentsDir } = createFixtureRuntime(); - try { - writeActiveProfile(runtimeConfigDir, 'core'); - writeSurface(runtimeConfigDir, { - baseProfile: 'core', - disabledClusters: [], - explicitAdds: [], - explicitRemoves: [], - }); - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); - applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); + t.after(() => cleanup(base)); + writeActiveProfile(runtimeConfigDir, 'core'); + writeSurface(runtimeConfigDir, { + baseProfile: 'core', + disabledClusters: [], + explicitAdds: [], + explicitRemoves: [], + }); + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); + applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); - const files = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md')); - // Every file should be a real stem we know about - for (const file of files) { - assert.ok(fs.existsSync(path.join(REAL_COMMANDS_DIR, file)), `unexpected file: ${file}`); - } - // At minimum core skills should be present - const coreStems = ['new-project', 'discuss-phase', 'plan-phase', 'execute-phase', 'help', 'update']; - for (const stem of coreStems) { - assert.ok(files.includes(`${stem}.md`), `core skill "${stem}" should be in commandsDir`); - } - } finally { - fs.rmSync(base, { recursive: true, force: true }); + const files = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md')); + // Every file should be a real stem we know about + for (const file of files) { + assert.ok(fs.existsSync(path.join(REAL_COMMANDS_DIR, file)), `unexpected file: ${file}`); + } + // At minimum core skills should be present + const coreStems = ['new-project', 'discuss-phase', 'plan-phase', 'execute-phase', 'help', 'update']; + for (const stem of coreStems) { + assert.ok(files.includes(`${stem}.md`), `core skill "${stem}" should be in commandsDir`); } }); - test('removes superseded files when profile shrinks', () => { + test('removes superseded files when profile shrinks', (t) => { const { base, runtimeConfigDir, commandsDir, agentsDir } = createFixtureRuntime(); - try { - // Start with standard: put some skill files in commandsDir - writeActiveProfile(runtimeConfigDir, 'standard'); - writeSurface(runtimeConfigDir, { - baseProfile: 'standard', - disabledClusters: [], - explicitAdds: [], - explicitRemoves: [], - }); - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); - applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); + t.after(() => cleanup(base)); + // Start with standard: put some skill files in commandsDir + writeActiveProfile(runtimeConfigDir, 'standard'); + writeSurface(runtimeConfigDir, { + baseProfile: 'standard', + disabledClusters: [], + explicitAdds: [], + explicitRemoves: [], + }); + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); + applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); - const afterStandard = new Set(fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'))); + const afterStandard = new Set(fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'))); - // Now switch to core: skills not in core should be removed - writeSurface(runtimeConfigDir, { - baseProfile: 'core', - disabledClusters: [], - explicitAdds: [], - explicitRemoves: [], - }); - applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); + // Now switch to core: skills not in core should be removed + writeSurface(runtimeConfigDir, { + baseProfile: 'core', + disabledClusters: [], + explicitAdds: [], + explicitRemoves: [], + }); + applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); - const afterCore = new Set(fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'))); + const afterCore = new Set(fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'))); - // core should be a subset of standard - assert.ok(afterCore.size <= afterStandard.size, 'core should have fewer or equal files than standard'); + // core should be a subset of standard + assert.ok(afterCore.size <= afterStandard.size, 'core should have fewer or equal files than standard'); - // Files removed should not be in core set - const coreStems = new Set(['new-project', 'discuss-phase', 'plan-phase', 'execute-phase', 'help', 'update']); - for (const file of afterCore) { - const stem = file.slice(0, -3); - assert.ok( - fs.existsSync(path.join(REAL_COMMANDS_DIR, file)), - `file in commandsDir not a real skill: ${file}` - ); - } - } finally { - fs.rmSync(base, { recursive: true, force: true }); - } - }); - - test('leaves non-gsd .md files alone in agentsDir', () => { - const { base, runtimeConfigDir, commandsDir, agentsDir } = createFixtureRuntime(); - try { - // Place a non-gsd agent file in agentsDir - const foreignAgent = path.join(agentsDir, 'my-custom-agent.md'); - fs.writeFileSync(foreignAgent, '# custom agent\n', 'utf8'); - - writeActiveProfile(runtimeConfigDir, 'core'); - writeSurface(runtimeConfigDir, { - baseProfile: 'core', - disabledClusters: [], - explicitAdds: [], - explicitRemoves: [], - }); - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); - applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); - - // Non-gsd file should still be there - assert.ok(fs.existsSync(foreignAgent), 'non-gsd agent file should not be touched'); - } finally { - fs.rmSync(base, { recursive: true, force: true }); - } - }); - - test('adds missing skill files from install source', () => { - const { base, runtimeConfigDir, commandsDir, agentsDir } = createFixtureRuntime(); - try { - // commandsDir starts empty - writeActiveProfile(runtimeConfigDir, 'core'); - writeSurface(runtimeConfigDir, { - baseProfile: 'core', - disabledClusters: [], - explicitAdds: [], - explicitRemoves: [], - }); - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); - applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); - - // Core skills should now be present + // Files removed should not be in core set + const coreStems = new Set(['new-project', 'discuss-phase', 'plan-phase', 'execute-phase', 'help', 'update']); + for (const file of afterCore) { + const stem = file.slice(0, -3); assert.ok( - fs.existsSync(path.join(commandsDir, 'help.md')), - 'help.md should be copied from install source' + fs.existsSync(path.join(REAL_COMMANDS_DIR, file)), + `file in commandsDir not a real skill: ${file}` ); - assert.ok( - fs.existsSync(path.join(commandsDir, 'new-project.md')), - 'new-project.md should be copied from install source' - ); - } finally { - fs.rmSync(base, { recursive: true, force: true }); } }); - test('_syncGsdDir skills kind: adds missing skill dirs, removes stale prefix-matched dirs, preserves foreign dirs', () => { + test('leaves non-gsd .md files alone in agentsDir', (t) => { + const { base, runtimeConfigDir, commandsDir, agentsDir } = createFixtureRuntime(); + t.after(() => cleanup(base)); + // Place a non-gsd agent file in agentsDir + const foreignAgent = path.join(agentsDir, 'my-custom-agent.md'); + fs.writeFileSync(foreignAgent, '# custom agent\n', 'utf8'); + + writeActiveProfile(runtimeConfigDir, 'core'); + writeSurface(runtimeConfigDir, { + baseProfile: 'core', + disabledClusters: [], + explicitAdds: [], + explicitRemoves: [], + }); + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); + applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); + + // Non-gsd file should still be there + assert.ok(fs.existsSync(foreignAgent), 'non-gsd agent file should not be touched'); + }); + + test('adds missing skill files from install source', (t) => { + const { base, runtimeConfigDir, commandsDir, agentsDir } = createFixtureRuntime(); + t.after(() => cleanup(base)); + // commandsDir starts empty + writeActiveProfile(runtimeConfigDir, 'core'); + writeSurface(runtimeConfigDir, { + baseProfile: 'core', + disabledClusters: [], + explicitAdds: [], + explicitRemoves: [], + }); + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); + applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); + + // Core skills should now be present + assert.ok( + fs.existsSync(path.join(commandsDir, 'help.md')), + 'help.md should be copied from install source' + ); + assert.ok( + fs.existsSync(path.join(commandsDir, 'new-project.md')), + 'new-project.md should be copied from install source' + ); + }); + + test('_syncGsdDir skills kind: adds missing skill dirs, removes stale prefix-matched dirs, preserves foreign dirs', (t) => { const { _syncGsdDir } = require('../get-shit-done/bin/lib/surface.cjs'); const { stageSkillsForRuntimeAsSkills } = require('../get-shit-done/bin/lib/install-profiles.cjs'); const { findInstallSourceRoot } = require('../get-shit-done/bin/lib/runtime-artifact-layout.cjs'); @@ -180,79 +164,73 @@ describe('applySurface', () => { ].join('\n'); } - const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-surface-skills-')); - try { - const stagedDir = path.join(base, 'staged'); - const destDir = path.join(base, 'dest'); - fs.mkdirSync(destDir, { recursive: true }); + const base = createTempDir('gsd-surface-skills-'); + t.after(() => cleanup(base)); + const stagedDir = path.join(base, 'staged'); + const destDir = path.join(base, 'dest'); + fs.mkdirSync(destDir, { recursive: true }); - // Build a staged dir manually: gsd-help/SKILL.md and gsd-update/SKILL.md - const stem1 = 'gsd-help'; - const stem2 = 'gsd-update'; - fs.mkdirSync(path.join(stagedDir, stem1), { recursive: true }); - fs.writeFileSync(path.join(stagedDir, stem1, 'SKILL.md'), '# help\n', 'utf8'); - fs.mkdirSync(path.join(stagedDir, stem2), { recursive: true }); - fs.writeFileSync(path.join(stagedDir, stem2, 'SKILL.md'), '# update\n', 'utf8'); + // Build a staged dir manually: gsd-help/SKILL.md and gsd-update/SKILL.md + const stem1 = 'gsd-help'; + const stem2 = 'gsd-update'; + fs.mkdirSync(path.join(stagedDir, stem1), { recursive: true }); + fs.writeFileSync(path.join(stagedDir, stem1, 'SKILL.md'), '# help\n', 'utf8'); + fs.mkdirSync(path.join(stagedDir, stem2), { recursive: true }); + fs.writeFileSync(path.join(stagedDir, stem2, 'SKILL.md'), '# update\n', 'utf8'); - // In destDir: stale gsd- dir + foreign user dir - const staleDir = path.join(destDir, 'gsd-old-skill'); - fs.mkdirSync(staleDir, { recursive: true }); - fs.writeFileSync(path.join(staleDir, 'SKILL.md'), '# old\n', 'utf8'); + // In destDir: stale gsd- dir + foreign user dir + const staleDir = path.join(destDir, 'gsd-old-skill'); + fs.mkdirSync(staleDir, { recursive: true }); + fs.writeFileSync(path.join(staleDir, 'SKILL.md'), '# old\n', 'utf8'); - const foreignDir = path.join(destDir, 'my-custom-skill'); - fs.mkdirSync(foreignDir, { recursive: true }); - fs.writeFileSync(path.join(foreignDir, 'SKILL.md'), '# custom\n', 'utf8'); + const foreignDir = path.join(destDir, 'my-custom-skill'); + fs.mkdirSync(foreignDir, { recursive: true }); + fs.writeFileSync(path.join(foreignDir, 'SKILL.md'), '# custom\n', 'utf8'); - const skillsKind = { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-', stage: () => stagedDir }; + const skillsKind = { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-', stage: () => stagedDir }; - _syncGsdDir(stagedDir, destDir, skillsKind); + _syncGsdDir(stagedDir, destDir, skillsKind); - // staged dirs copied - assert.ok(fs.existsSync(path.join(destDir, stem1, 'SKILL.md')), 'gsd-help/SKILL.md should be copied'); - assert.ok(fs.existsSync(path.join(destDir, stem2, 'SKILL.md')), 'gsd-update/SKILL.md should be copied'); + // staged dirs copied + assert.ok(fs.existsSync(path.join(destDir, stem1, 'SKILL.md')), 'gsd-help/SKILL.md should be copied'); + assert.ok(fs.existsSync(path.join(destDir, stem2, 'SKILL.md')), 'gsd-update/SKILL.md should be copied'); - // stale gsd- dir removed - assert.ok(!fs.existsSync(staleDir), 'stale gsd-old-skill dir should be removed'); + // stale gsd- dir removed + assert.ok(!fs.existsSync(staleDir), 'stale gsd-old-skill dir should be removed'); - // foreign dir preserved - assert.ok(fs.existsSync(foreignDir), 'my-custom-skill dir should be preserved'); - } finally { - fs.rmSync(base, { recursive: true, force: true }); - } + // foreign dir preserved + assert.ok(fs.existsSync(foreignDir), 'my-custom-skill dir should be preserved'); }); - test('_syncGsdDir skills kind (hermes): preserves non-GSD user dir under skills/gsd/ when kindPrefix is empty', () => { + test('_syncGsdDir skills kind (hermes): preserves non-GSD user dir under skills/gsd/ when kindPrefix is empty', (t) => { const { _syncGsdDir } = require('../get-shit-done/bin/lib/surface.cjs'); - const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-surface-hermes-')); - try { - const stagedDir = path.join(base, 'staged'); - const destDir = path.join(base, 'dest'); - fs.mkdirSync(destDir, { recursive: true }); + const base = createTempDir('gsd-surface-hermes-'); + t.after(() => cleanup(base)); + const stagedDir = path.join(base, 'staged'); + const destDir = path.join(base, 'dest'); + fs.mkdirSync(destDir, { recursive: true }); - // Staged contains a GSD skill named 'help' (no prefix under hermes skills/gsd/) - const stem1 = 'help'; - fs.mkdirSync(path.join(stagedDir, stem1), { recursive: true }); - fs.writeFileSync(path.join(stagedDir, stem1, 'SKILL.md'), '# help\n', 'utf8'); + // Staged contains a GSD skill named 'help' (no prefix under hermes skills/gsd/) + const stem1 = 'help'; + fs.mkdirSync(path.join(stagedDir, stem1), { recursive: true }); + fs.writeFileSync(path.join(stagedDir, stem1, 'SKILL.md'), '# help\n', 'utf8'); - // Dest also has a user-owned custom skill dir (no gsd- prefix — Hermes namespace) - const userDir = path.join(destDir, 'user-custom-skill'); - fs.mkdirSync(userDir, { recursive: true }); - fs.writeFileSync(path.join(userDir, 'SKILL.md'), '# user custom\n', 'utf8'); + // Dest also has a user-owned custom skill dir (no gsd- prefix — Hermes namespace) + const userDir = path.join(destDir, 'user-custom-skill'); + fs.mkdirSync(userDir, { recursive: true }); + fs.writeFileSync(path.join(userDir, 'SKILL.md'), '# user custom\n', 'utf8'); - // kindPrefix === '' simulates Hermes (destSubpath = skills/gsd, prefix = '') - const hermesKind = { kind: 'skills', destSubpath: 'skills/gsd', prefix: '', stage: () => stagedDir }; + // kindPrefix === '' simulates Hermes (destSubpath = skills/gsd, prefix = '') + const hermesKind = { kind: 'skills', destSubpath: 'skills/gsd', prefix: '', stage: () => stagedDir }; - _syncGsdDir(stagedDir, destDir, hermesKind); + _syncGsdDir(stagedDir, destDir, hermesKind); - // The user's custom skill dir must be preserved — it's not in staged but should not be removed - // (Fix 4: when kindPrefix === '', skip the startsWith guard and preserve ALL non-staged dirs) - assert.ok(fs.existsSync(userDir), 'user-custom-skill dir must be preserved when kindPrefix is empty (Hermes)'); + // The user's custom skill dir must be preserved — it's not in staged but should not be removed + // (Fix 4: when kindPrefix === '', skip the startsWith guard and preserve ALL non-staged dirs) + assert.ok(fs.existsSync(userDir), 'user-custom-skill dir must be preserved when kindPrefix is empty (Hermes)'); - // The staged skill must still be copied - assert.ok(fs.existsSync(path.join(destDir, stem1, 'SKILL.md')), 'GSD help/SKILL.md must be copied'); - } finally { - fs.rmSync(base, { recursive: true, force: true }); - } + // The staged skill must still be copied + assert.ok(fs.existsSync(path.join(destDir, stem1, 'SKILL.md')), 'GSD help/SKILL.md must be copied'); }); });