From a12d5d03b5824285ce2e4de8e81051e121232985 Mon Sep 17 00:00:00 2001 From: Adam Biggs Date: Thu, 26 Feb 2026 11:10:14 -0800 Subject: [PATCH 1/5] fix(installer): replace fs-extra with native fs to prevent non-deterministic file loss fs-extra routes all async operations through graceful-fs, whose EMFILE retry queue causes non-deterministic file loss on macOS APFS during bulk copy operations (~500+ files). Approximately 50% of install runs lose 26+ files from _bmad/. Replace fs-extra entirely with a thin native wrapper (tools/cli/lib/fs.js) that provides the same API surface backed by node:fs and node:fs/promises. Copy and remove operations use synchronous native calls to eliminate the race condition. Verified across 8+ consecutive runs with zero file loss. - Add tools/cli/lib/fs.js native wrapper with full fs-extra API compat - Update all 40 files to require the wrapper instead of fs-extra - Remove fs-extra from package.json dependencies - Add 37-test suite including 250-file bulk copy determinism test --- package.json | 4 +- test/test-fs-wrapper.js | 488 ++++++++++++++++++ test/test-install-to-bmad.js | 2 +- test/test-installation-components.js | 10 +- tools/cli/commands/status.js | 2 +- tools/cli/commands/uninstall.js | 2 +- .../installers/lib/core/config-collector.js | 2 +- .../lib/core/custom-module-cache.js | 2 +- .../lib/core/dependency-resolver.js | 2 +- tools/cli/installers/lib/core/detector.js | 2 +- .../installers/lib/core/ide-config-manager.js | 2 +- tools/cli/installers/lib/core/installer.js | 77 ++- .../installers/lib/core/manifest-generator.js | 2 +- tools/cli/installers/lib/core/manifest.js | 2 +- tools/cli/installers/lib/custom/handler.js | 2 +- tools/cli/installers/lib/ide/_base-ide.js | 2 +- .../cli/installers/lib/ide/_config-driven.js | 2 +- .../cli/installers/lib/ide/platform-codes.js | 2 +- .../lib/ide/shared/agent-command-generator.js | 2 +- .../lib/ide/shared/bmad-artifacts.js | 2 +- .../lib/ide/shared/module-injections.js | 2 +- .../lib/ide/shared/skill-manifest.js | 2 +- .../ide/shared/task-tool-command-generator.js | 2 +- .../ide/shared/workflow-command-generator.js | 2 +- tools/cli/installers/lib/message-loader.js | 2 +- .../lib/modules/external-manager.js | 2 +- tools/cli/installers/lib/modules/manager.js | 42 +- tools/cli/lib/activation-builder.js | 2 +- tools/cli/lib/agent-analyzer.js | 2 +- tools/cli/lib/agent-party-generator.js | 2 +- tools/cli/lib/config.js | 2 +- tools/cli/lib/file-ops.js | 2 +- tools/cli/lib/fs.js | 167 ++++++ tools/cli/lib/platform-codes.js | 2 +- tools/cli/lib/project-root.js | 2 +- tools/cli/lib/ui.js | 2 +- tools/cli/lib/xml-handler.js | 2 +- tools/cli/lib/yaml-xml-builder.js | 2 +- tools/migrate-custom-module-paths.js | 2 +- 39 files changed, 785 insertions(+), 69 deletions(-) create mode 100644 test/test-fs-wrapper.js create mode 100644 tools/cli/lib/fs.js diff --git a/package.json b/package.json index f3207f5fa..7179b0507 100644 --- a/package.json +++ b/package.json @@ -40,8 +40,9 @@ "lint:md": "markdownlint-cli2 \"**/*.md\"", "prepare": "command -v husky >/dev/null 2>&1 && husky || exit 0", "rebundle": "node tools/cli/bundlers/bundle-web.js rebundle", - "test": "npm run test:schemas && npm run test:refs && npm run test:install && npm run validate:schemas && npm run lint && npm run lint:md && npm run format:check", + "test": "npm run test:schemas && npm run test:refs && npm run test:install && npm run test:fs && npm run validate:schemas && npm run lint && npm run lint:md && npm run format:check", "test:coverage": "c8 --reporter=text --reporter=html npm run test:schemas", + "test:fs": "node test/test-fs-wrapper.js", "test:install": "node test/test-installation-components.js", "test:refs": "node test/test-file-refs-csv.js", "test:schemas": "node test/test-agent-schema.js", @@ -71,7 +72,6 @@ "chalk": "^4.1.2", "commander": "^14.0.0", "csv-parse": "^6.1.0", - "fs-extra": "^11.3.0", "glob": "^11.0.3", "ignore": "^7.0.5", "js-yaml": "^4.1.0", diff --git a/test/test-fs-wrapper.js b/test/test-fs-wrapper.js new file mode 100644 index 000000000..cfafbbd88 --- /dev/null +++ b/test/test-fs-wrapper.js @@ -0,0 +1,488 @@ +/** + * Native fs Wrapper Tests + * + * Validates that tools/cli/lib/fs.js provides the same API surface + * as fs-extra but backed entirely by native node:fs. Exercises every + * exported method the CLI codebase relies on. + * + * Usage: node test/test-fs-wrapper.js + * Exit codes: 0 = all tests pass, 1 = test failures + */ + +const nativeFs = require('node:fs'); +const path = require('node:path'); +const fs = require('../tools/cli/lib/fs'); + +// ANSI color codes +const colors = { + reset: '\u001B[0m', + green: '\u001B[32m', + red: '\u001B[31m', + yellow: '\u001B[33m', + cyan: '\u001B[36m', + dim: '\u001B[2m', +}; + +let totalTests = 0; +let passedTests = 0; +const failures = []; + +function test(name, fn) { + totalTests++; + try { + fn(); + passedTests++; + console.log(` ${colors.green}\u2713${colors.reset} ${name}`); + } catch (error) { + console.log(` ${colors.red}\u2717${colors.reset} ${name} ${colors.red}${error.message}${colors.reset}`); + failures.push({ name, message: error.message }); + } +} + +async function asyncTest(name, fn) { + totalTests++; + try { + await fn(); + passedTests++; + console.log(` ${colors.green}\u2713${colors.reset} ${name}`); + } catch (error) { + console.log(` ${colors.red}\u2717${colors.reset} ${name} ${colors.red}${error.message}${colors.reset}`); + failures.push({ name, message: error.message }); + } +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function assertEqual(actual, expected, message) { + if (actual !== expected) { + throw new Error(`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +// ── Test fixtures ─────────────────────────────────────────────────────────── + +const TMP = path.join(__dirname, '.tmp-fs-wrapper-test'); + +function setup() { + nativeFs.rmSync(TMP, { recursive: true, force: true }); + nativeFs.mkdirSync(TMP, { recursive: true }); +} + +function teardown() { + nativeFs.rmSync(TMP, { recursive: true, force: true }); +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +async function runTests() { + console.log(`${colors.cyan}========================================`); + console.log('Native fs Wrapper Tests'); + console.log(`========================================${colors.reset}\n`); + + setup(); + + // ── Re-exported native members ────────────────────────────────────────── + + console.log(`${colors.yellow}Re-exported native fs members${colors.reset}`); + + test('exports fs.constants', () => { + assert(fs.constants !== undefined, 'fs.constants is undefined'); + assert(typeof fs.constants.F_OK === 'number', 'fs.constants.F_OK is not a number'); + }); + + test('exports fs.existsSync', () => { + assert(typeof fs.existsSync === 'function', 'fs.existsSync is not a function'); + assert(fs.existsSync(__dirname), 'existsSync returns false for existing dir'); + assert(!fs.existsSync(path.join(TMP, 'nonexistent')), 'existsSync returns true for missing path'); + }); + + test('exports fs.readFileSync', () => { + const content = fs.readFileSync(__filename, 'utf8'); + assert(content.includes('Native fs Wrapper Tests'), 'readFileSync did not return expected content'); + }); + + test('exports fs.writeFileSync', () => { + const p = path.join(TMP, 'write-sync.txt'); + fs.writeFileSync(p, 'hello sync'); + assertEqual(nativeFs.readFileSync(p, 'utf8'), 'hello sync', 'writeFileSync content mismatch'); + }); + + test('exports fs.mkdirSync', () => { + const p = path.join(TMP, 'mkdir-sync'); + fs.mkdirSync(p); + assert(nativeFs.statSync(p).isDirectory(), 'mkdirSync did not create directory'); + }); + + test('exports fs.readdirSync', () => { + const entries = fs.readdirSync(TMP); + assert(Array.isArray(entries), 'readdirSync did not return array'); + }); + + test('exports fs.statSync', () => { + const stat = fs.statSync(__dirname); + assert(stat.isDirectory(), 'statSync did not return directory stat'); + }); + + test('exports fs.copyFileSync', () => { + const src = path.join(TMP, 'copy-src.txt'); + const dest = path.join(TMP, 'copy-dest.txt'); + nativeFs.writeFileSync(src, 'copy me'); + fs.copyFileSync(src, dest); + assertEqual(nativeFs.readFileSync(dest, 'utf8'), 'copy me', 'copyFileSync content mismatch'); + }); + + test('exports fs.accessSync', () => { + // Should not throw for existing file + fs.accessSync(__filename); + let threw = false; + try { + fs.accessSync(path.join(TMP, 'nonexistent')); + } catch { + threw = true; + } + assert(threw, 'accessSync did not throw for missing file'); + }); + + test('exports fs.createReadStream', () => { + assert(typeof fs.createReadStream === 'function', 'createReadStream is not a function'); + }); + + console.log(''); + + // ── Async promise-based methods ───────────────────────────────────────── + + console.log(`${colors.yellow}Async promise-based methods${colors.reset}`); + + await asyncTest('readFile returns promise with content', async () => { + const content = await fs.readFile(__filename, 'utf8'); + assert(content.includes('Native fs Wrapper Tests'), 'readFile did not return expected content'); + }); + + await asyncTest('writeFile writes content asynchronously', async () => { + const p = path.join(TMP, 'write-async.txt'); + await fs.writeFile(p, 'hello async'); + assertEqual(nativeFs.readFileSync(p, 'utf8'), 'hello async', 'writeFile content mismatch'); + }); + + await asyncTest('readdir returns directory entries', async () => { + const dir = path.join(TMP, 'readdir-test'); + nativeFs.mkdirSync(dir, { recursive: true }); + nativeFs.writeFileSync(path.join(dir, 'a.txt'), 'a'); + const entries = await fs.readdir(dir); + assert(Array.isArray(entries), 'readdir did not return array'); + assert(entries.length > 0, 'readdir returned empty array for non-empty dir'); + }); + + await asyncTest('readdir with withFileTypes returns Dirent objects', async () => { + const dir = path.join(TMP, 'dirent-test'); + nativeFs.mkdirSync(dir, { recursive: true }); + nativeFs.writeFileSync(path.join(dir, 'file.txt'), 'content'); + nativeFs.mkdirSync(path.join(dir, 'subdir')); + + const entries = await fs.readdir(dir, { withFileTypes: true }); + assert(Array.isArray(entries), 'should return array'); + + const fileEntry = entries.find((e) => e.name === 'file.txt'); + const dirEntry = entries.find((e) => e.name === 'subdir'); + + assert(fileEntry && typeof fileEntry.isFile === 'function', 'entry should have isFile method'); + assert(dirEntry && typeof dirEntry.isDirectory === 'function', 'entry should have isDirectory method'); + assert(fileEntry.isFile(), 'file entry should return true for isFile()'); + assert(dirEntry.isDirectory(), 'dir entry should return true for isDirectory()'); + }); + + await asyncTest('stat returns file stats', async () => { + const stat = await fs.stat(__dirname); + assert(stat.isDirectory(), 'stat did not return directory stat'); + }); + + await asyncTest('access resolves for existing file', async () => { + await fs.access(__filename); // should not throw + }); + + await asyncTest('access rejects for missing file', async () => { + let threw = false; + try { + await fs.access(path.join(TMP, 'nonexistent')); + } catch { + threw = true; + } + assert(threw, 'access did not reject for missing file'); + }); + + await asyncTest('rename moves a file', async () => { + const src = path.join(TMP, 'rename-src.txt'); + const dest = path.join(TMP, 'rename-dest.txt'); + nativeFs.writeFileSync(src, 'rename me'); + await fs.rename(src, dest); + assert(!nativeFs.existsSync(src), 'rename did not remove source'); + assertEqual(nativeFs.readFileSync(dest, 'utf8'), 'rename me', 'rename content mismatch'); + }); + + await asyncTest('realpath resolves path', async () => { + const resolved = await fs.realpath(__dirname); + assert(typeof resolved === 'string', 'realpath did not return string'); + assert(resolved.length > 0, 'realpath returned empty string'); + }); + + console.log(''); + + // ── fs-extra compatible methods ───────────────────────────────────────── + + console.log(`${colors.yellow}fs-extra compatible methods${colors.reset}`); + + await asyncTest('ensureDir creates nested directories', async () => { + const p = path.join(TMP, 'ensure', 'deep', 'nested'); + await fs.ensureDir(p); + assert(nativeFs.statSync(p).isDirectory(), 'ensureDir did not create nested dirs'); + }); + + await asyncTest('ensureDir is idempotent on existing directory', async () => { + const p = path.join(TMP, 'ensure', 'deep', 'nested'); + await fs.ensureDir(p); // should not throw + assert(nativeFs.statSync(p).isDirectory(), 'ensureDir failed on existing dir'); + }); + + await asyncTest('pathExists returns true for existing path', async () => { + assertEqual(await fs.pathExists(__filename), true, 'pathExists returned false for existing file'); + }); + + await asyncTest('pathExists returns false for missing path', async () => { + assertEqual(await fs.pathExists(path.join(TMP, 'nonexistent')), false, 'pathExists returned true for missing path'); + }); + + test('pathExistsSync returns true for existing path', () => { + assertEqual(fs.pathExistsSync(__filename), true, 'pathExistsSync returned false for existing file'); + }); + + test('pathExistsSync returns false for missing path', () => { + assertEqual(fs.pathExistsSync(path.join(TMP, 'nonexistent')), false, 'pathExistsSync returned true for missing path'); + }); + + await asyncTest('copy copies a single file', async () => { + const src = path.join(TMP, 'copy-file-src.txt'); + const dest = path.join(TMP, 'copy-file-dest.txt'); + nativeFs.writeFileSync(src, 'copy file'); + await fs.copy(src, dest); + assertEqual(nativeFs.readFileSync(dest, 'utf8'), 'copy file', 'copy file content mismatch'); + }); + + await asyncTest('copy creates parent directories for dest', async () => { + const src = path.join(TMP, 'copy-file-src.txt'); + const dest = path.join(TMP, 'copy-deep', 'nested', 'dest.txt'); + await fs.copy(src, dest); + assertEqual(nativeFs.readFileSync(dest, 'utf8'), 'copy file', 'copy with mkdir content mismatch'); + }); + + await asyncTest('copy copies a directory recursively', async () => { + const srcDir = path.join(TMP, 'copy-dir-src'); + nativeFs.mkdirSync(path.join(srcDir, 'sub'), { recursive: true }); + nativeFs.writeFileSync(path.join(srcDir, 'a.txt'), 'file a'); + nativeFs.writeFileSync(path.join(srcDir, 'sub', 'b.txt'), 'file b'); + + const destDir = path.join(TMP, 'copy-dir-dest'); + await fs.copy(srcDir, destDir); + + assertEqual(nativeFs.readFileSync(path.join(destDir, 'a.txt'), 'utf8'), 'file a', 'copy dir: top-level file mismatch'); + assertEqual(nativeFs.readFileSync(path.join(destDir, 'sub', 'b.txt'), 'utf8'), 'file b', 'copy dir: nested file mismatch'); + }); + + await asyncTest('copy respects overwrite: false for files', async () => { + const src = path.join(TMP, 'overwrite-src.txt'); + const dest = path.join(TMP, 'overwrite-dest.txt'); + nativeFs.writeFileSync(src, 'new content'); + nativeFs.writeFileSync(dest, 'original content'); + await fs.copy(src, dest, { overwrite: false }); + assertEqual(nativeFs.readFileSync(dest, 'utf8'), 'original content', 'copy overwrote file when overwrite: false'); + }); + + await asyncTest('copy respects overwrite: false for directories', async () => { + const srcDir = path.join(TMP, 'ow-dir-src'); + nativeFs.mkdirSync(srcDir, { recursive: true }); + nativeFs.writeFileSync(path.join(srcDir, 'file.txt'), 'new'); + + const destDir = path.join(TMP, 'ow-dir-dest'); + nativeFs.mkdirSync(destDir, { recursive: true }); + nativeFs.writeFileSync(path.join(destDir, 'file.txt'), 'original'); + + await fs.copy(srcDir, destDir, { overwrite: false }); + assertEqual(nativeFs.readFileSync(path.join(destDir, 'file.txt'), 'utf8'), 'original', 'copy dir overwrote file when overwrite: false'); + }); + + await asyncTest('copy respects filter option for files', async () => { + const srcDir = path.join(TMP, 'filter-src'); + nativeFs.mkdirSync(srcDir, { recursive: true }); + nativeFs.writeFileSync(path.join(srcDir, 'keep.txt'), 'keep me'); + nativeFs.writeFileSync(path.join(srcDir, 'skip.log'), 'skip me'); + + const destDir = path.join(TMP, 'filter-dest'); + await fs.copy(srcDir, destDir, { + filter: (src) => !src.endsWith('.log'), + }); + + assert(nativeFs.existsSync(path.join(destDir, 'keep.txt')), 'filter: kept file is missing'); + assert(!nativeFs.existsSync(path.join(destDir, 'skip.log')), 'filter: skipped file was copied'); + }); + + await asyncTest('copy respects filter option for directories', async () => { + const srcDir = path.join(TMP, 'filter-dir-src'); + nativeFs.mkdirSync(path.join(srcDir, 'include'), { recursive: true }); + nativeFs.mkdirSync(path.join(srcDir, 'node_modules'), { recursive: true }); + nativeFs.writeFileSync(path.join(srcDir, 'include', 'a.txt'), 'included'); + nativeFs.writeFileSync(path.join(srcDir, 'node_modules', 'b.txt'), 'excluded'); + + const destDir = path.join(TMP, 'filter-dir-dest'); + await fs.copy(srcDir, destDir, { + filter: (src) => !src.includes('node_modules'), + }); + + assert(nativeFs.existsSync(path.join(destDir, 'include', 'a.txt')), 'filter: included dir file is missing'); + assert(!nativeFs.existsSync(path.join(destDir, 'node_modules')), 'filter: excluded dir was copied'); + }); + + await asyncTest('copy filter skips top-level src when filter returns false', async () => { + const src = path.join(TMP, 'filter-skip-src.txt'); + const dest = path.join(TMP, 'filter-skip-dest.txt'); + nativeFs.writeFileSync(src, 'should not be copied'); + await fs.copy(src, dest, { + filter: () => false, + }); + assert(!nativeFs.existsSync(dest), 'filter: file was copied despite filter returning false'); + }); + + await asyncTest('remove deletes a file', async () => { + const p = path.join(TMP, 'remove-file.txt'); + nativeFs.writeFileSync(p, 'delete me'); + await fs.remove(p); + assert(!nativeFs.existsSync(p), 'remove did not delete file'); + }); + + await asyncTest('remove deletes a directory recursively', async () => { + const dir = path.join(TMP, 'remove-dir'); + nativeFs.mkdirSync(path.join(dir, 'sub'), { recursive: true }); + nativeFs.writeFileSync(path.join(dir, 'sub', 'file.txt'), 'nested'); + await fs.remove(dir); + assert(!nativeFs.existsSync(dir), 'remove did not delete directory'); + }); + + await asyncTest('remove does not throw for missing path', async () => { + await fs.remove(path.join(TMP, 'nonexistent-remove-target')); + // should not throw — force: true + }); + + await asyncTest('move renames a file', async () => { + const src = path.join(TMP, 'move-src.txt'); + const dest = path.join(TMP, 'move-dest.txt'); + nativeFs.writeFileSync(src, 'move me'); + await fs.move(src, dest); + assert(!nativeFs.existsSync(src), 'move did not remove source'); + assertEqual(nativeFs.readFileSync(dest, 'utf8'), 'move me', 'move content mismatch'); + }); + + await asyncTest('move renames a directory', async () => { + const srcDir = path.join(TMP, 'move-dir-src'); + nativeFs.mkdirSync(srcDir, { recursive: true }); + nativeFs.writeFileSync(path.join(srcDir, 'file.txt'), 'dir move'); + + const destDir = path.join(TMP, 'move-dir-dest'); + await fs.move(srcDir, destDir); + assert(!nativeFs.existsSync(srcDir), 'move did not remove source dir'); + assertEqual(nativeFs.readFileSync(path.join(destDir, 'file.txt'), 'utf8'), 'dir move', 'move dir content mismatch'); + }); + + test('readJsonSync parses JSON file', () => { + const p = path.join(TMP, 'test.json'); + nativeFs.writeFileSync(p, JSON.stringify({ key: 'value', num: 42 })); + const result = fs.readJsonSync(p); + assertEqual(result.key, 'value', 'readJsonSync key mismatch'); + assertEqual(result.num, 42, 'readJsonSync num mismatch'); + }); + + test('readJsonSync throws on invalid JSON', () => { + const p = path.join(TMP, 'bad.json'); + nativeFs.writeFileSync(p, '{ invalid json }'); + let threw = false; + try { + fs.readJsonSync(p); + } catch { + threw = true; + } + assert(threw, 'readJsonSync did not throw on invalid JSON'); + }); + + test('readJsonSync strips UTF-8 BOM', () => { + const p = path.join(TMP, 'bom.json'); + nativeFs.writeFileSync(p, '\uFEFF{"bom": true}'); + const result = fs.readJsonSync(p); + assertEqual(result.bom, true, 'readJsonSync failed to parse BOM-prefixed JSON'); + }); + + console.log(''); + + // ── Bulk copy stress test ─────────────────────────────────────────────── + + console.log(`${colors.yellow}Bulk copy determinism${colors.reset}`); + + await asyncTest('copy preserves all files in a large directory tree', async () => { + // Create a tree with 200+ files to verify no silent loss + const srcDir = path.join(TMP, 'bulk-src'); + const fileCount = 250; + + for (let i = 0; i < fileCount; i++) { + const subDir = path.join(srcDir, `dir-${String(Math.floor(i / 10)).padStart(2, '0')}`); + nativeFs.mkdirSync(subDir, { recursive: true }); + nativeFs.writeFileSync(path.join(subDir, `file-${i}.txt`), `content-${i}`); + } + + const destDir = path.join(TMP, 'bulk-dest'); + await fs.copy(srcDir, destDir); + + // Count all files in destination + let destCount = 0; + const countFiles = (dir) => { + const entries = nativeFs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + countFiles(path.join(dir, entry.name)); + } else { + destCount++; + } + } + }; + countFiles(destDir); + + assertEqual(destCount, fileCount, `bulk copy lost files: expected ${fileCount}, got ${destCount}`); + }); + + console.log(''); + + // ── Cleanup ───────────────────────────────────────────────────────────── + + teardown(); + + // ── Summary ───────────────────────────────────────────────────────────── + console.log(`${colors.cyan}========================================`); + console.log('Test Results:'); + console.log(` Total: ${totalTests}`); + console.log(` Passed: ${colors.green}${passedTests}${colors.reset}`); + console.log(` Failed: ${colors.red}${totalTests - passedTests}${colors.reset}`); + console.log(`========================================${colors.reset}\n`); + + if (failures.length === 0) { + console.log(`${colors.green}\u2728 All fs wrapper tests passed!${colors.reset}\n`); + process.exit(0); + } else { + console.log(`${colors.red}\u274C Some fs wrapper tests failed${colors.reset}\n`); + process.exit(1); + } +} + +// Run tests +runTests().catch((error) => { + teardown(); + console.error(`${colors.red}Test runner failed:${colors.reset}`, error.message); + console.error(error.stack); + process.exit(1); +}); diff --git a/test/test-install-to-bmad.js b/test/test-install-to-bmad.js index 0367dbe93..ffd9f9b8b 100644 --- a/test/test-install-to-bmad.js +++ b/test/test-install-to-bmad.js @@ -14,7 +14,7 @@ const path = require('node:path'); const os = require('node:os'); -const fs = require('fs-extra'); +const fs = require('../tools/cli/lib/fs'); const { loadSkillManifest, getInstallToBmad } = require('../tools/cli/installers/lib/ide/shared/skill-manifest'); // ANSI colors diff --git a/test/test-installation-components.js b/test/test-installation-components.js index e86541593..f3f7b0eae 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -13,7 +13,7 @@ const path = require('node:path'); const os = require('node:os'); -const fs = require('fs-extra'); +const fs = require('../tools/cli/lib/fs'); const { YamlXmlBuilder } = require('../tools/cli/lib/yaml-xml-builder'); const { ManifestGenerator } = require('../tools/cli/installers/lib/core/manifest-generator'); const { IdeManager } = require('../tools/cli/installers/lib/ide/manager'); @@ -158,7 +158,9 @@ async function runTests() { const tempOutput = path.join(__dirname, 'temp-pm-agent.md'); try { - const result = await builder.buildAgent(pmAgentPath, null, tempOutput, { includeMetadata: true }); + const result = await builder.buildAgent(pmAgentPath, null, tempOutput, { + includeMetadata: true, + }); assert(result && result.outputPath === tempOutput, 'Agent compilation returns result object with outputPath'); @@ -862,7 +864,9 @@ async function runTests() { const tempOutput = path.join(__dirname, 'temp-qa-agent.md'); try { - const result = await builder.buildAgent(qaAgentPath, null, tempOutput, { includeMetadata: true }); + const result = await builder.buildAgent(qaAgentPath, null, tempOutput, { + includeMetadata: true, + }); const compiled = await fs.readFile(tempOutput, 'utf8'); assert(compiled.includes('QA Engineer'), 'QA agent compilation includes agent title'); diff --git a/tools/cli/commands/status.js b/tools/cli/commands/status.js index ec931fe46..f01858857 100644 --- a/tools/cli/commands/status.js +++ b/tools/cli/commands/status.js @@ -19,7 +19,7 @@ module.exports = { const { bmadDir } = await installer.findBmadDir(projectDir); // Check if bmad directory exists - const fs = require('fs-extra'); + const fs = require('../lib/fs'); if (!(await fs.pathExists(bmadDir))) { await prompts.log.warn('No BMAD installation found in the current directory.'); await prompts.log.message(`Expected location: ${bmadDir}`); diff --git a/tools/cli/commands/uninstall.js b/tools/cli/commands/uninstall.js index 99734791e..f99a5268d 100644 --- a/tools/cli/commands/uninstall.js +++ b/tools/cli/commands/uninstall.js @@ -1,5 +1,5 @@ const path = require('node:path'); -const fs = require('fs-extra'); +const fs = require('../lib/fs'); const prompts = require('../lib/prompts'); const { Installer } = require('../installers/lib/core/installer'); diff --git a/tools/cli/installers/lib/core/config-collector.js b/tools/cli/installers/lib/core/config-collector.js index e8569cd0f..54e7039c5 100644 --- a/tools/cli/installers/lib/core/config-collector.js +++ b/tools/cli/installers/lib/core/config-collector.js @@ -1,5 +1,5 @@ const path = require('node:path'); -const fs = require('fs-extra'); +const fs = require('../../../lib/fs'); const yaml = require('yaml'); const { getProjectRoot, getModulePath } = require('../../../lib/project-root'); const { CLIUtils } = require('../../../lib/cli-utils'); diff --git a/tools/cli/installers/lib/core/custom-module-cache.js b/tools/cli/installers/lib/core/custom-module-cache.js index b1cc3d0f7..8cbf5c5e4 100644 --- a/tools/cli/installers/lib/core/custom-module-cache.js +++ b/tools/cli/installers/lib/core/custom-module-cache.js @@ -4,7 +4,7 @@ * and can be checked into source control */ -const fs = require('fs-extra'); +const fs = require('../../../lib/fs'); const path = require('node:path'); const crypto = require('node:crypto'); const prompts = require('../../../lib/prompts'); diff --git a/tools/cli/installers/lib/core/dependency-resolver.js b/tools/cli/installers/lib/core/dependency-resolver.js index 3fb282c5d..1e42120f2 100644 --- a/tools/cli/installers/lib/core/dependency-resolver.js +++ b/tools/cli/installers/lib/core/dependency-resolver.js @@ -1,4 +1,4 @@ -const fs = require('fs-extra'); +const fs = require('../../../lib/fs'); const path = require('node:path'); const glob = require('glob'); const yaml = require('yaml'); diff --git a/tools/cli/installers/lib/core/detector.js b/tools/cli/installers/lib/core/detector.js index 9bb736589..7c2bd68e4 100644 --- a/tools/cli/installers/lib/core/detector.js +++ b/tools/cli/installers/lib/core/detector.js @@ -1,5 +1,5 @@ const path = require('node:path'); -const fs = require('fs-extra'); +const fs = require('../../../lib/fs'); const yaml = require('yaml'); const { Manifest } = require('./manifest'); diff --git a/tools/cli/installers/lib/core/ide-config-manager.js b/tools/cli/installers/lib/core/ide-config-manager.js index c00c00d48..940c1e66a 100644 --- a/tools/cli/installers/lib/core/ide-config-manager.js +++ b/tools/cli/installers/lib/core/ide-config-manager.js @@ -1,5 +1,5 @@ const path = require('node:path'); -const fs = require('fs-extra'); +const fs = require('../../../lib/fs'); const yaml = require('yaml'); const prompts = require('../../../lib/prompts'); diff --git a/tools/cli/installers/lib/core/installer.js b/tools/cli/installers/lib/core/installer.js index 85864145f..45c78006b 100644 --- a/tools/cli/installers/lib/core/installer.js +++ b/tools/cli/installers/lib/core/installer.js @@ -1,5 +1,5 @@ const path = require('node:path'); -const fs = require('fs-extra'); +const fs = require('../../../lib/fs'); const { Detector } = require('./detector'); const { Manifest } = require('./manifest'); const { ModuleManager } = require('../modules/manager'); @@ -87,7 +87,7 @@ class Installer { if (textExtensions.includes(ext)) { try { // Read the file content - let content = await fs.readFile(sourcePath, 'utf8'); + const content = await fs.readFile(sourcePath, 'utf8'); // Write to target with replaced content await fs.ensureDir(path.dirname(targetPath)); @@ -260,7 +260,7 @@ class Installer { // Collect configurations for modules (skip if quick update already collected them) let moduleConfigs; - let customModulePaths = new Map(); + const customModulePaths = new Map(); if (config._quickUpdate) { // Quick update already collected all configs, use them directly @@ -524,7 +524,9 @@ class Installer { // Also check cache directory for custom modules (like quick update does) const cacheDir = path.join(bmadDir, '_config', 'custom'); if (await fs.pathExists(cacheDir)) { - const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true }); + const cachedModules = await fs.readdir(cacheDir, { + withFileTypes: true, + }); for (const cachedModule of cachedModules) { const moduleId = cachedModule.name; @@ -585,7 +587,9 @@ class Installer { const relativePath = path.relative(bmadDir, modifiedFile.path); const tempBackupPath = path.join(tempModifiedBackupDir, relativePath); await fs.ensureDir(path.dirname(tempBackupPath)); - await fs.copy(modifiedFile.path, tempBackupPath, { overwrite: true }); + await fs.copy(modifiedFile.path, tempBackupPath, { + overwrite: true, + }); } spinner.stop(`Backed up ${modifiedFiles.length} modified files`); @@ -608,7 +612,9 @@ class Installer { // Also check cache directory for custom modules (like quick update does) const cacheDir = path.join(bmadDir, '_config', 'custom'); if (await fs.pathExists(cacheDir)) { - const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true }); + const cachedModules = await fs.readdir(cacheDir, { + withFileTypes: true, + }); for (const cachedModule of cachedModules) { const moduleId = cachedModule.name; @@ -668,7 +674,9 @@ class Installer { const relativePath = path.relative(bmadDir, modifiedFile.path); const tempBackupPath = path.join(tempModifiedBackupDir, relativePath); await fs.ensureDir(path.dirname(tempBackupPath)); - await fs.copy(modifiedFile.path, tempBackupPath, { overwrite: true }); + await fs.copy(modifiedFile.path, tempBackupPath, { + overwrite: true, + }); } spinner.stop(`Backed up ${modifiedFiles.length} modified files`); config._tempModifiedBackupDir = tempModifiedBackupDir; @@ -907,7 +915,11 @@ class Installer { let taskResolution; // Collect directory creation results for output after tasks() completes - const dirResults = { createdDirs: [], movedDirs: [], createdWdsFolders: [] }; + const dirResults = { + createdDirs: [], + movedDirs: [], + createdWdsFolders: [], + }; // Build task list conditionally const installTasks = []; @@ -919,7 +931,9 @@ class Installer { task: async (message) => { await this.installCoreWithDependencies(bmadDir, { core: {} }); addResult('Core', 'ok', isQuickUpdate ? 'updated' : 'installed'); - await this.generateModuleConfigs(bmadDir, { core: config.coreConfig || {} }); + await this.generateModuleConfigs(bmadDir, { + core: config.coreConfig || {}, + }); return isQuickUpdate ? 'Core updated' : 'Core installed'; }, }); @@ -965,7 +979,11 @@ class Installer { const cachedModule = finalCustomContent.cachedModules.find((m) => m.id === moduleName); if (cachedModule) { isCustomModule = true; - customInfo = { id: moduleName, path: cachedModule.cachePath, config: {} }; + customInfo = { + id: moduleName, + path: cachedModule.cachePath, + config: {}, + }; } } @@ -1015,7 +1033,11 @@ class Installer { }, ); await this.generateModuleConfigs(bmadDir, { - [moduleName]: { ...config.coreConfig, ...customInfo.config, ...collectedModuleConfig }, + [moduleName]: { + ...config.coreConfig, + ...customInfo.config, + ...collectedModuleConfig, + }, }); } else { if (!resolution || !resolution.byModule) { @@ -1474,7 +1496,9 @@ class Installer { // Also check cache directory const cacheDir = path.join(bmadDir, '_config', 'custom'); if (await fs.pathExists(cacheDir)) { - const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true }); + const cachedModules = await fs.readdir(cacheDir, { + withFileTypes: true, + }); for (const cachedModule of cachedModules) { if (cachedModule.isDirectory()) { @@ -1549,7 +1573,9 @@ class Installer { for (const module of existingInstall.modules) { spinner.message(`Updating module: ${module.id}...`); - await this.moduleManager.update(module.id, bmadDir, config.force, { installer: this }); + await this.moduleManager.update(module.id, bmadDir, config.force, { + installer: this, + }); } // Update manifest @@ -1608,7 +1634,9 @@ class Installer { // 2. IDE CLEANUP (before _bmad/ deletion so configs are accessible) if (options.removeIdeConfigs !== false) { - await this.uninstallIdeConfigs(projectDir, existingInstall, { silent: options.silent }); + await this.uninstallIdeConfigs(projectDir, existingInstall, { + silent: options.silent, + }); removed.ideConfigs = true; } @@ -1847,7 +1875,11 @@ class Installer { // Lookup agent info const cleanAgentName = agentName ? agentName.trim() : ''; - const agentData = agentInfo.get(cleanAgentName) || { command: '', displayName: '', title: '' }; + const agentData = agentInfo.get(cleanAgentName) || { + command: '', + displayName: '', + title: '', + }; // Build new row with agent info const newRow = [ @@ -1902,8 +1934,8 @@ class Installer { } // Sequence comparison - const seqA = parseInt(colsA[4] || '0', 10); - const seqB = parseInt(colsB[4] || '0', 10); + const seqA = Number.parseInt(colsA[4] || '0', 10); + const seqB = Number.parseInt(colsB[4] || '0', 10); return seqA - seqB; }); @@ -2445,7 +2477,9 @@ class Installer { } const cacheDir = path.join(bmadDir, '_config', 'custom'); if (await fs.pathExists(cacheDir)) { - const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true }); + const cachedModules = await fs.readdir(cacheDir, { + withFileTypes: true, + }); for (const cachedModule of cachedModules) { const moduleId = cachedModule.name; @@ -2680,7 +2714,9 @@ class Installer { const customModuleSources = new Map(); const cacheDir = path.join(bmadDir, '_config', 'custom'); if (await fs.pathExists(cacheDir)) { - const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true }); + const cachedModules = await fs.readdir(cacheDir, { + withFileTypes: true, + }); for (const cachedModule of cachedModules) { if (cachedModule.isDirectory()) { @@ -3152,8 +3188,7 @@ class Installer { // Remove the module from filesystem and manifest const modulePath = path.join(bmadDir, missing.id); if (await fs.pathExists(modulePath)) { - const fsExtra = require('fs-extra'); - await fsExtra.remove(modulePath); + await fs.remove(modulePath); await prompts.log.warn(`Deleted module directory: ${path.relative(projectRoot, modulePath)}`); } diff --git a/tools/cli/installers/lib/core/manifest-generator.js b/tools/cli/installers/lib/core/manifest-generator.js index 5dc4ff078..706334d7a 100644 --- a/tools/cli/installers/lib/core/manifest-generator.js +++ b/tools/cli/installers/lib/core/manifest-generator.js @@ -1,5 +1,5 @@ const path = require('node:path'); -const fs = require('fs-extra'); +const fs = require('../../../lib/fs'); const yaml = require('yaml'); const crypto = require('node:crypto'); const csv = require('csv-parse/sync'); diff --git a/tools/cli/installers/lib/core/manifest.js b/tools/cli/installers/lib/core/manifest.js index 5fa1229e1..19a5faca7 100644 --- a/tools/cli/installers/lib/core/manifest.js +++ b/tools/cli/installers/lib/core/manifest.js @@ -1,5 +1,5 @@ const path = require('node:path'); -const fs = require('fs-extra'); +const fs = require('../../../lib/fs'); const crypto = require('node:crypto'); const { getProjectRoot } = require('../../../lib/project-root'); const prompts = require('../../../lib/prompts'); diff --git a/tools/cli/installers/lib/custom/handler.js b/tools/cli/installers/lib/custom/handler.js index 52595e4ff..7c1659f3d 100644 --- a/tools/cli/installers/lib/custom/handler.js +++ b/tools/cli/installers/lib/custom/handler.js @@ -1,5 +1,5 @@ const path = require('node:path'); -const fs = require('fs-extra'); +const fs = require('../../../lib/fs'); const yaml = require('yaml'); const prompts = require('../../../lib/prompts'); const { FileOps } = require('../../../lib/file-ops'); diff --git a/tools/cli/installers/lib/ide/_base-ide.js b/tools/cli/installers/lib/ide/_base-ide.js index a09222868..175e3b826 100644 --- a/tools/cli/installers/lib/ide/_base-ide.js +++ b/tools/cli/installers/lib/ide/_base-ide.js @@ -1,5 +1,5 @@ const path = require('node:path'); -const fs = require('fs-extra'); +const fs = require('../../../lib/fs'); const { XmlHandler } = require('../../../lib/xml-handler'); const prompts = require('../../../lib/prompts'); const { getSourcePath } = require('../../../lib/project-root'); diff --git a/tools/cli/installers/lib/ide/_config-driven.js b/tools/cli/installers/lib/ide/_config-driven.js index a93fe0c87..e4df3b8a2 100644 --- a/tools/cli/installers/lib/ide/_config-driven.js +++ b/tools/cli/installers/lib/ide/_config-driven.js @@ -1,6 +1,6 @@ const os = require('node:os'); const path = require('node:path'); -const fs = require('fs-extra'); +const fs = require('../../../lib/fs'); const yaml = require('yaml'); const { BaseIdeSetup } = require('./_base-ide'); const prompts = require('../../../lib/prompts'); diff --git a/tools/cli/installers/lib/ide/platform-codes.js b/tools/cli/installers/lib/ide/platform-codes.js index d5d8e0a47..890796e80 100644 --- a/tools/cli/installers/lib/ide/platform-codes.js +++ b/tools/cli/installers/lib/ide/platform-codes.js @@ -1,4 +1,4 @@ -const fs = require('fs-extra'); +const fs = require('../../../lib/fs'); const path = require('node:path'); const yaml = require('yaml'); diff --git a/tools/cli/installers/lib/ide/shared/agent-command-generator.js b/tools/cli/installers/lib/ide/shared/agent-command-generator.js index 37820992e..fe1ebc967 100644 --- a/tools/cli/installers/lib/ide/shared/agent-command-generator.js +++ b/tools/cli/installers/lib/ide/shared/agent-command-generator.js @@ -1,5 +1,5 @@ const path = require('node:path'); -const fs = require('fs-extra'); +const fs = require('../../../../lib/fs'); const { toColonPath, toDashPath, customAgentColonName, customAgentDashName, BMAD_FOLDER_NAME } = require('./path-utils'); /** diff --git a/tools/cli/installers/lib/ide/shared/bmad-artifacts.js b/tools/cli/installers/lib/ide/shared/bmad-artifacts.js index d3edf0cd2..ca82ec661 100644 --- a/tools/cli/installers/lib/ide/shared/bmad-artifacts.js +++ b/tools/cli/installers/lib/ide/shared/bmad-artifacts.js @@ -1,5 +1,5 @@ const path = require('node:path'); -const fs = require('fs-extra'); +const fs = require('../../../../lib/fs'); const { loadSkillManifest, getCanonicalId } = require('./skill-manifest'); /** diff --git a/tools/cli/installers/lib/ide/shared/module-injections.js b/tools/cli/installers/lib/ide/shared/module-injections.js index fe3f999d8..49b264d0e 100644 --- a/tools/cli/installers/lib/ide/shared/module-injections.js +++ b/tools/cli/installers/lib/ide/shared/module-injections.js @@ -1,5 +1,5 @@ const path = require('node:path'); -const fs = require('fs-extra'); +const fs = require('../../../../lib/fs'); const yaml = require('yaml'); const { glob } = require('glob'); const { getSourcePath } = require('../../../../lib/project-root'); diff --git a/tools/cli/installers/lib/ide/shared/skill-manifest.js b/tools/cli/installers/lib/ide/shared/skill-manifest.js index 22a7cceef..e7682de99 100644 --- a/tools/cli/installers/lib/ide/shared/skill-manifest.js +++ b/tools/cli/installers/lib/ide/shared/skill-manifest.js @@ -1,5 +1,5 @@ const path = require('node:path'); -const fs = require('fs-extra'); +const fs = require('../../../../lib/fs'); const yaml = require('yaml'); /** diff --git a/tools/cli/installers/lib/ide/shared/task-tool-command-generator.js b/tools/cli/installers/lib/ide/shared/task-tool-command-generator.js index f21a5d174..6beedc1d7 100644 --- a/tools/cli/installers/lib/ide/shared/task-tool-command-generator.js +++ b/tools/cli/installers/lib/ide/shared/task-tool-command-generator.js @@ -1,5 +1,5 @@ const path = require('node:path'); -const fs = require('fs-extra'); +const fs = require('../../../../lib/fs'); const csv = require('csv-parse/sync'); const { toColonName, toColonPath, toDashPath, BMAD_FOLDER_NAME } = require('./path-utils'); diff --git a/tools/cli/installers/lib/ide/shared/workflow-command-generator.js b/tools/cli/installers/lib/ide/shared/workflow-command-generator.js index ed8c3e508..964f7a2d5 100644 --- a/tools/cli/installers/lib/ide/shared/workflow-command-generator.js +++ b/tools/cli/installers/lib/ide/shared/workflow-command-generator.js @@ -1,5 +1,5 @@ const path = require('node:path'); -const fs = require('fs-extra'); +const fs = require('../../../../lib/fs'); const csv = require('csv-parse/sync'); const { BMAD_FOLDER_NAME } = require('./path-utils'); diff --git a/tools/cli/installers/lib/message-loader.js b/tools/cli/installers/lib/message-loader.js index 7198f0328..f8bb0c97c 100644 --- a/tools/cli/installers/lib/message-loader.js +++ b/tools/cli/installers/lib/message-loader.js @@ -1,4 +1,4 @@ -const fs = require('fs-extra'); +const fs = require('../../lib/fs'); const path = require('node:path'); const yaml = require('yaml'); const prompts = require('../../lib/prompts'); diff --git a/tools/cli/installers/lib/modules/external-manager.js b/tools/cli/installers/lib/modules/external-manager.js index f1ea2206e..afd673006 100644 --- a/tools/cli/installers/lib/modules/external-manager.js +++ b/tools/cli/installers/lib/modules/external-manager.js @@ -1,4 +1,4 @@ -const fs = require('fs-extra'); +const fs = require('../../../lib/fs'); const path = require('node:path'); const yaml = require('yaml'); const prompts = require('../../../lib/prompts'); diff --git a/tools/cli/installers/lib/modules/manager.js b/tools/cli/installers/lib/modules/manager.js index 7ac85678b..e690f1741 100644 --- a/tools/cli/installers/lib/modules/manager.js +++ b/tools/cli/installers/lib/modules/manager.js @@ -1,5 +1,5 @@ const path = require('node:path'); -const fs = require('fs-extra'); +const fs = require('../../../lib/fs'); const yaml = require('yaml'); const prompts = require('../../../lib/prompts'); const { XmlHandler } = require('../../../lib/xml-handler'); @@ -14,7 +14,7 @@ const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils'); * and agent file management including XML activation block injection. * * @class ModuleManager - * @requires fs-extra + * @requires lib/fs * @requires yaml * @requires prompts * @requires XmlHandler @@ -208,7 +208,9 @@ class ModuleManager { if (this.bmadDir) { const customCacheDir = path.join(this.bmadDir, '_config', 'custom'); if (await fs.pathExists(customCacheDir)) { - const cacheEntries = await fs.readdir(customCacheDir, { withFileTypes: true }); + const cacheEntries = await fs.readdir(customCacheDir, { + withFileTypes: true, + }); for (const entry of cacheEntries) { if (entry.isDirectory()) { const cachePath = path.join(customCacheDir, entry.name); @@ -387,7 +389,12 @@ class ModuleManager { const fetchSpinner = await createSpinner(); fetchSpinner.start(`Fetching ${moduleInfo.name}...`); try { - const currentRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim(); + const currentRef = execSync('git rev-parse HEAD', { + cwd: moduleCacheDir, + stdio: 'pipe', + }) + .toString() + .trim(); // Fetch and reset to remote - works better with shallow clones than pull execSync('git fetch origin --depth 1', { cwd: moduleCacheDir, @@ -399,7 +406,12 @@ class ModuleManager { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, }); - const newRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim(); + const newRef = execSync('git rev-parse HEAD', { + cwd: moduleCacheDir, + stdio: 'pipe', + }) + .toString() + .trim(); fetchSpinner.stop(`Fetched ${moduleInfo.name}`); // Force dependency install if we got new code @@ -521,7 +533,9 @@ class ModuleManager { * @param {Object} options.logger - Logger instance for output */ async install(moduleName, bmadDir, fileTrackingCallback = null, options = {}) { - const sourcePath = await this.findModuleSource(moduleName, { silent: options.silent }); + const sourcePath = await this.findModuleSource(moduleName, { + silent: options.silent, + }); const targetPath = path.join(bmadDir, moduleName); // Check if source module exists @@ -619,7 +633,9 @@ class ModuleManager { if (force) { // Force update - remove and reinstall await fs.remove(targetPath); - return await this.install(moduleName, bmadDir, null, { installer: options.installer }); + return await this.install(moduleName, bmadDir, null, { + installer: options.installer, + }); } else { // Selective update - preserve user modifications await this.syncModule(sourcePath, targetPath); @@ -855,7 +871,7 @@ class ModuleManager { // Check for customizations and build answers object let customizedFields = []; - let answers = {}; + const answers = {}; if (await fs.pathExists(customizePath)) { const customizeContent = await fs.readFile(customizePath, 'utf8'); const customizeData = yaml.parse(customizeContent); @@ -928,7 +944,9 @@ class ModuleManager { // Copy any non-sidecar files from agent directory (e.g., foo.md) const agentDir = path.dirname(agentFile); - const agentEntries = await fs.readdir(agentDir, { withFileTypes: true }); + const agentEntries = await fs.readdir(agentDir, { + withFileTypes: true, + }); for (const entry of agentEntries) { if (entry.isFile() && !entry.name.endsWith('.agent.yaml') && !entry.name.endsWith('.md')) { @@ -1139,7 +1157,11 @@ class ModuleManager { const moduleConfig = options.moduleConfig || {}; const existingModuleConfig = options.existingModuleConfig || {}; const projectRoot = path.dirname(bmadDir); - const emptyResult = { createdDirs: [], movedDirs: [], createdWdsFolders: [] }; + const emptyResult = { + createdDirs: [], + movedDirs: [], + createdWdsFolders: [], + }; // Special handling for core module - it's in src/core not src/modules let sourcePath; diff --git a/tools/cli/lib/activation-builder.js b/tools/cli/lib/activation-builder.js index 81e11158e..8b87e11e2 100644 --- a/tools/cli/lib/activation-builder.js +++ b/tools/cli/lib/activation-builder.js @@ -1,4 +1,4 @@ -const fs = require('fs-extra'); +const fs = require('./fs'); const path = require('node:path'); const { getSourcePath } = require('./project-root'); diff --git a/tools/cli/lib/agent-analyzer.js b/tools/cli/lib/agent-analyzer.js index a62bdd7cf..8d07e034e 100644 --- a/tools/cli/lib/agent-analyzer.js +++ b/tools/cli/lib/agent-analyzer.js @@ -1,5 +1,5 @@ const yaml = require('yaml'); -const fs = require('fs-extra'); +const fs = require('./fs'); /** * Analyzes agent YAML files to detect which handlers are needed diff --git a/tools/cli/lib/agent-party-generator.js b/tools/cli/lib/agent-party-generator.js index efc783a87..69606ea1d 100644 --- a/tools/cli/lib/agent-party-generator.js +++ b/tools/cli/lib/agent-party-generator.js @@ -1,5 +1,5 @@ const path = require('node:path'); -const fs = require('fs-extra'); +const fs = require('./fs'); const { escapeXml } = require('../../lib/xml-utils'); const AgentPartyGenerator = { diff --git a/tools/cli/lib/config.js b/tools/cli/lib/config.js index a78250305..faf5d5ceb 100644 --- a/tools/cli/lib/config.js +++ b/tools/cli/lib/config.js @@ -1,4 +1,4 @@ -const fs = require('fs-extra'); +const fs = require('./fs'); const yaml = require('yaml'); const path = require('node:path'); const packageJson = require('../../../package.json'); diff --git a/tools/cli/lib/file-ops.js b/tools/cli/lib/file-ops.js index 5cd7970d8..08d44d82c 100644 --- a/tools/cli/lib/file-ops.js +++ b/tools/cli/lib/file-ops.js @@ -1,4 +1,4 @@ -const fs = require('fs-extra'); +const fs = require('./fs'); const path = require('node:path'); const crypto = require('node:crypto'); diff --git a/tools/cli/lib/fs.js b/tools/cli/lib/fs.js new file mode 100644 index 000000000..f449560a3 --- /dev/null +++ b/tools/cli/lib/fs.js @@ -0,0 +1,167 @@ +/** + * Drop-in replacement for fs-extra that uses only native Node.js fs. + * + * fs-extra routes every call through graceful-fs, whose EMFILE retry queue + * causes non-deterministic file loss on macOS during bulk copy operations. + * This module provides the same API surface used by the CLI codebase but + * backed entirely by `node:fs` and `node:fs/promises` — no third-party + * wrappers, no retry queues, no silent data loss. + * + * Async methods return native promises (from `node:fs/promises`). + * Sync methods delegate directly to `node:fs`. + */ + +const fs = require('node:fs'); +const fsp = require('node:fs/promises'); +const path = require('node:path'); + +// ── Re-export every native fs member ──────────────────────────────────────── +// Callers that use fs.constants, fs.createReadStream, etc. keep working. +module.exports = { ...fs }; + +// ── Async methods (return promises, like fs-extra) ────────────────────────── + +module.exports.readFile = fsp.readFile; +module.exports.writeFile = fsp.writeFile; +module.exports.readdir = fsp.readdir; +module.exports.stat = fsp.stat; +module.exports.access = fsp.access; +module.exports.mkdtemp = fsp.mkdtemp; +module.exports.rename = fsp.rename; +module.exports.realpath = fsp.realpath; +module.exports.rmdir = fsp.rmdir; + +/** + * Recursively ensure a directory exists. + * @param {string} dirPath + */ +module.exports.ensureDir = async function ensureDir(dirPath) { + await fsp.mkdir(dirPath, { recursive: true }); +}; + +/** + * Check whether a path exists. + * @param {string} p + * @returns {Promise} + */ +module.exports.pathExists = async function pathExists(p) { + try { + await fsp.access(p); + return true; + } catch (error) { + if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) { + return false; + } + throw error; + } +}; + +/** + * Synchronous variant of pathExists. + * @param {string} p + * @returns {boolean} + */ +module.exports.pathExistsSync = function pathExistsSync(p) { + return fs.existsSync(p); +}; + +/** + * Recursively copy a directory tree synchronously. + * @param {string} src - Source directory + * @param {string} dest - Destination directory + * @param {boolean} force - Whether to overwrite existing files + * @param {Function} [filter] - Optional filter(srcPath) → boolean; return false to skip + */ +function copyDirSync(src, dest, force, filter) { + if (filter && !filter(src)) return; + fs.mkdirSync(dest, { recursive: true }); + const entries = fs.readdirSync(src, { withFileTypes: true }); + for (const entry of entries) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + if (filter && !filter(srcPath)) continue; + if (entry.isDirectory()) { + copyDirSync(srcPath, destPath, force, filter); + } else { + if (!force && fs.existsSync(destPath)) { + continue; + } + fs.copyFileSync(srcPath, destPath); + } + } +} + +/** + * Copy a file or directory. + * @param {string} src + * @param {string} dest + * @param {object} [options] + * @param {boolean} [options.overwrite=true] + * @param {Function} [options.filter] - Optional filter(srcPath) → boolean; return false to skip + */ +module.exports.copy = async function copy(src, dest, options = {}) { + const overwrite = options.overwrite !== false; + const filter = options.filter; + + if (filter && !filter(src)) return; + + const srcStat = await fsp.stat(src); + + if (srcStat.isDirectory()) { + copyDirSync(src, dest, overwrite, filter); + } else { + await fsp.mkdir(path.dirname(dest), { recursive: true }); + if (!overwrite) { + try { + await fsp.access(dest); + return; // dest exists, skip + } catch { + // dest doesn't exist, proceed + } + } + fs.copyFileSync(src, dest); + } +}; + +/** + * Recursively remove a file or directory. + * @param {string} p + */ +module.exports.remove = async function remove(p) { + fs.rmSync(p, { recursive: true, force: true }); +}; + +/** + * Move (rename) a file or directory, with cross-device fallback. + * @param {string} src + * @param {string} dest + */ +module.exports.move = async function move(src, dest) { + try { + await fsp.rename(src, dest); + } catch (error) { + if (error.code === 'EXDEV') { + // Cross-device: copy then remove + const srcStat = fs.statSync(src); + if (srcStat.isDirectory()) { + copyDirSync(src, dest, true); + } else { + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(src, dest); + } + fs.rmSync(src, { recursive: true, force: true }); + } else { + throw error; + } + } +}; + +/** + * Read and parse a JSON file synchronously. + * @param {string} filePath + * @returns {any} + */ +module.exports.readJsonSync = function readJsonSync(filePath) { + const raw = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''); + return JSON.parse(raw); +}; diff --git a/tools/cli/lib/platform-codes.js b/tools/cli/lib/platform-codes.js index bdf0e48c9..797d88ec3 100644 --- a/tools/cli/lib/platform-codes.js +++ b/tools/cli/lib/platform-codes.js @@ -1,4 +1,4 @@ -const fs = require('fs-extra'); +const fs = require('./fs'); const path = require('node:path'); const yaml = require('yaml'); const { getProjectRoot } = require('./project-root'); diff --git a/tools/cli/lib/project-root.js b/tools/cli/lib/project-root.js index 4533c773c..ef3460796 100644 --- a/tools/cli/lib/project-root.js +++ b/tools/cli/lib/project-root.js @@ -1,5 +1,5 @@ const path = require('node:path'); -const fs = require('fs-extra'); +const fs = require('./fs'); /** * Find the BMAD project root directory by looking for package.json diff --git a/tools/cli/lib/ui.js b/tools/cli/lib/ui.js index 1338c1f17..ee3963006 100644 --- a/tools/cli/lib/ui.js +++ b/tools/cli/lib/ui.js @@ -1,6 +1,6 @@ const path = require('node:path'); const os = require('node:os'); -const fs = require('fs-extra'); +const fs = require('./fs'); const { CLIUtils } = require('./cli-utils'); const { CustomHandler } = require('../installers/lib/custom/handler'); const { ExternalModuleManager } = require('../installers/lib/modules/external-manager'); diff --git a/tools/cli/lib/xml-handler.js b/tools/cli/lib/xml-handler.js index a6111b1a7..b3aa07e62 100644 --- a/tools/cli/lib/xml-handler.js +++ b/tools/cli/lib/xml-handler.js @@ -1,5 +1,5 @@ const xml2js = require('xml2js'); -const fs = require('fs-extra'); +const fs = require('./fs'); const path = require('node:path'); const { getProjectRoot, getSourcePath } = require('./project-root'); const { YamlXmlBuilder } = require('./yaml-xml-builder'); diff --git a/tools/cli/lib/yaml-xml-builder.js b/tools/cli/lib/yaml-xml-builder.js index f4f8e2f5a..b68474e99 100644 --- a/tools/cli/lib/yaml-xml-builder.js +++ b/tools/cli/lib/yaml-xml-builder.js @@ -1,5 +1,5 @@ const yaml = require('yaml'); -const fs = require('fs-extra'); +const fs = require('./fs'); const path = require('node:path'); const crypto = require('node:crypto'); const { AgentAnalyzer } = require('./agent-analyzer'); diff --git a/tools/migrate-custom-module-paths.js b/tools/migrate-custom-module-paths.js index 13aa3e710..33fc6b15c 100755 --- a/tools/migrate-custom-module-paths.js +++ b/tools/migrate-custom-module-paths.js @@ -3,7 +3,7 @@ * This should be run once to update existing installations */ -const fs = require('fs-extra'); +const fs = require('./cli/lib/fs'); const path = require('node:path'); const yaml = require('yaml'); const chalk = require('chalk'); From 2771dd76b86b1e07d7c8cb0d39b963339d6d928e Mon Sep 17 00:00:00 2001 From: Adam Biggs Date: Thu, 26 Feb 2026 13:29:42 -0800 Subject: [PATCH 2/5] fix(installer): narrow error handling in copy() and fix test interdependency - copy() overwrite:false catch now only ignores ENOENT/ENOTDIR, consistent with pathExists(); permission errors propagate correctly - 'copy creates parent directories' test creates its own fixture instead of depending on state from a previous test --- test/test-fs-wrapper.js | 5 +++-- tools/cli/lib/fs.js | 5 ++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/test/test-fs-wrapper.js b/test/test-fs-wrapper.js index cfafbbd88..ed02d5756 100644 --- a/test/test-fs-wrapper.js +++ b/test/test-fs-wrapper.js @@ -270,10 +270,11 @@ async function runTests() { }); await asyncTest('copy creates parent directories for dest', async () => { - const src = path.join(TMP, 'copy-file-src.txt'); + const src = path.join(TMP, 'copy-mkdir-src.txt'); + nativeFs.writeFileSync(src, 'copy mkdir'); const dest = path.join(TMP, 'copy-deep', 'nested', 'dest.txt'); await fs.copy(src, dest); - assertEqual(nativeFs.readFileSync(dest, 'utf8'), 'copy file', 'copy with mkdir content mismatch'); + assertEqual(nativeFs.readFileSync(dest, 'utf8'), 'copy mkdir', 'copy with mkdir content mismatch'); }); await asyncTest('copy copies a directory recursively', async () => { diff --git a/tools/cli/lib/fs.js b/tools/cli/lib/fs.js index f449560a3..4e6dcfa70 100644 --- a/tools/cli/lib/fs.js +++ b/tools/cli/lib/fs.js @@ -115,7 +115,10 @@ module.exports.copy = async function copy(src, dest, options = {}) { try { await fsp.access(dest); return; // dest exists, skip - } catch { + } catch (error) { + if (error && error.code !== 'ENOENT' && error.code !== 'ENOTDIR') { + throw error; + } // dest doesn't exist, proceed } } From 32c2724561f4e74e3000a8cd2604567cb427654d Mon Sep 17 00:00:00 2001 From: Adam Biggs Date: Thu, 26 Feb 2026 13:35:40 -0800 Subject: [PATCH 3/5] ci: add fs wrapper tests to quality workflow --- .github/workflows/quality.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/quality.yaml b/.github/workflows/quality.yaml index 78023e466..66dd49239 100644 --- a/.github/workflows/quality.yaml +++ b/.github/workflows/quality.yaml @@ -7,6 +7,7 @@ name: Quality & Validation # - Schema validation (YAML structure) # - Agent schema tests (fixture-based validation) # - Installation component tests (compilation) +# - fs wrapper tests (native fs replacement) # - Bundle validation (web bundle integrity) "on": @@ -112,5 +113,8 @@ jobs: - name: Test agent compilation components run: npm run test:install + - name: Test fs wrapper + run: npm run test:fs + - name: Validate file references run: npm run validate:refs From bce48d8139fc70efa6572868d57e0259ffc81eff Mon Sep 17 00:00:00 2001 From: Adam Biggs Date: Fri, 6 Mar 2026 12:29:48 -0800 Subject: [PATCH 4/5] fix(installer): include file path in JSON parse errors --- test/test-fs-wrapper.js | 5 ++++- tools/cli/lib/fs.js | 7 ++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/test/test-fs-wrapper.js b/test/test-fs-wrapper.js index ed02d5756..0102829d2 100644 --- a/test/test-fs-wrapper.js +++ b/test/test-fs-wrapper.js @@ -405,12 +405,15 @@ async function runTests() { const p = path.join(TMP, 'bad.json'); nativeFs.writeFileSync(p, '{ invalid json }'); let threw = false; + let errorMessage = ''; try { fs.readJsonSync(p); - } catch { + } catch (error) { threw = true; + errorMessage = error.message; } assert(threw, 'readJsonSync did not throw on invalid JSON'); + assert(errorMessage.includes(p), 'readJsonSync error did not include file path'); }); test('readJsonSync strips UTF-8 BOM', () => { diff --git a/tools/cli/lib/fs.js b/tools/cli/lib/fs.js index 4e6dcfa70..6cd2c631d 100644 --- a/tools/cli/lib/fs.js +++ b/tools/cli/lib/fs.js @@ -166,5 +166,10 @@ module.exports.move = async function move(src, dest) { */ module.exports.readJsonSync = function readJsonSync(filePath) { const raw = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''); - return JSON.parse(raw); + try { + return JSON.parse(raw); + } catch (error) { + error.message = `Failed to parse JSON in ${filePath}: ${error.message}`; + throw error; + } }; From 888b960793a16317c0e27f86ac1cf4f5b4c7903f Mon Sep 17 00:00:00 2001 From: Adam Biggs Date: Wed, 11 Mar 2026 18:09:26 -0700 Subject: [PATCH 5/5] chore(deps): sync lockfile after removing fs-extra Keep installs reproducible now that the CLI uses the native fs wrapper instead of fs-extra. --- package-lock.json | 37 +------------------------------------ 1 file changed, 1 insertion(+), 36 deletions(-) diff --git a/package-lock.json b/package-lock.json index 450469c4d..b66cf463a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,6 @@ "chalk": "^4.1.2", "commander": "^14.0.0", "csv-parse": "^6.1.0", - "fs-extra": "^11.3.0", "glob": "^11.0.3", "ignore": "^7.0.5", "js-yaml": "^4.1.0", @@ -6996,20 +6995,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/fs-extra": { - "version": "11.3.3", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", - "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -7227,6 +7212,7 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, "license": "ISC" }, "node_modules/h3": { @@ -9066,18 +9052,6 @@ "dev": true, "license": "MIT" }, - "node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/katex": { "version": "0.16.28", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.28.tgz", @@ -13607,15 +13581,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/unrs-resolver": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",