36 Commits
Author SHA1 Message Date
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
valknarandClaude Sonnet 5 d7c0be6b22 Add a Re-run button to the run detail page
Re-run takes you to the script's form pre-filled with that run's
variable values, rather than re-executing immediately - this is
necessary, not just cautious: stored run.variables already have secret
fields redacted to "***" (see the run detail command/variables view),
so a true one-click re-run would either fail validation or, worse,
silently pass the literal string "***" to the script as a real secret.
Pre-filling instead lets the user review/tweak values and forces secret
fields to be re-entered, both of which are new safe defaults now that
required fields are actually enforced.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 00:32:30 +02:00
valknarandClaude Sonnet 5 75b92d103c Fix status/script filter dropdowns being cramped on mobile
SelectTrigger defaults to w-fit, and the toolbar only set a width from
sm: up, so below that breakpoint each dropdown shrank to its label text
instead of using the full row it was already stacked onto.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 00:24:18 +02:00
valknarandClaude Sonnet 5 3f656baad1 Make Duration sortable on the runs page
It was left out because duration isn't a stored column, just
started_at/ended_at math - now expressed as a SQL case expression so it
can be sorted the same way as the other columns. Still-running rows
sort by elapsed-so-far; never-started (queued) rows sort as NULL, which
puts them out of the way at whichever end matches the current
direction.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 00:20:06 +02:00
valknarandClaude Sonnet 5 3131d8ce5b Add search, filtering, sorting, and pagination to Run History
The runs page was a flat top-100 list with no way to narrow it down.
It's now driven entirely by the URL (?q=&status=&scriptId=&sort=&dir=&page=),
so filtered/sorted views are shareable and survive back/forward
navigation:

- free-text search across script name, triggered-by, and the resolved
  command
- status and script dropdown filters
- sortable Script/Status/Triggered-by/Started column headers
- offset pagination (25/page) with a real total count, clamped so an
  out-of-range page falls back to the last valid one instead of
  showing a misleading "no results"

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 00:17:49 +02:00
valknarandClaude Sonnet 5 d4e4c2c7d2 Show the full resolved command and variables on the run detail page
The command lived in a narrow truncate'd grid cell, so anything but a
short invocation was unreadable. It now gets its own scrollable code
block, plus a breakdown of each variable (using the script's configured
labels when available) and the value it resolved to for that run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 00:11:27 +02:00
valknarandClaude Sonnet 5 004586ee64 Enforce required on string/multiselect variables without explicit bounds
required:true only worked by accident: a bare z.string() or
z.array(...) accepts "" / [] just fine, so a required field with no
minLength (e.g. secret tokens like webhookToken, apiKey) could be
submitted empty and the run would start anyway. Required now implies
min(1) when no stricter bound is already configured, for both string
and multiselect fields. This is the single schema shared by the client
form resolver and the server run-creation route, so both now reject it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 00:06:39 +02:00
valknarandClaude Sonnet 5 e6008a22cd Fix live output missing for fast-finishing scripts
Scripts that finish in milliseconds (e.g. notify.js) could complete and
broadcast all their output/status before a client's WS subscribe message
even arrived, leaving the run page's terminal permanently blank with no
way to catch up. The server now answers every subscribe with whatever
log bytes were written past what the client's server-rendered page
already had, plus the run's current status, before it starts streaming
live broadcasts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 00:02:57 +02:00
valknarandClaude Sonnet 5 c3312f5dc6 Remove theme toggle from the login page
Keep it in the main app header only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 21:17:49 +02:00
153 changed files with 4421 additions and 1648 deletions
+41
View File
@@ -0,0 +1,41 @@
name: Release
on:
push:
tags:
- "v*.*.*"
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/pnpm/action-setup@v4
with:
version: 11.21.0
- uses: https://github.com/actions/setup-node@v4
with:
node-version: 22
- run: pnpm install --frozen-lockfile
- run: pnpm run lint
- run: pnpm run typecheck
- run: pnpm run format:check
- run: pnpm run test
- name: Set package version from the tag
run: npm pkg set version="${GITHUB_REF_NAME#v}"
# Scoped to this one registry host+path (via publishConfig.registry in package.json) rather
# than actions/setup-node's registry-url, which would set it as the *default* registry for
# every install - breaking `pnpm install` for this project's own (unscoped, public) deps.
- name: Configure registry auth for publish
run: pnpm config set "//dev.pivoine.art/api/packages/valknar/npm/:_authToken" "$PACKAGE_TOKEN"
env:
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
- name: Publish to Gitea npm registry
run: pnpm publish --no-git-checks
+35 -12
View File
@@ -1,16 +1,38 @@
# Python # dependencies
__pycache__/ /node_modules
*.py[cod] /.pnpm-store
*.egg-info/ /.pnp
.eggs/ .pnp.*
build/ .yarn/*
dist/ !.yarn/patches
.venv/ !.yarn/plugins
venv/ !.yarn/releases
.pytest_cache/ !.yarn/versions
.ruff_cache/
# 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/ .triggershell/
# Secrets loaded by `triggershell` via ${VAR} interpolation - never commit these # Secrets loaded by `triggershell` via ${VAR} interpolation - never commit these
@@ -23,5 +45,6 @@ venv/
# Editors / OS # Editors / OS
.DS_Store .DS_Store
*.pem
.idea/ .idea/
.vscode/ .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
+52 -21
View File
@@ -24,20 +24,22 @@ API for automation.
## Quickstart ## Quickstart
```bash ```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 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 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), Edit `triggershell.yml` to add your own scripts (see [Configuration](#configuration) below), then
then run `triggershell start` for a production build. 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 ## Requirements
- Python >= 3.9 - Node.js >= 20 — the only thing you need installed. Everything else `triggershell` needs ships
- Node.js >= 20 (checked by the CLI; not auto-installed) inside the package itself and is resolved automatically when you install it.
- pnpm (auto-provisioned via Corepack if missing and Corepack is available)
## Configuration ## Configuration
@@ -98,14 +100,38 @@ the script — always as a discrete argv element or env var, never interpolated
## CLI Usage ## CLI Usage
| Command | Description | | Command | Description |
|---|---| | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `triggershell init [PATH]` | Scaffold a new config file + `.env` (`--port`, `--auth/--no-auth`, `--force`) | | `triggershell init [PATH]` | Scaffold a new config file + `.env` (`--port`, `--auth/--no-auth`, `--force`) |
| `triggershell validate [-c CONFIG]` | Validate a config file (fast Python pre-flight + full Node/Zod schema) | | `triggershell validate [-c CONFIG]` | Validate a config file against the full schema |
| `triggershell dev [-c CONFIG] [--port] [--host] [--no-browser]` | Run in development mode (hot reload) | | `triggershell start [-c CONFIG] [--port] [--host] [--no-browser]` | Run the web app |
| `triggershell start [-c CONFIG] [--port] [--host] [--no-browser] [--skip-build]` | Build (if stale) and run in production mode | | `triggershell doctor [-c CONFIG]` | Print environment/config diagnostics |
| `triggershell doctor` | Print environment/config diagnostics | | `triggershell scripts list [-c CONFIG]` | List configured scripts |
| `triggershell scripts show <scriptId> [-c CONFIG]` | Show a script's command and variables |
| `triggershell run <scriptId> [--var name=value...] [--token] [--local\|--remote] [--no-wait]` | Run a configured script - through an already-running server's API if one is reachable (so any open browser tab sees it live), otherwise standalone. See [Running scripts from the CLI](#running-scripts-from-the-cli). |
| `triggershell users add <username> [-c CONFIG] [--inline]` | Hash a password, store it in `.env`, and print a `${VAR}` snippet for `auth.users` (`--inline` prints the raw hash instead) | | `triggershell users add <username> [-c CONFIG] [--inline]` | Hash a password, store it in `.env`, and print a `${VAR}` snippet for `auth.users` (`--inline` prints the raw hash instead) |
| `triggershell users add-token <name> [-c CONFIG] [--inline]` | Generate an API token, store its hash in `.env`, and print a `${VAR}` snippet for `auth.tokens` (`--inline` prints the raw hash instead) | | `triggershell users add-token <name> [-c CONFIG] [--inline]` | Generate an API token, store its hash in `.env`, and print a `${VAR}` snippet for `auth.tokens` (`--inline` prints the raw hash instead) |
| `triggershell service install [--system]` | Install a systemd unit that runs `triggershell start` (per-user by default, Linux only) |
| `triggershell service uninstall [--system]` | Stop, disable, and remove the systemd unit |
| `triggershell service status [--system]` | Show the systemd unit's status |
### Running scripts from the CLI
`triggershell run <scriptId>` auto-detects whether the web app is already running (a quick
`/api/healthz` check against `server.host`/`server.port`, overridable with `--host`/`--port`):
- **Server reachable** — the run goes through the same `POST /api/scripts/:id/runs` endpoint the
web UI uses, authenticated with `--token`/`TRIGGERSHELL_API_TOKEN` if `auth.enabled`. It's a
completely normal run from the server's point of view: it shows up in Run History, and any
browser tab open on `/runs/:id` streams its output live, exactly as if it had been started from
the UI.
- **No server reachable** — `run` executes the script itself, in its own process, using the same
runner the web app uses. The run and its log are still persisted, just without a browser to watch it.
Force one or the other with `--local`/`--remote` (the latter fails instead of falling back if
nothing's reachable). Pass variables with repeated `--var name=value` flags (repeat the same name
for a `multiselect` variable); `--no-wait` prints the run ID and returns immediately instead of
streaming output and blocking until it finishes. Exit code is `0` for a succeeded run, `1`
otherwise. `Ctrl-C` while waiting cancels the run, the same as the UI's Cancel button.
## Web App Guide ## Web App Guide
@@ -129,7 +155,7 @@ Passwords are hashed with argon2id; only the hash ever lives in the config file.
Full reference with request/response shapes and curl examples: [`docs/API.md`](docs/API.md). Full reference with request/response shapes and curl examples: [`docs/API.md`](docs/API.md).
| Method | Path | Notes | | Method | Path | Notes |
|---|---|---| | ------ | ----------------------------- | ------------------------------------------------------ |
| GET | `/api/healthz` | Unauthenticated readiness probe | | GET | `/api/healthz` | Unauthenticated readiness probe |
| POST | `/api/auth/login` | `{username, password}` → sets session cookie | | POST | `/api/auth/login` | `{username, password}` → sets session cookie |
| POST | `/api/auth/logout` | Clears the session | | POST | `/api/auth/logout` | Clears the session |
@@ -145,19 +171,24 @@ Full reference with request/response shapes and curl examples: [`docs/API.md`](d
## Development ## 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 ```bash
pnpm --dir app install pnpm install
pnpm --dir app dev # or: triggershell dev, which wraps this pnpm dev # tsx watch server.ts - reads TRIGGERSHELL_CONFIG_PATH from the environment
pnpm --dir app lint pnpm lint
pnpm --dir app typecheck pnpm typecheck
pnpm --dir app db:studio # browse the SQLite DB 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 examples/ A runnable example config + scripts
docs/ Config/architecture/API reference docs docs/ Config/architecture/API reference docs
``` ```
-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);
}
-89
View File
@@ -1,89 +0,0 @@
export const dynamic = "force-dynamic";
import fs from "node:fs";
import type { Metadata } from "next";
import { eq } from "drizzle-orm";
import { notFound } from "next/navigation";
import { getDb } from "@/lib/db/client";
import { runs } from "@/lib/db/schema";
import { RunTerminal } from "@/components/runs/run-terminal";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
const MAX_INITIAL_LOG_BYTES = 200_000;
interface RunDetailPageProps {
params: Promise<{ runId: string }>;
}
export async function generateMetadata({
params,
}: RunDetailPageProps): Promise<Metadata> {
const { runId } = await params;
const run = getDb()
.select({ scriptName: runs.scriptName })
.from(runs)
.where(eq(runs.id, runId))
.get();
return { title: run ? `${run.scriptName} run` : "Run not found" };
}
function readTail(logFilePath: string): string {
if (!fs.existsSync(logFilePath)) return "";
const { size } = fs.statSync(logFilePath);
const start = Math.max(0, size - MAX_INITIAL_LOG_BYTES);
const fd = fs.openSync(logFilePath, "r");
try {
const buffer = Buffer.alloc(size - start);
fs.readSync(fd, buffer, 0, buffer.length, start);
return (start > 0 ? "... (truncated)\n" : "") + buffer.toString("utf-8");
} finally {
fs.closeSync(fd);
}
}
export default async function RunDetailPage({ params }: RunDetailPageProps) {
const { runId } = await params;
const db = getDb();
const run = db.select().from(runs).where(eq(runs.id, runId)).get();
if (!run) notFound();
const initialLog = readTail(run.logFilePath);
return (
<div className="mx-auto flex max-w-3xl flex-col gap-4">
<Card>
<CardHeader>
<CardTitle>{run.scriptName}</CardTitle>
<dl className="text-muted-foreground grid grid-cols-2 gap-x-4 gap-y-1 text-xs sm:grid-cols-4">
<div>
<dt className="font-medium">Triggered by</dt>
<dd>{run.triggeredBy}</dd>
</div>
<div>
<dt className="font-medium">Started</dt>
<dd>
{run.startedAt ? new Date(run.startedAt).toLocaleString() : "-"}
</dd>
</div>
<div>
<dt className="font-medium">Command</dt>
<dd className="truncate font-mono">{run.resolvedCommand}</dd>
</div>
<div>
<dt className="font-medium">Run ID</dt>
<dd className="truncate font-mono">{run.id}</dd>
</div>
</dl>
</CardHeader>
<CardContent>
<RunTerminal
runId={run.id}
initialStatus={run.status}
initialLog={initialLog}
initialExitCode={run.exitCode}
/>
</CardContent>
</Card>
</div>
);
}
-91
View File
@@ -1,91 +0,0 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { desc } from "drizzle-orm";
import { getDb } from "@/lib/db/client";
import { runs } from "@/lib/db/schema";
import { RunStatusBadge } from "@/components/runs/run-status-badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export const metadata: Metadata = { title: "Run History" };
function formatDuration(startedAt: Date | null, endedAt: Date | null): string {
if (!startedAt) return "-";
const end = endedAt ?? new Date();
const seconds = Math.max(
0,
Math.round((end.getTime() - startedAt.getTime()) / 1000),
);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
return `${minutes}m ${seconds % 60}s`;
}
export default function RunsPage() {
const db = getDb();
const rows = db
.select()
.from(runs)
.orderBy(desc(runs.createdAt))
.limit(100)
.all();
return (
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-semibold tracking-tight">Run History</h1>
{rows.length === 0 ? (
<p className="text-muted-foreground py-12 text-center">No runs yet.</p>
) : (
<div className="overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Script</TableHead>
<TableHead>Status</TableHead>
<TableHead>Triggered by</TableHead>
<TableHead>Started</TableHead>
<TableHead>Duration</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((run) => (
<TableRow key={run.id} className="cursor-pointer">
<TableCell>
<Link
href={`/runs/${run.id}`}
className="font-medium hover:underline"
>
{run.scriptName}
</Link>
</TableCell>
<TableCell>
<RunStatusBadge status={run.status} />
</TableCell>
<TableCell className="text-muted-foreground">
{run.triggeredBy}
</TableCell>
<TableCell className="text-muted-foreground">
{run.startedAt
? new Date(run.startedAt).toLocaleString()
: "-"}
</TableCell>
<TableCell className="text-muted-foreground">
{formatDuration(run.startedAt, run.endedAt)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
);
}
-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",
};
const labels: 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" />
)}
{labels[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>` `/api/auth/session` requires either a valid session cookie or an `Authorization: Bearer <token>`
header using a token from `triggershell users add-token`. header using a token from `triggershell users add-token`.
`triggershell run <scriptId>` is a first-party client of this exact REST + WS contract (see
[Running scripts from the CLI](../README.md#running-scripts-from-the-cli)) - nothing below is
CLI-specific.
## Auth ## Auth
### `POST /api/auth/login` ### `POST /api/auth/login`
@@ -73,7 +77,7 @@ code). `404` if not found.
### `POST /api/runs/:runId/cancel` ### `POST /api/runs/:runId/cancel`
`202 {"status": "cancelling"}`. `409` if the run already finished, or if it isn't tracked by *this* `202 {"status": "cancelling"}`. `409` if the run already finished, or if it isn't tracked by _this_
server process (e.g. after a restart — see "orphaned runs" in `docs/ARCHITECTURE.md`). server process (e.g. after a restart — see "orphaned runs" in `docs/ARCHITECTURE.md`).
### `GET /api/runs/:runId/logs` ### `GET /api/runs/:runId/logs`
+60 -17
View File
@@ -1,13 +1,14 @@
# Architecture # Architecture
``` ```
triggershell (Python CLI) app/ (Next.js, all server logic) triggershell (Node CLI, src/cli) server.ts + src/ (Next.js, all server logic)
─────────────────────── ────────────────────────────── ─────────────────────────────── ─────────────────────────────────────────
triggershell dev|start server.ts (custom Node server) triggershell start server.ts (custom Node server)
1. resolve + pre-flight the config ├─ Next.js request handler (pages, API routes) 1. resolve + validate the config (loadConfig) ├─ Next.js request handler (pages, API routes)
2. check node/pnpm, `pnpm install` if stale ├─ ws.WebSocketServer on /ws/runs 2. check the port is free ├─ ws.WebSocketServer on /ws/runs
3. set TRIGGERSHELL_CONFIG_PATH/PORT/HOST env └─ boot: migrate DB, sync auth, reconcile runs 3. set TRIGGERSHELL_CONFIG_PATH/PORT/HOST/ └─ boot: migrate DB, sync auth, reconcile runs
4. spawn `pnpm run dev|start`, forward signals NODE_ENV
4. import("./server.ts") — same process │
5. poll /api/healthz, open browser │ 5. poll /api/healthz, open browser │
┌──────────┴──────────┐ ┌──────────┴──────────┐
REST API WebSocket REST API WebSocket
@@ -23,22 +24,64 @@
## Why a custom Node server ## 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 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 `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`). `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) `triggershell start` (`src/cli/commands/start.ts`) doesn't spawn `server.ts` as a child process —
is naturally a Node problem — `execa` for argv-safe spawning, `ws` for streaming, Next for the UI. it sets `process.env` (`TRIGGERSHELL_CONFIG_PATH`, `PORT`, `HOST`, `NODE_ENV`) and then dynamically
Python's job is just: get Node/pnpm ready, validate the config fast, and manage the child process's `import()`s `server.ts` directly, in the same Node process. `server.ts` reads that env and installs
lifecycle (signals, readiness, browser launch) — a CLI concern, not a web-server concern. 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` are thin
wrappers around `systemctl`; log tailing is just `journalctl --user -u triggershell -f` — not
reimplemented.
## Cross-module-graph state ## Cross-module-graph state
Next compiles Route Handlers and Server Components through its own build/module graph, which is a Next compiles Route Handlers and Server Components through its own build/module graph, which is a
*separate* module instantiation from whatever `server.ts` imports directly via `tsx` at startup — _separate_ module instantiation from whatever `server.ts` imports directly via `tsx` at startup —
even though both run in the same OS process. A plain module-level singleton (e.g. `new Map()` at even though both run in the same OS process. A plain module-level singleton (e.g. `new Map()` at
the top of a file) ends up duplicated, one copy per graph, which silently breaks anything that the top of a file) ends up duplicated, one copy per graph, which silently breaks anything that
needs to be shared across that boundary (the WebSocket subscriber registry, the live-run-handle needs to be shared across that boundary (the WebSocket subscriber registry, the live-run-handle
@@ -67,10 +110,10 @@ queued → running → succeeded | failed | cancelled | timed_out
## Auth ## Auth
- Session: `iron-session` — a stateless, encrypted+signed cookie (no session-store table). - Session: `iron-session` — a stateless, encrypted+signed cookie (no session-store table).
- Config is the source of truth for *who* is allowed in; `src/lib/auth/sync.ts` upserts config - Config is the source of truth for _who_ is allowed in; `src/lib/auth/sync.ts` upserts config
users/tokens into SQLite on boot, giving a single DB-backed check path plus `lastLoginAt` tracking. users/tokens into SQLite on boot, giving a single DB-backed check path plus `lastLoginAt` tracking.
- `src/proxy.ts` (Next's Proxy, formerly "Middleware") does a fast, cookie-only redirect for - `src/proxy.ts` (Next's Proxy, formerly "Middleware") does a fast, cookie-only redirect for
unauthenticated page/API requests — explicitly *not* the real security boundary. Every Route unauthenticated page/API requests — explicitly _not_ the real security boundary. Every Route
Handler also calls `requireAuth()` itself; this is the actual auth check. Handler also calls `requireAuth()` itself; this is the actual auth check.
- The WS `upgrade` handler is outside Next's request pipeline entirely, so it authenticates by hand - The WS `upgrade` handler is outside Next's request pipeline entirely, so it authenticates by hand
(parsing the cookie header, or a `?token=` query param) via `authenticateUpgrade()`. (parsing the cookie header, or a `?token=` query param) via `authenticateUpgrade()`.
@@ -81,4 +124,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 `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 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 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 secrets referenced via `${VAR}` don't have to be committed alongside the config. `triggershell
init` scaffolds both files together. init` scaffolds both files together.
The canonical schema is the Zod schema at `app/src/lib/config/schema.ts` — this document mirrors The canonical schema is the Zod schema at `src/lib/config/schema.ts` — this document mirrors
it. `triggershell validate` runs the Python pre-flight checks below, then that full schema. it. `triggershell validate` loads and validates the config directly against the schema below (no
separate pre-flight step).
## `server` ## `server`
| Field | Type | Default | Notes | | Field | Type | Default | Notes |
|---|---|---|---| | ---------- | ------ | ----------- | ----------------------- |
| `host` | string | `127.0.0.1` | Bind address | | `host` | string | `127.0.0.1` | Bind address |
| `port` | number | `4173` | 1-65535 | | `port` | number | `4173` | 1-65535 |
| `basePath` | string | `""` | Reserved for future use | | `basePath` | string | `""` | Reserved for future use |
@@ -24,7 +25,7 @@ it. `triggershell validate` runs the Python pre-flight checks below, then that f
## `auth` ## `auth`
| Field | Type | Default | Notes | | Field | Type | Default | Notes |
|---|---|---|---| | ----------------- | ------- | ------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `enabled` | boolean | `true` | `false` disables login entirely | | `enabled` | boolean | `true` | `false` disables login entirely |
| `sessionSecret` | string | — | Required, >= 32 chars, if `enabled`. Reference it via `${TRIGGERSHELL_SESSION_SECRET}` and set the real value in `.env`, not here | | `sessionSecret` | string | — | Required, >= 32 chars, if `enabled`. Reference it via `${TRIGGERSHELL_SESSION_SECRET}` and set the real value in `.env`, not here |
| `sessionTtlHours` | number | `12` | Session cookie lifetime | | `sessionTtlHours` | number | `12` | Session cookie lifetime |
@@ -42,20 +43,20 @@ pass `--inline` to those commands to get the raw hash printed for pasting into t
## `database` ## `database`
| Field | Type | Default | | Field | Type | Default |
|---|---|---| | ------ | ------ | ------------------------------- |
| `path` | string | `.triggershell/triggershell.db` | | `path` | string | `.triggershell/triggershell.db` |
## `logs` ## `logs`
| Field | Type | Default | Notes | | Field | Type | Default | Notes |
|---|---|---|---| | --------------- | ------ | -------------------- | ----------------------------------------------------------- |
| `dir` | string | `.triggershell/logs` | One `<runId>.log` file per run | | `dir` | string | `.triggershell/logs` | One `<runId>.log` file per run |
| `retentionDays` | number | `30` | Not yet enforced automatically — prune manually or via cron | | `retentionDays` | number | `30` | Not yet enforced automatically — prune manually or via cron |
## `scripts[]` ## `scripts[]`
| Field | Type | Default | Notes | | Field | Type | Default | Notes |
|---|---|---|---| | ---------------- | -------- | ------- | ------------------------------------------------------------------------------------ |
| `id` | string | — | Required, unique, `[a-zA-Z0-9][a-zA-Z0-9_-]*` | | `id` | string | — | Required, unique, `[a-zA-Z0-9][a-zA-Z0-9_-]*` |
| `name` | string | — | Required, display name | | `name` | string | — | Required, display name |
| `description` | string | — | Optional | | `description` | string | — | Optional |
@@ -71,7 +72,7 @@ pass `--inline` to those commands to get the raw hash printed for pasting into t
Common fields on every variable: Common fields on every variable:
| Field | Type | Default | Notes | | Field | Type | Default | Notes |
|---|---|---|---| | ------------- | ----------------------------------- | ------------------ | -------------------------------------------------------------------------------------- |
| `name` | string | — | Required, unique per script | | `name` | string | — | Required, unique per script |
| `label` | string | `name` | Display label | | `label` | string | `name` | Display label |
| `description` | string | — | Shown as form help text | | `description` | string | — | Shown as form help text |
@@ -86,7 +87,7 @@ Common fields on every variable:
Type-specific fields: Type-specific fields:
| `type` | Extra fields | | `type` | Extra fields |
|---|---| | ------------- | ------------------------------------------------------------------------------------------------- |
| `string` | `default?: string`, `pattern?: string` (regex), `minLength?`, `maxLength?`, `multiline?: boolean` | | `string` | `default?: string`, `pattern?: string` (regex), `minLength?`, `maxLength?`, `multiline?: boolean` |
| `number` | `default?: number`, `min?`, `max?`, `step?` | | `number` | `default?: number`, `min?`, `max?`, `step?` |
| `boolean` | `default: boolean` (default `false`) | | `boolean` | `default: boolean` (default `false`) |
@@ -96,11 +97,11 @@ Type-specific fields:
### UI control mapping ### UI control mapping
| `type` | Default `control` | Valid overrides | | `type` | Default `control` | Valid overrides |
|---|---|---| | ------------- | ---------------------------------------- | ------------------------------------------------ |
| `string` | `text` (or `password` if `secret: true`) | `textarea` (needs `multiline: true`), `password` | | `string` | `text` (or `password` if `secret: true`) | `textarea` (needs `multiline: true`), `password` |
| `number` | `number` | `slider` (requires both `min` and `max`) | | `number` | `number` | `slider` (requires both `min` and `max`) |
| `boolean` | `checkbox` | `switch` | | `boolean` | `checkbox` | `switch` |
| `enum` | `select` | `radio` | | `enum` | `select` | `radio`, `combobox` (searchable, single-select) |
| `multiselect` | `multiselect` (combobox) | `checkboxGroup` | | `multiselect` | `multiselect` (combobox) | `checkboxGroup` |
### `passAs` semantics ### `passAs` semantics
+3 -1
View File
@@ -10,7 +10,9 @@ const targetDir = getArg("--dir");
const olderThanDays = getArg("--days"); const olderThanDays = getArg("--days");
const types = getArg("--types"); const types = getArg("--types");
console.log(`Scanning ${targetDir} for files older than ${olderThanDays} days (types: ${types})`); console.log(
`Scanning ${targetDir} for files older than ${olderThanDays} days (types: ${types})`,
);
if (process.env.CLEANUP_API_KEY) { if (process.env.CLEANUP_API_KEY) {
console.log("Using configured external API key."); console.log("Using configured external API key.");
} }
+1 -1
View File
@@ -72,7 +72,7 @@ scripts:
label: Notify Slack Channel label: Notify Slack Channel
type: string type: string
required: false required: false
pattern: '^#[a-z0-9-]+$' pattern: "^#[a-z0-9-]+$"
passAs: env passAs: env
envName: SLACK_CHANNEL envName: SLACK_CHANNEL
+35 -7
View File
@@ -1,13 +1,34 @@
{ {
"name": "app", "name": "triggershell",
"version": "0.1.0", "version": "0.1.0",
"private": true, "license": "MIT",
"type": "module", "type": "module",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://dev.pivoine.art/valknar/triggershell.git", "url": "https://dev.pivoine.art/valknar/triggershell.git"
"directory": "app"
}, },
"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": { "scripts": {
"dev": "tsx watch server.ts", "dev": "tsx watch server.ts",
"build": "next build", "build": "next build",
@@ -15,13 +36,16 @@
"lint": "eslint", "lint": "eslint",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"format": "prettier --write .", "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: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": { "dependencies": {
"@base-ui/react": "^1.7.0", "@base-ui/react": "^1.7.0",
"@hookform/resolvers": "^5.8.0", "@hookform/resolvers": "^5.8.0",
"@inquirer/prompts": "^8.5.2",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"argon2": "^0.45.1", "argon2": "^0.45.1",
@@ -29,6 +53,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"commander": "^15.0.0",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"execa": "^10.0.1", "execa": "^10.0.1",
"iron-session": "^8.0.4", "iron-session": "^8.0.4",
@@ -38,9 +63,12 @@
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
"react-hook-form": "^7.85.0", "react-hook-form": "^7.85.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"shadcn": "^4.18.0", "shadcn": "^4.18.0",
"sonner": "^2.0.8", "sonner": "^2.0.8",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tsx": "^4.23.12",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"ws": "^8.21.3", "ws": "^8.21.3",
"yaml": "^2.9.0", "yaml": "^2.9.0",
@@ -48,6 +76,7 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.20",
"@types/better-sqlite3": "^9.6.0", "@types/better-sqlite3": "^9.6.0",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
@@ -60,7 +89,6 @@
"prettier": "^3.9.6", "prettier": "^3.9.6",
"prettier-plugin-tailwindcss": "^0.8.1", "prettier-plugin-tailwindcss": "^0.8.1",
"tailwindcss": "^4", "tailwindcss": "^4",
"tsx": "^4.23.12",
"typescript": "^5" "typescript": "^5"
}, },
"packageManager": "pnpm@11.21.0" "packageManager": "pnpm@11.21.0"
+1194 -3
View File
File diff suppressed because it is too large Load Diff
@@ -2,5 +2,4 @@ allowBuilds:
argon2: true argon2: true
better-sqlite3: true better-sqlite3: true
esbuild: true esbuild: true
sharp: false
unrs-resolver: 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"]
+10 -1
View File
@@ -1,5 +1,7 @@
import "./src/bootstrap/async-local-storage-polyfill"; import "./src/bootstrap/async-local-storage-polyfill";
import { createServer } from "node:http"; import { createServer } from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import next from "next"; import next from "next";
import { WebSocketServer } from "ws"; import { WebSocketServer } from "ws";
import { getConfig } from "./src/lib/config/load"; import { getConfig } from "./src/lib/config/load";
@@ -19,7 +21,14 @@ migrateOnBoot();
syncAuthFromConfig(); syncAuthFromConfig();
reconcileOrphanedRuns(); reconcileOrphanedRuns();
const app = next({ dev, hostname, port }); // `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(); const handle = app.getRequestHandler();
app.prepare().then(() => { app.prepare().then(() => {
+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-3xl 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 { getConfig } from "@/lib/config/load";
import { requireAuth } from "@/lib/auth/guard"; import { requireAuth } from "@/lib/auth/guard";
import { Nav } from "@/components/layout/nav"; import { Nav } from "@/components/layout/nav";
import { Footer } from "@/components/layout/footer";
export default async function AppLayout({ export default async function AppLayout({
children, 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"> <main className="mx-auto w-full max-w-5xl flex-1 px-4 py-8">
{children} {children}
</main> </main>
<Footer />
</div> </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) { if (config.scripts.length === 0) {
return ( return (
<div className="text-muted-foreground py-24 text-center"> <div className="flex flex-col items-center gap-2 py-24 text-center">
No scripts configured yet. Add entries under{" "} <PlayCircle className="text-muted-foreground/50 mb-2 size-8" />
<code className="bg-muted rounded px-1.5 py-0.5">scripts:</code> in your <h1 className="font-heading text-lg font-semibold tracking-tight">
config file. 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> </div>
); );
} }
return ( return (
<div className="flex flex-col gap-4"> <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"> <div className="grid gap-3 sm:grid-cols-2">
{config.scripts.map((script) => ( {config.scripts.map((script) => (
<Link key={script.id} href={`/scripts/${script.id}`}> <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"> <CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<PlayCircle className="text-muted-foreground size-4.5 shrink-0" /> <PlayCircle className="text-muted-foreground size-4.5 shrink-0" />
+146
View File
@@ -0,0 +1,146 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { eq } from "drizzle-orm";
import { notFound } from "next/navigation";
import { RotateCw } from "lucide-react";
import { getDb } from "@/lib/db/client";
import { runs } from "@/lib/db/schema";
import { getScript } from "@/lib/config/load";
import { readLogTail } from "@/lib/runner/log-file";
import { RunTerminal } from "@/components/runs/run-terminal";
import { buttonVariants } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
interface RunDetailPageProps {
params: Promise<{ runId: string }>;
}
function formatVariableValue(value: unknown): string {
if (value === undefined || value === null || value === "") return "—";
if (Array.isArray(value)) return value.length ? value.join(", ") : "—";
if (typeof value === "boolean") return value ? "true" : "false";
return String(value);
}
export async function generateMetadata({
params,
}: RunDetailPageProps): Promise<Metadata> {
const { runId } = await params;
const run = getDb()
.select({ scriptName: runs.scriptName })
.from(runs)
.where(eq(runs.id, runId))
.get();
return { title: run ? `${run.scriptName} run` : "Run not found" };
}
export default async function RunDetailPage({ params }: RunDetailPageProps) {
const { runId } = await params;
const db = getDb();
const run = db.select().from(runs).where(eq(runs.id, runId)).get();
if (!run) notFound();
const { text: initialLog, size: initialLogBytes } = readLogTail(
run.logFilePath,
);
const script = getScript(run.scriptId);
const variableEntries = script
? script.variables.map((variable) => ({
key: variable.name,
label: variable.label ?? variable.name,
value: run.variables[variable.name],
}))
: Object.entries(run.variables).map(([key, value]) => ({
key,
label: key,
value,
}));
return (
<div className="mx-auto flex max-w-5xl flex-col gap-4">
<Card>
<CardHeader>
<CardTitle>{run.scriptName}</CardTitle>
{script && (
<CardAction className="row-span-1">
<Link
href={`/scripts/${run.scriptId}?fromRun=${run.id}`}
className={buttonVariants({ variant: "outline", size: "sm" })}
>
<RotateCw className="size-3.5" />
Re-run
</Link>
</CardAction>
)}
<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-mono text-[0.7rem] font-medium tracking-widest uppercase">
Triggered by
</dt>
<dd className="font-mono">{run.triggeredBy}</dd>
</div>
<div>
<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-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 font-mono text-[0.7rem] font-medium tracking-widest uppercase">
Command
</span>
<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 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 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>
<dd className="font-mono break-all">
{formatVariableValue(value)}
</dd>
</div>
))}
</dl>
</div>
)}
<RunTerminal
runId={run.id}
initialStatus={run.status}
initialLog={initialLog}
initialLogBytes={initialLogBytes}
initialExitCode={run.exitCode}
/>
</CardContent>
</Card>
</div>
);
}
+286
View File
@@ -0,0 +1,286 @@
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 { 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 { RunsToolbar } from "@/components/runs/runs-toolbar";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export const metadata: Metadata = { title: "Run History" };
const PAGE_SIZE = 25;
// Duration isn't a stored column - it's derived from started_at/ended_at (both unix seconds), so
// it needs a SQL expression rather than a plain column reference to be sortable. Still-running
// rows (ended_at IS NULL) count as elapsed-so-far; never-started (queued) rows sort as NULL,
// which SQLite puts first in ASC / last in DESC - keeping them out of the way either direction.
const durationExpr = sql`case when ${runs.startedAt} is null then null else coalesce(${runs.endedAt}, unixepoch()) - ${runs.startedAt} end`;
const SORT_COLUMNS = {
script: runs.scriptName,
status: runs.status,
triggeredBy: runs.triggeredBy,
started: runs.startedAt,
duration: durationExpr,
} as const;
type SortKey = keyof typeof SORT_COLUMNS;
const DEFAULT_SORT: SortKey = "started";
function isSortKey(value: string | undefined): value is SortKey {
return !!value && value in SORT_COLUMNS;
}
function formatDuration(startedAt: Date | null, endedAt: Date | null): string {
if (!startedAt) return "-";
const end = endedAt ?? new Date();
const seconds = Math.max(
0,
Math.round((end.getTime() - startedAt.getTime()) / 1000),
);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
return `${minutes}m ${seconds % 60}s`;
}
interface RunsPageProps {
searchParams: Promise<{
q?: string;
status?: string;
scriptId?: string;
sort?: string;
dir?: string;
page?: string;
}>;
}
export default async function RunsPage({ searchParams }: RunsPageProps) {
const params = await searchParams;
const search = params.q?.trim() ?? "";
const statusFilter = params.status ?? "all";
const scriptFilter = params.scriptId ?? "all";
const sort = isSortKey(params.sort) ? params.sort : DEFAULT_SORT;
const dir = params.dir === "asc" ? "asc" : "desc";
const requestedPage = Math.max(1, Math.floor(Number(params.page)) || 1);
const db = getDb();
const conditions: SQL[] = [];
if (search) {
const term = `%${search}%`;
conditions.push(
or(
like(runs.scriptName, term),
like(runs.triggeredBy, term),
like(runs.resolvedCommand, term),
)!,
);
}
if (statusFilter !== "all" && statusFilter in runStatusLabels) {
conditions.push(
eq(runs.status, statusFilter as keyof typeof runStatusLabels),
);
}
if (scriptFilter !== "all") {
conditions.push(eq(runs.scriptId, scriptFilter));
}
const whereClause = conditions.length ? and(...conditions) : undefined;
const orderColumn = SORT_COLUMNS[sort];
const orderBy = [
dir === "asc" ? asc(orderColumn) : desc(orderColumn),
desc(runs.createdAt),
];
const [{ total }] = db
.select({ total: sql<number>`count(*)` })
.from(runs)
.where(whereClause)
.all();
const scripts = db
.selectDistinct({ id: runs.scriptId, name: runs.scriptName })
.from(runs)
.orderBy(asc(runs.scriptName))
.all();
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const page = Math.min(requestedPage, totalPages);
const rows = db
.select()
.from(runs)
.where(whereClause)
.orderBy(...orderBy)
.limit(PAGE_SIZE)
.offset((page - 1) * PAGE_SIZE)
.all();
function hrefFor(overrides: Record<string, string | null>): string {
const query = new URLSearchParams();
if (search) query.set("q", search);
if (statusFilter !== "all") query.set("status", statusFilter);
if (scriptFilter !== "all") query.set("scriptId", scriptFilter);
if (sort !== DEFAULT_SORT) query.set("sort", sort);
if (dir !== "desc") query.set("dir", dir);
if (page !== 1) query.set("page", String(page));
for (const [key, value] of Object.entries(overrides)) {
if (value === null) query.delete(key);
else query.set(key, value);
}
const qs = query.toString();
return qs ? `/runs?${qs}` : "/runs";
}
function sortHeader(key: SortKey, label: string) {
const active = sort === key;
const nextDir = active && dir === "asc" ? "desc" : "asc";
const Icon = active ? (dir === "asc" ? ArrowUp : ArrowDown) : ArrowUpDown;
return (
<Link
href={hrefFor({ sort: key, dir: nextDir, page: null })}
className={cn(
"flex items-center gap-1 hover:text-foreground",
active && "text-foreground",
)}
>
{label}
<Icon className="size-3.5" />
</Link>
);
}
return (
<div className="flex flex-col gap-4">
<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"
? "No runs yet."
: "No runs match these filters."}
</p>
) : (
<>
<div className="overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{sortHeader("script", "Script")}</TableHead>
<TableHead>{sortHeader("status", "Status")}</TableHead>
<TableHead>
{sortHeader("triggeredBy", "Triggered by")}
</TableHead>
<TableHead>{sortHeader("started", "Started")}</TableHead>
<TableHead>{sortHeader("duration", "Duration")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((run) => (
<TableRow key={run.id} className="cursor-pointer">
<TableCell>
<Link
href={`/runs/${run.id}`}
className="font-medium hover:underline"
>
{run.scriptName}
</Link>
</TableCell>
<TableCell>
<RunStatusBadge status={run.status} />
</TableCell>
<TableCell className="text-muted-foreground font-mono text-xs">
{run.triggeredBy}
</TableCell>
<TableCell className="text-muted-foreground font-mono text-xs">
{run.startedAt
? new Date(run.startedAt).toLocaleString()
: "-"}
</TableCell>
<TableCell className="text-muted-foreground font-mono text-xs">
{formatDuration(run.startedAt, run.endedAt)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="text-muted-foreground flex items-center justify-between text-sm">
<span>
Page {page} of {totalPages} · {total} run{total === 1 ? "" : "s"}
</span>
<div className="flex items-center gap-2">
{page > 1 ? (
<Link
href={hrefFor({ page: String(page - 1) })}
className={buttonVariants({ variant: "outline", size: "sm" })}
>
<ChevronLeft className="size-3.5" />
Previous
</Link>
) : (
<span
aria-disabled
className={buttonVariants({
variant: "outline",
size: "sm",
className: "pointer-events-none opacity-50",
})}
>
<ChevronLeft className="size-3.5" />
Previous
</span>
)}
{page < totalPages ? (
<Link
href={hrefFor({ page: String(page + 1) })}
className={buttonVariants({ variant: "outline", size: "sm" })}
>
Next
<ChevronRight className="size-3.5" />
</Link>
) : (
<span
aria-disabled
className={buttonVariants({
variant: "outline",
size: "sm",
className: "pointer-events-none opacity-50",
})}
>
Next
<ChevronRight className="size-3.5" />
</span>
)}
</div>
</div>
</>
)}
</div>
);
}
@@ -1,9 +1,12 @@
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { eq } from "drizzle-orm";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getScript } from "@/lib/config/load"; import { getScript } from "@/lib/config/load";
import { serializeScriptForClient } from "@/lib/config/serialize"; import { serializeScriptForClient } from "@/lib/config/serialize";
import { getDb } from "@/lib/db/client";
import { runs } from "@/lib/db/schema";
import { DynamicForm } from "@/components/forms/dynamic-form"; import { DynamicForm } from "@/components/forms/dynamic-form";
import { import {
Card, Card,
@@ -15,6 +18,7 @@ import {
interface ScriptPageProps { interface ScriptPageProps {
params: Promise<{ scriptId: string }>; params: Promise<{ scriptId: string }>;
searchParams: Promise<{ fromRun?: string }>;
} }
export async function generateMetadata({ export async function generateMetadata({
@@ -25,15 +29,33 @@ export async function generateMetadata({
return { title: script?.name ?? "Script not found" }; return { title: script?.name ?? "Script not found" };
} }
export default async function ScriptPage({ params }: ScriptPageProps) { export default async function ScriptPage({
params,
searchParams,
}: ScriptPageProps) {
const { scriptId } = await params; const { scriptId } = await params;
const { fromRun } = await searchParams;
const script = getScript(scriptId); const script = getScript(scriptId);
if (!script) notFound(); if (!script) notFound();
const clientScript = serializeScriptForClient(script); const clientScript = serializeScriptForClient(script);
// Only trust a previous run's variables as prefill if it's actually a run of this same
// script - a `fromRun` id for a different script's run wouldn't line up with these variables.
let initialValues: Record<string, unknown> | undefined;
if (fromRun) {
const previousRun = getDb()
.select({ scriptId: runs.scriptId, variables: runs.variables })
.from(runs)
.where(eq(runs.id, fromRun))
.get();
if (previousRun?.scriptId === scriptId) {
initialValues = previousRun.variables;
}
}
return ( return (
<div className="mx-auto max-w-2xl"> <div className="mx-auto max-w-5xl">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{script.name}</CardTitle> <CardTitle>{script.name}</CardTitle>
@@ -42,7 +64,7 @@ export default async function ScriptPage({ params }: ScriptPageProps) {
)} )}
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<DynamicForm script={clientScript} /> <DynamicForm script={clientScript} initialValues={initialValues} />
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
+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 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 { ThemeProvider } from "next-themes";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ // Display face for page titles and card headings (wired via --font-heading in globals.css) - used
variable: "--font-geist-sans", // 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"], subsets: ["latin"],
}); });
const geistMono = Geist_Mono({ const sansFont = IBM_Plex_Sans({
variable: "--font-geist-mono", variable: "--font-sans",
subsets: ["latin"], 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 = { export const metadata: Metadata = {
@@ -31,7 +45,7 @@ export default function RootLayout({
return ( return (
<html <html
lang="en" lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${displayFont.variable} ${sansFont.variable} ${monoFont.variable} h-full antialiased`}
suppressHydrationWarning suppressHydrationWarning
> >
<body <body
@@ -50,7 +50,7 @@ export function LoginForm() {
<Card className="w-full max-w-sm"> <Card className="w-full max-w-sm">
<CardHeader> <CardHeader>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Terminal className="size-5" /> <Terminal className="text-primary size-5" />
<CardTitle>TriggerShell</CardTitle> <CardTitle>TriggerShell</CardTitle>
</div> </div>
<CardDescription> <CardDescription>
@@ -4,7 +4,6 @@ import type { Metadata } from "next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getConfig } from "@/lib/config/load"; import { getConfig } from "@/lib/config/load";
import { getSession } from "@/lib/auth/session"; import { getSession } from "@/lib/auth/session";
import { ThemeToggle } from "@/components/layout/theme-toggle";
import { LoginForm } from "./login-form"; import { LoginForm } from "./login-form";
export const metadata: Metadata = { title: "Sign in" }; export const metadata: Metadata = { title: "Sign in" };
@@ -17,10 +16,7 @@ export default async function LoginPage() {
if (session.userId) redirect("/"); if (session.userId) redirect("/");
return ( return (
<div className="relative flex min-h-full flex-1 items-center justify-center px-4"> <div className="flex min-h-full flex-1 items-center justify-center px-4">
<div className="absolute top-4 right-4">
<ThemeToggle />
</div>
<LoginForm /> <LoginForm />
</div> </div>
); );
+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(", ")})`);
}
}
+128
View File
@@ -0,0 +1,128 @@
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;
}
+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;
}
}
+188
View File
@@ -0,0 +1,188 @@
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,
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);
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>
);
}
@@ -4,37 +4,53 @@ import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Play, Loader2 } from "lucide-react"; import { Play, Loader2, Info } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Form } from "@/components/ui/form"; import { Form } from "@/components/ui/form";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { buildVariableSchemaFromList } from "@/lib/validation/variable-schema"; import { buildVariableSchemaFromList } from "@/lib/validation/variable-schema";
import { defaultValuesForScript } from "@/lib/config/defaults";
import type { ClientScript } from "@/lib/config/serialize"; import type { ClientScript } from "@/lib/config/serialize";
import { FieldRenderer } from "./field-renderer"; import { FieldRenderer } from "./field-renderer";
function emptyValueFor(variable: ClientScript["variables"][number]): unknown { /** Controls whose content doesn't shrink well into a narrow grid column - long-form text,
if (variable.type === "boolean") return false; * or a group of checkboxes that reads better as a single wide list - so they span the full
if (variable.type === "multiselect") return []; * grid width instead of sharing a row with other fields. */
return ""; const WIDE_CONTROLS = new Set(["textarea", "checkboxGroup"]);
}
function defaultValuesFor(script: ClientScript): Record<string, unknown> { /** `initialValues` comes from a previous run's (already-redacted) variables when re-running -
const values: Record<string, unknown> = {}; * secret fields are deliberately excluded there (their stored value is just "***", not the real
* 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 = defaultValuesForScript(script.variables);
if (!initialValues) return values;
for (const variable of script.variables) { for (const variable of script.variables) {
values[variable.name] = variable.default ?? emptyValueFor(variable); if (variable.secret) continue;
const fromPreviousRun = initialValues[variable.name];
if (fromPreviousRun !== undefined) values[variable.name] = fromPreviousRun;
} }
return values; return values;
} }
export function DynamicForm({ script }: { script: ClientScript }) { export function DynamicForm({
script,
initialValues,
}: {
script: ClientScript;
initialValues?: Record<string, unknown>;
}) {
const router = useRouter(); const router = useRouter();
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
const schema = buildVariableSchemaFromList(script.variables); const schema = buildVariableSchemaFromList(script.variables);
const hasSecretVariable = script.variables.some((v) => v.secret);
const form = useForm({ const form = useForm({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: defaultValuesFor(script), defaultValues: defaultValuesFor(script, initialValues),
}); });
async function onSubmit(values: Record<string, unknown>) { async function onSubmit(values: Record<string, unknown>) {
@@ -67,14 +83,37 @@ export function DynamicForm({ script }: { script: ClientScript }) {
<AlertDescription>{submitError}</AlertDescription> <AlertDescription>{submitError}</AlertDescription>
</Alert> </Alert>
)} )}
{initialValues && (
<Alert>
<Info />
<AlertDescription>
Pre-filled from a previous run.
{hasSecretVariable &&
" Secret fields aren't carried over and need to be entered again."}
</AlertDescription>
</Alert>
)}
{script.variables.length === 0 && ( {script.variables.length === 0 && (
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
This script takes no parameters. This script takes no parameters.
</p> </p>
)} )}
{script.variables.length > 0 && (
<div className="grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2 lg:grid-cols-3">
{script.variables.map((variable) => ( {script.variables.map((variable) => (
<FieldRenderer key={variable.name} variable={variable} /> <div
key={variable.name}
className={
WIDE_CONTROLS.has(variable.control)
? "sm:col-span-2 lg:col-span-3"
: undefined
}
>
<FieldRenderer variable={variable} />
</div>
))} ))}
</div>
)}
<Button <Button
type="submit" type="submit"
disabled={form.formState.isSubmitting} disabled={form.formState.isSubmitting}
@@ -24,6 +24,7 @@ import {
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { MultiSelect } from "./controls/multi-select"; import { MultiSelect } from "./controls/multi-select";
import { Combobox } from "./controls/combobox";
import type { ClientVariable } from "@/lib/config/serialize"; import type { ClientVariable } from "@/lib/config/serialize";
export function FieldRenderer({ variable }: { variable: ClientVariable }) { export function FieldRenderer({ variable }: { variable: ClientVariable }) {
@@ -211,6 +212,29 @@ export function FieldRenderer({ variable }: { variable: ClientVariable }) {
</FormItem> </FormItem>
); );
case "combobox":
return (
<FormItem>
<FormLabel>
{label}
{variable.required && (
<span className="text-destructive"> *</span>
)}
</FormLabel>
<FormControl>
<Combobox
choices={variable.type === "enum" ? variable.choices : []}
value={field.value ?? ""}
onChange={(value) => field.onChange(value)}
/>
</FormControl>
{variable.description && (
<FormDescription>{variable.description}</FormDescription>
)}
<FormMessage />
</FormItem>
);
case "radio": case "radio":
return ( return (
<FormItem> <FormItem>
+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"> <div className="flex min-w-0 items-center gap-3 sm:gap-6">
<Link <Link
href="/" 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 TriggerShell
</Link> </Link>
<nav className="flex items-center gap-1"> <nav className="flex items-center gap-1">
@@ -45,8 +45,8 @@ export function Nav({
key={href} key={href}
href={href} href={href}
className={cn( 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", "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-muted text-foreground", pathname === href && "bg-primary/10 text-foreground",
)} )}
> >
<Icon className="size-4" /> <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>
);
}
@@ -14,6 +14,7 @@ interface RunTerminalProps {
runId: string; runId: string;
initialStatus: RunStatus; initialStatus: RunStatus;
initialLog: string; initialLog: string;
initialLogBytes: number;
initialExitCode: number | null; initialExitCode: number | null;
} }
@@ -24,13 +25,16 @@ export function RunTerminal({
runId, runId,
initialStatus, initialStatus,
initialLog, initialLog,
initialLogBytes,
initialExitCode, initialExitCode,
}: RunTerminalProps) { }: RunTerminalProps) {
const [status, setStatus] = useState<RunStatus>(initialStatus); const [status, setStatus] = useState<RunStatus>(initialStatus);
const [exitCode, setExitCode] = useState<number | null>(initialExitCode); const [exitCode, setExitCode] = useState<number | null>(initialExitCode);
const termRef = useRef<XtermViewHandle>(null); const termRef = useRef<XtermViewHandle>(null);
const { cancel } = useRunSocket(runId, (message: ServerMessage) => { const { cancel } = useRunSocket(
runId,
(message: ServerMessage) => {
if (message.type === "output") { if (message.type === "output") {
// Scripts that colorize their own output (via ANSI codes) render as-is; stderr additionally // Scripts that colorize their own output (via ANSI codes) render as-is; stderr additionally
// gets wrapped in red so failures stand out even from tools that don't colorize themselves. // gets wrapped in red so failures stand out even from tools that don't colorize themselves.
@@ -40,15 +44,25 @@ export function RunTerminal({
: message.chunk; : message.chunk;
termRef.current?.write(chunk); termRef.current?.write(chunk);
} else if (message.type === "status") { } else if (message.type === "status") {
// The server also resends the run's current status right after subscribing, so a client
// that connects after a fast run has already finished still gets the real status instead
// of being stuck on whatever was known at page render. Only toast when it's new info.
const changed = message.status !== status;
setStatus(message.status); setStatus(message.status);
setExitCode(message.exitCode ?? null); setExitCode(message.exitCode ?? null);
if (message.status !== "queued" && message.status !== "running") { if (
changed &&
message.status !== "queued" &&
message.status !== "running"
) {
toast.info(`Run ${message.status.replace("_", " ")}`); toast.info(`Run ${message.status.replace("_", " ")}`);
} }
} else if (message.type === "error") { } else if (message.type === "error") {
toast.error(message.message); toast.error(message.message);
} }
}); },
initialLogBytes,
);
const isActive = status === "queued" || status === "running"; const isActive = status === "queued" || status === "running";
@@ -60,13 +74,13 @@ export function RunTerminal({
} }
return ( return (
<div className="flex flex-col gap-3"> <div className="border-border overflow-hidden rounded-lg border">
<div className="flex items-center justify-between"> <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"> <div className="flex items-center gap-3">
<RunStatusBadge status={status} /> <RunStatusBadge status={status} />
{exitCode !== null && ( {exitCode !== null && (
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground font-mono text-xs">
exit code {exitCode} exit {exitCode}
</span> </span>
)} )}
</div> </div>
+100
View File
@@ -0,0 +1,100 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { runStatusValues } from "@/lib/db/schema";
import { runStatusLabels } from "./run-status-badge";
interface RunsToolbarProps {
scripts: { id: string; name: string }[];
}
export function RunsToolbar({ scripts }: RunsToolbarProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const urlQ = searchParams.get("q") ?? "";
const [search, setSearch] = useState(urlQ);
// Tracks the URL value `search` was last synced from, so a change to the URL from elsewhere
// (back/forward, clearing filters) can be reflected without stomping on in-progress typing.
const [syncedQ, setSyncedQ] = useState(urlQ);
if (urlQ !== syncedQ) {
setSyncedQ(urlQ);
setSearch(urlQ);
}
function updateParams(updates: Record<string, string | null>) {
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
if (value === null) params.delete(key);
else params.set(key, value);
}
// Any filter/search change makes the current page number meaningless.
params.delete("page");
router.push(`${pathname}?${params.toString()}`);
}
useEffect(() => {
const timeout = setTimeout(() => {
if (search === urlQ) return;
updateParams({ q: search || null });
}, 300);
return () => clearTimeout(timeout);
// eslint-disable-next-line react-hooks/exhaustive-deps -- only re-debounce when the typed value changes
}, [search]);
return (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
placeholder="Search script, command, or triggered by..."
value={search}
onChange={(event) => setSearch(event.target.value)}
className="sm:max-w-xs"
/>
<Select
value={searchParams.get("status") ?? "all"}
onValueChange={(value) =>
updateParams({ status: value === "all" ? null : value })
}
>
<SelectTrigger className="w-full sm:w-40">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
{runStatusValues.map((status) => (
<SelectItem key={status} value={status}>
{runStatusLabels[status]}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={searchParams.get("scriptId") ?? "all"}
onValueChange={(value) =>
updateParams({ scriptId: value === "all" ? null : value })
}
>
<SelectTrigger className="w-full sm:w-48">
<SelectValue placeholder="Script" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All scripts</SelectItem>
{scripts.map((script) => (
<SelectItem key={script.id} value={script.id}>
{script.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
@@ -10,9 +10,9 @@ export interface XtermViewHandle {
} }
const theme = { const theme = {
background: "#09090b", background: "#101215",
foreground: "#f4f4f5", foreground: "#edeef0",
cursor: "#f4f4f5", cursor: "#e9a857",
selectionBackground: "#3f3f46", selectionBackground: "#3f3f46",
black: "#18181b", black: "#18181b",
red: "#f87171", red: "#f87171",
@@ -95,7 +95,7 @@ export const XtermView = forwardRef<XtermViewHandle, XtermViewProps>(
return ( return (
<div <div
ref={containerRef} 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