Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 100 additions & 23 deletions tools/installer/core/installer.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ class Installer {
}

if (existingInstall.installed) {
await this._removeDeselectedModules(existingInstall, config, paths);
await this._removeDeselectedModules(existingInstall, config, paths, originalConfig._preserveModules || []);
updateState = await this._prepareUpdateState(paths, config, existingInstall, officialModules);
await this._removeDeselectedIdes(existingInstall, config, paths);
}
Expand All @@ -76,25 +76,23 @@ class Installer {
const results = [];
const addResult = (step, status, detail = '', meta = {}) => results.push({ step, status, detail, ...meta });

// Capture previously installed skill IDs before they get overwritten
const previousSkillIds = new Set();
const prevCsvPath = path.join(paths.bmadDir, '_config', 'skill-manifest.csv');
if (await fs.pathExists(prevCsvPath)) {
try {
const csvParse = require('csv-parse/sync');
const content = await fs.readFile(prevCsvPath, 'utf8');
const records = csvParse.parse(content, { columns: true, skip_empty_lines: true });
for (const r of records) {
if (r.canonicalId) previousSkillIds.add(r.canonicalId);
}
} catch (error) {
await prompts.log.warn(`Failed to parse skill-manifest.csv: ${error.message}`);
}
}
// Capture previously installed skill rows before they get overwritten
const preservedModules = originalConfig._preserveModules || [];
const previousSkillManifestRows = await this._readSkillManifestRows(paths.bmadDir);
const previousSkillIds = this._getPreviousSkillIdsForCleanup(previousSkillManifestRows, preservedModules);

const allModules = config.modules || [];

await this._installAndConfigure(config, originalConfig, paths, allModules, allModules, addResult, officialModules);
await this._installAndConfigure(
config,
originalConfig,
paths,
allModules,
allModules,
addResult,
officialModules,
previousSkillManifestRows,
);

await this._setupIdes(config, allModules, paths, addResult, previousSkillIds);

Expand Down Expand Up @@ -144,10 +142,11 @@ class Installer {
* Remove modules that were previously installed but are no longer selected.
* No confirmation — the user's module selection is the decision.
*/
async _removeDeselectedModules(existingInstall, config, paths) {
async _removeDeselectedModules(existingInstall, config, paths, preservedModules = []) {
const previouslyInstalled = new Set(existingInstall.moduleIds);
const newlySelected = new Set(config.modules || []);
const toRemove = [...previouslyInstalled].filter((m) => !newlySelected.has(m) && m !== 'core');
const preserved = new Set(preservedModules);
const toRemove = [...previouslyInstalled].filter((m) => !newlySelected.has(m) && m !== 'core' && !preserved.has(m));

for (const moduleId of toRemove) {
const modulePath = paths.moduleDir(moduleId);
Expand Down Expand Up @@ -212,7 +211,16 @@ class Installer {
/**
* Install modules, create directories, generate configs and manifests.
*/
async _installAndConfigure(config, originalConfig, paths, officialModuleIds, allModules, addResult, officialModules) {
async _installAndConfigure(
config,
originalConfig,
paths,
officialModuleIds,
allModules,
addResult,
officialModules,
previousSkillManifestRows = [],
) {
const isQuickUpdate = config.isQuickUpdate();
const moduleConfigs = officialModules.moduleConfigs;

Expand Down Expand Up @@ -291,25 +299,29 @@ class Installer {

message('Generating manifests...');
const manifestGen = new ManifestGenerator();
const preservedModules = originalConfig._preserveModules || [];

const allModulesForManifest = config.isQuickUpdate()
? originalConfig._existingModules || allModules || []
: originalConfig._preserveModules
? [...allModules, ...originalConfig._preserveModules]
: preservedModules.length > 0
? [...allModules, ...preservedModules]
: allModules || [];

let modulesForCsvPreserve;
if (config.isQuickUpdate()) {
modulesForCsvPreserve = originalConfig._existingModules || allModules || [];
} else {
modulesForCsvPreserve = originalConfig._preserveModules ? [...allModules, ...originalConfig._preserveModules] : allModules;
modulesForCsvPreserve = preservedModules.length > 0 ? [...allModules, ...preservedModules] : allModules;
}

await this._trackPreservedModuleFiles(paths.bmadDir, preservedModules);

await manifestGen.generateManifests(paths.bmadDir, allModulesForManifest, [...this.installedFiles], {
ides: config.ides || [],
preservedModules: modulesForCsvPreserve,
moduleConfigs,
});
await this._appendPreservedSkillManifestRows(paths.bmadDir, previousSkillManifestRows, preservedModules);

// Apply post-install --set TOML patches. Runs after writeCentralConfig
// (inside generateManifests above) so the patch operates on the
Expand Down Expand Up @@ -411,6 +423,62 @@ class Installer {
}
}

async _readSkillManifestRows(bmadDir) {
const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv');
if (!(await fs.pathExists(csvPath))) return [];

try {
const csvParse = require('csv-parse/sync');
const content = await fs.readFile(csvPath, 'utf8');
return csvParse.parse(content, { columns: true, skip_empty_lines: true });
} catch (error) {
await prompts.log.warn(`Failed to parse skill-manifest.csv: ${error.message}`);
return [];
}
}

_getPreviousSkillIdsForCleanup(previousRows, preservedModules = []) {
const preservedModuleSet = new Set(preservedModules || []);
const ids = new Set();
for (const row of previousRows || []) {
if (row.canonicalId && !preservedModuleSet.has(row.module)) {
Copy link
Copy Markdown

@augmentcode augmentcode Bot May 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In _getPreviousSkillIdsForCleanup, preservation depends on row.module being present in the previous skill-manifest.csv; if older manifests are missing the module column (or it’s blank), preserved-module skills could still be scheduled for cleanup. Consider a fallback derivation (e.g., from row.path) before comparing against preservedModules to make the stale-module compatibility path more robust.

Severity: medium

Other Locations
  • tools/installer/core/installer.js:455

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

ids.add(row.canonicalId);
}
}
return ids;
}

async _appendPreservedSkillManifestRows(bmadDir, previousRows, preservedModules = []) {
if (!previousRows || previousRows.length === 0 || preservedModules.length === 0) return;

const preservedModuleSet = new Set(preservedModules);
const rowsToPreserve = previousRows.filter((row) => row.canonicalId && row.module && preservedModuleSet.has(row.module));
if (rowsToPreserve.length === 0) return;

const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv');
if (!(await fs.pathExists(csvPath))) return;

const currentRows = await this._readSkillManifestRows(bmadDir);
const activeIds = new Set(currentRows.map((row) => row.canonicalId).filter(Boolean));
const appendedRows = [];

for (const row of rowsToPreserve) {
if (activeIds.has(row.canonicalId)) continue;
activeIds.add(row.canonicalId);
appendedRows.push(
[row.canonicalId, row.name || row.canonicalId, row.description || '', row.module, row.path || '']
.map((field) => this.escapeCSVField(field))
.join(','),
);
}

if (appendedRows.length === 0) return;

const currentContent = await fs.readFile(csvPath, 'utf8');
const prefix = currentContent.endsWith('\n') ? currentContent : `${currentContent}\n`;
await fs.writeFile(csvPath, prefix + appendedRows.join('\n') + '\n', 'utf8');
}

/**
* Restore custom and modified files that were backed up before the update.
* No-op for fresh installs (updateState is null).
Expand Down Expand Up @@ -597,6 +665,15 @@ class Installer {
}
}

async _trackPreservedModuleFiles(bmadDir, preservedModules = []) {
for (const moduleName of preservedModules) {
const modulePath = path.join(bmadDir, moduleName);
if (await fs.pathExists(modulePath)) {
await this._trackFilesRecursive(modulePath);
}
}
}

/**
* Install official (non-custom) modules.
* @param {Object} config - Installation configuration
Expand Down
4 changes: 2 additions & 2 deletions tools/installer/ide/_config-driven.js
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,7 @@ class ConfigDrivenIdeSetup {

// Build removal set: previously installed skills + removals.txt entries
let removalSet;
if (options.previousSkillIds && options.previousSkillIds.size > 0) {
if (options.previousSkillIds) {
// Install/update flow: use pre-captured skill IDs (before manifest was overwritten)
removalSet = new Set(options.previousSkillIds);
if (resolvedBmadDir) {
Expand Down Expand Up @@ -547,7 +547,7 @@ class ConfigDrivenIdeSetup {
// previousSkillIds — full uninstall or per-IDE removal via
// cleanupByList), don't spare anything; the IDE itself is going away,
// so its pointers should go with it.
const isInstallFlow = options.previousSkillIds && options.previousSkillIds.size > 0;
const isInstallFlow = !!options.previousSkillIds;
const activeSkillIds = isInstallFlow ? await this._readActiveSkillIds(resolvedBmadDir) : new Set();
const extension = this.installerConfig.commands_extension || '.md';
await this.cleanupCommandPointers(
Expand Down
51 changes: 51 additions & 0 deletions tools/installer/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,44 @@ async function getModuleVersion(moduleCode, { repoUrl = null, registryDefault =
* UI utilities for the installer
*/
class UI {
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;
if (officialCodes.has(moduleId)) 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
Expand Down Expand Up @@ -273,6 +311,18 @@ class UI {
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(', ')}`,
);
}

// 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
Expand Down Expand Up @@ -317,6 +367,7 @@ class UI {
setOverrides,
skipPrompts: options.yes || false,
channelOptions,
_preserveModules: preservedModules,
};
}
}
Expand Down
Loading