BMAD-METHOD/tools/installer/global-config.js

181 lines
5.8 KiB
JavaScript

/**
* Helpers for the cross-platform global BMad config directory.
*
* The "global" tier is read-only to most installer code paths — only core
* scope:user answers (user_name, communication_language) and identity defaults
* are written there, and only by the post-install global-write step. Everything
* else reads.
*
* Location precedence:
* 1. $BMAD_HOME (for CI / corporate / multi-account setups)
* 2. ~/.bmad
*
* Works on macOS, Linux, WSL, and Windows: os.homedir() returns the
* platform-appropriate home (and on WSL, the Linux home — each WSL distro has
* its own global config).
*/
const path = require('node:path');
const os = require('node:os');
const fs = require('./fs-native');
function resolveGlobalDir() {
const override = process.env.BMAD_HOME;
if (override && override.trim()) {
return path.resolve(expandTilde(override.trim()));
}
return path.join(os.homedir(), '.bmad');
}
// JS counterpart to Python's Path.expanduser() — keeps installer/resolver
// agreement when BMAD_HOME is set in non-shell contexts (Docker, .env files,
// Windows env var GUI) where the shell never expands `~`.
function expandTilde(input) {
if (input === '~') return os.homedir();
if (input.startsWith('~/') || input.startsWith('~\\')) {
return path.join(os.homedir(), input.slice(2));
}
return input;
}
function globalTeamConfigPath() {
return path.join(resolveGlobalDir(), 'config.toml');
}
function globalUserConfigPath() {
return path.join(resolveGlobalDir(), 'config.user.toml');
}
/**
* Parse a minimal subset of TOML — enough for the installer-owned files:
* top-level tables ([section] / [section.sub]) and simple scalar values
* (string, number, boolean). No arrays of tables, inline tables, datetimes,
* or multiline strings — those don't appear in files we author. Reader stays
* dependency-free; we only consume what we emit.
*
* For an unrecognized shape, the offending line is silently dropped (rather
* than erroring) to keep the installer resilient against hand-edits that
* went slightly outside the documented schema.
*/
function parseSimpleToml(content) {
const result = {};
let currentTable = result;
for (const rawLine of content.split('\n')) {
const line = stripInlineComment(rawLine).trim();
if (!line) continue;
const sectionMatch = line.match(/^\[([^\]]+)]\s*$/);
if (sectionMatch) {
const parts = sectionMatch[1].split('.').map((p) => p.trim());
currentTable = result;
for (const part of parts) {
if (!currentTable[part] || typeof currentTable[part] !== 'object' || Array.isArray(currentTable[part])) {
currentTable[part] = {};
}
currentTable = currentTable[part];
}
continue;
}
const kvMatch = line.match(/^([A-Za-z0-9_-]+)\s*=\s*(.+)$/);
if (kvMatch) {
const [, key, rawValue] = kvMatch;
const parsed = parseTomlScalar(rawValue.trim());
if (parsed !== undefined) {
currentTable[key] = parsed;
}
}
}
return result;
}
/**
* Strip a trailing `# comment` from a TOML line, but only when the `#` lives
* outside a double-quoted string. We don't author multiline strings or
* literal strings, so a single double-quote scanner is sufficient.
*/
function stripInlineComment(line) {
let inString = false;
let escaped = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (escaped) {
escaped = false;
continue;
}
if (ch === '\\') {
escaped = true;
continue;
}
if (ch === '"') {
inString = !inString;
continue;
}
if (ch === '#' && !inString) {
return line.slice(0, i);
}
}
return line;
}
function parseTomlScalar(raw) {
if (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) {
// Single-pass unescape — sequential replaceAll lets `\\n` (backslash + n)
// collapse into a newline because the second pass sees the just-produced
// `\n` and treats it as the escape sequence. One regex avoids that.
const escapes = { '\\\\': '\\', '\\"': '"', '\\n': '\n', '\\r': '\r', '\\t': '\t' };
return raw.slice(1, -1).replaceAll(/\\["\\nrt]/g, (m) => escapes[m] ?? m);
}
if (raw === 'true') return true;
if (raw === 'false') return false;
if (/^-?\d+$/.test(raw)) return Number.parseInt(raw, 10);
if (/^-?\d+\.\d+$/.test(raw)) return Number.parseFloat(raw);
return; // dropped silently — see header comment
}
/**
* Load both global TOML files. Either may be missing; returns merged result.
* Files are read but never written by this helper.
*
* @returns {Promise<{ team: object, user: object, merged: object }>}
*/
async function loadGlobalConfig() {
const team = await readTomlFile(globalTeamConfigPath());
const user = await readTomlFile(globalUserConfigPath());
// Shallow-deep merge: user table wins over team at every key path. The
// installer only consults the merged view for default-seeding, so this is
// sufficient (we don't need the full structural-merge of resolve_config.py).
const merged = mergeDeep(team, user);
return { team, user, merged };
}
async function readTomlFile(filePath) {
if (!(await fs.pathExists(filePath))) return {};
try {
const content = await fs.readFile(filePath, 'utf8');
return parseSimpleToml(content);
} catch {
return {};
}
}
function mergeDeep(base, override) {
if (!override || typeof override !== 'object' || Array.isArray(override)) return override === undefined ? base : override;
if (!base || typeof base !== 'object' || Array.isArray(base)) return override;
const result = { ...base };
for (const [key, value] of Object.entries(override)) {
result[key] = mergeDeep(result[key], value);
}
return result;
}
module.exports = {
resolveGlobalDir,
globalTeamConfigPath,
globalUserConfigPath,
parseSimpleToml,
loadGlobalConfig,
};