mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-26 18:35:05 +00:00
fix: recover plugin migration at the effective settings scope
This commit is contained in:
2 files changed
+119
-19
No files matched your search
@@ -46,48 +46,60 @@ function recoveryCommands(sourceScope, destinationScope) {
|
|||||||
return commands;
|
return commands;
|
||||||
}
|
}
|
||||||
|
|
||||||
function enableRecoveryCommands(destinationScope) {
|
function enableRecoveryCommands(destinationScope, enableScope = destinationScope) {
|
||||||
return [
|
return [
|
||||||
`claude plugin enable ${CURRENT_PLUGIN_ID} --scope ${destinationScope}`,
|
`claude plugin enable ${CURRENT_PLUGIN_ID} --scope ${enableScope}`,
|
||||||
...recoveryCommands(null, destinationScope),
|
...recoveryCommands(null, destinationScope),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function destinationSettingsPaths(paths, destinationScope) {
|
function destinationSettingsPaths(paths) {
|
||||||
const projectSettingsDir = path.join(paths.projectRoot, '.claude');
|
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 [
|
return [
|
||||||
path.join(projectSettingsDir, 'settings.local.json'),
|
{ scope: 'local', settingsPath: path.join(projectSettingsDir, 'settings.local.json') },
|
||||||
path.join(projectSettingsDir, 'settings.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 {
|
try {
|
||||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
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 {
|
} catch {
|
||||||
// Missing or unreadable settings are not an explicit user choice.
|
// 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) {
|
function destinationExplicitlyDisabledError(paths, plugins, destinationScope) {
|
||||||
const installed = currentEccPlugins(plugins);
|
const installed = currentEccPlugins(plugins);
|
||||||
const destination = installed.find(plugin => plugin.scope === destinationScope);
|
const destination = installed.find(plugin => plugin.scope === destinationScope);
|
||||||
if (!destination || destination.enabled === true) {
|
if (!destination || destination.enabled === true) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const disabledSettingsPaths = destinationSettingsPaths(paths, destinationScope)
|
const effectiveSetting = effectiveDestinationSetting(paths);
|
||||||
.filter(readExplicitlyDisabledSettings);
|
if (!effectiveSetting || effectiveSetting.enabled !== false) {
|
||||||
if (disabledSettingsPaths.length === 0) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const disabledSettingsPaths = [effectiveSetting.settingsPath];
|
||||||
return migrationError(
|
return migrationError(
|
||||||
'DESTINATION_EXPLICITLY_DISABLED',
|
'DESTINATION_EXPLICITLY_DISABLED',
|
||||||
`${CURRENT_PLUGIN_ID} is installed at ${destinationScope} scope but explicitly `
|
`${CURRENT_PLUGIN_ID} is installed at ${destinationScope} scope but explicitly `
|
||||||
@@ -98,7 +110,7 @@ function destinationExplicitlyDisabledError(paths, plugins, destinationScope) {
|
|||||||
phase: 'destination-verification',
|
phase: 'destination-verification',
|
||||||
observedScopes: installed.map(plugin => plugin.scope),
|
observedScopes: installed.map(plugin => plugin.scope),
|
||||||
settingsPaths: disabledSettingsPaths,
|
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.`,
|
: `Could not verify ${CURRENT_PLUGIN_ID} at the destination before source cleanup.`,
|
||||||
phase,
|
phase,
|
||||||
recovery: destination && destination.enabled !== true
|
recovery: destination && destination.enabled !== true
|
||||||
? enableRecoveryCommands(destinationScope)
|
? destinationEnableRecoveryCommands(paths, destinationScope)
|
||||||
: recoveryCommands(null, destinationScope),
|
: recoveryCommands(null, destinationScope),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}`);
|
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||||
process.exit(failed > 0 ? 1 : 0);
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
Reference in new issue
Block a user