From 131f97784127083fcbaee5d7353867cbd2e43cc7 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Sun, 15 Mar 2026 21:47:31 -0700 Subject: [PATCH] feat: strengthen install lifecycle and target adapters (#512) * fix: strengthen install lifecycle adapters * fix: restore template content on uninstall --- scripts/lib/install-lifecycle.js | 571 ++++++++++++++++-- scripts/lib/install-state.js | 203 ++++++- .../install-targets/antigravity-project.js | 62 +- scripts/lib/install-targets/cursor-project.js | 39 +- scripts/lib/install-targets/helpers.js | 228 ++++++- scripts/lib/install-targets/registry.js | 16 +- tests/lib/install-lifecycle.test.js | 381 ++++++++++++ tests/lib/install-state.test.js | 92 +++ tests/lib/install-targets.test.js | 119 +++- tests/scripts/repair.test.js | 206 ++++++- tests/scripts/uninstall.test.js | 195 +++++- 11 files changed, 1987 insertions(+), 125 deletions(-) diff --git a/scripts/lib/install-lifecycle.js b/scripts/lib/install-lifecycle.js index 306c4825..dfb9c762 100644 --- a/scripts/lib/install-lifecycle.js +++ b/scripts/lib/install-lifecycle.js @@ -4,7 +4,6 @@ const path = require('path'); const { resolveInstallPlan, loadInstallManifests } = require('./install-manifests'); const { readInstallState, writeInstallState } = require('./install-state'); const { - applyInstallPlan, createLegacyInstallPlan, createManifestInstallPlan, } = require('./install-executor'); @@ -79,6 +78,420 @@ function areFilesEqual(leftPath, rightPath) { } } +function readFileUtf8(filePath) { + return fs.readFileSync(filePath, 'utf8'); +} + +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function cloneJsonValue(value) { + if (value === undefined) { + return undefined; + } + + return JSON.parse(JSON.stringify(value)); +} + +function parseJsonLikeValue(value, label) { + if (value === undefined) { + return undefined; + } + + if (typeof value === 'string') { + try { + return JSON.parse(value); + } catch (error) { + throw new Error(`Invalid ${label}: ${error.message}`); + } + } + + if (value === null || Array.isArray(value) || isPlainObject(value) || typeof value === 'number' || typeof value === 'boolean') { + return cloneJsonValue(value); + } + + throw new Error(`Invalid ${label}: expected JSON-compatible data`); +} + +function getOperationTextContent(operation) { + const candidateKeys = [ + 'renderedContent', + 'content', + 'managedContent', + 'expectedContent', + 'templateOutput', + ]; + + for (const key of candidateKeys) { + if (typeof operation[key] === 'string') { + return operation[key]; + } + } + + return null; +} + +function getOperationJsonPayload(operation) { + const candidateKeys = [ + 'mergePayload', + 'managedPayload', + 'payload', + 'value', + 'expectedValue', + ]; + + for (const key of candidateKeys) { + if (operation[key] !== undefined) { + return parseJsonLikeValue(operation[key], `${operation.kind}.${key}`); + } + } + + return undefined; +} + +function getOperationPreviousContent(operation) { + const candidateKeys = [ + 'previousContent', + 'originalContent', + 'backupContent', + ]; + + for (const key of candidateKeys) { + if (typeof operation[key] === 'string') { + return operation[key]; + } + } + + return null; +} + +function getOperationPreviousJson(operation) { + const candidateKeys = [ + 'previousValue', + 'previousJson', + 'originalValue', + ]; + + for (const key of candidateKeys) { + if (operation[key] !== undefined) { + return parseJsonLikeValue(operation[key], `${operation.kind}.${key}`); + } + } + + return undefined; +} + +function formatJson(value) { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function readJsonFile(filePath) { + return JSON.parse(readFileUtf8(filePath)); +} + +function ensureParentDir(filePath) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); +} + +function deepMergeJson(baseValue, patchValue) { + if (!isPlainObject(baseValue) || !isPlainObject(patchValue)) { + return cloneJsonValue(patchValue); + } + + const merged = { ...baseValue }; + for (const [key, value] of Object.entries(patchValue)) { + if (isPlainObject(value) && isPlainObject(merged[key])) { + merged[key] = deepMergeJson(merged[key], value); + } else { + merged[key] = cloneJsonValue(value); + } + } + return merged; +} + +function jsonContainsSubset(actualValue, expectedValue) { + if (isPlainObject(expectedValue)) { + if (!isPlainObject(actualValue)) { + return false; + } + + return Object.entries(expectedValue).every(([key, value]) => ( + Object.prototype.hasOwnProperty.call(actualValue, key) + && jsonContainsSubset(actualValue[key], value) + )); + } + + if (Array.isArray(expectedValue)) { + if (!Array.isArray(actualValue) || actualValue.length !== expectedValue.length) { + return false; + } + + return expectedValue.every((item, index) => jsonContainsSubset(actualValue[index], item)); + } + + return actualValue === expectedValue; +} + +const JSON_REMOVE_SENTINEL = Symbol('json-remove'); + +function deepRemoveJsonSubset(currentValue, managedValue) { + if (isPlainObject(managedValue)) { + if (!isPlainObject(currentValue)) { + return currentValue; + } + + const nextValue = { ...currentValue }; + for (const [key, value] of Object.entries(managedValue)) { + if (!Object.prototype.hasOwnProperty.call(nextValue, key)) { + continue; + } + + if (isPlainObject(value)) { + const nestedValue = deepRemoveJsonSubset(nextValue[key], value); + if (nestedValue === JSON_REMOVE_SENTINEL) { + delete nextValue[key]; + } else { + nextValue[key] = nestedValue; + } + continue; + } + + if (Array.isArray(value)) { + if (Array.isArray(nextValue[key]) && jsonContainsSubset(nextValue[key], value)) { + delete nextValue[key]; + } + continue; + } + + if (nextValue[key] === value) { + delete nextValue[key]; + } + } + + return Object.keys(nextValue).length === 0 ? JSON_REMOVE_SENTINEL : nextValue; + } + + if (Array.isArray(managedValue)) { + return jsonContainsSubset(currentValue, managedValue) ? JSON_REMOVE_SENTINEL : currentValue; + } + + return currentValue === managedValue ? JSON_REMOVE_SENTINEL : currentValue; +} + +function hydrateRecordedOperations(repoRoot, operations) { + return operations.map(operation => { + if (operation.kind !== 'copy-file') { + return { ...operation }; + } + + return { + ...operation, + sourcePath: resolveOperationSourcePath(repoRoot, operation), + }; + }); +} + +function buildRecordedStatePreview(state, context, operations) { + return { + ...state, + operations: operations.map(operation => ({ ...operation })), + source: { + ...state.source, + repoVersion: context.packageVersion, + manifestVersion: context.manifestVersion, + }, + lastValidatedAt: new Date().toISOString(), + }; +} + +function shouldRepairFromRecordedOperations(state) { + return getManagedOperations(state).some(operation => operation.kind !== 'copy-file'); +} + +function executeRepairOperation(repoRoot, operation) { + if (operation.kind === 'copy-file') { + const sourcePath = resolveOperationSourcePath(repoRoot, operation); + if (!sourcePath || !fs.existsSync(sourcePath)) { + throw new Error(`Missing source file for repair: ${sourcePath || operation.sourceRelativePath}`); + } + + ensureParentDir(operation.destinationPath); + fs.copyFileSync(sourcePath, operation.destinationPath); + return; + } + + if (operation.kind === 'render-template') { + const renderedContent = getOperationTextContent(operation); + if (renderedContent === null) { + throw new Error(`Missing rendered content for repair: ${operation.destinationPath}`); + } + + ensureParentDir(operation.destinationPath); + fs.writeFileSync(operation.destinationPath, renderedContent); + return; + } + + if (operation.kind === 'merge-json') { + const payload = getOperationJsonPayload(operation); + if (payload === undefined) { + throw new Error(`Missing merge payload for repair: ${operation.destinationPath}`); + } + + const currentValue = fs.existsSync(operation.destinationPath) + ? readJsonFile(operation.destinationPath) + : {}; + const mergedValue = deepMergeJson(currentValue, payload); + + ensureParentDir(operation.destinationPath); + fs.writeFileSync(operation.destinationPath, formatJson(mergedValue)); + return; + } + + if (operation.kind === 'remove') { + if (!fs.existsSync(operation.destinationPath)) { + return; + } + + fs.rmSync(operation.destinationPath, { recursive: true, force: true }); + return; + } + + throw new Error(`Unsupported repair operation kind: ${operation.kind}`); +} + +function executeUninstallOperation(operation) { + if (operation.kind === 'copy-file') { + if (!fs.existsSync(operation.destinationPath)) { + return { + removedPaths: [], + cleanupTargets: [], + }; + } + + fs.rmSync(operation.destinationPath, { force: true }); + return { + removedPaths: [operation.destinationPath], + cleanupTargets: [operation.destinationPath], + }; + } + + if (operation.kind === 'render-template') { + const previousContent = getOperationPreviousContent(operation); + if (previousContent !== null) { + ensureParentDir(operation.destinationPath); + fs.writeFileSync(operation.destinationPath, previousContent); + return { + removedPaths: [], + cleanupTargets: [], + }; + } + + const previousJson = getOperationPreviousJson(operation); + if (previousJson !== undefined) { + ensureParentDir(operation.destinationPath); + fs.writeFileSync(operation.destinationPath, formatJson(previousJson)); + return { + removedPaths: [], + cleanupTargets: [], + }; + } + + if (!fs.existsSync(operation.destinationPath)) { + return { + removedPaths: [], + cleanupTargets: [], + }; + } + + fs.rmSync(operation.destinationPath, { force: true }); + return { + removedPaths: [operation.destinationPath], + cleanupTargets: [operation.destinationPath], + }; + } + + if (operation.kind === 'merge-json') { + const previousContent = getOperationPreviousContent(operation); + if (previousContent !== null) { + ensureParentDir(operation.destinationPath); + fs.writeFileSync(operation.destinationPath, previousContent); + return { + removedPaths: [], + cleanupTargets: [], + }; + } + + const previousJson = getOperationPreviousJson(operation); + if (previousJson !== undefined) { + ensureParentDir(operation.destinationPath); + fs.writeFileSync(operation.destinationPath, formatJson(previousJson)); + return { + removedPaths: [], + cleanupTargets: [], + }; + } + + if (!fs.existsSync(operation.destinationPath)) { + return { + removedPaths: [], + cleanupTargets: [], + }; + } + + const payload = getOperationJsonPayload(operation); + if (payload === undefined) { + throw new Error(`Missing merge payload for uninstall: ${operation.destinationPath}`); + } + + const currentValue = readJsonFile(operation.destinationPath); + const nextValue = deepRemoveJsonSubset(currentValue, payload); + if (nextValue === JSON_REMOVE_SENTINEL) { + fs.rmSync(operation.destinationPath, { force: true }); + return { + removedPaths: [operation.destinationPath], + cleanupTargets: [operation.destinationPath], + }; + } + + ensureParentDir(operation.destinationPath); + fs.writeFileSync(operation.destinationPath, formatJson(nextValue)); + return { + removedPaths: [], + cleanupTargets: [], + }; + } + + if (operation.kind === 'remove') { + const previousContent = getOperationPreviousContent(operation); + if (previousContent !== null) { + ensureParentDir(operation.destinationPath); + fs.writeFileSync(operation.destinationPath, previousContent); + return { + removedPaths: [], + cleanupTargets: [], + }; + } + + const previousJson = getOperationPreviousJson(operation); + if (previousJson !== undefined) { + ensureParentDir(operation.destinationPath); + fs.writeFileSync(operation.destinationPath, formatJson(previousJson)); + return { + removedPaths: [], + cleanupTargets: [], + }; + } + + return { + removedPaths: [], + cleanupTargets: [], + }; + } + + throw new Error(`Unsupported uninstall operation kind: ${operation.kind}`); +} + function inspectManagedOperation(repoRoot, operation) { const destinationPath = operation.destinationPath; if (!destinationPath) { @@ -88,6 +501,22 @@ function inspectManagedOperation(repoRoot, operation) { }; } + if (operation.kind === 'remove') { + if (fs.existsSync(destinationPath)) { + return { + status: 'drifted', + operation, + destinationPath, + }; + } + + return { + status: 'ok', + operation, + destinationPath, + }; + } + if (!fs.existsSync(destinationPath)) { return { status: 'missing', @@ -96,38 +525,97 @@ function inspectManagedOperation(repoRoot, operation) { }; } - if (operation.kind !== 'copy-file') { - return { - status: 'unverified', - operation, - destinationPath, - }; - } + if (operation.kind === 'copy-file') { + const sourcePath = resolveOperationSourcePath(repoRoot, operation); + if (!sourcePath || !fs.existsSync(sourcePath)) { + return { + status: 'missing-source', + operation, + destinationPath, + sourcePath, + }; + } + + if (!areFilesEqual(sourcePath, destinationPath)) { + return { + status: 'drifted', + operation, + destinationPath, + sourcePath, + }; + } - const sourcePath = resolveOperationSourcePath(repoRoot, operation); - if (!sourcePath || !fs.existsSync(sourcePath)) { return { - status: 'missing-source', + status: 'ok', operation, destinationPath, sourcePath, }; } - if (!areFilesEqual(sourcePath, destinationPath)) { + if (operation.kind === 'render-template') { + const renderedContent = getOperationTextContent(operation); + if (renderedContent === null) { + return { + status: 'unverified', + operation, + destinationPath, + }; + } + + if (readFileUtf8(destinationPath) !== renderedContent) { + return { + status: 'drifted', + operation, + destinationPath, + }; + } + return { - status: 'drifted', + status: 'ok', + operation, + destinationPath, + }; + } + + if (operation.kind === 'merge-json') { + const payload = getOperationJsonPayload(operation); + if (payload === undefined) { + return { + status: 'unverified', + operation, + destinationPath, + }; + } + + try { + const currentValue = readJsonFile(destinationPath); + if (!jsonContainsSubset(currentValue, payload)) { + return { + status: 'drifted', + operation, + destinationPath, + }; + } + } catch (_error) { + return { + status: 'drifted', + operation, + destinationPath, + }; + } + + return { + status: 'ok', operation, destinationPath, - sourcePath, }; } return { - status: 'ok', + status: 'unverified', operation, destinationPath, - sourcePath, }; } @@ -455,25 +943,12 @@ function createRepairPlanFromRecord(record, context) { throw new Error('No install-state available for repair'); } - if (state.request.legacyMode) { - const operations = getManagedOperations(state).map(operation => ({ - ...operation, - sourcePath: resolveOperationSourcePath(context.repoRoot, operation), - })); - - const statePreview = { - ...state, - operations: operations.map(operation => ({ ...operation })), - source: { - ...state.source, - repoVersion: context.packageVersion, - manifestVersion: context.manifestVersion, - }, - lastValidatedAt: new Date().toISOString(), - }; + if (state.request.legacyMode || shouldRepairFromRecordedOperations(state)) { + const operations = hydrateRecordedOperations(context.repoRoot, getManagedOperations(state)); + const statePreview = buildRecordedStatePreview(state, context, operations); return { - mode: 'legacy', + mode: state.request.legacyMode ? 'legacy' : 'recorded', target: record.adapter.target, adapter: record.adapter, targetRoot: state.target.root, @@ -571,11 +1046,10 @@ function repairInstalledStates(options = {}) { } if (repairOperations.length > 0) { - applyInstallPlan({ - ...desiredPlan, - operations: repairOperations, - statePreview: desiredPlan.statePreview, - }); + for (const operation of repairOperations) { + executeRepairOperation(context.repoRoot, operation); + } + writeInstallState(desiredPlan.installStatePath, desiredPlan.statePreview); } else { writeInstallState(desiredPlan.installStatePath, desiredPlan.statePreview); } @@ -684,23 +1158,12 @@ function uninstallInstalledStates(options = {}) { try { const removedPaths = []; const cleanupTargets = []; - const filePaths = Array.from(new Set( - getManagedOperations(state).map(operation => operation.destinationPath) - )).sort((left, right) => right.length - left.length); + const operations = getManagedOperations(state); - for (const filePath of filePaths) { - if (!fs.existsSync(filePath)) { - continue; - } - - const stat = fs.lstatSync(filePath); - if (stat.isDirectory()) { - throw new Error(`Refusing to remove managed directory path without explicit support: ${filePath}`); - } - - fs.rmSync(filePath, { force: true }); - removedPaths.push(filePath); - cleanupTargets.push(filePath); + for (const operation of operations) { + const outcome = executeUninstallOperation(operation); + removedPaths.push(...outcome.removedPaths); + cleanupTargets.push(...outcome.cleanupTargets); } if (fs.existsSync(state.target.installStatePath)) { diff --git a/scripts/lib/install-state.js b/scripts/lib/install-state.js index 69208f92..56b2649e 100644 --- a/scripts/lib/install-state.js +++ b/scripts/lib/install-state.js @@ -1,11 +1,28 @@ const fs = require('fs'); const path = require('path'); -const Ajv = require('ajv'); + +let Ajv = null; +try { + // Prefer schema-backed validation when dependencies are installed. + // The fallback validator below keeps source checkouts usable in bare environments. + const ajvModule = require('ajv'); + Ajv = ajvModule.default || ajvModule; +} catch (_error) { + Ajv = null; +} const SCHEMA_PATH = path.join(__dirname, '..', '..', 'schemas', 'install-state.schema.json'); let cachedValidator = null; +function cloneJsonValue(value) { + if (value === undefined) { + return undefined; + } + + return JSON.parse(JSON.stringify(value)); +} + function readJson(filePath, label) { try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); @@ -19,12 +36,188 @@ function getValidator() { return cachedValidator; } - const schema = readJson(SCHEMA_PATH, 'install-state schema'); - const ajv = new Ajv({ allErrors: true }); - cachedValidator = ajv.compile(schema); + if (Ajv) { + const schema = readJson(SCHEMA_PATH, 'install-state schema'); + const ajv = new Ajv({ allErrors: true }); + cachedValidator = ajv.compile(schema); + return cachedValidator; + } + + cachedValidator = createFallbackValidator(); return cachedValidator; } +function createFallbackValidator() { + const validate = state => { + const errors = []; + validate.errors = errors; + + function pushError(instancePath, message) { + errors.push({ + instancePath, + message, + }); + } + + function isNonEmptyString(value) { + return typeof value === 'string' && value.length > 0; + } + + function validateNoAdditionalProperties(value, instancePath, allowedKeys) { + for (const key of Object.keys(value)) { + if (!allowedKeys.includes(key)) { + pushError(`${instancePath}/${key}`, 'must NOT have additional properties'); + } + } + } + + function validateStringArray(value, instancePath) { + if (!Array.isArray(value)) { + pushError(instancePath, 'must be array'); + return; + } + + for (let index = 0; index < value.length; index += 1) { + if (!isNonEmptyString(value[index])) { + pushError(`${instancePath}/${index}`, 'must be non-empty string'); + } + } + } + + function validateOptionalString(value, instancePath) { + if (value !== undefined && value !== null && !isNonEmptyString(value)) { + pushError(instancePath, 'must be string or null'); + } + } + + if (!state || typeof state !== 'object' || Array.isArray(state)) { + pushError('/', 'must be object'); + return false; + } + + validateNoAdditionalProperties( + state, + '', + ['schemaVersion', 'installedAt', 'lastValidatedAt', 'target', 'request', 'resolution', 'source', 'operations'] + ); + + if (state.schemaVersion !== 'ecc.install.v1') { + pushError('/schemaVersion', 'must equal ecc.install.v1'); + } + + if (!isNonEmptyString(state.installedAt)) { + pushError('/installedAt', 'must be non-empty string'); + } + + if (state.lastValidatedAt !== undefined && !isNonEmptyString(state.lastValidatedAt)) { + pushError('/lastValidatedAt', 'must be non-empty string'); + } + + const target = state.target; + if (!target || typeof target !== 'object' || Array.isArray(target)) { + pushError('/target', 'must be object'); + } else { + validateNoAdditionalProperties(target, '/target', ['id', 'target', 'kind', 'root', 'installStatePath']); + if (!isNonEmptyString(target.id)) { + pushError('/target/id', 'must be non-empty string'); + } + validateOptionalString(target.target, '/target/target'); + if (target.kind !== undefined && !['home', 'project'].includes(target.kind)) { + pushError('/target/kind', 'must be equal to one of the allowed values'); + } + if (!isNonEmptyString(target.root)) { + pushError('/target/root', 'must be non-empty string'); + } + if (!isNonEmptyString(target.installStatePath)) { + pushError('/target/installStatePath', 'must be non-empty string'); + } + } + + const request = state.request; + if (!request || typeof request !== 'object' || Array.isArray(request)) { + pushError('/request', 'must be object'); + } else { + validateNoAdditionalProperties( + request, + '/request', + ['profile', 'modules', 'includeComponents', 'excludeComponents', 'legacyLanguages', 'legacyMode'] + ); + if (!(Object.prototype.hasOwnProperty.call(request, 'profile') && (request.profile === null || typeof request.profile === 'string'))) { + pushError('/request/profile', 'must be string or null'); + } + validateStringArray(request.modules, '/request/modules'); + validateStringArray(request.includeComponents, '/request/includeComponents'); + validateStringArray(request.excludeComponents, '/request/excludeComponents'); + validateStringArray(request.legacyLanguages, '/request/legacyLanguages'); + if (typeof request.legacyMode !== 'boolean') { + pushError('/request/legacyMode', 'must be boolean'); + } + } + + const resolution = state.resolution; + if (!resolution || typeof resolution !== 'object' || Array.isArray(resolution)) { + pushError('/resolution', 'must be object'); + } else { + validateNoAdditionalProperties(resolution, '/resolution', ['selectedModules', 'skippedModules']); + validateStringArray(resolution.selectedModules, '/resolution/selectedModules'); + validateStringArray(resolution.skippedModules, '/resolution/skippedModules'); + } + + const source = state.source; + if (!source || typeof source !== 'object' || Array.isArray(source)) { + pushError('/source', 'must be object'); + } else { + validateNoAdditionalProperties(source, '/source', ['repoVersion', 'repoCommit', 'manifestVersion']); + validateOptionalString(source.repoVersion, '/source/repoVersion'); + validateOptionalString(source.repoCommit, '/source/repoCommit'); + if (!Number.isInteger(source.manifestVersion) || source.manifestVersion < 1) { + pushError('/source/manifestVersion', 'must be integer >= 1'); + } + } + + if (!Array.isArray(state.operations)) { + pushError('/operations', 'must be array'); + } else { + for (let index = 0; index < state.operations.length; index += 1) { + const operation = state.operations[index]; + const instancePath = `/operations/${index}`; + + if (!operation || typeof operation !== 'object' || Array.isArray(operation)) { + pushError(instancePath, 'must be object'); + continue; + } + + if (!isNonEmptyString(operation.kind)) { + pushError(`${instancePath}/kind`, 'must be non-empty string'); + } + if (!isNonEmptyString(operation.moduleId)) { + pushError(`${instancePath}/moduleId`, 'must be non-empty string'); + } + if (!isNonEmptyString(operation.sourceRelativePath)) { + pushError(`${instancePath}/sourceRelativePath`, 'must be non-empty string'); + } + if (!isNonEmptyString(operation.destinationPath)) { + pushError(`${instancePath}/destinationPath`, 'must be non-empty string'); + } + if (!isNonEmptyString(operation.strategy)) { + pushError(`${instancePath}/strategy`, 'must be non-empty string'); + } + if (!isNonEmptyString(operation.ownership)) { + pushError(`${instancePath}/ownership`, 'must be non-empty string'); + } + if (typeof operation.scaffoldOnly !== 'boolean') { + pushError(`${instancePath}/scaffoldOnly`, 'must be boolean'); + } + } + } + + return errors.length === 0; + }; + + validate.errors = []; + return validate; +} + function formatValidationErrors(errors = []) { return errors .map(error => `${error.instancePath || '/'} ${error.message}`) @@ -87,7 +280,7 @@ function createInstallState(options) { manifestVersion: options.source.manifestVersion, }, operations: Array.isArray(options.operations) - ? options.operations.map(operation => ({ ...operation })) + ? options.operations.map(operation => cloneJsonValue(operation)) : [], }; diff --git a/scripts/lib/install-targets/antigravity-project.js b/scripts/lib/install-targets/antigravity-project.js index 0d156405..818a0a7a 100644 --- a/scripts/lib/install-targets/antigravity-project.js +++ b/scripts/lib/install-targets/antigravity-project.js @@ -1,4 +1,10 @@ -const { createInstallTargetAdapter } = require('./helpers'); +const path = require('path'); + +const { + createFlatRuleOperations, + createInstallTargetAdapter, + createManagedScaffoldOperation, +} = require('./helpers'); module.exports = createInstallTargetAdapter({ id: 'antigravity-project', @@ -6,4 +12,58 @@ module.exports = createInstallTargetAdapter({ kind: 'project', rootSegments: ['.agent'], installStatePathSegments: ['ecc-install-state.json'], + planOperations(input, adapter) { + const modules = Array.isArray(input.modules) + ? input.modules + : (input.module ? [input.module] : []); + const { + repoRoot, + projectRoot, + homeDir, + } = input; + const planningInput = { + repoRoot, + projectRoot, + homeDir, + }; + const targetRoot = adapter.resolveRoot(planningInput); + + return modules.flatMap(module => { + const paths = Array.isArray(module.paths) ? module.paths : []; + return paths.flatMap(sourceRelativePath => { + if (sourceRelativePath === 'rules') { + return createFlatRuleOperations({ + moduleId: module.id, + repoRoot, + sourceRelativePath, + destinationDir: path.join(targetRoot, 'rules'), + }); + } + + if (sourceRelativePath === 'commands') { + return [ + createManagedScaffoldOperation( + module.id, + sourceRelativePath, + path.join(targetRoot, 'workflows'), + 'preserve-relative-path' + ), + ]; + } + + if (sourceRelativePath === 'agents') { + return [ + createManagedScaffoldOperation( + module.id, + sourceRelativePath, + path.join(targetRoot, 'skills'), + 'preserve-relative-path' + ), + ]; + } + + return [adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput)]; + }); + }); + }, }); diff --git a/scripts/lib/install-targets/cursor-project.js b/scripts/lib/install-targets/cursor-project.js index 0af26fb3..d3dd4e7b 100644 --- a/scripts/lib/install-targets/cursor-project.js +++ b/scripts/lib/install-targets/cursor-project.js @@ -1,4 +1,9 @@ -const { createInstallTargetAdapter } = require('./helpers'); +const path = require('path'); + +const { + createFlatRuleOperations, + createInstallTargetAdapter, +} = require('./helpers'); module.exports = createInstallTargetAdapter({ id: 'cursor-project', @@ -7,4 +12,36 @@ module.exports = createInstallTargetAdapter({ rootSegments: ['.cursor'], installStatePathSegments: ['ecc-install-state.json'], nativeRootRelativePath: '.cursor', + planOperations(input, adapter) { + const modules = Array.isArray(input.modules) + ? input.modules + : (input.module ? [input.module] : []); + const { + repoRoot, + projectRoot, + homeDir, + } = input; + const planningInput = { + repoRoot, + projectRoot, + homeDir, + }; + const targetRoot = adapter.resolveRoot(planningInput); + + return modules.flatMap(module => { + const paths = Array.isArray(module.paths) ? module.paths : []; + return paths.flatMap(sourceRelativePath => { + if (sourceRelativePath === 'rules') { + return createFlatRuleOperations({ + moduleId: module.id, + repoRoot, + sourceRelativePath, + destinationDir: path.join(targetRoot, 'rules'), + }); + } + + return [adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput)]; + }); + }); + }, }); diff --git a/scripts/lib/install-targets/helpers.js b/scripts/lib/install-targets/helpers.js index 411b3468..fda3e387 100644 --- a/scripts/lib/install-targets/helpers.js +++ b/scripts/lib/install-targets/helpers.js @@ -1,3 +1,4 @@ +const fs = require('fs'); const os = require('os'); const path = require('path'); @@ -24,6 +25,182 @@ function resolveBaseRoot(scope, input = {}) { throw new Error(`Unsupported install target scope: ${scope}`); } +function buildValidationIssue(severity, code, message, extra = {}) { + return { + severity, + code, + message, + ...extra, + }; +} + +function listRelativeFiles(dirPath, prefix = '') { + if (!fs.existsSync(dirPath)) { + return []; + } + + const entries = fs.readdirSync(dirPath, { withFileTypes: true }).sort((left, right) => ( + left.name.localeCompare(right.name) + )); + const files = []; + + for (const entry of entries) { + const entryPrefix = prefix ? path.join(prefix, entry.name) : entry.name; + const absolutePath = path.join(dirPath, entry.name); + + if (entry.isDirectory()) { + files.push(...listRelativeFiles(absolutePath, entryPrefix)); + } else if (entry.isFile()) { + files.push(normalizeRelativePath(entryPrefix)); + } + } + + return files; +} + +function createManagedOperation({ + kind = 'copy-path', + moduleId, + sourceRelativePath, + destinationPath, + strategy = 'preserve-relative-path', + ownership = 'managed', + scaffoldOnly = true, + ...rest +}) { + return { + kind, + moduleId, + sourceRelativePath: normalizeRelativePath(sourceRelativePath), + destinationPath, + strategy, + ownership, + scaffoldOnly, + ...rest, + }; +} + +function defaultValidateAdapterInput(config, input = {}) { + if (config.kind === 'project' && !input.projectRoot && !input.repoRoot) { + return [ + buildValidationIssue( + 'error', + 'missing-project-root', + 'projectRoot or repoRoot is required for project install targets' + ), + ]; + } + + if (config.kind === 'home' && !input.homeDir && !os.homedir()) { + return [ + buildValidationIssue( + 'error', + 'missing-home-dir', + 'homeDir is required for home install targets' + ), + ]; + } + + return []; +} + +function createRemappedOperation(adapter, moduleId, sourceRelativePath, destinationPath, options = {}) { + return createManagedOperation({ + kind: options.kind || 'copy-path', + moduleId, + sourceRelativePath, + destinationPath, + strategy: options.strategy || 'preserve-relative-path', + ownership: options.ownership || 'managed', + scaffoldOnly: Object.hasOwn(options, 'scaffoldOnly') ? options.scaffoldOnly : true, + ...options.extra, + }); +} + +function createNamespacedFlatRuleOperations(adapter, moduleId, sourceRelativePath, input = {}) { + const normalizedSourcePath = normalizeRelativePath(sourceRelativePath); + const sourceRoot = path.join(input.repoRoot || '', normalizedSourcePath); + + if (!input.repoRoot || !fs.existsSync(sourceRoot) || !fs.statSync(sourceRoot).isDirectory()) { + return []; + } + + const targetRulesDir = path.join(adapter.resolveRoot(input), 'rules'); + const operations = []; + const entries = fs.readdirSync(sourceRoot, { withFileTypes: true }).sort((left, right) => ( + left.name.localeCompare(right.name) + )); + + for (const entry of entries) { + const namespace = entry.name; + const entryPath = path.join(sourceRoot, entry.name); + + if (entry.isDirectory()) { + const relativeFiles = listRelativeFiles(entryPath); + for (const relativeFile of relativeFiles) { + const flattenedFileName = `${namespace}-${normalizeRelativePath(relativeFile).replace(/\//g, '-')}`; + const sourceRelativeFile = path.join(normalizedSourcePath, namespace, relativeFile); + operations.push(createManagedOperation({ + moduleId, + sourceRelativePath: sourceRelativeFile, + destinationPath: path.join(targetRulesDir, flattenedFileName), + strategy: 'flatten-copy', + })); + } + } else if (entry.isFile()) { + operations.push(createManagedOperation({ + moduleId, + sourceRelativePath: path.join(normalizedSourcePath, entry.name), + destinationPath: path.join(targetRulesDir, entry.name), + strategy: 'flatten-copy', + })); + } + } + + return operations; +} + +function createFlatRuleOperations({ moduleId, repoRoot, sourceRelativePath, destinationDir }) { + const normalizedSourcePath = normalizeRelativePath(sourceRelativePath); + const sourceRoot = path.join(repoRoot || '', normalizedSourcePath); + + if (!repoRoot || !fs.existsSync(sourceRoot) || !fs.statSync(sourceRoot).isDirectory()) { + return []; + } + + const operations = []; + const entries = fs.readdirSync(sourceRoot, { withFileTypes: true }).sort((left, right) => ( + left.name.localeCompare(right.name) + )); + + for (const entry of entries) { + const namespace = entry.name; + const entryPath = path.join(sourceRoot, entry.name); + + if (entry.isDirectory()) { + const relativeFiles = listRelativeFiles(entryPath); + for (const relativeFile of relativeFiles) { + const flattenedFileName = `${namespace}-${normalizeRelativePath(relativeFile).replace(/\//g, '-')}`; + operations.push(createManagedOperation({ + moduleId, + sourceRelativePath: path.join(normalizedSourcePath, namespace, relativeFile), + destinationPath: path.join(destinationDir, flattenedFileName), + strategy: 'flatten-copy', + })); + } + } else if (entry.isFile()) { + operations.push(createManagedOperation({ + moduleId, + sourceRelativePath: path.join(normalizedSourcePath, entry.name), + destinationPath: path.join(destinationDir, entry.name), + strategy: 'flatten-copy', + })); + } + } + + return operations; +} + function createInstallTargetAdapter(config) { const adapter = { id: config.id, @@ -68,15 +245,43 @@ function createInstallTargetAdapter(config) { }, createScaffoldOperation(moduleId, sourceRelativePath, input = {}) { const normalizedSourcePath = normalizeRelativePath(sourceRelativePath); - return { - kind: 'copy-path', + return createManagedOperation({ moduleId, sourceRelativePath: normalizedSourcePath, destinationPath: adapter.resolveDestinationPath(normalizedSourcePath, input), strategy: adapter.determineStrategy(normalizedSourcePath), - ownership: 'managed', - scaffoldOnly: true, - }; + }); + }, + planOperations(input = {}) { + if (typeof config.planOperations === 'function') { + return config.planOperations(input, adapter); + } + + if (Array.isArray(input.modules)) { + return input.modules.flatMap(module => { + const paths = Array.isArray(module.paths) ? module.paths : []; + return paths.map(sourceRelativePath => adapter.createScaffoldOperation( + module.id, + sourceRelativePath, + input + )); + }); + } + + const module = input.module || {}; + const paths = Array.isArray(module.paths) ? module.paths : []; + return paths.map(sourceRelativePath => adapter.createScaffoldOperation( + module.id, + sourceRelativePath, + input + )); + }, + validate(input = {}) { + if (typeof config.validate === 'function') { + return config.validate(input, adapter); + } + + return defaultValidateAdapterInput(config, input); }, }; @@ -84,6 +289,19 @@ function createInstallTargetAdapter(config) { } module.exports = { + buildValidationIssue, + createFlatRuleOperations, createInstallTargetAdapter, + createManagedOperation, + createManagedScaffoldOperation: (moduleId, sourceRelativePath, destinationPath, strategy) => ( + createManagedOperation({ + moduleId, + sourceRelativePath, + destinationPath, + strategy, + }) + ), + createNamespacedFlatRuleOperations, + createRemappedOperation, normalizeRelativePath, }; diff --git a/scripts/lib/install-targets/registry.js b/scripts/lib/install-targets/registry.js index 96260bc2..9cc34e2b 100644 --- a/scripts/lib/install-targets/registry.js +++ b/scripts/lib/install-targets/registry.js @@ -34,15 +34,16 @@ function planInstallTargetScaffold(options = {}) { projectRoot: options.projectRoot || options.repoRoot, homeDir: options.homeDir, }; + const validationIssues = adapter.validate(planningInput); + const blockingIssues = validationIssues.filter(issue => issue.severity === 'error'); + if (blockingIssues.length > 0) { + throw new Error(blockingIssues.map(issue => issue.message).join('; ')); + } const targetRoot = adapter.resolveRoot(planningInput); const installStatePath = adapter.getInstallStatePath(planningInput); - const operations = modules.flatMap(module => { - const paths = Array.isArray(module.paths) ? module.paths : []; - return paths.map(sourceRelativePath => adapter.createScaffoldOperation( - module.id, - sourceRelativePath, - planningInput - )); + const operations = adapter.planOperations({ + ...planningInput, + modules, }); return { @@ -53,6 +54,7 @@ function planInstallTargetScaffold(options = {}) { }, targetRoot, installStatePath, + validationIssues, operations, }; } diff --git a/tests/lib/install-lifecycle.test.js b/tests/lib/install-lifecycle.test.js index ae7754db..e1c0368e 100644 --- a/tests/lib/install-lifecycle.test.js +++ b/tests/lib/install-lifecycle.test.js @@ -10,6 +10,8 @@ const path = require('path'); const { buildDoctorReport, discoverInstalledStates, + repairInstalledStates, + uninstallInstalledStates, } = require('../../scripts/lib/install-lifecycle'); const { createInstallState, @@ -350,6 +352,385 @@ function runTests() { } })) 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++; + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); } diff --git a/tests/lib/install-state.test.js b/tests/lib/install-state.test.js index e3a28370..bdcdc542 100644 --- a/tests/lib/install-state.test.js +++ b/tests/lib/install-state.test.js @@ -117,6 +117,56 @@ function runTests() { } })) passed++; else failed++; + if (test('deep-clones nested operation metadata for lifecycle-managed operations', () => { + const operation = { + kind: 'merge-json', + moduleId: 'platform-configs', + sourceRelativePath: '.cursor/hooks.json', + destinationPath: '/repo/.cursor/hooks.json', + strategy: 'merge-json', + ownership: 'managed', + scaffoldOnly: false, + mergePayload: { + nested: { + enabled: true, + }, + }, + previousValue: { + nested: { + enabled: false, + }, + }, + }; + + const state = createInstallState({ + adapter: { id: 'cursor-project' }, + targetRoot: '/repo/.cursor', + installStatePath: '/repo/.cursor/ecc-install-state.json', + request: { + profile: null, + modules: ['platform-configs'], + legacyLanguages: [], + legacyMode: false, + }, + resolution: { + selectedModules: ['platform-configs'], + skippedModules: [], + }, + operations: [operation], + source: { + repoVersion: '1.9.0', + repoCommit: 'abc123', + manifestVersion: 1, + }, + }); + + operation.mergePayload.nested.enabled = false; + operation.previousValue.nested.enabled = true; + + assert.strictEqual(state.operations[0].mergePayload.nested.enabled, true); + assert.strictEqual(state.operations[0].previousValue.nested.enabled, false); + })) passed++; else failed++; + if (test('rejects invalid install-state payloads on read', () => { const testDir = createTestDir(); const statePath = path.join(testDir, 'ecc-install-state.json'); @@ -132,6 +182,48 @@ function runTests() { } })) passed++; else failed++; + if (test('rejects unexpected properties and missing required request fields', () => { + const testDir = createTestDir(); + const statePath = path.join(testDir, 'ecc-install-state.json'); + + try { + fs.writeFileSync(statePath, JSON.stringify({ + schemaVersion: 'ecc.install.v1', + installedAt: '2026-03-13T00:00:00Z', + unexpected: true, + target: { + id: 'cursor-project', + root: '/repo/.cursor', + installStatePath: '/repo/.cursor/ecc-install-state.json', + }, + request: { + modules: [], + includeComponents: [], + excludeComponents: [], + legacyLanguages: [], + legacyMode: false, + }, + resolution: { + selectedModules: [], + skippedModules: [], + }, + source: { + repoVersion: '1.9.0', + repoCommit: 'abc123', + manifestVersion: 1, + }, + operations: [], + }, null, 2)); + + assert.throws( + () => readInstallState(statePath), + /Invalid install-state/ + ); + } finally { + cleanupTestDir(testDir); + } + })) passed++; else failed++; + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); } diff --git a/tests/lib/install-targets.test.js b/tests/lib/install-targets.test.js index 2d67b0e6..1357fc3e 100644 --- a/tests/lib/install-targets.test.js +++ b/tests/lib/install-targets.test.js @@ -60,7 +60,7 @@ function runTests() { })) passed++; else failed++; if (test('plans scaffold operations and flattens native target roots', () => { - const repoRoot = '/repo/ecc'; + const repoRoot = path.join(__dirname, '..', '..'); const projectRoot = '/workspace/app'; const modules = [ { @@ -85,15 +85,124 @@ function runTests() { assert.strictEqual(plan.installStatePath, path.join(projectRoot, '.cursor', 'ecc-install-state.json')); const flattened = plan.operations.find(operation => operation.sourceRelativePath === '.cursor'); - const preserved = plan.operations.find(operation => operation.sourceRelativePath === 'rules'); + const preserved = plan.operations.find(operation => ( + operation.sourceRelativePath === path.join('rules', 'common', 'coding-style.md') + )); assert.ok(flattened, 'Should include .cursor scaffold operation'); assert.strictEqual(flattened.strategy, 'sync-root-children'); assert.strictEqual(flattened.destinationPath, path.join(projectRoot, '.cursor')); - assert.ok(preserved, 'Should include rules scaffold operation'); - assert.strictEqual(preserved.strategy, 'preserve-relative-path'); - assert.strictEqual(preserved.destinationPath, path.join(projectRoot, '.cursor', 'rules')); + assert.ok(preserved, 'Should include flattened rules scaffold operations'); + assert.strictEqual(preserved.strategy, 'flatten-copy'); + assert.strictEqual( + preserved.destinationPath, + path.join(projectRoot, '.cursor', 'rules', 'common-coding-style.md') + ); + })) passed++; else failed++; + + if (test('plans cursor rules with flat namespaced filenames to avoid rule collisions', () => { + const repoRoot = path.join(__dirname, '..', '..'); + const projectRoot = '/workspace/app'; + + const plan = planInstallTargetScaffold({ + target: 'cursor', + repoRoot, + projectRoot, + modules: [ + { + id: 'rules-core', + paths: ['rules'], + }, + ], + }); + + assert.ok( + plan.operations.some(operation => ( + operation.sourceRelativePath === path.join('rules', 'common', 'coding-style.md') + && operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-coding-style.md') + )), + 'Should flatten common rules into namespaced files' + ); + assert.ok( + plan.operations.some(operation => ( + operation.sourceRelativePath === path.join('rules', 'typescript', 'testing.md') + && operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'typescript-testing.md') + )), + 'Should flatten language rules into namespaced files' + ); + assert.ok( + !plan.operations.some(operation => ( + operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common', 'coding-style.md') + )), + 'Should not preserve nested rule directories for cursor installs' + ); + })) passed++; else failed++; + + if (test('plans antigravity remaps for workflows, skills, and flat rules', () => { + const repoRoot = path.join(__dirname, '..', '..'); + const projectRoot = '/workspace/app'; + + const plan = planInstallTargetScaffold({ + target: 'antigravity', + repoRoot, + projectRoot, + modules: [ + { + id: 'commands-core', + paths: ['commands'], + }, + { + id: 'agents-core', + paths: ['agents'], + }, + { + id: 'rules-core', + paths: ['rules'], + }, + ], + }); + + assert.ok( + plan.operations.some(operation => ( + operation.sourceRelativePath === 'commands' + && operation.destinationPath === path.join(projectRoot, '.agent', 'workflows') + )), + 'Should remap commands into workflows' + ); + assert.ok( + plan.operations.some(operation => ( + operation.sourceRelativePath === 'agents' + && operation.destinationPath === path.join(projectRoot, '.agent', 'skills') + )), + 'Should remap agents into skills' + ); + assert.ok( + plan.operations.some(operation => ( + operation.sourceRelativePath === path.join('rules', 'common', 'coding-style.md') + && operation.destinationPath === path.join(projectRoot, '.agent', 'rules', 'common-coding-style.md') + )), + 'Should flatten common rules for antigravity' + ); + })) passed++; else failed++; + + if (test('exposes validate and planOperations on adapters', () => { + const claudeAdapter = getInstallTargetAdapter('claude'); + const cursorAdapter = getInstallTargetAdapter('cursor'); + + assert.strictEqual(typeof claudeAdapter.planOperations, 'function'); + assert.strictEqual(typeof claudeAdapter.validate, 'function'); + assert.deepStrictEqual( + claudeAdapter.validate({ homeDir: '/Users/example', repoRoot: '/repo/ecc' }), + [] + ); + + assert.strictEqual(typeof cursorAdapter.planOperations, 'function'); + assert.strictEqual(typeof cursorAdapter.validate, 'function'); + assert.deepStrictEqual( + cursorAdapter.validate({ projectRoot: '/workspace/app', repoRoot: '/repo/ecc' }), + [] + ); })) passed++; else failed++; if (test('throws on unknown target adapter', () => { diff --git a/tests/scripts/repair.test.js b/tests/scripts/repair.test.js index 1ba0a1db..fa0d983b 100644 --- a/tests/scripts/repair.test.js +++ b/tests/scripts/repair.test.js @@ -12,6 +12,16 @@ const INSTALL_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'install-appl const DOCTOR_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'doctor.js'); const REPAIR_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'repair.js'); 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; +const { + createInstallState, + writeInstallState, +} = require('../../scripts/lib/install-state'); function createTempDir(prefix) { return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); @@ -21,6 +31,12 @@ function cleanup(dirPath) { fs.rmSync(dirPath, { recursive: true, force: true }); } +function writeState(filePath, options) { + const state = createInstallState(options); + writeInstallState(filePath, state); + return state; +} + function runNode(scriptPath, args = [], options = {}) { const env = { ...process.env, @@ -64,26 +80,25 @@ function runTests() { let passed = 0; let failed = 0; - if (test('repairs drifted managed files and refreshes install-state', () => { + if (test('repairs drifted files from a real install-apply state', () => { const homeDir = createTempDir('repair-home-'); const projectRoot = createTempDir('repair-project-'); try { - const installResult = runNode(INSTALL_SCRIPT, ['--target', 'cursor', '--modules', 'platform-configs'], { + const installResult = runNode(INSTALL_SCRIPT, ['--target', 'cursor', 'typescript'], { cwd: projectRoot, homeDir, }); assert.strictEqual(installResult.code, 0, installResult.stderr); - const cursorRoot = path.join(projectRoot, '.cursor'); - const managedPath = path.join(cursorRoot, 'hooks.json'); - const statePath = path.join(cursorRoot, 'ecc-install-state.json'); - const managedRealPath = fs.realpathSync(cursorRoot); - const expectedManagedPath = path.join(managedRealPath, 'hooks.json'); - const expectedContent = fs.readFileSync(path.join(REPO_ROOT, '.cursor', 'hooks.json'), 'utf8'); - const installedAtBefore = JSON.parse(fs.readFileSync(statePath, 'utf8')).installedAt; - - fs.writeFileSync(managedPath, '{"drifted":true}\n'); + const normalizedProjectRoot = fs.realpathSync(projectRoot); + const managedPath = path.join(normalizedProjectRoot, '.cursor', 'hooks', 'session-start.js'); + const statePath = path.join(normalizedProjectRoot, '.cursor', 'ecc-install-state.json'); + const expectedContent = fs.readFileSync( + path.join(REPO_ROOT, '.cursor', 'hooks', 'session-start.js'), + 'utf8' + ); + fs.writeFileSync(managedPath, '// drifted\n'); const doctorBefore = runNode(DOCTOR_SCRIPT, ['--target', 'cursor', '--json'], { cwd: projectRoot, @@ -100,8 +115,118 @@ function runTests() { const parsed = JSON.parse(repairResult.stdout); assert.strictEqual(parsed.results[0].status, 'repaired'); - assert.ok(parsed.results[0].repairedPaths.includes(expectedManagedPath)); + assert.ok(parsed.results[0].repairedPaths.includes(managedPath)); assert.strictEqual(fs.readFileSync(managedPath, 'utf8'), expectedContent); + assert.ok(fs.existsSync(statePath)); + } finally { + cleanup(homeDir); + cleanup(projectRoot); + } + })) passed++; else failed++; + + if (test('repairs drifted non-copy managed operations and refreshes install-state', () => { + const homeDir = createTempDir('repair-home-'); + const projectRoot = createTempDir('repair-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 jsonPath = path.join(normalizedTargetRoot, 'hooks.json'); + const renderedPath = path.join(normalizedTargetRoot, 'generated.md'); + const removedPath = path.join(normalizedTargetRoot, 'legacy-note.txt'); + fs.writeFileSync(jsonPath, JSON.stringify({ existing: true, managed: false }, null, 2)); + fs.writeFileSync(renderedPath, '# drifted\n'); + fs.writeFileSync(removedPath, 'stale\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: 'merge-json', + moduleId: 'platform-configs', + sourceRelativePath: '.cursor/hooks.json', + destinationPath: jsonPath, + strategy: 'merge-json', + ownership: 'managed', + scaffoldOnly: false, + mergePayload: { + managed: true, + nested: { + enabled: true, + }, + }, + }, + { + kind: 'render-template', + moduleId: 'platform-configs', + sourceRelativePath: '.cursor/generated.md.template', + destinationPath: renderedPath, + strategy: 'render-template', + ownership: 'managed', + scaffoldOnly: false, + renderedContent: '# generated\n', + }, + { + kind: 'remove', + moduleId: 'platform-configs', + sourceRelativePath: '.cursor/legacy-note.txt', + destinationPath: removedPath, + strategy: 'remove', + ownership: 'managed', + scaffoldOnly: false, + }, + ], + source: { + repoVersion: CURRENT_PACKAGE_VERSION, + repoCommit: 'abc123', + manifestVersion: CURRENT_MANIFEST_VERSION, + }, + }); + + const doctorBefore = runNode(DOCTOR_SCRIPT, ['--target', 'cursor', '--json'], { + cwd: projectRoot, + homeDir, + }); + assert.strictEqual(doctorBefore.code, 1); + assert.ok(JSON.parse(doctorBefore.stdout).results[0].issues.some(issue => issue.code === 'drifted-managed-files')); + + const installedAtBefore = JSON.parse(fs.readFileSync(statePath, 'utf8')).installedAt; + const repairResult = runNode(REPAIR_SCRIPT, ['--target', 'cursor', '--json'], { + cwd: projectRoot, + homeDir, + }); + assert.strictEqual(repairResult.code, 0, repairResult.stderr); + + const parsed = JSON.parse(repairResult.stdout); + assert.strictEqual(parsed.results[0].status, 'repaired'); + assert.ok(parsed.results[0].repairedPaths.includes(jsonPath)); + assert.ok(parsed.results[0].repairedPaths.includes(renderedPath)); + assert.ok(parsed.results[0].repairedPaths.includes(removedPath)); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(jsonPath, 'utf8')), { + existing: true, + managed: true, + nested: { + enabled: true, + }, + }); + assert.strictEqual(fs.readFileSync(renderedPath, 'utf8'), '# generated\n'); + assert.ok(!fs.existsSync(removedPath)); const repairedState = JSON.parse(fs.readFileSync(statePath, 'utf8')); assert.strictEqual(repairedState.installedAt, installedAtBefore); @@ -119,23 +244,52 @@ function runTests() { } })) passed++; else failed++; - if (test('supports dry-run without mutating drifted files', () => { + if (test('supports dry-run without mutating drifted non-copy operations', () => { const homeDir = createTempDir('repair-home-'); const projectRoot = createTempDir('repair-project-'); try { - const installResult = runNode(INSTALL_SCRIPT, ['--target', 'cursor', '--modules', 'platform-configs'], { - cwd: projectRoot, - homeDir, - }); - assert.strictEqual(installResult.code, 0, installResult.stderr); + 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, '# drifted\n'); - const cursorRoot = path.join(projectRoot, '.cursor'); - const managedPath = path.join(cursorRoot, 'hooks.json'); - const managedRealPath = fs.realpathSync(cursorRoot); - const expectedManagedPath = path.join(managedRealPath, 'hooks.json'); - const driftedContent = '{"drifted":true}\n'; - fs.writeFileSync(managedPath, driftedContent); + 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 repairResult = runNode(REPAIR_SCRIPT, ['--target', 'cursor', '--dry-run', '--json'], { cwd: projectRoot, @@ -144,8 +298,8 @@ function runTests() { assert.strictEqual(repairResult.code, 0, repairResult.stderr); const parsed = JSON.parse(repairResult.stdout); assert.strictEqual(parsed.dryRun, true); - assert.ok(parsed.results[0].plannedRepairs.includes(expectedManagedPath)); - assert.strictEqual(fs.readFileSync(managedPath, 'utf8'), driftedContent); + assert.ok(parsed.results[0].plannedRepairs.includes(renderedPath)); + assert.strictEqual(fs.readFileSync(renderedPath, 'utf8'), '# drifted\n'); } finally { cleanup(homeDir); cleanup(projectRoot); diff --git a/tests/scripts/uninstall.test.js b/tests/scripts/uninstall.test.js index 601f9a7f..ec1cff74 100644 --- a/tests/scripts/uninstall.test.js +++ b/tests/scripts/uninstall.test.js @@ -9,7 +9,18 @@ const path = require('path'); const { execFileSync } = require('child_process'); const INSTALL_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'install-apply.js'); -const UNINSTALL_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'uninstall.js'); +const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'uninstall.js'); +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; +const { + createInstallState, + writeInstallState, +} = require('../../scripts/lib/install-state'); function createTempDir(prefix) { return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); @@ -19,14 +30,20 @@ function cleanup(dirPath) { fs.rmSync(dirPath, { recursive: true, force: true }); } -function runNode(scriptPath, args = [], options = {}) { +function writeState(filePath, options) { + const state = createInstallState(options); + writeInstallState(filePath, state); + return state; +} + +function run(args = [], options = {}) { const env = { ...process.env, HOME: options.homeDir || process.env.HOME, }; try { - const stdout = execFileSync('node', [scriptPath, ...args], { + const stdout = execFileSync('node', [SCRIPT, ...args], { cwd: options.cwd, env, encoding: 'utf8', @@ -62,24 +79,30 @@ function runTests() { let passed = 0; let failed = 0; - if (test('removes managed files and keeps unrelated files', () => { + if (test('uninstalls files from a real install-apply state and preserves unrelated files', () => { const homeDir = createTempDir('uninstall-home-'); const projectRoot = createTempDir('uninstall-project-'); try { - const installResult = runNode(INSTALL_SCRIPT, ['--target', 'cursor', '--modules', 'platform-configs'], { + const installStdout = execFileSync('node', [INSTALL_SCRIPT, '--target', 'cursor', 'typescript'], { cwd: projectRoot, - homeDir, + env: { + ...process.env, + HOME: homeDir, + }, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 10000, }); - assert.strictEqual(installResult.code, 0, installResult.stderr); + assert.ok(installStdout.includes('Done. Install-state written')); - const cursorRoot = path.join(projectRoot, '.cursor'); - const managedPath = path.join(cursorRoot, 'hooks.json'); - const statePath = path.join(cursorRoot, 'ecc-install-state.json'); - const unrelatedPath = path.join(cursorRoot, 'custom-user-note.txt'); + const normalizedProjectRoot = fs.realpathSync(projectRoot); + const managedPath = path.join(normalizedProjectRoot, '.cursor', 'hooks.json'); + const statePath = path.join(normalizedProjectRoot, '.cursor', 'ecc-install-state.json'); + const unrelatedPath = path.join(normalizedProjectRoot, '.cursor', 'custom-user-note.txt'); fs.writeFileSync(unrelatedPath, 'leave me alone'); - const uninstallResult = runNode(UNINSTALL_SCRIPT, ['--target', 'cursor'], { + const uninstallResult = run(['--target', 'cursor'], { cwd: projectRoot, homeDir, }); @@ -94,22 +117,152 @@ function runTests() { } })) passed++; else failed++; - if (test('supports dry-run without removing files', () => { + if (test('reverses non-copy operations and keeps unrelated files', () => { const homeDir = createTempDir('uninstall-home-'); const projectRoot = createTempDir('uninstall-project-'); try { - const installResult = runNode(INSTALL_SCRIPT, ['--target', 'cursor', '--modules', 'platform-configs'], { + 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 copiedPath = path.join(normalizedTargetRoot, 'managed-rule.md'); + const mergedPath = path.join(normalizedTargetRoot, 'hooks.json'); + const removedPath = path.join(normalizedTargetRoot, 'legacy-note.txt'); + const unrelatedPath = path.join(normalizedTargetRoot, 'custom-user-note.txt'); + fs.writeFileSync(copiedPath, 'managed\n'); + fs.writeFileSync(mergedPath, JSON.stringify({ + existing: true, + managed: true, + }, null, 2)); + fs.writeFileSync(unrelatedPath, 'leave me alone'); + + 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: 'copy-file', + moduleId: 'platform-configs', + sourceRelativePath: 'rules/common/coding-style.md', + destinationPath: copiedPath, + strategy: 'preserve-relative-path', + ownership: 'managed', + scaffoldOnly: false, + }, + { + kind: 'merge-json', + moduleId: 'platform-configs', + sourceRelativePath: '.cursor/hooks.json', + destinationPath: mergedPath, + strategy: 'merge-json', + ownership: 'managed', + scaffoldOnly: false, + mergePayload: { + managed: true, + }, + previousContent: JSON.stringify({ + existing: true, + }, null, 2), + }, + { + kind: 'remove', + moduleId: 'platform-configs', + sourceRelativePath: '.cursor/legacy-note.txt', + destinationPath: removedPath, + strategy: 'remove', + ownership: 'managed', + scaffoldOnly: false, + previousContent: 'restore me\n', + }, + ], + source: { + repoVersion: CURRENT_PACKAGE_VERSION, + repoCommit: 'abc123', + manifestVersion: CURRENT_MANIFEST_VERSION, + }, + }); + + const uninstallResult = run(['--target', 'cursor'], { cwd: projectRoot, homeDir, }); - assert.strictEqual(installResult.code, 0, installResult.stderr); + assert.strictEqual(uninstallResult.code, 0, uninstallResult.stderr); + assert.ok(uninstallResult.stdout.includes('Uninstall summary')); + assert.ok(!fs.existsSync(copiedPath)); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(mergedPath, 'utf8')), { + existing: true, + }); + assert.strictEqual(fs.readFileSync(removedPath, 'utf8'), 'restore me\n'); + assert.ok(!fs.existsSync(statePath)); + assert.ok(fs.existsSync(unrelatedPath)); + } finally { + cleanup(homeDir); + cleanup(projectRoot); + } + })) passed++; else failed++; - const cursorRoot = path.join(projectRoot, '.cursor'); - const managedPath = path.join(cursorRoot, 'hooks.json'); - const statePath = path.join(cursorRoot, 'ecc-install-state.json'); + if (test('supports dry-run without mutating managed files', () => { + const homeDir = createTempDir('uninstall-home-'); + const projectRoot = createTempDir('uninstall-project-'); - const uninstallResult = runNode(UNINSTALL_SCRIPT, ['--target', 'cursor', '--dry-run', '--json'], { + 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', '--json'], { cwd: projectRoot, homeDir, }); @@ -117,8 +270,8 @@ function runTests() { const parsed = JSON.parse(uninstallResult.stdout); assert.strictEqual(parsed.dryRun, true); - assert.ok(parsed.results[0].plannedRemovals.length > 0); - assert.ok(fs.existsSync(managedPath)); + assert.ok(parsed.results[0].plannedRemovals.includes(renderedPath)); + assert.ok(fs.existsSync(renderedPath)); assert.ok(fs.existsSync(statePath)); } finally { cleanup(homeDir);