mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-12 20:57:59 +02:00
fix(install): ship ajv/sql.js with the plugin install bundle (#2822)
install-plan.js and install-apply.js both require ./lib/install/config at load time, and that module required ajv unconditionally at the top of the file even though ajv is only actually used when validating an ecc-install.json. When ECC is installed via the Claude Code plugin marketplace, the marketplace directory is a bare git clone with no node_modules, so requiring ajv crashes commands like --list-profiles that never touch install-config validation at all. Same root cause in scripts/lib/control-pane/state.js: sql.js and @iarna/toml were required at module scope even though they are only used inside openSqlDatabase() and readTomlConfig(), so control-pane.js --help crashed too. Make both requires lazy so they only load when the feature that actually needs them runs. For the case where ajv/sql.js/js-yaml/@iarna-toml is genuinely needed and still missing, add a small helper that turns the raw MODULE_NOT_FOUND into an actionable message naming the package and the install command, instead of a stack trace (install-apply.js) or, worse, an unhandled crash with a usage banner tacked on that reads like a bad argument (install-plan.js, control-pane.js). Applied the same helper to memory-mcp.mjs, where ajv is genuinely load-bearing (it compiles every MCP tool's JSON schema up front) so it can't be made lazy the same way. Added a regression test that copies just scripts/, schemas/, and manifests/ into a directory with no node_modules anywhere above it in the filesystem, which reproduces the plugin-marketplace install exactly, and asserts install-plan.js and control-pane.js still work.
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
+10
-1
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user