diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e07fcae2..48c24cda3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/releases/2.2.0/RELEASE_NOTES.md b/docs/releases/2.2.0/RELEASE_NOTES.md index aca04f96b..34e07abcf 100644 --- a/docs/releases/2.2.0/RELEASE_NOTES.md +++ b/docs/releases/2.2.0/RELEASE_NOTES.md @@ -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. diff --git a/docs/testing/ecc-2.2-release-readiness.tdd.md b/docs/testing/ecc-2.2-release-readiness.tdd.md index 7224c60b4..37ea8e1df 100644 --- a/docs/testing/ecc-2.2-release-readiness.tdd.md +++ b/docs/testing/ecc-2.2-release-readiness.tdd.md @@ -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. diff --git a/scripts/lib/install-lifecycle.js b/scripts/lib/install-lifecycle.js index bf5dd8ef6..e13996abd 100644 --- a/scripts/lib/install-lifecycle.js +++ b/scripts/lib/install-lifecycle.js @@ -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', diff --git a/scripts/lib/install/apply.js b/scripts/lib/install/apply.js index 340b9204a..b33e94057 100644 --- a/scripts/lib/install/apply.js +++ b/scripts/lib/install/apply.js @@ -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, }; diff --git a/scripts/lib/install/opencode-legacy-migration.js b/scripts/lib/install/opencode-legacy-migration.js new file mode 100644 index 000000000..3ff5b848a --- /dev/null +++ b/scripts/lib/install/opencode-legacy-migration.js @@ -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, +}; diff --git a/tests/ci/packed-artifact-lifecycle.js b/tests/ci/packed-artifact-lifecycle.js index ba9303ca5..12935b036 100644 --- a/tests/ci/packed-artifact-lifecycle.js +++ b/tests/ci/packed-artifact-lifecycle.js @@ -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) { diff --git a/tests/ci/release-packed-artifact-workflow.test.js b/tests/ci/release-packed-artifact-workflow.test.js index d75eeadcd..c24bfa781 100644 --- a/tests/ci/release-packed-artifact-workflow.test.js +++ b/tests/ci/release-packed-artifact-workflow.test.js @@ -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\)/); }); diff --git a/tests/lib/install-state-selective-reinstall.test.js b/tests/lib/install-state-selective-reinstall.test.js index 4af47a506..a75a6b024 100644 --- a/tests/lib/install-state-selective-reinstall.test.js +++ b/tests/lib/install-state-selective-reinstall.test.js @@ -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); diff --git a/tests/lib/opencode-legacy-migration.test.js b/tests/lib/opencode-legacy-migration.test.js index 541beeee9..e55745306 100644 --- a/tests/lib/opencode-legacy-migration.test.js +++ b/tests/lib/opencode-legacy-migration.test.js @@ -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);