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>
875 lines
30 KiB
JavaScript
875 lines
30 KiB
JavaScript
'use strict';
|
|
|
|
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 setupScript = path.join(repoRoot, 'scripts', 'setup.js');
|
|
const eccScript = path.join(repoRoot, 'scripts', 'ecc.js');
|
|
const fakeClaudeScript = path.join(repoRoot, 'tests', 'fixtures', 'fake-claude-plugin.js');
|
|
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.message}`);
|
|
failed += 1;
|
|
}
|
|
}
|
|
function createFixture(state = {}) {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc setup cli '));
|
|
const homeDir = path.join(root, 'home');
|
|
const configDir = path.join(root, 'config');
|
|
const projectRoot = path.join(root, 'project');
|
|
const binDir = path.join(root, 'bin');
|
|
const statePath = path.join(root, 'state.json');
|
|
const callsPath = path.join(root, 'calls.jsonl');
|
|
for (const dir of [homeDir, configDir, projectRoot, binDir]) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
fs.writeFileSync(statePath, `${JSON.stringify({
|
|
plugins: [],
|
|
marketplaces: [],
|
|
failures: [],
|
|
...state,
|
|
}, null, 2)}\n`);
|
|
const launcher = path.join(binDir, process.platform === 'win32' ? 'claude.cmd' : 'claude');
|
|
const source = process.platform === 'win32'
|
|
? `@echo off\r\n"${process.execPath}" "${fakeClaudeScript}" %*\r\n`
|
|
: `#!/bin/sh\nexec "${process.execPath}" "${fakeClaudeScript}" "$@"\n`;
|
|
fs.writeFileSync(launcher, source);
|
|
if (process.platform !== 'win32') fs.chmodSync(launcher, 0o755);
|
|
return {
|
|
root,
|
|
homeDir,
|
|
configDir,
|
|
projectRoot,
|
|
binDir,
|
|
statePath,
|
|
callsPath,
|
|
};
|
|
}
|
|
function runSetup(fixture, args) {
|
|
return spawnSync(process.execPath, [setupScript, ...args], {
|
|
cwd: fixture.projectRoot,
|
|
env: {
|
|
...process.env,
|
|
HOME: fixture.homeDir,
|
|
USERPROFILE: fixture.homeDir,
|
|
CLAUDE_CONFIG_DIR: fixture.configDir,
|
|
PATH: `${fixture.binDir}${path.delimiter}${process.env.PATH || ''}`,
|
|
ECC_TEST_CLAUDE_STATE: fixture.statePath,
|
|
ECC_TEST_CLAUDE_CALLS: fixture.callsPath,
|
|
},
|
|
encoding: 'utf8',
|
|
timeout: 15000,
|
|
});
|
|
}
|
|
function quoteShellArgument(value) {
|
|
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
}
|
|
function runInteractiveEccSetup(fixture, options = {}) {
|
|
if (process.platform === 'win32') {
|
|
return null;
|
|
}
|
|
|
|
const args = options.args || ['--dry-run'];
|
|
const answers = options.answers || ['3', '3'];
|
|
const command = [
|
|
process.execPath,
|
|
eccScript,
|
|
'setup',
|
|
...args,
|
|
];
|
|
const scriptArgs = process.platform === 'darwin'
|
|
? ['-q', '-e', '/dev/null', ...command]
|
|
: [
|
|
'-q',
|
|
'-e',
|
|
'-c',
|
|
command.map(quoteShellArgument).join(' '),
|
|
'/dev/null',
|
|
];
|
|
const pseudoTerminalCommand = ['script', ...scriptArgs]
|
|
.map(quoteShellArgument)
|
|
.join(' ');
|
|
const answerCommands = answers
|
|
.map(answer => `sleep 0.5; printf '%s\\n' ${quoteShellArgument(answer)}`)
|
|
.join('; ');
|
|
|
|
return spawnSync('sh', [
|
|
'-c',
|
|
`(${answerCommands}; sleep 0.1) | ${pseudoTerminalCommand}`,
|
|
], {
|
|
cwd: fixture.projectRoot,
|
|
env: {
|
|
...process.env,
|
|
HOME: fixture.homeDir,
|
|
USERPROFILE: fixture.homeDir,
|
|
CLAUDE_CONFIG_DIR: fixture.configDir,
|
|
PATH: `${fixture.binDir}${path.delimiter}${process.env.PATH || ''}`,
|
|
ECC_TEST_CLAUDE_STATE: fixture.statePath,
|
|
ECC_TEST_CLAUDE_CALLS: fixture.callsPath,
|
|
},
|
|
encoding: 'utf8',
|
|
timeout: 15000,
|
|
});
|
|
}
|
|
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 hasMutation(fixture) {
|
|
return readCalls(fixture).some(argv => !(
|
|
argv.join(' ') === 'plugin list --json'
|
|
|| argv.join(' ') === 'plugin marketplace list --json'
|
|
));
|
|
}
|
|
|
|
const SETUP_SPINNER_PATTERN = /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s+Applying ECC setup/;
|
|
|
|
function assertNoSetupSpinner(output) {
|
|
assert.doesNotMatch(output, SETUP_SPINNER_PATTERN);
|
|
}
|
|
|
|
function assertSetupSpinnerLifecycle(output, outcomePattern) {
|
|
const spinnerIndex = output.search(SETUP_SPINNER_PATTERN);
|
|
const clearIndex = output.indexOf('\x1b[2K', spinnerIndex);
|
|
const outcomeIndex = output.search(outcomePattern);
|
|
assert.ok(spinnerIndex >= 0, 'confirmed interactive apply should start the setup spinner');
|
|
assert.ok(clearIndex > spinnerIndex, 'setup spinner should clear its terminal line');
|
|
assert.ok(outcomeIndex > clearIndex, 'setup spinner should clear before the final outcome');
|
|
|
|
const visibleOutput = output
|
|
// eslint-disable-next-line no-control-regex
|
|
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
|
|
.replace(/\r/g, '');
|
|
assert.match(
|
|
visibleOutput,
|
|
/\[y\/N\] (?:y|yes)\n[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s+Applying ECC setup/,
|
|
'setup spinner should be the first visible status after confirmation'
|
|
);
|
|
|
|
assertNoSetupSpinner(output.slice(outcomeIndex));
|
|
}
|
|
|
|
function withFixture(state, fn) {
|
|
const fixture = createFixture(state);
|
|
try {
|
|
fn(fixture);
|
|
} finally {
|
|
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
console.log('\n=== ECC setup CLI tests ===\n');
|
|
|
|
test('fresh non-interactive plugin setup requires an explicit scope', () => {
|
|
withFixture({}, fixture => {
|
|
const result = runSetup(fixture, [
|
|
'--mode', 'claude-plugin',
|
|
'--hooks', 'standard',
|
|
'--yes',
|
|
]);
|
|
assert.strictEqual(result.status, 1);
|
|
assert.match(result.stderr, /--scope/i);
|
|
assert.strictEqual(hasMutation(fixture), false);
|
|
});
|
|
});
|
|
|
|
test('an existing install without --scope updates its detected scope', () => {
|
|
withFixture({
|
|
plugins: [{ id: 'ecc@ecc', scope: 'project', enabled: true, version: '1.9.0' }],
|
|
marketplaces: [{
|
|
name: 'ecc',
|
|
source: 'github',
|
|
repo: 'affaan-m/ECC',
|
|
scope: 'project',
|
|
}],
|
|
}, fixture => {
|
|
const result = runSetup(fixture, [
|
|
'--mode', 'claude-plugin',
|
|
'--hooks', 'strict',
|
|
'--yes',
|
|
'--json',
|
|
]);
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
const payload = JSON.parse(result.stdout);
|
|
assert.strictEqual(payload.action, 'updated');
|
|
assert.strictEqual(payload.scope, 'project');
|
|
assertNoSetupSpinner(`${result.stdout}${result.stderr}`);
|
|
assert.ok(readCalls(fixture).some(argv => (
|
|
JSON.stringify(argv) === JSON.stringify([
|
|
'plugin', 'update', 'ecc@ecc', '--scope', 'project',
|
|
])
|
|
)));
|
|
});
|
|
});
|
|
|
|
test('invalid plugin scopes and hook preferences are rejected before inventory', () => {
|
|
withFixture({}, fixture => {
|
|
for (const args of [
|
|
['--scope', 'global', '--hooks', 'standard'],
|
|
['--scope', 'user', '--hooks', 'aggressive'],
|
|
]) {
|
|
const result = runSetup(fixture, [
|
|
'--mode', 'claude-plugin',
|
|
...args,
|
|
'--yes',
|
|
]);
|
|
assert.strictEqual(result.status, 1);
|
|
assert.match(result.stderr, /invalid/i);
|
|
}
|
|
assert.deepStrictEqual(readCalls(fixture), []);
|
|
});
|
|
});
|
|
|
|
test('non-TTY mutation requires --yes', () => {
|
|
withFixture({}, fixture => {
|
|
const result = runSetup(fixture, [
|
|
'--mode', 'claude-plugin',
|
|
'--scope', 'user',
|
|
'--hooks', 'standard',
|
|
]);
|
|
assert.strictEqual(result.status, 1);
|
|
assert.match(result.stderr, /--yes/i);
|
|
assert.strictEqual(hasMutation(fixture), false);
|
|
});
|
|
});
|
|
|
|
test('dry-run JSON emits JSON only and reads inventory without mutation', () => {
|
|
withFixture({}, fixture => {
|
|
const result = runSetup(fixture, [
|
|
'--mode', 'claude-plugin',
|
|
'--scope', 'local',
|
|
'--hooks', 'minimal',
|
|
'--dry-run',
|
|
'--json',
|
|
]);
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
const payload = JSON.parse(result.stdout);
|
|
assert.strictEqual(result.stdout.trim(), JSON.stringify(payload, null, 2));
|
|
assert.strictEqual(payload.action, 'would-install');
|
|
assert.strictEqual(payload.scope, 'local');
|
|
assertNoSetupSpinner(`${result.stdout}${result.stderr}`);
|
|
assert.strictEqual(hasMutation(fixture), false);
|
|
});
|
|
});
|
|
|
|
test('setup automatically migrates an existing install to the selected scope and hooks', () => {
|
|
withFixture({
|
|
plugins: [{ id: 'ecc@ecc', scope: 'local', enabled: true, version: '1.9.0' }],
|
|
marketplaces: [{
|
|
name: 'ecc',
|
|
source: 'github',
|
|
repo: 'affaan-m/ECC',
|
|
scope: 'local',
|
|
}],
|
|
}, fixture => {
|
|
const result = runSetup(fixture, [
|
|
'--mode', 'claude-plugin',
|
|
'--scope', 'user',
|
|
'--hooks', 'minimal',
|
|
'--yes',
|
|
'--json',
|
|
]);
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
const payload = JSON.parse(result.stdout);
|
|
assert.strictEqual(payload.action, 'migrated');
|
|
assert.strictEqual(payload.sourceScope, 'local');
|
|
assert.strictEqual(payload.scope, 'user');
|
|
assert.strictEqual(payload.hooks, 'minimal');
|
|
const calls = readCalls(fixture);
|
|
assert.ok(calls.some(argv => (
|
|
argv.join(' ') === 'plugin install ecc@ecc --scope user'
|
|
+ ' --config hooks_enabled=true --config hook_profile=minimal'
|
|
)));
|
|
assert.ok(calls.some(argv => (
|
|
argv.join(' ') === 'plugin uninstall ecc@ecc --scope local --keep-data'
|
|
)));
|
|
assert.ok(!calls.flat().includes('--prune'));
|
|
const state = JSON.parse(fs.readFileSync(fixture.statePath, 'utf8'));
|
|
assert.deepStrictEqual(state.plugins, [{
|
|
id: 'ecc@ecc',
|
|
scope: 'user',
|
|
enabled: true,
|
|
version: '2.0.0',
|
|
}]);
|
|
const settings = JSON.parse(
|
|
fs.readFileSync(path.join(fixture.configDir, 'settings.json'), 'utf8')
|
|
);
|
|
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hooks_enabled, true);
|
|
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hook_profile, 'minimal');
|
|
});
|
|
});
|
|
|
|
test('setup resumes a safe two-scope migration without requiring --move-scope', () => {
|
|
withFixture({
|
|
plugins: [
|
|
{ id: 'ecc@ecc', scope: 'local', enabled: true, version: '1.9.0' },
|
|
{ id: 'ecc@ecc', scope: 'user', enabled: true, version: '2.0.0' },
|
|
],
|
|
marketplaces: [{
|
|
name: 'ecc',
|
|
source: 'github',
|
|
repo: 'affaan-m/ECC',
|
|
scope: 'user',
|
|
}],
|
|
}, fixture => {
|
|
const result = runSetup(fixture, [
|
|
'--mode', 'claude-plugin',
|
|
'--scope', 'user',
|
|
'--hooks', 'minimal',
|
|
'--yes',
|
|
'--json',
|
|
]);
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
const payload = JSON.parse(result.stdout);
|
|
assert.strictEqual(payload.action, 'resumed');
|
|
assert.strictEqual(payload.sourceScope, 'local');
|
|
assert.strictEqual(payload.scope, 'user');
|
|
assert.ok(readCalls(fixture).some(argv => (
|
|
argv.join(' ') === 'plugin uninstall ecc@ecc --scope local --keep-data'
|
|
)));
|
|
});
|
|
});
|
|
|
|
test('all interrupted migration and hook combinations resume without reinstalling', () => {
|
|
const scopes = ['user', 'project', 'local'];
|
|
const hooks = ['off', 'minimal', 'standard', 'strict'];
|
|
for (const sourceScope of scopes) {
|
|
for (const destinationScope of scopes.filter(scope => scope !== sourceScope)) {
|
|
for (const hookMode of hooks) {
|
|
withFixture({
|
|
plugins: [
|
|
{ id: 'ecc@ecc', scope: sourceScope, enabled: true, version: '1.9.0' },
|
|
{ id: 'ecc@ecc', scope: destinationScope, enabled: true, version: '2.0.0' },
|
|
],
|
|
marketplaces: [{
|
|
name: 'ecc',
|
|
source: 'github',
|
|
repo: 'affaan-m/ECC',
|
|
scope: destinationScope,
|
|
}],
|
|
}, fixture => {
|
|
const result = runSetup(fixture, [
|
|
'--mode', 'claude-plugin',
|
|
'--scope', destinationScope,
|
|
'--hooks', hookMode,
|
|
'--yes',
|
|
'--json',
|
|
]);
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
const payload = JSON.parse(result.stdout);
|
|
assert.strictEqual(payload.action, 'resumed');
|
|
assert.strictEqual(payload.sourceScope, sourceScope);
|
|
assert.strictEqual(payload.scope, destinationScope);
|
|
assert.strictEqual(payload.hooks, hookMode);
|
|
|
|
const calls = readCalls(fixture);
|
|
assert.ok(!calls.some(argv => argv[1] === 'install'));
|
|
assert.ok(calls.some(argv => (
|
|
argv.join(' ') === `plugin uninstall ecc@ecc --scope ${sourceScope} --keep-data`
|
|
)));
|
|
const state = JSON.parse(fs.readFileSync(fixture.statePath, 'utf8'));
|
|
assert.deepStrictEqual(state.plugins, [{
|
|
id: 'ecc@ecc',
|
|
scope: destinationScope,
|
|
enabled: true,
|
|
version: '2.0.0',
|
|
}]);
|
|
const settings = JSON.parse(
|
|
fs.readFileSync(path.join(fixture.configDir, 'settings.json'), 'utf8')
|
|
);
|
|
const stored = settings.pluginConfigs['ecc@ecc'].options;
|
|
assert.strictEqual(stored.hooks_enabled, hookMode !== 'off');
|
|
assert.strictEqual(
|
|
stored.hook_profile,
|
|
hookMode === 'off' ? 'standard' : hookMode
|
|
);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test('--move-scope remains explicit about its destination', () => {
|
|
withFixture({
|
|
plugins: [{ id: 'ecc@ecc', scope: 'user', enabled: true, version: '1.9.0' }],
|
|
}, fixture => {
|
|
const result = runSetup(fixture, [
|
|
'--mode', 'claude-plugin',
|
|
'--move-scope',
|
|
'--yes',
|
|
'--json',
|
|
]);
|
|
assert.strictEqual(result.status, 1);
|
|
assert.match(result.stderr, /scope/i);
|
|
assert.strictEqual(hasMutation(fixture), false);
|
|
});
|
|
});
|
|
|
|
test('destination-only --move-scope is an idempotent first call', () => {
|
|
withFixture({
|
|
plugins: [{ id: 'ecc@ecc', scope: 'local', enabled: true, version: '2.0.0' }],
|
|
}, fixture => {
|
|
const result = runSetup(fixture, [
|
|
'--mode', 'claude-plugin',
|
|
'--scope', 'local',
|
|
'--move-scope',
|
|
'--yes',
|
|
'--json',
|
|
]);
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
const payload = JSON.parse(result.stdout);
|
|
assert.strictEqual(payload.action, 'already-migrated');
|
|
assert.strictEqual(payload.scope, 'local');
|
|
assert.strictEqual(hasMutation(fixture), false);
|
|
});
|
|
});
|
|
|
|
test('destination-only --move-scope applies explicit hook preferences', () => {
|
|
withFixture({
|
|
plugins: [{ id: 'ecc@ecc', scope: 'local', enabled: true, version: '2.0.0' }],
|
|
}, fixture => {
|
|
const result = runSetup(fixture, [
|
|
'--mode', 'claude-plugin',
|
|
'--scope', 'local',
|
|
'--move-scope',
|
|
'--hooks', 'strict',
|
|
'--yes',
|
|
'--json',
|
|
]);
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
const payload = JSON.parse(result.stdout);
|
|
assert.strictEqual(payload.action, 'already-migrated');
|
|
assert.strictEqual(payload.preferencesUpdated, true);
|
|
const settings = JSON.parse(
|
|
fs.readFileSync(path.join(fixture.configDir, 'settings.json'), 'utf8')
|
|
);
|
|
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hooks_enabled, true);
|
|
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hook_profile, 'strict');
|
|
});
|
|
});
|
|
|
|
test('migration dry-run JSON exposes ordered actions without mutation', () => {
|
|
withFixture({
|
|
plugins: [{ id: 'ecc@ecc', scope: 'user', enabled: true, version: '1.9.0' }],
|
|
marketplaces: [{
|
|
name: 'ecc',
|
|
source: 'github',
|
|
repo: 'affaan-m/ECC',
|
|
scope: 'user',
|
|
}],
|
|
}, fixture => {
|
|
const result = runSetup(fixture, [
|
|
'--mode', 'claude-plugin',
|
|
'--scope', 'project',
|
|
'--dry-run',
|
|
'--json',
|
|
]);
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
const payload = JSON.parse(result.stdout);
|
|
assert.strictEqual(payload.action, 'would-migrate');
|
|
assert.strictEqual(payload.dryRun, true);
|
|
assert.deepStrictEqual(payload.plannedActions.slice(-4), [
|
|
['plugin', 'list', '--json'],
|
|
['plugin', 'list', '--json'],
|
|
['plugin', 'uninstall', 'ecc@ecc', '--scope', 'user', '--keep-data'],
|
|
['plugin', 'list', '--json'],
|
|
]);
|
|
assert.strictEqual(hasMutation(fixture), false);
|
|
});
|
|
});
|
|
|
|
test('migration JSON failures retain phase, scopes, and exact recovery', () => {
|
|
withFixture({
|
|
plugins: [{ id: 'ecc@ecc', scope: 'user', enabled: true, version: '1.9.0' }],
|
|
marketplaces: [{
|
|
name: 'ecc',
|
|
source: 'github',
|
|
repo: 'affaan-m/ECC',
|
|
scope: 'user',
|
|
}],
|
|
failures: [{
|
|
argv: ['plugin', 'uninstall', 'ecc@ecc', '--scope', 'user', '--keep-data'],
|
|
status: 9,
|
|
stderr: 'uninstall failed',
|
|
times: 1,
|
|
}],
|
|
}, fixture => {
|
|
const result = runSetup(fixture, [
|
|
'--mode', 'claude-plugin',
|
|
'--scope', 'project',
|
|
'--move-scope',
|
|
'--yes',
|
|
'--json',
|
|
]);
|
|
assert.strictEqual(result.status, 1);
|
|
assert.strictEqual(result.stdout, '');
|
|
const payload = JSON.parse(result.stderr);
|
|
assert.strictEqual(payload.error.phase, 'source-uninstall');
|
|
assert.deepStrictEqual([...payload.error.observedScopes].sort(), ['project', 'user']);
|
|
assert.deepStrictEqual(payload.error.recovery, [
|
|
'claude plugin uninstall ecc@ecc --scope user --keep-data',
|
|
'ecc setup --mode claude-plugin --scope project --move-scope --yes',
|
|
]);
|
|
});
|
|
});
|
|
|
|
test('help explains native scope names in user-facing language', () => {
|
|
const result = spawnSync(process.execPath, [setupScript, '--help'], {
|
|
cwd: repoRoot,
|
|
encoding: 'utf8',
|
|
});
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.match(result.stdout, /(?:user.{0,80}global|global.{0,80}user)/is);
|
|
assert.match(result.stdout, /(?:project.{0,80}shared|shared.{0,80}project)/is);
|
|
assert.match(result.stdout, /(?:local.{0,80}private|private.{0,80}local)/is);
|
|
assert.match(result.stdout, /--hooks off\|minimal\|standard\|strict/);
|
|
assert.match(result.stdout, /--move-scope/);
|
|
});
|
|
|
|
test('ecc setup delegates to the focused setup command', () => {
|
|
const result = spawnSync(process.execPath, [eccScript, 'setup', '--help'], {
|
|
cwd: repoRoot,
|
|
encoding: 'utf8',
|
|
timeout: 15000,
|
|
});
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.match(result.stdout, /ECC (guided )?setup/i);
|
|
assert.match(result.stdout, /claude-plugin/);
|
|
});
|
|
|
|
test('ecc setup preserves a real terminal for the interactive wizard', () => {
|
|
if (process.platform === 'win32') return;
|
|
|
|
withFixture({}, fixture => {
|
|
const result = runInteractiveEccSetup(fixture);
|
|
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
assert.ifError(result.error);
|
|
assert.match(result.stdout, /Where should Claude enable ecc@ecc\?/);
|
|
assert.match(result.stdout, /How should ECC hooks run\?/);
|
|
assert.doesNotMatch(result.stdout, /Interactive setup requires a terminal/);
|
|
assertNoSetupSpinner(`${result.stdout}${result.stderr}`);
|
|
});
|
|
});
|
|
|
|
test('confirmed interactive apply starts immediately and clears the spinner on success', () => {
|
|
if (process.platform === 'win32') return;
|
|
|
|
withFixture({}, fixture => {
|
|
const result = runInteractiveEccSetup(fixture, {
|
|
args: [],
|
|
answers: ['2', '2', 'y'],
|
|
});
|
|
assert.ifError(result.error);
|
|
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
assertSetupSpinnerLifecycle(
|
|
`${result.stdout}${result.stderr}`,
|
|
/ECC installed ecc@ecc at project scope/
|
|
);
|
|
});
|
|
});
|
|
|
|
test('confirmed interactive apply clears and stops the spinner when apply throws', () => {
|
|
if (process.platform === 'win32') return;
|
|
|
|
withFixture({
|
|
failures: [{
|
|
argv: [
|
|
'plugin', 'marketplace', 'add',
|
|
'https://github.com/affaan-m/ECC',
|
|
'--scope', 'user',
|
|
],
|
|
status: 8,
|
|
stderr: 'injected apply failure',
|
|
times: 1,
|
|
}],
|
|
}, fixture => {
|
|
const result = runInteractiveEccSetup(fixture, {
|
|
args: [],
|
|
answers: ['1', '3', 'yes'],
|
|
});
|
|
assert.ifError(result.error);
|
|
assert.strictEqual(result.status, 1, `${result.stdout}\n${result.stderr}`);
|
|
assertSetupSpinnerLifecycle(
|
|
`${result.stdout}${result.stderr}`,
|
|
/Error: Claude Code command failed: injected apply failure/
|
|
);
|
|
});
|
|
});
|
|
|
|
test('all interactive scope and hook choices install and persist the selected configuration', () => {
|
|
if (process.platform === 'win32') return;
|
|
|
|
const scopes = ['user', 'project', 'local'];
|
|
const hooks = ['off', 'minimal', 'standard', 'strict'];
|
|
for (const [scopeIndex, scope] of scopes.entries()) {
|
|
for (const [hookIndex, hookMode] of hooks.entries()) {
|
|
withFixture({}, fixture => {
|
|
const result = runInteractiveEccSetup(fixture, {
|
|
args: [],
|
|
answers: [String(scopeIndex + 1), String(hookIndex + 1), 'y'],
|
|
});
|
|
assert.ifError(result.error);
|
|
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
assert.match(result.stdout, new RegExp(`ECC installed ecc@ecc at ${scope} scope`));
|
|
assert.match(result.stdout, new RegExp(`Hook preference: ${hookMode}`));
|
|
|
|
const state = JSON.parse(fs.readFileSync(fixture.statePath, 'utf8'));
|
|
assert.deepStrictEqual(state.plugins, [{
|
|
id: 'ecc@ecc',
|
|
scope,
|
|
enabled: true,
|
|
version: '2.0.0',
|
|
}]);
|
|
const settings = JSON.parse(
|
|
fs.readFileSync(path.join(fixture.configDir, 'settings.json'), 'utf8')
|
|
);
|
|
const stored = settings.pluginConfigs['ecc@ecc'].options;
|
|
assert.strictEqual(stored.hooks_enabled, hookMode !== 'off');
|
|
assert.strictEqual(stored.hook_profile, hookMode === 'off' ? 'standard' : hookMode);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
test('all interactive choices from an existing install update or migrate to the selected configuration', () => {
|
|
if (process.platform === 'win32') return;
|
|
|
|
const scopes = ['user', 'project', 'local'];
|
|
const hooks = ['off', 'minimal', 'standard', 'strict'];
|
|
for (const [sourceIndex, sourceScope] of scopes.entries()) {
|
|
for (const [selectedIndex, selectedScope] of scopes.entries()) {
|
|
for (const [hookIndex, hookMode] of hooks.entries()) {
|
|
withFixture({
|
|
plugins: [{ id: 'ecc@ecc', scope: sourceScope, enabled: true, version: '1.9.0' }],
|
|
marketplaces: [{
|
|
name: 'ecc',
|
|
source: 'github',
|
|
repo: 'affaan-m/ECC',
|
|
scope: sourceScope,
|
|
}],
|
|
}, fixture => {
|
|
const result = runInteractiveEccSetup(fixture, {
|
|
args: [],
|
|
answers: [String(selectedIndex + 1), String(hookIndex + 1), 'y'],
|
|
});
|
|
assert.ifError(result.error);
|
|
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
|
|
const expectedAction = sourceIndex === selectedIndex ? 'updated' : 'migrated';
|
|
const expectedConfirmation = sourceIndex === selectedIndex ? 'Apply' : 'Migrate';
|
|
assert.match(
|
|
result.stdout,
|
|
new RegExp(`${expectedConfirmation} claude-plugin setup at ${selectedScope} scope`)
|
|
);
|
|
assert.match(
|
|
result.stdout,
|
|
new RegExp(`ECC ${expectedAction} ecc@ecc at ${selectedScope} scope`)
|
|
);
|
|
assert.match(result.stdout, new RegExp(`Hook preference: ${hookMode}`));
|
|
|
|
const state = JSON.parse(fs.readFileSync(fixture.statePath, 'utf8'));
|
|
assert.deepStrictEqual(state.plugins, [{
|
|
id: 'ecc@ecc',
|
|
scope: selectedScope,
|
|
enabled: true,
|
|
version: '2.0.0',
|
|
}]);
|
|
const settings = JSON.parse(
|
|
fs.readFileSync(path.join(fixture.configDir, 'settings.json'), 'utf8')
|
|
);
|
|
const stored = settings.pluginConfigs['ecc@ecc'].options;
|
|
assert.strictEqual(stored.hooks_enabled, hookMode !== 'off');
|
|
assert.strictEqual(stored.hook_profile, hookMode === 'off' ? 'standard' : hookMode);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test('interactive named choices install and persist the selected configuration', () => {
|
|
if (process.platform === 'win32') return;
|
|
|
|
withFixture({}, fixture => {
|
|
const result = runInteractiveEccSetup(fixture, {
|
|
args: [],
|
|
answers: ['project', 'strict', 'yes'],
|
|
});
|
|
assert.ifError(result.error);
|
|
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
assert.match(result.stdout, /ECC installed ecc@ecc at project scope/);
|
|
assert.match(result.stdout, /Hook preference: strict/);
|
|
|
|
const state = JSON.parse(fs.readFileSync(fixture.statePath, 'utf8'));
|
|
assert.deepStrictEqual(state.plugins, [{
|
|
id: 'ecc@ecc',
|
|
scope: 'project',
|
|
enabled: true,
|
|
version: '2.0.0',
|
|
}]);
|
|
const settings = JSON.parse(
|
|
fs.readFileSync(path.join(fixture.configDir, 'settings.json'), 'utf8')
|
|
);
|
|
const stored = settings.pluginConfigs['ecc@ecc'].options;
|
|
assert.strictEqual(stored.hooks_enabled, true);
|
|
assert.strictEqual(stored.hook_profile, 'strict');
|
|
});
|
|
});
|
|
|
|
test('invalid interactive choices explain the problem and allow a retry', () => {
|
|
if (process.platform === 'win32') return;
|
|
|
|
withFixture({}, fixture => {
|
|
const result = runInteractiveEccSetup(fixture, {
|
|
args: ['--dry-run'],
|
|
answers: ['1.5', 'not-a-scope', '2', '2junk', '9', '2'],
|
|
});
|
|
assert.ifError(result.error);
|
|
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
assert.match(result.stdout, /Please choose 1, 2, or 3/);
|
|
assert.match(result.stdout, /Please choose 1, 2, 3, or 4/);
|
|
assert.match(result.stdout, /ECC would-install ecc@ecc at project scope/);
|
|
assert.match(result.stdout, /Hook preference: minimal/);
|
|
assert.strictEqual(hasMutation(fixture), false);
|
|
});
|
|
});
|
|
|
|
test('interactive cancellation after non-default choices performs no mutation', () => {
|
|
if (process.platform === 'win32') return;
|
|
|
|
withFixture({}, fixture => {
|
|
const result = runInteractiveEccSetup(fixture, {
|
|
args: [],
|
|
answers: ['2', '2', 'n'],
|
|
});
|
|
assert.ifError(result.error);
|
|
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
assert.match(result.stdout, /ECC cancelled ecc@ecc at project scope/);
|
|
assertNoSetupSpinner(`${result.stdout}${result.stderr}`);
|
|
assert.strictEqual(hasMutation(fixture), false);
|
|
assert.ok(!fs.existsSync(path.join(fixture.configDir, 'settings.json')));
|
|
});
|
|
});
|
|
|
|
test('closing interactive input cancels cleanly without mutation', () => {
|
|
if (process.platform === 'win32') return;
|
|
|
|
withFixture({}, fixture => {
|
|
const result = runInteractiveEccSetup(fixture, {
|
|
args: [],
|
|
answers: ['2', '\u0004'],
|
|
});
|
|
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
assert.ifError(result.error);
|
|
assert.match(result.stdout, /cancelled/i);
|
|
assert.match(result.stdout, /no changes/i);
|
|
assert.strictEqual(hasMutation(fixture), false);
|
|
assert.ok(!fs.existsSync(path.join(fixture.configDir, 'settings.json')));
|
|
});
|
|
});
|
|
|
|
test('interactive mode flag still prompts for missing scope and hook choices', () => {
|
|
if (process.platform === 'win32') return;
|
|
|
|
withFixture({}, fixture => {
|
|
const result = runInteractiveEccSetup(fixture, {
|
|
args: ['--mode', 'claude-plugin', '--dry-run'],
|
|
answers: ['3', '4'],
|
|
});
|
|
assert.ifError(result.error);
|
|
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
assert.match(result.stdout, /Where should Claude enable ecc@ecc\?/);
|
|
assert.match(result.stdout, /How should ECC hooks run\?/);
|
|
assert.match(result.stdout, /ECC would-install ecc@ecc at local scope/);
|
|
assert.match(result.stdout, /Hook preference: strict/);
|
|
});
|
|
});
|
|
|
|
test('interactive defaults preserve an existing install scope and hook preference', () => {
|
|
if (process.platform === 'win32') return;
|
|
|
|
withFixture({
|
|
plugins: [{ id: 'ecc@ecc', scope: 'local', enabled: true, version: '1.9.0' }],
|
|
marketplaces: [{
|
|
name: 'ecc',
|
|
source: 'github',
|
|
repo: 'affaan-m/ECC',
|
|
scope: 'local',
|
|
}],
|
|
}, fixture => {
|
|
fs.writeFileSync(path.join(fixture.configDir, 'settings.json'), JSON.stringify({
|
|
pluginConfigs: {
|
|
'ecc@ecc': {
|
|
options: { hooks_enabled: true, hook_profile: 'minimal' },
|
|
},
|
|
},
|
|
}));
|
|
const result = runInteractiveEccSetup(fixture, {
|
|
args: ['--dry-run'],
|
|
answers: ['', ''],
|
|
});
|
|
assert.ifError(result.error);
|
|
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
assert.match(result.stdout, /Choose \[3\]:/);
|
|
assert.match(result.stdout, /Choose \[2\]:/);
|
|
assert.match(result.stdout, /ECC would-update ecc@ecc at local scope/);
|
|
assert.match(result.stdout, /Hook preference: minimal/);
|
|
assert.strictEqual(hasMutation(fixture), false);
|
|
});
|
|
});
|
|
|
|
test('partial migration requires an explicit destination and preserves stored hook defaults', () => {
|
|
if (process.platform === 'win32') return;
|
|
|
|
withFixture({
|
|
plugins: [
|
|
{ id: 'ecc@ecc', scope: 'user', enabled: true, version: '1.9.0' },
|
|
{ id: 'ecc@ecc', scope: 'project', enabled: true, version: '2.0.0' },
|
|
],
|
|
marketplaces: [{
|
|
name: 'ecc',
|
|
source: 'github',
|
|
repo: 'affaan-m/ECC',
|
|
scope: 'user',
|
|
}],
|
|
}, fixture => {
|
|
fs.writeFileSync(path.join(fixture.configDir, 'settings.json'), JSON.stringify({
|
|
pluginConfigs: {
|
|
'ecc@ecc': {
|
|
options: { hooks_enabled: true, hook_profile: 'minimal' },
|
|
},
|
|
},
|
|
}));
|
|
const result = runInteractiveEccSetup(fixture, {
|
|
args: ['--dry-run'],
|
|
answers: ['', 'project', ''],
|
|
});
|
|
assert.ifError(result.error);
|
|
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
assert.match(result.stdout, /Choose: /);
|
|
assert.match(result.stdout, /Please choose 1, 2, or 3/);
|
|
assert.match(result.stdout, /Choose \[2\]:/);
|
|
assert.match(result.stdout, /ECC would-resume ecc@ecc at project scope/);
|
|
assert.match(result.stdout, /Previous scope: user/);
|
|
assert.match(result.stdout, /Hook preference: minimal/);
|
|
assert.strictEqual(hasMutation(fixture), false);
|
|
});
|
|
});
|
|
|
|
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
|
process.exit(failed > 0 ? 1 : 0);
|