The app is already 100% Node, so the Python launcher was pure overhead - it existed mainly to bootstrap Node, which is circular. The CLI is now merged into app/ (the single published npm package): `triggershell start` validates the config and imports server.ts directly in-process, so server.ts's own SIGTERM/SIGINT handling just works with no signal-relay/child-process layer needed. `dev` is dropped from the public CLI (contributors use `pnpm --dir app dev` directly); there's no `build` command either, since the package ships a prebuilt `.next` via a `prepack` hook. Adds `triggershell service install|uninstall|status` for running as a per-user or system systemd unit. Also fixes two bugs found while wiring this up: server.ts resolved `.next` relative to `process.cwd()`, which broke once the CLI could run from a directory other than the app itself; and an explicitly-`files`-listed package directory bypasses .npmignore for its subpaths, so `.next/cache` was inflating the npm tarball to ~670MB (now stripped in `prepack`, ~7MB). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
6.8 KiB
Architecture
triggershell (Node CLI, src/cli) app/ (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 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 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.
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.
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.tsturns validated variable values into{argv, env, stdin}per each variable'spassAs— this is the only place variable values become process arguments, and it never builds a shell string.src/lib/runner/engine.tsspawns viaexeca, streams stdout/stderr chunks to both the run's log file (logs.dir/<runId>.log) andrunEvents(for WS broadcast), and updates therunsrow's status as the child process progresses.src/lib/runner/registry.tsholds an in-memoryMap<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, soreconcileOrphanedRuns()marks any DB row stillqueued/runningat boot asinterrupted— 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.tsupserts config users/tokens into SQLite on boot, giving a single DB-backed check path pluslastLoginAttracking. 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 callsrequireAuth()itself; this is the actual auth check.- The WS
upgradehandler is outside Next's request pipeline entirely, so it authenticates by hand (parsing the cookie header, or a?token=query param) viaauthenticateUpgrade().
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).