Replace the Python CLI with a Node CLI, add a systemd service command

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>
This commit is contained in:
2026-08-16 11:03:25 +02:00
co-authored by Claude Sonnet 5
parent e2b6c6102c
commit 30350d80f4
40 changed files with 1154 additions and 1071 deletions
+32 -13
View File
@@ -1,14 +1,15 @@
# 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
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)
@@ -28,12 +29,30 @@ request handler in a plain `http.createServer` and attaches a `ws.WebSocketServe
`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
## Why the CLI and server share one process
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.
`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
+2 -1
View File
@@ -11,7 +11,8 @@ secrets referenced via `${VAR}` don't have to be committed alongside the config.
init` scaffolds both files together.
The canonical schema is the Zod schema at `app/src/lib/config/schema.ts` — this document mirrors
it. `triggershell validate` runs the Python pre-flight checks below, then that full schema.
it. `triggershell validate` loads and validates the config directly against the schema below (no
separate pre-flight step).
## `server`