16 Commits
Author SHA1 Message Date
valknarandClaude Sonnet 5 e5a1b7ce53 Rename package to scoped @valknar/triggershell
CI / Checks (push) Successful in 46s
CI / Publish to npm registry (push) Successful in 48s
Matches vpinball-wasm's move back to a scoped name: an unscoped
package on this Gitea npm registry can't be mapped via a normal
`.npmrc` `@scope:registry=` entry, since the registry doesn't proxy
npmjs.org. Scoping lets any consumer add one registry line instead of
pinning a tarball URL per dependency.

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 09:54:16 +02:00
valknar 3272b9db76 fix: actually stage the checks/publish split
CI / Checks (push) Successful in 48s
CI / Publish to npm registry (push) Skipped
The previous commit renamed release.yaml to ci.yml but never staged
the content edit underneath it (git mv picked up the last-staged
version, not the unstaged working-tree changes) - it pushed with the
filename changed but the workflow itself untouched. This is the
content that commit was supposed to carry.
2026-08-17 17:38:31 +02:00
valknar 06edc60b55 ci: run checks on every push, publish only on tag
Same split as pulsenode's workflow: previously this only ran at all
when pushing a version tag, so lint/typecheck/format/test never ran on
regular commits or PRs - a broken push could sit unnoticed until
someone tried to cut a release. Now checks run on every push and PR;
publish to the npm registry stays gated to a tag push and requires
checks to pass first. Renamed release.yaml -> ci.yml to match.
2026-08-17 17:37:01 +02:00
valknarandClaude Sonnet 5 14158047ac Make the README link in docs/API.md absolute
Release / release (push) Successful in 1m6s
The docs viewer serves docs/*.md at /docs/<slug> but doesn't ship
README.md as a route, so the relative ../README.md link resolved to
a dead /README.md path in the rendered web UI. Point it at the file's
Gitea URL instead.

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 09:23:31 +02:00
valknarandClaude Sonnet 5 5574ffd1c8 Add a minimal 404 page matching the app's style
Two boundaries share the same content: src/app/not-found.tsx catches
genuinely unmatched URLs (rendered bare in the root layout), and
(app)/not-found.tsx catches notFound() calls from within app routes
(already used by scripts/[scriptId] and runs/[runId]) so it renders
nested inside AppLayout, keeping nav and footer visible.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 03:12:19 +02:00
valknarandClaude Sonnet 5 adeb21be19 Fix Re-run button squeezing the run metadata row in the header
CardAction's default row-span-2 reserved header column 2 across both
the title row and the metadata dl row below it, shrinking the dl's
available width (cramping the Run ID column) to fit around the
button. Scope the button to just the title row and let the dl span
the full header width on its own row.

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 20:37:11 +02:00
valknar b77041cfa8 Size the run terminal by aspect ratio instead of viewport height
h-[60vh] made the terminal's height track the viewport regardless of
its actual width, so it read as too tall on narrower layouts.
aspect-video keeps it at 16/9 relative to its own width instead.
2026-08-16 18:15:17 +02:00
29 changed files with 593 additions and 40 deletions
@@ -1,12 +1,12 @@
name: Release
name: CI
on:
push:
tags:
- "v*.*.*"
pull_request:
jobs:
release:
checks:
name: Checks
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
@@ -26,6 +26,24 @@ jobs:
- run: pnpm run format:check
- run: pnpm run test
publish:
name: Publish to npm registry
if: startsWith(github.ref, 'refs/tags/')
needs: checks
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/pnpm/action-setup@v4
with:
version: 11.21.0
- uses: https://github.com/actions/setup-node@v4
with:
node-version: 22
- run: pnpm install --frozen-lockfile
- name: Set package version from the tag
run: npm pkg set version="${GITHUB_REF_NAME#v}"
+2 -1
View File
@@ -24,7 +24,7 @@ API for automation.
## Quickstart
```bash
npm install -g triggershell # or: npx triggershell <command> for one-off use
npm install -g @valknar/triggershell # or: npx @valknar/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 start # start the app and open the browser
@@ -113,6 +113,7 @@ the script — always as a discrete argv element or env var, never interpolated
| `triggershell service install [--system]` | Install a systemd unit that runs `triggershell start` (per-user by default, Linux only) |
| `triggershell service uninstall [--system]` | Stop, disable, and remove the systemd unit |
| `triggershell service status [--system]` | Show the systemd unit's status |
| `triggershell service logs [-n LINES] [--no-follow] [--system]` | Tail the systemd unit's logs (wraps `journalctl -o cat`, so each line is raw JSON - pipe through `jq` for pretty-printing) |
### Running scripts from the CLI
+2 -2
View File
@@ -7,8 +7,8 @@ When `auth.enabled: true`, every endpoint below except `/api/healthz`, `/api/aut
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.
[Running scripts from the CLI](https://dev.pivoine.art/valknar/triggershell/src/branch/main/README.md#running-scripts-from-the-cli)) -
nothing below is CLI-specific.
## Auth
+5 -3
View File
@@ -74,9 +74,11 @@ installs a per-user unit (`~/.config/systemd/user/triggershell.service`, no root
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.
actually starts listening and running scripts. `triggershell service status`/`uninstall`/`logs` are
thin wrappers around `systemctl`/`journalctl` respectively - no unit-file parsing or log storage of
our own, journald already does that. `logs` passes `-o cat` so each line is the raw pino JSON
payload rather than journalctl's own timestamp/hostname/unit prefix - pipeable straight into `jq`
for pretty-printing without pulling `pino-pretty` into the CLI's runtime dependencies.
## Cross-module-graph state
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "triggershell",
"version": "0.1.0",
"name": "@valknar/triggershell",
"version": "1.2.0",
"license": "MIT",
"type": "module",
"repository": {
@@ -60,6 +60,7 @@
"lucide-react": "^1.31.0",
"next": "16.3.1",
"next-themes": "^0.4.6",
"pino": "^10.3.1",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-hook-form": "^7.85.0",
@@ -86,6 +87,7 @@
"drizzle-kit": "^0.31.10",
"eslint": "^9",
"eslint-config-next": "16.3.1",
"pino-pretty": "^13.1.3",
"prettier": "^3.9.6",
"prettier-plugin-tailwindcss": "^0.8.1",
"tailwindcss": "^4",
+178
View File
@@ -59,6 +59,9 @@ importers:
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
pino:
specifier: ^10.3.1
version: 10.3.1
react:
specifier: 19.2.8
version: 19.2.8
@@ -132,6 +135,9 @@ importers:
eslint-config-next:
specifier: 16.3.1
version: 16.3.1(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
pino-pretty:
specifier: ^13.1.3
version: 13.1.3
prettier:
specifier: ^3.9.6
version: 3.9.6
@@ -1358,6 +1364,9 @@ packages:
resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==}
engines: {node: '>=10'}
'@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
'@radix-ui/primitive@1.1.7':
resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==}
@@ -1987,6 +1996,10 @@ packages:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
atomically@1.7.0:
resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==}
engines: {node: '>=10.12.0'}
@@ -2136,6 +2149,9 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
@@ -2226,6 +2242,9 @@ packages:
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
engines: {node: '>= 0.4'}
dateformat@4.6.3:
resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==}
debounce-fn@4.0.0:
resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==}
engines: {node: '>=10'}
@@ -2439,6 +2458,9 @@ packages:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'}
end-of-stream@1.4.5:
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
enhanced-resolve@5.24.5:
resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==}
engines: {node: '>=10.13.0'}
@@ -2685,6 +2707,9 @@ packages:
extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
fast-copy@4.0.4:
resolution: {integrity: sha512-eVAiWVNPSEGIzDl5yPuLrx8fNMogScXvD9xp1Kzd41FjRIz2I3sSIcxsFeM5EzFfHAfobdvs8ZySffUopljvIA==}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -2702,6 +2727,9 @@ packages:
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
fast-safe-stringify@2.1.1:
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
fast-string-truncated-width@3.0.3:
resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==}
@@ -2894,6 +2922,9 @@ packages:
hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
help-me@5.0.0:
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
hermes-estree@0.25.1:
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
@@ -3172,6 +3203,10 @@ packages:
jose@6.2.8:
resolution: {integrity: sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==}
joycon@3.1.1:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -3649,6 +3684,10 @@ packages:
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
engines: {node: '>= 0.4'}
on-exit-leak-free@2.1.2:
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
engines: {node: '>=14.0.0'}
on-finished@2.4.1:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
@@ -3759,6 +3798,20 @@ packages:
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
engines: {node: '>=12'}
pino-abstract-transport@3.0.0:
resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==}
pino-pretty@13.1.3:
resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==}
hasBin: true
pino-std-serializers@7.1.0:
resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==}
pino@10.3.1:
resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
hasBin: true
pkce-challenge@5.0.1:
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
engines: {node: '>=16.20.0'}
@@ -3863,6 +3916,9 @@ packages:
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
engines: {node: '>=18'}
process-warning@5.1.0:
resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==}
prompts@2.4.2:
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
engines: {node: '>= 6'}
@@ -3877,6 +3933,9 @@ packages:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
pump@3.0.4:
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -3888,6 +3947,9 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
range-parser@1.3.0:
resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==}
engines: {node: '>= 0.6'}
@@ -3950,6 +4012,13 @@ packages:
resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
engines: {node: '>=0.10.0'}
real-require@0.2.0:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'}
real-require@1.0.0:
resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==}
recast@0.23.21:
resolution: {integrity: sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==}
engines: {node: '>= 4'}
@@ -4024,12 +4093,19 @@ packages:
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
engines: {node: '>= 0.4'}
safe-stable-stringify@2.5.0:
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
engines: {node: '>=10'}
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
secure-json-parse@4.1.0:
resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
@@ -4118,6 +4194,9 @@ packages:
resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==}
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
sonic-boom@4.2.1:
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
sonner@2.0.8:
resolution: {integrity: sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==}
peerDependencies:
@@ -4142,6 +4221,10 @@ packages:
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
@@ -4215,6 +4298,10 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
strip-json-comments@5.0.3:
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
engines: {node: '>=14.16'}
style-to-js@1.1.21:
resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
@@ -4258,6 +4345,10 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
thread-stream@4.2.0:
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
engines: {node: '>=20'}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -5447,6 +5538,8 @@ snapshots:
'@phc/format@1.0.0': {}
'@pinojs/redact@0.4.0': {}
'@radix-ui/primitive@1.1.7': {}
'@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.18)(react@19.2.8)':
@@ -6031,6 +6124,8 @@ snapshots:
async-function@1.0.0: {}
atomic-sleep@1.0.0: {}
atomically@1.7.0: {}
available-typed-arrays@1.0.7:
@@ -6172,6 +6267,8 @@ snapshots:
color-name@1.1.4: {}
colorette@2.0.20: {}
comma-separated-tokens@2.0.3: {}
commander@11.1.0: {}
@@ -6256,6 +6353,8 @@ snapshots:
es-errors: 1.3.0
is-data-view: 1.0.2
dateformat@4.6.3: {}
debounce-fn@4.0.0:
dependencies:
mimic-fn: 3.1.0
@@ -6357,6 +6456,10 @@ snapshots:
encodeurl@2.0.0: {}
end-of-stream@1.4.5:
dependencies:
once: 1.4.0
enhanced-resolve@5.24.5:
dependencies:
graceful-fs: 4.2.11
@@ -6877,6 +6980,8 @@ snapshots:
extend@3.0.2: {}
fast-copy@4.0.4: {}
fast-deep-equal@3.1.3: {}
fast-glob@3.3.1:
@@ -6899,6 +7004,8 @@ snapshots:
fast-levenshtein@2.0.6: {}
fast-safe-stringify@2.1.1: {}
fast-string-truncated-width@3.0.3: {}
fast-string-width@3.0.2:
@@ -7105,6 +7212,8 @@ snapshots:
dependencies:
'@types/hast': 3.0.5
help-me@5.0.0: {}
hermes-estree@0.25.1: {}
hermes-parser@0.25.1:
@@ -7350,6 +7459,8 @@ snapshots:
jose@6.2.8: {}
joycon@3.1.1: {}
js-tokens@4.0.0: {}
js-yaml@4.3.1:
@@ -7986,6 +8097,8 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.2
on-exit-leak-free@2.1.2: {}
on-finished@2.4.1:
dependencies:
ee-first: 1.1.1
@@ -8108,6 +8221,42 @@ snapshots:
picomatch@4.0.5: {}
pino-abstract-transport@3.0.0:
dependencies:
split2: 4.2.0
pino-pretty@13.1.3:
dependencies:
colorette: 2.0.20
dateformat: 4.6.3
fast-copy: 4.0.4
fast-safe-stringify: 2.1.1
help-me: 5.0.0
joycon: 3.1.1
minimist: 1.2.8
on-exit-leak-free: 2.1.2
pino-abstract-transport: 3.0.0
pump: 3.0.4
secure-json-parse: 4.1.0
sonic-boom: 4.2.1
strip-json-comments: 5.0.3
pino-std-serializers@7.1.0: {}
pino@10.3.1:
dependencies:
'@pinojs/redact': 0.4.0
atomic-sleep: 1.0.0
on-exit-leak-free: 2.1.2
pino-abstract-transport: 3.0.0
pino-std-serializers: 7.1.0
process-warning: 5.1.0
quick-format-unescaped: 4.0.4
real-require: 0.2.0
safe-stable-stringify: 2.5.0
sonic-boom: 4.2.1
thread-stream: 4.2.0
pkce-challenge@5.0.1: {}
pkg-up@3.1.0:
@@ -8154,6 +8303,8 @@ snapshots:
dependencies:
parse-ms: 4.0.0
process-warning@5.1.0: {}
prompts@2.4.2:
dependencies:
kleur: 3.0.3
@@ -8172,6 +8323,11 @@ snapshots:
forwarded: 0.2.0
ipaddr.js: 1.9.1
pump@3.0.4:
dependencies:
end-of-stream: 1.4.5
once: 1.4.0
punycode@2.3.1: {}
qs@6.15.3:
@@ -8181,6 +8337,8 @@ snapshots:
queue-microtask@1.2.3: {}
quick-format-unescaped@4.0.4: {}
range-parser@1.3.0: {}
raw-body@3.0.2:
@@ -8248,6 +8406,10 @@ snapshots:
react@19.2.8: {}
real-require@0.2.0: {}
real-require@1.0.0: {}
recast@0.23.21:
dependencies:
ast-types: 0.16.1
@@ -8369,10 +8531,14 @@ snapshots:
es-errors: 1.3.0
is-regex: 1.2.1
safe-stable-stringify@2.5.0: {}
safer-buffer@2.1.2: {}
scheduler@0.27.0: {}
secure-json-parse@4.1.0: {}
semver@6.3.1: {}
semver@7.8.5: {}
@@ -8548,6 +8714,10 @@ snapshots:
ip-address: 10.5.0
smart-buffer: 4.2.0
sonic-boom@4.2.1:
dependencies:
atomic-sleep: 1.0.0
sonner@2.0.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies:
react: 19.2.8
@@ -8566,6 +8736,8 @@ snapshots:
space-separated-tokens@2.0.2: {}
split2@4.2.0: {}
stable-hash@0.0.5: {}
statuses@2.0.2: {}
@@ -8661,6 +8833,8 @@ snapshots:
strip-json-comments@3.1.1: {}
strip-json-comments@5.0.3: {}
style-to-js@1.1.21:
dependencies:
style-to-object: 1.0.14
@@ -8690,6 +8864,10 @@ snapshots:
tapable@2.3.3: {}
thread-stream@4.2.0:
dependencies:
real-require: 1.0.0
tiny-invariant@1.3.3: {}
tinyglobby@0.2.17:
+49 -6
View File
@@ -1,8 +1,10 @@
import "./src/bootstrap/async-local-storage-polyfill";
import { randomUUID } from "node:crypto";
import { createServer } from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import next from "next";
import type { Level } from "pino";
import { WebSocketServer } from "ws";
import { getConfig } from "./src/lib/config/load";
import { migrateOnBoot } from "./src/lib/db/client";
@@ -10,6 +12,9 @@ import { syncAuthFromConfig } from "./src/lib/auth/sync";
import { reconcileOrphanedRuns } from "./src/lib/runner/engine";
import { killAllRuns } from "./src/lib/runner/registry";
import { attachWsServer, authenticateUpgrade } from "./src/lib/ws/server";
import { logger } from "./src/lib/logger";
const log = logger.child({ mod: "server" });
const dev = process.env.NODE_ENV !== "production";
const { config } = getConfig();
@@ -31,11 +36,41 @@ process.env.TRIGGERSHELL_APP_ROOT = dir;
const app = next({ dev, dir, hostname, port });
const handle = app.getRequestHandler();
// `/_next/*` asset requests happen dozens of times per page load and carry no operational
// signal - logged at debug so they don't drown out page/API requests in the default info level.
function accessLogLevel(pathname: string, statusCode: number): Level {
if (statusCode >= 500) return "error";
if (statusCode >= 400) return "warn";
return pathname.startsWith("/_next/") ? "debug" : "info";
}
app.prepare().then(() => {
const nextUpgradeHandler = app.getUpgradeHandler();
const httpServer = createServer((req, res) => {
handle(req, res);
const reqId = req.headers["x-request-id"]?.toString() ?? randomUUID();
req.headers["x-request-id"] = reqId;
const startedAt = process.hrtime.bigint();
res.on("finish", () => {
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1e6;
const { pathname } = new URL(req.url ?? "/", "http://internal");
log[accessLogLevel(pathname, res.statusCode)](
{
reqId,
method: req.method,
path: pathname,
status: res.statusCode,
durationMs: Math.round(durationMs),
},
"request",
);
});
handle(req, res).catch((error: unknown) => {
log.error({ reqId, err: error }, "unhandled error handling request");
if (!res.headersSent) res.writeHead(500).end();
});
});
const wss = new WebSocketServer({ noServer: true });
@@ -52,6 +87,7 @@ app.prepare().then(() => {
authenticateUpgrade(req)
.then((ok) => {
if (!ok) {
log.warn({ path: pathname }, "rejected unauthenticated WS upgrade");
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
socket.destroy();
return;
@@ -60,21 +96,28 @@ app.prepare().then(() => {
wss.emit("connection", ws, req);
});
})
.catch(() => socket.destroy());
.catch((error: unknown) => {
log.error({ err: error }, "error authenticating WS upgrade");
socket.destroy();
});
});
httpServer.listen(port, hostname, () => {
console.log(
`> triggershell ready on http://${hostname}:${port} (${dev ? "development" : "production"})`,
log.info(
{ hostname, port, mode: dev ? "development" : "production" },
"triggershell ready",
);
});
const shutdown = (signal: string) => {
console.log(`> received ${signal}, shutting down...`);
log.info({ signal }, "shutting down");
killAllRuns();
httpServer.close(() => process.exit(0));
// Force-exit if graceful shutdown hangs (e.g. a stuck WS connection).
setTimeout(() => process.exit(1), 5000).unref();
setTimeout(() => {
log.warn("graceful shutdown timed out, forcing exit");
process.exit(1);
}, 5000).unref();
};
process.on("SIGTERM", () => shutdown("SIGTERM"));
+1 -1
View File
@@ -27,7 +27,7 @@ export default async function DocPage({ params }: DocPageProps) {
if (content === null) notFound();
return (
<div className="mx-auto flex max-w-3xl flex-col gap-6">
<div className="mx-auto flex max-w-5xl flex-col gap-6">
<Link
href="/docs"
className="text-muted-foreground hover:text-foreground flex w-fit items-center gap-1 text-sm"
+5
View File
@@ -0,0 +1,5 @@
import { NotFoundContent } from "@/components/layout/not-found-content";
export default function NotFound() {
return <NotFoundContent />;
}
+4 -4
View File
@@ -66,12 +66,12 @@ export default async function RunDetailPage({ params }: RunDetailPageProps) {
}));
return (
<div className="mx-auto flex max-w-2xl flex-col gap-4">
<div className="mx-auto flex max-w-5xl flex-col gap-4">
<Card>
<CardHeader>
<CardTitle>{run.scriptName}</CardTitle>
{script && (
<CardAction>
<CardAction className="row-span-1">
<Link
href={`/scripts/${run.scriptId}?fromRun=${run.id}`}
className={buttonVariants({ variant: "outline", size: "sm" })}
@@ -81,7 +81,7 @@ export default async function RunDetailPage({ params }: RunDetailPageProps) {
</Link>
</CardAction>
)}
<dl className="text-muted-foreground grid grid-cols-2 gap-x-4 gap-y-2 text-xs sm:grid-cols-3">
<dl className="text-muted-foreground col-span-2 grid grid-cols-2 gap-x-4 gap-y-2 text-xs sm:grid-cols-3">
<div>
<dt className="font-mono text-[0.7rem] font-medium tracking-widest uppercase">
Triggered by
@@ -119,7 +119,7 @@ export default async function RunDetailPage({ params }: RunDetailPageProps) {
<span className="text-muted-foreground font-mono text-[0.7rem] font-medium tracking-widest uppercase">
Variables
</span>
<dl className="grid gap-x-6 gap-y-2 rounded-md border p-3 text-xs sm:grid-cols-2">
<dl className="grid gap-x-6 gap-y-2 rounded-md border p-3 text-xs sm:grid-cols-2 lg:grid-cols-3">
{variableEntries.map(({ key, label, value }) => (
<div key={key} className="flex flex-col gap-0.5">
<dt className="text-muted-foreground">{label}</dt>
+1 -1
View File
@@ -55,7 +55,7 @@ export default async function ScriptPage({
}
return (
<div className="mx-auto max-w-2xl">
<div className="mx-auto max-w-5xl">
<Card>
<CardHeader>
<CardTitle>{script.name}</CardTitle>
+10
View File
@@ -11,6 +11,9 @@ import {
recordFailedAttempt,
clearAttempts,
} from "@/lib/auth/rate-limit";
import { logger } from "@/lib/logger";
const log = logger.child({ mod: "auth" });
const loginSchema = z.object({
username: z.string().min(1),
@@ -20,6 +23,7 @@ const loginSchema = z.object({
export async function POST(request: Request) {
const rateLimitKey = request.headers.get("x-forwarded-for") ?? "local";
if (isRateLimited(rateLimitKey)) {
log.warn({ from: rateLimitKey }, "login rate-limited");
return Response.json(
{ error: "Too many attempts, try again later." },
{ status: 429 },
@@ -44,6 +48,10 @@ export async function POST(request: Request) {
!(await verifyPassword(user.passwordHash, parsed.data.password))
) {
recordFailedAttempt(rateLimitKey);
log.warn(
{ from: rateLimitKey, username: parsed.data.username },
"login failed: invalid credentials",
);
return Response.json({ error: "Invalid credentials" }, { status: 401 });
}
@@ -58,5 +66,7 @@ export async function POST(request: Request) {
session.username = user.username;
await session.save();
log.info({ from: rateLimitKey, username: user.username }, "login succeeded");
return Response.json({ user: { username: user.username } });
}
+8
View File
@@ -5,6 +5,9 @@ import { requireAuth, unauthorizedResponse } from "@/lib/auth/guard";
import { getDb } from "@/lib/db/client";
import { runs } from "@/lib/db/schema";
import { cancelRun } from "@/lib/runner/registry";
import { logger } from "@/lib/logger";
const log = logger.child({ mod: "api" });
export async function POST(
request: Request,
@@ -27,11 +30,16 @@ export async function POST(
const cancelled = cancelRun(runId);
if (!cancelled) {
log.warn(
{ runId },
"cancel requested for a run not tracked by this process",
);
return Response.json(
{ error: "Run is not active in this server process" },
{ status: 409 },
);
}
log.info({ runId, requestedBy: auth.identity }, "cancel requested via API");
return Response.json({ status: "cancelling" }, { status: 202 });
}
+9 -4
View File
@@ -4,6 +4,9 @@ import { requireAuth, unauthorizedResponse } from "@/lib/auth/guard";
import { getScript } from "@/lib/config/load";
import { buildVariableSchema } from "@/lib/validation/variable-schema";
import { startRun } from "@/lib/runner/engine";
import { logger } from "@/lib/logger";
const log = logger.child({ mod: "api" });
export async function POST(
request: Request,
@@ -26,11 +29,13 @@ export async function POST(
const variableSchema = buildVariableSchema(script);
const parsed = variableSchema.safeParse(variablesInput);
if (!parsed.success) {
const fieldErrors = parsed.error.flatten().fieldErrors;
log.warn(
{ scriptId: script.id, fields: Object.keys(fieldErrors) },
"run request failed variable validation",
);
return Response.json(
{
error: "Validation failed",
fieldErrors: parsed.error.flatten().fieldErrors,
},
{ error: "Validation failed", fieldErrors },
{ status: 400 },
);
}
+5
View File
@@ -0,0 +1,5 @@
import { NotFoundContent } from "@/components/layout/not-found-content";
export default function NotFound() {
return <NotFoundContent />;
}
+23
View File
@@ -126,3 +126,26 @@ export async function serviceStatusCommand(
});
process.exitCode = result.exitCode ?? 1;
}
export interface ServiceLogsOptions extends ServiceScopeOptions {
follow?: boolean;
lines?: number;
}
export async function serviceLogsCommand(
opts: ServiceLogsOptions,
): Promise<void> {
const scope = scopeOf(opts);
const args = scope === "user" ? ["--user"] : [];
// -o cat strips journalctl's own prefix (timestamp/hostname/unit) so each line is the raw pino
// JSON payload - pipeable straight into `jq` or similar without journalctl's wrapper in the way.
args.push("-u", SERVICE_NAME, "-o", "cat");
if (opts.follow !== false) args.push("-f");
if (opts.lines !== undefined) args.push("-n", String(opts.lines));
const result = await execa("journalctl", args, {
stdio: "inherit",
reject: false,
});
process.exitCode = result.exitCode ?? 1;
}
+18
View File
@@ -5,6 +5,7 @@ import { runCommand } from "./commands/run";
import { scriptsListCommand, scriptsShowCommand } from "./commands/scripts";
import {
serviceInstallCommand,
serviceLogsCommand,
serviceStatusCommand,
serviceUninstallCommand,
} from "./commands/service";
@@ -180,6 +181,23 @@ service
)
.action(serviceStatusCommand);
service
.command("logs")
.description("Tail the systemd unit's logs (journalctl).")
.option("-n, --lines <n>", "Number of recent log lines to show.", (v) =>
Number(v),
)
.option(
"--no-follow",
"Print recent logs and exit instead of tailing continuously.",
)
.option(
"--system",
"Target the system-wide unit instead of the per-user one.",
false,
)
.action(serviceLogsCommand);
if (process.argv.length <= 2) {
program.outputHelp();
process.exit(1);
+19 -1
View File
@@ -2,6 +2,19 @@ import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { cn } from "@/lib/utils";
// GFM tables (the config reference's Field/Type/Default/Notes tables) are wider than a phone
// screen and don't wrap - without their own scroll container the table forces the whole page
// to scroll horizontally instead. The typography plugin's table styles still apply to `table`
// here (its selectors match any descendant, not just direct children of `.prose`), so this
// wrapper only adds the scroll boundary.
function Table(props: React.ComponentProps<"table">) {
return (
<div className="overflow-x-auto">
<table {...props} />
</div>
);
}
export function MarkdownViewer({ content }: { content: string }) {
return (
<div
@@ -13,9 +26,14 @@ export function MarkdownViewer({ content }: { content: string }) {
// elsewhere in the app (see the "no scripts configured" message on the dashboard).
"prose-code:before:content-none prose-code:after:content-none",
"prose-code:rounded prose-code:bg-muted prose-code:px-1.5 prose-code:py-0.5 prose-code:font-mono prose-code:font-normal prose-code:text-foreground",
// Long unbroken strings (env var names, paths) in table cells or inline code would
// otherwise force their column/line wider than the viewport instead of wrapping.
"prose-td:break-words prose-th:break-words prose-code:break-words",
)}
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={{ table: Table }}>
{content}
</ReactMarkdown>
</div>
);
}
+21 -3
View File
@@ -14,6 +14,11 @@ import { defaultValuesForScript } from "@/lib/config/defaults";
import type { ClientScript } from "@/lib/config/serialize";
import { FieldRenderer } from "./field-renderer";
/** Controls whose content doesn't shrink well into a narrow grid column - long-form text,
* or a group of checkboxes that reads better as a single wide list - so they span the full
* grid width instead of sharing a row with other fields. */
const WIDE_CONTROLS = new Set(["textarea", "checkboxGroup"]);
/** `initialValues` comes from a previous run's (already-redacted) variables when re-running -
* secret fields are deliberately excluded there (their stored value is just "***", not the real
* one), so those always fall through to the normal default/empty state and have to be re-entered. */
@@ -93,9 +98,22 @@ export function DynamicForm({
This script takes no parameters.
</p>
)}
{script.variables.map((variable) => (
<FieldRenderer key={variable.name} variable={variable} />
))}
{script.variables.length > 0 && (
<div className="grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2 lg:grid-cols-3">
{script.variables.map((variable) => (
<div
key={variable.name}
className={
WIDE_CONTROLS.has(variable.control)
? "sm:col-span-2 lg:col-span-3"
: undefined
}
>
<FieldRenderer variable={variable} />
</div>
))}
</div>
)}
<Button
type="submit"
disabled={form.formState.isSubmitting}
@@ -0,0 +1,29 @@
import Link from "next/link";
import { Home } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function NotFoundContent() {
return (
<div className="mx-auto flex max-w-md flex-col items-center gap-3 py-24 text-center">
<span className="text-primary font-mono text-6xl font-semibold tracking-tight">
404
</span>
<h1 className="font-heading text-xl font-medium">Page not found</h1>
<p className="text-muted-foreground text-sm">
The page you&rsquo;re looking for doesn&rsquo;t exist or may have been
moved.
</p>
<Link
href="/"
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"mt-2",
)}
>
<Home className="size-3.5" />
Back to dashboard
</Link>
</div>
);
}
+1 -1
View File
@@ -95,7 +95,7 @@ export const XtermView = forwardRef<XtermViewHandle, XtermViewProps>(
return (
<div
ref={containerRef}
className="h-[60vh] overflow-hidden bg-[#101215] p-2"
className="aspect-video w-full overflow-hidden bg-[#101215] p-2"
/>
);
},
+15 -1
View File
@@ -2,12 +2,18 @@ import { eq } from "drizzle-orm";
import { getDb } from "../db/client";
import { users, apiTokens } from "../db/schema";
import { getConfig } from "../config/load";
import { logger } from "../logger";
const log = logger.child({ mod: "auth" });
/** Config is the source of truth for who's allowed in; this materializes it into SQLite so the
* runtime auth-check path is uniform and `lastLoginAt`/`lastUsedAt` can be tracked. Called on boot. */
export function syncAuthFromConfig() {
const { config } = getConfig();
if (!config.auth.enabled) return;
if (!config.auth.enabled) {
log.debug("auth disabled, skipping config sync");
return;
}
const db = getDb();
@@ -53,4 +59,12 @@ export function syncAuthFromConfig() {
.run();
}
}
log.info(
{
userCount: config.auth.users.length,
tokenCount: config.auth.tokens.length,
},
"synced auth config",
);
}
+12
View File
@@ -2,6 +2,9 @@ import fs from "node:fs";
import path from "node:path";
import { parse as parseYaml } from "yaml";
import { configSchema, type TriggerShellConfig } from "./schema";
import { logger } from "../logger";
const log = logger.child({ mod: "config" });
export class ConfigError extends Error {
issues: string[];
@@ -90,6 +93,15 @@ declare global {
export function getConfig(): LoadedConfig {
if (!globalThis.__triggershellConfig) {
globalThis.__triggershellConfig = loadConfig();
const { config, configPath } = globalThis.__triggershellConfig;
log.info(
{
configPath,
scriptCount: config.scripts.length,
authEnabled: config.auth.enabled,
},
"config loaded",
);
}
return globalThis.__triggershellConfig;
}
+5
View File
@@ -5,6 +5,9 @@ import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import * as schema from "./schema";
import { getConfig } from "../config/load";
import { logger } from "../logger";
const log = logger.child({ mod: "db" });
type Db = ReturnType<typeof drizzle<typeof schema>>;
@@ -25,6 +28,7 @@ export function getDb(): Db {
sqlite.pragma("journal_mode = WAL");
sqlite.pragma("foreign_keys = ON");
log.debug({ dbPath }, "opened database");
globalThis.__triggershellDb = drizzle(sqlite, { schema });
return globalThis.__triggershellDb;
}
@@ -37,4 +41,5 @@ export function migrateOnBoot() {
"../../../drizzle",
);
migrate(db, { migrationsFolder });
log.info("database migrations applied");
}
+65
View File
@@ -0,0 +1,65 @@
import pino from "pino";
const isProduction = process.env.NODE_ENV === "production";
const level = process.env.LOG_LEVEL ?? (isProduction ? "info" : "debug");
function createLogger(): pino.Logger {
return pino({
name: "triggershell",
level,
// Values are never logged under these key names in the first place - this is defense in
// depth against a future call site accidentally passing one through.
redact: {
paths: [
"password",
"*.password",
"token",
"*.token",
"passwordHash",
"*.passwordHash",
"tokenHash",
"*.tokenHash",
"sessionSecret",
"*.sessionSecret",
"authorization",
"*.authorization",
"cookie",
"*.cookie",
],
censor: "[REDACTED]",
},
transport: isProduction
? undefined
: {
target: "pino-pretty",
options: { colorize: true, ignore: "pid,hostname" },
},
});
}
declare global {
var __triggershellLogger: pino.Logger | undefined;
}
// Anchored on `globalThis` - see the comment in `runner/events.ts` for why: Next compiles Route
// Handlers through a separate module graph from what `server.ts` requires directly.
//
// Lazily constructed behind a Proxy rather than built at module-evaluation time: `config/load.ts`
// (imported by every CLI command for `loadConfig()`) also imports this module, and the dev-mode
// pretty transport spins up a worker thread the instant `pino({ transport })` runs. Without the
// laziness, a plain CLI invocation like `triggershell scripts list` would pay for a logger no
// backend code path in that process will ever call.
function ensureLogger(): pino.Logger {
if (!globalThis.__triggershellLogger) {
globalThis.__triggershellLogger = createLogger();
}
return globalThis.__triggershellLogger;
}
export const logger: pino.Logger = new Proxy({} as pino.Logger, {
get(_target, prop) {
const target = ensureLogger();
const value = Reflect.get(target, prop, target);
return typeof value === "function" ? value.bind(target) : value;
},
});
+14 -2
View File
@@ -18,6 +18,15 @@ function stringifyValue(value: unknown, joinWith: string): string {
return String(value);
}
/** Quotes a value for the human-readable `redactedCommandLine` display only - the real
* invocation always passes values as discrete argv elements/env vars (see build note below),
* so this never affects execution. Without it, a value like "Glitz and glam" renders as three
* bare words indistinguishable from separate argv entries. */
function quoteForDisplay(value: string): string {
if (value !== "" && /^[a-zA-Z0-9_@%+=:,./-]+$/.test(value)) return value;
return `"${value.replace(/([$`"\\])/g, "\\$1")}"`;
}
/** Builds an argv-array invocation from validated variable values. Never produces a shell string. */
export function buildInvocation(
script: ScriptConfig,
@@ -43,7 +52,10 @@ export function buildInvocation(
const argName = variable.argName!;
const value = stringifyValue(raw, variable.joinWith);
argv.push(argName, value);
redactedArgv.push(argName, variable.secret ? REDACTED : value);
redactedArgv.push(
argName,
variable.secret ? REDACTED : quoteForDisplay(value),
);
break;
}
case "flag": {
@@ -57,7 +69,7 @@ export function buildInvocation(
const value = stringifyValue(raw, variable.joinWith);
env[variable.envName!] = value;
redactedEnvAssignments.push(
`${variable.envName}=${variable.secret ? REDACTED : value}`,
`${variable.envName}=${variable.secret ? REDACTED : quoteForDisplay(value)}`,
);
break;
}
+40 -2
View File
@@ -9,6 +9,9 @@ import type { ScriptConfig } from "../config/schema";
import { buildInvocation, type Invocation } from "./build-args";
import { registerRun, unregisterRun } from "./registry";
import { emitRunMessage } from "./events";
import { logger } from "../logger";
const log = logger.child({ mod: "runner" });
export class ScriptNotFoundError extends Error {}
@@ -49,9 +52,14 @@ export async function startRun({
})
.run();
log.info({ runId, scriptId: script.id, triggeredBy }, "run queued");
// Fire and forget - the caller gets the runId immediately, progress streams over WS/polling.
void executeRun(runId, script, invocation, logFilePath).catch((error) => {
console.error(`[runner] unhandled error executing run ${runId}:`, error);
log.error(
{ runId, scriptId: script.id, err: error },
"unhandled error executing run",
);
});
return runId;
@@ -103,6 +111,11 @@ async function executeRun(
registerRun({ runId, scriptId: script.id, controller });
setStatus("running", { startedAt: new Date() });
log.info(
{ runId, scriptId: script.id, command: script.command },
"run started",
);
const startedAt = process.hrtime.bigint();
try {
const subprocess = execa(script.command, invocation.argv, {
@@ -145,11 +158,26 @@ async function executeRun(
? (result.shortMessage ?? null)
: null,
});
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1e6;
log[status === "succeeded" ? "info" : "warn"](
{
runId,
scriptId: script.id,
status,
exitCode: result.exitCode ?? null,
durationMs: Math.round(durationMs),
},
"run finished",
);
} catch (error) {
setStatus("failed", {
endedAt: new Date(),
errorMessage: (error as Error).message,
});
log.error(
{ runId, scriptId: script.id, err: error },
"run failed to execute",
);
} finally {
logStream.end();
unregisterRun(runId);
@@ -170,7 +198,8 @@ export function reconcileOrphanedRuns() {
})
.where(eq(runs.status, "running"))
.run();
db.update(runs)
const queued = db
.update(runs)
.set({
status: "interrupted",
endedAt: now,
@@ -178,5 +207,14 @@ export function reconcileOrphanedRuns() {
})
.where(eq(runs.status, "queued"))
.run();
const interruptedCount = orphaned.changes + queued.changes;
if (interruptedCount > 0) {
log.warn(
{ runningCount: orphaned.changes, queuedCount: queued.changes },
"marked orphaned runs as interrupted after restart",
);
}
return orphaned;
}
+18 -2
View File
@@ -13,8 +13,11 @@ import { apiTokens, runs } from "../db/schema";
import { runEvents } from "../runner/events";
import { cancelRun } from "../runner/registry";
import { readLogSince } from "../runner/log-file";
import { logger } from "../logger";
import { isClientMessage, type ServerMessage } from "./protocol";
const log = logger.child({ mod: "ws" });
const subscriptions = new Map<string, Set<WebSocket>>();
/** A run can start and finish (emitting all its output+status over `runEvents`) before a
@@ -104,28 +107,41 @@ export async function authenticateUpgrade(
export function attachWsServer(wss: WebSocketServer) {
wss.on("connection", (ws: WebSocket) => {
const connId = crypto.randomUUID();
log.debug({ connId }, "connection opened");
ws.on("message", (raw) => {
let parsed: unknown;
try {
parsed = JSON.parse(raw.toString());
} catch {
log.warn({ connId }, "dropped unparseable WS message");
return;
}
if (!isClientMessage(parsed)) {
log.warn({ connId, parsed }, "dropped unrecognized WS message");
return;
}
if (!isClientMessage(parsed)) return;
switch (parsed.type) {
case "subscribe":
log.debug({ connId, runId: parsed.runId }, "subscribe");
subscribe(parsed.runId, ws, parsed.afterBytes ?? 0);
break;
case "unsubscribe":
log.debug({ connId, runId: parsed.runId }, "unsubscribe");
unsubscribe(parsed.runId, ws);
break;
case "cancel":
log.info({ connId, runId: parsed.runId }, "cancel requested via WS");
cancelRun(parsed.runId);
break;
}
});
ws.on("close", () => unsubscribeAll(ws));
ws.on("close", () => {
log.debug({ connId }, "connection closed");
unsubscribeAll(ws);
});
});
}
+8
View File
@@ -2,6 +2,9 @@ import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { getConfig } from "@/lib/config/load";
import { verifySessionCookieValue } from "@/lib/auth/session";
import { logger } from "@/lib/logger";
const log = logger.child({ mod: "proxy" });
const PUBLIC_PATHS = [
"/login",
@@ -39,8 +42,13 @@ export async function proxy(request: NextRequest) {
if (!session?.userId) {
if (isApiRoute) {
log.warn({ path: pathname }, "blocked unauthenticated API request");
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
log.debug(
{ path: pathname },
"redirected unauthenticated request to login",
);
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("next", pathname);
return NextResponse.redirect(loginUrl);