feat: strengthen install lifecycle and target adapters (#512)

* fix: strengthen install lifecycle adapters

* fix: restore template content on uninstall
This commit is contained in:
Affaan Mustafa
2026-03-15 21:47:31 -07:00
committed by GitHub
parent 1e0238de96
commit 131f977841
11 changed files with 1987 additions and 125 deletions

View File

@@ -4,7 +4,6 @@ const path = require('path');
const { resolveInstallPlan, loadInstallManifests } = require('./install-manifests');
const { readInstallState, writeInstallState } = require('./install-state');
const {
applyInstallPlan,
createLegacyInstallPlan,
createManifestInstallPlan,
} = require('./install-executor');
@@ -79,6 +78,420 @@ function areFilesEqual(leftPath, rightPath) {
}
}
function readFileUtf8(filePath) {
return fs.readFileSync(filePath, 'utf8');
}
function isPlainObject(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function cloneJsonValue(value) {
if (value === undefined) {
return undefined;
}
return JSON.parse(JSON.stringify(value));
}
function parseJsonLikeValue(value, label) {
if (value === undefined) {
return undefined;
}
if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch (error) {
throw new Error(`Invalid ${label}: ${error.message}`);
}
}
if (value === null || Array.isArray(value) || isPlainObject(value) || typeof value === 'number' || typeof value === 'boolean') {
return cloneJsonValue(value);
}
throw new Error(`Invalid ${label}: expected JSON-compatible data`);
}
function getOperationTextContent(operation) {
const candidateKeys = [
'renderedContent',
'content',
'managedContent',
'expectedContent',
'templateOutput',
];
for (const key of candidateKeys) {
if (typeof operation[key] === 'string') {
return operation[key];
}
}
return null;
}
function getOperationJsonPayload(operation) {
const candidateKeys = [
'mergePayload',
'managedPayload',
'payload',
'value',
'expectedValue',
];
for (const key of candidateKeys) {
if (operation[key] !== undefined) {
return parseJsonLikeValue(operation[key], `${operation.kind}.${key}`);
}
}
return undefined;
}
function getOperationPreviousContent(operation) {
const candidateKeys = [
'previousContent',
'originalContent',
'backupContent',
];
for (const key of candidateKeys) {
if (typeof operation[key] === 'string') {
return operation[key];
}
}
return null;
}
function getOperationPreviousJson(operation) {
const candidateKeys = [
'previousValue',
'previousJson',
'originalValue',
];
for (const key of candidateKeys) {
if (operation[key] !== undefined) {
return parseJsonLikeValue(operation[key], `${operation.kind}.${key}`);
}
}
return undefined;
}
function formatJson(value) {
return `${JSON.stringify(value, null, 2)}\n`;
}
function readJsonFile(filePath) {
return JSON.parse(readFileUtf8(filePath));
}
function ensureParentDir(filePath) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
}
function deepMergeJson(baseValue, patchValue) {
if (!isPlainObject(baseValue) || !isPlainObject(patchValue)) {
return cloneJsonValue(patchValue);
}
const merged = { ...baseValue };
for (const [key, value] of Object.entries(patchValue)) {
if (isPlainObject(value) && isPlainObject(merged[key])) {
merged[key] = deepMergeJson(merged[key], value);
} else {
merged[key] = cloneJsonValue(value);
}
}
return merged;
}
function jsonContainsSubset(actualValue, expectedValue) {
if (isPlainObject(expectedValue)) {
if (!isPlainObject(actualValue)) {
return false;
}
return Object.entries(expectedValue).every(([key, value]) => (
Object.prototype.hasOwnProperty.call(actualValue, key)
&& jsonContainsSubset(actualValue[key], value)
));
}
if (Array.isArray(expectedValue)) {
if (!Array.isArray(actualValue) || actualValue.length !== expectedValue.length) {
return false;
}
return expectedValue.every((item, index) => jsonContainsSubset(actualValue[index], item));
}
return actualValue === expectedValue;
}
const JSON_REMOVE_SENTINEL = Symbol('json-remove');
function deepRemoveJsonSubset(currentValue, managedValue) {
if (isPlainObject(managedValue)) {
if (!isPlainObject(currentValue)) {
return currentValue;
}
const nextValue = { ...currentValue };
for (const [key, value] of Object.entries(managedValue)) {
if (!Object.prototype.hasOwnProperty.call(nextValue, key)) {
continue;
}
if (isPlainObject(value)) {
const nestedValue = deepRemoveJsonSubset(nextValue[key], value);
if (nestedValue === JSON_REMOVE_SENTINEL) {
delete nextValue[key];
} else {
nextValue[key] = nestedValue;
}
continue;
}
if (Array.isArray(value)) {
if (Array.isArray(nextValue[key]) && jsonContainsSubset(nextValue[key], value)) {
delete nextValue[key];
}
continue;
}
if (nextValue[key] === value) {
delete nextValue[key];
}
}
return Object.keys(nextValue).length === 0 ? JSON_REMOVE_SENTINEL : nextValue;
}
if (Array.isArray(managedValue)) {
return jsonContainsSubset(currentValue, managedValue) ? JSON_REMOVE_SENTINEL : currentValue;
}
return currentValue === managedValue ? JSON_REMOVE_SENTINEL : currentValue;
}
function hydrateRecordedOperations(repoRoot, operations) {
return operations.map(operation => {
if (operation.kind !== 'copy-file') {
return { ...operation };
}
return {
...operation,
sourcePath: resolveOperationSourcePath(repoRoot, operation),
};
});
}
function buildRecordedStatePreview(state, context, operations) {
return {
...state,
operations: operations.map(operation => ({ ...operation })),
source: {
...state.source,
repoVersion: context.packageVersion,
manifestVersion: context.manifestVersion,
},
lastValidatedAt: new Date().toISOString(),
};
}
function shouldRepairFromRecordedOperations(state) {
return getManagedOperations(state).some(operation => operation.kind !== 'copy-file');
}
function executeRepairOperation(repoRoot, operation) {
if (operation.kind === 'copy-file') {
const sourcePath = resolveOperationSourcePath(repoRoot, operation);
if (!sourcePath || !fs.existsSync(sourcePath)) {
throw new Error(`Missing source file for repair: ${sourcePath || operation.sourceRelativePath}`);
}
ensureParentDir(operation.destinationPath);
fs.copyFileSync(sourcePath, operation.destinationPath);
return;
}
if (operation.kind === 'render-template') {
const renderedContent = getOperationTextContent(operation);
if (renderedContent === null) {
throw new Error(`Missing rendered content for repair: ${operation.destinationPath}`);
}
ensureParentDir(operation.destinationPath);
fs.writeFileSync(operation.destinationPath, renderedContent);
return;
}
if (operation.kind === 'merge-json') {
const payload = getOperationJsonPayload(operation);
if (payload === undefined) {
throw new Error(`Missing merge payload for repair: ${operation.destinationPath}`);
}
const currentValue = fs.existsSync(operation.destinationPath)
? readJsonFile(operation.destinationPath)
: {};
const mergedValue = deepMergeJson(currentValue, payload);
ensureParentDir(operation.destinationPath);
fs.writeFileSync(operation.destinationPath, formatJson(mergedValue));
return;
}
if (operation.kind === 'remove') {
if (!fs.existsSync(operation.destinationPath)) {
return;
}
fs.rmSync(operation.destinationPath, { recursive: true, force: true });
return;
}
throw new Error(`Unsupported repair operation kind: ${operation.kind}`);
}
function executeUninstallOperation(operation) {
if (operation.kind === 'copy-file') {
if (!fs.existsSync(operation.destinationPath)) {
return {
removedPaths: [],
cleanupTargets: [],
};
}
fs.rmSync(operation.destinationPath, { force: true });
return {
removedPaths: [operation.destinationPath],
cleanupTargets: [operation.destinationPath],
};
}
if (operation.kind === 'render-template') {
const previousContent = getOperationPreviousContent(operation);
if (previousContent !== null) {
ensureParentDir(operation.destinationPath);
fs.writeFileSync(operation.destinationPath, previousContent);
return {
removedPaths: [],
cleanupTargets: [],
};
}
const previousJson = getOperationPreviousJson(operation);
if (previousJson !== undefined) {
ensureParentDir(operation.destinationPath);
fs.writeFileSync(operation.destinationPath, formatJson(previousJson));
return {
removedPaths: [],
cleanupTargets: [],
};
}
if (!fs.existsSync(operation.destinationPath)) {
return {
removedPaths: [],
cleanupTargets: [],
};
}
fs.rmSync(operation.destinationPath, { force: true });
return {
removedPaths: [operation.destinationPath],
cleanupTargets: [operation.destinationPath],
};
}
if (operation.kind === 'merge-json') {
const previousContent = getOperationPreviousContent(operation);
if (previousContent !== null) {
ensureParentDir(operation.destinationPath);
fs.writeFileSync(operation.destinationPath, previousContent);
return {
removedPaths: [],
cleanupTargets: [],
};
}
const previousJson = getOperationPreviousJson(operation);
if (previousJson !== undefined) {
ensureParentDir(operation.destinationPath);
fs.writeFileSync(operation.destinationPath, formatJson(previousJson));
return {
removedPaths: [],
cleanupTargets: [],
};
}
if (!fs.existsSync(operation.destinationPath)) {
return {
removedPaths: [],
cleanupTargets: [],
};
}
const payload = getOperationJsonPayload(operation);
if (payload === undefined) {
throw new Error(`Missing merge payload for uninstall: ${operation.destinationPath}`);
}
const currentValue = readJsonFile(operation.destinationPath);
const nextValue = deepRemoveJsonSubset(currentValue, payload);
if (nextValue === JSON_REMOVE_SENTINEL) {
fs.rmSync(operation.destinationPath, { force: true });
return {
removedPaths: [operation.destinationPath],
cleanupTargets: [operation.destinationPath],
};
}
ensureParentDir(operation.destinationPath);
fs.writeFileSync(operation.destinationPath, formatJson(nextValue));
return {
removedPaths: [],
cleanupTargets: [],
};
}
if (operation.kind === 'remove') {
const previousContent = getOperationPreviousContent(operation);
if (previousContent !== null) {
ensureParentDir(operation.destinationPath);
fs.writeFileSync(operation.destinationPath, previousContent);
return {
removedPaths: [],
cleanupTargets: [],
};
}
const previousJson = getOperationPreviousJson(operation);
if (previousJson !== undefined) {
ensureParentDir(operation.destinationPath);
fs.writeFileSync(operation.destinationPath, formatJson(previousJson));
return {
removedPaths: [],
cleanupTargets: [],
};
}
return {
removedPaths: [],
cleanupTargets: [],
};
}
throw new Error(`Unsupported uninstall operation kind: ${operation.kind}`);
}
function inspectManagedOperation(repoRoot, operation) {
const destinationPath = operation.destinationPath;
if (!destinationPath) {
@@ -88,6 +501,22 @@ function inspectManagedOperation(repoRoot, operation) {
};
}
if (operation.kind === 'remove') {
if (fs.existsSync(destinationPath)) {
return {
status: 'drifted',
operation,
destinationPath,
};
}
return {
status: 'ok',
operation,
destinationPath,
};
}
if (!fs.existsSync(destinationPath)) {
return {
status: 'missing',
@@ -96,38 +525,97 @@ function inspectManagedOperation(repoRoot, operation) {
};
}
if (operation.kind !== 'copy-file') {
return {
status: 'unverified',
operation,
destinationPath,
};
}
if (operation.kind === 'copy-file') {
const sourcePath = resolveOperationSourcePath(repoRoot, operation);
if (!sourcePath || !fs.existsSync(sourcePath)) {
return {
status: 'missing-source',
operation,
destinationPath,
sourcePath,
};
}
if (!areFilesEqual(sourcePath, destinationPath)) {
return {
status: 'drifted',
operation,
destinationPath,
sourcePath,
};
}
const sourcePath = resolveOperationSourcePath(repoRoot, operation);
if (!sourcePath || !fs.existsSync(sourcePath)) {
return {
status: 'missing-source',
status: 'ok',
operation,
destinationPath,
sourcePath,
};
}
if (!areFilesEqual(sourcePath, destinationPath)) {
if (operation.kind === 'render-template') {
const renderedContent = getOperationTextContent(operation);
if (renderedContent === null) {
return {
status: 'unverified',
operation,
destinationPath,
};
}
if (readFileUtf8(destinationPath) !== renderedContent) {
return {
status: 'drifted',
operation,
destinationPath,
};
}
return {
status: 'drifted',
status: 'ok',
operation,
destinationPath,
};
}
if (operation.kind === 'merge-json') {
const payload = getOperationJsonPayload(operation);
if (payload === undefined) {
return {
status: 'unverified',
operation,
destinationPath,
};
}
try {
const currentValue = readJsonFile(destinationPath);
if (!jsonContainsSubset(currentValue, payload)) {
return {
status: 'drifted',
operation,
destinationPath,
};
}
} catch (_error) {
return {
status: 'drifted',
operation,
destinationPath,
};
}
return {
status: 'ok',
operation,
destinationPath,
sourcePath,
};
}
return {
status: 'ok',
status: 'unverified',
operation,
destinationPath,
sourcePath,
};
}
@@ -455,25 +943,12 @@ function createRepairPlanFromRecord(record, context) {
throw new Error('No install-state available for repair');
}
if (state.request.legacyMode) {
const operations = getManagedOperations(state).map(operation => ({
...operation,
sourcePath: resolveOperationSourcePath(context.repoRoot, operation),
}));
const statePreview = {
...state,
operations: operations.map(operation => ({ ...operation })),
source: {
...state.source,
repoVersion: context.packageVersion,
manifestVersion: context.manifestVersion,
},
lastValidatedAt: new Date().toISOString(),
};
if (state.request.legacyMode || shouldRepairFromRecordedOperations(state)) {
const operations = hydrateRecordedOperations(context.repoRoot, getManagedOperations(state));
const statePreview = buildRecordedStatePreview(state, context, operations);
return {
mode: 'legacy',
mode: state.request.legacyMode ? 'legacy' : 'recorded',
target: record.adapter.target,
adapter: record.adapter,
targetRoot: state.target.root,
@@ -571,11 +1046,10 @@ function repairInstalledStates(options = {}) {
}
if (repairOperations.length > 0) {
applyInstallPlan({
...desiredPlan,
operations: repairOperations,
statePreview: desiredPlan.statePreview,
});
for (const operation of repairOperations) {
executeRepairOperation(context.repoRoot, operation);
}
writeInstallState(desiredPlan.installStatePath, desiredPlan.statePreview);
} else {
writeInstallState(desiredPlan.installStatePath, desiredPlan.statePreview);
}
@@ -684,23 +1158,12 @@ function uninstallInstalledStates(options = {}) {
try {
const removedPaths = [];
const cleanupTargets = [];
const filePaths = Array.from(new Set(
getManagedOperations(state).map(operation => operation.destinationPath)
)).sort((left, right) => right.length - left.length);
const operations = getManagedOperations(state);
for (const filePath of filePaths) {
if (!fs.existsSync(filePath)) {
continue;
}
const stat = fs.lstatSync(filePath);
if (stat.isDirectory()) {
throw new Error(`Refusing to remove managed directory path without explicit support: ${filePath}`);
}
fs.rmSync(filePath, { force: true });
removedPaths.push(filePath);
cleanupTargets.push(filePath);
for (const operation of operations) {
const outcome = executeUninstallOperation(operation);
removedPaths.push(...outcome.removedPaths);
cleanupTargets.push(...outcome.cleanupTargets);
}
if (fs.existsSync(state.target.installStatePath)) {

View File

@@ -1,11 +1,28 @@
const fs = require('fs');
const path = require('path');
const Ajv = require('ajv');
let Ajv = null;
try {
// Prefer schema-backed validation when dependencies are installed.
// The fallback validator below keeps source checkouts usable in bare environments.
const ajvModule = require('ajv');
Ajv = ajvModule.default || ajvModule;
} catch (_error) {
Ajv = null;
}
const SCHEMA_PATH = path.join(__dirname, '..', '..', 'schemas', 'install-state.schema.json');
let cachedValidator = null;
function cloneJsonValue(value) {
if (value === undefined) {
return undefined;
}
return JSON.parse(JSON.stringify(value));
}
function readJson(filePath, label) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
@@ -19,12 +36,188 @@ function getValidator() {
return cachedValidator;
}
const schema = readJson(SCHEMA_PATH, 'install-state schema');
const ajv = new Ajv({ allErrors: true });
cachedValidator = ajv.compile(schema);
if (Ajv) {
const schema = readJson(SCHEMA_PATH, 'install-state schema');
const ajv = new Ajv({ allErrors: true });
cachedValidator = ajv.compile(schema);
return cachedValidator;
}
cachedValidator = createFallbackValidator();
return cachedValidator;
}
function createFallbackValidator() {
const validate = state => {
const errors = [];
validate.errors = errors;
function pushError(instancePath, message) {
errors.push({
instancePath,
message,
});
}
function isNonEmptyString(value) {
return typeof value === 'string' && value.length > 0;
}
function validateNoAdditionalProperties(value, instancePath, allowedKeys) {
for (const key of Object.keys(value)) {
if (!allowedKeys.includes(key)) {
pushError(`${instancePath}/${key}`, 'must NOT have additional properties');
}
}
}
function validateStringArray(value, instancePath) {
if (!Array.isArray(value)) {
pushError(instancePath, 'must be array');
return;
}
for (let index = 0; index < value.length; index += 1) {
if (!isNonEmptyString(value[index])) {
pushError(`${instancePath}/${index}`, 'must be non-empty string');
}
}
}
function validateOptionalString(value, instancePath) {
if (value !== undefined && value !== null && !isNonEmptyString(value)) {
pushError(instancePath, 'must be string or null');
}
}
if (!state || typeof state !== 'object' || Array.isArray(state)) {
pushError('/', 'must be object');
return false;
}
validateNoAdditionalProperties(
state,
'',
['schemaVersion', 'installedAt', 'lastValidatedAt', 'target', 'request', 'resolution', 'source', 'operations']
);
if (state.schemaVersion !== 'ecc.install.v1') {
pushError('/schemaVersion', 'must equal ecc.install.v1');
}
if (!isNonEmptyString(state.installedAt)) {
pushError('/installedAt', 'must be non-empty string');
}
if (state.lastValidatedAt !== undefined && !isNonEmptyString(state.lastValidatedAt)) {
pushError('/lastValidatedAt', 'must be non-empty string');
}
const target = state.target;
if (!target || typeof target !== 'object' || Array.isArray(target)) {
pushError('/target', 'must be object');
} else {
validateNoAdditionalProperties(target, '/target', ['id', 'target', 'kind', 'root', 'installStatePath']);
if (!isNonEmptyString(target.id)) {
pushError('/target/id', 'must be non-empty string');
}
validateOptionalString(target.target, '/target/target');
if (target.kind !== undefined && !['home', 'project'].includes(target.kind)) {
pushError('/target/kind', 'must be equal to one of the allowed values');
}
if (!isNonEmptyString(target.root)) {
pushError('/target/root', 'must be non-empty string');
}
if (!isNonEmptyString(target.installStatePath)) {
pushError('/target/installStatePath', 'must be non-empty string');
}
}
const request = state.request;
if (!request || typeof request !== 'object' || Array.isArray(request)) {
pushError('/request', 'must be object');
} else {
validateNoAdditionalProperties(
request,
'/request',
['profile', 'modules', 'includeComponents', 'excludeComponents', 'legacyLanguages', 'legacyMode']
);
if (!(Object.prototype.hasOwnProperty.call(request, 'profile') && (request.profile === null || typeof request.profile === 'string'))) {
pushError('/request/profile', 'must be string or null');
}
validateStringArray(request.modules, '/request/modules');
validateStringArray(request.includeComponents, '/request/includeComponents');
validateStringArray(request.excludeComponents, '/request/excludeComponents');
validateStringArray(request.legacyLanguages, '/request/legacyLanguages');
if (typeof request.legacyMode !== 'boolean') {
pushError('/request/legacyMode', 'must be boolean');
}
}
const resolution = state.resolution;
if (!resolution || typeof resolution !== 'object' || Array.isArray(resolution)) {
pushError('/resolution', 'must be object');
} else {
validateNoAdditionalProperties(resolution, '/resolution', ['selectedModules', 'skippedModules']);
validateStringArray(resolution.selectedModules, '/resolution/selectedModules');
validateStringArray(resolution.skippedModules, '/resolution/skippedModules');
}
const source = state.source;
if (!source || typeof source !== 'object' || Array.isArray(source)) {
pushError('/source', 'must be object');
} else {
validateNoAdditionalProperties(source, '/source', ['repoVersion', 'repoCommit', 'manifestVersion']);
validateOptionalString(source.repoVersion, '/source/repoVersion');
validateOptionalString(source.repoCommit, '/source/repoCommit');
if (!Number.isInteger(source.manifestVersion) || source.manifestVersion < 1) {
pushError('/source/manifestVersion', 'must be integer >= 1');
}
}
if (!Array.isArray(state.operations)) {
pushError('/operations', 'must be array');
} else {
for (let index = 0; index < state.operations.length; index += 1) {
const operation = state.operations[index];
const instancePath = `/operations/${index}`;
if (!operation || typeof operation !== 'object' || Array.isArray(operation)) {
pushError(instancePath, 'must be object');
continue;
}
if (!isNonEmptyString(operation.kind)) {
pushError(`${instancePath}/kind`, 'must be non-empty string');
}
if (!isNonEmptyString(operation.moduleId)) {
pushError(`${instancePath}/moduleId`, 'must be non-empty string');
}
if (!isNonEmptyString(operation.sourceRelativePath)) {
pushError(`${instancePath}/sourceRelativePath`, 'must be non-empty string');
}
if (!isNonEmptyString(operation.destinationPath)) {
pushError(`${instancePath}/destinationPath`, 'must be non-empty string');
}
if (!isNonEmptyString(operation.strategy)) {
pushError(`${instancePath}/strategy`, 'must be non-empty string');
}
if (!isNonEmptyString(operation.ownership)) {
pushError(`${instancePath}/ownership`, 'must be non-empty string');
}
if (typeof operation.scaffoldOnly !== 'boolean') {
pushError(`${instancePath}/scaffoldOnly`, 'must be boolean');
}
}
}
return errors.length === 0;
};
validate.errors = [];
return validate;
}
function formatValidationErrors(errors = []) {
return errors
.map(error => `${error.instancePath || '/'} ${error.message}`)
@@ -87,7 +280,7 @@ function createInstallState(options) {
manifestVersion: options.source.manifestVersion,
},
operations: Array.isArray(options.operations)
? options.operations.map(operation => ({ ...operation }))
? options.operations.map(operation => cloneJsonValue(operation))
: [],
};

View File

@@ -1,4 +1,10 @@
const { createInstallTargetAdapter } = require('./helpers');
const path = require('path');
const {
createFlatRuleOperations,
createInstallTargetAdapter,
createManagedScaffoldOperation,
} = require('./helpers');
module.exports = createInstallTargetAdapter({
id: 'antigravity-project',
@@ -6,4 +12,58 @@ module.exports = createInstallTargetAdapter({
kind: 'project',
rootSegments: ['.agent'],
installStatePathSegments: ['ecc-install-state.json'],
planOperations(input, adapter) {
const modules = Array.isArray(input.modules)
? input.modules
: (input.module ? [input.module] : []);
const {
repoRoot,
projectRoot,
homeDir,
} = input;
const planningInput = {
repoRoot,
projectRoot,
homeDir,
};
const targetRoot = adapter.resolveRoot(planningInput);
return modules.flatMap(module => {
const paths = Array.isArray(module.paths) ? module.paths : [];
return paths.flatMap(sourceRelativePath => {
if (sourceRelativePath === 'rules') {
return createFlatRuleOperations({
moduleId: module.id,
repoRoot,
sourceRelativePath,
destinationDir: path.join(targetRoot, 'rules'),
});
}
if (sourceRelativePath === 'commands') {
return [
createManagedScaffoldOperation(
module.id,
sourceRelativePath,
path.join(targetRoot, 'workflows'),
'preserve-relative-path'
),
];
}
if (sourceRelativePath === 'agents') {
return [
createManagedScaffoldOperation(
module.id,
sourceRelativePath,
path.join(targetRoot, 'skills'),
'preserve-relative-path'
),
];
}
return [adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput)];
});
});
},
});

View File

@@ -1,4 +1,9 @@
const { createInstallTargetAdapter } = require('./helpers');
const path = require('path');
const {
createFlatRuleOperations,
createInstallTargetAdapter,
} = require('./helpers');
module.exports = createInstallTargetAdapter({
id: 'cursor-project',
@@ -7,4 +12,36 @@ module.exports = createInstallTargetAdapter({
rootSegments: ['.cursor'],
installStatePathSegments: ['ecc-install-state.json'],
nativeRootRelativePath: '.cursor',
planOperations(input, adapter) {
const modules = Array.isArray(input.modules)
? input.modules
: (input.module ? [input.module] : []);
const {
repoRoot,
projectRoot,
homeDir,
} = input;
const planningInput = {
repoRoot,
projectRoot,
homeDir,
};
const targetRoot = adapter.resolveRoot(planningInput);
return modules.flatMap(module => {
const paths = Array.isArray(module.paths) ? module.paths : [];
return paths.flatMap(sourceRelativePath => {
if (sourceRelativePath === 'rules') {
return createFlatRuleOperations({
moduleId: module.id,
repoRoot,
sourceRelativePath,
destinationDir: path.join(targetRoot, 'rules'),
});
}
return [adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput)];
});
});
},
});

View File

@@ -1,3 +1,4 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
@@ -24,6 +25,182 @@ function resolveBaseRoot(scope, input = {}) {
throw new Error(`Unsupported install target scope: ${scope}`);
}
function buildValidationIssue(severity, code, message, extra = {}) {
return {
severity,
code,
message,
...extra,
};
}
function listRelativeFiles(dirPath, prefix = '') {
if (!fs.existsSync(dirPath)) {
return [];
}
const entries = fs.readdirSync(dirPath, { withFileTypes: true }).sort((left, right) => (
left.name.localeCompare(right.name)
));
const files = [];
for (const entry of entries) {
const entryPrefix = prefix ? path.join(prefix, entry.name) : entry.name;
const absolutePath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
files.push(...listRelativeFiles(absolutePath, entryPrefix));
} else if (entry.isFile()) {
files.push(normalizeRelativePath(entryPrefix));
}
}
return files;
}
function createManagedOperation({
kind = 'copy-path',
moduleId,
sourceRelativePath,
destinationPath,
strategy = 'preserve-relative-path',
ownership = 'managed',
scaffoldOnly = true,
...rest
}) {
return {
kind,
moduleId,
sourceRelativePath: normalizeRelativePath(sourceRelativePath),
destinationPath,
strategy,
ownership,
scaffoldOnly,
...rest,
};
}
function defaultValidateAdapterInput(config, input = {}) {
if (config.kind === 'project' && !input.projectRoot && !input.repoRoot) {
return [
buildValidationIssue(
'error',
'missing-project-root',
'projectRoot or repoRoot is required for project install targets'
),
];
}
if (config.kind === 'home' && !input.homeDir && !os.homedir()) {
return [
buildValidationIssue(
'error',
'missing-home-dir',
'homeDir is required for home install targets'
),
];
}
return [];
}
function createRemappedOperation(adapter, moduleId, sourceRelativePath, destinationPath, options = {}) {
return createManagedOperation({
kind: options.kind || 'copy-path',
moduleId,
sourceRelativePath,
destinationPath,
strategy: options.strategy || 'preserve-relative-path',
ownership: options.ownership || 'managed',
scaffoldOnly: Object.hasOwn(options, 'scaffoldOnly') ? options.scaffoldOnly : true,
...options.extra,
});
}
function createNamespacedFlatRuleOperations(adapter, moduleId, sourceRelativePath, input = {}) {
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
const sourceRoot = path.join(input.repoRoot || '', normalizedSourcePath);
if (!input.repoRoot || !fs.existsSync(sourceRoot) || !fs.statSync(sourceRoot).isDirectory()) {
return [];
}
const targetRulesDir = path.join(adapter.resolveRoot(input), 'rules');
const operations = [];
const entries = fs.readdirSync(sourceRoot, { withFileTypes: true }).sort((left, right) => (
left.name.localeCompare(right.name)
));
for (const entry of entries) {
const namespace = entry.name;
const entryPath = path.join(sourceRoot, entry.name);
if (entry.isDirectory()) {
const relativeFiles = listRelativeFiles(entryPath);
for (const relativeFile of relativeFiles) {
const flattenedFileName = `${namespace}-${normalizeRelativePath(relativeFile).replace(/\//g, '-')}`;
const sourceRelativeFile = path.join(normalizedSourcePath, namespace, relativeFile);
operations.push(createManagedOperation({
moduleId,
sourceRelativePath: sourceRelativeFile,
destinationPath: path.join(targetRulesDir, flattenedFileName),
strategy: 'flatten-copy',
}));
}
} else if (entry.isFile()) {
operations.push(createManagedOperation({
moduleId,
sourceRelativePath: path.join(normalizedSourcePath, entry.name),
destinationPath: path.join(targetRulesDir, entry.name),
strategy: 'flatten-copy',
}));
}
}
return operations;
}
function createFlatRuleOperations({ moduleId, repoRoot, sourceRelativePath, destinationDir }) {
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
const sourceRoot = path.join(repoRoot || '', normalizedSourcePath);
if (!repoRoot || !fs.existsSync(sourceRoot) || !fs.statSync(sourceRoot).isDirectory()) {
return [];
}
const operations = [];
const entries = fs.readdirSync(sourceRoot, { withFileTypes: true }).sort((left, right) => (
left.name.localeCompare(right.name)
));
for (const entry of entries) {
const namespace = entry.name;
const entryPath = path.join(sourceRoot, entry.name);
if (entry.isDirectory()) {
const relativeFiles = listRelativeFiles(entryPath);
for (const relativeFile of relativeFiles) {
const flattenedFileName = `${namespace}-${normalizeRelativePath(relativeFile).replace(/\//g, '-')}`;
operations.push(createManagedOperation({
moduleId,
sourceRelativePath: path.join(normalizedSourcePath, namespace, relativeFile),
destinationPath: path.join(destinationDir, flattenedFileName),
strategy: 'flatten-copy',
}));
}
} else if (entry.isFile()) {
operations.push(createManagedOperation({
moduleId,
sourceRelativePath: path.join(normalizedSourcePath, entry.name),
destinationPath: path.join(destinationDir, entry.name),
strategy: 'flatten-copy',
}));
}
}
return operations;
}
function createInstallTargetAdapter(config) {
const adapter = {
id: config.id,
@@ -68,15 +245,43 @@ function createInstallTargetAdapter(config) {
},
createScaffoldOperation(moduleId, sourceRelativePath, input = {}) {
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
return {
kind: 'copy-path',
return createManagedOperation({
moduleId,
sourceRelativePath: normalizedSourcePath,
destinationPath: adapter.resolveDestinationPath(normalizedSourcePath, input),
strategy: adapter.determineStrategy(normalizedSourcePath),
ownership: 'managed',
scaffoldOnly: true,
};
});
},
planOperations(input = {}) {
if (typeof config.planOperations === 'function') {
return config.planOperations(input, adapter);
}
if (Array.isArray(input.modules)) {
return input.modules.flatMap(module => {
const paths = Array.isArray(module.paths) ? module.paths : [];
return paths.map(sourceRelativePath => adapter.createScaffoldOperation(
module.id,
sourceRelativePath,
input
));
});
}
const module = input.module || {};
const paths = Array.isArray(module.paths) ? module.paths : [];
return paths.map(sourceRelativePath => adapter.createScaffoldOperation(
module.id,
sourceRelativePath,
input
));
},
validate(input = {}) {
if (typeof config.validate === 'function') {
return config.validate(input, adapter);
}
return defaultValidateAdapterInput(config, input);
},
};
@@ -84,6 +289,19 @@ function createInstallTargetAdapter(config) {
}
module.exports = {
buildValidationIssue,
createFlatRuleOperations,
createInstallTargetAdapter,
createManagedOperation,
createManagedScaffoldOperation: (moduleId, sourceRelativePath, destinationPath, strategy) => (
createManagedOperation({
moduleId,
sourceRelativePath,
destinationPath,
strategy,
})
),
createNamespacedFlatRuleOperations,
createRemappedOperation,
normalizeRelativePath,
};

View File

@@ -34,15 +34,16 @@ function planInstallTargetScaffold(options = {}) {
projectRoot: options.projectRoot || options.repoRoot,
homeDir: options.homeDir,
};
const validationIssues = adapter.validate(planningInput);
const blockingIssues = validationIssues.filter(issue => issue.severity === 'error');
if (blockingIssues.length > 0) {
throw new Error(blockingIssues.map(issue => issue.message).join('; '));
}
const targetRoot = adapter.resolveRoot(planningInput);
const installStatePath = adapter.getInstallStatePath(planningInput);
const operations = modules.flatMap(module => {
const paths = Array.isArray(module.paths) ? module.paths : [];
return paths.map(sourceRelativePath => adapter.createScaffoldOperation(
module.id,
sourceRelativePath,
planningInput
));
const operations = adapter.planOperations({
...planningInput,
modules,
});
return {
@@ -53,6 +54,7 @@ function planInstallTargetScaffold(options = {}) {
},
targetRoot,
installStatePath,
validationIssues,
operations,
};
}