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>
347 lines
11 KiB
JavaScript
347 lines
11 KiB
JavaScript
/**
|
|
* Published npm binary aliases for the primary ECC CLI.
|
|
*
|
|
* The CI matrix sets CLAUDE_CODE_PACKAGE_MANAGER. Each lane must execute the
|
|
* packed artifact through its own package runner instead of silently falling
|
|
* back to npx.
|
|
*/
|
|
|
|
const assert = require('assert');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { spawnSync } = require('child_process');
|
|
|
|
const repoRoot = path.join(__dirname, '..', '..');
|
|
const packageJson = JSON.parse(
|
|
fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')
|
|
);
|
|
const packageLock = JSON.parse(
|
|
fs.readFileSync(path.join(repoRoot, 'package-lock.json'), 'utf8')
|
|
);
|
|
const activePackageManager = process.env.CLAUDE_CODE_PACKAGE_MANAGER || 'npm';
|
|
const supportedPackageManagers = new Set(['npm', 'pnpm', 'yarn', 'bun']);
|
|
const windowsPackageCommands = new Set([
|
|
'bun',
|
|
'bunx',
|
|
'npm',
|
|
'npx',
|
|
'pnpm',
|
|
'yarn',
|
|
]);
|
|
const unsafeWindowsShellChars = /[\r\n"&|<>^%!()]/;
|
|
const commandTimeoutMs = 90_000;
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
let packedFixture;
|
|
let localPackedProject;
|
|
|
|
function test(name, fn) {
|
|
try {
|
|
fn();
|
|
console.log(` ✓ ${name}`);
|
|
passed += 1;
|
|
} catch (error) {
|
|
console.log(` ✗ ${name}`);
|
|
console.log(` Error: ${error.message}`);
|
|
failed += 1;
|
|
}
|
|
}
|
|
|
|
function quoteWindowsCommandToken(value) {
|
|
const token = String(value);
|
|
assert.doesNotMatch(
|
|
token,
|
|
unsafeWindowsShellChars,
|
|
'Package command contains characters that are unsafe for cmd.exe'
|
|
);
|
|
if (token === '') return '""';
|
|
return /\s/.test(token) ? `"${token}"` : token;
|
|
}
|
|
|
|
function getSpawnInvocation(command, args, platform = process.platform) {
|
|
if (platform !== 'win32' || !windowsPackageCommands.has(command)) {
|
|
return { args, command };
|
|
}
|
|
|
|
// Node 18.20+/20.12+ refuse to spawn .cmd files directly after the
|
|
// CVE-2024-27980 mitigation. Build one validated command line so cmd.exe
|
|
// preserves path arguments containing spaces instead of re-splitting them.
|
|
return {
|
|
args: undefined,
|
|
command: [`${command}.cmd`, ...args]
|
|
.map(quoteWindowsCommandToken)
|
|
.join(' '),
|
|
shell: true,
|
|
};
|
|
}
|
|
|
|
function withPathPrefix(environment, prefix) {
|
|
const nextEnvironment = { ...environment };
|
|
const pathKey = Object.keys(nextEnvironment)
|
|
.find(key => key.toLowerCase() === 'path') || 'PATH';
|
|
nextEnvironment[pathKey] = [prefix, nextEnvironment[pathKey]]
|
|
.filter(Boolean)
|
|
.join(path.delimiter);
|
|
return nextEnvironment;
|
|
}
|
|
|
|
function run(command, args, options = {}) {
|
|
const invocation = getSpawnInvocation(command, args);
|
|
const result = spawnSync(invocation.command, invocation.args, {
|
|
cwd: options.cwd || repoRoot,
|
|
encoding: 'utf8',
|
|
env: options.env || process.env,
|
|
maxBuffer: 10 * 1024 * 1024,
|
|
shell: invocation.shell || false,
|
|
timeout: commandTimeoutMs,
|
|
windowsHide: true,
|
|
});
|
|
|
|
assert.ifError(result.error);
|
|
assert.strictEqual(
|
|
result.status,
|
|
0,
|
|
[
|
|
`${command} ${args.join(' ')} exited with ${result.status}`,
|
|
result.stdout,
|
|
result.stderr,
|
|
].filter(Boolean).join('\n')
|
|
);
|
|
return result;
|
|
}
|
|
|
|
function getPackedFixture() {
|
|
if (packedFixture) {
|
|
return packedFixture;
|
|
}
|
|
|
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-universal-bin-'));
|
|
const packResult = run(
|
|
'npm',
|
|
['pack', '--json', '--ignore-scripts', '--pack-destination', directory]
|
|
);
|
|
const packOutput = JSON.parse(packResult.stdout);
|
|
const filename = packOutput[0]?.filename;
|
|
assert.ok(filename, 'npm pack should report the archive filename');
|
|
|
|
packedFixture = {
|
|
archivePath: path.join(directory, filename),
|
|
directory,
|
|
publishedPaths: new Set(
|
|
packOutput[0]?.files?.map(file => file.path) || []
|
|
),
|
|
};
|
|
return packedFixture;
|
|
}
|
|
|
|
function prepareLocalPackedProject(packageManager) {
|
|
if (localPackedProject) {
|
|
return localPackedProject;
|
|
}
|
|
|
|
const fixture = getPackedFixture();
|
|
const projectDirectory = path.join(fixture.directory, 'local-project');
|
|
const modulesDirectory = path.join(projectDirectory, 'node_modules');
|
|
const extractedDirectory = path.join(modulesDirectory, 'package');
|
|
const packageDirectory = path.join(modulesDirectory, 'ecc-universal');
|
|
const binDirectory = path.join(modulesDirectory, '.bin');
|
|
|
|
fs.mkdirSync(projectDirectory, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(projectDirectory, 'package.json'),
|
|
`${JSON.stringify({ name: 'ecc-packed-smoke', private: true }, null, 2)}\n`
|
|
);
|
|
if (packageManager === 'yarn') {
|
|
// This empty fixture has no dependencies. Generate only its local lockfile
|
|
// so `yarn exec` can run the manually unpacked package in PR hardened mode.
|
|
run('yarn', ['install', '--mode=skip-build', '--no-immutable'], {
|
|
cwd: projectDirectory,
|
|
env: {
|
|
...process.env,
|
|
YARN_ENABLE_HARDENED_MODE: '0',
|
|
YARN_ENABLE_IMMUTABLE_INSTALLS: 'false',
|
|
YARN_ENABLE_NETWORK: '0',
|
|
},
|
|
});
|
|
}
|
|
fs.mkdirSync(modulesDirectory, { recursive: true });
|
|
run('tar', ['-xzf', fixture.archivePath, '-C', modulesDirectory], {
|
|
cwd: projectDirectory,
|
|
});
|
|
fs.renameSync(extractedDirectory, packageDirectory);
|
|
fs.mkdirSync(binDirectory, { recursive: true });
|
|
|
|
for (const executable of ['ecc', 'ecc-universal']) {
|
|
const scriptPath = path.join(packageDirectory, packageJson.bin[executable]);
|
|
fs.chmodSync(scriptPath, 0o755);
|
|
if (process.platform === 'win32') {
|
|
const cmdPath = path.join(binDirectory, `${executable}.cmd`);
|
|
const target = packageJson.bin[executable].replace(/\//g, '\\');
|
|
fs.writeFileSync(
|
|
cmdPath,
|
|
`@ECHO off\r\nnode "%~dp0\\..\\ecc-universal\\${target}" %*\r\n`
|
|
);
|
|
} else {
|
|
fs.symlinkSync(
|
|
path.join('..', 'ecc-universal', packageJson.bin[executable]),
|
|
path.join(binDirectory, executable)
|
|
);
|
|
}
|
|
}
|
|
|
|
localPackedProject = { binDirectory, projectDirectory };
|
|
return localPackedProject;
|
|
}
|
|
|
|
function getRunnerInvocation(packageManager, executable, args) {
|
|
const project = prepareLocalPackedProject(packageManager);
|
|
const localEnvironment = withPathPrefix(process.env, project.binDirectory);
|
|
switch (packageManager) {
|
|
case 'npm':
|
|
{
|
|
// npx --offline --package=<local.tgz> still resolves uncached
|
|
// transitive dependencies from the registry. Unpack the artifact and
|
|
// invoke npm's local executable runner so CI proves the packaged bin
|
|
// without depending on registry cache state.
|
|
return {
|
|
command: 'npm',
|
|
args: [
|
|
'exec',
|
|
'--offline',
|
|
'--package=./node_modules/ecc-universal',
|
|
'--',
|
|
executable,
|
|
...args,
|
|
],
|
|
cwd: project.projectDirectory,
|
|
env: { ...localEnvironment, npm_config_offline: 'true' },
|
|
};
|
|
}
|
|
case 'pnpm':
|
|
return {
|
|
command: 'pnpm',
|
|
args: ['exec', executable, ...args],
|
|
cwd: project.projectDirectory,
|
|
env: { ...localEnvironment, npm_config_offline: 'true' },
|
|
};
|
|
case 'yarn':
|
|
{
|
|
// Yarn dlx resolves transitive package metadata from the registry even
|
|
// when the package tarball and dependency archives are cached. For a
|
|
// hermetic pre-publish gate, execute the exact unpacked artifact through
|
|
// Yarn's runner with network disabled. A post-publish dlx smoke test is
|
|
// still required to validate registry metadata.
|
|
return {
|
|
command: 'yarn',
|
|
args: ['exec', executable, ...args],
|
|
env: {
|
|
...localEnvironment,
|
|
YARN_ENABLE_NETWORK: '0',
|
|
YARN_ENABLE_HARDENED_MODE: '0',
|
|
},
|
|
cwd: project.projectDirectory,
|
|
};
|
|
}
|
|
case 'bun':
|
|
{
|
|
// bunx has no strict offline install mode. Unpack the exact artifact
|
|
// locally and use --no-install so the smoke cannot reach the registry.
|
|
return {
|
|
command: 'bunx',
|
|
args: ['--no-install', executable, ...args],
|
|
env: localEnvironment,
|
|
cwd: project.projectDirectory,
|
|
};
|
|
}
|
|
default:
|
|
throw new Error(`Unsupported package manager: ${packageManager}`);
|
|
}
|
|
}
|
|
|
|
function launchPackedBinary(executable, args) {
|
|
const fixture = getPackedFixture();
|
|
const invocation = getRunnerInvocation(
|
|
activePackageManager,
|
|
executable,
|
|
args
|
|
);
|
|
return run(invocation.command, invocation.args, {
|
|
cwd: invocation.cwd || fixture.directory,
|
|
env: invocation.env,
|
|
});
|
|
}
|
|
|
|
console.log(`\n=== ECC universal packed binary tests (${activePackageManager}) ===\n`);
|
|
|
|
test('CI selects a supported package runner', () => {
|
|
assert.ok(
|
|
supportedPackageManagers.has(activePackageManager),
|
|
`CLAUDE_CODE_PACKAGE_MANAGER must be one of ${[...supportedPackageManagers].join(', ')}`
|
|
);
|
|
});
|
|
|
|
test('Windows package shims use one safely quoted command line', () => {
|
|
assert.deepStrictEqual(
|
|
getSpawnInvocation('npm', ['pack', '--pack-destination', 'C:\\Temp Dir'], 'win32'),
|
|
{
|
|
args: undefined,
|
|
command: 'npm.cmd pack --pack-destination "C:\\Temp Dir"',
|
|
shell: true,
|
|
}
|
|
);
|
|
assert.deepStrictEqual(
|
|
getSpawnInvocation('tar', ['-xzf', 'C:\\Temp Dir\\fixture.tgz'], 'win32'),
|
|
{
|
|
args: ['-xzf', 'C:\\Temp Dir\\fixture.tgz'],
|
|
command: 'tar',
|
|
}
|
|
);
|
|
assert.throws(
|
|
() => getSpawnInvocation('npm', ['pack', 'C:\\Temp & unsafe'], 'win32'),
|
|
/unsafe for cmd\.exe/
|
|
);
|
|
});
|
|
|
|
test('published package exposes ecc and ecc-universal through scripts/ecc.js', () => {
|
|
assert.strictEqual(packageJson.bin.ecc, 'scripts/ecc.js');
|
|
assert.strictEqual(packageJson.bin['ecc-universal'], 'scripts/ecc.js');
|
|
assert.deepStrictEqual(packageLock.packages[''].bin, packageJson.bin);
|
|
|
|
const fixture = getPackedFixture();
|
|
assert.ok(
|
|
fixture.publishedPaths.has('scripts/ecc.js'),
|
|
'npm package should publish the shared CLI target'
|
|
);
|
|
});
|
|
|
|
test('packed ecc-universal launches the guided Claude setup help', () => {
|
|
const result = launchPackedBinary('ecc-universal', ['setup', '--help']);
|
|
assert.match(result.stdout, /ECC guided setup/);
|
|
});
|
|
|
|
test('packed ecc-universal launches the guided multi-harness help', () => {
|
|
const result = launchPackedBinary(
|
|
'ecc-universal',
|
|
['install', '--guided', '--help']
|
|
);
|
|
assert.match(result.stdout, /ECC guided multi-harness install/);
|
|
assert.match(result.stdout, /Claude Code/);
|
|
assert.match(result.stdout, /Codex/);
|
|
assert.match(result.stdout, /Kimi/);
|
|
});
|
|
|
|
test('packed ecc alias launches the primary dispatcher', () => {
|
|
const result = launchPackedBinary('ecc', ['--help']);
|
|
assert.match(result.stdout, /ECC selective-install CLI/);
|
|
assert.match(result.stdout, /ecc install --guided/);
|
|
});
|
|
|
|
if (packedFixture) {
|
|
fs.rmSync(packedFixture.directory, { force: true, recursive: true });
|
|
}
|
|
|
|
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
|
process.exit(failed > 0 ? 1 : 0);
|