Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
677aabfa30 | ||
|
|
8ca8c57793 | ||
|
|
3f391ff584 | ||
|
|
ef453eadd0 | ||
|
|
a11dfd8f7e | ||
|
|
2a5b0b98f4 | ||
|
|
c6337ea942 | ||
|
|
e13b7e3b1a | ||
|
|
28407402c8 | ||
|
|
7e1569b94c | ||
|
|
f102ace7e8 | ||
|
|
3f379ca2ac | ||
|
|
30350d80f4 | ||
|
|
e2b6c6102c | ||
|
|
042391cdc3 | ||
|
|
dc34b38b26 | ||
|
|
346bf7995a | ||
|
|
1b415a7957 | ||
|
|
d7c0be6b22 | ||
|
|
75b92d103c | ||
|
|
3f656baad1 | ||
|
|
3131d8ce5b | ||
|
|
d4e4c2c7d2 | ||
|
|
004586ee64 | ||
|
|
e6008a22cd | ||
|
|
c3312f5dc6 | ||
|
|
79a7c8f5cb | ||
|
|
a4db53f44d | ||
|
|
35b0d95792 | ||
|
|
e779c5a93b | ||
|
|
acc6715a88 | ||
|
|
97bb6cc092 | ||
|
|
7daaa95ab9 | ||
|
|
6d7788e48b | ||
|
|
88f561230e | ||
|
|
7269886f9e |
@@ -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
|
||||||
@@ -1,16 +1,38 @@
|
|||||||
# Python
|
# dependencies
|
||||||
__pycache__/
|
/node_modules
|
||||||
*.py[cod]
|
/.pnpm-store
|
||||||
*.egg-info/
|
/.pnp
|
||||||
.eggs/
|
.pnp.*
|
||||||
build/
|
.yarn/*
|
||||||
dist/
|
!.yarn/patches
|
||||||
.venv/
|
!.yarn/plugins
|
||||||
venv/
|
!.yarn/releases
|
||||||
.pytest_cache/
|
!.yarn/versions
|
||||||
.ruff_cache/
|
|
||||||
|
|
||||||
# Runtime data (created by `triggershell start/dev` in whatever directory the config lives in)
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# Runtime data (created by `triggershell start` in whatever directory the config lives in)
|
||||||
.triggershell/
|
.triggershell/
|
||||||
|
|
||||||
# Secrets loaded by `triggershell` via ${VAR} interpolation - never commit these
|
# Secrets loaded by `triggershell` via ${VAR} interpolation - never commit these
|
||||||
@@ -23,5 +45,6 @@ venv/
|
|||||||
|
|
||||||
# Editors / OS
|
# Editors / OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
*.pem
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
@@ -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/
|
||||||
@@ -24,20 +24,22 @@ API for automation.
|
|||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install triggershell # or: pip install -e . from a checkout
|
npm install -g triggershell # or: npx triggershell <command> for one-off use
|
||||||
triggershell init # scaffold triggershell.yml (+ .env for secrets) in the current directory
|
triggershell init # scaffold triggershell.yml (+ .env for secrets) in the current directory
|
||||||
triggershell users add admin # create a login (skip if you set auth.enabled: false)
|
triggershell users add admin # create a login (skip if you set auth.enabled: false)
|
||||||
triggershell dev # start in dev mode and open the browser
|
triggershell start # start the app and open the browser
|
||||||
```
|
```
|
||||||
|
|
||||||
Edit `triggershell.yml` to add your own scripts (see [Configuration](#configuration) below),
|
Edit `triggershell.yml` to add your own scripts (see [Configuration](#configuration) below), then
|
||||||
then run `triggershell start` for a production build.
|
re-run `triggershell start`.
|
||||||
|
|
||||||
|
> The `triggershell` package isn't published to npm yet. Until it is, build and link a local copy
|
||||||
|
> instead: `pnpm install && pnpm build && pnpm link --global`.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Python >= 3.9
|
- Node.js >= 20 — the only thing you need installed. Everything else `triggershell` needs ships
|
||||||
- Node.js >= 20 (checked by the CLI; not auto-installed)
|
inside the package itself and is resolved automatically when you install it.
|
||||||
- pnpm (auto-provisioned via Corepack if missing and Corepack is available)
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -98,14 +100,38 @@ the script — always as a discrete argv element or env var, never interpolated
|
|||||||
## CLI Usage
|
## CLI Usage
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---|---|
|
| --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `triggershell init [PATH]` | Scaffold a new config file + `.env` (`--port`, `--auth/--no-auth`, `--force`) |
|
| `triggershell init [PATH]` | Scaffold a new config file + `.env` (`--port`, `--auth/--no-auth`, `--force`) |
|
||||||
| `triggershell validate [-c CONFIG]` | Validate a config file (fast Python pre-flight + full Node/Zod schema) |
|
| `triggershell validate [-c CONFIG]` | Validate a config file against the full schema |
|
||||||
| `triggershell dev [-c CONFIG] [--port] [--host] [--no-browser]` | Run in development mode (hot reload) |
|
| `triggershell start [-c CONFIG] [--port] [--host] [--no-browser]` | Run the web app |
|
||||||
| `triggershell start [-c CONFIG] [--port] [--host] [--no-browser] [--skip-build]` | Build (if stale) and run in production mode |
|
| `triggershell doctor [-c CONFIG]` | Print environment/config diagnostics |
|
||||||
| `triggershell doctor` | Print environment/config diagnostics |
|
| `triggershell scripts list [-c CONFIG]` | List configured scripts |
|
||||||
|
| `triggershell scripts show <scriptId> [-c CONFIG]` | Show a script's command and variables |
|
||||||
|
| `triggershell run <scriptId> [--var name=value...] [--token] [--local\|--remote] [--no-wait]` | Run a configured script - through an already-running server's API if one is reachable (so any open browser tab sees it live), otherwise standalone. See [Running scripts from the CLI](#running-scripts-from-the-cli). |
|
||||||
| `triggershell users add <username> [-c CONFIG] [--inline]` | Hash a password, store it in `.env`, and print a `${VAR}` snippet for `auth.users` (`--inline` prints the raw hash instead) |
|
| `triggershell users add <username> [-c CONFIG] [--inline]` | Hash a password, store it in `.env`, and print a `${VAR}` snippet for `auth.users` (`--inline` prints the raw hash instead) |
|
||||||
| `triggershell users add-token <name> [-c CONFIG] [--inline]` | Generate an API token, store its hash in `.env`, and print a `${VAR}` snippet for `auth.tokens` (`--inline` prints the raw hash instead) |
|
| `triggershell users add-token <name> [-c CONFIG] [--inline]` | Generate an API token, store its hash in `.env`, and print a `${VAR}` snippet for `auth.tokens` (`--inline` prints the raw hash instead) |
|
||||||
|
| `triggershell service install [--system]` | Install a systemd unit that runs `triggershell start` (per-user by default, Linux only) |
|
||||||
|
| `triggershell service uninstall [--system]` | Stop, disable, and remove the systemd unit |
|
||||||
|
| `triggershell service status [--system]` | Show the systemd unit's status |
|
||||||
|
|
||||||
|
### Running scripts from the CLI
|
||||||
|
|
||||||
|
`triggershell run <scriptId>` auto-detects whether the web app is already running (a quick
|
||||||
|
`/api/healthz` check against `server.host`/`server.port`, overridable with `--host`/`--port`):
|
||||||
|
|
||||||
|
- **Server reachable** — the run goes through the same `POST /api/scripts/:id/runs` endpoint the
|
||||||
|
web UI uses, authenticated with `--token`/`TRIGGERSHELL_API_TOKEN` if `auth.enabled`. It's a
|
||||||
|
completely normal run from the server's point of view: it shows up in Run History, and any
|
||||||
|
browser tab open on `/runs/:id` streams its output live, exactly as if it had been started from
|
||||||
|
the UI.
|
||||||
|
- **No server reachable** — `run` executes the script itself, in its own process, using the same
|
||||||
|
runner the web app uses. The run and its log are still persisted, just without a browser to watch it.
|
||||||
|
|
||||||
|
Force one or the other with `--local`/`--remote` (the latter fails instead of falling back if
|
||||||
|
nothing's reachable). Pass variables with repeated `--var name=value` flags (repeat the same name
|
||||||
|
for a `multiselect` variable); `--no-wait` prints the run ID and returns immediately instead of
|
||||||
|
streaming output and blocking until it finishes. Exit code is `0` for a succeeded run, `1`
|
||||||
|
otherwise. `Ctrl-C` while waiting cancels the run, the same as the UI's Cancel button.
|
||||||
|
|
||||||
## Web App Guide
|
## Web App Guide
|
||||||
|
|
||||||
@@ -129,7 +155,7 @@ Passwords are hashed with argon2id; only the hash ever lives in the config file.
|
|||||||
Full reference with request/response shapes and curl examples: [`docs/API.md`](docs/API.md).
|
Full reference with request/response shapes and curl examples: [`docs/API.md`](docs/API.md).
|
||||||
|
|
||||||
| Method | Path | Notes |
|
| Method | Path | Notes |
|
||||||
|---|---|---|
|
| ------ | ----------------------------- | ------------------------------------------------------ |
|
||||||
| GET | `/api/healthz` | Unauthenticated readiness probe |
|
| GET | `/api/healthz` | Unauthenticated readiness probe |
|
||||||
| POST | `/api/auth/login` | `{username, password}` → sets session cookie |
|
| POST | `/api/auth/login` | `{username, password}` → sets session cookie |
|
||||||
| POST | `/api/auth/logout` | Clears the session |
|
| POST | `/api/auth/logout` | Clears the session |
|
||||||
@@ -145,19 +171,24 @@ Full reference with request/response shapes and curl examples: [`docs/API.md`](d
|
|||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
This is the workflow for working on TriggerShell itself, not for installing/running it — it
|
||||||
|
bypasses the CLI entirely and talks to the app's own scripts directly, with hot reload. The app is
|
||||||
|
still not meant to be run standalone with `next dev`/`next start`, since it needs the custom server
|
||||||
|
(`server.ts`) for the WebSocket endpoint — `pnpm dev`/`pnpm start` below cover that:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm --dir app install
|
pnpm install
|
||||||
pnpm --dir app dev # or: triggershell dev, which wraps this
|
pnpm dev # tsx watch server.ts - reads TRIGGERSHELL_CONFIG_PATH from the environment
|
||||||
pnpm --dir app lint
|
pnpm lint
|
||||||
pnpm --dir app typecheck
|
pnpm typecheck
|
||||||
pnpm --dir app db:studio # browse the SQLite DB
|
pnpm test # CLI unit tests (src/cli/**/*.test.ts)
|
||||||
|
pnpm db:studio # browse the SQLite DB
|
||||||
```
|
```
|
||||||
|
|
||||||
Repo layout:
|
Repo layout — the repo root itself is the published npm package (Next.js app + the `triggershell`
|
||||||
|
CLI in `bin/`/`src/cli/`), alongside:
|
||||||
|
|
||||||
```
|
```
|
||||||
triggershell/ Python CLI (launcher/orchestrator only)
|
|
||||||
app/ Next.js app - all server logic (API, auth, script execution) lives here
|
|
||||||
examples/ A runnable example config + scripts
|
examples/ A runnable example config + scripts
|
||||||
docs/ Config/architecture/API reference docs
|
docs/ Config/architecture/API reference docs
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import type { NextConfig } from "next";
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {/* config options here */};
|
|
||||||
|
|
||||||
export default nextConfig;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 391 B |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 128 B |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 385 B |
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
import fs from "node:fs";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
import { getDb } from "@/lib/db/client";
|
|
||||||
import { runs } from "@/lib/db/schema";
|
|
||||||
import { RunTerminal } from "@/components/runs/run-terminal";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
|
|
||||||
const MAX_INITIAL_LOG_BYTES = 200_000;
|
|
||||||
|
|
||||||
function readTail(logFilePath: string): string {
|
|
||||||
if (!fs.existsSync(logFilePath)) return "";
|
|
||||||
const { size } = fs.statSync(logFilePath);
|
|
||||||
const start = Math.max(0, size - MAX_INITIAL_LOG_BYTES);
|
|
||||||
const fd = fs.openSync(logFilePath, "r");
|
|
||||||
try {
|
|
||||||
const buffer = Buffer.alloc(size - start);
|
|
||||||
fs.readSync(fd, buffer, 0, buffer.length, start);
|
|
||||||
return (start > 0 ? "... (truncated)\n" : "") + buffer.toString("utf-8");
|
|
||||||
} finally {
|
|
||||||
fs.closeSync(fd);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function RunDetailPage({
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: Promise<{ runId: string }>;
|
|
||||||
}) {
|
|
||||||
const { runId } = await params;
|
|
||||||
const db = getDb();
|
|
||||||
const run = db.select().from(runs).where(eq(runs.id, runId)).get();
|
|
||||||
if (!run) notFound();
|
|
||||||
|
|
||||||
const initialLog = readTail(run.logFilePath);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto flex max-w-3xl flex-col gap-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{run.scriptName}</CardTitle>
|
|
||||||
<dl className="text-muted-foreground grid grid-cols-2 gap-x-4 gap-y-1 text-xs sm:grid-cols-4">
|
|
||||||
<div>
|
|
||||||
<dt className="font-medium">Triggered by</dt>
|
|
||||||
<dd>{run.triggeredBy}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="font-medium">Started</dt>
|
|
||||||
<dd>
|
|
||||||
{run.startedAt ? new Date(run.startedAt).toLocaleString() : "-"}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="font-medium">Command</dt>
|
|
||||||
<dd className="truncate font-mono">{run.resolvedCommand}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="font-medium">Run ID</dt>
|
|
||||||
<dd className="truncate font-mono">{run.id}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<RunTerminal
|
|
||||||
runId={run.id}
|
|
||||||
initialStatus={run.status}
|
|
||||||
initialLog={initialLog}
|
|
||||||
initialExitCode={run.exitCode}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { desc } from "drizzle-orm";
|
|
||||||
import { getDb } from "@/lib/db/client";
|
|
||||||
import { runs } from "@/lib/db/schema";
|
|
||||||
import { RunStatusBadge } from "@/components/runs/run-status-badge";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
|
|
||||||
function formatDuration(startedAt: Date | null, endedAt: Date | null): string {
|
|
||||||
if (!startedAt) return "-";
|
|
||||||
const end = endedAt ?? new Date();
|
|
||||||
const seconds = Math.max(
|
|
||||||
0,
|
|
||||||
Math.round((end.getTime() - startedAt.getTime()) / 1000),
|
|
||||||
);
|
|
||||||
if (seconds < 60) return `${seconds}s`;
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
return `${minutes}m ${seconds % 60}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RunsPage() {
|
|
||||||
const db = getDb();
|
|
||||||
const rows = db
|
|
||||||
.select()
|
|
||||||
.from(runs)
|
|
||||||
.orderBy(desc(runs.createdAt))
|
|
||||||
.limit(100)
|
|
||||||
.all();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Run History</h1>
|
|
||||||
{rows.length === 0 ? (
|
|
||||||
<p className="text-muted-foreground py-12 text-center">No runs yet.</p>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto rounded-lg border">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Script</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead>Triggered by</TableHead>
|
|
||||||
<TableHead>Started</TableHead>
|
|
||||||
<TableHead>Duration</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{rows.map((run) => (
|
|
||||||
<TableRow key={run.id} className="cursor-pointer">
|
|
||||||
<TableCell>
|
|
||||||
<Link
|
|
||||||
href={`/runs/${run.id}`}
|
|
||||||
className="font-medium hover:underline"
|
|
||||||
>
|
|
||||||
{run.scriptName}
|
|
||||||
</Link>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<RunStatusBadge status={run.status} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">
|
|
||||||
{run.triggeredBy}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">
|
|
||||||
{run.startedAt
|
|
||||||
? new Date(run.startedAt).toLocaleString()
|
|
||||||
: "-"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">
|
|
||||||
{formatDuration(run.startedAt, run.endedAt)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
import { getScript } from "@/lib/config/load";
|
|
||||||
import { serializeScriptForClient } from "@/lib/config/serialize";
|
|
||||||
import { DynamicForm } from "@/components/forms/dynamic-form";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
|
|
||||||
export default async function ScriptPage({
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: Promise<{ scriptId: string }>;
|
|
||||||
}) {
|
|
||||||
const { scriptId } = await params;
|
|
||||||
const script = getScript(scriptId);
|
|
||||||
if (!script) notFound();
|
|
||||||
|
|
||||||
const clientScript = serializeScriptForClient(script);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-2xl">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{script.name}</CardTitle>
|
|
||||||
{script.description && (
|
|
||||||
<CardDescription>{script.description}</CardDescription>
|
|
||||||
)}
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<DynamicForm script={clientScript} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 25 KiB |
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
|
||||||
import "./globals.css";
|
|
||||||
|
|
||||||
const geistSans = Geist({
|
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "TriggerShell",
|
|
||||||
description: "Run configured shell scripts from a web UI.",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RootLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<html
|
|
||||||
lang="en"
|
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
|
||||||
>
|
|
||||||
<body className="bg-background text-foreground flex min-h-full flex-col" suppressHydrationWarning>
|
|
||||||
<TooltipProvider>
|
|
||||||
{children}
|
|
||||||
<Toaster />
|
|
||||||
</TooltipProvider>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import type { RunStatus } from "@/lib/db/schema";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const styles: Record<RunStatus, string> = {
|
|
||||||
queued: "bg-muted text-muted-foreground",
|
|
||||||
running: "bg-blue-500/15 text-blue-600 dark:text-blue-400",
|
|
||||||
succeeded: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400",
|
|
||||||
failed: "bg-destructive/15 text-destructive",
|
|
||||||
cancelled: "bg-muted text-muted-foreground",
|
|
||||||
timed_out: "bg-amber-500/15 text-amber-600 dark:text-amber-400",
|
|
||||||
interrupted: "bg-amber-500/15 text-amber-600 dark:text-amber-400",
|
|
||||||
};
|
|
||||||
|
|
||||||
const labels: Record<RunStatus, string> = {
|
|
||||||
queued: "Queued",
|
|
||||||
running: "Running",
|
|
||||||
succeeded: "Succeeded",
|
|
||||||
failed: "Failed",
|
|
||||||
cancelled: "Cancelled",
|
|
||||||
timed_out: "Timed out",
|
|
||||||
interrupted: "Interrupted",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function RunStatusBadge({ status }: { status: RunStatus }) {
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
className={cn("border-transparent font-medium", styles[status])}
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
{status === "running" && (
|
|
||||||
<span className="mr-1 inline-block size-1.5 animate-pulse rounded-full bg-current" />
|
|
||||||
)}
|
|
||||||
{labels[status]}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { Square } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useRunSocket } from "@/hooks/use-run-socket";
|
|
||||||
import type { RunStatus } from "@/lib/db/schema";
|
|
||||||
import type { ServerMessage } from "@/lib/ws/protocol";
|
|
||||||
import { RunStatusBadge } from "./run-status-badge";
|
|
||||||
|
|
||||||
interface RunTerminalProps {
|
|
||||||
runId: string;
|
|
||||||
initialStatus: RunStatus;
|
|
||||||
initialLog: string;
|
|
||||||
initialExitCode: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Line {
|
|
||||||
key: number;
|
|
||||||
stream: "stdout" | "stderr";
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RunTerminal({
|
|
||||||
runId,
|
|
||||||
initialStatus,
|
|
||||||
initialLog,
|
|
||||||
initialExitCode,
|
|
||||||
}: RunTerminalProps) {
|
|
||||||
const [status, setStatus] = useState<RunStatus>(initialStatus);
|
|
||||||
const [exitCode, setExitCode] = useState<number | null>(initialExitCode);
|
|
||||||
const [lines, setLines] = useState<Line[]>(() =>
|
|
||||||
initialLog ? [{ key: -1, stream: "stdout", text: initialLog }] : [],
|
|
||||||
);
|
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
|
||||||
const nextKey = useRef(0);
|
|
||||||
|
|
||||||
const { cancel } = useRunSocket(runId, (message: ServerMessage) => {
|
|
||||||
if (message.type === "output") {
|
|
||||||
setLines((prev) => [
|
|
||||||
...prev,
|
|
||||||
{ key: nextKey.current++, stream: message.stream, text: message.chunk },
|
|
||||||
]);
|
|
||||||
} else if (message.type === "status") {
|
|
||||||
setStatus(message.status);
|
|
||||||
setExitCode(message.exitCode ?? null);
|
|
||||||
if (message.status !== "queued" && message.status !== "running") {
|
|
||||||
toast.info(`Run ${message.status.replace("_", " ")}`);
|
|
||||||
}
|
|
||||||
} else if (message.type === "error") {
|
|
||||||
toast.error(message.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
|
||||||
}, [lines]);
|
|
||||||
|
|
||||||
const isActive = status === "queued" || status === "running";
|
|
||||||
|
|
||||||
async function handleCancel() {
|
|
||||||
cancel();
|
|
||||||
await fetch(`/api/runs/${runId}/cancel`, { method: "POST" }).catch(
|
|
||||||
() => {},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<RunStatusBadge status={status} />
|
|
||||||
{exitCode !== null && (
|
|
||||||
<span className="text-muted-foreground text-xs">
|
|
||||||
exit code {exitCode}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{isActive && (
|
|
||||||
<Button variant="destructive" size="sm" onClick={handleCancel}>
|
|
||||||
<Square className="size-3.5" />
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="max-h-[60vh] overflow-y-auto rounded-lg bg-zinc-950 p-4 font-mono text-xs leading-relaxed text-zinc-100">
|
|
||||||
{lines.length === 0 ? (
|
|
||||||
<span className="text-zinc-500">Waiting for output...</span>
|
|
||||||
) : (
|
|
||||||
lines.map((line) => (
|
|
||||||
<div
|
|
||||||
key={line.key}
|
|
||||||
className={cn(
|
|
||||||
"whitespace-pre-wrap break-all",
|
|
||||||
line.stream === "stderr" && "text-red-400",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{line.text}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
<div ref={bottomRef} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { register } from "tsx/esm/api";
|
||||||
|
|
||||||
|
register();
|
||||||
|
await import("../src/cli/index.ts");
|
||||||
@@ -6,6 +6,10 @@ When `auth.enabled: true`, every endpoint below except `/api/healthz`, `/api/aut
|
|||||||
`/api/auth/session` requires either a valid session cookie or an `Authorization: Bearer <token>`
|
`/api/auth/session` requires either a valid session cookie or an `Authorization: Bearer <token>`
|
||||||
header using a token from `triggershell users add-token`.
|
header using a token from `triggershell users add-token`.
|
||||||
|
|
||||||
|
`triggershell run <scriptId>` is a first-party client of this exact REST + WS contract (see
|
||||||
|
[Running scripts from the CLI](../README.md#running-scripts-from-the-cli)) - nothing below is
|
||||||
|
CLI-specific.
|
||||||
|
|
||||||
## Auth
|
## Auth
|
||||||
|
|
||||||
### `POST /api/auth/login`
|
### `POST /api/auth/login`
|
||||||
@@ -73,7 +77,7 @@ code). `404` if not found.
|
|||||||
|
|
||||||
### `POST /api/runs/:runId/cancel`
|
### `POST /api/runs/:runId/cancel`
|
||||||
|
|
||||||
`202 {"status": "cancelling"}`. `409` if the run already finished, or if it isn't tracked by *this*
|
`202 {"status": "cancelling"}`. `409` if the run already finished, or if it isn't tracked by _this_
|
||||||
server process (e.g. after a restart — see "orphaned runs" in `docs/ARCHITECTURE.md`).
|
server process (e.g. after a restart — see "orphaned runs" in `docs/ARCHITECTURE.md`).
|
||||||
|
|
||||||
### `GET /api/runs/:runId/logs`
|
### `GET /api/runs/:runId/logs`
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
triggershell (Python CLI) app/ (Next.js, all server logic)
|
triggershell (Node CLI, src/cli) server.ts + src/ (Next.js, all server logic)
|
||||||
─────────────────────── ──────────────────────────────
|
─────────────────────────────── ─────────────────────────────────────────
|
||||||
triggershell dev|start server.ts (custom Node server)
|
triggershell start server.ts (custom Node server)
|
||||||
1. resolve + pre-flight the config ├─ Next.js request handler (pages, API routes)
|
1. resolve + validate the config (loadConfig) ├─ Next.js request handler (pages, API routes)
|
||||||
2. check node/pnpm, `pnpm install` if stale ├─ ws.WebSocketServer on /ws/runs
|
2. check the port is free ├─ ws.WebSocketServer on /ws/runs
|
||||||
3. set TRIGGERSHELL_CONFIG_PATH/PORT/HOST env └─ boot: migrate DB, sync auth, reconcile runs
|
3. set TRIGGERSHELL_CONFIG_PATH/PORT/HOST/ └─ boot: migrate DB, sync auth, reconcile runs
|
||||||
4. spawn `pnpm run dev|start`, forward signals
|
NODE_ENV
|
||||||
|
4. import("./server.ts") — same process │
|
||||||
5. poll /api/healthz, open browser │
|
5. poll /api/healthz, open browser │
|
||||||
┌──────────┴──────────┐
|
┌──────────┴──────────┐
|
||||||
REST API WebSocket
|
REST API WebSocket
|
||||||
@@ -23,22 +24,64 @@
|
|||||||
|
|
||||||
## Why a custom Node server
|
## Why a custom Node server
|
||||||
|
|
||||||
Next.js Route Handlers can't host a persistent WebSocket server, so `app/server.ts` wraps Next's
|
Next.js Route Handlers can't host a persistent WebSocket server, so `server.ts` wraps Next's
|
||||||
request handler in a plain `http.createServer` and attaches a `ws.WebSocketServer` via the
|
request handler in a plain `http.createServer` and attaches a `ws.WebSocketServer` via the
|
||||||
`upgrade` event, scoped to `/ws/runs` with its own auth check (Route Handlers get auth via
|
`upgrade` event, scoped to `/ws/runs` with its own auth check (Route Handlers get auth via
|
||||||
`next/headers`'s `cookies()`, which isn't available on a raw `http.IncomingMessage`).
|
`next/headers`'s `cookies()`, which isn't available on a raw `http.IncomingMessage`).
|
||||||
|
|
||||||
## Why the Python CLI is thin
|
## Why the CLI and server share one process
|
||||||
|
|
||||||
Everything Node/pnpm/Next.js needs to do (serve pages, run scripts, stream output, enforce auth)
|
`triggershell start` (`src/cli/commands/start.ts`) doesn't spawn `server.ts` as a child process —
|
||||||
is naturally a Node problem — `execa` for argv-safe spawning, `ws` for streaming, Next for the UI.
|
it sets `process.env` (`TRIGGERSHELL_CONFIG_PATH`, `PORT`, `HOST`, `NODE_ENV`) and then dynamically
|
||||||
Python's job is just: get Node/pnpm ready, validate the config fast, and manage the child process's
|
`import()`s `server.ts` directly, in the same Node process. `server.ts` reads that env and installs
|
||||||
lifecycle (signals, readiness, browser launch) — a CLI concern, not a web-server concern.
|
its own `SIGTERM`/`SIGINT` handlers, so once it's imported, `Ctrl-C` or `systemctl stop` just work —
|
||||||
|
there's no parent process relaying signals to a child, no separate lifecycle to manage. The CLI's
|
||||||
|
`bin/triggershell.js` entry point registers `tsx`'s loader once for the whole process
|
||||||
|
(`tsx/esm/api`'s `register()`), so both the CLI's own `.ts` command files and `server.ts` run
|
||||||
|
straight from source, with no compile/bundle step for either.
|
||||||
|
|
||||||
|
## `triggershell run` - a second, independent client of the same run pipeline
|
||||||
|
|
||||||
|
`triggershell run <scriptId>` (`src/cli/commands/run.ts`) never duplicates the spawn/streaming
|
||||||
|
logic in `src/lib/runner/engine.ts` - it just calls it from a different position:
|
||||||
|
|
||||||
|
- If a server is reachable (`GET /api/healthz`), `run` is a plain HTTP+WS client: `POST
|
||||||
|
/api/scripts/:id/runs` (the same route the web UI's "Run" button calls) starts the run _inside
|
||||||
|
that server's process_, and `run` then subscribes over `/ws/runs` exactly like a browser tab
|
||||||
|
would, using the same `ClientMessage`/`ServerMessage` protocol (`src/lib/ws/protocol.ts`). This
|
||||||
|
is why a run started this way appears live in any open browser tab for free - the broadcast path
|
||||||
|
(`emitRunMessage` → the `runEvents` listener in `src/lib/ws/server.ts` → every subscribed
|
||||||
|
WebSocket) has no idea the run was triggered by a CLI instead of a click.
|
||||||
|
- If nothing's reachable, `run` calls `startRun()` directly, in its own short-lived process (after
|
||||||
|
its own `migrateOnBoot()`/`reconcileOrphanedRuns()`, so a from-scratch `.triggershell/` works
|
||||||
|
standalone). Since it's in the same process as the run it just started, it doesn't need WS at
|
||||||
|
all - it listens on the same in-process `runEvents` emitter a WS client would otherwise be fed
|
||||||
|
from, using the exact "read the log file and current status first, then attach a live listener"
|
||||||
|
ordering `subscribe()` in `ws/server.ts` already uses, for the same reason: a fast script can
|
||||||
|
finish before a listener is attached.
|
||||||
|
|
||||||
|
Either way, `Ctrl-C` cancels the run through the existing mechanism for that mode - a `{"type":
|
||||||
|
"cancel"}` WS message for the remote case, `cancelRun()` (`src/lib/runner/registry.ts`) directly
|
||||||
|
for the local case - not a new cancellation path.
|
||||||
|
|
||||||
|
## Running as a systemd service
|
||||||
|
|
||||||
|
`triggershell service install` renders a unit file (`src/cli/lib/systemd.ts`) whose `ExecStart`
|
||||||
|
line pins the exact `node` binary (`process.execPath`) and the exact, symlink-resolved path to the
|
||||||
|
installed CLI (`fs.realpathSync(process.argv[1])`) at install time — necessary because systemd
|
||||||
|
services run with a minimal `PATH` that may not include wherever Node actually lives. By default it
|
||||||
|
installs a per-user unit (`~/.config/systemd/user/triggershell.service`, no root required); `--system`
|
||||||
|
targets `/etc/systemd/system/` instead and prints the `sudo` commands to run if not already root.
|
||||||
|
`install` reloads the systemd daemon but does not enable/start the unit itself — that's a separate,
|
||||||
|
explicit `systemctl --user enable --now triggershell`, since it's the point where the service
|
||||||
|
actually starts listening and running scripts. `triggershell service status`/`uninstall` are thin
|
||||||
|
wrappers around `systemctl`; log tailing is just `journalctl --user -u triggershell -f` — not
|
||||||
|
reimplemented.
|
||||||
|
|
||||||
## Cross-module-graph state
|
## Cross-module-graph state
|
||||||
|
|
||||||
Next compiles Route Handlers and Server Components through its own build/module graph, which is a
|
Next compiles Route Handlers and Server Components through its own build/module graph, which is a
|
||||||
*separate* module instantiation from whatever `server.ts` imports directly via `tsx` at startup —
|
_separate_ module instantiation from whatever `server.ts` imports directly via `tsx` at startup —
|
||||||
even though both run in the same OS process. A plain module-level singleton (e.g. `new Map()` at
|
even though both run in the same OS process. A plain module-level singleton (e.g. `new Map()` at
|
||||||
the top of a file) ends up duplicated, one copy per graph, which silently breaks anything that
|
the top of a file) ends up duplicated, one copy per graph, which silently breaks anything that
|
||||||
needs to be shared across that boundary (the WebSocket subscriber registry, the live-run-handle
|
needs to be shared across that boundary (the WebSocket subscriber registry, the live-run-handle
|
||||||
@@ -67,10 +110,10 @@ queued → running → succeeded | failed | cancelled | timed_out
|
|||||||
## Auth
|
## Auth
|
||||||
|
|
||||||
- Session: `iron-session` — a stateless, encrypted+signed cookie (no session-store table).
|
- Session: `iron-session` — a stateless, encrypted+signed cookie (no session-store table).
|
||||||
- Config is the source of truth for *who* is allowed in; `src/lib/auth/sync.ts` upserts config
|
- Config is the source of truth for _who_ is allowed in; `src/lib/auth/sync.ts` upserts config
|
||||||
users/tokens into SQLite on boot, giving a single DB-backed check path plus `lastLoginAt` tracking.
|
users/tokens into SQLite on boot, giving a single DB-backed check path plus `lastLoginAt` tracking.
|
||||||
- `src/proxy.ts` (Next's Proxy, formerly "Middleware") does a fast, cookie-only redirect for
|
- `src/proxy.ts` (Next's Proxy, formerly "Middleware") does a fast, cookie-only redirect for
|
||||||
unauthenticated page/API requests — explicitly *not* the real security boundary. Every Route
|
unauthenticated page/API requests — explicitly _not_ the real security boundary. Every Route
|
||||||
Handler also calls `requireAuth()` itself; this is the actual auth check.
|
Handler also calls `requireAuth()` itself; this is the actual auth check.
|
||||||
- The WS `upgrade` handler is outside Next's request pipeline entirely, so it authenticates by hand
|
- The WS `upgrade` handler is outside Next's request pipeline entirely, so it authenticates by hand
|
||||||
(parsing the cookie header, or a `?token=` query param) via `authenticateUpgrade()`.
|
(parsing the cookie header, or a `?token=` query param) via `authenticateUpgrade()`.
|
||||||
@@ -81,4 +124,4 @@ Every server file that reads the config/DB at request time (`getConfig()`, `getD
|
|||||||
`requireAuth()`) sets `export const dynamic = "force-dynamic"`. Without it, `next build` tries to
|
`requireAuth()`) sets `export const dynamic = "force-dynamic"`. Without it, `next build` tries to
|
||||||
statically prerender pages like `/` at build time, which fails because there's no config file to
|
statically prerender pages like `/` at build time, which fails because there's no config file to
|
||||||
read yet (the config only exists at `triggershell start` runtime, in the user's own project
|
read yet (the config only exists at `triggershell start` runtime, in the user's own project
|
||||||
directory, not `app/`'s).
|
directory, not the package's).
|
||||||
|
|||||||
@@ -10,13 +10,14 @@ present) into its environment - without overriding any variable already set in t
|
|||||||
secrets referenced via `${VAR}` don't have to be committed alongside the config. `triggershell
|
secrets referenced via `${VAR}` don't have to be committed alongside the config. `triggershell
|
||||||
init` scaffolds both files together.
|
init` scaffolds both files together.
|
||||||
|
|
||||||
The canonical schema is the Zod schema at `app/src/lib/config/schema.ts` — this document mirrors
|
The canonical schema is the Zod schema at `src/lib/config/schema.ts` — this document mirrors
|
||||||
it. `triggershell validate` runs the Python pre-flight checks below, then that full schema.
|
it. `triggershell validate` loads and validates the config directly against the schema below (no
|
||||||
|
separate pre-flight step).
|
||||||
|
|
||||||
## `server`
|
## `server`
|
||||||
|
|
||||||
| Field | Type | Default | Notes |
|
| Field | Type | Default | Notes |
|
||||||
|---|---|---|---|
|
| ---------- | ------ | ----------- | ----------------------- |
|
||||||
| `host` | string | `127.0.0.1` | Bind address |
|
| `host` | string | `127.0.0.1` | Bind address |
|
||||||
| `port` | number | `4173` | 1-65535 |
|
| `port` | number | `4173` | 1-65535 |
|
||||||
| `basePath` | string | `""` | Reserved for future use |
|
| `basePath` | string | `""` | Reserved for future use |
|
||||||
@@ -24,7 +25,7 @@ it. `triggershell validate` runs the Python pre-flight checks below, then that f
|
|||||||
## `auth`
|
## `auth`
|
||||||
|
|
||||||
| Field | Type | Default | Notes |
|
| Field | Type | Default | Notes |
|
||||||
|---|---|---|---|
|
| ----------------- | ------- | ------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `enabled` | boolean | `true` | `false` disables login entirely |
|
| `enabled` | boolean | `true` | `false` disables login entirely |
|
||||||
| `sessionSecret` | string | — | Required, >= 32 chars, if `enabled`. Reference it via `${TRIGGERSHELL_SESSION_SECRET}` and set the real value in `.env`, not here |
|
| `sessionSecret` | string | — | Required, >= 32 chars, if `enabled`. Reference it via `${TRIGGERSHELL_SESSION_SECRET}` and set the real value in `.env`, not here |
|
||||||
| `sessionTtlHours` | number | `12` | Session cookie lifetime |
|
| `sessionTtlHours` | number | `12` | Session cookie lifetime |
|
||||||
@@ -42,20 +43,20 @@ pass `--inline` to those commands to get the raw hash printed for pasting into t
|
|||||||
## `database`
|
## `database`
|
||||||
|
|
||||||
| Field | Type | Default |
|
| Field | Type | Default |
|
||||||
|---|---|---|
|
| ------ | ------ | ------------------------------- |
|
||||||
| `path` | string | `.triggershell/triggershell.db` |
|
| `path` | string | `.triggershell/triggershell.db` |
|
||||||
|
|
||||||
## `logs`
|
## `logs`
|
||||||
|
|
||||||
| Field | Type | Default | Notes |
|
| Field | Type | Default | Notes |
|
||||||
|---|---|---|---|
|
| --------------- | ------ | -------------------- | ----------------------------------------------------------- |
|
||||||
| `dir` | string | `.triggershell/logs` | One `<runId>.log` file per run |
|
| `dir` | string | `.triggershell/logs` | One `<runId>.log` file per run |
|
||||||
| `retentionDays` | number | `30` | Not yet enforced automatically — prune manually or via cron |
|
| `retentionDays` | number | `30` | Not yet enforced automatically — prune manually or via cron |
|
||||||
|
|
||||||
## `scripts[]`
|
## `scripts[]`
|
||||||
|
|
||||||
| Field | Type | Default | Notes |
|
| Field | Type | Default | Notes |
|
||||||
|---|---|---|---|
|
| ---------------- | -------- | ------- | ------------------------------------------------------------------------------------ |
|
||||||
| `id` | string | — | Required, unique, `[a-zA-Z0-9][a-zA-Z0-9_-]*` |
|
| `id` | string | — | Required, unique, `[a-zA-Z0-9][a-zA-Z0-9_-]*` |
|
||||||
| `name` | string | — | Required, display name |
|
| `name` | string | — | Required, display name |
|
||||||
| `description` | string | — | Optional |
|
| `description` | string | — | Optional |
|
||||||
@@ -63,7 +64,7 @@ pass `--inline` to those commands to get the raw hash printed for pasting into t
|
|||||||
| `args` | string[] | `[]` | Fixed leading args, before variable-derived ones |
|
| `args` | string[] | `[]` | Fixed leading args, before variable-derived ones |
|
||||||
| `cwd` | string | `./` | Resolved relative to the config file's directory |
|
| `cwd` | string | `./` | Resolved relative to the config file's directory |
|
||||||
| `shell` | boolean | `false` | Opt-in shell interpretation — see the Security Notes in the README before using this |
|
| `shell` | boolean | `false` | Opt-in shell interpretation — see the Security Notes in the README before using this |
|
||||||
| `timeoutSeconds` | number | `1800` | 1–86400 |
|
| `timeoutSeconds` | number | `1800` | 0–86400. `0` means no timeout - the run is never killed for taking too long |
|
||||||
| `variables` | array | `[]` | See below |
|
| `variables` | array | `[]` | See below |
|
||||||
|
|
||||||
## `scripts[].variables[]`
|
## `scripts[].variables[]`
|
||||||
@@ -71,7 +72,7 @@ pass `--inline` to those commands to get the raw hash printed for pasting into t
|
|||||||
Common fields on every variable:
|
Common fields on every variable:
|
||||||
|
|
||||||
| Field | Type | Default | Notes |
|
| Field | Type | Default | Notes |
|
||||||
|---|---|---|---|
|
| ------------- | ----------------------------------- | ------------------ | -------------------------------------------------------------------------------------- |
|
||||||
| `name` | string | — | Required, unique per script |
|
| `name` | string | — | Required, unique per script |
|
||||||
| `label` | string | `name` | Display label |
|
| `label` | string | `name` | Display label |
|
||||||
| `description` | string | — | Shown as form help text |
|
| `description` | string | — | Shown as form help text |
|
||||||
@@ -86,7 +87,7 @@ Common fields on every variable:
|
|||||||
Type-specific fields:
|
Type-specific fields:
|
||||||
|
|
||||||
| `type` | Extra fields |
|
| `type` | Extra fields |
|
||||||
|---|---|
|
| ------------- | ------------------------------------------------------------------------------------------------- |
|
||||||
| `string` | `default?: string`, `pattern?: string` (regex), `minLength?`, `maxLength?`, `multiline?: boolean` |
|
| `string` | `default?: string`, `pattern?: string` (regex), `minLength?`, `maxLength?`, `multiline?: boolean` |
|
||||||
| `number` | `default?: number`, `min?`, `max?`, `step?` |
|
| `number` | `default?: number`, `min?`, `max?`, `step?` |
|
||||||
| `boolean` | `default: boolean` (default `false`) |
|
| `boolean` | `default: boolean` (default `false`) |
|
||||||
@@ -96,7 +97,7 @@ Type-specific fields:
|
|||||||
### UI control mapping
|
### UI control mapping
|
||||||
|
|
||||||
| `type` | Default `control` | Valid overrides |
|
| `type` | Default `control` | Valid overrides |
|
||||||
|---|---|---|
|
| ------------- | ---------------------------------------- | ------------------------------------------------ |
|
||||||
| `string` | `text` (or `password` if `secret: true`) | `textarea` (needs `multiline: true`), `password` |
|
| `string` | `text` (or `password` if `secret: true`) | `textarea` (needs `multiline: true`), `password` |
|
||||||
| `number` | `number` | `slider` (requires both `min` and `max`) |
|
| `number` | `number` | `slider` (requires both `min` and `max`) |
|
||||||
| `boolean` | `checkbox` | `switch` |
|
| `boolean` | `checkbox` | `switch` |
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ const targetDir = getArg("--dir");
|
|||||||
const olderThanDays = getArg("--days");
|
const olderThanDays = getArg("--days");
|
||||||
const types = getArg("--types");
|
const types = getArg("--types");
|
||||||
|
|
||||||
console.log(`Scanning ${targetDir} for files older than ${olderThanDays} days (types: ${types})`);
|
console.log(
|
||||||
|
`Scanning ${targetDir} for files older than ${olderThanDays} days (types: ${types})`,
|
||||||
|
);
|
||||||
if (process.env.CLEANUP_API_KEY) {
|
if (process.env.CLEANUP_API_KEY) {
|
||||||
console.log("Using configured external API key.");
|
console.log("Using configured external API key.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ scripts:
|
|||||||
label: Notify Slack Channel
|
label: Notify Slack Channel
|
||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
pattern: '^#[a-z0-9-]+$'
|
pattern: "^#[a-z0-9-]+$"
|
||||||
passAs: env
|
passAs: env
|
||||||
envName: SLACK_CHANNEL
|
envName: SLACK_CHANNEL
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
// Hides the on-screen dev-mode route indicator badge. Compile/runtime error overlays still show.
|
||||||
|
devIndicators: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -1,13 +1,34 @@
|
|||||||
{
|
{
|
||||||
"name": "app",
|
"name": "triggershell",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://dev.pivoine.art/valknar/triggershell.git",
|
"url": "https://dev.pivoine.art/valknar/triggershell.git"
|
||||||
"directory": "app"
|
|
||||||
},
|
},
|
||||||
|
"bin": {
|
||||||
|
"triggershell": "bin/triggershell.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://dev.pivoine.art/api/packages/valknar/npm/"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"bin",
|
||||||
|
"src",
|
||||||
|
"templates",
|
||||||
|
"drizzle",
|
||||||
|
"docs",
|
||||||
|
".next",
|
||||||
|
"server.ts",
|
||||||
|
"next.config.ts",
|
||||||
|
"next-env.d.ts",
|
||||||
|
"tsconfig.json",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch server.ts",
|
"dev": "tsx watch server.ts",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
@@ -15,18 +36,24 @@
|
|||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"validate-config": "tsx scripts/validate-config.ts",
|
"format:check": "prettier --check .",
|
||||||
|
"test": "tsx --test \"src/cli/**/*.test.ts\"",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:studio": "drizzle-kit studio"
|
"db:studio": "drizzle-kit studio",
|
||||||
|
"prepack": "rm -rf .next && next build && rm -rf .next/cache"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.7.0",
|
"@base-ui/react": "^1.7.0",
|
||||||
"@hookform/resolvers": "^5.8.0",
|
"@hookform/resolvers": "^5.8.0",
|
||||||
|
"@inquirer/prompts": "^8.5.2",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"argon2": "^0.45.1",
|
"argon2": "^0.45.1",
|
||||||
"better-sqlite3": "^13.0.3",
|
"better-sqlite3": "^13.0.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"commander": "^15.0.0",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"execa": "^10.0.1",
|
"execa": "^10.0.1",
|
||||||
"iron-session": "^8.0.4",
|
"iron-session": "^8.0.4",
|
||||||
@@ -36,9 +63,12 @@
|
|||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
"react-hook-form": "^7.85.0",
|
"react-hook-form": "^7.85.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"shadcn": "^4.18.0",
|
"shadcn": "^4.18.0",
|
||||||
"sonner": "^2.0.8",
|
"sonner": "^2.0.8",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tsx": "^4.23.12",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"ws": "^8.21.3",
|
"ws": "^8.21.3",
|
||||||
"yaml": "^2.9.0",
|
"yaml": "^2.9.0",
|
||||||
@@ -46,6 +76,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@tailwindcss/typography": "^0.5.20",
|
||||||
"@types/better-sqlite3": "^9.6.0",
|
"@types/better-sqlite3": "^9.6.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
@@ -58,7 +89,6 @@
|
|||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
"prettier-plugin-tailwindcss": "^0.8.1",
|
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"tsx": "^4.23.12",
|
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.21.0"
|
"packageManager": "pnpm@11.21.0"
|
||||||
@@ -2,5 +2,4 @@ allowBuilds:
|
|||||||
argon2: true
|
argon2: true
|
||||||
better-sqlite3: true
|
better-sqlite3: true
|
||||||
esbuild: true
|
esbuild: true
|
||||||
sharp: false
|
|
||||||
unrs-resolver: false
|
unrs-resolver: false
|
||||||
@@ -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"]
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import "./src/bootstrap/async-local-storage-polyfill";
|
import "./src/bootstrap/async-local-storage-polyfill";
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import next from "next";
|
import next from "next";
|
||||||
import { WebSocketServer } from "ws";
|
import { WebSocketServer } from "ws";
|
||||||
import { getConfig } from "./src/lib/config/load";
|
import { getConfig } from "./src/lib/config/load";
|
||||||
@@ -19,7 +21,14 @@ migrateOnBoot();
|
|||||||
syncAuthFromConfig();
|
syncAuthFromConfig();
|
||||||
reconcileOrphanedRuns();
|
reconcileOrphanedRuns();
|
||||||
|
|
||||||
const app = next({ dev, hostname, port });
|
// `dir` must be this file's own directory, not `process.cwd()` - when launched by the installed
|
||||||
|
// `triggershell` CLI, the working directory is wherever the user's config lives, not the package.
|
||||||
|
const dir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
// Exposed via `process.env` (not just the local `dir` const) so Route Handlers/Server Components -
|
||||||
|
// compiled through Next's own module graph, separate from this file's - can find it too. See
|
||||||
|
// `docs.ts`'s use of this and the `globalThis` comment in `runner/events.ts` for the same reasoning.
|
||||||
|
process.env.TRIGGERSHELL_APP_ROOT = dir;
|
||||||
|
const app = next({ dev, dir, hostname, port });
|
||||||
const handle = app.getRequestHandler();
|
const handle = app.getRequestHandler();
|
||||||
|
|
||||||
app.prepare().then(() => {
|
app.prepare().then(() => {
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { BookOpen, ChevronRight } from "lucide-react";
|
||||||
|
import { DOCS } from "@/lib/docs";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Docs" };
|
||||||
|
|
||||||
|
export default function DocsIndexPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h1 className="font-heading text-2xl font-semibold tracking-tight">
|
||||||
|
Docs
|
||||||
|
</h1>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{DOCS.map((doc) => (
|
||||||
|
<Link key={doc.slug} href={`/docs/${doc.slug}`}>
|
||||||
|
<Card className="hover:border-primary/50 h-full transition-colors">
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BookOpen className="text-muted-foreground size-4.5 shrink-0" />
|
||||||
|
<CardTitle className="text-base">{doc.title}</CardTitle>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="text-muted-foreground size-4 shrink-0" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-muted-foreground text-sm">
|
||||||
|
{doc.description}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ export const dynamic = "force-dynamic";
|
|||||||
import { getConfig } from "@/lib/config/load";
|
import { getConfig } from "@/lib/config/load";
|
||||||
import { requireAuth } from "@/lib/auth/guard";
|
import { requireAuth } from "@/lib/auth/guard";
|
||||||
import { Nav } from "@/components/layout/nav";
|
import { Nav } from "@/components/layout/nav";
|
||||||
|
import { Footer } from "@/components/layout/footer";
|
||||||
|
|
||||||
export default async function AppLayout({
|
export default async function AppLayout({
|
||||||
children,
|
children,
|
||||||
@@ -18,6 +19,7 @@ export default async function AppLayout({
|
|||||||
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-8">
|
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-8">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,30 +1,43 @@
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ChevronRight, PlayCircle } from "lucide-react";
|
import { ChevronRight, PlayCircle } from "lucide-react";
|
||||||
import { getConfig } from "@/lib/config/load";
|
import { getConfig } from "@/lib/config/load";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Scripts" };
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { config } = getConfig();
|
const { config } = getConfig();
|
||||||
|
|
||||||
if (config.scripts.length === 0) {
|
if (config.scripts.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="text-muted-foreground py-24 text-center">
|
<div className="flex flex-col items-center gap-2 py-24 text-center">
|
||||||
No scripts configured yet. Add entries under{" "}
|
<PlayCircle className="text-muted-foreground/50 mb-2 size-8" />
|
||||||
<code className="bg-muted rounded px-1.5 py-0.5">scripts:</code> in your
|
<h1 className="font-heading text-lg font-semibold tracking-tight">
|
||||||
config file.
|
No scripts yet
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground max-w-sm text-sm">
|
||||||
|
Add entries under{" "}
|
||||||
|
<code className="bg-muted rounded px-1.5 py-0.5 font-mono text-xs">
|
||||||
|
scripts:
|
||||||
|
</code>{" "}
|
||||||
|
in your config file to see them here.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Scripts</h1>
|
<h1 className="font-heading text-2xl font-semibold tracking-tight">
|
||||||
|
Scripts
|
||||||
|
</h1>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
{config.scripts.map((script) => (
|
{config.scripts.map((script) => (
|
||||||
<Link key={script.id} href={`/scripts/${script.id}`}>
|
<Link key={script.id} href={`/scripts/${script.id}`}>
|
||||||
<Card className="hover:border-foreground/30 h-full transition-colors">
|
<Card className="hover:border-primary/50 h-full transition-colors">
|
||||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<PlayCircle className="text-muted-foreground size-4.5 shrink-0" />
|
<PlayCircle className="text-muted-foreground size-4.5 shrink-0" />
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { RotateCw } from "lucide-react";
|
||||||
|
import { getDb } from "@/lib/db/client";
|
||||||
|
import { runs } from "@/lib/db/schema";
|
||||||
|
import { getScript } from "@/lib/config/load";
|
||||||
|
import { readLogTail } from "@/lib/runner/log-file";
|
||||||
|
import { RunTerminal } from "@/components/runs/run-terminal";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardAction,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
|
interface RunDetailPageProps {
|
||||||
|
params: Promise<{ runId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVariableValue(value: unknown): string {
|
||||||
|
if (value === undefined || value === null || value === "") return "—";
|
||||||
|
if (Array.isArray(value)) return value.length ? value.join(", ") : "—";
|
||||||
|
if (typeof value === "boolean") return value ? "true" : "false";
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: RunDetailPageProps): Promise<Metadata> {
|
||||||
|
const { runId } = await params;
|
||||||
|
const run = getDb()
|
||||||
|
.select({ scriptName: runs.scriptName })
|
||||||
|
.from(runs)
|
||||||
|
.where(eq(runs.id, runId))
|
||||||
|
.get();
|
||||||
|
return { title: run ? `${run.scriptName} run` : "Run not found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RunDetailPage({ params }: RunDetailPageProps) {
|
||||||
|
const { runId } = await params;
|
||||||
|
const db = getDb();
|
||||||
|
const run = db.select().from(runs).where(eq(runs.id, runId)).get();
|
||||||
|
if (!run) notFound();
|
||||||
|
|
||||||
|
const { text: initialLog, size: initialLogBytes } = readLogTail(
|
||||||
|
run.logFilePath,
|
||||||
|
);
|
||||||
|
|
||||||
|
const script = getScript(run.scriptId);
|
||||||
|
const variableEntries = script
|
||||||
|
? script.variables.map((variable) => ({
|
||||||
|
key: variable.name,
|
||||||
|
label: variable.label ?? variable.name,
|
||||||
|
value: run.variables[variable.name],
|
||||||
|
}))
|
||||||
|
: Object.entries(run.variables).map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
label: key,
|
||||||
|
value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex max-w-2xl flex-col gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{run.scriptName}</CardTitle>
|
||||||
|
{script && (
|
||||||
|
<CardAction>
|
||||||
|
<Link
|
||||||
|
href={`/scripts/${run.scriptId}?fromRun=${run.id}`}
|
||||||
|
className={buttonVariants({ variant: "outline", size: "sm" })}
|
||||||
|
>
|
||||||
|
<RotateCw className="size-3.5" />
|
||||||
|
Re-run
|
||||||
|
</Link>
|
||||||
|
</CardAction>
|
||||||
|
)}
|
||||||
|
<dl className="text-muted-foreground grid grid-cols-2 gap-x-4 gap-y-2 text-xs sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<dt className="font-mono text-[0.7rem] font-medium tracking-widest uppercase">
|
||||||
|
Triggered by
|
||||||
|
</dt>
|
||||||
|
<dd className="font-mono">{run.triggeredBy}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="font-mono text-[0.7rem] font-medium tracking-widest uppercase">
|
||||||
|
Started
|
||||||
|
</dt>
|
||||||
|
<dd className="font-mono">
|
||||||
|
{run.startedAt ? new Date(run.startedAt).toLocaleString() : "-"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="font-mono text-[0.7rem] font-medium tracking-widest uppercase">
|
||||||
|
Run ID
|
||||||
|
</dt>
|
||||||
|
<dd className="truncate font-mono">{run.id}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-muted-foreground font-mono text-[0.7rem] font-medium tracking-widest uppercase">
|
||||||
|
Command
|
||||||
|
</span>
|
||||||
|
<pre className="bg-muted border-primary/30 overflow-x-auto rounded-md border-l-2 p-3 font-mono text-xs whitespace-pre">
|
||||||
|
{run.resolvedCommand}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{variableEntries.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-muted-foreground font-mono text-[0.7rem] font-medium tracking-widest uppercase">
|
||||||
|
Variables
|
||||||
|
</span>
|
||||||
|
<dl className="grid gap-x-6 gap-y-2 rounded-md border p-3 text-xs sm:grid-cols-2">
|
||||||
|
{variableEntries.map(({ key, label, value }) => (
|
||||||
|
<div key={key} className="flex flex-col gap-0.5">
|
||||||
|
<dt className="text-muted-foreground">{label}</dt>
|
||||||
|
<dd className="font-mono break-all">
|
||||||
|
{formatVariableValue(value)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<RunTerminal
|
||||||
|
runId={run.id}
|
||||||
|
initialStatus={run.status}
|
||||||
|
initialLog={initialLog}
|
||||||
|
initialLogBytes={initialLogBytes}
|
||||||
|
initialExitCode={run.exitCode}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
ArrowDown,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowUpDown,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { and, asc, desc, eq, like, or, sql, type SQL } from "drizzle-orm";
|
||||||
|
import { getDb } from "@/lib/db/client";
|
||||||
|
import { runs } from "@/lib/db/schema";
|
||||||
|
import {
|
||||||
|
RunStatusBadge,
|
||||||
|
runStatusLabels,
|
||||||
|
} from "@/components/runs/run-status-badge";
|
||||||
|
import { RunsToolbar } from "@/components/runs/runs-toolbar";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Run History" };
|
||||||
|
|
||||||
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
// Duration isn't a stored column - it's derived from started_at/ended_at (both unix seconds), so
|
||||||
|
// it needs a SQL expression rather than a plain column reference to be sortable. Still-running
|
||||||
|
// rows (ended_at IS NULL) count as elapsed-so-far; never-started (queued) rows sort as NULL,
|
||||||
|
// which SQLite puts first in ASC / last in DESC - keeping them out of the way either direction.
|
||||||
|
const durationExpr = sql`case when ${runs.startedAt} is null then null else coalesce(${runs.endedAt}, unixepoch()) - ${runs.startedAt} end`;
|
||||||
|
|
||||||
|
const SORT_COLUMNS = {
|
||||||
|
script: runs.scriptName,
|
||||||
|
status: runs.status,
|
||||||
|
triggeredBy: runs.triggeredBy,
|
||||||
|
started: runs.startedAt,
|
||||||
|
duration: durationExpr,
|
||||||
|
} as const;
|
||||||
|
type SortKey = keyof typeof SORT_COLUMNS;
|
||||||
|
const DEFAULT_SORT: SortKey = "started";
|
||||||
|
|
||||||
|
function isSortKey(value: string | undefined): value is SortKey {
|
||||||
|
return !!value && value in SORT_COLUMNS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(startedAt: Date | null, endedAt: Date | null): string {
|
||||||
|
if (!startedAt) return "-";
|
||||||
|
const end = endedAt ?? new Date();
|
||||||
|
const seconds = Math.max(
|
||||||
|
0,
|
||||||
|
Math.round((end.getTime() - startedAt.getTime()) / 1000),
|
||||||
|
);
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
return `${minutes}m ${seconds % 60}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RunsPageProps {
|
||||||
|
searchParams: Promise<{
|
||||||
|
q?: string;
|
||||||
|
status?: string;
|
||||||
|
scriptId?: string;
|
||||||
|
sort?: string;
|
||||||
|
dir?: string;
|
||||||
|
page?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RunsPage({ searchParams }: RunsPageProps) {
|
||||||
|
const params = await searchParams;
|
||||||
|
const search = params.q?.trim() ?? "";
|
||||||
|
const statusFilter = params.status ?? "all";
|
||||||
|
const scriptFilter = params.scriptId ?? "all";
|
||||||
|
const sort = isSortKey(params.sort) ? params.sort : DEFAULT_SORT;
|
||||||
|
const dir = params.dir === "asc" ? "asc" : "desc";
|
||||||
|
const requestedPage = Math.max(1, Math.floor(Number(params.page)) || 1);
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const conditions: SQL[] = [];
|
||||||
|
if (search) {
|
||||||
|
const term = `%${search}%`;
|
||||||
|
conditions.push(
|
||||||
|
or(
|
||||||
|
like(runs.scriptName, term),
|
||||||
|
like(runs.triggeredBy, term),
|
||||||
|
like(runs.resolvedCommand, term),
|
||||||
|
)!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (statusFilter !== "all" && statusFilter in runStatusLabels) {
|
||||||
|
conditions.push(
|
||||||
|
eq(runs.status, statusFilter as keyof typeof runStatusLabels),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (scriptFilter !== "all") {
|
||||||
|
conditions.push(eq(runs.scriptId, scriptFilter));
|
||||||
|
}
|
||||||
|
const whereClause = conditions.length ? and(...conditions) : undefined;
|
||||||
|
|
||||||
|
const orderColumn = SORT_COLUMNS[sort];
|
||||||
|
const orderBy = [
|
||||||
|
dir === "asc" ? asc(orderColumn) : desc(orderColumn),
|
||||||
|
desc(runs.createdAt),
|
||||||
|
];
|
||||||
|
|
||||||
|
const [{ total }] = db
|
||||||
|
.select({ total: sql<number>`count(*)` })
|
||||||
|
.from(runs)
|
||||||
|
.where(whereClause)
|
||||||
|
.all();
|
||||||
|
const scripts = db
|
||||||
|
.selectDistinct({ id: runs.scriptId, name: runs.scriptName })
|
||||||
|
.from(runs)
|
||||||
|
.orderBy(asc(runs.scriptName))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
const page = Math.min(requestedPage, totalPages);
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.select()
|
||||||
|
.from(runs)
|
||||||
|
.where(whereClause)
|
||||||
|
.orderBy(...orderBy)
|
||||||
|
.limit(PAGE_SIZE)
|
||||||
|
.offset((page - 1) * PAGE_SIZE)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
function hrefFor(overrides: Record<string, string | null>): string {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (search) query.set("q", search);
|
||||||
|
if (statusFilter !== "all") query.set("status", statusFilter);
|
||||||
|
if (scriptFilter !== "all") query.set("scriptId", scriptFilter);
|
||||||
|
if (sort !== DEFAULT_SORT) query.set("sort", sort);
|
||||||
|
if (dir !== "desc") query.set("dir", dir);
|
||||||
|
if (page !== 1) query.set("page", String(page));
|
||||||
|
for (const [key, value] of Object.entries(overrides)) {
|
||||||
|
if (value === null) query.delete(key);
|
||||||
|
else query.set(key, value);
|
||||||
|
}
|
||||||
|
const qs = query.toString();
|
||||||
|
return qs ? `/runs?${qs}` : "/runs";
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortHeader(key: SortKey, label: string) {
|
||||||
|
const active = sort === key;
|
||||||
|
const nextDir = active && dir === "asc" ? "desc" : "asc";
|
||||||
|
const Icon = active ? (dir === "asc" ? ArrowUp : ArrowDown) : ArrowUpDown;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={hrefFor({ sort: key, dir: nextDir, page: null })}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 hover:text-foreground",
|
||||||
|
active && "text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<Icon className="size-3.5" />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h1 className="font-heading text-2xl font-semibold tracking-tight">
|
||||||
|
Run History
|
||||||
|
</h1>
|
||||||
|
<RunsToolbar scripts={scripts} />
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground py-12 text-center">
|
||||||
|
{total === 0 &&
|
||||||
|
!search &&
|
||||||
|
statusFilter === "all" &&
|
||||||
|
scriptFilter === "all"
|
||||||
|
? "No runs yet."
|
||||||
|
: "No runs match these filters."}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="overflow-x-auto rounded-lg border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{sortHeader("script", "Script")}</TableHead>
|
||||||
|
<TableHead>{sortHeader("status", "Status")}</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
{sortHeader("triggeredBy", "Triggered by")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>{sortHeader("started", "Started")}</TableHead>
|
||||||
|
<TableHead>{sortHeader("duration", "Duration")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((run) => (
|
||||||
|
<TableRow key={run.id} className="cursor-pointer">
|
||||||
|
<TableCell>
|
||||||
|
<Link
|
||||||
|
href={`/runs/${run.id}`}
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{run.scriptName}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<RunStatusBadge status={run.status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground font-mono text-xs">
|
||||||
|
{run.triggeredBy}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground font-mono text-xs">
|
||||||
|
{run.startedAt
|
||||||
|
? new Date(run.startedAt).toLocaleString()
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground font-mono text-xs">
|
||||||
|
{formatDuration(run.startedAt, run.endedAt)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground flex items-center justify-between text-sm">
|
||||||
|
<span>
|
||||||
|
Page {page} of {totalPages} · {total} run{total === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{page > 1 ? (
|
||||||
|
<Link
|
||||||
|
href={hrefFor({ page: String(page - 1) })}
|
||||||
|
className={buttonVariants({ variant: "outline", size: "sm" })}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-3.5" />
|
||||||
|
Previous
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
aria-disabled
|
||||||
|
className={buttonVariants({
|
||||||
|
variant: "outline",
|
||||||
|
size: "sm",
|
||||||
|
className: "pointer-events-none opacity-50",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-3.5" />
|
||||||
|
Previous
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{page < totalPages ? (
|
||||||
|
<Link
|
||||||
|
href={hrefFor({ page: String(page + 1) })}
|
||||||
|
className={buttonVariants({ variant: "outline", size: "sm" })}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="size-3.5" />
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
aria-disabled
|
||||||
|
className={buttonVariants({
|
||||||
|
variant: "outline",
|
||||||
|
size: "sm",
|
||||||
|
className: "pointer-events-none opacity-50",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="size-3.5" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getScript } from "@/lib/config/load";
|
||||||
|
import { serializeScriptForClient } from "@/lib/config/serialize";
|
||||||
|
import { getDb } from "@/lib/db/client";
|
||||||
|
import { runs } from "@/lib/db/schema";
|
||||||
|
import { DynamicForm } from "@/components/forms/dynamic-form";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
|
interface ScriptPageProps {
|
||||||
|
params: Promise<{ scriptId: string }>;
|
||||||
|
searchParams: Promise<{ fromRun?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: ScriptPageProps): Promise<Metadata> {
|
||||||
|
const { scriptId } = await params;
|
||||||
|
const script = getScript(scriptId);
|
||||||
|
return { title: script?.name ?? "Script not found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ScriptPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: ScriptPageProps) {
|
||||||
|
const { scriptId } = await params;
|
||||||
|
const { fromRun } = await searchParams;
|
||||||
|
const script = getScript(scriptId);
|
||||||
|
if (!script) notFound();
|
||||||
|
|
||||||
|
const clientScript = serializeScriptForClient(script);
|
||||||
|
|
||||||
|
// Only trust a previous run's variables as prefill if it's actually a run of this same
|
||||||
|
// script - a `fromRun` id for a different script's run wouldn't line up with these variables.
|
||||||
|
let initialValues: Record<string, unknown> | undefined;
|
||||||
|
if (fromRun) {
|
||||||
|
const previousRun = getDb()
|
||||||
|
.select({ scriptId: runs.scriptId, variables: runs.variables })
|
||||||
|
.from(runs)
|
||||||
|
.where(eq(runs.id, fromRun))
|
||||||
|
.get();
|
||||||
|
if (previousRun?.scriptId === scriptId) {
|
||||||
|
initialValues = previousRun.variables;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{script.name}</CardTitle>
|
||||||
|
{script.description && (
|
||||||
|
<CardDescription>{script.description}</CardDescription>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<DynamicForm script={clientScript} initialValues={initialValues} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -0,0 +1,69 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
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";
|
||||||
|
|
||||||
|
// 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 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 = {
|
||||||
|
title: {
|
||||||
|
default: "TriggerShell",
|
||||||
|
template: "%s · TriggerShell",
|
||||||
|
},
|
||||||
|
description: "Run configured shell scripts from a web UI.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html
|
||||||
|
lang="en"
|
||||||
|
className={`${displayFont.variable} ${sansFont.variable} ${monoFont.variable} h-full antialiased`}
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
|
<body
|
||||||
|
className="bg-background text-foreground flex min-h-full flex-col"
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
<TooltipProvider>
|
||||||
|
{children}
|
||||||
|
<Toaster />
|
||||||
|
</TooltipProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -50,7 +50,7 @@ export function LoginForm() {
|
|||||||
<Card className="w-full max-w-sm">
|
<Card className="w-full max-w-sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Terminal className="size-5" />
|
<Terminal className="text-primary size-5" />
|
||||||
<CardTitle>TriggerShell</CardTitle>
|
<CardTitle>TriggerShell</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
import type { Metadata } from "next";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getConfig } from "@/lib/config/load";
|
import { getConfig } from "@/lib/config/load";
|
||||||
import { getSession } from "@/lib/auth/session";
|
import { getSession } from "@/lib/auth/session";
|
||||||
import { LoginForm } from "./login-form";
|
import { LoginForm } from "./login-form";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Sign in" };
|
||||||
|
|
||||||
export default async function LoginPage() {
|
export default async function LoginPage() {
|
||||||
const { config } = getConfig();
|
const { config } = getConfig();
|
||||||
if (!config.auth.enabled) redirect("/");
|
if (!config.auth.enabled) redirect("/");
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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(", ")})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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}}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export function slug(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/[^A-Za-z0-9]+/g, "_")
|
||||||
|
.replace(/^_+|_+$/g, "")
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,37 +4,48 @@ import { useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Play, Loader2 } from "lucide-react";
|
import { Play, Loader2, Info } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Form } from "@/components/ui/form";
|
import { Form } from "@/components/ui/form";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { buildVariableSchemaFromList } from "@/lib/validation/variable-schema";
|
import { buildVariableSchemaFromList } from "@/lib/validation/variable-schema";
|
||||||
|
import { defaultValuesForScript } from "@/lib/config/defaults";
|
||||||
import type { ClientScript } from "@/lib/config/serialize";
|
import type { ClientScript } from "@/lib/config/serialize";
|
||||||
import { FieldRenderer } from "./field-renderer";
|
import { FieldRenderer } from "./field-renderer";
|
||||||
|
|
||||||
function emptyValueFor(variable: ClientScript["variables"][number]): unknown {
|
/** `initialValues` comes from a previous run's (already-redacted) variables when re-running -
|
||||||
if (variable.type === "boolean") return false;
|
* secret fields are deliberately excluded there (their stored value is just "***", not the real
|
||||||
if (variable.type === "multiselect") return [];
|
* one), so those always fall through to the normal default/empty state and have to be re-entered. */
|
||||||
return "";
|
function defaultValuesFor(
|
||||||
}
|
script: ClientScript,
|
||||||
|
initialValues?: Record<string, unknown>,
|
||||||
function defaultValuesFor(script: ClientScript): 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) {
|
for (const variable of script.variables) {
|
||||||
values[variable.name] = variable.default ?? emptyValueFor(variable);
|
if (variable.secret) continue;
|
||||||
|
const fromPreviousRun = initialValues[variable.name];
|
||||||
|
if (fromPreviousRun !== undefined) values[variable.name] = fromPreviousRun;
|
||||||
}
|
}
|
||||||
return values;
|
return values;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DynamicForm({ script }: { script: ClientScript }) {
|
export function DynamicForm({
|
||||||
|
script,
|
||||||
|
initialValues,
|
||||||
|
}: {
|
||||||
|
script: ClientScript;
|
||||||
|
initialValues?: Record<string, unknown>;
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
const schema = buildVariableSchemaFromList(script.variables);
|
const schema = buildVariableSchemaFromList(script.variables);
|
||||||
|
const hasSecretVariable = script.variables.some((v) => v.secret);
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: defaultValuesFor(script),
|
defaultValues: defaultValuesFor(script, initialValues),
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onSubmit(values: Record<string, unknown>) {
|
async function onSubmit(values: Record<string, unknown>) {
|
||||||
@@ -67,6 +78,16 @@ export function DynamicForm({ script }: { script: ClientScript }) {
|
|||||||
<AlertDescription>{submitError}</AlertDescription>
|
<AlertDescription>{submitError}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
{initialValues && (
|
||||||
|
<Alert>
|
||||||
|
<Info />
|
||||||
|
<AlertDescription>
|
||||||
|
Pre-filled from a previous run.
|
||||||
|
{hasSecretVariable &&
|
||||||
|
" Secret fields aren't carried over and need to be entered again."}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
{script.variables.length === 0 && (
|
{script.variables.length === 0 && (
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
This script takes no parameters.
|
This script takes no parameters.
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { usePathname, useRouter } from "next/navigation";
|
|||||||
import { Terminal, History, LogOut } from "lucide-react";
|
import { Terminal, History, LogOut } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ThemeToggle } from "./theme-toggle";
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
{ href: "/", label: "Scripts", icon: Terminal },
|
{ href: "/", label: "Scripts", icon: Terminal },
|
||||||
@@ -29,13 +30,13 @@ export function Nav({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="border-b bg-background/95 sticky top-0 z-10 backdrop-blur">
|
<header className="border-b bg-background/95 sticky top-0 z-10 backdrop-blur">
|
||||||
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-4">
|
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between gap-2 px-4">
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex min-w-0 items-center gap-3 sm:gap-6">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="flex items-center gap-2 font-semibold tracking-tight"
|
className="font-heading flex shrink-0 items-center gap-2 text-[1.05rem] font-semibold tracking-tight"
|
||||||
>
|
>
|
||||||
<Terminal className="size-5" />
|
<Terminal className="text-primary size-5" />
|
||||||
TriggerShell
|
TriggerShell
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="flex items-center gap-1">
|
<nav className="flex items-center gap-1">
|
||||||
@@ -44,26 +45,31 @@ export function Nav({
|
|||||||
key={href}
|
key={href}
|
||||||
href={href}
|
href={href}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-muted-foreground hover:text-foreground flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
|
"text-muted-foreground hover:text-foreground hover:bg-muted/60 flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium transition-colors sm:px-3",
|
||||||
pathname === href && "bg-muted text-foreground",
|
pathname === href && "bg-primary/10 text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="size-4" />
|
<Icon className="size-4" />
|
||||||
{label}
|
<span className="sr-only sm:not-sr-only">{label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1 sm:gap-2">
|
||||||
|
<ThemeToggle />
|
||||||
{authEnabled && (
|
{authEnabled && (
|
||||||
<div className="flex items-center gap-3">
|
<>
|
||||||
<span className="text-muted-foreground text-sm">{username}</span>
|
<span className="text-muted-foreground hidden max-w-32 truncate text-sm sm:inline-block">
|
||||||
|
{username}
|
||||||
|
</span>
|
||||||
<Button variant="ghost" size="sm" onClick={handleLogout}>
|
<Button variant="ghost" size="sm" onClick={handleLogout}>
|
||||||
<LogOut className="size-4" />
|
<LogOut className="size-4" />
|
||||||
Log out
|
<span className="sr-only sm:not-sr-only">Log out</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Moon, Sun } from "lucide-react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { resolvedTheme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="relative"
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
|
||||||
|
>
|
||||||
|
<Sun className="size-4 scale-100 rotate-0 transition-transform dark:scale-0 dark:-rotate-90" />
|
||||||
|
<Moon className="absolute size-4 scale-0 rotate-90 transition-transform dark:scale-100 dark:rotate-0" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { Square } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useRunSocket } from "@/hooks/use-run-socket";
|
||||||
|
import type { RunStatus } from "@/lib/db/schema";
|
||||||
|
import type { ServerMessage } from "@/lib/ws/protocol";
|
||||||
|
import { RunStatusBadge } from "./run-status-badge";
|
||||||
|
import { XtermView, type XtermViewHandle } from "./xterm-view";
|
||||||
|
|
||||||
|
interface RunTerminalProps {
|
||||||
|
runId: string;
|
||||||
|
initialStatus: RunStatus;
|
||||||
|
initialLog: string;
|
||||||
|
initialLogBytes: number;
|
||||||
|
initialExitCode: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANSI_RED = "\x1b[31m";
|
||||||
|
const ANSI_RESET = "\x1b[0m";
|
||||||
|
|
||||||
|
export function RunTerminal({
|
||||||
|
runId,
|
||||||
|
initialStatus,
|
||||||
|
initialLog,
|
||||||
|
initialLogBytes,
|
||||||
|
initialExitCode,
|
||||||
|
}: RunTerminalProps) {
|
||||||
|
const [status, setStatus] = useState<RunStatus>(initialStatus);
|
||||||
|
const [exitCode, setExitCode] = useState<number | null>(initialExitCode);
|
||||||
|
const termRef = useRef<XtermViewHandle>(null);
|
||||||
|
|
||||||
|
const { cancel } = useRunSocket(
|
||||||
|
runId,
|
||||||
|
(message: ServerMessage) => {
|
||||||
|
if (message.type === "output") {
|
||||||
|
// Scripts that colorize their own output (via ANSI codes) render as-is; stderr additionally
|
||||||
|
// gets wrapped in red so failures stand out even from tools that don't colorize themselves.
|
||||||
|
const chunk =
|
||||||
|
message.stream === "stderr"
|
||||||
|
? `${ANSI_RED}${message.chunk}${ANSI_RESET}`
|
||||||
|
: message.chunk;
|
||||||
|
termRef.current?.write(chunk);
|
||||||
|
} else if (message.type === "status") {
|
||||||
|
// The server also resends the run's current status right after subscribing, so a client
|
||||||
|
// that connects after a fast run has already finished still gets the real status instead
|
||||||
|
// of being stuck on whatever was known at page render. Only toast when it's new info.
|
||||||
|
const changed = message.status !== status;
|
||||||
|
setStatus(message.status);
|
||||||
|
setExitCode(message.exitCode ?? null);
|
||||||
|
if (
|
||||||
|
changed &&
|
||||||
|
message.status !== "queued" &&
|
||||||
|
message.status !== "running"
|
||||||
|
) {
|
||||||
|
toast.info(`Run ${message.status.replace("_", " ")}`);
|
||||||
|
}
|
||||||
|
} else if (message.type === "error") {
|
||||||
|
toast.error(message.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
initialLogBytes,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isActive = status === "queued" || status === "running";
|
||||||
|
|
||||||
|
async function handleCancel() {
|
||||||
|
cancel();
|
||||||
|
await fetch(`/api/runs/${runId}/cancel`, { method: "POST" }).catch(
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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 font-mono text-xs">
|
||||||
|
exit {exitCode}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isActive && (
|
||||||
|
<Button variant="destructive" size="sm" onClick={handleCancel}>
|
||||||
|
<Square className="size-3.5" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<XtermView ref={termRef} initialData={initialLog} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { runStatusValues } from "@/lib/db/schema";
|
||||||
|
import { runStatusLabels } from "./run-status-badge";
|
||||||
|
|
||||||
|
interface RunsToolbarProps {
|
||||||
|
scripts: { id: string; name: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RunsToolbar({ scripts }: RunsToolbarProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const urlQ = searchParams.get("q") ?? "";
|
||||||
|
const [search, setSearch] = useState(urlQ);
|
||||||
|
// Tracks the URL value `search` was last synced from, so a change to the URL from elsewhere
|
||||||
|
// (back/forward, clearing filters) can be reflected without stomping on in-progress typing.
|
||||||
|
const [syncedQ, setSyncedQ] = useState(urlQ);
|
||||||
|
if (urlQ !== syncedQ) {
|
||||||
|
setSyncedQ(urlQ);
|
||||||
|
setSearch(urlQ);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateParams(updates: Record<string, string | null>) {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
for (const [key, value] of Object.entries(updates)) {
|
||||||
|
if (value === null) params.delete(key);
|
||||||
|
else params.set(key, value);
|
||||||
|
}
|
||||||
|
// Any filter/search change makes the current page number meaningless.
|
||||||
|
params.delete("page");
|
||||||
|
router.push(`${pathname}?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (search === urlQ) return;
|
||||||
|
updateParams({ q: search || null });
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- only re-debounce when the typed value changes
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<Input
|
||||||
|
placeholder="Search script, command, or triggered by..."
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
className="sm:max-w-xs"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={searchParams.get("status") ?? "all"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateParams({ status: value === "all" ? null : value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full sm:w-40">
|
||||||
|
<SelectValue placeholder="Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All statuses</SelectItem>
|
||||||
|
{runStatusValues.map((status) => (
|
||||||
|
<SelectItem key={status} value={status}>
|
||||||
|
{runStatusLabels[status]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
value={searchParams.get("scriptId") ?? "all"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateParams({ scriptId: value === "all" ? null : value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full sm:w-48">
|
||||||
|
<SelectValue placeholder="Script" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All scripts</SelectItem>
|
||||||
|
{scripts.map((script) => (
|
||||||
|
<SelectItem key={script.id} value={script.id}>
|
||||||
|
{script.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
|
||||||
|
import { Terminal } from "@xterm/xterm";
|
||||||
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
|
||||||
|
export interface XtermViewHandle {
|
||||||
|
write: (data: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const theme = {
|
||||||
|
background: "#101215",
|
||||||
|
foreground: "#edeef0",
|
||||||
|
cursor: "#e9a857",
|
||||||
|
selectionBackground: "#3f3f46",
|
||||||
|
black: "#18181b",
|
||||||
|
red: "#f87171",
|
||||||
|
green: "#4ade80",
|
||||||
|
yellow: "#facc15",
|
||||||
|
blue: "#60a5fa",
|
||||||
|
magenta: "#c084fc",
|
||||||
|
cyan: "#22d3ee",
|
||||||
|
white: "#e4e4e7",
|
||||||
|
brightBlack: "#3f3f46",
|
||||||
|
brightRed: "#fca5a5",
|
||||||
|
brightGreen: "#86efac",
|
||||||
|
brightYellow: "#fde047",
|
||||||
|
brightBlue: "#93c5fd",
|
||||||
|
brightMagenta: "#d8b4fe",
|
||||||
|
brightCyan: "#67e8f9",
|
||||||
|
brightWhite: "#f4f4f5",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface XtermViewProps {
|
||||||
|
/** Written into the terminal as soon as it's created. Only read once, on mount. */
|
||||||
|
initialData?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A read-only xterm.js instance: interprets ANSI colors/control codes from streamed script
|
||||||
|
* output, unlike a plain <pre> which would just show raw escape characters.
|
||||||
|
*
|
||||||
|
* `initialData` is written inside the same effect that creates the Terminal - not via a
|
||||||
|
* ref call from the parent after the fact - specifically because React's Strict Mode (dev
|
||||||
|
* only) mounts, cleans up, and re-mounts every effect once: a fresh Terminal is created on
|
||||||
|
* each real mount, so a "write once" flag in the parent would write to the first (disposed)
|
||||||
|
* instance and silently skip the surviving one, leaving the terminal blank until the next
|
||||||
|
* live write arrives.
|
||||||
|
*/
|
||||||
|
export const XtermView = forwardRef<XtermViewHandle, XtermViewProps>(
|
||||||
|
function XtermView({ initialData }, ref) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const termRef = useRef<Terminal | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const term = new Terminal({
|
||||||
|
convertEol: true,
|
||||||
|
disableStdin: true,
|
||||||
|
cursorBlink: false,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
fontFamily: "ui-monospace, Menlo, monospace",
|
||||||
|
theme,
|
||||||
|
});
|
||||||
|
const fitAddon = new FitAddon();
|
||||||
|
term.loadAddon(fitAddon);
|
||||||
|
term.open(containerRef.current);
|
||||||
|
fitAddon.fit();
|
||||||
|
if (initialData) term.write(initialData);
|
||||||
|
termRef.current = term;
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
try {
|
||||||
|
fitAddon.fit();
|
||||||
|
} catch {
|
||||||
|
// container has zero size (e.g. mid-layout-shift) - next resize will retry
|
||||||
|
}
|
||||||
|
});
|
||||||
|
resizeObserver.observe(containerRef.current);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
term.dispose();
|
||||||
|
termRef.current = null;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- initialData is intentionally only used on mount
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
write: (data: string) => termRef.current?.write(data),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="h-[60vh] overflow-hidden bg-[#101215] p-2"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||