# Architecture ``` triggershell (Node CLI, src/cli) server.ts + src/ (Next.js, all server logic) ─────────────────────────────── ───────────────────────────────────────── triggershell start server.ts (custom Node server) 1. resolve + validate the config (loadConfig) ├─ Next.js request handler (pages, API routes) 2. check the port is free ├─ ws.WebSocketServer on /ws/runs 3. set TRIGGERSHELL_CONFIG_PATH/PORT/HOST/ └─ boot: migrate DB, sync auth, reconcile runs NODE_ENV 4. import("./server.ts") — same process │ 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 `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 CLI and server share one process `triggershell start` (`src/cli/commands/start.ts`) doesn't spawn `server.ts` as a child process — it sets `process.env` (`TRIGGERSHELL_CONFIG_PATH`, `PORT`, `HOST`, `NODE_ENV`) and then dynamically `import()`s `server.ts` directly, in the same Node process. `server.ts` reads that env and installs its own `SIGTERM`/`SIGINT` handlers, so once it's imported, `Ctrl-C` or `systemctl stop` just work — there's no parent process relaying signals to a child, no separate lifecycle to manage. The CLI's `bin/triggershell.js` entry point registers `tsx`'s loader once for the whole process (`tsx/esm/api`'s `register()`), so both the CLI's own `.ts` command files and `server.ts` run straight from source, with no compile/bundle step for either. ## `triggershell run` - a second, independent client of the same run pipeline `triggershell run ` (`src/cli/commands/run.ts`) never duplicates the spawn/streaming logic in `src/lib/runner/engine.ts` - it just calls it from a different position: - If a server is reachable (`GET /api/healthz`), `run` is a plain HTTP+WS client: `POST /api/scripts/:id/runs` (the same route the web UI's "Run" button calls) starts the run _inside that server's process_, and `run` then subscribes over `/ws/runs` exactly like a browser tab would, using the same `ClientMessage`/`ServerMessage` protocol (`src/lib/ws/protocol.ts`). This is why a run started this way appears live in any open browser tab for free - the broadcast path (`emitRunMessage` → the `runEvents` listener in `src/lib/ws/server.ts` → every subscribed WebSocket) has no idea the run was triggered by a CLI instead of a click. - If nothing's reachable, `run` calls `startRun()` directly, in its own short-lived process (after its own `migrateOnBoot()`/`reconcileOrphanedRuns()`, so a from-scratch `.triggershell/` works standalone). Since it's in the same process as the run it just started, it doesn't need WS at all - it listens on the same in-process `runEvents` emitter a WS client would otherwise be fed from, using the exact "read the log file and current status first, then attach a live listener" ordering `subscribe()` in `ws/server.ts` already uses, for the same reason: a fast script can finish before a listener is attached. Either way, `Ctrl-C` cancels the run through the existing mechanism for that mode - a `{"type": "cancel"}` WS message for the remote case, `cancelRun()` (`src/lib/runner/registry.ts`) directly for the local case - not a new cancellation path. ## Running as a systemd service `triggershell service install` renders a unit file (`src/cli/lib/systemd.ts`) whose `ExecStart` line pins the exact `node` binary (`process.execPath`) and the exact, symlink-resolved path to the installed CLI (`fs.realpathSync(process.argv[1])`) at install time — necessary because systemd services run with a minimal `PATH` that may not include wherever Node actually lives. By default it installs a per-user unit (`~/.config/systemd/user/triggershell.service`, no root required); `--system` targets `/etc/systemd/system/` instead and prints the `sudo` commands to run if not already root. `install` reloads the systemd daemon but does not enable/start the unit itself — that's a separate, explicit `systemctl --user enable --now triggershell`, since it's the point where the service actually starts listening and running scripts. `triggershell service status`/`uninstall`/`logs` are thin wrappers around `systemctl`/`journalctl` respectively - no unit-file parsing or log storage of our own, journald already does that. ## 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/.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` 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 the package's).