20 Commits
Author SHA1 Message Date
valknarandClaude Sonnet 5 14158047ac Make the README link in docs/API.md absolute
Release / release (push) Successful in 1m6s
The docs viewer serves docs/*.md at /docs/<slug> but doesn't ship
README.md as a route, so the relative ../README.md link resolved to
a dead /README.md path in the rendered web UI. Point it at the file's
Gitea URL instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 10:01:00 +02:00
valknarandClaude Sonnet 5 e5fb28ef31 Widen the doc detail view to max-w-5xl
Matches the width already used on the run and new-run cards.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 09:57:19 +02:00
valknarandClaude Sonnet 5 e7b2b9add2 Fix docs pages overflowing horizontally on mobile
Release / release (push) Successful in 1m36s
GFM tables (config reference's Field/Type/Default/Notes tables) are
wider than a phone screen and don't wrap, so without their own scroll
container they forced the whole page to scroll horizontally instead.
Wrap rendered tables in an overflow-x-auto div, and let long unbroken
strings in table cells and inline code (env var names, paths) break
instead of forcing extra width.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 09:23:31 +02:00
valknarandClaude Sonnet 5 5574ffd1c8 Add a minimal 404 page matching the app's style
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>
2026-08-17 03:12:19 +02:00
valknarandClaude Sonnet 5 adeb21be19 Fix Re-run button squeezing the run metadata row in the header
CardAction's default row-span-2 reserved header column 2 across both
the title row and the metadata dl row below it, shrinking the dl's
available width (cramping the Run ID column) to fit around the
button. Scope the button to just the title row and let the dl span
the full header width on its own row.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 03:05:55 +02:00
valknarandClaude Sonnet 5 335e7624b4 Quote variable values with spaces in the displayed run command line
Values containing spaces (e.g. an env-passed SCENE="Glitz and glam")
rendered as bare, space-separated words in the run detail view,
indistinguishable from separate argv/env entries. Now anything outside
a safe bareword character set is quoted and escaped for display only -
actual execution is unaffected, since values are always passed as
discrete argv elements/env vars, never through a shell.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 03:01:53 +02:00
valknarandClaude Sonnet 5 23a4e2ebc0 Widen run and new-run cards to max-w-5xl with a multi-column form layout
The narrower max-w-2xl card left a lot of unused width on scripts with
several variables, forcing a long single-column scroll. Widening the
card and laying out variable inputs in a responsive grid (up to 3
columns) uses that space; textareas and checkbox groups still span the
full width since they don't shrink well into a column.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 20:37:11 +02:00
valknar b77041cfa8 Size the run terminal by aspect ratio instead of viewport height
h-[60vh] made the terminal's height track the viewport regardless of
its actual width, so it read as too tall on narrower layouts.
aspect-video keeps it at 16/9 relative to its own width instead.
2026-08-16 18:15:17 +02:00
valknar e482810328 Show env-passed variables in the displayed run command line
Release / release (push) Successful in 1m6s
redactedCommandLine only ever included argv (script.command + args),
so a passAs:env variable like a scene name was invisible in run
history even though it's the main thing that varied between runs.
Secrets still redact to *** instead of being omitted outright.
2026-08-16 17:31:31 +02:00
valknar 86aa0b7539 Fix prettier formatting on the new combobox control
Release / release (push) Successful in 1m5s
2026-08-16 17:23:30 +02:00
valknar c7bc4421c5 Add a searchable combobox control for enum variables
select/radio don't scale to enums with dozens of choices. Reuses the
same cmdk Command/Popover primitives multi-select already uses, just
single-valued instead of an array.
2026-08-16 17:21:11 +02:00
valknarandClaude Sonnet 5 a496dc4865 Drop the redundant explicit build step from the release workflow
`pnpm run build` and pnpm publish's automatic prepack hook
(rm -rf .next && next build && rm -rf .next/cache) both ran a full
next build - the explicit step's output got thrown away and rebuilt
from scratch seconds later inside publish anyway. Kept only prepack's
build, since it's the one that actually has to succeed for a
publishable package to exist; a deterministic build that just passed
isn't going to fail differently a few steps later.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 14:24:11 +02:00
valknarandClaude Sonnet 5 677aabfa30 Exclude .pnpm-store from prettier and git
Release / release (push) Successful in 1m25s
On the Gitea runner, pnpm's content-addressable store ends up inside
the workspace (.pnpm-store/) instead of the global cache location -
format:check was scanning its content-addressable blobs as if they
were source files, some of which happen to parse as JS/TS-like text
and crash prettier's parser outright rather than just wasting time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 14:19:44 +02:00
valknarandClaude Sonnet 5 8ca8c57793 Stop routing pnpm install through the Gitea registry
Release / release (push) Canceled after 1m42s
actions/setup-node's registry-url sets the *default* npm registry for
every install, not just publishing - since triggershell is an
unscoped package name, that meant `pnpm install` tried to fetch every
ordinary dependency (zod, typescript, ws, ...) from
dev.pivoine.art/api/packages/valknar/npm/ instead of the public npm
registry, and got hammered with 429s retrying each one.

Removes registry-url from setup-node entirely (installs go back to
the default public registry) and instead scopes the auth token to
just that one registry host+path via `pnpm config set
"//dev.pivoine.art/api/packages/valknar/npm/:_authToken" ...` right
before the publish step - publishConfig.registry in package.json
already tells `pnpm publish` specifically where to go (verified
locally via `pnpm publish --dry-run` earlier), this only supplies the
matching credential without touching install resolution.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 14:16:41 +02:00
valknarandClaude Sonnet 5 3f391ff584 Drop pnpm cache from the release workflow - not reachable on this runner
actions/setup-node's cache: pnpm option tries to hit this Gitea
instance's Actions cache service, which times out (ETIMEDOUT against
an internal address) rather than failing fast - burning ~5 minutes on
every run before falling back to an uncached install anyway. Not
worth it for a release workflow that runs once per tag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 14:13:46 +02:00
valknarandClaude Sonnet 5 ef453eadd0 Bump release workflow's Node to 22 - pnpm 11 needs it to even run
Release / release (push) Canceled after 8m18s
pnpm 11.21.0 (pinned in packageManager) now uses node:sqlite
internally, which requires Node >=22.13 - unrelated to this project's
own engines.node: >=20 floor for end users. With node-version: 20 the
runner's pnpm binary couldn't execute at all (ERR_UNKNOWN_BUILTIN_MODULE
on the first pnpm invocation inside actions/setup-node's cache-path
detection), before ever reaching the actual lint/build/publish steps.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 14:05:48 +02:00
valknarandClaude Sonnet 5 a11dfd8f7e Rename the release workflow's secret to PACKAGE_TOKEN
Release / release (push) Failing after 1m14s
Gitea rejects secret names starting with GIT (not just the GITEA_/
GITHUB_ prefixes), so GITEA_PACKAGE_TOKEN wasn't a valid name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 14:01:40 +02:00
valknarandClaude Sonnet 5 2a5b0b98f4 Add a Gitea Actions release workflow: lint/typecheck/format check -> build -> publish
Triggered on tags matching v*.*.* - runs the quality gate (lint,
typecheck, format:check, the existing test suite, build) as
individual steps for clear failure attribution, then publishes to
this Gitea instance's own npm registry (dev.pivoine.art, unscoped
package name - Gitea's npm registry supports that directly, no
@owner/ rename needed).

The release version comes from the git tag (v1.0.0 -> 1.0.0 via
`npm pkg set`), patched into package.json only in the CI run, never
committed back. publishConfig.registry in package.json is a static
string (safe to commit); the auth token is supplied at publish time
via NODE_AUTH_TOKEN, written to a CI-generated user-level .npmrc by
actions/setup-node's registry-url option rather than a repo-committed
one - pnpm >=10.34.2/11.5.3 (this repo pins 11.21.0) blocks ${VAR}
expansion in repository-controlled npmrc/pnpm-workspace.yaml
specifically to stop a malicious repo from exfiltrating CI secrets
that way, so the token can't live in a committed .npmrc at all.

Verified locally end-to-end short of the actual registry upload:
lint/typecheck/format:check/test/build all pass, and
`pnpm publish --dry-run --no-git-checks` after a temporary version
bump confirms publishConfig.registry resolves to the right URL and
prepack (next build) fires automatically as part of publish.

One-time manual setup this can't do by itself (documented in the plan
file): a repo-scoped Gitea Personal Access Token with the `package`
Read&Write scope, stored as the GITEA_PACKAGE_TOKEN repo secret -
Gitea's own auto-injected GITEA_TOKEN explicitly cannot publish
packages (unimplemented per Gitea's own docs).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 13:55:47 +02:00
valknarandClaude Sonnet 5 c6337ea942 Run prettier across the repo, exclude the lockfile from it
Prettier had never been run in --check mode here before, so this had
drifted across most files (markdown tables, long option() chains,
line wrapping). Purely formatting, no logic changes - needed so a CI
format:check gate can actually pass. Adds .prettierignore for
pnpm-lock.yaml specifically: prettier's YAML formatter rewrites every
quoted key (single -> double quotes) producing an ~8700-line diff of
pure noise on a file pnpm itself owns the formatting of.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 13:55:25 +02:00
valknarandClaude Sonnet 5 e13b7e3b1a Add triggershell run/scripts - execute configured scripts from the CLI
`run <scriptId>` auto-detects whether the web server is already
reachable (a quick /api/healthz check):

- If it is, the run goes through the existing POST
  /api/scripts/:id/runs endpoint (token-authenticated, same as any
  other API client) and the CLI subscribes over /ws/runs exactly like
  a browser tab - so the run shows up live in Run History and any open
  browser watching it, with zero server-side changes, since the
  broadcast path has no idea a run was triggered by a click vs a CLI
  invocation.
- If nothing's reachable, it calls startRun() directly in its own
  process (after its own migrateOnBoot/reconcileOrphanedRuns, so a
  from-scratch .triggershell/ works standalone) and streams output by
  listening on the same in-process runEvents emitter a WS client would
  otherwise be fed from - read-log-then-listen, the same ordering
  ws/server.ts's subscribe() already uses, so a fast script finishing
  before the listener attaches still gets its output printed.

Both modes support --var name=value (repeatable; repeat a name for
multiselect), --no-wait, and Ctrl-C cancellation through the same
mechanism the web UI's Cancel button uses (a WS cancel message
remotely, cancelRun() directly locally). `scripts list`/`scripts show`
are local-only, no network - same direct-config-read pattern as
`validate`/`doctor`.

Extracts defaultValuesForScript() out of dynamic-form.tsx into
src/lib/config/defaults.ts so the CLI's --var handling and the web
form fill in a script's configured defaults identically instead of
duplicating that logic.

Verified live end-to-end: a CLI-triggered remote run was observed
streaming to both the triggering CLI process and an independent WS
client (simulating a browser tab) simultaneously; local-mode Ctrl-C
confirmed to actually kill the spawned child process, not just the
CLI; token, wrong-token, and TRIGGERSHELL_API_TOKEN auth paths all
verified against a running auth-enabled server.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 12:29:55 +02:00
47 changed files with 1285 additions and 172 deletions
+41
View File
@@ -0,0 +1,41 @@
name: Release
on:
push:
tags:
- "v*.*.*"
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/pnpm/action-setup@v4
with:
version: 11.21.0
- uses: https://github.com/actions/setup-node@v4
with:
node-version: 22
- run: pnpm install --frozen-lockfile
- run: pnpm run lint
- run: pnpm run typecheck
- run: pnpm run format:check
- run: pnpm run test
- name: Set package version from the tag
run: npm pkg set version="${GITHUB_REF_NAME#v}"
# Scoped to this one registry host+path (via publishConfig.registry in package.json) rather
# than actions/setup-node's registry-url, which would set it as the *default* registry for
# every install - breaking `pnpm install` for this project's own (unscoped, public) deps.
- name: Configure registry auth for publish
run: pnpm config set "//dev.pivoine.art/api/packages/valknar/npm/:_authToken" "$PACKAGE_TOKEN"
env:
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
- name: Publish to Gitea npm registry
run: pnpm publish --no-git-checks
+1
View File
@@ -1,5 +1,6 @@
# dependencies # dependencies
/node_modules /node_modules
/.pnpm-store
/.pnp /.pnp
.pnp.* .pnp.*
.yarn/* .yarn/*
+7
View File
@@ -0,0 +1,7 @@
# Machine-generated - pnpm owns this file's formatting, not prettier.
pnpm-lock.yaml
# pnpm's local content-addressable store - on some runners this ends up inside the workspace
# instead of the global cache location; its blobs aren't source files (some happen to parse as
# JS/TS-like content, which crashes prettier's parser rather than just wasting time on them).
.pnpm-store/
+25 -3
View File
@@ -100,17 +100,39 @@ the script — always as a discrete argv element or env var, never interpolated
## CLI Usage ## CLI Usage
| Command | Description | | Command | Description |
|---|---| | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `triggershell init [PATH]` | Scaffold a new config file + `.env` (`--port`, `--auth/--no-auth`, `--force`) | | `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 validate [-c CONFIG]` | Validate a config file against the full schema |
| `triggershell start [-c CONFIG] [--port] [--host] [--no-browser]` | Run the web app | | `triggershell start [-c CONFIG] [--port] [--host] [--no-browser]` | Run the web app |
| `triggershell doctor [-c CONFIG]` | Print environment/config diagnostics | | `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](#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 <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 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 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 uninstall [--system]` | Stop, disable, and remove the systemd unit |
| `triggershell service status [--system]` | Show the systemd unit's status | | `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/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.
## Web App Guide ## Web App Guide
- **Scripts** (`/`) — every configured script as a card; click through to its run form. - **Scripts** (`/`) — every configured script as a card; click through to its run form.
@@ -133,7 +155,7 @@ Passwords are hashed with argon2id; only the hash ever lives in the config file.
Full reference with request/response shapes and curl examples: [`docs/API.md`](docs/API.md). Full reference with request/response shapes and curl examples: [`docs/API.md`](docs/API.md).
| Method | Path | Notes | | Method | Path | Notes |
|---|---|---| | ------ | ----------------------------- | ------------------------------------------------------ |
| GET | `/api/healthz` | Unauthenticated readiness probe | | GET | `/api/healthz` | Unauthenticated readiness probe |
| POST | `/api/auth/login` | `{username, password}` → sets session cookie | | POST | `/api/auth/login` | `{username, password}` → sets session cookie |
| POST | `/api/auth/logout` | Clears the session | | POST | `/api/auth/logout` | Clears the session |
@@ -177,7 +199,7 @@ See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how the pieces fit togeth
- Scripts are always spawned with an argv array (`execa`), never a shell string — variable values - 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: can never inject additional shell commands. A script's own `command`/`args` may still use `shell:
true` as an explicit, documented opt-in when the script genuinely needs pipes/globs; that true` as an explicit, documented opt-in when the script genuinely needs pipes/globs; that
reintroduces shell interpretation of `passAs: arg` values, so prefer `passAs: env` for anything reintroduces shell interpretation of `passAs: arg` values, so prefer `passAs: env` for anything
user-controlled in that case. user-controlled in that case.
- `secret: true` variables are masked in the UI and redacted from persisted run records; only the - `secret: true` variables are masked in the UI and redacted from persisted run records; only the
+5 -1
View File
@@ -6,6 +6,10 @@ When `auth.enabled: true`, every endpoint below except `/api/healthz`, `/api/aut
`/api/auth/session` requires either a valid session cookie or an `Authorization: Bearer <token>` `/api/auth/session` requires either a valid session cookie or an `Authorization: Bearer <token>`
header using a token from `triggershell users add-token`. header using a token from `triggershell users add-token`.
`triggershell run <scriptId>` is a first-party client of this exact REST + WS contract (see
[Running scripts from the CLI](https://dev.pivoine.art/valknar/triggershell/src/branch/main/README.md#running-scripts-from-the-cli)) -
nothing below is CLI-specific.
## Auth ## Auth
### `POST /api/auth/login` ### `POST /api/auth/login`
@@ -73,7 +77,7 @@ code). `404` if not found.
### `POST /api/runs/:runId/cancel` ### `POST /api/runs/:runId/cancel`
`202 {"status": "cancelling"}`. `409` if the run already finished, or if it isn't tracked by *this* `202 {"status": "cancelling"}`. `409` if the run already finished, or if it isn't tracked by _this_
server process (e.g. after a restart — see "orphaned runs" in `docs/ARCHITECTURE.md`). server process (e.g. after a restart — see "orphaned runs" in `docs/ARCHITECTURE.md`).
### `GET /api/runs/:runId/logs` ### `GET /api/runs/:runId/logs`
+27 -3
View File
@@ -40,6 +40,30 @@ there's no parent process relaying signals to a child, no separate lifecycle to
(`tsx/esm/api`'s `register()`), so both the CLI's own `.ts` command files and `server.ts` run (`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. straight from source, with no compile/bundle step for either.
## `triggershell run` - a second, independent client of the same run pipeline
`triggershell run <scriptId>` (`src/cli/commands/run.ts`) never duplicates the spawn/streaming
logic in `src/lib/runner/engine.ts` - it just calls it from a different position:
- If a server is reachable (`GET /api/healthz`), `run` is a plain HTTP+WS client: `POST
/api/scripts/:id/runs` (the same route the web UI's "Run" button calls) starts the run _inside
that server's process_, and `run` then subscribes over `/ws/runs` exactly like a browser tab
would, using the same `ClientMessage`/`ServerMessage` protocol (`src/lib/ws/protocol.ts`). This
is why a run started this way appears live in any open browser tab for free - the broadcast path
(`emitRunMessage` → the `runEvents` listener in `src/lib/ws/server.ts` → every subscribed
WebSocket) has no idea the run was triggered by a CLI instead of a click.
- If nothing's reachable, `run` calls `startRun()` directly, in its own short-lived process (after
its own `migrateOnBoot()`/`reconcileOrphanedRuns()`, so a from-scratch `.triggershell/` works
standalone). Since it's in the same process as the run it just started, it doesn't need WS at
all - it listens on the same in-process `runEvents` emitter a WS client would otherwise be fed
from, using the exact "read the log file and current status first, then attach a live listener"
ordering `subscribe()` in `ws/server.ts` already uses, for the same reason: a fast script can
finish before a listener is attached.
Either way, `Ctrl-C` cancels the run through the existing mechanism for that mode - a `{"type":
"cancel"}` WS message for the remote case, `cancelRun()` (`src/lib/runner/registry.ts`) directly
for the local case - not a new cancellation path.
## Running as a systemd service ## Running as a systemd service
`triggershell service install` renders a unit file (`src/cli/lib/systemd.ts`) whose `ExecStart` `triggershell service install` renders a unit file (`src/cli/lib/systemd.ts`) whose `ExecStart`
@@ -57,7 +81,7 @@ reimplemented.
## Cross-module-graph state ## Cross-module-graph state
Next compiles Route Handlers and Server Components through its own build/module graph, which is a 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 — _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 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 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 needs to be shared across that boundary (the WebSocket subscriber registry, the live-run-handle
@@ -86,10 +110,10 @@ queued → running → succeeded | failed | cancelled | timed_out
## Auth ## Auth
- Session: `iron-session` — a stateless, encrypted+signed cookie (no session-store table). - 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.ts` upserts config - Config is the source of truth for _who_ is allowed in; `src/lib/auth/sync.ts` upserts config
users/tokens into SQLite on boot, giving a single DB-backed check path plus `lastLoginAt` tracking. users/tokens into SQLite on boot, giving a single DB-backed check path plus `lastLoginAt` tracking.
- `src/proxy.ts` (Next's Proxy, formerly "Middleware") does a fast, cookie-only redirect for - `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 unauthenticated page/API requests — explicitly _not_ the real security boundary. Every Route
Handler also calls `requireAuth()` itself; this is the actual auth check. Handler also calls `requireAuth()` itself; this is the actual auth check.
- The WS `upgrade` handler is outside Next's request pipeline entirely, so it authenticates by hand - The WS `upgrade` handler is outside Next's request pipeline entirely, so it authenticates by hand
(parsing the cookie header, or a `?token=` query param) via `authenticateUpgrade()`. (parsing the cookie header, or a `?token=` query param) via `authenticateUpgrade()`.
+9 -9
View File
@@ -17,7 +17,7 @@ separate pre-flight step).
## `server` ## `server`
| Field | Type | Default | Notes | | Field | Type | Default | Notes |
|---|---|---|---| | ---------- | ------ | ----------- | ----------------------- |
| `host` | string | `127.0.0.1` | Bind address | | `host` | string | `127.0.0.1` | Bind address |
| `port` | number | `4173` | 1-65535 | | `port` | number | `4173` | 1-65535 |
| `basePath` | string | `""` | Reserved for future use | | `basePath` | string | `""` | Reserved for future use |
@@ -25,7 +25,7 @@ separate pre-flight step).
## `auth` ## `auth`
| Field | Type | Default | Notes | | Field | Type | Default | Notes |
|---|---|---|---| | ----------------- | ------- | ------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `enabled` | boolean | `true` | `false` disables login entirely | | `enabled` | boolean | `true` | `false` disables login entirely |
| `sessionSecret` | string | — | Required, >= 32 chars, if `enabled`. Reference it via `${TRIGGERSHELL_SESSION_SECRET}` and set the real value in `.env`, not here | | `sessionSecret` | string | — | Required, >= 32 chars, if `enabled`. Reference it via `${TRIGGERSHELL_SESSION_SECRET}` and set the real value in `.env`, not here |
| `sessionTtlHours` | number | `12` | Session cookie lifetime | | `sessionTtlHours` | number | `12` | Session cookie lifetime |
@@ -43,20 +43,20 @@ pass `--inline` to those commands to get the raw hash printed for pasting into t
## `database` ## `database`
| Field | Type | Default | | Field | Type | Default |
|---|---|---| | ------ | ------ | ------------------------------- |
| `path` | string | `.triggershell/triggershell.db` | | `path` | string | `.triggershell/triggershell.db` |
## `logs` ## `logs`
| Field | Type | Default | Notes | | Field | Type | Default | Notes |
|---|---|---|---| | --------------- | ------ | -------------------- | ----------------------------------------------------------- |
| `dir` | string | `.triggershell/logs` | One `<runId>.log` file per run | | `dir` | string | `.triggershell/logs` | One `<runId>.log` file per run |
| `retentionDays` | number | `30` | Not yet enforced automatically — prune manually or via cron | | `retentionDays` | number | `30` | Not yet enforced automatically — prune manually or via cron |
## `scripts[]` ## `scripts[]`
| Field | Type | Default | Notes | | Field | Type | Default | Notes |
|---|---|---|---| | ---------------- | -------- | ------- | ------------------------------------------------------------------------------------ |
| `id` | string | — | Required, unique, `[a-zA-Z0-9][a-zA-Z0-9_-]*` | | `id` | string | — | Required, unique, `[a-zA-Z0-9][a-zA-Z0-9_-]*` |
| `name` | string | — | Required, display name | | `name` | string | — | Required, display name |
| `description` | string | — | Optional | | `description` | string | — | Optional |
@@ -72,7 +72,7 @@ pass `--inline` to those commands to get the raw hash printed for pasting into t
Common fields on every variable: Common fields on every variable:
| Field | Type | Default | Notes | | Field | Type | Default | Notes |
|---|---|---|---| | ------------- | ----------------------------------- | ------------------ | -------------------------------------------------------------------------------------- |
| `name` | string | — | Required, unique per script | | `name` | string | — | Required, unique per script |
| `label` | string | `name` | Display label | | `label` | string | `name` | Display label |
| `description` | string | — | Shown as form help text | | `description` | string | — | Shown as form help text |
@@ -87,7 +87,7 @@ Common fields on every variable:
Type-specific fields: Type-specific fields:
| `type` | Extra fields | | `type` | Extra fields |
|---|---| | ------------- | ------------------------------------------------------------------------------------------------- |
| `string` | `default?: string`, `pattern?: string` (regex), `minLength?`, `maxLength?`, `multiline?: boolean` | | `string` | `default?: string`, `pattern?: string` (regex), `minLength?`, `maxLength?`, `multiline?: boolean` |
| `number` | `default?: number`, `min?`, `max?`, `step?` | | `number` | `default?: number`, `min?`, `max?`, `step?` |
| `boolean` | `default: boolean` (default `false`) | | `boolean` | `default: boolean` (default `false`) |
@@ -97,11 +97,11 @@ Type-specific fields:
### UI control mapping ### UI control mapping
| `type` | Default `control` | Valid overrides | | `type` | Default `control` | Valid overrides |
|---|---|---| | ------------- | ---------------------------------------- | ------------------------------------------------ |
| `string` | `text` (or `password` if `secret: true`) | `textarea` (needs `multiline: true`), `password` | | `string` | `text` (or `password` if `secret: true`) | `textarea` (needs `multiline: true`), `password` |
| `number` | `number` | `slider` (requires both `min` and `max`) | | `number` | `number` | `slider` (requires both `min` and `max`) |
| `boolean` | `checkbox` | `switch` | | `boolean` | `checkbox` | `switch` |
| `enum` | `select` | `radio` | | `enum` | `select` | `radio`, `combobox` (searchable, single-select) |
| `multiselect` | `multiselect` (combobox) | `checkboxGroup` | | `multiselect` | `multiselect` (combobox) | `checkboxGroup` |
### `passAs` semantics ### `passAs` semantics
+3 -1
View File
@@ -10,7 +10,9 @@ const targetDir = getArg("--dir");
const olderThanDays = getArg("--days"); const olderThanDays = getArg("--days");
const types = getArg("--types"); const types = getArg("--types");
console.log(`Scanning ${targetDir} for files older than ${olderThanDays} days (types: ${types})`); console.log(
`Scanning ${targetDir} for files older than ${olderThanDays} days (types: ${types})`,
);
if (process.env.CLEANUP_API_KEY) { if (process.env.CLEANUP_API_KEY) {
console.log("Using configured external API key."); console.log("Using configured external API key.");
} }
+1 -1
View File
@@ -72,7 +72,7 @@ scripts:
label: Notify Slack Channel label: Notify Slack Channel
type: string type: string
required: false required: false
pattern: '^#[a-z0-9-]+$' pattern: "^#[a-z0-9-]+$"
passAs: env passAs: env
envName: SLACK_CHANNEL envName: SLACK_CHANNEL
+4
View File
@@ -13,6 +13,9 @@
"engines": { "engines": {
"node": ">=20" "node": ">=20"
}, },
"publishConfig": {
"registry": "https://dev.pivoine.art/api/packages/valknar/npm/"
},
"files": [ "files": [
"bin", "bin",
"src", "src",
@@ -33,6 +36,7 @@
"lint": "eslint", "lint": "eslint",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check .",
"test": "tsx --test \"src/cli/**/*.test.ts\"", "test": "tsx --test \"src/cli/**/*.test.ts\"",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
+1 -1
View File
@@ -27,7 +27,7 @@ export default async function DocPage({ params }: DocPageProps) {
if (content === null) notFound(); if (content === null) notFound();
return ( return (
<div className="mx-auto flex max-w-3xl flex-col gap-6"> <div className="mx-auto flex max-w-5xl flex-col gap-6">
<Link <Link
href="/docs" href="/docs"
className="text-muted-foreground hover:text-foreground flex w-fit items-center gap-1 text-sm" className="text-muted-foreground hover:text-foreground flex w-fit items-center gap-1 text-sm"
+5
View File
@@ -0,0 +1,5 @@
import { NotFoundContent } from "@/components/layout/not-found-content";
export default function NotFound() {
return <NotFoundContent />;
}
+4 -4
View File
@@ -66,12 +66,12 @@ export default async function RunDetailPage({ params }: RunDetailPageProps) {
})); }));
return ( return (
<div className="mx-auto flex max-w-2xl flex-col gap-4"> <div className="mx-auto flex max-w-5xl flex-col gap-4">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{run.scriptName}</CardTitle> <CardTitle>{run.scriptName}</CardTitle>
{script && ( {script && (
<CardAction> <CardAction className="row-span-1">
<Link <Link
href={`/scripts/${run.scriptId}?fromRun=${run.id}`} href={`/scripts/${run.scriptId}?fromRun=${run.id}`}
className={buttonVariants({ variant: "outline", size: "sm" })} className={buttonVariants({ variant: "outline", size: "sm" })}
@@ -81,7 +81,7 @@ export default async function RunDetailPage({ params }: RunDetailPageProps) {
</Link> </Link>
</CardAction> </CardAction>
)} )}
<dl className="text-muted-foreground grid grid-cols-2 gap-x-4 gap-y-2 text-xs sm:grid-cols-3"> <dl className="text-muted-foreground col-span-2 grid grid-cols-2 gap-x-4 gap-y-2 text-xs sm:grid-cols-3">
<div> <div>
<dt className="font-mono text-[0.7rem] font-medium tracking-widest uppercase"> <dt className="font-mono text-[0.7rem] font-medium tracking-widest uppercase">
Triggered by Triggered by
@@ -119,7 +119,7 @@ export default async function RunDetailPage({ params }: RunDetailPageProps) {
<span className="text-muted-foreground font-mono text-[0.7rem] font-medium tracking-widest uppercase"> <span className="text-muted-foreground font-mono text-[0.7rem] font-medium tracking-widest uppercase">
Variables Variables
</span> </span>
<dl className="grid gap-x-6 gap-y-2 rounded-md border p-3 text-xs sm:grid-cols-2"> <dl className="grid gap-x-6 gap-y-2 rounded-md border p-3 text-xs sm:grid-cols-2 lg:grid-cols-3">
{variableEntries.map(({ key, label, value }) => ( {variableEntries.map(({ key, label, value }) => (
<div key={key} className="flex flex-col gap-0.5"> <div key={key} className="flex flex-col gap-0.5">
<dt className="text-muted-foreground">{label}</dt> <dt className="text-muted-foreground">{label}</dt>
+21 -5
View File
@@ -2,11 +2,20 @@ export const dynamic = "force-dynamic";
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight } from "lucide-react"; import {
ArrowDown,
ArrowUp,
ArrowUpDown,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { and, asc, desc, eq, like, or, sql, type SQL } from "drizzle-orm"; import { and, asc, desc, eq, like, or, sql, type SQL } from "drizzle-orm";
import { getDb } from "@/lib/db/client"; import { getDb } from "@/lib/db/client";
import { runs } from "@/lib/db/schema"; import { runs } from "@/lib/db/schema";
import { RunStatusBadge, runStatusLabels } from "@/components/runs/run-status-badge"; import {
RunStatusBadge,
runStatusLabels,
} from "@/components/runs/run-status-badge";
import { RunsToolbar } from "@/components/runs/runs-toolbar"; import { RunsToolbar } from "@/components/runs/runs-toolbar";
import { buttonVariants } from "@/components/ui/button"; import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -89,7 +98,9 @@ export default async function RunsPage({ searchParams }: RunsPageProps) {
); );
} }
if (statusFilter !== "all" && statusFilter in runStatusLabels) { if (statusFilter !== "all" && statusFilter in runStatusLabels) {
conditions.push(eq(runs.status, statusFilter as keyof typeof runStatusLabels)); conditions.push(
eq(runs.status, statusFilter as keyof typeof runStatusLabels),
);
} }
if (scriptFilter !== "all") { if (scriptFilter !== "all") {
conditions.push(eq(runs.scriptId, scriptFilter)); conditions.push(eq(runs.scriptId, scriptFilter));
@@ -167,7 +178,10 @@ export default async function RunsPage({ searchParams }: RunsPageProps) {
<RunsToolbar scripts={scripts} /> <RunsToolbar scripts={scripts} />
{rows.length === 0 ? ( {rows.length === 0 ? (
<p className="text-muted-foreground py-12 text-center"> <p className="text-muted-foreground py-12 text-center">
{total === 0 && !search && statusFilter === "all" && scriptFilter === "all" {total === 0 &&
!search &&
statusFilter === "all" &&
scriptFilter === "all"
? "No runs yet." ? "No runs yet."
: "No runs match these filters."} : "No runs match these filters."}
</p> </p>
@@ -179,7 +193,9 @@ export default async function RunsPage({ searchParams }: RunsPageProps) {
<TableRow> <TableRow>
<TableHead>{sortHeader("script", "Script")}</TableHead> <TableHead>{sortHeader("script", "Script")}</TableHead>
<TableHead>{sortHeader("status", "Status")}</TableHead> <TableHead>{sortHeader("status", "Status")}</TableHead>
<TableHead>{sortHeader("triggeredBy", "Triggered by")}</TableHead> <TableHead>
{sortHeader("triggeredBy", "Triggered by")}
</TableHead>
<TableHead>{sortHeader("started", "Started")}</TableHead> <TableHead>{sortHeader("started", "Started")}</TableHead>
<TableHead>{sortHeader("duration", "Duration")}</TableHead> <TableHead>{sortHeader("duration", "Duration")}</TableHead>
</TableRow> </TableRow>
+1 -1
View File
@@ -55,7 +55,7 @@ export default async function ScriptPage({
} }
return ( return (
<div className="mx-auto max-w-2xl"> <div className="mx-auto max-w-5xl">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{script.name}</CardTitle> <CardTitle>{script.name}</CardTitle>
+5 -1
View File
@@ -1,5 +1,9 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Bricolage_Grotesque, IBM_Plex_Mono, IBM_Plex_Sans } from "next/font/google"; import {
Bricolage_Grotesque,
IBM_Plex_Mono,
IBM_Plex_Sans,
} from "next/font/google";
import { ThemeProvider } from "next-themes"; import { ThemeProvider } from "next-themes";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
+5
View File
@@ -0,0 +1,5 @@
import { NotFoundContent } from "@/components/layout/not-found-content";
export default function NotFound() {
return <NotFoundContent />;
}
+7 -2
View File
@@ -14,7 +14,10 @@ export async function doctorCommand(opts: DoctorOptions): Promise<void> {
const configPath = resolveConfigPath(opts.config); const configPath = resolveConfigPath(opts.config);
const exists = fs.existsSync(configPath); const exists = fs.existsSync(configPath);
rows.push(["Config path", `${configPath} ${exists ? "(exists)" : "(not found)"}`]); rows.push([
"Config path",
`${configPath} ${exists ? "(exists)" : "(not found)"}`,
]);
if (exists) { if (exists) {
try { try {
@@ -22,7 +25,9 @@ export async function doctorCommand(opts: DoctorOptions): Promise<void> {
const portFree = await isPortFree(config.server.host, config.server.port); const portFree = await isPortFree(config.server.host, config.server.port);
rows.push([ rows.push([
"Port available", "Port available",
portFree ? "yes" : `no (${config.server.host}:${config.server.port} in use)`, portFree
? "yes"
: `no (${config.server.host}:${config.server.port} in use)`,
]); ]);
rows.push(["Scripts configured", String(config.scripts.length)]); rows.push(["Scripts configured", String(config.scripts.length)]);
rows.push(["Auth enabled", String(config.auth.enabled)]); rows.push(["Auth enabled", String(config.auth.enabled)]);
+16 -4
View File
@@ -11,7 +11,10 @@ export interface InitOptions {
force: boolean; force: boolean;
} }
export async function initCommand(targetPath: string | undefined, opts: InitOptions): Promise<void> { export async function initCommand(
targetPath: string | undefined,
opts: InitOptions,
): Promise<void> {
const targetDir = path.resolve(process.cwd(), targetPath ?? "."); const targetDir = path.resolve(process.cwd(), targetPath ?? ".");
fs.mkdirSync(targetDir, { recursive: true }); fs.mkdirSync(targetDir, { recursive: true });
const configPath = path.join(targetDir, DEFAULT_CONFIG_NAME); const configPath = path.join(targetDir, DEFAULT_CONFIG_NAME);
@@ -22,7 +25,11 @@ export async function initCommand(targetPath: string | undefined, opts: InitOpti
return; return;
} }
const templatePath = path.join(resolveAppRoot(), "templates", DEFAULT_CONFIG_NAME); const templatePath = path.join(
resolveAppRoot(),
"templates",
DEFAULT_CONFIG_NAME,
);
const template = fs.readFileSync(templatePath, "utf-8"); const template = fs.readFileSync(templatePath, "utf-8");
const rendered = template const rendered = template
.replace("__PORT__", String(opts.port)) .replace("__PORT__", String(opts.port))
@@ -38,10 +45,15 @@ export async function initCommand(targetPath: string | undefined, opts: InitOpti
); );
} else { } else {
const sessionSecret = crypto.randomBytes(32).toString("hex"); const sessionSecret = crypto.randomBytes(32).toString("hex");
fs.writeFileSync(envPath, `TRIGGERSHELL_SESSION_SECRET=${sessionSecret}\n`); fs.writeFileSync(
envPath,
`TRIGGERSHELL_SESSION_SECRET=${sessionSecret}\n`,
);
console.log(`Created ${envPath} (keep this out of version control)`); console.log(`Created ${envPath} (keep this out of version control)`);
} }
console.log("\nAuth is enabled but no users are configured yet. Add one with:"); console.log(
"\nAuth is enabled but no users are configured yet. Add one with:",
);
console.log(` triggershell users add <username> --config ${configPath}`); console.log(` triggershell users add <username> --config ${configPath}`);
} }
+285
View File
@@ -0,0 +1,285 @@
import path from "node:path";
import { eq } from "drizzle-orm";
import { WebSocket } from "ws";
import { ConfigError, loadConfig } from "../../lib/config/load";
import { defaultValuesForScript } from "../../lib/config/defaults";
import { getDb, migrateOnBoot } from "../../lib/db/client";
import { runs, type RunStatus } from "../../lib/db/schema";
import { startRun, reconcileOrphanedRuns } from "../../lib/runner/engine";
import { cancelRun } from "../../lib/runner/registry";
import { runEvents } from "../../lib/runner/events";
import { readLogTail } from "../../lib/runner/log-file";
import { buildVariableSchema } from "../../lib/validation/variable-schema";
import type { ServerMessage } from "../../lib/ws/protocol";
import { loadDotenv } from "../lib/env-file";
import { isServerReachable } from "../lib/network";
import { resolveConfigPath } from "../lib/paths";
import { coerceVariables, parseVarFlags } from "../lib/variables";
export interface RunOptions {
config?: string;
var: string[];
host?: string;
port?: number;
token?: string;
local?: boolean;
remote?: boolean;
wait: boolean;
}
const NON_TERMINAL: RunStatus[] = ["queued", "running"];
function exitCodeFor(status: RunStatus): number {
return status === "succeeded" ? 0 : 1;
}
export async function runCommand(
scriptId: string,
opts: RunOptions,
): Promise<void> {
const configPath = resolveConfigPath(opts.config);
loadDotenv(path.join(path.dirname(configPath), ".env"));
let loaded;
try {
loaded = loadConfig(configPath);
} catch (error) {
if (error instanceof ConfigError) {
console.error(`Config error: ${error.message}`);
for (const issue of error.issues) console.error(` - ${issue}`);
process.exitCode = 1;
return;
}
throw error;
}
const { config } = loaded;
const script = config.scripts.find((s) => s.id === scriptId);
if (!script) {
console.error(`No script '${scriptId}' configured.`);
if (config.scripts.length > 0) {
console.error(
`Available scripts: ${config.scripts.map((s) => s.id).join(", ")}`,
);
}
process.exitCode = 1;
return;
}
let variables: Record<string, unknown>;
try {
const grouped = parseVarFlags(opts.var);
const raw = {
...defaultValuesForScript(script.variables),
...coerceVariables(script.variables, grouped),
};
const parsed = buildVariableSchema(script).safeParse(raw);
if (!parsed.success) {
console.error("Validation failed:");
for (const [field, issues] of Object.entries(
parsed.error.flatten().fieldErrors,
)) {
console.error(` ${field}: ${(issues ?? []).join(", ")}`);
}
process.exitCode = 1;
return;
}
variables = parsed.data;
} catch (error) {
console.error((error as Error).message);
process.exitCode = 1;
return;
}
const host = opts.host ?? config.server.host;
const port = opts.port ?? config.server.port;
const url = `http://${host}:${port}`;
const token = opts.token ?? process.env.TRIGGERSHELL_API_TOKEN;
if (opts.local && opts.remote) {
console.error("--local and --remote can't be used together.");
process.exitCode = 1;
return;
}
let useRemote: boolean;
if (opts.remote) {
if (!(await isServerReachable(url))) {
console.error(`No triggershell server reachable at ${url}.`);
process.exitCode = 1;
return;
}
useRemote = true;
} else if (opts.local) {
useRemote = false;
} else {
useRemote = await isServerReachable(url);
}
if (useRemote) {
if (config.auth.enabled && !token) {
console.error(
`${url} requires auth - pass --token or set TRIGGERSHELL_API_TOKEN (generate one with \`triggershell users add-token\`).`,
);
process.exitCode = 1;
return;
}
await runRemote(url, scriptId, variables, token, opts.wait);
} else {
await runLocal(configPath, script.id, variables, opts.wait);
}
}
async function runLocal(
configPath: string,
scriptId: string,
variables: Record<string, unknown>,
wait: boolean,
): Promise<void> {
process.env.TRIGGERSHELL_CONFIG_PATH = configPath;
migrateOnBoot();
reconcileOrphanedRuns();
const runId = await startRun({ scriptId, variables, triggeredBy: "cli" });
console.log(`Started run ${runId}`);
if (!wait) return;
// Read-then-register, same as the WS subscribe handler (src/lib/ws/server.ts): the run may
// already have produced output - or even finished - between `startRun` returning and this line,
// so we snapshot the log file and current status first, synchronously, before attaching a live
// listener for anything after that point. Both this read and the listener attach below are
// synchronous (better-sqlite3 and fs are sync here), so there's no gap either could fall through.
const row = getDb().select().from(runs).where(eq(runs.id, runId)).get();
if (row) {
const { text } = readLogTail(row.logFilePath);
if (text) process.stdout.write(text);
if (!NON_TERMINAL.includes(row.status)) {
process.exitCode = exitCodeFor(row.status);
return;
}
}
const finalStatus = await new Promise<RunStatus>((resolve) => {
function onMessage(message: ServerMessage) {
if (message.runId !== runId) return;
if (message.type === "output") {
process.stdout.write(message.chunk);
} else if (
message.type === "status" &&
!NON_TERMINAL.includes(message.status)
) {
cleanup();
resolve(message.status);
}
}
function onSigint() {
cancelRun(runId);
}
function cleanup() {
runEvents.off("message", onMessage);
process.off("SIGINT", onSigint);
}
process.on("SIGINT", onSigint);
runEvents.on("message", onMessage);
});
process.exitCode = exitCodeFor(finalStatus);
}
async function runRemote(
url: string,
scriptId: string,
variables: Record<string, unknown>,
token: string | undefined,
wait: boolean,
): Promise<void> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (token) headers.Authorization = `Bearer ${token}`;
const response = await fetch(`${url}/api/scripts/${scriptId}/runs`, {
method: "POST",
headers,
body: JSON.stringify({ variables }),
});
if (!response.ok) {
const body = await response
.json()
.catch(() => ({}) as Record<string, unknown>);
if (response.status === 401) {
console.error(
"Unauthorized - pass --token or set TRIGGERSHELL_API_TOKEN (see `triggershell users add-token`).",
);
} else if (body.fieldErrors) {
console.error("Validation failed:");
for (const [field, issues] of Object.entries(
body.fieldErrors as Record<string, string[]>,
)) {
console.error(` ${field}: ${issues.join(", ")}`);
}
} else {
console.error(
(body.error as string) ?? `Request failed (${response.status})`,
);
}
process.exitCode = 1;
return;
}
const { runId } = (await response.json()) as { runId: string };
console.log(`Started run ${runId}`);
if (!wait) return;
const wsUrl = `${url.replace(/^http/, "ws")}/ws/runs${token ? `?token=${encodeURIComponent(token)}` : ""}`;
const finalStatus = await new Promise<RunStatus>((resolve, reject) => {
const ws = new WebSocket(wsUrl);
function onSigint() {
try {
ws.send(JSON.stringify({ type: "cancel", runId }));
} catch {
// socket may already be closing - nothing more we can do
}
}
process.on("SIGINT", onSigint);
function cleanup() {
process.off("SIGINT", onSigint);
ws.close();
}
ws.on("open", () => {
ws.send(JSON.stringify({ type: "subscribe", runId, afterBytes: 0 }));
});
ws.on("message", (raw) => {
let message: ServerMessage;
try {
message = JSON.parse(raw.toString());
} catch {
return;
}
if (message.runId !== runId) return;
if (message.type === "output") {
process.stdout.write(message.chunk);
} else if (
message.type === "status" &&
!NON_TERMINAL.includes(message.status)
) {
cleanup();
resolve(message.status);
} else if (message.type === "error") {
cleanup();
reject(new Error(message.message));
}
});
ws.on("error", (error) => {
process.off("SIGINT", onSigint);
reject(error);
});
});
process.exitCode = exitCodeFor(finalStatus);
}
+86
View File
@@ -0,0 +1,86 @@
import path from "node:path";
import { ConfigError, loadConfig } from "../../lib/config/load";
import type { ScriptConfig } from "../../lib/config/schema";
import { loadDotenv } from "../lib/env-file";
import { resolveConfigPath } from "../lib/paths";
export interface ScriptsOptions {
config?: string;
}
function loadScripts(opts: ScriptsOptions): ScriptConfig[] | null {
const configPath = resolveConfigPath(opts.config);
loadDotenv(path.join(path.dirname(configPath), ".env"));
try {
return loadConfig(configPath).config.scripts;
} catch (error) {
if (error instanceof ConfigError) {
console.error(`Config error: ${error.message}`);
for (const issue of error.issues) console.error(` - ${issue}`);
process.exitCode = 1;
return null;
}
throw error;
}
}
export async function scriptsListCommand(opts: ScriptsOptions): Promise<void> {
const scripts = loadScripts(opts);
if (!scripts) return;
if (scripts.length === 0) {
console.log("No scripts configured.");
return;
}
const idWidth = Math.max(...scripts.map((s) => s.id.length));
for (const script of scripts) {
const description = script.description ? ` - ${script.description}` : "";
console.log(`${script.id.padEnd(idWidth)} ${script.name}${description}`);
}
}
export async function scriptsShowCommand(
scriptId: string,
opts: ScriptsOptions,
): Promise<void> {
const scripts = loadScripts(opts);
if (!scripts) return;
const script = scripts.find((s) => s.id === scriptId);
if (!script) {
console.error(`No script '${scriptId}' configured.`);
if (scripts.length > 0) {
console.error(
`Available scripts: ${scripts.map((s) => s.id).join(", ")}`,
);
}
process.exitCode = 1;
return;
}
console.log(script.name);
if (script.description) console.log(script.description);
console.log(
`\ncommand: ${script.command} ${script.args.join(" ")}`.trimEnd(),
);
if (script.variables.length === 0) {
console.log("\nThis script takes no parameters.");
return;
}
console.log("\nvariables:");
for (const variable of script.variables) {
const parts: string[] = [variable.type];
if (variable.required) parts.push("required");
if (variable.type === "enum" || variable.type === "multiselect") {
parts.push(`choices: ${variable.choices.join(", ")}`);
}
if (variable.default !== undefined) {
parts.push(`default: ${JSON.stringify(variable.default)}`);
}
console.log(` ${variable.name} (${parts.join(", ")})`);
}
}
+25 -8
View File
@@ -26,7 +26,9 @@ function scopeOf(opts: ServiceScopeOptions): "user" | "system" {
return opts.system ? "system" : "user"; return opts.system ? "system" : "user";
} }
export async function serviceInstallCommand(opts: ServiceInstallOptions): Promise<void> { export async function serviceInstallCommand(
opts: ServiceInstallOptions,
): Promise<void> {
const scope = scopeOf(opts); const scope = scopeOf(opts);
const configPath = resolveConfigPath(opts.config); const configPath = resolveConfigPath(opts.config);
const unit = renderUnit({ const unit = renderUnit({
@@ -68,18 +70,24 @@ export async function serviceInstallCommand(opts: ServiceInstallOptions): Promis
const scratchPath = path.join(os.tmpdir(), `${SERVICE_NAME}.service`); const scratchPath = path.join(os.tmpdir(), `${SERVICE_NAME}.service`);
fs.writeFileSync(scratchPath, unit); fs.writeFileSync(scratchPath, unit);
console.log(`Not running as root - wrote the unit file to ${scratchPath} instead.`); console.log(
`Not running as root - wrote the unit file to ${scratchPath} instead.`,
);
console.log("\nReview it, then run:"); console.log("\nReview it, then run:");
console.log(` sudo install -m 644 ${scratchPath} ${systemUnitPath()}`); console.log(` sudo install -m 644 ${scratchPath} ${systemUnitPath()}`);
console.log(" sudo systemctl daemon-reload"); console.log(" sudo systemctl daemon-reload");
console.log(` sudo systemctl enable --now ${SERVICE_NAME}`); console.log(` sudo systemctl enable --now ${SERVICE_NAME}`);
} }
export async function serviceUninstallCommand(opts: ServiceScopeOptions): Promise<void> { export async function serviceUninstallCommand(
opts: ServiceScopeOptions,
): Promise<void> {
const scope = scopeOf(opts); const scope = scopeOf(opts);
if (scope === "user") { if (scope === "user") {
await execa("systemctl", ["--user", "disable", "--now", SERVICE_NAME], { reject: false }); await execa("systemctl", ["--user", "disable", "--now", SERVICE_NAME], {
reject: false,
});
const unitPath = userUnitPath(); const unitPath = userUnitPath();
if (fs.existsSync(unitPath)) fs.rmSync(unitPath); if (fs.existsSync(unitPath)) fs.rmSync(unitPath);
await execa("systemctl", ["--user", "daemon-reload"], { reject: false }); await execa("systemctl", ["--user", "daemon-reload"], { reject: false });
@@ -88,7 +96,9 @@ export async function serviceUninstallCommand(opts: ServiceScopeOptions): Promis
} }
if (isRoot()) { if (isRoot()) {
await execa("systemctl", ["disable", "--now", SERVICE_NAME], { reject: false }); await execa("systemctl", ["disable", "--now", SERVICE_NAME], {
reject: false,
});
const unitPath = systemUnitPath(); const unitPath = systemUnitPath();
if (fs.existsSync(unitPath)) fs.rmSync(unitPath); if (fs.existsSync(unitPath)) fs.rmSync(unitPath);
await execa("systemctl", ["daemon-reload"], { reject: false }); await execa("systemctl", ["daemon-reload"], { reject: false });
@@ -102,10 +112,17 @@ export async function serviceUninstallCommand(opts: ServiceScopeOptions): Promis
console.log(" sudo systemctl daemon-reload"); console.log(" sudo systemctl daemon-reload");
} }
export async function serviceStatusCommand(opts: ServiceScopeOptions): Promise<void> { export async function serviceStatusCommand(
opts: ServiceScopeOptions,
): Promise<void> {
const scope = scopeOf(opts); const scope = scopeOf(opts);
const args = const args =
scope === "user" ? ["--user", "status", SERVICE_NAME] : ["status", SERVICE_NAME]; scope === "user"
const result = await execa("systemctl", args, { stdio: "inherit", reject: false }); ? ["--user", "status", SERVICE_NAME]
: ["status", SERVICE_NAME];
const result = await execa("systemctl", args, {
stdio: "inherit",
reject: false,
});
process.exitCode = result.exitCode ?? 1; process.exitCode = result.exitCode ?? 1;
} }
+3 -1
View File
@@ -54,6 +54,8 @@ export async function startCommand(opts: StartOptions): Promise<void> {
}); });
} }
const serverEntry = pathToFileURL(path.join(resolveAppRoot(), "server.ts")).href; const serverEntry = pathToFileURL(
path.join(resolveAppRoot(), "server.ts"),
).href;
await import(serverEntry); await import(serverEntry);
} }
+20 -5
View File
@@ -20,18 +20,27 @@ function printSnippet(heading: string, entry: Record<string, unknown>): void {
async function promptNewPassword(): Promise<string> { async function promptNewPassword(): Promise<string> {
for (;;) { for (;;) {
const first = await promptPassword({ message: "Password", mask: true }); const first = await promptPassword({ message: "Password", mask: true });
const second = await promptPassword({ message: "Confirm password", mask: true }); const second = await promptPassword({
message: "Confirm password",
mask: true,
});
if (first === second) return first; if (first === second) return first;
console.error("Passwords did not match, try again.\n"); console.error("Passwords did not match, try again.\n");
} }
} }
export async function usersAddCommand(username: string, opts: UsersOptions): Promise<void> { export async function usersAddCommand(
username: string,
opts: UsersOptions,
): Promise<void> {
const password = await promptNewPassword(); const password = await promptNewPassword();
const passwordHash = await hashPassword(password); const passwordHash = await hashPassword(password);
if (opts.inline) { if (opts.inline) {
printSnippet("Add this under `auth.users:` in your config file:", { username, passwordHash }); printSnippet("Add this under `auth.users:` in your config file:", {
username,
passwordHash,
});
return; return;
} }
@@ -47,7 +56,10 @@ export async function usersAddCommand(username: string, opts: UsersOptions): Pro
}); });
} }
export async function usersAddTokenCommand(name: string, opts: UsersOptions): Promise<void> { export async function usersAddTokenCommand(
name: string,
opts: UsersOptions,
): Promise<void> {
const token = crypto.randomBytes(32).toString("hex"); const token = crypto.randomBytes(32).toString("hex");
const tokenHash = `sha256:${crypto.createHash("sha256").update(token).digest("hex")}`; const tokenHash = `sha256:${crypto.createHash("sha256").update(token).digest("hex")}`;
@@ -56,7 +68,10 @@ export async function usersAddTokenCommand(name: string, opts: UsersOptions): Pr
console.log(`Use it as: Authorization: Bearer ${token}`); console.log(`Use it as: Authorization: Bearer ${token}`);
if (opts.inline) { if (opts.inline) {
printSnippet("Add this under `auth.tokens:` in your config file:", { name, tokenHash }); printSnippet("Add this under `auth.tokens:` in your config file:", {
name,
tokenHash,
});
return; return;
} }
+115 -14
View File
@@ -1,20 +1,37 @@
import { Command } from "commander"; import { Command } from "commander";
import { doctorCommand } from "./commands/doctor"; import { doctorCommand } from "./commands/doctor";
import { initCommand } from "./commands/init"; import { initCommand } from "./commands/init";
import { serviceInstallCommand, serviceStatusCommand, serviceUninstallCommand } from "./commands/service"; import { runCommand } from "./commands/run";
import { scriptsListCommand, scriptsShowCommand } from "./commands/scripts";
import {
serviceInstallCommand,
serviceStatusCommand,
serviceUninstallCommand,
} from "./commands/service";
import { startCommand } from "./commands/start"; import { startCommand } from "./commands/start";
import { usersAddCommand, usersAddTokenCommand } from "./commands/users"; import { usersAddCommand, usersAddTokenCommand } from "./commands/users";
import { validateCommand } from "./commands/validate"; import { validateCommand } from "./commands/validate";
import { getVersion } from "./version"; import { getVersion } from "./version";
function collect(value: string, previous: string[]): string[] {
return [...previous, value];
}
const program = new Command("triggershell") const program = new Command("triggershell")
.version(getVersion()) .version(getVersion())
.description("Launch the TriggerShell web app: run your configured shell scripts from a browser."); .description(
"Launch the TriggerShell web app: run your configured shell scripts from a browser.",
);
program program
.command("init [path]") .command("init [path]")
.description("Scaffold a new triggershell.yml (and .env, if auth is enabled)") .description("Scaffold a new triggershell.yml (and .env, if auth is enabled)")
.option("--port <port>", "Port the web app will listen on.", (v) => Number(v), 4173) .option(
"--port <port>",
"Port the web app will listen on.",
(v) => Number(v),
4173,
)
.option("--no-auth", "Disable built-in login for the web app.") .option("--no-auth", "Disable built-in login for the web app.")
.option("--force", "Overwrite an existing config file.", false) .option("--force", "Overwrite an existing config file.", false)
.action(initCommand); .action(initCommand);
@@ -29,7 +46,9 @@ program
.command("start") .command("start")
.description("Run the web app in production mode.") .description("Run the web app in production mode.")
.option("-c, --config <path>", "Path to the config file.") .option("-c, --config <path>", "Path to the config file.")
.option("--port <port>", "Override the port from the config file.", (v) => Number(v)) .option("--port <port>", "Override the port from the config file.", (v) =>
Number(v),
)
.option("--host <host>", "Override the host from the config file.") .option("--host <host>", "Override the host from the config file.")
.option("--no-browser", "Don't open a browser automatically.") .option("--no-browser", "Don't open a browser automatically.")
.action(startCommand); .action(startCommand);
@@ -40,43 +59,125 @@ program
.option("-c, --config <path>", "Path to the config file.") .option("-c, --config <path>", "Path to the config file.")
.action(doctorCommand); .action(doctorCommand);
const users = program.command("users").description("Manage auth users and API tokens defined in your config file."); const scripts = program
.command("scripts")
.description("List and inspect configured scripts.");
scripts
.command("list")
.description("List configured scripts.")
.option("-c, --config <path>", "Path to the config file.")
.action(scriptsListCommand);
scripts
.command("show <scriptId>")
.description("Show a script's command and variables.")
.option("-c, --config <path>", "Path to the config file.")
.action(scriptsShowCommand);
program
.command("run <scriptId>")
.description("Run a configured script.")
.option("-c, --config <path>", "Path to the config file.")
.option(
"--var <keyValue>",
"Set a variable, e.g. --var environment=staging (repeatable; repeat the same name for a multiselect variable).",
collect,
[],
)
.option("--host <host>", "Override the host from the config file.")
.option("--port <port>", "Override the port from the config file.", (v) =>
Number(v),
)
.option(
"--token <token>",
"API token for an already-running server (or set TRIGGERSHELL_API_TOKEN).",
)
.option(
"--local",
"Always run in this process, even if the web server is reachable.",
false,
)
.option(
"--remote",
"Require a reachable web server; don't fall back to running locally.",
false,
)
.option(
"--no-wait",
"Print the run ID and exit immediately instead of streaming output.",
)
.action(runCommand);
const users = program
.command("users")
.description("Manage auth users and API tokens defined in your config file.");
users users
.command("add <username>") .command("add <username>")
.description("Hash a password with argon2id and wire it up for auth.users.") .description("Hash a password with argon2id and wire it up for auth.users.")
.option("-c, --config <path>", "Path to the config file (used to locate .env).") .option(
.option("--inline", "Print the raw hash to paste into the config instead of storing it in .env.", false) "-c, --config <path>",
"Path to the config file (used to locate .env).",
)
.option(
"--inline",
"Print the raw hash to paste into the config instead of storing it in .env.",
false,
)
.action(usersAddCommand); .action(usersAddCommand);
users users
.command("add-token <name>") .command("add-token <name>")
.description("Generate an API token and wire its hash up for auth.tokens.") .description("Generate an API token and wire its hash up for auth.tokens.")
.option("-c, --config <path>", "Path to the config file (used to locate .env).") .option(
.option("--inline", "Print the raw hash to paste into the config instead of storing it in .env.", false) "-c, --config <path>",
"Path to the config file (used to locate .env).",
)
.option(
"--inline",
"Print the raw hash to paste into the config instead of storing it in .env.",
false,
)
.action(usersAddTokenCommand); .action(usersAddTokenCommand);
const service = program.command("service").description("Manage the systemd service (Linux only)."); const service = program
.command("service")
.description("Manage the systemd service (Linux only).");
service service
.command("install") .command("install")
.description("Install a systemd unit that runs `triggershell start`.") .description("Install a systemd unit that runs `triggershell start`.")
.option("-c, --config <path>", "Path to the config file.") .option("-c, --config <path>", "Path to the config file.")
.option("--port <port>", "Override the port from the config file.", (v) => Number(v)) .option("--port <port>", "Override the port from the config file.", (v) =>
Number(v),
)
.option("--host <host>", "Override the host from the config file.") .option("--host <host>", "Override the host from the config file.")
.option("--system", "Install a system-wide unit instead of a per-user one.", false) .option(
"--system",
"Install a system-wide unit instead of a per-user one.",
false,
)
.action(serviceInstallCommand); .action(serviceInstallCommand);
service service
.command("uninstall") .command("uninstall")
.description("Stop, disable, and remove the systemd unit.") .description("Stop, disable, and remove the systemd unit.")
.option("--system", "Target the system-wide unit instead of the per-user one.", false) .option(
"--system",
"Target the system-wide unit instead of the per-user one.",
false,
)
.action(serviceUninstallCommand); .action(serviceUninstallCommand);
service service
.command("status") .command("status")
.description("Show the systemd unit's status.") .description("Show the systemd unit's status.")
.option("--system", "Target the system-wide unit instead of the per-user one.", false) .option(
"--system",
"Target the system-wide unit instead of the per-user one.",
false,
)
.action(serviceStatusCommand); .action(serviceStatusCommand);
if (process.argv.length <= 2) { if (process.argv.length <= 2) {
+4 -1
View File
@@ -6,7 +6,10 @@ import { test } from "node:test";
import { loadDotenv, upsertEnvVar } from "./env-file"; import { loadDotenv, upsertEnvVar } from "./env-file";
function tmpEnvPath(): string { function tmpEnvPath(): string {
return path.join(fs.mkdtempSync(path.join(os.tmpdir(), "triggershell-env-")), ".env"); return path.join(
fs.mkdtempSync(path.join(os.tmpdir(), "triggershell-env-")),
".env",
);
} }
test("upsertEnvVar appends a new key", () => { test("upsertEnvVar appends a new key", () => {
+8 -2
View File
@@ -20,13 +20,19 @@ function parseEnvLines(content: string): EnvEntry[] {
/** Loads a `.env` file into `process.env`, without overriding vars already set. */ /** Loads a `.env` file into `process.env`, without overriding vars already set. */
export function loadDotenv(envPath: string): void { export function loadDotenv(envPath: string): void {
if (!fs.existsSync(envPath)) return; if (!fs.existsSync(envPath)) return;
for (const { key, value } of parseEnvLines(fs.readFileSync(envPath, "utf-8"))) { for (const { key, value } of parseEnvLines(
fs.readFileSync(envPath, "utf-8"),
)) {
if (process.env[key] === undefined) process.env[key] = value; if (process.env[key] === undefined) process.env[key] = value;
} }
} }
/** Sets `key=value` in a `.env` file, replacing an existing line for that key rather than duplicating it. */ /** Sets `key=value` in a `.env` file, replacing an existing line for that key rather than duplicating it. */
export function upsertEnvVar(envPath: string, key: string, value: string): void { export function upsertEnvVar(
envPath: string,
key: string,
value: string,
): void {
const lines = fs.existsSync(envPath) const lines = fs.existsSync(envPath)
? fs.readFileSync(envPath, "utf-8").split("\n") ? fs.readFileSync(envPath, "utf-8").split("\n")
: []; : [];
+23 -2
View File
@@ -36,12 +36,33 @@ export async function waitUntilReady(
return false; return false;
} }
/** Single-shot check (not a poll loop, unlike `waitUntilReady`) for whether a triggershell server
* is already listening at `url` - used to decide whether `run` can go through the REST/WS API. */
export async function isServerReachable(url: string): Promise<boolean> {
try {
const response = await fetch(`${url}/api/healthz`, {
signal: AbortSignal.timeout(1000),
});
return response.status === 200;
} catch {
return false;
}
}
export function openBrowser(url: string): void { export function openBrowser(url: string): void {
const command = const command =
process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"; process.platform === "darwin"
? "open"
: process.platform === "win32"
? "start"
: "xdg-open";
const args = process.platform === "win32" ? ["", url] : [url]; const args = process.platform === "win32" ? ["", url] : [url];
try { try {
spawn(command, args, { detached: true, stdio: "ignore", shell: process.platform === "win32" }).unref(); spawn(command, args, {
detached: true,
stdio: "ignore",
shell: process.platform === "win32",
}).unref();
} catch { } catch {
// best-effort - not fatal if no browser opener is available // best-effort - not fatal if no browser opener is available
} }
+6 -1
View File
@@ -6,7 +6,12 @@ const DEFAULT_CONFIG_NAME = "triggershell.yml";
/** Root of the installed `triggershell` package - one level up from `src/cli/lib`. */ /** Root of the installed `triggershell` package - one level up from `src/cli/lib`. */
export function resolveAppRoot(): string { export function resolveAppRoot(): string {
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); return path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"..",
"..",
);
} }
export function resolveConfigPath(configArg?: string): string { export function resolveConfigPath(configArg?: string): string {
+6 -2
View File
@@ -5,7 +5,8 @@ import { renderUnit } from "./systemd";
test("renderUnit builds an absolute-path ExecStart with the given args", () => { test("renderUnit builds an absolute-path ExecStart with the given args", () => {
const unit = renderUnit({ const unit = renderUnit({
execPath: "/usr/bin/node", execPath: "/usr/bin/node",
binPath: "/home/user/.local/share/pnpm/global/5/node_modules/.bin/triggershell", binPath:
"/home/user/.local/share/pnpm/global/5/node_modules/.bin/triggershell",
configPath: "/home/user/project/triggershell.yml", configPath: "/home/user/project/triggershell.yml",
configDir: "/home/user/project", configDir: "/home/user/project",
port: 8080, port: 8080,
@@ -31,5 +32,8 @@ test("renderUnit uses multi-user.target for the system scope", () => {
}); });
assert.match(unit, /WantedBy=multi-user\.target/); assert.match(unit, /WantedBy=multi-user\.target/);
assert.match(unit, /ExecStart=\/usr\/bin\/node .*start --config .*--no-browser$/m); assert.match(
unit,
/ExecStart=\/usr\/bin\/node .*start --config .*--no-browser$/m,
);
}); });
+9 -2
View File
@@ -22,7 +22,8 @@ export function renderUnit(opts: UnitOptions): string {
.map((part) => (part.includes(" ") ? `"${part}"` : part)) .map((part) => (part.includes(" ") ? `"${part}"` : part))
.join(" "); .join(" ");
const wantedBy = opts.scope === "user" ? "default.target" : "multi-user.target"; const wantedBy =
opts.scope === "user" ? "default.target" : "multi-user.target";
return `[Unit] return `[Unit]
Description=TriggerShell - self-hosted script runner Description=TriggerShell - self-hosted script runner
@@ -42,7 +43,13 @@ WantedBy=${wantedBy}
} }
export function userUnitPath(): string { export function userUnitPath(): string {
return path.join(os.homedir(), ".config", "systemd", "user", `${SERVICE_NAME}.service`); return path.join(
os.homedir(),
".config",
"systemd",
"user",
`${SERVICE_NAME}.service`,
);
} }
export function systemUnitPath(): string { export function systemUnitPath(): string {
+116
View File
@@ -0,0 +1,116 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import type { VariableConfig } from "../../lib/config/schema";
import { coerceVariables, parseVarFlags } from "./variables";
function stringVar(
name: string,
overrides: Partial<VariableConfig> = {},
): VariableConfig {
return {
type: "string",
name,
required: false,
secret: false,
passAs: "arg",
joinWith: ",",
multiline: false,
...overrides,
} as VariableConfig;
}
function boolVar(name: string): VariableConfig {
return {
type: "boolean",
name,
required: false,
secret: false,
passAs: "flag",
joinWith: ",",
default: false,
} as VariableConfig;
}
function numberVar(name: string): VariableConfig {
return {
type: "number",
name,
required: false,
secret: false,
passAs: "arg",
joinWith: ",",
} as VariableConfig;
}
function multiselectVar(name: string, choices: string[]): VariableConfig {
return {
type: "multiselect",
name,
required: false,
secret: false,
passAs: "arg",
joinWith: ",",
choices,
default: [],
} as VariableConfig;
}
test("parseVarFlags groups repeated names into arrays", () => {
const grouped = parseVarFlags(["environment=staging", "tag=a", "tag=b"]);
assert.deepEqual(grouped, { environment: ["staging"], tag: ["a", "b"] });
});
test("parseVarFlags rejects a flag with no '='", () => {
assert.throws(() => parseVarFlags(["oops"]), /missing '='/);
});
test("coerceVariables coerces booleans and numbers, passes strings through", () => {
const variables = [
stringVar("environment"),
boolVar("dryRun"),
numberVar("replicas"),
];
const values = coerceVariables(variables, {
environment: ["staging"],
dryRun: ["true"],
replicas: ["3"],
});
assert.deepEqual(values, {
environment: "staging",
dryRun: true,
replicas: 3,
});
});
test("coerceVariables rejects an invalid boolean/number", () => {
assert.throws(
() => coerceVariables([boolVar("dryRun")], { dryRun: ["yes"] }),
/must be 'true' or 'false'/,
);
assert.throws(
() => coerceVariables([numberVar("replicas")], { replicas: ["abc"] }),
/not a valid number/,
);
});
test("coerceVariables collects a multiselect variable's repeats into an array", () => {
const values = coerceVariables([multiselectVar("tags", ["a", "b", "c"])], {
tags: ["a", "c"],
});
assert.deepEqual(values, { tags: ["a", "c"] });
});
test("coerceVariables rejects a non-multiselect variable given more than once", () => {
assert.throws(
() =>
coerceVariables([stringVar("environment")], { environment: ["a", "b"] }),
/given 2 times/,
);
});
test("coerceVariables rejects an unknown variable name", () => {
assert.throws(
() => coerceVariables([stringVar("environment")], { nope: ["x"] }),
/does not match any variable/,
);
});
+77
View File
@@ -0,0 +1,77 @@
import type { VariableConfig } from "../../lib/config/schema";
/** Splits each `name=value` pair and groups by name - repeats accumulate into an array, which is
* how a `multiselect` variable is given more than one selection on the command line. */
export function parseVarFlags(pairs: string[]): Record<string, string[]> {
const grouped: Record<string, string[]> = {};
for (const pair of pairs) {
const eq = pair.indexOf("=");
if (eq === -1) {
throw new Error(
`--var ${pair} is missing '=' - expected --var name=value`,
);
}
const name = pair.slice(0, eq);
const value = pair.slice(eq + 1);
(grouped[name] ??= []).push(value);
}
return grouped;
}
/** Coerces raw `--var` strings into the JS type each variable expects, ready for
* `buildVariableSchema(script).safeParse(...)` - the same schema the web form and the
* `/api/scripts/:id/runs` route already validate against. Variables not present in `grouped`
* are left out entirely (the caller fills those from `defaultValuesForScript` first). */
export function coerceVariables(
variables: readonly VariableConfig[],
grouped: Record<string, string[]>,
): Record<string, unknown> {
const values: Record<string, unknown> = {};
const known = new Set(variables.map((v) => v.name));
for (const name of Object.keys(grouped)) {
if (!known.has(name)) {
throw new Error(
`--var ${name}=... does not match any variable on this script`,
);
}
}
for (const variable of variables) {
const raw = grouped[variable.name];
if (!raw) continue;
if (variable.type === "multiselect") {
values[variable.name] = raw;
continue;
}
if (raw.length > 1) {
throw new Error(
`--var ${variable.name}=... was given ${raw.length} times, but '${variable.name}' is not a multiselect variable`,
);
}
const value = raw[0];
if (variable.type === "boolean") {
if (value === "true") values[variable.name] = true;
else if (value === "false") values[variable.name] = false;
else
throw new Error(
`--var ${variable.name}=${value} must be 'true' or 'false'`,
);
} else if (variable.type === "number") {
const n = Number(value);
if (Number.isNaN(n)) {
throw new Error(
`--var ${variable.name}=${value} is not a valid number`,
);
}
values[variable.name] = n;
} else {
values[variable.name] = value;
}
}
return values;
}
+3 -1
View File
@@ -4,6 +4,8 @@ import { resolveAppRoot } from "./lib/paths";
export function getVersion(): string { export function getVersion(): string {
const pkgPath = path.join(resolveAppRoot(), "package.json"); const pkgPath = path.join(resolveAppRoot(), "package.json");
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { version: string }; const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as {
version: string;
};
return pkg.version; return pkg.version;
} }
+19 -1
View File
@@ -2,6 +2,19 @@ import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
// GFM tables (the config reference's Field/Type/Default/Notes tables) are wider than a phone
// screen and don't wrap - without their own scroll container the table forces the whole page
// to scroll horizontally instead. The typography plugin's table styles still apply to `table`
// here (its selectors match any descendant, not just direct children of `.prose`), so this
// wrapper only adds the scroll boundary.
function Table(props: React.ComponentProps<"table">) {
return (
<div className="overflow-x-auto">
<table {...props} />
</div>
);
}
export function MarkdownViewer({ content }: { content: string }) { export function MarkdownViewer({ content }: { content: string }) {
return ( return (
<div <div
@@ -13,9 +26,14 @@ export function MarkdownViewer({ content }: { content: string }) {
// elsewhere in the app (see the "no scripts configured" message on the dashboard). // elsewhere in the app (see the "no scripts configured" message on the dashboard).
"prose-code:before:content-none prose-code:after:content-none", "prose-code:before:content-none prose-code:after:content-none",
"prose-code:rounded prose-code:bg-muted prose-code:px-1.5 prose-code:py-0.5 prose-code:font-mono prose-code:font-normal prose-code:text-foreground", "prose-code:rounded prose-code:bg-muted prose-code:px-1.5 prose-code:py-0.5 prose-code:font-mono prose-code:font-normal prose-code:text-foreground",
// Long unbroken strings (env var names, paths) in table cells or inline code would
// otherwise force their column/line wider than the viewport instead of wrapping.
"prose-td:break-words prose-th:break-words prose-code:break-words",
)} )}
> >
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown> <ReactMarkdown remarkPlugins={[remarkGfm]} components={{ table: Table }}>
{content}
</ReactMarkdown>
</div> </div>
); );
} }
@@ -0,0 +1,80 @@
"use client";
import { useState } from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
interface ComboboxProps {
choices: string[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
export function Combobox({
choices,
value,
onChange,
placeholder = "Select...",
}: ComboboxProps) {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
className={cn(
buttonVariants({ variant: "outline" }),
"h-auto min-h-8 w-full justify-between font-normal",
)}
>
<span
className={cn("flex-1 text-left", !value && "text-muted-foreground")}
>
{value || placeholder}
</span>
<ChevronsUpDown className="text-muted-foreground size-4 shrink-0" />
</PopoverTrigger>
<PopoverContent className="w-80 p-0">
<Command>
<CommandInput placeholder="Search..." />
<CommandList>
<CommandEmpty>No matches.</CommandEmpty>
<CommandGroup>
{choices.map((choice) => (
<CommandItem
key={choice}
onSelect={() => {
onChange(choice);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 size-4",
value === choice ? "opacity-100" : "opacity-0",
)}
/>
{choice}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
+25 -14
View File
@@ -10,30 +10,28 @@ import { Button } from "@/components/ui/button";
import { Form } from "@/components/ui/form"; import { Form } from "@/components/ui/form";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { buildVariableSchemaFromList } from "@/lib/validation/variable-schema"; import { buildVariableSchemaFromList } from "@/lib/validation/variable-schema";
import { defaultValuesForScript } from "@/lib/config/defaults";
import type { ClientScript } from "@/lib/config/serialize"; import type { ClientScript } from "@/lib/config/serialize";
import { FieldRenderer } from "./field-renderer"; import { FieldRenderer } from "./field-renderer";
function emptyValueFor(variable: ClientScript["variables"][number]): unknown { /** Controls whose content doesn't shrink well into a narrow grid column - long-form text,
if (variable.type === "boolean") return false; * or a group of checkboxes that reads better as a single wide list - so they span the full
if (variable.type === "multiselect") return []; * grid width instead of sharing a row with other fields. */
return ""; const WIDE_CONTROLS = new Set(["textarea", "checkboxGroup"]);
}
/** `initialValues` comes from a previous run's (already-redacted) variables when re-running - /** `initialValues` comes from a previous run's (already-redacted) variables when re-running -
* secret fields are deliberately excluded there (their stored value is just "***", not the real * secret fields are deliberately excluded there (their stored value is just "***", not the real
* one), so those always fall through to the normal empty/default state and have to be re-entered. */ * one), so those always fall through to the normal default/empty state and have to be re-entered. */
function defaultValuesFor( function defaultValuesFor(
script: ClientScript, script: ClientScript,
initialValues?: Record<string, unknown>, initialValues?: Record<string, unknown>,
): Record<string, unknown> { ): Record<string, unknown> {
const values: Record<string, unknown> = {}; const values = defaultValuesForScript(script.variables);
if (!initialValues) return values;
for (const variable of script.variables) { for (const variable of script.variables) {
const fromPreviousRun = if (variable.secret) continue;
initialValues && !variable.secret const fromPreviousRun = initialValues[variable.name];
? initialValues[variable.name] if (fromPreviousRun !== undefined) values[variable.name] = fromPreviousRun;
: undefined;
values[variable.name] =
fromPreviousRun ?? variable.default ?? emptyValueFor(variable);
} }
return values; return values;
} }
@@ -100,9 +98,22 @@ export function DynamicForm({
This script takes no parameters. This script takes no parameters.
</p> </p>
)} )}
{script.variables.length > 0 && (
<div className="grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2 lg:grid-cols-3">
{script.variables.map((variable) => ( {script.variables.map((variable) => (
<FieldRenderer key={variable.name} variable={variable} /> <div
key={variable.name}
className={
WIDE_CONTROLS.has(variable.control)
? "sm:col-span-2 lg:col-span-3"
: undefined
}
>
<FieldRenderer variable={variable} />
</div>
))} ))}
</div>
)}
<Button <Button
type="submit" type="submit"
disabled={form.formState.isSubmitting} disabled={form.formState.isSubmitting}
+24
View File
@@ -24,6 +24,7 @@ import {
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { MultiSelect } from "./controls/multi-select"; import { MultiSelect } from "./controls/multi-select";
import { Combobox } from "./controls/combobox";
import type { ClientVariable } from "@/lib/config/serialize"; import type { ClientVariable } from "@/lib/config/serialize";
export function FieldRenderer({ variable }: { variable: ClientVariable }) { export function FieldRenderer({ variable }: { variable: ClientVariable }) {
@@ -211,6 +212,29 @@ export function FieldRenderer({ variable }: { variable: ClientVariable }) {
</FormItem> </FormItem>
); );
case "combobox":
return (
<FormItem>
<FormLabel>
{label}
{variable.required && (
<span className="text-destructive"> *</span>
)}
</FormLabel>
<FormControl>
<Combobox
choices={variable.type === "enum" ? variable.choices : []}
value={field.value ?? ""}
onChange={(value) => field.onChange(value)}
/>
</FormControl>
{variable.description && (
<FormDescription>{variable.description}</FormDescription>
)}
<FormMessage />
</FormItem>
);
case "radio": case "radio":
return ( return (
<FormItem> <FormItem>
@@ -0,0 +1,29 @@
import Link from "next/link";
import { Home } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function NotFoundContent() {
return (
<div className="mx-auto flex max-w-md flex-col items-center gap-3 py-24 text-center">
<span className="text-primary font-mono text-6xl font-semibold tracking-tight">
404
</span>
<h1 className="font-heading text-xl font-medium">Page not found</h1>
<p className="text-muted-foreground text-sm">
The page you&rsquo;re looking for doesn&rsquo;t exist or may have been
moved.
</p>
<Link
href="/"
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"mt-2",
)}
>
<Home className="size-3.5" />
Back to dashboard
</Link>
</div>
);
}
+1 -1
View File
@@ -95,7 +95,7 @@ export const XtermView = forwardRef<XtermViewHandle, XtermViewProps>(
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className="h-[60vh] overflow-hidden bg-[#101215] p-2" className="aspect-video w-full overflow-hidden bg-[#101215] p-2"
/> />
); );
}, },
+21
View File
@@ -0,0 +1,21 @@
import type { VariableConfig } from "./schema";
export function emptyValueFor(variable: VariableConfig): unknown {
if (variable.type === "boolean") return false;
if (variable.type === "multiselect") return [];
return "";
}
/** Fills in each variable's configured `default` (or an empty value) - the Zod schema in
* `variable-schema.ts` doesn't apply `default` itself, it only validates whatever is present, so
* any caller that wants "an omitted variable behaves like its configured default" applies this
* first. Shared by the web form (`dynamic-form.tsx`) and the CLI's `run` command. */
export function defaultValuesForScript(
variables: readonly VariableConfig[],
): Record<string, unknown> {
const values: Record<string, unknown> = {};
for (const variable of variables) {
values[variable.name] = variable.default ?? emptyValueFor(variable);
}
return values;
}
+1 -3
View File
@@ -36,9 +36,7 @@ export interface LoadedConfig {
export function resolveConfigPath(configPathArg?: string): string { export function resolveConfigPath(configPathArg?: string): string {
const candidate = const candidate =
configPathArg ?? configPathArg ?? process.env.TRIGGERSHELL_CONFIG_PATH ?? "triggershell.yml";
process.env.TRIGGERSHELL_CONFIG_PATH ??
"triggershell.yml";
return path.resolve(/*turbopackIgnore: true*/ candidate); return path.resolve(/*turbopackIgnore: true*/ candidate);
} }
+8
View File
@@ -18,6 +18,7 @@ const controlSchema = z.enum([
"switch", "switch",
"select", "select",
"radio", "radio",
"combobox",
"multiselect", "multiselect",
"checkboxGroup", "checkboxGroup",
]); ]);
@@ -115,6 +116,13 @@ const variableWithChecks = variableSchema.superRefine((variable, ctx) => {
path: ["control"], path: ["control"],
}); });
} }
if (variable.control === "combobox" && variable.type !== "enum") {
ctx.addIssue({
code: "custom",
message: "control 'combobox' requires type 'enum'",
path: ["control"],
});
}
if ( if (
variable.control === "slider" && variable.control === "slider" &&
variable.type === "number" && variable.type === "number" &&
+6 -3
View File
@@ -12,13 +12,15 @@ export const DOCS: DocMeta[] = [
{ {
slug: "api", slug: "api",
title: "API Reference", title: "API Reference",
description: "REST and WebSocket endpoints, auth, and request/response shapes.", description:
"REST and WebSocket endpoints, auth, and request/response shapes.",
file: "API.md", file: "API.md",
}, },
{ {
slug: "config", slug: "config",
title: "Config Reference", title: "Config Reference",
description: "Every field in triggershell.yml: server, auth, scripts, and variables.", description:
"Every field in triggershell.yml: server, auth, scripts, and variables.",
file: "CONFIG_REFERENCE.md", file: "CONFIG_REFERENCE.md",
}, },
]; ];
@@ -34,7 +36,8 @@ export function getDocMeta(slug: string): DocMeta | undefined {
// Falls back to a source-relative resolution for contexts where server.ts never ran (e.g. tests). // Falls back to a source-relative resolution for contexts where server.ts never ran (e.g. tests).
function docsDir(): string { function docsDir(): string {
const appRoot = const appRoot =
process.env.TRIGGERSHELL_APP_ROOT ?? path.resolve(import.meta.dirname, "..", ".."); process.env.TRIGGERSHELL_APP_ROOT ??
path.resolve(import.meta.dirname, "..", "..");
return path.resolve(appRoot, "docs"); return path.resolve(appRoot, "docs");
} }
+22 -2
View File
@@ -18,6 +18,15 @@ function stringifyValue(value: unknown, joinWith: string): string {
return String(value); return String(value);
} }
/** Quotes a value for the human-readable `redactedCommandLine` display only - the real
* invocation always passes values as discrete argv elements/env vars (see build note below),
* so this never affects execution. Without it, a value like "Glitz and glam" renders as three
* bare words indistinguishable from separate argv entries. */
function quoteForDisplay(value: string): string {
if (value !== "" && /^[a-zA-Z0-9_@%+=:,./-]+$/.test(value)) return value;
return `"${value.replace(/([$`"\\])/g, "\\$1")}"`;
}
/** Builds an argv-array invocation from validated variable values. Never produces a shell string. */ /** Builds an argv-array invocation from validated variable values. Never produces a shell string. */
export function buildInvocation( export function buildInvocation(
script: ScriptConfig, script: ScriptConfig,
@@ -26,6 +35,7 @@ export function buildInvocation(
const argv = [...script.args]; const argv = [...script.args];
const env: Record<string, string> = {}; const env: Record<string, string> = {};
const redactedArgv = [...script.args]; const redactedArgv = [...script.args];
const redactedEnvAssignments: string[] = [];
const redactedVariables: Record<string, unknown> = {}; const redactedVariables: Record<string, unknown> = {};
for (const variable of script.variables) { for (const variable of script.variables) {
@@ -42,7 +52,10 @@ export function buildInvocation(
const argName = variable.argName!; const argName = variable.argName!;
const value = stringifyValue(raw, variable.joinWith); const value = stringifyValue(raw, variable.joinWith);
argv.push(argName, value); argv.push(argName, value);
redactedArgv.push(argName, variable.secret ? REDACTED : value); redactedArgv.push(
argName,
variable.secret ? REDACTED : quoteForDisplay(value),
);
break; break;
} }
case "flag": { case "flag": {
@@ -55,6 +68,9 @@ export function buildInvocation(
case "env": { case "env": {
const value = stringifyValue(raw, variable.joinWith); const value = stringifyValue(raw, variable.joinWith);
env[variable.envName!] = value; env[variable.envName!] = value;
redactedEnvAssignments.push(
`${variable.envName}=${variable.secret ? REDACTED : quoteForDisplay(value)}`,
);
break; break;
} }
case "stdin": { case "stdin": {
@@ -74,7 +90,11 @@ export function buildInvocation(
) )
: undefined; : undefined;
const redactedCommandLine = [script.command, ...redactedArgv].join(" "); const redactedCommandLine = [
...redactedEnvAssignments,
script.command,
...redactedArgv,
].join(" ");
return { argv, env, stdin, redactedVariables, redactedCommandLine }; return { argv, env, stdin, redactedVariables, redactedCommandLine };
} }
+2 -1
View File
@@ -9,7 +9,8 @@ declare global {
* Anchored on `globalThis` because Next compiles Route Handlers through its own module graph, * Anchored on `globalThis` because Next compiles Route Handlers through its own module graph,
* separate from the modules `server.ts` requires directly via tsx - a plain module-level * separate from the modules `server.ts` requires directly via tsx - a plain module-level
* singleton would silently end up duplicated (one copy per graph) instead of shared. */ * singleton would silently end up duplicated (one copy per graph) instead of shared. */
export const runEvents: EventEmitter = globalThis.__triggershellRunEvents ?? new EventEmitter(); export const runEvents: EventEmitter =
globalThis.__triggershellRunEvents ?? new EventEmitter();
globalThis.__triggershellRunEvents = runEvents; globalThis.__triggershellRunEvents = runEvents;
runEvents.setMaxListeners(0); runEvents.setMaxListeners(0);
+2 -1
View File
@@ -13,7 +13,8 @@ declare global {
* Anchored on `globalThis` - see the comment in `runner/events.ts` for why a plain module-level * Anchored on `globalThis` - see the comment in `runner/events.ts` for why a plain module-level
* singleton isn't safe here (Next compiles Route Handlers through a separate module graph from * singleton isn't safe here (Next compiles Route Handlers through a separate module graph from
* what `server.ts` requires directly). */ * what `server.ts` requires directly). */
const handles: Map<string, RunHandle> = globalThis.__triggershellRunHandles ?? new Map(); const handles: Map<string, RunHandle> =
globalThis.__triggershellRunHandles ?? new Map();
globalThis.__triggershellRunHandles = handles; globalThis.__triggershellRunHandles = handles;
export function registerRun(handle: RunHandle) { export function registerRun(handle: RunHandle) {