Initial implementation of TriggerShell

A Python CLI (typer) that bootstraps Node/pnpm and launches a Next.js 16 web
app for running configured shell scripts: YAML config validated by a shared
Zod schema, dynamic per-script forms mapped to shadcn controls, argv-safe
execa execution with live WebSocket streaming, SQLite/Drizzle run history,
and optional argon2 session + API token auth.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 18:37:30 +02:00
co-authored by Claude Sonnet 5
commit ced99a8e75
117 changed files with 17367 additions and 0 deletions
+105
View File
@@ -0,0 +1,105 @@
# API Reference
Base URL: `http://<server.host>:<server.port>` (default `http://127.0.0.1:4173`).
When `auth.enabled: true`, every endpoint below except `/api/healthz`, `/api/auth/login`, and
`/api/auth/session` requires either a valid session cookie or an `Authorization: Bearer <token>`
header using a token from `triggershell users add-token`.
## Auth
### `POST /api/auth/login`
```bash
curl -c cookies.txt -X POST http://127.0.0.1:4173/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"..."}'
```
`200 {"user": {"username": "admin"}}` on success (sets the session cookie), `401` on bad
credentials, `429` if rate-limited.
### `POST /api/auth/logout`
`204` — clears the session.
### `GET /api/auth/session`
`200 {"authRequired": bool, "authenticated": bool, "user": {"username": string} | null}`
## Scripts
### `GET /api/scripts`
`200 {"scripts": [{"id", "name", "description"}]}`
### `GET /api/scripts/:scriptId`
Full schema for one script, including resolved UI `control` per variable and secret defaults
stripped:
```bash
curl -b cookies.txt http://127.0.0.1:4173/api/scripts/deploy-service
```
`404` if not found.
### `POST /api/scripts/:scriptId/runs`
Starts a run.
```bash
curl -b cookies.txt -X POST http://127.0.0.1:4173/api/scripts/deploy-service/runs \
-H "Content-Type: application/json" \
-d '{"variables": {"environment": "staging", "replicas": 2, "dryRun": true}}'
```
`201 {"runId": string, "status": "queued"}` on success. `400 {"error", "fieldErrors"}` if the
variables fail validation (the same Zod schema the UI form uses). `404` if the script doesn't exist.
## Runs
### `GET /api/runs`
Query params: `scriptId`, `status` (one of `queued|running|succeeded|failed|cancelled|timed_out|interrupted`),
`limit` (default 50, max 200), `cursor` (offset, from the previous page's `nextCursor`).
`200 {"runs": [...], "nextCursor": string | null}`
### `GET /api/runs/:runId`
The full run record (status, variables with secrets redacted, resolved command, timestamps, exit
code). `404` if not found.
### `POST /api/runs/:runId/cancel`
`202 {"status": "cancelling"}`. `409` if the run already finished, or if it isn't tracked by *this*
server process (e.g. after a restart — see "orphaned runs" in `docs/ARCHITECTURE.md`).
### `GET /api/runs/:runId/logs`
Plain-text log output. Query params: `tail=<N>` (last N lines only), `download=1` (sets
`Content-Disposition: attachment`).
## WebSocket: `/ws/runs`
Auth: session cookie, or `?token=<api-token>` for non-browser clients (the upgrade request has no
`Authorization` header support, since it's a plain HTTP upgrade).
Client → server messages:
```jsonc
{ "type": "subscribe", "runId": "..." }
{ "type": "unsubscribe", "runId": "..." }
{ "type": "cancel", "runId": "..." }
```
Server → client messages:
```jsonc
{ "type": "output", "runId": "...", "stream": "stdout" | "stderr", "chunk": "...", "seq": 0, "ts": 0 }
{ "type": "status", "runId": "...", "status": "running", "exitCode": null, "ts": 0 }
{ "type": "error", "runId": "...", "message": "..." }
```
A client only receives messages for runs it has subscribed to.
+84
View File
@@ -0,0 +1,84 @@
# Architecture
```
triggershell (Python CLI) app/ (Next.js, all server logic)
─────────────────────── ──────────────────────────────
triggershell dev|start server.ts (custom Node server)
1. resolve + pre-flight the config ├─ Next.js request handler (pages, API routes)
2. check node/pnpm, `pnpm install` if stale ├─ ws.WebSocketServer on /ws/runs
3. set TRIGGERSHELL_CONFIG_PATH/PORT/HOST env └─ boot: migrate DB, sync auth, reconcile runs
4. spawn `pnpm run dev|start`, forward signals
5. poll /api/healthz, open browser │
┌──────────┴──────────┐
REST API WebSocket
(src/app/api/**) (src/lib/ws/server.ts)
│ │
└───────────┬───────────┘
src/lib/runner/engine.ts
execa(argv, env) — never a shell string
child process (the configured script)
```
## Why a custom Node server
Next.js Route Handlers can't host a persistent WebSocket server, so `app/server.ts` wraps Next's
request handler in a plain `http.createServer` and attaches a `ws.WebSocketServer` via the
`upgrade` event, scoped to `/ws/runs` with its own auth check (Route Handlers get auth via
`next/headers`'s `cookies()`, which isn't available on a raw `http.IncomingMessage`).
## Why the Python CLI is thin
Everything Node/pnpm/Next.js needs to do (serve pages, run scripts, stream output, enforce auth)
is naturally a Node problem — `execa` for argv-safe spawning, `ws` for streaming, Next for the UI.
Python's job is just: get Node/pnpm ready, validate the config fast, and manage the child process's
lifecycle (signals, readiness, browser launch) — a CLI concern, not a web-server concern.
## Cross-module-graph state
Next compiles Route Handlers and Server Components through its own build/module graph, which is a
*separate* module instantiation from whatever `server.ts` imports directly via `tsx` at startup —
even though both run in the same OS process. A plain module-level singleton (e.g. `new Map()` at
the top of a file) ends up duplicated, one copy per graph, which silently breaks anything that
needs to be shared across that boundary (the WebSocket subscriber registry, the live-run-handle
registry used for cancellation, the config/DB singletons). These are anchored on `globalThis`
instead, which is the one truly process-wide object regardless of which graph loaded the file —
see the comment in `src/lib/runner/events.ts` for the canonical explanation.
## Run lifecycle
```
queued → running → succeeded | failed | cancelled | timed_out
↘ interrupted (set at boot for any row still queued/running - see below)
```
- `src/lib/runner/build-args.ts` turns validated variable values into `{argv, env, stdin}` per
each variable's `passAs` — this is the only place variable values become process arguments, and
it never builds a shell string.
- `src/lib/runner/engine.ts` spawns via `execa`, streams stdout/stderr chunks to both the run's log
file (`logs.dir/<runId>.log`) and `runEvents` (for WS broadcast), and updates the `runs` row's
status as the child process progresses.
- `src/lib/runner/registry.ts` holds an in-memory `Map<runId, {controller: AbortController}>` for
live runs, used to route a cancel request (REST or WS) to the right process. This can't survive
a restart, so `reconcileOrphanedRuns()` marks any DB row still `queued`/`running` at boot as
`interrupted` — its output can't be re-attached, only its final state corrected.
## Auth
- Session: `iron-session` — a stateless, encrypted+signed cookie (no session-store table).
- Config is the source of truth for *who* is allowed in; `src/lib/auth/sync.ts` upserts config
users/tokens into SQLite on boot, giving a single DB-backed check path plus `lastLoginAt` tracking.
- `src/proxy.ts` (Next's Proxy, formerly "Middleware") does a fast, cookie-only redirect for
unauthenticated page/API requests — explicitly *not* the real security boundary. Every Route
Handler also calls `requireAuth()` itself; this is the actual auth check.
- The WS `upgrade` handler is outside Next's request pipeline entirely, so it authenticates by hand
(parsing the cookie header, or a `?token=` query param) via `authenticateUpgrade()`.
## Build-time vs. runtime config
Every server file that reads the config/DB at request time (`getConfig()`, `getDb()`,
`requireAuth()`) sets `export const dynamic = "force-dynamic"`. Without it, `next build` tries to
statically prerender pages like `/` at build time, which fails because there's no config file to
read yet (the config only exists at `triggershell start` runtime, in the user's own project
directory, not `app/`'s).
+103
View File
@@ -0,0 +1,103 @@
# Configuration Reference
The config file is YAML, resolved from (in order): `-c/--config`, `TRIGGERSHELL_CONFIG_PATH`, or
`./triggershell.config.yaml`. Values support `${VAR}` / `${VAR:-default}` interpolation against
the CLI's environment, evaluated before YAML parsing. `database.path` and `logs.dir` are resolved
relative to the config file's own directory, not the current working directory.
The canonical schema is the Zod schema at `app/src/lib/config/schema.ts` — this document mirrors
it. `triggershell validate` runs the Python pre-flight checks below, then that full schema.
## `server`
| Field | Type | Default | Notes |
|---|---|---|---|
| `host` | string | `127.0.0.1` | Bind address |
| `port` | number | `4173` | 1-65535 |
| `basePath` | string | `""` | Reserved for future use |
## `auth`
| Field | Type | Default | Notes |
|---|---|---|---|
| `enabled` | boolean | `true` | `false` disables login entirely |
| `sessionSecret` | string | — | Required, >= 32 chars, if `enabled` |
| `sessionTtlHours` | number | `12` | Session cookie lifetime |
| `users` | array | `[]` | `{username, passwordHash}` — hash via `triggershell users add` |
| `tokens` | array | `[]` | `{name, tokenHash}` — hash via `triggershell users add-token` |
If `enabled: true`, at least one user or token must be configured.
## `database`
| Field | Type | Default |
|---|---|---|
| `path` | string | `.triggershell/triggershell.db` |
## `logs`
| Field | Type | Default | Notes |
|---|---|---|---|
| `dir` | string | `.triggershell/logs` | One `<runId>.log` file per run |
| `retentionDays` | number | `30` | Not yet enforced automatically — prune manually or via cron |
## `scripts[]`
| Field | Type | Default | Notes |
|---|---|---|---|
| `id` | string | — | Required, unique, `[a-zA-Z0-9][a-zA-Z0-9_-]*` |
| `name` | string | — | Required, display name |
| `description` | string | — | Optional |
| `command` | string | — | Required, e.g. `bash`, `node`, `./script.sh` |
| `args` | string[] | `[]` | Fixed leading args, before variable-derived ones |
| `cwd` | string | `./` | Resolved relative to the config file's directory |
| `shell` | boolean | `false` | Opt-in shell interpretation — see the Security Notes in the README before using this |
| `timeoutSeconds` | number | `1800` | 186400 |
| `variables` | array | `[]` | See below |
## `scripts[].variables[]`
Common fields on every variable:
| Field | Type | Default | Notes |
|---|---|---|---|
| `name` | string | — | Required, unique per script |
| `label` | string | `name` | Display label |
| `description` | string | — | Shown as form help text |
| `required` | boolean | `false` | |
| `secret` | boolean | `false` | Only valid on `type: string`. Masks the UI control, redacts from persisted run records |
| `control` | string | type-based default | See mapping below |
| `passAs` | `arg` \| `flag` \| `env` \| `stdin` | `arg` | How the value reaches the process |
| `argName` | string | — | Required for `passAs: arg`/`flag`, e.g. `--env` |
| `envName` | string | — | Required for `passAs: env`, e.g. `SLACK_CHANNEL` |
| `joinWith` | string | `,` | Separator used when an array value is passed as a single arg/env string |
Type-specific fields:
| `type` | Extra fields |
|---|---|
| `string` | `default?: string`, `pattern?: string` (regex), `minLength?`, `maxLength?`, `multiline?: boolean` |
| `number` | `default?: number`, `min?`, `max?`, `step?` |
| `boolean` | `default: boolean` (default `false`) |
| `enum` | `choices: string[]` (required, non-empty), `default?: string` |
| `multiselect` | `choices: string[]` (required, non-empty), `default: string[]` (default `[]`) |
### UI control mapping
| `type` | Default `control` | Valid overrides |
|---|---|---|
| `string` | `text` (or `password` if `secret: true`) | `textarea` (needs `multiline: true`), `password` |
| `number` | `number` | `slider` (requires both `min` and `max`) |
| `boolean` | `checkbox` | `switch` |
| `enum` | `select` | `radio` |
| `multiselect` | `multiselect` (combobox) | `checkboxGroup` |
### `passAs` semantics
- `arg` — appends `argName value` to argv (e.g. `--env staging`).
- `flag` — appends `argName` alone, only when the boolean value is `true`.
- `env` — sets `envName=value` in the child process's environment.
- `stdin` — the value is piped to the process's stdin (only one `stdin` variable is meaningful per script).
Values are always passed as discrete argv elements or env vars — never concatenated into a shell
string — so arbitrary characters (including shell metacharacters) in a variable's value are inert.