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
+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;
}