fix: recover plugin migration at the effective settings scope

This commit is contained in:
haelyra committed 2026-10-11 15:36:49 -04:00
1 parent 87c136fcae
commit 1d1b62e58e
2 files changed
+119 -19

No files matched your search

+31 -19
View File
@@ -46,48 +46,60 @@ function recoveryCommands(sourceScope, destinationScope) {
return commands;
}
function enableRecoveryCommands(destinationScope) {
function enableRecoveryCommands(destinationScope, enableScope = destinationScope) {
return [
`claude plugin enable ${CURRENT_PLUGIN_ID} --scope ${destinationScope}`,
`claude plugin enable ${CURRENT_PLUGIN_ID} --scope ${enableScope}`,
...recoveryCommands(null, destinationScope),
];
}
function destinationSettingsPaths(paths, destinationScope) {
function destinationSettingsPaths(paths) {
const projectSettingsDir = path.join(paths.projectRoot, '.claude');
if (destinationScope === 'user') {
return [path.join(paths.configDir, 'settings.json')];
}
if (destinationScope === 'project') {
return [path.join(projectSettingsDir, 'settings.json')];
}
return [
path.join(projectSettingsDir, 'settings.local.json'),
path.join(projectSettingsDir, 'settings.json'),
{ scope: 'local', settingsPath: path.join(projectSettingsDir, 'settings.local.json') },
{ scope: 'project', settingsPath: path.join(projectSettingsDir, 'settings.json') },
{ scope: 'user', settingsPath: path.join(paths.configDir, 'settings.json') },
];
}
function readExplicitlyDisabledSettings(settingsPath) {
function readPluginEnabledSetting(settingsPath) {
try {
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
return settings?.enabledPlugins?.[CURRENT_PLUGIN_ID] === false;
const enabled = settings?.enabledPlugins?.[CURRENT_PLUGIN_ID];
return typeof enabled === 'boolean' ? enabled : null;
} catch {
// Missing or unreadable settings are not an explicit user choice.
return false;
return null;
}
}
function effectiveDestinationSetting(paths) {
for (const setting of destinationSettingsPaths(paths)) {
const enabled = readPluginEnabledSetting(setting.settingsPath);
if (enabled !== null) return { ...setting, enabled };
}
return null;
}
function destinationEnableRecoveryCommands(paths, destinationScope, setting = effectiveDestinationSetting(paths)) {
const scopes = ['user', 'project', 'local'];
const enableScope = setting?.enabled === false
&& scopes.indexOf(setting.scope) > scopes.indexOf(destinationScope)
? setting.scope : destinationScope;
return enableRecoveryCommands(destinationScope, enableScope);
}
function destinationExplicitlyDisabledError(paths, plugins, destinationScope) {
const installed = currentEccPlugins(plugins);
const destination = installed.find(plugin => plugin.scope === destinationScope);
if (!destination || destination.enabled === true) {
return null;
}
const disabledSettingsPaths = destinationSettingsPaths(paths, destinationScope)
.filter(readExplicitlyDisabledSettings);
if (disabledSettingsPaths.length === 0) {
const effectiveSetting = effectiveDestinationSetting(paths);
if (!effectiveSetting || effectiveSetting.enabled !== false) {
return null;
}
const disabledSettingsPaths = [effectiveSetting.settingsPath];
return migrationError(
'DESTINATION_EXPLICITLY_DISABLED',
`${CURRENT_PLUGIN_ID} is installed at ${destinationScope} scope but explicitly `
@@ -98,7 +110,7 @@ function destinationExplicitlyDisabledError(paths, plugins, destinationScope) {
phase: 'destination-verification',
observedScopes: installed.map(plugin => plugin.scope),
settingsPaths: disabledSettingsPaths,
recovery: enableRecoveryCommands(destinationScope),
recovery: destinationEnableRecoveryCommands(paths, destinationScope, effectiveSetting),
}
);
}
@@ -244,7 +256,7 @@ function verifySourceAndDestination(run, paths, migration, destinationScope, pha
: `Could not verify ${CURRENT_PLUGIN_ID} at the destination before source cleanup.`,
phase,
recovery: destination && destination.enabled !== true
? enableRecoveryCommands(destinationScope)
? destinationEnableRecoveryCommands(paths, destinationScope)
: recoveryCommands(null, destinationScope),
}
);
+88
View File
@@ -836,5 +836,93 @@ test('a two-scope disabled destination offers enable recovery without removing t
});
});
for (const destinationScope of ['project', 'user']) {
test(`local settings control recovery for a disabled ${destinationScope} destination`, () => {
const sourceScope = destinationScope === 'project' ? 'user' : 'project';
withFixture({ plugins: [plugin(sourceScope), plugin(destinationScope, { enabled: false })] }, fixture => {
const localSettings = path.join(fixture.projectRoot, '.claude', 'settings.local.json');
writeExplicitDisable(localSettings);
const before = fs.readFileSync(localSettings, 'utf8');
const error = captureError(() => migrateClaudePluginScope(migrationOptions(fixture, destinationScope)));
assert.strictEqual(error.code, 'DESTINATION_EXPLICITLY_DISABLED');
assert.ok(error.message.includes(localSettings));
assert.strictEqual(error.recovery[0], 'claude plugin enable ecc@ecc --scope local');
assert.strictEqual(error.recovery[1], `ecc setup --mode claude-plugin --scope ${destinationScope} --move-scope --yes`);
assert.deepStrictEqual(mutationCalls(fixture), []);
assert.strictEqual(fs.readFileSync(localSettings, 'utf8'), before);
assert.strictEqual(readState(fixture).plugins.length, 2);
});
});
}
test('a newly installed disabled destination points recovery at the local override before cleanup', () => {
withFixture({
plugins: [plugin('user')],
marketplaces: [marketplace('user')],
pluginListResponses: [
[plugin('user')],
[plugin('user'), plugin('project', { enabled: false })],
],
}, fixture => {
const localSettings = path.join(fixture.projectRoot, '.claude', 'settings.local.json');
writeExplicitDisable(localSettings);
const before = fs.readFileSync(localSettings, 'utf8');
const error = captureError(() => migrateClaudePluginScope(migrationOptions(fixture, 'project')));
assert.strictEqual(error.code, 'DESTINATION_VERIFICATION_FAILED');
assert.strictEqual(error.recovery[0], 'claude plugin enable ecc@ecc --scope local');
assert.ok(!readCalls(fixture).some(argv => argv[1] === 'uninstall'));
assert.strictEqual(fs.readFileSync(localSettings, 'utf8'), before);
assert.ok(readState(fixture).plugins.some(entry => entry.scope === 'user'));
});
});
test('a higher-precedence true does not blame an overridden false', () => {
withFixture({ plugins: [plugin('project', { enabled: false })] }, fixture => {
const projectSettings = path.join(fixture.projectRoot, '.claude', 'settings.json');
const localSettings = path.join(fixture.projectRoot, '.claude', 'settings.local.json');
writeExplicitDisable(projectSettings);
fs.writeFileSync(localSettings, JSON.stringify({ enabledPlugins: { 'ecc@ecc': true } }));
const error = captureError(() => migrateClaudePluginScope(migrationOptions(fixture, 'project')));
assert.strictEqual(error.code, 'DESTINATION_VERIFICATION_FAILED');
assert.deepStrictEqual(mutationCalls(fixture), []);
});
});
test('an unrelated local plugin entry leaves the project choice effective', () => {
withFixture({ plugins: [plugin('project', { enabled: false })] }, fixture => {
const projectSettings = path.join(fixture.projectRoot, '.claude', 'settings.json');
const localSettings = path.join(fixture.projectRoot, '.claude', 'settings.local.json');
writeExplicitDisable(projectSettings);
writeExplicitDisable(fixture.settingsPath);
fs.writeFileSync(localSettings, JSON.stringify({ enabledPlugins: { 'other@marketplace': true } }));
const error = captureError(() => migrateClaudePluginScope(migrationOptions(fixture, 'project')));
assert.strictEqual(error.code, 'DESTINATION_EXPLICITLY_DISABLED');
assert.ok(error.message.includes(projectSettings));
assert.ok(!error.message.includes(fixture.settingsPath));
assert.strictEqual(error.recovery[0], 'claude plugin enable ecc@ecc --scope project');
assert.deepStrictEqual(mutationCalls(fixture), []);
});
});
test('the public setup CLI preserves locally disabled project migration and names local recovery', () => {
withFixture({ plugins: [plugin('user'), plugin('project', { enabled: false })] }, fixture => {
const localSettings = path.join(fixture.projectRoot, '.claude', 'settings.local.json');
writeExplicitDisable(localSettings);
const before = fs.readFileSync(localSettings, 'utf8');
const result = require('child_process').spawnSync(process.execPath, [
path.join(repoRoot, 'scripts/ecc.js'), 'setup', '--mode', 'claude-plugin',
'--scope', 'project', '--move-scope', '--yes', '--json',
], { cwd: fixture.projectRoot, env: process.env, encoding: 'utf8', timeout: 15000 });
assert.strictEqual(result.error, undefined);
assert.strictEqual(result.status, 1, result.stderr);
const error = JSON.parse(result.stderr).error;
assert.strictEqual(error.code, 'DESTINATION_EXPLICITLY_DISABLED');
assert.strictEqual(error.recovery[0], 'claude plugin enable ecc@ecc --scope local');
assert.deepStrictEqual(mutationCalls(fixture), []);
assert.strictEqual(fs.readFileSync(localSettings, 'utf8'), before);
assert.strictEqual(readState(fixture).plugins.length, 2);
});
});
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);