2026-08-15 18:37:30 +02:00
|
|
|
# Architecture
|
|
|
|
|
|
|
|
|
|
```
|
2026-08-16 11:14:01 +02:00
|
|
|
triggershell (Node CLI, src/cli) server.ts + src/ (Next.js, all server logic)
|
|
|
|
|
─────────────────────────────── ─────────────────────────────────────────
|
2026-08-16 11:03:25 +02:00
|
|
|
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 │
|
2026-08-15 18:37:30 +02:00
|
|
|
┌──────────┴──────────┐
|
|
|
|
|
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
|
|
|
|
|
|
2026-08-16 11:14:01 +02:00
|
|
|
Next.js Route Handlers can't host a persistent WebSocket server, so `server.ts` wraps Next's
|
2026-08-15 18:37:30 +02:00
|
|
|
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`).
|
|
|
|
|
|
2026-08-16 11:03:25 +02:00
|
|
|
## Why the CLI and server share one process
|
2026-08-15 18:37:30 +02:00
|
|
|
|
2026-08-16 11:03:25 +02:00
|
|
|
`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.
|
|
|
|
|
|
|
|
|
|
## 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` are thin
|
|
|
|
|
wrappers around `systemctl`; log tailing is just `journalctl --user -u triggershell -f` — not
|
|
|
|
|
reimplemented.
|
2026-08-15 18:37:30 +02:00
|
|
|
|
|
|
|
|
## 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
|
2026-08-16 11:14:01 +02:00
|
|
|
directory, not the package's).
|