Files
Brian 825099b2cb fix(installer): ask about deprecated shims during Quick Update, and report the outcome (#2746)
* feat(installer): ask about shims during Quick Update

Quick Update returned before the shim prompt, so anyone who only ever
runs it carried their compatibility shims forward release after release
without once being offered the chance to drop them.

Quick Update now asks, defaulting to keeping the shims so pressing enter
never removes a skill in active use. It stays quiet for an installation
that already dropped its shims rather than re-asking every update. The
prompt carries the recommendation to remove them and names the one case
that justifies keeping them: a customized shim not yet migrated.

Whenever an install retains shims, it now lists every one of them and
what it forwards to. That notice is emitted where the policy is resolved
rather than at the prompt, so it also reaches the paths that never
prompt: --yes, --shims, and scripted quick updates.

* fix(installer): never prompt for shims without a TTY, and report removal

Two gaps in the Quick Update shim prompt.

The prompt could be reached by a scripted run. `--action quick-update`
is a documented scripting flag and is not tied to `--yes`, so a headless
invocation on an install that still had shims fell through to a confirm.
clack's confirm never resolves without a TTY: the process drained its
event loop and exited silently, mid-install, with status 0. It now keeps
the standing answer whenever stdin is not a TTY, leaving --shims and
--no-shims as the way to change it from a script.

Removing shims was also completely silent. Source filtering just skips
the directories, and the IDE cleanup that deletes the stale skill dirs
suppresses its logging on purpose, so nothing anywhere told the user
that a skill they may still invoke had just gone. Any run that removes
shims now lists them and says how to put them back, mirroring the
retained notice. Between the two, every run that has shims either way
reports which way it went, on interactive and headless paths alike.

* feat(installer): carry the shim outcome into the final summary

Both shim notices print before the install tasks start, so a long run
buries them well above the fold. The summary box already repeats the uv
warning for exactly this reason; the shim outcome now rides along the
same way, as a single line next to the preserved/backed-up file counts.

Retained reads "Deprecated shim skills retained: N (re-run to remove
them)" in yellow, removed reads "Deprecated shim skills removed: N" in
green, and an install with no shims either way adds no line at all.

* fix(installer): say what an empty module selection installs

The official module picker allows an empty selection on purpose: core is
always installed and is not a row in the list, so selecting nothing is a
valid core-only install. The prompt did not say so, and collapsed to a
bare "0 items selected", which reads as though the install is about to
do nothing.

autocompleteMultiselect takes an optional emptyLabel, shown while
selecting as "Nothing selected: installs core only" and on submit as
"0 items selected (core only)". Pickers that pass no emptyLabel are
unchanged. Also fixes the count to say "1 item" rather than "1 items".

* refactor(installer): trim explanatory comments to what the code cannot say

Cuts 35 comment lines added across this branch down to seven, keeping
only the non-obvious constraints: clack's confirm hanging without a TTY,
core not being a row in the module picker, and why the shim notices are
emitted where they are.

* fix(installer): report shims removed after they are retired from source

Removal reporting was derived from the shims the incoming release ships,
so a shim retired from source fell out of the report entirely: it was
absent from discovery, yet the update cleanup still deleted its installed
target using the previous manifest. The v7 cut is exactly that case, and
it would have removed every shim in silence.

Removal is now derived from what is installed, read back from
skill-manifest.csv, and the retained/removed split moves into
selectShimOutcome. This also covers the mixed run where one shim is
retired while the rest stay enabled: both notices fire, and the summary
carries both counts. The recovery line no longer offers --shims when the
release cannot reinstall them.

Raised by greptile and coderabbit on #2746.
2026-08-15 18:53:03 -05:00

2233 lines
86 KiB
JavaScript

const path = require('node:path');
const os = require('node:os');
const semver = require('semver');
const fs = require('./fs-native');
const installerPackageJson = require('../../package.json');
const { CLIUtils } = require('./cli-utils');
const { ExternalModuleManager } = require('./modules/external-manager');
const { resolveModuleVersion } = require('./modules/version-resolver');
const { Manifest } = require('./core/manifest');
const {
parseChannelOptions,
buildPlan,
decideChannelForModule,
orphanPinWarnings,
bundledTargetWarnings,
} = require('./modules/channel-plan');
const channelResolver = require('./modules/channel-resolver');
const prompts = require('./prompts');
const { parseSetEntries } = require('./set-overrides');
const { inferShimPreference, readInstalledSkillIds } = require('./core/shim-policy');
const manifest = new Manifest();
/**
* Format a resolved version for display in installer labels.
* Semver-like values are normalized to a single leading "v".
* @param {string|null|undefined} version
* @returns {string}
*/
function formatDisplayVersion(version) {
const trimmed = typeof version === 'string' ? version.trim() : '';
if (!trimmed) return '';
const normalized = semver.valid(semver.coerce(trimmed));
if (normalized) {
return `v${normalized}`;
}
return trimmed;
}
/**
* Build the display label for a module, showing an upgrade arrow when an
* installed semver differs from the latest resolvable semver.
* @param {string} name
* @param {string} latestVersion
* @param {string} installedVersion
* @returns {string}
*/
function buildModuleLabel(name, latestVersion, installedVersion = '') {
const latestDisplay = formatDisplayVersion(latestVersion);
if (!latestDisplay) return name;
const installedDisplay = formatDisplayVersion(installedVersion);
const latestSemver = semver.valid(semver.coerce(latestVersion || ''));
const installedSemver = semver.valid(semver.coerce(installedVersion || ''));
if (installedDisplay && latestSemver && installedSemver && semver.neq(installedSemver, latestSemver)) {
return `${name} (${installedDisplay} → ${latestDisplay})`;
}
return `${name} (${latestDisplay})`;
}
/**
* Resolve the version to show for a module picker entry. External modules use
* the same channel/tag resolver as installs; bundled modules fall back to local
* source metadata.
* @param {string} moduleCode - Module code (e.g., 'core', 'bmm', 'cis')
* @param {Object} options
* @param {string|null} [options.repoUrl] - Module repository URL for tag resolution
* @param {string|null} [options.registryDefault] - Registry default channel
* @param {Object|null} [options.channelOptions] - Parsed installer channel options
* @returns {Promise<{version: string, lookupAttempted: boolean, lookupSucceeded: boolean}>}
*/
async function getModuleVersion(moduleCode, { repoUrl = null, registryDefault = null, channelOptions = null } = {}) {
if (repoUrl) {
const plan = decideChannelForModule({
code: moduleCode,
channelOptions,
registryDefault,
});
try {
const resolved = await channelResolver.resolveChannel({
channel: plan.channel,
pin: plan.pin,
repoUrl,
});
if (resolved?.version) {
return {
version: resolved.version,
lookupAttempted: plan.channel === 'stable',
lookupSucceeded: true,
};
}
} catch {
// Fall back to local metadata when tag resolution is unavailable.
}
}
const versionInfo = await resolveModuleVersion(moduleCode);
return {
version: versionInfo.version || '',
lookupAttempted: !!repoUrl,
lookupSucceeded: false,
};
}
/**
* UI utilities for the installer
*/
class UI {
async _selectShimPreference({ selectedModules, bmadDir, existing, options, channelOptions, quickUpdate = false }) {
const { OfficialModules } = require('./modules/official-modules');
const officialModules = new OfficialModules({ channelOptions });
const availableShims = await officialModules.discoverShims(selectedModules, { channelOptions });
// The prompt is capability-driven. Once the last shim leaves the incoming
// release this becomes an ordinary empty set, regardless of old state.
if (availableShims.length === 0) return;
const previousManifest = existing ? await manifest.read(bmadDir) : null;
const installedSkillIds = existing ? await readInstalledSkillIds(bmadDir) : new Set();
const currentValue = inferShimPreference({
requested: options.shims,
persisted: previousManifest?.installShims,
availableShims,
installedSkillIds,
existing,
});
if (typeof options.shims === 'boolean' || options.yes) return currentValue;
// clack's confirm never resolves without a TTY: a scripted run would exit mid-install.
if (!process.stdin.isTTY) return currentValue;
// Nothing to give up, so nothing to ask on every single update.
if (quickUpdate && !currentValue) return currentValue;
const verb = currentValue ? 'Keep' : 'Install';
const message =
`${verb} ${availableShims.length} deprecated compatibility shim skill(s)? Recommended: No. ` +
`If you say yes, the deprecated skills will exist as a skill that forwards to its replacement skill. ` +
`Shims will be removed with v7. You should only retain if you customized a shimmed skill and need to ` +
`still transition it to the replacement.`;
return prompts.confirm({ message, default: currentValue });
}
/**
* Warn once for each selected module the registry marks deprecated.
*
* A deprecated module is never dropped from the selection — an existing
* install keeps working and keeps being updated on request. The warning is
* the only behavior change, and it is what tells CLI users (`--modules`,
* `--yes`) what the interactive picker shows as an option hint.
*
* @param {Array<string>} selectedModules - Module codes about to be installed
* @returns {Promise<Array<string>>} The deprecated codes that were warned about
*/
async _warnDeprecatedModules(selectedModules = []) {
const externalManager = new ExternalModuleManager();
let registryModules;
try {
registryModules = await externalManager.listAvailable();
} catch {
return []; // Registry unreadable — never block an install over a notice.
}
const deprecatedByCode = new Map();
for (const mod of registryModules) {
if (!mod.deprecated) continue;
deprecatedByCode.set(mod.code, mod);
for (const alias of mod.aliases) deprecatedByCode.set(alias, mod);
}
const warned = [];
for (const code of selectedModules) {
const mod = deprecatedByCode.get(code);
if (!mod || warned.includes(mod.code)) continue;
warned.push(mod.code);
const detail = mod.deprecationMessage || 'It is no longer receiving updates.';
await prompts.log.warn(`${mod.name} (${mod.code}) is deprecated. ${detail}`);
}
return warned;
}
async _retainUnavailableInstalledModules(selectedModules, installedModuleIds, bmadDir, options = {}) {
const { OfficialModules } = require('./modules/official-modules');
const officialCodes = new Set(['core']);
const builtInModules = (await new OfficialModules().listAvailable()).modules || [];
for (const mod of builtInModules) {
officialCodes.add(mod.id);
}
const externalManager = new ExternalModuleManager();
const registryModules = await externalManager.listAvailable();
for (const mod of registryModules) {
officialCodes.add(mod.code);
}
const { CustomModuleManager } = require('./modules/custom-module-manager');
const customMgr = new CustomModuleManager();
const selectedSet = new Set(selectedModules);
const preserveModules = [];
for (const moduleId of installedModuleIds) {
if (moduleId === 'core') continue;
if (!selectedSet.has(moduleId) && !options.preserveUnselected) continue;
// Resolve a possibly-renamed module code (e.g. `bauto` -> `bmad-loop`)
// before checking availability, so a registry rename doesn't freeze
// the install here the way it would have prior to the alias support
// in ExternalModuleManager.getModuleByCode().
const canonicalId = await externalManager.resolveCanonicalCode(moduleId);
if (officialCodes.has(canonicalId)) continue;
const customSource = await customMgr.findModuleSourceByCode(moduleId, { bmadDir });
if (!customSource) {
preserveModules.push(moduleId);
}
}
const preservedSet = new Set(preserveModules);
return {
selectedModules: selectedModules.filter((moduleId) => !preservedSet.has(moduleId)),
preserveModules,
};
}
/**
* Prompt for installation configuration
* @param {Object} options - Command-line options from install command
* @returns {Object} Installation configuration
*/
async promptInstall(options = {}) {
await CLIUtils.displayLogo();
// Display version-specific start message from install-messages.yaml
const { MessageLoader } = require('./message-loader');
const messageLoader = new MessageLoader();
await messageLoader.displayStartMessage();
// Probe for `uv` before any other prompts: it's the runner for the Python
// scripts BMAD skills shell out to (`uv run <script>`), and uv provisions
// the interpreter itself, so it's the single thing worth checking for.
// As of v6.11.0 `bmad-build` and `bmad-build-auto` HALT without it.
//
// Still warn-don't-block, with no ack prompt: core-only, docs-only, and
// CI installs never touch a rendered skill, so a missing `uv` must not
// fail the run. `installer.js` repeats the warning in the post-install
// summary so it survives the scrollback. The installer runs in the
// destination environment, so probing PATH here tests the right machine.
const { checkUvEnvironment } = require('./core/uv-check');
await checkUvEnvironment();
// Parse channel flags (--channel/--all-*/--next=/--pin) once. Warnings
// are surfaced immediately so the user sees them before any git ops run.
const channelOptions = parseChannelOptions(options);
for (const warning of channelOptions.warnings) {
await prompts.log.warn(warning);
}
// When the user launched the installer from a prerelease (npx bmad-method@next),
// mirror that intent for external modules: seed the global channel to 'next' so
// the module picker's version labels resolve from main HEAD (matching what
// actually gets installed) and the interactive channel gate skips — the user
// already declared "next" intent by typing @next. Explicit channel flags
// override this seed.
if (
semver.prerelease(installerPackageJson.version) !== null &&
!channelOptions.global &&
channelOptions.nextSet.size === 0 &&
channelOptions.pins.size === 0
) {
channelOptions.global = 'next';
await prompts.log.info(
'Launched from a prerelease — installing all external modules from main HEAD (next channel). Pass --all-stable or --pin to override.',
);
}
// Get directory from options or prompt
let confirmedDirectory;
if (options.directory) {
// Use provided directory from command-line
const expandedDir = this.expandUserPath(options.directory);
const validation = this.validateDirectorySync(expandedDir);
if (validation) {
throw new Error(`Invalid directory: ${validation}`);
}
confirmedDirectory = expandedDir;
await prompts.log.info(`Using directory from command-line: ${confirmedDirectory}`);
} else {
confirmedDirectory = await this.getConfirmedDirectory();
}
const { Installer } = require('./core/installer');
const installer = new Installer();
const { bmadDir } = await installer.findBmadDir(confirmedDirectory);
// Check if there's an existing BMAD installation
const hasExistingInstall = await fs.pathExists(bmadDir);
// Track action type (only set if there's an existing installation)
let actionType;
// Only show action menu if there's an existing installation
if (hasExistingInstall) {
// Get version information
const { existingInstall, bmadDir } = await this.getExistingInstallation(confirmedDirectory);
// Build menu choices dynamically
const choices = [];
// Always show Quick Update first (allows refreshing installation even on same version)
if (existingInstall.installed) {
choices.push({
name: 'Quick Update',
value: 'quick-update',
});
}
// Common actions
choices.push({ name: 'Modify BMAD Installation', value: 'update' });
// Check if action is provided via command-line
if (options.action) {
const validActions = choices.map((c) => c.value);
if (!validActions.includes(options.action)) {
throw new Error(`Invalid action: ${options.action}. Valid actions: ${validActions.join(', ')}`);
}
actionType = options.action;
await prompts.log.info(`Using action from command-line: ${actionType}`);
} else if (options.yes) {
// Default to quick-update if available, unless flags that require the
// full update path are present (e.g. --custom-source which re-clones
// modules at a new version — quick-update skips that entirely).
if (choices.length === 0) {
throw new Error('No valid actions available for this installation');
}
const hasQuickUpdate = choices.some((c) => c.value === 'quick-update');
const needsFullUpdate = !!options.customSource || typeof options.shims === 'boolean';
actionType = hasQuickUpdate && !needsFullUpdate ? 'quick-update' : (choices.find((c) => c.value === 'update') || choices[0]).value;
await prompts.log.info(`Non-interactive mode (--yes): defaulting to ${actionType}`);
} else {
actionType = await prompts.select({
message: 'How would you like to proceed?',
choices: choices,
default: choices[0].value,
});
}
// Handle quick update separately
if (actionType === 'quick-update') {
// Quick update never shows the module picker, so this is the only
// place an existing install of a deprecated module hears about it.
await this._warnDeprecatedModules(existingInstall.moduleIds || []);
const installShims = await this._selectShimPreference({
selectedModules: existingInstall.moduleIds || [],
bmadDir,
existing: true,
options,
channelOptions,
quickUpdate: true,
});
return {
actionType: 'quick-update',
directory: confirmedDirectory,
skipPrompts: options.yes || false,
installShims: installShims === undefined ? options.shims : installShims,
};
}
// If actionType === 'update', handle it with the new flow
// Return early with modify configuration
if (actionType === 'update') {
// Get existing installation info
const { installedModuleIds, installedModuleVersions } = await this.getExistingInstallation(confirmedDirectory);
await prompts.log.message(`Found existing modules: ${[...installedModuleIds].join(', ')}`);
// Unified module selection - all modules in one grouped multiselect
let selectedModules;
if (options.modules) {
// Use modules from command-line
selectedModules = options.modules
.split(',')
.map((m) => m.trim())
.filter(Boolean);
await prompts.log.info(`Using modules from command-line: ${selectedModules.join(', ')}`);
} else if (options.customSource && !options.yes) {
// Custom source without --modules or --yes: start with empty list
// (only custom source modules + core will be installed).
// When --yes is also set, fall through to the --yes branch so all
// installed modules are included alongside the custom source modules.
selectedModules = [];
} else if (options.yes) {
selectedModules = await this.getDefaultModules(installedModuleIds);
await prompts.log.info(
`Non-interactive mode (--yes): using default modules (installed + defaults): ${selectedModules.join(', ')}`,
);
} else {
selectedModules = await this.selectAllModules(installedModuleIds, installedModuleVersions, channelOptions);
}
// Resolve custom sources from --custom-source flag
if (options.customSource) {
const customCodes = await this._resolveCustomSourcesCli(options.customSource);
for (const code of customCodes) {
if (!selectedModules.includes(code)) selectedModules.push(code);
}
}
// Ensure core is in the modules list
if (!selectedModules.includes('core')) {
selectedModules.unshift('core');
}
const retainedModuleResult = await this._retainUnavailableInstalledModules(selectedModules, installedModuleIds, bmadDir, {
preserveUnselected: options.yes && !options.modules,
});
selectedModules = retainedModuleResult.selectedModules;
const preservedModules = retainedModuleResult.preserveModules;
if (preservedModules.length > 0) {
await prompts.log.warn(
`Retaining ${preservedModules.length} installed module(s) with no available source: ${preservedModules.join(', ')}`,
);
}
// Surface deprecation notices for whatever ended up selected. The
// interactive picker only hints at them in the option list, and the
// --modules / --yes paths never see that list at all.
await this._warnDeprecatedModules(selectedModules);
// For existing installs, resolve per-module update decisions BEFORE
// we clone anything. Reads the existing manifest's recorded channel
// per module and prompts the user on available upgrades (patch/minor
// default Y, major default N). Legacy entries with no channel are
// migrated here too. Mutates channelOptions.pins to lock rejections.
await this._resolveUpdateChannels({
bmadDir,
selectedModules,
channelOptions,
yes: options.yes || false,
});
// Get tool selection
const toolSelection = await this.promptToolSelection(confirmedDirectory, options);
const { moduleConfigs, setOverrides } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
...options,
channelOptions,
});
const installShims = await this._selectShimPreference({
selectedModules,
bmadDir,
existing: true,
options,
channelOptions,
});
// Warn about --pin/--next flags that refer to modules the user didn't
// select, or that target bundled modules (core/bmm) where channel
// flags don't apply.
{
const bundledCodes = await this._bundledModuleCodes();
for (const warning of [
...orphanPinWarnings(channelOptions, selectedModules),
...bundledTargetWarnings(channelOptions, bundledCodes),
]) {
await prompts.log.warn(warning);
}
}
return {
actionType: 'update',
directory: confirmedDirectory,
modules: selectedModules,
ides: toolSelection.ides,
skipIde: toolSelection.skipIde,
coreConfig: moduleConfigs.core || {},
moduleConfigs: moduleConfigs,
setOverrides,
skipPrompts: options.yes || false,
channelOptions,
_preserveModules: preservedModules,
installShims,
};
}
}
// This section is only for new installations (update returns early above)
const { installedModuleIds, installedModuleVersions } = await this.getExistingInstallation(confirmedDirectory);
// Unified module selection - all modules in one grouped multiselect
let selectedModules;
if (options.modules) {
// Use modules from command-line
selectedModules = options.modules
.split(',')
.map((m) => m.trim())
.filter(Boolean);
await prompts.log.info(`Using modules from command-line: ${selectedModules.join(', ')}`);
} else if (options.customSource) {
// Custom source without --modules: start with empty list (core added below)
selectedModules = [];
} else if (options.yes) {
// Use default modules when --yes flag is set
selectedModules = await this.getDefaultModules(installedModuleIds);
await prompts.log.info(`Using default modules (--yes flag): ${selectedModules.join(', ')}`);
} else {
selectedModules = await this.selectAllModules(installedModuleIds, installedModuleVersions, channelOptions);
}
// Resolve custom sources from --custom-source flag
if (options.customSource) {
const customCodes = await this._resolveCustomSourcesCli(options.customSource);
for (const code of customCodes) {
if (!selectedModules.includes(code)) selectedModules.push(code);
}
}
// Ensure core is in the modules list
if (!selectedModules.includes('core')) {
selectedModules.unshift('core');
}
await this._warnDeprecatedModules(selectedModules);
// Interactive channel gate: "Ready to install (all stable)? [Y/n]"
// Only shown for fresh installs with no channel flags and an external module
// selected. Skipped for prerelease launches because channelOptions.global
// was already seeded to 'next' upstream. Non-interactive installs skip this
// and fall through to the registry default (stable) or whatever flags were
// supplied.
await this._interactiveChannelGate({ options, channelOptions, selectedModules });
let toolSelection = await this.promptToolSelection(confirmedDirectory, options);
const { moduleConfigs, setOverrides } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
...options,
channelOptions,
});
const installShims = await this._selectShimPreference({
selectedModules,
bmadDir,
existing: false,
options,
channelOptions,
});
// Warn about --pin/--next flags that refer to modules the user didn't
// select, or that target bundled modules (core/bmm) where channel
// flags don't apply.
{
const bundledCodes = await this._bundledModuleCodes();
for (const warning of [
...orphanPinWarnings(channelOptions, selectedModules),
...bundledTargetWarnings(channelOptions, bundledCodes),
]) {
await prompts.log.warn(warning);
}
}
return {
actionType: 'install',
directory: confirmedDirectory,
modules: selectedModules,
ides: toolSelection.ides,
skipIde: toolSelection.skipIde,
coreConfig: moduleConfigs.core || {},
moduleConfigs: moduleConfigs,
setOverrides,
skipPrompts: options.yes || false,
channelOptions,
installShims,
};
}
/**
* Prompt for tool/IDE selection (called after module configuration)
* Uses a split prompt approach:
* 1. Recommended tools - standard multiselect for preferred tools
* 2. Additional tools - autocompleteMultiselect with search capability
* @param {string} projectDir - Project directory to check for existing IDEs
* @param {Object} options - Command-line options
* @returns {Object} Tool configuration
*/
_parseToolsFlag(toolsArg, allKnownValues) {
const selectedIdes = toolsArg
.split(',')
.map((t) => t.trim())
.filter(Boolean);
if (selectedIdes.length === 0) {
const err = new Error(
'--tools was passed empty. Provide at least one tool ID (e.g. --tools claude-code) or run with --list-tools to see valid IDs.',
);
err.expected = true;
throw err;
}
const unknown = selectedIdes.filter((id) => !allKnownValues.has(id));
if (unknown.length > 0) {
const err = new Error(
[
`Unknown tool ID${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}`,
'',
'Run with --list-tools to see all valid IDs.',
'Common: claude-code, cursor, copilot, windsurf, cline',
].join('\n'),
);
err.expected = true;
throw err;
}
return selectedIdes;
}
async promptToolSelection(projectDir, options = {}) {
const { ExistingInstall } = require('./core/existing-install');
const { Installer } = require('./core/installer');
const installer = new Installer();
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('./ide/manager');
const ideManager = new IdeManager();
await ideManager.ensureInitialized(); // IMPORTANT: Must initialize before getting IDEs
const preferredIdes = ideManager.getPreferredIdes();
const otherIdes = ideManager.getOtherIdes();
// Determine which configured IDEs are in "preferred" vs "other" categories
const configuredPreferred = configuredIdes.filter((id) => preferredIdes.some((ide) => ide.value === id));
const configuredOther = configuredIdes.filter((id) => otherIdes.some((ide) => ide.value === id));
// Warn about previously configured tools that are no longer available
const allKnownValues = new Set([...preferredIdes, ...otherIdes].map((ide) => ide.value));
const unknownTools = configuredIdes.filter((id) => id && typeof id === 'string' && !allKnownValues.has(id));
if (unknownTools.length > 0) {
await prompts.log.warn(`Previously configured tools are no longer available: ${unknownTools.join(', ')}`);
}
// ─────────────────────────────────────────────────────────────────────────────
// UPGRADE PATH: If tools already configured, show all tools with configured at top
// ─────────────────────────────────────────────────────────────────────────────
if (configuredIdes.length > 0) {
const allTools = [...preferredIdes, ...otherIdes];
// Non-interactive: handle --tools and --yes flags before interactive prompt
// Use !== undefined so an explicit --tools "" falls through to _parseToolsFlag and
// gets a specific "passed empty" error instead of being silently ignored.
if (options.tools !== undefined) {
const selectedIdes = this._parseToolsFlag(options.tools, allKnownValues);
await prompts.log.info(`Using tools from command-line: ${selectedIdes.join(', ')}`);
await this.displaySelectedTools(selectedIdes, preferredIdes, allTools);
return { ides: selectedIdes, skipIde: false };
}
if (options.yes) {
await prompts.log.info(`Non-interactive mode (--yes): keeping configured tools: ${configuredIdes.join(', ')}`);
await this.displaySelectedTools(configuredIdes, preferredIdes, allTools);
return { ides: configuredIdes, skipIde: false };
}
// Sort: configured tools first, then preferred, then others
const sortedTools = [
...allTools.filter((ide) => configuredIdes.includes(ide.value)),
...allTools.filter((ide) => !configuredIdes.includes(ide.value)),
];
const upgradeOptions = sortedTools.map((ide) => {
const isConfigured = configuredIdes.includes(ide.value);
const isPreferred = preferredIdes.some((p) => p.value === ide.value);
let label = ide.name;
if (isPreferred) label += ' ⭐';
if (isConfigured) label += ' ✅';
return { label, value: ide.value };
});
// Sort initialValues to match display order
const sortedInitialValues = sortedTools.filter((ide) => configuredIdes.includes(ide.value)).map((ide) => ide.value);
const upgradeSelected = await prompts.autocompleteMultiselect({
message: 'Integrate with',
options: upgradeOptions,
initialValues: sortedInitialValues,
required: false,
maxItems: 8,
});
const selectedIdes = upgradeSelected || [];
if (selectedIdes.length === 0) {
const confirmNoTools = await prompts.confirm({
message: 'No tools selected. Continue without installing any tools?',
default: false,
});
if (!confirmNoTools) {
return this.promptToolSelection(projectDir, options);
}
return { ides: [], skipIde: true };
}
// Display selected tools
await this.displaySelectedTools(selectedIdes, preferredIdes, allTools);
return { ides: selectedIdes, skipIde: false };
}
// ─────────────────────────────────────────────────────────────────────────────
// NEW INSTALL: Show all tools with search
// ─────────────────────────────────────────────────────────────────────────────
const allTools = [...preferredIdes, ...otherIdes];
const allToolOptions = allTools.map((ide) => {
const isPreferred = preferredIdes.some((p) => p.value === ide.value);
let label = ide.name;
if (isPreferred) label += ' ⭐';
return {
label,
value: ide.value,
};
});
let selectedIdes = [];
// Check if tools are provided via command-line.
// Use !== undefined so an explicit --tools "" still hits _parseToolsFlag's empty-value error.
if (options.tools !== undefined) {
selectedIdes = this._parseToolsFlag(options.tools, allKnownValues);
await prompts.log.info(`Using tools from command-line: ${selectedIdes.join(', ')}`);
await this.displaySelectedTools(selectedIdes, preferredIdes, allTools);
return { ides: selectedIdes, skipIde: false };
} else if (options.yes) {
// If --yes flag is set, skip tool prompt and use previously configured tools or empty
if (configuredIdes.length > 0) {
await prompts.log.info(`Using previously configured tools (--yes flag): ${configuredIdes.join(', ')}`);
await this.displaySelectedTools(configuredIdes, preferredIdes, allTools);
return { ides: configuredIdes, skipIde: false };
} else {
const err = new Error(
[
'--tools is required for non-interactive install (--yes / -y) when no tools are previously configured.',
'',
'Common: claude-code, cursor, copilot, windsurf, cline',
'See all supported tools: bmad-method install --list-tools',
'',
'Example: bmad-method install --modules bmm --tools claude-code -y',
].join('\n'),
);
err.expected = true;
throw err;
}
}
// Interactive mode
const interactiveSelectedIdes = await prompts.autocompleteMultiselect({
message: 'Integrate with:',
options: allToolOptions,
initialValues: configuredIdes.length > 0 ? configuredIdes : undefined,
required: false,
maxItems: 8,
});
selectedIdes = interactiveSelectedIdes || [];
// ─────────────────────────────────────────────────────────────────────────────
// STEP 3: Confirm if no tools selected
// ─────────────────────────────────────────────────────────────────────────────
if (selectedIdes.length === 0) {
const confirmNoTools = await prompts.confirm({
message: 'No tools selected. Continue without installing any tools?',
default: false,
});
if (!confirmNoTools) {
// User wants to select tools - recurse
return this.promptToolSelection(projectDir, options);
}
return {
ides: [],
skipIde: true,
};
}
// Display selected tools
await this.displaySelectedTools(selectedIdes, preferredIdes, allTools);
return {
ides: selectedIdes,
skipIde: selectedIdes.length === 0,
};
}
/**
* Prompt for update configuration
* @returns {Object} Update configuration
*/
async promptUpdate() {
const backupFirst = await prompts.confirm({
message: 'Create backup before updating?',
default: true,
});
const preserveCustomizations = await prompts.confirm({
message: 'Preserve local customizations?',
default: true,
});
return { backupFirst, preserveCustomizations };
}
/**
* Confirm action
* @param {string} message - Confirmation message
* @param {boolean} defaultValue - Default value
* @returns {boolean} User confirmation
*/
async confirm(message, defaultValue = false) {
return await prompts.confirm({
message,
default: defaultValue,
});
}
/**
* Get confirmed directory from user
* @returns {string} Confirmed directory path
*/
async getConfirmedDirectory() {
let confirmedDirectory = null;
while (!confirmedDirectory) {
const directoryAnswer = await this.promptForDirectory();
await this.displayDirectoryInfo(directoryAnswer.directory);
if (await this.confirmDirectory(directoryAnswer.directory)) {
confirmedDirectory = directoryAnswer.directory;
}
}
return confirmedDirectory;
}
/**
* Get existing installation info and installed modules
* @param {string} directory - Installation directory
* @returns {Object} Object with existingInstall, installedModuleIds, installedModuleVersions, and bmadDir
*/
async getExistingInstallation(directory) {
const { ExistingInstall } = require('./core/existing-install');
const { Installer } = require('./core/installer');
const installer = new Installer();
const { bmadDir } = await installer.findBmadDir(directory);
const existingInstall = await ExistingInstall.detect(bmadDir);
const installedModuleIds = new Set(existingInstall.moduleIds);
const installedModuleVersions = new Map();
const manifestModules = await manifest.getAllModuleVersions(bmadDir);
for (const module of manifestModules) {
if (module?.name && module.version) {
installedModuleVersions.set(module.name, module.version);
}
}
for (const module of existingInstall.modules) {
if (module?.id && module.version && module.version !== 'unknown' && !installedModuleVersions.has(module.id)) {
installedModuleVersions.set(module.id, module.version);
}
}
if (existingInstall.hasCore && existingInstall.version && !installedModuleVersions.has('core')) {
installedModuleVersions.set('core', existingInstall.version);
}
return { existingInstall, installedModuleIds, installedModuleVersions, bmadDir };
}
/**
* Collect all module configurations (core + selected modules).
* All interactive prompting happens here in the UI layer.
* @param {string} directory - Installation directory
* @param {string[]} modules - Modules to configure (including 'core')
* @param {Object} options - Command-line options
* @returns {Object} Collected module configurations keyed by module name
*/
async collectModuleConfigs(directory, modules, options = {}) {
const { OfficialModules } = require('./modules/official-modules');
// Parse --set up front purely to surface user-error before the install
// burns time on the network / filesystem. The actual application happens
// in installer.install() as a post-write TOML patch — see
// `tools/installer/set-overrides.js`. We also warn about overrides
// targeting modules the user didn't include, since those will silently
// miss the file the patch step looks for.
let setOverrides = {};
try {
setOverrides = parseSetEntries(options.set || []);
} catch (error) {
// install.js validated already; rethrow as-is for the user.
throw error;
}
// Drop overrides for modules that aren't in the install set so the
// post-install patch step doesn't create orphan sections in config.toml
// for modules that were never installed.
const selectedModuleSet = new Set(['core', ...modules]);
for (const moduleCode of Object.keys(setOverrides)) {
if (!selectedModuleSet.has(moduleCode)) {
await prompts.log.warn(
`--set ${moduleCode}.* — module '${moduleCode}' is not in the install set; values will be ignored. Add it to --modules to apply.`,
);
delete setOverrides[moduleCode];
}
}
const configCollector = new OfficialModules({ channelOptions: options.channelOptions });
const hasCoreCliOptions =
options.userName || options.communicationLanguage || options.documentOutputLanguage || options.outputFolder || setOverrides.core;
// Seed core config from CLI options if provided. `--set core.<key>` seeds it
// too: core values are dependency-bearing — module artifact paths are built
// from output_folder here, and each module's config.yaml snapshots the core
// values — so the post-install patch alone lands too late.
if (hasCoreCliOptions) {
const coreConfig = { ...setOverrides.core };
if (options.userName) {
coreConfig.user_name = options.userName;
await prompts.log.info(`Using user name from command-line: ${options.userName}`);
}
if (options.communicationLanguage) {
coreConfig.communication_language = options.communicationLanguage;
await prompts.log.info(`Using communication language from command-line: ${options.communicationLanguage}`);
}
if (options.documentOutputLanguage) {
coreConfig.document_output_language = options.documentOutputLanguage;
await prompts.log.info(`Using document output language from command-line: ${options.documentOutputLanguage}`);
}
if (options.outputFolder) {
coreConfig.output_folder = options.outputFolder;
await prompts.log.info(`Using output folder from command-line: ${options.outputFolder}`);
}
// Load existing config to merge with provided options
await configCollector.loadExistingConfig(directory);
const existingConfig = configCollector.existingConfig.core || {};
let defaultConfig = {};
if (options.yes) {
let safeUsername;
try {
safeUsername = os.userInfo().username;
} catch {
safeUsername = process.env.USER || process.env.USERNAME || 'User';
}
defaultConfig = {
user_name: safeUsername.charAt(0).toUpperCase() + safeUsername.slice(1),
project_name: path.basename(directory),
communication_language: 'English',
document_output_language: 'English',
output_folder: '_bmad-output',
};
}
configCollector.collectedConfig.core = { ...defaultConfig, ...existingConfig, ...coreConfig };
// If not all options are provided, collect the missing ones interactively (unless --yes flag)
if (
!options.yes &&
(!options.userName || !options.communicationLanguage || !options.documentOutputLanguage || !options.outputFolder)
) {
await configCollector.collectModuleConfig('core', directory, false, true);
}
} else if (options.yes) {
// Use all defaults when --yes flag is set
await configCollector.loadExistingConfig(directory);
const existingConfig = configCollector.existingConfig.core || {};
if (Object.keys(existingConfig).length === 0) {
let safeUsername;
try {
safeUsername = os.userInfo().username;
} catch {
safeUsername = process.env.USER || process.env.USERNAME || 'User';
}
const defaultUsername = safeUsername.charAt(0).toUpperCase() + safeUsername.slice(1);
configCollector.collectedConfig.core = {
user_name: defaultUsername,
// {directory_name} default per src/core-skills/module.yaml — matches what the
// interactive flow resolves via buildQuestion()'s {directory_name} placeholder.
project_name: path.basename(directory),
communication_language: 'English',
document_output_language: 'English',
output_folder: '_bmad-output',
};
await prompts.log.info('Using default configuration (--yes flag)');
}
}
// Collect all module configs — core is skipped if already seeded above
await configCollector.collectAllConfigurations(modules, directory, {
skipPrompts: options.yes || false,
});
return { moduleConfigs: configCollector.collectedConfig, setOverrides };
}
/**
* Select all modules across three tiers: official, community, and custom URL.
* @param {Set} installedModuleIds - Currently installed module IDs
* @param {Map<string, string>} installedModuleVersions - Installed module versions from the local manifest
* @param {Object|null} channelOptions - Parsed installer channel options
* @returns {Array} Selected module codes, always including core
*/
async selectAllModules(installedModuleIds = new Set(), installedModuleVersions = new Map(), channelOptions = null) {
// Phase 1: Official modules
const officialSelected = await this._selectOfficialModules(installedModuleIds, installedModuleVersions, channelOptions);
// Identify installed modules that aren't official (previously installed
// community modules or custom-source modules). Preserve them on update;
// they can be managed via --custom-source, uninstall, or a dedicated installer.
const externalManager = new ExternalModuleManager();
const registryModules = await externalManager.listAvailable();
const officialRegistryCodes = new Set(['core', 'bmm', ...registryModules.map((m) => m.code)]);
const installedNonOfficial = [...installedModuleIds].filter((id) => !officialRegistryCodes.has(id));
// Phase 2: Custom URL modules
const customSelected = await this._addCustomUrlModules(installedModuleIds);
const allSelected = new Set([...officialSelected, ...customSelected, ...installedNonOfficial]);
return [...allSelected];
}
/**
* Select official modules using autocompleteMultiselect.
* Extracted from the original selectAllModules - unchanged behavior.
* @param {Set} installedModuleIds - Currently installed module IDs
* @param {Map<string, string>} installedModuleVersions - Installed module versions from the local manifest
* @param {Object|null} channelOptions - Parsed installer channel options
* @returns {Array} Selected official module codes
*/
async _selectOfficialModules(installedModuleIds = new Set(), installedModuleVersions = new Map(), channelOptions = null) {
// Built-in modules (core, bmm) come from local source, not the registry
const { OfficialModules } = require('./modules/official-modules');
const builtInModules = (await new OfficialModules().listAvailable()).modules || [];
// External modules come from the registry (with fallback)
const externalManager = new ExternalModuleManager();
const registryModules = await externalManager.listAvailable();
const allOptions = [];
const initialValues = [];
const buildModuleEntry = async (code, name, description, isDefault, repoUrl = null, registryDefault = null) => {
const isInstalled = installedModuleIds.has(code);
const installedVersion = installedModuleVersions.get(code) || '';
const versionState = await getModuleVersion(code, { repoUrl, registryDefault, channelOptions });
const label = buildModuleLabel(name, versionState.version, installedVersion);
return {
label,
value: code,
hint: description,
selected: isInstalled || isDefault,
lookupAttempted: versionState.lookupAttempted,
lookupSucceeded: versionState.lookupSucceeded,
};
};
// Add built-in modules first (always available regardless of network).
// core is not offered as a row: it is a dependency of every module, always
// installed, and was only ever rendered as a locked always-on checkbox.
// It is still added back to the result below.
const builtInCodes = new Set();
for (const mod of builtInModules) {
const code = mod.id;
builtInCodes.add(code);
if (code === 'core') continue;
const entry = await buildModuleEntry(code, mod.name, mod.description, mod.defaultSelected);
allOptions.push({ label: entry.label, value: entry.value, hint: entry.hint });
if (entry.selected) {
initialValues.push(code);
}
}
// Add external registry modules (skip built-in duplicates and deprecated
// modules that are not already installed — deprecated modules stay visible
// only so existing users can continue to manage them).
const externalRegistryModules = registryModules.filter(
(mod) => !mod.builtIn && !builtInCodes.has(mod.code) && (!mod.deprecated || installedModuleIds.has(mod.code)),
);
let externalRegistryEntries = [];
if (externalRegistryModules.length > 0) {
const spinner = await prompts.spinner();
spinner.start('Checking latest module versions...');
externalRegistryEntries = await Promise.all(
externalRegistryModules.map(async (mod) => {
const entry = await buildModuleEntry(
mod.code,
mod.name,
mod.description,
mod.defaultSelected,
mod.url || null,
mod.defaultChannel || null,
);
if (mod.deprecated && mod.deprecationMessage) {
entry.hint = entry.hint ? `${entry.hint} — ${mod.deprecationMessage}` : mod.deprecationMessage;
}
return { code: mod.code, entry };
}),
);
spinner.stop('Checked latest module versions.');
const attemptedLookups = externalRegistryEntries.filter(({ entry }) => entry.lookupAttempted).length;
const successfulLookups = externalRegistryEntries.filter(({ entry }) => entry.lookupSucceeded).length;
if (attemptedLookups > 0 && successfulLookups === 0) {
await prompts.log.warn('Could not check latest module versions; showing cached/local versions.');
}
}
for (const { code, entry } of externalRegistryEntries) {
allOptions.push({ label: entry.label, value: entry.value, hint: entry.hint });
if (entry.selected) {
initialValues.push(code);
}
}
const selected = await prompts.autocompleteMultiselect({
message: 'Select official modules to install:',
options: allOptions,
initialValues: initialValues.length > 0 ? initialValues : undefined,
// Core installs either way and is not a row here, so empty is a valid core-only install.
required: false,
emptyLabel: 'core only',
maxItems: allOptions.length,
});
const chosen = selected ? [...selected] : [];
if (chosen.length > 0) {
const moduleLines = chosen.map((moduleId) => {
const opt = allOptions.find((o) => o.value === moduleId);
return ` \u2022 ${opt?.label || moduleId}`;
});
await prompts.log.message('Selected official modules:\n' + moduleLines.join('\n'));
}
// core is never shown but always installed.
return chosen.includes('core') ? chosen : ['core', ...chosen];
}
/**
* Prompt user to install modules from custom sources (Git URLs or local paths).
* @param {Set} installedModuleIds - Currently installed module IDs
* @returns {Array} Selected custom module code strings
*/
async _addCustomUrlModules(installedModuleIds = new Set()) {
const addCustom = await prompts.confirm({
message: 'Do you want to install custom or community modules (Git URL or local path)?',
default: false,
});
if (!addCustom) return [];
const { CustomModuleManager } = require('./modules/custom-module-manager');
const customMgr = new CustomModuleManager();
const selectedModules = [];
let addMore = true;
while (addMore) {
const sourceInput = await prompts.text({
message: 'Git URL or local path:',
placeholder: 'https://github.com/owner/repo or /path/to/module',
validate: (input) => {
if (!input || input.trim() === '') return 'Source is required';
const result = customMgr.parseSource(input.trim());
return result.isValid ? undefined : result.error;
},
});
const s = await prompts.spinner();
s.start('Resolving source...');
let sourceResult;
try {
sourceResult = await customMgr.resolveSource(sourceInput.trim(), { skipInstall: true, silent: true });
s.stop(sourceResult.parsed.type === 'local' ? 'Local source resolved' : 'Repository cloned');
} catch (error) {
s.error('Failed to resolve source');
await prompts.log.error(` ${error.message}`);
addMore = await prompts.confirm({ message: 'Try another source?', default: false });
continue;
}
if (sourceResult.parsed.type === 'local') {
await prompts.log.info('LOCAL MODULE: Pointing directly at local source (changes take effect on reinstall).');
} else {
await prompts.log.warn(
'UNVERIFIED MODULE: This module has not been reviewed by the BMad team.\n' + ' Only install modules from sources you trust.',
);
}
// Resolve plugins based on discovery mode vs direct mode
s.start('Analyzing plugin structure...');
const allResolved = [];
const localPath = sourceResult.parsed.type === 'local' ? sourceResult.rootDir : null;
if (sourceResult.mode === 'discovery') {
// Discovery mode: marketplace.json found, list available plugins
let plugins;
try {
plugins = await customMgr.discoverModules(sourceResult.marketplace, sourceResult.sourceUrl);
} catch (discoverError) {
s.error('Failed to discover modules');
await prompts.log.error(` ${discoverError.message}`);
addMore = await prompts.confirm({ message: 'Try another source?', default: false });
continue;
}
const effectiveRepoPath = sourceResult.repoPath || sourceResult.rootDir;
for (const plugin of plugins) {
try {
const resolved = await customMgr.resolvePlugin(effectiveRepoPath, plugin.rawPlugin, sourceResult.sourceUrl, localPath);
if (resolved.length > 0) {
allResolved.push(...resolved);
} else {
// No skills array or empty - use plugin metadata as-is (legacy)
allResolved.push({
code: plugin.code,
name: plugin.displayName || plugin.name,
version: plugin.version,
description: plugin.description,
strategy: 0,
pluginName: plugin.name,
skillPaths: [],
});
}
} catch (resolveError) {
await prompts.log.warn(` Could not resolve ${plugin.name}: ${resolveError.message}`);
}
}
} else {
// Direct mode: no marketplace.json, scan directory for skills and resolve
const directPlugin = {
name: sourceResult.parsed.displayName || path.basename(sourceResult.rootDir),
source: '.',
skills: [],
};
// Scan for SKILL.md directories to populate skills array
try {
const entries = await fs.readdir(sourceResult.rootDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const skillMd = path.join(sourceResult.rootDir, entry.name, 'SKILL.md');
if (await fs.pathExists(skillMd)) {
directPlugin.skills.push(entry.name);
}
}
}
} catch (scanError) {
s.error('Failed to scan directory');
await prompts.log.error(` ${scanError.message}`);
addMore = await prompts.confirm({ message: 'Try another source?', default: false });
continue;
}
if (directPlugin.skills.length > 0) {
try {
const resolved = await customMgr.resolvePlugin(sourceResult.rootDir, directPlugin, sourceResult.sourceUrl, localPath);
allResolved.push(...resolved);
} catch (resolveError) {
await prompts.log.warn(` Could not resolve: ${resolveError.message}`);
}
}
}
s.stop(`Found ${allResolved.length} installable module${allResolved.length === 1 ? '' : 's'}`);
if (allResolved.length === 0) {
await prompts.log.warn('No installable modules found in this source.');
addMore = await prompts.confirm({ message: 'Try another source?', default: false });
continue;
}
// Build multiselect choices
// Already-installed modules are pre-checked (update). New modules are unchecked (opt-in).
// Unchecking an installed module means "skip update" - removal is handled elsewhere.
const choices = allResolved.map((mod) => {
const versionStr = mod.version ? ` v${mod.version}` : '';
const skillCount = mod.skillPaths ? mod.skillPaths.length : 0;
const skillStr = skillCount > 0 ? ` (${skillCount} skill${skillCount === 1 ? '' : 's'})` : '';
const alreadyInstalled = installedModuleIds.has(mod.code);
const hint = alreadyInstalled ? 'update' : undefined;
return {
name: `${mod.name}${versionStr}${skillStr}`,
value: mod.code,
hint,
checked: alreadyInstalled,
};
});
// Show descriptions before the multiselect
for (const mod of allResolved) {
const versionStr = mod.version ? ` v${mod.version}` : '';
await prompts.log.info(` ${mod.name}${versionStr}\n ${mod.description}`);
}
const selected = await prompts.multiselect({
message: 'Select modules to install:',
choices,
required: false,
});
if (selected && selected.length > 0) {
for (const code of selected) {
selectedModules.push(code);
}
}
addMore = await prompts.confirm({
message: 'Add another custom source?',
default: false,
});
}
if (selectedModules.length > 0) {
await prompts.log.message('Selected custom modules:\n' + selectedModules.map((c) => ` \u2022 ${c}`).join('\n'));
}
return selectedModules;
}
/**
* Resolve custom sources from --custom-source CLI flag (non-interactive).
* Auto-selects all discovered modules from each source.
* @param {string} sourcesArg - Comma-separated Git URLs or local paths
* @returns {Array} Module codes from all resolved sources
*/
async _resolveCustomSourcesCli(sourcesArg) {
const { CustomModuleManager } = require('./modules/custom-module-manager');
const customMgr = new CustomModuleManager();
const allCodes = [];
const sources = sourcesArg
.split(',')
.map((s) => s.trim())
.filter(Boolean);
for (const source of sources) {
const s = await prompts.spinner();
s.start(`Resolving ${source}...`);
let sourceResult;
try {
sourceResult = await customMgr.resolveSource(source, { skipInstall: true, silent: true });
s.stop(sourceResult.parsed.type === 'local' ? 'Local source resolved' : 'Repository cloned');
} catch (error) {
s.error(`Failed to resolve ${source}`);
await prompts.log.error(` ${error.message}`);
continue;
}
const s2 = await prompts.spinner();
s2.start('Analyzing plugin structure...');
const allResolved = [];
const localPath = sourceResult.parsed.type === 'local' ? sourceResult.rootDir : null;
if (sourceResult.mode === 'discovery') {
try {
const plugins = await customMgr.discoverModules(sourceResult.marketplace, sourceResult.sourceUrl);
const effectiveRepoPath = sourceResult.repoPath || sourceResult.rootDir;
for (const plugin of plugins) {
try {
const resolved = await customMgr.resolvePlugin(effectiveRepoPath, plugin.rawPlugin, sourceResult.sourceUrl, localPath);
if (resolved.length > 0) {
allResolved.push(...resolved);
}
} catch {
// Skip unresolvable plugins
}
}
} catch (discoverError) {
s2.error('Failed to discover modules');
await prompts.log.error(` ${discoverError.message}`);
continue;
}
} else {
// Direct mode: scan for SKILL.md directories
const directPlugin = {
name: sourceResult.parsed.displayName || path.basename(sourceResult.rootDir),
source: '.',
skills: [],
};
try {
const entries = await fs.readdir(sourceResult.rootDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const skillMd = path.join(sourceResult.rootDir, entry.name, 'SKILL.md');
if (await fs.pathExists(skillMd)) {
directPlugin.skills.push(entry.name);
}
}
}
} catch {
// Skip unreadable directories
}
if (directPlugin.skills.length > 0) {
try {
const resolved = await customMgr.resolvePlugin(sourceResult.rootDir, directPlugin, sourceResult.sourceUrl, localPath);
allResolved.push(...resolved);
} catch {
// Skip unresolvable
}
}
}
s2.stop(`Found ${allResolved.length} module${allResolved.length === 1 ? '' : 's'}`);
for (const mod of allResolved) {
allCodes.push(mod.code);
const versionStr = mod.version ? ` v${mod.version}` : '';
await prompts.log.info(` Custom module: ${mod.name}${versionStr}`);
}
}
return allCodes;
}
/**
* Get default modules for non-interactive mode
* @param {Set} installedModuleIds - Already installed module IDs
* @returns {Array} Default module codes
*/
async getDefaultModules(installedModuleIds = new Set()) {
// Built-in modules with default_selected come from local source
const { OfficialModules } = require('./modules/official-modules');
const builtInModules = (await new OfficialModules().listAvailable()).modules || [];
const defaultModules = [];
const seen = new Set();
for (const mod of builtInModules) {
if (mod.defaultSelected || installedModuleIds.has(mod.id)) {
defaultModules.push(mod.id);
seen.add(mod.id);
}
}
// Add external registry defaults
const externalManager = new ExternalModuleManager();
const registryModules = await externalManager.listAvailable();
for (const mod of registryModules) {
if (mod.builtIn || seen.has(mod.code)) continue;
if (mod.defaultSelected || installedModuleIds.has(mod.code)) {
defaultModules.push(mod.code);
}
}
// If no defaults found, use 'bmm' as the fallback default
if (defaultModules.length === 0) {
defaultModules.push('bmm');
}
return defaultModules;
}
/**
* Prompt for directory selection
* @returns {Object} Directory answer from prompt
*/
async promptForDirectory() {
// Use sync validation because @clack/prompts doesn't support async validate
const directory = await prompts.directory({
message: 'Installation directory:',
default: process.cwd(),
placeholder: process.cwd(),
validate: (input) => this.validateDirectorySync(input),
});
// Apply filter logic
let filteredDir = directory;
if (!filteredDir || filteredDir.trim() === '') {
filteredDir = process.cwd();
} else {
filteredDir = this.expandUserPath(filteredDir);
}
return { directory: filteredDir };
}
/**
* Display directory information
* @param {string} directory - The directory path
*/
async displayDirectoryInfo(directory) {
await prompts.log.info(`Resolved installation path: ${directory}`);
const dirExists = await fs.pathExists(directory);
if (dirExists) {
// Show helpful context about the existing path
const stats = await fs.stat(directory);
if (stats.isDirectory()) {
const files = await fs.readdir(directory);
if (files.length > 0) {
// Check for any bmad installation (any folder with _config/manifest.yaml)
const { Installer } = require('./core/installer');
const installer = new Installer();
const bmadResult = await installer.findBmadDir(directory);
const hasBmadInstall =
(await fs.pathExists(bmadResult.bmadDir)) && (await fs.pathExists(path.join(bmadResult.bmadDir, '_config', 'manifest.yaml')));
const bmadNote = hasBmadInstall ? ` including existing BMAD installation (${path.basename(bmadResult.bmadDir)})` : '';
await prompts.log.message(`Directory exists and contains ${files.length} item(s)${bmadNote}`);
} else {
await prompts.log.message('Directory exists and is empty');
}
}
}
}
/**
* Confirm directory selection
* @param {string} directory - The directory path
* @returns {boolean} Whether user confirmed
*/
async confirmDirectory(directory) {
const dirExists = await fs.pathExists(directory);
if (dirExists) {
const proceed = await prompts.confirm({
message: 'Install to this directory?',
default: true,
});
if (!proceed) {
await prompts.log.warn("Let's try again with a different path.");
}
return proceed;
} else {
// Ask for confirmation to create the directory
const create = await prompts.confirm({
message: `Create directory: ${directory}?`,
default: false,
});
if (!create) {
await prompts.log.warn("Let's try again with a different path.");
}
return create;
}
}
/**
* Validate directory path for installation (sync version for clack prompts)
* @param {string} input - User input path
* @returns {string|undefined} Error message or undefined if valid
*/
validateDirectorySync(input) {
// Allow empty input to use the default
if (!input || input.trim() === '') {
return; // Empty means use default, undefined = valid for clack
}
let expandedPath;
try {
expandedPath = this.expandUserPath(input.trim());
} catch (error) {
return error.message;
}
// Check if the path exists
const pathExists = fs.pathExistsSync(expandedPath);
if (!pathExists) {
// Find the first existing parent directory
const existingParent = this.findExistingParentSync(expandedPath);
if (!existingParent) {
return 'Cannot create directory: no existing parent directory found';
}
// Check if the existing parent is writable
try {
fs.accessSync(existingParent, fs.constants.W_OK);
// Path doesn't exist but can be created - will prompt for confirmation later
return;
} catch {
// Provide a detailed error message explaining both issues
return `Directory '${expandedPath}' does not exist and cannot be created: parent directory '${existingParent}' is not writable`;
}
}
// If it exists, validate it's a directory and writable
const stat = fs.statSync(expandedPath);
if (!stat.isDirectory()) {
return `Path exists but is not a directory: ${expandedPath}`;
}
// Check write permissions
try {
fs.accessSync(expandedPath, fs.constants.W_OK);
} catch {
return `Directory is not writable: ${expandedPath}`;
}
return;
}
/**
* Validate directory path for installation (async version)
* @param {string} input - User input path
* @returns {string|true} Error message or true if valid
*/
async validateDirectory(input) {
// Allow empty input to use the default
if (!input || input.trim() === '') {
return true; // Empty means use default
}
let expandedPath;
try {
expandedPath = this.expandUserPath(input.trim());
} catch (error) {
return error.message;
}
// Check if the path exists
const pathExists = await fs.pathExists(expandedPath);
if (!pathExists) {
// Find the first existing parent directory
const existingParent = await this.findExistingParent(expandedPath);
if (!existingParent) {
return 'Cannot create directory: no existing parent directory found';
}
// Check if the existing parent is writable
try {
await fs.access(existingParent, fs.constants.W_OK);
// Path doesn't exist but can be created - will prompt for confirmation later
return true;
} catch {
// Provide a detailed error message explaining both issues
return `Directory '${expandedPath}' does not exist and cannot be created: parent directory '${existingParent}' is not writable`;
}
}
// If it exists, validate it's a directory and writable
const stat = await fs.stat(expandedPath);
if (!stat.isDirectory()) {
return `Path exists but is not a directory: ${expandedPath}`;
}
// Check write permissions
try {
await fs.access(expandedPath, fs.constants.W_OK);
} catch {
return `Directory is not writable: ${expandedPath}`;
}
return true;
}
/**
* Find the first existing parent directory (sync version)
* @param {string} targetPath - The path to check
* @returns {string|null} The first existing parent directory, or null if none found
*/
findExistingParentSync(targetPath) {
let currentPath = path.resolve(targetPath);
// Walk up the directory tree until we find an existing directory
while (currentPath !== path.dirname(currentPath)) {
// Stop at root
const parent = path.dirname(currentPath);
if (fs.pathExistsSync(parent)) {
return parent;
}
currentPath = parent;
}
return null; // No existing parent found (shouldn't happen in practice)
}
/**
* Find the first existing parent directory (async version)
* @param {string} targetPath - The path to check
* @returns {string|null} The first existing parent directory, or null if none found
*/
async findExistingParent(targetPath) {
let currentPath = path.resolve(targetPath);
// Walk up the directory tree until we find an existing directory
while (currentPath !== path.dirname(currentPath)) {
// Stop at root
const parent = path.dirname(currentPath);
if (await fs.pathExists(parent)) {
return parent;
}
currentPath = parent;
}
return null; // No existing parent found (shouldn't happen in practice)
}
/**
* Expands the user-provided path: handles ~ and resolves to absolute.
* @param {string} inputPath - User input path.
* @returns {string} Absolute expanded path.
*/
expandUserPath(inputPath) {
if (typeof inputPath !== 'string') {
throw new TypeError('Path must be a string.');
}
let expanded = inputPath.trim();
// Handle tilde expansion
if (expanded.startsWith('~')) {
if (expanded === '~') {
expanded = os.homedir();
} else if (expanded.startsWith('~' + path.sep)) {
const pathAfterHome = expanded.slice(2); // Remove ~/ or ~\
expanded = path.join(os.homedir(), pathAfterHome);
} else {
const restOfPath = expanded.slice(1);
const separatorIndex = restOfPath.indexOf(path.sep);
const username = separatorIndex === -1 ? restOfPath : restOfPath.slice(0, separatorIndex);
if (username) {
throw new Error(`Path expansion for ~${username} is not supported. Please use an absolute path or ~${path.sep}`);
}
}
}
// Resolve to the absolute path relative to the current working directory
return path.resolve(expanded);
}
/**
* Get configured IDEs from existing installation
* @param {string} directory - Installation directory
* @returns {Array} List of configured IDEs
*/
async getConfiguredIdes(directory) {
const { ExistingInstall } = require('./core/existing-install');
const { Installer } = require('./core/installer');
const installer = new Installer();
const { bmadDir } = await installer.findBmadDir(directory);
const existingInstall = await ExistingInstall.detect(bmadDir);
return existingInstall.ides;
}
/**
* Display module versions with update availability
* @param {Array} modules - Array of module info objects with version info
* @param {Array} availableUpdates - Array of available updates
*/
async displayModuleVersions(modules, availableUpdates = []) {
// Group modules by source
const builtIn = modules.filter((m) => m.source === 'built-in');
const external = modules.filter((m) => m.source === 'external');
const community = modules.filter((m) => m.source === 'community');
const custom = modules.filter((m) => m.source === 'custom');
const unknown = modules.filter((m) => m.source === 'unknown');
const lines = [];
const formatGroup = (group, title) => {
if (group.length === 0) return;
lines.push(title);
for (const mod of group) {
const updateInfo = availableUpdates.find((u) => u.name === mod.name);
const versionDisplay = mod.version || 'unknown';
if (updateInfo) {
lines.push(` ${mod.name.padEnd(20)} ${versionDisplay} \u2192 ${updateInfo.latestVersion} \u2191`);
} else {
lines.push(` ${mod.name.padEnd(20)} ${versionDisplay} \u2713`);
}
}
};
formatGroup(builtIn, 'Built-in Modules');
formatGroup(external, 'External Modules (Official)');
formatGroup(community, 'Community Modules');
formatGroup(custom, 'Custom Modules');
formatGroup(unknown, 'Other Modules');
await prompts.note(lines.join('\n'), 'Module Versions');
}
/**
* Prompt user to select which modules to update
* @param {Array} availableUpdates - Array of available updates
* @returns {Array} Selected module names to update
*/
async promptUpdateSelection(availableUpdates) {
if (availableUpdates.length === 0) {
return [];
}
await prompts.log.info('Available Updates');
const choices = availableUpdates.map((update) => ({
name: `${update.name} (v${update.installedVersion} \u2192 v${update.latestVersion})`,
value: update.name,
checked: true, // Default to selecting all updates
}));
// Add "Update All" and "Cancel" options
const action = await prompts.select({
message: 'How would you like to proceed?',
choices: [
{ name: 'Update all available modules', value: 'all' },
{ name: 'Select specific modules to update', value: 'select' },
{ name: 'Skip updates for now', value: 'skip' },
],
default: 'all',
});
if (action === 'all') {
return availableUpdates.map((u) => u.name);
}
if (action === 'skip') {
return [];
}
// Allow specific selection
const selected = await prompts.multiselect({
message: 'Select modules to update (use arrow keys, space to toggle):',
choices: choices,
required: true,
});
return selected || [];
}
/**
* Display status of all installed modules
* @param {Object} statusData - Status data with modules, installation info, and available updates
*/
async displayStatus(statusData) {
const { installation, modules, availableUpdates, bmadDir } = statusData;
// Installation info
const infoLines = [
`Version: ${installation.version || 'unknown'}`,
`Location: ${bmadDir}`,
`Installed: ${new Date(installation.installDate).toLocaleDateString()}`,
`Last Updated: ${installation.lastUpdated ? new Date(installation.lastUpdated).toLocaleDateString() : 'unknown'}`,
];
await prompts.note(infoLines.join('\n'), 'BMAD Status');
// Module versions
await this.displayModuleVersions(modules, availableUpdates);
// Update summary
if (availableUpdates.length > 0) {
await prompts.log.warn(`${availableUpdates.length} update(s) available`);
await prompts.log.message('Run \'bmad install\' and select "Quick Update" to update');
} else {
await prompts.log.success('All modules are up to date');
}
}
/**
* Display list of selected tools after IDE selection
* @param {Array} selectedIdes - Array of selected IDE values
* @param {Array} preferredIdes - Array of preferred IDE objects
* @param {Array} allTools - Array of all tool objects
*/
async displaySelectedTools(selectedIdes, preferredIdes, allTools) {
if (selectedIdes.length === 0) return;
const preferredValues = new Set(preferredIdes.map((ide) => ide.value));
const toolLines = selectedIdes.map((ideValue) => {
const tool = allTools.find((t) => t.value === ideValue);
const name = tool?.name || ideValue;
const marker = preferredValues.has(ideValue) ? ' \u2B50' : '';
return ` \u2022 ${name}${marker}`;
});
await prompts.log.message('Selected tools:\n' + toolLines.join('\n'));
}
/**
* Return the set of module codes the registry marks as built-in (core, bmm).
* These ship with the installer binary and have no per-module channel.
*/
async _bundledModuleCodes() {
const externalManager = new ExternalModuleManager();
try {
const modules = await externalManager.listAvailable();
return modules.filter((m) => m.builtIn).map((m) => m.code);
} catch {
// Registry unreachable — fall back to the known bundled codes.
return ['core', 'bmm'];
}
}
/**
* Fast-path channel gate: confirm "all stable" or open the per-module picker.
*
* Skipped when:
* - running non-interactively (--yes)
* - the user already passed channel flags (--channel / --pin / --next), OR
* the installer was launched from a prerelease (which seeds
* channelOptions.global = 'next' upstream in promptInstall)
* - no externals/community modules are selected
*
* Mutates channelOptions.pins and channelOptions.nextSet to reflect picker choices.
*/
async _interactiveChannelGate({ options, channelOptions, selectedModules }) {
if (options.yes) return;
// If the user already declared their channel intent via flags, trust them
// and skip the gate.
const haveFlagIntent = channelOptions.global || channelOptions.nextSet.size > 0 || channelOptions.pins.size > 0;
if (haveFlagIntent) return;
// Figure out which selected modules actually get a channel (externals only).
// Bundled core/bmm and custom modules skip the picker.
const externalManager = new ExternalModuleManager();
const externals = await externalManager.listAvailable();
const externalByCode = new Map(externals.map((m) => [m.code, m]));
const channelSelectable = selectedModules.filter((code) => {
const info = externalByCode.get(code);
return info && !info.builtIn;
});
if (channelSelectable.length === 0) return;
const fastPath = await prompts.confirm({
message: `Ready to install (all stable)? Pick "n" to customize channels or pin versions.`,
default: true,
});
if (fastPath) return; // stable for all, registry default applies
// Customize path: per-module picker.
const { fetchStableTags, parseGitHubRepo } = require('./modules/channel-resolver');
for (const code of channelSelectable) {
const info = externalByCode.get(code);
const repoUrl = info.url;
// Try to pre-resolve the top stable tag so we can surface it in the picker.
let stableLabel = 'stable (released version)';
try {
const parsed = repoUrl ? parseGitHubRepo(repoUrl) : null;
if (parsed) {
const tags = await fetchStableTags(parsed.owner, parsed.repo);
if (tags.length > 0) {
stableLabel = `stable ${tags[0].tag} (released version)`;
}
}
} catch {
// fall through with the generic label
}
const choice = await prompts.select({
message: `${code}: choose a channel`,
choices: [
{ name: stableLabel, value: 'stable' },
{ name: 'next (main HEAD \u2014 current development)', value: 'next' },
{ name: 'pin (specific version)', value: 'pin' },
],
default: 'stable',
});
if (choice === 'next') {
channelOptions.nextSet.add(code);
} else if (choice === 'pin') {
const pinValue = await prompts.text({
message: `Enter a version tag for '${code}' (e.g. v1.6.0):`,
validate: (value) => {
if (!value || !/^[\w.\-+/]+$/.test(String(value).trim())) {
return 'Must be a non-empty tag name (letters, digits, dots, hyphens).';
}
},
});
channelOptions.pins.set(code, String(pinValue).trim());
}
// 'stable' is the default; nothing to record.
}
}
/**
* Resolve channel decisions for an update over an existing install.
*
* For each selected external/community module:
* - Read the recorded channel from the existing manifest.
* - On `stable`: query tags; if a newer stable exists, classify the diff
* and prompt. Patch/minor default Y; major defaults N. `--yes` accepts
* defaults (patches/minors) but NOT majors — a major under --yes stays
* frozen unless the user also passes `--pin CODE=NEW_TAG`.
* - On `next`: no prompt (pull HEAD).
* - On `pinned`: no prompt (stays pinned).
* - No channel recorded and `version: null`: one-time migration prompt
* ("Switch to stable / Keep on next").
*
* Decisions that freeze the current version are applied by adding a pin to
* `channelOptions.pins` so downstream clone logic honors them.
*/
async _resolveUpdateChannels({ bmadDir, selectedModules, channelOptions, yes }) {
const { Manifest } = require('./core/manifest');
const manifestObj = new Manifest();
const manifest = await manifestObj.read(bmadDir);
const existingByName = new Map();
for (const m of manifest?.modulesDetailed || []) {
if (m?.name) existingByName.set(m.name, m);
}
if (existingByName.size === 0) return;
const externalManager = new ExternalModuleManager();
const externals = await externalManager.listAvailable();
const externalByCode = new Map(externals.map((m) => [m.code, m]));
const { fetchStableTags, classifyUpgrade, releaseNotesUrl } = require('./modules/channel-resolver');
const { parseGitHubRepo } = require('./modules/channel-resolver');
// Interactive-only: offer a one-time gate to review / switch channels for
// selected modules that are already installed. Default N so normal Modify
// flows (add/remove modules) aren't interrupted.
let reviewChannels = false;
if (!yes) {
const existingWithChannel = selectedModules.filter((code) => {
const prev = existingByName.get(code);
if (!prev) return false;
const info = externalByCode.get(code);
return info && !info.builtIn;
});
if (existingWithChannel.length > 0) {
reviewChannels = await prompts.confirm({
message: 'Review channel assignments (stable / next / pin) for your existing modules?',
default: false,
});
}
}
for (const code of selectedModules) {
const prev = existingByName.get(code);
if (!prev) continue;
const info = externalByCode.get(code);
if (!info) continue;
// Bundled modules (core/bmm) ship with the installer binary itself —
// their version is stapled to the CLI version, not a git tag. Skip
// tag-API lookups for them; the "upgrade" mechanism is `npx bmad@X install`.
if (info.builtIn) continue;
const repoUrl = info.url;
const parsed = repoUrl ? parseGitHubRepo(repoUrl) : null;
// Legacy migration: manifest carries no channel and a null/empty
// version. Offer the one-time pick between stable and next.
const recordedChannel = prev.channel || null;
const needsMigration = !recordedChannel && (prev.version == null || prev.version === '');
if (needsMigration) {
if (yes) {
// Conservative headless default: stable.
continue;
}
const chosen = await prompts.select({
message: `${code}: your existing install tracks the main branch. Switch to stable releases (recommended for production), or keep on main?`,
choices: [
{ name: 'Switch to stable', value: 'stable' },
{ name: 'Keep on main (next)', value: 'next' },
],
default: 'stable',
});
if (chosen === 'next') channelOptions.nextSet.add(code);
continue;
}
// Optional channel-switch offer. Fires only when the user opted in via
// the gate above. 'keep' falls through to the existing per-channel
// logic (which runs upgrade classification for stable). Any switch
// records the new intent into channelOptions and skips upgrade prompts.
if (reviewChannels && recordedChannel) {
const switchChoices = [
{
name: `Keep on '${recordedChannel}'${prev.version ? ` @ ${prev.version}` : ''}`,
value: 'keep',
},
];
if (recordedChannel !== 'stable') {
switchChoices.push({ name: 'Switch to stable (released version)', value: 'stable' });
}
if (recordedChannel !== 'next') {
switchChoices.push({ name: 'Switch to next (main HEAD)', value: 'next' });
}
switchChoices.push({ name: 'Pin to a specific version tag', value: 'pin' });
const choice = await prompts.select({
message: `${code} channel:`,
choices: switchChoices,
default: 'keep',
});
if (choice === 'next') {
channelOptions.nextSet.add(code);
continue;
}
if (choice === 'pin') {
const pinValue = await prompts.text({
message: `Enter a version tag for '${code}' (e.g. v1.6.0):`,
validate: (value) => {
if (!value || !/^[\w.\-+/]+$/.test(String(value).trim())) {
return 'Must be a non-empty tag name (letters, digits, dots, hyphens).';
}
},
});
channelOptions.pins.set(code, String(pinValue).trim());
continue;
}
if (choice === 'stable') {
// Switch to stable: install at the top stable tag without an
// upgrade-classification prompt (the user explicitly opted in).
// Also warm the tag cache here so the actual clone step doesn't
// need a second GitHub API call (can hit rate limits).
if (parsed) {
try {
await fetchStableTags(parsed.owner, parsed.repo);
} catch {
// best effort; clone step will surface any failure
}
}
continue;
}
// 'keep' → fall through with recordedChannel below.
}
if (recordedChannel === 'pinned' || recordedChannel === 'next') {
// Respect any explicit channel intent the user already expressed via
// CLI flags (--channel / --all-* / --next=CODE / --pin CODE=TAG) or
// via the interactive review gate above. Only auto-re-assert the
// recorded channel when the user hasn't opted into anything else —
// otherwise --all-stable (or a review "switch to stable") would be
// silently clobbered by the prior channel.
const alreadyDecided = channelOptions.global || channelOptions.nextSet.has(code) || channelOptions.pins.has(code);
if (!alreadyDecided) {
if (recordedChannel === 'pinned' && prev.version) {
channelOptions.pins.set(code, prev.version);
} else if (recordedChannel === 'next') {
channelOptions.nextSet.add(code);
}
}
continue;
}
// Stable channel: check for a newer released tag.
if (!parsed) continue;
// Respect explicit CLI intent (--pin / --next=CODE / --all-*) and any
// choice the user already made in the earlier review gate. Without this
// guard the upgrade classifier below would unconditionally call
// `channelOptions.pins.set(code, prev.version)` on decline/major-refuse/
// fetch-error, silently clobbering the user's override.
const alreadyDecided = channelOptions.global || channelOptions.nextSet.has(code) || channelOptions.pins.has(code);
if (alreadyDecided) continue;
let tags;
try {
tags = await fetchStableTags(parsed.owner, parsed.repo);
} catch (error) {
await prompts.log.warn(`Could not check for updates on ${code} (${error.message}). Leaving at ${prev.version}.`);
if (prev.version) channelOptions.pins.set(code, prev.version);
continue;
}
if (!tags || tags.length === 0) continue;
const topTag = tags[0].tag; // e.g. "v1.7.0"
const currentTag = prev.version || '';
const diffClass = classifyUpgrade(currentTag, topTag);
if (diffClass === 'none') continue; // already at or above top tag
const notes = releaseNotesUrl(repoUrl, topTag);
let accept;
if (diffClass === 'major') {
if (yes) {
// Major under --yes is refused by design.
await prompts.log.warn(
`${code} ${currentTag} → ${topTag} is a new major release; staying on ${currentTag}. ` +
`To accept, rerun with --pin ${code}=${topTag}.`,
);
channelOptions.pins.set(code, currentTag);
continue;
}
accept = await prompts.confirm({
message:
`${code} ${topTag} available — new major release (may change behavior).` +
(notes ? ` Release notes: ${notes}.` : '') +
' Upgrade?',
default: false,
});
} else if (diffClass === 'minor') {
if (yes) {
accept = true;
} else {
accept = await prompts.confirm({
message: `${code} ${topTag} available (new features).` + (notes ? ` Release notes: ${notes}.` : '') + ' Upgrade?',
default: true,
});
}
} else {
// patch
if (yes) {
accept = true;
} else {
accept = await prompts.confirm({
message: `${code} ${topTag} available. Upgrade?`,
default: true,
});
}
}
if (!accept && currentTag) {
// Freeze the current version by pinning it for this run.
channelOptions.pins.set(code, currentTag);
}
}
}
}
module.exports = { UI };