Compare commits
28
Commits
28407402c8
...
v1.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5a1b7ce53 | ||
|
|
90d68f2510 | ||
|
|
1fa8c5ba66 | ||
|
|
b291a119d5 | ||
|
|
e63129d156 | ||
|
|
96a66fc857 | ||
|
|
3272b9db76 | ||
|
|
06edc60b55 | ||
|
|
14158047ac | ||
|
|
e5fb28ef31 | ||
|
|
e7b2b9add2 | ||
|
|
5574ffd1c8 | ||
|
|
adeb21be19 | ||
|
|
335e7624b4 | ||
|
|
23a4e2ebc0 | ||
|
|
b77041cfa8 | ||
|
|
e482810328 | ||
|
|
86aa0b7539 | ||
|
|
c7bc4421c5 | ||
|
|
a496dc4865 | ||
|
|
677aabfa30 | ||
|
|
8ca8c57793 | ||
|
|
3f391ff584 | ||
|
|
ef453eadd0 | ||
|
|
a11dfd8f7e | ||
|
|
2a5b0b98f4 | ||
|
|
c6337ea942 | ||
|
|
e13b7e3b1a |
@@ -0,0 +1,59 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
checks:
|
||||||
|
name: Checks
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: https://github.com/pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 11.21.0
|
||||||
|
|
||||||
|
- uses: https://github.com/actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- run: pnpm run lint
|
||||||
|
- run: pnpm run typecheck
|
||||||
|
- run: pnpm run format:check
|
||||||
|
- run: pnpm run test
|
||||||
|
|
||||||
|
publish:
|
||||||
|
name: Publish to npm registry
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
needs: checks
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: https://github.com/pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 11.21.0
|
||||||
|
|
||||||
|
- uses: https://github.com/actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Set package version from the tag
|
||||||
|
run: npm pkg set version="${GITHUB_REF_NAME#v}"
|
||||||
|
|
||||||
|
# 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,5 +1,6 @@
|
|||||||
# dependencies
|
# dependencies
|
||||||
/node_modules
|
/node_modules
|
||||||
|
/.pnpm-store
|
||||||
/.pnp
|
/.pnp
|
||||||
.pnp.*
|
.pnp.*
|
||||||
.yarn/*
|
.yarn/*
|
||||||
|
|||||||
@@ -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,7 +24,7 @@ API for automation.
|
|||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install -g triggershell # or: npx triggershell <command> for one-off use
|
npm install -g @valknar/triggershell # or: npx @valknar/triggershell <command> for one-off use
|
||||||
triggershell init # scaffold triggershell.yml (+ .env for secrets) in the current directory
|
triggershell 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 start # start the app and open the browser
|
triggershell start # start the app and open the browser
|
||||||
@@ -100,16 +100,39 @@ the script — always as a discrete argv element or env var, never interpolated
|
|||||||
## CLI Usage
|
## CLI Usage
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---|---|
|
| --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `triggershell init [PATH]` | Scaffold a new config file + `.env` (`--port`, `--auth/--no-auth`, `--force`) |
|
| `triggershell init [PATH]` | Scaffold a new config file + `.env` (`--port`, `--auth/--no-auth`, `--force`) |
|
||||||
| `triggershell validate [-c CONFIG]` | Validate a config file against the full schema |
|
| `triggershell validate [-c CONFIG]` | Validate a config file against the full schema |
|
||||||
| `triggershell start [-c CONFIG] [--port] [--host] [--no-browser]` | Run the web app |
|
| `triggershell start [-c CONFIG] [--port] [--host] [--no-browser]` | Run the web app |
|
||||||
| `triggershell doctor [-c CONFIG]` | Print environment/config diagnostics |
|
| `triggershell doctor [-c CONFIG]` | Print environment/config diagnostics |
|
||||||
|
| `triggershell scripts list [-c CONFIG]` | List configured scripts |
|
||||||
|
| `triggershell scripts show <scriptId> [-c CONFIG]` | Show a script's command and variables |
|
||||||
|
| `triggershell run <scriptId> [--var name=value...] [--token] [--local\|--remote] [--no-wait]` | Run a configured script - through an already-running server's API if one is reachable (so any open browser tab sees it live), otherwise standalone. See [Running scripts from the CLI](#running-scripts-from-the-cli). |
|
||||||
| `triggershell users add <username> [-c CONFIG] [--inline]` | Hash a password, store it in `.env`, and print a `${VAR}` snippet for `auth.users` (`--inline` prints the raw hash instead) |
|
| `triggershell users add <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 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 uninstall [--system]` | Stop, disable, and remove the systemd unit |
|
||||||
| `triggershell service status [--system]` | Show the systemd unit's status |
|
| `triggershell service status [--system]` | Show the systemd unit's status |
|
||||||
|
| `triggershell service logs [-n LINES] [--no-follow] [--system]` | Tail the systemd unit's logs (wraps `journalctl -o cat`, so each line is raw JSON - pipe through `jq` for pretty-printing) |
|
||||||
|
|
||||||
|
### Running scripts from the CLI
|
||||||
|
|
||||||
|
`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
|
||||||
|
|
||||||
@@ -133,7 +156,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 |
|
||||||
|
|||||||
+5
-1
@@ -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](https://dev.pivoine.art/valknar/triggershell/src/branch/main/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`
|
||||||
|
|||||||
+32
-6
@@ -40,6 +40,30 @@ there's no parent process relaying signals to a child, no separate lifecycle to
|
|||||||
(`tsx/esm/api`'s `register()`), so both the CLI's own `.ts` command files and `server.ts` run
|
(`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.
|
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
|
## Running as a systemd service
|
||||||
|
|
||||||
`triggershell service install` renders a unit file (`src/cli/lib/systemd.ts`) whose `ExecStart`
|
`triggershell service install` renders a unit file (`src/cli/lib/systemd.ts`) whose `ExecStart`
|
||||||
@@ -50,14 +74,16 @@ installs a per-user unit (`~/.config/systemd/user/triggershell.service`, no root
|
|||||||
targets `/etc/systemd/system/` instead and prints the `sudo` commands to run if not already root.
|
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,
|
`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
|
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
|
actually starts listening and running scripts. `triggershell service status`/`uninstall`/`logs` are
|
||||||
wrappers around `systemctl`; log tailing is just `journalctl --user -u triggershell -f` — not
|
thin wrappers around `systemctl`/`journalctl` respectively - no unit-file parsing or log storage of
|
||||||
reimplemented.
|
our own, journald already does that. `logs` passes `-o cat` so each line is the raw pino JSON
|
||||||
|
payload rather than journalctl's own timestamp/hostname/unit prefix - pipeable straight into `jq`
|
||||||
|
for pretty-printing without pulling `pino-pretty` into the CLI's runtime dependencies.
|
||||||
|
|
||||||
## Cross-module-graph state
|
## 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
|
||||||
@@ -86,10 +112,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()`.
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ separate pre-flight step).
|
|||||||
## `server`
|
## `server`
|
||||||
|
|
||||||
| Field | Type | Default | Notes |
|
| Field | Type | Default | Notes |
|
||||||
|---|---|---|---|
|
| ---------- | ------ | ----------- | ----------------------- |
|
||||||
| `host` | string | `127.0.0.1` | Bind address |
|
| `host` | string | `127.0.0.1` | Bind address |
|
||||||
| `port` | number | `4173` | 1-65535 |
|
| `port` | number | `4173` | 1-65535 |
|
||||||
| `basePath` | string | `""` | Reserved for future use |
|
| `basePath` | string | `""` | Reserved for future use |
|
||||||
@@ -25,7 +25,7 @@ separate pre-flight step).
|
|||||||
## `auth`
|
## `auth`
|
||||||
|
|
||||||
| Field | Type | Default | Notes |
|
| Field | Type | Default | Notes |
|
||||||
|---|---|---|---|
|
| ----------------- | ------- | ------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `enabled` | boolean | `true` | `false` disables login entirely |
|
| `enabled` | boolean | `true` | `false` disables login entirely |
|
||||||
| `sessionSecret` | string | — | Required, >= 32 chars, if `enabled`. Reference it via `${TRIGGERSHELL_SESSION_SECRET}` and set the real value in `.env`, not here |
|
| `sessionSecret` | string | — | Required, >= 32 chars, if `enabled`. Reference it via `${TRIGGERSHELL_SESSION_SECRET}` and set the real value in `.env`, not here |
|
||||||
| `sessionTtlHours` | number | `12` | Session cookie lifetime |
|
| `sessionTtlHours` | number | `12` | Session cookie lifetime |
|
||||||
@@ -43,20 +43,20 @@ pass `--inline` to those commands to get the raw hash printed for pasting into t
|
|||||||
## `database`
|
## `database`
|
||||||
|
|
||||||
| Field | Type | Default |
|
| Field | Type | Default |
|
||||||
|---|---|---|
|
| ------ | ------ | ------------------------------- |
|
||||||
| `path` | string | `.triggershell/triggershell.db` |
|
| `path` | string | `.triggershell/triggershell.db` |
|
||||||
|
|
||||||
## `logs`
|
## `logs`
|
||||||
|
|
||||||
| Field | Type | Default | Notes |
|
| Field | Type | Default | Notes |
|
||||||
|---|---|---|---|
|
| --------------- | ------ | -------------------- | ----------------------------------------------------------- |
|
||||||
| `dir` | string | `.triggershell/logs` | One `<runId>.log` file per run |
|
| `dir` | string | `.triggershell/logs` | One `<runId>.log` file per run |
|
||||||
| `retentionDays` | number | `30` | Not yet enforced automatically — prune manually or via cron |
|
| `retentionDays` | number | `30` | Not yet enforced automatically — prune manually or via cron |
|
||||||
|
|
||||||
## `scripts[]`
|
## `scripts[]`
|
||||||
|
|
||||||
| Field | Type | Default | Notes |
|
| Field | Type | Default | Notes |
|
||||||
|---|---|---|---|
|
| ---------------- | -------- | ------- | ------------------------------------------------------------------------------------ |
|
||||||
| `id` | string | — | Required, unique, `[a-zA-Z0-9][a-zA-Z0-9_-]*` |
|
| `id` | string | — | Required, unique, `[a-zA-Z0-9][a-zA-Z0-9_-]*` |
|
||||||
| `name` | string | — | Required, display name |
|
| `name` | string | — | Required, display name |
|
||||||
| `description` | string | — | Optional |
|
| `description` | string | — | Optional |
|
||||||
@@ -72,7 +72,7 @@ pass `--inline` to those commands to get the raw hash printed for pasting into t
|
|||||||
Common fields on every variable:
|
Common fields on every variable:
|
||||||
|
|
||||||
| Field | Type | Default | Notes |
|
| Field | Type | Default | Notes |
|
||||||
|---|---|---|---|
|
| ------------- | ----------------------------------- | ------------------ | -------------------------------------------------------------------------------------- |
|
||||||
| `name` | string | — | Required, unique per script |
|
| `name` | string | — | Required, unique per script |
|
||||||
| `label` | string | `name` | Display label |
|
| `label` | string | `name` | Display label |
|
||||||
| `description` | string | — | Shown as form help text |
|
| `description` | string | — | Shown as form help text |
|
||||||
@@ -87,7 +87,7 @@ Common fields on every variable:
|
|||||||
Type-specific fields:
|
Type-specific fields:
|
||||||
|
|
||||||
| `type` | Extra fields |
|
| `type` | Extra fields |
|
||||||
|---|---|
|
| ------------- | ------------------------------------------------------------------------------------------------- |
|
||||||
| `string` | `default?: string`, `pattern?: string` (regex), `minLength?`, `maxLength?`, `multiline?: boolean` |
|
| `string` | `default?: string`, `pattern?: string` (regex), `minLength?`, `maxLength?`, `multiline?: boolean` |
|
||||||
| `number` | `default?: number`, `min?`, `max?`, `step?` |
|
| `number` | `default?: number`, `min?`, `max?`, `step?` |
|
||||||
| `boolean` | `default: boolean` (default `false`) |
|
| `boolean` | `default: boolean` (default `false`) |
|
||||||
@@ -97,11 +97,11 @@ Type-specific fields:
|
|||||||
### UI control mapping
|
### UI control mapping
|
||||||
|
|
||||||
| `type` | Default `control` | Valid overrides |
|
| `type` | Default `control` | Valid overrides |
|
||||||
|---|---|---|
|
| ------------- | ---------------------------------------- | ------------------------------------------------ |
|
||||||
| `string` | `text` (or `password` if `secret: true`) | `textarea` (needs `multiline: true`), `password` |
|
| `string` | `text` (or `password` if `secret: true`) | `textarea` (needs `multiline: true`), `password` |
|
||||||
| `number` | `number` | `slider` (requires both `min` and `max`) |
|
| `number` | `number` | `slider` (requires both `min` and `max`) |
|
||||||
| `boolean` | `checkbox` | `switch` |
|
| `boolean` | `checkbox` | `switch` |
|
||||||
| `enum` | `select` | `radio` |
|
| `enum` | `select` | `radio`, `combobox` (searchable, single-select) |
|
||||||
| `multiselect` | `multiselect` (combobox) | `checkboxGroup` |
|
| `multiselect` | `multiselect` (combobox) | `checkboxGroup` |
|
||||||
|
|
||||||
### `passAs` semantics
|
### `passAs` semantics
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+8
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "triggershell",
|
"name": "@valknar/triggershell",
|
||||||
"version": "0.1.0",
|
"version": "1.2.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -13,6 +13,9 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://dev.pivoine.art/api/packages/valknar/npm/"
|
||||||
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"bin",
|
"bin",
|
||||||
"src",
|
"src",
|
||||||
@@ -33,6 +36,7 @@
|
|||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
"test": "tsx --test \"src/cli/**/*.test.ts\"",
|
"test": "tsx --test \"src/cli/**/*.test.ts\"",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
@@ -56,6 +60,7 @@
|
|||||||
"lucide-react": "^1.31.0",
|
"lucide-react": "^1.31.0",
|
||||||
"next": "16.3.1",
|
"next": "16.3.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"pino": "^10.3.1",
|
||||||
"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",
|
||||||
@@ -82,6 +87,7 @@
|
|||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.3.1",
|
"eslint-config-next": "16.3.1",
|
||||||
|
"pino-pretty": "^13.1.3",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
"prettier-plugin-tailwindcss": "^0.8.1",
|
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
|||||||
Generated
+178
@@ -59,6 +59,9 @@ importers:
|
|||||||
next-themes:
|
next-themes:
|
||||||
specifier: ^0.4.6
|
specifier: ^0.4.6
|
||||||
version: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
version: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||||
|
pino:
|
||||||
|
specifier: ^10.3.1
|
||||||
|
version: 10.3.1
|
||||||
react:
|
react:
|
||||||
specifier: 19.2.8
|
specifier: 19.2.8
|
||||||
version: 19.2.8
|
version: 19.2.8
|
||||||
@@ -132,6 +135,9 @@ importers:
|
|||||||
eslint-config-next:
|
eslint-config-next:
|
||||||
specifier: 16.3.1
|
specifier: 16.3.1
|
||||||
version: 16.3.1(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
|
version: 16.3.1(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
|
||||||
|
pino-pretty:
|
||||||
|
specifier: ^13.1.3
|
||||||
|
version: 13.1.3
|
||||||
prettier:
|
prettier:
|
||||||
specifier: ^3.9.6
|
specifier: ^3.9.6
|
||||||
version: 3.9.6
|
version: 3.9.6
|
||||||
@@ -1358,6 +1364,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==}
|
resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
'@pinojs/redact@0.4.0':
|
||||||
|
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||||
|
|
||||||
'@radix-ui/primitive@1.1.7':
|
'@radix-ui/primitive@1.1.7':
|
||||||
resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==}
|
resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==}
|
||||||
|
|
||||||
@@ -1987,6 +1996,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
|
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
atomic-sleep@1.0.0:
|
||||||
|
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
||||||
|
engines: {node: '>=8.0.0'}
|
||||||
|
|
||||||
atomically@1.7.0:
|
atomically@1.7.0:
|
||||||
resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==}
|
resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==}
|
||||||
engines: {node: '>=10.12.0'}
|
engines: {node: '>=10.12.0'}
|
||||||
@@ -2136,6 +2149,9 @@ packages:
|
|||||||
color-name@1.1.4:
|
color-name@1.1.4:
|
||||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||||
|
|
||||||
|
colorette@2.0.20:
|
||||||
|
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
|
||||||
|
|
||||||
comma-separated-tokens@2.0.3:
|
comma-separated-tokens@2.0.3:
|
||||||
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
|
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
|
||||||
|
|
||||||
@@ -2226,6 +2242,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
|
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
dateformat@4.6.3:
|
||||||
|
resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==}
|
||||||
|
|
||||||
debounce-fn@4.0.0:
|
debounce-fn@4.0.0:
|
||||||
resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==}
|
resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -2439,6 +2458,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
|
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
end-of-stream@1.4.5:
|
||||||
|
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
|
||||||
|
|
||||||
enhanced-resolve@5.24.5:
|
enhanced-resolve@5.24.5:
|
||||||
resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==}
|
resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
@@ -2685,6 +2707,9 @@ packages:
|
|||||||
extend@3.0.2:
|
extend@3.0.2:
|
||||||
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
|
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
|
||||||
|
|
||||||
|
fast-copy@4.0.4:
|
||||||
|
resolution: {integrity: sha512-eVAiWVNPSEGIzDl5yPuLrx8fNMogScXvD9xp1Kzd41FjRIz2I3sSIcxsFeM5EzFfHAfobdvs8ZySffUopljvIA==}
|
||||||
|
|
||||||
fast-deep-equal@3.1.3:
|
fast-deep-equal@3.1.3:
|
||||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||||
|
|
||||||
@@ -2702,6 +2727,9 @@ packages:
|
|||||||
fast-levenshtein@2.0.6:
|
fast-levenshtein@2.0.6:
|
||||||
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
||||||
|
|
||||||
|
fast-safe-stringify@2.1.1:
|
||||||
|
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
|
||||||
|
|
||||||
fast-string-truncated-width@3.0.3:
|
fast-string-truncated-width@3.0.3:
|
||||||
resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==}
|
resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==}
|
||||||
|
|
||||||
@@ -2894,6 +2922,9 @@ packages:
|
|||||||
hast-util-whitespace@3.0.0:
|
hast-util-whitespace@3.0.0:
|
||||||
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
|
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
|
||||||
|
|
||||||
|
help-me@5.0.0:
|
||||||
|
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
|
||||||
|
|
||||||
hermes-estree@0.25.1:
|
hermes-estree@0.25.1:
|
||||||
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
|
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
|
||||||
|
|
||||||
@@ -3172,6 +3203,10 @@ packages:
|
|||||||
jose@6.2.8:
|
jose@6.2.8:
|
||||||
resolution: {integrity: sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==}
|
resolution: {integrity: sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==}
|
||||||
|
|
||||||
|
joycon@3.1.1:
|
||||||
|
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
js-tokens@4.0.0:
|
js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
|
|
||||||
@@ -3649,6 +3684,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
|
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
on-exit-leak-free@2.1.2:
|
||||||
|
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
on-finished@2.4.1:
|
on-finished@2.4.1:
|
||||||
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -3759,6 +3798,20 @@ packages:
|
|||||||
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
|
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
pino-abstract-transport@3.0.0:
|
||||||
|
resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==}
|
||||||
|
|
||||||
|
pino-pretty@13.1.3:
|
||||||
|
resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
pino-std-serializers@7.1.0:
|
||||||
|
resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==}
|
||||||
|
|
||||||
|
pino@10.3.1:
|
||||||
|
resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
pkce-challenge@5.0.1:
|
pkce-challenge@5.0.1:
|
||||||
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
|
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
|
||||||
engines: {node: '>=16.20.0'}
|
engines: {node: '>=16.20.0'}
|
||||||
@@ -3863,6 +3916,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
|
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
process-warning@5.1.0:
|
||||||
|
resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==}
|
||||||
|
|
||||||
prompts@2.4.2:
|
prompts@2.4.2:
|
||||||
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@@ -3877,6 +3933,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
|
|
||||||
|
pump@3.0.4:
|
||||||
|
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
|
||||||
|
|
||||||
punycode@2.3.1:
|
punycode@2.3.1:
|
||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -3888,6 +3947,9 @@ packages:
|
|||||||
queue-microtask@1.2.3:
|
queue-microtask@1.2.3:
|
||||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||||
|
|
||||||
|
quick-format-unescaped@4.0.4:
|
||||||
|
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
|
||||||
|
|
||||||
range-parser@1.3.0:
|
range-parser@1.3.0:
|
||||||
resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==}
|
resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -3950,6 +4012,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
|
resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
real-require@0.2.0:
|
||||||
|
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
|
||||||
|
engines: {node: '>= 12.13.0'}
|
||||||
|
|
||||||
|
real-require@1.0.0:
|
||||||
|
resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==}
|
||||||
|
|
||||||
recast@0.23.21:
|
recast@0.23.21:
|
||||||
resolution: {integrity: sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==}
|
resolution: {integrity: sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
@@ -4024,12 +4093,19 @@ packages:
|
|||||||
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
|
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
safe-stable-stringify@2.5.0:
|
||||||
|
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
safer-buffer@2.1.2:
|
safer-buffer@2.1.2:
|
||||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||||
|
|
||||||
scheduler@0.27.0:
|
scheduler@0.27.0:
|
||||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||||
|
|
||||||
|
secure-json-parse@4.1.0:
|
||||||
|
resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
|
||||||
|
|
||||||
semver@6.3.1:
|
semver@6.3.1:
|
||||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -4118,6 +4194,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==}
|
resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==}
|
||||||
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
|
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
|
||||||
|
|
||||||
|
sonic-boom@4.2.1:
|
||||||
|
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
|
||||||
|
|
||||||
sonner@2.0.8:
|
sonner@2.0.8:
|
||||||
resolution: {integrity: sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==}
|
resolution: {integrity: sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -4142,6 +4221,10 @@ packages:
|
|||||||
space-separated-tokens@2.0.2:
|
space-separated-tokens@2.0.2:
|
||||||
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
|
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
|
||||||
|
|
||||||
|
split2@4.2.0:
|
||||||
|
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||||
|
engines: {node: '>= 10.x'}
|
||||||
|
|
||||||
stable-hash@0.0.5:
|
stable-hash@0.0.5:
|
||||||
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
|
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
|
||||||
|
|
||||||
@@ -4215,6 +4298,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
strip-json-comments@5.0.3:
|
||||||
|
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
|
||||||
|
engines: {node: '>=14.16'}
|
||||||
|
|
||||||
style-to-js@1.1.21:
|
style-to-js@1.1.21:
|
||||||
resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
|
resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
|
||||||
|
|
||||||
@@ -4258,6 +4345,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
|
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
thread-stream@4.2.0:
|
||||||
|
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
tiny-invariant@1.3.3:
|
tiny-invariant@1.3.3:
|
||||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||||
|
|
||||||
@@ -5447,6 +5538,8 @@ snapshots:
|
|||||||
|
|
||||||
'@phc/format@1.0.0': {}
|
'@phc/format@1.0.0': {}
|
||||||
|
|
||||||
|
'@pinojs/redact@0.4.0': {}
|
||||||
|
|
||||||
'@radix-ui/primitive@1.1.7': {}
|
'@radix-ui/primitive@1.1.7': {}
|
||||||
|
|
||||||
'@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.18)(react@19.2.8)':
|
'@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.18)(react@19.2.8)':
|
||||||
@@ -6031,6 +6124,8 @@ snapshots:
|
|||||||
|
|
||||||
async-function@1.0.0: {}
|
async-function@1.0.0: {}
|
||||||
|
|
||||||
|
atomic-sleep@1.0.0: {}
|
||||||
|
|
||||||
atomically@1.7.0: {}
|
atomically@1.7.0: {}
|
||||||
|
|
||||||
available-typed-arrays@1.0.7:
|
available-typed-arrays@1.0.7:
|
||||||
@@ -6172,6 +6267,8 @@ snapshots:
|
|||||||
|
|
||||||
color-name@1.1.4: {}
|
color-name@1.1.4: {}
|
||||||
|
|
||||||
|
colorette@2.0.20: {}
|
||||||
|
|
||||||
comma-separated-tokens@2.0.3: {}
|
comma-separated-tokens@2.0.3: {}
|
||||||
|
|
||||||
commander@11.1.0: {}
|
commander@11.1.0: {}
|
||||||
@@ -6256,6 +6353,8 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
is-data-view: 1.0.2
|
is-data-view: 1.0.2
|
||||||
|
|
||||||
|
dateformat@4.6.3: {}
|
||||||
|
|
||||||
debounce-fn@4.0.0:
|
debounce-fn@4.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
mimic-fn: 3.1.0
|
mimic-fn: 3.1.0
|
||||||
@@ -6357,6 +6456,10 @@ snapshots:
|
|||||||
|
|
||||||
encodeurl@2.0.0: {}
|
encodeurl@2.0.0: {}
|
||||||
|
|
||||||
|
end-of-stream@1.4.5:
|
||||||
|
dependencies:
|
||||||
|
once: 1.4.0
|
||||||
|
|
||||||
enhanced-resolve@5.24.5:
|
enhanced-resolve@5.24.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
@@ -6877,6 +6980,8 @@ snapshots:
|
|||||||
|
|
||||||
extend@3.0.2: {}
|
extend@3.0.2: {}
|
||||||
|
|
||||||
|
fast-copy@4.0.4: {}
|
||||||
|
|
||||||
fast-deep-equal@3.1.3: {}
|
fast-deep-equal@3.1.3: {}
|
||||||
|
|
||||||
fast-glob@3.3.1:
|
fast-glob@3.3.1:
|
||||||
@@ -6899,6 +7004,8 @@ snapshots:
|
|||||||
|
|
||||||
fast-levenshtein@2.0.6: {}
|
fast-levenshtein@2.0.6: {}
|
||||||
|
|
||||||
|
fast-safe-stringify@2.1.1: {}
|
||||||
|
|
||||||
fast-string-truncated-width@3.0.3: {}
|
fast-string-truncated-width@3.0.3: {}
|
||||||
|
|
||||||
fast-string-width@3.0.2:
|
fast-string-width@3.0.2:
|
||||||
@@ -7105,6 +7212,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/hast': 3.0.5
|
'@types/hast': 3.0.5
|
||||||
|
|
||||||
|
help-me@5.0.0: {}
|
||||||
|
|
||||||
hermes-estree@0.25.1: {}
|
hermes-estree@0.25.1: {}
|
||||||
|
|
||||||
hermes-parser@0.25.1:
|
hermes-parser@0.25.1:
|
||||||
@@ -7350,6 +7459,8 @@ snapshots:
|
|||||||
|
|
||||||
jose@6.2.8: {}
|
jose@6.2.8: {}
|
||||||
|
|
||||||
|
joycon@3.1.1: {}
|
||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
|
|
||||||
js-yaml@4.3.1:
|
js-yaml@4.3.1:
|
||||||
@@ -7986,6 +8097,8 @@ snapshots:
|
|||||||
define-properties: 1.2.1
|
define-properties: 1.2.1
|
||||||
es-object-atoms: 1.1.2
|
es-object-atoms: 1.1.2
|
||||||
|
|
||||||
|
on-exit-leak-free@2.1.2: {}
|
||||||
|
|
||||||
on-finished@2.4.1:
|
on-finished@2.4.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
ee-first: 1.1.1
|
ee-first: 1.1.1
|
||||||
@@ -8108,6 +8221,42 @@ snapshots:
|
|||||||
|
|
||||||
picomatch@4.0.5: {}
|
picomatch@4.0.5: {}
|
||||||
|
|
||||||
|
pino-abstract-transport@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
split2: 4.2.0
|
||||||
|
|
||||||
|
pino-pretty@13.1.3:
|
||||||
|
dependencies:
|
||||||
|
colorette: 2.0.20
|
||||||
|
dateformat: 4.6.3
|
||||||
|
fast-copy: 4.0.4
|
||||||
|
fast-safe-stringify: 2.1.1
|
||||||
|
help-me: 5.0.0
|
||||||
|
joycon: 3.1.1
|
||||||
|
minimist: 1.2.8
|
||||||
|
on-exit-leak-free: 2.1.2
|
||||||
|
pino-abstract-transport: 3.0.0
|
||||||
|
pump: 3.0.4
|
||||||
|
secure-json-parse: 4.1.0
|
||||||
|
sonic-boom: 4.2.1
|
||||||
|
strip-json-comments: 5.0.3
|
||||||
|
|
||||||
|
pino-std-serializers@7.1.0: {}
|
||||||
|
|
||||||
|
pino@10.3.1:
|
||||||
|
dependencies:
|
||||||
|
'@pinojs/redact': 0.4.0
|
||||||
|
atomic-sleep: 1.0.0
|
||||||
|
on-exit-leak-free: 2.1.2
|
||||||
|
pino-abstract-transport: 3.0.0
|
||||||
|
pino-std-serializers: 7.1.0
|
||||||
|
process-warning: 5.1.0
|
||||||
|
quick-format-unescaped: 4.0.4
|
||||||
|
real-require: 0.2.0
|
||||||
|
safe-stable-stringify: 2.5.0
|
||||||
|
sonic-boom: 4.2.1
|
||||||
|
thread-stream: 4.2.0
|
||||||
|
|
||||||
pkce-challenge@5.0.1: {}
|
pkce-challenge@5.0.1: {}
|
||||||
|
|
||||||
pkg-up@3.1.0:
|
pkg-up@3.1.0:
|
||||||
@@ -8154,6 +8303,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
parse-ms: 4.0.0
|
parse-ms: 4.0.0
|
||||||
|
|
||||||
|
process-warning@5.1.0: {}
|
||||||
|
|
||||||
prompts@2.4.2:
|
prompts@2.4.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
kleur: 3.0.3
|
kleur: 3.0.3
|
||||||
@@ -8172,6 +8323,11 @@ snapshots:
|
|||||||
forwarded: 0.2.0
|
forwarded: 0.2.0
|
||||||
ipaddr.js: 1.9.1
|
ipaddr.js: 1.9.1
|
||||||
|
|
||||||
|
pump@3.0.4:
|
||||||
|
dependencies:
|
||||||
|
end-of-stream: 1.4.5
|
||||||
|
once: 1.4.0
|
||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
qs@6.15.3:
|
qs@6.15.3:
|
||||||
@@ -8181,6 +8337,8 @@ snapshots:
|
|||||||
|
|
||||||
queue-microtask@1.2.3: {}
|
queue-microtask@1.2.3: {}
|
||||||
|
|
||||||
|
quick-format-unescaped@4.0.4: {}
|
||||||
|
|
||||||
range-parser@1.3.0: {}
|
range-parser@1.3.0: {}
|
||||||
|
|
||||||
raw-body@3.0.2:
|
raw-body@3.0.2:
|
||||||
@@ -8248,6 +8406,10 @@ snapshots:
|
|||||||
|
|
||||||
react@19.2.8: {}
|
react@19.2.8: {}
|
||||||
|
|
||||||
|
real-require@0.2.0: {}
|
||||||
|
|
||||||
|
real-require@1.0.0: {}
|
||||||
|
|
||||||
recast@0.23.21:
|
recast@0.23.21:
|
||||||
dependencies:
|
dependencies:
|
||||||
ast-types: 0.16.1
|
ast-types: 0.16.1
|
||||||
@@ -8369,10 +8531,14 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
is-regex: 1.2.1
|
is-regex: 1.2.1
|
||||||
|
|
||||||
|
safe-stable-stringify@2.5.0: {}
|
||||||
|
|
||||||
safer-buffer@2.1.2: {}
|
safer-buffer@2.1.2: {}
|
||||||
|
|
||||||
scheduler@0.27.0: {}
|
scheduler@0.27.0: {}
|
||||||
|
|
||||||
|
secure-json-parse@4.1.0: {}
|
||||||
|
|
||||||
semver@6.3.1: {}
|
semver@6.3.1: {}
|
||||||
|
|
||||||
semver@7.8.5: {}
|
semver@7.8.5: {}
|
||||||
@@ -8548,6 +8714,10 @@ snapshots:
|
|||||||
ip-address: 10.5.0
|
ip-address: 10.5.0
|
||||||
smart-buffer: 4.2.0
|
smart-buffer: 4.2.0
|
||||||
|
|
||||||
|
sonic-boom@4.2.1:
|
||||||
|
dependencies:
|
||||||
|
atomic-sleep: 1.0.0
|
||||||
|
|
||||||
sonner@2.0.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
|
sonner@2.0.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.8
|
react: 19.2.8
|
||||||
@@ -8566,6 +8736,8 @@ snapshots:
|
|||||||
|
|
||||||
space-separated-tokens@2.0.2: {}
|
space-separated-tokens@2.0.2: {}
|
||||||
|
|
||||||
|
split2@4.2.0: {}
|
||||||
|
|
||||||
stable-hash@0.0.5: {}
|
stable-hash@0.0.5: {}
|
||||||
|
|
||||||
statuses@2.0.2: {}
|
statuses@2.0.2: {}
|
||||||
@@ -8661,6 +8833,8 @@ snapshots:
|
|||||||
|
|
||||||
strip-json-comments@3.1.1: {}
|
strip-json-comments@3.1.1: {}
|
||||||
|
|
||||||
|
strip-json-comments@5.0.3: {}
|
||||||
|
|
||||||
style-to-js@1.1.21:
|
style-to-js@1.1.21:
|
||||||
dependencies:
|
dependencies:
|
||||||
style-to-object: 1.0.14
|
style-to-object: 1.0.14
|
||||||
@@ -8690,6 +8864,10 @@ snapshots:
|
|||||||
|
|
||||||
tapable@2.3.3: {}
|
tapable@2.3.3: {}
|
||||||
|
|
||||||
|
thread-stream@4.2.0:
|
||||||
|
dependencies:
|
||||||
|
real-require: 1.0.0
|
||||||
|
|
||||||
tiny-invariant@1.3.3: {}
|
tiny-invariant@1.3.3: {}
|
||||||
|
|
||||||
tinyglobby@0.2.17:
|
tinyglobby@0.2.17:
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import "./src/bootstrap/async-local-storage-polyfill";
|
import "./src/bootstrap/async-local-storage-polyfill";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import next from "next";
|
import next from "next";
|
||||||
|
import type { Level } from "pino";
|
||||||
import { WebSocketServer } from "ws";
|
import { WebSocketServer } from "ws";
|
||||||
import { getConfig } from "./src/lib/config/load";
|
import { getConfig } from "./src/lib/config/load";
|
||||||
import { migrateOnBoot } from "./src/lib/db/client";
|
import { migrateOnBoot } from "./src/lib/db/client";
|
||||||
@@ -10,6 +12,9 @@ import { syncAuthFromConfig } from "./src/lib/auth/sync";
|
|||||||
import { reconcileOrphanedRuns } from "./src/lib/runner/engine";
|
import { reconcileOrphanedRuns } from "./src/lib/runner/engine";
|
||||||
import { killAllRuns } from "./src/lib/runner/registry";
|
import { killAllRuns } from "./src/lib/runner/registry";
|
||||||
import { attachWsServer, authenticateUpgrade } from "./src/lib/ws/server";
|
import { attachWsServer, authenticateUpgrade } from "./src/lib/ws/server";
|
||||||
|
import { logger } from "./src/lib/logger";
|
||||||
|
|
||||||
|
const log = logger.child({ mod: "server" });
|
||||||
|
|
||||||
const dev = process.env.NODE_ENV !== "production";
|
const dev = process.env.NODE_ENV !== "production";
|
||||||
const { config } = getConfig();
|
const { config } = getConfig();
|
||||||
@@ -31,11 +36,41 @@ process.env.TRIGGERSHELL_APP_ROOT = dir;
|
|||||||
const app = next({ dev, dir, hostname, port });
|
const app = next({ dev, dir, hostname, port });
|
||||||
const handle = app.getRequestHandler();
|
const handle = app.getRequestHandler();
|
||||||
|
|
||||||
|
// `/_next/*` asset requests happen dozens of times per page load and carry no operational
|
||||||
|
// signal - logged at debug so they don't drown out page/API requests in the default info level.
|
||||||
|
function accessLogLevel(pathname: string, statusCode: number): Level {
|
||||||
|
if (statusCode >= 500) return "error";
|
||||||
|
if (statusCode >= 400) return "warn";
|
||||||
|
return pathname.startsWith("/_next/") ? "debug" : "info";
|
||||||
|
}
|
||||||
|
|
||||||
app.prepare().then(() => {
|
app.prepare().then(() => {
|
||||||
const nextUpgradeHandler = app.getUpgradeHandler();
|
const nextUpgradeHandler = app.getUpgradeHandler();
|
||||||
|
|
||||||
const httpServer = createServer((req, res) => {
|
const httpServer = createServer((req, res) => {
|
||||||
handle(req, res);
|
const reqId = req.headers["x-request-id"]?.toString() ?? randomUUID();
|
||||||
|
req.headers["x-request-id"] = reqId;
|
||||||
|
const startedAt = process.hrtime.bigint();
|
||||||
|
|
||||||
|
res.on("finish", () => {
|
||||||
|
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1e6;
|
||||||
|
const { pathname } = new URL(req.url ?? "/", "http://internal");
|
||||||
|
log[accessLogLevel(pathname, res.statusCode)](
|
||||||
|
{
|
||||||
|
reqId,
|
||||||
|
method: req.method,
|
||||||
|
path: pathname,
|
||||||
|
status: res.statusCode,
|
||||||
|
durationMs: Math.round(durationMs),
|
||||||
|
},
|
||||||
|
"request",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
handle(req, res).catch((error: unknown) => {
|
||||||
|
log.error({ reqId, err: error }, "unhandled error handling request");
|
||||||
|
if (!res.headersSent) res.writeHead(500).end();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const wss = new WebSocketServer({ noServer: true });
|
const wss = new WebSocketServer({ noServer: true });
|
||||||
@@ -52,6 +87,7 @@ app.prepare().then(() => {
|
|||||||
authenticateUpgrade(req)
|
authenticateUpgrade(req)
|
||||||
.then((ok) => {
|
.then((ok) => {
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
|
log.warn({ path: pathname }, "rejected unauthenticated WS upgrade");
|
||||||
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
return;
|
return;
|
||||||
@@ -60,21 +96,28 @@ app.prepare().then(() => {
|
|||||||
wss.emit("connection", ws, req);
|
wss.emit("connection", ws, req);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => socket.destroy());
|
.catch((error: unknown) => {
|
||||||
|
log.error({ err: error }, "error authenticating WS upgrade");
|
||||||
|
socket.destroy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
httpServer.listen(port, hostname, () => {
|
httpServer.listen(port, hostname, () => {
|
||||||
console.log(
|
log.info(
|
||||||
`> triggershell ready on http://${hostname}:${port} (${dev ? "development" : "production"})`,
|
{ hostname, port, mode: dev ? "development" : "production" },
|
||||||
|
"triggershell ready",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const shutdown = (signal: string) => {
|
const shutdown = (signal: string) => {
|
||||||
console.log(`> received ${signal}, shutting down...`);
|
log.info({ signal }, "shutting down");
|
||||||
killAllRuns();
|
killAllRuns();
|
||||||
httpServer.close(() => process.exit(0));
|
httpServer.close(() => process.exit(0));
|
||||||
// Force-exit if graceful shutdown hangs (e.g. a stuck WS connection).
|
// Force-exit if graceful shutdown hangs (e.g. a stuck WS connection).
|
||||||
setTimeout(() => process.exit(1), 5000).unref();
|
setTimeout(() => {
|
||||||
|
log.warn("graceful shutdown timed out, forcing exit");
|
||||||
|
process.exit(1);
|
||||||
|
}, 5000).unref();
|
||||||
};
|
};
|
||||||
|
|
||||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default async function DocPage({ params }: DocPageProps) {
|
|||||||
if (content === null) notFound();
|
if (content === null) notFound();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-3xl flex-col gap-6">
|
<div className="mx-auto flex max-w-5xl flex-col gap-6">
|
||||||
<Link
|
<Link
|
||||||
href="/docs"
|
href="/docs"
|
||||||
className="text-muted-foreground hover:text-foreground flex w-fit items-center gap-1 text-sm"
|
className="text-muted-foreground hover:text-foreground flex w-fit items-center gap-1 text-sm"
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { NotFoundContent } from "@/components/layout/not-found-content";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return <NotFoundContent />;
|
||||||
|
}
|
||||||
@@ -66,12 +66,12 @@ export default async function RunDetailPage({ params }: RunDetailPageProps) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-2xl flex-col gap-4">
|
<div className="mx-auto flex max-w-5xl flex-col gap-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{run.scriptName}</CardTitle>
|
<CardTitle>{run.scriptName}</CardTitle>
|
||||||
{script && (
|
{script && (
|
||||||
<CardAction>
|
<CardAction className="row-span-1">
|
||||||
<Link
|
<Link
|
||||||
href={`/scripts/${run.scriptId}?fromRun=${run.id}`}
|
href={`/scripts/${run.scriptId}?fromRun=${run.id}`}
|
||||||
className={buttonVariants({ variant: "outline", size: "sm" })}
|
className={buttonVariants({ variant: "outline", size: "sm" })}
|
||||||
@@ -81,7 +81,7 @@ export default async function RunDetailPage({ params }: RunDetailPageProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
</CardAction>
|
</CardAction>
|
||||||
)}
|
)}
|
||||||
<dl className="text-muted-foreground grid grid-cols-2 gap-x-4 gap-y-2 text-xs sm:grid-cols-3">
|
<dl className="text-muted-foreground col-span-2 grid grid-cols-2 gap-x-4 gap-y-2 text-xs sm:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<dt className="font-mono text-[0.7rem] font-medium tracking-widest uppercase">
|
<dt className="font-mono text-[0.7rem] font-medium tracking-widest uppercase">
|
||||||
Triggered by
|
Triggered by
|
||||||
@@ -119,7 +119,7 @@ export default async function RunDetailPage({ params }: RunDetailPageProps) {
|
|||||||
<span className="text-muted-foreground font-mono text-[0.7rem] font-medium tracking-widest uppercase">
|
<span className="text-muted-foreground font-mono text-[0.7rem] font-medium tracking-widest uppercase">
|
||||||
Variables
|
Variables
|
||||||
</span>
|
</span>
|
||||||
<dl className="grid gap-x-6 gap-y-2 rounded-md border p-3 text-xs sm:grid-cols-2">
|
<dl className="grid gap-x-6 gap-y-2 rounded-md border p-3 text-xs sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{variableEntries.map(({ key, label, value }) => (
|
{variableEntries.map(({ key, label, value }) => (
|
||||||
<div key={key} className="flex flex-col gap-0.5">
|
<div key={key} className="flex flex-col gap-0.5">
|
||||||
<dt className="text-muted-foreground">{label}</dt>
|
<dt className="text-muted-foreground">{label}</dt>
|
||||||
|
|||||||
@@ -2,11 +2,20 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight } from "lucide-react";
|
import {
|
||||||
|
ArrowDown,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowUpDown,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
} from "lucide-react";
|
||||||
import { and, asc, desc, eq, like, or, sql, type SQL } from "drizzle-orm";
|
import { and, asc, desc, eq, like, or, sql, type SQL } from "drizzle-orm";
|
||||||
import { getDb } from "@/lib/db/client";
|
import { getDb } from "@/lib/db/client";
|
||||||
import { runs } from "@/lib/db/schema";
|
import { runs } from "@/lib/db/schema";
|
||||||
import { RunStatusBadge, runStatusLabels } from "@/components/runs/run-status-badge";
|
import {
|
||||||
|
RunStatusBadge,
|
||||||
|
runStatusLabels,
|
||||||
|
} from "@/components/runs/run-status-badge";
|
||||||
import { RunsToolbar } from "@/components/runs/runs-toolbar";
|
import { RunsToolbar } from "@/components/runs/runs-toolbar";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -89,7 +98,9 @@ export default async function RunsPage({ searchParams }: RunsPageProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (statusFilter !== "all" && statusFilter in runStatusLabels) {
|
if (statusFilter !== "all" && statusFilter in runStatusLabels) {
|
||||||
conditions.push(eq(runs.status, statusFilter as keyof typeof runStatusLabels));
|
conditions.push(
|
||||||
|
eq(runs.status, statusFilter as keyof typeof runStatusLabels),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (scriptFilter !== "all") {
|
if (scriptFilter !== "all") {
|
||||||
conditions.push(eq(runs.scriptId, scriptFilter));
|
conditions.push(eq(runs.scriptId, scriptFilter));
|
||||||
@@ -167,7 +178,10 @@ export default async function RunsPage({ searchParams }: RunsPageProps) {
|
|||||||
<RunsToolbar scripts={scripts} />
|
<RunsToolbar scripts={scripts} />
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<p className="text-muted-foreground py-12 text-center">
|
<p className="text-muted-foreground py-12 text-center">
|
||||||
{total === 0 && !search && statusFilter === "all" && scriptFilter === "all"
|
{total === 0 &&
|
||||||
|
!search &&
|
||||||
|
statusFilter === "all" &&
|
||||||
|
scriptFilter === "all"
|
||||||
? "No runs yet."
|
? "No runs yet."
|
||||||
: "No runs match these filters."}
|
: "No runs match these filters."}
|
||||||
</p>
|
</p>
|
||||||
@@ -179,7 +193,9 @@ export default async function RunsPage({ searchParams }: RunsPageProps) {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>{sortHeader("script", "Script")}</TableHead>
|
<TableHead>{sortHeader("script", "Script")}</TableHead>
|
||||||
<TableHead>{sortHeader("status", "Status")}</TableHead>
|
<TableHead>{sortHeader("status", "Status")}</TableHead>
|
||||||
<TableHead>{sortHeader("triggeredBy", "Triggered by")}</TableHead>
|
<TableHead>
|
||||||
|
{sortHeader("triggeredBy", "Triggered by")}
|
||||||
|
</TableHead>
|
||||||
<TableHead>{sortHeader("started", "Started")}</TableHead>
|
<TableHead>{sortHeader("started", "Started")}</TableHead>
|
||||||
<TableHead>{sortHeader("duration", "Duration")}</TableHead>
|
<TableHead>{sortHeader("duration", "Duration")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export default async function ScriptPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-2xl">
|
<div className="mx-auto max-w-5xl">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{script.name}</CardTitle>
|
<CardTitle>{script.name}</CardTitle>
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import {
|
|||||||
recordFailedAttempt,
|
recordFailedAttempt,
|
||||||
clearAttempts,
|
clearAttempts,
|
||||||
} from "@/lib/auth/rate-limit";
|
} from "@/lib/auth/rate-limit";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
|
const log = logger.child({ mod: "auth" });
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
username: z.string().min(1),
|
username: z.string().min(1),
|
||||||
@@ -20,6 +23,7 @@ const loginSchema = z.object({
|
|||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const rateLimitKey = request.headers.get("x-forwarded-for") ?? "local";
|
const rateLimitKey = request.headers.get("x-forwarded-for") ?? "local";
|
||||||
if (isRateLimited(rateLimitKey)) {
|
if (isRateLimited(rateLimitKey)) {
|
||||||
|
log.warn({ from: rateLimitKey }, "login rate-limited");
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ error: "Too many attempts, try again later." },
|
{ error: "Too many attempts, try again later." },
|
||||||
{ status: 429 },
|
{ status: 429 },
|
||||||
@@ -44,6 +48,10 @@ export async function POST(request: Request) {
|
|||||||
!(await verifyPassword(user.passwordHash, parsed.data.password))
|
!(await verifyPassword(user.passwordHash, parsed.data.password))
|
||||||
) {
|
) {
|
||||||
recordFailedAttempt(rateLimitKey);
|
recordFailedAttempt(rateLimitKey);
|
||||||
|
log.warn(
|
||||||
|
{ from: rateLimitKey, username: parsed.data.username },
|
||||||
|
"login failed: invalid credentials",
|
||||||
|
);
|
||||||
return Response.json({ error: "Invalid credentials" }, { status: 401 });
|
return Response.json({ error: "Invalid credentials" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,5 +66,7 @@ export async function POST(request: Request) {
|
|||||||
session.username = user.username;
|
session.username = user.username;
|
||||||
await session.save();
|
await session.save();
|
||||||
|
|
||||||
|
log.info({ from: rateLimitKey, username: user.username }, "login succeeded");
|
||||||
|
|
||||||
return Response.json({ user: { username: user.username } });
|
return Response.json({ user: { username: user.username } });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import { requireAuth, unauthorizedResponse } from "@/lib/auth/guard";
|
|||||||
import { getDb } from "@/lib/db/client";
|
import { getDb } from "@/lib/db/client";
|
||||||
import { runs } from "@/lib/db/schema";
|
import { runs } from "@/lib/db/schema";
|
||||||
import { cancelRun } from "@/lib/runner/registry";
|
import { cancelRun } from "@/lib/runner/registry";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
|
const log = logger.child({ mod: "api" });
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -27,11 +30,16 @@ export async function POST(
|
|||||||
|
|
||||||
const cancelled = cancelRun(runId);
|
const cancelled = cancelRun(runId);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
|
log.warn(
|
||||||
|
{ runId },
|
||||||
|
"cancel requested for a run not tracked by this process",
|
||||||
|
);
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ error: "Run is not active in this server process" },
|
{ error: "Run is not active in this server process" },
|
||||||
{ status: 409 },
|
{ status: 409 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.info({ runId, requestedBy: auth.identity }, "cancel requested via API");
|
||||||
return Response.json({ status: "cancelling" }, { status: 202 });
|
return Response.json({ status: "cancelling" }, { status: 202 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { requireAuth, unauthorizedResponse } from "@/lib/auth/guard";
|
|||||||
import { getScript } from "@/lib/config/load";
|
import { getScript } from "@/lib/config/load";
|
||||||
import { buildVariableSchema } from "@/lib/validation/variable-schema";
|
import { buildVariableSchema } from "@/lib/validation/variable-schema";
|
||||||
import { startRun } from "@/lib/runner/engine";
|
import { startRun } from "@/lib/runner/engine";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
|
const log = logger.child({ mod: "api" });
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -26,11 +29,13 @@ export async function POST(
|
|||||||
const variableSchema = buildVariableSchema(script);
|
const variableSchema = buildVariableSchema(script);
|
||||||
const parsed = variableSchema.safeParse(variablesInput);
|
const parsed = variableSchema.safeParse(variablesInput);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
|
const fieldErrors = parsed.error.flatten().fieldErrors;
|
||||||
|
log.warn(
|
||||||
|
{ scriptId: script.id, fields: Object.keys(fieldErrors) },
|
||||||
|
"run request failed variable validation",
|
||||||
|
);
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{ error: "Validation failed", fieldErrors },
|
||||||
error: "Validation failed",
|
|
||||||
fieldErrors: parsed.error.flatten().fieldErrors,
|
|
||||||
},
|
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -1,5 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Bricolage_Grotesque, IBM_Plex_Mono, IBM_Plex_Sans } from "next/font/google";
|
import {
|
||||||
|
Bricolage_Grotesque,
|
||||||
|
IBM_Plex_Mono,
|
||||||
|
IBM_Plex_Sans,
|
||||||
|
} from "next/font/google";
|
||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { NotFoundContent } from "@/components/layout/not-found-content";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return <NotFoundContent />;
|
||||||
|
}
|
||||||
@@ -14,7 +14,10 @@ export async function doctorCommand(opts: DoctorOptions): Promise<void> {
|
|||||||
|
|
||||||
const configPath = resolveConfigPath(opts.config);
|
const configPath = resolveConfigPath(opts.config);
|
||||||
const exists = fs.existsSync(configPath);
|
const exists = fs.existsSync(configPath);
|
||||||
rows.push(["Config path", `${configPath} ${exists ? "(exists)" : "(not found)"}`]);
|
rows.push([
|
||||||
|
"Config path",
|
||||||
|
`${configPath} ${exists ? "(exists)" : "(not found)"}`,
|
||||||
|
]);
|
||||||
|
|
||||||
if (exists) {
|
if (exists) {
|
||||||
try {
|
try {
|
||||||
@@ -22,7 +25,9 @@ export async function doctorCommand(opts: DoctorOptions): Promise<void> {
|
|||||||
const portFree = await isPortFree(config.server.host, config.server.port);
|
const portFree = await isPortFree(config.server.host, config.server.port);
|
||||||
rows.push([
|
rows.push([
|
||||||
"Port available",
|
"Port available",
|
||||||
portFree ? "yes" : `no (${config.server.host}:${config.server.port} in use)`,
|
portFree
|
||||||
|
? "yes"
|
||||||
|
: `no (${config.server.host}:${config.server.port} in use)`,
|
||||||
]);
|
]);
|
||||||
rows.push(["Scripts configured", String(config.scripts.length)]);
|
rows.push(["Scripts configured", String(config.scripts.length)]);
|
||||||
rows.push(["Auth enabled", String(config.auth.enabled)]);
|
rows.push(["Auth enabled", String(config.auth.enabled)]);
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ export interface InitOptions {
|
|||||||
force: boolean;
|
force: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function initCommand(targetPath: string | undefined, opts: InitOptions): Promise<void> {
|
export async function initCommand(
|
||||||
|
targetPath: string | undefined,
|
||||||
|
opts: InitOptions,
|
||||||
|
): Promise<void> {
|
||||||
const targetDir = path.resolve(process.cwd(), targetPath ?? ".");
|
const targetDir = path.resolve(process.cwd(), targetPath ?? ".");
|
||||||
fs.mkdirSync(targetDir, { recursive: true });
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
const configPath = path.join(targetDir, DEFAULT_CONFIG_NAME);
|
const configPath = path.join(targetDir, DEFAULT_CONFIG_NAME);
|
||||||
@@ -22,7 +25,11 @@ export async function initCommand(targetPath: string | undefined, opts: InitOpti
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const templatePath = path.join(resolveAppRoot(), "templates", DEFAULT_CONFIG_NAME);
|
const templatePath = path.join(
|
||||||
|
resolveAppRoot(),
|
||||||
|
"templates",
|
||||||
|
DEFAULT_CONFIG_NAME,
|
||||||
|
);
|
||||||
const template = fs.readFileSync(templatePath, "utf-8");
|
const template = fs.readFileSync(templatePath, "utf-8");
|
||||||
const rendered = template
|
const rendered = template
|
||||||
.replace("__PORT__", String(opts.port))
|
.replace("__PORT__", String(opts.port))
|
||||||
@@ -38,10 +45,15 @@ export async function initCommand(targetPath: string | undefined, opts: InitOpti
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const sessionSecret = crypto.randomBytes(32).toString("hex");
|
const sessionSecret = crypto.randomBytes(32).toString("hex");
|
||||||
fs.writeFileSync(envPath, `TRIGGERSHELL_SESSION_SECRET=${sessionSecret}\n`);
|
fs.writeFileSync(
|
||||||
|
envPath,
|
||||||
|
`TRIGGERSHELL_SESSION_SECRET=${sessionSecret}\n`,
|
||||||
|
);
|
||||||
console.log(`Created ${envPath} (keep this out of version control)`);
|
console.log(`Created ${envPath} (keep this out of version control)`);
|
||||||
}
|
}
|
||||||
console.log("\nAuth is enabled but no users are configured yet. Add one with:");
|
console.log(
|
||||||
|
"\nAuth is enabled but no users are configured yet. Add one with:",
|
||||||
|
);
|
||||||
console.log(` triggershell users add <username> --config ${configPath}`);
|
console.log(` triggershell users add <username> --config ${configPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(", ")})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,9 @@ function scopeOf(opts: ServiceScopeOptions): "user" | "system" {
|
|||||||
return opts.system ? "system" : "user";
|
return opts.system ? "system" : "user";
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function serviceInstallCommand(opts: ServiceInstallOptions): Promise<void> {
|
export async function serviceInstallCommand(
|
||||||
|
opts: ServiceInstallOptions,
|
||||||
|
): Promise<void> {
|
||||||
const scope = scopeOf(opts);
|
const scope = scopeOf(opts);
|
||||||
const configPath = resolveConfigPath(opts.config);
|
const configPath = resolveConfigPath(opts.config);
|
||||||
const unit = renderUnit({
|
const unit = renderUnit({
|
||||||
@@ -68,18 +70,24 @@ export async function serviceInstallCommand(opts: ServiceInstallOptions): Promis
|
|||||||
|
|
||||||
const scratchPath = path.join(os.tmpdir(), `${SERVICE_NAME}.service`);
|
const scratchPath = path.join(os.tmpdir(), `${SERVICE_NAME}.service`);
|
||||||
fs.writeFileSync(scratchPath, unit);
|
fs.writeFileSync(scratchPath, unit);
|
||||||
console.log(`Not running as root - wrote the unit file to ${scratchPath} instead.`);
|
console.log(
|
||||||
|
`Not running as root - wrote the unit file to ${scratchPath} instead.`,
|
||||||
|
);
|
||||||
console.log("\nReview it, then run:");
|
console.log("\nReview it, then run:");
|
||||||
console.log(` sudo install -m 644 ${scratchPath} ${systemUnitPath()}`);
|
console.log(` sudo install -m 644 ${scratchPath} ${systemUnitPath()}`);
|
||||||
console.log(" sudo systemctl daemon-reload");
|
console.log(" sudo systemctl daemon-reload");
|
||||||
console.log(` sudo systemctl enable --now ${SERVICE_NAME}`);
|
console.log(` sudo systemctl enable --now ${SERVICE_NAME}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function serviceUninstallCommand(opts: ServiceScopeOptions): Promise<void> {
|
export async function serviceUninstallCommand(
|
||||||
|
opts: ServiceScopeOptions,
|
||||||
|
): Promise<void> {
|
||||||
const scope = scopeOf(opts);
|
const scope = scopeOf(opts);
|
||||||
|
|
||||||
if (scope === "user") {
|
if (scope === "user") {
|
||||||
await execa("systemctl", ["--user", "disable", "--now", SERVICE_NAME], { reject: false });
|
await execa("systemctl", ["--user", "disable", "--now", SERVICE_NAME], {
|
||||||
|
reject: false,
|
||||||
|
});
|
||||||
const unitPath = userUnitPath();
|
const unitPath = userUnitPath();
|
||||||
if (fs.existsSync(unitPath)) fs.rmSync(unitPath);
|
if (fs.existsSync(unitPath)) fs.rmSync(unitPath);
|
||||||
await execa("systemctl", ["--user", "daemon-reload"], { reject: false });
|
await execa("systemctl", ["--user", "daemon-reload"], { reject: false });
|
||||||
@@ -88,7 +96,9 @@ export async function serviceUninstallCommand(opts: ServiceScopeOptions): Promis
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isRoot()) {
|
if (isRoot()) {
|
||||||
await execa("systemctl", ["disable", "--now", SERVICE_NAME], { reject: false });
|
await execa("systemctl", ["disable", "--now", SERVICE_NAME], {
|
||||||
|
reject: false,
|
||||||
|
});
|
||||||
const unitPath = systemUnitPath();
|
const unitPath = systemUnitPath();
|
||||||
if (fs.existsSync(unitPath)) fs.rmSync(unitPath);
|
if (fs.existsSync(unitPath)) fs.rmSync(unitPath);
|
||||||
await execa("systemctl", ["daemon-reload"], { reject: false });
|
await execa("systemctl", ["daemon-reload"], { reject: false });
|
||||||
@@ -102,10 +112,40 @@ export async function serviceUninstallCommand(opts: ServiceScopeOptions): Promis
|
|||||||
console.log(" sudo systemctl daemon-reload");
|
console.log(" sudo systemctl daemon-reload");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function serviceStatusCommand(opts: ServiceScopeOptions): Promise<void> {
|
export async function serviceStatusCommand(
|
||||||
|
opts: ServiceScopeOptions,
|
||||||
|
): Promise<void> {
|
||||||
const scope = scopeOf(opts);
|
const scope = scopeOf(opts);
|
||||||
const args =
|
const args =
|
||||||
scope === "user" ? ["--user", "status", SERVICE_NAME] : ["status", SERVICE_NAME];
|
scope === "user"
|
||||||
const result = await execa("systemctl", args, { stdio: "inherit", reject: false });
|
? ["--user", "status", SERVICE_NAME]
|
||||||
|
: ["status", SERVICE_NAME];
|
||||||
|
const result = await execa("systemctl", args, {
|
||||||
|
stdio: "inherit",
|
||||||
|
reject: false,
|
||||||
|
});
|
||||||
|
process.exitCode = result.exitCode ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceLogsOptions extends ServiceScopeOptions {
|
||||||
|
follow?: boolean;
|
||||||
|
lines?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serviceLogsCommand(
|
||||||
|
opts: ServiceLogsOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
const scope = scopeOf(opts);
|
||||||
|
const args = scope === "user" ? ["--user"] : [];
|
||||||
|
// -o cat strips journalctl's own prefix (timestamp/hostname/unit) so each line is the raw pino
|
||||||
|
// JSON payload - pipeable straight into `jq` or similar without journalctl's wrapper in the way.
|
||||||
|
args.push("-u", SERVICE_NAME, "-o", "cat");
|
||||||
|
if (opts.follow !== false) args.push("-f");
|
||||||
|
if (opts.lines !== undefined) args.push("-n", String(opts.lines));
|
||||||
|
|
||||||
|
const result = await execa("journalctl", args, {
|
||||||
|
stdio: "inherit",
|
||||||
|
reject: false,
|
||||||
|
});
|
||||||
process.exitCode = result.exitCode ?? 1;
|
process.exitCode = result.exitCode ?? 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ export async function startCommand(opts: StartOptions): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverEntry = pathToFileURL(path.join(resolveAppRoot(), "server.ts")).href;
|
const serverEntry = pathToFileURL(
|
||||||
|
path.join(resolveAppRoot(), "server.ts"),
|
||||||
|
).href;
|
||||||
await import(serverEntry);
|
await import(serverEntry);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,18 +20,27 @@ function printSnippet(heading: string, entry: Record<string, unknown>): void {
|
|||||||
async function promptNewPassword(): Promise<string> {
|
async function promptNewPassword(): Promise<string> {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const first = await promptPassword({ message: "Password", mask: true });
|
const first = await promptPassword({ message: "Password", mask: true });
|
||||||
const second = await promptPassword({ message: "Confirm password", mask: true });
|
const second = await promptPassword({
|
||||||
|
message: "Confirm password",
|
||||||
|
mask: true,
|
||||||
|
});
|
||||||
if (first === second) return first;
|
if (first === second) return first;
|
||||||
console.error("Passwords did not match, try again.\n");
|
console.error("Passwords did not match, try again.\n");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function usersAddCommand(username: string, opts: UsersOptions): Promise<void> {
|
export async function usersAddCommand(
|
||||||
|
username: string,
|
||||||
|
opts: UsersOptions,
|
||||||
|
): Promise<void> {
|
||||||
const password = await promptNewPassword();
|
const password = await promptNewPassword();
|
||||||
const passwordHash = await hashPassword(password);
|
const passwordHash = await hashPassword(password);
|
||||||
|
|
||||||
if (opts.inline) {
|
if (opts.inline) {
|
||||||
printSnippet("Add this under `auth.users:` in your config file:", { username, passwordHash });
|
printSnippet("Add this under `auth.users:` in your config file:", {
|
||||||
|
username,
|
||||||
|
passwordHash,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +56,10 @@ export async function usersAddCommand(username: string, opts: UsersOptions): Pro
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function usersAddTokenCommand(name: string, opts: UsersOptions): Promise<void> {
|
export async function usersAddTokenCommand(
|
||||||
|
name: string,
|
||||||
|
opts: UsersOptions,
|
||||||
|
): Promise<void> {
|
||||||
const token = crypto.randomBytes(32).toString("hex");
|
const token = crypto.randomBytes(32).toString("hex");
|
||||||
const tokenHash = `sha256:${crypto.createHash("sha256").update(token).digest("hex")}`;
|
const tokenHash = `sha256:${crypto.createHash("sha256").update(token).digest("hex")}`;
|
||||||
|
|
||||||
@@ -56,7 +68,10 @@ export async function usersAddTokenCommand(name: string, opts: UsersOptions): Pr
|
|||||||
console.log(`Use it as: Authorization: Bearer ${token}`);
|
console.log(`Use it as: Authorization: Bearer ${token}`);
|
||||||
|
|
||||||
if (opts.inline) {
|
if (opts.inline) {
|
||||||
printSnippet("Add this under `auth.tokens:` in your config file:", { name, tokenHash });
|
printSnippet("Add this under `auth.tokens:` in your config file:", {
|
||||||
|
name,
|
||||||
|
tokenHash,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+133
-14
@@ -1,20 +1,38 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { doctorCommand } from "./commands/doctor";
|
import { doctorCommand } from "./commands/doctor";
|
||||||
import { initCommand } from "./commands/init";
|
import { initCommand } from "./commands/init";
|
||||||
import { serviceInstallCommand, serviceStatusCommand, serviceUninstallCommand } from "./commands/service";
|
import { runCommand } from "./commands/run";
|
||||||
|
import { scriptsListCommand, scriptsShowCommand } from "./commands/scripts";
|
||||||
|
import {
|
||||||
|
serviceInstallCommand,
|
||||||
|
serviceLogsCommand,
|
||||||
|
serviceStatusCommand,
|
||||||
|
serviceUninstallCommand,
|
||||||
|
} from "./commands/service";
|
||||||
import { startCommand } from "./commands/start";
|
import { startCommand } from "./commands/start";
|
||||||
import { usersAddCommand, usersAddTokenCommand } from "./commands/users";
|
import { usersAddCommand, usersAddTokenCommand } from "./commands/users";
|
||||||
import { validateCommand } from "./commands/validate";
|
import { validateCommand } from "./commands/validate";
|
||||||
import { getVersion } from "./version";
|
import { getVersion } from "./version";
|
||||||
|
|
||||||
|
function collect(value: string, previous: string[]): string[] {
|
||||||
|
return [...previous, value];
|
||||||
|
}
|
||||||
|
|
||||||
const program = new Command("triggershell")
|
const program = new Command("triggershell")
|
||||||
.version(getVersion())
|
.version(getVersion())
|
||||||
.description("Launch the TriggerShell web app: run your configured shell scripts from a browser.");
|
.description(
|
||||||
|
"Launch the TriggerShell web app: run your configured shell scripts from a browser.",
|
||||||
|
);
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("init [path]")
|
.command("init [path]")
|
||||||
.description("Scaffold a new triggershell.yml (and .env, if auth is enabled)")
|
.description("Scaffold a new triggershell.yml (and .env, if auth is enabled)")
|
||||||
.option("--port <port>", "Port the web app will listen on.", (v) => Number(v), 4173)
|
.option(
|
||||||
|
"--port <port>",
|
||||||
|
"Port the web app will listen on.",
|
||||||
|
(v) => Number(v),
|
||||||
|
4173,
|
||||||
|
)
|
||||||
.option("--no-auth", "Disable built-in login for the web app.")
|
.option("--no-auth", "Disable built-in login for the web app.")
|
||||||
.option("--force", "Overwrite an existing config file.", false)
|
.option("--force", "Overwrite an existing config file.", false)
|
||||||
.action(initCommand);
|
.action(initCommand);
|
||||||
@@ -29,7 +47,9 @@ program
|
|||||||
.command("start")
|
.command("start")
|
||||||
.description("Run the web app in production mode.")
|
.description("Run the web app in production mode.")
|
||||||
.option("-c, --config <path>", "Path to the config file.")
|
.option("-c, --config <path>", "Path to the config file.")
|
||||||
.option("--port <port>", "Override the port from the config file.", (v) => Number(v))
|
.option("--port <port>", "Override the port from the config file.", (v) =>
|
||||||
|
Number(v),
|
||||||
|
)
|
||||||
.option("--host <host>", "Override the host from the config file.")
|
.option("--host <host>", "Override the host from the config file.")
|
||||||
.option("--no-browser", "Don't open a browser automatically.")
|
.option("--no-browser", "Don't open a browser automatically.")
|
||||||
.action(startCommand);
|
.action(startCommand);
|
||||||
@@ -40,45 +60,144 @@ program
|
|||||||
.option("-c, --config <path>", "Path to the config file.")
|
.option("-c, --config <path>", "Path to the config file.")
|
||||||
.action(doctorCommand);
|
.action(doctorCommand);
|
||||||
|
|
||||||
const users = program.command("users").description("Manage auth users and API tokens defined in your config file.");
|
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
|
users
|
||||||
.command("add <username>")
|
.command("add <username>")
|
||||||
.description("Hash a password with argon2id and wire it up for auth.users.")
|
.description("Hash a password with argon2id and wire it up for auth.users.")
|
||||||
.option("-c, --config <path>", "Path to the config file (used to locate .env).")
|
.option(
|
||||||
.option("--inline", "Print the raw hash to paste into the config instead of storing it in .env.", false)
|
"-c, --config <path>",
|
||||||
|
"Path to the config file (used to locate .env).",
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--inline",
|
||||||
|
"Print the raw hash to paste into the config instead of storing it in .env.",
|
||||||
|
false,
|
||||||
|
)
|
||||||
.action(usersAddCommand);
|
.action(usersAddCommand);
|
||||||
|
|
||||||
users
|
users
|
||||||
.command("add-token <name>")
|
.command("add-token <name>")
|
||||||
.description("Generate an API token and wire its hash up for auth.tokens.")
|
.description("Generate an API token and wire its hash up for auth.tokens.")
|
||||||
.option("-c, --config <path>", "Path to the config file (used to locate .env).")
|
.option(
|
||||||
.option("--inline", "Print the raw hash to paste into the config instead of storing it in .env.", false)
|
"-c, --config <path>",
|
||||||
|
"Path to the config file (used to locate .env).",
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--inline",
|
||||||
|
"Print the raw hash to paste into the config instead of storing it in .env.",
|
||||||
|
false,
|
||||||
|
)
|
||||||
.action(usersAddTokenCommand);
|
.action(usersAddTokenCommand);
|
||||||
|
|
||||||
const service = program.command("service").description("Manage the systemd service (Linux only).");
|
const service = program
|
||||||
|
.command("service")
|
||||||
|
.description("Manage the systemd service (Linux only).");
|
||||||
|
|
||||||
service
|
service
|
||||||
.command("install")
|
.command("install")
|
||||||
.description("Install a systemd unit that runs `triggershell start`.")
|
.description("Install a systemd unit that runs `triggershell start`.")
|
||||||
.option("-c, --config <path>", "Path to the config file.")
|
.option("-c, --config <path>", "Path to the config file.")
|
||||||
.option("--port <port>", "Override the port from the config file.", (v) => Number(v))
|
.option("--port <port>", "Override the port from the config file.", (v) =>
|
||||||
|
Number(v),
|
||||||
|
)
|
||||||
.option("--host <host>", "Override the host from the config file.")
|
.option("--host <host>", "Override the host from the config file.")
|
||||||
.option("--system", "Install a system-wide unit instead of a per-user one.", false)
|
.option(
|
||||||
|
"--system",
|
||||||
|
"Install a system-wide unit instead of a per-user one.",
|
||||||
|
false,
|
||||||
|
)
|
||||||
.action(serviceInstallCommand);
|
.action(serviceInstallCommand);
|
||||||
|
|
||||||
service
|
service
|
||||||
.command("uninstall")
|
.command("uninstall")
|
||||||
.description("Stop, disable, and remove the systemd unit.")
|
.description("Stop, disable, and remove the systemd unit.")
|
||||||
.option("--system", "Target the system-wide unit instead of the per-user one.", false)
|
.option(
|
||||||
|
"--system",
|
||||||
|
"Target the system-wide unit instead of the per-user one.",
|
||||||
|
false,
|
||||||
|
)
|
||||||
.action(serviceUninstallCommand);
|
.action(serviceUninstallCommand);
|
||||||
|
|
||||||
service
|
service
|
||||||
.command("status")
|
.command("status")
|
||||||
.description("Show the systemd unit's status.")
|
.description("Show the systemd unit's status.")
|
||||||
.option("--system", "Target the system-wide unit instead of the per-user one.", false)
|
.option(
|
||||||
|
"--system",
|
||||||
|
"Target the system-wide unit instead of the per-user one.",
|
||||||
|
false,
|
||||||
|
)
|
||||||
.action(serviceStatusCommand);
|
.action(serviceStatusCommand);
|
||||||
|
|
||||||
|
service
|
||||||
|
.command("logs")
|
||||||
|
.description("Tail the systemd unit's logs (journalctl).")
|
||||||
|
.option("-n, --lines <n>", "Number of recent log lines to show.", (v) =>
|
||||||
|
Number(v),
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--no-follow",
|
||||||
|
"Print recent logs and exit instead of tailing continuously.",
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--system",
|
||||||
|
"Target the system-wide unit instead of the per-user one.",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.action(serviceLogsCommand);
|
||||||
|
|
||||||
if (process.argv.length <= 2) {
|
if (process.argv.length <= 2) {
|
||||||
program.outputHelp();
|
program.outputHelp();
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import { test } from "node:test";
|
|||||||
import { loadDotenv, upsertEnvVar } from "./env-file";
|
import { loadDotenv, upsertEnvVar } from "./env-file";
|
||||||
|
|
||||||
function tmpEnvPath(): string {
|
function tmpEnvPath(): string {
|
||||||
return path.join(fs.mkdtempSync(path.join(os.tmpdir(), "triggershell-env-")), ".env");
|
return path.join(
|
||||||
|
fs.mkdtempSync(path.join(os.tmpdir(), "triggershell-env-")),
|
||||||
|
".env",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
test("upsertEnvVar appends a new key", () => {
|
test("upsertEnvVar appends a new key", () => {
|
||||||
|
|||||||
@@ -20,13 +20,19 @@ function parseEnvLines(content: string): EnvEntry[] {
|
|||||||
/** Loads a `.env` file into `process.env`, without overriding vars already set. */
|
/** Loads a `.env` file into `process.env`, without overriding vars already set. */
|
||||||
export function loadDotenv(envPath: string): void {
|
export function loadDotenv(envPath: string): void {
|
||||||
if (!fs.existsSync(envPath)) return;
|
if (!fs.existsSync(envPath)) return;
|
||||||
for (const { key, value } of parseEnvLines(fs.readFileSync(envPath, "utf-8"))) {
|
for (const { key, value } of parseEnvLines(
|
||||||
|
fs.readFileSync(envPath, "utf-8"),
|
||||||
|
)) {
|
||||||
if (process.env[key] === undefined) process.env[key] = value;
|
if (process.env[key] === undefined) process.env[key] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sets `key=value` in a `.env` file, replacing an existing line for that key rather than duplicating it. */
|
/** Sets `key=value` in a `.env` file, replacing an existing line for that key rather than duplicating it. */
|
||||||
export function upsertEnvVar(envPath: string, key: string, value: string): void {
|
export function upsertEnvVar(
|
||||||
|
envPath: string,
|
||||||
|
key: string,
|
||||||
|
value: string,
|
||||||
|
): void {
|
||||||
const lines = fs.existsSync(envPath)
|
const lines = fs.existsSync(envPath)
|
||||||
? fs.readFileSync(envPath, "utf-8").split("\n")
|
? fs.readFileSync(envPath, "utf-8").split("\n")
|
||||||
: [];
|
: [];
|
||||||
|
|||||||
+23
-2
@@ -36,12 +36,33 @@ export async function waitUntilReady(
|
|||||||
return false;
|
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 {
|
export function openBrowser(url: string): void {
|
||||||
const command =
|
const command =
|
||||||
process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
process.platform === "darwin"
|
||||||
|
? "open"
|
||||||
|
: process.platform === "win32"
|
||||||
|
? "start"
|
||||||
|
: "xdg-open";
|
||||||
const args = process.platform === "win32" ? ["", url] : [url];
|
const args = process.platform === "win32" ? ["", url] : [url];
|
||||||
try {
|
try {
|
||||||
spawn(command, args, { detached: true, stdio: "ignore", shell: process.platform === "win32" }).unref();
|
spawn(command, args, {
|
||||||
|
detached: true,
|
||||||
|
stdio: "ignore",
|
||||||
|
shell: process.platform === "win32",
|
||||||
|
}).unref();
|
||||||
} catch {
|
} catch {
|
||||||
// best-effort - not fatal if no browser opener is available
|
// best-effort - not fatal if no browser opener is available
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ const DEFAULT_CONFIG_NAME = "triggershell.yml";
|
|||||||
|
|
||||||
/** Root of the installed `triggershell` package - one level up from `src/cli/lib`. */
|
/** Root of the installed `triggershell` package - one level up from `src/cli/lib`. */
|
||||||
export function resolveAppRoot(): string {
|
export function resolveAppRoot(): string {
|
||||||
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
return path.resolve(
|
||||||
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveConfigPath(configArg?: string): string {
|
export function resolveConfigPath(configArg?: string): string {
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { renderUnit } from "./systemd";
|
|||||||
test("renderUnit builds an absolute-path ExecStart with the given args", () => {
|
test("renderUnit builds an absolute-path ExecStart with the given args", () => {
|
||||||
const unit = renderUnit({
|
const unit = renderUnit({
|
||||||
execPath: "/usr/bin/node",
|
execPath: "/usr/bin/node",
|
||||||
binPath: "/home/user/.local/share/pnpm/global/5/node_modules/.bin/triggershell",
|
binPath:
|
||||||
|
"/home/user/.local/share/pnpm/global/5/node_modules/.bin/triggershell",
|
||||||
configPath: "/home/user/project/triggershell.yml",
|
configPath: "/home/user/project/triggershell.yml",
|
||||||
configDir: "/home/user/project",
|
configDir: "/home/user/project",
|
||||||
port: 8080,
|
port: 8080,
|
||||||
@@ -31,5 +32,8 @@ test("renderUnit uses multi-user.target for the system scope", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.match(unit, /WantedBy=multi-user\.target/);
|
assert.match(unit, /WantedBy=multi-user\.target/);
|
||||||
assert.match(unit, /ExecStart=\/usr\/bin\/node .*start --config .*--no-browser$/m);
|
assert.match(
|
||||||
|
unit,
|
||||||
|
/ExecStart=\/usr\/bin\/node .*start --config .*--no-browser$/m,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ export function renderUnit(opts: UnitOptions): string {
|
|||||||
.map((part) => (part.includes(" ") ? `"${part}"` : part))
|
.map((part) => (part.includes(" ") ? `"${part}"` : part))
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
||||||
const wantedBy = opts.scope === "user" ? "default.target" : "multi-user.target";
|
const wantedBy =
|
||||||
|
opts.scope === "user" ? "default.target" : "multi-user.target";
|
||||||
|
|
||||||
return `[Unit]
|
return `[Unit]
|
||||||
Description=TriggerShell - self-hosted script runner
|
Description=TriggerShell - self-hosted script runner
|
||||||
@@ -42,7 +43,13 @@ WantedBy=${wantedBy}
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function userUnitPath(): string {
|
export function userUnitPath(): string {
|
||||||
return path.join(os.homedir(), ".config", "systemd", "user", `${SERVICE_NAME}.service`);
|
return path.join(
|
||||||
|
os.homedir(),
|
||||||
|
".config",
|
||||||
|
"systemd",
|
||||||
|
"user",
|
||||||
|
`${SERVICE_NAME}.service`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function systemUnitPath(): string {
|
export function systemUnitPath(): string {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+3
-1
@@ -4,6 +4,8 @@ import { resolveAppRoot } from "./lib/paths";
|
|||||||
|
|
||||||
export function getVersion(): string {
|
export function getVersion(): string {
|
||||||
const pkgPath = path.join(resolveAppRoot(), "package.json");
|
const pkgPath = path.join(resolveAppRoot(), "package.json");
|
||||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { version: string };
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as {
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
return pkg.version;
|
return pkg.version;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,19 @@ import ReactMarkdown from "react-markdown";
|
|||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
// GFM tables (the config reference's Field/Type/Default/Notes tables) are wider than a phone
|
||||||
|
// screen and don't wrap - without their own scroll container the table forces the whole page
|
||||||
|
// to scroll horizontally instead. The typography plugin's table styles still apply to `table`
|
||||||
|
// here (its selectors match any descendant, not just direct children of `.prose`), so this
|
||||||
|
// wrapper only adds the scroll boundary.
|
||||||
|
function Table(props: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table {...props} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function MarkdownViewer({ content }: { content: string }) {
|
export function MarkdownViewer({ content }: { content: string }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -13,9 +26,14 @@ export function MarkdownViewer({ content }: { content: string }) {
|
|||||||
// elsewhere in the app (see the "no scripts configured" message on the dashboard).
|
// elsewhere in the app (see the "no scripts configured" message on the dashboard).
|
||||||
"prose-code:before:content-none prose-code:after:content-none",
|
"prose-code:before:content-none prose-code:after:content-none",
|
||||||
"prose-code:rounded prose-code:bg-muted prose-code:px-1.5 prose-code:py-0.5 prose-code:font-mono prose-code:font-normal prose-code:text-foreground",
|
"prose-code:rounded prose-code:bg-muted prose-code:px-1.5 prose-code:py-0.5 prose-code:font-mono prose-code:font-normal prose-code:text-foreground",
|
||||||
|
// Long unbroken strings (env var names, paths) in table cells or inline code would
|
||||||
|
// otherwise force their column/line wider than the viewport instead of wrapping.
|
||||||
|
"prose-td:break-words prose-th:break-words prose-code:break-words",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
<ReactMarkdown remarkPlugins={[remarkGfm]} components={{ table: Table }}>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Check, ChevronsUpDown } from "lucide-react";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ComboboxProps {
|
||||||
|
choices: string[];
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Combobox({
|
||||||
|
choices,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = "Select...",
|
||||||
|
}: ComboboxProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "outline" }),
|
||||||
|
"h-auto min-h-8 w-full justify-between font-normal",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn("flex-1 text-left", !value && "text-muted-foreground")}
|
||||||
|
>
|
||||||
|
{value || placeholder}
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDown className="text-muted-foreground size-4 shrink-0" />
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-80 p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Search..." />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No matches.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{choices.map((choice) => (
|
||||||
|
<CommandItem
|
||||||
|
key={choice}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(choice);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 size-4",
|
||||||
|
value === choice ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{choice}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,30 +10,28 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Form } from "@/components/ui/form";
|
import { Form } from "@/components/ui/form";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { buildVariableSchemaFromList } from "@/lib/validation/variable-schema";
|
import { buildVariableSchemaFromList } from "@/lib/validation/variable-schema";
|
||||||
|
import { defaultValuesForScript } from "@/lib/config/defaults";
|
||||||
import type { ClientScript } from "@/lib/config/serialize";
|
import type { ClientScript } from "@/lib/config/serialize";
|
||||||
import { FieldRenderer } from "./field-renderer";
|
import { FieldRenderer } from "./field-renderer";
|
||||||
|
|
||||||
function emptyValueFor(variable: ClientScript["variables"][number]): unknown {
|
/** Controls whose content doesn't shrink well into a narrow grid column - long-form text,
|
||||||
if (variable.type === "boolean") return false;
|
* or a group of checkboxes that reads better as a single wide list - so they span the full
|
||||||
if (variable.type === "multiselect") return [];
|
* grid width instead of sharing a row with other fields. */
|
||||||
return "";
|
const WIDE_CONTROLS = new Set(["textarea", "checkboxGroup"]);
|
||||||
}
|
|
||||||
|
|
||||||
/** `initialValues` comes from a previous run's (already-redacted) variables when re-running -
|
/** `initialValues` comes from a previous run's (already-redacted) variables when re-running -
|
||||||
* secret fields are deliberately excluded there (their stored value is just "***", not the real
|
* secret fields are deliberately excluded there (their stored value is just "***", not the real
|
||||||
* one), so those always fall through to the normal empty/default state and have to be re-entered. */
|
* one), so those always fall through to the normal default/empty state and have to be re-entered. */
|
||||||
function defaultValuesFor(
|
function defaultValuesFor(
|
||||||
script: ClientScript,
|
script: ClientScript,
|
||||||
initialValues?: Record<string, unknown>,
|
initialValues?: Record<string, unknown>,
|
||||||
): 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) {
|
||||||
const fromPreviousRun =
|
if (variable.secret) continue;
|
||||||
initialValues && !variable.secret
|
const fromPreviousRun = initialValues[variable.name];
|
||||||
? initialValues[variable.name]
|
if (fromPreviousRun !== undefined) values[variable.name] = fromPreviousRun;
|
||||||
: undefined;
|
|
||||||
values[variable.name] =
|
|
||||||
fromPreviousRun ?? variable.default ?? emptyValueFor(variable);
|
|
||||||
}
|
}
|
||||||
return values;
|
return values;
|
||||||
}
|
}
|
||||||
@@ -100,9 +98,22 @@ export function DynamicForm({
|
|||||||
This script takes no parameters.
|
This script takes no parameters.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{script.variables.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{script.variables.map((variable) => (
|
{script.variables.map((variable) => (
|
||||||
<FieldRenderer key={variable.name} variable={variable} />
|
<div
|
||||||
|
key={variable.name}
|
||||||
|
className={
|
||||||
|
WIDE_CONTROLS.has(variable.control)
|
||||||
|
? "sm:col-span-2 lg:col-span-3"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FieldRenderer variable={variable} />
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={form.formState.isSubmitting}
|
disabled={form.formState.isSubmitting}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { MultiSelect } from "./controls/multi-select";
|
import { MultiSelect } from "./controls/multi-select";
|
||||||
|
import { Combobox } from "./controls/combobox";
|
||||||
import type { ClientVariable } from "@/lib/config/serialize";
|
import type { ClientVariable } from "@/lib/config/serialize";
|
||||||
|
|
||||||
export function FieldRenderer({ variable }: { variable: ClientVariable }) {
|
export function FieldRenderer({ variable }: { variable: ClientVariable }) {
|
||||||
@@ -211,6 +212,29 @@ export function FieldRenderer({ variable }: { variable: ClientVariable }) {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case "combobox":
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{label}
|
||||||
|
{variable.required && (
|
||||||
|
<span className="text-destructive"> *</span>
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Combobox
|
||||||
|
choices={variable.type === "enum" ? variable.choices : []}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
onChange={(value) => field.onChange(value)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
{variable.description && (
|
||||||
|
<FormDescription>{variable.description}</FormDescription>
|
||||||
|
)}
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
|
||||||
case "radio":
|
case "radio":
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Home } from "lucide-react";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function NotFoundContent() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex max-w-md flex-col items-center gap-3 py-24 text-center">
|
||||||
|
<span className="text-primary font-mono text-6xl font-semibold tracking-tight">
|
||||||
|
404
|
||||||
|
</span>
|
||||||
|
<h1 className="font-heading text-xl font-medium">Page not found</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
The page you’re looking for doesn’t exist or may have been
|
||||||
|
moved.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "outline", size: "sm" }),
|
||||||
|
"mt-2",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Home className="size-3.5" />
|
||||||
|
Back to dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -95,7 +95,7 @@ export const XtermView = forwardRef<XtermViewHandle, XtermViewProps>(
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="h-[60vh] overflow-hidden bg-[#101215] p-2"
|
className="aspect-video w-full overflow-hidden bg-[#101215] p-2"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
+15
-1
@@ -2,12 +2,18 @@ import { eq } from "drizzle-orm";
|
|||||||
import { getDb } from "../db/client";
|
import { getDb } from "../db/client";
|
||||||
import { users, apiTokens } from "../db/schema";
|
import { users, apiTokens } from "../db/schema";
|
||||||
import { getConfig } from "../config/load";
|
import { getConfig } from "../config/load";
|
||||||
|
import { logger } from "../logger";
|
||||||
|
|
||||||
|
const log = logger.child({ mod: "auth" });
|
||||||
|
|
||||||
/** Config is the source of truth for who's allowed in; this materializes it into SQLite so the
|
/** Config is the source of truth for who's allowed in; this materializes it into SQLite so the
|
||||||
* runtime auth-check path is uniform and `lastLoginAt`/`lastUsedAt` can be tracked. Called on boot. */
|
* runtime auth-check path is uniform and `lastLoginAt`/`lastUsedAt` can be tracked. Called on boot. */
|
||||||
export function syncAuthFromConfig() {
|
export function syncAuthFromConfig() {
|
||||||
const { config } = getConfig();
|
const { config } = getConfig();
|
||||||
if (!config.auth.enabled) return;
|
if (!config.auth.enabled) {
|
||||||
|
log.debug("auth disabled, skipping config sync");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
@@ -53,4 +59,12 @@ export function syncAuthFromConfig() {
|
|||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
{
|
||||||
|
userCount: config.auth.users.length,
|
||||||
|
tokenCount: config.auth.tokens.length,
|
||||||
|
},
|
||||||
|
"synced auth config",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { VariableConfig } from "./schema";
|
||||||
|
|
||||||
|
export function emptyValueFor(variable: VariableConfig): unknown {
|
||||||
|
if (variable.type === "boolean") return false;
|
||||||
|
if (variable.type === "multiselect") return [];
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fills in each variable's configured `default` (or an empty value) - the Zod schema in
|
||||||
|
* `variable-schema.ts` doesn't apply `default` itself, it only validates whatever is present, so
|
||||||
|
* any caller that wants "an omitted variable behaves like its configured default" applies this
|
||||||
|
* first. Shared by the web form (`dynamic-form.tsx`) and the CLI's `run` command. */
|
||||||
|
export function defaultValuesForScript(
|
||||||
|
variables: readonly VariableConfig[],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const values: Record<string, unknown> = {};
|
||||||
|
for (const variable of variables) {
|
||||||
|
values[variable.name] = variable.default ?? emptyValueFor(variable);
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
+13
-3
@@ -2,6 +2,9 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { parse as parseYaml } from "yaml";
|
import { parse as parseYaml } from "yaml";
|
||||||
import { configSchema, type TriggerShellConfig } from "./schema";
|
import { configSchema, type TriggerShellConfig } from "./schema";
|
||||||
|
import { logger } from "../logger";
|
||||||
|
|
||||||
|
const log = logger.child({ mod: "config" });
|
||||||
|
|
||||||
export class ConfigError extends Error {
|
export class ConfigError extends Error {
|
||||||
issues: string[];
|
issues: string[];
|
||||||
@@ -36,9 +39,7 @@ export interface LoadedConfig {
|
|||||||
|
|
||||||
export function resolveConfigPath(configPathArg?: string): string {
|
export function resolveConfigPath(configPathArg?: string): string {
|
||||||
const candidate =
|
const candidate =
|
||||||
configPathArg ??
|
configPathArg ?? process.env.TRIGGERSHELL_CONFIG_PATH ?? "triggershell.yml";
|
||||||
process.env.TRIGGERSHELL_CONFIG_PATH ??
|
|
||||||
"triggershell.yml";
|
|
||||||
return path.resolve(/*turbopackIgnore: true*/ candidate);
|
return path.resolve(/*turbopackIgnore: true*/ candidate);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +93,15 @@ declare global {
|
|||||||
export function getConfig(): LoadedConfig {
|
export function getConfig(): LoadedConfig {
|
||||||
if (!globalThis.__triggershellConfig) {
|
if (!globalThis.__triggershellConfig) {
|
||||||
globalThis.__triggershellConfig = loadConfig();
|
globalThis.__triggershellConfig = loadConfig();
|
||||||
|
const { config, configPath } = globalThis.__triggershellConfig;
|
||||||
|
log.info(
|
||||||
|
{
|
||||||
|
configPath,
|
||||||
|
scriptCount: config.scripts.length,
|
||||||
|
authEnabled: config.auth.enabled,
|
||||||
|
},
|
||||||
|
"config loaded",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return globalThis.__triggershellConfig;
|
return globalThis.__triggershellConfig;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const controlSchema = z.enum([
|
|||||||
"switch",
|
"switch",
|
||||||
"select",
|
"select",
|
||||||
"radio",
|
"radio",
|
||||||
|
"combobox",
|
||||||
"multiselect",
|
"multiselect",
|
||||||
"checkboxGroup",
|
"checkboxGroup",
|
||||||
]);
|
]);
|
||||||
@@ -115,6 +116,13 @@ const variableWithChecks = variableSchema.superRefine((variable, ctx) => {
|
|||||||
path: ["control"],
|
path: ["control"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (variable.control === "combobox" && variable.type !== "enum") {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "control 'combobox' requires type 'enum'",
|
||||||
|
path: ["control"],
|
||||||
|
});
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
variable.control === "slider" &&
|
variable.control === "slider" &&
|
||||||
variable.type === "number" &&
|
variable.type === "number" &&
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import { drizzle } from "drizzle-orm/better-sqlite3";
|
|||||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
import * as schema from "./schema";
|
import * as schema from "./schema";
|
||||||
import { getConfig } from "../config/load";
|
import { getConfig } from "../config/load";
|
||||||
|
import { logger } from "../logger";
|
||||||
|
|
||||||
|
const log = logger.child({ mod: "db" });
|
||||||
|
|
||||||
type Db = ReturnType<typeof drizzle<typeof schema>>;
|
type Db = ReturnType<typeof drizzle<typeof schema>>;
|
||||||
|
|
||||||
@@ -25,6 +28,7 @@ export function getDb(): Db {
|
|||||||
sqlite.pragma("journal_mode = WAL");
|
sqlite.pragma("journal_mode = WAL");
|
||||||
sqlite.pragma("foreign_keys = ON");
|
sqlite.pragma("foreign_keys = ON");
|
||||||
|
|
||||||
|
log.debug({ dbPath }, "opened database");
|
||||||
globalThis.__triggershellDb = drizzle(sqlite, { schema });
|
globalThis.__triggershellDb = drizzle(sqlite, { schema });
|
||||||
return globalThis.__triggershellDb;
|
return globalThis.__triggershellDb;
|
||||||
}
|
}
|
||||||
@@ -37,4 +41,5 @@ export function migrateOnBoot() {
|
|||||||
"../../../drizzle",
|
"../../../drizzle",
|
||||||
);
|
);
|
||||||
migrate(db, { migrationsFolder });
|
migrate(db, { migrationsFolder });
|
||||||
|
log.info("database migrations applied");
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -12,13 +12,15 @@ export const DOCS: DocMeta[] = [
|
|||||||
{
|
{
|
||||||
slug: "api",
|
slug: "api",
|
||||||
title: "API Reference",
|
title: "API Reference",
|
||||||
description: "REST and WebSocket endpoints, auth, and request/response shapes.",
|
description:
|
||||||
|
"REST and WebSocket endpoints, auth, and request/response shapes.",
|
||||||
file: "API.md",
|
file: "API.md",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: "config",
|
slug: "config",
|
||||||
title: "Config Reference",
|
title: "Config Reference",
|
||||||
description: "Every field in triggershell.yml: server, auth, scripts, and variables.",
|
description:
|
||||||
|
"Every field in triggershell.yml: server, auth, scripts, and variables.",
|
||||||
file: "CONFIG_REFERENCE.md",
|
file: "CONFIG_REFERENCE.md",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -34,7 +36,8 @@ export function getDocMeta(slug: string): DocMeta | undefined {
|
|||||||
// Falls back to a source-relative resolution for contexts where server.ts never ran (e.g. tests).
|
// Falls back to a source-relative resolution for contexts where server.ts never ran (e.g. tests).
|
||||||
function docsDir(): string {
|
function docsDir(): string {
|
||||||
const appRoot =
|
const appRoot =
|
||||||
process.env.TRIGGERSHELL_APP_ROOT ?? path.resolve(import.meta.dirname, "..", "..");
|
process.env.TRIGGERSHELL_APP_ROOT ??
|
||||||
|
path.resolve(import.meta.dirname, "..", "..");
|
||||||
return path.resolve(appRoot, "docs");
|
return path.resolve(appRoot, "docs");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import pino from "pino";
|
||||||
|
|
||||||
|
const isProduction = process.env.NODE_ENV === "production";
|
||||||
|
const level = process.env.LOG_LEVEL ?? (isProduction ? "info" : "debug");
|
||||||
|
|
||||||
|
function createLogger(): pino.Logger {
|
||||||
|
return pino({
|
||||||
|
name: "triggershell",
|
||||||
|
level,
|
||||||
|
// Values are never logged under these key names in the first place - this is defense in
|
||||||
|
// depth against a future call site accidentally passing one through.
|
||||||
|
redact: {
|
||||||
|
paths: [
|
||||||
|
"password",
|
||||||
|
"*.password",
|
||||||
|
"token",
|
||||||
|
"*.token",
|
||||||
|
"passwordHash",
|
||||||
|
"*.passwordHash",
|
||||||
|
"tokenHash",
|
||||||
|
"*.tokenHash",
|
||||||
|
"sessionSecret",
|
||||||
|
"*.sessionSecret",
|
||||||
|
"authorization",
|
||||||
|
"*.authorization",
|
||||||
|
"cookie",
|
||||||
|
"*.cookie",
|
||||||
|
],
|
||||||
|
censor: "[REDACTED]",
|
||||||
|
},
|
||||||
|
transport: isProduction
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
target: "pino-pretty",
|
||||||
|
options: { colorize: true, ignore: "pid,hostname" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
var __triggershellLogger: pino.Logger | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anchored on `globalThis` - see the comment in `runner/events.ts` for why: Next compiles Route
|
||||||
|
// Handlers through a separate module graph from what `server.ts` requires directly.
|
||||||
|
//
|
||||||
|
// Lazily constructed behind a Proxy rather than built at module-evaluation time: `config/load.ts`
|
||||||
|
// (imported by every CLI command for `loadConfig()`) also imports this module, and the dev-mode
|
||||||
|
// pretty transport spins up a worker thread the instant `pino({ transport })` runs. Without the
|
||||||
|
// laziness, a plain CLI invocation like `triggershell scripts list` would pay for a logger no
|
||||||
|
// backend code path in that process will ever call.
|
||||||
|
function ensureLogger(): pino.Logger {
|
||||||
|
if (!globalThis.__triggershellLogger) {
|
||||||
|
globalThis.__triggershellLogger = createLogger();
|
||||||
|
}
|
||||||
|
return globalThis.__triggershellLogger;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logger: pino.Logger = new Proxy({} as pino.Logger, {
|
||||||
|
get(_target, prop) {
|
||||||
|
const target = ensureLogger();
|
||||||
|
const value = Reflect.get(target, prop, target);
|
||||||
|
return typeof value === "function" ? value.bind(target) : value;
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -18,6 +18,15 @@ function stringifyValue(value: unknown, joinWith: string): string {
|
|||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Quotes a value for the human-readable `redactedCommandLine` display only - the real
|
||||||
|
* invocation always passes values as discrete argv elements/env vars (see build note below),
|
||||||
|
* so this never affects execution. Without it, a value like "Glitz and glam" renders as three
|
||||||
|
* bare words indistinguishable from separate argv entries. */
|
||||||
|
function quoteForDisplay(value: string): string {
|
||||||
|
if (value !== "" && /^[a-zA-Z0-9_@%+=:,./-]+$/.test(value)) return value;
|
||||||
|
return `"${value.replace(/([$`"\\])/g, "\\$1")}"`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Builds an argv-array invocation from validated variable values. Never produces a shell string. */
|
/** Builds an argv-array invocation from validated variable values. Never produces a shell string. */
|
||||||
export function buildInvocation(
|
export function buildInvocation(
|
||||||
script: ScriptConfig,
|
script: ScriptConfig,
|
||||||
@@ -26,6 +35,7 @@ export function buildInvocation(
|
|||||||
const argv = [...script.args];
|
const argv = [...script.args];
|
||||||
const env: Record<string, string> = {};
|
const env: Record<string, string> = {};
|
||||||
const redactedArgv = [...script.args];
|
const redactedArgv = [...script.args];
|
||||||
|
const redactedEnvAssignments: string[] = [];
|
||||||
const redactedVariables: Record<string, unknown> = {};
|
const redactedVariables: Record<string, unknown> = {};
|
||||||
|
|
||||||
for (const variable of script.variables) {
|
for (const variable of script.variables) {
|
||||||
@@ -42,7 +52,10 @@ export function buildInvocation(
|
|||||||
const argName = variable.argName!;
|
const argName = variable.argName!;
|
||||||
const value = stringifyValue(raw, variable.joinWith);
|
const value = stringifyValue(raw, variable.joinWith);
|
||||||
argv.push(argName, value);
|
argv.push(argName, value);
|
||||||
redactedArgv.push(argName, variable.secret ? REDACTED : value);
|
redactedArgv.push(
|
||||||
|
argName,
|
||||||
|
variable.secret ? REDACTED : quoteForDisplay(value),
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "flag": {
|
case "flag": {
|
||||||
@@ -55,6 +68,9 @@ export function buildInvocation(
|
|||||||
case "env": {
|
case "env": {
|
||||||
const value = stringifyValue(raw, variable.joinWith);
|
const value = stringifyValue(raw, variable.joinWith);
|
||||||
env[variable.envName!] = value;
|
env[variable.envName!] = value;
|
||||||
|
redactedEnvAssignments.push(
|
||||||
|
`${variable.envName}=${variable.secret ? REDACTED : quoteForDisplay(value)}`,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "stdin": {
|
case "stdin": {
|
||||||
@@ -74,7 +90,11 @@ export function buildInvocation(
|
|||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const redactedCommandLine = [script.command, ...redactedArgv].join(" ");
|
const redactedCommandLine = [
|
||||||
|
...redactedEnvAssignments,
|
||||||
|
script.command,
|
||||||
|
...redactedArgv,
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
return { argv, env, stdin, redactedVariables, redactedCommandLine };
|
return { argv, env, stdin, redactedVariables, redactedCommandLine };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import type { ScriptConfig } from "../config/schema";
|
|||||||
import { buildInvocation, type Invocation } from "./build-args";
|
import { buildInvocation, type Invocation } from "./build-args";
|
||||||
import { registerRun, unregisterRun } from "./registry";
|
import { registerRun, unregisterRun } from "./registry";
|
||||||
import { emitRunMessage } from "./events";
|
import { emitRunMessage } from "./events";
|
||||||
|
import { logger } from "../logger";
|
||||||
|
|
||||||
|
const log = logger.child({ mod: "runner" });
|
||||||
|
|
||||||
export class ScriptNotFoundError extends Error {}
|
export class ScriptNotFoundError extends Error {}
|
||||||
|
|
||||||
@@ -49,9 +52,14 @@ export async function startRun({
|
|||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
|
log.info({ runId, scriptId: script.id, triggeredBy }, "run queued");
|
||||||
|
|
||||||
// Fire and forget - the caller gets the runId immediately, progress streams over WS/polling.
|
// Fire and forget - the caller gets the runId immediately, progress streams over WS/polling.
|
||||||
void executeRun(runId, script, invocation, logFilePath).catch((error) => {
|
void executeRun(runId, script, invocation, logFilePath).catch((error) => {
|
||||||
console.error(`[runner] unhandled error executing run ${runId}:`, error);
|
log.error(
|
||||||
|
{ runId, scriptId: script.id, err: error },
|
||||||
|
"unhandled error executing run",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return runId;
|
return runId;
|
||||||
@@ -103,6 +111,11 @@ async function executeRun(
|
|||||||
|
|
||||||
registerRun({ runId, scriptId: script.id, controller });
|
registerRun({ runId, scriptId: script.id, controller });
|
||||||
setStatus("running", { startedAt: new Date() });
|
setStatus("running", { startedAt: new Date() });
|
||||||
|
log.info(
|
||||||
|
{ runId, scriptId: script.id, command: script.command },
|
||||||
|
"run started",
|
||||||
|
);
|
||||||
|
const startedAt = process.hrtime.bigint();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const subprocess = execa(script.command, invocation.argv, {
|
const subprocess = execa(script.command, invocation.argv, {
|
||||||
@@ -145,11 +158,26 @@ async function executeRun(
|
|||||||
? (result.shortMessage ?? null)
|
? (result.shortMessage ?? null)
|
||||||
: null,
|
: null,
|
||||||
});
|
});
|
||||||
|
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1e6;
|
||||||
|
log[status === "succeeded" ? "info" : "warn"](
|
||||||
|
{
|
||||||
|
runId,
|
||||||
|
scriptId: script.id,
|
||||||
|
status,
|
||||||
|
exitCode: result.exitCode ?? null,
|
||||||
|
durationMs: Math.round(durationMs),
|
||||||
|
},
|
||||||
|
"run finished",
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus("failed", {
|
setStatus("failed", {
|
||||||
endedAt: new Date(),
|
endedAt: new Date(),
|
||||||
errorMessage: (error as Error).message,
|
errorMessage: (error as Error).message,
|
||||||
});
|
});
|
||||||
|
log.error(
|
||||||
|
{ runId, scriptId: script.id, err: error },
|
||||||
|
"run failed to execute",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
logStream.end();
|
logStream.end();
|
||||||
unregisterRun(runId);
|
unregisterRun(runId);
|
||||||
@@ -170,7 +198,8 @@ export function reconcileOrphanedRuns() {
|
|||||||
})
|
})
|
||||||
.where(eq(runs.status, "running"))
|
.where(eq(runs.status, "running"))
|
||||||
.run();
|
.run();
|
||||||
db.update(runs)
|
const queued = db
|
||||||
|
.update(runs)
|
||||||
.set({
|
.set({
|
||||||
status: "interrupted",
|
status: "interrupted",
|
||||||
endedAt: now,
|
endedAt: now,
|
||||||
@@ -178,5 +207,14 @@ export function reconcileOrphanedRuns() {
|
|||||||
})
|
})
|
||||||
.where(eq(runs.status, "queued"))
|
.where(eq(runs.status, "queued"))
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
|
const interruptedCount = orphaned.changes + queued.changes;
|
||||||
|
if (interruptedCount > 0) {
|
||||||
|
log.warn(
|
||||||
|
{ runningCount: orphaned.changes, queuedCount: queued.changes },
|
||||||
|
"marked orphaned runs as interrupted after restart",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return orphaned;
|
return orphaned;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ declare global {
|
|||||||
* Anchored on `globalThis` because Next compiles Route Handlers through its own module graph,
|
* Anchored on `globalThis` because Next compiles Route Handlers through its own module graph,
|
||||||
* separate from the modules `server.ts` requires directly via tsx - a plain module-level
|
* separate from the modules `server.ts` requires directly via tsx - a plain module-level
|
||||||
* singleton would silently end up duplicated (one copy per graph) instead of shared. */
|
* singleton would silently end up duplicated (one copy per graph) instead of shared. */
|
||||||
export const runEvents: EventEmitter = globalThis.__triggershellRunEvents ?? new EventEmitter();
|
export const runEvents: EventEmitter =
|
||||||
|
globalThis.__triggershellRunEvents ?? new EventEmitter();
|
||||||
globalThis.__triggershellRunEvents = runEvents;
|
globalThis.__triggershellRunEvents = runEvents;
|
||||||
runEvents.setMaxListeners(0);
|
runEvents.setMaxListeners(0);
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ declare global {
|
|||||||
* Anchored on `globalThis` - see the comment in `runner/events.ts` for why a plain module-level
|
* Anchored on `globalThis` - see the comment in `runner/events.ts` for why a plain module-level
|
||||||
* singleton isn't safe here (Next compiles Route Handlers through a separate module graph from
|
* singleton isn't safe here (Next compiles Route Handlers through a separate module graph from
|
||||||
* what `server.ts` requires directly). */
|
* what `server.ts` requires directly). */
|
||||||
const handles: Map<string, RunHandle> = globalThis.__triggershellRunHandles ?? new Map();
|
const handles: Map<string, RunHandle> =
|
||||||
|
globalThis.__triggershellRunHandles ?? new Map();
|
||||||
globalThis.__triggershellRunHandles = handles;
|
globalThis.__triggershellRunHandles = handles;
|
||||||
|
|
||||||
export function registerRun(handle: RunHandle) {
|
export function registerRun(handle: RunHandle) {
|
||||||
|
|||||||
+18
-2
@@ -13,8 +13,11 @@ import { apiTokens, runs } from "../db/schema";
|
|||||||
import { runEvents } from "../runner/events";
|
import { runEvents } from "../runner/events";
|
||||||
import { cancelRun } from "../runner/registry";
|
import { cancelRun } from "../runner/registry";
|
||||||
import { readLogSince } from "../runner/log-file";
|
import { readLogSince } from "../runner/log-file";
|
||||||
|
import { logger } from "../logger";
|
||||||
import { isClientMessage, type ServerMessage } from "./protocol";
|
import { isClientMessage, type ServerMessage } from "./protocol";
|
||||||
|
|
||||||
|
const log = logger.child({ mod: "ws" });
|
||||||
|
|
||||||
const subscriptions = new Map<string, Set<WebSocket>>();
|
const subscriptions = new Map<string, Set<WebSocket>>();
|
||||||
|
|
||||||
/** A run can start and finish (emitting all its output+status over `runEvents`) before a
|
/** A run can start and finish (emitting all its output+status over `runEvents`) before a
|
||||||
@@ -104,28 +107,41 @@ export async function authenticateUpgrade(
|
|||||||
|
|
||||||
export function attachWsServer(wss: WebSocketServer) {
|
export function attachWsServer(wss: WebSocketServer) {
|
||||||
wss.on("connection", (ws: WebSocket) => {
|
wss.on("connection", (ws: WebSocket) => {
|
||||||
|
const connId = crypto.randomUUID();
|
||||||
|
log.debug({ connId }, "connection opened");
|
||||||
|
|
||||||
ws.on("message", (raw) => {
|
ws.on("message", (raw) => {
|
||||||
let parsed: unknown;
|
let parsed: unknown;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(raw.toString());
|
parsed = JSON.parse(raw.toString());
|
||||||
} catch {
|
} catch {
|
||||||
|
log.warn({ connId }, "dropped unparseable WS message");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isClientMessage(parsed)) {
|
||||||
|
log.warn({ connId, parsed }, "dropped unrecognized WS message");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isClientMessage(parsed)) return;
|
|
||||||
|
|
||||||
switch (parsed.type) {
|
switch (parsed.type) {
|
||||||
case "subscribe":
|
case "subscribe":
|
||||||
|
log.debug({ connId, runId: parsed.runId }, "subscribe");
|
||||||
subscribe(parsed.runId, ws, parsed.afterBytes ?? 0);
|
subscribe(parsed.runId, ws, parsed.afterBytes ?? 0);
|
||||||
break;
|
break;
|
||||||
case "unsubscribe":
|
case "unsubscribe":
|
||||||
|
log.debug({ connId, runId: parsed.runId }, "unsubscribe");
|
||||||
unsubscribe(parsed.runId, ws);
|
unsubscribe(parsed.runId, ws);
|
||||||
break;
|
break;
|
||||||
case "cancel":
|
case "cancel":
|
||||||
|
log.info({ connId, runId: parsed.runId }, "cancel requested via WS");
|
||||||
cancelRun(parsed.runId);
|
cancelRun(parsed.runId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on("close", () => unsubscribeAll(ws));
|
ws.on("close", () => {
|
||||||
|
log.debug({ connId }, "connection closed");
|
||||||
|
unsubscribeAll(ws);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { NextResponse } from "next/server";
|
|||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { getConfig } from "@/lib/config/load";
|
import { getConfig } from "@/lib/config/load";
|
||||||
import { verifySessionCookieValue } from "@/lib/auth/session";
|
import { verifySessionCookieValue } from "@/lib/auth/session";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
|
const log = logger.child({ mod: "proxy" });
|
||||||
|
|
||||||
const PUBLIC_PATHS = [
|
const PUBLIC_PATHS = [
|
||||||
"/login",
|
"/login",
|
||||||
@@ -39,8 +42,13 @@ export async function proxy(request: NextRequest) {
|
|||||||
|
|
||||||
if (!session?.userId) {
|
if (!session?.userId) {
|
||||||
if (isApiRoute) {
|
if (isApiRoute) {
|
||||||
|
log.warn({ path: pathname }, "blocked unauthenticated API request");
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
log.debug(
|
||||||
|
{ path: pathname },
|
||||||
|
"redirected unauthenticated request to login",
|
||||||
|
);
|
||||||
const loginUrl = new URL("/login", request.url);
|
const loginUrl = new URL("/login", request.url);
|
||||||
loginUrl.searchParams.set("next", pathname);
|
loginUrl.searchParams.set("next", pathname);
|
||||||
return NextResponse.redirect(loginUrl);
|
return NextResponse.redirect(loginUrl);
|
||||||
|
|||||||
Reference in New Issue
Block a user