From e13b7e3b1aa4b4883a702b9169a97dc8d6ad9df3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Sun, 16 Aug 2026 12:29:55 +0200 Subject: [PATCH] Add `triggershell run`/`scripts` - execute configured scripts from the CLI `run ` 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 --- README.md | 22 +++ docs/API.md | 4 + docs/ARCHITECTURE.md | 24 +++ src/cli/commands/run.ts | 270 ++++++++++++++++++++++++++ src/cli/commands/scripts.ts | 82 ++++++++ src/cli/index.ts | 45 +++++ src/cli/lib/network.ts | 13 ++ src/cli/lib/variables.test.ts | 98 ++++++++++ src/cli/lib/variables.ts | 68 +++++++ src/components/forms/dynamic-form.tsx | 21 +- src/lib/config/defaults.ts | 21 ++ 11 files changed, 654 insertions(+), 14 deletions(-) create mode 100644 src/cli/commands/run.ts create mode 100644 src/cli/commands/scripts.ts create mode 100644 src/cli/lib/variables.test.ts create mode 100644 src/cli/lib/variables.ts create mode 100644 src/lib/config/defaults.ts diff --git a/README.md b/README.md index 4fd322c..f1d0cfa 100644 --- a/README.md +++ b/README.md @@ -105,12 +105,34 @@ the script — always as a discrete argv element or env var, never interpolated | `triggershell validate [-c CONFIG]` | Validate a config file against the full schema | | `triggershell start [-c CONFIG] [--port] [--host] [--no-browser]` | Run the web app | | `triggershell doctor [-c CONFIG]` | Print environment/config diagnostics | +| `triggershell scripts list [-c CONFIG]` | List configured scripts | +| `triggershell scripts show [-c CONFIG]` | Show a script's command and variables | +| `triggershell run [--var name=value...] [--token] [--local\|--remote] [--no-wait]` | Run a configured script - through an already-running server's API if one is reachable (so any open browser tab sees it live), otherwise standalone. See [Running scripts from the CLI](#running-scripts-from-the-cli). | | `triggershell users add [-c CONFIG] [--inline]` | Hash a password, store it in `.env`, and print a `${VAR}` snippet for `auth.users` (`--inline` prints the raw hash instead) | | `triggershell users add-token [-c CONFIG] [--inline]` | Generate an API token, store its hash in `.env`, and print a `${VAR}` snippet for `auth.tokens` (`--inline` prints the raw hash instead) | | `triggershell service install [--system]` | Install a systemd unit that runs `triggershell start` (per-user by default, Linux only) | | `triggershell service uninstall [--system]` | Stop, disable, and remove the systemd unit | | `triggershell service status [--system]` | Show the systemd unit's status | +### Running scripts from the CLI + +`triggershell run ` auto-detects whether the web app is already running (a quick +`/api/healthz` check against `server.host`/`server.port`, overridable with `--host`/`--port`): + +- **Server reachable** — the run goes through the same `POST /api/scripts/:id/runs` endpoint the + web UI uses, authenticated with `--token`/`TRIGGERSHELL_API_TOKEN` if `auth.enabled`. It's a + completely normal run from the server's point of view: it shows up in Run History, and any + browser tab open on `/runs/:id` streams its output live, exactly as if it had been started from + the UI. +- **No server reachable** — `run` executes the script itself, in its own process, using the same + runner the web app uses. The run and its log are still persisted, just without a browser to watch it. + +Force one or the other with `--local`/`--remote` (the latter fails instead of falling back if +nothing's reachable). Pass variables with repeated `--var name=value` flags (repeat the same name +for a `multiselect` variable); `--no-wait` prints the run ID and returns immediately instead of +streaming output and blocking until it finishes. Exit code is `0` for a succeeded run, `1` +otherwise. `Ctrl-C` while waiting cancels the run, the same as the UI's Cancel button. + ## Web App Guide - **Scripts** (`/`) — every configured script as a card; click through to its run form. diff --git a/docs/API.md b/docs/API.md index f83b61f..317e9ba 100644 --- a/docs/API.md +++ b/docs/API.md @@ -6,6 +6,10 @@ When `auth.enabled: true`, every endpoint below except `/api/healthz`, `/api/aut `/api/auth/session` requires either a valid session cookie or an `Authorization: Bearer ` header using a token from `triggershell users add-token`. +`triggershell run ` is a first-party client of this exact REST + WS contract (see +[Running scripts from the CLI](../README.md#running-scripts-from-the-cli)) - nothing below is +CLI-specific. + ## Auth ### `POST /api/auth/login` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index eec7f53..1197cfd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -40,6 +40,30 @@ there's no parent process relaying signals to a child, no separate lifecycle to (`tsx/esm/api`'s `register()`), so both the CLI's own `.ts` command files and `server.ts` run straight from source, with no compile/bundle step for either. +## `triggershell run` - a second, independent client of the same run pipeline + +`triggershell run ` (`src/cli/commands/run.ts`) never duplicates the spawn/streaming +logic in `src/lib/runner/engine.ts` - it just calls it from a different position: + +- If a server is reachable (`GET /api/healthz`), `run` is a plain HTTP+WS client: `POST + /api/scripts/:id/runs` (the same route the web UI's "Run" button calls) starts the run *inside + that server's process*, and `run` then subscribes over `/ws/runs` exactly like a browser tab + would, using the same `ClientMessage`/`ServerMessage` protocol (`src/lib/ws/protocol.ts`). This + is why a run started this way appears live in any open browser tab for free - the broadcast path + (`emitRunMessage` → the `runEvents` listener in `src/lib/ws/server.ts` → every subscribed + WebSocket) has no idea the run was triggered by a CLI instead of a click. +- If nothing's reachable, `run` calls `startRun()` directly, in its own short-lived process (after + its own `migrateOnBoot()`/`reconcileOrphanedRuns()`, so a from-scratch `.triggershell/` works + standalone). Since it's in the same process as the run it just started, it doesn't need WS at + all - it listens on the same in-process `runEvents` emitter a WS client would otherwise be fed + from, using the exact "read the log file and current status first, then attach a live listener" + ordering `subscribe()` in `ws/server.ts` already uses, for the same reason: a fast script can + finish before a listener is attached. + +Either way, `Ctrl-C` cancels the run through the existing mechanism for that mode - a `{"type": +"cancel"}` WS message for the remote case, `cancelRun()` (`src/lib/runner/registry.ts`) directly +for the local case - not a new cancellation path. + ## Running as a systemd service `triggershell service install` renders a unit file (`src/cli/lib/systemd.ts`) whose `ExecStart` diff --git a/src/cli/commands/run.ts b/src/cli/commands/run.ts new file mode 100644 index 0000000..fc3fd83 --- /dev/null +++ b/src/cli/commands/run.ts @@ -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 { + 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; + 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, + wait: boolean, +): Promise { + 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((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, + token: string | undefined, + wait: boolean, +): Promise { + const headers: Record = { "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); + 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, + )) { + 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((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); +} diff --git a/src/cli/commands/scripts.ts b/src/cli/commands/scripts.ts new file mode 100644 index 0000000..a891c22 --- /dev/null +++ b/src/cli/commands/scripts.ts @@ -0,0 +1,82 @@ +import path from "node:path"; +import { ConfigError, loadConfig } from "../../lib/config/load"; +import type { ScriptConfig } from "../../lib/config/schema"; +import { loadDotenv } from "../lib/env-file"; +import { resolveConfigPath } from "../lib/paths"; + +export interface ScriptsOptions { + config?: string; +} + +function loadScripts(opts: ScriptsOptions): ScriptConfig[] | null { + const configPath = resolveConfigPath(opts.config); + loadDotenv(path.join(path.dirname(configPath), ".env")); + + try { + return loadConfig(configPath).config.scripts; + } 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 null; + } + throw error; + } +} + +export async function scriptsListCommand(opts: ScriptsOptions): Promise { + const scripts = loadScripts(opts); + if (!scripts) return; + + if (scripts.length === 0) { + console.log("No scripts configured."); + return; + } + + const idWidth = Math.max(...scripts.map((s) => s.id.length)); + for (const script of scripts) { + const description = script.description ? ` - ${script.description}` : ""; + console.log(`${script.id.padEnd(idWidth)} ${script.name}${description}`); + } +} + +export async function scriptsShowCommand( + scriptId: string, + opts: ScriptsOptions, +): Promise { + const scripts = loadScripts(opts); + if (!scripts) return; + + const script = scripts.find((s) => s.id === scriptId); + if (!script) { + console.error(`No script '${scriptId}' configured.`); + if (scripts.length > 0) { + console.error(`Available scripts: ${scripts.map((s) => s.id).join(", ")}`); + } + process.exitCode = 1; + return; + } + + console.log(script.name); + if (script.description) console.log(script.description); + console.log(`\ncommand: ${script.command} ${script.args.join(" ")}`.trimEnd()); + + if (script.variables.length === 0) { + console.log("\nThis script takes no parameters."); + return; + } + + console.log("\nvariables:"); + for (const variable of script.variables) { + const parts: string[] = [variable.type]; + if (variable.required) parts.push("required"); + if (variable.type === "enum" || variable.type === "multiselect") { + parts.push(`choices: ${variable.choices.join(", ")}`); + } + if (variable.default !== undefined) { + parts.push(`default: ${JSON.stringify(variable.default)}`); + } + console.log(` ${variable.name} (${parts.join(", ")})`); + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index c2e6c08..8d0b9a3 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,12 +1,18 @@ import { Command } from "commander"; import { doctorCommand } from "./commands/doctor"; import { initCommand } from "./commands/init"; +import { runCommand } from "./commands/run"; +import { scriptsListCommand, scriptsShowCommand } from "./commands/scripts"; import { serviceInstallCommand, serviceStatusCommand, serviceUninstallCommand } from "./commands/service"; import { startCommand } from "./commands/start"; import { usersAddCommand, usersAddTokenCommand } from "./commands/users"; import { validateCommand } from "./commands/validate"; import { getVersion } from "./version"; +function collect(value: string, previous: string[]): string[] { + return [...previous, value]; +} + const program = new Command("triggershell") .version(getVersion()) .description("Launch the TriggerShell web app: run your configured shell scripts from a browser."); @@ -40,6 +46,45 @@ program .option("-c, --config ", "Path to the config file.") .action(doctorCommand); +const scripts = program.command("scripts").description("List and inspect configured scripts."); + +scripts + .command("list") + .description("List configured scripts.") + .option("-c, --config ", "Path to the config file.") + .action(scriptsListCommand); + +scripts + .command("show ") + .description("Show a script's command and variables.") + .option("-c, --config ", "Path to the config file.") + .action(scriptsShowCommand); + +program + .command("run ") + .description("Run a configured script.") + .option("-c, --config ", "Path to the config file.") + .option( + "--var ", + "Set a variable, e.g. --var environment=staging (repeatable; repeat the same name for a multiselect variable).", + collect, + [], + ) + .option("--host ", "Override the host from the config file.") + .option("--port ", "Override the port from the config file.", (v) => Number(v)) + .option( + "--token ", + "API token for an already-running server (or set TRIGGERSHELL_API_TOKEN).", + ) + .option("--local", "Always run in this process, even if the web server is reachable.", false) + .option( + "--remote", + "Require a reachable web server; don't fall back to running locally.", + false, + ) + .option("--no-wait", "Print the run ID and exit immediately instead of streaming output.") + .action(runCommand); + const users = program.command("users").description("Manage auth users and API tokens defined in your config file."); users diff --git a/src/cli/lib/network.ts b/src/cli/lib/network.ts index 7db6b99..80bf183 100644 --- a/src/cli/lib/network.ts +++ b/src/cli/lib/network.ts @@ -36,6 +36,19 @@ export async function waitUntilReady( return false; } +/** Single-shot check (not a poll loop, unlike `waitUntilReady`) for whether a triggershell server + * is already listening at `url` - used to decide whether `run` can go through the REST/WS API. */ +export async function isServerReachable(url: string): Promise { + try { + const response = await fetch(`${url}/api/healthz`, { + signal: AbortSignal.timeout(1000), + }); + return response.status === 200; + } catch { + return false; + } +} + export function openBrowser(url: string): void { const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"; diff --git a/src/cli/lib/variables.test.ts b/src/cli/lib/variables.test.ts new file mode 100644 index 0000000..d21e2c1 --- /dev/null +++ b/src/cli/lib/variables.test.ts @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { VariableConfig } from "../../lib/config/schema"; +import { coerceVariables, parseVarFlags } from "./variables"; + +function stringVar(name: string, overrides: Partial = {}): VariableConfig { + return { + type: "string", + name, + required: false, + secret: false, + passAs: "arg", + joinWith: ",", + multiline: false, + ...overrides, + } as VariableConfig; +} + +function boolVar(name: string): VariableConfig { + return { + type: "boolean", + name, + required: false, + secret: false, + passAs: "flag", + joinWith: ",", + default: false, + } as VariableConfig; +} + +function numberVar(name: string): VariableConfig { + return { + type: "number", + name, + required: false, + secret: false, + passAs: "arg", + joinWith: ",", + } as VariableConfig; +} + +function multiselectVar(name: string, choices: string[]): VariableConfig { + return { + type: "multiselect", + name, + required: false, + secret: false, + passAs: "arg", + joinWith: ",", + choices, + default: [], + } as VariableConfig; +} + +test("parseVarFlags groups repeated names into arrays", () => { + const grouped = parseVarFlags(["environment=staging", "tag=a", "tag=b"]); + assert.deepEqual(grouped, { environment: ["staging"], tag: ["a", "b"] }); +}); + +test("parseVarFlags rejects a flag with no '='", () => { + assert.throws(() => parseVarFlags(["oops"]), /missing '='/); +}); + +test("coerceVariables coerces booleans and numbers, passes strings through", () => { + const variables = [stringVar("environment"), boolVar("dryRun"), numberVar("replicas")]; + const values = coerceVariables(variables, { + environment: ["staging"], + dryRun: ["true"], + replicas: ["3"], + }); + assert.deepEqual(values, { environment: "staging", dryRun: true, replicas: 3 }); +}); + +test("coerceVariables rejects an invalid boolean/number", () => { + assert.throws(() => coerceVariables([boolVar("dryRun")], { dryRun: ["yes"] }), /must be 'true' or 'false'/); + assert.throws(() => coerceVariables([numberVar("replicas")], { replicas: ["abc"] }), /not a valid number/); +}); + +test("coerceVariables collects a multiselect variable's repeats into an array", () => { + const values = coerceVariables([multiselectVar("tags", ["a", "b", "c"])], { + tags: ["a", "c"], + }); + assert.deepEqual(values, { tags: ["a", "c"] }); +}); + +test("coerceVariables rejects a non-multiselect variable given more than once", () => { + assert.throws( + () => coerceVariables([stringVar("environment")], { environment: ["a", "b"] }), + /given 2 times/, + ); +}); + +test("coerceVariables rejects an unknown variable name", () => { + assert.throws( + () => coerceVariables([stringVar("environment")], { nope: ["x"] }), + /does not match any variable/, + ); +}); diff --git a/src/cli/lib/variables.ts b/src/cli/lib/variables.ts new file mode 100644 index 0000000..07e981f --- /dev/null +++ b/src/cli/lib/variables.ts @@ -0,0 +1,68 @@ +import type { VariableConfig } from "../../lib/config/schema"; + +/** Splits each `name=value` pair and groups by name - repeats accumulate into an array, which is + * how a `multiselect` variable is given more than one selection on the command line. */ +export function parseVarFlags(pairs: string[]): Record { + const grouped: Record = {}; + for (const pair of pairs) { + const eq = pair.indexOf("="); + if (eq === -1) { + throw new Error(`--var ${pair} is missing '=' - expected --var name=value`); + } + const name = pair.slice(0, eq); + const value = pair.slice(eq + 1); + (grouped[name] ??= []).push(value); + } + return grouped; +} + +/** Coerces raw `--var` strings into the JS type each variable expects, ready for + * `buildVariableSchema(script).safeParse(...)` - the same schema the web form and the + * `/api/scripts/:id/runs` route already validate against. Variables not present in `grouped` + * are left out entirely (the caller fills those from `defaultValuesForScript` first). */ +export function coerceVariables( + variables: readonly VariableConfig[], + grouped: Record, +): Record { + const values: Record = {}; + const known = new Set(variables.map((v) => v.name)); + + for (const name of Object.keys(grouped)) { + if (!known.has(name)) { + throw new Error(`--var ${name}=... does not match any variable on this script`); + } + } + + for (const variable of variables) { + const raw = grouped[variable.name]; + if (!raw) continue; + + if (variable.type === "multiselect") { + values[variable.name] = raw; + continue; + } + + if (raw.length > 1) { + throw new Error( + `--var ${variable.name}=... was given ${raw.length} times, but '${variable.name}' is not a multiselect variable`, + ); + } + const value = raw[0]; + + if (variable.type === "boolean") { + if (value === "true") values[variable.name] = true; + else if (value === "false") values[variable.name] = false; + else throw new Error(`--var ${variable.name}=${value} must be 'true' or 'false'`); + } else if (variable.type === "number") { + const n = Number(value); + if (Number.isNaN(n)) { + throw new Error(`--var ${variable.name}=${value} is not a valid number`); + } + values[variable.name] = n; + } else { + values[variable.name] = value; + } + } + + return values; +} diff --git a/src/components/forms/dynamic-form.tsx b/src/components/forms/dynamic-form.tsx index b0ef0cb..f12967b 100644 --- a/src/components/forms/dynamic-form.tsx +++ b/src/components/forms/dynamic-form.tsx @@ -10,30 +10,23 @@ import { Button } from "@/components/ui/button"; import { Form } from "@/components/ui/form"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { buildVariableSchemaFromList } from "@/lib/validation/variable-schema"; +import { defaultValuesForScript } from "@/lib/config/defaults"; import type { ClientScript } from "@/lib/config/serialize"; import { FieldRenderer } from "./field-renderer"; -function emptyValueFor(variable: ClientScript["variables"][number]): unknown { - if (variable.type === "boolean") return false; - if (variable.type === "multiselect") return []; - return ""; -} - /** `initialValues` comes from a previous run's (already-redacted) variables when re-running - * secret fields are deliberately excluded there (their stored value is just "***", not the real - * one), so those always fall through to the normal empty/default state and have to be re-entered. */ + * one), so those always fall through to the normal default/empty state and have to be re-entered. */ function defaultValuesFor( script: ClientScript, initialValues?: Record, ): Record { - const values: Record = {}; + const values = defaultValuesForScript(script.variables); + if (!initialValues) return values; for (const variable of script.variables) { - const fromPreviousRun = - initialValues && !variable.secret - ? initialValues[variable.name] - : undefined; - values[variable.name] = - fromPreviousRun ?? variable.default ?? emptyValueFor(variable); + if (variable.secret) continue; + const fromPreviousRun = initialValues[variable.name]; + if (fromPreviousRun !== undefined) values[variable.name] = fromPreviousRun; } return values; } diff --git a/src/lib/config/defaults.ts b/src/lib/config/defaults.ts new file mode 100644 index 0000000..7054443 --- /dev/null +++ b/src/lib/config/defaults.ts @@ -0,0 +1,21 @@ +import type { VariableConfig } from "./schema"; + +export function emptyValueFor(variable: VariableConfig): unknown { + if (variable.type === "boolean") return false; + if (variable.type === "multiselect") return []; + return ""; +} + +/** Fills in each variable's configured `default` (or an empty value) - the Zod schema in + * `variable-schema.ts` doesn't apply `default` itself, it only validates whatever is present, so + * any caller that wants "an omitted variable behaves like its configured default" applies this + * first. Shared by the web form (`dynamic-form.tsx`) and the CLI's `run` command. */ +export function defaultValuesForScript( + variables: readonly VariableConfig[], +): Record { + const values: Record = {}; + for (const variable of variables) { + values[variable.name] = variable.default ?? emptyValueFor(variable); + } + return values; +}