mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-28 10:49:45 +02:00
fix(uninstall): preserve path identity and user drift
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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-');
|
||||
|
||||
Reference in New Issue
Block a user