mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-10 11:23:32 +08:00
feat: strengthen install lifecycle and target adapters (#512)
* fix: strengthen install lifecycle adapters * fix: restore template content on uninstall
This commit is contained in:
@@ -4,7 +4,6 @@ const path = require('path');
|
|||||||
const { resolveInstallPlan, loadInstallManifests } = require('./install-manifests');
|
const { resolveInstallPlan, loadInstallManifests } = require('./install-manifests');
|
||||||
const { readInstallState, writeInstallState } = require('./install-state');
|
const { readInstallState, writeInstallState } = require('./install-state');
|
||||||
const {
|
const {
|
||||||
applyInstallPlan,
|
|
||||||
createLegacyInstallPlan,
|
createLegacyInstallPlan,
|
||||||
createManifestInstallPlan,
|
createManifestInstallPlan,
|
||||||
} = require('./install-executor');
|
} = 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) {
|
function inspectManagedOperation(repoRoot, operation) {
|
||||||
const destinationPath = operation.destinationPath;
|
const destinationPath = operation.destinationPath;
|
||||||
if (!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)) {
|
if (!fs.existsSync(destinationPath)) {
|
||||||
return {
|
return {
|
||||||
status: 'missing',
|
status: 'missing',
|
||||||
@@ -96,38 +525,97 @@ function inspectManagedOperation(repoRoot, operation) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (operation.kind !== 'copy-file') {
|
if (operation.kind === 'copy-file') {
|
||||||
return {
|
const sourcePath = resolveOperationSourcePath(repoRoot, operation);
|
||||||
status: 'unverified',
|
if (!sourcePath || !fs.existsSync(sourcePath)) {
|
||||||
operation,
|
return {
|
||||||
destinationPath,
|
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 {
|
return {
|
||||||
status: 'missing-source',
|
status: 'ok',
|
||||||
operation,
|
operation,
|
||||||
destinationPath,
|
destinationPath,
|
||||||
sourcePath,
|
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 {
|
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,
|
operation,
|
||||||
destinationPath,
|
destinationPath,
|
||||||
sourcePath,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: 'ok',
|
status: 'unverified',
|
||||||
operation,
|
operation,
|
||||||
destinationPath,
|
destinationPath,
|
||||||
sourcePath,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,25 +943,12 @@ function createRepairPlanFromRecord(record, context) {
|
|||||||
throw new Error('No install-state available for repair');
|
throw new Error('No install-state available for repair');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.request.legacyMode) {
|
if (state.request.legacyMode || shouldRepairFromRecordedOperations(state)) {
|
||||||
const operations = getManagedOperations(state).map(operation => ({
|
const operations = hydrateRecordedOperations(context.repoRoot, getManagedOperations(state));
|
||||||
...operation,
|
const statePreview = buildRecordedStatePreview(state, context, operations);
|
||||||
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(),
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mode: 'legacy',
|
mode: state.request.legacyMode ? 'legacy' : 'recorded',
|
||||||
target: record.adapter.target,
|
target: record.adapter.target,
|
||||||
adapter: record.adapter,
|
adapter: record.adapter,
|
||||||
targetRoot: state.target.root,
|
targetRoot: state.target.root,
|
||||||
@@ -571,11 +1046,10 @@ function repairInstalledStates(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (repairOperations.length > 0) {
|
if (repairOperations.length > 0) {
|
||||||
applyInstallPlan({
|
for (const operation of repairOperations) {
|
||||||
...desiredPlan,
|
executeRepairOperation(context.repoRoot, operation);
|
||||||
operations: repairOperations,
|
}
|
||||||
statePreview: desiredPlan.statePreview,
|
writeInstallState(desiredPlan.installStatePath, desiredPlan.statePreview);
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
writeInstallState(desiredPlan.installStatePath, desiredPlan.statePreview);
|
writeInstallState(desiredPlan.installStatePath, desiredPlan.statePreview);
|
||||||
}
|
}
|
||||||
@@ -684,23 +1158,12 @@ function uninstallInstalledStates(options = {}) {
|
|||||||
try {
|
try {
|
||||||
const removedPaths = [];
|
const removedPaths = [];
|
||||||
const cleanupTargets = [];
|
const cleanupTargets = [];
|
||||||
const filePaths = Array.from(new Set(
|
const operations = getManagedOperations(state);
|
||||||
getManagedOperations(state).map(operation => operation.destinationPath)
|
|
||||||
)).sort((left, right) => right.length - left.length);
|
|
||||||
|
|
||||||
for (const filePath of filePaths) {
|
for (const operation of operations) {
|
||||||
if (!fs.existsSync(filePath)) {
|
const outcome = executeUninstallOperation(operation);
|
||||||
continue;
|
removedPaths.push(...outcome.removedPaths);
|
||||||
}
|
cleanupTargets.push(...outcome.cleanupTargets);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fs.existsSync(state.target.installStatePath)) {
|
if (fs.existsSync(state.target.installStatePath)) {
|
||||||
|
|||||||
@@ -1,11 +1,28 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
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');
|
const SCHEMA_PATH = path.join(__dirname, '..', '..', 'schemas', 'install-state.schema.json');
|
||||||
|
|
||||||
let cachedValidator = null;
|
let cachedValidator = null;
|
||||||
|
|
||||||
|
function cloneJsonValue(value) {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
function readJson(filePath, label) {
|
function readJson(filePath, label) {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||||
@@ -19,12 +36,188 @@ function getValidator() {
|
|||||||
return cachedValidator;
|
return cachedValidator;
|
||||||
}
|
}
|
||||||
|
|
||||||
const schema = readJson(SCHEMA_PATH, 'install-state schema');
|
if (Ajv) {
|
||||||
const ajv = new Ajv({ allErrors: true });
|
const schema = readJson(SCHEMA_PATH, 'install-state schema');
|
||||||
cachedValidator = ajv.compile(schema);
|
const ajv = new Ajv({ allErrors: true });
|
||||||
|
cachedValidator = ajv.compile(schema);
|
||||||
|
return cachedValidator;
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedValidator = createFallbackValidator();
|
||||||
return cachedValidator;
|
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 = []) {
|
function formatValidationErrors(errors = []) {
|
||||||
return errors
|
return errors
|
||||||
.map(error => `${error.instancePath || '/'} ${error.message}`)
|
.map(error => `${error.instancePath || '/'} ${error.message}`)
|
||||||
@@ -87,7 +280,7 @@ function createInstallState(options) {
|
|||||||
manifestVersion: options.source.manifestVersion,
|
manifestVersion: options.source.manifestVersion,
|
||||||
},
|
},
|
||||||
operations: Array.isArray(options.operations)
|
operations: Array.isArray(options.operations)
|
||||||
? options.operations.map(operation => ({ ...operation }))
|
? options.operations.map(operation => cloneJsonValue(operation))
|
||||||
: [],
|
: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
const { createInstallTargetAdapter } = require('./helpers');
|
const path = require('path');
|
||||||
|
|
||||||
|
const {
|
||||||
|
createFlatRuleOperations,
|
||||||
|
createInstallTargetAdapter,
|
||||||
|
createManagedScaffoldOperation,
|
||||||
|
} = require('./helpers');
|
||||||
|
|
||||||
module.exports = createInstallTargetAdapter({
|
module.exports = createInstallTargetAdapter({
|
||||||
id: 'antigravity-project',
|
id: 'antigravity-project',
|
||||||
@@ -6,4 +12,58 @@ module.exports = createInstallTargetAdapter({
|
|||||||
kind: 'project',
|
kind: 'project',
|
||||||
rootSegments: ['.agent'],
|
rootSegments: ['.agent'],
|
||||||
installStatePathSegments: ['ecc-install-state.json'],
|
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)];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
const { createInstallTargetAdapter } = require('./helpers');
|
const path = require('path');
|
||||||
|
|
||||||
|
const {
|
||||||
|
createFlatRuleOperations,
|
||||||
|
createInstallTargetAdapter,
|
||||||
|
} = require('./helpers');
|
||||||
|
|
||||||
module.exports = createInstallTargetAdapter({
|
module.exports = createInstallTargetAdapter({
|
||||||
id: 'cursor-project',
|
id: 'cursor-project',
|
||||||
@@ -7,4 +12,36 @@ module.exports = createInstallTargetAdapter({
|
|||||||
rootSegments: ['.cursor'],
|
rootSegments: ['.cursor'],
|
||||||
installStatePathSegments: ['ecc-install-state.json'],
|
installStatePathSegments: ['ecc-install-state.json'],
|
||||||
nativeRootRelativePath: '.cursor',
|
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)];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
const fs = require('fs');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
@@ -24,6 +25,182 @@ function resolveBaseRoot(scope, input = {}) {
|
|||||||
throw new Error(`Unsupported install target scope: ${scope}`);
|
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) {
|
function createInstallTargetAdapter(config) {
|
||||||
const adapter = {
|
const adapter = {
|
||||||
id: config.id,
|
id: config.id,
|
||||||
@@ -68,15 +245,43 @@ function createInstallTargetAdapter(config) {
|
|||||||
},
|
},
|
||||||
createScaffoldOperation(moduleId, sourceRelativePath, input = {}) {
|
createScaffoldOperation(moduleId, sourceRelativePath, input = {}) {
|
||||||
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
|
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
|
||||||
return {
|
return createManagedOperation({
|
||||||
kind: 'copy-path',
|
|
||||||
moduleId,
|
moduleId,
|
||||||
sourceRelativePath: normalizedSourcePath,
|
sourceRelativePath: normalizedSourcePath,
|
||||||
destinationPath: adapter.resolveDestinationPath(normalizedSourcePath, input),
|
destinationPath: adapter.resolveDestinationPath(normalizedSourcePath, input),
|
||||||
strategy: adapter.determineStrategy(normalizedSourcePath),
|
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 = {
|
module.exports = {
|
||||||
|
buildValidationIssue,
|
||||||
|
createFlatRuleOperations,
|
||||||
createInstallTargetAdapter,
|
createInstallTargetAdapter,
|
||||||
|
createManagedOperation,
|
||||||
|
createManagedScaffoldOperation: (moduleId, sourceRelativePath, destinationPath, strategy) => (
|
||||||
|
createManagedOperation({
|
||||||
|
moduleId,
|
||||||
|
sourceRelativePath,
|
||||||
|
destinationPath,
|
||||||
|
strategy,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
createNamespacedFlatRuleOperations,
|
||||||
|
createRemappedOperation,
|
||||||
normalizeRelativePath,
|
normalizeRelativePath,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,15 +34,16 @@ function planInstallTargetScaffold(options = {}) {
|
|||||||
projectRoot: options.projectRoot || options.repoRoot,
|
projectRoot: options.projectRoot || options.repoRoot,
|
||||||
homeDir: options.homeDir,
|
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 targetRoot = adapter.resolveRoot(planningInput);
|
||||||
const installStatePath = adapter.getInstallStatePath(planningInput);
|
const installStatePath = adapter.getInstallStatePath(planningInput);
|
||||||
const operations = modules.flatMap(module => {
|
const operations = adapter.planOperations({
|
||||||
const paths = Array.isArray(module.paths) ? module.paths : [];
|
...planningInput,
|
||||||
return paths.map(sourceRelativePath => adapter.createScaffoldOperation(
|
modules,
|
||||||
module.id,
|
|
||||||
sourceRelativePath,
|
|
||||||
planningInput
|
|
||||||
));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -53,6 +54,7 @@ function planInstallTargetScaffold(options = {}) {
|
|||||||
},
|
},
|
||||||
targetRoot,
|
targetRoot,
|
||||||
installStatePath,
|
installStatePath,
|
||||||
|
validationIssues,
|
||||||
operations,
|
operations,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ const path = require('path');
|
|||||||
const {
|
const {
|
||||||
buildDoctorReport,
|
buildDoctorReport,
|
||||||
discoverInstalledStates,
|
discoverInstalledStates,
|
||||||
|
repairInstalledStates,
|
||||||
|
uninstallInstalledStates,
|
||||||
} = require('../../scripts/lib/install-lifecycle');
|
} = require('../../scripts/lib/install-lifecycle');
|
||||||
const {
|
const {
|
||||||
createInstallState,
|
createInstallState,
|
||||||
@@ -350,6 +352,385 @@ function runTests() {
|
|||||||
}
|
}
|
||||||
})) passed++; else failed++;
|
})) 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}`);
|
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||||
process.exit(failed > 0 ? 1 : 0);
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,56 @@ function runTests() {
|
|||||||
}
|
}
|
||||||
})) passed++; else failed++;
|
})) 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', () => {
|
if (test('rejects invalid install-state payloads on read', () => {
|
||||||
const testDir = createTestDir();
|
const testDir = createTestDir();
|
||||||
const statePath = path.join(testDir, 'ecc-install-state.json');
|
const statePath = path.join(testDir, 'ecc-install-state.json');
|
||||||
@@ -132,6 +182,48 @@ function runTests() {
|
|||||||
}
|
}
|
||||||
})) passed++; else failed++;
|
})) 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}`);
|
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||||
process.exit(failed > 0 ? 1 : 0);
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ function runTests() {
|
|||||||
})) passed++; else failed++;
|
})) passed++; else failed++;
|
||||||
|
|
||||||
if (test('plans scaffold operations and flattens native target roots', () => {
|
if (test('plans scaffold operations and flattens native target roots', () => {
|
||||||
const repoRoot = '/repo/ecc';
|
const repoRoot = path.join(__dirname, '..', '..');
|
||||||
const projectRoot = '/workspace/app';
|
const projectRoot = '/workspace/app';
|
||||||
const modules = [
|
const modules = [
|
||||||
{
|
{
|
||||||
@@ -85,15 +85,124 @@ function runTests() {
|
|||||||
assert.strictEqual(plan.installStatePath, path.join(projectRoot, '.cursor', 'ecc-install-state.json'));
|
assert.strictEqual(plan.installStatePath, path.join(projectRoot, '.cursor', 'ecc-install-state.json'));
|
||||||
|
|
||||||
const flattened = plan.operations.find(operation => operation.sourceRelativePath === '.cursor');
|
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.ok(flattened, 'Should include .cursor scaffold operation');
|
||||||
assert.strictEqual(flattened.strategy, 'sync-root-children');
|
assert.strictEqual(flattened.strategy, 'sync-root-children');
|
||||||
assert.strictEqual(flattened.destinationPath, path.join(projectRoot, '.cursor'));
|
assert.strictEqual(flattened.destinationPath, path.join(projectRoot, '.cursor'));
|
||||||
|
|
||||||
assert.ok(preserved, 'Should include rules scaffold operation');
|
assert.ok(preserved, 'Should include flattened rules scaffold operations');
|
||||||
assert.strictEqual(preserved.strategy, 'preserve-relative-path');
|
assert.strictEqual(preserved.strategy, 'flatten-copy');
|
||||||
assert.strictEqual(preserved.destinationPath, path.join(projectRoot, '.cursor', 'rules'));
|
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++;
|
})) passed++; else failed++;
|
||||||
|
|
||||||
if (test('throws on unknown target adapter', () => {
|
if (test('throws on unknown target adapter', () => {
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ const INSTALL_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'install-appl
|
|||||||
const DOCTOR_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'doctor.js');
|
const DOCTOR_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'doctor.js');
|
||||||
const REPAIR_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'repair.js');
|
const REPAIR_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'repair.js');
|
||||||
const REPO_ROOT = path.join(__dirname, '..', '..');
|
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) {
|
function createTempDir(prefix) {
|
||||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||||
@@ -21,6 +31,12 @@ function cleanup(dirPath) {
|
|||||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
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 = {}) {
|
function runNode(scriptPath, args = [], options = {}) {
|
||||||
const env = {
|
const env = {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -64,26 +80,25 @@ function runTests() {
|
|||||||
let passed = 0;
|
let passed = 0;
|
||||||
let failed = 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 homeDir = createTempDir('repair-home-');
|
||||||
const projectRoot = createTempDir('repair-project-');
|
const projectRoot = createTempDir('repair-project-');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const installResult = runNode(INSTALL_SCRIPT, ['--target', 'cursor', '--modules', 'platform-configs'], {
|
const installResult = runNode(INSTALL_SCRIPT, ['--target', 'cursor', 'typescript'], {
|
||||||
cwd: projectRoot,
|
cwd: projectRoot,
|
||||||
homeDir,
|
homeDir,
|
||||||
});
|
});
|
||||||
assert.strictEqual(installResult.code, 0, installResult.stderr);
|
assert.strictEqual(installResult.code, 0, installResult.stderr);
|
||||||
|
|
||||||
const cursorRoot = path.join(projectRoot, '.cursor');
|
const normalizedProjectRoot = fs.realpathSync(projectRoot);
|
||||||
const managedPath = path.join(cursorRoot, 'hooks.json');
|
const managedPath = path.join(normalizedProjectRoot, '.cursor', 'hooks', 'session-start.js');
|
||||||
const statePath = path.join(cursorRoot, 'ecc-install-state.json');
|
const statePath = path.join(normalizedProjectRoot, '.cursor', 'ecc-install-state.json');
|
||||||
const managedRealPath = fs.realpathSync(cursorRoot);
|
const expectedContent = fs.readFileSync(
|
||||||
const expectedManagedPath = path.join(managedRealPath, 'hooks.json');
|
path.join(REPO_ROOT, '.cursor', 'hooks', 'session-start.js'),
|
||||||
const expectedContent = fs.readFileSync(path.join(REPO_ROOT, '.cursor', 'hooks.json'), 'utf8');
|
'utf8'
|
||||||
const installedAtBefore = JSON.parse(fs.readFileSync(statePath, 'utf8')).installedAt;
|
);
|
||||||
|
fs.writeFileSync(managedPath, '// drifted\n');
|
||||||
fs.writeFileSync(managedPath, '{"drifted":true}\n');
|
|
||||||
|
|
||||||
const doctorBefore = runNode(DOCTOR_SCRIPT, ['--target', 'cursor', '--json'], {
|
const doctorBefore = runNode(DOCTOR_SCRIPT, ['--target', 'cursor', '--json'], {
|
||||||
cwd: projectRoot,
|
cwd: projectRoot,
|
||||||
@@ -100,8 +115,118 @@ function runTests() {
|
|||||||
|
|
||||||
const parsed = JSON.parse(repairResult.stdout);
|
const parsed = JSON.parse(repairResult.stdout);
|
||||||
assert.strictEqual(parsed.results[0].status, 'repaired');
|
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.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'));
|
const repairedState = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
||||||
assert.strictEqual(repairedState.installedAt, installedAtBefore);
|
assert.strictEqual(repairedState.installedAt, installedAtBefore);
|
||||||
@@ -119,23 +244,52 @@ function runTests() {
|
|||||||
}
|
}
|
||||||
})) passed++; else failed++;
|
})) 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 homeDir = createTempDir('repair-home-');
|
||||||
const projectRoot = createTempDir('repair-project-');
|
const projectRoot = createTempDir('repair-project-');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const installResult = runNode(INSTALL_SCRIPT, ['--target', 'cursor', '--modules', 'platform-configs'], {
|
const targetRoot = path.join(projectRoot, '.cursor');
|
||||||
cwd: projectRoot,
|
fs.mkdirSync(targetRoot, { recursive: true });
|
||||||
homeDir,
|
const normalizedTargetRoot = fs.realpathSync(targetRoot);
|
||||||
});
|
const statePath = path.join(normalizedTargetRoot, 'ecc-install-state.json');
|
||||||
assert.strictEqual(installResult.code, 0, installResult.stderr);
|
const renderedPath = path.join(normalizedTargetRoot, 'generated.md');
|
||||||
|
fs.writeFileSync(renderedPath, '# drifted\n');
|
||||||
|
|
||||||
const cursorRoot = path.join(projectRoot, '.cursor');
|
writeState(statePath, {
|
||||||
const managedPath = path.join(cursorRoot, 'hooks.json');
|
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
|
||||||
const managedRealPath = fs.realpathSync(cursorRoot);
|
targetRoot: normalizedTargetRoot,
|
||||||
const expectedManagedPath = path.join(managedRealPath, 'hooks.json');
|
installStatePath: statePath,
|
||||||
const driftedContent = '{"drifted":true}\n';
|
request: {
|
||||||
fs.writeFileSync(managedPath, driftedContent);
|
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'], {
|
const repairResult = runNode(REPAIR_SCRIPT, ['--target', 'cursor', '--dry-run', '--json'], {
|
||||||
cwd: projectRoot,
|
cwd: projectRoot,
|
||||||
@@ -144,8 +298,8 @@ function runTests() {
|
|||||||
assert.strictEqual(repairResult.code, 0, repairResult.stderr);
|
assert.strictEqual(repairResult.code, 0, repairResult.stderr);
|
||||||
const parsed = JSON.parse(repairResult.stdout);
|
const parsed = JSON.parse(repairResult.stdout);
|
||||||
assert.strictEqual(parsed.dryRun, true);
|
assert.strictEqual(parsed.dryRun, true);
|
||||||
assert.ok(parsed.results[0].plannedRepairs.includes(expectedManagedPath));
|
assert.ok(parsed.results[0].plannedRepairs.includes(renderedPath));
|
||||||
assert.strictEqual(fs.readFileSync(managedPath, 'utf8'), driftedContent);
|
assert.strictEqual(fs.readFileSync(renderedPath, 'utf8'), '# drifted\n');
|
||||||
} finally {
|
} finally {
|
||||||
cleanup(homeDir);
|
cleanup(homeDir);
|
||||||
cleanup(projectRoot);
|
cleanup(projectRoot);
|
||||||
|
|||||||
@@ -9,7 +9,18 @@ const path = require('path');
|
|||||||
const { execFileSync } = require('child_process');
|
const { execFileSync } = require('child_process');
|
||||||
|
|
||||||
const INSTALL_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'install-apply.js');
|
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) {
|
function createTempDir(prefix) {
|
||||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||||
@@ -19,14 +30,20 @@ function cleanup(dirPath) {
|
|||||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
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 = {
|
const env = {
|
||||||
...process.env,
|
...process.env,
|
||||||
HOME: options.homeDir || process.env.HOME,
|
HOME: options.homeDir || process.env.HOME,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stdout = execFileSync('node', [scriptPath, ...args], {
|
const stdout = execFileSync('node', [SCRIPT, ...args], {
|
||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
env,
|
env,
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
@@ -62,24 +79,30 @@ function runTests() {
|
|||||||
let passed = 0;
|
let passed = 0;
|
||||||
let failed = 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 homeDir = createTempDir('uninstall-home-');
|
||||||
const projectRoot = createTempDir('uninstall-project-');
|
const projectRoot = createTempDir('uninstall-project-');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const installResult = runNode(INSTALL_SCRIPT, ['--target', 'cursor', '--modules', 'platform-configs'], {
|
const installStdout = execFileSync('node', [INSTALL_SCRIPT, '--target', 'cursor', 'typescript'], {
|
||||||
cwd: projectRoot,
|
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 normalizedProjectRoot = fs.realpathSync(projectRoot);
|
||||||
const managedPath = path.join(cursorRoot, 'hooks.json');
|
const managedPath = path.join(normalizedProjectRoot, '.cursor', 'hooks.json');
|
||||||
const statePath = path.join(cursorRoot, 'ecc-install-state.json');
|
const statePath = path.join(normalizedProjectRoot, '.cursor', 'ecc-install-state.json');
|
||||||
const unrelatedPath = path.join(cursorRoot, 'custom-user-note.txt');
|
const unrelatedPath = path.join(normalizedProjectRoot, '.cursor', 'custom-user-note.txt');
|
||||||
fs.writeFileSync(unrelatedPath, 'leave me alone');
|
fs.writeFileSync(unrelatedPath, 'leave me alone');
|
||||||
|
|
||||||
const uninstallResult = runNode(UNINSTALL_SCRIPT, ['--target', 'cursor'], {
|
const uninstallResult = run(['--target', 'cursor'], {
|
||||||
cwd: projectRoot,
|
cwd: projectRoot,
|
||||||
homeDir,
|
homeDir,
|
||||||
});
|
});
|
||||||
@@ -94,22 +117,152 @@ function runTests() {
|
|||||||
}
|
}
|
||||||
})) passed++; else failed++;
|
})) 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 homeDir = createTempDir('uninstall-home-');
|
||||||
const projectRoot = createTempDir('uninstall-project-');
|
const projectRoot = createTempDir('uninstall-project-');
|
||||||
|
|
||||||
try {
|
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,
|
cwd: projectRoot,
|
||||||
homeDir,
|
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');
|
if (test('supports dry-run without mutating managed files', () => {
|
||||||
const managedPath = path.join(cursorRoot, 'hooks.json');
|
const homeDir = createTempDir('uninstall-home-');
|
||||||
const statePath = path.join(cursorRoot, 'ecc-install-state.json');
|
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,
|
cwd: projectRoot,
|
||||||
homeDir,
|
homeDir,
|
||||||
});
|
});
|
||||||
@@ -117,8 +270,8 @@ function runTests() {
|
|||||||
|
|
||||||
const parsed = JSON.parse(uninstallResult.stdout);
|
const parsed = JSON.parse(uninstallResult.stdout);
|
||||||
assert.strictEqual(parsed.dryRun, true);
|
assert.strictEqual(parsed.dryRun, true);
|
||||||
assert.ok(parsed.results[0].plannedRemovals.length > 0);
|
assert.ok(parsed.results[0].plannedRemovals.includes(renderedPath));
|
||||||
assert.ok(fs.existsSync(managedPath));
|
assert.ok(fs.existsSync(renderedPath));
|
||||||
assert.ok(fs.existsSync(statePath));
|
assert.ok(fs.existsSync(statePath));
|
||||||
} finally {
|
} finally {
|
||||||
cleanup(homeDir);
|
cleanup(homeDir);
|
||||||
|
|||||||
Reference in New Issue
Block a user