18 Commits
Author SHA1 Message Date
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
147 changed files with 3527 additions and 1451 deletions
+42
View File
@@ -0,0 +1,42 @@
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
- run: pnpm run build
- name: Set package version from the tag
run: npm pkg set version="${GITHUB_REF_NAME#v}"
# Scoped to this one registry host+path (via publishConfig.registry in package.json) rather
# than actions/setup-node's registry-url, which would set it as the *default* registry for
# every install - breaking `pnpm install` for this project's own (unscoped, public) deps.
- name: Configure registry auth for publish
run: pnpm config set "//dev.pivoine.art/api/packages/valknar/npm/:_authToken" "$PACKAGE_TOKEN"
env:
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
- name: Publish to Gitea npm registry
run: pnpm publish --no-git-checks
+35 -12
View File
@@ -1,16 +1,38 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.eggs/
build/
dist/
.venv/
venv/
.pytest_cache/
.ruff_cache/
# dependencies
/node_modules
/.pnpm-store
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# Runtime data (created by `triggershell start/dev` in whatever directory the config lives in)
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# typescript
*.tsbuildinfo
next-env.d.ts
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# vercel
.vercel
# Runtime data (created by `triggershell start` in whatever directory the config lives in)
.triggershell/
# Secrets loaded by `triggershell` via ${VAR} interpolation - never commit these
@@ -23,5 +45,6 @@ venv/
# Editors / OS
.DS_Store
*.pem
.idea/
.vscode/
+7
View File
@@ -0,0 +1,7 @@
# Machine-generated - pnpm owns this file's formatting, not prettier.
pnpm-lock.yaml
# pnpm's local content-addressable store - on some runners this ends up inside the workspace
# instead of the global cache location; its blobs aren't source files (some happen to parse as
# JS/TS-like content, which crashes prettier's parser rather than just wasting time on them).
.pnpm-store/
View File
View File
+52 -21
View File
@@ -24,20 +24,22 @@ API for automation.
## Quickstart
```bash
pip install triggershell # or: pip install -e . from a checkout
npm install -g triggershell # or: npx triggershell <command> for one-off use
triggershell init # scaffold triggershell.yml (+ .env for secrets) in the current directory
triggershell users add admin # create a login (skip if you set auth.enabled: false)
triggershell dev # start in dev mode and open the browser
triggershell start # start the app and open the browser
```
Edit `triggershell.yml` to add your own scripts (see [Configuration](#configuration) below),
then run `triggershell start` for a production build.
Edit `triggershell.yml` to add your own scripts (see [Configuration](#configuration) below), then
re-run `triggershell start`.
> The `triggershell` package isn't published to npm yet. Until it is, build and link a local copy
> instead: `pnpm install && pnpm build && pnpm link --global`.
## Requirements
- Python >= 3.9
- Node.js >= 20 (checked by the CLI; not auto-installed)
- pnpm (auto-provisioned via Corepack if missing and Corepack is available)
- Node.js >= 20 — the only thing you need installed. Everything else `triggershell` needs ships
inside the package itself and is resolved automatically when you install it.
## Configuration
@@ -98,14 +100,38 @@ the script — always as a discrete argv element or env var, never interpolated
## CLI Usage
| Command | Description |
|---|---|
| --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `triggershell init [PATH]` | Scaffold a new config file + `.env` (`--port`, `--auth/--no-auth`, `--force`) |
| `triggershell validate [-c CONFIG]` | Validate a config file (fast Python pre-flight + full Node/Zod schema) |
| `triggershell dev [-c CONFIG] [--port] [--host] [--no-browser]` | Run in development mode (hot reload) |
| `triggershell start [-c CONFIG] [--port] [--host] [--no-browser] [--skip-build]` | Build (if stale) and run in production mode |
| `triggershell doctor` | Print environment/config diagnostics |
| `triggershell validate [-c CONFIG]` | Validate a config file against the full schema |
| `triggershell start [-c CONFIG] [--port] [--host] [--no-browser]` | Run the web app |
| `triggershell doctor [-c CONFIG]` | Print environment/config diagnostics |
| `triggershell scripts list [-c CONFIG]` | List configured scripts |
| `triggershell scripts show <scriptId> [-c CONFIG]` | Show a script's command and variables |
| `triggershell run <scriptId> [--var name=value...] [--token] [--local\|--remote] [--no-wait]` | Run a configured script - through an already-running server's API if one is reachable (so any open browser tab sees it live), otherwise standalone. See [Running scripts from the CLI](#running-scripts-from-the-cli). |
| `triggershell users add <username> [-c CONFIG] [--inline]` | Hash a password, store it in `.env`, and print a `${VAR}` snippet for `auth.users` (`--inline` prints the raw hash instead) |
| `triggershell users add-token <name> [-c CONFIG] [--inline]` | Generate an API token, store its hash in `.env`, and print a `${VAR}` snippet for `auth.tokens` (`--inline` prints the raw hash instead) |
| `triggershell service install [--system]` | Install a systemd unit that runs `triggershell start` (per-user by default, Linux only) |
| `triggershell service uninstall [--system]` | Stop, disable, and remove the systemd unit |
| `triggershell service status [--system]` | Show the systemd unit's status |
### Running scripts from the CLI
`triggershell run <scriptId>` auto-detects whether the web app is already running (a quick
`/api/healthz` check against `server.host`/`server.port`, overridable with `--host`/`--port`):
- **Server reachable** — the run goes through the same `POST /api/scripts/:id/runs` endpoint the
web UI uses, authenticated with `--token`/`TRIGGERSHELL_API_TOKEN` if `auth.enabled`. It's a
completely normal run from the server's point of view: it shows up in Run History, and any
browser tab open on `/runs/:id` streams its output live, exactly as if it had been started from
the UI.
- **No server reachable** — `run` executes the script itself, in its own process, using the same
runner the web app uses. The run and its log are still persisted, just without a browser to watch it.
Force one or the other with `--local`/`--remote` (the latter fails instead of falling back if
nothing's reachable). Pass variables with repeated `--var name=value` flags (repeat the same name
for a `multiselect` variable); `--no-wait` prints the run ID and returns immediately instead of
streaming output and blocking until it finishes. Exit code is `0` for a succeeded run, `1`
otherwise. `Ctrl-C` while waiting cancels the run, the same as the UI's Cancel button.
## Web App Guide
@@ -129,7 +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).
| Method | Path | Notes |
|---|---|---|
| ------ | ----------------------------- | ------------------------------------------------------ |
| GET | `/api/healthz` | Unauthenticated readiness probe |
| POST | `/api/auth/login` | `{username, password}` → sets session cookie |
| POST | `/api/auth/logout` | Clears the session |
@@ -145,19 +171,24 @@ Full reference with request/response shapes and curl examples: [`docs/API.md`](d
## Development
This is the workflow for working on TriggerShell itself, not for installing/running it — it
bypasses the CLI entirely and talks to the app's own scripts directly, with hot reload. The app is
still not meant to be run standalone with `next dev`/`next start`, since it needs the custom server
(`server.ts`) for the WebSocket endpoint — `pnpm dev`/`pnpm start` below cover that:
```bash
pnpm --dir app install
pnpm --dir app dev # or: triggershell dev, which wraps this
pnpm --dir app lint
pnpm --dir app typecheck
pnpm --dir app db:studio # browse the SQLite DB
pnpm install
pnpm dev # tsx watch server.ts - reads TRIGGERSHELL_CONFIG_PATH from the environment
pnpm lint
pnpm typecheck
pnpm test # CLI unit tests (src/cli/**/*.test.ts)
pnpm db:studio # browse the SQLite DB
```
Repo layout:
Repo layout — the repo root itself is the published npm package (Next.js app + the `triggershell`
CLI in `bin/`/`src/cli/`), alongside:
```
triggershell/ Python CLI (launcher/orchestrator only)
app/ Next.js app - all server logic (API, auth, script execution) lives here
examples/ A runnable example config + scripts
docs/ Config/architecture/API reference docs
```
-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);
}
-130
View File
@@ -1,130 +0,0 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
-5
View File
@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<rect width="24" height="24" rx="5" fill="#09090b" />
<path d="M12 19h8" stroke="#fafafa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none" />
<path d="m4 17 6-6-6-6" stroke="#fafafa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none" />
</svg>

Before

Width:  |  Height:  |  Size: 386 B

@@ -1,37 +0,0 @@
import { Badge } from "@/components/ui/badge";
import type { RunStatus } from "@/lib/db/schema";
import { cn } from "@/lib/utils";
const styles: Record<RunStatus, string> = {
queued: "bg-muted text-muted-foreground",
running: "bg-blue-500/15 text-blue-600 dark:text-blue-400",
succeeded: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400",
failed: "bg-destructive/15 text-destructive",
cancelled: "bg-muted text-muted-foreground",
timed_out: "bg-amber-500/15 text-amber-600 dark:text-amber-400",
interrupted: "bg-amber-500/15 text-amber-600 dark:text-amber-400",
};
export const runStatusLabels: Record<RunStatus, string> = {
queued: "Queued",
running: "Running",
succeeded: "Succeeded",
failed: "Failed",
cancelled: "Cancelled",
timed_out: "Timed out",
interrupted: "Interrupted",
};
export function RunStatusBadge({ status }: { status: RunStatus }) {
return (
<Badge
className={cn("border-transparent font-medium", styles[status])}
variant="outline"
>
{status === "running" && (
<span className="mr-1 inline-block size-1.5 animate-pulse rounded-full bg-current" />
)}
{runStatusLabels[status]}
</Badge>
);
}
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env node
import { register } from "tsx/esm/api";
register();
await import("../src/cli/index.ts");
+5 -1
View File
@@ -6,6 +6,10 @@ When `auth.enabled: true`, every endpoint below except `/api/healthz`, `/api/aut
`/api/auth/session` requires either a valid session cookie or an `Authorization: Bearer <token>`
header using a token from `triggershell users add-token`.
`triggershell run <scriptId>` is a first-party client of this exact REST + WS contract (see
[Running scripts from the CLI](../README.md#running-scripts-from-the-cli)) - nothing below is
CLI-specific.
## Auth
### `POST /api/auth/login`
@@ -73,7 +77,7 @@ code). `404` if not found.
### `POST /api/runs/:runId/cancel`
`202 {"status": "cancelling"}`. `409` if the run already finished, or if it isn't tracked by *this*
`202 {"status": "cancelling"}`. `409` if the run already finished, or if it isn't tracked by _this_
server process (e.g. after a restart — see "orphaned runs" in `docs/ARCHITECTURE.md`).
### `GET /api/runs/:runId/logs`
+60 -17
View File
@@ -1,13 +1,14 @@
# Architecture
```
triggershell (Python CLI) app/ (Next.js, all server logic)
─────────────────────── ──────────────────────────────
triggershell dev|start server.ts (custom Node server)
1. resolve + pre-flight the config ├─ Next.js request handler (pages, API routes)
2. check node/pnpm, `pnpm install` if stale ├─ ws.WebSocketServer on /ws/runs
3. set TRIGGERSHELL_CONFIG_PATH/PORT/HOST env └─ boot: migrate DB, sync auth, reconcile runs
4. spawn `pnpm run dev|start`, forward signals
triggershell (Node CLI, src/cli) server.ts + src/ (Next.js, all server logic)
─────────────────────────────── ─────────────────────────────────────────
triggershell start server.ts (custom Node server)
1. resolve + validate the config (loadConfig) ├─ Next.js request handler (pages, API routes)
2. check the port is free ├─ ws.WebSocketServer on /ws/runs
3. set TRIGGERSHELL_CONFIG_PATH/PORT/HOST/ └─ boot: migrate DB, sync auth, reconcile runs
NODE_ENV
4. import("./server.ts") — same process │
5. poll /api/healthz, open browser │
┌──────────┴──────────┐
REST API WebSocket
@@ -23,22 +24,64 @@
## Why a custom Node server
Next.js Route Handlers can't host a persistent WebSocket server, so `app/server.ts` wraps Next's
Next.js Route Handlers can't host a persistent WebSocket server, so `server.ts` wraps Next's
request handler in a plain `http.createServer` and attaches a `ws.WebSocketServer` via the
`upgrade` event, scoped to `/ws/runs` with its own auth check (Route Handlers get auth via
`next/headers`'s `cookies()`, which isn't available on a raw `http.IncomingMessage`).
## Why the Python CLI is thin
## Why the CLI and server share one process
Everything Node/pnpm/Next.js needs to do (serve pages, run scripts, stream output, enforce auth)
is naturally a Node problem — `execa` for argv-safe spawning, `ws` for streaming, Next for the UI.
Python's job is just: get Node/pnpm ready, validate the config fast, and manage the child process's
lifecycle (signals, readiness, browser launch) — a CLI concern, not a web-server concern.
`triggershell start` (`src/cli/commands/start.ts`) doesn't spawn `server.ts` as a child process —
it sets `process.env` (`TRIGGERSHELL_CONFIG_PATH`, `PORT`, `HOST`, `NODE_ENV`) and then dynamically
`import()`s `server.ts` directly, in the same Node process. `server.ts` reads that env and installs
its own `SIGTERM`/`SIGINT` handlers, so once it's imported, `Ctrl-C` or `systemctl stop` just work —
there's no parent process relaying signals to a child, no separate lifecycle to manage. The CLI's
`bin/triggershell.js` entry point registers `tsx`'s loader once for the whole process
(`tsx/esm/api`'s `register()`), so both the CLI's own `.ts` command files and `server.ts` run
straight from source, with no compile/bundle step for either.
## `triggershell run` - a second, independent client of the same run pipeline
`triggershell run <scriptId>` (`src/cli/commands/run.ts`) never duplicates the spawn/streaming
logic in `src/lib/runner/engine.ts` - it just calls it from a different position:
- If a server is reachable (`GET /api/healthz`), `run` is a plain HTTP+WS client: `POST
/api/scripts/:id/runs` (the same route the web UI's "Run" button calls) starts the run _inside
that server's process_, and `run` then subscribes over `/ws/runs` exactly like a browser tab
would, using the same `ClientMessage`/`ServerMessage` protocol (`src/lib/ws/protocol.ts`). This
is why a run started this way appears live in any open browser tab for free - the broadcast path
(`emitRunMessage` → the `runEvents` listener in `src/lib/ws/server.ts` → every subscribed
WebSocket) has no idea the run was triggered by a CLI instead of a click.
- If nothing's reachable, `run` calls `startRun()` directly, in its own short-lived process (after
its own `migrateOnBoot()`/`reconcileOrphanedRuns()`, so a from-scratch `.triggershell/` works
standalone). Since it's in the same process as the run it just started, it doesn't need WS at
all - it listens on the same in-process `runEvents` emitter a WS client would otherwise be fed
from, using the exact "read the log file and current status first, then attach a live listener"
ordering `subscribe()` in `ws/server.ts` already uses, for the same reason: a fast script can
finish before a listener is attached.
Either way, `Ctrl-C` cancels the run through the existing mechanism for that mode - a `{"type":
"cancel"}` WS message for the remote case, `cancelRun()` (`src/lib/runner/registry.ts`) directly
for the local case - not a new cancellation path.
## Running as a systemd service
`triggershell service install` renders a unit file (`src/cli/lib/systemd.ts`) whose `ExecStart`
line pins the exact `node` binary (`process.execPath`) and the exact, symlink-resolved path to the
installed CLI (`fs.realpathSync(process.argv[1])`) at install time — necessary because systemd
services run with a minimal `PATH` that may not include wherever Node actually lives. By default it
installs a per-user unit (`~/.config/systemd/user/triggershell.service`, no root required); `--system`
targets `/etc/systemd/system/` instead and prints the `sudo` commands to run if not already root.
`install` reloads the systemd daemon but does not enable/start the unit itself — that's a separate,
explicit `systemctl --user enable --now triggershell`, since it's the point where the service
actually starts listening and running scripts. `triggershell service status`/`uninstall` are thin
wrappers around `systemctl`; log tailing is just `journalctl --user -u triggershell -f` — not
reimplemented.
## Cross-module-graph state
Next compiles Route Handlers and Server Components through its own build/module graph, which is a
*separate* module instantiation from whatever `server.ts` imports directly via `tsx` at startup —
_separate_ module instantiation from whatever `server.ts` imports directly via `tsx` at startup —
even though both run in the same OS process. A plain module-level singleton (e.g. `new Map()` at
the top of a file) ends up duplicated, one copy per graph, which silently breaks anything that
needs to be shared across that boundary (the WebSocket subscriber registry, the live-run-handle
@@ -67,10 +110,10 @@ queued → running → succeeded | failed | cancelled | timed_out
## Auth
- Session: `iron-session` — a stateless, encrypted+signed cookie (no session-store table).
- Config is the source of truth for *who* is allowed in; `src/lib/auth/sync.ts` upserts config
- Config is the source of truth for _who_ is allowed in; `src/lib/auth/sync.ts` upserts config
users/tokens into SQLite on boot, giving a single DB-backed check path plus `lastLoginAt` tracking.
- `src/proxy.ts` (Next's Proxy, formerly "Middleware") does a fast, cookie-only redirect for
unauthenticated page/API requests — explicitly *not* the real security boundary. Every Route
unauthenticated page/API requests — explicitly _not_ the real security boundary. Every Route
Handler also calls `requireAuth()` itself; this is the actual auth check.
- The WS `upgrade` handler is outside Next's request pipeline entirely, so it authenticates by hand
(parsing the cookie header, or a `?token=` query param) via `authenticateUpgrade()`.
@@ -81,4 +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
statically prerender pages like `/` at build time, which fails because there's no config file to
read yet (the config only exists at `triggershell start` runtime, in the user's own project
directory, not `app/`'s).
directory, not the package's).
+11 -10
View File
@@ -10,13 +10,14 @@ present) into its environment - without overriding any variable already set in t
secrets referenced via `${VAR}` don't have to be committed alongside the config. `triggershell
init` scaffolds both files together.
The canonical schema is the Zod schema at `app/src/lib/config/schema.ts` — this document mirrors
it. `triggershell validate` runs the Python pre-flight checks below, then that full schema.
The canonical schema is the Zod schema at `src/lib/config/schema.ts` — this document mirrors
it. `triggershell validate` loads and validates the config directly against the schema below (no
separate pre-flight step).
## `server`
| Field | Type | Default | Notes |
|---|---|---|---|
| ---------- | ------ | ----------- | ----------------------- |
| `host` | string | `127.0.0.1` | Bind address |
| `port` | number | `4173` | 1-65535 |
| `basePath` | string | `""` | Reserved for future use |
@@ -24,7 +25,7 @@ it. `triggershell validate` runs the Python pre-flight checks below, then that f
## `auth`
| Field | Type | Default | Notes |
|---|---|---|---|
| ----------------- | ------- | ------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `enabled` | boolean | `true` | `false` disables login entirely |
| `sessionSecret` | string | — | Required, >= 32 chars, if `enabled`. Reference it via `${TRIGGERSHELL_SESSION_SECRET}` and set the real value in `.env`, not here |
| `sessionTtlHours` | number | `12` | Session cookie lifetime |
@@ -42,20 +43,20 @@ pass `--inline` to those commands to get the raw hash printed for pasting into t
## `database`
| Field | Type | Default |
|---|---|---|
| ------ | ------ | ------------------------------- |
| `path` | string | `.triggershell/triggershell.db` |
## `logs`
| Field | Type | Default | Notes |
|---|---|---|---|
| --------------- | ------ | -------------------- | ----------------------------------------------------------- |
| `dir` | string | `.triggershell/logs` | One `<runId>.log` file per run |
| `retentionDays` | number | `30` | Not yet enforced automatically — prune manually or via cron |
## `scripts[]`
| Field | Type | Default | Notes |
|---|---|---|---|
| ---------------- | -------- | ------- | ------------------------------------------------------------------------------------ |
| `id` | string | — | Required, unique, `[a-zA-Z0-9][a-zA-Z0-9_-]*` |
| `name` | string | — | Required, display name |
| `description` | string | — | Optional |
@@ -71,7 +72,7 @@ pass `--inline` to those commands to get the raw hash printed for pasting into t
Common fields on every variable:
| Field | Type | Default | Notes |
|---|---|---|---|
| ------------- | ----------------------------------- | ------------------ | -------------------------------------------------------------------------------------- |
| `name` | string | — | Required, unique per script |
| `label` | string | `name` | Display label |
| `description` | string | — | Shown as form help text |
@@ -86,7 +87,7 @@ Common fields on every variable:
Type-specific fields:
| `type` | Extra fields |
|---|---|
| ------------- | ------------------------------------------------------------------------------------------------- |
| `string` | `default?: string`, `pattern?: string` (regex), `minLength?`, `maxLength?`, `multiline?: boolean` |
| `number` | `default?: number`, `min?`, `max?`, `step?` |
| `boolean` | `default: boolean` (default `false`) |
@@ -96,7 +97,7 @@ Type-specific fields:
### UI control mapping
| `type` | Default `control` | Valid overrides |
|---|---|---|
| ------------- | ---------------------------------------- | ------------------------------------------------ |
| `string` | `text` (or `password` if `secret: true`) | `textarea` (needs `multiline: true`), `password` |
| `number` | `number` | `slider` (requires both `min` and `max`) |
| `boolean` | `checkbox` | `switch` |
+3 -1
View File
@@ -10,7 +10,9 @@ const targetDir = getArg("--dir");
const olderThanDays = getArg("--days");
const types = getArg("--types");
console.log(`Scanning ${targetDir} for files older than ${olderThanDays} days (types: ${types})`);
console.log(
`Scanning ${targetDir} for files older than ${olderThanDays} days (types: ${types})`,
);
if (process.env.CLEANUP_API_KEY) {
console.log("Using configured external API key.");
}
+1 -1
View File
@@ -72,7 +72,7 @@ scripts:
label: Notify Slack Channel
type: string
required: false
pattern: '^#[a-z0-9-]+$'
pattern: "^#[a-z0-9-]+$"
passAs: env
envName: SLACK_CHANNEL
+35 -7
View File
@@ -1,13 +1,34 @@
{
"name": "app",
"name": "triggershell",
"version": "0.1.0",
"private": true,
"license": "MIT",
"type": "module",
"repository": {
"type": "git",
"url": "https://dev.pivoine.art/valknar/triggershell.git",
"directory": "app"
"url": "https://dev.pivoine.art/valknar/triggershell.git"
},
"bin": {
"triggershell": "bin/triggershell.js"
},
"engines": {
"node": ">=20"
},
"publishConfig": {
"registry": "https://dev.pivoine.art/api/packages/valknar/npm/"
},
"files": [
"bin",
"src",
"templates",
"drizzle",
"docs",
".next",
"server.ts",
"next.config.ts",
"next-env.d.ts",
"tsconfig.json",
"LICENSE"
],
"scripts": {
"dev": "tsx watch server.ts",
"build": "next build",
@@ -15,13 +36,16 @@
"lint": "eslint",
"typecheck": "tsc --noEmit",
"format": "prettier --write .",
"validate-config": "tsx scripts/validate-config.ts",
"format:check": "prettier --check .",
"test": "tsx --test \"src/cli/**/*.test.ts\"",
"db:generate": "drizzle-kit generate",
"db:studio": "drizzle-kit studio"
"db:studio": "drizzle-kit studio",
"prepack": "rm -rf .next && next build && rm -rf .next/cache"
},
"dependencies": {
"@base-ui/react": "^1.7.0",
"@hookform/resolvers": "^5.8.0",
"@inquirer/prompts": "^8.5.2",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"argon2": "^0.45.1",
@@ -29,6 +53,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"commander": "^15.0.0",
"drizzle-orm": "^0.45.2",
"execa": "^10.0.1",
"iron-session": "^8.0.4",
@@ -38,9 +63,12 @@
"react": "19.2.8",
"react-dom": "19.2.8",
"react-hook-form": "^7.85.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"shadcn": "^4.18.0",
"sonner": "^2.0.8",
"tailwind-merge": "^3.6.0",
"tsx": "^4.23.12",
"tw-animate-css": "^1.4.0",
"ws": "^8.21.3",
"yaml": "^2.9.0",
@@ -48,6 +76,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.20",
"@types/better-sqlite3": "^9.6.0",
"@types/node": "^20",
"@types/react": "^19",
@@ -60,7 +89,6 @@
"prettier": "^3.9.6",
"prettier-plugin-tailwindcss": "^0.8.1",
"tailwindcss": "^4",
"tsx": "^4.23.12",
"typescript": "^5"
},
"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
better-sqlite3: true
esbuild: true
sharp: false
unrs-resolver: false
-48
View File
@@ -1,48 +0,0 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "triggershell"
version = "0.1.0"
description = "CLI launcher for the TriggerShell web app - run configured shell scripts from a browser."
readme = "README.md"
requires-python = ">=3.9"
license = "MIT"
authors = [{ name = "TriggerShell contributors" }]
dependencies = [
"typer>=0.12",
"rich>=13.7",
"pyyaml>=6.0",
"argon2-cffi>=23.1",
"python-dotenv>=1.0",
]
[project.urls]
Repository = "https://dev.pivoine.art/valknar/triggershell.git"
[project.scripts]
triggershell = "triggershell.cli:app"
[tool.hatch.build.targets.wheel]
packages = ["triggershell"]
[tool.hatch.build.targets.wheel.force-include]
"app" = "triggershell/_app"
[tool.hatch.build.targets.sdist]
include = ["triggershell", "app", "README.md"]
exclude = ["app/node_modules", "app/.next", "app/drizzle.config.ts.bak"]
[tool.ruff]
line-length = 120
target-version = "py39"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
# B008: typer's `= typer.Option(...)` / `= typer.Argument(...)` defaults are the intended API, not a bug.
# UP045: `X | None` needs 3.10 at runtime for typer's introspection; this project targets 3.9+.
ignore = ["B008", "UP045"]
[dependency-groups]
dev = ["ruff>=0.6", "pytest>=8.0"]
+10 -1
View File
@@ -1,5 +1,7 @@
import "./src/bootstrap/async-local-storage-polyfill";
import { createServer } from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import next from "next";
import { WebSocketServer } from "ws";
import { getConfig } from "./src/lib/config/load";
@@ -19,7 +21,14 @@ migrateOnBoot();
syncAuthFromConfig();
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();
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 { requireAuth } from "@/lib/auth/guard";
import { Nav } from "@/components/layout/nav";
import { Footer } from "@/components/layout/footer";
export default async function AppLayout({
children,
@@ -18,6 +19,7 @@ export default async function AppLayout({
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-8">
{children}
</main>
<Footer />
</div>
);
}
@@ -13,21 +13,31 @@ export default function DashboardPage() {
if (config.scripts.length === 0) {
return (
<div className="text-muted-foreground py-24 text-center">
No scripts configured yet. Add entries under{" "}
<code className="bg-muted rounded px-1.5 py-0.5">scripts:</code> in your
config file.
<div className="flex flex-col items-center gap-2 py-24 text-center">
<PlayCircle className="text-muted-foreground/50 mb-2 size-8" />
<h1 className="font-heading text-lg font-semibold tracking-tight">
No scripts yet
</h1>
<p className="text-muted-foreground max-w-sm text-sm">
Add entries under{" "}
<code className="bg-muted rounded px-1.5 py-0.5 font-mono text-xs">
scripts:
</code>{" "}
in your config file to see them here.
</p>
</div>
);
}
return (
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-semibold tracking-tight">Scripts</h1>
<h1 className="font-heading text-2xl font-semibold tracking-tight">
Scripts
</h1>
<div className="grid gap-3 sm:grid-cols-2">
{config.scripts.map((script) => (
<Link key={script.id} href={`/scripts/${script.id}`}>
<Card className="hover:border-foreground/30 h-full transition-colors">
<Card className="hover:border-primary/50 h-full transition-colors">
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
<div className="flex items-center gap-2">
<PlayCircle className="text-muted-foreground size-4.5 shrink-0" />
@@ -66,7 +66,7 @@ export default async function RunDetailPage({ params }: RunDetailPageProps) {
}));
return (
<div className="mx-auto flex max-w-3xl flex-col gap-4">
<div className="mx-auto flex max-w-2xl flex-col gap-4">
<Card>
<CardHeader>
<CardTitle>{run.scriptName}</CardTitle>
@@ -81,36 +81,42 @@ export default async function RunDetailPage({ params }: RunDetailPageProps) {
</Link>
</CardAction>
)}
<dl className="text-muted-foreground grid grid-cols-2 gap-x-4 gap-y-1 text-xs sm:grid-cols-3">
<dl className="text-muted-foreground grid grid-cols-2 gap-x-4 gap-y-2 text-xs sm:grid-cols-3">
<div>
<dt className="font-medium">Triggered by</dt>
<dd>{run.triggeredBy}</dd>
<dt className="font-mono text-[0.7rem] font-medium tracking-widest uppercase">
Triggered by
</dt>
<dd className="font-mono">{run.triggeredBy}</dd>
</div>
<div>
<dt className="font-medium">Started</dt>
<dd>
<dt className="font-mono text-[0.7rem] font-medium tracking-widest uppercase">
Started
</dt>
<dd className="font-mono">
{run.startedAt ? new Date(run.startedAt).toLocaleString() : "-"}
</dd>
</div>
<div>
<dt className="font-medium">Run ID</dt>
<dt className="font-mono text-[0.7rem] font-medium tracking-widest uppercase">
Run ID
</dt>
<dd className="truncate font-mono">{run.id}</dd>
</div>
</dl>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs font-medium">
<span className="text-muted-foreground font-mono text-[0.7rem] font-medium tracking-widest uppercase">
Command
</span>
<pre className="bg-muted overflow-x-auto rounded-md p-3 font-mono text-xs whitespace-pre">
<pre className="bg-muted border-primary/30 overflow-x-auto rounded-md border-l-2 p-3 font-mono text-xs whitespace-pre">
{run.resolvedCommand}
</pre>
</div>
{variableEntries.length > 0 && (
<div className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs font-medium">
<span className="text-muted-foreground font-mono text-[0.7rem] font-medium tracking-widest uppercase">
Variables
</span>
<dl className="grid gap-x-6 gap-y-2 rounded-md border p-3 text-xs sm:grid-cols-2">
@@ -2,11 +2,20 @@ export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import Link from "next/link";
import { ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight } from "lucide-react";
import {
ArrowDown,
ArrowUp,
ArrowUpDown,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { and, asc, desc, eq, like, or, sql, type SQL } from "drizzle-orm";
import { getDb } from "@/lib/db/client";
import { runs } from "@/lib/db/schema";
import { RunStatusBadge, runStatusLabels } from "@/components/runs/run-status-badge";
import {
RunStatusBadge,
runStatusLabels,
} from "@/components/runs/run-status-badge";
import { RunsToolbar } from "@/components/runs/runs-toolbar";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -89,7 +98,9 @@ export default async function RunsPage({ searchParams }: RunsPageProps) {
);
}
if (statusFilter !== "all" && statusFilter in runStatusLabels) {
conditions.push(eq(runs.status, statusFilter as keyof typeof runStatusLabels));
conditions.push(
eq(runs.status, statusFilter as keyof typeof runStatusLabels),
);
}
if (scriptFilter !== "all") {
conditions.push(eq(runs.scriptId, scriptFilter));
@@ -161,11 +172,16 @@ export default async function RunsPage({ searchParams }: RunsPageProps) {
return (
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-semibold tracking-tight">Run History</h1>
<h1 className="font-heading text-2xl font-semibold tracking-tight">
Run History
</h1>
<RunsToolbar scripts={scripts} />
{rows.length === 0 ? (
<p className="text-muted-foreground py-12 text-center">
{total === 0 && !search && statusFilter === "all" && scriptFilter === "all"
{total === 0 &&
!search &&
statusFilter === "all" &&
scriptFilter === "all"
? "No runs yet."
: "No runs match these filters."}
</p>
@@ -177,7 +193,9 @@ export default async function RunsPage({ searchParams }: RunsPageProps) {
<TableRow>
<TableHead>{sortHeader("script", "Script")}</TableHead>
<TableHead>{sortHeader("status", "Status")}</TableHead>
<TableHead>{sortHeader("triggeredBy", "Triggered by")}</TableHead>
<TableHead>
{sortHeader("triggeredBy", "Triggered by")}
</TableHead>
<TableHead>{sortHeader("started", "Started")}</TableHead>
<TableHead>{sortHeader("duration", "Duration")}</TableHead>
</TableRow>
@@ -196,15 +214,15 @@ export default async function RunsPage({ searchParams }: RunsPageProps) {
<TableCell>
<RunStatusBadge status={run.status} />
</TableCell>
<TableCell className="text-muted-foreground">
<TableCell className="text-muted-foreground font-mono text-xs">
{run.triggeredBy}
</TableCell>
<TableCell className="text-muted-foreground">
<TableCell className="text-muted-foreground font-mono text-xs">
{run.startedAt
? new Date(run.startedAt).toLocaleString()
: "-"}
</TableCell>
<TableCell className="text-muted-foreground">
<TableCell className="text-muted-foreground font-mono text-xs">
{formatDuration(run.startedAt, run.endedAt)}
</TableCell>
</TableRow>
+154
View File
@@ -0,0 +1,154 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-heading: var(--font-display);
--color-status-queued: var(--status-queued);
--color-status-running: var(--status-running);
--color-status-succeeded: var(--status-succeeded);
--color-status-failed: var(--status-failed);
--color-status-cancelled: var(--status-cancelled);
--color-status-warn: var(--status-warn);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
/*
* Palette - "instrument panel": cool graphite ink/paper with a warm brass signal accent.
* The brass tone doubles as the "running" status color, so a live run literally lights the UI
* up with the brand color - see run-status-badge.tsx and run-terminal.tsx.
*/
:root {
--background: #f5f6f7;
--foreground: #15171a;
--card: #ffffff;
--card-foreground: #15171a;
--popover: #ffffff;
--popover-foreground: #15171a;
--primary: #c8842e;
--primary-foreground: #17130a;
--secondary: #ececee;
--secondary-foreground: #15171a;
--muted: #ececee;
--muted-foreground: #6b7280;
--accent: #ececee;
--accent-foreground: #15171a;
--destructive: #b3402e;
--border: #dfe1e3;
--input: #dfe1e3;
--ring: #c8842e;
--status-queued: #6b7280;
--status-running: #c8842e;
--status-succeeded: #3f8f5f;
--status-failed: #b3402e;
--status-cancelled: #6b7280;
--status-warn: #8a7530;
--chart-1: #c8842e;
--chart-2: #3f8f5f;
--chart-3: #6b7280;
--chart-4: #8a7530;
--chart-5: #b3402e;
--radius: 0.5rem;
--sidebar: #f5f6f7;
--sidebar-foreground: #15171a;
--sidebar-primary: #c8842e;
--sidebar-primary-foreground: #17130a;
--sidebar-accent: #ececee;
--sidebar-accent-foreground: #15171a;
--sidebar-border: #dfe1e3;
--sidebar-ring: #c8842e;
}
.dark {
--background: #14161a;
--foreground: #edeef0;
--card: #1b1e23;
--card-foreground: #edeef0;
--popover: #1b1e23;
--popover-foreground: #edeef0;
--primary: #e9a857;
--primary-foreground: #1a1305;
--secondary: #23262c;
--secondary-foreground: #edeef0;
--muted: #23262c;
--muted-foreground: #9aa0a8;
--accent: #23262c;
--accent-foreground: #edeef0;
--destructive: #e2685a;
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 14%);
--ring: #e9a857;
--status-queued: #9aa0a8;
--status-running: #e9a857;
--status-succeeded: #5fb983;
--status-failed: #e2685a;
--status-cancelled: #9aa0a8;
--status-warn: #c9a344;
--chart-1: #e9a857;
--chart-2: #5fb983;
--chart-3: #9aa0a8;
--chart-4: #c9a344;
--chart-5: #e2685a;
--sidebar: #1b1e23;
--sidebar-foreground: #edeef0;
--sidebar-primary: #e9a857;
--sidebar-primary-foreground: #1a1305;
--sidebar-accent: #23262c;
--sidebar-accent-foreground: #edeef0;
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: #e9a857;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<style>
rect { fill: #15171a; }
path { stroke: #c8842e; }
@media (prefers-color-scheme: dark) {
path { stroke: #e9a857; }
}
</style>
<rect width="24" height="24" rx="5" />
<path d="M12 19h8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none" />
<path d="m4 17 6-6-6-6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none" />
</svg>

After

Width:  |  Height:  |  Size: 496 B

+20 -6
View File
@@ -1,18 +1,32 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import {
Bricolage_Grotesque,
IBM_Plex_Mono,
IBM_Plex_Sans,
} from "next/font/google";
import { ThemeProvider } from "next-themes";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/sonner";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
// Display face for page titles and card headings (wired via --font-heading in globals.css) - used
// with restraint, never for body copy. Body/UI text and technical data (run IDs, commands,
// timestamps) share the IBM Plex family so the two registers still read as one system.
const displayFont = Bricolage_Grotesque({
variable: "--font-display",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
const sansFont = IBM_Plex_Sans({
variable: "--font-sans",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
});
const monoFont = IBM_Plex_Mono({
variable: "--font-mono",
subsets: ["latin"],
weight: ["400", "500", "600"],
});
export const metadata: Metadata = {
@@ -31,7 +45,7 @@ export default function RootLayout({
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
className={`${displayFont.variable} ${sansFont.variable} ${monoFont.variable} h-full antialiased`}
suppressHydrationWarning
>
<body
@@ -50,7 +50,7 @@ export function LoginForm() {
<Card className="w-full max-w-sm">
<CardHeader>
<div className="flex items-center gap-2">
<Terminal className="size-5" />
<Terminal className="text-primary size-5" />
<CardTitle>TriggerShell</CardTitle>
</div>
<CardDescription>
+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;
}
+21
View File
@@ -0,0 +1,21 @@
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { cn } from "@/lib/utils";
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",
)}
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
);
}
@@ -10,30 +10,23 @@ import { Button } from "@/components/ui/button";
import { Form } from "@/components/ui/form";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { buildVariableSchemaFromList } from "@/lib/validation/variable-schema";
import { defaultValuesForScript } from "@/lib/config/defaults";
import type { ClientScript } from "@/lib/config/serialize";
import { FieldRenderer } from "./field-renderer";
function emptyValueFor(variable: ClientScript["variables"][number]): unknown {
if (variable.type === "boolean") return false;
if (variable.type === "multiselect") return [];
return "";
}
/** `initialValues` comes from a previous run's (already-redacted) variables when re-running -
* secret fields are deliberately excluded there (their stored value is just "***", not the real
* one), so those always fall through to the normal empty/default state and have to be re-entered. */
* one), so those always fall through to the normal default/empty state and have to be re-entered. */
function defaultValuesFor(
script: ClientScript,
initialValues?: Record<string, unknown>,
): Record<string, unknown> {
const values: Record<string, unknown> = {};
const values = defaultValuesForScript(script.variables);
if (!initialValues) return values;
for (const variable of script.variables) {
const fromPreviousRun =
initialValues && !variable.secret
? initialValues[variable.name]
: undefined;
values[variable.name] =
fromPreviousRun ?? variable.default ?? emptyValueFor(variable);
if (variable.secret) continue;
const fromPreviousRun = initialValues[variable.name];
if (fromPreviousRun !== undefined) values[variable.name] = fromPreviousRun;
}
return values;
}
+26
View File
@@ -0,0 +1,26 @@
import Link from "next/link";
const PROJECT_URL = "https://dev.pivoine.art/valknar/triggershell";
export function Footer() {
return (
<footer className="border-t">
<div className="text-muted-foreground mx-auto flex w-full max-w-5xl flex-col items-center gap-2 px-4 py-4 text-xs sm:flex-row sm:justify-between">
<span>© {new Date().getFullYear()} TriggerShell</span>
<div className="flex items-center gap-4">
<Link href="/docs" className="hover:text-foreground">
Docs
</Link>
<a
href={PROJECT_URL}
target="_blank"
rel="noreferrer"
className="hover:text-foreground"
>
Project
</a>
</div>
</div>
</footer>
);
}
@@ -34,9 +34,9 @@ export function Nav({
<div className="flex min-w-0 items-center gap-3 sm:gap-6">
<Link
href="/"
className="flex shrink-0 items-center gap-2 font-semibold tracking-tight"
className="font-heading flex shrink-0 items-center gap-2 text-[1.05rem] font-semibold tracking-tight"
>
<Terminal className="size-5" />
<Terminal className="text-primary size-5" />
TriggerShell
</Link>
<nav className="flex items-center gap-1">
@@ -45,8 +45,8 @@ export function Nav({
key={href}
href={href}
className={cn(
"text-muted-foreground hover:text-foreground flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium transition-colors sm:px-3",
pathname === href && "bg-muted text-foreground",
"text-muted-foreground hover:text-foreground hover:bg-muted/60 flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium transition-colors sm:px-3",
pathname === href && "bg-primary/10 text-foreground",
)}
>
<Icon className="size-4" />
+43
View File
@@ -0,0 +1,43 @@
import { Badge } from "@/components/ui/badge";
import type { RunStatus } from "@/lib/db/schema";
import { cn } from "@/lib/utils";
const styles: Record<RunStatus, string> = {
queued: "bg-status-queued/12 text-status-queued",
running: "bg-status-running/15 text-status-running",
succeeded: "bg-status-succeeded/12 text-status-succeeded",
failed: "bg-status-failed/12 text-status-failed",
cancelled: "bg-status-cancelled/12 text-status-cancelled",
timed_out: "bg-status-warn/12 text-status-warn",
interrupted: "bg-status-warn/12 text-status-warn",
};
export const runStatusLabels: Record<RunStatus, string> = {
queued: "Queued",
running: "Running",
succeeded: "Succeeded",
failed: "Failed",
cancelled: "Cancelled",
timed_out: "Timed out",
interrupted: "Interrupted",
};
export function RunStatusBadge({ status }: { status: RunStatus }) {
return (
<Badge
className={cn(
"gap-1.5 border-transparent font-mono text-[0.7rem] font-medium tracking-wide uppercase",
styles[status],
)}
variant="outline"
>
<span
className={cn(
"size-1.5 shrink-0 rounded-full bg-current",
status === "running" && "animate-pulse",
)}
/>
{runStatusLabels[status]}
</Badge>
);
}
@@ -74,13 +74,13 @@ export function RunTerminal({
}
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="border-border overflow-hidden rounded-lg border">
<div className="bg-muted/40 flex items-center justify-between gap-3 border-b px-3 py-2">
<div className="flex items-center gap-3">
<RunStatusBadge status={status} />
{exitCode !== null && (
<span className="text-muted-foreground text-xs">
exit code {exitCode}
<span className="text-muted-foreground font-mono text-xs">
exit {exitCode}
</span>
)}
</div>
@@ -10,9 +10,9 @@ export interface XtermViewHandle {
}
const theme = {
background: "#09090b",
foreground: "#f4f4f5",
cursor: "#f4f4f5",
background: "#101215",
foreground: "#edeef0",
cursor: "#e9a857",
selectionBackground: "#3f3f46",
black: "#18181b",
red: "#f87171",
@@ -95,7 +95,7 @@ export const XtermView = forwardRef<XtermViewHandle, XtermViewProps>(
return (
<div
ref={containerRef}
className="h-[60vh] overflow-hidden rounded-lg bg-zinc-950 p-2"
className="h-[60vh] overflow-hidden bg-[#101215] p-2"
/>
);
},

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