mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-30 12:25:19 +00:00
fix(opencode): make the declined-hooks plugin stub satisfy the V2 loader schema
The inert stub written when hook consent is not granted was
`export default async () => ({})`. OpenCode v2 validates every plugin
module before running it and requires the default export to be a definition
object with an `id` and an `effect` or `setup` function, so a declined or
runtime-less home install printed the same startup error as the V1 hook
plugin:
PluginModule.LoadError: Plugin must export a default definition with an id
and an effect or setup function.
(cause: SchemaError(Expected object at ["default"]))
Write `export default { id: "ecc-hooks", setup() {} }` instead. It keeps the
inert behaviour (no imports, no module initialization of the replaced
plugin) and satisfies the loader.
Tests that pinned the generated bytes now compare against
getDisabledOpenCodePluginContent() rather than a duplicated literal, and
tests/opencode-plugin-loader-contract.test.js asserts the loader contract on
the generated payload. That test fails on the previous stub
("default export must be an object, got function").
Refs #3537. The same assertion should cover .opencode/plugins/*.ts once a V2
entrypoint lands (#3536, #3224); those plugins are scanned independently in
home installs, so both the barrel and the implementation are loaded.
This commit is contained in:
6 files changed
+123
-13
No files matched your search
@@ -183,7 +183,12 @@ function isOpenCodePluginEntrypoint(operation = {}) {
|
||||
function getDisabledOpenCodePluginContent() {
|
||||
// OpenCode discovers plugins independently of opencode.json registration.
|
||||
// Do not import the original module: even module initialization has effects.
|
||||
return 'export default async () => ({});\n';
|
||||
// The stub must still satisfy the V2 loader schema: the default export has to
|
||||
// be a definition object with an id and an effect or setup function. A factory
|
||||
// (`export default async () => ({})`) is rejected at startup with
|
||||
// PluginModule.LoadError / SchemaError(Expected object at ["default"]), which
|
||||
// is the same failure reported for the V1 hook plugin in issue #3537.
|
||||
return 'export default { id: "ecc-hooks", setup() {} };\n';
|
||||
}
|
||||
|
||||
function isOpenCodeHookActivationOperation(operation = {}) {
|
||||
|
||||
@@ -22,7 +22,7 @@ const {
|
||||
const { applyInstallPlan: applyInstallPlanDirect } = require('../../scripts/lib/install/apply');
|
||||
const { normalizeInstallRequest } = require('../../scripts/lib/install/request');
|
||||
const { createInstallPlanFromRequest } = require('../../scripts/lib/install/runtime');
|
||||
const { withHookConsent } = require('../../scripts/lib/install/hook-consent');
|
||||
const { getDisabledOpenCodePluginContent, withHookConsent } = require('../../scripts/lib/install/hook-consent');
|
||||
const { repairInstalledStates, uninstallInstalledStates } = require('../../scripts/lib/install-lifecycle');
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
||||
@@ -53,7 +53,7 @@ function operationFor(plan, suffix) {
|
||||
));
|
||||
}
|
||||
|
||||
const INERT_OPENCODE_PLUGIN = 'export default async () => ({});\n';
|
||||
const INERT_OPENCODE_PLUGIN = getDisabledOpenCodePluginContent();
|
||||
const OPENCODE_ENTRYPOINTS = ['ecc-hooks.ts', 'index.ts'];
|
||||
|
||||
function snapshotFiles(root) {
|
||||
|
||||
@@ -29,6 +29,7 @@ const {
|
||||
materializeManagedHooks,
|
||||
} = require('../../scripts/lib/install/claude-settings');
|
||||
const { readHooksConfig } = require('../../scripts/lib/hooks-config');
|
||||
const { getDisabledOpenCodePluginContent } = require('../../scripts/lib/install/hook-consent');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..', '..');
|
||||
const CURRENT_PACKAGE_VERSION = JSON.parse(
|
||||
@@ -2048,7 +2049,7 @@ function runTests() {
|
||||
applyInstallPlan(plan);
|
||||
const pluginPaths = ['ecc-hooks.ts', 'index.ts'].map(name => path.join(plan.targetRoot, 'plugins', name));
|
||||
for (const pluginPath of pluginPaths) {
|
||||
assert.strictEqual(fs.readFileSync(pluginPath, 'utf8'), 'export default async () => ({});\n');
|
||||
assert.strictEqual(fs.readFileSync(pluginPath, 'utf8'), getDisabledOpenCodePluginContent());
|
||||
fs.unlinkSync(pluginPath);
|
||||
}
|
||||
const before = buildDoctorReport({ repoRoot: REPO_ROOT, homeDir, projectRoot: homeDir, targets: ['opencode'] });
|
||||
@@ -2067,7 +2068,7 @@ function runTests() {
|
||||
assert.strictEqual(buildCalls, 1, 'Only the injected inert builder is used');
|
||||
assert.strictEqual(repaired.results[0].status, 'repaired');
|
||||
for (const pluginPath of pluginPaths) {
|
||||
assert.strictEqual(fs.readFileSync(pluginPath, 'utf8'), 'export default async () => ({});\n');
|
||||
assert.strictEqual(fs.readFileSync(pluginPath, 'utf8'), getDisabledOpenCodePluginContent());
|
||||
}
|
||||
const state = readInstallState(plan.installStatePath);
|
||||
assert.strictEqual(state.request.hookConsent, 'declined');
|
||||
@@ -2101,7 +2102,7 @@ function runTests() {
|
||||
});
|
||||
assert.strictEqual(result.results[0].status, 'repaired');
|
||||
for (const name of ['ecc-hooks.ts', 'index.ts']) {
|
||||
assert.strictEqual(fs.readFileSync(path.join(recorded.targetRoot, 'plugins', name), 'utf8'), 'export default async () => ({});\n');
|
||||
assert.strictEqual(fs.readFileSync(path.join(recorded.targetRoot, 'plugins', name), 'utf8'), getDisabledOpenCodePluginContent());
|
||||
}
|
||||
assert.ok(!JSON.parse(fs.readFileSync(path.join(recorded.targetRoot, 'opencode.json'), 'utf8')).plugin.includes('./plugins'));
|
||||
const state = readInstallState(recorded.installStatePath);
|
||||
|
||||
@@ -11,7 +11,7 @@ const { assertFilePostimage } = require('./helpers/assert-file-postimage');
|
||||
const fileSystem = { ...fs };
|
||||
const load = createFileSystemLoader(fileSystem);
|
||||
const { applyInstallPlan } = load(require.resolve('../../scripts/lib/install/apply'));
|
||||
const { withHookConsent } = require('../../scripts/lib/install/hook-consent');
|
||||
const { getDisabledOpenCodePluginContent, withHookConsent } = require('../../scripts/lib/install/hook-consent');
|
||||
const { repairInstalledStates, buildDoctorReport } = load(require.resolve('../../scripts/lib/install-lifecycle'));
|
||||
const { createInstallState, readInstallState, writeInstallState } = require('../../scripts/lib/install-state');
|
||||
const { withOpenCodeInstallLocks } = load(require.resolve('../../scripts/lib/install/opencode-install-lock'));
|
||||
@@ -325,7 +325,7 @@ function runTests() {
|
||||
assert.ok(!state.resolution.selectedModules.includes('hooks-runtime'));
|
||||
assert.notStrictEqual(state.request.hookConsent, 'enabled');
|
||||
assert.strictEqual(fs.readFileSync(path.join(value.canonicalRoot, 'plugins/ecc-hooks.ts'), 'utf8'),
|
||||
'export default async () => ({});\n');
|
||||
getDisabledOpenCodePluginContent());
|
||||
assert.deepStrictEqual(JSON.parse(fs.readFileSync(path.join(value.canonicalRoot, 'opencode.json'))).plugin, []);
|
||||
for (const relative of ['opencode.json', 'plugins/ecc-hooks.ts', 'ecc-install-state.json']) {
|
||||
assert.strictEqual(fs.existsSync(path.join(value.legacyRoot, relative)), false);
|
||||
|
||||
@@ -9,7 +9,11 @@ const { createFileSystemLoader } = require('./helpers/load-with-file-system');
|
||||
const fileSystem = { ...fs };
|
||||
const load = createFileSystemLoader(fileSystem);
|
||||
const { applyInstallPlan } = load(require.resolve('../../scripts/lib/install/apply'));
|
||||
const { parseJsoncConfig, withHookConsent } = require('../../scripts/lib/install/hook-consent');
|
||||
const {
|
||||
getDisabledOpenCodePluginContent,
|
||||
parseJsoncConfig,
|
||||
withHookConsent,
|
||||
} = require('../../scripts/lib/install/hook-consent');
|
||||
const { createInstallState, readInstallState, writeInstallState } = require('../../scripts/lib/install-state');
|
||||
const { buildDoctorReport, repairInstalledStates } = load(require.resolve('../../scripts/lib/install-lifecycle'));
|
||||
|
||||
@@ -326,7 +330,7 @@ function runTests() {
|
||||
assert.strictEqual(result.status, 'repaired', result.error);
|
||||
assert.strictEqual(result.stateRefreshed, true);
|
||||
assert.strictEqual(fs.readFileSync(path.join(value.targetRoot, 'plugins/ecc-hooks.ts'), 'utf8'),
|
||||
'export default async () => ({});\n');
|
||||
getDisabledOpenCodePluginContent());
|
||||
assert.deepStrictEqual(JSON.parse(fs.readFileSync(path.join(value.targetRoot, 'opencode.json'))),
|
||||
{ plugin: [], userSetting: true });
|
||||
assert.strictEqual(fs.existsSync(`${value.installStatePath}.ecc.lock`), false);
|
||||
@@ -348,7 +352,7 @@ function runTests() {
|
||||
assert.ok(!operations.some(operation => operation.destinationPath === destination), 'Do not adopt a user config');
|
||||
}
|
||||
assert.strictEqual(fs.readFileSync(path.join(value.targetRoot, 'plugins/ecc-hooks.ts'), 'utf8'),
|
||||
'export default async () => ({});\n');
|
||||
getDisabledOpenCodePluginContent());
|
||||
assert.strictEqual(fs.existsSync(`${value.installStatePath}.ecc.lock`), false);
|
||||
}));
|
||||
}
|
||||
@@ -446,7 +450,7 @@ function runTests() {
|
||||
// must never adopt the preserved user config after this refusal.
|
||||
const checkpoint = readInstallState(value.installStatePath);
|
||||
assert.deepStrictEqual(checkpoint.operations.map(operation => operation.destinationPath), [plugin]);
|
||||
assert.strictEqual(fs.readFileSync(plugin, 'utf8'), 'export default async () => ({});\n');
|
||||
assert.strictEqual(fs.readFileSync(plugin, 'utf8'), getDisabledOpenCodePluginContent());
|
||||
assert.strictEqual(checkpoint.operations[0].contentSha256, sha256(fs.readFileSync(plugin)));
|
||||
assert.strictEqual(fs.existsSync(`${value.installStatePath}.ecc.lock`), false);
|
||||
}));
|
||||
@@ -629,7 +633,7 @@ function runTests() {
|
||||
operations: [...value.basePlan.operations, operation] }, consent);
|
||||
assert.strictEqual(applyInstallPlan(plan).applied, true);
|
||||
assert.strictEqual(fs.readFileSync(destinationPath, 'utf8'), consent === 'enabled'
|
||||
? content : 'export default async () => ({});\n');
|
||||
? content : getDisabledOpenCodePluginContent());
|
||||
}));
|
||||
}
|
||||
test('nested package metadata fails before writing and never receives a JavaScript tombstone', () => fixture(value => {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Tests for the OpenCode V2 plugin loader contract.
|
||||
*
|
||||
* OpenCode v2 validates every plugin module before running it: the default export
|
||||
* must be an object with an `id` and an `effect` or `setup` function
|
||||
* (packages/core/src/plugin/module.ts). A factory default export, which is what a
|
||||
* V1 plugin used, fails at startup with
|
||||
*
|
||||
* PluginModule.LoadError: Plugin must export a default definition with an id and
|
||||
* an effect or setup function. (cause: SchemaError(Expected object at ["default"]))
|
||||
*
|
||||
* so a declined-hooks install still printed the same error as the V1 plugin did
|
||||
* (issue #3537). These tests pin the contract on the payload ECC generates itself.
|
||||
*
|
||||
* Run with: node tests/opencode-plugin-loader-contract.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { pathToFileURL } = require('url');
|
||||
|
||||
const { getDisabledOpenCodePluginContent } = require('../scripts/lib/install/hook-consent');
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Loads generated plugin source the way the OpenCode loader does: import it. */
|
||||
async function loadPluginModule(source) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-plugin-contract-'));
|
||||
const file = path.join(dir, 'plugin.mjs');
|
||||
fs.writeFileSync(file, source);
|
||||
return import(pathToFileURL(file).href);
|
||||
}
|
||||
|
||||
/** Mirrors the loader schema: { default: { id, effect } | { id, setup } }. */
|
||||
function assertV2PluginDefinition(namespace, label) {
|
||||
const definition = namespace.default;
|
||||
assert.ok(
|
||||
definition && typeof definition === 'object',
|
||||
`${label}: default export must be an object, got ${typeof definition}`
|
||||
);
|
||||
assert.strictEqual(typeof definition.id, 'string', `${label}: default export needs a string id`);
|
||||
assert.ok(
|
||||
typeof definition.effect === 'function' || typeof definition.setup === 'function',
|
||||
`${label}: default export needs an effect or setup function`
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
const check = async (name, fn) => {
|
||||
try {
|
||||
await fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
passed += 1;
|
||||
} catch (err) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
failed += 1;
|
||||
}
|
||||
};
|
||||
|
||||
const stub = getDisabledOpenCodePluginContent();
|
||||
const stubModule = await loadPluginModule(stub);
|
||||
|
||||
await check('declined-hooks stub satisfies the V2 loader schema', () => {
|
||||
assertV2PluginDefinition(stubModule, 'disabled OpenCode plugin stub');
|
||||
});
|
||||
|
||||
await check('the stub check still rejects a V1 factory default export', async () => {
|
||||
const v1 = await loadPluginModule('export default async () => ({});\n');
|
||||
assert.throws(() => assertV2PluginDefinition(v1, 'V1 factory'), /default export must be an object/);
|
||||
});
|
||||
|
||||
await check('the stub does not import the module it replaces', () => {
|
||||
assert.ok(!/^\s*import\s/m.test(stub), 'stub must stay free of imports');
|
||||
});
|
||||
|
||||
// The same check applies to every shipped plugin entrypoint once a V2 entrypoint
|
||||
// lands (.opencode/plugins/*.ts is scanned independently in home installs).
|
||||
// See issue #3537 and the API ports in #3536 and #3224.
|
||||
|
||||
console.log(`\n ${passed} passed, ${failed} failed\n`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in new issue
Block a user