/** * Tests for scripts/lib/install-lifecycle.js */ const assert = require('assert'); const fs = require('fs'); const os = require('os'); const path = require('path'); const { buildDoctorReport, discoverInstalledStates, normalizeTargets, repairInstalledStates, uninstallInstalledStates, } = require('../../scripts/lib/install-lifecycle'); const { createInstallState, writeInstallState, } = require('../../scripts/lib/install-state'); const REPO_ROOT = path.join(__dirname, '..', '..'); const CURRENT_PACKAGE_VERSION = JSON.parse( fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8') ).version; const CURRENT_MANIFEST_VERSION = JSON.parse( fs.readFileSync(path.join(REPO_ROOT, 'manifests', 'install-modules.json'), 'utf8') ).version; function test(name, fn) { try { fn(); console.log(` \u2713 ${name}`); return true; } catch (error) { console.log(` \u2717 ${name}`); console.log(` Error: ${error.message}`); return false; } } function createTempDir(prefix) { return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); } function cleanup(dirPath) { fs.rmSync(dirPath, { recursive: true, force: true }); } function writeState(filePath, options) { const state = createInstallState(options); writeInstallState(filePath, state); return state; } function createCursorStateOptions(projectRoot, overrides = {}) { const targetRoot = overrides.targetRoot || path.join(projectRoot, '.cursor'); const installStatePath = overrides.installStatePath || path.join(targetRoot, 'ecc-install-state.json'); return { adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' }, targetRoot, installStatePath, request: { profile: null, modules: [], includeComponents: [], excludeComponents: [], legacyLanguages: ['typescript'], legacyMode: true, ...(overrides.request || {}), }, resolution: { selectedModules: ['legacy-cursor-install'], skippedModules: [], ...(overrides.resolution || {}), }, operations: overrides.operations || [], source: { repoVersion: CURRENT_PACKAGE_VERSION, repoCommit: 'abc123', manifestVersion: CURRENT_MANIFEST_VERSION, ...(overrides.source || {}), }, }; } function writeCursorState(projectRoot, overrides = {}) { const options = createCursorStateOptions(projectRoot, overrides); writeState(options.installStatePath, options); return { targetRoot: options.targetRoot, installStatePath: options.installStatePath, state: options, }; } function managedOperation(kind, destinationPath, overrides = {}) { return { kind, moduleId: 'test-module', sourceRelativePath: 'rules/common/coding-style.md', destinationPath, strategy: kind, ownership: 'managed', scaffoldOnly: false, ...overrides, }; } function runTests() { console.log('\n=== Testing install-lifecycle.js ===\n'); let passed = 0; let failed = 0; if (test('normalizes default targets and dedupes adapter aliases', () => { const defaultTargets = normalizeTargets(); assert.ok(defaultTargets.includes('claude')); assert.ok(defaultTargets.includes('cursor')); assert.ok(defaultTargets.includes('codex')); assert.deepStrictEqual( normalizeTargets(['cursor-project', 'cursor', 'claude-home', 'claude']), ['cursor', 'claude'] ); })) passed++; else failed++; if (test('discovers installed states for multiple targets in the current context', () => { const homeDir = createTempDir('install-lifecycle-home-'); const projectRoot = createTempDir('install-lifecycle-project-'); try { const claudeStatePath = path.join(homeDir, '.claude', 'ecc', 'install-state.json'); const cursorStatePath = path.join(projectRoot, '.cursor', 'ecc-install-state.json'); writeState(claudeStatePath, { adapter: { id: 'claude-home', target: 'claude', kind: 'home' }, targetRoot: path.join(homeDir, '.claude'), installStatePath: claudeStatePath, request: { profile: null, modules: [], legacyLanguages: ['typescript'], legacyMode: true, }, resolution: { selectedModules: ['legacy-claude-rules'], skippedModules: [], }, operations: [], source: { repoVersion: CURRENT_PACKAGE_VERSION, repoCommit: 'abc123', manifestVersion: CURRENT_MANIFEST_VERSION, }, }); writeState(cursorStatePath, { adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' }, targetRoot: path.join(projectRoot, '.cursor'), installStatePath: cursorStatePath, request: { profile: 'core', modules: [], legacyLanguages: [], legacyMode: false, }, resolution: { selectedModules: ['rules-core', 'platform-configs'], skippedModules: [], }, operations: [], source: { repoVersion: CURRENT_PACKAGE_VERSION, repoCommit: 'def456', manifestVersion: CURRENT_MANIFEST_VERSION, }, }); const records = discoverInstalledStates({ homeDir, projectRoot, targets: ['claude', 'cursor'], }); assert.strictEqual(records.length, 2); assert.strictEqual(records[0].exists, true); assert.strictEqual(records[1].exists, true); assert.strictEqual(records[0].state.target.id, 'claude-home'); assert.strictEqual(records[1].state.target.id, 'cursor-project'); } finally { cleanup(homeDir); cleanup(projectRoot); } })) passed++; else failed++; if (test('discovers missing and invalid install-state records', () => { const homeDir = createTempDir('install-lifecycle-home-'); const projectRoot = createTempDir('install-lifecycle-project-'); try { let records = discoverInstalledStates({ homeDir, projectRoot, targets: ['cursor'], }); assert.strictEqual(records.length, 1); assert.strictEqual(records[0].exists, false); assert.strictEqual(records[0].state, null); assert.strictEqual(records[0].error, null); const targetRoot = path.join(projectRoot, '.cursor'); const statePath = path.join(targetRoot, 'ecc-install-state.json'); fs.mkdirSync(targetRoot, { recursive: true }); fs.writeFileSync(statePath, '{not-json', 'utf8'); records = discoverInstalledStates({ homeDir, projectRoot, targets: ['cursor'], }); assert.strictEqual(records[0].exists, true); assert.strictEqual(records[0].state, null); assert.ok(records[0].error.includes('Failed to read install-state')); } finally { cleanup(homeDir); cleanup(projectRoot); } })) passed++; else failed++; if (test('doctor reports missing managed files as an error', () => { const homeDir = createTempDir('install-lifecycle-home-'); const projectRoot = createTempDir('install-lifecycle-project-'); try { const targetRoot = path.join(projectRoot, '.cursor'); const statePath = path.join(targetRoot, 'ecc-install-state.json'); fs.mkdirSync(targetRoot, { recursive: true }); writeState(statePath, { adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' }, targetRoot, installStatePath: statePath, request: { profile: null, modules: ['platform-configs'], legacyLanguages: [], legacyMode: false, }, resolution: { selectedModules: ['platform-configs'], skippedModules: [], }, operations: [ { kind: 'copy-file', moduleId: 'platform-configs', sourceRelativePath: '.cursor/hooks.json', destinationPath: path.join(targetRoot, 'hooks.json'), strategy: 'sync-root-children', ownership: 'managed', scaffoldOnly: false, }, ], source: { repoVersion: CURRENT_PACKAGE_VERSION, repoCommit: 'abc123', manifestVersion: CURRENT_MANIFEST_VERSION, }, }); const report = buildDoctorReport({ repoRoot: REPO_ROOT, homeDir, projectRoot, targets: ['cursor'], }); assert.strictEqual(report.results.length, 1); assert.strictEqual(report.results[0].status, 'error'); assert.ok(report.results[0].issues.some(issue => issue.code === 'missing-managed-files')); } finally { cleanup(homeDir); cleanup(projectRoot); } })) passed++; else failed++; if (test('doctor reports target mismatches, missing sources, unverified operations, and version drift', () => { const homeDir = createTempDir('install-lifecycle-home-'); const projectRoot = createTempDir('install-lifecycle-project-'); try { const actualTargetRoot = path.join(projectRoot, '.cursor'); const actualStatePath = path.join(actualTargetRoot, 'ecc-install-state.json'); const recordedTargetRoot = path.join(projectRoot, '.old-cursor'); const recordedStatePath = path.join(recordedTargetRoot, 'state.json'); const copyDestination = path.join(actualTargetRoot, 'rules', 'missing-source.md'); const customDestination = path.join(actualTargetRoot, 'custom.txt'); fs.mkdirSync(path.dirname(copyDestination), { recursive: true }); fs.writeFileSync(copyDestination, 'managed copy\n'); fs.writeFileSync(customDestination, 'custom\n'); writeState(actualStatePath, createCursorStateOptions(projectRoot, { targetRoot: recordedTargetRoot, installStatePath: recordedStatePath, request: { profile: 'missing-profile', legacyLanguages: [], legacyMode: false, }, resolution: { selectedModules: [], skippedModules: [], }, source: { repoVersion: '0.0.1', manifestVersion: CURRENT_MANIFEST_VERSION + 100, }, operations: [ managedOperation('copy-file', copyDestination, { sourceRelativePath: 'missing/source.md', strategy: 'copy-file', }), managedOperation('custom-kind', customDestination), ], })); const report = buildDoctorReport({ repoRoot: REPO_ROOT, homeDir, projectRoot, targets: ['cursor'], }); const codes = report.results[0].issues.map(issue => issue.code); assert.strictEqual(report.results[0].status, 'error'); assert.ok(codes.includes('missing-target-root')); assert.ok(codes.includes('target-root-mismatch')); assert.ok(codes.includes('install-state-path-mismatch')); assert.ok(codes.includes('missing-source-files')); assert.ok(codes.includes('unverified-managed-operations')); assert.ok(codes.includes('manifest-version-mismatch')); assert.ok(codes.includes('repo-version-mismatch')); assert.ok(codes.includes('resolution-unavailable')); assert.strictEqual(report.summary.checkedCount, 1); assert.ok(report.summary.errorCount >= 3); assert.ok(report.summary.warningCount >= 4); } finally { cleanup(homeDir); cleanup(projectRoot); } })) passed++; else failed++; if (test('doctor verifies render-template and merge-json operations by content', () => { const homeDir = createTempDir('install-lifecycle-home-'); const projectRoot = createTempDir('install-lifecycle-project-'); try { const targetRoot = path.join(projectRoot, '.cursor'); const templatePath = path.join(targetRoot, 'generated.txt'); const jsonPath = path.join(targetRoot, 'settings.json'); fs.mkdirSync(targetRoot, { recursive: true }); fs.writeFileSync(templatePath, 'generated\n'); fs.writeFileSync(jsonPath, JSON.stringify({ keep: true, nested: { managed: true, extra: true, }, }, null, 2)); writeCursorState(projectRoot, { operations: [ managedOperation('render-template', templatePath, { renderedContent: 'generated\n', }), managedOperation('merge-json', jsonPath, { mergePayload: { nested: { managed: true, }, }, }), ], }); const report = buildDoctorReport({ repoRoot: REPO_ROOT, homeDir, projectRoot, targets: ['cursor'], }); assert.strictEqual(report.results[0].status, 'ok'); assert.strictEqual(report.results[0].issues.length, 0); } finally { cleanup(homeDir); cleanup(projectRoot); } })) passed++; else failed++; if (test('doctor classifies remove, unverified template/json, and invalid JSON operation health', () => { const homeDir = createTempDir('install-lifecycle-home-'); const projectRoot = createTempDir('install-lifecycle-project-'); try { const targetRoot = path.join(projectRoot, '.cursor'); const templatePath = path.join(targetRoot, 'template.txt'); const missingPayloadJsonPath = path.join(targetRoot, 'missing-payload.json'); const invalidJsonPath = path.join(targetRoot, 'invalid.json'); const removedPath = path.join(targetRoot, 'already-removed.txt'); fs.mkdirSync(targetRoot, { recursive: true }); fs.writeFileSync(templatePath, 'generated\n'); fs.writeFileSync(missingPayloadJsonPath, '{"managed":true}\n'); fs.writeFileSync(invalidJsonPath, '{not-json', 'utf8'); writeCursorState(projectRoot, { operations: [ managedOperation('remove', removedPath), managedOperation('render-template', templatePath), managedOperation('merge-json', missingPayloadJsonPath), managedOperation('merge-json', invalidJsonPath, { mergePayload: { managed: true }, }), ], }); const report = buildDoctorReport({ repoRoot: REPO_ROOT, homeDir, projectRoot, targets: ['cursor'], }); const codes = report.results[0].issues.map(issue => issue.code); assert.strictEqual(report.results[0].status, 'warning'); assert.ok(codes.includes('unverified-managed-operations')); assert.ok(codes.includes('drifted-managed-files')); assert.ok(!report.results[0].issues.some(issue => issue.code === 'missing-managed-files')); } finally { cleanup(homeDir); cleanup(projectRoot); } })) passed++; else failed++; if (test('doctor reports invalid install-state files as errors', () => { const homeDir = createTempDir('install-lifecycle-home-'); const projectRoot = createTempDir('install-lifecycle-project-'); try { const statePath = path.join(projectRoot, '.cursor', 'ecc-install-state.json'); fs.mkdirSync(path.dirname(statePath), { recursive: true }); fs.writeFileSync(statePath, '{"schemaVersion":"wrong"}\n'); const report = buildDoctorReport({ repoRoot: REPO_ROOT, homeDir, projectRoot, targets: ['cursor'], }); assert.strictEqual(report.results[0].status, 'error'); assert.ok(report.results[0].issues.some(issue => issue.code === 'invalid-install-state')); } finally { cleanup(homeDir); cleanup(projectRoot); } })) passed++; else failed++; if (test('doctor reports a healthy legacy install when managed files are present', () => { const homeDir = createTempDir('install-lifecycle-home-'); const projectRoot = createTempDir('install-lifecycle-project-'); try { const targetRoot = path.join(homeDir, '.claude'); const statePath = path.join(targetRoot, 'ecc', 'install-state.json'); const managedFile = path.join(targetRoot, 'rules', 'common', 'coding-style.md'); const sourceContent = fs.readFileSync(path.join(REPO_ROOT, 'rules', 'common', 'coding-style.md'), 'utf8'); fs.mkdirSync(path.dirname(managedFile), { recursive: true }); fs.writeFileSync(managedFile, sourceContent); writeState(statePath, { adapter: { id: 'claude-home', target: 'claude', kind: 'home' }, targetRoot, installStatePath: statePath, request: { profile: null, modules: [], legacyLanguages: ['typescript'], legacyMode: true, }, resolution: { selectedModules: ['legacy-claude-rules'], skippedModules: [], }, operations: [ { kind: 'copy-file', moduleId: 'legacy-claude-rules', sourceRelativePath: 'rules/common/coding-style.md', destinationPath: managedFile, strategy: 'preserve-relative-path', ownership: 'managed', scaffoldOnly: false, }, ], source: { repoVersion: CURRENT_PACKAGE_VERSION, repoCommit: 'abc123', manifestVersion: CURRENT_MANIFEST_VERSION, }, }); const report = buildDoctorReport({ repoRoot: REPO_ROOT, homeDir, projectRoot, targets: ['claude'], }); assert.strictEqual(report.results.length, 1); assert.strictEqual(report.results[0].status, 'ok'); assert.strictEqual(report.results[0].issues.length, 0); } finally { cleanup(homeDir); cleanup(projectRoot); } })) passed++; else failed++; if (test('repair dry-run reports planned copy repairs without writing files', () => { 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', 'coding-style.md'); writeCursorState(projectRoot, { operations: [ managedOperation('copy-file', destinationPath, { sourceRelativePath: 'rules/common/coding-style.md', strategy: 'copy-file', }), ], }); const result = repairInstalledStates({ repoRoot: REPO_ROOT, homeDir, projectRoot, targets: ['cursor'], dryRun: true, }); assert.strictEqual(result.dryRun, true); assert.strictEqual(result.results[0].status, 'planned'); assert.deepStrictEqual(result.results[0].plannedRepairs, [destinationPath]); assert.ok(!fs.existsSync(destinationPath)); } finally { cleanup(homeDir); cleanup(projectRoot); } })) passed++; else failed++; if (test('repair copies missing managed files from recorded source paths', () => { 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', 'coding-style.md'); const sourcePath = path.join(REPO_ROOT, 'rules', 'common', 'coding-style.md'); writeCursorState(projectRoot, { operations: [ managedOperation('copy-file', destinationPath, { sourceRelativePath: 'rules/common/coding-style.md', strategy: 'copy-file', }), ], }); const result = repairInstalledStates({ repoRoot: REPO_ROOT, homeDir, projectRoot, targets: ['cursor'], }); assert.strictEqual(result.results[0].status, 'repaired'); assert.ok(fs.readFileSync(destinationPath).equals(fs.readFileSync(sourcePath))); } finally { cleanup(homeDir); cleanup(projectRoot); } })) passed++; else failed++; if (test('repair reports invalid states, missing sources, unsupported operations, and no-op refreshes', () => { const homeDir = createTempDir('install-lifecycle-home-'); const invalidProjectRoot = createTempDir('install-lifecycle-invalid-'); const missingSourceProjectRoot = createTempDir('install-lifecycle-missing-source-'); const unsupportedProjectRoot = createTempDir('install-lifecycle-unsupported-'); const okProjectRoot = createTempDir('install-lifecycle-ok-'); try { const invalidStatePath = path.join(invalidProjectRoot, '.cursor', 'ecc-install-state.json'); fs.mkdirSync(path.dirname(invalidStatePath), { recursive: true }); fs.writeFileSync(invalidStatePath, '{"schemaVersion":"wrong"}\n'); let result = repairInstalledStates({ repoRoot: REPO_ROOT, homeDir, projectRoot: invalidProjectRoot, targets: ['cursor'], }); assert.strictEqual(result.results[0].status, 'error'); assert.ok(result.results[0].error.includes('Invalid install-state')); const missingDestination = path.join(missingSourceProjectRoot, '.cursor', 'rules', 'missing.md'); fs.mkdirSync(path.dirname(missingDestination), { recursive: true }); fs.writeFileSync(missingDestination, 'managed\n'); writeCursorState(missingSourceProjectRoot, { operations: [ managedOperation('copy-file', missingDestination, { sourceRelativePath: 'missing/source.md', strategy: 'copy-file', }), ], }); result = repairInstalledStates({ repoRoot: REPO_ROOT, homeDir, projectRoot: missingSourceProjectRoot, targets: ['cursor'], }); assert.strictEqual(result.results[0].status, 'error'); assert.ok(result.results[0].error.includes('Missing source file(s)')); const unsupportedDestination = path.join(unsupportedProjectRoot, '.cursor', 'custom.txt'); writeCursorState(unsupportedProjectRoot, { operations: [ managedOperation('custom-kind', unsupportedDestination), ], }); result = repairInstalledStates({ repoRoot: REPO_ROOT, homeDir, projectRoot: unsupportedProjectRoot, targets: ['cursor'], }); assert.strictEqual(result.results[0].status, 'error'); assert.ok(result.results[0].error.includes('Unsupported repair operation kind')); writeCursorState(okProjectRoot, { operations: [] }); result = repairInstalledStates({ repoRoot: REPO_ROOT, homeDir, projectRoot: okProjectRoot, targets: ['cursor'], }); assert.strictEqual(result.results[0].status, 'ok'); assert.strictEqual(result.results[0].stateRefreshed, true); assert.strictEqual(result.summary.errorCount, 0); } finally { cleanup(homeDir); cleanup(invalidProjectRoot); cleanup(missingSourceProjectRoot); cleanup(unsupportedProjectRoot); cleanup(okProjectRoot); } })) passed++; else failed++; if (test('repair dry-run reports ok when no managed operations need changes', () => { const homeDir = createTempDir('install-lifecycle-home-'); const projectRoot = createTempDir('install-lifecycle-project-'); try { writeCursorState(projectRoot, { operations: [] }); const result = repairInstalledStates({ repoRoot: REPO_ROOT, homeDir, projectRoot, targets: ['cursor'], dryRun: true, }); assert.strictEqual(result.results[0].status, 'ok'); assert.strictEqual(result.results[0].stateRefreshed, true); assert.deepStrictEqual(result.results[0].plannedRepairs, []); } finally { cleanup(homeDir); cleanup(projectRoot); } })) passed++; else failed++; if (test('repair surfaces missing source errors from execution when destination is absent', () => { const homeDir = createTempDir('install-lifecycle-home-'); const projectRoot = createTempDir('install-lifecycle-project-'); try { const destinationPath = path.join(projectRoot, '.cursor', 'rules', 'missing.md'); writeCursorState(projectRoot, { operations: [ managedOperation('copy-file', destinationPath, { sourceRelativePath: 'missing/source.md', strategy: 'copy-file', }), ], }); const result = repairInstalledStates({ repoRoot: REPO_ROOT, homeDir, projectRoot, targets: ['cursor'], }); assert.strictEqual(result.results[0].status, 'error'); assert.ok(result.results[0].error.includes('Missing source file for repair')); } finally { cleanup(homeDir); cleanup(projectRoot); } })) passed++; else failed++; if (test('doctor reports drifted managed files as a warning', () => { const homeDir = createTempDir('install-lifecycle-home-'); const projectRoot = createTempDir('install-lifecycle-project-'); try { const targetRoot = path.join(projectRoot, '.cursor'); const statePath = path.join(targetRoot, 'ecc-install-state.json'); const sourcePath = path.join(REPO_ROOT, '.cursor', 'hooks.json'); const destinationPath = path.join(targetRoot, 'hooks.json'); fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); fs.writeFileSync(destinationPath, '{"drifted":true}\n'); writeState(statePath, { adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' }, targetRoot, installStatePath: statePath, request: { profile: null, modules: ['platform-configs'], legacyLanguages: [], legacyMode: false, }, resolution: { selectedModules: ['platform-configs'], skippedModules: [], }, operations: [ { kind: 'copy-file', moduleId: 'platform-configs', sourcePath, sourceRelativePath: '.cursor/hooks.json', destinationPath, strategy: 'sync-root-children', ownership: 'managed', scaffoldOnly: false, }, ], source: { repoVersion: CURRENT_PACKAGE_VERSION, repoCommit: 'abc123', manifestVersion: CURRENT_MANIFEST_VERSION, }, }); const report = buildDoctorReport({ repoRoot: REPO_ROOT, homeDir, projectRoot, targets: ['cursor'], }); assert.strictEqual(report.results.length, 1); assert.strictEqual(report.results[0].status, 'warning'); assert.ok(report.results[0].issues.some(issue => issue.code === 'drifted-managed-files')); } finally { cleanup(homeDir); cleanup(projectRoot); } })) passed++; else failed++; if (test('doctor reports manifest resolution drift for non-legacy installs', () => { const homeDir = createTempDir('install-lifecycle-home-'); const projectRoot = createTempDir('install-lifecycle-project-'); try { const targetRoot = path.join(projectRoot, '.cursor'); const statePath = path.join(targetRoot, 'ecc-install-state.json'); fs.mkdirSync(targetRoot, { recursive: true }); writeState(statePath, { adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' }, targetRoot, installStatePath: statePath, request: { profile: 'core', modules: [], legacyLanguages: [], legacyMode: false, }, resolution: { selectedModules: ['rules-core'], skippedModules: [], }, operations: [], source: { repoVersion: CURRENT_PACKAGE_VERSION, repoCommit: 'abc123', manifestVersion: CURRENT_MANIFEST_VERSION, }, }); const report = buildDoctorReport({ repoRoot: REPO_ROOT, homeDir, projectRoot, targets: ['cursor'], }); assert.strictEqual(report.results.length, 1); assert.strictEqual(report.results[0].status, 'warning'); assert.ok(report.results[0].issues.some(issue => issue.code === 'resolution-drift')); } finally { cleanup(homeDir); cleanup(projectRoot); } })) passed++; else failed++; if (test('repair restores render-template outputs from recorded rendered content', () => { const homeDir = createTempDir('install-lifecycle-home-'); const projectRoot = createTempDir('install-lifecycle-project-'); try { const targetRoot = path.join(homeDir, '.claude'); const statePath = path.join(targetRoot, 'ecc', 'install-state.json'); const destinationPath = path.join(targetRoot, 'plugin.json'); fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); fs.writeFileSync(destinationPath, '{"drifted":true}\n'); writeState(statePath, { adapter: { id: 'claude-home', target: 'claude', kind: 'home' }, targetRoot, installStatePath: statePath, request: { profile: null, modules: [], legacyLanguages: ['typescript'], legacyMode: true, }, resolution: { selectedModules: ['legacy-claude-rules'], skippedModules: [], }, operations: [ { kind: 'render-template', moduleId: 'platform-configs', sourceRelativePath: '.claude-plugin/plugin.json.template', destinationPath, strategy: 'render-template', ownership: 'managed', scaffoldOnly: false, renderedContent: '{"ok":true}\n', }, ], source: { repoVersion: CURRENT_PACKAGE_VERSION, repoCommit: 'abc123', manifestVersion: CURRENT_MANIFEST_VERSION, }, }); const result = repairInstalledStates({ repoRoot: REPO_ROOT, homeDir, projectRoot, targets: ['claude'], }); assert.strictEqual(result.results[0].status, 'repaired'); assert.strictEqual(fs.readFileSync(destinationPath, 'utf8'), '{"ok":true}\n'); } finally { cleanup(homeDir); cleanup(projectRoot); } })) passed++; else failed++; if (test('repair reapplies merge-json operations without clobbering unrelated keys', () => { const homeDir = createTempDir('install-lifecycle-home-'); const projectRoot = createTempDir('install-lifecycle-project-'); try { const targetRoot = path.join(projectRoot, '.cursor'); const statePath = path.join(targetRoot, 'ecc-install-state.json'); const destinationPath = path.join(targetRoot, 'hooks.json'); fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); fs.writeFileSync(destinationPath, JSON.stringify({ existing: true, nested: { enabled: false, }, }, null, 2)); writeState(statePath, { adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' }, targetRoot, installStatePath: statePath, request: { profile: null, modules: [], legacyLanguages: ['typescript'], legacyMode: true, }, resolution: { selectedModules: ['legacy-cursor-install'], skippedModules: [], }, operations: [ { kind: 'merge-json', moduleId: 'platform-configs', sourceRelativePath: '.cursor/hooks.json', destinationPath, strategy: 'merge-json', ownership: 'managed', scaffoldOnly: false, mergePayload: { nested: { enabled: true, }, managed: 'yes', }, }, ], source: { repoVersion: CURRENT_PACKAGE_VERSION, repoCommit: 'abc123', manifestVersion: CURRENT_MANIFEST_VERSION, }, }); const result = repairInstalledStates({ repoRoot: REPO_ROOT, homeDir, projectRoot, targets: ['cursor'], }); assert.strictEqual(result.results[0].status, 'repaired'); assert.deepStrictEqual(JSON.parse(fs.readFileSync(destinationPath, 'utf8')), { existing: true, nested: { enabled: true, }, managed: 'yes', }); } finally { cleanup(homeDir); cleanup(projectRoot); } })) passed++; else failed++; if (test('repair re-applies managed remove operations when files reappear', () => { const homeDir = createTempDir('install-lifecycle-home-'); const projectRoot = createTempDir('install-lifecycle-project-'); try { const targetRoot = path.join(projectRoot, '.cursor'); const statePath = path.join(targetRoot, 'ecc-install-state.json'); const destinationPath = path.join(targetRoot, 'legacy-note.txt'); fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); fs.writeFileSync(destinationPath, 'stale'); writeState(statePath, { adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' }, targetRoot, installStatePath: statePath, request: { profile: null, modules: [], legacyLanguages: ['typescript'], legacyMode: true, }, resolution: { selectedModules: ['legacy-cursor-install'], skippedModules: [], }, operations: [ { kind: 'remove', moduleId: 'platform-configs', sourceRelativePath: '.cursor/legacy-note.txt', destinationPath, strategy: 'remove', ownership: 'managed', scaffoldOnly: false, }, ], source: { repoVersion: CURRENT_PACKAGE_VERSION, repoCommit: 'abc123', manifestVersion: CURRENT_MANIFEST_VERSION, }, }); const result = repairInstalledStates({ repoRoot: REPO_ROOT, homeDir, projectRoot, targets: ['cursor'], }); assert.strictEqual(result.results[0].status, 'repaired'); assert.ok(!fs.existsSync(destinationPath)); } finally { cleanup(homeDir); cleanup(projectRoot); } })) passed++; else failed++; if (test('uninstall restores JSON merged files from recorded previous content', () => { const homeDir = createTempDir('install-lifecycle-home-'); const projectRoot = createTempDir('install-lifecycle-project-'); try { const targetRoot = path.join(projectRoot, '.cursor'); const statePath = path.join(targetRoot, 'ecc-install-state.json'); const destinationPath = path.join(targetRoot, 'hooks.json'); fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); fs.writeFileSync(destinationPath, JSON.stringify({ existing: true, managed: true, }, null, 2)); writeState(statePath, { adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' }, targetRoot, installStatePath: statePath, request: { profile: null, modules: [], legacyLanguages: ['typescript'], legacyMode: true, }, resolution: { selectedModules: ['legacy-cursor-install'], skippedModules: [], }, operations: [ { kind: 'merge-json', moduleId: 'platform-configs', sourceRelativePath: '.cursor/hooks.json', destinationPath, strategy: 'merge-json', ownership: 'managed', scaffoldOnly: false, mergePayload: { managed: true, }, previousContent: JSON.stringify({ existing: true, }, null, 2), }, ], source: { repoVersion: CURRENT_PACKAGE_VERSION, repoCommit: 'abc123', manifestVersion: CURRENT_MANIFEST_VERSION, }, }); const result = uninstallInstalledStates({ homeDir, projectRoot, targets: ['cursor'], }); assert.strictEqual(result.results[0].status, 'uninstalled'); assert.deepStrictEqual(JSON.parse(fs.readFileSync(destinationPath, 'utf8')), { existing: true, }); assert.ok(!fs.existsSync(statePath)); } finally { cleanup(homeDir); cleanup(projectRoot); } })) passed++; else failed++; if (test('uninstall restores rendered template files from recorded previous content', () => { const tempDir = createTempDir('install-lifecycle-'); try { const targetRoot = path.join(tempDir, '.claude'); const statePath = path.join(targetRoot, 'ecc', 'install-state.json'); const destinationPath = path.join(targetRoot, 'plugin.json'); fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); fs.writeFileSync(destinationPath, '{"generated":true}\n'); writeInstallState(statePath, createInstallState({ adapter: { id: 'claude-home', target: 'claude', kind: 'home' }, targetRoot, installStatePath: statePath, request: { profile: 'core', modules: ['platform-configs'], includeComponents: [], excludeComponents: [], legacyLanguages: [], legacyMode: false, }, resolution: { selectedModules: ['platform-configs'], skippedModules: [], }, source: { repoVersion: '1.8.0', repoCommit: 'abc123', manifestVersion: 1, }, operations: [ { kind: 'render-template', moduleId: 'platform-configs', sourceRelativePath: '.claude/plugin.json.template', destinationPath, strategy: 'render-template', ownership: 'managed', scaffoldOnly: false, renderedContent: '{"generated":true}\n', previousContent: '{"existing":true}\n', }, ], })); const result = uninstallInstalledStates({ homeDir: tempDir, projectRoot: tempDir, targets: ['claude'], }); assert.strictEqual(result.summary.uninstalledCount, 1); assert.strictEqual(fs.readFileSync(destinationPath, 'utf8'), '{"existing":true}\n'); assert.ok(!fs.existsSync(statePath)); } finally { cleanup(tempDir); } })) passed++; else failed++; if (test('uninstall restores files removed during install when previous content is recorded', () => { const homeDir = createTempDir('install-lifecycle-home-'); const projectRoot = createTempDir('install-lifecycle-project-'); try { const targetRoot = path.join(projectRoot, '.cursor'); const statePath = path.join(targetRoot, 'ecc-install-state.json'); const destinationPath = path.join(targetRoot, 'legacy-note.txt'); fs.mkdirSync(targetRoot, { recursive: true }); writeState(statePath, { adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' }, targetRoot, installStatePath: statePath, request: { profile: null, modules: [], legacyLanguages: ['typescript'], legacyMode: true, }, resolution: { selectedModules: ['legacy-cursor-install'], skippedModules: [], }, operations: [ { kind: 'remove', moduleId: 'platform-configs', sourceRelativePath: '.cursor/legacy-note.txt', destinationPath, strategy: 'remove', ownership: 'managed', scaffoldOnly: false, previousContent: 'restore me\n', }, ], source: { repoVersion: CURRENT_PACKAGE_VERSION, repoCommit: 'abc123', manifestVersion: CURRENT_MANIFEST_VERSION, }, }); const result = uninstallInstalledStates({ homeDir, projectRoot, targets: ['cursor'], }); assert.strictEqual(result.results[0].status, 'uninstalled'); assert.strictEqual(fs.readFileSync(destinationPath, 'utf8'), 'restore me\n'); assert.ok(!fs.existsSync(statePath)); } finally { cleanup(homeDir); cleanup(projectRoot); } })) passed++; else failed++; if (test('uninstall dry-run reports deduped managed removals without deleting files', () => { 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', 'coding-style.md'); fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); fs.writeFileSync(destinationPath, 'managed\n'); const { installStatePath } = writeCursorState(projectRoot, { operations: [ managedOperation('copy-file', destinationPath, { strategy: 'copy-file' }), managedOperation('copy-file', destinationPath, { strategy: 'copy-file' }), ], }); const result = uninstallInstalledStates({ homeDir, projectRoot, targets: ['cursor'], dryRun: true, }); assert.strictEqual(result.dryRun, true); assert.strictEqual(result.results[0].status, 'planned'); assert.deepStrictEqual(result.results[0].plannedRemovals, [ destinationPath, installStatePath, ]); assert.ok(fs.existsSync(destinationPath)); assert.ok(fs.existsSync(installStatePath)); } finally { cleanup(homeDir); cleanup(projectRoot); } })) passed++; else failed++; if (test('uninstall reports invalid install states as errors', () => { const homeDir = createTempDir('install-lifecycle-home-'); const projectRoot = createTempDir('install-lifecycle-project-'); try { const statePath = path.join(projectRoot, '.cursor', 'ecc-install-state.json'); fs.mkdirSync(path.dirname(statePath), { recursive: true }); fs.writeFileSync(statePath, '{not-json', 'utf8'); const result = uninstallInstalledStates({ homeDir, projectRoot, targets: ['cursor'], }); assert.strictEqual(result.results[0].status, 'error'); assert.ok(result.results[0].error.includes('Failed to read install-state')); assert.strictEqual(result.summary.errorCount, 1); } finally { cleanup(homeDir); cleanup(projectRoot); } })) passed++; else failed++; if (test('uninstall removes copied files and cleans empty parent directories', () => { 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', 'nested', 'managed.md'); fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); fs.writeFileSync(destinationPath, 'managed\n'); writeCursorState(projectRoot, { operations: [ managedOperation('copy-file', destinationPath, { strategy: 'copy-file' }), ], }); const result = uninstallInstalledStates({ homeDir, projectRoot, targets: ['cursor'], }); assert.strictEqual(result.results[0].status, 'uninstalled'); assert.ok(result.results[0].removedPaths.includes(destinationPath)); assert.ok(!fs.existsSync(destinationPath)); assert.ok(!fs.existsSync(path.dirname(destinationPath))); assert.ok(fs.existsSync(targetRoot)); } finally { cleanup(homeDir); cleanup(projectRoot); } })) passed++; else failed++; if (test('uninstall handles merge-json subset removal and full-file deletion', () => { const homeDir = createTempDir('install-lifecycle-home-'); const partialProjectRoot = createTempDir('install-lifecycle-partial-'); const fullProjectRoot = createTempDir('install-lifecycle-full-'); try { let targetRoot = path.join(partialProjectRoot, '.cursor'); let destinationPath = path.join(targetRoot, 'settings.json'); fs.mkdirSync(targetRoot, { recursive: true }); fs.writeFileSync(destinationPath, JSON.stringify({ keep: true, managed: true, nested: { keep: true, remove: true, }, list: ['a', 'b'], }, null, 2)); writeCursorState(partialProjectRoot, { operations: [ managedOperation('merge-json', destinationPath, { mergePayload: { managed: true, nested: { remove: true }, list: ['a', 'b'], }, }), ], }); let result = uninstallInstalledStates({ homeDir, projectRoot: partialProjectRoot, targets: ['cursor'], }); assert.strictEqual(result.results[0].status, 'uninstalled'); assert.deepStrictEqual(JSON.parse(fs.readFileSync(destinationPath, 'utf8')), { keep: true, nested: { keep: true, }, }); targetRoot = path.join(fullProjectRoot, '.cursor'); destinationPath = path.join(targetRoot, 'settings.json'); fs.mkdirSync(targetRoot, { recursive: true }); fs.writeFileSync(destinationPath, JSON.stringify({ managed: true }, null, 2)); writeCursorState(fullProjectRoot, { operations: [ managedOperation('merge-json', destinationPath, { mergePayload: { managed: true }, }), ], }); result = uninstallInstalledStates({ homeDir, projectRoot: fullProjectRoot, targets: ['cursor'], }); assert.strictEqual(result.results[0].status, 'uninstalled'); assert.ok(!fs.existsSync(destinationPath)); } finally { cleanup(homeDir); cleanup(partialProjectRoot); cleanup(fullProjectRoot); } })) passed++; else failed++; if (test('uninstall handles merge-json edge shapes and absent destinations', () => { const homeDir = createTempDir('install-lifecycle-home-'); const projects = [ createTempDir('install-lifecycle-current-primitive-'), createTempDir('install-lifecycle-missing-key-'), createTempDir('install-lifecycle-nested-delete-'), createTempDir('install-lifecycle-array-root-'), createTempDir('install-lifecycle-primitive-root-'), createTempDir('install-lifecycle-absent-dest-'), createTempDir('install-lifecycle-previous-json-'), ]; try { const cases = [ { projectRoot: projects[0], initial: '"plain"', payload: { managed: true }, expected: 'plain', }, { projectRoot: projects[1], initial: { keep: true }, payload: { missing: true }, expected: { keep: true }, }, { projectRoot: projects[2], initial: { keep: true, nested: { remove: true } }, payload: { nested: { remove: true } }, expected: { keep: true }, }, { projectRoot: projects[3], initial: ['a', 'b'], payload: ['a', 'b'], removed: true, }, { projectRoot: projects[4], initial: true, payload: true, removed: true, }, { projectRoot: projects[5], payload: { managed: true }, absent: true, }, { projectRoot: projects[6], initial: { generated: true }, payload: { generated: true }, previousJson: { restored: true }, expected: { restored: true }, }, ]; for (const testCase of cases) { const targetRoot = path.join(testCase.projectRoot, '.cursor'); const destinationPath = path.join(targetRoot, 'settings.json'); fs.mkdirSync(targetRoot, { recursive: true }); if (!testCase.absent) { fs.writeFileSync( destinationPath, typeof testCase.initial === 'string' ? `${testCase.initial}\n` : JSON.stringify(testCase.initial, null, 2) ); } writeCursorState(testCase.projectRoot, { operations: [ managedOperation('merge-json', destinationPath, { mergePayload: testCase.payload, previousJson: testCase.previousJson, }), ], }); const result = uninstallInstalledStates({ homeDir, projectRoot: testCase.projectRoot, targets: ['cursor'], }); assert.strictEqual(result.results[0].status, 'uninstalled'); if (testCase.removed || testCase.absent) { assert.ok(!fs.existsSync(destinationPath)); } else { assert.deepStrictEqual(JSON.parse(fs.readFileSync(destinationPath, 'utf8')), testCase.expected); } } } finally { cleanup(homeDir); for (const projectRoot of projects) { cleanup(projectRoot); } } })) passed++; else failed++; if (test('uninstall removes generated render-template files and no-backup remove operations are no-ops', () => { const homeDir = createTempDir('install-lifecycle-home-'); const projectRoot = createTempDir('install-lifecycle-project-'); try { const targetRoot = path.join(projectRoot, '.cursor'); const templatePath = path.join(targetRoot, 'generated', 'plugin.json'); const removedPath = path.join(targetRoot, 'already-removed.txt'); fs.mkdirSync(path.dirname(templatePath), { recursive: true }); fs.writeFileSync(templatePath, '{"generated":true}\n'); writeCursorState(projectRoot, { operations: [ managedOperation('render-template', templatePath, { renderedContent: '{"generated":true}\n', }), managedOperation('remove', removedPath), ], }); const result = uninstallInstalledStates({ homeDir, projectRoot, targets: ['cursor'], }); assert.strictEqual(result.results[0].status, 'uninstalled'); assert.ok(result.results[0].removedPaths.includes(templatePath)); assert.ok(!fs.existsSync(templatePath)); assert.ok(!fs.existsSync(path.dirname(templatePath))); } finally { cleanup(homeDir); cleanup(projectRoot); } })) 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-'); try { const targetRoot = path.join(projectRoot, '.cursor'); const templatePath = path.join(targetRoot, 'plugin.json'); const removedPath = path.join(targetRoot, 'legacy.json'); fs.mkdirSync(targetRoot, { recursive: true }); fs.writeFileSync(templatePath, '{"generated":true}\n'); writeCursorState(projectRoot, { operations: [ managedOperation('render-template', templatePath, { previousJson: { existing: true }, renderedContent: '{"generated":true}\n', }), managedOperation('remove', removedPath, { previousJson: { restored: true }, }), ], }); const result = uninstallInstalledStates({ homeDir, projectRoot, targets: ['cursor'], }); assert.strictEqual(result.results[0].status, 'uninstalled'); assert.deepStrictEqual(JSON.parse(fs.readFileSync(templatePath, 'utf8')), { existing: true, }); assert.deepStrictEqual(JSON.parse(fs.readFileSync(removedPath, 'utf8')), { restored: true, }); } finally { cleanup(homeDir); cleanup(projectRoot); } })) passed++; else failed++; if (test('uninstall reports unsupported operations and missing merge payloads as errors', () => { const homeDir = createTempDir('install-lifecycle-home-'); const unsupportedProjectRoot = createTempDir('install-lifecycle-unsupported-'); const missingPayloadProjectRoot = createTempDir('install-lifecycle-missing-payload-'); try { let targetRoot = path.join(unsupportedProjectRoot, '.cursor'); let destinationPath = path.join(targetRoot, 'custom.txt'); fs.mkdirSync(targetRoot, { recursive: true }); fs.writeFileSync(destinationPath, 'custom\n'); writeCursorState(unsupportedProjectRoot, { operations: [ managedOperation('custom-kind', destinationPath), ], }); let result = uninstallInstalledStates({ homeDir, projectRoot: unsupportedProjectRoot, targets: ['cursor'], }); assert.strictEqual(result.results[0].status, 'error'); assert.ok(result.results[0].error.includes('Unsupported uninstall operation kind')); targetRoot = path.join(missingPayloadProjectRoot, '.cursor'); destinationPath = path.join(targetRoot, 'settings.json'); fs.mkdirSync(targetRoot, { recursive: true }); fs.writeFileSync(destinationPath, '{"managed":true}\n'); writeCursorState(missingPayloadProjectRoot, { operations: [ managedOperation('merge-json', destinationPath), ], }); result = uninstallInstalledStates({ homeDir, projectRoot: missingPayloadProjectRoot, targets: ['cursor'], }); assert.strictEqual(result.results[0].status, 'error'); assert.ok(result.results[0].error.includes('Missing merge payload for uninstall')); } finally { cleanup(homeDir); cleanup(unsupportedProjectRoot); cleanup(missingPayloadProjectRoot); } })) passed++; else failed++; console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); } runTests();