BMAD-METHOD/src/core-skills/bmad-module/scripts/lib/module-dirs.mjs

122 lines
5.1 KiB
JavaScript

import fs from 'node:fs/promises';
import path from 'node:path';
import { parse as parseYaml } from './vendor/yaml.mjs';
// Create the project working directories a module declares in its module.yaml
// `directories:` key, mirroring the full installer's
// OfficialModules.createModuleDirectories (tools/installer/modules/official-modules.js).
//
// Each entry is a `{config_key}` reference resolved against the module's config
// values (produced by config-gen). `{project-root}` is stripped to a project-
// relative path; the dir is created under the project root (the parent of
// `_bmad/`). On update, a changed path moves the old directory to the new one.
// All failures are non-fatal warnings — the module itself is already installed.
const warn = (msg) => process.stderr.write(`[bmad-module] warn: ${msg}\n`);
async function pathExists(p) {
try {
await fs.stat(p);
return true;
} catch {
return false;
}
}
// `moduleConfig` / `existingConfig`: {configKey: resolvedValue} maps, where a
// value may carry a leading `{project-root}/`. Returns a summary for display.
export async function createModuleDirectories(bmadDir, code, moduleConfig = {}, existingConfig = {}) {
const projectRoot = path.dirname(bmadDir);
const empty = { createdDirs: [], movedDirs: [], createdWdsFolders: [] };
let moduleYamlRaw;
try {
moduleYamlRaw = await fs.readFile(path.join(bmadDir, code, 'module.yaml'), 'utf8');
} catch {
return empty; // no module.yaml flattened into the install — nothing to do
}
let moduleYaml;
try {
moduleYaml = parseYaml(moduleYamlRaw);
} catch (e) {
warn(`invalid ${code}/module.yaml: ${e.message}`);
return empty;
}
if (!moduleYaml || !Array.isArray(moduleYaml.directories)) return empty;
const wdsFolders = Array.isArray(moduleYaml.wds_folders) ? moduleYaml.wds_folders : [];
const createdDirs = [];
const movedDirs = [];
const createdWdsFolders = [];
const normalizedRoot = path.normalize(projectRoot);
const toRelPath = (value) => path.normalize(value.replace(/^\{project-root\}\/?/, '').replaceAll('{project-root}', ''));
for (const dirRef of moduleYaml.directories) {
const varMatch = typeof dirRef === 'string' && dirRef.match(/^\{([^}]+)\}$/);
if (!varMatch) continue; // only variable references are honored
const configKey = varMatch[1];
const dirValue = moduleConfig[configKey];
if (!dirValue || typeof dirValue !== 'string') continue;
const dirPath = toRelPath(dirValue);
const fullPath = path.join(projectRoot, dirPath);
const normalizedNewAbs = path.normalize(fullPath);
if (normalizedNewAbs !== normalizedRoot && !normalizedNewAbs.startsWith(normalizedRoot + path.sep)) {
warn(`${configKey} path escapes project root, skipping: ${dirPath}`);
continue;
}
// Detect a changed path vs the previous install for a move.
let oldFullPath = null;
let oldDirPath = null;
const oldValue = existingConfig[configKey];
if (oldValue && typeof oldValue === 'string') {
const normalizedOld = toRelPath(oldValue);
if (normalizedOld !== dirPath) {
oldDirPath = normalizedOld;
oldFullPath = path.join(projectRoot, oldDirPath);
const normalizedOldAbs = path.normalize(oldFullPath);
if (normalizedOldAbs !== normalizedRoot && !normalizedOldAbs.startsWith(normalizedRoot + path.sep)) {
oldFullPath = null; // old path escapes root — ignore
} else if (normalizedOldAbs.startsWith(normalizedNewAbs + path.sep) || normalizedNewAbs.startsWith(normalizedOldAbs + path.sep)) {
warn(`${configKey}: cannot move between parent/child paths (${oldDirPath} / ${dirPath}); creating new directory`);
oldFullPath = null;
}
}
}
const dirName = configKey.replaceAll('_', ' ');
const newExists = await pathExists(fullPath);
if (oldFullPath && (await pathExists(oldFullPath)) && !newExists) {
try {
await fs.mkdir(path.dirname(fullPath), { recursive: true });
await fs.rename(oldFullPath, fullPath);
movedDirs.push(`${dirName}: ${oldDirPath}${dirPath}`);
} catch (moveErr) {
warn(`failed to move ${oldDirPath}${dirPath}: ${moveErr.message}. Creating new directory; move contents manually.`);
await fs.mkdir(fullPath, { recursive: true });
createdDirs.push(`${dirName}: ${dirPath}`);
}
} else if (oldFullPath && (await pathExists(oldFullPath)) && newExists) {
warn(`${dirName}: path changed but both old (${oldDirPath}) and new (${dirPath}) exist — review/merge manually.`);
} else if (!newExists) {
await fs.mkdir(fullPath, { recursive: true });
createdDirs.push(`${dirName}: ${dirPath}`);
}
// WDS subfolders under design_artifacts.
if (configKey === 'design_artifacts' && wdsFolders.length) {
for (const sub of wdsFolders) {
const subPath = path.join(fullPath, sub);
if (!(await pathExists(subPath))) {
await fs.mkdir(subPath, { recursive: true });
createdWdsFolders.push(sub);
}
}
}
}
return { createdDirs, movedDirs, createdWdsFolders };
}