2026-08-15 18:37:30 +02:00
# 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
```bash
2026-08-16 11:03:25 +02:00
npm install -g triggershell # or: npx triggershell <command> for one-off use
2026-08-15 19:15:03 +02:00
triggershell init # scaffold triggershell.yml (+ .env for secrets) in the current directory
2026-08-15 18:37:30 +02:00
triggershell users add admin # create a login (skip if you set auth.enabled: false)
2026-08-16 11:03:25 +02:00
triggershell start # start the app and open the browser
2026-08-15 18:37:30 +02:00
```
2026-08-16 11:03:25 +02:00
Edit `triggershell.yml` to add your own scripts (see [Configuration ](#configuration ) below), then
re-run `triggershell start` .
> The `triggershell` package isn't published to npm yet. Until it is, build and link a local copy
2026-08-16 11:14:01 +02:00
> instead: `pnpm install && pnpm build && pnpm link --global`.
2026-08-15 18:37:30 +02:00
## Requirements
2026-08-16 11:03:25 +02:00
- Node.js >= 20 — the only thing you need installed. Everything else `triggershell` needs ships
inside the package itself and is resolved automatically when you install it.
2026-08-15 18:37:30 +02:00
## Configuration
2026-08-15 19:15:03 +02:00
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:
2026-08-15 18:37:30 +02:00
```yaml
server :
host : 127.0.0.1
port : 4173
auth :
enabled : true
sessionSecret : "${TRIGGERSHELL_SESSION_SECRET}" # >= 32 chars
users :
- username : admin
2026-08-15 19:31:41 +02:00
passwordHash : "${TRIGGERSHELL_USER_ADMIN_PASSWORD_HASH}" # from `triggershell users add`
2026-08-15 18:37:30 +02:00
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
```
2026-08-15 19:15:03 +02:00
A full, richly-commented example lives at [`examples/triggershell.yml` ](examples/triggershell.yml )
(with a matching [`.env.example` ](examples/.env.example )), and the complete field-by-field
reference is in [`docs/CONFIG_REFERENCE.md` ](docs/CONFIG_REFERENCE.md ).
2026-08-15 18:37:30 +02:00
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
2026-08-16 13:55:25 +02:00
| 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 |
2026-08-16 12:29:55 +02:00
| `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 ](#running-scripts-from-the-cli ). |
2026-08-16 13:55:25 +02:00
| `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 |
2026-08-19 18:06:06 +02:00
| `triggershell service logs [-n LINES] [--no-follow] [--system]` | Tail the systemd unit's logs (wraps `journalctl -o cat` , so each line is raw JSON - pipe through `jq` for pretty-printing) |
2026-08-15 18:37:30 +02:00
2026-08-16 12:29:55 +02:00
### 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/runs` endpoint the
web UI uses, authenticated with `--token` /`TRIGGERSHELL_API_TOKEN` if `auth.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/:id` streams its output live, exactly as if it had been started from
the UI.
- **No server reachable** — `run` executes 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.
2026-08-15 18:37:30 +02:00
## 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` ](docs/API.md ).
2026-08-16 13:55:25 +02:00
| 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` |
2026-08-15 18:37:30 +02:00
## Development
2026-08-16 11:03:25 +02:00
This is the workflow for working on TriggerShell itself, not for installing/running it — it
2026-08-16 11:14:01 +02:00
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:
2026-08-16 11:03:25 +02:00
2026-08-15 18:37:30 +02:00
```bash
2026-08-16 11:14:01 +02:00
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
2026-08-15 18:37:30 +02:00
```
2026-08-16 11:14:01 +02:00
Repo layout — the repo root itself is the published npm package (Next.js app + the `triggershell`
CLI in `bin/` /`src/cli/` ), alongside:
2026-08-15 18:37:30 +02:00
```
examples/ A runnable example config + scripts
docs/ Config/architecture/API reference docs
```
See [`docs/ARCHITECTURE.md` ](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 own `command` /`args` may still use `shell:
2026-08-16 13:55:25 +02:00
true` as an explicit, documented opt-in when the script genuinely needs pipes/globs; that
2026-08-15 18:37:30 +02:00
reintroduces shell interpretation of `passAs: arg` values, so prefer `passAs: env` for anything
user-controlled in that case.
- `secret: true` variables 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.1` by default — set `server.host` explicitly to expose it further.
- Route Handlers check auth themselves (`requireAuth()` ); `proxy.ts` is only a fast, optimistic
redirect layer, not the security boundary.
2026-08-15 19:15:03 +02:00
- `auth.sessionSecret` should live in `.env` , not the config file — `triggershell init` sets this
2026-08-15 19:31:41 +02:00
up for you. `passwordHash` /`tokenHash` are one-way hashes (not the secrets themselves), so
storing them directly in the config is reasonably safe — the same trust model as `/etc/shadow`
or `.htpasswd` — but `triggershell users add` /`add-token` store them in `.env` via `${VAR}` by
default too, for cases where you don't want them readable by anyone with config access at all.
Pass `--inline` to get the old behavior of printing the raw hash to paste into the config.
2026-08-15 18:37:30 +02:00
## License
MIT — see [LICENSE ](LICENSE ).