diff --git a/test/test-installation-components.js b/test/test-installation-components.js index d5d1a6e62..6e015322a 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -3273,6 +3273,51 @@ async function runTests() { console.log(''); + // ============================================================ + // Test Suite 45: _cleanupSkillDirs prunes empty parent dirs (#empty-bmm-folders) + // ============================================================ + console.log(`${colors.yellow}Test Suite 45: cleanup prunes empty skill-group dirs${colors.reset}\n`); + + let root45; + try { + root45 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-cleanup-test-')); + const bmadDir45 = path.join(root45, '_bmad'); + await fs.ensureDir(path.join(bmadDir45, '_config')); + + // Two skills nested under the same grouping dir (1-analysis), plus a + // module-level file that must survive the cleanup. + await fs.writeFile( + path.join(bmadDir45, '_config', 'skill-manifest.csv'), + [ + 'canonicalId,name,description,module,path', + '"bmad-agent-analyst","bmad-agent-analyst","fixture","bmm","_bmad/bmm/1-analysis/bmad-agent-analyst/SKILL.md"', + '"bmad-research","bmad-research","fixture","bmm","_bmad/bmm/1-analysis/research/bmad-research/SKILL.md"', + '', + ].join('\n'), + ); + await fs.ensureDir(path.join(bmadDir45, 'bmm', '1-analysis', 'bmad-agent-analyst')); + await fs.writeFile(path.join(bmadDir45, 'bmm', '1-analysis', 'bmad-agent-analyst', 'SKILL.md'), 'x'); + await fs.ensureDir(path.join(bmadDir45, 'bmm', '1-analysis', 'research', 'bmad-research')); + await fs.writeFile(path.join(bmadDir45, 'bmm', '1-analysis', 'research', 'bmad-research', 'SKILL.md'), 'x'); + await fs.writeFile(path.join(bmadDir45, 'bmm', 'config.yaml'), 'module: bmm\n'); + + const installer45 = new Installer(); + await installer45._cleanupSkillDirs(bmadDir45); + + assert(!(await fs.pathExists(path.join(bmadDir45, 'bmm', '1-analysis'))), 'empty skill-group dir is pruned after cleanup'); + assert(!(await fs.pathExists(path.join(bmadDir45, 'bmm', '1-analysis', 'research'))), 'empty nested skill-group dir is pruned'); + assert(await fs.pathExists(path.join(bmadDir45, 'bmm', 'config.yaml')), 'module-level files are preserved'); + assert(await fs.pathExists(bmadDir45), 'bmad root is never removed'); + } catch (error) { + console.log(`${colors.red}Test Suite 45 setup failed: ${error.message}${colors.reset}`); + console.log(error.stack); + failed++; + } finally { + if (root45) await fs.remove(root45).catch(() => {}); + } + + console.log(''); + // ============================================================ // Summary // ============================================================ diff --git a/tools/installer/core/installer.js b/tools/installer/core/installer.js index 9347e1f0b..df9955f40 100644 --- a/tools/installer/core/installer.js +++ b/tools/installer/core/installer.js @@ -419,10 +419,35 @@ class Installer { const sourceDir = path.dirname(path.join(bmadDir, relativePath)); if (await fs.pathExists(sourceDir)) { await fs.remove(sourceDir); + await this._removeEmptyParents(path.dirname(sourceDir), bmadDir); } } } + /** + * Remove now-empty parent directories left behind after skill dir cleanup. + * Walks up from dir, stopping at (and never removing) bmadDir. Best-effort: + * a directory that vanishes or fills in mid-walk just ends the walk. + * @param {string} dir - Directory to start walking up from + * @param {string} bmadDir - BMAD installation directory (boundary) + */ + async _removeEmptyParents(dir, bmadDir) { + let current = dir; + while (true) { + // Path-boundary check (not a string prefix, so siblings like _bmad2 don't match). + const rel = path.relative(bmadDir, current); + if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) break; + try { + const entries = await fs.readdir(current); + if (entries.length > 0) break; + await fs.rmdir(current); + } catch { + break; + } + current = path.dirname(current); + } + } + async _readSkillManifestRows(bmadDir) { const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv'); if (!(await fs.pathExists(csvPath))) return [];