diff --git a/scripts/control-pane.js b/scripts/control-pane.js index 790f2a681..c5b7215f4 100755 --- a/scripts/control-pane.js +++ b/scripts/control-pane.js @@ -8,6 +8,7 @@ const { parseArgs, usage, } = require('./lib/control-pane/server'); +const { describeMissingDependencyError } = require('./lib/missing-dependency'); function openBrowser(url) { if (process.platform !== 'darwin') return; @@ -55,7 +56,7 @@ async function main(argv = process.argv) { if (require.main === module) { main().catch(error => { - console.error(`[control-pane] ${error.message}`); + console.error(`[control-pane] ${describeMissingDependencyError(error) || error.message}`); process.exit(1); }); } diff --git a/scripts/install-apply.js b/scripts/install-apply.js index 40b8c7993..1435d2ff6 100755 --- a/scripts/install-apply.js +++ b/scripts/install-apply.js @@ -19,6 +19,7 @@ const { } = require('./lib/install/request'); const { getComputeSponsorCopy } = require('./lib/compute-sponsor'); const { stripAnsi } = require('./lib/utils'); +const { describeMissingDependencyError } = require('./lib/missing-dependency'); function getHelpText() { const languages = listLegacyCompatibilityLanguages(); @@ -200,7 +201,12 @@ async function main() { printHumanPlan(result, false); } } catch (error) { - process.stderr.write(`Error: ${error.message}${getHelpText()}`); + const missingDependencyMessage = describeMissingDependencyError(error); + process.stderr.write( + missingDependencyMessage + ? `Error: ${missingDependencyMessage}\n` + : `Error: ${error.message}${getHelpText()}` + ); process.exit(1); } } diff --git a/scripts/install-plan.js b/scripts/install-plan.js index 0be25bc14..e2d5fc653 100644 --- a/scripts/install-plan.js +++ b/scripts/install-plan.js @@ -14,6 +14,7 @@ const { loadInstallConfig, } = require('./lib/install/config'); const { normalizeInstallRequest } = require('./lib/install/request'); +const { describeMissingDependencyError } = require('./lib/missing-dependency'); function showHelp() { console.log(` @@ -268,7 +269,7 @@ function main() { printPlan(plan); } } catch (error) { - console.error(`Error: ${error.message}`); + console.error(`Error: ${describeMissingDependencyError(error) || error.message}`); process.exit(1); } } diff --git a/scripts/lib/control-pane/state.js b/scripts/lib/control-pane/state.js index b6c41d056..9827443c5 100644 --- a/scripts/lib/control-pane/state.js +++ b/scripts/lib/control-pane/state.js @@ -4,9 +4,6 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); -const initSqlJs = require('sql.js'); -const toml = require('@iarna/toml'); - const { buildControlPaneActions } = require('./actions'); const SNAPSHOT_SCHEMA_VERSION = 'ecc.control-pane.snapshot.v1'; @@ -85,6 +82,10 @@ function normalizeConfig(rawConfig = {}, options = {}) { } function readTomlConfig(configPath) { + // @iarna/toml is required lazily so commands that never resolve a config + // file (e.g. `--help`, or a first run before any ecc2.toml exists) don't + // need it on the require path. + const toml = require('@iarna/toml'); const raw = fs.readFileSync(configPath, 'utf8'); return toml.parse(raw); } @@ -113,6 +114,10 @@ function resolveControlPaneConfig(options = {}) { async function openSqlDatabase(dbPath) { if (!dbPath || !fs.existsSync(dbPath)) return null; + // sql.js is required lazily so commands that never open an existing + // ecc2.db (e.g. `--help`, or a first run before any db exists) don't need + // it on the require path. + const initSqlJs = require('sql.js'); const SQL = await initSqlJs(); const buffer = fs.readFileSync(dbPath); return new SQL.Database(buffer); diff --git a/scripts/lib/install/config.js b/scripts/lib/install/config.js index 2ba012267..32c1b47a9 100644 --- a/scripts/lib/install/config.js +++ b/scripts/lib/install/config.js @@ -2,7 +2,6 @@ const fs = require('fs'); const path = require('path'); -const Ajv = require('ajv'); const DEFAULT_INSTALL_CONFIG = 'ecc-install.json'; const CONFIG_SCHEMA_PATH = path.join(__dirname, '..', '..', '..', 'schemas', 'ecc-install-config.schema.json'); @@ -22,6 +21,9 @@ function getValidator() { return cachedValidator; } + // ajv is required lazily so scripts that never load an install config (the + // common case, e.g. `--list-profiles`) don't need it on the require path. + const Ajv = require('ajv'); const schema = readJson(CONFIG_SCHEMA_PATH, 'ecc-install-config.schema.json'); const ajv = new Ajv({ allErrors: true }); cachedValidator = ajv.compile(schema); diff --git a/scripts/lib/missing-dependency.js b/scripts/lib/missing-dependency.js new file mode 100644 index 000000000..7292cd9d5 --- /dev/null +++ b/scripts/lib/missing-dependency.js @@ -0,0 +1,38 @@ +'use strict'; + +// Production dependencies declared in package.json's "dependencies" field. +// `npm install` never runs when ECC is installed via the Claude Code plugin +// marketplace (a plain git clone), so these can be missing at runtime even +// though the code that needs them is fine. +const RUNTIME_DEPENDENCY_VERSIONS = { + ajv: '8.20.0', + 'sql.js': '1.14.2', + 'js-yaml': '4.3.1', + '@iarna/toml': '2.2.5', +}; + +function describeMissingDependencyError(error) { + if (!error || error.code !== 'MODULE_NOT_FOUND') { + return null; + } + + const match = /Cannot find module '([^']+)'/.exec(error.message || ''); + const moduleName = match && match[1]; + const pinnedVersion = moduleName && RUNTIME_DEPENDENCY_VERSIONS[moduleName]; + + if (!pinnedVersion) { + return null; + } + + return ( + `Missing dependency '${moduleName}'. ECC's production dependencies aren't installed ` + + '(this happens when ECC was installed via the Claude Code plugin marketplace, which ' + + 'clones the repo but never runs npm install). Run "npm install" from the ECC repo ' + + `root, or install just this package with "npm install --no-save ${moduleName}@${pinnedVersion}".` + ); +} + +module.exports = { + RUNTIME_DEPENDENCY_VERSIONS, + describeMissingDependencyError, +}; diff --git a/scripts/memory-mcp.mjs b/scripts/memory-mcp.mjs index 7821efbfc..741f864fb 100755 --- a/scripts/memory-mcp.mjs +++ b/scripts/memory-mcp.mjs @@ -3,7 +3,16 @@ import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); -const Ajv = require('ajv'); +const { describeMissingDependencyError } = require('./lib/missing-dependency.js'); + +let Ajv; +try { + Ajv = require('ajv'); +} catch (error) { + process.stderr.write(`ECC memory MCP startup failed: ${describeMissingDependencyError(error) || error.message}\n`); + process.exit(1); +} + const fs = require('fs'); const path = require('path'); const { fileURLToPath } = require('url'); diff --git a/tests/lib/missing-dependency.test.js b/tests/lib/missing-dependency.test.js new file mode 100644 index 000000000..61f4af46b --- /dev/null +++ b/tests/lib/missing-dependency.test.js @@ -0,0 +1,70 @@ +/** + * Tests for scripts/lib/missing-dependency.js + */ + +const assert = require('assert'); + +const { describeMissingDependencyError } = require('../../scripts/lib/missing-dependency'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +function moduleNotFoundError(moduleName, requireStack) { + const error = new Error( + `Cannot find module '${moduleName}'\nRequire stack:\n${requireStack.map(entry => `- ${entry}`).join('\n')}` + ); + error.code = 'MODULE_NOT_FOUND'; + return error; +} + +function runTests() { + console.log('\n=== Testing missing-dependency.js ===\n'); + + let passed = 0; + let failed = 0; + + if (test('describes a missing production dependency with an install command', () => { + const error = moduleNotFoundError('ajv', [ + 'scripts/lib/install/config.js', + 'scripts/install-plan.js', + ]); + const message = describeMissingDependencyError(error); + assert.ok(message.includes("'ajv'")); + assert.ok(message.includes('npm install')); + assert.ok(message.includes('ajv@8.20.0')); + })) passed++; else failed++; + + if (test('recognizes every declared production dependency', () => { + for (const moduleName of ['ajv', 'sql.js', 'js-yaml', '@iarna/toml']) { + const error = moduleNotFoundError(moduleName, ['some/file.js']); + assert.ok(describeMissingDependencyError(error), `expected a message for ${moduleName}`); + } + })) passed++; else failed++; + + if (test('returns null for an unrelated MODULE_NOT_FOUND error', () => { + const error = moduleNotFoundError('./lib/some-local-file', ['scripts/foo.js']); + assert.strictEqual(describeMissingDependencyError(error), null); + })) passed++; else failed++; + + if (test('returns null for a non-MODULE_NOT_FOUND error', () => { + assert.strictEqual(describeMissingDependencyError(new Error('boom')), null); + })) passed++; else failed++; + + if (test('returns null for a falsy error', () => { + assert.strictEqual(describeMissingDependencyError(null), null); + })) passed++; else failed++; + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/scripts/plugin-install-without-node-modules.test.js b/tests/scripts/plugin-install-without-node-modules.test.js new file mode 100644 index 000000000..b89ab74e2 --- /dev/null +++ b/tests/scripts/plugin-install-without-node-modules.test.js @@ -0,0 +1,106 @@ +/** + * Regression test for https://github.com/affaan-m/ECC/issues/2822 + * + * When ECC is installed through the Claude Code plugin marketplace, the + * marketplace directory is a plain git clone: `npm install` never runs, so + * node_modules never exists. This copies just the runtime files (scripts/, + * schemas/, manifests/) into a temp directory with no node_modules anywhere + * in its ancestor chain, which reproduces that install exactly, and asserts + * that the user-facing entry points named in the issue still work. + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const REPO_ROOT = path.join(__dirname, '..', '..'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +function copyRuntimeFiles(destDir) { + for (const entry of ['scripts', 'schemas', 'manifests']) { + fs.cpSync(path.join(REPO_ROOT, entry), path.join(destDir, entry), { recursive: true }); + } +} + +function run(scriptRelativePath, args, cwd) { + try { + const stdout = execFileSync('node', [path.join(cwd, scriptRelativePath), ...args], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 10000, + }); + return { code: 0, stdout, stderr: '' }; + } catch (error) { + return { + code: error.status ?? 1, + stdout: error.stdout || '', + stderr: error.stderr || '', + }; + } +} + +function runTests() { + console.log('\n=== Testing plugin install without node_modules (issue #2822) ===\n'); + + let passed = 0; + let failed = 0; + + // No node_modules exists anywhere above os.tmpdir(), so this faithfully + // reproduces a plugin-marketplace git clone with no dependencies installed. + const pluginDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-plugin-install-')); + + try { + copyRuntimeFiles(pluginDir); + + if (test('install-plan.js --list-profiles runs without ajv installed', () => { + const result = run('scripts/install-plan.js', ['--list-profiles'], pluginDir); + assert.strictEqual(result.code, 0, `stderr: ${result.stderr}`); + assert.ok(!result.stderr.includes('Cannot find module'), `stderr: ${result.stderr}`); + assert.ok(result.stdout.includes('Install profiles')); + })) passed++; else failed++; + + if (test('install-plan.js --list-modules runs without ajv installed', () => { + const result = run('scripts/install-plan.js', ['--list-modules'], pluginDir); + assert.strictEqual(result.code, 0, `stderr: ${result.stderr}`); + assert.ok(result.stdout.includes('Install modules')); + })) passed++; else failed++; + + if (test('control-pane.js --help runs without sql.js installed', () => { + const result = run('scripts/control-pane.js', ['--help'], pluginDir); + assert.strictEqual(result.code, 0, `stderr: ${result.stderr}`); + assert.ok(!result.stderr.includes('Cannot find module'), `stderr: ${result.stderr}`); + assert.ok(result.stdout.includes('Usage:')); + })) passed++; else failed++; + + if (test('install-plan.js --config gives an actionable error when ajv is genuinely missing', () => { + const configPath = path.join(pluginDir, 'ecc-install.json'); + fs.writeFileSync(configPath, JSON.stringify({ version: 1, profile: 'minimal' })); + + const result = run('scripts/install-plan.js', ['--config', configPath], pluginDir); + assert.strictEqual(result.code, 1); + assert.ok(result.stderr.includes("Missing dependency 'ajv'"), `stderr: ${result.stderr}`); + assert.ok(result.stderr.includes('npm install'), `stderr: ${result.stderr}`); + assert.ok(!result.stderr.includes('Require stack'), `stderr should not leak a raw stack trace: ${result.stderr}`); + })) passed++; else failed++; + } finally { + fs.rmSync(pluginDir, { recursive: true, force: true }); + } + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests();