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 <affaan@itomarkets.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot]
2026-07-08 17:14:33 -04:00
committed by GitHub
co-authored by Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> affaan Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent a511395613
commit 67537ea480
6 changed files with 444 additions and 6 deletions
+2 -1
View File
@@ -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(
+77 -4
View File
@@ -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: [],
+1
View File
@@ -680,6 +680,7 @@ function resolveInstallPlan(options = {}) {
projectRoot: targetPlanningInput.projectRoot,
homeDir: targetPlanningInput.homeDir,
modules: selectedModules,
exemptValidationCodes: options.exemptValidationCodes || [],
})
: null;
+4 -1
View File
@@ -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('; '));
}
+291
View File
@@ -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-');
+69
View File
@@ -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);
}