BMAD-METHOD/tools/cli/lib/fs.js

176 lines
5.1 KiB
JavaScript

/**
* 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;
}
};