Now that the CLI and the Next.js app are one package, nesting it inside app/ served no purpose - the repo root itself becomes the published npm package. Merges app/.gitignore and app/README.md into the root versions, drops the now-duplicate app/LICENSE, and updates path references (README, docs/ARCHITECTURE.md, docs/CONFIG_REFERENCE.md, package.json's repository.directory) that assumed the app/ nesting. Also fixes a real bug this surfaced: the in-app docs viewer resolved docs/ relative to process.cwd(), which only worked by accident when the CLI happened to be invoked from app/'s parent directory. A first attempt at fixing it with import.meta.dirname broke instead, for the same cross-module-graph reason config-path resolution already documented - Next compiles Route Handlers through a separate module graph that doesn't preserve source-relative import.meta paths. Fixed by exposing the app root via TRIGGERSHELL_APP_ROOT (set once in server.ts, where import.meta *does* resolve correctly), the same pattern already used for TRIGGERSHELL_CONFIG_PATH. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.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);
|
|
}
|