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
+98
View File
@@ -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/,
);
});