Scripts that finish in milliseconds (e.g. notify.js) could complete and broadcast all their output/status before a client's WS subscribe message even arrived, leaving the run page's terminal permanently blank with no way to catch up. The server now answers every subscribe with whatever log bytes were written past what the client's server-rendered page already had, plus the run's current status, before it starts streaming live broadcasts. 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
pip install triggershell # or: pip install -e . from a checkout
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 dev # start in dev mode and open the browser
Edit triggershell.yml to add your own scripts (see Configuration below),
then run triggershell start for a production build.
Requirements
- Python >= 3.9
- Node.js >= 20 (checked by the CLI; not auto-installed)
- pnpm (auto-provisioned via Corepack if missing and Corepack is available)
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 (fast Python pre-flight + full Node/Zod schema) |
triggershell dev [-c CONFIG] [--port] [--host] [--no-browser] |
Run in development mode (hot reload) |
triggershell start [-c CONFIG] [--port] [--host] [--no-browser] [--skip-build] |
Build (if stale) and run in production mode |
triggershell doctor |
Print environment/config diagnostics |
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) |
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
pnpm --dir app install
pnpm --dir app dev # or: triggershell dev, which wraps this
pnpm --dir app lint
pnpm --dir app typecheck
pnpm --dir app db:studio # browse the SQLite DB
Repo layout:
triggershell/ Python CLI (launcher/orchestrator only)
app/ Next.js app - all server logic (API, auth, script execution) lives here
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.