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:
wakqasahmed
2026-09-07 16:26:10 -04:00
committed by haelyra
parent f2bcc00d69
commit ce11e8f690
9 changed files with 246 additions and 8 deletions
+2 -1
View File
@@ -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);
});
}
+7 -1
View File
@@ -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);
}
}
+2 -1
View File
@@ -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);
}
}
+8 -3
View File
@@ -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);
+3 -1
View File
@@ -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);
+38
View File
@@ -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
View File
@@ -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');