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

@@ -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))
: [],
};