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