From 67537ea480496db8197e7b32985e59ca2e17bbe7 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:14:33 -0400 Subject: [PATCH] fix(repair): build opencode payload so `repair` clears doctor's `opencode-plugin-not-built` warning (#2414) (#2438) * fix(repair): build opencode plugin payload so repair clears doctor's opencode-plugin-not-built warning (#2414) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(repair): narrow validation bypass to specific codes; fix test-artifact cleanup (#2414 review) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: affaan Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- scripts/lib/install-executor.js | 3 +- scripts/lib/install-lifecycle.js | 81 ++++++- scripts/lib/install-manifests.js | 1 + scripts/lib/install-targets/registry.js | 5 +- tests/lib/install-lifecycle.test.js | 291 ++++++++++++++++++++++++ tests/lib/install-targets.test.js | 69 ++++++ 6 files changed, 444 insertions(+), 6 deletions(-) diff --git a/scripts/lib/install-executor.js b/scripts/lib/install-executor.js index 5147f3c4d..b030b49b6 100644 --- a/scripts/lib/install-executor.js +++ b/scripts/lib/install-executor.js @@ -739,7 +739,8 @@ function createManifestInstallPlan(options = {}) { moduleIds: options.moduleIds || [], includeComponentIds: options.includeComponentIds || [], excludeComponentIds: options.excludeComponentIds || [], - target + target, + exemptValidationCodes: options.exemptValidationCodes || [], }); const adapter = getInstallTargetAdapter(target); const operations = dedupeCopyFileOperations( diff --git a/scripts/lib/install-lifecycle.js b/scripts/lib/install-lifecycle.js index b35764c66..792ba9935 100644 --- a/scripts/lib/install-lifecycle.js +++ b/scripts/lib/install-lifecycle.js @@ -1,4 +1,5 @@ const fs = require('fs'); +const { execFileSync } = require('child_process'); const os = require('os'); const path = require('path'); @@ -7,6 +8,9 @@ const { readInstallState, writeInstallState } = require('./install-state'); const { assertWithinTrustedRoot } = require('./path-safety'); const { createManifestInstallPlan } = require('./install-executor'); const { getInstallTargetAdapter, listInstallTargetAdapters } = require('./install-targets/registry'); +const OPENCODE_BUILD_ARTIFACT = path.join('.opencode', 'dist'); +const OPENCODE_BUILD_SCRIPT = path.join('scripts', 'build-opencode.js'); +const OPENCODE_PLUGIN_NOT_BUILT_CODE = 'opencode-plugin-not-built'; const DEFAULT_REPO_ROOT = path.join(__dirname, '../..'); @@ -46,6 +50,31 @@ function compareStringArrays(left, right) { return leftValues.every((value, index) => value === rightValues[index]); } +function hasOpencodeBuildError(issues) { + return Array.isArray(issues) && issues.some(issue => issue.code === OPENCODE_PLUGIN_NOT_BUILT_CODE); +} + +function getOpencodeBuildValidationIssues(context) { + return getInstallTargetAdapter('opencode').validate({ + homeDir: context.homeDir, + repoRoot: context.repoRoot, + }); +} + +function buildOpencodePayload(repoRoot, buildRunner = execFileSync) { + buildRunner(process.execPath, [path.join(repoRoot, OPENCODE_BUILD_SCRIPT)], { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +function formatBuildErrorMessage(error) { + const stderr = typeof error.stderr === 'string' ? error.stderr.trim() : ''; + const stdout = typeof error.stdout === 'string' ? error.stdout.trim() : ''; + return stderr || stdout || error.message || 'Failed to build OpenCode payload'; +} + function getManagedOperations(state) { return Array.isArray(state && state.operations) ? state.operations.filter(operation => operation.ownership === 'managed') : []; } @@ -876,7 +905,7 @@ function buildDoctorReport(options = {}) { }; } -function createRepairPlanFromRecord(record, context) { +function createRepairPlanFromRecord(record, context, options = {}) { const state = record.state; if (!state) { throw new Error('No install-state available for repair'); @@ -908,7 +937,8 @@ function createRepairPlanFromRecord(record, context) { includeComponentIds: state.request.includeComponents || [], excludeComponentIds: state.request.excludeComponents || [], projectRoot: context.projectRoot, - homeDir: context.homeDir + homeDir: context.homeDir, + exemptValidationCodes: options.exemptValidationCodes || [], }); return { @@ -931,6 +961,9 @@ function repairInstalledStates(options = {}) { manifestVersion: manifests.modulesVersion, packageVersion: readPackageVersion(repoRoot) }; + const buildOpencodeRunner = typeof options.buildOpencodePayload === 'function' + ? options.buildOpencodePayload + : buildOpencodePayload; const records = discoverInstalledStates({ homeDir: context.homeDir, projectRoot: context.projectRoot, @@ -950,6 +983,44 @@ function repairInstalledStates(options = {}) { } try { + const needsOpencodeBuild = record.adapter.target === 'opencode' + && hasOpencodeBuildError(getOpencodeBuildValidationIssues(context)); + const opencodeBuildRepairPath = path.join(context.repoRoot, OPENCODE_BUILD_ARTIFACT); + + if (needsOpencodeBuild && options.dryRun) { + const desiredPlan = createRepairPlanFromRecord(record, context, { + exemptValidationCodes: [OPENCODE_PLUGIN_NOT_BUILT_CODE], + }); + const operationHealth = summarizeManagedOperationHealth(context.repoRoot, desiredPlan.operations); + const repairOperations = [...operationHealth.missing.map(entry => ({ ...entry.operation })), ...operationHealth.drifted.map(entry => ({ ...entry.operation }))]; + const plannedRepairs = [opencodeBuildRepairPath, ...repairOperations.map(operation => operation.destinationPath)]; + + return { + adapter: record.adapter, + status: 'planned', + installStatePath: record.installStatePath, + repairedPaths: [], + plannedRepairs, + stateRefreshed: false, + error: null + }; + } + + if (needsOpencodeBuild) { + try { + buildOpencodeRunner(context.repoRoot); + } catch (error) { + return { + adapter: record.adapter, + status: 'error', + installStatePath: record.installStatePath, + repairedPaths: [], + plannedRepairs: [], + error: formatBuildErrorMessage(error) + }; + } + } + const desiredPlan = createRepairPlanFromRecord(record, context); const operationHealth = summarizeManagedOperationHealth(context.repoRoot, desiredPlan.operations); @@ -965,7 +1036,9 @@ function repairInstalledStates(options = {}) { } const repairOperations = [...operationHealth.missing.map(entry => ({ ...entry.operation })), ...operationHealth.drifted.map(entry => ({ ...entry.operation }))]; - const plannedRepairs = repairOperations.map(operation => operation.destinationPath); + const plannedRepairs = needsOpencodeBuild + ? [opencodeBuildRepairPath, ...repairOperations.map(operation => operation.destinationPath)] + : repairOperations.map(operation => operation.destinationPath); if (options.dryRun) { return { @@ -990,7 +1063,7 @@ function repairInstalledStates(options = {}) { return { adapter: record.adapter, - status: repairOperations.length > 0 ? 'repaired' : 'ok', + status: (repairOperations.length > 0 || needsOpencodeBuild) ? 'repaired' : 'ok', installStatePath: record.installStatePath, repairedPaths: plannedRepairs, plannedRepairs: [], diff --git a/scripts/lib/install-manifests.js b/scripts/lib/install-manifests.js index a6c12f795..3c98eaf8e 100644 --- a/scripts/lib/install-manifests.js +++ b/scripts/lib/install-manifests.js @@ -680,6 +680,7 @@ function resolveInstallPlan(options = {}) { projectRoot: targetPlanningInput.projectRoot, homeDir: targetPlanningInput.homeDir, modules: selectedModules, + exemptValidationCodes: options.exemptValidationCodes || [], }) : null; diff --git a/scripts/lib/install-targets/registry.js b/scripts/lib/install-targets/registry.js index 268cf707b..3f07320a2 100644 --- a/scripts/lib/install-targets/registry.js +++ b/scripts/lib/install-targets/registry.js @@ -47,13 +47,16 @@ function getInstallTargetAdapter(targetOrAdapterId) { function planInstallTargetScaffold(options = {}) { const adapter = getInstallTargetAdapter(options.target); const modules = Array.isArray(options.modules) ? options.modules : []; + const exemptValidationCodes = new Set(Array.isArray(options.exemptValidationCodes) ? options.exemptValidationCodes : []); const planningInput = { repoRoot: options.repoRoot, projectRoot: options.projectRoot || options.repoRoot, homeDir: options.homeDir, }; const validationIssues = adapter.validate(planningInput); - const blockingIssues = validationIssues.filter(issue => issue.severity === 'error'); + const blockingIssues = validationIssues.filter(issue => ( + issue.severity === 'error' && !exemptValidationCodes.has(issue.code) + )); if (blockingIssues.length > 0) { throw new Error(blockingIssues.map(issue => issue.message).join('; ')); } diff --git a/tests/lib/install-lifecycle.test.js b/tests/lib/install-lifecycle.test.js index 3bd259486..c037c0604 100644 --- a/tests/lib/install-lifecycle.test.js +++ b/tests/lib/install-lifecycle.test.js @@ -14,6 +14,7 @@ const { repairInstalledStates, uninstallInstalledStates, } = require('../../scripts/lib/install-lifecycle'); +const { getInstallTargetAdapter } = require('../../scripts/lib/install-targets/registry'); const { createInstallState, writeInstallState, @@ -95,6 +96,74 @@ function writeCursorState(projectRoot, overrides = {}) { }; } +function createOpencodeStateOptions(homeDir, overrides = {}) { + const targetRoot = overrides.targetRoot || path.join(homeDir, '.opencode'); + const installStatePath = overrides.installStatePath || path.join(targetRoot, 'ecc-install-state.json'); + + return { + adapter: { id: 'opencode-home', target: 'opencode', kind: 'home' }, + targetRoot, + installStatePath, + request: { + profile: null, + modules: ['commands-core'], + includeComponents: [], + excludeComponents: [], + legacyLanguages: [], + legacyMode: false, + ...(overrides.request || {}), + }, + resolution: { + selectedModules: ['commands-core'], + skippedModules: [], + ...(overrides.resolution || {}), + }, + operations: overrides.operations || [], + source: { + repoVersion: CURRENT_PACKAGE_VERSION, + repoCommit: 'abc123', + manifestVersion: CURRENT_MANIFEST_VERSION, + ...(overrides.source || {}), + }, + }; +} + +function writeOpencodeState(homeDir, overrides = {}) { + const options = createOpencodeStateOptions(homeDir, overrides); + writeState(options.installStatePath, options); + return { + targetRoot: options.targetRoot, + installStatePath: options.installStatePath, + state: options, + }; +} + +function withTemporarilyMovedPath(filePath, callback) { + if (!fs.existsSync(filePath)) { + try { + return callback(null); + } finally { + if (fs.existsSync(filePath)) { + fs.rmSync(filePath, { recursive: true, force: true }); + } + } + } + + const backupPath = `${filePath}.backup-${process.pid}-${Date.now()}`; + fs.renameSync(filePath, backupPath); + + try { + return callback(backupPath); + } finally { + if (fs.existsSync(filePath)) { + fs.rmSync(filePath, { recursive: true, force: true }); + } + if (fs.existsSync(backupPath)) { + fs.renameSync(backupPath, filePath); + } + } +} + function managedOperation(kind, destinationPath, overrides = {}) { return { kind, @@ -696,6 +765,228 @@ function runTests() { } })) passed++; else failed++; + if (test('repair builds the OpenCode payload and clears the missing-payload warning', () => { + const homeDir = createTempDir('install-lifecycle-home-'); + const projectRoot = createTempDir('install-lifecycle-project-'); + + try { + withTemporarilyMovedPath(path.join(REPO_ROOT, '.opencode', 'dist'), () => { + writeOpencodeState(homeDir, { + request: { + profile: null, + modules: ['commands-core'], + includeComponents: [], + excludeComponents: [], + legacyLanguages: [], + legacyMode: false, + }, + resolution: { + selectedModules: ['commands-core'], + skippedModules: [], + }, + operations: [], + }); + + const beforeValidate = getInstallTargetAdapter('opencode').validate({ + homeDir, + repoRoot: REPO_ROOT, + }); + assert.ok(beforeValidate.some(issue => issue.code === 'opencode-plugin-not-built')); + + const beforeDoctor = buildDoctorReport({ + repoRoot: REPO_ROOT, + homeDir, + projectRoot, + targets: ['opencode'], + }); + assert.strictEqual(beforeDoctor.results[0].status, 'error'); + assert.ok(beforeDoctor.results[0].issues.some(issue => issue.code === 'resolution-unavailable')); + + let buildCalls = 0; + const result = repairInstalledStates({ + repoRoot: REPO_ROOT, + homeDir, + projectRoot, + targets: ['opencode'], + buildOpencodePayload: repoRoot => { + buildCalls += 1; + 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'), 'module.exports = {};\\n'); + }, + }); + + assert.strictEqual(buildCalls, 1); + assert.strictEqual(result.results[0].status, 'repaired'); + assert.ok(fs.existsSync(path.join(REPO_ROOT, '.opencode', 'dist', 'index.js'))); + + const afterValidate = getInstallTargetAdapter('opencode').validate({ + homeDir, + repoRoot: REPO_ROOT, + }); + assert.deepStrictEqual(afterValidate, []); + + const afterDoctor = buildDoctorReport({ + repoRoot: REPO_ROOT, + homeDir, + projectRoot, + targets: ['opencode'], + }); + assert.strictEqual(afterDoctor.results[0].status, 'ok'); + assert.strictEqual(afterDoctor.results[0].issues.length, 0); + }); + } finally { + cleanup(homeDir); + cleanup(projectRoot); + } + })) passed++; else failed++; + + if (test('repair dry-run plans the OpenCode payload build without creating it', () => { + const homeDir = createTempDir('install-lifecycle-home-'); + const projectRoot = createTempDir('install-lifecycle-project-'); + + try { + withTemporarilyMovedPath(path.join(REPO_ROOT, '.opencode', 'dist'), () => { + writeOpencodeState(homeDir, { + request: { + profile: null, + modules: ['commands-core'], + includeComponents: [], + excludeComponents: [], + legacyLanguages: [], + legacyMode: false, + }, + resolution: { + selectedModules: ['commands-core'], + skippedModules: [], + }, + operations: [], + }); + + const result = repairInstalledStates({ + repoRoot: REPO_ROOT, + homeDir, + projectRoot, + targets: ['opencode'], + dryRun: true, + buildOpencodePayload: () => { + throw new Error('build should not run during dry-run'); + }, + }); + + assert.strictEqual(result.results[0].status, 'planned'); + assert.ok(result.results[0].plannedRepairs.includes(path.join(REPO_ROOT, '.opencode', 'dist'))); + assert.ok(!fs.existsSync(path.join(REPO_ROOT, '.opencode', 'dist', 'index.js'))); + }); + } finally { + cleanup(homeDir); + cleanup(projectRoot); + } + })) passed++; else failed++; + + if (test('withTemporarilyMovedPath cleans up newly created paths when nothing was pre-existing', () => { + const filePath = path.join(REPO_ROOT, '.opencode', 'dist'); + const backupPath = `${filePath}.backup-${process.pid}-test`; + + try { + fs.rmSync(filePath, { recursive: true, force: true }); + fs.rmSync(backupPath, { recursive: true, force: true }); + + const result = withTemporarilyMovedPath(filePath, receivedBackupPath => { + assert.strictEqual(receivedBackupPath, null); + fs.mkdirSync(path.join(filePath, 'plugins'), { recursive: true }); + fs.mkdirSync(path.join(filePath, 'tools'), { recursive: true }); + fs.writeFileSync(path.join(filePath, 'index.js'), '// temp build\n'); + return 'callback-result'; + }); + + assert.strictEqual(result, 'callback-result'); + assert.ok(!fs.existsSync(filePath), 'Temporary path should be removed after the callback'); + } finally { + fs.rmSync(filePath, { recursive: true, force: true }); + fs.rmSync(backupPath, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('repair surfaces OpenCode build failures without blocking other targets', () => { + const homeDir = createTempDir('install-lifecycle-home-'); + const projectRoot = createTempDir('install-lifecycle-project-'); + + try { + withTemporarilyMovedPath(path.join(REPO_ROOT, '.opencode', 'dist'), () => { + const cursorTargetRoot = path.join(projectRoot, '.cursor'); + const cursorStatePath = path.join(cursorTargetRoot, 'ecc-install-state.json'); + const cursorDestinationPath = path.join(cursorTargetRoot, 'rules', 'coding-style.md'); + fs.mkdirSync(path.dirname(cursorDestinationPath), { recursive: true }); + + writeOpencodeState(homeDir, { + request: { + profile: null, + modules: ['commands-core'], + includeComponents: [], + excludeComponents: [], + legacyLanguages: [], + legacyMode: false, + }, + resolution: { + selectedModules: ['commands-core'], + skippedModules: [], + }, + operations: [], + }); + + writeState(cursorStatePath, { + adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' }, + targetRoot: cursorTargetRoot, + installStatePath: cursorStatePath, + request: { + profile: null, + modules: [], + legacyLanguages: ['typescript'], + legacyMode: true, + }, + resolution: { + selectedModules: ['legacy-cursor-install'], + skippedModules: [], + }, + operations: [ + managedOperation('copy-file', cursorDestinationPath, { + sourceRelativePath: 'rules/common/coding-style.md', + strategy: 'copy-file', + }), + ], + source: { + repoVersion: CURRENT_PACKAGE_VERSION, + repoCommit: 'abc123', + manifestVersion: CURRENT_MANIFEST_VERSION, + }, + }); + + const result = repairInstalledStates({ + repoRoot: REPO_ROOT, + homeDir, + projectRoot, + targets: ['opencode', 'cursor'], + buildOpencodePayload: () => { + throw new Error('typescript dependency missing'); + }, + }); + + const opencodeResult = result.results.find(entry => entry.adapter.id === 'opencode-home'); + const cursorResult = result.results.find(entry => entry.adapter.id === 'cursor-project'); + + assert.strictEqual(opencodeResult.status, 'error'); + assert.ok(opencodeResult.error.includes('typescript dependency missing')); + assert.strictEqual(cursorResult.status, 'repaired'); + assert.ok(fs.existsSync(cursorDestinationPath)); + }); + } finally { + cleanup(homeDir); + cleanup(projectRoot); + } + })) passed++; else failed++; + if (test('repair surfaces missing source errors from execution when destination is absent', () => { const homeDir = createTempDir('install-lifecycle-home-'); const projectRoot = createTempDir('install-lifecycle-project-'); diff --git a/tests/lib/install-targets.test.js b/tests/lib/install-targets.test.js index 8d039f93d..3e192648e 100644 --- a/tests/lib/install-targets.test.js +++ b/tests/lib/install-targets.test.js @@ -1088,6 +1088,75 @@ function runTests() { } })) passed++; else failed++; + if (test('planInstallTargetScaffold only exempts explicitly allowed validation codes', () => { + const registryPath = require.resolve('../../scripts/lib/install-targets/registry'); + const opencodeHomePath = require.resolve('../../scripts/lib/install-targets/opencode-home'); + const originalRegistryEntry = require.cache[registryPath]; + const originalOpencodeHomeEntry = require.cache[opencodeHomePath]; + const repoRoot = path.join(__dirname, '..', '..'); + const homeDir = '/Users/example'; + + try { + delete require.cache[registryPath]; + require.cache[opencodeHomePath] = { + id: opencodeHomePath, + filename: opencodeHomePath, + loaded: true, + exports: { + id: 'opencode-home', + target: 'opencode', + kind: 'home', + supports: target => target === 'opencode', + resolveRoot: input => path.join((input.homeDir || '/Users/example'), '.opencode'), + getInstallStatePath: input => path.join((input.homeDir || '/Users/example'), '.opencode', 'ecc-install-state.json'), + validate: () => ([ + { severity: 'error', code: 'opencode-plugin-not-built', message: 'missing payload' }, + { severity: 'error', code: 'opencode-other-blocker', message: 'still blocked' }, + ]), + planOperations: () => [], + }, + }; + + const { planInstallTargetScaffold: sandboxedPlanInstallTargetScaffold } = require('../../scripts/lib/install-targets/registry'); + + assert.throws( + () => sandboxedPlanInstallTargetScaffold({ + target: 'opencode', + repoRoot, + homeDir, + exemptValidationCodes: ['opencode-plugin-not-built'], + }), + /still blocked/ + ); + + require.cache[opencodeHomePath].exports.validate = () => ([ + { severity: 'error', code: 'opencode-plugin-not-built', message: 'missing payload' }, + ]); + + const plan = sandboxedPlanInstallTargetScaffold({ + target: 'opencode', + repoRoot, + homeDir, + exemptValidationCodes: ['opencode-plugin-not-built'], + }); + + assert.strictEqual(plan.adapter.id, 'opencode-home'); + assert.deepStrictEqual(plan.operations, []); + } finally { + if (originalOpencodeHomeEntry) { + require.cache[opencodeHomePath] = originalOpencodeHomeEntry; + } else { + delete require.cache[opencodeHomePath]; + } + + if (originalRegistryEntry) { + require.cache[registryPath] = originalRegistryEntry; + } else { + delete require.cache[registryPath]; + } + } + })) passed++; else failed++; + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); }