mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-03-30 13:43:26 +08:00
* fix: strengthen install lifecycle adapters * fix: restore template content on uninstall
314 lines
10 KiB
JavaScript
314 lines
10 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
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'));
|
|
} catch (error) {
|
|
throw new Error(`Failed to read ${label}: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
function getValidator() {
|
|
if (cachedValidator) {
|
|
return cachedValidator;
|
|
}
|
|
|
|
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}`)
|
|
.join('; ');
|
|
}
|
|
|
|
function validateInstallState(state) {
|
|
const validator = getValidator();
|
|
const valid = validator(state);
|
|
return {
|
|
valid,
|
|
errors: validator.errors || [],
|
|
};
|
|
}
|
|
|
|
function assertValidInstallState(state, label) {
|
|
const result = validateInstallState(state);
|
|
if (!result.valid) {
|
|
throw new Error(`Invalid install-state${label ? ` (${label})` : ''}: ${formatValidationErrors(result.errors)}`);
|
|
}
|
|
}
|
|
|
|
function createInstallState(options) {
|
|
const installedAt = options.installedAt || new Date().toISOString();
|
|
const state = {
|
|
schemaVersion: 'ecc.install.v1',
|
|
installedAt,
|
|
target: {
|
|
id: options.adapter.id,
|
|
target: options.adapter.target || undefined,
|
|
kind: options.adapter.kind || undefined,
|
|
root: options.targetRoot,
|
|
installStatePath: options.installStatePath,
|
|
},
|
|
request: {
|
|
profile: options.request.profile || null,
|
|
modules: Array.isArray(options.request.modules) ? [...options.request.modules] : [],
|
|
includeComponents: Array.isArray(options.request.includeComponents)
|
|
? [...options.request.includeComponents]
|
|
: [],
|
|
excludeComponents: Array.isArray(options.request.excludeComponents)
|
|
? [...options.request.excludeComponents]
|
|
: [],
|
|
legacyLanguages: Array.isArray(options.request.legacyLanguages)
|
|
? [...options.request.legacyLanguages]
|
|
: [],
|
|
legacyMode: Boolean(options.request.legacyMode),
|
|
},
|
|
resolution: {
|
|
selectedModules: Array.isArray(options.resolution.selectedModules)
|
|
? [...options.resolution.selectedModules]
|
|
: [],
|
|
skippedModules: Array.isArray(options.resolution.skippedModules)
|
|
? [...options.resolution.skippedModules]
|
|
: [],
|
|
},
|
|
source: {
|
|
repoVersion: options.source.repoVersion || null,
|
|
repoCommit: options.source.repoCommit || null,
|
|
manifestVersion: options.source.manifestVersion,
|
|
},
|
|
operations: Array.isArray(options.operations)
|
|
? options.operations.map(operation => cloneJsonValue(operation))
|
|
: [],
|
|
};
|
|
|
|
if (options.lastValidatedAt) {
|
|
state.lastValidatedAt = options.lastValidatedAt;
|
|
}
|
|
|
|
assertValidInstallState(state, 'create');
|
|
return state;
|
|
}
|
|
|
|
function readInstallState(filePath) {
|
|
const state = readJson(filePath, 'install-state');
|
|
assertValidInstallState(state, filePath);
|
|
return state;
|
|
}
|
|
|
|
function writeInstallState(filePath, state) {
|
|
assertValidInstallState(state, filePath);
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
fs.writeFileSync(filePath, `${JSON.stringify(state, null, 2)}\n`);
|
|
return state;
|
|
}
|
|
|
|
module.exports = {
|
|
createInstallState,
|
|
readInstallState,
|
|
validateInstallState,
|
|
writeInstallState,
|
|
};
|