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-');