fix(uninstall): preserve path identity and user drift

This commit is contained in:
haelyra
2026-08-13 18:02:06 -04:00
parent 8dc6a8e73a
commit 01335551a3
4 changed files with 235 additions and 33 deletions
+69 -18
View File
@@ -529,13 +529,46 @@ function removeContainedPath(destinationPath, trustedRoot, action, options = {})
return null;
}
const finalDestination = getManagedDestination(
const managedDestination = getManagedDestination(
existingDestination,
trustedRoot,
action,
{ allowFinalSymlink: true }
).managedPath;
fs.rmSync(finalDestination, options);
);
const finalDestination = managedDestination.managedPath;
const expectedStat = fs.lstatSync(finalDestination, { bigint: true });
const quarantineDir = fs.mkdtempSync(path.join(
path.dirname(managedDestination.canonicalRoot),
'.ecc-remove-'
));
const quarantinePath = path.join(quarantineDir, path.basename(finalDestination));
try {
fs.renameSync(finalDestination, quarantinePath);
} catch (error) {
fs.rmdirSync(quarantineDir);
throw error;
}
const quarantinedStat = fs.lstatSync(quarantinePath, { bigint: true });
if (!hasSameFileIdentity(expectedStat, quarantinedStat)) {
try {
fs.renameSync(quarantinePath, finalDestination);
fs.rmdirSync(quarantineDir);
} catch (_restoreError) {
throw new Error(
`Refusing to ${action}: managed destination changed before removal; replacement preserved at ${quarantinePath}.`
);
}
throw createChangedDestinationError(action);
}
if (quarantinedStat.isDirectory() && !options.recursive) {
fs.rmdirSync(quarantinePath);
} else {
fs.rmSync(quarantinePath, options);
}
fs.rmdirSync(quarantineDir);
return finalDestination;
}
@@ -724,7 +757,8 @@ function executeUninstallOperation(operation, trustedRoot, options = {}) {
const existingDestination = getContainedExistingPath(
operation.destinationPath,
trustedRoot,
'uninstall'
'uninstall',
{ allowFinalSymlink: true }
);
if (!existingDestination) {
return {
@@ -732,6 +766,13 @@ function executeUninstallOperation(operation, trustedRoot, options = {}) {
cleanupTargets: []
};
}
if (fs.lstatSync(existingDestination).isSymbolicLink()) {
return {
removedPaths: [],
cleanupTargets: [],
retainedPaths: [operation.destinationPath]
};
}
const recordedDigest = operation.contentSha256;
const currentDigest = /^[a-f0-9]{64}$/i.test(recordedDigest || '')
? crypto.createHash('sha256')
@@ -741,7 +782,8 @@ function executeUninstallOperation(operation, trustedRoot, options = {}) {
if (!currentDigest || currentDigest !== recordedDigest.toLowerCase()) {
return {
removedPaths: [],
cleanupTargets: []
cleanupTargets: [],
retainedPaths: [operation.destinationPath]
};
}
}
@@ -1869,8 +1911,9 @@ function cleanupEmptyParentDirs(filePath, stopAt) {
}
const finalPath = assertWithinTrustedRoot(validatedPath, trustedStopAt, 'clean up');
fs.rmdirSync(finalPath);
currentPath = path.dirname(finalPath);
const removedPath = removeContainedPath(finalPath, trustedStopAt, 'clean up');
if (!removedPath) break;
currentPath = path.dirname(removedPath);
}
}
@@ -1926,25 +1969,29 @@ function uninstallInstalledStates(options = {}) {
try {
const removedPaths = [];
const cleanupTargets = [];
const retainedPaths = [];
const operations = getManagedOperations(state);
for (const operation of operations) {
const outcome = executeUninstallOperation(operation, record.targetRoot, {
preserveDriftedCopies: record.legacy,
preserveDriftedCopies: true,
});
removedPaths.push(...outcome.removedPaths);
cleanupTargets.push(...outcome.cleanupTargets);
retainedPaths.push(...(outcome.retainedPaths || []));
}
const removedStatePath = removeContainedPath(
record.installStatePath,
record.targetRoot,
'uninstall',
{ force: true }
);
if (removedStatePath) {
removedPaths.push(record.installStatePath);
cleanupTargets.push(removedStatePath);
if (retainedPaths.length === 0) {
const removedStatePath = removeContainedPath(
record.installStatePath,
record.targetRoot,
'uninstall',
{ force: true }
);
if (removedStatePath) {
removedPaths.push(record.installStatePath);
cleanupTargets.push(removedStatePath);
}
}
for (const cleanupTarget of cleanupTargets) {
@@ -1953,10 +2000,14 @@ function uninstallInstalledStates(options = {}) {
return {
adapter: record.adapter,
status: 'uninstalled',
status: retainedPaths.length > 0 ? 'partial' : 'uninstalled',
installStatePath: record.installStatePath,
removedPaths,
retainedPaths: [...new Set(retainedPaths)].sort(),
plannedRemovals: [],
warning: retainedPaths.length > 0
? 'Modified or unverifiable managed files were preserved together with install-state for review.'
: null,
error: null
};
} catch (error) {
+15 -10
View File
@@ -350,20 +350,25 @@ function applyInstallPlan(plan, dependencies = {}) {
continue;
}
// Markdown may reference files whose installed paths move, such as rules
// copied under rules/ecc. Rewrite only links that point at installed targets;
// untouched links and non-markdown files stay on the byte-for-byte path.
if (
// Declared transforms are part of the install contract and always apply.
// Markdown link rewriting is additive when the plan has a usable index.
const needsLinkRewrite = Boolean(
linkIndex
&& operation.kind === 'copy-file'
&& operation.sourceRelativePath
&& isMarkdownPath(operation.destinationPath)
) {
const rewritten = rewriteRelativeLinks(
transformInstallContent(operation, fs.readFileSync(operation.sourcePath, 'utf8')),
{ sourceRel: operation.sourceRelativePath, index: linkIndex }
);
if (operation.kind === 'copy-file' && (operation.contentTransform || needsLinkRewrite)) {
const transformed = transformInstallContent(
operation,
fs.readFileSync(operation.sourcePath, 'utf8')
);
fs.writeFileSync(operation.destinationPath, rewritten, 'utf8');
const installedContent = needsLinkRewrite
? rewriteRelativeLinks(transformed, {
sourceRel: operation.sourceRelativePath,
index: linkIndex,
})
: transformed;
fs.writeFileSync(operation.destinationPath, installedContent, 'utf8');
continue;
}
@@ -652,6 +652,45 @@ function runTests() {
}
})) passed++; else failed++;
if (test('applies a declared Antigravity transform without link-index metadata', () => {
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'antigravity-transform-only-'));
try {
const sourcePath = path.join(REPO_ROOT, 'agents', 'architect.md');
const targetRoot = path.join(projectRoot, '.agents');
const installStatePath = path.join(targetRoot, 'ecc-install-state.json');
const operation = {
kind: 'copy-file',
moduleId: 'agents-core',
sourcePath,
sourceRelativePath: null,
destinationPath: path.join(targetRoot, 'agents', 'architect.md'),
strategy: 'copy-file',
ownership: 'managed',
scaffoldOnly: false,
contentTransform: 'antigravity-agent-frontmatter',
};
const plan = {
mode: 'legacy',
target: 'antigravity',
adapter: { id: 'antigravity-project', target: 'antigravity', kind: 'project' },
targetRoot,
installRoot: targetRoot,
installStatePath,
operations: [operation],
warnings: [],
statePreview: createAntigravityState(targetRoot, installStatePath, []),
};
applyInstallPlan(plan, { writeInstallState() {} });
const installedContent = fs.readFileSync(operation.destinationPath, 'utf8');
assert.notStrictEqual(installedContent, fs.readFileSync(sourcePath, 'utf8'));
assert.ok(!installedContent.includes('color:'));
} finally {
fs.rmSync(projectRoot, { recursive: true, force: true });
}
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
+112 -5
View File
@@ -168,7 +168,7 @@ function withTemporarilyMovedPath(filePath, callback) {
}
function managedOperation(kind, destinationPath, overrides = {}) {
return {
const operation = {
kind,
moduleId: 'test-module',
sourceRelativePath: 'rules/common/coding-style.md',
@@ -178,6 +178,18 @@ function managedOperation(kind, destinationPath, overrides = {}) {
scaffoldOnly: false,
...overrides,
};
if (
kind === 'copy-file'
&& !Object.prototype.hasOwnProperty.call(overrides, 'contentSha256')
&& fs.existsSync(destinationPath)
&& fs.lstatSync(destinationPath).isFile()
&& !fs.lstatSync(destinationPath).isSymbolicLink()
) {
operation.contentSha256 = crypto.createHash('sha256')
.update(fs.readFileSync(destinationPath))
.digest('hex');
}
return operation;
}
function runTests() {
@@ -2478,7 +2490,7 @@ function runTests() {
targets: ['cursor'],
});
assert.strictEqual(result.results[0].status, 'uninstalled');
assert.strictEqual(result.results[0].status, 'uninstalled', result.results[0].error);
assert.ok(result.results[0].removedPaths.includes(destinationPath));
assert.ok(!fs.existsSync(destinationPath));
assert.ok(!fs.existsSync(path.dirname(destinationPath)));
@@ -2489,6 +2501,40 @@ function runTests() {
}
})) passed++; else failed++;
if (test('uninstall preserves drifted canonical copied files and install-state', () => {
const homeDir = createTempDir('install-lifecycle-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
try {
const targetRoot = path.join(projectRoot, '.cursor');
const destinationPath = path.join(targetRoot, 'rules', 'managed.md');
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
fs.writeFileSync(destinationPath, 'managed\n');
const operation = managedOperation('copy-file', destinationPath, {
strategy: 'copy-file',
});
const { installStatePath } = writeCursorState(projectRoot, {
request: { legacyMode: false, legacyLanguages: [] },
operations: [operation],
});
fs.appendFileSync(destinationPath, 'user edit\n');
const result = uninstallInstalledStates({
homeDir,
projectRoot,
targets: ['cursor'],
});
assert.strictEqual(result.results[0].status, 'partial');
assert.ok(result.results[0].retainedPaths.includes(destinationPath));
assert.strictEqual(fs.readFileSync(destinationPath, 'utf8'), 'managed\nuser edit\n');
assert.ok(fs.existsSync(installStatePath));
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
if (test('uninstall cleanup stops at the adapter-derived target root', () => {
const homeDir = createTempDir('install-lifecycle-home-');
const cleanupBoundaryRoot = createTempDir('install-lifecycle-boundary-');
@@ -2730,7 +2776,7 @@ function runTests() {
}
})) passed++; else failed++;
if (test('uninstall removes an in-root final symlink without deleting its victim', () => {
if (test('uninstall preserves a managed path replaced by a symlink and its victim', () => {
const homeDir = createTempDir('install-lifecycle-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
@@ -2758,8 +2804,9 @@ function runTests() {
targets: ['cursor'],
});
assert.strictEqual(result.results[0].status, 'uninstalled');
assert.ok(!fs.existsSync(destinationPath));
assert.strictEqual(result.results[0].status, 'partial');
assert.ok(fs.lstatSync(destinationPath).isSymbolicLink());
assert.ok(result.results[0].retainedPaths.includes(destinationPath));
assert.strictEqual(fs.readFileSync(victimPath, 'utf8'), 'victim sentinel\n');
} finally {
cleanup(homeDir);
@@ -2831,6 +2878,66 @@ function runTests() {
}
})) passed++; else failed++;
if (test('uninstall quarantine prevents an ancestor swap from deleting outside-root content', () => {
const homeDir = createTempDir('install-lifecycle-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
const outsideRoot = createTempDir('install-lifecycle-outside-');
const targetRoot = path.join(projectRoot, '.cursor');
const destinationParent = path.join(targetRoot, 'swap-parent');
const backupParent = path.join(targetRoot, 'swap-parent-backup');
const destinationPath = path.join(destinationParent, 'managed.md');
const outsideDestinationPath = path.join(outsideRoot, 'managed.md');
const originalRenameSync = fs.renameSync;
let swapped = false;
let result;
try {
fs.mkdirSync(destinationParent, { recursive: true });
fs.writeFileSync(destinationPath, 'managed\n');
fs.writeFileSync(outsideDestinationPath, 'outside sentinel\n');
writeCursorState(projectRoot, {
operations: [managedOperation('copy-file', destinationPath)],
});
fs.renameSync = function renameSyncWithAncestorSwap(sourcePath, targetPath) {
if (
!swapped
&& path.basename(sourcePath) === path.basename(destinationPath)
&& path.basename(path.dirname(targetPath)).startsWith('.ecc-remove-')
) {
originalRenameSync.call(fs, destinationParent, backupParent);
fs.symlinkSync(
outsideRoot,
destinationParent,
process.platform === 'win32' ? 'junction' : 'dir'
);
swapped = true;
}
return originalRenameSync.call(fs, sourcePath, targetPath);
};
result = uninstallInstalledStates({
homeDir,
projectRoot,
targets: ['cursor'],
});
} finally {
fs.renameSync = originalRenameSync;
}
try {
assert.strictEqual(swapped, true);
assert.strictEqual(result.results[0].status, 'error');
assert.match(result.results[0].error, /changed during|changed before removal/);
assert.strictEqual(fs.readFileSync(outsideDestinationPath, 'utf8'), 'outside sentinel\n');
assert.strictEqual(fs.readFileSync(path.join(backupParent, 'managed.md'), 'utf8'), 'managed\n');
} finally {
cleanup(homeDir);
cleanup(projectRoot);
cleanup(outsideRoot);
}
})) passed++; else failed++;
if (test('uninstall restores previous JSON snapshots for template and remove operations', () => {
const homeDir = createTempDir('install-lifecycle-home-');
const projectRoot = createTempDir('install-lifecycle-project-');