37 Commits
Author SHA1 Message Date
valknar 90d68f2510 Fix prettier formatting
CI / Publish to npm registry (push) Successful in 49s
CI / Checks (push) Successful in 47s
CI caught this on the v1.1.0 tag push - format:check wasn't run locally before committing.
2026-08-19 18:10:28 +02:00
valknar 1fa8c5ba66 Bump version to 1.1.0
CI / Checks (push) Failing after 45s
CI / Publish to npm registry (push) Skipped
2026-08-19 18:07:35 +02:00
valknar b291a119d5 Emit raw JSON from service logs via journalctl -o cat
Strips journalctl's own prefix so each line is pino's raw JSON payload, pipeable into jq for pretty-printing without pulling pino-pretty into runtime dependencies.
2026-08-19 18:06:06 +02:00
valknar e63129d156 Add triggershell service logs command
CI / Checks (push) Failing after 48s
CI / Publish to npm registry (push) Skipped
Thin wrapper around journalctl, consistent with the existing status/uninstall wrappers around systemctl.
2026-08-19 17:58:41 +02:00
valknarandClaude Sonnet 5 96a66fc857 Add structured backend logging with pino
CI / Checks (push) Successful in 47s
CI / Publish to npm registry (push) Successful in 50s
Wires leveled, structured logging (pretty in dev, JSON in prod) through
the server lifecycle, HTTP/WS request handling, run engine, auth, db,
and config loading. CLI command output is left untouched since it's
user-facing terminal UX, not backend logs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 09:54:16 +02:00
valknar 3272b9db76 fix: actually stage the checks/publish split
CI / Checks (push) Successful in 48s
CI / Publish to npm registry (push) Skipped
The previous commit renamed release.yaml to ci.yml but never staged
the content edit underneath it (git mv picked up the last-staged
version, not the unstaged working-tree changes) - it pushed with the
filename changed but the workflow itself untouched. This is the
content that commit was supposed to carry.
2026-08-17 17:38:31 +02:00
valknar 06edc60b55 ci: run checks on every push, publish only on tag
Same split as pulsenode's workflow: previously this only ran at all
when pushing a version tag, so lint/typecheck/format/test never ran on
regular commits or PRs - a broken push could sit unnoticed until
someone tried to cut a release. Now checks run on every push and PR;
publish to the npm registry stays gated to a tag push and requires
checks to pass first. Renamed release.yaml -> ci.yml to match.
2026-08-17 17:37:01 +02:00
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
valknarandClaude Sonnet 5 28407402c8 Make the favicon track the header icon's primary color per theme
The nav's Terminal icon uses text-primary, which resolves to a
different brass shade in light vs dark mode - the favicon was
hardcoded to only the dark-mode shade. Uses prefers-color-scheme
so the favicon glyph always matches whichever primary the header
is actually showing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 11:49:41 +02:00
valknarandClaude Sonnet 5 7e1569b94c Restyle the UI: instrument-panel palette, type system, and a signal accent
Replaces the default shadcn grayscale/Geist look with a deliberate
identity built around the product's own subject - a control panel for
running scripts and watching their output live:

- Type: Bricolage Grotesque for page/card titles (via the existing
  --font-heading token, used with restraint), IBM Plex Sans for UI body
  text, IBM Plex Mono for technical data (run IDs, commands, timestamps,
  status labels) - all self-hosted at build time via next/font/google,
  no runtime CDN dependency for a self-hosted tool.
- Color: a cool graphite ink/paper base with a warm brass signal accent
  in both themes. The brass tone doubles as the "running" status color,
  so an active run literally lights the UI up with the brand color.
  New --status-* tokens give queued/running/succeeded/failed/cancelled/
  warn a single source of truth instead of ad-hoc Tailwind color classes.
- Structural language: small tracked-out uppercase mono labels mark
  technical fields (Command, Variables, Triggered by...) consistently;
  the live-output terminal gets an instrument-bezel frame (header bar +
  panel) instead of floating loose above the xterm canvas.

Layout/IA is unchanged throughout - this is a token- and detail-level
pass, not a restructuring.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 11:44:30 +02:00
valknarandClaude Sonnet 5 f102ace7e8 Drop the stale sharp entry from pnpm-workspace.yaml's allowBuilds
pnpm re-scaffolds this block from the current dependency tree when it
finds unapproved build scripts; sharp is no longer a candidate, so
pnpm install can't fill it in and errors out with ERR_PNPM_IGNORED_BUILDS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 11:25:07 +02:00
valknarandClaude Sonnet 5 3f379ca2ac Flatten the repo: move everything out of app/ to the root
Now that the CLI and the Next.js app are one package, nesting it inside
app/ served no purpose - the repo root itself becomes the published
npm package. Merges app/.gitignore and app/README.md into the root
versions, drops the now-duplicate app/LICENSE, and updates path
references (README, docs/ARCHITECTURE.md, docs/CONFIG_REFERENCE.md,
package.json's repository.directory) that assumed the app/ nesting.

Also fixes a real bug this surfaced: the in-app docs viewer resolved
docs/ relative to process.cwd(), which only worked by accident when the
CLI happened to be invoked from app/'s parent directory. A first attempt
at fixing it with import.meta.dirname broke instead, for the same
cross-module-graph reason config-path resolution already documented -
Next compiles Route Handlers through a separate module graph that
doesn't preserve source-relative import.meta paths. Fixed by exposing
the app root via TRIGGERSHELL_APP_ROOT (set once in server.ts, where
import.meta *does* resolve correctly), the same pattern already used
for TRIGGERSHELL_CONFIG_PATH.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 11:14:01 +02:00
valknarandClaude Sonnet 5 30350d80f4 Replace the Python CLI with a Node CLI, add a systemd service command
The app is already 100% Node, so the Python launcher was pure overhead - it
existed mainly to bootstrap Node, which is circular. The CLI is now merged
into app/ (the single published npm package): `triggershell start` validates
the config and imports server.ts directly in-process, so server.ts's own
SIGTERM/SIGINT handling just works with no signal-relay/child-process layer
needed. `dev` is dropped from the public CLI (contributors use `pnpm --dir
app dev` directly); there's no `build` command either, since the package
ships a prebuilt `.next` via a `prepack` hook. Adds `triggershell service
install|uninstall|status` for running as a per-user or system systemd unit.

Also fixes two bugs found while wiring this up: server.ts resolved `.next`
relative to `process.cwd()`, which broke once the CLI could run from a
directory other than the app itself; and an explicitly-`files`-listed
package directory bypasses .npmignore for its subpaths, so `.next/cache`
was inflating the npm tarball to ~670MB (now stripped in `prepack`, ~7MB).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 11:03:25 +02:00
valknar e2b6c6102c chore: remove architecture doc in web app 2026-08-16 06:09:31 +02:00
valknarandClaude Sonnet 5 042391cdc3 Match run detail card width to the new-run form card
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 06:08:40 +02:00
valknarandClaude Sonnet 5 dc34b38b26 Drop "contributors" from the footer copyright line
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 00:59:11 +02:00
valknarandClaude Sonnet 5 346bf7995a Style inline code in docs as a pill instead of literal backticks
Tailwind Typography's default inline <code> style is just bold text
with decorative backtick characters added via CSS content - with docs
like CONFIG_REFERENCE.md that use backticks constantly, it read as
unstyled/half-parsed. Swapped it for the same muted rounded-pill look
already used for inline code elsewhere in the app (e.g. the dashboard's
"no scripts configured" message).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 00:58:14 +02:00
valknarandClaude Sonnet 5 1b415a7957 Add a footer and an in-app docs viewer
Footer (in the authenticated app layout, matching where Nav lives):
copyright line, a Docs link, and a link to the project repo.

Docs viewer: /docs lists docs/API.md, CONFIG_REFERENCE.md, and
ARCHITECTURE.md; /docs/[slug] renders one via react-markdown +
remark-gfm (tables, fenced code) inside a Tailwind Typography `prose`
block, dark-mode aware via prose-invert. The markdown files themselves
stay the single source of truth at the repo's docs/ - the app reads
them at request time rather than duplicating their content, resolved
from the app's cwd the same way config paths already are, since a
compiled Route Handler's module graph doesn't preserve source-relative
paths.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 00:54:15 +02:00
153 changed files with 4288 additions and 1540 deletions
+59
View File
@@ -0,0 +1,59 @@
name: CI
on:
push:
pull_request:
jobs:
checks:
name: Checks
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
publish:
name: Publish to npm registry
if: startsWith(github.ref, 'refs/tags/')
needs: checks
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
- 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
+35 -12
View File
@@ -1,16 +1,38 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.eggs/
build/
dist/
.venv/
venv/
.pytest_cache/
.ruff_cache/
# dependencies
/node_modules
/.pnpm-store
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# Runtime data (created by `triggershell start/dev` in whatever directory the config lives in)
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# typescript
*.tsbuildinfo
next-env.d.ts
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# vercel
.vercel
# Runtime data (created by `triggershell start` in whatever directory the config lives in)
.triggershell/
# Secrets loaded by `triggershell` via ${VAR} interpolation - never commit these
@@ -23,5 +45,6 @@ venv/
# Editors / OS
.DS_Store
*.pem
.idea/
.vscode/
+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/
View File
View File
+54 -22
View File
@@ -24,20 +24,22 @@ API for automation.
## Quickstart
```bash
pip install triggershell # or: pip install -e . from a checkout
npm install -g triggershell # or: npx triggershell <command> for one-off use
triggershell init # scaffold triggershell.yml (+ .env for secrets) in the current directory
triggershell users add admin # create a login (skip if you set auth.enabled: false)
triggershell dev # start in dev mode and open the browser
triggershell start # start the app and open the browser
```
Edit `triggershell.yml` to add your own scripts (see [Configuration](#configuration) below),
then run `triggershell start` for a production build.
Edit `triggershell.yml` to add your own scripts (see [Configuration](#configuration) below), then
re-run `triggershell start`.
> The `triggershell` package isn't published to npm yet. Until it is, build and link a local copy
> instead: `pnpm install && pnpm build && pnpm link --global`.
## Requirements
- Python >= 3.9
- Node.js >= 20 (checked by the CLI; not auto-installed)
- pnpm (auto-provisioned via Corepack if missing and Corepack is available)
- Node.js >= 20 — the only thing you need installed. Everything else `triggershell` needs ships
inside the package itself and is resolved automatically when you install it.
## Configuration
@@ -98,14 +100,39 @@ the script — always as a discrete argv element or env var, never interpolated
## CLI Usage
| Command | Description |
|---|---|
| --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `triggershell init [PATH]` | Scaffold a new config file + `.env` (`--port`, `--auth/--no-auth`, `--force`) |
| `triggershell validate [-c CONFIG]` | Validate a config file (fast Python pre-flight + full Node/Zod schema) |
| `triggershell dev [-c CONFIG] [--port] [--host] [--no-browser]` | Run in development mode (hot reload) |
| `triggershell start [-c CONFIG] [--port] [--host] [--no-browser] [--skip-build]` | Build (if stale) and run in production mode |
| `triggershell doctor` | Print environment/config diagnostics |
| `triggershell validate [-c CONFIG]` | Validate a config file against the full schema |
| `triggershell start [-c CONFIG] [--port] [--host] [--no-browser]` | Run the web app |
| `triggershell doctor [-c CONFIG]` | Print environment/config diagnostics |
| `triggershell scripts list [-c CONFIG]` | List configured scripts |
| `triggershell scripts show <scriptId> [-c CONFIG]` | Show a script's command and variables |
| `triggershell run <scriptId> [--var name=value...] [--token] [--local\|--remote] [--no-wait]` | Run a configured script - through an already-running server's API if one is reachable (so any open browser tab sees it live), otherwise standalone. See [Running scripts from the CLI](#running-scripts-from-the-cli). |
| `triggershell users add <username> [-c CONFIG] [--inline]` | Hash a password, store it in `.env`, and print a `${VAR}` snippet for `auth.users` (`--inline` prints the raw hash instead) |
| `triggershell users add-token <name> [-c CONFIG] [--inline]` | Generate an API token, store its hash in `.env`, and print a `${VAR}` snippet for `auth.tokens` (`--inline` prints the raw hash instead) |
| `triggershell service install [--system]` | Install a systemd unit that runs `triggershell start` (per-user by default, Linux only) |
| `triggershell service uninstall [--system]` | Stop, disable, and remove the systemd unit |
| `triggershell service status [--system]` | Show the systemd unit's status |
| `triggershell service logs [-n LINES] [--no-follow] [--system]` | Tail the systemd unit's logs (wraps `journalctl -o cat`, so each line is raw JSON - pipe through `jq` for pretty-printing) |
### 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
@@ -129,7 +156,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).
| Method | Path | Notes |
|---|---|---|
| ------ | ----------------------------- | ------------------------------------------------------ |
| GET | `/api/healthz` | Unauthenticated readiness probe |
| POST | `/api/auth/login` | `{username, password}` → sets session cookie |
| POST | `/api/auth/logout` | Clears the session |
@@ -145,19 +172,24 @@ Full reference with request/response shapes and curl examples: [`docs/API.md`](d
## Development
This is the workflow for working on TriggerShell itself, not for installing/running it — it
bypasses the CLI entirely and talks to the app's own scripts directly, with hot reload. The app is
still not meant to be run standalone with `next dev`/`next start`, since it needs the custom server
(`server.ts`) for the WebSocket endpoint — `pnpm dev`/`pnpm start` below cover that:
```bash
pnpm --dir app install
pnpm --dir app dev # or: triggershell dev, which wraps this
pnpm --dir app lint
pnpm --dir app typecheck
pnpm --dir app db:studio # browse the SQLite DB
pnpm install
pnpm dev # tsx watch server.ts - reads TRIGGERSHELL_CONFIG_PATH from the environment
pnpm lint
pnpm typecheck
pnpm test # CLI unit tests (src/cli/**/*.test.ts)
pnpm db:studio # browse the SQLite DB
```
Repo layout:
Repo layout — the repo root itself is the published npm package (Next.js app + the `triggershell`
CLI in `bin/`/`src/cli/`), alongside:
```
triggershell/ Python CLI (launcher/orchestrator only)
app/ Next.js app - all server logic (API, auth, script execution) lives here
examples/ A runnable example config + scripts
docs/ Config/architecture/API reference docs
```
@@ -168,7 +200,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
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
user-controlled in that case.
- `secret: true` variables are masked in the UI and redacted from persisted run records; only the
-41
View File
@@ -1,41 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
-16
View File
@@ -1,16 +0,0 @@
# TriggerShell web app
This is the Next.js app that TriggerShell's Python CLI (`triggershell dev` / `triggershell start`)
launches — it's not meant to be run standalone with `next dev`/`next start` since it needs a
custom server (`server.ts`) for the WebSocket endpoint.
See the [repo root README](../README.md) for how to run TriggerShell end-to-end, and
[`../docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) for how this app is put together.
```bash
pnpm install
pnpm dev # tsx watch server.ts - reads TRIGGERSHELL_CONFIG_PATH from the environment
pnpm lint
pnpm typecheck
pnpm db:studio # browse the SQLite database
```
-35
View File
@@ -1,35 +0,0 @@
import { loadConfig, ConfigError } from "../src/lib/config/load";
const configPathArg = process.argv[2];
try {
const { config, configPath } = loadConfig(configPathArg);
console.log(
JSON.stringify({
ok: true,
configPath,
scriptCount: config.scripts.length,
authEnabled: config.auth.enabled,
}),
);
process.exit(0);
} catch (error) {
if (error instanceof ConfigError) {
console.log(
JSON.stringify({
ok: false,
message: error.message,
issues: error.issues,
}),
);
} else {
console.log(
JSON.stringify({
ok: false,
message: (error as Error).message,
issues: [],
}),
);
}
process.exit(1);
}
-73
View File
@@ -1,73 +0,0 @@
import "./src/bootstrap/async-local-storage-polyfill";
import { createServer } from "node:http";
import next from "next";
import { WebSocketServer } from "ws";
import { getConfig } from "./src/lib/config/load";
import { migrateOnBoot } from "./src/lib/db/client";
import { syncAuthFromConfig } from "./src/lib/auth/sync";
import { reconcileOrphanedRuns } from "./src/lib/runner/engine";
import { killAllRuns } from "./src/lib/runner/registry";
import { attachWsServer, authenticateUpgrade } from "./src/lib/ws/server";
const dev = process.env.NODE_ENV !== "production";
const { config } = getConfig();
const port = Number(process.env.PORT ?? config.server.port);
const hostname = process.env.HOST ?? config.server.host;
migrateOnBoot();
syncAuthFromConfig();
reconcileOrphanedRuns();
const app = next({ dev, hostname, port });
const handle = app.getRequestHandler();
app.prepare().then(() => {
const nextUpgradeHandler = app.getUpgradeHandler();
const httpServer = createServer((req, res) => {
handle(req, res);
});
const wss = new WebSocketServer({ noServer: true });
attachWsServer(wss);
httpServer.on("upgrade", (req, socket, head) => {
const { pathname } = new URL(req.url ?? "/", "http://internal");
if (pathname !== "/ws/runs") {
// Anything else (e.g. Next's own dev-mode HMR websocket at /_next/hmr) is Next's to handle.
nextUpgradeHandler(req, socket, head).catch(() => socket.destroy());
return;
}
authenticateUpgrade(req)
.then((ok) => {
if (!ok) {
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
})
.catch(() => socket.destroy());
});
httpServer.listen(port, hostname, () => {
console.log(
`> triggershell ready on http://${hostname}:${port} (${dev ? "development" : "production"})`,
);
});
const shutdown = (signal: string) => {
console.log(`> received ${signal}, shutting down...`);
killAllRuns();
httpServer.close(() => process.exit(0));
// Force-exit if graceful shutdown hangs (e.g. a stuck WS connection).
setTimeout(() => process.exit(1), 5000).unref();
};
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
});
-130
View File
@@ -1,130 +0,0 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
-5
View File
@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<rect width="24" height="24" rx="5" fill="#09090b" />
<path d="M12 19h8" stroke="#fafafa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none" />
<path d="m4 17 6-6-6-6" stroke="#fafafa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none" />
</svg>

Before

Width:  |  Height:  |  Size: 386 B

@@ -1,37 +0,0 @@
import { Badge } from "@/components/ui/badge";
import type { RunStatus } from "@/lib/db/schema";
import { cn } from "@/lib/utils";
const styles: Record<RunStatus, string> = {
queued: "bg-muted text-muted-foreground",
running: "bg-blue-500/15 text-blue-600 dark:text-blue-400",
succeeded: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400",
failed: "bg-destructive/15 text-destructive",
cancelled: "bg-muted text-muted-foreground",
timed_out: "bg-amber-500/15 text-amber-600 dark:text-amber-400",
interrupted: "bg-amber-500/15 text-amber-600 dark:text-amber-400",
};
export const runStatusLabels: Record<RunStatus, string> = {
queued: "Queued",
running: "Running",
succeeded: "Succeeded",
failed: "Failed",
cancelled: "Cancelled",
timed_out: "Timed out",
interrupted: "Interrupted",
};
export function RunStatusBadge({ status }: { status: RunStatus }) {
return (
<Badge
className={cn("border-transparent font-medium", styles[status])}
variant="outline"
>
{status === "running" && (
<span className="mr-1 inline-block size-1.5 animate-pulse rounded-full bg-current" />
)}
{runStatusLabels[status]}
</Badge>
);
}
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env node
import { register } from "tsx/esm/api";
register();
await import("../src/cli/index.ts");
+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>`
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
### `POST /api/auth/login`
@@ -73,7 +77,7 @@ code). `404` if not found.
### `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`).
### `GET /api/runs/:runId/logs`
+62 -17
View File
@@ -1,13 +1,14 @@
# Architecture
```
triggershell (Python CLI) app/ (Next.js, all server logic)
─────────────────────── ──────────────────────────────
triggershell dev|start server.ts (custom Node server)
1. resolve + pre-flight the config ├─ Next.js request handler (pages, API routes)
2. check node/pnpm, `pnpm install` if stale ├─ ws.WebSocketServer on /ws/runs
3. set TRIGGERSHELL_CONFIG_PATH/PORT/HOST env └─ boot: migrate DB, sync auth, reconcile runs
4. spawn `pnpm run dev|start`, forward signals
triggershell (Node CLI, src/cli) server.ts + src/ (Next.js, all server logic)
─────────────────────────────── ─────────────────────────────────────────
triggershell start server.ts (custom Node server)
1. resolve + validate the config (loadConfig) ├─ Next.js request handler (pages, API routes)
2. check the port is free ├─ ws.WebSocketServer on /ws/runs
3. set TRIGGERSHELL_CONFIG_PATH/PORT/HOST/ └─ boot: migrate DB, sync auth, reconcile runs
NODE_ENV
4. import("./server.ts") — same process │
5. poll /api/healthz, open browser │
┌──────────┴──────────┐
REST API WebSocket
@@ -23,22 +24,66 @@
## Why a custom Node server
Next.js Route Handlers can't host a persistent WebSocket server, so `app/server.ts` wraps Next's
Next.js Route Handlers can't host a persistent WebSocket server, so `server.ts` wraps Next's
request handler in a plain `http.createServer` and attaches a `ws.WebSocketServer` via the
`upgrade` event, scoped to `/ws/runs` with its own auth check (Route Handlers get auth via
`next/headers`'s `cookies()`, which isn't available on a raw `http.IncomingMessage`).
## Why the Python CLI is thin
## Why the CLI and server share one process
Everything Node/pnpm/Next.js needs to do (serve pages, run scripts, stream output, enforce auth)
is naturally a Node problem — `execa` for argv-safe spawning, `ws` for streaming, Next for the UI.
Python's job is just: get Node/pnpm ready, validate the config fast, and manage the child process's
lifecycle (signals, readiness, browser launch) — a CLI concern, not a web-server concern.
`triggershell start` (`src/cli/commands/start.ts`) doesn't spawn `server.ts` as a child process —
it sets `process.env` (`TRIGGERSHELL_CONFIG_PATH`, `PORT`, `HOST`, `NODE_ENV`) and then dynamically
`import()`s `server.ts` directly, in the same Node process. `server.ts` reads that env and installs
its own `SIGTERM`/`SIGINT` handlers, so once it's imported, `Ctrl-C` or `systemctl stop` just work —
there's no parent process relaying signals to a child, no separate lifecycle to manage. The CLI's
`bin/triggershell.js` entry point registers `tsx`'s loader once for the whole process
(`tsx/esm/api`'s `register()`), so both the CLI's own `.ts` command files and `server.ts` run
straight from source, with no compile/bundle step for either.
## `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
`triggershell service install` renders a unit file (`src/cli/lib/systemd.ts`) whose `ExecStart`
line pins the exact `node` binary (`process.execPath`) and the exact, symlink-resolved path to the
installed CLI (`fs.realpathSync(process.argv[1])`) at install time — necessary because systemd
services run with a minimal `PATH` that may not include wherever Node actually lives. By default it
installs a per-user unit (`~/.config/systemd/user/triggershell.service`, no root required); `--system`
targets `/etc/systemd/system/` instead and prints the `sudo` commands to run if not already root.
`install` reloads the systemd daemon but does not enable/start the unit itself — that's a separate,
explicit `systemctl --user enable --now triggershell`, since it's the point where the service
actually starts listening and running scripts. `triggershell service status`/`uninstall`/`logs` are
thin wrappers around `systemctl`/`journalctl` respectively - no unit-file parsing or log storage of
our own, journald already does that. `logs` passes `-o cat` so each line is the raw pino JSON
payload rather than journalctl's own timestamp/hostname/unit prefix - pipeable straight into `jq`
for pretty-printing without pulling `pino-pretty` into the CLI's runtime dependencies.
## Cross-module-graph state
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
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
@@ -67,10 +112,10 @@ queued → running → succeeded | failed | cancelled | timed_out
## Auth
- 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.
- `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.
- 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()`.
@@ -81,4 +126,4 @@ Every server file that reads the config/DB at request time (`getConfig()`, `getD
`requireAuth()`) sets `export const dynamic = "force-dynamic"`. Without it, `next build` tries to
statically prerender pages like `/` at build time, which fails because there's no config file to
read yet (the config only exists at `triggershell start` runtime, in the user's own project
directory, not `app/`'s).
directory, not the package's).
+12 -11
View File
@@ -10,13 +10,14 @@ present) into its environment - without overriding any variable already set in t
secrets referenced via `${VAR}` don't have to be committed alongside the config. `triggershell
init` scaffolds both files together.
The canonical schema is the Zod schema at `app/src/lib/config/schema.ts` — this document mirrors
it. `triggershell validate` runs the Python pre-flight checks below, then that full schema.
The canonical schema is the Zod schema at `src/lib/config/schema.ts` — this document mirrors
it. `triggershell validate` loads and validates the config directly against the schema below (no
separate pre-flight step).
## `server`
| Field | Type | Default | Notes |
|---|---|---|---|
| ---------- | ------ | ----------- | ----------------------- |
| `host` | string | `127.0.0.1` | Bind address |
| `port` | number | `4173` | 1-65535 |
| `basePath` | string | `""` | Reserved for future use |
@@ -24,7 +25,7 @@ it. `triggershell validate` runs the Python pre-flight checks below, then that f
## `auth`
| Field | Type | Default | Notes |
|---|---|---|---|
| ----------------- | ------- | ------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `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 |
| `sessionTtlHours` | number | `12` | Session cookie lifetime |
@@ -42,20 +43,20 @@ pass `--inline` to those commands to get the raw hash printed for pasting into t
## `database`
| Field | Type | Default |
|---|---|---|
| ------ | ------ | ------------------------------- |
| `path` | string | `.triggershell/triggershell.db` |
## `logs`
| Field | Type | Default | Notes |
|---|---|---|---|
| --------------- | ------ | -------------------- | ----------------------------------------------------------- |
| `dir` | string | `.triggershell/logs` | One `<runId>.log` file per run |
| `retentionDays` | number | `30` | Not yet enforced automatically — prune manually or via cron |
## `scripts[]`
| Field | Type | Default | Notes |
|---|---|---|---|
| ---------------- | -------- | ------- | ------------------------------------------------------------------------------------ |
| `id` | string | — | Required, unique, `[a-zA-Z0-9][a-zA-Z0-9_-]*` |
| `name` | string | — | Required, display name |
| `description` | string | — | Optional |
@@ -71,7 +72,7 @@ pass `--inline` to those commands to get the raw hash printed for pasting into t
Common fields on every variable:
| Field | Type | Default | Notes |
|---|---|---|---|
| ------------- | ----------------------------------- | ------------------ | -------------------------------------------------------------------------------------- |
| `name` | string | — | Required, unique per script |
| `label` | string | `name` | Display label |
| `description` | string | — | Shown as form help text |
@@ -86,7 +87,7 @@ Common fields on every variable:
Type-specific fields:
| `type` | Extra fields |
|---|---|
| ------------- | ------------------------------------------------------------------------------------------------- |
| `string` | `default?: string`, `pattern?: string` (regex), `minLength?`, `maxLength?`, `multiline?: boolean` |
| `number` | `default?: number`, `min?`, `max?`, `step?` |
| `boolean` | `default: boolean` (default `false`) |
@@ -96,11 +97,11 @@ Type-specific fields:
### UI control mapping
| `type` | Default `control` | Valid overrides |
|---|---|---|
| ------------- | ---------------------------------------- | ------------------------------------------------ |
| `string` | `text` (or `password` if `secret: true`) | `textarea` (needs `multiline: true`), `password` |
| `number` | `number` | `slider` (requires both `min` and `max`) |
| `boolean` | `checkbox` | `switch` |
| `enum` | `select` | `radio` |
| `enum` | `select` | `radio`, `combobox` (searchable, single-select) |
| `multiselect` | `multiselect` (combobox) | `checkboxGroup` |
### `passAs` semantics
+3 -1
View File
@@ -10,7 +10,9 @@ const targetDir = getArg("--dir");
const olderThanDays = getArg("--days");
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) {
console.log("Using configured external API key.");
}
+1 -1
View File
@@ -72,7 +72,7 @@ scripts:
label: Notify Slack Channel
type: string
required: false
pattern: '^#[a-z0-9-]+$'
pattern: "^#[a-z0-9-]+$"
passAs: env
envName: SLACK_CHANNEL
+38 -8
View File
@@ -1,13 +1,34 @@
{
"name": "app",
"version": "0.1.0",
"private": true,
"name": "triggershell",
"version": "1.1.0",
"license": "MIT",
"type": "module",
"repository": {
"type": "git",
"url": "https://dev.pivoine.art/valknar/triggershell.git",
"directory": "app"
"url": "https://dev.pivoine.art/valknar/triggershell.git"
},
"bin": {
"triggershell": "bin/triggershell.js"
},
"engines": {
"node": ">=20"
},
"publishConfig": {
"registry": "https://dev.pivoine.art/api/packages/valknar/npm/"
},
"files": [
"bin",
"src",
"templates",
"drizzle",
"docs",
".next",
"server.ts",
"next.config.ts",
"next-env.d.ts",
"tsconfig.json",
"LICENSE"
],
"scripts": {
"dev": "tsx watch server.ts",
"build": "next build",
@@ -15,13 +36,16 @@
"lint": "eslint",
"typecheck": "tsc --noEmit",
"format": "prettier --write .",
"validate-config": "tsx scripts/validate-config.ts",
"format:check": "prettier --check .",
"test": "tsx --test \"src/cli/**/*.test.ts\"",
"db:generate": "drizzle-kit generate",
"db:studio": "drizzle-kit studio"
"db:studio": "drizzle-kit studio",
"prepack": "rm -rf .next && next build && rm -rf .next/cache"
},
"dependencies": {
"@base-ui/react": "^1.7.0",
"@hookform/resolvers": "^5.8.0",
"@inquirer/prompts": "^8.5.2",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"argon2": "^0.45.1",
@@ -29,18 +53,23 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"commander": "^15.0.0",
"drizzle-orm": "^0.45.2",
"execa": "^10.0.1",
"iron-session": "^8.0.4",
"lucide-react": "^1.31.0",
"next": "16.3.1",
"next-themes": "^0.4.6",
"pino": "^10.3.1",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-hook-form": "^7.85.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"shadcn": "^4.18.0",
"sonner": "^2.0.8",
"tailwind-merge": "^3.6.0",
"tsx": "^4.23.12",
"tw-animate-css": "^1.4.0",
"ws": "^8.21.3",
"yaml": "^2.9.0",
@@ -48,6 +77,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.20",
"@types/better-sqlite3": "^9.6.0",
"@types/node": "^20",
"@types/react": "^19",
@@ -57,10 +87,10 @@
"drizzle-kit": "^0.31.10",
"eslint": "^9",
"eslint-config-next": "16.3.1",
"pino-pretty": "^13.1.3",
"prettier": "^3.9.6",
"prettier-plugin-tailwindcss": "^0.8.1",
"tailwindcss": "^4",
"tsx": "^4.23.12",
"typescript": "^5"
},
"packageManager": "pnpm@11.21.0"
+1372 -3
View File
File diff suppressed because it is too large Load Diff
@@ -2,5 +2,4 @@ allowBuilds:
argon2: true
better-sqlite3: true
esbuild: true
sharp: false
unrs-resolver: false
-48
View File
@@ -1,48 +0,0 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "triggershell"
version = "0.1.0"
description = "CLI launcher for the TriggerShell web app - run configured shell scripts from a browser."
readme = "README.md"
requires-python = ">=3.9"
license = "MIT"
authors = [{ name = "TriggerShell contributors" }]
dependencies = [
"typer>=0.12",
"rich>=13.7",
"pyyaml>=6.0",
"argon2-cffi>=23.1",
"python-dotenv>=1.0",
]
[project.urls]
Repository = "https://dev.pivoine.art/valknar/triggershell.git"
[project.scripts]
triggershell = "triggershell.cli:app"
[tool.hatch.build.targets.wheel]
packages = ["triggershell"]
[tool.hatch.build.targets.wheel.force-include]
"app" = "triggershell/_app"
[tool.hatch.build.targets.sdist]
include = ["triggershell", "app", "README.md"]
exclude = ["app/node_modules", "app/.next", "app/drizzle.config.ts.bak"]
[tool.ruff]
line-length = 120
target-version = "py39"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
# B008: typer's `= typer.Option(...)` / `= typer.Argument(...)` defaults are the intended API, not a bug.
# UP045: `X | None` needs 3.10 at runtime for typer's introspection; this project targets 3.9+.
ignore = ["B008", "UP045"]
[dependency-groups]
dev = ["ruff>=0.6", "pytest>=8.0"]
+125
View File
@@ -0,0 +1,125 @@
import "./src/bootstrap/async-local-storage-polyfill";
import { randomUUID } from "node:crypto";
import { createServer } from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import next from "next";
import type { Level } from "pino";
import { WebSocketServer } from "ws";
import { getConfig } from "./src/lib/config/load";
import { migrateOnBoot } from "./src/lib/db/client";
import { syncAuthFromConfig } from "./src/lib/auth/sync";
import { reconcileOrphanedRuns } from "./src/lib/runner/engine";
import { killAllRuns } from "./src/lib/runner/registry";
import { attachWsServer, authenticateUpgrade } from "./src/lib/ws/server";
import { logger } from "./src/lib/logger";
const log = logger.child({ mod: "server" });
const dev = process.env.NODE_ENV !== "production";
const { config } = getConfig();
const port = Number(process.env.PORT ?? config.server.port);
const hostname = process.env.HOST ?? config.server.host;
migrateOnBoot();
syncAuthFromConfig();
reconcileOrphanedRuns();
// `dir` must be this file's own directory, not `process.cwd()` - when launched by the installed
// `triggershell` CLI, the working directory is wherever the user's config lives, not the package.
const dir = path.dirname(fileURLToPath(import.meta.url));
// Exposed via `process.env` (not just the local `dir` const) so Route Handlers/Server Components -
// compiled through Next's own module graph, separate from this file's - can find it too. See
// `docs.ts`'s use of this and the `globalThis` comment in `runner/events.ts` for the same reasoning.
process.env.TRIGGERSHELL_APP_ROOT = dir;
const app = next({ dev, dir, hostname, port });
const handle = app.getRequestHandler();
// `/_next/*` asset requests happen dozens of times per page load and carry no operational
// signal - logged at debug so they don't drown out page/API requests in the default info level.
function accessLogLevel(pathname: string, statusCode: number): Level {
if (statusCode >= 500) return "error";
if (statusCode >= 400) return "warn";
return pathname.startsWith("/_next/") ? "debug" : "info";
}
app.prepare().then(() => {
const nextUpgradeHandler = app.getUpgradeHandler();
const httpServer = createServer((req, res) => {
const reqId = req.headers["x-request-id"]?.toString() ?? randomUUID();
req.headers["x-request-id"] = reqId;
const startedAt = process.hrtime.bigint();
res.on("finish", () => {
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1e6;
const { pathname } = new URL(req.url ?? "/", "http://internal");
log[accessLogLevel(pathname, res.statusCode)](
{
reqId,
method: req.method,
path: pathname,
status: res.statusCode,
durationMs: Math.round(durationMs),
},
"request",
);
});
handle(req, res).catch((error: unknown) => {
log.error({ reqId, err: error }, "unhandled error handling request");
if (!res.headersSent) res.writeHead(500).end();
});
});
const wss = new WebSocketServer({ noServer: true });
attachWsServer(wss);
httpServer.on("upgrade", (req, socket, head) => {
const { pathname } = new URL(req.url ?? "/", "http://internal");
if (pathname !== "/ws/runs") {
// Anything else (e.g. Next's own dev-mode HMR websocket at /_next/hmr) is Next's to handle.
nextUpgradeHandler(req, socket, head).catch(() => socket.destroy());
return;
}
authenticateUpgrade(req)
.then((ok) => {
if (!ok) {
log.warn({ path: pathname }, "rejected unauthenticated WS upgrade");
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
})
.catch((error: unknown) => {
log.error({ err: error }, "error authenticating WS upgrade");
socket.destroy();
});
});
httpServer.listen(port, hostname, () => {
log.info(
{ hostname, port, mode: dev ? "development" : "production" },
"triggershell ready",
);
});
const shutdown = (signal: string) => {
log.info({ signal }, "shutting down");
killAllRuns();
httpServer.close(() => process.exit(0));
// Force-exit if graceful shutdown hangs (e.g. a stuck WS connection).
setTimeout(() => {
log.warn("graceful shutdown timed out, forcing exit");
process.exit(1);
}, 5000).unref();
};
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
});
+41
View File
@@ -0,0 +1,41 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { getDocMeta, readDocContent } from "@/lib/docs";
import { MarkdownViewer } from "@/components/docs/markdown-viewer";
interface DocPageProps {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({
params,
}: DocPageProps): Promise<Metadata> {
const { slug } = await params;
return { title: getDocMeta(slug)?.title ?? "Doc not found" };
}
export default async function DocPage({ params }: DocPageProps) {
const { slug } = await params;
const doc = getDocMeta(slug);
if (!doc) notFound();
const content = readDocContent(doc);
if (content === null) notFound();
return (
<div className="mx-auto flex max-w-5xl flex-col gap-6">
<Link
href="/docs"
className="text-muted-foreground hover:text-foreground flex w-fit items-center gap-1 text-sm"
>
<ArrowLeft className="size-3.5" />
All docs
</Link>
<MarkdownViewer content={content} />
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { BookOpen, ChevronRight } from "lucide-react";
import { DOCS } from "@/lib/docs";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export const metadata: Metadata = { title: "Docs" };
export default function DocsIndexPage() {
return (
<div className="flex flex-col gap-4">
<h1 className="font-heading text-2xl font-semibold tracking-tight">
Docs
</h1>
<div className="grid gap-3 sm:grid-cols-2">
{DOCS.map((doc) => (
<Link key={doc.slug} href={`/docs/${doc.slug}`}>
<Card className="hover:border-primary/50 h-full transition-colors">
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
<div className="flex items-center gap-2">
<BookOpen className="text-muted-foreground size-4.5 shrink-0" />
<CardTitle className="text-base">{doc.title}</CardTitle>
</div>
<ChevronRight className="text-muted-foreground size-4 shrink-0" />
</CardHeader>
<CardContent className="text-muted-foreground text-sm">
{doc.description}
</CardContent>
</Card>
</Link>
))}
</div>
</div>
);
}
@@ -3,6 +3,7 @@ export const dynamic = "force-dynamic";
import { getConfig } from "@/lib/config/load";
import { requireAuth } from "@/lib/auth/guard";
import { Nav } from "@/components/layout/nav";
import { Footer } from "@/components/layout/footer";
export default async function AppLayout({
children,
@@ -18,6 +19,7 @@ export default async function AppLayout({
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-8">
{children}
</main>
<Footer />
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { NotFoundContent } from "@/components/layout/not-found-content";
export default function NotFound() {
return <NotFoundContent />;
}
@@ -13,21 +13,31 @@ export default function DashboardPage() {
if (config.scripts.length === 0) {
return (
<div className="text-muted-foreground py-24 text-center">
No scripts configured yet. Add entries under{" "}
<code className="bg-muted rounded px-1.5 py-0.5">scripts:</code> in your
config file.
<div className="flex flex-col items-center gap-2 py-24 text-center">
<PlayCircle className="text-muted-foreground/50 mb-2 size-8" />
<h1 className="font-heading text-lg font-semibold tracking-tight">
No scripts yet
</h1>
<p className="text-muted-foreground max-w-sm text-sm">
Add entries under{" "}
<code className="bg-muted rounded px-1.5 py-0.5 font-mono text-xs">
scripts:
</code>{" "}
in your config file to see them here.
</p>
</div>
);
}
return (
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-semibold tracking-tight">Scripts</h1>
<h1 className="font-heading text-2xl font-semibold tracking-tight">
Scripts
</h1>
<div className="grid gap-3 sm:grid-cols-2">
{config.scripts.map((script) => (
<Link key={script.id} href={`/scripts/${script.id}`}>
<Card className="hover:border-foreground/30 h-full transition-colors">
<Card className="hover:border-primary/50 h-full transition-colors">
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
<div className="flex items-center gap-2">
<PlayCircle className="text-muted-foreground size-4.5 shrink-0" />
@@ -66,12 +66,12 @@ export default async function RunDetailPage({ params }: RunDetailPageProps) {
}));
return (
<div className="mx-auto flex max-w-3xl flex-col gap-4">
<div className="mx-auto flex max-w-5xl flex-col gap-4">
<Card>
<CardHeader>
<CardTitle>{run.scriptName}</CardTitle>
{script && (
<CardAction>
<CardAction className="row-span-1">
<Link
href={`/scripts/${run.scriptId}?fromRun=${run.id}`}
className={buttonVariants({ variant: "outline", size: "sm" })}
@@ -81,39 +81,45 @@ export default async function RunDetailPage({ params }: RunDetailPageProps) {
</Link>
</CardAction>
)}
<dl className="text-muted-foreground grid grid-cols-2 gap-x-4 gap-y-1 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>
<dt className="font-medium">Triggered by</dt>
<dd>{run.triggeredBy}</dd>
<dt className="font-mono text-[0.7rem] font-medium tracking-widest uppercase">
Triggered by
</dt>
<dd className="font-mono">{run.triggeredBy}</dd>
</div>
<div>
<dt className="font-medium">Started</dt>
<dd>
<dt className="font-mono text-[0.7rem] font-medium tracking-widest uppercase">
Started
</dt>
<dd className="font-mono">
{run.startedAt ? new Date(run.startedAt).toLocaleString() : "-"}
</dd>
</div>
<div>
<dt className="font-medium">Run ID</dt>
<dt className="font-mono text-[0.7rem] font-medium tracking-widest uppercase">
Run ID
</dt>
<dd className="truncate font-mono">{run.id}</dd>
</div>
</dl>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs font-medium">
<span className="text-muted-foreground font-mono text-[0.7rem] font-medium tracking-widest uppercase">
Command
</span>
<pre className="bg-muted overflow-x-auto rounded-md p-3 font-mono text-xs whitespace-pre">
<pre className="bg-muted border-primary/30 overflow-x-auto rounded-md border-l-2 p-3 font-mono text-xs whitespace-pre">
{run.resolvedCommand}
</pre>
</div>
{variableEntries.length > 0 && (
<div className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs font-medium">
<span className="text-muted-foreground font-mono text-[0.7rem] font-medium tracking-widest uppercase">
Variables
</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 }) => (
<div key={key} className="flex flex-col gap-0.5">
<dt className="text-muted-foreground">{label}</dt>
@@ -2,11 +2,20 @@ export const dynamic = "force-dynamic";
import type { Metadata } from "next";
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 { getDb } from "@/lib/db/client";
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 { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -89,7 +98,9 @@ export default async function RunsPage({ searchParams }: RunsPageProps) {
);
}
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") {
conditions.push(eq(runs.scriptId, scriptFilter));
@@ -161,11 +172,16 @@ export default async function RunsPage({ searchParams }: RunsPageProps) {
return (
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-semibold tracking-tight">Run History</h1>
<h1 className="font-heading text-2xl font-semibold tracking-tight">
Run History
</h1>
<RunsToolbar scripts={scripts} />
{rows.length === 0 ? (
<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 match these filters."}
</p>
@@ -177,7 +193,9 @@ export default async function RunsPage({ searchParams }: RunsPageProps) {
<TableRow>
<TableHead>{sortHeader("script", "Script")}</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("duration", "Duration")}</TableHead>
</TableRow>
@@ -196,15 +214,15 @@ export default async function RunsPage({ searchParams }: RunsPageProps) {
<TableCell>
<RunStatusBadge status={run.status} />
</TableCell>
<TableCell className="text-muted-foreground">
<TableCell className="text-muted-foreground font-mono text-xs">
{run.triggeredBy}
</TableCell>
<TableCell className="text-muted-foreground">
<TableCell className="text-muted-foreground font-mono text-xs">
{run.startedAt
? new Date(run.startedAt).toLocaleString()
: "-"}
</TableCell>
<TableCell className="text-muted-foreground">
<TableCell className="text-muted-foreground font-mono text-xs">
{formatDuration(run.startedAt, run.endedAt)}
</TableCell>
</TableRow>
@@ -55,7 +55,7 @@ export default async function ScriptPage({
}
return (
<div className="mx-auto max-w-2xl">
<div className="mx-auto max-w-5xl">
<Card>
<CardHeader>
<CardTitle>{script.name}</CardTitle>
@@ -11,6 +11,9 @@ import {
recordFailedAttempt,
clearAttempts,
} from "@/lib/auth/rate-limit";
import { logger } from "@/lib/logger";
const log = logger.child({ mod: "auth" });
const loginSchema = z.object({
username: z.string().min(1),
@@ -20,6 +23,7 @@ const loginSchema = z.object({
export async function POST(request: Request) {
const rateLimitKey = request.headers.get("x-forwarded-for") ?? "local";
if (isRateLimited(rateLimitKey)) {
log.warn({ from: rateLimitKey }, "login rate-limited");
return Response.json(
{ error: "Too many attempts, try again later." },
{ status: 429 },
@@ -44,6 +48,10 @@ export async function POST(request: Request) {
!(await verifyPassword(user.passwordHash, parsed.data.password))
) {
recordFailedAttempt(rateLimitKey);
log.warn(
{ from: rateLimitKey, username: parsed.data.username },
"login failed: invalid credentials",
);
return Response.json({ error: "Invalid credentials" }, { status: 401 });
}
@@ -58,5 +66,7 @@ export async function POST(request: Request) {
session.username = user.username;
await session.save();
log.info({ from: rateLimitKey, username: user.username }, "login succeeded");
return Response.json({ user: { username: user.username } });
}
@@ -5,6 +5,9 @@ import { requireAuth, unauthorizedResponse } from "@/lib/auth/guard";
import { getDb } from "@/lib/db/client";
import { runs } from "@/lib/db/schema";
import { cancelRun } from "@/lib/runner/registry";
import { logger } from "@/lib/logger";
const log = logger.child({ mod: "api" });
export async function POST(
request: Request,
@@ -27,11 +30,16 @@ export async function POST(
const cancelled = cancelRun(runId);
if (!cancelled) {
log.warn(
{ runId },
"cancel requested for a run not tracked by this process",
);
return Response.json(
{ error: "Run is not active in this server process" },
{ status: 409 },
);
}
log.info({ runId, requestedBy: auth.identity }, "cancel requested via API");
return Response.json({ status: "cancelling" }, { status: 202 });
}
@@ -4,6 +4,9 @@ import { requireAuth, unauthorizedResponse } from "@/lib/auth/guard";
import { getScript } from "@/lib/config/load";
import { buildVariableSchema } from "@/lib/validation/variable-schema";
import { startRun } from "@/lib/runner/engine";
import { logger } from "@/lib/logger";
const log = logger.child({ mod: "api" });
export async function POST(
request: Request,
@@ -26,11 +29,13 @@ export async function POST(
const variableSchema = buildVariableSchema(script);
const parsed = variableSchema.safeParse(variablesInput);
if (!parsed.success) {
const fieldErrors = parsed.error.flatten().fieldErrors;
log.warn(
{ scriptId: script.id, fields: Object.keys(fieldErrors) },
"run request failed variable validation",
);
return Response.json(
{
error: "Validation failed",
fieldErrors: parsed.error.flatten().fieldErrors,
},
{ error: "Validation failed", fieldErrors },
{ status: 400 },
);
}
+154
View File
@@ -0,0 +1,154 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-heading: var(--font-display);
--color-status-queued: var(--status-queued);
--color-status-running: var(--status-running);
--color-status-succeeded: var(--status-succeeded);
--color-status-failed: var(--status-failed);
--color-status-cancelled: var(--status-cancelled);
--color-status-warn: var(--status-warn);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
/*
* Palette - "instrument panel": cool graphite ink/paper with a warm brass signal accent.
* The brass tone doubles as the "running" status color, so a live run literally lights the UI
* up with the brand color - see run-status-badge.tsx and run-terminal.tsx.
*/
:root {
--background: #f5f6f7;
--foreground: #15171a;
--card: #ffffff;
--card-foreground: #15171a;
--popover: #ffffff;
--popover-foreground: #15171a;
--primary: #c8842e;
--primary-foreground: #17130a;
--secondary: #ececee;
--secondary-foreground: #15171a;
--muted: #ececee;
--muted-foreground: #6b7280;
--accent: #ececee;
--accent-foreground: #15171a;
--destructive: #b3402e;
--border: #dfe1e3;
--input: #dfe1e3;
--ring: #c8842e;
--status-queued: #6b7280;
--status-running: #c8842e;
--status-succeeded: #3f8f5f;
--status-failed: #b3402e;
--status-cancelled: #6b7280;
--status-warn: #8a7530;
--chart-1: #c8842e;
--chart-2: #3f8f5f;
--chart-3: #6b7280;
--chart-4: #8a7530;
--chart-5: #b3402e;
--radius: 0.5rem;
--sidebar: #f5f6f7;
--sidebar-foreground: #15171a;
--sidebar-primary: #c8842e;
--sidebar-primary-foreground: #17130a;
--sidebar-accent: #ececee;
--sidebar-accent-foreground: #15171a;
--sidebar-border: #dfe1e3;
--sidebar-ring: #c8842e;
}
.dark {
--background: #14161a;
--foreground: #edeef0;
--card: #1b1e23;
--card-foreground: #edeef0;
--popover: #1b1e23;
--popover-foreground: #edeef0;
--primary: #e9a857;
--primary-foreground: #1a1305;
--secondary: #23262c;
--secondary-foreground: #edeef0;
--muted: #23262c;
--muted-foreground: #9aa0a8;
--accent: #23262c;
--accent-foreground: #edeef0;
--destructive: #e2685a;
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 14%);
--ring: #e9a857;
--status-queued: #9aa0a8;
--status-running: #e9a857;
--status-succeeded: #5fb983;
--status-failed: #e2685a;
--status-cancelled: #9aa0a8;
--status-warn: #c9a344;
--chart-1: #e9a857;
--chart-2: #5fb983;
--chart-3: #9aa0a8;
--chart-4: #c9a344;
--chart-5: #e2685a;
--sidebar: #1b1e23;
--sidebar-foreground: #edeef0;
--sidebar-primary: #e9a857;
--sidebar-primary-foreground: #1a1305;
--sidebar-accent: #23262c;
--sidebar-accent-foreground: #edeef0;
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: #e9a857;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<style>
rect { fill: #15171a; }
path { stroke: #c8842e; }
@media (prefers-color-scheme: dark) {
path { stroke: #e9a857; }
}
</style>
<rect width="24" height="24" rx="5" />
<path d="M12 19h8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none" />
<path d="m4 17 6-6-6-6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none" />
</svg>

After

Width:  |  Height:  |  Size: 496 B

+20 -6
View File
@@ -1,18 +1,32 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import {
Bricolage_Grotesque,
IBM_Plex_Mono,
IBM_Plex_Sans,
} from "next/font/google";
import { ThemeProvider } from "next-themes";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/sonner";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
// Display face for page titles and card headings (wired via --font-heading in globals.css) - used
// with restraint, never for body copy. Body/UI text and technical data (run IDs, commands,
// timestamps) share the IBM Plex family so the two registers still read as one system.
const displayFont = Bricolage_Grotesque({
variable: "--font-display",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
const sansFont = IBM_Plex_Sans({
variable: "--font-sans",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
});
const monoFont = IBM_Plex_Mono({
variable: "--font-mono",
subsets: ["latin"],
weight: ["400", "500", "600"],
});
export const metadata: Metadata = {
@@ -31,7 +45,7 @@ export default function RootLayout({
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
className={`${displayFont.variable} ${sansFont.variable} ${monoFont.variable} h-full antialiased`}
suppressHydrationWarning
>
<body
@@ -50,7 +50,7 @@ export function LoginForm() {
<Card className="w-full max-w-sm">
<CardHeader>
<div className="flex items-center gap-2">
<Terminal className="size-5" />
<Terminal className="text-primary size-5" />
<CardTitle>TriggerShell</CardTitle>
</div>
<CardDescription>
+5
View File
@@ -0,0 +1,5 @@
import { NotFoundContent } from "@/components/layout/not-found-content";
export default function NotFound() {
return <NotFoundContent />;
}
+47
View File
@@ -0,0 +1,47 @@
import fs from "node:fs";
import { ConfigError, loadConfig } from "../../lib/config/load";
import { isPortFree } from "../lib/network";
import { resolveConfigPath } from "../lib/paths";
export interface DoctorOptions {
config?: string;
}
export async function doctorCommand(opts: DoctorOptions): Promise<void> {
const rows: [string, string][] = [];
rows.push(["Node.js", process.version]);
const configPath = resolveConfigPath(opts.config);
const exists = fs.existsSync(configPath);
rows.push([
"Config path",
`${configPath} ${exists ? "(exists)" : "(not found)"}`,
]);
if (exists) {
try {
const { config } = loadConfig(configPath);
const portFree = await isPortFree(config.server.host, config.server.port);
rows.push([
"Port available",
portFree
? "yes"
: `no (${config.server.host}:${config.server.port} in use)`,
]);
rows.push(["Scripts configured", String(config.scripts.length)]);
rows.push(["Auth enabled", String(config.auth.enabled)]);
} catch (error) {
if (error instanceof ConfigError) {
rows.push(["Config", error.message]);
} else {
throw error;
}
}
}
const labelWidth = Math.max(...rows.map(([label]) => label.length));
for (const [label, value] of rows) {
console.log(`${label.padEnd(labelWidth)} ${value}`);
}
}
+62
View File
@@ -0,0 +1,62 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { resolveAppRoot } from "../lib/paths";
const DEFAULT_CONFIG_NAME = "triggershell.yml";
export interface InitOptions {
port: number;
auth: boolean;
force: boolean;
}
export async function initCommand(
targetPath: string | undefined,
opts: InitOptions,
): Promise<void> {
const targetDir = path.resolve(process.cwd(), targetPath ?? ".");
fs.mkdirSync(targetDir, { recursive: true });
const configPath = path.join(targetDir, DEFAULT_CONFIG_NAME);
if (fs.existsSync(configPath) && !opts.force) {
console.error(`${configPath} already exists. Use --force to overwrite.`);
process.exitCode = 1;
return;
}
const templatePath = path.join(
resolveAppRoot(),
"templates",
DEFAULT_CONFIG_NAME,
);
const template = fs.readFileSync(templatePath, "utf-8");
const rendered = template
.replace("__PORT__", String(opts.port))
.replace("__AUTH_ENABLED__", opts.auth ? "true" : "false");
fs.writeFileSync(configPath, rendered);
console.log(`Created ${configPath}`);
const envPath = path.join(targetDir, ".env");
if (opts.auth) {
if (fs.existsSync(envPath)) {
console.log(
`${envPath} already exists - make sure it sets TRIGGERSHELL_SESSION_SECRET (>= 32 chars).`,
);
} else {
const sessionSecret = crypto.randomBytes(32).toString("hex");
fs.writeFileSync(
envPath,
`TRIGGERSHELL_SESSION_SECRET=${sessionSecret}\n`,
);
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(` triggershell users add <username> --config ${configPath}`);
}
console.log("\nStart the app with:");
console.log(` triggershell start --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(", ")})`);
}
}
+151
View File
@@ -0,0 +1,151 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execa } from "execa";
import { resolveConfigPath, resolveInvokedBinPath } from "../lib/paths";
import {
isRoot,
renderUnit,
SERVICE_NAME,
systemUnitPath,
userUnitPath,
} from "../lib/systemd";
export interface ServiceInstallOptions {
config?: string;
port?: number;
host?: string;
system?: boolean;
}
export interface ServiceScopeOptions {
system?: boolean;
}
function scopeOf(opts: ServiceScopeOptions): "user" | "system" {
return opts.system ? "system" : "user";
}
export async function serviceInstallCommand(
opts: ServiceInstallOptions,
): Promise<void> {
const scope = scopeOf(opts);
const configPath = resolveConfigPath(opts.config);
const unit = renderUnit({
execPath: process.execPath,
binPath: resolveInvokedBinPath(),
configPath,
configDir: path.dirname(configPath),
port: opts.port,
host: opts.host,
scope,
});
if (scope === "user") {
const unitPath = userUnitPath();
fs.mkdirSync(path.dirname(unitPath), { recursive: true });
fs.writeFileSync(unitPath, unit);
await execa("systemctl", ["--user", "daemon-reload"], { reject: false });
console.log(`Installed ${unitPath}`);
console.log("\nReview it, then start the service with:");
console.log(` systemctl --user enable --now ${SERVICE_NAME}`);
console.log("\nTail logs with:");
console.log(` journalctl --user -u ${SERVICE_NAME} -f`);
return;
}
if (isRoot()) {
const unitPath = systemUnitPath();
fs.writeFileSync(unitPath, unit);
await execa("systemctl", ["daemon-reload"], { reject: false });
console.log(`Installed ${unitPath}`);
console.log("\nReview it, then start the service with:");
console.log(` systemctl enable --now ${SERVICE_NAME}`);
console.log("\nTail logs with:");
console.log(` journalctl -u ${SERVICE_NAME} -f`);
return;
}
const scratchPath = path.join(os.tmpdir(), `${SERVICE_NAME}.service`);
fs.writeFileSync(scratchPath, unit);
console.log(
`Not running as root - wrote the unit file to ${scratchPath} instead.`,
);
console.log("\nReview it, then run:");
console.log(` sudo install -m 644 ${scratchPath} ${systemUnitPath()}`);
console.log(" sudo systemctl daemon-reload");
console.log(` sudo systemctl enable --now ${SERVICE_NAME}`);
}
export async function serviceUninstallCommand(
opts: ServiceScopeOptions,
): Promise<void> {
const scope = scopeOf(opts);
if (scope === "user") {
await execa("systemctl", ["--user", "disable", "--now", SERVICE_NAME], {
reject: false,
});
const unitPath = userUnitPath();
if (fs.existsSync(unitPath)) fs.rmSync(unitPath);
await execa("systemctl", ["--user", "daemon-reload"], { reject: false });
console.log(`Removed ${unitPath}`);
return;
}
if (isRoot()) {
await execa("systemctl", ["disable", "--now", SERVICE_NAME], {
reject: false,
});
const unitPath = systemUnitPath();
if (fs.existsSync(unitPath)) fs.rmSync(unitPath);
await execa("systemctl", ["daemon-reload"], { reject: false });
console.log(`Removed ${unitPath}`);
return;
}
console.log("Not running as root. Remove the system service manually with:");
console.log(` sudo systemctl disable --now ${SERVICE_NAME}`);
console.log(` sudo rm ${systemUnitPath()}`);
console.log(" sudo systemctl daemon-reload");
}
export async function serviceStatusCommand(
opts: ServiceScopeOptions,
): Promise<void> {
const scope = scopeOf(opts);
const args =
scope === "user"
? ["--user", "status", SERVICE_NAME]
: ["status", SERVICE_NAME];
const result = await execa("systemctl", args, {
stdio: "inherit",
reject: false,
});
process.exitCode = result.exitCode ?? 1;
}
export interface ServiceLogsOptions extends ServiceScopeOptions {
follow?: boolean;
lines?: number;
}
export async function serviceLogsCommand(
opts: ServiceLogsOptions,
): Promise<void> {
const scope = scopeOf(opts);
const args = scope === "user" ? ["--user"] : [];
// -o cat strips journalctl's own prefix (timestamp/hostname/unit) so each line is the raw pino
// JSON payload - pipeable straight into `jq` or similar without journalctl's wrapper in the way.
args.push("-u", SERVICE_NAME, "-o", "cat");
if (opts.follow !== false) args.push("-f");
if (opts.lines !== undefined) args.push("-n", String(opts.lines));
const result = await execa("journalctl", args, {
stdio: "inherit",
reject: false,
});
process.exitCode = result.exitCode ?? 1;
}
+61
View File
@@ -0,0 +1,61 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
import { ConfigError, loadConfig } from "../../lib/config/load";
import { loadDotenv } from "../lib/env-file";
import { isPortFree, openBrowser, waitUntilReady } from "../lib/network";
import { resolveAppRoot, resolveConfigPath } from "../lib/paths";
export interface StartOptions {
config?: string;
port?: number;
host?: string;
noBrowser?: boolean;
}
export async function startCommand(opts: StartOptions): 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 effectiveHost = opts.host ?? loaded.config.server.host;
const effectivePort = opts.port ?? loaded.config.server.port;
if (!(await isPortFree(effectiveHost, effectivePort))) {
console.error(
`Port ${effectivePort} on ${effectiveHost} is already in use. Pass --port to use a different one.`,
);
process.exitCode = 1;
return;
}
process.env.TRIGGERSHELL_CONFIG_PATH = configPath;
process.env.PORT = String(effectivePort);
process.env.HOST = effectiveHost;
// Next's generated types mark NODE_ENV readonly; this is the one legitimate place that sets it
// (the CLI IS what decides production mode) before importing server.ts.
(process.env as { NODE_ENV: string }).NODE_ENV = "production";
const url = `http://${effectiveHost}:${effectivePort}`;
if (!opts.noBrowser) {
void waitUntilReady(`${url}/api/healthz`, 45_000).then((ready) => {
if (ready) openBrowser(url);
});
}
const serverEntry = pathToFileURL(
path.join(resolveAppRoot(), "server.ts"),
).href;
await import(serverEntry);
}
+88
View File
@@ -0,0 +1,88 @@
import crypto from "node:crypto";
import path from "node:path";
import { password as promptPassword } from "@inquirer/prompts";
import { stringify } from "yaml";
import { hashPassword } from "../../lib/auth/password";
import { upsertEnvVar } from "../lib/env-file";
import { resolveConfigPath } from "../lib/paths";
import { slug } from "../lib/slug";
export interface UsersOptions {
config?: string;
inline?: boolean;
}
function printSnippet(heading: string, entry: Record<string, unknown>): void {
console.log(`\n${heading}\n`);
console.log(stringify([entry]));
}
async function promptNewPassword(): Promise<string> {
for (;;) {
const first = await promptPassword({ message: "Password", mask: true });
const second = await promptPassword({
message: "Confirm password",
mask: true,
});
if (first === second) return first;
console.error("Passwords did not match, try again.\n");
}
}
export async function usersAddCommand(
username: string,
opts: UsersOptions,
): Promise<void> {
const password = await promptNewPassword();
const passwordHash = await hashPassword(password);
if (opts.inline) {
printSnippet("Add this under `auth.users:` in your config file:", {
username,
passwordHash,
});
return;
}
const configPath = resolveConfigPath(opts.config);
const envPath = path.join(path.dirname(configPath), ".env");
const varName = `TRIGGERSHELL_USER_${slug(username)}_PASSWORD_HASH`;
upsertEnvVar(envPath, varName, passwordHash);
console.log(`Stored ${varName} in ${envPath}`);
printSnippet("Add this under `auth.users:` in your config file:", {
username,
passwordHash: `\${${varName}}`,
});
}
export async function usersAddTokenCommand(
name: string,
opts: UsersOptions,
): Promise<void> {
const token = crypto.randomBytes(32).toString("hex");
const tokenHash = `sha256:${crypto.createHash("sha256").update(token).digest("hex")}`;
console.log("\nSave this token now - it will not be shown again:");
console.log(` ${token}`);
console.log(`Use it as: Authorization: Bearer ${token}`);
if (opts.inline) {
printSnippet("Add this under `auth.tokens:` in your config file:", {
name,
tokenHash,
});
return;
}
const configPath = resolveConfigPath(opts.config);
const envPath = path.join(path.dirname(configPath), ".env");
const varName = `TRIGGERSHELL_TOKEN_${slug(name)}_HASH`;
upsertEnvVar(envPath, varName, tokenHash);
console.log(`Stored ${varName} in ${envPath}`);
printSnippet("Add this under `auth.tokens:` in your config file:", {
name,
tokenHash: `\${${varName}}`,
});
}
+28
View File
@@ -0,0 +1,28 @@
import path from "node:path";
import { ConfigError, loadConfig } from "../../lib/config/load";
import { loadDotenv } from "../lib/env-file";
import { resolveConfigPath } from "../lib/paths";
export interface ValidateOptions {
config?: string;
}
export async function validateCommand(opts: ValidateOptions): Promise<void> {
const configPath = resolveConfigPath(opts.config);
loadDotenv(path.join(path.dirname(configPath), ".env"));
try {
const { config } = loadConfig(configPath);
console.log(`OK - ${configPath}`);
console.log(` ${config.scripts.length} script(s) configured`);
console.log(` auth.enabled: ${config.auth.enabled}`);
} 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;
}
}
+206
View File
@@ -0,0 +1,206 @@
import { Command } from "commander";
import { doctorCommand } from "./commands/doctor";
import { initCommand } from "./commands/init";
import { runCommand } from "./commands/run";
import { scriptsListCommand, scriptsShowCommand } from "./commands/scripts";
import {
serviceInstallCommand,
serviceLogsCommand,
serviceStatusCommand,
serviceUninstallCommand,
} from "./commands/service";
import { startCommand } from "./commands/start";
import { usersAddCommand, usersAddTokenCommand } from "./commands/users";
import { validateCommand } from "./commands/validate";
import { getVersion } from "./version";
function collect(value: string, previous: string[]): string[] {
return [...previous, value];
}
const program = new Command("triggershell")
.version(getVersion())
.description(
"Launch the TriggerShell web app: run your configured shell scripts from a browser.",
);
program
.command("init [path]")
.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("--no-auth", "Disable built-in login for the web app.")
.option("--force", "Overwrite an existing config file.", false)
.action(initCommand);
program
.command("validate")
.description("Validate a config file against the full schema.")
.option("-c, --config <path>", "Path to the config file.")
.action(validateCommand);
program
.command("start")
.description("Run the web app in production mode.")
.option("-c, --config <path>", "Path to the config file.")
.option("--port <port>", "Override the port from the config file.", (v) =>
Number(v),
)
.option("--host <host>", "Override the host from the config file.")
.option("--no-browser", "Don't open a browser automatically.")
.action(startCommand);
program
.command("doctor")
.description("Print diagnostic info about your environment and config.")
.option("-c, --config <path>", "Path to the config file.")
.action(doctorCommand);
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
.command("add <username>")
.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(
"--inline",
"Print the raw hash to paste into the config instead of storing it in .env.",
false,
)
.action(usersAddCommand);
users
.command("add-token <name>")
.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(
"--inline",
"Print the raw hash to paste into the config instead of storing it in .env.",
false,
)
.action(usersAddTokenCommand);
const service = program
.command("service")
.description("Manage the systemd service (Linux only).");
service
.command("install")
.description("Install a systemd unit that runs `triggershell start`.")
.option("-c, --config <path>", "Path to the config file.")
.option("--port <port>", "Override the port from the config file.", (v) =>
Number(v),
)
.option("--host <host>", "Override the host from the config file.")
.option(
"--system",
"Install a system-wide unit instead of a per-user one.",
false,
)
.action(serviceInstallCommand);
service
.command("uninstall")
.description("Stop, disable, and remove the systemd unit.")
.option(
"--system",
"Target the system-wide unit instead of the per-user one.",
false,
)
.action(serviceUninstallCommand);
service
.command("status")
.description("Show the systemd unit's status.")
.option(
"--system",
"Target the system-wide unit instead of the per-user one.",
false,
)
.action(serviceStatusCommand);
service
.command("logs")
.description("Tail the systemd unit's logs (journalctl).")
.option("-n, --lines <n>", "Number of recent log lines to show.", (v) =>
Number(v),
)
.option(
"--no-follow",
"Print recent logs and exit instead of tailing continuously.",
)
.option(
"--system",
"Target the system-wide unit instead of the per-user one.",
false,
)
.action(serviceLogsCommand);
if (process.argv.length <= 2) {
program.outputHelp();
process.exit(1);
}
await program.parseAsync(process.argv);
+53
View File
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { test } from "node:test";
import { loadDotenv, upsertEnvVar } from "./env-file";
function tmpEnvPath(): string {
return path.join(
fs.mkdtempSync(path.join(os.tmpdir(), "triggershell-env-")),
".env",
);
}
test("upsertEnvVar appends a new key", () => {
const envPath = tmpEnvPath();
upsertEnvVar(envPath, "FOO", "bar");
assert.equal(fs.readFileSync(envPath, "utf-8"), "FOO=bar\n");
});
test("upsertEnvVar replaces an existing key without duplicating the line", () => {
const envPath = tmpEnvPath();
upsertEnvVar(envPath, "FOO", "first");
upsertEnvVar(envPath, "FOO", "second");
const lines = fs.readFileSync(envPath, "utf-8").trim().split("\n");
assert.equal(lines.length, 1);
assert.equal(lines[0], "FOO=second");
});
test("upsertEnvVar preserves other existing keys", () => {
const envPath = tmpEnvPath();
upsertEnvVar(envPath, "FOO", "1");
upsertEnvVar(envPath, "BAR", "2");
const content = fs.readFileSync(envPath, "utf-8");
assert.match(content, /FOO=1/);
assert.match(content, /BAR=2/);
});
test("loadDotenv sets process.env without overriding an already-set var", () => {
const envPath = tmpEnvPath();
upsertEnvVar(envPath, "TRIGGERSHELL_TEST_ALREADY_SET", "from-file");
upsertEnvVar(envPath, "TRIGGERSHELL_TEST_NEW", "from-file");
process.env.TRIGGERSHELL_TEST_ALREADY_SET = "from-shell";
delete process.env.TRIGGERSHELL_TEST_NEW;
loadDotenv(envPath);
assert.equal(process.env.TRIGGERSHELL_TEST_ALREADY_SET, "from-shell");
assert.equal(process.env.TRIGGERSHELL_TEST_NEW, "from-file");
delete process.env.TRIGGERSHELL_TEST_ALREADY_SET;
delete process.env.TRIGGERSHELL_TEST_NEW;
});
+49
View File
@@ -0,0 +1,49 @@
import fs from "node:fs";
interface EnvEntry {
key: string;
value: string;
}
function parseEnvLines(content: string): EnvEntry[] {
const entries: EnvEntry[] = [];
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq === -1) continue;
entries.push({ key: trimmed.slice(0, eq), value: trimmed.slice(eq + 1) });
}
return entries;
}
/** Loads a `.env` file into `process.env`, without overriding vars already set. */
export function loadDotenv(envPath: string): void {
if (!fs.existsSync(envPath)) return;
for (const { key, value } of parseEnvLines(
fs.readFileSync(envPath, "utf-8"),
)) {
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. */
export function upsertEnvVar(
envPath: string,
key: string,
value: string,
): void {
const lines = fs.existsSync(envPath)
? fs.readFileSync(envPath, "utf-8").split("\n")
: [];
const prefix = `${key}=`;
const index = lines.findIndex((line) => line.startsWith(prefix));
const newLine = `${key}=${value}`;
if (index >= 0) {
lines[index] = newLine;
} else {
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
lines.push(newLine);
}
fs.writeFileSync(envPath, lines.join("\n") + "\n");
}
+69
View File
@@ -0,0 +1,69 @@
import { spawn } from "node:child_process";
import net from "node:net";
export function isPortFree(host: string, port: number): Promise<boolean> {
return new Promise((resolve) => {
const socket = net.connect({ host, port, timeout: 500 });
socket.once("connect", () => {
socket.destroy();
resolve(false);
});
socket.once("timeout", () => {
socket.destroy();
resolve(true);
});
socket.once("error", () => {
resolve(true);
});
});
}
export async function waitUntilReady(
url: string,
timeoutMs: number,
intervalMs = 400,
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const response = await fetch(url, { signal: AbortSignal.timeout(1500) });
if (response.status === 200) return true;
} catch {
// not ready yet
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
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 {
const command =
process.platform === "darwin"
? "open"
: process.platform === "win32"
? "start"
: "xdg-open";
const args = process.platform === "win32" ? ["", url] : [url];
try {
spawn(command, args, {
detached: true,
stdio: "ignore",
shell: process.platform === "win32",
}).unref();
} catch {
// best-effort - not fatal if no browser opener is available
}
}
+28
View File
@@ -0,0 +1,28 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const DEFAULT_CONFIG_NAME = "triggershell.yml";
/** Root of the installed `triggershell` package - one level up from `src/cli/lib`. */
export function resolveAppRoot(): string {
return path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"..",
"..",
);
}
export function resolveConfigPath(configArg?: string): string {
return path.resolve(process.cwd(), configArg ?? DEFAULT_CONFIG_NAME);
}
/**
* Absolute path to the script that was actually invoked (`node <this>`), with any symlink
* (as created by a global npm/pnpm install or `npm link`) resolved away. Used to build a
* `systemd` `ExecStart` line that keeps working regardless of how the CLI was installed.
*/
export function resolveInvokedBinPath(): string {
return fs.realpathSync(process.argv[1]);
}
+9
View File
@@ -0,0 +1,9 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { slug } from "./slug";
test("slug", () => {
assert.equal(slug("ci-bot"), "CI_BOT");
assert.equal(slug("Admin User"), "ADMIN_USER");
assert.equal(slug("__weird--name__"), "WEIRD_NAME");
});
+6
View File
@@ -0,0 +1,6 @@
export function slug(value: string): string {
return value
.replace(/[^A-Za-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.toUpperCase();
}
+39
View File
@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { renderUnit } from "./systemd";
test("renderUnit builds an absolute-path ExecStart with the given args", () => {
const unit = renderUnit({
execPath: "/usr/bin/node",
binPath:
"/home/user/.local/share/pnpm/global/5/node_modules/.bin/triggershell",
configPath: "/home/user/project/triggershell.yml",
configDir: "/home/user/project",
port: 8080,
host: "0.0.0.0",
scope: "user",
});
assert.match(
unit,
/ExecStart=\/usr\/bin\/node .*triggershell start --config \/home\/user\/project\/triggershell\.yml --no-browser --port 8080 --host 0\.0\.0\.0/,
);
assert.match(unit, /WorkingDirectory=\/home\/user\/project/);
assert.match(unit, /WantedBy=default\.target/);
});
test("renderUnit uses multi-user.target for the system scope", () => {
const unit = renderUnit({
execPath: "/usr/bin/node",
binPath: "/usr/lib/node_modules/triggershell/bin/triggershell.js",
configPath: "/etc/triggershell/triggershell.yml",
configDir: "/etc/triggershell",
scope: "system",
});
assert.match(unit, /WantedBy=multi-user\.target/);
assert.match(
unit,
/ExecStart=\/usr\/bin\/node .*start --config .*--no-browser$/m,
);
});
+61
View File
@@ -0,0 +1,61 @@
import os from "node:os";
import path from "node:path";
export const SERVICE_NAME = "triggershell";
export interface UnitOptions {
execPath: string;
binPath: string;
configPath: string;
configDir: string;
port?: number;
host?: string;
scope: "user" | "system";
}
export function renderUnit(opts: UnitOptions): string {
const args = ["start", "--config", opts.configPath, "--no-browser"];
if (opts.port !== undefined) args.push("--port", String(opts.port));
if (opts.host !== undefined) args.push("--host", opts.host);
const execStart = [opts.execPath, opts.binPath, ...args]
.map((part) => (part.includes(" ") ? `"${part}"` : part))
.join(" ");
const wantedBy =
opts.scope === "user" ? "default.target" : "multi-user.target";
return `[Unit]
Description=TriggerShell - self-hosted script runner
After=network.target
[Service]
Type=simple
ExecStart=${execStart}
WorkingDirectory=${opts.configDir}
Restart=on-failure
RestartSec=2
Environment=NODE_ENV=production
[Install]
WantedBy=${wantedBy}
`;
}
export function userUnitPath(): string {
return path.join(
os.homedir(),
".config",
"systemd",
"user",
`${SERVICE_NAME}.service`,
);
}
export function systemUnitPath(): string {
return path.join("/etc", "systemd", "system", `${SERVICE_NAME}.service`);
}
export function isRoot(): boolean {
return process.getuid?.() === 0;
}
+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;
}
+11
View File
@@ -0,0 +1,11 @@
import fs from "node:fs";
import path from "node:path";
import { resolveAppRoot } from "./lib/paths";
export function getVersion(): string {
const pkgPath = path.join(resolveAppRoot(), "package.json");
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as {
version: string;
};
return pkg.version;
}
+39
View File
@@ -0,0 +1,39 @@
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
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 }) {
return (
<div
className={cn(
"prose prose-neutral dark:prose-invert max-w-none",
"prose-pre:bg-muted prose-pre:text-foreground",
// Typography's default inline `code` style is just bold text wrapped in decorative
// backtick characters - swap that for the same muted pill used for inline code
// 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: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]} components={{ table: Table }}>
{content}
</ReactMarkdown>
</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>
);
}
@@ -10,30 +10,28 @@ import { Button } from "@/components/ui/button";
import { Form } from "@/components/ui/form";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { buildVariableSchemaFromList } from "@/lib/validation/variable-schema";
import { defaultValuesForScript } from "@/lib/config/defaults";
import type { ClientScript } from "@/lib/config/serialize";
import { FieldRenderer } from "./field-renderer";
function emptyValueFor(variable: ClientScript["variables"][number]): unknown {
if (variable.type === "boolean") return false;
if (variable.type === "multiselect") return [];
return "";
}
/** Controls whose content doesn't shrink well into a narrow grid column - long-form text,
* or a group of checkboxes that reads better as a single wide list - so they span the full
* grid width instead of sharing a row with other fields. */
const WIDE_CONTROLS = new Set(["textarea", "checkboxGroup"]);
/** `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
* 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(
script: ClientScript,
initialValues?: 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) {
const fromPreviousRun =
initialValues && !variable.secret
? initialValues[variable.name]
: undefined;
values[variable.name] =
fromPreviousRun ?? variable.default ?? emptyValueFor(variable);
if (variable.secret) continue;
const fromPreviousRun = initialValues[variable.name];
if (fromPreviousRun !== undefined) values[variable.name] = fromPreviousRun;
}
return values;
}
@@ -100,9 +98,22 @@ export function DynamicForm({
This script takes no parameters.
</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) => (
<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
type="submit"
disabled={form.formState.isSubmitting}
@@ -24,6 +24,7 @@ import {
import { Slider } from "@/components/ui/slider";
import { Label } from "@/components/ui/label";
import { MultiSelect } from "./controls/multi-select";
import { Combobox } from "./controls/combobox";
import type { ClientVariable } from "@/lib/config/serialize";
export function FieldRenderer({ variable }: { variable: ClientVariable }) {
@@ -211,6 +212,29 @@ export function FieldRenderer({ variable }: { variable: ClientVariable }) {
</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":
return (
<FormItem>
+26
View File
@@ -0,0 +1,26 @@
import Link from "next/link";
const PROJECT_URL = "https://dev.pivoine.art/valknar/triggershell";
export function Footer() {
return (
<footer className="border-t">
<div className="text-muted-foreground mx-auto flex w-full max-w-5xl flex-col items-center gap-2 px-4 py-4 text-xs sm:flex-row sm:justify-between">
<span>© {new Date().getFullYear()} TriggerShell</span>
<div className="flex items-center gap-4">
<Link href="/docs" className="hover:text-foreground">
Docs
</Link>
<a
href={PROJECT_URL}
target="_blank"
rel="noreferrer"
className="hover:text-foreground"
>
Project
</a>
</div>
</div>
</footer>
);
}
@@ -34,9 +34,9 @@ export function Nav({
<div className="flex min-w-0 items-center gap-3 sm:gap-6">
<Link
href="/"
className="flex shrink-0 items-center gap-2 font-semibold tracking-tight"
className="font-heading flex shrink-0 items-center gap-2 text-[1.05rem] font-semibold tracking-tight"
>
<Terminal className="size-5" />
<Terminal className="text-primary size-5" />
TriggerShell
</Link>
<nav className="flex items-center gap-1">
@@ -45,8 +45,8 @@ export function Nav({
key={href}
href={href}
className={cn(
"text-muted-foreground hover:text-foreground flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium transition-colors sm:px-3",
pathname === href && "bg-muted text-foreground",
"text-muted-foreground hover:text-foreground hover:bg-muted/60 flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium transition-colors sm:px-3",
pathname === href && "bg-primary/10 text-foreground",
)}
>
<Icon className="size-4" />
@@ -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>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { Badge } from "@/components/ui/badge";
import type { RunStatus } from "@/lib/db/schema";
import { cn } from "@/lib/utils";
const styles: Record<RunStatus, string> = {
queued: "bg-status-queued/12 text-status-queued",
running: "bg-status-running/15 text-status-running",
succeeded: "bg-status-succeeded/12 text-status-succeeded",
failed: "bg-status-failed/12 text-status-failed",
cancelled: "bg-status-cancelled/12 text-status-cancelled",
timed_out: "bg-status-warn/12 text-status-warn",
interrupted: "bg-status-warn/12 text-status-warn",
};
export const runStatusLabels: Record<RunStatus, string> = {
queued: "Queued",
running: "Running",
succeeded: "Succeeded",
failed: "Failed",
cancelled: "Cancelled",
timed_out: "Timed out",
interrupted: "Interrupted",
};
export function RunStatusBadge({ status }: { status: RunStatus }) {
return (
<Badge
className={cn(
"gap-1.5 border-transparent font-mono text-[0.7rem] font-medium tracking-wide uppercase",
styles[status],
)}
variant="outline"
>
<span
className={cn(
"size-1.5 shrink-0 rounded-full bg-current",
status === "running" && "animate-pulse",
)}
/>
{runStatusLabels[status]}
</Badge>
);
}
@@ -74,13 +74,13 @@ export function RunTerminal({
}
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="border-border overflow-hidden rounded-lg border">
<div className="bg-muted/40 flex items-center justify-between gap-3 border-b px-3 py-2">
<div className="flex items-center gap-3">
<RunStatusBadge status={status} />
{exitCode !== null && (
<span className="text-muted-foreground text-xs">
exit code {exitCode}
<span className="text-muted-foreground font-mono text-xs">
exit {exitCode}
</span>
)}
</div>
@@ -10,9 +10,9 @@ export interface XtermViewHandle {
}
const theme = {
background: "#09090b",
foreground: "#f4f4f5",
cursor: "#f4f4f5",
background: "#101215",
foreground: "#edeef0",
cursor: "#e9a857",
selectionBackground: "#3f3f46",
black: "#18181b",
red: "#f87171",
@@ -95,7 +95,7 @@ export const XtermView = forwardRef<XtermViewHandle, XtermViewProps>(
return (
<div
ref={containerRef}
className="h-[60vh] overflow-hidden rounded-lg bg-zinc-950 p-2"
className="aspect-video w-full overflow-hidden bg-[#101215] p-2"
/>
);
},

Some files were not shown because too many files have changed in this diff Show More