mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-16 21:25:42 +02:00
Merge PR #3256: reconcile obsolete managed installer files safely
Preserve main OpenCode lease and activation gates and native Claude namespace receipts. Detect same-size in-place edits using bigint modification and change times during hashing and before deletion; verify both races and actual public CLI reinstall lifecycles.
This commit is contained in:
@@ -140,6 +140,13 @@ function printHumanPlan(plan, dryRun) {
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(plan.reconciledStalePaths) && plan.reconciledStalePaths.length > 0) {
|
||||
console.log('\nRemoved files no longer in the install plan:');
|
||||
for (const removedPath of plan.reconciledStalePaths) {
|
||||
console.log(`- removed ${removedPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!dryRun) {
|
||||
console.log(`\nDone. Install-state written to ${plan.installStatePath}`);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,12 @@ const {
|
||||
completeExcludedPathsReconciliation,
|
||||
prepareExcludedPathsReconciliation,
|
||||
} = require('./excluded-paths-reconciliation');
|
||||
const {
|
||||
completeStaleOperationsReconciliation,
|
||||
describeStaleOperationsPreview,
|
||||
prepareStaleOperationsReconciliation,
|
||||
withoutStaleOperations,
|
||||
} = require('./stale-operations-reconciliation');
|
||||
const { buildInstallIndex, rewriteRelativeLinks } = require('./link-rewrite');
|
||||
const { transformInstallContent } = require('./content-transform');
|
||||
|
||||
@@ -672,9 +678,12 @@ function prepareHookConsentMigration(plan, migration) {
|
||||
|
||||
function previewInstallPlan(plan) {
|
||||
assertOpenCodeHookDeactivationReady(plan);
|
||||
const migration = prepareHookConsentMigration(
|
||||
const migration = prepareStaleOperationsReconciliation(
|
||||
plan,
|
||||
prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan))
|
||||
prepareHookConsentMigration(
|
||||
plan,
|
||||
prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan))
|
||||
)
|
||||
);
|
||||
const appliedPlan = {
|
||||
...plan,
|
||||
@@ -686,7 +695,7 @@ function previewInstallPlan(plan) {
|
||||
: [];
|
||||
return {
|
||||
...plan,
|
||||
statePreview: migration.finalState,
|
||||
statePreview: withoutStaleOperations(migration.finalState, migration),
|
||||
plannedOperations: [...plan.operations],
|
||||
operations: migration.appliedOperations,
|
||||
skippedOperations: migration.skippedOperations,
|
||||
@@ -694,6 +703,7 @@ function previewInstallPlan(plan) {
|
||||
...(Array.isArray(plan.warnings) ? plan.warnings : []),
|
||||
...migration.warnings,
|
||||
...hookConsentWarnings,
|
||||
...describeStaleOperationsPreview(migration),
|
||||
],
|
||||
applied: false,
|
||||
};
|
||||
@@ -736,11 +746,14 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
|
||||
assertOpenCodeLeaseCoverage(plan, dependencies.opencodeLease);
|
||||
const legacyActivation = inspectLegacyOpenCodeDeactivation(plan);
|
||||
const activationSnapshot = assertOpenCodeHookDeactivationReady(plan);
|
||||
const migration = prepareExcludedPathsReconciliation(
|
||||
const migration = prepareStaleOperationsReconciliation(
|
||||
plan,
|
||||
prepareHookConsentMigration(
|
||||
prepareExcludedPathsReconciliation(
|
||||
plan,
|
||||
prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan))
|
||||
prepareHookConsentMigration(
|
||||
plan,
|
||||
prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan))
|
||||
)
|
||||
)
|
||||
);
|
||||
const appliedPlan = {
|
||||
@@ -902,10 +915,14 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
|
||||
);
|
||||
}
|
||||
|
||||
// Include preserved user configs omitted from the write plan: they must
|
||||
// still be inactive before we record a completed install.
|
||||
// Preserve main's activation safety gate before publishing ownership.
|
||||
assertOpenCodeHookDeactivationReady(plan, { requireInactive: true });
|
||||
finalState = stateWithContentDigests(migration.finalState, appliedPlan);
|
||||
// Stale records leave only the successful final state; the bridge and
|
||||
// failure checkpoint above keep them so a failed install retains ownership.
|
||||
finalState = stateWithContentDigests(
|
||||
withoutStaleOperations(migration.finalState, migration),
|
||||
appliedPlan
|
||||
);
|
||||
if (typeof beforeInstallStateWrite === 'function') {
|
||||
beforeInstallStateWrite({ plan: appliedPlan, state: finalState });
|
||||
}
|
||||
@@ -994,6 +1011,18 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
|
||||
];
|
||||
}
|
||||
|
||||
let stalePathsRemoved = [];
|
||||
let stalePathsWarnings = [];
|
||||
try {
|
||||
const staleReconciliation = completeStaleOperationsReconciliation(migration, appliedPlan);
|
||||
stalePathsRemoved = staleReconciliation.removedPaths;
|
||||
stalePathsWarnings = staleReconciliation.warnings;
|
||||
} catch (error) {
|
||||
stalePathsWarnings = [
|
||||
`Stale install-state reconciliation did not finish: ${error.message}. Files ECC no longer installs were preserved; remove them manually if unwanted.`,
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
...plan,
|
||||
statePreview: finalState,
|
||||
@@ -1001,12 +1030,14 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
|
||||
operations: migration.appliedOperations,
|
||||
skippedOperations: migration.skippedOperations,
|
||||
reconciledExcludedPaths: excludedPathsRemoved,
|
||||
reconciledStalePaths: stalePathsRemoved,
|
||||
warnings: [
|
||||
...(Array.isArray(plan.warnings) ? plan.warnings : []),
|
||||
...migration.warnings,
|
||||
...antigravityMigrationWarnings,
|
||||
...opencodeMigrationWarnings,
|
||||
...excludedPathsWarnings,
|
||||
...stalePathsWarnings,
|
||||
],
|
||||
applied: true,
|
||||
};
|
||||
|
||||
@@ -243,6 +243,7 @@ function createDisabledMigration(plan, previousState) {
|
||||
bridgeState: finalState,
|
||||
finalState,
|
||||
legacyOperationsToRemove: [],
|
||||
retainedLegacyOperations: [],
|
||||
requiresBridgeState: plan.operations.length > 0,
|
||||
};
|
||||
}
|
||||
@@ -380,6 +381,10 @@ function prepareClaudeSkillMigration(plan) {
|
||||
bridgeState: states.bridgeState,
|
||||
finalState: states.finalState,
|
||||
legacyOperationsToRemove: states.legacyOperationsToRemove,
|
||||
// Nested legacy copies this migration deliberately keeps (user-owned flat
|
||||
// conflicts, or skills this plan does not install). Stale-operation
|
||||
// reconciliation must not treat them as obsolete.
|
||||
retainedLegacyOperations: [...classification.retainedLegacyOperations],
|
||||
requiresBridgeState: states.requiresBridgeState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { assertWithinTrustedRoot } = require('../path-safety');
|
||||
const { isHookRuntimeOperation } = require('./hook-consent');
|
||||
|
||||
/**
|
||||
* Stale managed copy-file reconciliation (issue #3232).
|
||||
*
|
||||
* Install-state is cumulative: every reinstall merges the previous managed
|
||||
* operations with the new plan so selective `--modules` installs keep earlier
|
||||
* ownership. Without reconciliation, a copy-file operation that a selected
|
||||
* module no longer ships (a removed source, or a file whose kind/destination
|
||||
* changed) stays in install-state forever and doctor reports it as missing or
|
||||
* drifted on every run.
|
||||
*
|
||||
* prepareStaleOperationsReconciliation identifies those records: managed
|
||||
* copy-file operations whose module is selected by the current plan but whose
|
||||
* destination is no longer planned by any operation. The records stay in the
|
||||
* bridge/checkpoint states so a failed install keeps its ownership;
|
||||
* withoutStaleOperations drops them only from the successful final state and
|
||||
* the dry-run preview. completeStaleOperationsReconciliation runs after the
|
||||
* final state is written and deletes a destination only when it is an
|
||||
* unchanged regular file with a recorded digest, reached without symlinks,
|
||||
* inside the target root. Anything else is preserved with one orphan warning.
|
||||
*/
|
||||
|
||||
const SHA256_PATTERN = /^[a-f0-9]{64}$/i;
|
||||
|
||||
function comparablePath(filePath) {
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
return process.platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath;
|
||||
}
|
||||
|
||||
function destinationKeys(operations) {
|
||||
return new Set((operations || [])
|
||||
.filter(operation => operation && typeof operation.destinationPath === 'string')
|
||||
.map(operation => comparablePath(operation.destinationPath)));
|
||||
}
|
||||
|
||||
function materializesWholeModules(plan) {
|
||||
// Legacy language installs are cumulative inside one module: pure legacy
|
||||
// plans share a module id across languages and Antigravity legacy-compat
|
||||
// filters rules-core by language, so an absent operation is not stale there.
|
||||
if (plan.mode === 'legacy') {
|
||||
return false;
|
||||
}
|
||||
const request = plan.statePreview && plan.statePreview.request;
|
||||
return !(request && request.legacyMode && plan.target === 'antigravity');
|
||||
}
|
||||
|
||||
function hooksDeclined(plan) {
|
||||
const request = plan.statePreview && plan.statePreview.request;
|
||||
return plan.hookConsent === 'declined' || Boolean(request && request.hookConsent === 'declined');
|
||||
}
|
||||
|
||||
function prepareStaleOperationsReconciliation(plan, migration) {
|
||||
const unchanged = { ...migration, staleOperationCandidates: [] };
|
||||
const finalOperations = migration && migration.finalState && migration.finalState.operations;
|
||||
const resolution = plan && plan.statePreview && plan.statePreview.resolution;
|
||||
if (!Array.isArray(finalOperations) || !resolution || !materializesWholeModules(plan)) {
|
||||
return unchanged;
|
||||
}
|
||||
|
||||
const selectedModules = new Set(resolution.selectedModules || []);
|
||||
const plannedDestinations = destinationKeys([
|
||||
...(plan.operations || []),
|
||||
...(plan.statePreview.operations || []),
|
||||
]);
|
||||
// Claude flat-skill migration deliberately keeps nested legacy copies that
|
||||
// conflict with user-owned flat skills; those records are not stale.
|
||||
const retainedLegacyDestinations = destinationKeys(migration.retainedLegacyOperations);
|
||||
const skipHookRuntime = hooksDeclined(plan);
|
||||
const seen = new Set();
|
||||
const staleOperationCandidates = finalOperations.filter(operation => {
|
||||
if (
|
||||
!operation
|
||||
|| operation.kind !== 'copy-file'
|
||||
|| operation.ownership !== 'managed'
|
||||
|| typeof operation.destinationPath !== 'string'
|
||||
|| !selectedModules.has(operation.moduleId)
|
||||
// Declining hooks strips hook files without the module dropping them.
|
||||
|| (skipHookRuntime && isHookRuntimeOperation(operation))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const key = comparablePath(operation.destinationPath);
|
||||
if (plannedDestinations.has(key) || retainedLegacyDestinations.has(key) || seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
return { ...migration, staleOperationCandidates };
|
||||
}
|
||||
|
||||
function withoutStaleOperations(state, migration) {
|
||||
const staleDestinations = destinationKeys(migration && migration.staleOperationCandidates);
|
||||
if (!state || !Array.isArray(state.operations) || staleDestinations.size === 0) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
operations: state.operations.filter(operation => !(
|
||||
operation
|
||||
&& operation.kind === 'copy-file'
|
||||
&& typeof operation.destinationPath === 'string'
|
||||
&& staleDestinations.has(comparablePath(operation.destinationPath))
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
function describeStaleOperationsPreview(migration) {
|
||||
const count = ((migration && migration.staleOperationCandidates) || []).length;
|
||||
if (count === 0) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
`${count} previously managed file(s) are no longer part of this install plan; `
|
||||
+ 'applying it removes their install-state records and deletes only unchanged copies.',
|
||||
];
|
||||
}
|
||||
|
||||
class PreservedStaleFile extends Error {}
|
||||
|
||||
function lstatOrNull(filePath) {
|
||||
try {
|
||||
return fs.lstatSync(filePath, { bigint: true });
|
||||
} catch (error) {
|
||||
if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the destination's lstat, or null when it is absent. Every segment
|
||||
// from the root to the destination is checked with lstat so an in-root
|
||||
// symlink can never redirect the delete onto its target.
|
||||
function inspectDestinationPath(destinationPath, targetRoot) {
|
||||
const resolvedRoot = path.resolve(targetRoot);
|
||||
const resolvedTarget = path.resolve(destinationPath);
|
||||
const relativePath = path.relative(resolvedRoot, resolvedTarget);
|
||||
if (
|
||||
!relativePath
|
||||
|| relativePath === '..'
|
||||
|| relativePath.startsWith(`..${path.sep}`)
|
||||
|| path.isAbsolute(relativePath)
|
||||
) {
|
||||
throw new PreservedStaleFile('it is outside the install root');
|
||||
}
|
||||
assertWithinTrustedRoot(resolvedTarget, resolvedRoot, 'reconcile stale install file');
|
||||
|
||||
const segments = relativePath.split(path.sep);
|
||||
let currentPath = resolvedRoot;
|
||||
for (let index = 0; index <= segments.length; index += 1) {
|
||||
if (index > 0) {
|
||||
currentPath = path.join(currentPath, segments[index - 1]);
|
||||
}
|
||||
const stat = lstatOrNull(currentPath);
|
||||
if (!stat) {
|
||||
return null;
|
||||
}
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new PreservedStaleFile(index === segments.length
|
||||
? 'it is a symlink'
|
||||
: `its path passes through the symlink ${currentPath}`);
|
||||
}
|
||||
if (index < segments.length && !stat.isDirectory()) {
|
||||
throw new PreservedStaleFile(`${currentPath} is not a directory`);
|
||||
}
|
||||
if (index === segments.length && !stat.isFile()) {
|
||||
throw new PreservedStaleFile('it is not a regular file');
|
||||
}
|
||||
if (index === segments.length) {
|
||||
return stat;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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, { bigint: true });
|
||||
if (!before.isFile()) {
|
||||
throw new PreservedStaleFile('it is not a regular file');
|
||||
}
|
||||
const content = fs.readFileSync(descriptor);
|
||||
const after = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (!sameFile(before, after)) {
|
||||
throw new PreservedStaleFile('it changed while being verified');
|
||||
}
|
||||
return {
|
||||
digest: crypto.createHash('sha256').update(content).digest('hex'),
|
||||
stat: after,
|
||||
};
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function sameFile(left, right) {
|
||||
return Boolean(left && right)
|
||||
&& left.ino === right.ino
|
||||
&& left.dev === right.dev
|
||||
&& left.size === right.size
|
||||
&& left.mtimeNs === right.mtimeNs
|
||||
&& left.ctimeNs === right.ctimeNs;
|
||||
}
|
||||
|
||||
function removeEmptyParents(filePath, targetRoot) {
|
||||
const resolvedRoot = path.resolve(targetRoot);
|
||||
let currentPath = path.dirname(path.resolve(filePath));
|
||||
while (
|
||||
comparablePath(currentPath) !== comparablePath(resolvedRoot)
|
||||
&& comparablePath(currentPath).startsWith(`${comparablePath(resolvedRoot)}${path.sep}`)
|
||||
) {
|
||||
const stat = lstatOrNull(currentPath);
|
||||
if (!stat || stat.isSymbolicLink() || !stat.isDirectory() || fs.readdirSync(currentPath).length > 0) {
|
||||
return;
|
||||
}
|
||||
fs.rmdirSync(currentPath);
|
||||
currentPath = path.dirname(currentPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Removes one stale destination. Returns true when removed, false when it was
|
||||
// already absent, and throws PreservedStaleFile when it must be kept.
|
||||
function removeUnchangedStaleFile(candidate, targetRoot) {
|
||||
const initialStat = inspectDestinationPath(candidate.destinationPath, targetRoot);
|
||||
if (!initialStat) {
|
||||
return false;
|
||||
}
|
||||
if (typeof candidate.contentSha256 !== 'string' || !SHA256_PATTERN.test(candidate.contentSha256)) {
|
||||
throw new PreservedStaleFile('its record has no content digest, so it cannot be verified unchanged');
|
||||
}
|
||||
|
||||
const destinationPath = path.resolve(candidate.destinationPath);
|
||||
const hashed = hashFileNoFollow(destinationPath);
|
||||
if (hashed.digest !== candidate.contentSha256.toLowerCase()) {
|
||||
throw new PreservedStaleFile('its content changed after install');
|
||||
}
|
||||
// Revalidate right before unlinking; the path must still name the exact
|
||||
// regular file that was hashed.
|
||||
const finalStat = inspectDestinationPath(destinationPath, targetRoot);
|
||||
if (!sameFile(initialStat, hashed.stat) || !sameFile(finalStat, hashed.stat)) {
|
||||
throw new PreservedStaleFile('it changed while being verified');
|
||||
}
|
||||
fs.unlinkSync(destinationPath);
|
||||
try {
|
||||
removeEmptyParents(destinationPath, targetRoot);
|
||||
} catch (_error) {
|
||||
// Pruning emptied directories is best effort; the stale file is gone.
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function completeStaleOperationsReconciliation(migration, plan) {
|
||||
const removedPaths = [];
|
||||
const warnings = [];
|
||||
for (const candidate of (migration && migration.staleOperationCandidates) || []) {
|
||||
try {
|
||||
if (removeUnchangedStaleFile(candidate, plan.targetRoot)) {
|
||||
removedPaths.push(path.resolve(candidate.destinationPath));
|
||||
}
|
||||
} catch (error) {
|
||||
const reason = error instanceof PreservedStaleFile
|
||||
? error.message
|
||||
: `it could not be verified or removed (${error.message})`;
|
||||
warnings.push(
|
||||
`Preserved orphaned file ${candidate.destinationPath}: ECC no longer installs it and `
|
||||
+ `stopped tracking it, but ${reason}; remove it manually if unwanted.`
|
||||
);
|
||||
}
|
||||
}
|
||||
return { removedPaths, warnings };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
completeStaleOperationsReconciliation,
|
||||
describeStaleOperationsPreview,
|
||||
prepareStaleOperationsReconciliation,
|
||||
withoutStaleOperations,
|
||||
};
|
||||
@@ -0,0 +1,605 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Regression tests for stale install-state operations (issue #3232).
|
||||
*
|
||||
* A reinstall must drop managed copy-file records that a selected module no
|
||||
* longer ships, delete only unchanged regular files reached without symlinks,
|
||||
* and keep ownership whenever the install fails or the record is out of scope.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const { applyInstallPlan, previewInstallPlan } = require('../../scripts/lib/install/apply');
|
||||
const { readInstallState, writeInstallState } = require('../../scripts/lib/install-state');
|
||||
const { withHookConsent } = require('../../scripts/lib/install/hook-consent');
|
||||
const { createInstallPlanFromRequest } = require('../../scripts/lib/install/runtime');
|
||||
const { normalizeInstallRequest, parseInstallArgs } = require('../../scripts/lib/install/request');
|
||||
const { buildDoctorReport } = require('../../scripts/lib/install-lifecycle');
|
||||
const { completeStaleOperationsReconciliation } = require('../../scripts/lib/install/stale-operations-reconciliation');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..', '..');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-stale-ops-'));
|
||||
try {
|
||||
if (fn(root) === 'skip') {
|
||||
console.log(` - ${name} (skipped: symlinks unavailable)`);
|
||||
skipped += 1;
|
||||
return;
|
||||
}
|
||||
console.log(` ✓ ${name}`);
|
||||
passed += 1;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${error.stack || error.message}`);
|
||||
failed += 1;
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(content) {
|
||||
return crypto.createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
function writeFile(filePath, content) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content);
|
||||
}
|
||||
|
||||
function trySymlink(targetPath, linkPath, type) {
|
||||
try {
|
||||
fs.symlinkSync(targetPath, linkPath, type);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (process.platform === 'win32' && (error.code === 'EPERM' || error.code === 'EACCES')) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function cursorTarget(root) {
|
||||
const targetRoot = path.join(root, '.cursor');
|
||||
return {
|
||||
root,
|
||||
targetRoot,
|
||||
installStatePath: path.join(targetRoot, 'ecc-install-state.json'),
|
||||
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
|
||||
};
|
||||
}
|
||||
|
||||
function claudeTarget(root) {
|
||||
const targetRoot = path.join(root, '.claude');
|
||||
return {
|
||||
root,
|
||||
targetRoot,
|
||||
installStatePath: path.join(targetRoot, 'ecc', 'install-state.json'),
|
||||
adapter: { id: 'claude-home', target: 'claude', kind: 'home' },
|
||||
};
|
||||
}
|
||||
|
||||
// spec: { moduleId, source, dest?, kind?, content?, payload?, skipSource? }
|
||||
function buildOperation(target, spec) {
|
||||
const destinationPath = path.join(target.targetRoot, spec.dest || spec.source);
|
||||
if (spec.kind === 'merge-json') {
|
||||
return {
|
||||
kind: 'merge-json',
|
||||
moduleId: spec.moduleId,
|
||||
sourceRelativePath: spec.source,
|
||||
destinationPath,
|
||||
strategy: 'merge-json',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
mergePayload: spec.payload || { managed: true },
|
||||
};
|
||||
}
|
||||
const sourcePath = path.join(target.root, 'source', spec.source);
|
||||
if (!spec.skipSource) {
|
||||
writeFile(sourcePath, spec.content === undefined ? `${spec.source}\n` : spec.content);
|
||||
}
|
||||
return {
|
||||
kind: 'copy-file',
|
||||
moduleId: spec.moduleId,
|
||||
sourcePath,
|
||||
sourceRelativePath: spec.source,
|
||||
destinationPath,
|
||||
strategy: 'preserve-relative-path',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
};
|
||||
}
|
||||
|
||||
function makePlan(target, specs, options = {}) {
|
||||
const operations = specs.map(spec => buildOperation(target, spec));
|
||||
const modules = options.modules || [...new Set(operations.map(operation => operation.moduleId))];
|
||||
return {
|
||||
mode: 'manifest',
|
||||
target: target.adapter.target,
|
||||
adapter: target.adapter,
|
||||
targetRoot: target.targetRoot,
|
||||
installRoot: target.targetRoot,
|
||||
installStatePath: target.installStatePath,
|
||||
operations,
|
||||
statePreview: {
|
||||
schemaVersion: 'ecc.install.v1',
|
||||
installedAt: new Date().toISOString(),
|
||||
target: { ...target.adapter, root: target.targetRoot, installStatePath: target.installStatePath },
|
||||
request: {
|
||||
profile: null,
|
||||
modules,
|
||||
includeComponents: [],
|
||||
excludeComponents: [],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
hookConsent: null,
|
||||
},
|
||||
resolution: { selectedModules: modules, skippedModules: [] },
|
||||
source: { manifestVersion: 1 },
|
||||
operations,
|
||||
},
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
function relativeDestinations(target, operations) {
|
||||
const ops = operations || readInstallState(target.installStatePath).operations;
|
||||
return ops
|
||||
.map(operation => path.relative(target.targetRoot, operation.destinationPath).split(path.sep).join('/'))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function findRecord(target, relativePath) {
|
||||
const destinationPath = path.join(target.targetRoot, relativePath);
|
||||
return readInstallState(target.installStatePath).operations
|
||||
.find(operation => operation.destinationPath === destinationPath);
|
||||
}
|
||||
|
||||
function editState(target, transform) {
|
||||
const state = readInstallState(target.installStatePath);
|
||||
writeInstallState(target.installStatePath, { ...state, operations: transform(state.operations) });
|
||||
}
|
||||
|
||||
function orphanWarnings(result) {
|
||||
return result.warnings.filter(warning => warning.startsWith('Preserved orphaned file '));
|
||||
}
|
||||
|
||||
const KEEP = { moduleId: 'core', source: 'lib/keep.js' };
|
||||
|
||||
console.log('\n=== Testing stale install-state operations (#3232) ===\n');
|
||||
|
||||
for (const phase of ['during hashing', 'after hashing']) {
|
||||
test(`preserves an equal-length user edit ${phase}`, root => {
|
||||
const target = cursorTarget(root);
|
||||
const file = path.join(target.targetRoot, 'obsolete.txt');
|
||||
const original = 'installed bytes\n';
|
||||
const edited = 'user edit bytes\n';
|
||||
assert.strictEqual(original.length, edited.length);
|
||||
writeFile(file, original);
|
||||
const identity = fs.statSync(file, { bigint: true });
|
||||
const originalRead = fs.readFileSync;
|
||||
const originalFstat = fs.fstatSync;
|
||||
let mutationApplied = false;
|
||||
let matchingStats = 0;
|
||||
const matches = stat => stat.dev === identity.dev && stat.ino === identity.ino;
|
||||
const mutate = () => {
|
||||
fs.writeFileSync(file, edited);
|
||||
// Avoid depending on filesystem timestamp granularity in the race fixture.
|
||||
const later = new Date(Date.now() + 2000);
|
||||
fs.utimesSync(file, later, later);
|
||||
mutationApplied = true;
|
||||
};
|
||||
fs.readFileSync = function (value, ...args) {
|
||||
const bytes = originalRead.call(this, value, ...args);
|
||||
if (phase === 'during hashing' && typeof value === 'number'
|
||||
&& matches(originalFstat(value, { bigint: true }))) mutate();
|
||||
return bytes;
|
||||
};
|
||||
fs.fstatSync = function (descriptor, ...args) {
|
||||
const stat = originalFstat.call(this, descriptor, ...args);
|
||||
if (phase === 'after hashing' && matches(stat) && ++matchingStats === 2) mutate();
|
||||
return stat;
|
||||
};
|
||||
let result;
|
||||
try {
|
||||
result = completeStaleOperationsReconciliation({ staleOperationCandidates: [{
|
||||
destinationPath: file, contentSha256: sha256(original),
|
||||
}] }, { targetRoot: target.targetRoot });
|
||||
} finally {
|
||||
fs.readFileSync = originalRead;
|
||||
fs.fstatSync = originalFstat;
|
||||
}
|
||||
assert.ok(mutationApplied, 'The equal-length mutation must actually execute');
|
||||
assert.strictEqual(fs.readFileSync(file, 'utf8'), edited);
|
||||
assert.deepStrictEqual(result.removedPaths, []);
|
||||
assert.strictEqual(result.warnings.length, 1);
|
||||
assert.match(result.warnings[0], /changed while being verified/);
|
||||
});
|
||||
}
|
||||
|
||||
test('removes an unchanged stale copy and its record after a successful reinstall', root => {
|
||||
const target = cursorTarget(root);
|
||||
const stale = { moduleId: 'core', source: 'lib/obsolete/cost-estimate.js' };
|
||||
applyInstallPlan(makePlan(target, [KEEP, stale]));
|
||||
const stalePath = path.join(target.targetRoot, stale.source);
|
||||
assert.ok(fs.existsSync(stalePath));
|
||||
|
||||
const result = applyInstallPlan(makePlan(target, [KEEP]));
|
||||
assert.ok(!fs.existsSync(stalePath), 'unchanged stale copy should be deleted');
|
||||
assert.ok(!fs.existsSync(path.dirname(stalePath)), 'emptied stale directory should be pruned');
|
||||
assert.ok(fs.existsSync(path.join(target.targetRoot, KEEP.source)));
|
||||
assert.deepStrictEqual(result.reconciledStalePaths, [path.resolve(stalePath)]);
|
||||
assert.deepStrictEqual(result.warnings, []);
|
||||
assert.deepStrictEqual(relativeDestinations(target), ['lib/keep.js']);
|
||||
assert.deepStrictEqual(relativeDestinations(target, result.statePreview.operations), ['lib/keep.js']);
|
||||
});
|
||||
|
||||
test('preserves a modified stale copy, drops its record, and warns only once', root => {
|
||||
const target = cursorTarget(root);
|
||||
const stale = { moduleId: 'core', source: 'lib/edited.js' };
|
||||
applyInstallPlan(makePlan(target, [KEEP, stale]));
|
||||
const stalePath = path.join(target.targetRoot, stale.source);
|
||||
fs.writeFileSync(stalePath, 'user edit\n');
|
||||
|
||||
const result = applyInstallPlan(makePlan(target, [KEEP]));
|
||||
assert.strictEqual(fs.readFileSync(stalePath, 'utf8'), 'user edit\n');
|
||||
assert.deepStrictEqual(result.reconciledStalePaths, []);
|
||||
const warnings = orphanWarnings(result);
|
||||
assert.strictEqual(warnings.length, 1, warnings.join('\n'));
|
||||
assert.ok(warnings[0].includes(stalePath) && /content changed after install/.test(warnings[0]));
|
||||
assert.deepStrictEqual(relativeDestinations(target), ['lib/keep.js']);
|
||||
|
||||
const rerun = applyInstallPlan(makePlan(target, [KEEP]));
|
||||
assert.deepStrictEqual(orphanWarnings(rerun), [], 'an untracked orphan must not warn again');
|
||||
assert.strictEqual(fs.readFileSync(stalePath, 'utf8'), 'user edit\n');
|
||||
});
|
||||
|
||||
test('preserves a stale copy whose record has no content digest', root => {
|
||||
const target = cursorTarget(root);
|
||||
const stale = { moduleId: 'core', source: 'lib/old-ledger.js' };
|
||||
applyInstallPlan(makePlan(target, [KEEP, stale]));
|
||||
editState(target, operations => operations.map(operation => {
|
||||
if (!operation.destinationPath.endsWith('old-ledger.js')) {
|
||||
return operation;
|
||||
}
|
||||
const { contentSha256: _digest, ...digestless } = operation;
|
||||
return digestless;
|
||||
}));
|
||||
|
||||
const result = applyInstallPlan(makePlan(target, [KEEP]));
|
||||
assert.ok(fs.existsSync(path.join(target.targetRoot, stale.source)));
|
||||
const warnings = orphanWarnings(result);
|
||||
assert.strictEqual(warnings.length, 1);
|
||||
assert.ok(/no content digest/.test(warnings[0]));
|
||||
assert.deepStrictEqual(relativeDestinations(target), ['lib/keep.js']);
|
||||
});
|
||||
|
||||
test('preserves a stale destination that is no longer a regular file', root => {
|
||||
const target = cursorTarget(root);
|
||||
const stale = { moduleId: 'core', source: 'lib/now-a-dir.js' };
|
||||
applyInstallPlan(makePlan(target, [KEEP, stale]));
|
||||
const stalePath = path.join(target.targetRoot, stale.source);
|
||||
fs.rmSync(stalePath);
|
||||
writeFile(path.join(stalePath, 'user.txt'), 'user data\n');
|
||||
|
||||
const result = applyInstallPlan(makePlan(target, [KEEP]));
|
||||
assert.strictEqual(fs.readFileSync(path.join(stalePath, 'user.txt'), 'utf8'), 'user data\n');
|
||||
const warnings = orphanWarnings(result);
|
||||
assert.strictEqual(warnings.length, 1);
|
||||
assert.ok(/not a regular file/.test(warnings[0]));
|
||||
assert.deepStrictEqual(relativeDestinations(target), ['lib/keep.js']);
|
||||
});
|
||||
|
||||
test('never deletes a stale record destination outside the target root', root => {
|
||||
const target = cursorTarget(root);
|
||||
applyInstallPlan(makePlan(target, [KEEP]));
|
||||
const outsidePath = path.join(root, 'outside', 'victim.js');
|
||||
writeFile(outsidePath, 'victim\n');
|
||||
editState(target, operations => [...operations, {
|
||||
kind: 'copy-file',
|
||||
moduleId: 'core',
|
||||
sourceRelativePath: 'lib/victim.js',
|
||||
destinationPath: outsidePath,
|
||||
strategy: 'preserve-relative-path',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
contentSha256: sha256('victim\n'),
|
||||
}]);
|
||||
|
||||
const result = applyInstallPlan(makePlan(target, [KEEP]));
|
||||
assert.strictEqual(fs.readFileSync(outsidePath, 'utf8'), 'victim\n');
|
||||
const warnings = orphanWarnings(result);
|
||||
assert.strictEqual(warnings.length, 1);
|
||||
assert.ok(/outside the install root/.test(warnings[0]));
|
||||
assert.deepStrictEqual(relativeDestinations(target), ['lib/keep.js']);
|
||||
});
|
||||
|
||||
test('never deletes the in-root target of a stale symlinked destination', root => {
|
||||
const target = cursorTarget(root);
|
||||
const stale = { moduleId: 'core', source: 'lib/linked.js' };
|
||||
applyInstallPlan(makePlan(target, [KEEP, stale]));
|
||||
const linkPath = path.join(target.targetRoot, stale.source);
|
||||
// The link target holds exactly the recorded bytes, so following the link
|
||||
// would pass the digest check and delete the user's file.
|
||||
const linkTarget = path.join(target.targetRoot, 'user', 'linked-target.js');
|
||||
writeFile(linkTarget, `${stale.source}\n`);
|
||||
fs.rmSync(linkPath);
|
||||
if (!trySymlink(linkTarget, linkPath, 'file')) {
|
||||
return 'skip';
|
||||
}
|
||||
|
||||
const result = applyInstallPlan(makePlan(target, [KEEP]));
|
||||
assert.ok(fs.lstatSync(linkPath).isSymbolicLink(), 'the symlink itself must be preserved');
|
||||
assert.strictEqual(fs.readFileSync(linkTarget, 'utf8'), `${stale.source}\n`);
|
||||
const warnings = orphanWarnings(result);
|
||||
assert.strictEqual(warnings.length, 1);
|
||||
assert.ok(/is a symlink/.test(warnings[0]));
|
||||
assert.deepStrictEqual(relativeDestinations(target), ['lib/keep.js']);
|
||||
});
|
||||
|
||||
test('never deletes through an in-root symlinked parent directory', root => {
|
||||
const target = cursorTarget(root);
|
||||
const stale = { moduleId: 'core', source: 'nested/stale.js' };
|
||||
applyInstallPlan(makePlan(target, [KEEP, stale]));
|
||||
const linkedDir = path.join(target.targetRoot, 'nested');
|
||||
const realDir = path.join(target.targetRoot, 'real-nested');
|
||||
fs.renameSync(linkedDir, realDir);
|
||||
if (!trySymlink(realDir, linkedDir, 'dir')) {
|
||||
return 'skip';
|
||||
}
|
||||
|
||||
const result = applyInstallPlan(makePlan(target, [KEEP]));
|
||||
assert.strictEqual(fs.readFileSync(path.join(realDir, 'stale.js'), 'utf8'), `${stale.source}\n`);
|
||||
assert.ok(fs.lstatSync(linkedDir).isSymbolicLink());
|
||||
const warnings = orphanWarnings(result);
|
||||
assert.strictEqual(warnings.length, 1);
|
||||
assert.ok(/passes through the symlink/.test(warnings[0]));
|
||||
assert.deepStrictEqual(relativeDestinations(target), ['lib/keep.js']);
|
||||
});
|
||||
|
||||
test('dry-run previews the reconciled state without mutating files or install-state', root => {
|
||||
const target = cursorTarget(root);
|
||||
const stale = { moduleId: 'core', source: 'lib/obsolete.js' };
|
||||
applyInstallPlan(makePlan(target, [KEEP, stale]));
|
||||
const stateBefore = fs.readFileSync(target.installStatePath, 'utf8');
|
||||
|
||||
const preview = previewInstallPlan(makePlan(target, [KEEP]));
|
||||
assert.strictEqual(preview.applied, false);
|
||||
assert.ok(fs.existsSync(path.join(target.targetRoot, stale.source)), 'dry-run must not delete');
|
||||
assert.strictEqual(fs.readFileSync(target.installStatePath, 'utf8'), stateBefore);
|
||||
assert.deepStrictEqual(relativeDestinations(target, preview.statePreview.operations), ['lib/keep.js']);
|
||||
assert.ok(preview.warnings.some(warning => /^1 previously managed file\(s\) are no longer part/.test(warning)));
|
||||
assert.deepStrictEqual(orphanWarnings(preview), []);
|
||||
});
|
||||
|
||||
test('a failed reinstall keeps stale records and files for a later retry', root => {
|
||||
const target = cursorTarget(root);
|
||||
const stale = { moduleId: 'core', source: 'lib/obsolete.js' };
|
||||
applyInstallPlan(makePlan(target, [KEEP, stale]));
|
||||
const stalePath = path.join(target.targetRoot, stale.source);
|
||||
const recorded = findRecord(target, stale.source);
|
||||
|
||||
// Failure while copying: the checkpoint is the bridge state.
|
||||
const broken = { moduleId: 'core', source: 'lib/missing-source.js', skipSource: true };
|
||||
assert.throws(() => applyInstallPlan(makePlan(target, [KEEP, broken])), /ENOENT/);
|
||||
assert.ok(fs.existsSync(stalePath));
|
||||
assert.deepStrictEqual(findRecord(target, stale.source), recorded);
|
||||
|
||||
// Failure while persisting the final state, after every copy succeeded.
|
||||
let stateWrites = 0;
|
||||
assert.throws(() => applyInstallPlan(makePlan(target, [KEEP]), {
|
||||
beforeInstallStateWrite() {
|
||||
stateWrites += 1;
|
||||
if (stateWrites === 2) {
|
||||
throw new Error('simulated final install-state write failure');
|
||||
}
|
||||
},
|
||||
}), /simulated final install-state write failure/);
|
||||
assert.strictEqual(stateWrites, 2, 'bridge and final state writes should both be attempted');
|
||||
assert.ok(fs.existsSync(stalePath), 'stale file must survive a failed install');
|
||||
assert.deepStrictEqual(findRecord(target, stale.source), recorded);
|
||||
|
||||
const retry = applyInstallPlan(makePlan(target, [KEEP]));
|
||||
assert.ok(!fs.existsSync(stalePath));
|
||||
assert.deepStrictEqual(retry.reconciledStalePaths, [path.resolve(stalePath)]);
|
||||
assert.deepStrictEqual(relativeDestinations(target), ['lib/keep.js']);
|
||||
});
|
||||
|
||||
test('selective --modules installs only reconcile modules they select', root => {
|
||||
const target = cursorTarget(root);
|
||||
const alphaOne = { moduleId: 'alpha', source: 'alpha/one.md' };
|
||||
const alphaTwo = { moduleId: 'alpha', source: 'alpha/two.md' };
|
||||
const beta = { moduleId: 'beta', source: 'beta/one.md' };
|
||||
applyInstallPlan(makePlan(target, [alphaOne, alphaTwo]));
|
||||
applyInstallPlan(makePlan(target, [beta]));
|
||||
assert.deepStrictEqual(relativeDestinations(target), ['alpha/one.md', 'alpha/two.md', 'beta/one.md']);
|
||||
|
||||
const alphaResult = applyInstallPlan(makePlan(target, [alphaOne]));
|
||||
assert.ok(!fs.existsSync(path.join(target.targetRoot, alphaTwo.source)));
|
||||
assert.deepStrictEqual(alphaResult.reconciledStalePaths, [
|
||||
path.resolve(path.join(target.targetRoot, alphaTwo.source)),
|
||||
]);
|
||||
assert.ok(fs.existsSync(path.join(target.targetRoot, beta.source)), 'unselected module files stay');
|
||||
assert.deepStrictEqual(relativeDestinations(target), ['alpha/one.md', 'beta/one.md']);
|
||||
|
||||
const betaResult = applyInstallPlan(makePlan(target, [beta]));
|
||||
assert.deepStrictEqual(betaResult.reconciledStalePaths, []);
|
||||
assert.deepStrictEqual(relativeDestinations(target), ['alpha/one.md', 'beta/one.md']);
|
||||
});
|
||||
|
||||
test('reconciles a destination change for the same module and source', root => {
|
||||
const target = cursorTarget(root);
|
||||
applyInstallPlan(makePlan(target, [{ moduleId: 'core', source: 'cfg/tool.json', dest: 'old/tool.json' }]));
|
||||
const result = applyInstallPlan(makePlan(target, [{ moduleId: 'core', source: 'cfg/tool.json', dest: 'new/tool.json' }]));
|
||||
assert.ok(!fs.existsSync(path.join(target.targetRoot, 'old', 'tool.json')));
|
||||
assert.ok(fs.existsSync(path.join(target.targetRoot, 'new', 'tool.json')));
|
||||
assert.strictEqual(result.reconciledStalePaths.length, 1);
|
||||
const state = readInstallState(target.installStatePath);
|
||||
assert.deepStrictEqual(relativeDestinations(target), ['new/tool.json']);
|
||||
assert.strictEqual(state.operations[0].sourceRelativePath, 'cfg/tool.json');
|
||||
});
|
||||
|
||||
test('keeps a destination whose kind changes and drops a kind-plus-destination move', root => {
|
||||
const target = cursorTarget(root);
|
||||
applyInstallPlan(makePlan(target, [
|
||||
{ moduleId: 'core', source: 'cfg/config.json', dest: 'config.json', content: '{"base":1}\n' },
|
||||
{ moduleId: 'core', source: 'cfg/events.json', dest: 'events/events.json', content: '{"events":[]}\n' },
|
||||
]));
|
||||
|
||||
const result = applyInstallPlan(makePlan(target, [
|
||||
{ moduleId: 'core', kind: 'merge-json', source: 'cfg/config.json', dest: 'config.json', payload: { merged: true } },
|
||||
{ moduleId: 'core', kind: 'merge-json', source: 'cfg/events.json', dest: 'settings.json', payload: { events: [] } },
|
||||
]));
|
||||
|
||||
assert.deepStrictEqual(
|
||||
JSON.parse(fs.readFileSync(path.join(target.targetRoot, 'config.json'), 'utf8')),
|
||||
{ base: 1, merged: true },
|
||||
'a same-destination kind change must keep the file'
|
||||
);
|
||||
assert.ok(!fs.existsSync(path.join(target.targetRoot, 'events', 'events.json')));
|
||||
assert.deepStrictEqual(result.reconciledStalePaths, [
|
||||
path.resolve(path.join(target.targetRoot, 'events', 'events.json')),
|
||||
]);
|
||||
const state = readInstallState(target.installStatePath);
|
||||
assert.deepStrictEqual(relativeDestinations(target), ['config.json', 'settings.json']);
|
||||
assert.ok(state.operations.every(operation => operation.kind === 'merge-json'));
|
||||
});
|
||||
|
||||
test('preserves Claude legacy-skill migration conflict ownership', root => {
|
||||
const target = claudeTarget(root);
|
||||
const legacyPath = path.join(target.targetRoot, 'skills', 'ecc', 'demo', 'SKILL.md');
|
||||
const flatUserPath = path.join(target.targetRoot, 'skills', 'demo', 'SKILL.md');
|
||||
writeFile(legacyPath, '# demo (nested ECC copy)\n');
|
||||
writeFile(flatUserPath, '# demo (user-owned)\n');
|
||||
const seedPlan = makePlan(target, []);
|
||||
writeInstallState(target.installStatePath, {
|
||||
...seedPlan.statePreview,
|
||||
request: { ...seedPlan.statePreview.request, modules: ['skills-core'] },
|
||||
resolution: { selectedModules: ['skills-core'], skippedModules: [] },
|
||||
operations: [{
|
||||
kind: 'copy-file',
|
||||
moduleId: 'skills-core',
|
||||
sourceRelativePath: 'skills/demo/SKILL.md',
|
||||
destinationPath: legacyPath,
|
||||
strategy: 'preserve-relative-path',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
contentSha256: sha256('# demo (nested ECC copy)\n'),
|
||||
}],
|
||||
});
|
||||
|
||||
const result = applyInstallPlan(makePlan(target, [
|
||||
{ moduleId: 'skills-core', source: 'skills/demo/SKILL.md', dest: 'skills/demo/SKILL.md' },
|
||||
{ moduleId: 'skills-core', source: 'rules/keep.md', dest: 'rules/ecc/keep.md' },
|
||||
]));
|
||||
|
||||
assert.ok(result.warnings.some(warning => warning.startsWith("Skipped Claude skill 'demo'")));
|
||||
assert.deepStrictEqual(orphanWarnings(result), []);
|
||||
assert.deepStrictEqual(result.reconciledStalePaths, []);
|
||||
assert.strictEqual(fs.readFileSync(legacyPath, 'utf8'), '# demo (nested ECC copy)\n');
|
||||
assert.strictEqual(fs.readFileSync(flatUserPath, 'utf8'), '# demo (user-owned)\n');
|
||||
assert.deepStrictEqual(relativeDestinations(target), ['rules/ecc/keep.md', 'skills/ecc/demo/SKILL.md']);
|
||||
});
|
||||
|
||||
test('declining hooks keeps hook files from modules that remain selected', root => {
|
||||
const target = cursorTarget(root);
|
||||
const hook = { moduleId: 'platform-configs', source: '.cursor/hooks/after-edit.js', dest: 'hooks/after-edit.js' };
|
||||
const rule = { moduleId: 'platform-configs', source: 'rules/keep.md' };
|
||||
applyInstallPlan(withHookConsent(makePlan(target, [hook, rule]), 'enabled'));
|
||||
|
||||
const declined = withHookConsent(makePlan(target, [hook, rule]), 'declined');
|
||||
assert.ok(!declined.operations.some(operation => operation.sourceRelativePath === hook.source));
|
||||
const result = applyInstallPlan(declined);
|
||||
assert.deepStrictEqual(result.reconciledStalePaths, []);
|
||||
assert.ok(fs.existsSync(path.join(target.targetRoot, hook.dest)));
|
||||
assert.deepStrictEqual(relativeDestinations(target), ['hooks/after-edit.js', 'rules/keep.md']);
|
||||
});
|
||||
|
||||
test('legacy language installs stay cumulative inside a shared module', root => {
|
||||
const target = cursorTarget(root);
|
||||
const legacyPlan = specs => ({ ...makePlan(target, specs), mode: 'legacy' });
|
||||
applyInstallPlan(legacyPlan([{ moduleId: 'legacy-cursor-install', source: 'rules/python-style.md' }]));
|
||||
const result = applyInstallPlan(legacyPlan([{ moduleId: 'legacy-cursor-install', source: 'rules/typescript-style.md' }]));
|
||||
assert.deepStrictEqual(result.reconciledStalePaths, []);
|
||||
assert.ok(fs.existsSync(path.join(target.targetRoot, 'rules', 'python-style.md')));
|
||||
assert.deepStrictEqual(relativeDestinations(target), ['rules/python-style.md', 'rules/typescript-style.md']);
|
||||
});
|
||||
|
||||
test('Claude home reinstall drops obsolete hooks.json and cost-estimate.js records (#3232)', root => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const projectRoot = path.join(root, 'project');
|
||||
fs.mkdirSync(homeDir, { recursive: true });
|
||||
fs.mkdirSync(projectRoot, { recursive: true });
|
||||
const env = { HOME: homeDir, USERPROFILE: homeDir };
|
||||
const request = normalizeInstallRequest({
|
||||
...parseInstallArgs(['node', 'install-apply.js', '--profile', 'core', '--enable-hooks']),
|
||||
config: null,
|
||||
});
|
||||
const createPlan = () => createInstallPlanFromRequest(request, { projectRoot, homeDir, env });
|
||||
const doctorCodes = () => buildDoctorReport({ repoRoot: REPO_ROOT, homeDir, projectRoot, targets: ['claude'], env })
|
||||
.results.flatMap(result => result.issues.map(issue => issue.code));
|
||||
|
||||
const initialPlan = createPlan();
|
||||
applyInstallPlan(initialPlan);
|
||||
const claudeRoot = initialPlan.targetRoot;
|
||||
const hooksJsonPath = path.join(claudeRoot, 'hooks', 'hooks.json');
|
||||
const costEstimatePath = path.join(claudeRoot, 'scripts', 'lib', 'cost-estimate.js');
|
||||
const legacyHooksJson = '{"hooks":{}}\n';
|
||||
const legacyCostEstimate = 'module.exports = {};\n';
|
||||
// Seed the records an older pin left behind: hooks.json was copied before it
|
||||
// moved into settings.json, and cost-estimate.js was later deleted upstream.
|
||||
writeFile(hooksJsonPath, legacyHooksJson);
|
||||
writeFile(costEstimatePath, '// user tweak\n');
|
||||
const staleRecord = (sourceRelativePath, destinationPath, installedContent) => ({
|
||||
kind: 'copy-file',
|
||||
moduleId: 'hooks-runtime',
|
||||
sourcePath: path.join(REPO_ROOT, sourceRelativePath),
|
||||
sourceRelativePath,
|
||||
destinationPath,
|
||||
strategy: 'preserve-relative-path',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
contentSha256: sha256(installedContent),
|
||||
});
|
||||
editState({ installStatePath: initialPlan.installStatePath }, operations => [
|
||||
...operations,
|
||||
staleRecord('hooks/hooks.json', hooksJsonPath, legacyHooksJson),
|
||||
staleRecord('scripts/lib/cost-estimate.js', costEstimatePath, legacyCostEstimate),
|
||||
]);
|
||||
assert.ok(doctorCodes().includes('missing-source-files'), 'seeded state should reproduce the doctor error');
|
||||
|
||||
const plannedDestinations = new Set(createPlan().operations.map(operation => operation.destinationPath));
|
||||
const preview = previewInstallPlan(createPlan());
|
||||
assert.ok(
|
||||
preview.statePreview.operations.every(operation => plannedDestinations.has(operation.destinationPath)),
|
||||
'dry-run state preview must not carry operations the plan no longer contains'
|
||||
);
|
||||
assert.ok(fs.existsSync(hooksJsonPath) && fs.existsSync(costEstimatePath));
|
||||
|
||||
const result = applyInstallPlan(createPlan());
|
||||
assert.ok(!fs.existsSync(hooksJsonPath), 'unchanged obsolete hooks.json should be removed');
|
||||
assert.strictEqual(fs.readFileSync(costEstimatePath, 'utf8'), '// user tweak\n');
|
||||
const warnings = orphanWarnings(result);
|
||||
assert.strictEqual(warnings.length, 1, warnings.join('\n'));
|
||||
assert.ok(warnings[0].includes(costEstimatePath));
|
||||
|
||||
const state = readInstallState(initialPlan.installStatePath);
|
||||
assert.ok(!state.operations.some(operation => (
|
||||
operation.destinationPath === hooksJsonPath || operation.destinationPath === costEstimatePath
|
||||
)));
|
||||
const codes = doctorCodes();
|
||||
assert.ok(!codes.includes('missing-source-files'), `doctor still reports: ${codes.join(', ')}`);
|
||||
assert.ok(!codes.includes('drifted-managed-files'), `doctor still reports: ${codes.join(', ')}`);
|
||||
});
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}, Skipped: ${skipped}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -5,6 +5,8 @@ const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
const { createHash } = require('crypto');
|
||||
const { readInstallState, writeInstallState } = require('../../scripts/lib/install-state');
|
||||
|
||||
const cli = path.resolve(__dirname, '../../scripts/ecc.js');
|
||||
let passed = 0;
|
||||
@@ -51,6 +53,37 @@ for (const target of ['claude', 'claude-project']) {
|
||||
const report = JSON.parse(run(['doctor', '--target', target, '--json']).stdout);
|
||||
assert.strictEqual(report.summary.errorCount, 0);
|
||||
assert.strictEqual(report.summary.warningCount, 0);
|
||||
// Seed obsolete managed records from an older install, then exercise the
|
||||
// public reinstall instead of calling reconciliation directly.
|
||||
const statePath = path.join(installRoot, 'ecc/install-state.json');
|
||||
const obsoleteContent = '# obsolete managed rule\n';
|
||||
const stalePaths = ['obsolete-unchanged.md', 'obsolete-edited.md']
|
||||
.map(name => path.join(installRoot, 'rules/ecc/common', name));
|
||||
const state = readInstallState(statePath);
|
||||
for (const file of stalePaths) {
|
||||
fs.writeFileSync(file, obsoleteContent);
|
||||
state.operations.push({
|
||||
kind: 'copy-file', moduleId: 'rules-core',
|
||||
sourceRelativePath: `rules/common/${path.basename(file)}`,
|
||||
destinationPath: file, strategy: 'preserve-relative-path',
|
||||
ownership: 'managed', scaffoldOnly: false,
|
||||
contentTransform: 'claude-manual-plugin-namespace',
|
||||
contentSha256: createHash('sha256').update(obsoleteContent).digest('hex'),
|
||||
});
|
||||
}
|
||||
writeInstallState(statePath, state);
|
||||
const userEdit = '# user wants to keep this obsolete rule\n';
|
||||
fs.writeFileSync(stalePaths[1], userEdit);
|
||||
const reinstall = JSON.parse(run([...installArgs, '--json']).stdout).result;
|
||||
assert.ok(!fs.existsSync(stalePaths[0]), 'Unmodified obsolete managed files should be removed');
|
||||
assert.strictEqual(fs.readFileSync(stalePaths[1], 'utf8'), userEdit);
|
||||
assert.deepStrictEqual(reinstall.reconciledStalePaths, [stalePaths[0]]);
|
||||
assert.strictEqual(reinstall.warnings.filter(warning => warning.startsWith('Preserved orphaned file ')).length, 1);
|
||||
assert.ok(readInstallState(statePath).operations.every(operation => !stalePaths.includes(operation.destinationPath)));
|
||||
assertNativeNames();
|
||||
const afterReinstall = JSON.parse(run(['doctor', '--target', target, '--json']).stdout);
|
||||
assert.strictEqual(afterReinstall.summary.errorCount, 0);
|
||||
assert.strictEqual(afterReinstall.summary.warningCount, 0);
|
||||
fs.appendFileSync(rule, '\nlocal drift\n');
|
||||
run(['repair', '--target', target, '--json']);
|
||||
assertNativeNames();
|
||||
@@ -62,6 +95,7 @@ for (const target of ['claude', 'claude-project']) {
|
||||
run(['uninstall', '--target', target, '--json']);
|
||||
assert.ok(!fs.existsSync(rule));
|
||||
assert.strictEqual(fs.readFileSync(userFile, 'utf8'), 'keep this user file\n');
|
||||
assert.strictEqual(fs.readFileSync(stalePaths[1], 'utf8'), userEdit);
|
||||
passed++;
|
||||
console.log(` PASS ${target} install, doctor, repair, reinstall and uninstall preserve native names`);
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user