mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-28 10:49:45 +02:00
fix(opencode): migrate legacy managed home installs
This commit is contained in:
+1
-1
@@ -14,7 +14,7 @@
|
||||
### Changed
|
||||
|
||||
- Default MCP connector set reduced to a single connector (`chrome-devtools`) per the new connector policy (`docs/MCP-CONNECTOR-POLICY.md`). The six previous defaults (`github`, `context7`, `exa`, `memory`, `playwright`, `sequential-thinking`) were retired after the June 2026 audit: their jobs are covered by skills wrapping CLIs/REST APIs (`github-ops`, `documentation-lookup`, `exa-search`, e2e skills) or by harness-native features (memory, extended thinking, web search). All six remain opt-in via `mcp-configs/mcp-servers.json`.
|
||||
- OpenCode home installs now use its canonical `~/.config/opencode` location, and bundled agents inherit the model selected by the user instead of pinning an Anthropic provider.
|
||||
- OpenCode home installs now use its canonical `~/.config/opencode` location, safely discover and migrate unchanged ECC-managed files from legacy `~/.opencode` installs, and preserve modified legacy files for review. Bundled agents inherit the model selected by the user instead of pinning an Anthropic provider.
|
||||
- `skill-comply` is now part of the install manifest and npm distribution, with generated Python caches excluded from both install and package surfaces.
|
||||
- Release automation now verifies the tag is exactly on `origin/main`, fails closed on npm registry errors, tests the exact packed artifact across Linux, macOS, and Windows, publishes npm before creating the GitHub Release, and uses reviewed release notes.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ ECC 2.2.0 makes the universal installer a first-class, cross-harness distributio
|
||||
|
||||
- Antigravity installs natively to `.agents/{rules,workflows,skills,agents}`. Do not manually rename a legacy `.agent` directory. Re-run ECC 2.2.0 so the installer can apply its ownership-aware migration rules.
|
||||
- Repeated selective installs retain the complete managed ownership ledger. A later module install no longer causes previously installed ECC files to survive uninstall.
|
||||
- OpenCode home installs use `~/.config/opencode`, and its bundled agent definitions inherit the user's selected model provider.
|
||||
- OpenCode home installs use `~/.config/opencode`. Reinstall or repair discovers legacy `~/.opencode` ownership, migrates unchanged ECC-managed files, and preserves modified files for review. Bundled agent definitions inherit the user's selected model provider.
|
||||
- Legacy Codex sync cleanup requires ownership evidence by default and preserves untracked or modified user files.
|
||||
- `skill-comply` is included in both the install graph and npm archive. Python bytecode and pytest caches remain excluded.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Date: 2026-08-24
|
||||
|
||||
## Scope
|
||||
|
||||
This pass covers the release blockers found in the delta from `v2.1.0`: cumulative selective-install ownership, native Antigravity packaging, canonical OpenCode installation, provider-neutral OpenCode agents, `skill-comply` distribution, conservative legacy Codex uninstall, release-workflow safety, and guided-install filesystem boundaries.
|
||||
This pass covers the release blockers found in the delta from `v2.1.0`: cumulative selective-install ownership, native Antigravity packaging, canonical OpenCode installation and conservative legacy migration, provider-neutral OpenCode agents, `skill-comply` distribution, conservative legacy Codex uninstall, release-workflow safety, and guided-install filesystem boundaries.
|
||||
|
||||
## RED
|
||||
|
||||
@@ -21,6 +21,8 @@ Commit `528dbea0` added a security regression proving guided preflight accepted
|
||||
|
||||
Commit `a504b194` added a release regression after review proved both workflows reused the literal 2.2.0 notes path for later valid versions. Both workflow cases failed before the version-derived notes repair.
|
||||
|
||||
Commit `55a2d482` added five OpenCode upgrade regressions. Discovery, uninstall, canonical reinstall, repair migration, and no-follow symlink preservation all failed before the legacy managed-root repair.
|
||||
|
||||
## GREEN
|
||||
|
||||
- Focused installer, lifecycle, packaging, release-workflow, manifest, OpenCode, Antigravity, and uninstall tests passed.
|
||||
|
||||
@@ -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,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,
|
||||
};
|
||||
@@ -263,9 +263,15 @@ function runTargetSmoke(options) {
|
||||
`${options.target} packed install`
|
||||
);
|
||||
const statePath = path.join(options.targetRoot, 'ecc-install-state.json');
|
||||
const installedSkillPath = path.join(
|
||||
options.targetRoot,
|
||||
'skills',
|
||||
'skill-comply',
|
||||
'SKILL.md'
|
||||
);
|
||||
assert.ok(fs.existsSync(statePath), `${options.target} install-state must exist`);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(options.targetRoot, 'skills', 'skill-comply', 'SKILL.md')),
|
||||
fs.existsSync(installedSkillPath),
|
||||
`${options.target} must install skill-comply from the packed archive`
|
||||
);
|
||||
|
||||
@@ -281,6 +287,10 @@ function runTargetSmoke(options) {
|
||||
);
|
||||
assert.strictEqual(uninstall.summary.errorCount, 0);
|
||||
assert.ok(!fs.existsSync(statePath), `${options.target} uninstall must remove install-state`);
|
||||
assert.ok(
|
||||
!fs.existsSync(installedSkillPath),
|
||||
`${options.target} uninstall must remove the installed skill`
|
||||
);
|
||||
}
|
||||
|
||||
function runLifecycle(options) {
|
||||
|
||||
@@ -184,7 +184,7 @@ test('packed lifecycle validates canonical Antigravity and OpenCode installs', (
|
||||
assert.match(lifecycleRunnerSource, /target:\s*'opencode'/);
|
||||
assert.match(lifecycleRunnerSource, /path\.join\(homeDir, '\.config', 'opencode'\)/);
|
||||
assert.match(lifecycleRunnerSource, /\['doctor', '--target', options\.target, '--json'\]/);
|
||||
assert.match(lifecycleRunnerSource, /skill-comply.*SKILL\.md/);
|
||||
assert.match(lifecycleRunnerSource, /skill-comply[\s\S]*SKILL\.md/);
|
||||
assert.match(lifecycleRunnerSource, /!fs\.existsSync\(installedSkillPath\)/);
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ const { applyInstallPlan } = require('../../scripts/lib/install/apply');
|
||||
const { readInstallState } = require('../../scripts/lib/install-state');
|
||||
const { uninstallInstalledStates } = require('../../scripts/lib/install-lifecycle');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function makePlan(root, moduleId, fileName) {
|
||||
const targetRoot = path.join(root, '.cursor');
|
||||
const installStatePath = path.join(targetRoot, 'ecc-install-state.json');
|
||||
@@ -79,6 +82,13 @@ try {
|
||||
assert.ok(!fs.existsSync(first.operations[0].destinationPath));
|
||||
assert.ok(!fs.existsSync(second.operations[0].destinationPath));
|
||||
console.log(' ✓ selective reinstall preserves cumulative ownership and uninstall removes it');
|
||||
passed += 1;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${error.message}`);
|
||||
failed += 1;
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
|
||||
@@ -88,6 +88,7 @@ function canonicalPlan(homeDir) {
|
||||
moduleIds: ['workflow-quality'],
|
||||
projectRoot: homeDir,
|
||||
homeDir,
|
||||
exemptValidationCodes: ['opencode-plugin-not-built'],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -124,7 +125,7 @@ test('uninstall removes unchanged legacy-managed files and preserves user conten
|
||||
const sentinelPath = path.join(legacy.targetRoot, 'user.txt');
|
||||
fs.writeFileSync(sentinelPath, 'keep\n');
|
||||
const result = uninstallInstalledStates({ homeDir, projectRoot: homeDir, targets: ['opencode'] });
|
||||
assert.strictEqual(result.summary.errorCount, 0);
|
||||
assert.strictEqual(result.summary.errorCount, 0, JSON.stringify(result));
|
||||
assert.ok(!fs.existsSync(legacy.destinationPath));
|
||||
assert.ok(!fs.existsSync(legacy.installStatePath));
|
||||
assert.strictEqual(fs.readFileSync(sentinelPath, 'utf8'), 'keep\n');
|
||||
@@ -157,7 +158,7 @@ test('repair migrates a legacy install while preserving modified legacy files',
|
||||
projectRoot: homeDir,
|
||||
targets: ['opencode'],
|
||||
});
|
||||
assert.strictEqual(result.summary.errorCount, 0);
|
||||
assert.strictEqual(result.summary.errorCount, 0, JSON.stringify(result));
|
||||
assert.ok(fs.existsSync(path.join(homeDir, '.config', 'opencode', 'ecc-install-state.json')));
|
||||
assert.strictEqual(fs.readFileSync(legacy.destinationPath, 'utf8'), 'user-modified\n');
|
||||
assert.ok(fs.existsSync(legacy.installStatePath));
|
||||
@@ -166,5 +167,32 @@ test('repair migrates a legacy install while preserving modified legacy files',
|
||||
}
|
||||
});
|
||||
|
||||
test('migration never follows a legacy managed-file symlink', () => {
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-legacy-symlink-'));
|
||||
try {
|
||||
const legacy = seedLegacyInstall(homeDir);
|
||||
const victimPath = path.join(homeDir, 'victim.txt');
|
||||
fs.writeFileSync(victimPath, 'do-not-delete\n');
|
||||
fs.rmSync(legacy.destinationPath);
|
||||
try {
|
||||
fs.symlinkSync(victimPath, legacy.destinationPath);
|
||||
} catch (error) {
|
||||
if (process.platform === 'win32' && error.code === 'EPERM') {
|
||||
console.log(' (symlink unsupported on this platform; skipping)');
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const result = applyInstallPlan(canonicalPlan(homeDir));
|
||||
assert.ok(result.warnings.some(warning => warning.includes('Legacy OpenCode migration')));
|
||||
assert.strictEqual(fs.readFileSync(victimPath, 'utf8'), 'do-not-delete\n');
|
||||
assert.ok(fs.lstatSync(legacy.destinationPath).isSymbolicLink());
|
||||
assert.ok(fs.existsSync(legacy.installStatePath));
|
||||
} finally {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
|
||||
Reference in New Issue
Block a user