mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-17 21:15:40 +02:00
* 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>
479 lines
14 KiB
JavaScript
479 lines
14 KiB
JavaScript
'use strict';
|
|
|
|
const { execFile: nodeExecFile } = require('child_process');
|
|
const path = require('path');
|
|
const { normalizeGitHubGitOrigin } = require('./github-origin');
|
|
|
|
const CODEX_PLUGIN_ID = 'ecc@ecc';
|
|
const OFFICIAL_MARKETPLACE_NAME = 'ecc';
|
|
const OFFICIAL_MARKETPLACE_REPO = 'affaan-m/ECC';
|
|
const NORMALIZED_OFFICIAL_MARKETPLACE_REPO = OFFICIAL_MARKETPLACE_REPO.toLowerCase();
|
|
const MAX_OUTPUT_BYTES = 10 * 1024 * 1024;
|
|
const PROVIDER_COMMAND_TIMEOUT_MS = 120 * 1000;
|
|
|
|
class CodexPluginSetupError extends Error {
|
|
constructor(code, message, details = {}) {
|
|
super(message);
|
|
this.name = 'CodexPluginSetupError';
|
|
this.code = code;
|
|
this.phase = details.phase || 'inventory';
|
|
this.argv = [...(details.argv || [])];
|
|
}
|
|
}
|
|
|
|
function fail(code, message, details) {
|
|
throw new CodexPluginSetupError(code, message, details);
|
|
}
|
|
|
|
function parseJsonObject(stdout, inventoryName, phase = 'inventory') {
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(String(stdout || ''));
|
|
} catch (error) {
|
|
fail(
|
|
`INVALID_${inventoryName.toUpperCase()}_INVENTORY`,
|
|
`Codex ${inventoryName} inventory returned invalid JSON: ${error.message}`,
|
|
{ phase }
|
|
);
|
|
}
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
fail(
|
|
`INVALID_${inventoryName.toUpperCase()}_INVENTORY`,
|
|
`Codex ${inventoryName} inventory is invalid: expected a JSON object`,
|
|
{ phase }
|
|
);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function parseMarketplaceInventory(stdout, phase) {
|
|
const inventory = parseJsonObject(stdout, 'marketplace', phase);
|
|
if (!Array.isArray(inventory.marketplaces)) {
|
|
fail(
|
|
'INVALID_MARKETPLACE_INVENTORY',
|
|
'Codex marketplace inventory is invalid: expected `marketplaces` to be an array',
|
|
{ phase }
|
|
);
|
|
}
|
|
for (const marketplace of inventory.marketplaces) {
|
|
if (
|
|
!marketplace
|
|
|| typeof marketplace.name !== 'string'
|
|
|| marketplace.name.length === 0
|
|
|| typeof marketplace.root !== 'string'
|
|
|| marketplace.root.length === 0
|
|
) {
|
|
fail(
|
|
'INVALID_MARKETPLACE_INVENTORY',
|
|
'Codex marketplace inventory contains an invalid marketplace entry',
|
|
{ phase }
|
|
);
|
|
}
|
|
}
|
|
const eccEntries = inventory.marketplaces.filter(
|
|
marketplace => marketplace.name === OFFICIAL_MARKETPLACE_NAME
|
|
);
|
|
if (eccEntries.length > 1) {
|
|
fail(
|
|
'INVALID_MARKETPLACE_INVENTORY',
|
|
'Codex marketplace inventory contains duplicate `ecc` entries',
|
|
{ phase }
|
|
);
|
|
}
|
|
return inventory.marketplaces;
|
|
}
|
|
|
|
function assertPluginEntries(entries, field, phase) {
|
|
if (!Array.isArray(entries)) {
|
|
fail(
|
|
'INVALID_PLUGIN_INVENTORY',
|
|
`Codex plugin inventory is invalid: expected \`${field}\` to be an array`,
|
|
{ phase }
|
|
);
|
|
}
|
|
for (const plugin of entries) {
|
|
if (
|
|
!plugin
|
|
|| typeof plugin.pluginId !== 'string'
|
|
|| plugin.pluginId.length === 0
|
|
) {
|
|
fail(
|
|
'INVALID_PLUGIN_INVENTORY',
|
|
`Codex plugin inventory contains an invalid \`${field}\` entry`,
|
|
{ phase }
|
|
);
|
|
}
|
|
if (plugin.installed !== undefined && typeof plugin.installed !== 'boolean') {
|
|
fail(
|
|
'INVALID_PLUGIN_INVENTORY',
|
|
`Codex plugin inventory contains an invalid \`${field}\` install state`,
|
|
{ phase }
|
|
);
|
|
}
|
|
if (plugin.enabled !== undefined && typeof plugin.enabled !== 'boolean') {
|
|
fail(
|
|
'INVALID_PLUGIN_INVENTORY',
|
|
`Codex plugin inventory contains an invalid \`${field}\` enabled state`,
|
|
{ phase }
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function parsePluginInventory(stdout, phase) {
|
|
const inventory = parseJsonObject(stdout, 'plugin', phase);
|
|
assertPluginEntries(inventory.installed, 'installed', phase);
|
|
assertPluginEntries(inventory.available, 'available', phase);
|
|
const eccEntries = inventory.installed.filter(
|
|
plugin => plugin.pluginId === CODEX_PLUGIN_ID
|
|
);
|
|
if (eccEntries.length > 1) {
|
|
fail(
|
|
'INVALID_PLUGIN_INVENTORY',
|
|
`Codex plugin inventory contains duplicate ${CODEX_PLUGIN_ID} entries`,
|
|
{ phase }
|
|
);
|
|
}
|
|
return {
|
|
installed: [...inventory.installed],
|
|
available: [...inventory.available],
|
|
};
|
|
}
|
|
|
|
function executeFile(execFile, command, args, options) {
|
|
return new Promise((resolve, reject) => {
|
|
execFile(command, args, options, (error, stdout, stderr) => {
|
|
if (error) {
|
|
if (error.stderr === undefined) error.stderr = stderr;
|
|
if (error.stdout === undefined) error.stdout = stdout;
|
|
reject(error);
|
|
return;
|
|
}
|
|
resolve({ stdout: String(stdout || ''), stderr: String(stderr || '') });
|
|
});
|
|
});
|
|
}
|
|
|
|
function isCommandTimeout(error, killSignal = 'SIGKILL') {
|
|
return error?.code === 'ETIMEDOUT'
|
|
|| (error?.killed === true && error?.signal === killSignal);
|
|
}
|
|
|
|
async function runCodexCommand(args, options = {}, dependencies = {}) {
|
|
const command = dependencies.command || options.command || 'codex';
|
|
const execFile = dependencies.execFile || nodeExecFile;
|
|
const argv = [...args];
|
|
const timeoutMs = options.timeoutMs ?? PROVIDER_COMMAND_TIMEOUT_MS;
|
|
const killSignal = 'SIGKILL';
|
|
try {
|
|
return await executeFile(execFile, command, argv, {
|
|
cwd: options.cwd || process.cwd(),
|
|
encoding: 'utf8',
|
|
env: options.env || process.env,
|
|
maxBuffer: MAX_OUTPUT_BYTES,
|
|
killSignal,
|
|
shell: false,
|
|
timeout: timeoutMs,
|
|
windowsHide: true,
|
|
});
|
|
} catch (error) {
|
|
if (isCommandTimeout(error, killSignal)) {
|
|
fail(
|
|
'CODEX_COMMAND_TIMEOUT',
|
|
`Codex command timed out after ${timeoutMs} ms`,
|
|
{ argv, phase: options.phase }
|
|
);
|
|
}
|
|
if (error?.code === 'ENOENT') {
|
|
fail(
|
|
'CODEX_NOT_FOUND',
|
|
'Codex CLI is not installed or `codex` is not on PATH. Install Codex, then rerun ECC setup.',
|
|
{ argv, phase: options.phase }
|
|
);
|
|
}
|
|
const detail = String(error?.stderr || error?.stdout || error?.message || '').trim();
|
|
fail(
|
|
'CODEX_COMMAND_FAILED',
|
|
`Codex command failed${detail ? `: ${detail}` : ''}`,
|
|
{ argv, phase: options.phase }
|
|
);
|
|
}
|
|
}
|
|
|
|
async function resolveMarketplaceRepository(marketplace, options = {}, dependencies = {}) {
|
|
const execFile = dependencies.execFile || nodeExecFile;
|
|
const timeoutMs = options.timeoutMs ?? PROVIDER_COMMAND_TIMEOUT_MS;
|
|
const killSignal = 'SIGKILL';
|
|
let result;
|
|
try {
|
|
result = await executeFile(
|
|
execFile,
|
|
dependencies.gitCommand || 'git',
|
|
['-C', marketplace.root, 'remote', 'get-url', 'origin'],
|
|
{
|
|
cwd: options.cwd || process.cwd(),
|
|
encoding: 'utf8',
|
|
env: options.env || process.env,
|
|
maxBuffer: MAX_OUTPUT_BYTES,
|
|
killSignal,
|
|
shell: false,
|
|
timeout: timeoutMs,
|
|
windowsHide: true,
|
|
}
|
|
);
|
|
} catch (error) {
|
|
if (isCommandTimeout(error, killSignal)) {
|
|
fail(
|
|
'MARKETPLACE_PROVENANCE_TIMEOUT',
|
|
`Git provenance verification timed out after ${timeoutMs} ms`,
|
|
{ phase: options.phase || 'marketplace-provenance' }
|
|
);
|
|
}
|
|
const detail = String(error?.stderr || error?.message || '').trim();
|
|
fail(
|
|
'MARKETPLACE_COLLISION',
|
|
`Refusing the existing \`ecc\` marketplace because its Git provenance could not be verified${detail ? `: ${detail}` : ''}.`,
|
|
{ phase: options.phase || 'marketplace-provenance' }
|
|
);
|
|
}
|
|
return String(result.stdout || '').trim();
|
|
}
|
|
|
|
async function assertOfficialMarketplace(
|
|
marketplace,
|
|
options,
|
|
dependencies,
|
|
phase = 'marketplace-provenance'
|
|
) {
|
|
if (!marketplace) return;
|
|
const resolveRepository = dependencies.resolveMarketplaceRepository
|
|
|| (entry => resolveMarketplaceRepository(
|
|
entry,
|
|
{ ...options, phase },
|
|
dependencies
|
|
));
|
|
let repository;
|
|
try {
|
|
repository = normalizeGitHubGitOrigin(await resolveRepository(marketplace));
|
|
} catch (error) {
|
|
if (error instanceof CodexPluginSetupError) throw error;
|
|
const detail = String(error?.message || error || '').trim();
|
|
fail(
|
|
'MARKETPLACE_COLLISION',
|
|
`Refusing the existing \`ecc\` marketplace because its provenance could not be verified${detail ? `: ${detail}` : ''}.`,
|
|
{ phase }
|
|
);
|
|
}
|
|
if (repository !== NORMALIZED_OFFICIAL_MARKETPLACE_REPO) {
|
|
fail(
|
|
'MARKETPLACE_COLLISION',
|
|
'Refusing the existing `ecc` marketplace because it is not the official affaan-m/ECC source.',
|
|
{ phase }
|
|
);
|
|
}
|
|
}
|
|
|
|
function normalizeMarketplaceRoot(value) {
|
|
if (typeof value !== 'string' || value.length === 0) return null;
|
|
const isWindowsPath = /^[a-z]:[\\/]/i.test(value) || /^\\\\/.test(value);
|
|
const normalized = isWindowsPath
|
|
? path.win32.normalize(value)
|
|
: path.posix.normalize(value);
|
|
return isWindowsPath ? normalized.toLowerCase() : normalized;
|
|
}
|
|
|
|
function parseMarketplaceUpgradeResult(stdout, marketplace) {
|
|
const phase = 'marketplace-upgrade';
|
|
const argv = [
|
|
'plugin', 'marketplace', 'upgrade', OFFICIAL_MARKETPLACE_NAME, '--json',
|
|
];
|
|
let result;
|
|
try {
|
|
result = JSON.parse(String(stdout || ''));
|
|
} catch (error) {
|
|
fail(
|
|
'INVALID_MARKETPLACE_UPGRADE_RESULT',
|
|
`Codex marketplace refresh returned invalid JSON: ${error.message}`,
|
|
{ phase, argv }
|
|
);
|
|
}
|
|
const validShape = (
|
|
result
|
|
&& typeof result === 'object'
|
|
&& !Array.isArray(result)
|
|
&& Array.isArray(result.selectedMarketplaces)
|
|
&& result.selectedMarketplaces.every(name => typeof name === 'string')
|
|
&& Array.isArray(result.upgradedRoots)
|
|
&& result.upgradedRoots.every(root => typeof root === 'string' && root.length > 0)
|
|
&& Array.isArray(result.errors)
|
|
);
|
|
if (!validShape) {
|
|
fail(
|
|
'INVALID_MARKETPLACE_UPGRADE_RESULT',
|
|
'Codex marketplace refresh returned an invalid result.',
|
|
{ phase, argv }
|
|
);
|
|
}
|
|
const expectedRoot = normalizeMarketplaceRoot(marketplace.root);
|
|
const upgradedRoot = result.upgradedRoots.length === 1
|
|
? normalizeMarketplaceRoot(result.upgradedRoots[0])
|
|
: null;
|
|
if (
|
|
result.errors.length > 0
|
|
|| result.selectedMarketplaces.length !== 1
|
|
|| result.selectedMarketplaces[0] !== OFFICIAL_MARKETPLACE_NAME
|
|
|| upgradedRoot !== expectedRoot
|
|
) {
|
|
fail(
|
|
'MARKETPLACE_REFRESH_FAILED',
|
|
'Codex did not confirm that the official ECC marketplace was refreshed.',
|
|
{ phase, argv }
|
|
);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function findEccMarketplace(marketplaces) {
|
|
return marketplaces.find(
|
|
marketplace => marketplace.name === OFFICIAL_MARKETPLACE_NAME
|
|
) || null;
|
|
}
|
|
|
|
function findInstalledEccPlugin(inventory) {
|
|
return inventory.installed.find(
|
|
plugin => plugin.pluginId === CODEX_PLUGIN_ID
|
|
) || null;
|
|
}
|
|
|
|
async function readMarketplaceInventory(run, phase) {
|
|
const result = await run(
|
|
['plugin', 'marketplace', 'list', '--json'],
|
|
{ phase }
|
|
);
|
|
return parseMarketplaceInventory(result.stdout, phase);
|
|
}
|
|
|
|
async function readPluginInventory(run, phase) {
|
|
const result = await run(['plugin', 'list', '--json'], { phase });
|
|
return parsePluginInventory(result.stdout, phase);
|
|
}
|
|
|
|
async function reconcileCodexPlugin(options = {}, dependencies = {}) {
|
|
const run = (args, details = {}) => runCodexCommand(
|
|
args,
|
|
{
|
|
command: options.command,
|
|
cwd: options.cwd,
|
|
env: options.env,
|
|
phase: details.phase,
|
|
},
|
|
dependencies
|
|
);
|
|
const marketplaces = await readMarketplaceInventory(run, 'marketplace-inventory');
|
|
const plugins = await readPluginInventory(run, 'plugin-inventory');
|
|
const marketplace = findEccMarketplace(marketplaces);
|
|
const installedPlugin = findInstalledEccPlugin(plugins);
|
|
await assertOfficialMarketplace(marketplace, options, dependencies);
|
|
const pluginReady = (
|
|
installedPlugin?.installed === true
|
|
&& installedPlugin.enabled === true
|
|
);
|
|
const isReconciled = Boolean(marketplace && pluginReady);
|
|
|
|
if (options.dryRun) {
|
|
return {
|
|
action: isReconciled
|
|
? 'unchanged'
|
|
: (installedPlugin ? 'would-update' : 'would-install'),
|
|
dryRun: true,
|
|
marketplaceAction: marketplace
|
|
? 'would-upgrade'
|
|
: 'would-add',
|
|
pluginId: CODEX_PLUGIN_ID,
|
|
restartRequired: !isReconciled,
|
|
};
|
|
}
|
|
|
|
const marketplaceArgs = marketplace
|
|
? ['plugin', 'marketplace', 'upgrade', OFFICIAL_MARKETPLACE_NAME, '--json']
|
|
: ['plugin', 'marketplace', 'add', OFFICIAL_MARKETPLACE_REPO, '--json'];
|
|
const marketplaceAction = marketplace ? 'upgraded' : 'added';
|
|
const marketplaceResult = await run(marketplaceArgs, {
|
|
phase: marketplace ? 'marketplace-upgrade' : 'marketplace-add',
|
|
});
|
|
if (marketplace) {
|
|
parseMarketplaceUpgradeResult(marketplaceResult.stdout, marketplace);
|
|
}
|
|
|
|
const verifiedMarketplaces = await readMarketplaceInventory(
|
|
run,
|
|
'marketplace-verification'
|
|
);
|
|
if (!findEccMarketplace(verifiedMarketplaces)) {
|
|
fail(
|
|
'MARKETPLACE_VERIFICATION_FAILED',
|
|
'Could not verify the ECC marketplace after reconciliation.',
|
|
{ phase: 'marketplace-verification' }
|
|
);
|
|
}
|
|
await assertOfficialMarketplace(
|
|
findEccMarketplace(verifiedMarketplaces),
|
|
options,
|
|
dependencies,
|
|
'marketplace-verification'
|
|
);
|
|
|
|
const pluginsAfterMarketplace = marketplace
|
|
? await readPluginInventory(run, 'plugin-verification')
|
|
: plugins;
|
|
const pluginAfterMarketplace = findInstalledEccPlugin(pluginsAfterMarketplace);
|
|
const pluginReadyAfterMarketplace = (
|
|
pluginAfterMarketplace?.installed === true
|
|
&& pluginAfterMarketplace.enabled === true
|
|
);
|
|
|
|
if (!pluginReadyAfterMarketplace) {
|
|
await run(
|
|
['plugin', 'add', CODEX_PLUGIN_ID, '--json'],
|
|
{ phase: 'plugin-add' }
|
|
);
|
|
}
|
|
|
|
const verifiedPlugins = pluginReadyAfterMarketplace
|
|
? pluginsAfterMarketplace
|
|
: await readPluginInventory(run, 'plugin-verification');
|
|
const verifiedPlugin = findInstalledEccPlugin(verifiedPlugins);
|
|
if (!(verifiedPlugin?.installed === true && verifiedPlugin.enabled === true)) {
|
|
fail(
|
|
'PLUGIN_VERIFICATION_FAILED',
|
|
`Could not verify ${CODEX_PLUGIN_ID} as installed and enabled after reconciliation.`,
|
|
{ phase: 'plugin-verification' }
|
|
);
|
|
}
|
|
|
|
return {
|
|
action: installedPlugin ? 'updated' : 'installed',
|
|
marketplaceAction,
|
|
pluginId: CODEX_PLUGIN_ID,
|
|
restartRequired: marketplaceAction === 'upgraded' || !pluginReadyAfterMarketplace,
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
CODEX_PLUGIN_ID,
|
|
CodexPluginSetupError,
|
|
OFFICIAL_MARKETPLACE_NAME,
|
|
OFFICIAL_MARKETPLACE_REPO,
|
|
PROVIDER_COMMAND_TIMEOUT_MS,
|
|
executeFile,
|
|
findEccMarketplace,
|
|
findInstalledEccPlugin,
|
|
normalizeGitHubGitOrigin,
|
|
parseMarketplaceInventory,
|
|
parseMarketplaceUpgradeResult,
|
|
parsePluginInventory,
|
|
reconcileCodexPlugin,
|
|
resolveMarketplaceRepository,
|
|
runCodexCommand,
|
|
};
|