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:
@@ -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).
|
||||
Reference in New Issue
Block a user