104 lines
3.2 KiB
TypeScript
104 lines
3.2 KiB
TypeScript
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.config.yaml";
|
||
|
|
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);
|
||
|
|
}
|