diff --git a/scripts/lib/claude-scope-migration.js b/scripts/lib/claude-scope-migration.js index c4e00bc18..2ebac390a 100644 --- a/scripts/lib/claude-scope-migration.js +++ b/scripts/lib/claude-scope-migration.js @@ -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), } ); diff --git a/tests/lib/claude-scope-migration.test.js b/tests/lib/claude-scope-migration.test.js index 648b591b1..c7fe81582 100644 --- a/tests/lib/claude-scope-migration.test.js +++ b/tests/lib/claude-scope-migration.test.js @@ -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);