Files
triggershell/src/lib/config/load.ts
T

104 lines
3.2 KiB
TypeScript
Raw Normal View History

2026-08-15 18:37:30 +02:00
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";
2026-08-15 18:37:30 +02:00
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) ?? {};
2026-08-15 18:37:30 +02:00
} 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);
}