Add triggershell run/scripts - execute configured scripts from the CLI

`run <scriptId>` auto-detects whether the web server is already
reachable (a quick /api/healthz check):

- If it is, the run goes through the existing POST
  /api/scripts/:id/runs endpoint (token-authenticated, same as any
  other API client) and the CLI subscribes over /ws/runs exactly like
  a browser tab - so the run shows up live in Run History and any open
  browser watching it, with zero server-side changes, since the
  broadcast path has no idea a run was triggered by a click vs a CLI
  invocation.
- If nothing's reachable, it calls startRun() directly in its own
  process (after its own migrateOnBoot/reconcileOrphanedRuns, so a
  from-scratch .triggershell/ works standalone) and streams output by
  listening on the same in-process runEvents emitter a WS client would
  otherwise be fed from - read-log-then-listen, the same ordering
  ws/server.ts's subscribe() already uses, so a fast script finishing
  before the listener attaches still gets its output printed.

Both modes support --var name=value (repeatable; repeat a name for
multiselect), --no-wait, and Ctrl-C cancellation through the same
mechanism the web UI's Cancel button uses (a WS cancel message
remotely, cancelRun() directly locally). `scripts list`/`scripts show`
are local-only, no network - same direct-config-read pattern as
`validate`/`doctor`.

Extracts defaultValuesForScript() out of dynamic-form.tsx into
src/lib/config/defaults.ts so the CLI's --var handling and the web
form fill in a script's configured defaults identically instead of
duplicating that logic.

Verified live end-to-end: a CLI-triggered remote run was observed
streaming to both the triggering CLI process and an independent WS
client (simulating a browser tab) simultaneously; local-mode Ctrl-C
confirmed to actually kill the spawned child process, not just the
CLI; token, wrong-token, and TRIGGERSHELL_API_TOKEN auth paths all
verified against a running auth-enabled server.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 12:29:55 +02:00
co-authored by Claude Sonnet 5
parent 28407402c8
commit e13b7e3b1a
11 changed files with 654 additions and 14 deletions
+270
View File
@@ -0,0 +1,270 @@
import path from "node:path";
import { eq } from "drizzle-orm";
import { WebSocket } from "ws";
import { ConfigError, loadConfig } from "../../lib/config/load";
import { defaultValuesForScript } from "../../lib/config/defaults";
import { getDb, migrateOnBoot } from "../../lib/db/client";
import { runs, type RunStatus } from "../../lib/db/schema";
import { startRun, reconcileOrphanedRuns } from "../../lib/runner/engine";
import { cancelRun } from "../../lib/runner/registry";
import { runEvents } from "../../lib/runner/events";
import { readLogTail } from "../../lib/runner/log-file";
import { buildVariableSchema } from "../../lib/validation/variable-schema";
import type { ServerMessage } from "../../lib/ws/protocol";
import { loadDotenv } from "../lib/env-file";
import { isServerReachable } from "../lib/network";
import { resolveConfigPath } from "../lib/paths";
import { coerceVariables, parseVarFlags } from "../lib/variables";
export interface RunOptions {
config?: string;
var: string[];
host?: string;
port?: number;
token?: string;
local?: boolean;
remote?: boolean;
wait: boolean;
}
const NON_TERMINAL: RunStatus[] = ["queued", "running"];
function exitCodeFor(status: RunStatus): number {
return status === "succeeded" ? 0 : 1;
}
export async function runCommand(scriptId: string, opts: RunOptions): Promise<void> {
const configPath = resolveConfigPath(opts.config);
loadDotenv(path.join(path.dirname(configPath), ".env"));
let loaded;
try {
loaded = loadConfig(configPath);
} catch (error) {
if (error instanceof ConfigError) {
console.error(`Config error: ${error.message}`);
for (const issue of error.issues) console.error(` - ${issue}`);
process.exitCode = 1;
return;
}
throw error;
}
const { config } = loaded;
const script = config.scripts.find((s) => s.id === scriptId);
if (!script) {
console.error(`No script '${scriptId}' configured.`);
if (config.scripts.length > 0) {
console.error(
`Available scripts: ${config.scripts.map((s) => s.id).join(", ")}`,
);
}
process.exitCode = 1;
return;
}
let variables: Record<string, unknown>;
try {
const grouped = parseVarFlags(opts.var);
const raw = {
...defaultValuesForScript(script.variables),
...coerceVariables(script.variables, grouped),
};
const parsed = buildVariableSchema(script).safeParse(raw);
if (!parsed.success) {
console.error("Validation failed:");
for (const [field, issues] of Object.entries(
parsed.error.flatten().fieldErrors,
)) {
console.error(` ${field}: ${(issues ?? []).join(", ")}`);
}
process.exitCode = 1;
return;
}
variables = parsed.data;
} catch (error) {
console.error((error as Error).message);
process.exitCode = 1;
return;
}
const host = opts.host ?? config.server.host;
const port = opts.port ?? config.server.port;
const url = `http://${host}:${port}`;
const token = opts.token ?? process.env.TRIGGERSHELL_API_TOKEN;
if (opts.local && opts.remote) {
console.error("--local and --remote can't be used together.");
process.exitCode = 1;
return;
}
let useRemote: boolean;
if (opts.remote) {
if (!(await isServerReachable(url))) {
console.error(`No triggershell server reachable at ${url}.`);
process.exitCode = 1;
return;
}
useRemote = true;
} else if (opts.local) {
useRemote = false;
} else {
useRemote = await isServerReachable(url);
}
if (useRemote) {
if (config.auth.enabled && !token) {
console.error(
`${url} requires auth - pass --token or set TRIGGERSHELL_API_TOKEN (generate one with \`triggershell users add-token\`).`,
);
process.exitCode = 1;
return;
}
await runRemote(url, scriptId, variables, token, opts.wait);
} else {
await runLocal(configPath, script.id, variables, opts.wait);
}
}
async function runLocal(
configPath: string,
scriptId: string,
variables: Record<string, unknown>,
wait: boolean,
): Promise<void> {
process.env.TRIGGERSHELL_CONFIG_PATH = configPath;
migrateOnBoot();
reconcileOrphanedRuns();
const runId = await startRun({ scriptId, variables, triggeredBy: "cli" });
console.log(`Started run ${runId}`);
if (!wait) return;
// Read-then-register, same as the WS subscribe handler (src/lib/ws/server.ts): the run may
// already have produced output - or even finished - between `startRun` returning and this line,
// so we snapshot the log file and current status first, synchronously, before attaching a live
// listener for anything after that point. Both this read and the listener attach below are
// synchronous (better-sqlite3 and fs are sync here), so there's no gap either could fall through.
const row = getDb().select().from(runs).where(eq(runs.id, runId)).get();
if (row) {
const { text } = readLogTail(row.logFilePath);
if (text) process.stdout.write(text);
if (!NON_TERMINAL.includes(row.status)) {
process.exitCode = exitCodeFor(row.status);
return;
}
}
const finalStatus = await new Promise<RunStatus>((resolve) => {
function onMessage(message: ServerMessage) {
if (message.runId !== runId) return;
if (message.type === "output") {
process.stdout.write(message.chunk);
} else if (message.type === "status" && !NON_TERMINAL.includes(message.status)) {
cleanup();
resolve(message.status);
}
}
function onSigint() {
cancelRun(runId);
}
function cleanup() {
runEvents.off("message", onMessage);
process.off("SIGINT", onSigint);
}
process.on("SIGINT", onSigint);
runEvents.on("message", onMessage);
});
process.exitCode = exitCodeFor(finalStatus);
}
async function runRemote(
url: string,
scriptId: string,
variables: Record<string, unknown>,
token: string | undefined,
wait: boolean,
): Promise<void> {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) headers.Authorization = `Bearer ${token}`;
const response = await fetch(`${url}/api/scripts/${scriptId}/runs`, {
method: "POST",
headers,
body: JSON.stringify({ variables }),
});
if (!response.ok) {
const body = await response.json().catch(() => ({}) as Record<string, unknown>);
if (response.status === 401) {
console.error(
"Unauthorized - pass --token or set TRIGGERSHELL_API_TOKEN (see `triggershell users add-token`).",
);
} else if (body.fieldErrors) {
console.error("Validation failed:");
for (const [field, issues] of Object.entries(
body.fieldErrors as Record<string, string[]>,
)) {
console.error(` ${field}: ${issues.join(", ")}`);
}
} else {
console.error((body.error as string) ?? `Request failed (${response.status})`);
}
process.exitCode = 1;
return;
}
const { runId } = (await response.json()) as { runId: string };
console.log(`Started run ${runId}`);
if (!wait) return;
const wsUrl = `${url.replace(/^http/, "ws")}/ws/runs${token ? `?token=${encodeURIComponent(token)}` : ""}`;
const finalStatus = await new Promise<RunStatus>((resolve, reject) => {
const ws = new WebSocket(wsUrl);
function onSigint() {
try {
ws.send(JSON.stringify({ type: "cancel", runId }));
} catch {
// socket may already be closing - nothing more we can do
}
}
process.on("SIGINT", onSigint);
function cleanup() {
process.off("SIGINT", onSigint);
ws.close();
}
ws.on("open", () => {
ws.send(JSON.stringify({ type: "subscribe", runId, afterBytes: 0 }));
});
ws.on("message", (raw) => {
let message: ServerMessage;
try {
message = JSON.parse(raw.toString());
} catch {
return;
}
if (message.runId !== runId) return;
if (message.type === "output") {
process.stdout.write(message.chunk);
} else if (message.type === "status" && !NON_TERMINAL.includes(message.status)) {
cleanup();
resolve(message.status);
} else if (message.type === "error") {
cleanup();
reject(new Error(message.message));
}
});
ws.on("error", (error) => {
process.off("SIGINT", onSigint);
reject(error);
});
});
process.exitCode = exitCodeFor(finalStatus);
}