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>
658 lines
21 KiB
JavaScript
658 lines
21 KiB
JavaScript
'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,
|
|
buildWindowsCommandLine,
|
|
isOfficialMarketplace,
|
|
runClaude,
|
|
setupClaudePlugin,
|
|
} = require('../../scripts/lib/claude-plugin-setup');
|
|
|
|
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(initialState = {}) {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc plugin setup '));
|
|
const homeDir = path.join(root, 'home with spaces');
|
|
const configDir = path.join(root, 'claude 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 cleanupFixture(fixture) {
|
|
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
}
|
|
|
|
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;
|
|
}
|
|
cleanupFixture(fixture);
|
|
}
|
|
}
|
|
|
|
function setupOptions(fixture, overrides = {}) {
|
|
return {
|
|
hooks: 'standard',
|
|
homeDir: fixture.homeDir,
|
|
configDir: fixture.configDir,
|
|
projectRoot: fixture.projectRoot,
|
|
...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 mutationCalls(calls) {
|
|
return calls.filter(argv => !(
|
|
argv.join(' ') === 'plugin list --json'
|
|
|| argv.join(' ') === 'plugin marketplace list --json'
|
|
));
|
|
}
|
|
|
|
function assertThrowsContaining(fn, fragments) {
|
|
assert.throws(fn, error => (
|
|
fragments.every(fragment => error.message.toLowerCase().includes(fragment.toLowerCase()))
|
|
));
|
|
}
|
|
|
|
function officialMarketplace(scope = 'user') {
|
|
return {
|
|
name: 'ecc',
|
|
source: 'github',
|
|
repo: 'affaan-m/ECC',
|
|
scope,
|
|
};
|
|
}
|
|
|
|
function installedPlugin(scope = 'user', overrides = {}) {
|
|
return {
|
|
id: 'ecc@ecc',
|
|
scope,
|
|
enabled: true,
|
|
version: '1.9.0',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function writeManagedState(fixture, selectedModules, operations = []) {
|
|
const statePath = path.join(fixture.configDir, 'ecc', 'install-state.json');
|
|
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
|
fs.writeFileSync(statePath, `${JSON.stringify({
|
|
schemaVersion: 'ecc.install.v1',
|
|
target: { target: 'claude' },
|
|
resolution: { selectedModules, skippedModules: [] },
|
|
operations,
|
|
}, null, 2)}\n`);
|
|
}
|
|
|
|
console.log('\n=== Claude plugin setup library tests ===\n');
|
|
|
|
test('Windows command-line fallback preserves spaced paths and JSON arguments', () => {
|
|
assert.strictEqual(
|
|
buildWindowsCommandLine(
|
|
'C:\\Program Files\\Claude\\claude.cmd',
|
|
['plugin', 'install', 'ecc@ecc', '--config', '{"hooks_enabled":false}']
|
|
),
|
|
'"C:\\Program Files\\Claude\\claude.cmd" plugin install ecc@ecc --config "{""hooks_enabled"":false}"'
|
|
);
|
|
assert.throws(
|
|
() => buildWindowsCommandLine('claude.cmd', ['plugin', 'install', 'bad&unsafe']),
|
|
/unsafe/
|
|
);
|
|
});
|
|
|
|
test('provider runner times out a hung Claude command with structured context', () => {
|
|
const timeoutError = Object.assign(new Error('spawnSync timed out'), {
|
|
code: 'ETIMEDOUT',
|
|
killed: true,
|
|
signal: 'SIGKILL',
|
|
});
|
|
const spawn = (command, args, options) => {
|
|
assert.strictEqual(command, process.execPath);
|
|
assert.deepStrictEqual(args, ['plugin', 'marketplace', 'update', 'ecc']);
|
|
assert.strictEqual(options.timeout, 25);
|
|
assert.strictEqual(options.killSignal, 'SIGKILL');
|
|
return { error: timeoutError, signal: 'SIGKILL', status: null };
|
|
};
|
|
assert.throws(
|
|
() => runClaude(
|
|
['plugin', 'marketplace', 'update', 'ecc'],
|
|
{
|
|
command: process.execPath,
|
|
phase: 'marketplace',
|
|
timeoutMs: 25,
|
|
},
|
|
{ spawnSync: spawn }
|
|
),
|
|
error => {
|
|
assert.strictEqual(error.code, 'CLAUDE_COMMAND_FAILED');
|
|
assert.strictEqual(error.phase, 'marketplace');
|
|
assert.match(error.message, /timed out after 25 ms/i);
|
|
return true;
|
|
}
|
|
);
|
|
});
|
|
|
|
test('marketplace provenance is validated according to its source type', () => {
|
|
assert.strictEqual(isOfficialMarketplace(officialMarketplace()), true);
|
|
assert.strictEqual(isOfficialMarketplace({
|
|
name: 'ecc',
|
|
source: 'git',
|
|
url: 'https://github.com/affaan-m/ECC.git',
|
|
}), true);
|
|
for (const url of [
|
|
'affaan-m/ECC',
|
|
'http://github.com/affaan-m/ECC.git',
|
|
]) {
|
|
assert.strictEqual(isOfficialMarketplace({
|
|
name: 'ecc',
|
|
source: 'git',
|
|
url,
|
|
}), false);
|
|
}
|
|
});
|
|
|
|
test('fresh installs require an explicit scope and perform no mutation', () => {
|
|
withFixture({}, fixture => {
|
|
assertThrowsContaining(
|
|
() => setupClaudePlugin(setupOptions(fixture)),
|
|
['scope', 'user', 'project', 'local']
|
|
);
|
|
assert.deepStrictEqual(mutationCalls(readCalls(fixture)), []);
|
|
assert.ok(!fs.existsSync(fixture.settingsPath));
|
|
});
|
|
});
|
|
|
|
test('an existing single-scope install defaults to its detected scope', () => {
|
|
withFixture({
|
|
plugins: [installedPlugin('project')],
|
|
marketplaces: [officialMarketplace('project')],
|
|
}, fixture => {
|
|
const result = setupClaudePlugin(setupOptions(fixture, { hooks: 'minimal' }));
|
|
assert.strictEqual(result.action, 'updated');
|
|
assert.strictEqual(result.scope, 'project');
|
|
assert.deepStrictEqual(readCalls(fixture), [
|
|
['plugin', 'list', '--json'],
|
|
['plugin', 'marketplace', 'list', '--json'],
|
|
['plugin', 'marketplace', 'update', 'ecc'],
|
|
['plugin', 'marketplace', 'list', '--json'],
|
|
['plugin', 'update', 'ecc@ecc', '--scope', 'project'],
|
|
['plugin', 'list', '--json'],
|
|
]);
|
|
const settings = JSON.parse(fs.readFileSync(fixture.settingsPath, 'utf8'));
|
|
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hook_profile, 'minimal');
|
|
});
|
|
});
|
|
|
|
test('requesting another scope fails without the PR 2 move-scope operation', () => {
|
|
withFixture({
|
|
plugins: [installedPlugin('user')],
|
|
marketplaces: [officialMarketplace('user')],
|
|
}, fixture => {
|
|
assertThrowsContaining(
|
|
() => setupClaudePlugin(setupOptions(fixture, { scope: 'project' })),
|
|
['already installed', 'user', 'scope migration']
|
|
);
|
|
assert.deepStrictEqual(mutationCalls(readCalls(fixture)), []);
|
|
});
|
|
});
|
|
|
|
test('fresh install follows the exact inventory, marketplace, install, and verification sequence', () => {
|
|
withFixture({}, fixture => {
|
|
const result = setupClaudePlugin(setupOptions(fixture, {
|
|
scope: 'project',
|
|
hooks: 'strict',
|
|
}));
|
|
assert.strictEqual(result.action, 'installed');
|
|
assert.strictEqual(result.scope, 'project');
|
|
assert.deepStrictEqual(readCalls(fixture), [
|
|
['plugin', 'list', '--json'],
|
|
['plugin', 'marketplace', 'list', '--json'],
|
|
['plugin', 'marketplace', 'add', OFFICIAL_MARKETPLACE_URL, '--scope', 'project'],
|
|
['plugin', 'marketplace', 'list', '--json'],
|
|
[
|
|
'plugin', 'install', 'ecc@ecc',
|
|
'--scope', 'project',
|
|
'--config', 'hooks_enabled=true',
|
|
'--config', 'hook_profile=strict',
|
|
],
|
|
['plugin', 'list', '--json'],
|
|
]);
|
|
});
|
|
});
|
|
|
|
test('fresh installs support all Claude scopes while hook preferences stay user-only', () => {
|
|
for (const scope of ['user', 'project', 'local']) {
|
|
withFixture({}, fixture => {
|
|
setupClaudePlugin(setupOptions(fixture, { scope, hooks: 'minimal' }));
|
|
const calls = readCalls(fixture);
|
|
assert.ok(calls.some(argv => (
|
|
argv[0] === 'plugin'
|
|
&& argv[1] === 'marketplace'
|
|
&& argv[2] === 'add'
|
|
&& argv.includes('--scope')
|
|
&& argv[argv.indexOf('--scope') + 1] === scope
|
|
)));
|
|
assert.ok(calls.some(argv => (
|
|
argv[0] === 'plugin'
|
|
&& argv[1] === 'install'
|
|
&& argv[argv.indexOf('--scope') + 1] === scope
|
|
)));
|
|
assert.ok(fs.existsSync(fixture.settingsPath));
|
|
assert.ok(!fs.existsSync(path.join(fixture.projectRoot, '.claude', 'settings.json')));
|
|
assert.ok(!fs.existsSync(path.join(fixture.projectRoot, '.claude', 'settings.local.json')));
|
|
});
|
|
}
|
|
});
|
|
|
|
test('same-scope repeat setup updates ECC and changes durable user hook preferences', () => {
|
|
withFixture({
|
|
plugins: [installedPlugin('local')],
|
|
marketplaces: [officialMarketplace('local')],
|
|
}, fixture => {
|
|
fs.writeFileSync(fixture.settingsPath, `${JSON.stringify({
|
|
theme: 'dark',
|
|
pluginConfigs: {
|
|
'another@market': { enabled: false },
|
|
'ecc@ecc': {
|
|
enabled: true,
|
|
futureKey: { keep: true },
|
|
options: { hooks_enabled: true, hook_profile: 'minimal', unknown: 'keep' },
|
|
},
|
|
},
|
|
}, null, 2)}\n`);
|
|
|
|
setupClaudePlugin(setupOptions(fixture, { scope: 'local', hooks: 'off' }));
|
|
const settings = JSON.parse(fs.readFileSync(fixture.settingsPath, 'utf8'));
|
|
assert.strictEqual(settings.theme, 'dark');
|
|
assert.deepStrictEqual(settings.pluginConfigs['another@market'], { enabled: false });
|
|
assert.deepStrictEqual(settings.pluginConfigs['ecc@ecc'].futureKey, { keep: true });
|
|
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.unknown, 'keep');
|
|
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hooks_enabled, false);
|
|
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hook_profile, 'standard');
|
|
assert.ok(!fs.readdirSync(fixture.configDir).some(name => name.includes('.tmp')));
|
|
});
|
|
});
|
|
|
|
test('repeat setup preserves the current hook preference when --hooks is omitted', () => {
|
|
withFixture({
|
|
plugins: [installedPlugin('user')],
|
|
marketplaces: [officialMarketplace('user')],
|
|
}, fixture => {
|
|
fs.writeFileSync(fixture.settingsPath, `${JSON.stringify({
|
|
pluginConfigs: {
|
|
'ecc@ecc': {
|
|
options: {
|
|
hooks_enabled: false,
|
|
hook_profile: 'strict',
|
|
},
|
|
},
|
|
},
|
|
}, null, 2)}\n`);
|
|
|
|
const result = setupClaudePlugin(setupOptions(fixture, { hooks: undefined }));
|
|
const settings = JSON.parse(fs.readFileSync(fixture.settingsPath, 'utf8'));
|
|
assert.strictEqual(result.hooks, 'off');
|
|
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hooks_enabled, false);
|
|
assert.strictEqual(settings.pluginConfigs['ecc@ecc'].options.hook_profile, 'strict');
|
|
});
|
|
});
|
|
|
|
test('malformed user settings fail preflight without provider mutation or corruption', () => {
|
|
withFixture({}, fixture => {
|
|
const malformed = '{"theme":';
|
|
fs.writeFileSync(fixture.settingsPath, malformed);
|
|
assertThrowsContaining(
|
|
() => setupClaudePlugin(setupOptions(fixture, { scope: 'user' })),
|
|
['settings', 'invalid']
|
|
);
|
|
assert.deepStrictEqual(mutationCalls(readCalls(fixture)), []);
|
|
assert.strictEqual(fs.readFileSync(fixture.settingsPath, 'utf8'), malformed);
|
|
});
|
|
});
|
|
|
|
test('legacy plugin inventory fails closed before marketplace or plugin mutation', () => {
|
|
withFixture({
|
|
plugins: [{
|
|
id: 'everything-claude-code@everything-claude-code',
|
|
scope: 'user',
|
|
enabled: true,
|
|
}],
|
|
}, fixture => {
|
|
assertThrowsContaining(
|
|
() => setupClaudePlugin(setupOptions(fixture, { scope: 'user' })),
|
|
['legacy', 'uninstall']
|
|
);
|
|
assert.deepStrictEqual(mutationCalls(readCalls(fixture)), []);
|
|
});
|
|
});
|
|
|
|
test('skills-directory ECC plugins fail closed before marketplace or plugin mutation', () => {
|
|
withFixture({
|
|
plugins: [{
|
|
id: 'ecc@skills-dir',
|
|
scope: 'user',
|
|
enabled: true,
|
|
}],
|
|
}, fixture => {
|
|
assertThrowsContaining(
|
|
() => setupClaudePlugin(setupOptions(fixture, { scope: 'user' })),
|
|
['ecc@skills-dir', 'duplicate', 'uninstall']
|
|
);
|
|
assert.deepStrictEqual(mutationCalls(readCalls(fixture)), []);
|
|
});
|
|
});
|
|
|
|
test('manual plugin layouts fail closed before provider mutation', () => {
|
|
withFixture({}, fixture => {
|
|
const manualManifest = path.join(
|
|
fixture.configDir,
|
|
'plugins',
|
|
'ecc',
|
|
'.claude-plugin',
|
|
'plugin.json'
|
|
);
|
|
fs.mkdirSync(path.dirname(manualManifest), { recursive: true });
|
|
fs.writeFileSync(manualManifest, JSON.stringify({ name: 'ecc' }));
|
|
assertThrowsContaining(
|
|
() => setupClaudePlugin(setupOptions(fixture, { scope: 'user' })),
|
|
['manual', 'ecc']
|
|
);
|
|
assert.deepStrictEqual(mutationCalls(readCalls(fixture)), []);
|
|
});
|
|
});
|
|
|
|
test('duplicate ECC plugin scopes fail closed before mutation', () => {
|
|
withFixture({
|
|
plugins: [installedPlugin('user'), installedPlugin('project')],
|
|
}, fixture => {
|
|
assertThrowsContaining(
|
|
() => setupClaudePlugin(setupOptions(fixture, { scope: 'user' })),
|
|
['multiple', 'scope']
|
|
);
|
|
assert.deepStrictEqual(mutationCalls(readCalls(fixture)), []);
|
|
});
|
|
});
|
|
|
|
test('malformed plugin JSON and malformed plugin entries fail closed', () => {
|
|
for (const pluginListResponses of [['{not-json'], [[{ id: 'ecc@ecc', enabled: true }]]]) {
|
|
withFixture({ pluginListResponses }, fixture => {
|
|
assertThrowsContaining(
|
|
() => setupClaudePlugin(setupOptions(fixture, { scope: 'user' })),
|
|
['plugin', 'inventory']
|
|
);
|
|
assert.deepStrictEqual(mutationCalls(readCalls(fixture)), []);
|
|
});
|
|
}
|
|
});
|
|
|
|
test('malformed marketplace JSON and marketplace name collisions fail closed', () => {
|
|
const cases = [
|
|
{
|
|
initial: { marketplaceListResponses: ['{not-json'] },
|
|
fragments: ['marketplace', 'inventory'],
|
|
},
|
|
{
|
|
initial: {
|
|
marketplaces: [{
|
|
name: 'ecc',
|
|
source: 'git',
|
|
url: 'https://github.com/example/not-ecc.git',
|
|
scope: 'user',
|
|
}],
|
|
},
|
|
fragments: ['marketplace', 'collision'],
|
|
},
|
|
{
|
|
initial: { marketplaces: [{ name: 'ecc' }] },
|
|
fragments: ['marketplace', 'invalid'],
|
|
},
|
|
];
|
|
for (const { initial, fragments } of cases) {
|
|
withFixture(initial, fixture => {
|
|
assertThrowsContaining(
|
|
() => setupClaudePlugin(setupOptions(fixture, { scope: 'user' })),
|
|
fragments
|
|
);
|
|
assert.deepStrictEqual(mutationCalls(readCalls(fixture)), []);
|
|
});
|
|
}
|
|
});
|
|
|
|
test('managed rules-only state is allowed but overlapping managed content is rejected', () => {
|
|
withFixture({}, fixture => {
|
|
writeManagedState(fixture, ['rules-core']);
|
|
assert.strictEqual(
|
|
setupClaudePlugin(setupOptions(fixture, { scope: 'user' })).action,
|
|
'installed'
|
|
);
|
|
});
|
|
withFixture({}, fixture => {
|
|
writeManagedState(fixture, ['rules-core', 'hooks-core']);
|
|
assertThrowsContaining(
|
|
() => setupClaudePlugin(setupOptions(fixture, { scope: 'user' })),
|
|
['managed', 'overlap']
|
|
);
|
|
assert.deepStrictEqual(mutationCalls(readCalls(fixture)), []);
|
|
});
|
|
});
|
|
|
|
test('managed overlap detection resolves symlink aliases before classifying paths', () => {
|
|
if (process.platform === 'win32') return;
|
|
|
|
withFixture({}, fixture => {
|
|
const aliasPath = path.join(fixture.configDir, 'alias');
|
|
fs.symlinkSync(fixture.configDir, aliasPath, 'dir');
|
|
writeManagedState(fixture, ['rules-core'], [{
|
|
destinationPath: path.join(aliasPath, 'hooks', 'hooks.json'),
|
|
}]);
|
|
assertThrowsContaining(
|
|
() => setupClaudePlugin(setupOptions(fixture, { scope: 'user' })),
|
|
['managed', 'overlap']
|
|
);
|
|
assert.deepStrictEqual(mutationCalls(readCalls(fixture)), []);
|
|
});
|
|
});
|
|
|
|
test('dry-run reads inventory only and never writes settings', () => {
|
|
withFixture({}, fixture => {
|
|
const result = setupClaudePlugin(setupOptions(fixture, {
|
|
scope: 'local',
|
|
hooks: 'strict',
|
|
dryRun: true,
|
|
}));
|
|
assert.strictEqual(result.action, 'would-install');
|
|
assert.strictEqual(result.dryRun, true);
|
|
assert.deepStrictEqual(mutationCalls(readCalls(fixture)), []);
|
|
assert.ok(!fs.existsSync(fixture.settingsPath));
|
|
});
|
|
});
|
|
|
|
test('provider failures stop later operations and leave settings untouched', () => {
|
|
const marketplaceArgv = [
|
|
'plugin', 'marketplace', 'add',
|
|
OFFICIAL_MARKETPLACE_URL,
|
|
'--scope', 'user',
|
|
];
|
|
const installArgv = [
|
|
'plugin', 'install', 'ecc@ecc',
|
|
'--scope', 'user',
|
|
'--config', 'hooks_enabled=true',
|
|
'--config', 'hook_profile=standard',
|
|
];
|
|
withFixture({
|
|
failures: [{
|
|
argv: installArgv,
|
|
status: 7,
|
|
stderr: 'install exploded',
|
|
times: 1,
|
|
}],
|
|
}, fixture => {
|
|
fs.writeFileSync(fixture.settingsPath, '{"theme":"dark"}\n');
|
|
assertThrowsContaining(
|
|
() => setupClaudePlugin(setupOptions(fixture, { scope: 'user' })),
|
|
['install exploded']
|
|
);
|
|
const calls = readCalls(fixture);
|
|
assert.deepStrictEqual(calls.at(-1), installArgv);
|
|
assert.strictEqual(fs.readFileSync(fixture.settingsPath, 'utf8'), '{"theme":"dark"}\n');
|
|
});
|
|
withFixture({
|
|
failures: [{
|
|
argv: marketplaceArgv,
|
|
status: 8,
|
|
stderr: 'marketplace exploded',
|
|
times: 1,
|
|
}],
|
|
}, fixture => {
|
|
assertThrowsContaining(
|
|
() => setupClaudePlugin(setupOptions(fixture, { scope: 'user' })),
|
|
['marketplace exploded']
|
|
);
|
|
const calls = readCalls(fixture);
|
|
assert.deepStrictEqual(calls.at(-1), marketplaceArgv);
|
|
assert.ok(!calls.some(argv => argv[1] === 'install'));
|
|
assert.ok(!fs.existsSync(fixture.settingsPath));
|
|
});
|
|
});
|
|
|
|
test('post-install verification rejects absent, wrong-scope, disabled, and duplicate results', () => {
|
|
const invalidVerificationResults = [
|
|
[],
|
|
[installedPlugin('project')],
|
|
[installedPlugin('user', { enabled: false })],
|
|
[installedPlugin('user'), installedPlugin('project')],
|
|
];
|
|
for (const verification of invalidVerificationResults) {
|
|
withFixture({
|
|
pluginListResponses: [[], verification],
|
|
}, fixture => {
|
|
assertThrowsContaining(
|
|
() => setupClaudePlugin(setupOptions(fixture, { scope: 'user' })),
|
|
['verify', 'ecc@ecc']
|
|
);
|
|
assert.ok(!fs.existsSync(fixture.settingsPath));
|
|
});
|
|
}
|
|
});
|
|
|
|
test('marketplace verification failure prevents plugin installation', () => {
|
|
withFixture({
|
|
marketplaceListResponses: [[], []],
|
|
}, fixture => {
|
|
assertThrowsContaining(
|
|
() => setupClaudePlugin(setupOptions(fixture, { scope: 'user' })),
|
|
['verify', 'marketplace']
|
|
);
|
|
assert.ok(!readCalls(fixture).some(argv => argv[1] === 'install'));
|
|
assert.ok(!fs.existsSync(fixture.settingsPath));
|
|
});
|
|
});
|
|
|
|
test('CLAUDE_CONFIG_DIR and paths containing spaces are honored', () => {
|
|
withFixture({}, fixture => {
|
|
const result = setupClaudePlugin(setupOptions(fixture, {
|
|
scope: 'project',
|
|
hooks: 'minimal',
|
|
}));
|
|
assert.strictEqual(path.resolve(result.settingsPath), path.resolve(fixture.settingsPath));
|
|
assert.ok(result.settingsPath.includes(' '));
|
|
assert.ok(fs.existsSync(fixture.settingsPath));
|
|
});
|
|
});
|
|
|
|
test('missing Claude executable reports an actionable recovery', () => {
|
|
withFixture({}, fixture => {
|
|
process.env.PATH = fixture.binDir;
|
|
fs.rmSync(path.join(fixture.binDir, process.platform === 'win32' ? 'claude.cmd' : 'claude'));
|
|
assertThrowsContaining(
|
|
() => setupClaudePlugin(setupOptions(fixture, { scope: 'user' })),
|
|
['claude', 'install']
|
|
);
|
|
assert.ok(!fs.existsSync(fixture.settingsPath));
|
|
});
|
|
});
|
|
|
|
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
|
process.exit(failed > 0 ? 1 : 0);
|