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, };