Files
ECC/scripts/lib/claude-scope-migration.js
T
28e53a0bc1 feat(install): add guided multi-harness installer (#2649)
* feat(install): add guided Claude plugin setup

* fix: support Claude command shims on Windows

* feat: support safe Claude plugin scope migration

* fix(install): preserve interactive setup terminal

* fix(install): auto-migrate setup scope changes

* feat(install): add guided multi-harness installer

* fix(install): sync Yarn binary metadata

* fix(install): handle wizard EOF on Node 18

* ci: allow installer matrix tests to finish

* test(install): allow slower PowerShell delegation

* fix(install): harden guided provider reconciliation

* test(install): harden packaged and local compatibility

* chore: prepare guided installer release 2.2.0

* fix(install): report refreshed Codex marketplace state

* fix(install): verify managed content provenance

* test(install): allow empty Yarn smoke fixture

* test(install): invoke Windows package shims safely

* fix(install): close cross-platform release gaps

* fix(install): require trusted GitHub origins

* fix(install): preserve hook profile precedence

* refactor(install): centralize trusted GitHub origins

* ci: retrigger workflow run after merge of main

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 15:39:49 -04:00

391 lines
11 KiB
JavaScript

'use strict';
const path = require('path');
const {
ClaudeSetupError,
CURRENT_PLUGIN_ID,
OFFICIAL_MARKETPLACE_URL,
VALID_HOOK_MODES,
VALID_SCOPES,
assertNoConflictingEccPlugins,
assertSafeLocalInventory,
currentEccPlugins,
deriveHookMode,
ensureOfficialMarketplace,
ensurePluginAtScope,
hookOptions,
isOfficialMarketplace,
parseMarketplaceList,
parsePluginList,
readSettings,
readStoredHookOptions,
runClaude,
writeClaudePluginOptions,
} = require('./claude-plugin-setup');
const { resolveClaudePaths } = require('./install/inventory');
function migrationError(code, message, details = {}) {
return new ClaudeSetupError(code, message, details);
}
function recoveryCommands(sourceScope, destinationScope) {
const commands = [];
if (sourceScope) {
commands.push(
`claude plugin uninstall ${CURRENT_PLUGIN_ID} --scope ${sourceScope} --keep-data`
);
}
commands.push(
`ecc setup --mode claude-plugin --scope ${destinationScope} --move-scope --yes`
);
return commands;
}
function readPluginInventory(run, projectRoot, phase) {
return parsePluginList(
run(
['plugin', 'list', '--json'],
{ cwd: projectRoot, phase }
).stdout
);
}
function assertMigrationInventory(plugins, destinationScope) {
assertNoConflictingEccPlugins(plugins);
const installed = currentEccPlugins(plugins);
const observedScopes = installed.map(plugin => plugin.scope);
const uniqueScopes = new Set(observedScopes);
if (installed.length === 0) {
throw migrationError(
'PLUGIN_NOT_INSTALLED',
`${CURRENT_PLUGIN_ID} is not installed, so there is no source scope to migrate.`,
{
observedScopes,
recovery: [
`ecc setup --mode claude-plugin --scope ${destinationScope} --yes`,
],
}
);
}
if (
installed.length > 2
|| uniqueScopes.size !== installed.length
|| (
installed.length === 2
&& !uniqueScopes.has(destinationScope)
)
) {
throw migrationError(
'AMBIGUOUS_PLUGIN_SCOPES',
`Cannot safely migrate ${CURRENT_PLUGIN_ID} from ambiguous scopes: ${observedScopes.join(', ')}.`,
{ observedScopes }
);
}
if (installed.length === 1 && installed[0].scope === destinationScope) {
if (installed[0].enabled !== true) {
throw migrationError(
'DESTINATION_VERIFICATION_FAILED',
`${CURRENT_PLUGIN_ID} exists at ${destinationScope} scope but is not enabled.`,
{
phase: 'destination-verification',
observedScopes,
recovery: recoveryCommands(null, destinationScope),
}
);
}
return {
destination: installed[0],
mode: 'already-migrated',
observedScopes,
sourceScope: null,
};
}
if (installed.length === 1) {
return {
destination: null,
mode: 'migrate',
observedScopes,
sourceScope: installed[0].scope,
};
}
return {
destination: installed.find(plugin => plugin.scope === destinationScope),
mode: 'resume',
observedScopes,
sourceScope: installed.find(plugin => plugin.scope !== destinationScope).scope,
};
}
function validateExpectedScopes(plugins, expectedScopes, options = {}) {
assertNoConflictingEccPlugins(plugins);
const installed = currentEccPlugins(plugins);
const observedScopes = installed.map(plugin => plugin.scope);
const actual = [...observedScopes].sort();
const expected = [...expectedScopes].sort();
const destination = installed.find(plugin => plugin.scope === options.destinationScope);
const matches = (
actual.length === expected.length
&& actual.every((scope, index) => scope === expected[index])
&& destination?.enabled === true
);
if (!matches) {
throw migrationError(
options.code,
options.message,
{
phase: options.phase,
observedScopes,
recovery: options.recovery || [],
}
);
}
return installed;
}
function plannedActions(migration, destinationScope, marketplaceAction, hookConfiguration) {
const actions = [];
if (migration.mode === 'migrate') {
actions.push(marketplaceAction);
actions.push([
'plugin', 'install', CURRENT_PLUGIN_ID,
'--scope', destinationScope,
'--config', `hooks_enabled=${hookConfiguration.hooks_enabled}`,
'--config', `hook_profile=${hookConfiguration.hook_profile}`,
]);
}
actions.push(['plugin', 'list', '--json']);
actions.push(['plugin', 'list', '--json']);
actions.push([
'plugin', 'uninstall', CURRENT_PLUGIN_ID,
'--scope', migration.sourceScope,
'--keep-data',
]);
actions.push(['plugin', 'list', '--json']);
return actions;
}
function verifySourceAndDestination(run, paths, migration, destinationScope, phase) {
const expectedScopes = [migration.sourceScope, destinationScope];
return validateExpectedScopes(
readPluginInventory(run, paths.projectRoot, phase),
expectedScopes,
{
code: phase === 'concurrency-check'
? 'CONCURRENT_SCOPE_CHANGE'
: 'DESTINATION_VERIFICATION_FAILED',
destinationScope,
message: phase === 'concurrency-check'
? 'Claude plugin scopes changed during migration; the source was not removed.'
: `Could not verify ${CURRENT_PLUGIN_ID} at the destination before source cleanup.`,
phase,
recovery: recoveryCommands(null, destinationScope),
}
);
}
function uninstallSource(run, paths, migration, destinationScope) {
const args = [
'plugin', 'uninstall', CURRENT_PLUGIN_ID,
'--scope', migration.sourceScope,
'--keep-data',
];
try {
run(args, { cwd: paths.projectRoot, phase: 'source-uninstall' });
return [];
} catch {
let observedScopes = [migration.sourceScope, destinationScope];
try {
const plugins = readPluginInventory(
run,
paths.projectRoot,
'source-uninstall-verification'
);
assertNoConflictingEccPlugins(plugins);
const installed = currentEccPlugins(plugins);
observedScopes = installed.map(plugin => plugin.scope);
if (
installed.length === 1
&& installed[0].scope === destinationScope
&& installed[0].enabled === true
) {
return ['Claude reported an uninstall error, but destination-only state was verified.'];
}
} catch {
// Preserve the safest known two-scope state in the structured recovery.
}
throw migrationError(
'SOURCE_UNINSTALL_FAILED',
`The destination is installed, but Claude could not remove the ${migration.sourceScope} source scope.`,
{
phase: 'source-uninstall',
observedScopes,
recovery: recoveryCommands(migration.sourceScope, destinationScope),
}
);
}
}
function verifyFinalState(run, paths, destinationScope) {
const plugins = readPluginInventory(run, paths.projectRoot, 'final-verification');
return validateExpectedScopes(plugins, [destinationScope], {
code: 'FINAL_VERIFICATION_FAILED',
destinationScope,
message: `Could not verify destination-only ${CURRENT_PLUGIN_ID} state after source cleanup.`,
phase: 'final-verification',
recovery: recoveryCommands(null, destinationScope),
});
}
function migrateClaudePluginScope(options = {}, dependencies = {}) {
if (!VALID_SCOPES.has(options.scope)) {
throw migrationError(
'INVALID_SCOPE',
'Scope migration requires --scope user, project, or local.'
);
}
if (options.hooks !== undefined && !VALID_HOOK_MODES.has(options.hooks)) {
throw migrationError('INVALID_HOOK_MODE', `Invalid hook mode: ${options.hooks}`);
}
const paths = resolveClaudePaths(options);
const settingsPath = path.join(paths.configDir, 'settings.json');
const settings = readSettings(settingsPath);
assertSafeLocalInventory(paths);
const run = dependencies.runClaude || runClaude;
const plugins = readPluginInventory(run, paths.projectRoot, 'inventory');
const migration = assertMigrationInventory(plugins, options.scope);
const hooks = options.hooks === undefined
? deriveHookMode(settings)
: options.hooks;
const hookConfiguration = options.hooks === undefined
? readStoredHookOptions(settings)
: hookOptions(options.hooks);
const marketplaces = parseMarketplaceList(
run(
['plugin', 'marketplace', 'list', '--json'],
{ cwd: paths.projectRoot, phase: 'marketplace-inventory' }
).stdout
);
const namedMarketplace = marketplaces.find(entry => entry?.name === 'ecc');
if (namedMarketplace && !isOfficialMarketplace(namedMarketplace)) {
throw migrationError(
'MARKETPLACE_COLLISION',
'Refusing the `ecc` marketplace collision because it is not the official affaan-m/ECC source.',
{
phase: 'marketplace-inventory',
observedScopes: migration.observedScopes,
}
);
}
if (migration.mode === 'already-migrated') {
const result = {
action: 'already-migrated',
hooks,
pluginId: CURRENT_PLUGIN_ID,
sourceScope: null,
scope: options.scope,
};
if (options.dryRun) {
return {
...result,
dryRun: true,
preferencesUpdated: false,
plannedActions: options.hooks === undefined ? [] : [{
action: 'write-hook-preferences',
...hookConfiguration,
}],
};
}
if (options.hooks !== undefined) {
writeClaudePluginOptions(settingsPath, options.hooks);
return { ...result, preferencesUpdated: true };
}
return result;
}
let marketplaceAction = null;
if (migration.mode === 'migrate') {
marketplaceAction = namedMarketplace
? ['plugin', 'marketplace', 'update', 'ecc']
: [
'plugin', 'marketplace', 'add',
OFFICIAL_MARKETPLACE_URL,
'--scope', options.scope,
];
}
if (options.dryRun) {
return {
action: migration.mode === 'resume' ? 'would-resume' : 'would-migrate',
dryRun: true,
hooks,
plannedActions: plannedActions(
migration,
options.scope,
marketplaceAction,
hookConfiguration
),
pluginId: CURRENT_PLUGIN_ID,
sourceScope: migration.sourceScope,
scope: options.scope,
};
}
if (migration.mode === 'migrate') {
ensureOfficialMarketplace({
marketplaces,
projectRoot: paths.projectRoot,
run,
scope: options.scope,
});
ensurePluginAtScope({
hookConfiguration,
hooks,
installed: false,
projectRoot: paths.projectRoot,
run,
scope: options.scope,
});
}
verifySourceAndDestination(
run,
paths,
migration,
options.scope,
'destination-verification'
);
verifySourceAndDestination(
run,
paths,
migration,
options.scope,
'concurrency-check'
);
const warnings = uninstallSource(run, paths, migration, options.scope);
verifyFinalState(run, paths, options.scope);
if (options.hooks !== undefined) {
writeClaudePluginOptions(settingsPath, options.hooks);
}
const result = {
action: migration.mode === 'resume' ? 'resumed' : 'migrated',
hooks,
pluginId: CURRENT_PLUGIN_ID,
sourceScope: migration.sourceScope,
scope: options.scope,
};
return warnings.length > 0 ? { ...result, warnings } : result;
}
module.exports = {
migrateClaudePluginScope,
};