Files
triggershell/docs/ARCHITECTURE.md
T
valknar e63129d156
CI / Checks (push) Failing after 48s
CI / Publish to npm registry (push) Skipped
Add triggershell service logs command
Thin wrapper around journalctl, consistent with the existing status/uninstall wrappers around systemctl.
2026-08-19 17:58:41 +02:00

8.6 KiB

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 <scriptId> (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/<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 the package's).