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:
@@ -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 validate [-c CONFIG]` | Validate a config file against the full schema |
|
||||||
| `triggershell start [-c CONFIG] [--port] [--host] [--no-browser]` | Run the web app |
|
| `triggershell start [-c CONFIG] [--port] [--host] [--no-browser]` | Run the web app |
|
||||||
| `triggershell doctor [-c CONFIG]` | Print environment/config diagnostics |
|
| `triggershell doctor [-c CONFIG]` | Print environment/config diagnostics |
|
||||||
|
| `triggershell scripts list [-c CONFIG]` | List configured scripts |
|
||||||
|
| `triggershell scripts show <scriptId> [-c CONFIG]` | Show a script's command and variables |
|
||||||
|
| `triggershell run <scriptId> [--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 <username> [-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 <username> [-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 <name> [-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 users add-token <name> [-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 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 uninstall [--system]` | Stop, disable, and remove the systemd unit |
|
||||||
| `triggershell service status [--system]` | Show the systemd unit's status |
|
| `triggershell service status [--system]` | Show the systemd unit's status |
|
||||||
|
|
||||||
|
### Running scripts from the CLI
|
||||||
|
|
||||||
|
`triggershell run <scriptId>` 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
|
## Web App Guide
|
||||||
|
|
||||||
- **Scripts** (`/`) — every configured script as a card; click through to its run form.
|
- **Scripts** (`/`) — every configured script as a card; click through to its run form.
|
||||||
|
|||||||
@@ -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 <token>`
|
`/api/auth/session` requires either a valid session cookie or an `Authorization: Bearer <token>`
|
||||||
header using a token from `triggershell users add-token`.
|
header using a token from `triggershell users add-token`.
|
||||||
|
|
||||||
|
`triggershell run <scriptId>` 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
|
## Auth
|
||||||
|
|
||||||
### `POST /api/auth/login`
|
### `POST /api/auth/login`
|
||||||
|
|||||||
@@ -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
|
(`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.
|
straight from source, with no compile/bundle step for either.
|
||||||
|
|
||||||
|
## `triggershell run` - a second, independent client of the same run pipeline
|
||||||
|
|
||||||
|
`triggershell run <scriptId>` (`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
|
## Running as a systemd service
|
||||||
|
|
||||||
`triggershell service install` renders a unit file (`src/cli/lib/systemd.ts`) whose `ExecStart`
|
`triggershell service install` renders a unit file (`src/cli/lib/systemd.ts`) whose `ExecStart`
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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(", ")})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { doctorCommand } from "./commands/doctor";
|
import { doctorCommand } from "./commands/doctor";
|
||||||
import { initCommand } from "./commands/init";
|
import { initCommand } from "./commands/init";
|
||||||
|
import { runCommand } from "./commands/run";
|
||||||
|
import { scriptsListCommand, scriptsShowCommand } from "./commands/scripts";
|
||||||
import { serviceInstallCommand, serviceStatusCommand, serviceUninstallCommand } from "./commands/service";
|
import { serviceInstallCommand, serviceStatusCommand, serviceUninstallCommand } from "./commands/service";
|
||||||
import { startCommand } from "./commands/start";
|
import { startCommand } from "./commands/start";
|
||||||
import { usersAddCommand, usersAddTokenCommand } from "./commands/users";
|
import { usersAddCommand, usersAddTokenCommand } from "./commands/users";
|
||||||
import { validateCommand } from "./commands/validate";
|
import { validateCommand } from "./commands/validate";
|
||||||
import { getVersion } from "./version";
|
import { getVersion } from "./version";
|
||||||
|
|
||||||
|
function collect(value: string, previous: string[]): string[] {
|
||||||
|
return [...previous, value];
|
||||||
|
}
|
||||||
|
|
||||||
const program = new Command("triggershell")
|
const program = new Command("triggershell")
|
||||||
.version(getVersion())
|
.version(getVersion())
|
||||||
.description("Launch the TriggerShell web app: run your configured shell scripts from a browser.");
|
.description("Launch the TriggerShell web app: run your configured shell scripts from a browser.");
|
||||||
@@ -40,6 +46,45 @@ program
|
|||||||
.option("-c, --config <path>", "Path to the config file.")
|
.option("-c, --config <path>", "Path to the config file.")
|
||||||
.action(doctorCommand);
|
.action(doctorCommand);
|
||||||
|
|
||||||
|
const scripts = program.command("scripts").description("List and inspect configured scripts.");
|
||||||
|
|
||||||
|
scripts
|
||||||
|
.command("list")
|
||||||
|
.description("List configured scripts.")
|
||||||
|
.option("-c, --config <path>", "Path to the config file.")
|
||||||
|
.action(scriptsListCommand);
|
||||||
|
|
||||||
|
scripts
|
||||||
|
.command("show <scriptId>")
|
||||||
|
.description("Show a script's command and variables.")
|
||||||
|
.option("-c, --config <path>", "Path to the config file.")
|
||||||
|
.action(scriptsShowCommand);
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("run <scriptId>")
|
||||||
|
.description("Run a configured script.")
|
||||||
|
.option("-c, --config <path>", "Path to the config file.")
|
||||||
|
.option(
|
||||||
|
"--var <keyValue>",
|
||||||
|
"Set a variable, e.g. --var environment=staging (repeatable; repeat the same name for a multiselect variable).",
|
||||||
|
collect,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.option("--host <host>", "Override the host from the config file.")
|
||||||
|
.option("--port <port>", "Override the port from the config file.", (v) => Number(v))
|
||||||
|
.option(
|
||||||
|
"--token <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.");
|
const users = program.command("users").description("Manage auth users and API tokens defined in your config file.");
|
||||||
|
|
||||||
users
|
users
|
||||||
|
|||||||
@@ -36,6 +36,19 @@ export async function waitUntilReady(
|
|||||||
return false;
|
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<boolean> {
|
||||||
|
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 {
|
export function openBrowser(url: string): void {
|
||||||
const command =
|
const command =
|
||||||
process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
||||||
|
|||||||
@@ -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> = {}): 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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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<string, string[]> {
|
||||||
|
const grouped: Record<string, string[]> = {};
|
||||||
|
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<string, string[]>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const values: Record<string, unknown> = {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -10,30 +10,23 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Form } from "@/components/ui/form";
|
import { Form } from "@/components/ui/form";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { buildVariableSchemaFromList } from "@/lib/validation/variable-schema";
|
import { buildVariableSchemaFromList } from "@/lib/validation/variable-schema";
|
||||||
|
import { defaultValuesForScript } from "@/lib/config/defaults";
|
||||||
import type { ClientScript } from "@/lib/config/serialize";
|
import type { ClientScript } from "@/lib/config/serialize";
|
||||||
import { FieldRenderer } from "./field-renderer";
|
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 -
|
/** `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
|
* 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(
|
function defaultValuesFor(
|
||||||
script: ClientScript,
|
script: ClientScript,
|
||||||
initialValues?: Record<string, unknown>,
|
initialValues?: Record<string, unknown>,
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
const values: Record<string, unknown> = {};
|
const values = defaultValuesForScript(script.variables);
|
||||||
|
if (!initialValues) return values;
|
||||||
for (const variable of script.variables) {
|
for (const variable of script.variables) {
|
||||||
const fromPreviousRun =
|
if (variable.secret) continue;
|
||||||
initialValues && !variable.secret
|
const fromPreviousRun = initialValues[variable.name];
|
||||||
? initialValues[variable.name]
|
if (fromPreviousRun !== undefined) values[variable.name] = fromPreviousRun;
|
||||||
: undefined;
|
|
||||||
values[variable.name] =
|
|
||||||
fromPreviousRun ?? variable.default ?? emptyValueFor(variable);
|
|
||||||
}
|
}
|
||||||
return values;
|
return values;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, unknown> {
|
||||||
|
const values: Record<string, unknown> = {};
|
||||||
|
for (const variable of variables) {
|
||||||
|
values[variable.name] = variable.default ?? emptyValueFor(variable);
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user