fix(opencode): migrate legacy managed home installs

This commit is contained in:
haelyra
2026-08-24 20:59:57 -04:00
parent 47d629633b
commit e3a1ac6f3f
10 changed files with 536 additions and 17 deletions
+124 -10
View File
@@ -15,6 +15,10 @@ const {
getLegacyAntigravityLocation,
inspectLegacyAntigravityState,
} = require('./install/antigravity-legacy-migration');
const {
getLegacyOpencodeLocation,
inspectLegacyOpencodeState,
} = require('./install/opencode-legacy-migration');
const { adaptAntigravityAgent } = require('./install/antigravity-agent');
const { buildInstallIndex, rewriteRelativeLinks } = require('./install/link-rewrite');
const { getInstallTargetAdapter, listInstallTargetAdapters } = require('./install-targets/registry');
@@ -1209,7 +1213,8 @@ function buildDiscoveryRecord(adapter, context, location = null, knownState = nu
exists: false,
state: null,
error: null,
legacy: Boolean(location)
legacy: Boolean(location),
legacyLayout: location?.legacyLayout || null
};
}
@@ -1225,7 +1230,8 @@ function buildDiscoveryRecord(adapter, context, location = null, knownState = nu
exists: true,
state: knownState,
error: null,
legacy: Boolean(location)
legacy: Boolean(location),
legacyLayout: location?.legacyLayout || null
};
}
@@ -1242,7 +1248,8 @@ function buildDiscoveryRecord(adapter, context, location = null, knownState = nu
exists: true,
state,
error: null,
legacy: Boolean(location)
legacy: Boolean(location),
legacyLayout: location?.legacyLayout || null
};
} catch (error) {
return {
@@ -1256,7 +1263,8 @@ function buildDiscoveryRecord(adapter, context, location = null, knownState = nu
exists: true,
state: null,
error: error.message,
legacy: Boolean(location)
legacy: Boolean(location),
legacyLayout: location?.legacyLayout || null
};
}
}
@@ -1271,11 +1279,46 @@ function discoverInstalledStates(options = {}) {
return targets.flatMap(target => {
const adapter = getInstallTargetAdapter(target);
const canonicalRecord = buildDiscoveryRecord(adapter, context);
if (adapter.target === 'opencode') {
const legacyLocation = getLegacyOpencodeLocation(context.homeDir);
const legacyInspection = inspectLegacyOpencodeState(legacyLocation);
if (
path.resolve(legacyLocation.installStatePath) === path.resolve(canonicalRecord.installStatePath)
|| legacyInspection.status === 'absent'
|| legacyInspection.status === 'invalid'
) {
return [canonicalRecord];
}
if (legacyInspection.status === 'unreadable') {
return [canonicalRecord, {
adapter: {
id: adapter.id,
target: adapter.target,
kind: adapter.kind,
},
targetRoot: legacyLocation.targetRoot,
installStatePath: legacyLocation.installStatePath,
exists: true,
state: null,
error: legacyInspection.error,
legacy: true,
legacyLayout: 'opencode',
}];
}
return [
canonicalRecord,
buildDiscoveryRecord(adapter, context, legacyLocation, legacyInspection.state),
];
}
if (adapter.target !== 'antigravity') {
return [canonicalRecord];
}
const legacyLocation = getLegacyAntigravityLocation(context.projectRoot);
const legacyLocation = {
...getLegacyAntigravityLocation(context.projectRoot),
legacyLayout: 'antigravity',
};
const legacyInspection = inspectLegacyAntigravityState(legacyLocation);
if (
path.resolve(legacyLocation.installStatePath) === path.resolve(canonicalRecord.installStatePath)
@@ -1296,8 +1339,9 @@ function discoverInstalledStates(options = {}) {
installStatePath: legacyLocation.installStatePath,
exists: true,
state: null,
error: legacyInspection.error,
legacy: true,
error: legacyInspection.error,
legacy: true,
legacyLayout: 'antigravity',
}];
}
@@ -1332,7 +1376,7 @@ function determineStatus(issues) {
function analyzeRecord(record, context) {
const issues = [];
if (record.legacy) {
if (record.legacyLayout === 'antigravity') {
issues.push(buildIssue(
'warning',
'legacy-antigravity-layout',
@@ -1340,6 +1384,14 @@ function analyzeRecord(record, context) {
));
}
if (record.legacyLayout === 'opencode') {
issues.push(buildIssue(
'warning',
'legacy-opencode-layout',
'Legacy OpenCode install-state remains under ~/.opencode. Rerun the OpenCode install or repair command to migrate unchanged ECC-managed files to ~/.config/opencode; modified files are preserved for review.'
));
}
if (record.error) {
issues.push(buildIssue('error', 'invalid-install-state', record.error));
return {
@@ -1669,7 +1721,10 @@ function repairInstalledStates(options = {}) {
homeDir: context.homeDir,
projectRoot: context.projectRoot,
targets: options.targets
}).filter(record => record.exists && !record.legacy);
}).filter(record => (
record.exists
&& (!record.legacy || record.legacyLayout === 'opencode')
));
const results = records.map(record => {
if (record.error) {
@@ -1688,6 +1743,65 @@ function repairInstalledStates(options = {}) {
&& hasOpencodeBuildError(getOpencodeBuildValidationIssues(context));
const opencodeBuildRepairPath = path.join(context.repoRoot, OPENCODE_BUILD_ARTIFACT);
if (record.legacyLayout === 'opencode') {
if (needsOpencodeBuild && !options.dryRun) {
try {
buildOpencodeRunner(context.repoRoot);
} catch (error) {
return {
adapter: record.adapter,
status: 'error',
installStatePath: record.installStatePath,
repairedPaths: [],
plannedRepairs: [],
error: formatBuildErrorMessage(error),
};
}
}
const canonicalPlan = createRepairPlanFromRecord(record, context, {
exemptValidationCodes: options.dryRun && needsOpencodeBuild
? [OPENCODE_PLUGIN_NOT_BUILT_CODE]
: [],
});
const plannedRepairs = [...new Set([
...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []),
...canonicalPlan.operations.map(operation => operation.destinationPath),
...getManagedOperations(record.state).map(operation => operation.destinationPath),
record.installStatePath,
])];
if (options.dryRun) {
return {
adapter: record.adapter,
status: 'planned',
installStatePath: canonicalPlan.installStatePath,
repairedPaths: [],
plannedRepairs,
stateRefreshed: false,
warnings: canonicalPlan.warnings,
error: null,
};
}
// Load lazily to avoid a module cycle during install-lifecycle startup.
const { applyInstallPlan } = require('./install/apply');
const appliedPlan = applyInstallPlan(canonicalPlan);
return {
adapter: record.adapter,
status: 'repaired',
installStatePath: canonicalPlan.installStatePath,
repairedPaths: [
...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []),
...canonicalPlan.operations.map(operation => operation.destinationPath),
],
plannedRepairs: [],
stateRefreshed: true,
warnings: appliedPlan.warnings,
error: null,
};
}
if (needsOpencodeBuild && options.dryRun) {
const rawPlan = createRepairPlanFromRecord(record, context, {
exemptValidationCodes: [OPENCODE_PLUGIN_NOT_BUILT_CODE],
@@ -1938,7 +2052,7 @@ function uninstallInstalledStates(options = {}) {
const state = record.state;
const managedOperations = getManagedOperations(state);
if (record.legacy && managedOperations.length > 0) {
if (record.legacyLayout === 'antigravity' && managedOperations.length > 0) {
return {
adapter: record.adapter,
status: 'partial',
+17
View File
@@ -17,6 +17,7 @@ const {
removeLegacyClaudeSkillFiles,
} = require('./claude-skill-migration');
const { cleanupLegacyAntigravityInstall } = require('./antigravity-legacy-migration');
const { cleanupLegacyOpencodeInstall } = require('./opencode-legacy-migration');
const { buildInstallIndex, rewriteRelativeLinks } = require('./link-rewrite');
const { adaptAntigravityAgent } = require('./antigravity-agent');
@@ -493,6 +494,21 @@ function applyInstallPlan(plan, dependencies = {}) {
];
}
let opencodeMigrationWarnings = [];
try {
const opencodeMigration = cleanupLegacyOpencodeInstall(appliedPlan);
if (opencodeMigration.detected && !opencodeMigration.complete) {
opencodeMigrationWarnings = [
'Legacy OpenCode migration is incomplete. ECC preserved modified or unverifiable managed content under ~/.opencode; review it and rerun the OpenCode install.',
...(Array.isArray(opencodeMigration.warnings) ? opencodeMigration.warnings : []),
];
}
} catch (error) {
opencodeMigrationWarnings = [
`Legacy OpenCode cleanup did not finish: ${error.message}. Content under ~/.opencode was preserved; rerun the OpenCode install or review it manually.`,
];
}
return {
...plan,
statePreview: finalState,
@@ -503,6 +519,7 @@ function applyInstallPlan(plan, dependencies = {}) {
...(Array.isArray(plan.warnings) ? plan.warnings : []),
...migration.warnings,
...antigravityMigrationWarnings,
...opencodeMigrationWarnings,
],
applied: true,
};
@@ -0,0 +1,338 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { readInstallState } = require('../install-state');
const { assertWithinTrustedRoot } = require('../path-safety');
const OPENCODE_TARGET = 'opencode';
const INSTALL_STATE_NAME = 'ecc-install-state.json';
function samePath(leftPath, rightPath) {
const left = path.resolve(leftPath);
const right = path.resolve(rightPath);
return process.platform === 'win32'
? left.toLowerCase() === right.toLowerCase()
: left === right;
}
function pathExists(filePath) {
try {
fs.lstatSync(filePath);
return true;
} catch (error) {
if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) {
return false;
}
throw error;
}
}
function getLegacyOpencodeLocation(homeDir) {
const targetRoot = path.join(path.resolve(homeDir), '.opencode');
return {
targetRoot,
installStatePath: path.join(targetRoot, INSTALL_STATE_NAME),
legacyLayout: 'opencode',
};
}
function getLegacyLocationForPlan(plan) {
if (
!plan
|| plan.adapter?.target !== OPENCODE_TARGET
|| typeof plan.targetRoot !== 'string'
) {
return null;
}
const canonicalRoot = path.resolve(plan.targetRoot);
if (
path.basename(canonicalRoot) !== 'opencode'
|| path.basename(path.dirname(canonicalRoot)) !== '.config'
) {
return null;
}
return getLegacyOpencodeLocation(path.dirname(path.dirname(canonicalRoot)));
}
function inspectLegacyOpencodeState(location) {
if (!location) {
return { status: 'absent', state: null, error: null };
}
try {
if (!pathExists(location.installStatePath)) {
return { status: 'absent', state: null, error: null };
}
const rootStat = fs.lstatSync(location.targetRoot);
const stateStat = fs.lstatSync(location.installStatePath);
if (
!rootStat.isDirectory()
|| rootStat.isSymbolicLink()
|| !stateStat.isFile()
|| stateStat.isSymbolicLink()
) {
return { status: 'invalid', state: null, error: null };
}
const state = readInstallState(location.installStatePath);
const isOpencode = state.target.target === OPENCODE_TARGET
|| state.target.id === 'opencode-home';
if (
!isOpencode
|| !samePath(state.target.root, location.targetRoot)
|| !samePath(state.target.installStatePath, location.installStatePath)
) {
return { status: 'invalid', state: null, error: null };
}
return { status: 'valid', state, error: null };
} catch (error) {
return {
status: 'unreadable',
state: null,
error: `Unable to inspect legacy OpenCode install-state at ${location.installStatePath}: ${error.message}`,
};
}
}
function hashFileNoFollow(filePath) {
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
const descriptor = fs.openSync(filePath, flags);
try {
const before = fs.fstatSync(descriptor);
if (!before.isFile()) {
throw new Error(`Refusing to read a non-file at ${filePath}`);
}
const content = fs.readFileSync(descriptor);
const after = fs.fstatSync(descriptor);
const finalPathStat = fs.lstatSync(filePath);
const unchanged = before.dev === after.dev
&& before.ino === after.ino
&& before.size === after.size
&& before.mtimeMs === after.mtimeMs
&& before.ctimeMs === after.ctimeMs
&& after.dev === finalPathStat.dev
&& after.ino === finalPathStat.ino
&& after.size === finalPathStat.size
&& after.mtimeMs === finalPathStat.mtimeMs
&& after.ctimeMs === finalPathStat.ctimeMs;
if (finalPathStat.isSymbolicLink() || !finalPathStat.isFile() || !unchanged) {
throw new Error(`Refusing to read a file that changed during validation: ${filePath}`);
}
return {
digest: crypto.createHash('sha256').update(content).digest('hex'),
stat: after,
};
} finally {
fs.closeSync(descriptor);
}
}
function removeEmptyParents(startPath, legacyRoot) {
let currentPath = path.dirname(startPath);
while (!samePath(currentPath, legacyRoot)) {
const safePath = assertWithinTrustedRoot(
currentPath,
legacyRoot,
'clean legacy OpenCode install'
);
if (!pathExists(safePath)) {
currentPath = path.dirname(safePath);
continue;
}
const stat = fs.lstatSync(safePath);
if (!stat.isDirectory() || stat.isSymbolicLink() || fs.readdirSync(safePath).length > 0) {
return;
}
fs.rmdirSync(safePath);
currentPath = path.dirname(safePath);
}
}
function verifyManagedLegacyFile(operation, location, sourceRoot) {
if (
operation?.kind !== 'copy-file'
|| operation.ownership !== 'managed'
|| typeof operation.destinationPath !== 'string'
|| typeof operation.sourceRelativePath !== 'string'
|| !/^[a-f0-9]{64}$/i.test(operation.contentSha256 || '')
) {
return { retainedPath: operation?.destinationPath || location.targetRoot };
}
let destinationPath;
let sourcePath;
try {
destinationPath = assertWithinTrustedRoot(
operation.destinationPath,
location.targetRoot,
'migrate legacy OpenCode install'
);
sourcePath = assertWithinTrustedRoot(
path.join(sourceRoot, operation.sourceRelativePath),
sourceRoot,
'verify legacy OpenCode source'
);
} catch (_error) {
return { retainedPath: operation.destinationPath };
}
let destination;
try {
destination = hashFileNoFollow(destinationPath);
} catch (error) {
if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) {
return { missing: true };
}
return { retainedPath: destinationPath };
}
if (destination.digest !== operation.contentSha256.toLowerCase()) {
return { retainedPath: destinationPath };
}
let source;
try {
source = hashFileNoFollow(sourcePath);
} catch (_error) {
return { retainedPath: destinationPath };
}
if (source.digest !== destination.digest) {
return { retainedPath: destinationPath };
}
return { destinationPath, stat: destination.stat };
}
function removeVerifiedLegacyFile(entry, location) {
const safePath = assertWithinTrustedRoot(
entry.destinationPath,
location.targetRoot,
'remove verified legacy OpenCode file'
);
const quarantineDir = fs.mkdtempSync(path.join(
path.dirname(location.targetRoot),
'.ecc-opencode-remove-'
));
const quarantinePath = path.join(quarantineDir, path.basename(safePath));
try {
fs.renameSync(safePath, quarantinePath);
const quarantinedStat = fs.lstatSync(quarantinePath);
const identityMatches = !quarantinedStat.isSymbolicLink()
&& quarantinedStat.isFile()
&& quarantinedStat.dev === entry.stat.dev
&& quarantinedStat.ino === entry.stat.ino;
if (!identityMatches) {
fs.renameSync(quarantinePath, safePath);
fs.rmdirSync(quarantineDir);
return false;
}
fs.rmSync(quarantinePath);
fs.rmdirSync(quarantineDir);
return true;
} catch (error) {
try {
if (pathExists(quarantinePath) && !pathExists(safePath)) {
fs.renameSync(quarantinePath, safePath);
}
if (pathExists(quarantineDir) && fs.readdirSync(quarantineDir).length === 0) {
fs.rmdirSync(quarantineDir);
}
} catch (_restoreError) {
// Preserve the quarantined entry when restoration cannot be proven safe.
}
throw error;
}
}
function cleanupLegacyOpencodeInstall(plan) {
const location = getLegacyLocationForPlan(plan);
const emptyResult = {
detected: false,
complete: false,
removedPaths: [],
retainedPaths: [],
warnings: [],
};
if (!location || typeof plan.sourceRoot !== 'string' || !pathExists(plan.installStatePath)) {
return emptyResult;
}
try {
const canonicalState = readInstallState(plan.installStatePath);
if (
(canonicalState.target.target !== OPENCODE_TARGET
&& canonicalState.target.id !== 'opencode-home')
|| !samePath(canonicalState.target.root, plan.targetRoot)
|| !samePath(canonicalState.target.installStatePath, plan.installStatePath)
) {
return emptyResult;
}
} catch (_error) {
return emptyResult;
}
const inspection = inspectLegacyOpencodeState(location);
if (inspection.status === 'unreadable') {
return {
...emptyResult,
detected: true,
retainedPaths: [location.targetRoot],
warnings: [inspection.error],
};
}
if (inspection.status !== 'valid') {
return emptyResult;
}
const removable = [];
const retainedPaths = [];
for (const operation of inspection.state.operations || []) {
const verified = verifyManagedLegacyFile(operation, location, plan.sourceRoot);
if (verified.destinationPath) {
removable.push(verified);
} else if (verified.retainedPath) {
retainedPaths.push(verified.retainedPath);
}
}
const removedPaths = [];
for (const entry of removable) {
try {
if (!removeVerifiedLegacyFile(entry, location)) {
retainedPaths.push(entry.destinationPath);
continue;
}
removedPaths.push(entry.destinationPath);
removeEmptyParents(entry.destinationPath, location.targetRoot);
} catch (_error) {
retainedPaths.push(entry.destinationPath);
}
}
const complete = retainedPaths.length === 0;
if (complete) {
fs.rmSync(location.installStatePath, { force: true });
removedPaths.push(location.installStatePath);
try {
if (pathExists(location.targetRoot) && fs.readdirSync(location.targetRoot).length === 0) {
fs.rmdirSync(location.targetRoot);
}
} catch (_error) {
// Removing an empty legacy root is best effort after ownership is cleared.
}
}
return {
detected: true,
complete,
removedPaths,
retainedPaths: [...new Set(retainedPaths)].sort(),
warnings: complete
? []
: ['Modified, unsupported, or unverifiable managed files remain under ~/.opencode and were preserved.'],
};
}
module.exports = {
cleanupLegacyOpencodeInstall,
getLegacyOpencodeLocation,
inspectLegacyOpencodeState,
};