/** * Configuration Service * * Centralized service for loading, validating, and managing Wave configuration files. * Replaces distributed configuration logic previously embedded in hook.ts. */ import { readFileSync, existsSync, promises as fs } from "fs"; import * as path from "path"; import * as os from "os"; import { isValidHookEvent } from "../types/hooks.js"; import { logger } from "../utils/globalLogger.js"; import type { ConfigurationLoadResult, ValidationResult, ConfigurationPaths, WaveConfiguration, Scope, MarketplaceConfig, } from "../types/configuration.js"; import { getAllConfigPaths, getExistingConfigPaths, getUserConfigPaths, getProjectConfigPaths, } from "../utils/configPaths.js"; import { type EnvironmentValidationResult, type MergedEnvironmentContext, type EnvironmentMergeOptions, isValidEnvironmentVars, } from "../types/environment.js"; import { GatewayConfig, ModelConfig, PermissionMode, AgentOptions, } from "../types/index.js"; import { DEFAULT_WAVE_MAX_INPUT_TOKENS, DEFAULT_WAVE_MAX_OUTPUT_TOKENS, DEFAULT_SERVER_URL, } from "../utils/constants.js"; import { ClientOptions } from "openai"; import { parseCustomHeaders } from "../utils/stringUtils.js"; import { getRemoteSettingsSync, mergeRemoteSettings, } from "./remoteSettingsService.js"; import { createAuthAwareFetch } from "./authService.js"; import { ensureWaveRuntimeFilesExcluded } from "../utils/gitUtils.js"; import { atomicWriteFile } from "../utils/atomicWrite.js"; /** * Default ConfigurationService implementation * * Provides centralized configuration loading, validation, and management. * Extracted from distributed logic in hook.ts with improved error handling. */ export class ConfigurationService { private currentConfiguration: WaveConfiguration | null = null; private options: AgentOptions = {}; private _configuredEnvKeys = new Set(); /** * Set agent options for configuration resolution */ setOptions(options: AgentOptions): void { this.options = options; } // Core loading operations /** * Load and merge configuration with comprehensive validation */ async loadMergedConfiguration( workdir: string, ): Promise { try { // Use the merged configuration function (this loads user and project configs internally) const mergedConfig = loadMergedWaveConfig(workdir); if (!mergedConfig) { // Still set system environment variables even if no config is found const env = { WAVE_PROJECT_DIR: workdir }; this.setEnvironmentVars(env); return { configuration: { env }, success: true, // No config is valid warnings: [ "No configuration files found in user or project directories", ], }; } // Comprehensive validation const validation = this.validateConfiguration(mergedConfig); if (!validation.isValid) { return { configuration: null, success: false, error: `Merged configuration validation failed: ${validation.errors.join(", ")}`, warnings: validation.warnings, }; } // Merge remote settings (highest priority: Remote > Local > Project > User) const remoteSettings = getRemoteSettingsSync(); const finalConfig = remoteSettings ? mergeRemoteSettings(mergedConfig, remoteSettings) : mergedConfig; // Success case this.currentConfiguration = finalConfig; // Set environment variables from merged config and inject system variables const env = { ...(finalConfig.env || {}), WAVE_PROJECT_DIR: workdir, }; this.setEnvironmentVars(env); finalConfig.env = env; return { configuration: finalConfig, success: true, sourcePath: "merged configuration", warnings: validation.warnings, }; } catch (error) { return { configuration: null, success: false, error: `Failed to load merged configuration from ${workdir}: ${(error as Error).message}`, warnings: [], }; } } // Validation operations /** * Validate configuration object structure and values */ validateConfiguration(config: WaveConfiguration): ValidationResult { const result: ValidationResult = { isValid: true, errors: [], warnings: [], }; // Validate basic structure if (!config || typeof config !== "object") { result.isValid = false; result.errors.push("Configuration must be a valid object"); return result; } // Validate hooks if present if (config.hooks !== undefined) { if (typeof config.hooks !== "object" || config.hooks === null) { result.isValid = false; result.errors.push("Hooks configuration must be an object"); } else { for (const [event, eventConfigs] of Object.entries(config.hooks)) { if (!isValidHookEvent(event)) { result.warnings.push(`Unknown hook event: ${event}`); continue; } if (!Array.isArray(eventConfigs)) { result.isValid = false; result.errors.push(`Hook event '${event}' must be an array`); continue; } // Validate individual hook configurations for (let i = 0; i < eventConfigs.length; i++) { const hookConfig = eventConfigs[i]; if (!hookConfig || typeof hookConfig !== "object") { result.isValid = false; result.errors.push( `Hook configuration ${i} for event '${event}' must be an object`, ); } } } } } // Validate enabledPlugins if present if (config.enabledPlugins !== undefined) { if ( typeof config.enabledPlugins !== "object" || config.enabledPlugins === null ) { result.isValid = false; result.errors.push("enabledPlugins configuration must be an object"); } else { for (const [pluginId, enabled] of Object.entries( config.enabledPlugins, )) { if (typeof enabled !== "boolean") { result.isValid = false; result.errors.push( `Value for plugin '${pluginId}' in enabledPlugins must be a boolean`, ); } if (!pluginId.includes("@")) { result.warnings.push( `Plugin ID '${pluginId}' in enabledPlugins should follow 'name@marketplace' format`, ); } } } } // Validate environment variables if present if (config.env !== undefined) { const envValidation = validateEnvironmentConfig(config.env); if (!envValidation.isValid) { result.isValid = false; result.errors.push(...envValidation.errors); } result.warnings.push(...envValidation.warnings); } // Validate permissions if present if (config.permissions !== undefined) { if ( typeof config.permissions !== "object" || config.permissions === null ) { result.isValid = false; result.errors.push("Permissions configuration must be an object"); } else { // Validate allow if present if (config.permissions.allow !== undefined) { if (!Array.isArray(config.permissions.allow)) { result.isValid = false; result.errors.push("Permissions allow must be an array of strings"); } else if ( !config.permissions.allow.every((rule) => typeof rule === "string") ) { result.isValid = false; result.errors.push("All permission allow rules must be strings"); } } // Validate deny if present if (config.permissions.deny !== undefined) { if (!Array.isArray(config.permissions.deny)) { result.isValid = false; result.errors.push("Permissions deny must be an array of strings"); } else if ( !config.permissions.deny.every((rule) => typeof rule === "string") ) { result.isValid = false; result.errors.push("All permission deny rules must be strings"); } } // Validate permissionMode if present if (config.permissions.permissionMode !== undefined) { const validModes: PermissionMode[] = [ "default", "bypassPermissions", "acceptEdits", "plan", "dontAsk", ]; if (!validModes.includes(config.permissions.permissionMode)) { result.isValid = false; result.errors.push( `Invalid permissionMode: "${config.permissions.permissionMode}". Must be one of: ${validModes.join(", ")}`, ); } } } } // Validate autoMemoryEnabled if present if ( config.autoMemoryEnabled !== undefined && typeof config.autoMemoryEnabled !== "boolean" ) { result.isValid = false; result.errors.push("autoMemoryEnabled configuration must be a boolean"); } // Validate autoMemoryFrequency if present if ( config.autoMemoryFrequency !== undefined && (typeof config.autoMemoryFrequency !== "number" || config.autoMemoryFrequency <= 0) ) { result.isValid = false; result.errors.push( "autoMemoryFrequency configuration must be a positive number", ); } // Validate models if present if (config.models !== undefined) { if (typeof config.models !== "object" || config.models === null) { result.isValid = false; result.errors.push("models configuration must be an object"); } else { for (const [modelName, modelConfig] of Object.entries(config.models)) { if (typeof modelConfig !== "object" || modelConfig === null) { result.isValid = false; result.errors.push( `Configuration for model '${modelName}' must be an object`, ); } } } } return result; } /** * Validate configuration file without loading */ validateConfigurationFile(filePath: string): ValidationResult { const result: ValidationResult = { isValid: true, errors: [], warnings: [], }; if (!existsSync(filePath)) { result.isValid = false; result.errors.push(`Configuration file not found: ${filePath}`); return result; } try { const content = readFileSync(filePath, "utf-8"); const config = JSON.parse(content) as WaveConfiguration; // Use the main validation method const configValidation = this.validateConfiguration(config); result.isValid = configValidation.isValid; result.errors = configValidation.errors; result.warnings = configValidation.warnings; } catch (error) { result.isValid = false; if (error instanceof SyntaxError) { result.errors.push( `Invalid JSON syntax in ${filePath}: ${error.message}`, ); } else { result.errors.push( `Error reading configuration file ${filePath}: ${(error as Error).message}`, ); } } return result; } // Utility operations /** * Set environment variables from configuration * This replaces direct process.env modification */ setEnvironmentVars(env: Record): void { for (const [key, value] of Object.entries(env)) { if (process.env[key] !== undefined && !this._configuredEnvKeys.has(key)) { logger.warn(`Overriding environment variable: ${key}`); } process.env[key] = value; this._configuredEnvKeys.add(key); } } // ============================================================================= // Configuration Resolution Methods (merged from configResolver.ts) // ============================================================================= /** * Read SSO token from ~/.wave/auth.json */ private readSSOToken(): string | undefined { const homeDir = os.homedir(); const authPath = path.join(homeDir, ".wave", "auth.json"); if (!existsSync(authPath)) { return undefined; } try { const content = readFileSync(authPath, "utf-8"); const authConfig = JSON.parse(content) as { SSO_TOKEN?: string }; return authConfig.SSO_TOKEN; } catch { return undefined; } } /** * Resolves gateway configuration from constructor args and environment * Resolution priority: SSO_TOKEN > options > env (from settings.json) > process.env > error * @param apiKey - API key override (optional) * @param baseURL - Base URL override (optional) * @param defaultHeaders - HTTP headers override (optional) * @param fetchOptions - Fetch options override (optional) * @param fetch - Custom fetch implementation override (optional) * @returns Resolved gateway configuration * @returns Resolved model configuration (model/fastModel may be undefined if not yet configured) */ resolveGatewayConfig( apiKey?: string, baseURL?: string, defaultHeaders?: Record, fetchOptions?: ClientOptions["fetchOptions"], fetch?: ClientOptions["fetch"], ): GatewayConfig { // Check for SSO token first - if present and server URL is available, use SSO mode // Server URL resolution: options > process.env > default const ssoToken = this.readSSOToken(); const serverUrl = this.options.serverUrl || process.env.WAVE_SERVER_URL || DEFAULT_SERVER_URL; if (ssoToken && serverUrl) { const baseFetch = fetch ?? this.options.fetch ?? globalThis.fetch; const authAwareFetch = createAuthAwareFetch( baseFetch as typeof globalThis.fetch, ) as ClientOptions["fetch"]; return { apiKey: ssoToken, baseURL: `${serverUrl}/api/v1`, defaultHeaders: Object.keys(defaultHeaders || {}).length > 0 ? defaultHeaders : undefined, fetchOptions: fetchOptions ?? this.options.fetchOptions, fetch: authAwareFetch, }; } // Resolve API key: override > options > env (settings.json) > process.env // Note: Explicitly provided empty strings should be treated as invalid, not fall back to env let resolvedApiKey: string | undefined; if (apiKey !== undefined) { resolvedApiKey = apiKey; } else if (this.options.apiKey !== undefined) { resolvedApiKey = this.options.apiKey; } else { resolvedApiKey = process.env.WAVE_API_KEY; } // Resolve base URL: override > options > env (settings.json) > process.env // Note: Explicitly provided empty strings should be treated as invalid, not fall back to env let resolvedBaseURL: string | undefined; if (baseURL !== undefined) { resolvedBaseURL = baseURL; } else if (this.options.baseURL !== undefined) { resolvedBaseURL = this.options.baseURL; } else { resolvedBaseURL = process.env.WAVE_BASE_URL; } // Fallback to process.env if still not resolved (for dynamic updates in tests) if (resolvedApiKey === undefined) { resolvedApiKey = process.env.WAVE_API_KEY; } if (!resolvedBaseURL) { resolvedBaseURL = process.env.WAVE_BASE_URL; } // Treat empty string as not provided if (resolvedBaseURL?.trim() === "") { resolvedBaseURL = undefined; } // Resolve custom headers from environment: env (settings.json) > process.env const envCustomHeaders = process.env.WAVE_CUSTOM_HEADERS || ""; const parsedEnvHeaders = parseCustomHeaders(envCustomHeaders); // Merge headers: env headers < options < override const resolvedHeaders = { ...parsedEnvHeaders, ...this.options.defaultHeaders, ...defaultHeaders, }; return { apiKey: resolvedApiKey, baseURL: resolvedBaseURL, defaultHeaders: Object.keys(resolvedHeaders).length > 0 ? resolvedHeaders : undefined, fetchOptions: fetchOptions ?? this.options.fetchOptions, fetch: fetch ?? this.options.fetch, }; } /** * Resolves model configuration with fallbacks * Resolution priority: override > options > env (from settings.json) > process.env > default * @param model - Agent model override (optional) * @param fastModel - Fast model override (optional) * @param maxTokens - Max output tokens override (optional) * @param permissionMode - Permission mode override (optional) * @returns Resolved model configuration with defaults */ resolveModelConfig( model?: string, fastModel?: string, maxTokens?: number, permissionMode?: PermissionMode, ): ModelConfig { // Resolve agent model: override > options > currentConfiguration (settings.json model, possibly remote-merged) > process.env // Priority: user's explicit model field > admin's env.WAVE_MODEL default. // If admin wants hard enforcement, they set the `model` scalar field (overwrites local in mergeRemoteSettings). const resolvedAgentModel = model || this.options.model || this.currentConfiguration?.model || process.env.WAVE_MODEL; // Resolve fast model: override > options > process.env (includes settings.json env) const resolvedFastModel = fastModel || this.options.fastModel || process.env.WAVE_FAST_MODEL; // Resolve max output tokens const resolvedMaxTokens = this.resolveMaxOutputTokens(maxTokens); const baseConfig: ModelConfig = { model: resolvedAgentModel, fastModel: resolvedFastModel, maxTokens: resolvedMaxTokens, permissionMode: permissionMode ?? this.options.permissionMode, }; // Resolve fast model generation params from models[fastModel].options. // Set on baseConfig before the modelSpecificConfig spread so it isn't overwritten. const fastModelSource = resolvedFastModel && this.currentConfiguration?.models?.[resolvedFastModel]; if (fastModelSource && fastModelSource.options) { baseConfig.fastModelOptions = fastModelSource.options; } // Merge model-specific settings from configuration const modelSpecificConfig = resolvedAgentModel && this.currentConfiguration?.models?.[resolvedAgentModel]; if (modelSpecificConfig) { return { ...baseConfig, ...modelSpecificConfig, }; } return baseConfig; } /** * Resolves token limit with fallbacks * Resolution priority: override > options > env (from settings.json) > process.env > default * @param constructorLimit - Token limit override (optional) * @returns Resolved token limit */ resolveMaxInputTokens(constructorLimit?: number): number { // If override value provided, use it if (constructorLimit !== undefined) { return constructorLimit; } // If options value provided, use it if (this.options.maxInputTokens !== undefined) { return this.options.maxInputTokens; } // Try env (settings.json) first, then process.env const envMaxInputTokens = process.env.WAVE_MAX_INPUT_TOKENS; if (envMaxInputTokens) { const parsed = parseInt(envMaxInputTokens, 10); if (!isNaN(parsed)) { return parsed; } } // Use default return DEFAULT_WAVE_MAX_INPUT_TOKENS; } /** * Resolves preferred language with fallbacks * Resolution priority: override > options > settings.json > undefined * @param constructorLanguage - Language override (optional) * @returns Resolved language or undefined */ resolveLanguage(constructorLanguage?: string): string | undefined { // 1. Override (highest priority) if (constructorLanguage !== undefined) { return constructorLanguage; } // 2. Agent options if (this.options.language !== undefined) { return this.options.language; } // 2. settings.json (merged) if (this.currentConfiguration?.language) { return this.currentConfiguration.language; } return undefined; } /** * Resolves auto-memory enabled state with fallbacks * Resolution priority: settings.json > WAVE_DISABLE_AUTO_MEMORY > default (true) * @returns Resolved auto-memory enabled state */ resolveAutoMemoryEnabled(): boolean { // 1. settings.json (merged) if (this.currentConfiguration?.autoMemoryEnabled !== undefined) { return this.currentConfiguration.autoMemoryEnabled; } // 2. WAVE_DISABLE_AUTO_MEMORY environment variable const disableAutoMemory = process.env.WAVE_DISABLE_AUTO_MEMORY || process.env.WAVE_DISABLE_AUTO_MEMORY; if (disableAutoMemory === "1" || disableAutoMemory === "true") { return false; } // 3. Default (true) return true; } /** * Resolves auto-memory extraction frequency with fallbacks * Resolution priority: settings.json > WAVE_AUTO_MEMORY_FREQUENCY > default (1) * @returns Resolved auto-memory extraction frequency (turns) */ resolveAutoMemoryFrequency(): number { // 1. settings.json (merged) if (this.currentConfiguration?.autoMemoryFrequency !== undefined) { return this.currentConfiguration.autoMemoryFrequency; } // 2. WAVE_AUTO_MEMORY_FREQUENCY environment variable const envFrequency = process.env.WAVE_AUTO_MEMORY_FREQUENCY || process.env.WAVE_AUTO_MEMORY_FREQUENCY; if (envFrequency) { const parsed = parseInt(envFrequency, 10); if (!isNaN(parsed) && parsed > 0) { return parsed; } } // 3. Default (1) return 1; } /** * Resolves max output tokens with fallbacks * Resolution priority: override > options > env (from settings.json) > process.env > default * @param constructorLimit - Max output tokens override (optional) * @returns Resolved max output tokens */ resolveMaxOutputTokens(constructorLimit?: number): number { // If override value provided, use it if (constructorLimit !== undefined) { return constructorLimit; } // If options value provided, use it if (this.options.maxTokens !== undefined) { return this.options.maxTokens; } // Try env (settings.json) first, then process.env const envMaxOutputTokens = process.env.WAVE_MAX_OUTPUT_TOKENS; if (envMaxOutputTokens) { const parsed = parseInt(envMaxOutputTokens, 10); if (!isNaN(parsed) && parsed > 0) { return parsed; } } // Use default return DEFAULT_WAVE_MAX_OUTPUT_TOKENS; } /** * Set the active model in the session */ setModel(model: string): void { this.options.model = model; this.persistModelToSettings(model); } private async persistModelToSettings(model: string): Promise { const configPath = getUserConfigPaths()[0]; // ~/.wave/settings.json const configDir = path.dirname(configPath); if (!existsSync(configDir)) { await fs.mkdir(configDir, { recursive: true }); } let config: WaveConfiguration = {}; if (existsSync(configPath)) { try { const content = await fs.readFile(configPath, "utf-8"); config = JSON.parse(content); } catch { // Start fresh if corrupted } } config.model = model; await atomicWriteFile(configPath, JSON.stringify(config, null, 2)); } /** * Get all configured models from settings.json and environment */ getConfiguredModels(): string[] { const models = new Set(); // Add current model from options or environment const currentModel = this.options.model || process.env.WAVE_MODEL; if (currentModel) { models.add(currentModel); } // Persisted model from settings (includes remote-merged) if (this.currentConfiguration?.model) { models.add(this.currentConfiguration.model); } // Add models from merged configuration if (this.currentConfiguration?.models) { Object.keys(this.currentConfiguration.models).forEach((model) => { models.add(model); }); } return Array.from(models); } /** * Get the telemetry config from the loaded settings. */ resolveTelemetryConfig(): | Partial | undefined { return this.currentConfiguration?.monitoring?.telemetry; } /** * Resolve all configuration file paths */ getConfigurationPaths(workdir: string): ConfigurationPaths { const allPaths = getAllConfigPaths(workdir); const existingPaths = getExistingConfigPaths(workdir); return { userPaths: allPaths.userPaths, projectPaths: allPaths.projectPaths, builtinPaths: allPaths.builtinPaths, allPaths: allPaths.allPaths, existingPaths: existingPaths.existingPaths, }; } /** * Add a permission rule to the local settings.local.json */ async addAllowedRule(workdir: string, rule: string): Promise { const localConfigPath = path.join(workdir, ".wave", "settings.local.json"); // Ensure .wave directory exists const waveDir = path.join(workdir, ".wave"); ensureWaveRuntimeFilesExcluded(workdir); if (!existsSync(waveDir)) { await fs.mkdir(waveDir, { recursive: true }); } let config: WaveConfiguration = {}; if (existsSync(localConfigPath)) { try { const content = await fs.readFile(localConfigPath, "utf-8"); config = JSON.parse(content); } catch { // If file is corrupted, start with empty config } } if (!config.permissions) { config.permissions = {}; } if (!config.permissions.allow) { config.permissions.allow = []; } if (!config.permissions.allow.includes(rule)) { config.permissions.allow.push(rule); await atomicWriteFile(localConfigPath, JSON.stringify(config, null, 2)); } } /** * Update the enabled state of a plugin in the specified scope */ async updateEnabledPlugin( workdir: string, scope: Scope, pluginId: string, enabled: boolean, ): Promise { if (scope !== "user" && !existsSync(workdir)) { throw new Error(`Working directory does not exist: ${workdir}`); } let configPath: string; if (scope === "user") { configPath = getUserConfigPaths()[0]; // settings.json } else if (scope === "project") { configPath = getProjectConfigPaths(workdir)[1]; // settings.json } else { configPath = getProjectConfigPaths(workdir)[0]; // local settings.local.json } // Ensure directory exists const configDir = path.dirname(configPath); if (!existsSync(configDir)) { await fs.mkdir(configDir, { recursive: true }); } let config: WaveConfiguration = {}; if (existsSync(configPath)) { try { const content = await fs.readFile(configPath, "utf-8"); config = JSON.parse(content); } catch { // Start with empty config if file is corrupted } } if (!config.enabledPlugins) { config.enabledPlugins = {}; } config.enabledPlugins[pluginId] = enabled; await atomicWriteFile(configPath, JSON.stringify(config, null, 2)); } /** * Get merged marketplaces from all scopes */ getMergedMarketplaces(workdir: string): Record { const mergedConfig = loadMergedWaveConfig(workdir); return mergedConfig?.marketplaces || {}; } /** * Get marketplaces at a specific scope */ getScopedMarketplaces( workdir: string, scope: Scope, ): Record { let configPath: string; if (scope === "user") { configPath = getUserConfigPaths()[0]; } else if (scope === "project") { configPath = getProjectConfigPaths(workdir)[1]; } else { configPath = getProjectConfigPaths(workdir)[0]; } const config = loadWaveConfigFromFile(configPath); return config?.marketplaces || {}; } /** * Add a marketplace to the specified scope */ async addMarketplaceToScope( workdir: string, scope: Scope, name: string, config: MarketplaceConfig, ): Promise { if (scope !== "user" && !existsSync(workdir)) { throw new Error(`Working directory does not exist: ${workdir}`); } let configPath: string; if (scope === "user") { configPath = getUserConfigPaths()[0]; } else if (scope === "project") { configPath = getProjectConfigPaths(workdir)[1]; } else { configPath = getProjectConfigPaths(workdir)[0]; } const configDir = path.dirname(configPath); if (!existsSync(configDir)) { await fs.mkdir(configDir, { recursive: true }); } let fileConfig: WaveConfiguration = {}; if (existsSync(configPath)) { try { const content = await fs.readFile(configPath, "utf-8"); fileConfig = JSON.parse(content); } catch { // Start with empty config if file is corrupted } } if (!fileConfig.marketplaces) { fileConfig.marketplaces = {}; } fileConfig.marketplaces[name] = config; await atomicWriteFile(configPath, JSON.stringify(fileConfig, null, 2)); } /** * Remove a marketplace from the specified scope */ async removeMarketplaceFromScope( workdir: string, scope: Scope, name: string, ): Promise { if (scope !== "user" && !existsSync(workdir)) { throw new Error(`Working directory does not exist: ${workdir}`); } let configPath: string; if (scope === "user") { configPath = getUserConfigPaths()[0]; } else if (scope === "project") { configPath = getProjectConfigPaths(workdir)[1]; } else { configPath = getProjectConfigPaths(workdir)[0]; } if (!existsSync(configPath)) { return; } try { const content = await fs.readFile(configPath, "utf-8"); const fileConfig: WaveConfiguration = JSON.parse(content); if (fileConfig.marketplaces && name in fileConfig.marketplaces) { delete fileConfig.marketplaces[name]; await atomicWriteFile(configPath, JSON.stringify(fileConfig, null, 2)); } } catch { // Ignore errors for corrupted or non-existent files } } /** * Remove a plugin from the enabled plugins in the specified scope */ async removeEnabledPlugin( workdir: string, scope: Scope, pluginId: string, ): Promise { if (scope !== "user" && !existsSync(workdir)) { throw new Error(`Working directory does not exist: ${workdir}`); } let configPath: string; if (scope === "user") { configPath = getUserConfigPaths()[0]; // settings.json } else if (scope === "project") { configPath = getProjectConfigPaths(workdir)[1]; // settings.json } else { configPath = getProjectConfigPaths(workdir)[0]; // local settings.local.json } if (!existsSync(configPath)) { return; // Nothing to remove } try { const content = await fs.readFile(configPath, "utf-8"); const config: WaveConfiguration = JSON.parse(content); if (config.enabledPlugins && pluginId in config.enabledPlugins) { delete config.enabledPlugins[pluginId]; await atomicWriteFile(configPath, JSON.stringify(config, null, 2)); } } catch { // Ignore errors for corrupted or non-existent files } } /** * Get merged enabled plugins from all scopes */ getMergedEnabledPlugins(workdir: string): Record { const mergedConfig = loadMergedWaveConfig(workdir); return mergedConfig?.enabledPlugins || {}; } /** * Load Wave configuration from a JSON file * Supports both hooks and environment variables with proper validation */ loadWaveConfigFromFile(filePath: string): WaveConfiguration | null { return loadWaveConfigFromFile(filePath); } } // ============================================================================= // Extracted Configuration Functions // ============================================================================= /** * Validate environment variable configuration */ export function validateEnvironmentConfig( env: unknown, configPath?: string, ): EnvironmentValidationResult { const result: EnvironmentValidationResult = { isValid: true, errors: [], warnings: [], }; // Check if env is defined if (env === undefined || env === null) { return result; // undefined/null env is valid (means no env vars) } // Validate that env is a Record if (!isValidEnvironmentVars(env)) { result.isValid = false; result.errors.push( `Invalid env field format${configPath ? ` in ${configPath}` : ""}. Environment variables must be a Record.`, ); return result; } // Additional validation for environment variable names const envVars = env as Record; for (const [key, value] of Object.entries(envVars)) { // Check for valid environment variable naming convention if (!/^[A-Z_][A-Z0-9_]*$/i.test(key)) { result.warnings.push( `Environment variable '${key}' does not follow standard naming convention (alphanumeric and underscores only).`, ); } // Check for empty values if (value === "") { result.warnings.push(`Environment variable '${key}' has an empty value.`); } // Check for reserved variable names that might cause conflicts const reservedNames = [ "PATH", "HOME", "USER", "PWD", "SHELL", "TERM", "NODE_ENV", ]; if (reservedNames.includes(key.toUpperCase())) { result.warnings.push( `Environment variable '${key}' overrides a system variable, which may cause unexpected behavior.`, ); } } return result; } /** * Merge environment configurations with project taking precedence over user */ export function mergeEnvironmentConfig( userEnv: Record | undefined, projectEnv: Record | undefined, options: EnvironmentMergeOptions = {}, ): MergedEnvironmentContext { const userVars = userEnv || {}; const projectVars = projectEnv || {}; const mergedVars: Record = {}; const conflicts: MergedEnvironmentContext["conflicts"] = []; // Start with user environment variables Object.assign(mergedVars, userVars); // Override with project environment variables and track conflicts for (const [key, projectValue] of Object.entries(projectVars)) { const userValue = userVars[key]; if ( userValue !== undefined && userValue !== projectValue && options.includeConflictWarnings !== false ) { // Conflict detected - project value takes precedence conflicts.push({ key, userValue, projectValue, resolvedValue: projectValue, }); } mergedVars[key] = projectValue; } return { userVars, projectVars, mergedVars, conflicts, }; } /** * Load Wave configuration from a JSON file * Supports both hooks and environment variables with proper validation */ export function loadWaveConfigFromFile( filePath: string, ): WaveConfiguration | null { if (!existsSync(filePath)) { return null; } try { const content = readFileSync(filePath, "utf-8"); // Tolerate empty/whitespace-only files: a concurrent writer may have the // file truncated mid-write. Returning null lets callers skip this source // instead of crashing (consistent with the "file not found" path). if (content.trim() === "") { logger.warn(`Empty configuration file (likely mid-write): ${filePath}`); return null; } const config = JSON.parse(content) as WaveConfiguration; return { hooks: config.hooks || undefined, env: config.env || undefined, permissions: config.permissions || undefined, enabledPlugins: config.enabledPlugins || undefined, language: config.language || undefined, model: config.model || undefined, autoMemoryEnabled: config.autoMemoryEnabled !== undefined ? config.autoMemoryEnabled : undefined, models: config.models || undefined, marketplaces: config.marketplaces || undefined, }; } catch (error) { if (error instanceof SyntaxError) { // Tolerate corrupt JSON: a concurrent writer may leave the file in a // partially-written state. Warn and return null so callers skip this // source instead of crashing. logger.warn(`Invalid JSON syntax in ${filePath}: ${error.message}`); return null; } // Re-throw other errors as-is throw error; } } /** * Load and merge Wave configuration from both user and project sources * Project configuration takes precedence over user configuration * Checks .local.json files first, then falls back to .json files */ export function loadMergedWaveConfig( workdir: string, ): WaveConfiguration | null { const userPaths = getUserConfigPaths(); // [json] const projectPaths = getProjectConfigPaths(workdir); // [local, json] // Priority order (lowest to highest): // user settings.json -> project settings.json -> local settings.local.json const pathsToLoad = [ userPaths[0], // user settings.json projectPaths[1], // project settings.json projectPaths[0], // local settings.local.json ]; const configs: WaveConfiguration[] = []; for (const path of pathsToLoad) { const config = loadWaveConfigFromFile(path); if (config) { configs.push(config); } } if (configs.length === 0) { return null; } // Merge all configurations in order const mergedConfig: WaveConfiguration = {}; for (const config of configs) { // Merge hooks if (config.hooks) { if (!mergedConfig.hooks) mergedConfig.hooks = {}; for (const [event, eventConfigs] of Object.entries(config.hooks)) { if (!isValidHookEvent(event)) continue; if (!mergedConfig.hooks[event]) mergedConfig.hooks[event] = []; mergedConfig.hooks[event]!.push(...eventConfigs); } } // Merge env if (config.env) { const environmentContext = mergeEnvironmentConfig( mergedConfig.env, config.env, { includeConflictWarnings: true }, ); mergedConfig.env = environmentContext.mergedVars; } // Merge permissions if (config.permissions) { if (!mergedConfig.permissions) mergedConfig.permissions = {}; // Merge allow rules if (config.permissions.allow) { if (!mergedConfig.permissions.allow) mergedConfig.permissions.allow = []; mergedConfig.permissions.allow = [ ...new Set([ ...mergedConfig.permissions.allow, ...config.permissions.allow, ]), ]; } // Merge deny rules if (config.permissions.deny) { if (!mergedConfig.permissions.deny) mergedConfig.permissions.deny = []; mergedConfig.permissions.deny = [ ...new Set([ ...mergedConfig.permissions.deny, ...config.permissions.deny, ]), ]; } // Merge permissionMode (last one wins) if (config.permissions.permissionMode !== undefined) { mergedConfig.permissions.permissionMode = config.permissions.permissionMode; } // Merge additionalDirectories if (config.permissions.additionalDirectories) { if (!mergedConfig.permissions.additionalDirectories) mergedConfig.permissions.additionalDirectories = []; mergedConfig.permissions.additionalDirectories = [ ...new Set([ ...mergedConfig.permissions.additionalDirectories, ...config.permissions.additionalDirectories, ]), ]; } } // Merge enabledPlugins if (config.enabledPlugins) { if (!mergedConfig.enabledPlugins) mergedConfig.enabledPlugins = {}; Object.assign(mergedConfig.enabledPlugins, config.enabledPlugins); } // Merge language (last one wins) if (config.language !== undefined) { mergedConfig.language = config.language; } // Merge model (last one wins) if (config.model !== undefined) { mergedConfig.model = config.model; } // Merge autoMemoryEnabled (last one wins) if (config.autoMemoryEnabled !== undefined) { mergedConfig.autoMemoryEnabled = config.autoMemoryEnabled; } // Merge autoMemoryFrequency (last one wins) if (config.autoMemoryFrequency !== undefined) { mergedConfig.autoMemoryFrequency = config.autoMemoryFrequency; } // Merge marketplaces (last one wins for same key) if (config.marketplaces) { if (!mergedConfig.marketplaces) mergedConfig.marketplaces = {}; Object.assign(mergedConfig.marketplaces, config.marketplaces); } // Merge models if (config.models) { if (!mergedConfig.models) mergedConfig.models = {}; for (const [modelName, modelConfig] of Object.entries(config.models)) { if (!mergedConfig.models[modelName]) { mergedConfig.models[modelName] = {}; } Object.assign(mergedConfig.models[modelName], modelConfig); } } } return { hooks: mergedConfig.hooks && Object.keys(mergedConfig.hooks).length > 0 ? mergedConfig.hooks : undefined, env: mergedConfig.env && Object.keys(mergedConfig.env).length > 0 ? mergedConfig.env : undefined, permissions: mergedConfig.permissions && Object.keys(mergedConfig.permissions).length > 0 ? mergedConfig.permissions : undefined, enabledPlugins: mergedConfig.enabledPlugins && Object.keys(mergedConfig.enabledPlugins).length > 0 ? mergedConfig.enabledPlugins : undefined, language: mergedConfig.language, model: mergedConfig.model, autoMemoryEnabled: mergedConfig.autoMemoryEnabled, marketplaces: mergedConfig.marketplaces && Object.keys(mergedConfig.marketplaces).length > 0 ? mergedConfig.marketplaces : undefined, models: mergedConfig.models && Object.keys(mergedConfig.models).length > 0 ? mergedConfig.models : undefined, }; }