Initial implementation of TriggerShell
A Python CLI (typer) that bootstraps Node/pnpm and launches a Next.js 16 web app for running configured shell scripts: YAML config validated by a shared Zod schema, dynamic per-script forms mapped to shadcn controls, argv-safe execa execution with live WebSocket streaming, SQLite/Drizzle run history, and optional argon2 session + API token auth. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user