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:
+105
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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` | 1–86400 |
|
||||
| `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.
|
||||
Reference in New Issue
Block a user