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..56880235c5 100644 --- a/scripts/lib/install-targets/opencode-home.js +++ b/scripts/lib/install-targets/opencode-home.js @@ -1,4 +1,83 @@ -const { createInstallTargetAdapter } = require('./helpers'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + buildValidationIssue, + createInstallTargetAdapter, +} = require('./helpers'); + +const COMPILED_PLUGIN_DIST_DIR = path.join('.opencode', 'dist'); +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)'; + +// 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 && MISSING_ARTEFACT_ERROR_CODES.has(error.code)) { + return false; + } + throw error; + } + return expectedType === 'file' ? stat.isFile() : stat.isDirectory(); +} + +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 missingPaths = REQUIRED_COMPILED_ARTEFACTS + .map(artefact => ({ + relativePath: artefact.relativePath, + absolutePath: path.join(input.repoRoot, artefact.relativePath), + expectedType: artefact.expectedType, + })) + .filter(entry => !isExpectedType(entry.absolutePath, entry.expectedType)); + + 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 under ' + + `${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), + } + ), + ]; + } + + return []; +} module.exports = createInstallTargetAdapter({ id: 'opencode-home', @@ -7,4 +86,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..f59afd8edc 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,132 @@ 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') || 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 }); + } + })) 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 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-')); + try { + const distDir = path.join(repoRoot, '.opencode', 'dist'); + 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 }); + 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); }