fix(installer): preserve stale installed modules on update
This commit is contained in:
parent
0eae7c4352
commit
97f31f8a7e
|
|
@ -164,6 +164,39 @@ async function runTests() {
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Test 4b: Preserve installed modules with no source
|
||||||
|
// ============================================================
|
||||||
|
console.log(`${colors.yellow}Test Suite 4b: Preserve Installed Modules Without Source${colors.reset}\n`);
|
||||||
|
|
||||||
|
let staleInstallRoot;
|
||||||
|
try {
|
||||||
|
staleInstallRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-stale-module-'));
|
||||||
|
const staleBmadDir = path.join(staleInstallRoot, '_bmad');
|
||||||
|
const staleModuleDir = path.join(staleBmadDir, 'baut');
|
||||||
|
await fs.ensureDir(staleModuleDir);
|
||||||
|
await fs.writeFile(path.join(staleModuleDir, 'marker.txt'), 'keep me\n', 'utf8');
|
||||||
|
|
||||||
|
const ui = new (require('../tools/installer/ui').UI)();
|
||||||
|
const unavailable = await ui._findUnavailableInstalledModules(new Set(['core', 'bmm', 'baut']), staleBmadDir);
|
||||||
|
assert(unavailable.length === 1 && unavailable[0] === 'baut', 'UI detects stale installed modules with no current source');
|
||||||
|
|
||||||
|
const installer = new Installer();
|
||||||
|
await installer._removeDeselectedModules(
|
||||||
|
{ moduleIds: ['core', 'bmm', 'baut'] },
|
||||||
|
{ modules: ['core', 'bmm'] },
|
||||||
|
{ moduleDir: (moduleId) => path.join(staleBmadDir, moduleId) },
|
||||||
|
['baut'],
|
||||||
|
);
|
||||||
|
assert(await fs.pathExists(path.join(staleModuleDir, 'marker.txt')), 'Preserved modules are not removed during modify/update installs');
|
||||||
|
} catch (error) {
|
||||||
|
assert(false, 'Installed modules with no source are preserved during update', error.message);
|
||||||
|
} finally {
|
||||||
|
if (staleInstallRoot) await fs.remove(staleInstallRoot).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Test 5: Kiro Native Skills Install
|
// Test 5: Kiro Native Skills Install
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ class Installer {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingInstall.installed) {
|
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);
|
updateState = await this._prepareUpdateState(paths, config, existingInstall, officialModules);
|
||||||
await this._removeDeselectedIdes(existingInstall, config, paths);
|
await this._removeDeselectedIdes(existingInstall, config, paths);
|
||||||
}
|
}
|
||||||
|
|
@ -144,10 +144,11 @@ class Installer {
|
||||||
* Remove modules that were previously installed but are no longer selected.
|
* Remove modules that were previously installed but are no longer selected.
|
||||||
* No confirmation — the user's module selection is the decision.
|
* 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 previouslyInstalled = new Set(existingInstall.moduleIds);
|
||||||
const newlySelected = new Set(config.modules || []);
|
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) {
|
for (const moduleId of toRemove) {
|
||||||
const modulePath = paths.moduleDir(moduleId);
|
const modulePath = paths.moduleDir(moduleId);
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,44 @@ async function getModuleVersion(moduleCode, { repoUrl = null, registryDefault =
|
||||||
* UI utilities for the installer
|
* UI utilities for the installer
|
||||||
*/
|
*/
|
||||||
class UI {
|
class UI {
|
||||||
|
async _findUnavailableInstalledModules(installedModuleIds = new Set(), bmadDir) {
|
||||||
|
const { OfficialModules } = require('./modules/official-modules');
|
||||||
|
const availableModulesData = await new OfficialModules().listAvailable();
|
||||||
|
const availableModules = [...availableModulesData.modules];
|
||||||
|
|
||||||
|
const externalManager = new ExternalModuleManager();
|
||||||
|
const externalModules = await externalManager.listAvailable();
|
||||||
|
for (const externalModule of externalModules) {
|
||||||
|
if (installedModuleIds.has(externalModule.code) && !availableModules.some((m) => m.id === externalModule.code)) {
|
||||||
|
availableModules.push({
|
||||||
|
id: externalModule.code,
|
||||||
|
name: externalModule.name,
|
||||||
|
isExternal: true,
|
||||||
|
fromExternal: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { CustomModuleManager } = require('./modules/custom-module-manager');
|
||||||
|
const customMgr = new CustomModuleManager();
|
||||||
|
for (const moduleId of installedModuleIds) {
|
||||||
|
if (!availableModules.some((m) => m.id === moduleId)) {
|
||||||
|
const customSource = await customMgr.findModuleSourceByCode(moduleId, { bmadDir });
|
||||||
|
if (customSource) {
|
||||||
|
availableModules.push({
|
||||||
|
id: moduleId,
|
||||||
|
name: moduleId,
|
||||||
|
isExternal: true,
|
||||||
|
fromCustom: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableModuleIds = new Set(availableModules.map((m) => m.id));
|
||||||
|
return [...installedModuleIds].filter((id) => !availableModuleIds.has(id));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prompt for installation configuration
|
* Prompt for installation configuration
|
||||||
* @param {Object} options - Command-line options from install command
|
* @param {Object} options - Command-line options from install command
|
||||||
|
|
@ -273,6 +311,15 @@ class UI {
|
||||||
selectedModules.unshift('core');
|
selectedModules.unshift('core');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const preservedModules = await this._findUnavailableInstalledModules(installedModuleIds, bmadDir);
|
||||||
|
if (preservedModules.length > 0) {
|
||||||
|
const preservedSet = new Set(preservedModules);
|
||||||
|
selectedModules = selectedModules.filter((moduleName) => !preservedSet.has(moduleName));
|
||||||
|
await prompts.log.warn(
|
||||||
|
`Retaining ${preservedModules.length} installed module(s) with no source available: ${preservedModules.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// For existing installs, resolve per-module update decisions BEFORE
|
// For existing installs, resolve per-module update decisions BEFORE
|
||||||
// we clone anything. Reads the existing manifest's recorded channel
|
// we clone anything. Reads the existing manifest's recorded channel
|
||||||
// per module and prompts the user on available upgrades (patch/minor
|
// per module and prompts the user on available upgrades (patch/minor
|
||||||
|
|
@ -317,6 +364,7 @@ class UI {
|
||||||
setOverrides,
|
setOverrides,
|
||||||
skipPrompts: options.yes || false,
|
skipPrompts: options.yes || false,
|
||||||
channelOptions,
|
channelOptions,
|
||||||
|
_preserveModules: preservedModules,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue