import fs from "node:fs"; import path from "node:path"; import { parse as parseYaml } from "yaml"; import { configSchema, type TriggerShellConfig } from "./schema"; export class ConfigError extends Error { issues: string[]; constructor(message: string, issues: string[] = []) { super(message); this.name = "ConfigError"; this.issues = issues; } } /** Resolves `${VAR}` / `${VAR:-default}` references against process.env. */ function interpolateEnv(raw: string): string { return raw.replace( /\$\{([A-Z0-9_]+)(:-([^}]*))?\}/gi, (_match, name: string, _hasDefault, fallback: string) => { const value = process.env[name]; if (value !== undefined && value !== "") return value; if (fallback !== undefined) return fallback; return ""; }, ); } export interface LoadedConfig { config: TriggerShellConfig; configPath: string; configDir: string; dbPath: string; logsDir: string; } export function resolveConfigPath(configPathArg?: string): string { const candidate = configPathArg ?? process.env.TRIGGERSHELL_CONFIG_PATH ?? "triggershell.yml"; return path.resolve(/*turbopackIgnore: true*/ candidate); } export function loadConfig(configPathArg?: string): LoadedConfig { const configPath = resolveConfigPath(configPathArg); // This path is resolved at runtime from a user-supplied config location, never known at build // time - see the `--skip-build`/tracing note in docs/ARCHITECTURE.md. if (!fs.existsSync(/*turbopackIgnore: true*/ configPath)) { throw new ConfigError(`Config file not found at ${configPath}`); } const raw = fs.readFileSync(/*turbopackIgnore: true*/ configPath, "utf-8"); const interpolated = interpolateEnv(raw); let parsedYaml: unknown; try { parsedYaml = parseYaml(interpolated) ?? {}; } catch (error) { throw new ConfigError(`Failed to parse YAML: ${(error as Error).message}`); } const result = configSchema.safeParse(parsedYaml); if (!result.success) { const issues = result.error.issues.map( (issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`, ); throw new ConfigError("Config validation failed", issues); } const configDir = path.dirname(configPath); const config = result.data; return { config, configPath, configDir, dbPath: path.resolve(configDir, config.database.path), logsDir: path.resolve(configDir, config.logs.dir), }; } declare global { var __triggershellConfig: LoadedConfig | undefined; } // Anchored on `globalThis` - see the comment in `runner/events.ts` for why: Next compiles Route // Handlers through a separate module graph from what `server.ts` requires directly, so a plain // module-level singleton would reparse the config file a second time instead of reusing one. /** Loads once per process and caches the result; the custom server restarts the process on config edits. */ export function getConfig(): LoadedConfig { if (!globalThis.__triggershellConfig) { globalThis.__triggershellConfig = loadConfig(); } return globalThis.__triggershellConfig; } export function getScript( scriptId: string, ): TriggerShellConfig["scripts"][number] | undefined { return getConfig().config.scripts.find((script) => script.id === scriptId); }