Two boundaries share the same content: src/app/not-found.tsx catches genuinely unmatched URLs (rendered bare in the root layout), and (app)/not-found.tsx catches notFound() calls from within app routes (already used by scripts/[scriptId] and runs/[runId]) so it renders nested inside AppLayout, keeping nav and footer visible. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
TriggerShell
A CLI that launches a self-hosted web app for running your shell scripts: define scripts and their parameters in a config file, get a generated form UI, run them asynchronously with live streaming output, and keep a searchable history — all behind optional login, with a full REST/WS API for automation.
Features
- One config file defines the server, auth, and every script (command, working dir, timeout, and typed/validated variables with defaults and choices).
- Generated forms — each variable renders as the right control (text, number, slider, select, radio, checkbox, switch, multi-select) based on its type, with client- and server-side validation from the same schema.
- Safe execution — scripts run via argv arrays, never a shell string. User input can never break out into shell metacharacters.
- Live monitoring — stdout/stderr and status stream over WebSocket to an in-browser terminal view; cancel a run from the UI or the API.
- Run history — every run's variables (secrets redacted), resolved command, status, exit code, and full log are persisted in SQLite.
- Optional auth — built-in username/password sessions and API tokens, toggleable per config.
- REST + WebSocket API for scripting your script runner.
Quickstart
npm install -g triggershell # or: npx triggershell <command> for one-off use
triggershell init # scaffold triggershell.yml (+ .env for secrets) in the current directory
triggershell users add admin # create a login (skip if you set auth.enabled: false)
triggershell start # start the app and open the browser
Edit triggershell.yml to add your own scripts (see Configuration below), then
re-run triggershell start.
The
triggershellpackage isn't published to npm yet. Until it is, build and link a local copy instead:pnpm install && pnpm build && pnpm link --global.
Requirements
- Node.js >= 20 — the only thing you need installed. Everything else
triggershellneeds ships inside the package itself and is resolved automatically when you install it.
Configuration
TriggerShell is driven entirely by one YAML file (default: ./triggershell.yml, override with
-c/--config or TRIGGERSHELL_CONFIG_PATH). Secrets referenced via ${VAR} (like
auth.sessionSecret) are meant to live in a .env file next to the config, not in the config
itself — triggershell init generates both. Minimal example:
server:
host: 127.0.0.1
port: 4173
auth:
enabled: true
sessionSecret: "${TRIGGERSHELL_SESSION_SECRET}" # >= 32 chars
users:
- username: admin
passwordHash: "${TRIGGERSHELL_USER_ADMIN_PASSWORD_HASH}" # from `triggershell users add`
tokens: []
database:
path: .triggershell/triggershell.db
logs:
dir: .triggershell/logs
retentionDays: 30
scripts:
- id: deploy-service
name: Deploy Service
command: bash
args: ["./scripts/deploy.sh"]
timeoutSeconds: 600
variables:
- name: environment
type: enum
choices: [staging, production]
default: staging
required: true
passAs: arg
argName: --env
- name: dryRun
type: boolean
default: false
passAs: flag
argName: --dry-run
A full, richly-commented example lives at examples/triggershell.yml
(with a matching .env.example), and the complete field-by-field
reference is in docs/CONFIG_REFERENCE.md.
Each variable's type (string / number / boolean / enum / multiselect) picks a sensible
default UI control; set control explicitly to override it (e.g. slider for a number, radio
for an enum). passAs (arg / flag / env / stdin) decides how the validated value reaches
the script — always as a discrete argv element or env var, never interpolated into a shell string.
CLI Usage
| Command | Description |
|---|---|
triggershell init [PATH] |
Scaffold a new config file + .env (--port, --auth/--no-auth, --force) |
triggershell validate [-c CONFIG] |
Validate a config file against the full schema |
triggershell start [-c CONFIG] [--port] [--host] [--no-browser] |
Run the web app |
triggershell doctor [-c CONFIG] |
Print environment/config diagnostics |
triggershell scripts list [-c CONFIG] |
List configured scripts |
triggershell scripts show <scriptId> [-c CONFIG] |
Show a script's command and variables |
triggershell run <scriptId> [--var name=value...] [--token] [--local|--remote] [--no-wait] |
Run a configured script - through an already-running server's API if one is reachable (so any open browser tab sees it live), otherwise standalone. See Running scripts from the CLI. |
triggershell users add <username> [-c CONFIG] [--inline] |
Hash a password, store it in .env, and print a ${VAR} snippet for auth.users (--inline prints the raw hash instead) |
triggershell users add-token <name> [-c CONFIG] [--inline] |
Generate an API token, store its hash in .env, and print a ${VAR} snippet for auth.tokens (--inline prints the raw hash instead) |
triggershell service install [--system] |
Install a systemd unit that runs triggershell start (per-user by default, Linux only) |
triggershell service uninstall [--system] |
Stop, disable, and remove the systemd unit |
triggershell service status [--system] |
Show the systemd unit's status |
Running scripts from the CLI
triggershell run <scriptId> auto-detects whether the web app is already running (a quick
/api/healthz check against server.host/server.port, overridable with --host/--port):
- Server reachable — the run goes through the same
POST /api/scripts/:id/runsendpoint the web UI uses, authenticated with--token/TRIGGERSHELL_API_TOKENifauth.enabled. It's a completely normal run from the server's point of view: it shows up in Run History, and any browser tab open on/runs/:idstreams its output live, exactly as if it had been started from the UI. - No server reachable —
runexecutes the script itself, in its own process, using the same runner the web app uses. The run and its log are still persisted, just without a browser to watch it.
Force one or the other with --local/--remote (the latter fails instead of falling back if
nothing's reachable). Pass variables with repeated --var name=value flags (repeat the same name
for a multiselect variable); --no-wait prints the run ID and returns immediately instead of
streaming output and blocking until it finishes. Exit code is 0 for a succeeded run, 1
otherwise. Ctrl-C while waiting cancels the run, the same as the UI's Cancel button.
Web App Guide
- Scripts (
/) — every configured script as a card; click through to its run form. - Run form (
/scripts/:id) — a generated form for the script's variables, validated before submit. - Run detail (
/runs/:id) — live streaming terminal output, status, exit code, and a Cancel button while the run is active. - History (
/runs) — every past run, filterable by script/status via the API (?scriptId=,?status=).
Authentication
Set auth.enabled: false for trusted/local-only use — the app then requires no login at all.
When enabled, sessions are signed/encrypted cookies (no server-side session store), and API
clients can instead send Authorization: Bearer <token> using a token from users add-token.
Passwords are hashed with argon2id; only the hash ever lives in the config file.
API Reference
Full reference with request/response shapes and curl examples: docs/API.md.
| Method | Path | Notes |
|---|---|---|
| GET | /api/healthz |
Unauthenticated readiness probe |
| POST | /api/auth/login |
{username, password} → sets session cookie |
| POST | /api/auth/logout |
Clears the session |
| GET | /api/auth/session |
Current auth state |
| GET | /api/scripts |
List configured scripts |
| GET | /api/scripts/:scriptId |
Full script schema (variables, UI hints) |
| POST | /api/scripts/:scriptId/runs |
Start a run: {variables: {...}} |
| GET | /api/runs |
History: ?scriptId=&status=&limit=&cursor= |
| GET | /api/runs/:runId |
Single run's status/metadata |
| POST | /api/runs/:runId/cancel |
Cancel an active run |
| GET | /api/runs/:runId/logs |
Full or tailed log output (?tail=&download=) |
| WS | /ws/runs |
Subscribe to a run's live output/status; send cancel |
Development
This is the workflow for working on TriggerShell itself, not for installing/running it — it
bypasses the CLI entirely and talks to the app's own scripts directly, with hot reload. The app is
still not meant to be run standalone with next dev/next start, since it needs the custom server
(server.ts) for the WebSocket endpoint — pnpm dev/pnpm start below cover that:
pnpm install
pnpm dev # tsx watch server.ts - reads TRIGGERSHELL_CONFIG_PATH from the environment
pnpm lint
pnpm typecheck
pnpm test # CLI unit tests (src/cli/**/*.test.ts)
pnpm db:studio # browse the SQLite DB
Repo layout — the repo root itself is the published npm package (Next.js app + the triggershell
CLI in bin//src/cli/), alongside:
examples/ A runnable example config + scripts
docs/ Config/architecture/API reference docs
See docs/ARCHITECTURE.md for how the pieces fit together.
Security Notes
- Scripts are always spawned with an argv array (
execa), never a shell string — variable values can never inject additional shell commands. A script's owncommand/argsmay still useshell: trueas an explicit, documented opt-in when the script genuinely needs pipes/globs; that reintroduces shell interpretation ofpassAs: argvalues, so preferpassAs: envfor anything user-controlled in that case. secret: truevariables are masked in the UI and redacted from persisted run records; only the live child process ever sees the real value.- The server binds to
127.0.0.1by default — setserver.hostexplicitly to expose it further. - Route Handlers check auth themselves (
requireAuth());proxy.tsis only a fast, optimistic redirect layer, not the security boundary. auth.sessionSecretshould live in.env, not the config file —triggershell initsets this up for you.passwordHash/tokenHashare one-way hashes (not the secrets themselves), so storing them directly in the config is reasonably safe — the same trust model as/etc/shadowor.htpasswd— buttriggershell users add/add-tokenstore them in.envvia${VAR}by default too, for cases where you don't want them readable by anyone with config access at all. Pass--inlineto get the old behavior of printing the raw hash to paste into the config.
License
MIT — see LICENSE.