mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-20 16:47:59 +02:00
176 lines
7.2 KiB
JavaScript
176 lines
7.2 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const { readInstallState } = require('../install-state');
|
|
const { hasEditedCodexUserConfig } = require('./codex-user-config');
|
|
|
|
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 plannedOperations = (migration && migration.appliedOperations) || [];
|
|
const plannedDestinations = new Set(plannedOperations.map(operation => comparablePath(operation.destinationPath)));
|
|
// Selective reinstalls retain earlier modules in the ledger. Inspect those
|
|
// entries too, without turning them into additional writes in this install.
|
|
const retainedOperations = ((migration.finalState && migration.finalState.operations) || [])
|
|
.filter(operation => !plannedDestinations.has(comparablePath(operation.destinationPath))
|
|
&& previousManagedOperations.has(comparablePath(operation.destinationPath)));
|
|
const skippedOperations = [];
|
|
const warnings = [];
|
|
for (const operation of [...plannedOperations, ...retainedOperations]) {
|
|
const previousOperation = previousManagedOperations.get(comparablePath(operation.destinationPath));
|
|
const editedConfig = previousOperation
|
|
&& hasEditedCodexUserConfig(plan, operation, previousOperation);
|
|
if (
|
|
operation
|
|
&& operation.kind === 'copy-file'
|
|
&& operation.destinationPath
|
|
&& pathExists(operation.destinationPath)
|
|
&& (!managedDestinations.has(comparablePath(operation.destinationPath)) || editedConfig)
|
|
) {
|
|
skippedOperations.push(operation);
|
|
warnings.push(editedConfig
|
|
? `Preserved user configuration ${operation.destinationPath}: changed or unverifiable since installation. ECC no longer manages this file; apply future configuration updates manually.`
|
|
: `Skipped user-owned file ${operation.destinationPath}: the existing file is not recorded in ECC install-state.`);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (skippedOperations.length === 0) {
|
|
return { ...migration, managedDestinations, previousManagedOperations };
|
|
}
|
|
|
|
const skippedDestinations = new Set(
|
|
skippedOperations.map(operation => comparablePath(operation.destinationPath))
|
|
);
|
|
const appliedOperations = plannedOperations.filter(operation => (
|
|
!skippedDestinations.has(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, plan) {
|
|
const previousOperation = migration.previousManagedOperations.get(comparablePath(operation.destinationPath));
|
|
if (plan && hasEditedCodexUserConfig(plan, operation, previousOperation)) {
|
|
throw new Error(`Refusing to overwrite user configuration changed after planning: ${operation.destinationPath}. Rerun to preserve it.`);
|
|
}
|
|
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,
|
|
};
|