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:
@@ -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<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 {
|
||||
const command =
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user