'use strict'; const assert = require('assert'); const fs = require('fs'); const os = require('os'); const path = require('path'); const repoRoot = path.join(__dirname, '..', '..'); const fakeClaudeScript = path.join(repoRoot, 'tests', 'fixtures', 'fake-claude-plugin.js'); const { OFFICIAL_MARKETPLACE_URL, } = require('../../scripts/lib/claude-plugin-setup'); const { migrateClaudePluginScope, } = require('../../scripts/lib/claude-scope-migration'); let passed = 0; let failed = 0; function test(name, fn) { try { fn(); console.log(` ✓ ${name}`); passed += 1; } catch (error) { console.log(` ✗ ${name}`); console.log(` Error: ${error.stack || error.message}`); failed += 1; } } function plugin(scope, overrides = {}) { return { id: 'ecc@ecc', scope, enabled: true, version: '1.9.0', ...overrides, }; } function marketplace(scope = 'user') { return { name: 'ecc', source: 'github', repo: 'affaan-m/ECC', scope, }; } function createFixture(initialState = {}) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc scope migration ')); const homeDir = path.join(root, 'home with spaces'); const configDir = path.join(root, 'config with spaces'); const projectRoot = path.join(root, 'project with spaces'); const binDir = path.join(root, 'bin with spaces'); const statePath = path.join(root, 'claude-state.json'); const callsPath = path.join(root, 'claude-calls.jsonl'); for (const dir of [homeDir, configDir, projectRoot, binDir]) { fs.mkdirSync(dir, { recursive: true }); } fs.writeFileSync(statePath, `${JSON.stringify({ plugins: [], marketplaces: [], failures: [], ...initialState, }, null, 2)}\n`); const launcher = path.join(binDir, process.platform === 'win32' ? 'claude.cmd' : 'claude'); const launcherSource = process.platform === 'win32' ? `@echo off\r\n"${process.execPath}" "${fakeClaudeScript}" %*\r\n` : `#!/bin/sh\nexec "${process.execPath}" "${fakeClaudeScript}" "$@"\n`; fs.writeFileSync(launcher, launcherSource); if (process.platform !== 'win32') fs.chmodSync(launcher, 0o755); return { root, homeDir, configDir, projectRoot, binDir, statePath, callsPath, settingsPath: path.join(configDir, 'settings.json'), }; } function withFixture(initialState, fn) { const fixture = createFixture(initialState); const previous = { cwd: process.cwd(), HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE, PATH: process.env.PATH, CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, ECC_TEST_CLAUDE_STATE: process.env.ECC_TEST_CLAUDE_STATE, ECC_TEST_CLAUDE_CALLS: process.env.ECC_TEST_CLAUDE_CALLS, }; try { process.chdir(fixture.projectRoot); process.env.HOME = fixture.homeDir; process.env.USERPROFILE = fixture.homeDir; process.env.PATH = `${fixture.binDir}${path.delimiter}${previous.PATH || ''}`; process.env.CLAUDE_CONFIG_DIR = fixture.configDir; process.env.ECC_TEST_CLAUDE_STATE = fixture.statePath; process.env.ECC_TEST_CLAUDE_CALLS = fixture.callsPath; return fn(fixture); } finally { process.chdir(previous.cwd); for (const [key, value] of Object.entries(previous)) { if (key === 'cwd') continue; if (value === undefined) delete process.env[key]; else process.env[key] = value; } fs.rmSync(fixture.root, { recursive: true, force: true }); } } function migrationOptions(fixture, scope, overrides = {}) { return { homeDir: fixture.homeDir, configDir: fixture.configDir, projectRoot: fixture.projectRoot, scope, ...overrides, }; } function readCalls(fixture) { if (!fs.existsSync(fixture.callsPath)) return []; return fs.readFileSync(fixture.callsPath, 'utf8') .trim() .split(/\r?\n/) .filter(Boolean) .map(line => JSON.parse(line)); } function readState(fixture) { return JSON.parse(fs.readFileSync(fixture.statePath, 'utf8')); } function mutationCalls(fixture) { return readCalls(fixture).filter(argv => !( argv.join(' ') === 'plugin list --json' || argv.join(' ') === 'plugin marketplace list --json' )); } function captureError(fn) { try { fn(); } catch (error) { return error; } assert.fail('Expected operation to throw'); } function installArgv(scope, hooks = 'standard', profileOverride) { const enabled = hooks !== 'off'; const profile = profileOverride || (hooks === 'off' ? 'standard' : hooks); return [ 'plugin', 'install', 'ecc@ecc', '--scope', scope, '--config', `hooks_enabled=${enabled}`, '--config', `hook_profile=${profile}`, ]; } function uninstallArgv(scope) { return ['plugin', 'uninstall', 'ecc@ecc', '--scope', scope, '--keep-data']; } function expectedMigrationCalls(sourceScope, destinationScope, hooks = 'standard') { return [ ['plugin', 'list', '--json'], ['plugin', 'marketplace', 'list', '--json'], ['plugin', 'marketplace', 'update', 'ecc'], ['plugin', 'marketplace', 'list', '--json'], installArgv(destinationScope, hooks), ['plugin', 'list', '--json'], ['plugin', 'list', '--json'], uninstallArgv(sourceScope), ['plugin', 'list', '--json'], ]; } console.log('\n=== Claude plugin scope migration tests ===\n'); test('all six directed scope pairs migrate destination-first with exact verification order', () => { const scopes = ['user', 'project', 'local']; for (const sourceScope of scopes) { for (const destinationScope of scopes.filter(scope => scope !== sourceScope)) { withFixture({ plugins: [plugin(sourceScope)], marketplaces: [marketplace(sourceScope)], }, fixture => { const result = migrateClaudePluginScope( migrationOptions(fixture, destinationScope) ); assert.deepStrictEqual(result, { action: 'migrated', hooks: 'standard', pluginId: 'ecc@ecc', sourceScope, scope: destinationScope, }); assert.deepStrictEqual( readCalls(fixture), expectedMigrationCalls(sourceScope, destinationScope) ); assert.deepStrictEqual(readState(fixture).plugins, [ plugin(destinationScope, { version: '2.0.0' }), ]); assert.deepStrictEqual(readState(fixture).marketplaces, [ marketplace(sourceScope), ]); }); } } }); test('a missing marketplace is added at the destination before plugin installation', () => { withFixture({ plugins: [plugin('user')] }, fixture => { migrateClaudePluginScope(migrationOptions(fixture, 'project')); const calls = readCalls(fixture); const marketplaceAdd = [ 'plugin', 'marketplace', 'add', OFFICIAL_MARKETPLACE_URL, '--scope', 'project', ]; const addIndex = calls.findIndex(argv => ( JSON.stringify(argv) === JSON.stringify(marketplaceAdd) )); const installIndex = calls.findIndex(argv => argv[1] === 'install'); assert.ok(addIndex >= 0); assert.ok(installIndex >= 0); assert.ok(addIndex < installIndex); }); }); test('an interrupted source-plus-destination state resumes cleanup without reinstalling', () => { withFixture({ plugins: [plugin('user'), plugin('project', { version: '2.0.0' })], marketplaces: [marketplace('user')], }, fixture => { const result = migrateClaudePluginScope(migrationOptions(fixture, 'project')); assert.strictEqual(result.action, 'resumed'); assert.strictEqual(result.sourceScope, 'user'); assert.strictEqual(result.scope, 'project'); assert.deepStrictEqual(readCalls(fixture), [ ['plugin', 'list', '--json'], ['plugin', 'marketplace', 'list', '--json'], ['plugin', 'list', '--json'], ['plugin', 'list', '--json'], uninstallArgv('user'), ['plugin', 'list', '--json'], ]); assert.deepStrictEqual(readState(fixture).plugins, [ plugin('project', { version: '2.0.0' }), ]); }); }); test('resume verifies an enabled destination before removing the source', () => { withFixture({ plugins: [plugin('user'), plugin('project', { enabled: false })], marketplaces: [marketplace('user')], }, fixture => { const error = captureError(() => ( migrateClaudePluginScope(migrationOptions(fixture, 'project')) )); assert.strictEqual(error.phase, 'destination-verification'); assert.ok(!readCalls(fixture).some(argv => argv[1] === 'uninstall')); assert.deepStrictEqual( readState(fixture).plugins.map(entry => entry.scope).sort(), ['project', 'user'] ); }); }); test('destination-only state is idempotently already migrated, including same-scope input', () => { for (const scope of ['user', 'project', 'local']) { withFixture({ plugins: [plugin(scope)] }, fixture => { const result = migrateClaudePluginScope(migrationOptions(fixture, scope)); assert.strictEqual(result.action, 'already-migrated'); assert.strictEqual(result.sourceScope, null); assert.strictEqual(result.scope, scope); assert.deepStrictEqual(readCalls(fixture), [ ['plugin', 'list', '--json'], ['plugin', 'marketplace', 'list', '--json'], ]); assert.deepStrictEqual(mutationCalls(fixture), []); }); } }); test('destination-only migration honors explicit hook preferences and reports dry-run writes', () => { withFixture({ plugins: [plugin('local')] }, fixture => { fs.writeFileSync(fixture.settingsPath, JSON.stringify({ pluginConfigs: { 'ecc@ecc': { options: { hooks_enabled: false, hook_profile: 'minimal' }, }, }, })); const result = migrateClaudePluginScope(migrationOptions(fixture, 'local', { hooks: 'strict', })); assert.strictEqual(result.action, 'already-migrated'); assert.strictEqual(result.preferencesUpdated, true); const settings = JSON.parse(fs.readFileSync(fixture.settingsPath, 'utf8')); assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hooks_enabled, true); assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hook_profile, 'strict'); }); withFixture({ plugins: [plugin('local')] }, fixture => { const result = migrateClaudePluginScope(migrationOptions(fixture, 'local', { dryRun: true, hooks: 'off', })); assert.strictEqual(result.action, 'already-migrated'); assert.strictEqual(result.dryRun, true); assert.strictEqual(result.preferencesUpdated, false); assert.deepStrictEqual(result.plannedActions, [ { action: 'write-hook-preferences', hooks_enabled: false, hook_profile: 'standard', }, { action: 'write-commit-attribution-preference', includeCoAuthoredBy: false, }, ]); assert.ok(!fs.existsSync(fixture.settingsPath)); }); }); test('destination-only state must be enabled before it is considered migrated', () => { withFixture({ plugins: [plugin('project', { enabled: false })] }, fixture => { const error = captureError(() => ( migrateClaudePluginScope(migrationOptions(fixture, 'project')) )); assert.strictEqual(error.code, 'DESTINATION_VERIFICATION_FAILED'); assert.strictEqual(error.phase, 'destination-verification'); assert.deepStrictEqual(error.observedScopes, ['project']); assert.deepStrictEqual(mutationCalls(fixture), []); }); }); test('zero installs, ambiguous non-destination scopes, and invalid inventories fail closed', () => { const cases = [ { state: {}, scope: 'project', code: 'PLUGIN_NOT_INSTALLED' }, { state: { plugins: [plugin('user'), plugin('local')] }, scope: 'project', code: 'AMBIGUOUS_PLUGIN_SCOPES', }, { state: { plugins: [plugin('user'), plugin('project'), plugin('local')] }, scope: 'project', code: 'AMBIGUOUS_PLUGIN_SCOPES', }, { state: { pluginListResponses: ['{not-json'] }, scope: 'project', code: 'INVALID_PLUGIN_INVENTORY', }, { state: { pluginListResponses: [[{ id: 'ecc@ecc', enabled: true }]] }, scope: 'project', code: 'INVALID_PLUGIN_INVENTORY', }, { state: { plugins: [plugin('user')], marketplaceListResponses: ['{not-json'], }, scope: 'project', code: 'INVALID_MARKETPLACE_INVENTORY', }, ]; for (const { state, scope, code } of cases) { withFixture(state, fixture => { const error = captureError(() => ( migrateClaudePluginScope(migrationOptions(fixture, scope)) )); assert.strictEqual(error.code, code); assert.deepStrictEqual(mutationCalls(fixture), []); }); } }); test('marketplace collisions fail closed in migration dry-run and resume cleanup', () => { const collision = { name: 'ecc', source: 'github', repo: 'attacker/ecc', scope: 'user', }; const cases = [ { state: { plugins: [plugin('user')], marketplaces: [collision], }, options: { dryRun: true }, }, { state: { plugins: [plugin('user'), plugin('project')], marketplaces: [collision], }, options: {}, }, { state: { plugins: [plugin('project')], marketplaces: [collision], }, options: { hooks: 'strict' }, }, ]; for (const { state, options } of cases) { withFixture(state, fixture => { const error = captureError(() => ( migrateClaudePluginScope(migrationOptions(fixture, 'project', options)) )); assert.strictEqual(error.code, 'MARKETPLACE_COLLISION'); assert.deepStrictEqual(mutationCalls(fixture), []); assert.ok(!readCalls(fixture).some(argv => argv[1] === 'uninstall')); assert.deepStrictEqual( readState(fixture).plugins.map(entry => entry.scope).sort(), state.plugins.map(entry => entry.scope).sort() ); assert.ok(!fs.existsSync(fixture.settingsPath)); }); } }); test('destination marketplace, install, and verification failures never uninstall the source', () => { const destinationInstall = installArgv('project'); const cases = [ { state: { plugins: [plugin('user')], marketplaces: [marketplace('user')], failures: [{ argv: ['plugin', 'marketplace', 'update', 'ecc'], status: 7, stderr: 'marketplace failed', times: 1, }], }, }, { state: { plugins: [plugin('user')], marketplaces: [marketplace('user')], failures: [{ argv: destinationInstall, status: 8, stderr: 'install failed', times: 1, }], }, }, { state: { plugins: [plugin('user')], marketplaces: [marketplace('user')], pluginListResponses: [[plugin('user')], [plugin('user')]], }, }, ]; for (const { state } of cases) { withFixture(state, fixture => { captureError(() => ( migrateClaudePluginScope(migrationOptions(fixture, 'project')) )); assert.ok(!readCalls(fixture).some(argv => argv[1] === 'uninstall')); assert.ok(readState(fixture).plugins.some(entry => entry.scope === 'user')); }); } }); test('a concurrent non-destination install aborts before source cleanup', () => { withFixture({ plugins: [plugin('user')], marketplaces: [marketplace('user')], pluginListResponses: [ [plugin('user')], [plugin('user'), plugin('project')], [plugin('user'), plugin('project'), plugin('local')], ], }, fixture => { const error = captureError(() => ( migrateClaudePluginScope(migrationOptions(fixture, 'project')) )); assert.strictEqual(error.phase, 'concurrency-check'); assert.deepStrictEqual([...error.observedScopes].sort(), ['local', 'project', 'user']); assert.ok(!readCalls(fixture).some(argv => argv[1] === 'uninstall')); }); }); test('source uninstall failure reports both scopes and exact forward recovery', () => { withFixture({ plugins: [plugin('user')], marketplaces: [marketplace('user')], failures: [{ argv: uninstallArgv('user'), status: 9, stderr: 'uninstall failed', times: 1, }], }, fixture => { const error = captureError(() => ( migrateClaudePluginScope(migrationOptions(fixture, 'project')) )); assert.strictEqual(error.phase, 'source-uninstall'); assert.deepStrictEqual([...error.observedScopes].sort(), ['project', 'user']); assert.deepStrictEqual(error.recovery, [ 'claude plugin uninstall ecc@ecc --scope user --keep-data', 'ecc setup --mode claude-plugin --scope project --move-scope --yes', ]); assert.ok(!readCalls(fixture).flat().includes('--prune')); assert.deepStrictEqual( readState(fixture).plugins.map(entry => entry.scope).sort(), ['project', 'user'] ); }); }); test('final verification failure is structured and leaves a resumable destination state', () => { withFixture({ plugins: [plugin('user')], marketplaces: [marketplace('user')], pluginListResponses: [ [plugin('user')], [plugin('user'), plugin('project')], [plugin('user'), plugin('project')], [], ], }, fixture => { const error = captureError(() => ( migrateClaudePluginScope(migrationOptions(fixture, 'project')) )); assert.strictEqual(error.phase, 'final-verification'); assert.deepStrictEqual(error.observedScopes, []); assert.deepStrictEqual(error.recovery, [ 'ecc setup --mode claude-plugin --scope project --move-scope --yes', ]); assert.deepStrictEqual(readState(fixture).plugins.map(entry => entry.scope), ['project']); }); }); test('dry-run returns exact ordered actions and performs no mutation', () => { withFixture({ plugins: [plugin('user')], marketplaces: [marketplace('user')], }, fixture => { const before = fs.readFileSync(fixture.statePath, 'utf8'); const result = migrateClaudePluginScope(migrationOptions(fixture, 'project', { dryRun: true, })); assert.strictEqual(result.action, 'would-migrate'); assert.strictEqual(result.dryRun, true); assert.deepStrictEqual(result.plannedActions, [ ['plugin', 'marketplace', 'update', 'ecc'], installArgv('project'), ['plugin', 'list', '--json'], ['plugin', 'list', '--json'], uninstallArgv('user'), ['plugin', 'list', '--json'], ]); assert.deepStrictEqual(mutationCalls(fixture), []); assert.strictEqual(fs.readFileSync(fixture.statePath, 'utf8'), before); }); withFixture({ plugins: [plugin('user'), plugin('project')], marketplaces: [marketplace('user')], }, fixture => { const result = migrateClaudePluginScope(migrationOptions(fixture, 'project', { dryRun: true, })); assert.strictEqual(result.action, 'would-resume'); assert.strictEqual(result.sourceScope, 'user'); assert.deepStrictEqual(result.plannedActions, [ ['plugin', 'list', '--json'], ['plugin', 'list', '--json'], uninstallArgv('user'), ['plugin', 'list', '--json'], ]); assert.deepStrictEqual(mutationCalls(fixture), []); }); }); test('migration preserves hook preferences unless --hooks is explicit', () => { withFixture({ plugins: [plugin('user')], marketplaces: [marketplace('user')], }, fixture => { const original = { theme: 'dark', pluginConfigs: { 'another@market': { enabled: false }, 'ecc@ecc': { futureKey: { keep: true }, options: { hooks_enabled: false, hook_profile: 'strict', unknown: 'keep', }, }, }, }; fs.writeFileSync(fixture.settingsPath, `${JSON.stringify(original, null, 2)}\n`); const result = migrateClaudePluginScope(migrationOptions(fixture, 'project')); assert.strictEqual(result.hooks, 'off'); assert.deepStrictEqual( JSON.parse(fs.readFileSync(fixture.settingsPath, 'utf8')), { ...original, includeCoAuthoredBy: false, } ); assert.ok(readCalls(fixture).some(argv => ( JSON.stringify(argv) === JSON.stringify(installArgv('project', 'off', 'strict')) ))); }); withFixture({ plugins: [plugin('user')], marketplaces: [marketplace('user')], }, fixture => { fs.writeFileSync(fixture.settingsPath, JSON.stringify({ theme: 'dark', pluginConfigs: { 'ecc@ecc': { futureKey: true, options: { hooks_enabled: false, hook_profile: 'minimal', unknown: 'keep' }, }, }, })); migrateClaudePluginScope(migrationOptions(fixture, 'project', { hooks: 'strict' })); const settings = JSON.parse(fs.readFileSync(fixture.settingsPath, 'utf8')); assert.strictEqual(settings.theme, 'dark'); assert.strictEqual(settings.includeCoAuthoredBy, false); assert.strictEqual(settings.pluginConfigs['ecc@ecc'].futureKey, true); assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.unknown, 'keep'); assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hooks_enabled, true); assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hook_profile, 'strict'); }); }); test('migration preserves an explicit includeCoAuthoredBy opt-in', () => { withFixture({ plugins: [plugin('user')], marketplaces: [marketplace('user')], }, fixture => { fs.writeFileSync(fixture.settingsPath, `${JSON.stringify({ includeCoAuthoredBy: true, pluginConfigs: { 'ecc@ecc': { options: { hooks_enabled: true, hook_profile: 'minimal' }, }, }, }, null, 2)}\n`); migrateClaudePluginScope(migrationOptions(fixture, 'project', { hooks: 'strict' })); const settings = JSON.parse(fs.readFileSync(fixture.settingsPath, 'utf8')); assert.strictEqual(settings.includeCoAuthoredBy, true); assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hook_profile, 'strict'); }); }); console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0);