mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-18 15:50:25 +02:00
fix(install): preserve user files and honor uninstall previews
Generalize PR #2981 ownership protection to every managed target. Reject mismatched target state and preserve files that appear during writes or failed-install checkpoints. Keep prior hashes for managed files a failed attempt never writes. Integrate PR #2980 preview wording and global dry-run propagation, with PR #2956 fail-closed environment validation and CLI/legacy regression coverage. Fixes #2964. Fixes #2952. Co-authored-by: ilkmajans-cpu <ilkmajans-cpu@users.noreply.github.com> Co-authored-by: wellkilo <wellkilo@foxmail.com>
This commit is contained in:
co-authored by
ilkmajans-cpu
wellkilo
parent
967a5fa4ba
commit
59b74901c4
@@ -28,6 +28,11 @@ const {
|
||||
removeLegacyClaudeSkillFiles,
|
||||
} = require('./claude-skill-migration');
|
||||
const { cleanupLegacyAntigravityInstall } = require('./antigravity-legacy-migration');
|
||||
const {
|
||||
assertNoNewUserOwnedFile,
|
||||
prepareUserOwnedFileGuard,
|
||||
preserveUnwrittenFiles,
|
||||
} = require('./ownership-guard');
|
||||
const { cleanupLegacyOpencodeInstall } = require('./opencode-legacy-migration');
|
||||
const { buildInstallIndex, rewriteRelativeLinks } = require('./link-rewrite');
|
||||
const { adaptAntigravityAgent } = require('./antigravity-agent');
|
||||
@@ -393,7 +398,7 @@ function prepareHookConsentMigration(plan, migration) {
|
||||
function previewInstallPlan(plan) {
|
||||
const migration = prepareHookConsentMigration(
|
||||
plan,
|
||||
prepareClaudeSkillMigration(plan)
|
||||
prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan))
|
||||
);
|
||||
const appliedPlan = {
|
||||
...plan,
|
||||
@@ -446,7 +451,7 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
|
||||
}
|
||||
const migration = prepareHookConsentMigration(
|
||||
plan,
|
||||
prepareClaudeSkillMigration(plan)
|
||||
prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan))
|
||||
);
|
||||
const appliedPlan = {
|
||||
...plan,
|
||||
@@ -460,6 +465,7 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
|
||||
operation.kind === 'remove-claude-settings-hooks'
|
||||
)).length;
|
||||
let completedHookRemovalCount = 0;
|
||||
const writtenDestinations = new Set();
|
||||
if (migration.requiresBridgeState) {
|
||||
// Own every operation that may be written during a flat-skill migration
|
||||
// before the first copy. A later failure is retryable and uninstall can
|
||||
@@ -485,6 +491,7 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
|
||||
if (typeof beforeOperationWrite === 'function') {
|
||||
beforeOperationWrite({ plan: appliedPlan, operation });
|
||||
}
|
||||
assertNoNewUserOwnedFile(migration, operation);
|
||||
|
||||
if (
|
||||
operation.kind === 'update-claude-settings'
|
||||
@@ -516,6 +523,7 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
|
||||
assertSafeInstallOperation(appliedPlan, operation);
|
||||
},
|
||||
});
|
||||
writtenDestinations.add(operation.destinationPath);
|
||||
if (operation.kind === 'remove-claude-settings-hooks') {
|
||||
completedHookRemovalCount += 1;
|
||||
}
|
||||
@@ -540,6 +548,7 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
|
||||
);
|
||||
const mergedValue = deepMergeJson(currentValue, filteredPayload);
|
||||
fs.writeFileSync(operation.destinationPath, formatJson(mergedValue), 'utf8');
|
||||
writtenDestinations.add(operation.destinationPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -547,6 +556,7 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
|
||||
const sourceConfig = readJsonObject(operation.sourcePath, 'MCP config');
|
||||
const filteredConfig = filterMcpConfig(sourceConfig, disabledServers).config;
|
||||
fs.writeFileSync(operation.destinationPath, formatJson(filteredConfig), 'utf8');
|
||||
writtenDestinations.add(operation.destinationPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -569,10 +579,12 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
|
||||
})
|
||||
: transformed;
|
||||
fs.writeFileSync(operation.destinationPath, installedContent, 'utf8');
|
||||
writtenDestinations.add(operation.destinationPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
fs.copyFileSync(operation.sourcePath, operation.destinationPath);
|
||||
writtenDestinations.add(operation.destinationPath);
|
||||
}
|
||||
|
||||
if (hasLegacyMigration) {
|
||||
@@ -600,10 +612,19 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
|
||||
persistInstallState(
|
||||
plan.installStatePath,
|
||||
stateWithContentDigests(
|
||||
hookRemovalCount > 0 && completedHookRemovalCount === hookRemovalCount
|
||||
? migration.finalState
|
||||
: migration.bridgeState,
|
||||
appliedPlan
|
||||
preserveUnwrittenFiles(
|
||||
hookRemovalCount > 0 && completedHookRemovalCount === hookRemovalCount
|
||||
? migration.finalState
|
||||
: migration.bridgeState,
|
||||
migration,
|
||||
writtenDestinations
|
||||
),
|
||||
{
|
||||
...appliedPlan,
|
||||
operations: appliedPlan.operations.filter(operation => (
|
||||
writtenDestinations.has(operation.destinationPath)
|
||||
)),
|
||||
}
|
||||
)
|
||||
);
|
||||
} catch (checkpointError) {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { readInstallState } = require('../install-state');
|
||||
|
||||
function pathExists(filePath) {
|
||||
try {
|
||||
fs.lstatSync(filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function comparablePath(filePath) {
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
return process.platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* #2964: the shared copy path used to write every copy-file operation
|
||||
* unconditionally and record the destination as `ownership: 'managed'` even
|
||||
* when the file already existed and was authored by the user. The visible
|
||||
* symptom is a lost edit; the dangerous one is the install-state record,
|
||||
* which makes a later uninstall delete the user's file.
|
||||
*
|
||||
* This guard generalises the Claude flat-skill migration conflict pattern to
|
||||
* every adapter copy operation: when a destination exists and is NOT recorded
|
||||
* as an ECC-managed operation in the previous install-state, the operation is
|
||||
* skipped with a warning instead of overwriting and claiming ownership.
|
||||
*
|
||||
* All managed targets share this ownership boundary (#2964).
|
||||
*/
|
||||
function prepareUserOwnedFileGuard(plan, migration) {
|
||||
const previousState = pathExists(plan.installStatePath)
|
||||
? readInstallState(plan.installStatePath)
|
||||
: null;
|
||||
if (previousState && (
|
||||
previousState.target.id !== plan.adapter.id
|
||||
|| comparablePath(previousState.target.root) !== comparablePath(plan.targetRoot)
|
||||
|| comparablePath(previousState.target.installStatePath) !== comparablePath(plan.installStatePath)
|
||||
)) {
|
||||
throw new Error(`Refusing install: install-state target does not match the current plan at ${plan.installStatePath}.`);
|
||||
}
|
||||
// Recorded files remain updateable by reinstall/repair. Preserve their prior
|
||||
// digests if an attempt fails before writing them so uninstall detects drift.
|
||||
const previousManagedOperations = new Map(
|
||||
((previousState && previousState.operations) || [])
|
||||
.filter(operation => (
|
||||
operation
|
||||
&& operation.ownership === 'managed'
|
||||
&& operation.destinationPath
|
||||
))
|
||||
.map(operation => [comparablePath(operation.destinationPath), operation])
|
||||
);
|
||||
const managedDestinations = new Set(previousManagedOperations.keys());
|
||||
|
||||
const appliedOperations = [];
|
||||
const skippedOperations = [];
|
||||
const warnings = [];
|
||||
for (const operation of (migration && migration.appliedOperations) || []) {
|
||||
if (
|
||||
operation
|
||||
&& operation.kind === 'copy-file'
|
||||
&& operation.destinationPath
|
||||
&& pathExists(operation.destinationPath)
|
||||
&& !managedDestinations.has(comparablePath(operation.destinationPath))
|
||||
) {
|
||||
skippedOperations.push(operation);
|
||||
warnings.push(
|
||||
`Skipped user-owned file ${operation.destinationPath}: the existing file is not recorded in ECC install-state.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
appliedOperations.push(operation);
|
||||
}
|
||||
|
||||
if (skippedOperations.length === 0) {
|
||||
return { ...migration, managedDestinations, previousManagedOperations };
|
||||
}
|
||||
|
||||
const skippedDestinations = new Set(
|
||||
skippedOperations.map(operation => comparablePath(operation.destinationPath))
|
||||
);
|
||||
const filterStateOperations = operations => (operations || [])
|
||||
.filter(operation => !skippedDestinations.has(comparablePath(operation.destinationPath)));
|
||||
|
||||
// Never leave a skipped destination inside the install-state: recording it
|
||||
// would claim ownership of a file ECC did not create and make uninstall
|
||||
// delete it (#2964).
|
||||
const bridgeState = migration.bridgeState
|
||||
? {
|
||||
...migration.bridgeState,
|
||||
operations: filterStateOperations(migration.bridgeState.operations),
|
||||
}
|
||||
: migration.bridgeState;
|
||||
const finalState = migration.finalState
|
||||
? {
|
||||
...migration.finalState,
|
||||
operations: filterStateOperations(migration.finalState.operations),
|
||||
}
|
||||
: migration.finalState;
|
||||
|
||||
return {
|
||||
...migration,
|
||||
managedDestinations,
|
||||
previousManagedOperations,
|
||||
appliedOperations,
|
||||
skippedOperations: [
|
||||
...((migration && migration.skippedOperations) || []),
|
||||
...skippedOperations,
|
||||
],
|
||||
warnings: [...((migration && migration.warnings) || []), ...warnings],
|
||||
bridgeState,
|
||||
finalState,
|
||||
// Only keep bridge persistence when operations actually remain; a fully
|
||||
// skipped plan installs nothing and must not claim anything.
|
||||
requiresBridgeState: Boolean(migration.requiresBridgeState)
|
||||
&& appliedOperations.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function assertNoNewUserOwnedFile(migration, operation) {
|
||||
if (operation.kind !== 'copy-file'
|
||||
|| migration.managedDestinations.has(comparablePath(operation.destinationPath))
|
||||
|| !pathExists(operation.destinationPath)) {
|
||||
return;
|
||||
}
|
||||
throw new Error(`Refusing install: a user-owned file appeared at ${operation.destinationPath} after planning. Rerun the installer to preserve it.`);
|
||||
}
|
||||
|
||||
function preserveUnwrittenFiles(state, migration, writtenDestinations) {
|
||||
const writtenPaths = new Set([...writtenDestinations].map(comparablePath));
|
||||
return {
|
||||
...state,
|
||||
operations: state.operations.filter(operation => (
|
||||
operation.kind !== 'copy-file'
|
||||
|| migration.managedDestinations.has(comparablePath(operation.destinationPath))
|
||||
|| writtenPaths.has(comparablePath(operation.destinationPath))
|
||||
|| !pathExists(operation.destinationPath)
|
||||
)).map(operation => {
|
||||
const destination = comparablePath(operation.destinationPath);
|
||||
return operation.kind === 'copy-file' && !writtenPaths.has(destination)
|
||||
? migration.previousManagedOperations.get(destination) || operation
|
||||
: operation;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
assertNoNewUserOwnedFile,
|
||||
prepareUserOwnedFileGuard,
|
||||
preserveUnwrittenFiles,
|
||||
};
|
||||
Reference in New Issue
Block a user