const path = require('node:path'); const fs = require('fs-extra'); const yaml = require('yaml'); const { Manifest } = require('./manifest'); /** * Immutable snapshot of an existing BMAD installation. * Pure query object — no filesystem operations after construction. */ class ExistingInstall { #version; constructor({ installed, version, hasCore, modules, ides, customModules }) { this.installed = installed; this.#version = version; this.hasCore = hasCore; this.modules = Object.freeze(modules.map((m) => Object.freeze({ ...m }))); this.moduleIds = Object.freeze(this.modules.map((m) => m.id)); this.ides = Object.freeze([...ides]); this.customModules = Object.freeze([...customModules]); Object.freeze(this); } get version() { if (!this.installed) { throw new Error('version is not available when nothing is installed'); } return this.#version; } static empty() { return new ExistingInstall({ installed: false, version: null, hasCore: false, modules: [], ides: [], customModules: [], }); } /** * Scan a bmad directory and return an immutable snapshot of what's installed. * @param {string} bmadDir - Path to bmad directory * @returns {Promise} */ static async detect(bmadDir) { if (!(await fs.pathExists(bmadDir))) { return ExistingInstall.empty(); } let version = null; let hasCore = false; const modules = []; let ides = []; let customModules = []; const manifest = new Manifest(); const manifestData = await manifest.read(bmadDir); if (manifestData) { version = manifestData.version; if (manifestData.customModules) { customModules = manifestData.customModules; } if (manifestData.ides) { ides = manifestData.ides.filter((ide) => ide && typeof ide === 'string'); } } const corePath = path.join(bmadDir, 'core'); if (await fs.pathExists(corePath)) { hasCore = true; if (!version) { const coreConfigPath = path.join(corePath, 'config.yaml'); if (await fs.pathExists(coreConfigPath)) { try { const configContent = await fs.readFile(coreConfigPath, 'utf8'); const config = yaml.parse(configContent); if (config.version) { version = config.version; } } catch { // Ignore config read errors } } } } if (manifestData && manifestData.modules && manifestData.modules.length > 0) { for (const moduleId of manifestData.modules) { const modulePath = path.join(bmadDir, moduleId); const moduleConfigPath = path.join(modulePath, 'config.yaml'); const moduleInfo = { id: moduleId, path: modulePath, version: 'unknown', }; if (await fs.pathExists(moduleConfigPath)) { try { const configContent = await fs.readFile(moduleConfigPath, 'utf8'); const config = yaml.parse(configContent); moduleInfo.version = config.version || 'unknown'; moduleInfo.name = config.name || moduleId; moduleInfo.description = config.description; } catch { // Ignore config read errors } } modules.push(moduleInfo); } } const installed = hasCore || modules.length > 0 || !!manifestData; if (!installed) { return ExistingInstall.empty(); } return new ExistingInstall({ installed, version, hasCore, modules, ides, customModules }); } } module.exports = { ExistingInstall };