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:
haelyra
2026-09-07 16:40:47 -04:00
co-authored by ilkmajans-cpu wellkilo
parent 967a5fa4ba
commit 59b74901c4
6 changed files with 688 additions and 30 deletions
+85 -12
View File
@@ -3,10 +3,12 @@
*/
const assert = require('assert');
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const { createInstallState, writeInstallState } = require('../../scripts/lib/install-state');
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'ecc.js');
@@ -14,23 +16,21 @@ function runCli(args, options = {}) {
const envOverrides = {
...(options.env || {}),
};
if (typeof envOverrides.HOME === 'string' && !('USERPROFILE' in envOverrides)) {
envOverrides.USERPROFILE = envOverrides.HOME;
}
if (typeof envOverrides.USERPROFILE === 'string' && !('HOME' in envOverrides)) {
envOverrides.HOME = envOverrides.USERPROFILE;
}
const inheritedEnv = Object.fromEntries(
Object.entries(process.env).filter(([key]) => key !== 'ECC_DRY_RUN')
);
const homeAlias = typeof envOverrides.HOME === 'string' && !('USERPROFILE' in envOverrides)
? { USERPROFILE: envOverrides.HOME }
: typeof envOverrides.USERPROFILE === 'string' && !('HOME' in envOverrides)
? { HOME: envOverrides.USERPROFILE }
: {};
const env = { ...inheritedEnv, ...envOverrides, ...homeAlias };
return spawnSync('node', [SCRIPT, ...args], {
encoding: 'utf8',
cwd: options.cwd || process.cwd(),
maxBuffer: 10 * 1024 * 1024,
env: {
...process.env,
...envOverrides,
},
env,
});
}
@@ -153,6 +153,79 @@ function main() {
const payload = parseJson(result.stdout);
assert.deepStrictEqual(payload.records, []);
}],
['keeps uninstall read-only when global --dry-run precedes the command', () => {
const homeDir = createTempDir('ecc-cli-uninstall-home-');
const projectRoot = createTempDir('ecc-cli-uninstall-project-');
try {
const targetRoot = path.join(projectRoot, '.cursor');
const statePath = path.join(targetRoot, 'ecc-install-state.json');
const managedPath = path.join(targetRoot, 'managed-rule.md');
const managedContent = 'managed\n';
fs.mkdirSync(targetRoot, { recursive: true });
fs.writeFileSync(managedPath, managedContent);
writeInstallState(statePath, createInstallState({
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
targetRoot,
installStatePath: statePath,
request: {
profile: null,
modules: [],
includeComponents: [],
excludeComponents: [],
legacyLanguages: ['typescript'],
legacyMode: true,
},
resolution: {
selectedModules: ['legacy-cursor-install'],
skippedModules: [],
},
source: {
repoVersion: null,
repoCommit: null,
manifestVersion: 1,
},
operations: [{
kind: 'copy-file',
moduleId: 'rules-core',
sourceRelativePath: 'rules/common/coding-style.md',
destinationPath: managedPath,
strategy: 'preserve-relative-path',
ownership: 'managed',
scaffoldOnly: false,
contentSha256: crypto.createHash('sha256').update(managedContent).digest('hex'),
}],
}));
const jsonResult = runCli(['--dry-run', 'uninstall', '--target', 'cursor', '--json'], {
cwd: projectRoot,
env: { HOME: homeDir },
});
assert.strictEqual(jsonResult.status, 0, jsonResult.stderr);
const preview = parseJson(jsonResult.stdout);
assert.strictEqual(preview.dryRun, true);
assert.strictEqual(preview.results[0].status, 'planned');
assert.deepStrictEqual(
preview.results[0].plannedRemovals.map(candidate => fs.realpathSync(candidate)).sort(),
[managedPath, statePath].map(candidate => fs.realpathSync(candidate)).sort()
);
const humanResult = runCli(['--dry-run', 'uninstall', '--target', 'cursor'], {
cwd: projectRoot,
env: { HOME: homeDir },
});
assert.strictEqual(humanResult.status, 0, humanResult.stderr);
assert.match(humanResult.stdout, /Status: WOULD UNINSTALL/);
assert.match(humanResult.stdout, /Would remove: 2/);
assert.doesNotMatch(humanResult.stdout, /Status: UNINSTALLED|Removed paths:/);
assert.ok(fs.existsSync(managedPath), 'global dry-run must preserve managed files');
assert.ok(fs.existsSync(statePath), 'global dry-run must preserve install-state');
} finally {
fs.rmSync(homeDir, { force: true, recursive: true });
fs.rmSync(projectRoot, { force: true, recursive: true });
}
}],
['delegates auto-update command', () => {
const homeDir = createTempDir('ecc-cli-home-');
const projectRoot = createTempDir('ecc-cli-project-');
+180
View File
@@ -0,0 +1,180 @@
'use strict';
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
const { createManifestInstallPlan } = require('../../scripts/lib/install/plan');
const { applyInstallPlan, previewInstallPlan } = require('../../scripts/lib/install/apply');
const { listInstallTargetAdapters } = require('../../scripts/lib/install-targets/registry');
const { uninstallInstalledStates } = require('../../scripts/lib/install-lifecycle');
let passed = 0;
let failed = 0;
function test(name, fn) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-ownership-'));
try {
const projectRoot = path.join(root, 'project');
const homeDir = path.join(root, 'home');
fs.mkdirSync(projectRoot);
fs.mkdirSync(homeDir);
fn({ projectRoot, homeDir, env: {} });
passed++;
console.log(` PASS ${name}`);
} catch (error) {
failed++;
console.error(` FAIL ${name}: ${error.stack}`);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
}
function readState(plan) {
return JSON.parse(fs.readFileSync(plan.installStatePath, 'utf8'));
}
for (const adapter of listInstallTargetAdapters()) {
test(`${adapter.target}: preserve user files through preview, install, reinstall and uninstall`, context => {
const nativeTarget = ['codex', 'gemini', 'opencode'].includes(adapter.target);
const resolved = createManifestInstallPlan({
...context, target: adapter.target,
moduleIds: [nativeTarget ? 'platform-configs' : 'rules-core'],
// This test exercises ownership of source files, not plugin compilation.
exemptValidationCodes: ['opencode-plugin-not-built'],
});
const operation = resolved.operations.find(item => item.kind === 'copy-file');
assert.ok(operation, 'target must produce a real copy operation');
const plan = {
...resolved, operations: [operation],
statePreview: { ...resolved.statePreview, operations: [operation] },
};
const destination = operation.destinationPath;
fs.mkdirSync(path.dirname(destination), { recursive: true });
fs.writeFileSync(destination, 'User-authored content\n');
const preview = previewInstallPlan(plan);
assert.ok(preview.skippedOperations.some(item => item.destinationPath === destination));
assert.ok(!preview.statePreview.operations.some(item => item.destinationPath === destination));
assert.ok(!fs.existsSync(plan.installStatePath), 'preview must not create state');
for (let attempt = 0; attempt < 2; attempt++) {
const installed = applyInstallPlan(plan);
assert.strictEqual(fs.readFileSync(destination, 'utf8'), 'User-authored content\n');
assert.ok(installed.warnings.some(warning => warning.includes('Skipped user-owned file')));
assert.ok(!readState(plan).operations.some(item => item.destinationPath === destination));
}
const result = uninstallInstalledStates({ ...context, targets: [adapter.target] });
assert.strictEqual(result.summary.errorCount, 0);
assert.strictEqual(fs.readFileSync(destination, 'utf8'), 'User-authored content\n');
assert.ok(!fs.existsSync(plan.installStatePath));
});
}
test('Antigravity transforms preserve a conflicting agent and still update managed files', context => {
const plan = createManifestInstallPlan({ ...context, target: 'antigravity', moduleIds: ['agents-core'] });
const userOperation = plan.operations.find(item => item.sourceRelativePath === 'agents/architect.md');
fs.mkdirSync(path.dirname(userOperation.destinationPath), { recursive: true });
fs.writeFileSync(userOperation.destinationPath, 'My architect\n');
applyInstallPlan(plan);
const managed = plan.operations.find(item => item.destinationPath !== userOperation.destinationPath);
const original = fs.readFileSync(managed.destinationPath, 'utf8');
fs.writeFileSync(managed.destinationPath, 'old managed version\n');
applyInstallPlan(plan);
assert.strictEqual(fs.readFileSync(managed.destinationPath, 'utf8'), original);
assert.strictEqual(fs.readFileSync(userOperation.destinationPath, 'utf8'), 'My architect\n');
assert.ok(!readState(plan).operations.some(item => item.destinationPath === userOperation.destinationPath));
});
for (const field of ['id', 'root', 'installStatePath']) {
test(`rejects previous state with mismatched target ${field}`, context => {
const plan = createManifestInstallPlan({ ...context, target: 'antigravity', moduleIds: ['rules-core'] });
applyInstallPlan(plan);
const operation = plan.operations[0];
const state = readState(plan);
const mismatched = { ...state, target: { ...state.target, [field]: `${state.target[field]}-other` } };
fs.writeFileSync(plan.installStatePath, JSON.stringify(mismatched));
fs.writeFileSync(operation.destinationPath, 'User file\n');
assert.throws(() => applyInstallPlan(plan), /install-state target does not match/);
assert.strictEqual(fs.readFileSync(operation.destinationPath, 'utf8'), 'User file\n');
assert.deepStrictEqual(readState(plan), mismatched);
});
}
test('preserves multiple user files created at the write boundary without checkpoint ownership', context => {
const plan = createManifestInstallPlan({ ...context, target: 'antigravity', moduleIds: ['rules-core'] });
const collisions = plan.operations.slice(0, 2).map(operation => operation.destinationPath);
assert.throws(() => applyInstallPlan(plan, {
beforeOperationWrite({ operation }) {
if (operation.destinationPath === collisions[0]) {
for (const collision of collisions) {
fs.mkdirSync(path.dirname(collision), { recursive: true });
fs.writeFileSync(collision, 'Concurrent user file\n');
}
}
},
}), /user-owned file appeared/);
for (const collision of collisions) {
assert.strictEqual(fs.readFileSync(collision, 'utf8'), 'Concurrent user file\n');
assert.ok(!readState(plan).operations.some(item => item.destinationPath === collision));
}
uninstallInstalledStates({ ...context, targets: ['antigravity'] });
for (const collision of collisions) {
assert.strictEqual(fs.readFileSync(collision, 'utf8'), 'Concurrent user file\n');
}
});
test('failed reinstall preserves prior hashes for modified managed files it never wrote', context => {
const plan = createManifestInstallPlan({ ...context, target: 'antigravity', moduleIds: ['rules-core'] });
applyInstallPlan(plan);
const modified = plan.operations[1].destinationPath;
const prior = readState(plan).operations.find(operation => operation.destinationPath === modified);
fs.writeFileSync(modified, 'My modified managed file\n');
assert.throws(() => applyInstallPlan(plan, {
beforeOperationWrite() { throw new Error('injected early failure'); },
}), /injected early failure/);
assert.strictEqual(readState(plan).operations.find(operation => operation.destinationPath === modified).contentSha256,
prior.contentSha256, 'unattempted managed files must keep their prior digest');
uninstallInstalledStates({ ...context, targets: ['antigravity'] });
assert.strictEqual(fs.readFileSync(modified, 'utf8'), 'My modified managed file\n');
});
test('partial install checkpoints never claim skipped user files', context => {
const plan = createManifestInstallPlan({ ...context, target: 'antigravity', moduleIds: ['rules-core'] });
const userOperation = plan.operations[0];
fs.mkdirSync(path.dirname(userOperation.destinationPath), { recursive: true });
fs.writeFileSync(userOperation.destinationPath, 'Keep this file\n');
assert.throws(() => applyInstallPlan(plan, {
beforeOperationWrite() { throw new Error('injected write failure'); },
}), /injected write failure/);
assert.ok(!readState(plan).operations.some(item => item.destinationPath === userOperation.destinationPath));
uninstallInstalledStates({ ...context, targets: ['antigravity'] });
assert.strictEqual(fs.readFileSync(userOperation.destinationPath, 'utf8'), 'Keep this file\n');
});
test('global CLI dry-run preserves installed files, state and canonical database', context => {
const plan = createManifestInstallPlan({ ...context, target: 'cursor', moduleIds: ['rules-core'] });
applyInstallPlan(plan);
const stateBefore = fs.readFileSync(plan.installStatePath);
const operation = plan.operations[0];
const fileBefore = fs.readFileSync(operation.destinationPath);
const env = {
...process.env, HOME: context.homeDir, USERPROFILE: context.homeDir,
CODEX_HOME: path.join(context.homeDir, '.codex'),
XDG_CONFIG_HOME: path.join(context.homeDir, '.config'),
ECC_DRY_RUN: '0',
};
const cli = path.join(__dirname, '../../scripts/ecc.js');
for (const args of [['--dry-run', 'uninstall'], ['uninstall', '--dry-run']]) {
const stdout = execFileSync(process.execPath, [cli, ...args, '--target', 'cursor'], {
cwd: context.projectRoot, env, encoding: 'utf8', timeout: 30000,
});
assert.match(stdout, /WOULD UNINSTALL/);
assert.match(stdout, /Would remove:/);
assert.doesNotMatch(stdout, /Status: UNINSTALLED|Removed paths:/);
assert.deepStrictEqual(fs.readFileSync(plan.installStatePath), stateBefore);
assert.deepStrictEqual(fs.readFileSync(operation.destinationPath), fileBefore);
assert.deepStrictEqual(fs.readdirSync(context.homeDir), [], 'dry-run must not initialize canonical state');
}
});
console.log(`Results: Passed: ${passed}, Failed: ${failed}`);
process.exitCode = failed ? 1 : 0;
+207 -3
View File
@@ -47,9 +47,13 @@ function writeState(filePath, options) {
}
function run(args = [], options = {}) {
const env = options.homeDir
? { ...process.env, HOME: options.homeDir, CODEX_HOME: path.join(options.homeDir, '.codex') }
: Object.fromEntries(Object.entries(process.env).filter(([key]) => key !== 'CODEX_HOME'))
const inheritedEnv = Object.fromEntries(
Object.entries(process.env).filter(([key]) => key !== 'ECC_DRY_RUN' && key !== 'CODEX_HOME')
);
const homeEnv = options.homeDir
? { HOME: options.homeDir, USERPROFILE: options.homeDir, CODEX_HOME: path.join(options.homeDir, '.codex') }
: {};
const env = { ...inheritedEnv, ...(options.env || {}), ...homeEnv };
try {
const stdout = execFileSync('node', [SCRIPT, ...args], {
@@ -291,6 +295,138 @@ function runTests() {
}
})) passed++; else failed++;
// #2952: the global `ecc --dry-run uninstall` prefix sets ECC_DRY_RUN=1.
// The uninstaller must honor it exactly like the subcommand-level flag.
if (test('honors global ECC_DRY_RUN=1 without mutating managed files (#2952)', () => {
const homeDir = createTempDir('uninstall-home-');
const projectRoot = createTempDir('uninstall-project-');
try {
const targetRoot = path.join(projectRoot, '.cursor');
fs.mkdirSync(targetRoot, { recursive: true });
const normalizedTargetRoot = fs.realpathSync(targetRoot);
const statePath = path.join(normalizedTargetRoot, 'ecc-install-state.json');
const renderedPath = path.join(normalizedTargetRoot, 'generated.md');
fs.writeFileSync(renderedPath, '# generated\n');
writeState(statePath, {
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
targetRoot: normalizedTargetRoot,
installStatePath: statePath,
request: {
profile: null,
modules: ['platform-configs'],
includeComponents: [],
excludeComponents: [],
legacyLanguages: [],
legacyMode: false,
},
resolution: {
selectedModules: ['platform-configs'],
skippedModules: [],
},
operations: [
{
kind: 'render-template',
moduleId: 'platform-configs',
sourceRelativePath: '.cursor/generated.md.template',
destinationPath: renderedPath,
strategy: 'render-template',
ownership: 'managed',
scaffoldOnly: false,
renderedContent: '# generated\n',
},
],
source: {
repoVersion: CURRENT_PACKAGE_VERSION,
repoCommit: 'abc123',
manifestVersion: CURRENT_MANIFEST_VERSION,
},
});
// No --dry-run flag: the global flag form must still be a no-op.
const uninstallResult = run(['--target', 'cursor', '--json'], {
cwd: projectRoot,
homeDir,
env: { ECC_DRY_RUN: '1' },
});
assert.strictEqual(uninstallResult.code, 0, uninstallResult.stderr);
const parsed = JSON.parse(uninstallResult.stdout);
assert.strictEqual(parsed.dryRun, true, 'ECC_DRY_RUN=1 must enable dry-run mode');
assert.ok(parsed.results[0].plannedRemovals.includes(renderedPath));
assert.ok(fs.existsSync(renderedPath), 'managed file must survive the dry run');
assert.ok(fs.existsSync(statePath), 'install-state must survive the dry run');
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
if (test('phrases dry-run human output as an unmistakable preview (#2952)', () => {
const homeDir = createTempDir('uninstall-home-');
const projectRoot = createTempDir('uninstall-project-');
try {
const targetRoot = path.join(projectRoot, '.cursor');
fs.mkdirSync(targetRoot, { recursive: true });
const normalizedTargetRoot = fs.realpathSync(targetRoot);
const statePath = path.join(normalizedTargetRoot, 'ecc-install-state.json');
const renderedPath = path.join(normalizedTargetRoot, 'generated.md');
fs.writeFileSync(renderedPath, '# generated\n');
writeState(statePath, {
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
targetRoot: normalizedTargetRoot,
installStatePath: statePath,
request: {
profile: null,
modules: ['platform-configs'],
includeComponents: [],
excludeComponents: [],
legacyLanguages: [],
legacyMode: false,
},
resolution: {
selectedModules: ['platform-configs'],
skippedModules: [],
},
operations: [
{
kind: 'render-template',
moduleId: 'platform-configs',
sourceRelativePath: '.cursor/generated.md.template',
destinationPath: renderedPath,
strategy: 'render-template',
ownership: 'managed',
scaffoldOnly: false,
renderedContent: '# generated\n',
},
],
source: {
repoVersion: CURRENT_PACKAGE_VERSION,
repoCommit: 'abc123',
manifestVersion: CURRENT_MANIFEST_VERSION,
},
});
const uninstallResult = run(['--target', 'cursor', '--dry-run'], {
cwd: projectRoot,
homeDir,
});
assert.strictEqual(uninstallResult.code, 0, uninstallResult.stderr);
assert.ok(uninstallResult.stdout.includes('dry run'), 'summary header must carry the dry-run marker');
assert.ok(uninstallResult.stdout.includes('WOULD UNINSTALL (dry run)'), 'status must use conditional wording');
assert.ok(uninstallResult.stdout.includes('Would remove:'), 'path count must use conditional wording');
assert.ok(!uninstallResult.stdout.includes('Status: UNINSTALLED'), 'dry run must not claim UNINSTALLED');
assert.ok(!uninstallResult.stdout.includes('Removed paths:'), 'dry run must not claim removal');
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
if (test('reports preserved legacy Antigravity files as an incomplete uninstall', () => {
const homeDir = createTempDir('uninstall-home-');
const projectRoot = createTempDir('uninstall-project-');
@@ -415,6 +551,74 @@ function runTests() {
}
})) passed++; else failed++;
if (test('global dry-run environment previews legacy Codex cleanup without removing artifacts', () => {
const homeDir = createTempDir('uninstall-legacy-codex-dry-run-home-');
const projectRoot = createTempDir('uninstall-legacy-codex-dry-run-project-');
try {
const codexHome = path.join(homeDir, '.codex');
const promptPath = path.join(codexHome, 'prompts', 'ecc-plan.md');
fs.mkdirSync(path.dirname(promptPath), { recursive: true });
const statePath = beginLegacySyncState({
codexHome,
backupDir: path.join(codexHome, 'backups', 'ecc-test'),
});
recordLegacySyncPath({ statePath, filePath: promptPath });
fs.writeFileSync(promptPath, '# ECC generated prompt\n');
finalizeLegacySyncState({ statePath });
const uninstallResult = run(['--legacy-codex-sync'], {
cwd: projectRoot,
homeDir,
env: { ECC_DRY_RUN: '1' },
});
assert.strictEqual(uninstallResult.code, 0, uninstallResult.stderr);
assert.match(uninstallResult.stdout, /Status: PLANNED/);
assert.match(uninstallResult.stdout, /Planned changes:/);
assert.doesNotMatch(uninstallResult.stdout, /Status: UNINSTALLED|Removed paths:/);
assert.ok(fs.existsSync(promptPath), 'global dry-run must preserve legacy artifacts');
assert.ok(fs.existsSync(statePath), 'global dry-run must preserve legacy state');
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
if (test('rejects an invalid global dry-run value before legacy cleanup', () => {
const homeDir = createTempDir('uninstall-legacy-codex-invalid-dry-run-home-');
const projectRoot = createTempDir('uninstall-legacy-codex-invalid-dry-run-project-');
try {
const codexHome = path.join(homeDir, '.codex');
const promptPath = path.join(codexHome, 'prompts', 'ecc-plan.md');
fs.mkdirSync(path.dirname(promptPath), { recursive: true });
const statePath = beginLegacySyncState({
codexHome,
backupDir: path.join(codexHome, 'backups', 'ecc-test'),
});
recordLegacySyncPath({ statePath, filePath: promptPath });
fs.writeFileSync(promptPath, '# ECC generated prompt\n');
finalizeLegacySyncState({ statePath });
const uninstallResult = run(['--legacy-codex-sync'], {
cwd: projectRoot,
homeDir,
env: { ECC_DRY_RUN: 'true' },
});
assert.strictEqual(uninstallResult.code, 1);
assert.match(uninstallResult.stderr, /ECC_DRY_RUN must be "1" or "0" when set/);
assert.ok(fs.existsSync(promptPath), 'invalid dry-run input must preserve legacy artifacts');
assert.ok(fs.existsSync(statePath), 'invalid dry-run input must preserve legacy state');
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
if (test('does not misclassify a clean Codex home as a legacy install', () => {
const homeDir = createTempDir('uninstall-clean-codex-home-');
const projectRoot = createTempDir('uninstall-clean-codex-project-');