refactor(installer): replace Detector class with ExistingInstall value object

Remove all legacy/v4 detection and migration code. Replace stateless
Detector class with immutable ExistingInstall that exposes a static
detect() factory and precomputed query properties (moduleIds, ides).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Verkhovsky 2026-03-22 00:51:31 -06:00
parent 232fba8cc8
commit 3e54815961
6 changed files with 207 additions and 603 deletions

View File

@ -63,8 +63,8 @@ module.exports = {
const existingInstall = await installer.getStatus(projectDir);
const version = existingInstall.version || 'unknown';
const modules = (existingInstall.modules || []).map((m) => m.id || m.name).join(', ');
const ides = (existingInstall.ides || []).join(', ');
const modules = existingInstall.moduleIds.join(', ');
const ides = existingInstall.ides.join(', ');
const outputFolder = await installer.getOutputFolder(projectDir);

View File

@ -1,223 +0,0 @@
const path = require('node:path');
const fs = require('fs-extra');
const yaml = require('yaml');
const { Manifest } = require('./manifest');
class Detector {
/**
* Detect existing BMAD installation
* @param {string} bmadDir - Path to bmad directory
* @returns {Object} Installation status and details
*/
async detect(bmadDir) {
const result = {
installed: false,
path: bmadDir,
version: null,
hasCore: false,
modules: [],
ides: [],
customModules: [],
manifest: null,
};
// Check if bmad directory exists
if (!(await fs.pathExists(bmadDir))) {
return result;
}
// Check for manifest using the Manifest class
const manifest = new Manifest();
const manifestData = await manifest.read(bmadDir);
if (manifestData) {
result.manifest = manifestData;
result.version = manifestData.version;
result.installed = true;
// Copy custom modules if they exist
if (manifestData.customModules) {
result.customModules = manifestData.customModules;
}
}
// Check for core
const corePath = path.join(bmadDir, 'core');
if (await fs.pathExists(corePath)) {
result.hasCore = true;
// Try to get core version from config
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 (!result.version && config.version) {
result.version = config.version;
}
} catch {
// Ignore config read errors
}
}
}
// Check for modules
// If manifest exists, use it as the source of truth for installed modules
// Otherwise fall back to directory scanning (legacy installations)
if (manifestData && manifestData.modules && manifestData.modules.length > 0) {
// Use manifest module list - these are officially installed modules
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
}
}
result.modules.push(moduleInfo);
}
} else {
// Fallback: scan directory for modules (legacy installations without manifest)
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name !== 'core' && entry.name !== '_config') {
const modulePath = path.join(bmadDir, entry.name);
const moduleConfigPath = path.join(modulePath, 'config.yaml');
// Only treat it as a module if it has a config.yaml
if (await fs.pathExists(moduleConfigPath)) {
const moduleInfo = {
id: entry.name,
path: modulePath,
version: 'unknown',
};
try {
const configContent = await fs.readFile(moduleConfigPath, 'utf8');
const config = yaml.parse(configContent);
moduleInfo.version = config.version || 'unknown';
moduleInfo.name = config.name || entry.name;
moduleInfo.description = config.description;
} catch {
// Ignore config read errors
}
result.modules.push(moduleInfo);
}
}
}
}
// Check for IDE configurations from manifest
if (result.manifest && result.manifest.ides) {
// Filter out any undefined/null values
result.ides = result.manifest.ides.filter((ide) => ide && typeof ide === 'string');
}
// Mark as installed if we found core or modules
if (result.hasCore || result.modules.length > 0) {
result.installed = true;
}
return result;
}
/**
* Detect legacy installation (_bmad-method, .bmm, .cis)
* @param {string} projectDir - Project directory to check
* @returns {Object} Legacy installation details
*/
async detectLegacy(projectDir) {
const result = {
hasLegacy: false,
legacyCore: false,
legacyModules: [],
paths: [],
};
// Check for legacy core (_bmad-method)
const legacyCorePath = path.join(projectDir, '_bmad-method');
if (await fs.pathExists(legacyCorePath)) {
result.hasLegacy = true;
result.legacyCore = true;
result.paths.push(legacyCorePath);
}
// Check for legacy modules (directories starting with .)
const entries = await fs.readdir(projectDir, { withFileTypes: true });
for (const entry of entries) {
if (
entry.isDirectory() &&
entry.name.startsWith('.') &&
entry.name !== '_bmad-method' &&
!entry.name.startsWith('.git') &&
!entry.name.startsWith('.vscode') &&
!entry.name.startsWith('.idea')
) {
const modulePath = path.join(projectDir, entry.name);
const moduleManifestPath = path.join(modulePath, 'install-manifest.yaml');
// Check if it's likely a BMAD module
if ((await fs.pathExists(moduleManifestPath)) || (await fs.pathExists(path.join(modulePath, 'config.yaml')))) {
result.hasLegacy = true;
result.legacyModules.push({
name: entry.name.slice(1), // Remove leading dot
path: modulePath,
});
result.paths.push(modulePath);
}
}
}
return result;
}
/**
* Check if migration from legacy is needed
* @param {string} projectDir - Project directory
* @returns {Object} Migration requirements
*/
async checkMigrationNeeded(projectDir) {
const bmadDir = path.join(projectDir, 'bmad');
const current = await this.detect(bmadDir);
const legacy = await this.detectLegacy(projectDir);
return {
needed: legacy.hasLegacy && !current.installed,
canMigrate: legacy.hasLegacy,
legacy: legacy,
current: current,
};
}
/**
* Detect legacy BMAD v4 .bmad-method folder
* @param {string} projectDir - Project directory to check
* @returns {{ hasLegacyV4: boolean, offenders: string[] }}
*/
async detectLegacyV4(projectDir) {
const offenders = [];
// Check for .bmad-method folder
const bmadMethodPath = path.join(projectDir, '.bmad-method');
if (await fs.pathExists(bmadMethodPath)) {
offenders.push(bmadMethodPath);
}
return { hasLegacyV4: offenders.length > 0, offenders };
}
}
module.exports = { Detector };

View File

@ -0,0 +1,127 @@
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<ExistingInstall>}
*/
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 };

View File

@ -1,12 +1,11 @@
const path = require('node:path');
const fs = require('fs-extra');
const { Detector } = require('./detector');
const { Manifest } = require('./manifest');
const { OfficialModules } = require('../modules/official-modules');
const { CustomModules } = require('../modules/custom-modules');
const { IdeManager } = require('../ide/manager');
const { FileOps } = require('../../../lib/file-ops');
const { Config } = require('../../../lib/config');
const { Config } = require('./config');
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
const { ManifestGenerator } = require('./manifest-generator');
const { IdeConfigManager } = require('./ide-config-manager');
@ -16,16 +15,15 @@ const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
const { InstallPaths } = require('./install-paths');
const { ExternalModuleManager } = require('../modules/external-manager');
const { ExistingInstall } = require('./existing-install');
class Installer {
constructor() {
this.externalModuleManager = new ExternalModuleManager();
this.detector = new Detector();
this.manifest = new Manifest();
this.officialModules = new OfficialModules();
this.customModules = new CustomModules();
this.ideManager = new IdeManager();
this.fileOps = new FileOps();
this.config = new Config();
this.ideConfigManager = new IdeConfigManager();
this.installedFiles = new Set(); // Track all installed files
this.bmadFolderName = BMAD_FOLDER_NAME;
@ -39,26 +37,22 @@ class Installer {
* @param {string[]} config.ides - IDEs to configure
*/
async install(originalConfig) {
const config = this._buildConfig(originalConfig);
// Everything else — custom modules, quick-update state, the whole mess
const customConfig = { ...originalConfig };
const paths = await InstallPaths.create(config);
// Collect configurations for official modules
await this.officialModules.collectConfigs(config, paths);
await this.customModules.discoverPaths(config, paths);
try {
const existingInstall = await this.detector.detect(paths.bmadDir);
const config = Config.build(originalConfig);
const paths = await InstallPaths.create(config);
const officialModules = await OfficialModules.build(config, paths);
const existingInstall = await ExistingInstall.detect(paths.bmadDir);
await this.customModules.discoverPaths(config, paths);
if (existingInstall.installed && !config.force) {
if (!config.isQuickUpdate()) {
await this._removeDeselectedModules(existingInstall, config, paths);
}
await this._prepareUpdateState(paths, config, customConfig, existingInstall);
await this._prepareUpdateState(paths, config, customConfig, existingInstall, officialModules);
}
const ideConfigurations = await this._loadIdeConfigurations(config, customConfig, paths);
@ -74,8 +68,8 @@ class Installer {
await this._cacheCustomModules(paths, addResult);
const { officialModules, allModules } = await this._buildModuleLists(config, customConfig, paths);
await this._installAndConfigure(config, customConfig, paths, officialModules, allModules, addResult);
const { officialModuleIds, allModules } = await this._buildModuleLists(config, customConfig, paths);
await this._installAndConfigure(config, customConfig, paths, officialModuleIds, allModules, addResult, officialModules);
await this._setupIdes(config, ideConfigurations, allModules, paths, addResult);
@ -123,7 +117,7 @@ class Installer {
* No confirmation the user's module selection is the decision.
*/
async _removeDeselectedModules(existingInstall, config, paths) {
const previouslyInstalled = new Set(existingInstall.modules.map((m) => m.id));
const previouslyInstalled = new Set(existingInstall.moduleIds);
const newlySelected = new Set(config.modules || []);
const toRemove = [...previouslyInstalled].filter((m) => !newlySelected.has(m) && m !== 'core');
@ -156,10 +150,8 @@ class Installer {
const bmadDir = paths.bmadDir;
const savedIdeConfigs = await this.ideConfigManager.loadAllIdeConfigs(bmadDir);
const { Detector } = require('./detector');
const detector = new Detector();
const existingInstall = await detector.detect(bmadDir);
const previouslyConfigured = existingInstall.ides || [];
const existingInstall = await ExistingInstall.detect(bmadDir);
const previouslyConfigured = existingInstall.ides;
for (const ide of config.ides || []) {
if (previouslyConfigured.includes(ide) && savedIdeConfigs[ide]) {
@ -201,7 +193,7 @@ class Installer {
* No confirmation the user's IDE selection is the decision.
*/
async _removeDeselectedIdes(existingInstall, config, ideConfigurations, paths) {
const previouslyInstalled = new Set(existingInstall.ides || []);
const previouslyInstalled = new Set(existingInstall.ides);
const newlySelected = new Set(config.ides || []);
const toRemove = [...previouslyInstalled].filter((ide) => !newlySelected.has(ide));
@ -243,7 +235,7 @@ class Installer {
/**
* Build the official and combined module lists from config and custom sources.
* @returns {{ officialModules: string[], allModules: string[] }}
* @returns {{ officialModuleIds: string[], allModules: string[] }}
*/
async _buildModuleLists(config, customConfig, paths) {
const finalCustomContent = customConfig.customContent;
@ -274,25 +266,25 @@ class Installer {
}
}
const officialModules = (config.modules || []).filter((m) => !customModuleIds.has(m));
const officialModuleIds = (config.modules || []).filter((m) => !customModuleIds.has(m));
const allModules = [...officialModules];
const allModules = [...officialModuleIds];
for (const id of customModuleIds) {
if (!allModules.includes(id)) {
allModules.push(id);
}
}
return { officialModules, allModules };
return { officialModuleIds, allModules };
}
/**
* Install modules, create directories, generate configs and manifests.
*/
async _installAndConfigure(config, customConfig, paths, officialModules, allModules, addResult) {
async _installAndConfigure(config, customConfig, paths, officialModuleIds, allModules, addResult, officialModules) {
const isQuickUpdate = config.isQuickUpdate();
const finalCustomContent = customConfig.customContent;
const moduleConfigs = this.officialModules.moduleConfigs;
const moduleConfigs = officialModules.moduleConfigs;
const dirResults = { createdDirs: [], movedDirs: [], createdWdsFolders: [] };
@ -304,12 +296,12 @@ class Installer {
task: async (message) => {
const installedModuleNames = new Set();
await this._installOfficialModules(config, paths, officialModules, addResult, isQuickUpdate, {
await this._installOfficialModules(config, paths, officialModuleIds, addResult, isQuickUpdate, officialModules, {
message,
installedModuleNames,
});
await this._installCustomModules(customConfig, paths, finalCustomContent, addResult, isQuickUpdate, {
await this._installCustomModules(customConfig, paths, finalCustomContent, addResult, isQuickUpdate, officialModules, {
message,
installedModuleNames,
});
@ -332,10 +324,10 @@ class Installer {
if (config.modules && config.modules.length > 0) {
for (const moduleName of config.modules) {
message(`Setting up ${moduleName}...`);
const result = await this.officialModules.createModuleDirectories(moduleName, paths.bmadDir, {
const result = await officialModules.createModuleDirectories(moduleName, paths.bmadDir, {
installedIDEs: config.ides || [],
moduleConfig: moduleConfigs[moduleName] || {},
existingModuleConfig: this.officialModules.existingConfig?.[moduleName] || {},
existingModuleConfig: officialModules.existingConfig?.[moduleName] || {},
coreConfig: moduleConfigs.core || {},
logger: moduleLogger,
silent: true,
@ -526,31 +518,6 @@ class Installer {
]);
}
_buildConfig(originalConfig) {
const modules = [...(originalConfig.modules || [])];
if (originalConfig.installCore && !modules.includes('core')) {
modules.unshift('core');
}
return {
directory: originalConfig.directory,
modules,
ides: originalConfig.skipIde ? [] : [...(originalConfig.ides || [])],
skipPrompts: originalConfig.skipPrompts || false,
verbose: originalConfig.verbose || false,
force: originalConfig.force || false,
actionType: originalConfig.actionType,
coreConfig: originalConfig.coreConfig || {},
moduleConfigs: originalConfig.moduleConfigs || null,
hasCoreConfig() {
return this.coreConfig && Object.keys(this.coreConfig).length > 0;
},
isQuickUpdate() {
return originalConfig._quickUpdate || false;
},
};
}
/**
* Scan the custom module cache directory and register any cached custom modules
* that aren't already known from the manifest or external module list.
@ -600,7 +567,7 @@ class Installer {
* @param {Object} customConfig - Full config bag (mutated with update state)
* @param {Object} existingInstall - Detection result from detector.detect()
*/
async _prepareUpdateState(paths, config, customConfig, existingInstall) {
async _prepareUpdateState(paths, config, customConfig, existingInstall, officialModules) {
customConfig._isUpdate = true;
customConfig._existingInstall = existingInstall;
@ -622,7 +589,7 @@ class Installer {
config.coreConfig = existingCoreConfig;
customConfig.coreConfig = existingCoreConfig;
this.officialModules.moduleConfigs.core = existingCoreConfig;
officialModules.moduleConfigs.core = existingCoreConfig;
} catch (error) {
await prompts.log.warn(`Warning: Could not read existing core config: ${error.message}`);
}
@ -678,22 +645,22 @@ class Installer {
* Install official (non-custom) modules.
* @param {Object} config - Installation configuration
* @param {Object} paths - InstallPaths instance
* @param {string[]} officialModules - Official module IDs to install
* @param {string[]} officialModuleIds - Official module IDs to install
* @param {Function} addResult - Callback to record installation results
* @param {boolean} isQuickUpdate - Whether this is a quick update
* @param {Object} ctx - Shared context: { message, installedModuleNames }
*/
async _installOfficialModules(config, paths, officialModules, addResult, isQuickUpdate, ctx) {
async _installOfficialModules(config, paths, officialModuleIds, addResult, isQuickUpdate, officialModules, ctx) {
const { message, installedModuleNames } = ctx;
for (const moduleName of officialModules) {
for (const moduleName of officialModuleIds) {
if (installedModuleNames.has(moduleName)) continue;
installedModuleNames.add(moduleName);
message(`${isQuickUpdate ? 'Updating' : 'Installing'} ${moduleName}...`);
const moduleConfig = this.officialModules.moduleConfigs[moduleName] || {};
await this.officialModules.install(
const moduleConfig = officialModules.moduleConfigs[moduleName] || {};
await officialModules.install(
moduleName,
paths.bmadDir,
(filePath) => {
@ -720,7 +687,7 @@ class Installer {
* @param {boolean} isQuickUpdate - Whether this is a quick update
* @param {Object} ctx - Shared context: { message, installedModuleNames }
*/
async _installCustomModules(customConfig, paths, finalCustomContent, addResult, isQuickUpdate, ctx) {
async _installCustomModules(customConfig, paths, finalCustomContent, addResult, isQuickUpdate, officialModules, ctx) {
const { message, installedModuleNames } = ctx;
// Collect all custom module IDs with their info from all sources
@ -776,8 +743,8 @@ class Installer {
this.customModules.paths.set(moduleName, customInfo.path);
}
const collectedModuleConfig = this.officialModules.moduleConfigs[moduleName] || {};
await this.officialModules.install(
const collectedModuleConfig = officialModules.moduleConfigs[moduleName] || {};
await officialModules.install(
moduleName,
paths.bmadDir,
(filePath) => {
@ -834,19 +801,16 @@ class Installer {
}
// Check for already configured IDEs
const { Detector } = require('./detector');
const detector = new Detector();
const bmadDir = path.join(projectDir, BMAD_FOLDER_NAME);
// During full reinstall, use the saved previous IDEs since bmad dir was deleted
// Otherwise detect from existing installation
let previouslyConfiguredIdes;
if (isFullReinstall) {
// During reinstall, treat all IDEs as new (need configuration)
previouslyConfiguredIdes = [];
} else {
const existingInstall = await detector.detect(bmadDir);
previouslyConfiguredIdes = existingInstall.ides || [];
const existingInstall = await ExistingInstall.detect(bmadDir);
previouslyConfiguredIdes = existingInstall.ides;
}
// Load saved IDE configurations for already-configured IDEs
@ -1467,9 +1431,9 @@ class Installer {
}
// Detect existing installation
const existingInstall = await this.detector.detect(bmadDir);
const installedModules = existingInstall.modules.map((m) => m.id);
const configuredIdes = existingInstall.ides || [];
const existingInstall = await ExistingInstall.detect(bmadDir);
const installedModules = existingInstall.moduleIds;
const configuredIdes = existingInstall.ides;
const projectRoot = path.dirname(bmadDir);
// Get custom module sources: first from --custom-content (re-cache from source), then from cache
@ -1532,7 +1496,7 @@ class Installer {
const savedIdeConfigs = await this.ideConfigManager.loadAllIdeConfigs(bmadDir);
// Get available modules (what we have source for)
const availableModulesData = await this.officialModules.listAvailable();
const availableModulesData = await new OfficialModules().listAvailable();
const availableModules = [...availableModulesData.modules, ...availableModulesData.customModules];
// Add external official modules to available modules
@ -1612,19 +1576,20 @@ class Installer {
// Load existing configs and collect new fields (if any)
await prompts.log.info('Checking for new configuration options...');
await this.officialModules.loadExistingConfig(projectDir);
const quickModules = new OfficialModules();
await quickModules.loadExistingConfig(projectDir);
let promptedForNewFields = false;
// Check core config for new fields
const corePrompted = await this.officialModules.collectModuleConfigQuick('core', projectDir, true);
const corePrompted = await quickModules.collectModuleConfigQuick('core', projectDir, true);
if (corePrompted) {
promptedForNewFields = true;
}
// Check each module we're updating for new fields (NOT skipped modules)
for (const moduleName of modulesToUpdate) {
const modulePrompted = await this.officialModules.collectModuleConfigQuick(moduleName, projectDir, true);
const modulePrompted = await quickModules.collectModuleConfigQuick(moduleName, projectDir, true);
if (modulePrompted) {
promptedForNewFields = true;
}
@ -1635,7 +1600,7 @@ class Installer {
}
// Add metadata
this.officialModules.collectedConfig._meta = {
quickModules.collectedConfig._meta = {
version: require(path.join(getProjectRoot(), 'package.json')).version,
installDate: new Date().toISOString(),
lastModified: new Date().toISOString(),
@ -1646,7 +1611,8 @@ class Installer {
directory: projectDir,
modules: modulesToUpdate, // Only update modules we have source for (includes core)
ides: configuredIdes,
coreConfig: this.officialModules.collectedConfig.core,
coreConfig: quickModules.collectedConfig.core,
moduleConfigs: quickModules.collectedConfig, // Pass collected configs so build() picks them up
actionType: 'install', // Use regular install flow
_quickUpdate: true, // Flag to skip certain prompts
_preserveModules: skippedModules, // Preserve these in manifest even though we didn't update them
@ -1679,7 +1645,7 @@ class Installer {
try {
const projectDir = path.resolve(config.directory);
const { bmadDir } = await this.findBmadDir(projectDir);
const existingInstall = await this.detector.detect(bmadDir);
const existingInstall = await ExistingInstall.detect(bmadDir);
if (!existingInstall.installed) {
throw new Error(`No BMAD installation found at ${bmadDir}`);
@ -1692,11 +1658,8 @@ class Installer {
// Check for custom modules with missing sources before update
const customModuleSources = new Map();
// Check manifest for backward compatibility
if (existingInstall.customModules) {
for (const customModule of existingInstall.customModules) {
customModuleSources.set(customModule.id, customModule);
}
for (const customModule of existingInstall.customModules) {
customModuleSources.set(customModule.id, customModule);
}
// Also check cache directory
@ -1745,7 +1708,7 @@ class Installer {
bmadDir,
projectRoot,
'update',
existingInstall.modules.map((m) => m.id),
existingInstall.moduleIds,
config.skipPrompts || false,
);
}
@ -1770,8 +1733,9 @@ class Installer {
await this.updateCore(bmadDir, config.force);
}
const updateModules = new OfficialModules();
for (const module of existingInstall.modules) {
await this.officialModules.update(module.id, bmadDir, config.force, { installer: this });
await updateModules.update(module.id, bmadDir, config.force, { installer: this });
}
// Update manifest
@ -1791,7 +1755,7 @@ class Installer {
*/
async updateCore(bmadDir, force = false) {
if (force) {
await this.officialModules.install('core', bmadDir, (filePath) => this.installedFiles.add(filePath), {
await new OfficialModules().install('core', bmadDir, (filePath) => this.installedFiles.add(filePath), {
skipModuleInstaller: true,
silent: true,
});
@ -1821,7 +1785,7 @@ class Installer {
}
// 1. DETECT: Read state BEFORE deleting anything
const existingInstall = await this.detector.detect(bmadDir);
const existingInstall = await ExistingInstall.detect(bmadDir);
const outputFolder = await this._readOutputFolder(bmadDir);
const removed = { modules: false, ideConfigs: false, outputFolder: false };
@ -1855,7 +1819,7 @@ class Installer {
async uninstallIdeConfigs(projectDir, existingInstall, options = {}) {
await this.ideManager.ensureInitialized();
const cleanupOptions = { isUninstall: true, silent: options.silent };
const ideList = existingInstall.ides || [];
const ideList = existingInstall.ides;
if (ideList.length > 0) {
return this.ideManager.cleanupByList(projectDir, ideList, cleanupOptions);
}
@ -1902,14 +1866,14 @@ class Installer {
async getStatus(directory) {
const projectDir = path.resolve(directory);
const { bmadDir } = await this.findBmadDir(projectDir);
return await this.detector.detect(bmadDir);
return await ExistingInstall.detect(bmadDir);
}
/**
* Get available modules
*/
async getAvailableModules() {
return await this.officialModules.listAvailable();
return await new OfficialModules().listAvailable();
}
/**
@ -1923,48 +1887,6 @@ class Installer {
return this._readOutputFolder(bmadDir);
}
/**
* Handle legacy BMAD v4 detection with simple warning
* @param {string} _projectDir - Project directory (unused in simplified version)
* @param {Object} _legacyV4 - Legacy V4 detection result (unused in simplified version)
*/
async handleLegacyV4Migration(_projectDir, _legacyV4) {
await prompts.note(
'Found .bmad-method folder from BMAD v4 installation.\n\n' +
'Before continuing with installation, we recommend:\n' +
' 1. Remove the .bmad-method folder, OR\n' +
' 2. Back it up by renaming it to another name (e.g., bmad-method-backup)\n\n' +
'If your v4 installation set up rules or commands, you should remove those as well.',
'Legacy BMAD v4 detected',
);
const proceed = await prompts.select({
message: 'What would you like to do?',
choices: [
{
name: 'Exit and clean up manually (recommended)',
value: 'exit',
hint: 'Exit installation',
},
{
name: 'Continue with installation anyway',
value: 'continue',
hint: 'Continue',
},
],
default: 'exit',
});
if (proceed === 'exit') {
await prompts.log.info('Please remove the .bmad-method folder and any v4 rules/commands, then run the installer again.');
// Allow event loop to flush pending I/O before exit
setImmediate(() => process.exit(0));
return;
}
await prompts.log.warn('Proceeding with installation despite legacy v4 folder');
}
/**
* Handle missing custom module sources interactively
* @param {Map} customModuleSources - Map of custom module ID to info
@ -2201,29 +2123,12 @@ class Installer {
/**
* Find the bmad installation directory in a project
* Always uses the standard _bmad folder name
* Also checks for legacy _cfg folder for migration
* @param {string} projectDir - Project directory
* @returns {Promise<Object>} { bmadDir: string, hasLegacyCfg: boolean }
* @returns {Promise<Object>} { bmadDir: string }
*/
async findBmadDir(projectDir) {
const bmadDir = path.join(projectDir, BMAD_FOLDER_NAME);
// Check if project directory exists
if (!(await fs.pathExists(projectDir))) {
// Project doesn't exist yet, return default
return { bmadDir, hasLegacyCfg: false };
}
// Check for legacy _cfg folder if bmad directory exists
let hasLegacyCfg = false;
if (await fs.pathExists(bmadDir)) {
const legacyCfgPath = path.join(bmadDir, '_cfg');
if (await fs.pathExists(legacyCfgPath)) {
hasLegacyCfg = true;
}
}
return { bmadDir, hasLegacyCfg };
return { bmadDir };
}
async createDirectoryStructure(bmadDir) {

View File

@ -37,7 +37,7 @@ class CustomModules {
}
// From manifest (regular updates)
if (config._isUpdate && config._existingInstall && config._existingInstall.customModules) {
if (config._isUpdate && config._existingInstall) {
for (const customModule of config._existingInstall.customModules) {
let absoluteSourcePath = customModule.sourcePath;

View File

@ -51,125 +51,11 @@ class UI {
confirmedDirectory = await this.getConfirmedDirectory();
}
// Preflight: Check for legacy BMAD v4 footprints immediately after getting directory
const { Detector } = require('../installers/lib/core/detector');
const { Installer } = require('../installers/lib/core/installer');
const detector = new Detector();
const installer = new Installer();
const legacyV4 = await detector.detectLegacyV4(confirmedDirectory);
if (legacyV4.hasLegacyV4) {
await installer.handleLegacyV4Migration(confirmedDirectory, legacyV4);
}
const { bmadDir } = await installer.findBmadDir(confirmedDirectory);
// Check for legacy folders and prompt for rename before showing any menus
let hasLegacyCfg = false;
let hasLegacyBmadFolder = false;
let bmadDir = null;
let legacyBmadPath = null;
// First check for legacy .bmad folder (instead of _bmad)
// Only check if directory exists
if (await fs.pathExists(confirmedDirectory)) {
const entries = await fs.readdir(confirmedDirectory, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && (entry.name === '.bmad' || entry.name === 'bmad')) {
hasLegacyBmadFolder = true;
legacyBmadPath = path.join(confirmedDirectory, entry.name);
bmadDir = legacyBmadPath;
// Check if it has _cfg folder
const cfgPath = path.join(legacyBmadPath, '_cfg');
if (await fs.pathExists(cfgPath)) {
hasLegacyCfg = true;
}
break;
}
}
}
// If no .bmad or bmad found, check for current installations _bmad
if (!hasLegacyBmadFolder) {
const bmadResult = await installer.findBmadDir(confirmedDirectory);
bmadDir = bmadResult.bmadDir;
hasLegacyCfg = bmadResult.hasLegacyCfg;
}
// Handle legacy .bmad or _cfg folder - these are very old (v4 or alpha)
// Show version warning instead of offering conversion
if (hasLegacyBmadFolder || hasLegacyCfg) {
await prompts.log.warn('LEGACY INSTALLATION DETECTED');
await prompts.note(
'Found a ".bmad"/"bmad" folder, or a legacy "_cfg" folder under the bmad folder -\n' +
'this is from an old BMAD version that is out of date for automatic upgrade,\n' +
'manual intervention required.\n\n' +
'You have a legacy version installed (v4 or alpha).\n' +
'Legacy installations may have compatibility issues.\n\n' +
'For the best experience, we strongly recommend:\n' +
' 1. Delete your current BMAD installation folder (.bmad or bmad)\n' +
' 2. Run a fresh installation\n\n' +
'If you do not want to start fresh, you can attempt to proceed beyond this\n' +
'point IF you have ensured the bmad folder is named _bmad, and under it there\n' +
'is a _config folder. If you have a folder under your bmad folder named _cfg,\n' +
'you would need to rename it _config, and then restart the installer.\n\n' +
'Benefits of a fresh install:\n' +
' \u2022 Cleaner configuration without legacy artifacts\n' +
' \u2022 All new features properly configured\n' +
' \u2022 Fewer potential conflicts\n\n' +
'If you have already produced output from an earlier alpha version, you can\n' +
'still retain those artifacts. After installation, ensure you configured during\n' +
'install the proper file locations for artifacts depending on the module you\n' +
'are using, or move the files to the proper locations.',
'Legacy Installation Detected',
);
const proceed = await prompts.select({
message: 'How would you like to proceed?',
choices: [
{
name: 'Cancel and do a fresh install (recommended)',
value: 'cancel',
},
{
name: 'Proceed anyway (will attempt update, potentially may fail or have unstable behavior)',
value: 'proceed',
},
],
default: 'cancel',
});
if (proceed === 'cancel') {
await prompts.note('1. Delete the existing bmad folder in your project\n' + "2. Run 'bmad install' again", 'To do a fresh install');
process.exit(0);
return;
}
const s = await prompts.spinner();
s.start('Updating folder structure...');
try {
// Handle .bmad folder
if (hasLegacyBmadFolder) {
const newBmadPath = path.join(confirmedDirectory, '_bmad');
await fs.move(legacyBmadPath, newBmadPath);
bmadDir = newBmadPath;
s.stop(`Renamed "${path.basename(legacyBmadPath)}" to "_bmad"`);
}
// Handle _cfg folder (either from .bmad or standalone)
const cfgPath = path.join(bmadDir, '_cfg');
if (await fs.pathExists(cfgPath)) {
s.start('Renaming configuration folder...');
const newCfgPath = path.join(bmadDir, '_config');
await fs.move(cfgPath, newCfgPath);
s.stop('Renamed "_cfg" to "_config"');
}
} catch (error) {
s.stop('Failed to update folder structure');
await prompts.log.error(`Error: ${error.message}`);
process.exit(1);
}
}
// Check if there's an existing BMAD installation (after any folder renames)
// Check if there's an existing BMAD installation
const hasExistingInstall = await fs.pathExists(bmadDir);
let customContentConfig = { hasCustomContent: false };
@ -188,15 +74,6 @@ class UI {
const currentVersion = require(packageJsonPath).version;
const installedVersion = existingInstall.version || 'unknown';
// Check if version is pre beta
const shouldProceed = await this.showLegacyVersionWarning(installedVersion, currentVersion, path.basename(bmadDir), options);
// If user chose to cancel, exit the installer
if (!shouldProceed) {
process.exit(0);
return;
}
// Build menu choices dynamically
const choices = [];
@ -575,15 +452,12 @@ class UI {
* @returns {Object} Tool configuration
*/
async promptToolSelection(projectDir, options = {}) {
// Check for existing configured IDEs - use findBmadDir to detect custom folder names
const { Detector } = require('../installers/lib/core/detector');
const { ExistingInstall } = require('../installers/lib/core/existing-install');
const { Installer } = require('../installers/lib/core/installer');
const detector = new Detector();
const installer = new Installer();
const bmadResult = await installer.findBmadDir(projectDir || process.cwd());
const bmadDir = bmadResult.bmadDir;
const existingInstall = await detector.detect(bmadDir);
const configuredIdes = existingInstall.ides || [];
const { bmadDir } = await installer.findBmadDir(projectDir || process.cwd());
const existingInstall = await ExistingInstall.detect(bmadDir);
const configuredIdes = existingInstall.ides;
// Get IDE manager to fetch available IDEs dynamically
const { IdeManager } = require('../installers/lib/ide/manager');
@ -816,14 +690,12 @@ class UI {
* @returns {Object} Object with existingInstall, installedModuleIds, and bmadDir
*/
async getExistingInstallation(directory) {
const { Detector } = require('../installers/lib/core/detector');
const { ExistingInstall } = require('../installers/lib/core/existing-install');
const { Installer } = require('../installers/lib/core/installer');
const detector = new Detector();
const installer = new Installer();
const bmadDirResult = await installer.findBmadDir(directory);
const bmadDir = bmadDirResult.bmadDir;
const existingInstall = await detector.detect(bmadDir);
const installedModuleIds = new Set(existingInstall.modules.map((mod) => mod.id));
const { bmadDir } = await installer.findBmadDir(directory);
const existingInstall = await ExistingInstall.detect(bmadDir);
const installedModuleIds = new Set(existingInstall.moduleIds);
return { existingInstall, installedModuleIds, bmadDir };
}
@ -1393,13 +1265,12 @@ class UI {
* @returns {Array} List of configured IDEs
*/
async getConfiguredIdes(directory) {
const { Detector } = require('../installers/lib/core/detector');
const { ExistingInstall } = require('../installers/lib/core/existing-install');
const { Installer } = require('../installers/lib/core/installer');
const detector = new Detector();
const installer = new Installer();
const bmadResult = await installer.findBmadDir(directory);
const existingInstall = await detector.detect(bmadResult.bmadDir);
return existingInstall.ides || [];
const { bmadDir } = await installer.findBmadDir(directory);
const existingInstall = await ExistingInstall.detect(bmadDir);
return existingInstall.ides;
}
/**
@ -1678,82 +1549,6 @@ class UI {
return result;
}
/**
* Check if installed version is a legacy version that needs fresh install
* @param {string} installedVersion - The installed version
* @returns {boolean} True if legacy (v4 or any alpha)
*/
isLegacyVersion(installedVersion) {
if (!installedVersion || installedVersion === 'unknown') {
return true; // Treat unknown as legacy for safety
}
// Check if version string contains -alpha or -Alpha (any v6 alpha)
return /-alpha\./i.test(installedVersion);
}
/**
* Show warning for legacy version (v4 or alpha) and ask if user wants to proceed
* @param {string} installedVersion - The installed version
* @param {string} currentVersion - The current version
* @param {string} bmadFolderName - Name of the BMAD folder
* @returns {Promise<boolean>} True if user wants to proceed, false if they cancel
*/
async showLegacyVersionWarning(installedVersion, currentVersion, bmadFolderName, options = {}) {
if (!this.isLegacyVersion(installedVersion)) {
return true; // Not legacy, proceed
}
let warningContent;
if (installedVersion === 'unknown') {
warningContent = 'Unable to detect your installed BMAD version.\n' + 'This appears to be a legacy or unsupported installation.';
} else {
warningContent =
`You are updating from ${installedVersion} to ${currentVersion}.\n` + 'You have a legacy version installed (v4 or alpha).';
}
warningContent +=
'\n\nFor the best experience, we recommend:\n' +
' 1. Delete your current BMAD installation folder\n' +
` (the "${bmadFolderName}/" folder in your project)\n` +
' 2. Run a fresh installation\n\n' +
'Benefits of a fresh install:\n' +
' \u2022 Cleaner configuration without legacy artifacts\n' +
' \u2022 All new features properly configured\n' +
' \u2022 Fewer potential conflicts';
await prompts.log.warn('VERSION WARNING');
await prompts.note(warningContent, 'Version Warning');
if (options.yes) {
await prompts.log.warn('Non-interactive mode (--yes): auto-proceeding with legacy update');
return true;
}
const proceed = await prompts.select({
message: 'How would you like to proceed?',
choices: [
{
name: 'Proceed with update anyway (may have issues)',
value: 'proceed',
},
{
name: 'Cancel (recommended - do a fresh install instead)',
value: 'cancel',
},
],
default: 'cancel',
});
if (proceed === 'cancel') {
await prompts.note(
`1. Delete the "${bmadFolderName}/" folder in your project\n` + "2. Run 'bmad install' again",
'To do a fresh install',
);
}
return proceed === 'proceed';
}
/**
* Display module versions with update availability
* @param {Array} modules - Array of module info objects with version info