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:
@@ -1070,6 +1070,7 @@ async function runCommand(command, args, cwd, raw, defaultValue) {
|
||||
'agents',
|
||||
path.join('commands', 'gsd'),
|
||||
'hooks',
|
||||
'skills',
|
||||
];
|
||||
|
||||
function walkDir(dir, baseDir) {
|
||||
|
||||
177
tests/bug-2942-detect-custom-skills.test.cjs
Normal file
177
tests/bug-2942-detect-custom-skills.test.cjs
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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)}`
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user