fix(detect-custom-files): add skills/ to GSD_MANAGED_DIRS (#2942) (#2945)

After v1.39.0 skill consolidation (#2790), skills/ became a GSD-managed
root that the installer wipes on update. GSD_MANAGED_DIRS in gsd-tools.cjs
was missing 'skills', so user-added skill directories (e.g.
skills/custom-skill/SKILL.md) were never walked and silently destroyed
during /gsd-update.

- Add 'skills' to GSD_MANAGED_DIRS so the directory is walked
- Add tests/bug-2942-detect-custom-skills.test.cjs with 5 targeted tests
- Update tests/update-custom-backup.test.cjs: replace the now-incorrect
  "skills/ must NOT be scanned" assertion (written pre-#2790) with a test
  that verifies custom skills ARE detected and GSD-owned skills are not
  falsely flagged

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-01 09:22:13 -04:00
committed by GitHub
parent 7e9477bb30
commit faee0287a0
3 changed files with 199 additions and 13 deletions

View File

@@ -1070,6 +1070,7 @@ async function runCommand(command, args, cwd, raw, defaultValue) {
'agents',
path.join('commands', 'gsd'),
'hooks',
'skills',
];
function walkDir(dir, baseDir) {

View File

@@ -0,0 +1,177 @@
/**
* GSD Tools Tests — detect-custom-files misses skills/ directory (#2942)
*
* After v1.39.0 skill consolidation (#2790), skills/ became a GSD-managed root.
* GSD_MANAGED_DIRS was missing 'skills', so user-added skill directories like
* skills/custom-skill/SKILL.md were never walked and got silently destroyed
* during /gsd-update.
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { runGsdTools, createTempDir, cleanup } = require('./helpers.cjs');
function sha256(content) {
return crypto.createHash('sha256').update(content).digest('hex');
}
/**
* Write a fake gsd-file-manifest.json into configDir with the given file entries.
* Each entry is also written to disk so the directory structure exists.
*/
function writeManifest(configDir, files) {
const manifest = {
version: '1.39.0',
timestamp: new Date().toISOString(),
files: {}
};
for (const [relPath, content] of Object.entries(files)) {
const fullPath = path.join(configDir, relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content);
manifest.files[relPath] = sha256(content);
}
fs.writeFileSync(
path.join(configDir, 'gsd-file-manifest.json'),
JSON.stringify(manifest, null, 2)
);
}
/**
* Write a file inside configDir (creating parent dirs), but do NOT add it to the manifest.
*/
function writeCustomFile(configDir, relPath, content) {
const fullPath = path.join(configDir, relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content);
}
describe('detect-custom-files — skills/ directory missing from GSD_MANAGED_DIRS (#2942)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-2942-skills-');
});
afterEach(() => {
cleanup(tmpDir);
});
// Test 1: detects custom skill in skills/<name>/SKILL.md
test('detects custom skill file at skills/<name>/SKILL.md', () => {
writeManifest(tmpDir, {
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
});
// User-added custom skill — NOT in manifest
writeCustomFile(tmpDir, 'skills/test-custom/SKILL.md', '# My Custom Skill\n');
const result = runGsdTools(
['detect-custom-files', '--config-dir', tmpDir],
tmpDir
);
assert.ok(result.success, `Command failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.ok(Array.isArray(json.custom_files), 'custom_files should be an array');
assert.ok(json.custom_count >= 1, `custom_count should be >= 1, got ${json.custom_count}`);
assert.ok(
json.custom_files.includes('skills/test-custom/SKILL.md'),
`skills/test-custom/SKILL.md should be in custom_files; got: ${JSON.stringify(json.custom_files)}`
);
});
// Test 2: does not flag GSD-owned skills as custom (manifest-tracked path NOT in custom_files)
test('does not flag GSD-owned skill as custom when it is tracked in manifest', () => {
writeManifest(tmpDir, {
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
});
// No extra files — only the manifest-tracked skill exists
const result = runGsdTools(
['detect-custom-files', '--config-dir', tmpDir],
tmpDir
);
assert.ok(result.success, `Command failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.ok(Array.isArray(json.custom_files), 'custom_files should be an array');
assert.ok(
!json.custom_files.includes('skills/gsd-planner/SKILL.md'),
`GSD-owned skill should NOT be in custom_files; got: ${JSON.stringify(json.custom_files)}`
);
});
// Test 3: regression guard — still detects custom files in get-shit-done/workflows/
test('regression: still detects custom files in get-shit-done/workflows/', () => {
writeManifest(tmpDir, {
'get-shit-done/workflows/plan-phase.md': '# Plan Phase\n',
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
});
writeCustomFile(tmpDir, 'get-shit-done/workflows/custom-workflow.md', '# My Custom Workflow\n');
const result = runGsdTools(
['detect-custom-files', '--config-dir', tmpDir],
tmpDir
);
assert.ok(result.success, `Command failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.ok(
json.custom_files.includes('get-shit-done/workflows/custom-workflow.md'),
`custom workflow should still be detected; got: ${JSON.stringify(json.custom_files)}`
);
});
// Test 4: custom_count matches custom_files.length
test('custom_count matches custom_files.length when multiple custom skills exist', () => {
writeManifest(tmpDir, {
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
});
writeCustomFile(tmpDir, 'skills/test-custom/SKILL.md', '# Custom Skill One\n');
writeCustomFile(tmpDir, 'skills/another-custom/SKILL.md', '# Custom Skill Two\n');
const result = runGsdTools(
['detect-custom-files', '--config-dir', tmpDir],
tmpDir
);
assert.ok(result.success, `Command failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.strictEqual(
json.custom_count,
json.custom_files.length,
`custom_count (${json.custom_count}) should equal custom_files.length (${json.custom_files.length})`
);
assert.strictEqual(json.custom_count, 2, 'should detect exactly 2 custom skill files');
});
// Test 5: manifest_found: true when manifest is present
test('manifest_found is true when manifest is present', () => {
writeManifest(tmpDir, {
'skills/gsd-planner/SKILL.md': '# GSD Planner Skill\n',
});
const result = runGsdTools(
['detect-custom-files', '--config-dir', tmpDir],
tmpDir
);
assert.ok(result.success, `Command failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.strictEqual(json.manifest_found, true, 'manifest_found should be true');
});
});

View File

@@ -226,19 +226,20 @@ describe('detect-custom-files — update workflow backup detection (#1997)', ()
);
});
// #2505 — installer does NOT wipe skills/ or command/; scanning them produces
// false-positive "custom file" reports for every skill the user has installed
// from other packages.
test('does not scan skills/ directory (installer does not wipe it)', () => {
// After v1.39.0 skill consolidation (#2790), the installer wipes skills/ on
// update. skills/ is now a GSD-managed directory and must be scanned so that
// user-added skill directories are backed up before the wipe (#2942).
// GSD-owned skills (tracked in manifest) must NOT be flagged as custom.
test('scans skills/ directory and detects user-added skills not in manifest (#2942)', () => {
writeManifest(tmpDir, {
'get-shit-done/workflows/execute-phase.md': '# Execute Phase\n',
'skills/gsd-planner/SKILL.md': '# GSD Planner\n',
});
// Simulate user having third-party skills installed — none in manifest
const skillsDir = path.join(tmpDir, 'skills');
fs.mkdirSync(skillsDir, { recursive: true });
fs.writeFileSync(path.join(skillsDir, 'my-custom-skill.md'), '# My Skill\n');
fs.writeFileSync(path.join(skillsDir, 'another-plugin-skill.md'), '# Another\n');
// Simulate user having a custom skill installed — NOT in manifest
const customSkillDir = path.join(tmpDir, 'skills', 'my-custom-skill');
fs.mkdirSync(customSkillDir, { recursive: true });
fs.writeFileSync(path.join(customSkillDir, 'SKILL.md'), '# My Custom Skill\n');
const result = runGsdTools(
['detect-custom-files', '--config-dir', tmpDir],
@@ -248,10 +249,17 @@ describe('detect-custom-files — update workflow backup detection (#1997)', ()
assert.ok(result.success, `Command failed: ${result.error}`);
const json = JSON.parse(result.output);
const skillFiles = json.custom_files.filter(f => f.startsWith('skills/'));
assert.strictEqual(
skillFiles.length, 0,
`skills/ should not be scanned; got false positives: ${JSON.stringify(skillFiles)}`
// The user's custom skill should be detected
assert.ok(
json.custom_files.includes('skills/my-custom-skill/SKILL.md'),
`custom skill should be detected; got: ${JSON.stringify(json.custom_files)}`
);
// The GSD-owned skill (in manifest) should NOT be flagged as custom
assert.ok(
!json.custom_files.includes('skills/gsd-planner/SKILL.md'),
`GSD-owned skill should not be flagged as custom; got: ${JSON.stringify(json.custom_files)}`
);
});