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:
2026-08-15 18:37:30 +02:00
co-authored by Claude Sonnet 5
commit ced99a8e75
117 changed files with 17367 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
import { eq } from "drizzle-orm";
import { getConfig } from "../config/load";
import { getDb } from "../db/client";
import { apiTokens } from "../db/schema";
import { getSession } from "./session";
import { hashToken, verifyTokenHash } from "./tokens";
export interface AuthContext {
authenticated: boolean;
/** display identity: a username, `api-token:<name>`, or "anonymous" when auth is disabled */
identity: string | null;
via: "session" | "token" | "disabled" | null;
}
const anonymous: AuthContext = {
authenticated: true,
identity: "anonymous",
via: "disabled",
};
function checkBearerToken(request: Request): AuthContext | null {
const header = request.headers.get("authorization");
if (!header?.startsWith("Bearer ")) return null;
const token = header.slice("Bearer ".length).trim();
if (!token) return null;
const candidateHash = hashToken(token);
const db = getDb();
const match = db
.select()
.from(apiTokens)
.all()
.find((row) => verifyTokenHash(candidateHash, row.tokenHash));
if (!match) return null;
db.update(apiTokens)
.set({ lastUsedAt: new Date() })
.where(eq(apiTokens.id, match.id))
.run();
return {
authenticated: true,
identity: `api-token:${match.name}`,
via: "token",
};
}
/** Called at the top of every Route Handler as defense-in-depth, independent of `proxy.ts`. */
export async function requireAuth(request?: Request): Promise<AuthContext> {
const { config } = getConfig();
if (!config.auth.enabled) return anonymous;
if (request) {
const tokenResult = checkBearerToken(request);
if (tokenResult) return tokenResult;
}
const session = await getSession();
if (session.userId) {
return {
authenticated: true,
identity: session.username ?? session.userId,
via: "session",
};
}
return { authenticated: false, identity: null, via: null };
}
export function unauthorizedResponse(): Response {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
+16
View File
@@ -0,0 +1,16 @@
import argon2 from "argon2";
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, { type: argon2.argon2id });
}
export async function verifyPassword(
hash: string,
password: string,
): Promise<boolean> {
try {
return await argon2.verify(hash, password);
} catch {
return false;
}
}
+26
View File
@@ -0,0 +1,26 @@
const attempts = new Map<string, { count: number; resetAt: number }>();
const MAX_ATTEMPTS = 10;
const WINDOW_MS = 5 * 60 * 1000;
/** Simple in-memory per-key rate limit; adequate for a local/trusted tool, no external store needed. */
export function isRateLimited(key: string): boolean {
const entry = attempts.get(key);
const now = Date.now();
if (!entry || entry.resetAt < now) return false;
return entry.count >= MAX_ATTEMPTS;
}
export function recordFailedAttempt(key: string) {
const now = Date.now();
const entry = attempts.get(key);
if (!entry || entry.resetAt < now) {
attempts.set(key, { count: 1, resetAt: now + WINDOW_MS });
return;
}
entry.count += 1;
}
export function clearAttempts(key: string) {
attempts.delete(key);
}
+62
View File
@@ -0,0 +1,62 @@
import { cookies } from "next/headers";
import { getIronSession, unsealData, type SessionOptions } from "iron-session";
import { getConfig } from "../config/load";
export interface SessionData {
userId?: string;
username?: string;
}
export const SESSION_COOKIE_NAME = "triggershell_session";
export function getSessionOptions(): SessionOptions {
const { config } = getConfig();
return {
cookieName: SESSION_COOKIE_NAME,
password: config.auth.sessionSecret ?? "",
ttl: Math.round(config.auth.sessionTtlHours * 3600),
cookieOptions: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
},
};
}
/** For use inside Route Handlers / Server Components / Server Actions only. */
export async function getSession() {
return getIronSession<SessionData>(await cookies(), getSessionOptions());
}
/** For contexts outside Next's request pipeline - the raw WS `upgrade` handler in server.ts. */
export async function verifySessionCookieValue(
sealed: string | undefined,
): Promise<SessionData | null> {
if (!sealed) return null;
try {
const options = getSessionOptions();
const data = await unsealData<SessionData>(sealed, {
password: options.password,
ttl: options.ttl,
});
return data.userId ? data : null;
} catch {
return null;
}
}
export function extractCookieValue(
cookieHeader: string | undefined,
name: string,
): string | undefined {
if (!cookieHeader) return undefined;
for (const part of cookieHeader.split(";")) {
const separatorIndex = part.indexOf("=");
if (separatorIndex === -1) continue;
const key = part.slice(0, separatorIndex).trim();
if (key === name)
return decodeURIComponent(part.slice(separatorIndex + 1).trim());
}
return undefined;
}
+56
View File
@@ -0,0 +1,56 @@
import { eq } from "drizzle-orm";
import { getDb } from "../db/client";
import { users, apiTokens } from "../db/schema";
import { getConfig } from "../config/load";
/** Config is the source of truth for who's allowed in; this materializes it into SQLite so the
* runtime auth-check path is uniform and `lastLoginAt`/`lastUsedAt` can be tracked. Called on boot. */
export function syncAuthFromConfig() {
const { config } = getConfig();
if (!config.auth.enabled) return;
const db = getDb();
for (const configUser of config.auth.users) {
const existing = db
.select()
.from(users)
.where(eq(users.username, configUser.username))
.get();
if (existing) {
if (existing.passwordHash !== configUser.passwordHash) {
db.update(users)
.set({ passwordHash: configUser.passwordHash })
.where(eq(users.id, existing.id))
.run();
}
} else {
db.insert(users)
.values({
username: configUser.username,
passwordHash: configUser.passwordHash,
})
.run();
}
}
for (const configToken of config.auth.tokens) {
const existing = db
.select()
.from(apiTokens)
.where(eq(apiTokens.name, configToken.name))
.get();
if (existing) {
if (existing.tokenHash !== configToken.tokenHash) {
db.update(apiTokens)
.set({ tokenHash: configToken.tokenHash })
.where(eq(apiTokens.id, existing.id))
.run();
}
} else {
db.insert(apiTokens)
.values({ name: configToken.name, tokenHash: configToken.tokenHash })
.run();
}
}
}
+20
View File
@@ -0,0 +1,20 @@
import crypto from "node:crypto";
/** Config/DB store `sha256:<hex>` - opaque tokens are never stored in reversible form. */
export function hashToken(token: string): string {
return `sha256:${crypto.createHash("sha256").update(token).digest("hex")}`;
}
export function verifyTokenHash(
candidateHash: string,
storedHash: string,
): boolean {
const a = Buffer.from(candidateHash);
const b = Buffer.from(storedHash);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
export function generateToken(): string {
return crypto.randomBytes(32).toString("hex");
}
+103
View File
@@ -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);
}
+271
View File
@@ -0,0 +1,271 @@
import { z } from "zod";
const identifier = z
.string()
.min(1)
.regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, {
message:
"must start with an alphanumeric character and contain only letters, numbers, - and _",
});
const controlSchema = z.enum([
"text",
"textarea",
"password",
"number",
"slider",
"checkbox",
"switch",
"select",
"radio",
"multiselect",
"checkboxGroup",
]);
export type ControlType = z.infer<typeof controlSchema>;
const baseVariable = z.object({
name: identifier,
label: z.string().min(1).optional(),
description: z.string().optional(),
required: z.boolean().default(false),
secret: z.boolean().default(false),
control: controlSchema.optional(),
passAs: z.enum(["arg", "flag", "env", "stdin"]).default("arg"),
argName: z.string().optional(),
envName: z.string().optional(),
joinWith: z.string().default(","),
});
const stringVariable = baseVariable.extend({
type: z.literal("string"),
default: z.string().optional(),
pattern: z.string().optional(),
minLength: z.number().int().nonnegative().optional(),
maxLength: z.number().int().nonnegative().optional(),
multiline: z.boolean().default(false),
});
const numberVariable = baseVariable.extend({
type: z.literal("number"),
default: z.number().optional(),
min: z.number().optional(),
max: z.number().optional(),
step: z.number().positive().optional(),
});
const booleanVariable = baseVariable.extend({
type: z.literal("boolean"),
default: z.boolean().default(false),
});
const enumVariable = baseVariable.extend({
type: z.literal("enum"),
default: z.string().optional(),
choices: z.array(z.string().min(1)).min(1),
});
const multiselectVariable = baseVariable.extend({
type: z.literal("multiselect"),
default: z.array(z.string()).default([]),
choices: z.array(z.string().min(1)).min(1),
});
export const variableSchema = z.discriminatedUnion("type", [
stringVariable,
numberVariable,
booleanVariable,
enumVariable,
multiselectVariable,
]);
export type VariableConfig = z.infer<typeof variableSchema>;
const variableWithChecks = variableSchema.superRefine((variable, ctx) => {
if (variable.type === "string" && variable.pattern) {
try {
new RegExp(variable.pattern);
} catch {
ctx.addIssue({
code: "custom",
message: `invalid regular expression: ${variable.pattern}`,
path: ["pattern"],
});
}
}
if (
variable.type === "number" &&
variable.min !== undefined &&
variable.max !== undefined
) {
if (variable.min > variable.max) {
ctx.addIssue({
code: "custom",
message: "min must be <= max",
path: ["min"],
});
}
}
if (variable.control === "slider" && variable.type !== "number") {
ctx.addIssue({
code: "custom",
message: "control 'slider' requires type 'number'",
path: ["control"],
});
}
if (
variable.control === "slider" &&
variable.type === "number" &&
(variable.min === undefined || variable.max === undefined)
) {
ctx.addIssue({
code: "custom",
message: "control 'slider' requires both min and max",
path: ["control"],
});
}
if (variable.passAs === "arg" && !variable.argName) {
ctx.addIssue({
code: "custom",
message: "passAs 'arg' requires argName",
path: ["argName"],
});
}
if (variable.passAs === "flag" && !variable.argName) {
ctx.addIssue({
code: "custom",
message: "passAs 'flag' requires argName",
path: ["argName"],
});
}
if (variable.passAs === "flag" && variable.type !== "boolean") {
ctx.addIssue({
code: "custom",
message: "passAs 'flag' requires type 'boolean'",
path: ["passAs"],
});
}
if (variable.passAs === "env" && !variable.envName) {
ctx.addIssue({
code: "custom",
message: "passAs 'env' requires envName",
path: ["envName"],
});
}
if (variable.secret && variable.type !== "string") {
ctx.addIssue({
code: "custom",
message: "secret variables must be of type 'string'",
path: ["secret"],
});
}
});
const scriptSchema = z
.object({
id: identifier,
name: z.string().min(1),
description: z.string().optional(),
command: z.string().min(1),
args: z.array(z.string()).default([]),
cwd: z.string().default("./"),
shell: z.boolean().default(false),
timeoutSeconds: z.number().int().positive().max(86400).default(1800),
variables: z.array(variableWithChecks).default([]),
})
.superRefine((script, ctx) => {
const seen = new Set<string>();
for (const [index, variable] of script.variables.entries()) {
if (seen.has(variable.name)) {
ctx.addIssue({
code: "custom",
message: `duplicate variable name '${variable.name}' in script '${script.id}'`,
path: ["variables", index, "name"],
});
}
seen.add(variable.name);
}
});
export type ScriptConfig = z.infer<typeof scriptSchema>;
const userSchema = z.object({
username: identifier,
passwordHash: z.string().min(1),
});
const tokenSchema = z.object({
name: identifier,
tokenHash: z.string().min(1),
});
const authSchema = z
.object({
enabled: z.boolean().default(true),
sessionSecret: z.string().optional(),
sessionTtlHours: z.number().positive().default(12),
users: z.array(userSchema).default([]),
tokens: z.array(tokenSchema).default([]),
})
.superRefine((auth, ctx) => {
if (auth.enabled) {
if (!auth.sessionSecret || auth.sessionSecret.length < 32) {
ctx.addIssue({
code: "custom",
message:
"auth.sessionSecret must be set and at least 32 characters when auth is enabled",
path: ["sessionSecret"],
});
}
if (auth.users.length === 0 && auth.tokens.length === 0) {
ctx.addIssue({
code: "custom",
message: "auth.enabled is true but no users or tokens are configured",
path: ["users"],
});
}
}
});
const serverSchema = z.object({
host: z.string().default("127.0.0.1"),
port: z.number().int().positive().max(65535).default(4173),
basePath: z.string().default(""),
});
const databaseSchema = z.object({
path: z.string().default(".triggershell/triggershell.db"),
});
const logsSchema = z.object({
dir: z.string().default(".triggershell/logs"),
retentionDays: z.number().int().positive().default(30),
});
export const configSchema = z
.object({
server: serverSchema.prefault({}),
auth: authSchema.prefault({}),
database: databaseSchema.prefault({}),
logs: logsSchema.prefault({}),
scripts: z.array(scriptSchema).default([]),
})
.superRefine((config, ctx) => {
const seen = new Set<string>();
for (const [index, script] of config.scripts.entries()) {
if (seen.has(script.id)) {
ctx.addIssue({
code: "custom",
message: `duplicate script id '${script.id}'`,
path: ["scripts", index, "id"],
});
}
seen.add(script.id);
}
});
export type TriggerShellConfig = z.infer<typeof configSchema>;
+32
View File
@@ -0,0 +1,32 @@
import type { ScriptConfig } from "./schema";
import { resolveControl } from "./ui-control-map";
/** Client-safe view of a script: strips defaults for `secret` variables so nothing sensitive
* ever reaches the browser, and resolves the effective UI control for each variable. */
export function serializeScriptForClient(script: ScriptConfig) {
return {
id: script.id,
name: script.name,
description: script.description ?? null,
variables: script.variables.map((variable) => {
const control = resolveControl(variable);
// `secret` is only ever true for the string variant (enforced by the config schema), so this
// narrows `variable` and keeps `default` type-correct for every other variant.
if (variable.type === "string" && variable.secret) {
return { ...variable, default: undefined, control };
}
return { ...variable, control };
}),
};
}
export type ClientScript = ReturnType<typeof serializeScriptForClient>;
export type ClientVariable = ClientScript["variables"][number];
export function serializeScriptSummary(script: ScriptConfig) {
return {
id: script.id,
name: script.name,
description: script.description ?? null,
};
}
+21
View File
@@ -0,0 +1,21 @@
import type { ControlType, VariableConfig } from "./schema";
/** Resolves the effective shadcn control for a variable: explicit `control` wins, otherwise a type-based default. */
export function resolveControl(variable: VariableConfig): ControlType {
if (variable.control) return variable.control;
switch (variable.type) {
case "string":
if (variable.secret) return "password";
if (variable.multiline) return "textarea";
return "text";
case "number":
return "number";
case "boolean":
return "checkbox";
case "enum":
return "select";
case "multiselect":
return "multiselect";
}
}
+40
View File
@@ -0,0 +1,40 @@
import fs from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import * as schema from "./schema";
import { getConfig } from "../config/load";
type Db = ReturnType<typeof drizzle<typeof schema>>;
declare global {
var __triggershellDb: Db | 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 open a second, wasteful SQLite connection instead of reusing one.
export function getDb(): Db {
if (globalThis.__triggershellDb) return globalThis.__triggershellDb;
const { dbPath } = getConfig();
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
const sqlite = new Database(dbPath);
sqlite.pragma("journal_mode = WAL");
sqlite.pragma("foreign_keys = ON");
globalThis.__triggershellDb = drizzle(sqlite, { schema });
return globalThis.__triggershellDb;
}
/** Applies committed migrations; safe to call on every boot. */
export function migrateOnBoot() {
const db = getDb();
const migrationsFolder = path.resolve(
import.meta.dirname,
"../../../drizzle",
);
migrate(db, { migrationsFolder });
}
+74
View File
@@ -0,0 +1,74 @@
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
export const users = sqliteTable("users", {
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
username: text("username").notNull().unique(),
passwordHash: text("password_hash").notNull(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
lastLoginAt: integer("last_login_at", { mode: "timestamp" }),
});
export const runStatusValues = [
"queued",
"running",
"succeeded",
"failed",
"cancelled",
"timed_out",
"interrupted",
] as const;
export type RunStatus = (typeof runStatusValues)[number];
export const runs = sqliteTable(
"runs",
{
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
scriptId: text("script_id").notNull(),
scriptName: text("script_name").notNull(),
status: text("status", { enum: runStatusValues })
.notNull()
.default("queued"),
variables: text("variables", { mode: "json" })
.notNull()
.$type<Record<string, unknown>>(),
resolvedCommand: text("resolved_command").notNull(),
pid: integer("pid"),
exitCode: integer("exit_code"),
startedAt: integer("started_at", { mode: "timestamp" }),
endedAt: integer("ended_at", { mode: "timestamp" }),
timeoutSeconds: integer("timeout_seconds"),
triggeredBy: text("triggered_by").notNull(),
logFilePath: text("log_file_path").notNull(),
errorMessage: text("error_message"),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
},
(table) => [
index("runs_script_id_idx").on(table.scriptId),
index("runs_status_idx").on(table.status),
],
);
export const apiTokens = sqliteTable("api_tokens", {
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: text("name").notNull().unique(),
tokenHash: text("token_hash").notNull(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
lastUsedAt: integer("last_used_at", { mode: "timestamp" }),
});
export type Run = typeof runs.$inferSelect;
export type NewRun = typeof runs.$inferInsert;
export type User = typeof users.$inferSelect;
export type ApiToken = typeof apiTokens.$inferSelect;
+80
View File
@@ -0,0 +1,80 @@
import type { ScriptConfig } from "../config/schema";
export interface Invocation {
argv: string[];
env: Record<string, string>;
stdin?: string;
/** variable values with `secret: true` fields replaced, safe to persist/display */
redactedVariables: Record<string, unknown>;
/** human-readable command line with secrets redacted, safe to persist/display */
redactedCommandLine: string;
}
const REDACTED = "***";
function stringifyValue(value: unknown, joinWith: string): string {
if (Array.isArray(value)) return value.join(joinWith);
if (typeof value === "boolean") return String(value);
return String(value);
}
/** Builds an argv-array invocation from validated variable values. Never produces a shell string. */
export function buildInvocation(
script: ScriptConfig,
values: Record<string, unknown>,
): Invocation {
const argv = [...script.args];
const env: Record<string, string> = {};
const redactedArgv = [...script.args];
const redactedVariables: Record<string, unknown> = {};
for (const variable of script.variables) {
const raw = values[variable.name] ?? variable.default;
if (raw === undefined || raw === null || raw === "") {
redactedVariables[variable.name] = raw;
continue;
}
redactedVariables[variable.name] = variable.secret ? REDACTED : raw;
switch (variable.passAs) {
case "arg": {
const argName = variable.argName!;
const value = stringifyValue(raw, variable.joinWith);
argv.push(argName, value);
redactedArgv.push(argName, variable.secret ? REDACTED : value);
break;
}
case "flag": {
if (raw === true) {
argv.push(variable.argName!);
redactedArgv.push(variable.argName!);
}
break;
}
case "env": {
const value = stringifyValue(raw, variable.joinWith);
env[variable.envName!] = value;
break;
}
case "stdin": {
// handled by caller via the returned `stdin` field; only one stdin variable is meaningful
break;
}
}
}
const stdinVariable = script.variables.find(
(variable) => variable.passAs === "stdin",
);
const stdin = stdinVariable
? stringifyValue(
values[stdinVariable.name] ?? stdinVariable.default,
stdinVariable.joinWith,
)
: undefined;
const redactedCommandLine = [script.command, ...redactedArgv].join(" ");
return { argv, env, stdin, redactedVariables, redactedCommandLine };
}
+172
View File
@@ -0,0 +1,172 @@
import fs from "node:fs";
import path from "node:path";
import { execa } from "execa";
import { eq } from "drizzle-orm";
import { getDb } from "../db/client";
import { runs, type RunStatus } from "../db/schema";
import { getConfig, getScript } from "../config/load";
import type { ScriptConfig } from "../config/schema";
import { buildInvocation, type Invocation } from "./build-args";
import { registerRun, unregisterRun } from "./registry";
import { emitRunMessage } from "./events";
export class ScriptNotFoundError extends Error {}
interface StartRunOptions {
scriptId: string;
variables: Record<string, unknown>;
triggeredBy: string;
}
export async function startRun({
scriptId,
variables,
triggeredBy,
}: StartRunOptions): Promise<string> {
const script = getScript(scriptId);
if (!script) throw new ScriptNotFoundError(`Unknown script '${scriptId}'`);
const { logsDir } = getConfig();
fs.mkdirSync(logsDir, { recursive: true });
const invocation = buildInvocation(script, variables);
const db = getDb();
const runId = crypto.randomUUID();
const logFilePath = path.join(logsDir, `${runId}.log`);
db.insert(runs)
.values({
id: runId,
scriptId: script.id,
scriptName: script.name,
status: "queued",
variables: invocation.redactedVariables,
resolvedCommand: invocation.redactedCommandLine,
timeoutSeconds: script.timeoutSeconds,
triggeredBy,
logFilePath,
})
.run();
// Fire and forget - the caller gets the runId immediately, progress streams over WS/polling.
void executeRun(runId, script, invocation, logFilePath).catch((error) => {
console.error(`[runner] unhandled error executing run ${runId}:`, error);
});
return runId;
}
async function executeRun(
runId: string,
script: ScriptConfig,
invocation: Invocation,
logFilePath: string,
) {
const db = getDb();
const logStream = fs.createWriteStream(logFilePath, { flags: "a" });
const setStatus = (
status: RunStatus,
extra: Partial<typeof runs.$inferInsert> = {},
) => {
db.update(runs)
.set({ status, ...extra })
.where(eq(runs.id, runId))
.run();
emitRunMessage({
type: "status",
runId,
status,
exitCode: (extra.exitCode as number | null | undefined) ?? null,
ts: Date.now(),
});
};
const { configDir } = getConfig();
const cwd = path.resolve(configDir, script.cwd);
const controller = new AbortController();
let seq = 0;
const onChunk = (stream: "stdout" | "stderr") => (data: Buffer) => {
const chunk = data.toString("utf-8");
logStream.write(chunk);
emitRunMessage({
type: "output",
runId,
stream,
chunk,
seq: seq++,
ts: Date.now(),
});
};
registerRun({ runId, scriptId: script.id, controller });
setStatus("running", { startedAt: new Date() });
try {
const subprocess = execa(script.command, invocation.argv, {
cwd,
env: { ...process.env, ...invocation.env },
timeout: script.timeoutSeconds * 1000,
cancelSignal: controller.signal,
reject: false,
shell: script.shell,
input: invocation.stdin,
buffer: false,
});
subprocess.stdout?.on("data", onChunk("stdout"));
subprocess.stderr?.on("data", onChunk("stderr"));
const result = await subprocess;
let status: RunStatus;
if (result.isCanceled) status = "cancelled";
else if (result.timedOut) status = "timed_out";
else if (result.failed) status = "failed";
else status = "succeeded";
setStatus(status, {
exitCode: result.exitCode ?? null,
endedAt: new Date(),
errorMessage:
status === "failed" || status === "timed_out"
? (result.shortMessage ?? null)
: null,
});
} catch (error) {
setStatus("failed", {
endedAt: new Date(),
errorMessage: (error as Error).message,
});
} finally {
logStream.end();
unregisterRun(runId);
}
}
/** On boot, any DB row still `running`/`queued` has no live handle in this process - mark it interrupted
* rather than pretending we can resume streaming its output. */
export function reconcileOrphanedRuns() {
const db = getDb();
const now = new Date();
const orphaned = db
.update(runs)
.set({
status: "interrupted",
endedAt: now,
errorMessage: "Server restarted while this run was in progress.",
})
.where(eq(runs.status, "running"))
.run();
db.update(runs)
.set({
status: "interrupted",
endedAt: now,
errorMessage: "Server restarted before this run could start.",
})
.where(eq(runs.status, "queued"))
.run();
return orphaned;
}
+18
View File
@@ -0,0 +1,18 @@
import { EventEmitter } from "node:events";
import type { ServerMessage } from "../ws/protocol";
declare global {
var __triggershellRunEvents: EventEmitter | undefined;
}
/** Decouples the run engine from the WS transport: the engine emits, ws/server.ts broadcasts.
* Anchored on `globalThis` because Next compiles Route Handlers through its own module graph,
* separate from the modules `server.ts` requires directly via tsx - a plain module-level
* singleton would silently end up duplicated (one copy per graph) instead of shared. */
export const runEvents: EventEmitter = globalThis.__triggershellRunEvents ?? new EventEmitter();
globalThis.__triggershellRunEvents = runEvents;
runEvents.setMaxListeners(0);
export function emitRunMessage(message: ServerMessage) {
runEvents.emit("message", message);
}
+47
View File
@@ -0,0 +1,47 @@
interface RunHandle {
runId: string;
scriptId: string;
controller: AbortController;
pid?: number;
}
declare global {
var __triggershellRunHandles: Map<string, RunHandle> | undefined;
}
/** In-memory only - live run handles cannot survive a process restart; see `reconcileOrphanedRuns`.
* Anchored on `globalThis` - see the comment in `runner/events.ts` for why a plain module-level
* singleton isn't safe here (Next compiles Route Handlers through a separate module graph from
* what `server.ts` requires directly). */
const handles: Map<string, RunHandle> = globalThis.__triggershellRunHandles ?? new Map();
globalThis.__triggershellRunHandles = handles;
export function registerRun(handle: RunHandle) {
handles.set(handle.runId, handle);
}
export function unregisterRun(runId: string) {
handles.delete(runId);
}
export function getRunHandle(runId: string): RunHandle | undefined {
return handles.get(runId);
}
export function isRunLive(runId: string): boolean {
return handles.has(runId);
}
/** Requests cancellation; returns false if the run isn't tracked (already finished or not this process). */
export function cancelRun(runId: string): boolean {
const handle = handles.get(runId);
if (!handle) return false;
handle.controller.abort();
return true;
}
export function killAllRuns() {
for (const handle of handles.values()) {
handle.controller.abort();
}
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+57
View File
@@ -0,0 +1,57 @@
import { z } from "zod";
import type { ScriptConfig, VariableConfig } from "../config/schema";
function fieldSchema(variable: VariableConfig): z.ZodTypeAny {
let field: z.ZodTypeAny;
switch (variable.type) {
case "string": {
let s = z.string();
if (variable.minLength !== undefined) s = s.min(variable.minLength);
if (variable.maxLength !== undefined) s = s.max(variable.maxLength);
if (variable.pattern) s = s.regex(new RegExp(variable.pattern));
field = s;
break;
}
case "number": {
let n = z.number();
if (variable.min !== undefined) n = n.min(variable.min);
if (variable.max !== undefined) n = n.max(variable.max);
field = n;
break;
}
case "boolean":
field = z.boolean();
break;
case "enum":
field = z.enum(variable.choices as [string, ...string[]]);
break;
case "multiselect":
field = z.array(z.enum(variable.choices as [string, ...string[]]));
break;
}
if (!variable.required) {
field = field.optional().or(z.literal(""));
}
return field;
}
/** Builds one Zod object schema from a list of variable definitions - the single source of truth
* imported by both the client form resolver and the server-side run-creation handler. */
export function buildVariableSchemaFromList(
variables: readonly VariableConfig[],
) {
const shape: Record<string, z.ZodTypeAny> = {};
for (const variable of variables) {
shape[variable.name] = fieldSchema(variable);
}
return z.object(shape);
}
export function buildVariableSchema(script: ScriptConfig) {
return buildVariableSchemaFromList(script.variables);
}
export type VariableValues = Record<string, unknown>;
+35
View File
@@ -0,0 +1,35 @@
import type { RunStatus } from "../db/schema";
export type ClientMessage =
| { type: "subscribe"; runId: string }
| { type: "unsubscribe"; runId: string }
| { type: "cancel"; runId: string };
export type ServerMessage =
| {
type: "output";
runId: string;
stream: "stdout" | "stderr";
chunk: string;
seq: number;
ts: number;
}
| {
type: "status";
runId: string;
status: RunStatus;
exitCode?: number | null;
ts: number;
}
| { type: "error"; runId: string; message: string };
export function isClientMessage(value: unknown): value is ClientMessage {
if (typeof value !== "object" || value === null) return false;
const message = value as Record<string, unknown>;
return (
(message.type === "subscribe" ||
message.type === "unsubscribe" ||
message.type === "cancel") &&
typeof message.runId === "string"
);
}
+96
View File
@@ -0,0 +1,96 @@
import type { IncomingMessage } from "node:http";
import { WebSocketServer, WebSocket } from "ws";
import { getConfig } from "../config/load";
import {
extractCookieValue,
verifySessionCookieValue,
SESSION_COOKIE_NAME,
} from "../auth/session";
import { hashToken, verifyTokenHash } from "../auth/tokens";
import { getDb } from "../db/client";
import { apiTokens } from "../db/schema";
import { runEvents } from "../runner/events";
import { cancelRun } from "../runner/registry";
import { isClientMessage, type ServerMessage } from "./protocol";
const subscriptions = new Map<string, Set<WebSocket>>();
function subscribe(runId: string, ws: WebSocket) {
let set = subscriptions.get(runId);
if (!set) {
set = new Set();
subscriptions.set(runId, set);
}
set.add(ws);
}
function unsubscribe(runId: string, ws: WebSocket) {
subscriptions.get(runId)?.delete(ws);
}
function unsubscribeAll(ws: WebSocket) {
for (const set of subscriptions.values()) set.delete(ws);
}
runEvents.on("message", (message: ServerMessage) => {
const set = subscriptions.get(message.runId);
if (!set || set.size === 0) return;
const payload = JSON.stringify(message);
for (const ws of set) {
if (ws.readyState === WebSocket.OPEN) ws.send(payload);
}
});
/** Authenticates the WS upgrade outside Next's normal request pipeline: session cookie first,
* falling back to a `?token=` bearer-style query param for non-browser clients. */
export async function authenticateUpgrade(
req: IncomingMessage,
): Promise<boolean> {
const { config } = getConfig();
if (!config.auth.enabled) return true;
const url = new URL(req.url ?? "/", "http://internal");
const tokenParam = url.searchParams.get("token");
if (tokenParam) {
const candidateHash = hashToken(tokenParam);
const db = getDb();
const match = db
.select()
.from(apiTokens)
.all()
.some((row) => verifyTokenHash(candidateHash, row.tokenHash));
if (match) return true;
}
const sealed = extractCookieValue(req.headers.cookie, SESSION_COOKIE_NAME);
const session = await verifySessionCookieValue(sealed);
return Boolean(session?.userId);
}
export function attachWsServer(wss: WebSocketServer) {
wss.on("connection", (ws: WebSocket) => {
ws.on("message", (raw) => {
let parsed: unknown;
try {
parsed = JSON.parse(raw.toString());
} catch {
return;
}
if (!isClientMessage(parsed)) return;
switch (parsed.type) {
case "subscribe":
subscribe(parsed.runId, ws);
break;
case "unsubscribe":
unsubscribe(parsed.runId, ws);
break;
case "cancel":
cancelRun(parsed.runId);
break;
}
});
ws.on("close", () => unsubscribeAll(ws));
});
}