902 lines
29 KiB
TypeScript
902 lines
29 KiB
TypeScript
|
|
/**
|
||
|
|
* Config loader for APOPHIS CLI.
|
||
|
|
*
|
||
|
|
* Responsibilities:
|
||
|
|
* - Config file discovery (.js, .ts, .json, or "apophis" field in package.json)
|
||
|
|
* - Config loading with tsx for .ts files
|
||
|
|
* - Profile resolution from config.profiles
|
||
|
|
* - Preset resolution and application
|
||
|
|
* - Environment-specific overrides
|
||
|
|
* - Unknown-key hard failure with exact path
|
||
|
|
* - Monorepo boundary detection
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
||
|
|
import { resolve, dirname } from 'node:path';
|
||
|
|
import { pathToFileURL } from 'node:url';
|
||
|
|
import { resolvePacks } from '../../protocol-packs/index.js';
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Types
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
export interface Config {
|
||
|
|
mode?: 'verify' | 'observe' | 'qualify';
|
||
|
|
profile?: string;
|
||
|
|
preset?: string;
|
||
|
|
routes?: string[];
|
||
|
|
seed?: number;
|
||
|
|
artifactDir?: string;
|
||
|
|
environments?: Record<string, EnvironmentPolicy>;
|
||
|
|
profiles?: Record<string, ProfileDefinition>;
|
||
|
|
presets?: Record<string, PresetDefinition>;
|
||
|
|
generationProfiles?: Record<string, 'quick' | 'standard' | 'thorough' | { base: 'quick' | 'standard' | 'thorough' }>;
|
||
|
|
[key: string]: unknown;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface EnvironmentPolicy {
|
||
|
|
allowedModes?: ('verify' | 'observe' | 'qualify')[];
|
||
|
|
blockQualify?: boolean;
|
||
|
|
allowChaosOnProtected?: boolean;
|
||
|
|
[key: string]: unknown;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface ProfileDefinition {
|
||
|
|
preset?: string;
|
||
|
|
routes?: string[];
|
||
|
|
seed?: number;
|
||
|
|
features?: string[];
|
||
|
|
[key: string]: unknown;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface PresetDefinition {
|
||
|
|
mode?: 'verify' | 'observe' | 'qualify';
|
||
|
|
routes?: string[];
|
||
|
|
seed?: number;
|
||
|
|
features?: string[];
|
||
|
|
[key: string]: unknown;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface LoadConfigOptions {
|
||
|
|
cwd: string;
|
||
|
|
configPath?: string;
|
||
|
|
profileName?: string;
|
||
|
|
env?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface LoadConfigResult {
|
||
|
|
config: Config;
|
||
|
|
configPath: string | null;
|
||
|
|
isMonorepo: boolean;
|
||
|
|
profileName: string | null;
|
||
|
|
presetName: string | null;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Schema definition (TypeBox-style, plain TS for now)
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
interface SchemaField {
|
||
|
|
type: 'string' | 'number' | 'boolean' | 'array' | 'object';
|
||
|
|
optional?: boolean;
|
||
|
|
items?: SchemaField;
|
||
|
|
properties?: Record<string, SchemaField>;
|
||
|
|
enumValues?: string[];
|
||
|
|
min?: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Schema for top-level config keys
|
||
|
|
const CONFIG_SCHEMA: Record<string, SchemaField> = {
|
||
|
|
mode: { type: 'string', optional: true, enumValues: ['verify', 'observe', 'qualify'] },
|
||
|
|
profile: { type: 'string', optional: true },
|
||
|
|
preset: { type: 'string', optional: true },
|
||
|
|
routes: { type: 'array', optional: true, items: { type: 'string' } },
|
||
|
|
seed: { type: 'number', optional: true },
|
||
|
|
artifactDir: { type: 'string', optional: true },
|
||
|
|
environments: {
|
||
|
|
type: 'object',
|
||
|
|
optional: true,
|
||
|
|
},
|
||
|
|
profiles: {
|
||
|
|
type: 'object',
|
||
|
|
optional: true,
|
||
|
|
properties: {},
|
||
|
|
},
|
||
|
|
presets: {
|
||
|
|
type: 'object',
|
||
|
|
optional: true,
|
||
|
|
properties: {},
|
||
|
|
},
|
||
|
|
generationProfiles: {
|
||
|
|
type: 'object',
|
||
|
|
optional: true,
|
||
|
|
properties: {},
|
||
|
|
},
|
||
|
|
packs: {
|
||
|
|
type: 'array',
|
||
|
|
optional: true,
|
||
|
|
items: { type: 'string' },
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
// Schema for EnvironmentPolicy values (inside environments.<name>)
|
||
|
|
const ENVIRONMENT_POLICY_SCHEMA: Record<string, SchemaField> = {
|
||
|
|
name: { type: 'string', optional: false },
|
||
|
|
allowVerify: { type: 'boolean', optional: true },
|
||
|
|
allowObserve: { type: 'boolean', optional: true },
|
||
|
|
allowQualify: { type: 'boolean', optional: true },
|
||
|
|
allowChaos: { type: 'boolean', optional: true },
|
||
|
|
allowBlocking: { type: 'boolean', optional: true },
|
||
|
|
requireSink: { type: 'boolean', optional: true },
|
||
|
|
allowedModes: { type: 'array', optional: true, items: { type: 'string' } },
|
||
|
|
blockQualify: { type: 'boolean', optional: true },
|
||
|
|
allowChaosOnProtected: { type: 'boolean', optional: true },
|
||
|
|
};
|
||
|
|
|
||
|
|
// Schema for ProfileDefinition values (inside profiles.<name>)
|
||
|
|
const PROFILE_SCHEMA: Record<string, SchemaField> = {
|
||
|
|
name: { type: 'string', optional: false },
|
||
|
|
mode: { type: 'string', optional: true, enumValues: ['verify', 'observe', 'qualify'] },
|
||
|
|
preset: { type: 'string', optional: true },
|
||
|
|
routes: { type: 'array', optional: true, items: { type: 'string' } },
|
||
|
|
seed: { type: 'number', optional: true },
|
||
|
|
artifactDir: { type: 'string', optional: true },
|
||
|
|
environment: { type: 'string', optional: true },
|
||
|
|
features: { type: 'array', optional: true, items: { type: 'string' } },
|
||
|
|
sampling: { type: 'number', optional: true },
|
||
|
|
blocking: { type: 'boolean', optional: true },
|
||
|
|
sinks: { type: 'object', optional: true },
|
||
|
|
};
|
||
|
|
|
||
|
|
// Schema for PresetDefinition values (inside presets.<name>)
|
||
|
|
const PRESET_SCHEMA: Record<string, SchemaField> = {
|
||
|
|
name: { type: 'string', optional: false },
|
||
|
|
depth: { type: 'string', optional: true, enumValues: ['quick', 'standard', 'deep'] },
|
||
|
|
timeout: { type: 'number', optional: true, min: 0 },
|
||
|
|
parallel: { type: 'boolean', optional: true },
|
||
|
|
chaos: { type: 'boolean', optional: true },
|
||
|
|
observe: { type: 'boolean', optional: true },
|
||
|
|
features: { type: 'array', optional: true, items: { type: 'string' } },
|
||
|
|
sampling: { type: 'number', optional: true },
|
||
|
|
blocking: { type: 'boolean', optional: true },
|
||
|
|
sinks: { type: 'object', optional: true },
|
||
|
|
};
|
||
|
|
|
||
|
|
const GENERATION_PROFILE_ALIAS_SCHEMA: Record<string, SchemaField> = {
|
||
|
|
base: { type: 'string', optional: false, enumValues: ['quick', 'standard', 'thorough'] },
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Config discovery
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
const CONFIG_FILES = [
|
||
|
|
'apophis.config.js',
|
||
|
|
'apophis.config.ts',
|
||
|
|
'apophis.config.json',
|
||
|
|
];
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Discover config file in cwd or return null.
|
||
|
|
*/
|
||
|
|
export function discoverConfig(cwd: string): string | null {
|
||
|
|
for (const file of CONFIG_FILES) {
|
||
|
|
const fullPath = resolve(cwd, file);
|
||
|
|
if (existsSync(fullPath)) {
|
||
|
|
return fullPath;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Load package.json and check for "apophis" field.
|
||
|
|
*/
|
||
|
|
export function loadPackageJsonConfig(cwd: string): { config: Config | null; path: string | null } {
|
||
|
|
const pkgPath = resolve(cwd, 'package.json');
|
||
|
|
if (!existsSync(pkgPath)) {
|
||
|
|
return { config: null, path: null };
|
||
|
|
}
|
||
|
|
|
||
|
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
||
|
|
if (pkg.apophis && typeof pkg.apophis === 'object') {
|
||
|
|
return { config: pkg.apophis as Config, path: pkgPath };
|
||
|
|
}
|
||
|
|
return { config: null, path: pkgPath };
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Config loading
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Load a config file by path.
|
||
|
|
* Supports .js, .ts (via dynamic import, assumes tsx available), and .json.
|
||
|
|
*/
|
||
|
|
export async function loadConfigFile(configPath: string): Promise<Config> {
|
||
|
|
if (configPath.endsWith('.json')) {
|
||
|
|
const content = readFileSync(configPath, 'utf-8');
|
||
|
|
return JSON.parse(content) as Config;
|
||
|
|
}
|
||
|
|
|
||
|
|
// For .js and .ts, use dynamic import.
|
||
|
|
// tsx handles .ts files in dev environments.
|
||
|
|
const fileUrl = pathToFileURL(configPath).href;
|
||
|
|
const mod = await import(fileUrl);
|
||
|
|
|
||
|
|
// Support both default export and direct export
|
||
|
|
const config = mod.default ?? mod;
|
||
|
|
|
||
|
|
if (!config || typeof config !== 'object') {
|
||
|
|
throw new Error(`Config file at ${configPath} must export an object`);
|
||
|
|
}
|
||
|
|
|
||
|
|
return config as Config;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Schema validation (unknown-key rejection)
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
export class ConfigValidationError extends Error {
|
||
|
|
constructor(
|
||
|
|
message: string,
|
||
|
|
public readonly path: string,
|
||
|
|
public readonly key: string,
|
||
|
|
public readonly value?: unknown,
|
||
|
|
public readonly guidance?: string,
|
||
|
|
) {
|
||
|
|
super(message);
|
||
|
|
this.name = 'ConfigValidationError';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get the appropriate schema for a dynamic container's child objects.
|
||
|
|
* Returns the schema to validate values inside profiles.<name>, presets.<name>, or environments.<name>.
|
||
|
|
*/
|
||
|
|
function getDynamicContainerSchema(path: string): Record<string, SchemaField> | null {
|
||
|
|
if (path === 'profiles') return PROFILE_SCHEMA;
|
||
|
|
if (path === 'presets') return PRESET_SCHEMA;
|
||
|
|
if (path === 'environments') return ENVIRONMENT_POLICY_SCHEMA;
|
||
|
|
if (path === 'generationProfiles') return GENERATION_PROFILE_ALIAS_SCHEMA;
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if a path is inside a dynamic container (e.g., profiles.foo, presets.bar).
|
||
|
|
*/
|
||
|
|
function isInsideDynamicContainer(path: string): boolean {
|
||
|
|
return path.startsWith('profiles.') || path.startsWith('presets.') || path.startsWith('environments.') || path.startsWith('generationProfiles.');
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Validate that a value matches the expected type for a schema field.
|
||
|
|
* Throws ConfigValidationError on type mismatch.
|
||
|
|
*/
|
||
|
|
function validateType(
|
||
|
|
fieldValue: unknown,
|
||
|
|
fieldSchema: SchemaField,
|
||
|
|
currentPath: string,
|
||
|
|
key: string,
|
||
|
|
): void {
|
||
|
|
// Null/undefined is only valid if optional
|
||
|
|
if (fieldValue === null || fieldValue === undefined) {
|
||
|
|
if (!fieldSchema.optional) {
|
||
|
|
throw new ConfigValidationError(
|
||
|
|
`Missing required config key at ${currentPath}`,
|
||
|
|
currentPath,
|
||
|
|
key,
|
||
|
|
fieldValue,
|
||
|
|
`This field is required. Provide a ${fieldSchema.type} value.`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const actualType = Array.isArray(fieldValue) ? 'array' : typeof fieldValue;
|
||
|
|
|
||
|
|
if (actualType !== fieldSchema.type) {
|
||
|
|
throw new ConfigValidationError(
|
||
|
|
`Invalid type at ${currentPath}: expected ${fieldSchema.type}, got ${actualType}`,
|
||
|
|
currentPath,
|
||
|
|
key,
|
||
|
|
fieldValue,
|
||
|
|
`Expected ${fieldSchema.type}. Received ${actualType === 'object' ? JSON.stringify(fieldValue) : String(fieldValue)}.`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate enum values
|
||
|
|
if (fieldSchema.enumValues && fieldSchema.type === 'string' && typeof fieldValue === 'string') {
|
||
|
|
if (!fieldSchema.enumValues.includes(fieldValue)) {
|
||
|
|
throw new ConfigValidationError(
|
||
|
|
`Invalid value at ${currentPath}: "${fieldValue}" is not a valid ${key}. Allowed: ${fieldSchema.enumValues.join(', ')}`,
|
||
|
|
currentPath,
|
||
|
|
key,
|
||
|
|
fieldValue,
|
||
|
|
`Must be one of: ${fieldSchema.enumValues.join(', ')}.`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate numeric constraints
|
||
|
|
if (fieldSchema.type === 'number' && typeof fieldValue === 'number') {
|
||
|
|
if (fieldSchema.min !== undefined && fieldValue < fieldSchema.min) {
|
||
|
|
throw new ConfigValidationError(
|
||
|
|
`Invalid value at ${currentPath}: ${fieldValue} is less than minimum ${fieldSchema.min}`,
|
||
|
|
currentPath,
|
||
|
|
key,
|
||
|
|
fieldValue,
|
||
|
|
`Must be >= ${fieldSchema.min}.`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate array item types
|
||
|
|
if (fieldSchema.type === 'array' && Array.isArray(fieldValue) && fieldSchema.items) {
|
||
|
|
for (let i = 0; i < fieldValue.length; i++) {
|
||
|
|
const item = fieldValue[i];
|
||
|
|
const itemPath = `${currentPath}[${i}]`;
|
||
|
|
const itemType = Array.isArray(item) ? 'array' : typeof item;
|
||
|
|
if (itemType !== fieldSchema.items.type) {
|
||
|
|
throw new ConfigValidationError(
|
||
|
|
`Invalid type at ${itemPath}: expected ${fieldSchema.items.type}, got ${itemType}`,
|
||
|
|
itemPath,
|
||
|
|
`${key}[${i}]`,
|
||
|
|
item,
|
||
|
|
`Array items must be ${fieldSchema.items.type}. Received ${itemType === 'object' ? JSON.stringify(item) : String(item)}.`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Recursively validate an object against a schema.
|
||
|
|
* Checks:
|
||
|
|
* - Unknown keys (hard failure)
|
||
|
|
* - Type mismatches (hard failure)
|
||
|
|
* - Enum value violations (hard failure)
|
||
|
|
* - Array item type mismatches (hard failure)
|
||
|
|
* - Numeric constraints (hard failure)
|
||
|
|
*
|
||
|
|
* Throws ConfigValidationError on any validation failure.
|
||
|
|
*/
|
||
|
|
export function validateConfigAgainstSchema(
|
||
|
|
value: unknown,
|
||
|
|
schema: Record<string, SchemaField>,
|
||
|
|
path: string = '',
|
||
|
|
): void {
|
||
|
|
if (value === null || typeof value !== 'object') {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const obj = value as Record<string, unknown>;
|
||
|
|
|
||
|
|
for (const key of Object.keys(obj)) {
|
||
|
|
const currentPath = path ? `${path}.${key}` : key;
|
||
|
|
const fieldSchema = schema[key];
|
||
|
|
|
||
|
|
// Handle dynamic containers: profiles, presets, environments
|
||
|
|
// The keys are user-defined names; their values have specific schemas
|
||
|
|
const isDynamicContainer = path === 'profiles' || path === 'presets' || path === 'environments' || path === 'generationProfiles';
|
||
|
|
if (!fieldSchema && isDynamicContainer) {
|
||
|
|
const childSchema = getDynamicContainerSchema(path);
|
||
|
|
const fieldValue = obj[key];
|
||
|
|
if (path === 'generationProfiles' && typeof fieldValue === 'string') {
|
||
|
|
validateType(
|
||
|
|
fieldValue,
|
||
|
|
{ type: 'string', optional: false, enumValues: ['quick', 'standard', 'thorough'] },
|
||
|
|
currentPath,
|
||
|
|
key,
|
||
|
|
);
|
||
|
|
} else if (childSchema && fieldValue !== null && typeof fieldValue === 'object') {
|
||
|
|
// Validate the dynamic container value against its specific schema
|
||
|
|
validateConfigAgainstSchema(fieldValue, childSchema, currentPath);
|
||
|
|
} else if (childSchema) {
|
||
|
|
// Value is a primitive inside a dynamic container — type check it
|
||
|
|
validateType(fieldValue, { type: 'object', optional: false }, currentPath, key);
|
||
|
|
}
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Handle nested keys inside profile/preset/environment objects
|
||
|
|
if (!fieldSchema && isInsideDynamicContainer(path)) {
|
||
|
|
const parentContainer = path.split('.')[0] || '';
|
||
|
|
const childSchema = getDynamicContainerSchema(parentContainer);
|
||
|
|
if (childSchema) {
|
||
|
|
const nestedSchema = childSchema[key];
|
||
|
|
if (nestedSchema) {
|
||
|
|
const fieldValue = obj[key];
|
||
|
|
validateType(fieldValue, nestedSchema, currentPath, key);
|
||
|
|
// Recurse into nested objects
|
||
|
|
if (nestedSchema.type === 'object' && fieldValue !== null && typeof fieldValue === 'object') {
|
||
|
|
if (nestedSchema.properties) {
|
||
|
|
validateConfigAgainstSchema(fieldValue, nestedSchema.properties, currentPath);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (nestedSchema.type === 'array' && Array.isArray(fieldValue) && nestedSchema.items?.properties) {
|
||
|
|
for (let i = 0; i < fieldValue.length; i++) {
|
||
|
|
const item = fieldValue[i];
|
||
|
|
if (item !== null && typeof item === 'object') {
|
||
|
|
validateConfigAgainstSchema(item, nestedSchema.items.properties, `${currentPath}[${i}]`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// Unknown key inside a profile/preset/environment object
|
||
|
|
throw new ConfigValidationError(
|
||
|
|
`Unknown config key at ${currentPath}`,
|
||
|
|
currentPath,
|
||
|
|
key,
|
||
|
|
obj[key],
|
||
|
|
`Valid keys for ${parentContainer} entries: ${Object.keys(childSchema || {}).join(', ')}.`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!fieldSchema) {
|
||
|
|
throw new ConfigValidationError(
|
||
|
|
`Unknown config key at ${currentPath}`,
|
||
|
|
currentPath,
|
||
|
|
key,
|
||
|
|
obj[key],
|
||
|
|
`Valid top-level keys: ${Object.keys(CONFIG_SCHEMA).join(', ')}.`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const fieldValue = obj[key];
|
||
|
|
|
||
|
|
// Validate type for known fields
|
||
|
|
validateType(fieldValue, fieldSchema, currentPath, key);
|
||
|
|
|
||
|
|
// Recurse into objects with known properties
|
||
|
|
if (fieldSchema.type === 'object') {
|
||
|
|
if (fieldValue !== null && typeof fieldValue === 'object') {
|
||
|
|
if (fieldSchema.properties) {
|
||
|
|
validateConfigAgainstSchema(fieldValue, fieldSchema.properties, currentPath);
|
||
|
|
} else {
|
||
|
|
// For objects without explicit properties (like profiles/presets/environments),
|
||
|
|
// we still recurse to validate nested objects, but we pass the same schema
|
||
|
|
// and the skip logic above will handle dynamic container keys
|
||
|
|
validateConfigAgainstSchema(fieldValue, schema, currentPath);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Recurse into array items if they are objects
|
||
|
|
if (fieldSchema.type === 'array' && fieldSchema.items && Array.isArray(fieldValue)) {
|
||
|
|
for (let i = 0; i < fieldValue.length; i++) {
|
||
|
|
const item = fieldValue[i];
|
||
|
|
if (item !== null && typeof item === 'object' && fieldSchema.items.properties) {
|
||
|
|
validateConfigAgainstSchema(item, fieldSchema.items.properties, `${currentPath}[${i}]`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Monorepo detection
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if cwd is inside a monorepo (has workspaces in root package.json).
|
||
|
|
*/
|
||
|
|
export function detectMonorepo(cwd: string): boolean {
|
||
|
|
let current = cwd;
|
||
|
|
while (current !== dirname(current)) {
|
||
|
|
const pkgPath = resolve(current, 'package.json');
|
||
|
|
if (existsSync(pkgPath)) {
|
||
|
|
try {
|
||
|
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
||
|
|
if (pkg.workspaces && Array.isArray(pkg.workspaces)) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
// Also check for pnpm workspaces
|
||
|
|
const pnpmWorkspacePath = resolve(current, 'pnpm-workspace.yaml');
|
||
|
|
if (existsSync(pnpmWorkspacePath)) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
// Ignore parse errors
|
||
|
|
}
|
||
|
|
// Stop at first package.json found
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
current = dirname(current);
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Find all workspace package directories under cwd.
|
||
|
|
* Supports npm workspaces (package.json workspaces field) and pnpm-workspace.yaml.
|
||
|
|
* Returns absolute paths to each package directory.
|
||
|
|
*/
|
||
|
|
export function findWorkspacePackages(cwd: string): string[] {
|
||
|
|
let root = cwd;
|
||
|
|
while (root !== dirname(root)) {
|
||
|
|
const pkgPath = resolve(root, 'package.json');
|
||
|
|
if (existsSync(pkgPath)) {
|
||
|
|
try {
|
||
|
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
||
|
|
if (pkg.workspaces && Array.isArray(pkg.workspaces)) {
|
||
|
|
return expandWorkspacePatterns(root, pkg.workspaces);
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
// Ignore parse errors
|
||
|
|
}
|
||
|
|
// Check for pnpm-workspace.yaml
|
||
|
|
const pnpmWorkspacePath = resolve(root, 'pnpm-workspace.yaml');
|
||
|
|
if (existsSync(pnpmWorkspacePath)) {
|
||
|
|
const patterns = parsePnpmWorkspaceYaml(pnpmWorkspacePath);
|
||
|
|
return expandWorkspacePatterns(root, patterns);
|
||
|
|
}
|
||
|
|
// Stop at first package.json found
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
root = dirname(root);
|
||
|
|
}
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
function expandWorkspacePatterns(root: string, patterns: string[]): string[] {
|
||
|
|
const packages: string[] = [];
|
||
|
|
for (const pattern of patterns) {
|
||
|
|
if (pattern.endsWith('/*')) {
|
||
|
|
const dir = pattern.slice(0, -2);
|
||
|
|
const dirPath = resolve(root, dir);
|
||
|
|
if (existsSync(dirPath)) {
|
||
|
|
const entries = readdirSync(dirPath, { withFileTypes: true });
|
||
|
|
for (const entry of entries) {
|
||
|
|
if (entry.isDirectory()) {
|
||
|
|
packages.push(resolve(dirPath, entry.name));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
const exactPath = resolve(root, pattern);
|
||
|
|
if (existsSync(exactPath)) {
|
||
|
|
const stat = statSync(exactPath);
|
||
|
|
if (stat.isDirectory()) {
|
||
|
|
packages.push(exactPath);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return packages;
|
||
|
|
}
|
||
|
|
|
||
|
|
function parsePnpmWorkspaceYaml(yamlPath: string): string[] {
|
||
|
|
try {
|
||
|
|
const content = readFileSync(yamlPath, 'utf-8');
|
||
|
|
const patterns: string[] = [];
|
||
|
|
for (const line of content.split('\n')) {
|
||
|
|
const trimmed = line.trim();
|
||
|
|
if (trimmed.startsWith('- ')) {
|
||
|
|
patterns.push(trimmed.slice(2).trim());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return patterns;
|
||
|
|
} catch {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Semantic validation (cross-references, value constraints)
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Validate semantic constraints that go beyond schema types:
|
||
|
|
* - Profile references nonexistent preset
|
||
|
|
* - Environment policy references unknown mode
|
||
|
|
* - Timeout is a positive number
|
||
|
|
* - Routes array elements are non-empty strings
|
||
|
|
* - Seed is an integer
|
||
|
|
*
|
||
|
|
* Throws ConfigValidationError on any semantic violation.
|
||
|
|
*/
|
||
|
|
export function validateConfigSemantics(config: Config): void {
|
||
|
|
// Validate profile references
|
||
|
|
if (config.profiles) {
|
||
|
|
for (const [profileName, profile] of Object.entries(config.profiles)) {
|
||
|
|
if (profile.preset) {
|
||
|
|
const availablePresets = config.presets ? Object.keys(config.presets) : [];
|
||
|
|
if (!config.presets || !(profile.preset in config.presets)) {
|
||
|
|
throw new ConfigValidationError(
|
||
|
|
`Profile "${profileName}" references unknown preset "${profile.preset}"`,
|
||
|
|
`profiles.${profileName}.preset`,
|
||
|
|
'preset',
|
||
|
|
profile.preset,
|
||
|
|
`Available presets: ${availablePresets.join(', ') || 'none'}. Define preset "${profile.preset}" in config.presets.`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate preset values
|
||
|
|
if (config.presets) {
|
||
|
|
for (const [presetName, preset] of Object.entries(config.presets)) {
|
||
|
|
if (preset.timeout !== undefined) {
|
||
|
|
if (typeof preset.timeout !== 'number' || preset.timeout < 0) {
|
||
|
|
throw new ConfigValidationError(
|
||
|
|
`Preset "${presetName}" has invalid timeout: ${preset.timeout}`,
|
||
|
|
`presets.${presetName}.timeout`,
|
||
|
|
'timeout',
|
||
|
|
preset.timeout,
|
||
|
|
`Timeout must be a non-negative number (milliseconds).`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (preset.depth !== undefined) {
|
||
|
|
const validDepths = ['quick', 'standard', 'deep'];
|
||
|
|
const depthValue = preset.depth;
|
||
|
|
if (typeof depthValue === 'string' && !validDepths.includes(depthValue as string)) {
|
||
|
|
throw new ConfigValidationError(
|
||
|
|
`Preset "${presetName}" has invalid depth: "${depthValue}"`,
|
||
|
|
`presets.${presetName}.depth`,
|
||
|
|
'depth',
|
||
|
|
depthValue,
|
||
|
|
`Must be one of: ${validDepths.join(', ')}.`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate environment policy allowedModes
|
||
|
|
if (config.environments) {
|
||
|
|
for (const [envName, envPolicy] of Object.entries(config.environments)) {
|
||
|
|
if (envPolicy.allowedModes) {
|
||
|
|
const validModes = ['verify', 'observe', 'qualify'];
|
||
|
|
for (const mode of envPolicy.allowedModes) {
|
||
|
|
if (!validModes.includes(mode)) {
|
||
|
|
throw new ConfigValidationError(
|
||
|
|
`Environment "${envName}" has invalid allowedMode: "${mode}"`,
|
||
|
|
`environments.${envName}.allowedModes`,
|
||
|
|
'allowedModes',
|
||
|
|
mode,
|
||
|
|
`Allowed modes must be one of: ${validModes.join(', ')}.`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate routes are non-empty strings
|
||
|
|
if (config.routes) {
|
||
|
|
for (let i = 0; i < config.routes.length; i++) {
|
||
|
|
const route = config.routes[i];
|
||
|
|
if (typeof route !== 'string' || route.trim().length === 0) {
|
||
|
|
throw new ConfigValidationError(
|
||
|
|
`Invalid route at routes[${i}]: ${JSON.stringify(route)}`,
|
||
|
|
`routes[${i}]`,
|
||
|
|
'routes',
|
||
|
|
route,
|
||
|
|
`Routes must be non-empty strings like "GET /users" or "POST /api/items".`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate seed is an integer
|
||
|
|
if (config.seed !== undefined) {
|
||
|
|
if (typeof config.seed !== 'number' || !Number.isInteger(config.seed)) {
|
||
|
|
throw new ConfigValidationError(
|
||
|
|
`Invalid seed: ${config.seed}`,
|
||
|
|
'seed',
|
||
|
|
'seed',
|
||
|
|
config.seed,
|
||
|
|
`Seed must be an integer number.`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Profile and preset resolution
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Resolve profile from config.profiles.
|
||
|
|
* Returns merged config: preset defaults + profile overrides.
|
||
|
|
*/
|
||
|
|
export function resolveProfile(
|
||
|
|
config: Config,
|
||
|
|
profileName: string | undefined,
|
||
|
|
): { config: Config; profileName: string | null; presetName: string | null } {
|
||
|
|
if (!profileName) {
|
||
|
|
return { config, profileName: null, presetName: config.preset ?? null };
|
||
|
|
}
|
||
|
|
|
||
|
|
const profiles = config.profiles ?? {};
|
||
|
|
const profile = profiles[profileName];
|
||
|
|
|
||
|
|
if (!profile) {
|
||
|
|
const available = Object.keys(profiles).join(', ');
|
||
|
|
throw new Error(
|
||
|
|
`Unknown profile "${profileName}". Available profiles: ${available || 'none'}.`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Start with preset if profile references one
|
||
|
|
let merged: Config = { ...config };
|
||
|
|
let presetName: string | null = null;
|
||
|
|
|
||
|
|
if (profile.preset && config.presets) {
|
||
|
|
const preset = config.presets[profile.preset];
|
||
|
|
if (preset) {
|
||
|
|
merged = { ...merged, ...preset };
|
||
|
|
presetName = profile.preset;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Apply profile overrides
|
||
|
|
merged = {
|
||
|
|
...merged,
|
||
|
|
...profile,
|
||
|
|
// Don't overwrite the top-level preset with the profile's preset string
|
||
|
|
preset: profile.preset ? undefined : merged.preset,
|
||
|
|
};
|
||
|
|
|
||
|
|
// Clean up undefined values
|
||
|
|
if (merged.preset === undefined) {
|
||
|
|
delete merged.preset;
|
||
|
|
}
|
||
|
|
|
||
|
|
return { config: merged, profileName, presetName };
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Environment-specific overrides
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Apply environment-specific policy overrides.
|
||
|
|
*/
|
||
|
|
export function applyEnvironmentOverrides(
|
||
|
|
config: Config,
|
||
|
|
env: string | undefined,
|
||
|
|
): Config {
|
||
|
|
if (!env || !config.environments) {
|
||
|
|
return config;
|
||
|
|
}
|
||
|
|
|
||
|
|
const envPolicy = config.environments[env];
|
||
|
|
if (!envPolicy) {
|
||
|
|
return config;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Environment policy doesn't override config values directly,
|
||
|
|
// but we merge it for policy engine consumption
|
||
|
|
return {
|
||
|
|
...config,
|
||
|
|
environments: {
|
||
|
|
...config.environments,
|
||
|
|
[env]: envPolicy,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Main entry point
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Load and resolve config for the CLI.
|
||
|
|
*
|
||
|
|
* Discovery order:
|
||
|
|
* 1. --config override
|
||
|
|
* 2. apophis.config.js
|
||
|
|
* 3. apophis.config.ts
|
||
|
|
* 4. apophis.config.json
|
||
|
|
* 5. "apophis" field in package.json
|
||
|
|
*
|
||
|
|
* Resolution order:
|
||
|
|
* 1. Load raw config
|
||
|
|
* 2. Validate against schema (unknown keys = hard failure)
|
||
|
|
* 3. Resolve profile (preset defaults + profile overrides)
|
||
|
|
* 4. Apply environment-specific overrides
|
||
|
|
* 5. Detect monorepo
|
||
|
|
*/
|
||
|
|
export async function loadConfig(options: LoadConfigOptions): Promise<LoadConfigResult> {
|
||
|
|
const { cwd, configPath: explicitPath, profileName, env } = options;
|
||
|
|
|
||
|
|
let configPath: string | null = null;
|
||
|
|
let rawConfig: Config;
|
||
|
|
|
||
|
|
// 1. Explicit --config override
|
||
|
|
if (explicitPath) {
|
||
|
|
const resolvedPath = resolve(cwd, explicitPath);
|
||
|
|
if (!existsSync(resolvedPath)) {
|
||
|
|
throw new Error(`Config file not found: ${resolvedPath}`);
|
||
|
|
}
|
||
|
|
configPath = resolvedPath;
|
||
|
|
rawConfig = await loadConfigFile(resolvedPath);
|
||
|
|
} else {
|
||
|
|
// 2. Discover config file
|
||
|
|
const discoveredPath = discoverConfig(cwd);
|
||
|
|
if (discoveredPath) {
|
||
|
|
configPath = discoveredPath;
|
||
|
|
rawConfig = await loadConfigFile(discoveredPath);
|
||
|
|
} else {
|
||
|
|
// 3. Check package.json "apophis" field
|
||
|
|
const pkgConfig = loadPackageJsonConfig(cwd);
|
||
|
|
if (pkgConfig.config) {
|
||
|
|
configPath = pkgConfig.path;
|
||
|
|
rawConfig = pkgConfig.config;
|
||
|
|
} else {
|
||
|
|
// No config found
|
||
|
|
return {
|
||
|
|
config: {},
|
||
|
|
configPath: null,
|
||
|
|
isMonorepo: detectMonorepo(cwd),
|
||
|
|
profileName: null,
|
||
|
|
presetName: null,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 4. Resolve protocol packs if specified
|
||
|
|
if (rawConfig.packs && Array.isArray(rawConfig.packs) && rawConfig.packs.length > 0) {
|
||
|
|
const packFragment = resolvePacks(rawConfig.packs as string[], {
|
||
|
|
seed: rawConfig.seed,
|
||
|
|
});
|
||
|
|
rawConfig = {
|
||
|
|
...packFragment,
|
||
|
|
...rawConfig,
|
||
|
|
profiles: {
|
||
|
|
...packFragment.profiles,
|
||
|
|
...rawConfig.profiles,
|
||
|
|
},
|
||
|
|
presets: {
|
||
|
|
...packFragment.presets,
|
||
|
|
...rawConfig.presets,
|
||
|
|
},
|
||
|
|
environments: {
|
||
|
|
...packFragment.environments,
|
||
|
|
...rawConfig.environments,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// 5. Validate against schema (unknown keys = hard failure with exact path)
|
||
|
|
validateConfigAgainstSchema(rawConfig, CONFIG_SCHEMA);
|
||
|
|
|
||
|
|
// 5b. Validate semantic constraints (cross-references, value constraints)
|
||
|
|
validateConfigSemantics(rawConfig);
|
||
|
|
|
||
|
|
// 5. Resolve profile and preset
|
||
|
|
const { config: profiledConfig, profileName: resolvedProfile, presetName } = resolveProfile(
|
||
|
|
rawConfig,
|
||
|
|
profileName,
|
||
|
|
);
|
||
|
|
|
||
|
|
// 6. Apply environment overrides
|
||
|
|
const envConfig = applyEnvironmentOverrides(profiledConfig, env);
|
||
|
|
|
||
|
|
// 7. Detect monorepo
|
||
|
|
const isMonorepo = detectMonorepo(cwd);
|
||
|
|
|
||
|
|
return {
|
||
|
|
config: envConfig,
|
||
|
|
configPath,
|
||
|
|
isMonorepo,
|
||
|
|
profileName: resolvedProfile,
|
||
|
|
presetName,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Re-export for convenience
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
export { CONFIG_SCHEMA };
|