This commit is contained in:
Adam Biggs 2026-03-12 12:14:24 -07:00 committed by GitHub
commit 610aca0641
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 802 additions and 105 deletions

View File

@ -7,6 +7,7 @@ name: Quality & Validation
# - Schema validation (YAML structure) # - Schema validation (YAML structure)
# - Agent schema tests (fixture-based validation) # - Agent schema tests (fixture-based validation)
# - Installation component tests (compilation) # - Installation component tests (compilation)
# - fs wrapper tests (native fs replacement)
# - Bundle validation (web bundle integrity) # - Bundle validation (web bundle integrity)
"on": "on":
@ -112,5 +113,8 @@ jobs:
- name: Test agent compilation components - name: Test agent compilation components
run: npm run test:install run: npm run test:install
- name: Test fs wrapper
run: npm run test:fs
- name: Validate file references - name: Validate file references
run: npm run validate:refs run: npm run validate:refs

37
package-lock.json generated
View File

@ -15,7 +15,6 @@
"chalk": "^4.1.2", "chalk": "^4.1.2",
"commander": "^14.0.0", "commander": "^14.0.0",
"csv-parse": "^6.1.0", "csv-parse": "^6.1.0",
"fs-extra": "^11.3.0",
"glob": "^11.0.3", "glob": "^11.0.3",
"ignore": "^7.0.5", "ignore": "^7.0.5",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
@ -6996,20 +6995,6 @@
"url": "https://github.com/sponsors/isaacs" "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": { "node_modules/fs.realpath": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@ -7227,6 +7212,7 @@
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/h3": { "node_modules/h3": {
@ -9066,18 +9052,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/katex": {
"version": "0.16.28", "version": "0.16.28",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.28.tgz", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.28.tgz",
@ -13607,15 +13581,6 @@
"url": "https://opencollective.com/unified" "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": { "node_modules/unrs-resolver": {
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",

View File

@ -40,8 +40,9 @@
"lint:md": "markdownlint-cli2 \"**/*.md\"", "lint:md": "markdownlint-cli2 \"**/*.md\"",
"prepare": "command -v husky >/dev/null 2>&1 && husky || exit 0", "prepare": "command -v husky >/dev/null 2>&1 && husky || exit 0",
"rebundle": "node tools/cli/bundlers/bundle-web.js rebundle", "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: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:install": "node test/test-installation-components.js",
"test:refs": "node test/test-file-refs-csv.js", "test:refs": "node test/test-file-refs-csv.js",
"test:schemas": "node test/test-agent-schema.js", "test:schemas": "node test/test-agent-schema.js",
@ -71,7 +72,6 @@
"chalk": "^4.1.2", "chalk": "^4.1.2",
"commander": "^14.0.0", "commander": "^14.0.0",
"csv-parse": "^6.1.0", "csv-parse": "^6.1.0",
"fs-extra": "^11.3.0",
"glob": "^11.0.3", "glob": "^11.0.3",
"ignore": "^7.0.5", "ignore": "^7.0.5",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",

492
test/test-fs-wrapper.js Normal file
View File

@ -0,0 +1,492 @@
/**
* 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-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 mkdir', '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;
let errorMessage = '';
try {
fs.readJsonSync(p);
} 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', () => {
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);
});

View File

@ -14,7 +14,7 @@
const path = require('node:path'); const path = require('node:path');
const os = require('node:os'); 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'); const { loadSkillManifest, getInstallToBmad } = require('../tools/cli/installers/lib/ide/shared/skill-manifest');
// ANSI colors // ANSI colors

View File

@ -13,7 +13,7 @@
const path = require('node:path'); const path = require('node:path');
const os = require('node:os'); 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 { YamlXmlBuilder } = require('../tools/cli/lib/yaml-xml-builder');
const { ManifestGenerator } = require('../tools/cli/installers/lib/core/manifest-generator'); const { ManifestGenerator } = require('../tools/cli/installers/lib/core/manifest-generator');
const { IdeManager } = require('../tools/cli/installers/lib/ide/manager'); 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'); const tempOutput = path.join(__dirname, 'temp-pm-agent.md');
try { 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'); 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'); const tempOutput = path.join(__dirname, 'temp-qa-agent.md');
try { 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'); const compiled = await fs.readFile(tempOutput, 'utf8');
assert(compiled.includes('QA Engineer'), 'QA agent compilation includes agent title'); assert(compiled.includes('QA Engineer'), 'QA agent compilation includes agent title');

View File

@ -19,7 +19,7 @@ module.exports = {
const { bmadDir } = await installer.findBmadDir(projectDir); const { bmadDir } = await installer.findBmadDir(projectDir);
// Check if bmad directory exists // Check if bmad directory exists
const fs = require('fs-extra'); const fs = require('../lib/fs');
if (!(await fs.pathExists(bmadDir))) { if (!(await fs.pathExists(bmadDir))) {
await prompts.log.warn('No BMAD installation found in the current directory.'); await prompts.log.warn('No BMAD installation found in the current directory.');
await prompts.log.message(`Expected location: ${bmadDir}`); await prompts.log.message(`Expected location: ${bmadDir}`);

View File

@ -1,5 +1,5 @@
const path = require('node:path'); const path = require('node:path');
const fs = require('fs-extra'); const fs = require('../lib/fs');
const prompts = require('../lib/prompts'); const prompts = require('../lib/prompts');
const { Installer } = require('../installers/lib/core/installer'); const { Installer } = require('../installers/lib/core/installer');

View File

@ -1,5 +1,5 @@
const path = require('node:path'); const path = require('node:path');
const fs = require('fs-extra'); const fs = require('../../../lib/fs');
const yaml = require('yaml'); const yaml = require('yaml');
const { getProjectRoot, getModulePath } = require('../../../lib/project-root'); const { getProjectRoot, getModulePath } = require('../../../lib/project-root');
const { CLIUtils } = require('../../../lib/cli-utils'); const { CLIUtils } = require('../../../lib/cli-utils');

View File

@ -4,7 +4,7 @@
* and can be checked into source control * and can be checked into source control
*/ */
const fs = require('fs-extra'); const fs = require('../../../lib/fs');
const path = require('node:path'); const path = require('node:path');
const crypto = require('node:crypto'); const crypto = require('node:crypto');
const prompts = require('../../../lib/prompts'); const prompts = require('../../../lib/prompts');

View File

@ -1,4 +1,4 @@
const fs = require('fs-extra'); const fs = require('../../../lib/fs');
const path = require('node:path'); const path = require('node:path');
const glob = require('glob'); const glob = require('glob');
const yaml = require('yaml'); const yaml = require('yaml');

View File

@ -1,5 +1,5 @@
const path = require('node:path'); const path = require('node:path');
const fs = require('fs-extra'); const fs = require('../../../lib/fs');
const yaml = require('yaml'); const yaml = require('yaml');
const { Manifest } = require('./manifest'); const { Manifest } = require('./manifest');

View File

@ -1,5 +1,5 @@
const path = require('node:path'); const path = require('node:path');
const fs = require('fs-extra'); const fs = require('../../../lib/fs');
const yaml = require('yaml'); const yaml = require('yaml');
const prompts = require('../../../lib/prompts'); const prompts = require('../../../lib/prompts');

View File

@ -1,5 +1,5 @@
const path = require('node:path'); const path = require('node:path');
const fs = require('fs-extra'); const fs = require('../../../lib/fs');
const { Detector } = require('./detector'); const { Detector } = require('./detector');
const { Manifest } = require('./manifest'); const { Manifest } = require('./manifest');
const { ModuleManager } = require('../modules/manager'); const { ModuleManager } = require('../modules/manager');
@ -87,7 +87,7 @@ class Installer {
if (textExtensions.includes(ext)) { if (textExtensions.includes(ext)) {
try { try {
// Read the file content // Read the file content
let content = await fs.readFile(sourcePath, 'utf8'); const content = await fs.readFile(sourcePath, 'utf8');
// Write to target with replaced content // Write to target with replaced content
await fs.ensureDir(path.dirname(targetPath)); await fs.ensureDir(path.dirname(targetPath));
@ -260,7 +260,7 @@ class Installer {
// Collect configurations for modules (skip if quick update already collected them) // Collect configurations for modules (skip if quick update already collected them)
let moduleConfigs; let moduleConfigs;
let customModulePaths = new Map(); const customModulePaths = new Map();
if (config._quickUpdate) { if (config._quickUpdate) {
// Quick update already collected all configs, use them directly // 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) // Also check cache directory for custom modules (like quick update does)
const cacheDir = path.join(bmadDir, '_config', 'custom'); const cacheDir = path.join(bmadDir, '_config', 'custom');
if (await fs.pathExists(cacheDir)) { 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) { for (const cachedModule of cachedModules) {
const moduleId = cachedModule.name; const moduleId = cachedModule.name;
@ -585,7 +587,9 @@ class Installer {
const relativePath = path.relative(bmadDir, modifiedFile.path); const relativePath = path.relative(bmadDir, modifiedFile.path);
const tempBackupPath = path.join(tempModifiedBackupDir, relativePath); const tempBackupPath = path.join(tempModifiedBackupDir, relativePath);
await fs.ensureDir(path.dirname(tempBackupPath)); 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`); 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) // Also check cache directory for custom modules (like quick update does)
const cacheDir = path.join(bmadDir, '_config', 'custom'); const cacheDir = path.join(bmadDir, '_config', 'custom');
if (await fs.pathExists(cacheDir)) { 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) { for (const cachedModule of cachedModules) {
const moduleId = cachedModule.name; const moduleId = cachedModule.name;
@ -668,7 +674,9 @@ class Installer {
const relativePath = path.relative(bmadDir, modifiedFile.path); const relativePath = path.relative(bmadDir, modifiedFile.path);
const tempBackupPath = path.join(tempModifiedBackupDir, relativePath); const tempBackupPath = path.join(tempModifiedBackupDir, relativePath);
await fs.ensureDir(path.dirname(tempBackupPath)); 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`); spinner.stop(`Backed up ${modifiedFiles.length} modified files`);
config._tempModifiedBackupDir = tempModifiedBackupDir; config._tempModifiedBackupDir = tempModifiedBackupDir;
@ -907,7 +915,11 @@ class Installer {
let taskResolution; let taskResolution;
// Collect directory creation results for output after tasks() completes // Collect directory creation results for output after tasks() completes
const dirResults = { createdDirs: [], movedDirs: [], createdWdsFolders: [] }; const dirResults = {
createdDirs: [],
movedDirs: [],
createdWdsFolders: [],
};
// Build task list conditionally // Build task list conditionally
const installTasks = []; const installTasks = [];
@ -919,7 +931,9 @@ class Installer {
task: async (message) => { task: async (message) => {
await this.installCoreWithDependencies(bmadDir, { core: {} }); await this.installCoreWithDependencies(bmadDir, { core: {} });
addResult('Core', 'ok', isQuickUpdate ? 'updated' : 'installed'); 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'; return isQuickUpdate ? 'Core updated' : 'Core installed';
}, },
}); });
@ -965,7 +979,11 @@ class Installer {
const cachedModule = finalCustomContent.cachedModules.find((m) => m.id === moduleName); const cachedModule = finalCustomContent.cachedModules.find((m) => m.id === moduleName);
if (cachedModule) { if (cachedModule) {
isCustomModule = true; 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, { await this.generateModuleConfigs(bmadDir, {
[moduleName]: { ...config.coreConfig, ...customInfo.config, ...collectedModuleConfig }, [moduleName]: {
...config.coreConfig,
...customInfo.config,
...collectedModuleConfig,
},
}); });
} else { } else {
if (!resolution || !resolution.byModule) { if (!resolution || !resolution.byModule) {
@ -1474,7 +1496,9 @@ class Installer {
// Also check cache directory // Also check cache directory
const cacheDir = path.join(bmadDir, '_config', 'custom'); const cacheDir = path.join(bmadDir, '_config', 'custom');
if (await fs.pathExists(cacheDir)) { 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) { for (const cachedModule of cachedModules) {
if (cachedModule.isDirectory()) { if (cachedModule.isDirectory()) {
@ -1549,7 +1573,9 @@ class Installer {
for (const module of existingInstall.modules) { for (const module of existingInstall.modules) {
spinner.message(`Updating module: ${module.id}...`); 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 // Update manifest
@ -1608,7 +1634,9 @@ class Installer {
// 2. IDE CLEANUP (before _bmad/ deletion so configs are accessible) // 2. IDE CLEANUP (before _bmad/ deletion so configs are accessible)
if (options.removeIdeConfigs !== false) { if (options.removeIdeConfigs !== false) {
await this.uninstallIdeConfigs(projectDir, existingInstall, { silent: options.silent }); await this.uninstallIdeConfigs(projectDir, existingInstall, {
silent: options.silent,
});
removed.ideConfigs = true; removed.ideConfigs = true;
} }
@ -1847,7 +1875,11 @@ class Installer {
// Lookup agent info // Lookup agent info
const cleanAgentName = agentName ? agentName.trim() : ''; 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 // Build new row with agent info
const newRow = [ const newRow = [
@ -1902,8 +1934,8 @@ class Installer {
} }
// Sequence comparison // Sequence comparison
const seqA = parseInt(colsA[4] || '0', 10); const seqA = Number.parseInt(colsA[4] || '0', 10);
const seqB = parseInt(colsB[4] || '0', 10); const seqB = Number.parseInt(colsB[4] || '0', 10);
return seqA - seqB; return seqA - seqB;
}); });
@ -2445,7 +2477,9 @@ class Installer {
} }
const cacheDir = path.join(bmadDir, '_config', 'custom'); const cacheDir = path.join(bmadDir, '_config', 'custom');
if (await fs.pathExists(cacheDir)) { 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) { for (const cachedModule of cachedModules) {
const moduleId = cachedModule.name; const moduleId = cachedModule.name;
@ -2680,7 +2714,9 @@ class Installer {
const customModuleSources = new Map(); const customModuleSources = new Map();
const cacheDir = path.join(bmadDir, '_config', 'custom'); const cacheDir = path.join(bmadDir, '_config', 'custom');
if (await fs.pathExists(cacheDir)) { 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) { for (const cachedModule of cachedModules) {
if (cachedModule.isDirectory()) { if (cachedModule.isDirectory()) {
@ -3152,8 +3188,7 @@ class Installer {
// Remove the module from filesystem and manifest // Remove the module from filesystem and manifest
const modulePath = path.join(bmadDir, missing.id); const modulePath = path.join(bmadDir, missing.id);
if (await fs.pathExists(modulePath)) { if (await fs.pathExists(modulePath)) {
const fsExtra = require('fs-extra'); await fs.remove(modulePath);
await fsExtra.remove(modulePath);
await prompts.log.warn(`Deleted module directory: ${path.relative(projectRoot, modulePath)}`); await prompts.log.warn(`Deleted module directory: ${path.relative(projectRoot, modulePath)}`);
} }

View File

@ -1,5 +1,5 @@
const path = require('node:path'); const path = require('node:path');
const fs = require('fs-extra'); const fs = require('../../../lib/fs');
const yaml = require('yaml'); const yaml = require('yaml');
const crypto = require('node:crypto'); const crypto = require('node:crypto');
const csv = require('csv-parse/sync'); const csv = require('csv-parse/sync');

View File

@ -1,5 +1,5 @@
const path = require('node:path'); const path = require('node:path');
const fs = require('fs-extra'); const fs = require('../../../lib/fs');
const crypto = require('node:crypto'); const crypto = require('node:crypto');
const { getProjectRoot } = require('../../../lib/project-root'); const { getProjectRoot } = require('../../../lib/project-root');
const prompts = require('../../../lib/prompts'); const prompts = require('../../../lib/prompts');

View File

@ -1,5 +1,5 @@
const path = require('node:path'); const path = require('node:path');
const fs = require('fs-extra'); const fs = require('../../../lib/fs');
const yaml = require('yaml'); const yaml = require('yaml');
const prompts = require('../../../lib/prompts'); const prompts = require('../../../lib/prompts');
const { FileOps } = require('../../../lib/file-ops'); const { FileOps } = require('../../../lib/file-ops');

View File

@ -1,5 +1,5 @@
const path = require('node:path'); const path = require('node:path');
const fs = require('fs-extra'); const fs = require('../../../lib/fs');
const { XmlHandler } = require('../../../lib/xml-handler'); const { XmlHandler } = require('../../../lib/xml-handler');
const prompts = require('../../../lib/prompts'); const prompts = require('../../../lib/prompts');
const { getSourcePath } = require('../../../lib/project-root'); const { getSourcePath } = require('../../../lib/project-root');

View File

@ -1,6 +1,6 @@
const os = require('node:os'); const os = require('node:os');
const path = require('node:path'); const path = require('node:path');
const fs = require('fs-extra'); const fs = require('../../../lib/fs');
const yaml = require('yaml'); const yaml = require('yaml');
const { BaseIdeSetup } = require('./_base-ide'); const { BaseIdeSetup } = require('./_base-ide');
const prompts = require('../../../lib/prompts'); const prompts = require('../../../lib/prompts');

View File

@ -1,4 +1,4 @@
const fs = require('fs-extra'); const fs = require('../../../lib/fs');
const path = require('node:path'); const path = require('node:path');
const yaml = require('yaml'); const yaml = require('yaml');

View File

@ -1,5 +1,5 @@
const path = require('node:path'); 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'); const { toColonPath, toDashPath, customAgentColonName, customAgentDashName, BMAD_FOLDER_NAME } = require('./path-utils');
/** /**

View File

@ -1,5 +1,5 @@
const path = require('node:path'); const path = require('node:path');
const fs = require('fs-extra'); const fs = require('../../../../lib/fs');
const { loadSkillManifest, getCanonicalId } = require('./skill-manifest'); const { loadSkillManifest, getCanonicalId } = require('./skill-manifest');
/** /**

View File

@ -1,5 +1,5 @@
const path = require('node:path'); const path = require('node:path');
const fs = require('fs-extra'); const fs = require('../../../../lib/fs');
const yaml = require('yaml'); const yaml = require('yaml');
const { glob } = require('glob'); const { glob } = require('glob');
const { getSourcePath } = require('../../../../lib/project-root'); const { getSourcePath } = require('../../../../lib/project-root');

View File

@ -1,5 +1,5 @@
const path = require('node:path'); const path = require('node:path');
const fs = require('fs-extra'); const fs = require('../../../../lib/fs');
const yaml = require('yaml'); const yaml = require('yaml');
/** /**

View File

@ -1,5 +1,5 @@
const path = require('node:path'); const path = require('node:path');
const fs = require('fs-extra'); const fs = require('../../../../lib/fs');
const csv = require('csv-parse/sync'); const csv = require('csv-parse/sync');
const { toColonName, toColonPath, toDashPath, BMAD_FOLDER_NAME } = require('./path-utils'); const { toColonName, toColonPath, toDashPath, BMAD_FOLDER_NAME } = require('./path-utils');

View File

@ -1,5 +1,5 @@
const path = require('node:path'); const path = require('node:path');
const fs = require('fs-extra'); const fs = require('../../../../lib/fs');
const csv = require('csv-parse/sync'); const csv = require('csv-parse/sync');
const { BMAD_FOLDER_NAME } = require('./path-utils'); const { BMAD_FOLDER_NAME } = require('./path-utils');

View File

@ -1,4 +1,4 @@
const fs = require('fs-extra'); const fs = require('../../lib/fs');
const path = require('node:path'); const path = require('node:path');
const yaml = require('yaml'); const yaml = require('yaml');
const prompts = require('../../lib/prompts'); const prompts = require('../../lib/prompts');

View File

@ -1,4 +1,4 @@
const fs = require('fs-extra'); const fs = require('../../../lib/fs');
const path = require('node:path'); const path = require('node:path');
const yaml = require('yaml'); const yaml = require('yaml');
const prompts = require('../../../lib/prompts'); const prompts = require('../../../lib/prompts');

View File

@ -1,5 +1,5 @@
const path = require('node:path'); const path = require('node:path');
const fs = require('fs-extra'); const fs = require('../../../lib/fs');
const yaml = require('yaml'); const yaml = require('yaml');
const prompts = require('../../../lib/prompts'); const prompts = require('../../../lib/prompts');
const { XmlHandler } = require('../../../lib/xml-handler'); 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. * and agent file management including XML activation block injection.
* *
* @class ModuleManager * @class ModuleManager
* @requires fs-extra * @requires lib/fs
* @requires yaml * @requires yaml
* @requires prompts * @requires prompts
* @requires XmlHandler * @requires XmlHandler
@ -208,7 +208,9 @@ class ModuleManager {
if (this.bmadDir) { if (this.bmadDir) {
const customCacheDir = path.join(this.bmadDir, '_config', 'custom'); const customCacheDir = path.join(this.bmadDir, '_config', 'custom');
if (await fs.pathExists(customCacheDir)) { 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) { for (const entry of cacheEntries) {
if (entry.isDirectory()) { if (entry.isDirectory()) {
const cachePath = path.join(customCacheDir, entry.name); const cachePath = path.join(customCacheDir, entry.name);
@ -387,7 +389,12 @@ class ModuleManager {
const fetchSpinner = await createSpinner(); const fetchSpinner = await createSpinner();
fetchSpinner.start(`Fetching ${moduleInfo.name}...`); fetchSpinner.start(`Fetching ${moduleInfo.name}...`);
try { 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 // Fetch and reset to remote - works better with shallow clones than pull
execSync('git fetch origin --depth 1', { execSync('git fetch origin --depth 1', {
cwd: moduleCacheDir, cwd: moduleCacheDir,
@ -399,7 +406,12 @@ class ModuleManager {
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, 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}`); fetchSpinner.stop(`Fetched ${moduleInfo.name}`);
// Force dependency install if we got new code // Force dependency install if we got new code
@ -521,7 +533,9 @@ class ModuleManager {
* @param {Object} options.logger - Logger instance for output * @param {Object} options.logger - Logger instance for output
*/ */
async install(moduleName, bmadDir, fileTrackingCallback = null, options = {}) { 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); const targetPath = path.join(bmadDir, moduleName);
// Check if source module exists // Check if source module exists
@ -619,7 +633,9 @@ class ModuleManager {
if (force) { if (force) {
// Force update - remove and reinstall // Force update - remove and reinstall
await fs.remove(targetPath); 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 { } else {
// Selective update - preserve user modifications // Selective update - preserve user modifications
await this.syncModule(sourcePath, targetPath); await this.syncModule(sourcePath, targetPath);
@ -855,7 +871,7 @@ class ModuleManager {
// Check for customizations and build answers object // Check for customizations and build answers object
let customizedFields = []; let customizedFields = [];
let answers = {}; const answers = {};
if (await fs.pathExists(customizePath)) { if (await fs.pathExists(customizePath)) {
const customizeContent = await fs.readFile(customizePath, 'utf8'); const customizeContent = await fs.readFile(customizePath, 'utf8');
const customizeData = yaml.parse(customizeContent); const customizeData = yaml.parse(customizeContent);
@ -928,7 +944,9 @@ class ModuleManager {
// Copy any non-sidecar files from agent directory (e.g., foo.md) // Copy any non-sidecar files from agent directory (e.g., foo.md)
const agentDir = path.dirname(agentFile); 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) { for (const entry of agentEntries) {
if (entry.isFile() && !entry.name.endsWith('.agent.yaml') && !entry.name.endsWith('.md')) { if (entry.isFile() && !entry.name.endsWith('.agent.yaml') && !entry.name.endsWith('.md')) {
@ -1139,7 +1157,11 @@ class ModuleManager {
const moduleConfig = options.moduleConfig || {}; const moduleConfig = options.moduleConfig || {};
const existingModuleConfig = options.existingModuleConfig || {}; const existingModuleConfig = options.existingModuleConfig || {};
const projectRoot = path.dirname(bmadDir); 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 // Special handling for core module - it's in src/core not src/modules
let sourcePath; let sourcePath;

View File

@ -1,4 +1,4 @@
const fs = require('fs-extra'); const fs = require('./fs');
const path = require('node:path'); const path = require('node:path');
const { getSourcePath } = require('./project-root'); const { getSourcePath } = require('./project-root');

View File

@ -1,5 +1,5 @@
const yaml = require('yaml'); const yaml = require('yaml');
const fs = require('fs-extra'); const fs = require('./fs');
/** /**
* Analyzes agent YAML files to detect which handlers are needed * Analyzes agent YAML files to detect which handlers are needed

View File

@ -1,5 +1,5 @@
const path = require('node:path'); const path = require('node:path');
const fs = require('fs-extra'); const fs = require('./fs');
const { escapeXml } = require('../../lib/xml-utils'); const { escapeXml } = require('../../lib/xml-utils');
const AgentPartyGenerator = { const AgentPartyGenerator = {

View File

@ -1,4 +1,4 @@
const fs = require('fs-extra'); const fs = require('./fs');
const yaml = require('yaml'); const yaml = require('yaml');
const path = require('node:path'); const path = require('node:path');
const packageJson = require('../../../package.json'); const packageJson = require('../../../package.json');

View File

@ -1,4 +1,4 @@
const fs = require('fs-extra'); const fs = require('./fs');
const path = require('node:path'); const path = require('node:path');
const crypto = require('node:crypto'); const crypto = require('node:crypto');

175
tools/cli/lib/fs.js Normal file
View File

@ -0,0 +1,175 @@
/**
* 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<boolean>}
*/
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 (error) {
if (error && error.code !== 'ENOENT' && error.code !== 'ENOTDIR') {
throw error;
}
// 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/, '');
try {
return JSON.parse(raw);
} catch (error) {
error.message = `Failed to parse JSON in ${filePath}: ${error.message}`;
throw error;
}
};

View File

@ -1,4 +1,4 @@
const fs = require('fs-extra'); const fs = require('./fs');
const path = require('node:path'); const path = require('node:path');
const yaml = require('yaml'); const yaml = require('yaml');
const { getProjectRoot } = require('./project-root'); const { getProjectRoot } = require('./project-root');

View File

@ -1,5 +1,5 @@
const path = require('node:path'); 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 * Find the BMAD project root directory by looking for package.json

View File

@ -1,6 +1,6 @@
const path = require('node:path'); const path = require('node:path');
const os = require('node:os'); const os = require('node:os');
const fs = require('fs-extra'); const fs = require('./fs');
const { CLIUtils } = require('./cli-utils'); const { CLIUtils } = require('./cli-utils');
const { CustomHandler } = require('../installers/lib/custom/handler'); const { CustomHandler } = require('../installers/lib/custom/handler');
const { ExternalModuleManager } = require('../installers/lib/modules/external-manager'); const { ExternalModuleManager } = require('../installers/lib/modules/external-manager');

View File

@ -1,5 +1,5 @@
const xml2js = require('xml2js'); const xml2js = require('xml2js');
const fs = require('fs-extra'); const fs = require('./fs');
const path = require('node:path'); const path = require('node:path');
const { getProjectRoot, getSourcePath } = require('./project-root'); const { getProjectRoot, getSourcePath } = require('./project-root');
const { YamlXmlBuilder } = require('./yaml-xml-builder'); const { YamlXmlBuilder } = require('./yaml-xml-builder');

View File

@ -1,5 +1,5 @@
const yaml = require('yaml'); const yaml = require('yaml');
const fs = require('fs-extra'); const fs = require('./fs');
const path = require('node:path'); const path = require('node:path');
const crypto = require('node:crypto'); const crypto = require('node:crypto');
const { AgentAnalyzer } = require('./agent-analyzer'); const { AgentAnalyzer } = require('./agent-analyzer');

View File

@ -3,7 +3,7 @@
* This should be run once to update existing installations * 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 path = require('node:path');
const yaml = require('yaml'); const yaml = require('yaml');
const chalk = require('chalk'); const chalk = require('chalk');