From 3ef3b0e6a27ca7ff47a3733a04b7a429bccd2c33 Mon Sep 17 00:00:00 2001 From: gaurav0107 Date: Fri, 22 May 2026 21:35:12 +0530 Subject: [PATCH 1/4] fix(install-targets): validate compiled OpenCode plugin before install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #2040. The OpenCode home installer copies plugin sources from `.opencode/` into the user's install root, but only `.opencode/dist/` (produced by `scripts/build-opencode.js`) contains the compiled JavaScript that OpenCode actually loads. From a plain `git clone`, that directory does not exist until `npm run build:opencode` (or `npm pack`/`prepack`) has run, so users who go straight to `node scripts/install-apply.js --target opencode --profile full` end up with `/` slash commands that show in autocomplete but silently do nothing — the plugin entry point loads, the runtime has no JS to execute. This change adds a `validate(input)` override on `opencode-home` that fails fast with an actionable message pointing at `node scripts/build-opencode.js` (or `npm run build:opencode`) when `.opencode/dist/index.js` is missing in the source repo. It does not change the install destination or any other runtime behaviour, and it has no effect when the plugin has already been built (the common case once `prepack` runs during `npm pack`). Tests: - `tests/lib/install-targets.test.js`: adds the previously-missing per-adapter resolution test for `opencode-home`, plus two `validate()` cases that cover the missing-build and built-payload code paths via temp `repoRoot` directories. - `node tests/run-all.js` is green (2571 passed, 0 failed). Docs: - `.opencode/README.md`: documents the build-first requirement under the existing "Direct Use" option so future users running the installer from a clone hit the build step before the validate gate fires. --- .opencode/README.md | 13 +++++ scripts/lib/install-targets/opencode-home.js | 45 ++++++++++++++++- tests/lib/install-targets.test.js | 51 ++++++++++++++++++++ 3 files changed, 108 insertions(+), 1 deletion(-) diff --git a/.opencode/README.md b/.opencode/README.md index 6fe4657f53..e8c845fcff 100644 --- a/.opencode/README.md +++ b/.opencode/README.md @@ -57,6 +57,19 @@ cd ECC opencode ``` +If you also want to apply the ECC home install +(`node scripts/install-apply.js --target opencode --profile full`), build the +plugin first so the compiled payload at `.opencode/dist/` exists: + +```bash +node scripts/build-opencode.js # or: npm run build:opencode +node scripts/install-apply.js --target opencode --profile full +``` + +Without `.opencode/dist/index.js`, OpenCode will detect the slash commands +but silently skip plugin hooks and tools. The installer now fails fast with +a pointer to this command if the build step is missing. + ## Features ### Agents (12) diff --git a/scripts/lib/install-targets/opencode-home.js b/scripts/lib/install-targets/opencode-home.js index c8e629e47b..b4c50f9a52 100644 --- a/scripts/lib/install-targets/opencode-home.js +++ b/scripts/lib/install-targets/opencode-home.js @@ -1,4 +1,46 @@ -const { createInstallTargetAdapter } = require('./helpers'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + buildValidationIssue, + createInstallTargetAdapter, +} = require('./helpers'); + +const COMPILED_PLUGIN_RELATIVE_PATH = path.join('.opencode', 'dist', 'index.js'); +const BUILD_COMMAND_HINT = 'node scripts/build-opencode.js (or: npm run build:opencode)'; + +function defaultValidateOpencodeHome(input = {}) { + if (!input.homeDir && !os.homedir()) { + return [ + buildValidationIssue( + 'error', + 'missing-home-dir', + 'homeDir is required for home install targets' + ), + ]; + } + + if (!input.repoRoot) { + return []; + } + + const compiledPluginPath = path.join(input.repoRoot, COMPILED_PLUGIN_RELATIVE_PATH); + if (!fs.existsSync(compiledPluginPath)) { + return [ + buildValidationIssue( + 'error', + 'opencode-plugin-not-built', + 'OpenCode install requires the compiled plugin payload at ' + + `${COMPILED_PLUGIN_RELATIVE_PATH}, but it was not found. Run ` + + `${BUILD_COMMAND_HINT} from the repo root before re-running the installer.`, + { compiledPluginPath } + ), + ]; + } + + return []; +} module.exports = createInstallTargetAdapter({ id: 'opencode-home', @@ -7,4 +49,5 @@ module.exports = createInstallTargetAdapter({ rootSegments: ['.opencode'], installStatePathSegments: ['ecc-install-state.json'], nativeRootRelativePath: '.opencode', + validate: defaultValidateOpencodeHome, }); diff --git a/tests/lib/install-targets.test.js b/tests/lib/install-targets.test.js index d74d95aa28..1a51bf4755 100644 --- a/tests/lib/install-targets.test.js +++ b/tests/lib/install-targets.test.js @@ -3,6 +3,8 @@ */ const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); const path = require('path'); const { @@ -966,6 +968,55 @@ function runTests() { ); })) passed++; else failed++; + if (test('resolves opencode adapter root and install-state path from home dir', () => { + const adapter = getInstallTargetAdapter('opencode'); + const homeDir = '/Users/example'; + const root = adapter.resolveRoot({ homeDir }); + const statePath = adapter.getInstallStatePath({ homeDir }); + + assert.strictEqual(adapter.id, 'opencode-home'); + assert.strictEqual(adapter.target, 'opencode'); + assert.strictEqual(adapter.kind, 'home'); + assert.strictEqual(root, path.join(homeDir, '.opencode')); + assert.strictEqual(statePath, path.join(homeDir, '.opencode', 'ecc-install-state.json')); + })) passed++; else failed++; + + if (test('opencode adapter validate reports an error when compiled plugin is missing', () => { + const adapter = getInstallTargetAdapter('opencode'); + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'install-targets-opencode-missing-')); + try { + const issues = adapter.validate({ homeDir: '/Users/example', repoRoot }); + assert.strictEqual(issues.length, 1, 'Should surface exactly one validation issue'); + assert.strictEqual(issues[0].severity, 'error'); + assert.strictEqual(issues[0].code, 'opencode-plugin-not-built'); + assert.ok( + issues[0].message.includes('.opencode/dist/index.js') || issues[0].message.includes('.opencode\\dist\\index.js'), + 'Validation message should reference the missing compiled plugin path' + ); + assert.ok( + issues[0].message.includes('build-opencode.js') || issues[0].message.includes('build:opencode'), + 'Validation message should hint at the build command' + ); + } finally { + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('opencode adapter validate passes once compiled plugin payload exists', () => { + const adapter = getInstallTargetAdapter('opencode'); + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'install-targets-opencode-built-')); + try { + const distDir = path.join(repoRoot, '.opencode', 'dist'); + fs.mkdirSync(distDir, { recursive: true }); + fs.writeFileSync(path.join(distDir, 'index.js'), '// stub\n'); + + const issues = adapter.validate({ homeDir: '/Users/example', repoRoot }); + assert.deepStrictEqual(issues, [], 'Should not surface validation issues when plugin is built'); + } finally { + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + })) passed++; else failed++; + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); } From 439fed94f8618c14d0f7cdbfc6454aa4b72f9d8c Mon Sep 17 00:00:00 2001 From: gaurav0107 Date: Fri, 22 May 2026 21:56:25 +0530 Subject: [PATCH 2/4] fix(install-targets): also require dist/plugins and dist/tools for opencode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit flagged that the opencode-home validate gate only checked `.opencode/dist/index.js`, but the OpenCode runtime contract also needs `.opencode/dist/plugins/` (plugin hooks) and `.opencode/dist/tools/` (custom tools) — both are produced by `scripts/build-opencode.js` and both are listed in `tests/scripts/build-opencode.test.js` as required npm-pack contents. A half-finished or interrupted compile that left only `index.js` would have squeezed past the previous gate and reproduced the original `/`-commands-do-nothing symptom from #2040. The validate function now collects every missing required artefact into a single `error` issue with `missingRelativePaths` metadata so the maintainer (and any caller code) can see exactly which artefact triggered the failure. The error message lists all missing paths in one line and still points at `node scripts/build-opencode.js`. A new test case `opencode adapter validate reports a partial build (entry present, runtime dirs absent)` covers the half-built scenario, and the existing "passes once compiled plugin payload exists" test now materializes both subdirectories so it asserts the full happy path. Full suite still green: `node tests/run-all.js` → 2572 passed, 0 failed. --- scripts/lib/install-targets/opencode-home.js | 30 +++++++++++++++----- tests/lib/install-targets.test.js | 30 ++++++++++++++++++-- 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/scripts/lib/install-targets/opencode-home.js b/scripts/lib/install-targets/opencode-home.js index b4c50f9a52..db7c4ddbc2 100644 --- a/scripts/lib/install-targets/opencode-home.js +++ b/scripts/lib/install-targets/opencode-home.js @@ -7,7 +7,12 @@ const { createInstallTargetAdapter, } = require('./helpers'); -const COMPILED_PLUGIN_RELATIVE_PATH = path.join('.opencode', 'dist', 'index.js'); +const COMPILED_PLUGIN_DIST_DIR = path.join('.opencode', 'dist'); +const REQUIRED_COMPILED_RELATIVE_PATHS = Object.freeze([ + path.join(COMPILED_PLUGIN_DIST_DIR, 'index.js'), + path.join(COMPILED_PLUGIN_DIST_DIR, 'plugins'), + path.join(COMPILED_PLUGIN_DIST_DIR, 'tools'), +]); const BUILD_COMMAND_HINT = 'node scripts/build-opencode.js (or: npm run build:opencode)'; function defaultValidateOpencodeHome(input = {}) { @@ -25,16 +30,27 @@ function defaultValidateOpencodeHome(input = {}) { return []; } - const compiledPluginPath = path.join(input.repoRoot, COMPILED_PLUGIN_RELATIVE_PATH); - if (!fs.existsSync(compiledPluginPath)) { + const missingPaths = REQUIRED_COMPILED_RELATIVE_PATHS + .map(relativePath => ({ + relativePath, + absolutePath: path.join(input.repoRoot, relativePath), + })) + .filter(entry => !fs.existsSync(entry.absolutePath)); + + if (missingPaths.length > 0) { + const missingList = missingPaths.map(entry => entry.relativePath).join(', '); return [ buildValidationIssue( 'error', 'opencode-plugin-not-built', - 'OpenCode install requires the compiled plugin payload at ' - + `${COMPILED_PLUGIN_RELATIVE_PATH}, but it was not found. Run ` - + `${BUILD_COMMAND_HINT} from the repo root before re-running the installer.`, - { compiledPluginPath } + 'OpenCode install requires the compiled plugin payload under ' + + `${COMPILED_PLUGIN_DIST_DIR}, but the following artefact(s) were not found: ` + + `${missingList}. Run ${BUILD_COMMAND_HINT} from the repo root before ` + + 're-running the installer.', + { + missingPaths: missingPaths.map(entry => entry.absolutePath), + missingRelativePaths: missingPaths.map(entry => entry.relativePath), + } ), ]; } diff --git a/tests/lib/install-targets.test.js b/tests/lib/install-targets.test.js index 1a51bf4755..2ffc373a4f 100644 --- a/tests/lib/install-targets.test.js +++ b/tests/lib/install-targets.test.js @@ -990,13 +990,36 @@ function runTests() { assert.strictEqual(issues[0].severity, 'error'); assert.strictEqual(issues[0].code, 'opencode-plugin-not-built'); assert.ok( - issues[0].message.includes('.opencode/dist/index.js') || issues[0].message.includes('.opencode\\dist\\index.js'), - 'Validation message should reference the missing compiled plugin path' + issues[0].message.includes('.opencode/dist') || issues[0].message.includes('.opencode\\dist'), + 'Validation message should reference the .opencode/dist payload location' ); assert.ok( issues[0].message.includes('build-opencode.js') || issues[0].message.includes('build:opencode'), 'Validation message should hint at the build command' ); + assert.ok(Array.isArray(issues[0].missingRelativePaths) && issues[0].missingRelativePaths.length >= 1, + 'Validation issue should expose the list of missing artefacts as metadata'); + } finally { + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('opencode adapter validate reports a partial build (entry present, runtime dirs absent)', () => { + const adapter = getInstallTargetAdapter('opencode'); + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'install-targets-opencode-partial-')); + try { + const distDir = path.join(repoRoot, '.opencode', 'dist'); + fs.mkdirSync(distDir, { recursive: true }); + fs.writeFileSync(path.join(distDir, 'index.js'), '// stub\n'); + // Intentionally omit dist/plugins and dist/tools. + + const issues = adapter.validate({ homeDir: '/Users/example', repoRoot }); + assert.strictEqual(issues.length, 1, 'Should surface a single validation issue for partial builds'); + assert.strictEqual(issues[0].code, 'opencode-plugin-not-built'); + const missing = issues[0].missingRelativePaths.map(p => p.replace(/\\/g, '/')); + assert.ok(missing.includes('.opencode/dist/plugins'), 'Missing list should include dist/plugins'); + assert.ok(missing.includes('.opencode/dist/tools'), 'Missing list should include dist/tools'); + assert.ok(!missing.includes('.opencode/dist/index.js'), 'Missing list should not include the present entry'); } finally { fs.rmSync(repoRoot, { recursive: true, force: true }); } @@ -1007,7 +1030,8 @@ function runTests() { const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'install-targets-opencode-built-')); try { const distDir = path.join(repoRoot, '.opencode', 'dist'); - fs.mkdirSync(distDir, { recursive: true }); + fs.mkdirSync(path.join(distDir, 'plugins'), { recursive: true }); + fs.mkdirSync(path.join(distDir, 'tools'), { recursive: true }); fs.writeFileSync(path.join(distDir, 'index.js'), '// stub\n'); const issues = adapter.validate({ homeDir: '/Users/example', repoRoot }); From 8753a3bdb766cd86c51cf7dfda7ec63976d1cec4 Mon Sep 17 00:00:00 2001 From: gaurav0107 Date: Fri, 22 May 2026 22:10:54 +0530 Subject: [PATCH 3/4] fix(install-targets): enforce file/directory types in opencode validate Per CodeRabbit: existsSync alone treats any filesystem entry as valid, which would let a stray file at `.opencode/dist/plugins` (or tools) squeeze past the gate even though OpenCode requires those entries to be directories. The validator now stat()s each required artefact and asserts `isFile()` for `dist/index.js` and `isDirectory()` for `dist/plugins` and `dist/tools`, returning the same `opencode-plugin-not-built` issue with an extra `expectedTypes` metadata array so callers can tell missing-vs-wrong- type failures apart. A new test materializes regular files where directories are expected and asserts the validator flags `dist/plugins` and `dist/tools` while leaving the correctly-typed `dist/index.js` alone. Existing happy-path and partial-build tests continue to pass. Full suite: 2573/2573 (one new test). --- scripts/lib/install-targets/opencode-home.js | 40 ++++++++++++++------ tests/lib/install-targets.test.js | 23 +++++++++++ 2 files changed, 51 insertions(+), 12 deletions(-) diff --git a/scripts/lib/install-targets/opencode-home.js b/scripts/lib/install-targets/opencode-home.js index db7c4ddbc2..8d7152844f 100644 --- a/scripts/lib/install-targets/opencode-home.js +++ b/scripts/lib/install-targets/opencode-home.js @@ -8,13 +8,26 @@ const { } = require('./helpers'); const COMPILED_PLUGIN_DIST_DIR = path.join('.opencode', 'dist'); -const REQUIRED_COMPILED_RELATIVE_PATHS = Object.freeze([ - path.join(COMPILED_PLUGIN_DIST_DIR, 'index.js'), - path.join(COMPILED_PLUGIN_DIST_DIR, 'plugins'), - path.join(COMPILED_PLUGIN_DIST_DIR, 'tools'), +const REQUIRED_COMPILED_ARTEFACTS = Object.freeze([ + { relativePath: path.join(COMPILED_PLUGIN_DIST_DIR, 'index.js'), expectedType: 'file' }, + { relativePath: path.join(COMPILED_PLUGIN_DIST_DIR, 'plugins'), expectedType: 'directory' }, + { relativePath: path.join(COMPILED_PLUGIN_DIST_DIR, 'tools'), expectedType: 'directory' }, ]); const BUILD_COMMAND_HINT = 'node scripts/build-opencode.js (or: npm run build:opencode)'; +function isExpectedType(absolutePath, expectedType) { + let stat; + try { + stat = fs.statSync(absolutePath); + } catch (error) { + if (error && error.code === 'ENOENT') { + return false; + } + throw error; + } + return expectedType === 'file' ? stat.isFile() : stat.isDirectory(); +} + function defaultValidateOpencodeHome(input = {}) { if (!input.homeDir && !os.homedir()) { return [ @@ -30,12 +43,13 @@ function defaultValidateOpencodeHome(input = {}) { return []; } - const missingPaths = REQUIRED_COMPILED_RELATIVE_PATHS - .map(relativePath => ({ - relativePath, - absolutePath: path.join(input.repoRoot, relativePath), + const missingPaths = REQUIRED_COMPILED_ARTEFACTS + .map(artefact => ({ + relativePath: artefact.relativePath, + absolutePath: path.join(input.repoRoot, artefact.relativePath), + expectedType: artefact.expectedType, })) - .filter(entry => !fs.existsSync(entry.absolutePath)); + .filter(entry => !isExpectedType(entry.absolutePath, entry.expectedType)); if (missingPaths.length > 0) { const missingList = missingPaths.map(entry => entry.relativePath).join(', '); @@ -44,12 +58,14 @@ function defaultValidateOpencodeHome(input = {}) { 'error', 'opencode-plugin-not-built', 'OpenCode install requires the compiled plugin payload under ' - + `${COMPILED_PLUGIN_DIST_DIR}, but the following artefact(s) were not found: ` - + `${missingList}. Run ${BUILD_COMMAND_HINT} from the repo root before ` - + 're-running the installer.', + + `${COMPILED_PLUGIN_DIST_DIR}, but the following artefact(s) were ` + + `missing or had the wrong type: ${missingList}. Run ` + + `${BUILD_COMMAND_HINT} from the repo root before re-running the ` + + 'installer.', { missingPaths: missingPaths.map(entry => entry.absolutePath), missingRelativePaths: missingPaths.map(entry => entry.relativePath), + expectedTypes: missingPaths.map(entry => entry.expectedType), } ), ]; diff --git a/tests/lib/install-targets.test.js b/tests/lib/install-targets.test.js index 2ffc373a4f..ec2a647f41 100644 --- a/tests/lib/install-targets.test.js +++ b/tests/lib/install-targets.test.js @@ -1025,6 +1025,29 @@ function runTests() { } })) passed++; else failed++; + if (test('opencode adapter validate rejects wrong artefact type (file where directory expected)', () => { + const adapter = getInstallTargetAdapter('opencode'); + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'install-targets-opencode-wrongtype-')); + try { + const distDir = path.join(repoRoot, '.opencode', 'dist'); + fs.mkdirSync(distDir, { recursive: true }); + fs.writeFileSync(path.join(distDir, 'index.js'), '// stub\n'); + // Materialize plugins/tools as files instead of directories. + fs.writeFileSync(path.join(distDir, 'plugins'), 'not-a-dir'); + fs.writeFileSync(path.join(distDir, 'tools'), 'not-a-dir'); + + const issues = adapter.validate({ homeDir: '/Users/example', repoRoot }); + assert.strictEqual(issues.length, 1, 'Wrong-type artefacts should still surface a validation issue'); + assert.strictEqual(issues[0].code, 'opencode-plugin-not-built'); + const missing = issues[0].missingRelativePaths.map(p => p.replace(/\\/g, '/')); + assert.ok(missing.includes('.opencode/dist/plugins'), 'Should flag plugins file as wrong type'); + assert.ok(missing.includes('.opencode/dist/tools'), 'Should flag tools file as wrong type'); + assert.ok(!missing.includes('.opencode/dist/index.js'), 'Should not flag index.js when it is correctly a file'); + } finally { + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + })) passed++; else failed++; + if (test('opencode adapter validate passes once compiled plugin payload exists', () => { const adapter = getInstallTargetAdapter('opencode'); const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'install-targets-opencode-built-')); From 847584188a195ff5296ce3661fefcabf9286e827 Mon Sep 17 00:00:00 2001 From: gaurav0107 Date: Fri, 22 May 2026 22:27:17 +0530 Subject: [PATCH 4/4] fix(install-targets): treat ENOTDIR as validation miss in opencode validate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isExpectedType() previously rethrew every non-ENOENT filesystem error. When an intermediate path component is a regular file (e.g. .opencode/dist itself is a file rather than a directory), fs.statSync raises ENOTDIR — that escaped out of the validate gate and crashed planInstallTargetScaffold instead of producing the structured opencode-plugin-not-built issue. Now both ENOENT and ENOTDIR collapse to a 'missing artefact' return so the gate keeps producing the actionable error message. Other I/O errors (EACCES, EIO, ...) still surface — those are real system faults the user needs to see. Adds a regression test that materialises .opencode/dist as a regular file and asserts validate() does not throw and surfaces the structured issue. --- scripts/lib/install-targets/opencode-home.js | 7 ++++- tests/lib/install-targets.test.js | 30 ++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/scripts/lib/install-targets/opencode-home.js b/scripts/lib/install-targets/opencode-home.js index 8d7152844f..56880235c5 100644 --- a/scripts/lib/install-targets/opencode-home.js +++ b/scripts/lib/install-targets/opencode-home.js @@ -15,12 +15,17 @@ const REQUIRED_COMPILED_ARTEFACTS = Object.freeze([ ]); const BUILD_COMMAND_HINT = 'node scripts/build-opencode.js (or: npm run build:opencode)'; +// Errors that mean "this artefact does not exist at the expected path / type". +// Anything else (EACCES, EIO, ...) is a genuine system fault we surface to the +// caller rather than masking as a missing artefact. +const MISSING_ARTEFACT_ERROR_CODES = new Set(['ENOENT', 'ENOTDIR']); + function isExpectedType(absolutePath, expectedType) { let stat; try { stat = fs.statSync(absolutePath); } catch (error) { - if (error && error.code === 'ENOENT') { + if (error && MISSING_ARTEFACT_ERROR_CODES.has(error.code)) { return false; } throw error; diff --git a/tests/lib/install-targets.test.js b/tests/lib/install-targets.test.js index ec2a647f41..f59afd8edc 100644 --- a/tests/lib/install-targets.test.js +++ b/tests/lib/install-targets.test.js @@ -1048,6 +1048,36 @@ function runTests() { } })) passed++; else failed++; + if (test('opencode adapter validate handles ENOTDIR (intermediate path is a file) without throwing', () => { + const adapter = getInstallTargetAdapter('opencode'); + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'install-targets-opencode-enotdir-')); + try { + // Create `.opencode/dist` as a regular file. Stat'ing + // `.opencode/dist/index.js` then throws ENOTDIR (intermediate component + // is a file, not a directory). The validate gate must treat this as a + // missing artefact and surface the structured opencode-plugin-not-built + // issue, not propagate the raw fs error. + const opencodeDir = path.join(repoRoot, '.opencode'); + fs.mkdirSync(opencodeDir, { recursive: true }); + fs.writeFileSync(path.join(opencodeDir, 'dist'), 'not-a-dir'); + + let issues; + assert.doesNotThrow( + () => { issues = adapter.validate({ homeDir: '/Users/example', repoRoot }); }, + 'validate should swallow ENOTDIR and surface a structured issue' + ); + assert.strictEqual(issues.length, 1, 'ENOTDIR case should produce exactly one validation issue'); + assert.strictEqual(issues[0].severity, 'error'); + assert.strictEqual(issues[0].code, 'opencode-plugin-not-built'); + const missing = issues[0].missingRelativePaths.map(p => p.replace(/\\/g, '/')); + assert.ok(missing.includes('.opencode/dist/index.js'), 'ENOTDIR target should be reported as missing'); + assert.ok(missing.includes('.opencode/dist/plugins'), 'Sibling artefacts under the bad path should be reported'); + assert.ok(missing.includes('.opencode/dist/tools'), 'Sibling artefacts under the bad path should be reported'); + } finally { + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + })) passed++; else failed++; + if (test('opencode adapter validate passes once compiled plugin payload exists', () => { const adapter = getInstallTargetAdapter('opencode'); const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'install-targets-opencode-built-'));