Run prettier across the repo, exclude the lockfile from it

Prettier had never been run in --check mode here before, so this had
drifted across most files (markdown tables, long option() chains,
line wrapping). Purely formatting, no logic changes - needed so a CI
format:check gate can actually pass. Adds .prettierignore for
pnpm-lock.yaml specifically: prettier's YAML formatter rewrites every
quoted key (single -> double quotes) producing an ~8700-line diff of
pure noise on a file pnpm itself owns the formatting of.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 13:55:25 +02:00
co-authored by Claude Sonnet 5
parent e13b7e3b1a
commit c6337ea942
30 changed files with 387 additions and 172 deletions
+2
View File
@@ -0,0 +1,2 @@
# Machine-generated - pnpm owns this file's formatting, not prettier.
pnpm-lock.yaml
+28 -28
View File
@@ -99,20 +99,20 @@ 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 list [-c CONFIG]` | List configured scripts |
| `triggershell scripts show <scriptId> [-c CONFIG]` | Show a script's command and variables | | `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 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 |
### Running scripts from the CLI ### Running scripts from the CLI
@@ -154,20 +154,20 @@ 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 |
| GET | `/api/auth/session` | Current auth state | | GET | `/api/auth/session` | Current auth state |
| GET | `/api/scripts` | List configured scripts | | GET | `/api/scripts` | List configured scripts |
| GET | `/api/scripts/:scriptId` | Full script schema (variables, UI hints) | | GET | `/api/scripts/:scriptId` | Full script schema (variables, UI hints) |
| POST | `/api/scripts/:scriptId/runs` | Start a run: `{variables: {...}}` | | POST | `/api/scripts/:scriptId/runs` | Start a run: `{variables: {...}}` |
| GET | `/api/runs` | History: `?scriptId=&status=&limit=&cursor=` | | GET | `/api/runs` | History: `?scriptId=&status=&limit=&cursor=` |
| GET | `/api/runs/:runId` | Single run's status/metadata | | GET | `/api/runs/:runId` | Single run's status/metadata |
| POST | `/api/runs/:runId/cancel` | Cancel an active run | | POST | `/api/runs/:runId/cancel` | Cancel an active run |
| GET | `/api/runs/:runId/logs` | Full or tailed log output (`?tail=&download=`) | | GET | `/api/runs/:runId/logs` | Full or tailed log output (`?tail=&download=`) |
| WS | `/ws/runs` | Subscribe to a run's live output/status; send `cancel` | | WS | `/ws/runs` | Subscribe to a run's live output/status; send `cancel` |
## Development ## Development
@@ -199,7 +199,7 @@ See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how the pieces fit togeth
- Scripts are always spawned with an argv array (`execa`), never a shell string — variable values - Scripts are always spawned with an argv array (`execa`), never a shell string — variable values
can never inject additional shell commands. A script's own `command`/`args` may still use `shell: can never inject additional shell commands. A script's own `command`/`args` may still use `shell:
true` as an explicit, documented opt-in when the script genuinely needs pipes/globs; that true` as an explicit, documented opt-in when the script genuinely needs pipes/globs; that
reintroduces shell interpretation of `passAs: arg` values, so prefer `passAs: env` for anything reintroduces shell interpretation of `passAs: arg` values, so prefer `passAs: env` for anything
user-controlled in that case. user-controlled in that case.
- `secret: true` variables are masked in the UI and redacted from persisted run records; only the - `secret: true` variables are masked in the UI and redacted from persisted run records; only the
+1 -1
View File
@@ -77,7 +77,7 @@ code). `404` if not found.
### `POST /api/runs/:runId/cancel` ### `POST /api/runs/:runId/cancel`
`202 {"status": "cancelling"}`. `409` if the run already finished, or if it isn't tracked by *this* `202 {"status": "cancelling"}`. `409` if the run already finished, or if it isn't tracked by _this_
server process (e.g. after a restart — see "orphaned runs" in `docs/ARCHITECTURE.md`). server process (e.g. after a restart — see "orphaned runs" in `docs/ARCHITECTURE.md`).
### `GET /api/runs/:runId/logs` ### `GET /api/runs/:runId/logs`
+5 -5
View File
@@ -46,8 +46,8 @@ straight from source, with no compile/bundle step for either.
logic in `src/lib/runner/engine.ts` - it just calls it from a different position: logic in `src/lib/runner/engine.ts` - it just calls it from a different position:
- If a server is reachable (`GET /api/healthz`), `run` is a plain HTTP+WS client: `POST - If a server is reachable (`GET /api/healthz`), `run` is a plain HTTP+WS client: `POST
/api/scripts/:id/runs` (the same route the web UI's "Run" button calls) starts the run *inside /api/scripts/:id/runs` (the same route the web UI's "Run" button calls) starts the run _inside
that server's process*, and `run` then subscribes over `/ws/runs` exactly like a browser tab that server's process_, and `run` then subscribes over `/ws/runs` exactly like a browser tab
would, using the same `ClientMessage`/`ServerMessage` protocol (`src/lib/ws/protocol.ts`). This would, using the same `ClientMessage`/`ServerMessage` protocol (`src/lib/ws/protocol.ts`). This
is why a run started this way appears live in any open browser tab for free - the broadcast path is why a run started this way appears live in any open browser tab for free - the broadcast path
(`emitRunMessage` → the `runEvents` listener in `src/lib/ws/server.ts` → every subscribed (`emitRunMessage` → the `runEvents` listener in `src/lib/ws/server.ts` → every subscribed
@@ -81,7 +81,7 @@ reimplemented.
## Cross-module-graph state ## Cross-module-graph state
Next compiles Route Handlers and Server Components through its own build/module graph, which is a Next compiles Route Handlers and Server Components through its own build/module graph, which is a
*separate* module instantiation from whatever `server.ts` imports directly via `tsx` at startup — _separate_ module instantiation from whatever `server.ts` imports directly via `tsx` at startup —
even though both run in the same OS process. A plain module-level singleton (e.g. `new Map()` at even though both run in the same OS process. A plain module-level singleton (e.g. `new Map()` at
the top of a file) ends up duplicated, one copy per graph, which silently breaks anything that the top of a file) ends up duplicated, one copy per graph, which silently breaks anything that
needs to be shared across that boundary (the WebSocket subscriber registry, the live-run-handle needs to be shared across that boundary (the WebSocket subscriber registry, the live-run-handle
@@ -110,10 +110,10 @@ queued → running → succeeded | failed | cancelled | timed_out
## Auth ## Auth
- Session: `iron-session` — a stateless, encrypted+signed cookie (no session-store table). - Session: `iron-session` — a stateless, encrypted+signed cookie (no session-store table).
- Config is the source of truth for *who* is allowed in; `src/lib/auth/sync.ts` upserts config - Config is the source of truth for _who_ is allowed in; `src/lib/auth/sync.ts` upserts config
users/tokens into SQLite on boot, giving a single DB-backed check path plus `lastLoginAt` tracking. users/tokens into SQLite on boot, giving a single DB-backed check path plus `lastLoginAt` tracking.
- `src/proxy.ts` (Next's Proxy, formerly "Middleware") does a fast, cookie-only redirect for - `src/proxy.ts` (Next's Proxy, formerly "Middleware") does a fast, cookie-only redirect for
unauthenticated page/API requests — explicitly *not* the real security boundary. Every Route unauthenticated page/API requests — explicitly _not_ the real security boundary. Every Route
Handler also calls `requireAuth()` itself; this is the actual auth check. Handler also calls `requireAuth()` itself; this is the actual auth check.
- The WS `upgrade` handler is outside Next's request pipeline entirely, so it authenticates by hand - The WS `upgrade` handler is outside Next's request pipeline entirely, so it authenticates by hand
(parsing the cookie header, or a `?token=` query param) via `authenticateUpgrade()`. (parsing the cookie header, or a `?token=` query param) via `authenticateUpgrade()`.
+55 -55
View File
@@ -16,21 +16,21 @@ 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 |
## `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 |
| `users` | array | `[]` | `{username, passwordHash}` — generate via `triggershell users add` | | `users` | array | `[]` | `{username, passwordHash}` — generate via `triggershell users add` |
| `tokens` | array | `[]` | `{name, tokenHash}` — generate via `triggershell users add-token` | | `tokens` | array | `[]` | `{name, tokenHash}` — generate via `triggershell users add-token` |
If `enabled: true`, at least one user or token must be configured. If `enabled: true`, at least one user or token must be configured.
@@ -42,67 +42,67 @@ 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 |
| `command` | string | — | Required, e.g. `bash`, `node`, `./script.sh` | | `command` | string | — | Required, e.g. `bash`, `node`, `./script.sh` |
| `args` | string[] | `[]` | Fixed leading args, before variable-derived ones | | `args` | string[] | `[]` | Fixed leading args, before variable-derived ones |
| `cwd` | string | `./` | Resolved relative to the config file's directory | | `cwd` | string | `./` | Resolved relative to the config file's directory |
| `shell` | boolean | `false` | Opt-in shell interpretation — see the Security Notes in the README before using this | | `shell` | boolean | `false` | Opt-in shell interpretation — see the Security Notes in the README before using this |
| `timeoutSeconds` | number | `1800` | 086400. `0` means no timeout - the run is never killed for taking too long | | `timeoutSeconds` | number | `1800` | 086400. `0` means no timeout - the run is never killed for taking too long |
| `variables` | array | `[]` | See below | | `variables` | array | `[]` | See below |
## `scripts[].variables[]` ## `scripts[].variables[]`
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 |
| `required` | boolean | `false` | | | `required` | boolean | `false` | |
| `secret` | boolean | `false` | Only valid on `type: string`. Masks the UI control, redacts from persisted run records | | `secret` | boolean | `false` | Only valid on `type: string`. Masks the UI control, redacts from persisted run records |
| `control` | string | type-based default | See mapping below | | `control` | string | type-based default | See mapping below |
| `passAs` | `arg` \| `flag` \| `env` \| `stdin` | `arg` | How the value reaches the process | | `passAs` | `arg` \| `flag` \| `env` \| `stdin` | `arg` | How the value reaches the process |
| `argName` | string | — | Required for `passAs: arg`/`flag`, e.g. `--env` | | `argName` | string | — | Required for `passAs: arg`/`flag`, e.g. `--env` |
| `envName` | string | — | Required for `passAs: env`, e.g. `SLACK_CHANNEL` | | `envName` | string | — | Required for `passAs: env`, e.g. `SLACK_CHANNEL` |
| `joinWith` | string | `,` | Separator used when an array value is passed as a single arg/env string | | `joinWith` | string | `,` | Separator used when an array value is passed as a single arg/env string |
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`) |
| `enum` | `choices: string[]` (required, non-empty), `default?: string` | | `enum` | `choices: string[]` (required, non-empty), `default?: string` |
| `multiselect` | `choices: string[]` (required, non-empty), `default: string[]` (default `[]`) | | `multiselect` | `choices: string[]` (required, non-empty), `default: string[]` (default `[]`) |
### 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` |
| `multiselect` | `multiselect` (combobox) | `checkboxGroup` | | `multiselect` | `multiselect` (combobox) | `checkboxGroup` |
### `passAs` semantics ### `passAs` semantics
+3 -1
View File
@@ -10,7 +10,9 @@ const targetDir = getArg("--dir");
const olderThanDays = getArg("--days"); const olderThanDays = getArg("--days");
const types = getArg("--types"); const types = getArg("--types");
console.log(`Scanning ${targetDir} for files older than ${olderThanDays} days (types: ${types})`); console.log(
`Scanning ${targetDir} for files older than ${olderThanDays} days (types: ${types})`,
);
if (process.env.CLEANUP_API_KEY) { if (process.env.CLEANUP_API_KEY) {
console.log("Using configured external API key."); console.log("Using configured external API key.");
} }
+1 -1
View File
@@ -72,7 +72,7 @@ scripts:
label: Notify Slack Channel label: Notify Slack Channel
type: string type: string
required: false required: false
pattern: '^#[a-z0-9-]+$' pattern: "^#[a-z0-9-]+$"
passAs: env passAs: env
envName: SLACK_CHANNEL envName: SLACK_CHANNEL
+21 -5
View File
@@ -2,11 +2,20 @@ export const dynamic = "force-dynamic";
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight } from "lucide-react"; import {
ArrowDown,
ArrowUp,
ArrowUpDown,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { and, asc, desc, eq, like, or, sql, type SQL } from "drizzle-orm"; import { and, asc, desc, eq, like, or, sql, type SQL } from "drizzle-orm";
import { getDb } from "@/lib/db/client"; import { getDb } from "@/lib/db/client";
import { runs } from "@/lib/db/schema"; import { runs } from "@/lib/db/schema";
import { RunStatusBadge, runStatusLabels } from "@/components/runs/run-status-badge"; import {
RunStatusBadge,
runStatusLabels,
} from "@/components/runs/run-status-badge";
import { RunsToolbar } from "@/components/runs/runs-toolbar"; import { RunsToolbar } from "@/components/runs/runs-toolbar";
import { buttonVariants } from "@/components/ui/button"; import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -89,7 +98,9 @@ export default async function RunsPage({ searchParams }: RunsPageProps) {
); );
} }
if (statusFilter !== "all" && statusFilter in runStatusLabels) { if (statusFilter !== "all" && statusFilter in runStatusLabels) {
conditions.push(eq(runs.status, statusFilter as keyof typeof runStatusLabels)); conditions.push(
eq(runs.status, statusFilter as keyof typeof runStatusLabels),
);
} }
if (scriptFilter !== "all") { if (scriptFilter !== "all") {
conditions.push(eq(runs.scriptId, scriptFilter)); conditions.push(eq(runs.scriptId, scriptFilter));
@@ -167,7 +178,10 @@ export default async function RunsPage({ searchParams }: RunsPageProps) {
<RunsToolbar scripts={scripts} /> <RunsToolbar scripts={scripts} />
{rows.length === 0 ? ( {rows.length === 0 ? (
<p className="text-muted-foreground py-12 text-center"> <p className="text-muted-foreground py-12 text-center">
{total === 0 && !search && statusFilter === "all" && scriptFilter === "all" {total === 0 &&
!search &&
statusFilter === "all" &&
scriptFilter === "all"
? "No runs yet." ? "No runs yet."
: "No runs match these filters."} : "No runs match these filters."}
</p> </p>
@@ -179,7 +193,9 @@ export default async function RunsPage({ searchParams }: RunsPageProps) {
<TableRow> <TableRow>
<TableHead>{sortHeader("script", "Script")}</TableHead> <TableHead>{sortHeader("script", "Script")}</TableHead>
<TableHead>{sortHeader("status", "Status")}</TableHead> <TableHead>{sortHeader("status", "Status")}</TableHead>
<TableHead>{sortHeader("triggeredBy", "Triggered by")}</TableHead> <TableHead>
{sortHeader("triggeredBy", "Triggered by")}
</TableHead>
<TableHead>{sortHeader("started", "Started")}</TableHead> <TableHead>{sortHeader("started", "Started")}</TableHead>
<TableHead>{sortHeader("duration", "Duration")}</TableHead> <TableHead>{sortHeader("duration", "Duration")}</TableHead>
</TableRow> </TableRow>
+5 -1
View File
@@ -1,5 +1,9 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Bricolage_Grotesque, IBM_Plex_Mono, IBM_Plex_Sans } from "next/font/google"; import {
Bricolage_Grotesque,
IBM_Plex_Mono,
IBM_Plex_Sans,
} from "next/font/google";
import { ThemeProvider } from "next-themes"; import { ThemeProvider } from "next-themes";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
+7 -2
View File
@@ -14,7 +14,10 @@ export async function doctorCommand(opts: DoctorOptions): Promise<void> {
const configPath = resolveConfigPath(opts.config); const configPath = resolveConfigPath(opts.config);
const exists = fs.existsSync(configPath); const exists = fs.existsSync(configPath);
rows.push(["Config path", `${configPath} ${exists ? "(exists)" : "(not found)"}`]); rows.push([
"Config path",
`${configPath} ${exists ? "(exists)" : "(not found)"}`,
]);
if (exists) { if (exists) {
try { try {
@@ -22,7 +25,9 @@ export async function doctorCommand(opts: DoctorOptions): Promise<void> {
const portFree = await isPortFree(config.server.host, config.server.port); const portFree = await isPortFree(config.server.host, config.server.port);
rows.push([ rows.push([
"Port available", "Port available",
portFree ? "yes" : `no (${config.server.host}:${config.server.port} in use)`, portFree
? "yes"
: `no (${config.server.host}:${config.server.port} in use)`,
]); ]);
rows.push(["Scripts configured", String(config.scripts.length)]); rows.push(["Scripts configured", String(config.scripts.length)]);
rows.push(["Auth enabled", String(config.auth.enabled)]); rows.push(["Auth enabled", String(config.auth.enabled)]);
+16 -4
View File
@@ -11,7 +11,10 @@ export interface InitOptions {
force: boolean; force: boolean;
} }
export async function initCommand(targetPath: string | undefined, opts: InitOptions): Promise<void> { export async function initCommand(
targetPath: string | undefined,
opts: InitOptions,
): Promise<void> {
const targetDir = path.resolve(process.cwd(), targetPath ?? "."); const targetDir = path.resolve(process.cwd(), targetPath ?? ".");
fs.mkdirSync(targetDir, { recursive: true }); fs.mkdirSync(targetDir, { recursive: true });
const configPath = path.join(targetDir, DEFAULT_CONFIG_NAME); const configPath = path.join(targetDir, DEFAULT_CONFIG_NAME);
@@ -22,7 +25,11 @@ export async function initCommand(targetPath: string | undefined, opts: InitOpti
return; return;
} }
const templatePath = path.join(resolveAppRoot(), "templates", DEFAULT_CONFIG_NAME); const templatePath = path.join(
resolveAppRoot(),
"templates",
DEFAULT_CONFIG_NAME,
);
const template = fs.readFileSync(templatePath, "utf-8"); const template = fs.readFileSync(templatePath, "utf-8");
const rendered = template const rendered = template
.replace("__PORT__", String(opts.port)) .replace("__PORT__", String(opts.port))
@@ -38,10 +45,15 @@ export async function initCommand(targetPath: string | undefined, opts: InitOpti
); );
} else { } else {
const sessionSecret = crypto.randomBytes(32).toString("hex"); const sessionSecret = crypto.randomBytes(32).toString("hex");
fs.writeFileSync(envPath, `TRIGGERSHELL_SESSION_SECRET=${sessionSecret}\n`); fs.writeFileSync(
envPath,
`TRIGGERSHELL_SESSION_SECRET=${sessionSecret}\n`,
);
console.log(`Created ${envPath} (keep this out of version control)`); console.log(`Created ${envPath} (keep this out of version control)`);
} }
console.log("\nAuth is enabled but no users are configured yet. Add one with:"); console.log(
"\nAuth is enabled but no users are configured yet. Add one with:",
);
console.log(` triggershell users add <username> --config ${configPath}`); console.log(` triggershell users add <username> --config ${configPath}`);
} }
+21 -6
View File
@@ -33,7 +33,10 @@ function exitCodeFor(status: RunStatus): number {
return status === "succeeded" ? 0 : 1; return status === "succeeded" ? 0 : 1;
} }
export async function runCommand(scriptId: string, opts: RunOptions): Promise<void> { export async function runCommand(
scriptId: string,
opts: RunOptions,
): Promise<void> {
const configPath = resolveConfigPath(opts.config); const configPath = resolveConfigPath(opts.config);
loadDotenv(path.join(path.dirname(configPath), ".env")); loadDotenv(path.join(path.dirname(configPath), ".env"));
@@ -161,7 +164,10 @@ async function runLocal(
if (message.runId !== runId) return; if (message.runId !== runId) return;
if (message.type === "output") { if (message.type === "output") {
process.stdout.write(message.chunk); process.stdout.write(message.chunk);
} else if (message.type === "status" && !NON_TERMINAL.includes(message.status)) { } else if (
message.type === "status" &&
!NON_TERMINAL.includes(message.status)
) {
cleanup(); cleanup();
resolve(message.status); resolve(message.status);
} }
@@ -187,7 +193,9 @@ async function runRemote(
token: string | undefined, token: string | undefined,
wait: boolean, wait: boolean,
): Promise<void> { ): Promise<void> {
const headers: Record<string, string> = { "Content-Type": "application/json" }; const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (token) headers.Authorization = `Bearer ${token}`; if (token) headers.Authorization = `Bearer ${token}`;
const response = await fetch(`${url}/api/scripts/${scriptId}/runs`, { const response = await fetch(`${url}/api/scripts/${scriptId}/runs`, {
@@ -197,7 +205,9 @@ async function runRemote(
}); });
if (!response.ok) { if (!response.ok) {
const body = await response.json().catch(() => ({}) as Record<string, unknown>); const body = await response
.json()
.catch(() => ({}) as Record<string, unknown>);
if (response.status === 401) { if (response.status === 401) {
console.error( console.error(
"Unauthorized - pass --token or set TRIGGERSHELL_API_TOKEN (see `triggershell users add-token`).", "Unauthorized - pass --token or set TRIGGERSHELL_API_TOKEN (see `triggershell users add-token`).",
@@ -210,7 +220,9 @@ async function runRemote(
console.error(` ${field}: ${issues.join(", ")}`); console.error(` ${field}: ${issues.join(", ")}`);
} }
} else { } else {
console.error((body.error as string) ?? `Request failed (${response.status})`); console.error(
(body.error as string) ?? `Request failed (${response.status})`,
);
} }
process.exitCode = 1; process.exitCode = 1;
return; return;
@@ -252,7 +264,10 @@ async function runRemote(
if (message.runId !== runId) return; if (message.runId !== runId) return;
if (message.type === "output") { if (message.type === "output") {
process.stdout.write(message.chunk); process.stdout.write(message.chunk);
} else if (message.type === "status" && !NON_TERMINAL.includes(message.status)) { } else if (
message.type === "status" &&
!NON_TERMINAL.includes(message.status)
) {
cleanup(); cleanup();
resolve(message.status); resolve(message.status);
} else if (message.type === "error") { } else if (message.type === "error") {
+6 -2
View File
@@ -52,7 +52,9 @@ export async function scriptsShowCommand(
if (!script) { if (!script) {
console.error(`No script '${scriptId}' configured.`); console.error(`No script '${scriptId}' configured.`);
if (scripts.length > 0) { if (scripts.length > 0) {
console.error(`Available scripts: ${scripts.map((s) => s.id).join(", ")}`); console.error(
`Available scripts: ${scripts.map((s) => s.id).join(", ")}`,
);
} }
process.exitCode = 1; process.exitCode = 1;
return; return;
@@ -60,7 +62,9 @@ export async function scriptsShowCommand(
console.log(script.name); console.log(script.name);
if (script.description) console.log(script.description); if (script.description) console.log(script.description);
console.log(`\ncommand: ${script.command} ${script.args.join(" ")}`.trimEnd()); console.log(
`\ncommand: ${script.command} ${script.args.join(" ")}`.trimEnd(),
);
if (script.variables.length === 0) { if (script.variables.length === 0) {
console.log("\nThis script takes no parameters."); console.log("\nThis script takes no parameters.");
+25 -8
View File
@@ -26,7 +26,9 @@ function scopeOf(opts: ServiceScopeOptions): "user" | "system" {
return opts.system ? "system" : "user"; return opts.system ? "system" : "user";
} }
export async function serviceInstallCommand(opts: ServiceInstallOptions): Promise<void> { export async function serviceInstallCommand(
opts: ServiceInstallOptions,
): Promise<void> {
const scope = scopeOf(opts); const scope = scopeOf(opts);
const configPath = resolveConfigPath(opts.config); const configPath = resolveConfigPath(opts.config);
const unit = renderUnit({ const unit = renderUnit({
@@ -68,18 +70,24 @@ export async function serviceInstallCommand(opts: ServiceInstallOptions): Promis
const scratchPath = path.join(os.tmpdir(), `${SERVICE_NAME}.service`); const scratchPath = path.join(os.tmpdir(), `${SERVICE_NAME}.service`);
fs.writeFileSync(scratchPath, unit); fs.writeFileSync(scratchPath, unit);
console.log(`Not running as root - wrote the unit file to ${scratchPath} instead.`); console.log(
`Not running as root - wrote the unit file to ${scratchPath} instead.`,
);
console.log("\nReview it, then run:"); console.log("\nReview it, then run:");
console.log(` sudo install -m 644 ${scratchPath} ${systemUnitPath()}`); console.log(` sudo install -m 644 ${scratchPath} ${systemUnitPath()}`);
console.log(" sudo systemctl daemon-reload"); console.log(" sudo systemctl daemon-reload");
console.log(` sudo systemctl enable --now ${SERVICE_NAME}`); console.log(` sudo systemctl enable --now ${SERVICE_NAME}`);
} }
export async function serviceUninstallCommand(opts: ServiceScopeOptions): Promise<void> { export async function serviceUninstallCommand(
opts: ServiceScopeOptions,
): Promise<void> {
const scope = scopeOf(opts); const scope = scopeOf(opts);
if (scope === "user") { if (scope === "user") {
await execa("systemctl", ["--user", "disable", "--now", SERVICE_NAME], { reject: false }); await execa("systemctl", ["--user", "disable", "--now", SERVICE_NAME], {
reject: false,
});
const unitPath = userUnitPath(); const unitPath = userUnitPath();
if (fs.existsSync(unitPath)) fs.rmSync(unitPath); if (fs.existsSync(unitPath)) fs.rmSync(unitPath);
await execa("systemctl", ["--user", "daemon-reload"], { reject: false }); await execa("systemctl", ["--user", "daemon-reload"], { reject: false });
@@ -88,7 +96,9 @@ export async function serviceUninstallCommand(opts: ServiceScopeOptions): Promis
} }
if (isRoot()) { if (isRoot()) {
await execa("systemctl", ["disable", "--now", SERVICE_NAME], { reject: false }); await execa("systemctl", ["disable", "--now", SERVICE_NAME], {
reject: false,
});
const unitPath = systemUnitPath(); const unitPath = systemUnitPath();
if (fs.existsSync(unitPath)) fs.rmSync(unitPath); if (fs.existsSync(unitPath)) fs.rmSync(unitPath);
await execa("systemctl", ["daemon-reload"], { reject: false }); await execa("systemctl", ["daemon-reload"], { reject: false });
@@ -102,10 +112,17 @@ export async function serviceUninstallCommand(opts: ServiceScopeOptions): Promis
console.log(" sudo systemctl daemon-reload"); console.log(" sudo systemctl daemon-reload");
} }
export async function serviceStatusCommand(opts: ServiceScopeOptions): Promise<void> { export async function serviceStatusCommand(
opts: ServiceScopeOptions,
): Promise<void> {
const scope = scopeOf(opts); const scope = scopeOf(opts);
const args = const args =
scope === "user" ? ["--user", "status", SERVICE_NAME] : ["status", SERVICE_NAME]; scope === "user"
const result = await execa("systemctl", args, { stdio: "inherit", reject: false }); ? ["--user", "status", SERVICE_NAME]
: ["status", SERVICE_NAME];
const result = await execa("systemctl", args, {
stdio: "inherit",
reject: false,
});
process.exitCode = result.exitCode ?? 1; process.exitCode = result.exitCode ?? 1;
} }
+3 -1
View File
@@ -54,6 +54,8 @@ export async function startCommand(opts: StartOptions): Promise<void> {
}); });
} }
const serverEntry = pathToFileURL(path.join(resolveAppRoot(), "server.ts")).href; const serverEntry = pathToFileURL(
path.join(resolveAppRoot(), "server.ts"),
).href;
await import(serverEntry); await import(serverEntry);
} }
+20 -5
View File
@@ -20,18 +20,27 @@ function printSnippet(heading: string, entry: Record<string, unknown>): void {
async function promptNewPassword(): Promise<string> { async function promptNewPassword(): Promise<string> {
for (;;) { for (;;) {
const first = await promptPassword({ message: "Password", mask: true }); const first = await promptPassword({ message: "Password", mask: true });
const second = await promptPassword({ message: "Confirm password", mask: true }); const second = await promptPassword({
message: "Confirm password",
mask: true,
});
if (first === second) return first; if (first === second) return first;
console.error("Passwords did not match, try again.\n"); console.error("Passwords did not match, try again.\n");
} }
} }
export async function usersAddCommand(username: string, opts: UsersOptions): Promise<void> { export async function usersAddCommand(
username: string,
opts: UsersOptions,
): Promise<void> {
const password = await promptNewPassword(); const password = await promptNewPassword();
const passwordHash = await hashPassword(password); const passwordHash = await hashPassword(password);
if (opts.inline) { if (opts.inline) {
printSnippet("Add this under `auth.users:` in your config file:", { username, passwordHash }); printSnippet("Add this under `auth.users:` in your config file:", {
username,
passwordHash,
});
return; return;
} }
@@ -47,7 +56,10 @@ export async function usersAddCommand(username: string, opts: UsersOptions): Pro
}); });
} }
export async function usersAddTokenCommand(name: string, opts: UsersOptions): Promise<void> { export async function usersAddTokenCommand(
name: string,
opts: UsersOptions,
): Promise<void> {
const token = crypto.randomBytes(32).toString("hex"); const token = crypto.randomBytes(32).toString("hex");
const tokenHash = `sha256:${crypto.createHash("sha256").update(token).digest("hex")}`; const tokenHash = `sha256:${crypto.createHash("sha256").update(token).digest("hex")}`;
@@ -56,7 +68,10 @@ export async function usersAddTokenCommand(name: string, opts: UsersOptions): Pr
console.log(`Use it as: Authorization: Bearer ${token}`); console.log(`Use it as: Authorization: Bearer ${token}`);
if (opts.inline) { if (opts.inline) {
printSnippet("Add this under `auth.tokens:` in your config file:", { name, tokenHash }); printSnippet("Add this under `auth.tokens:` in your config file:", {
name,
tokenHash,
});
return; return;
} }
+74 -18
View File
@@ -3,7 +3,11 @@ import { doctorCommand } from "./commands/doctor";
import { initCommand } from "./commands/init"; import { initCommand } from "./commands/init";
import { runCommand } from "./commands/run"; import { runCommand } from "./commands/run";
import { scriptsListCommand, scriptsShowCommand } from "./commands/scripts"; import { scriptsListCommand, scriptsShowCommand } from "./commands/scripts";
import { serviceInstallCommand, serviceStatusCommand, serviceUninstallCommand } from "./commands/service"; import {
serviceInstallCommand,
serviceStatusCommand,
serviceUninstallCommand,
} from "./commands/service";
import { startCommand } from "./commands/start"; import { startCommand } from "./commands/start";
import { usersAddCommand, usersAddTokenCommand } from "./commands/users"; import { usersAddCommand, usersAddTokenCommand } from "./commands/users";
import { validateCommand } from "./commands/validate"; import { validateCommand } from "./commands/validate";
@@ -15,12 +19,19 @@ function collect(value: string, previous: string[]): string[] {
const program = new Command("triggershell") const program = new Command("triggershell")
.version(getVersion()) .version(getVersion())
.description("Launch the TriggerShell web app: run your configured shell scripts from a browser."); .description(
"Launch the TriggerShell web app: run your configured shell scripts from a browser.",
);
program program
.command("init [path]") .command("init [path]")
.description("Scaffold a new triggershell.yml (and .env, if auth is enabled)") .description("Scaffold a new triggershell.yml (and .env, if auth is enabled)")
.option("--port <port>", "Port the web app will listen on.", (v) => Number(v), 4173) .option(
"--port <port>",
"Port the web app will listen on.",
(v) => Number(v),
4173,
)
.option("--no-auth", "Disable built-in login for the web app.") .option("--no-auth", "Disable built-in login for the web app.")
.option("--force", "Overwrite an existing config file.", false) .option("--force", "Overwrite an existing config file.", false)
.action(initCommand); .action(initCommand);
@@ -35,7 +46,9 @@ program
.command("start") .command("start")
.description("Run the web app in production mode.") .description("Run the web app in production mode.")
.option("-c, --config <path>", "Path to the config file.") .option("-c, --config <path>", "Path to the config file.")
.option("--port <port>", "Override the port from the config file.", (v) => Number(v)) .option("--port <port>", "Override the port from the config file.", (v) =>
Number(v),
)
.option("--host <host>", "Override the host from the config file.") .option("--host <host>", "Override the host from the config file.")
.option("--no-browser", "Don't open a browser automatically.") .option("--no-browser", "Don't open a browser automatically.")
.action(startCommand); .action(startCommand);
@@ -46,7 +59,9 @@ program
.option("-c, --config <path>", "Path to the config file.") .option("-c, --config <path>", "Path to the config file.")
.action(doctorCommand); .action(doctorCommand);
const scripts = program.command("scripts").description("List and inspect configured scripts."); const scripts = program
.command("scripts")
.description("List and inspect configured scripts.");
scripts scripts
.command("list") .command("list")
@@ -71,57 +86,98 @@ program
[], [],
) )
.option("--host <host>", "Override the host from the config file.") .option("--host <host>", "Override the host from the config file.")
.option("--port <port>", "Override the port from the config file.", (v) => Number(v)) .option("--port <port>", "Override the port from the config file.", (v) =>
Number(v),
)
.option( .option(
"--token <token>", "--token <token>",
"API token for an already-running server (or set TRIGGERSHELL_API_TOKEN).", "API token for an already-running server (or set TRIGGERSHELL_API_TOKEN).",
) )
.option("--local", "Always run in this process, even if the web server is reachable.", false) .option(
"--local",
"Always run in this process, even if the web server is reachable.",
false,
)
.option( .option(
"--remote", "--remote",
"Require a reachable web server; don't fall back to running locally.", "Require a reachable web server; don't fall back to running locally.",
false, false,
) )
.option("--no-wait", "Print the run ID and exit immediately instead of streaming output.") .option(
"--no-wait",
"Print the run ID and exit immediately instead of streaming output.",
)
.action(runCommand); .action(runCommand);
const users = program.command("users").description("Manage auth users and API tokens defined in your config file."); const users = program
.command("users")
.description("Manage auth users and API tokens defined in your config file.");
users users
.command("add <username>") .command("add <username>")
.description("Hash a password with argon2id and wire it up for auth.users.") .description("Hash a password with argon2id and wire it up for auth.users.")
.option("-c, --config <path>", "Path to the config file (used to locate .env).") .option(
.option("--inline", "Print the raw hash to paste into the config instead of storing it in .env.", false) "-c, --config <path>",
"Path to the config file (used to locate .env).",
)
.option(
"--inline",
"Print the raw hash to paste into the config instead of storing it in .env.",
false,
)
.action(usersAddCommand); .action(usersAddCommand);
users users
.command("add-token <name>") .command("add-token <name>")
.description("Generate an API token and wire its hash up for auth.tokens.") .description("Generate an API token and wire its hash up for auth.tokens.")
.option("-c, --config <path>", "Path to the config file (used to locate .env).") .option(
.option("--inline", "Print the raw hash to paste into the config instead of storing it in .env.", false) "-c, --config <path>",
"Path to the config file (used to locate .env).",
)
.option(
"--inline",
"Print the raw hash to paste into the config instead of storing it in .env.",
false,
)
.action(usersAddTokenCommand); .action(usersAddTokenCommand);
const service = program.command("service").description("Manage the systemd service (Linux only)."); const service = program
.command("service")
.description("Manage the systemd service (Linux only).");
service service
.command("install") .command("install")
.description("Install a systemd unit that runs `triggershell start`.") .description("Install a systemd unit that runs `triggershell start`.")
.option("-c, --config <path>", "Path to the config file.") .option("-c, --config <path>", "Path to the config file.")
.option("--port <port>", "Override the port from the config file.", (v) => Number(v)) .option("--port <port>", "Override the port from the config file.", (v) =>
Number(v),
)
.option("--host <host>", "Override the host from the config file.") .option("--host <host>", "Override the host from the config file.")
.option("--system", "Install a system-wide unit instead of a per-user one.", false) .option(
"--system",
"Install a system-wide unit instead of a per-user one.",
false,
)
.action(serviceInstallCommand); .action(serviceInstallCommand);
service service
.command("uninstall") .command("uninstall")
.description("Stop, disable, and remove the systemd unit.") .description("Stop, disable, and remove the systemd unit.")
.option("--system", "Target the system-wide unit instead of the per-user one.", false) .option(
"--system",
"Target the system-wide unit instead of the per-user one.",
false,
)
.action(serviceUninstallCommand); .action(serviceUninstallCommand);
service service
.command("status") .command("status")
.description("Show the systemd unit's status.") .description("Show the systemd unit's status.")
.option("--system", "Target the system-wide unit instead of the per-user one.", false) .option(
"--system",
"Target the system-wide unit instead of the per-user one.",
false,
)
.action(serviceStatusCommand); .action(serviceStatusCommand);
if (process.argv.length <= 2) { if (process.argv.length <= 2) {
+4 -1
View File
@@ -6,7 +6,10 @@ import { test } from "node:test";
import { loadDotenv, upsertEnvVar } from "./env-file"; import { loadDotenv, upsertEnvVar } from "./env-file";
function tmpEnvPath(): string { function tmpEnvPath(): string {
return path.join(fs.mkdtempSync(path.join(os.tmpdir(), "triggershell-env-")), ".env"); return path.join(
fs.mkdtempSync(path.join(os.tmpdir(), "triggershell-env-")),
".env",
);
} }
test("upsertEnvVar appends a new key", () => { test("upsertEnvVar appends a new key", () => {
+8 -2
View File
@@ -20,13 +20,19 @@ function parseEnvLines(content: string): EnvEntry[] {
/** Loads a `.env` file into `process.env`, without overriding vars already set. */ /** Loads a `.env` file into `process.env`, without overriding vars already set. */
export function loadDotenv(envPath: string): void { export function loadDotenv(envPath: string): void {
if (!fs.existsSync(envPath)) return; if (!fs.existsSync(envPath)) return;
for (const { key, value } of parseEnvLines(fs.readFileSync(envPath, "utf-8"))) { for (const { key, value } of parseEnvLines(
fs.readFileSync(envPath, "utf-8"),
)) {
if (process.env[key] === undefined) process.env[key] = value; if (process.env[key] === undefined) process.env[key] = value;
} }
} }
/** Sets `key=value` in a `.env` file, replacing an existing line for that key rather than duplicating it. */ /** Sets `key=value` in a `.env` file, replacing an existing line for that key rather than duplicating it. */
export function upsertEnvVar(envPath: string, key: string, value: string): void { export function upsertEnvVar(
envPath: string,
key: string,
value: string,
): void {
const lines = fs.existsSync(envPath) const lines = fs.existsSync(envPath)
? fs.readFileSync(envPath, "utf-8").split("\n") ? fs.readFileSync(envPath, "utf-8").split("\n")
: []; : [];
+10 -2
View File
@@ -51,10 +51,18 @@ export async function isServerReachable(url: string): Promise<boolean> {
export function openBrowser(url: string): void { export function openBrowser(url: string): void {
const command = const command =
process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"; process.platform === "darwin"
? "open"
: process.platform === "win32"
? "start"
: "xdg-open";
const args = process.platform === "win32" ? ["", url] : [url]; const args = process.platform === "win32" ? ["", url] : [url];
try { try {
spawn(command, args, { detached: true, stdio: "ignore", shell: process.platform === "win32" }).unref(); spawn(command, args, {
detached: true,
stdio: "ignore",
shell: process.platform === "win32",
}).unref();
} catch { } catch {
// best-effort - not fatal if no browser opener is available // best-effort - not fatal if no browser opener is available
} }
+6 -1
View File
@@ -6,7 +6,12 @@ const DEFAULT_CONFIG_NAME = "triggershell.yml";
/** Root of the installed `triggershell` package - one level up from `src/cli/lib`. */ /** Root of the installed `triggershell` package - one level up from `src/cli/lib`. */
export function resolveAppRoot(): string { export function resolveAppRoot(): string {
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); return path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"..",
"..",
);
} }
export function resolveConfigPath(configArg?: string): string { export function resolveConfigPath(configArg?: string): string {
+6 -2
View File
@@ -5,7 +5,8 @@ import { renderUnit } from "./systemd";
test("renderUnit builds an absolute-path ExecStart with the given args", () => { test("renderUnit builds an absolute-path ExecStart with the given args", () => {
const unit = renderUnit({ const unit = renderUnit({
execPath: "/usr/bin/node", execPath: "/usr/bin/node",
binPath: "/home/user/.local/share/pnpm/global/5/node_modules/.bin/triggershell", binPath:
"/home/user/.local/share/pnpm/global/5/node_modules/.bin/triggershell",
configPath: "/home/user/project/triggershell.yml", configPath: "/home/user/project/triggershell.yml",
configDir: "/home/user/project", configDir: "/home/user/project",
port: 8080, port: 8080,
@@ -31,5 +32,8 @@ test("renderUnit uses multi-user.target for the system scope", () => {
}); });
assert.match(unit, /WantedBy=multi-user\.target/); assert.match(unit, /WantedBy=multi-user\.target/);
assert.match(unit, /ExecStart=\/usr\/bin\/node .*start --config .*--no-browser$/m); assert.match(
unit,
/ExecStart=\/usr\/bin\/node .*start --config .*--no-browser$/m,
);
}); });
+9 -2
View File
@@ -22,7 +22,8 @@ export function renderUnit(opts: UnitOptions): string {
.map((part) => (part.includes(" ") ? `"${part}"` : part)) .map((part) => (part.includes(" ") ? `"${part}"` : part))
.join(" "); .join(" ");
const wantedBy = opts.scope === "user" ? "default.target" : "multi-user.target"; const wantedBy =
opts.scope === "user" ? "default.target" : "multi-user.target";
return `[Unit] return `[Unit]
Description=TriggerShell - self-hosted script runner Description=TriggerShell - self-hosted script runner
@@ -42,7 +43,13 @@ WantedBy=${wantedBy}
} }
export function userUnitPath(): string { export function userUnitPath(): string {
return path.join(os.homedir(), ".config", "systemd", "user", `${SERVICE_NAME}.service`); return path.join(
os.homedir(),
".config",
"systemd",
"user",
`${SERVICE_NAME}.service`,
);
} }
export function systemUnitPath(): string { export function systemUnitPath(): string {
+24 -6
View File
@@ -3,7 +3,10 @@ import { test } from "node:test";
import type { VariableConfig } from "../../lib/config/schema"; import type { VariableConfig } from "../../lib/config/schema";
import { coerceVariables, parseVarFlags } from "./variables"; import { coerceVariables, parseVarFlags } from "./variables";
function stringVar(name: string, overrides: Partial<VariableConfig> = {}): VariableConfig { function stringVar(
name: string,
overrides: Partial<VariableConfig> = {},
): VariableConfig {
return { return {
type: "string", type: "string",
name, name,
@@ -62,18 +65,32 @@ test("parseVarFlags rejects a flag with no '='", () => {
}); });
test("coerceVariables coerces booleans and numbers, passes strings through", () => { test("coerceVariables coerces booleans and numbers, passes strings through", () => {
const variables = [stringVar("environment"), boolVar("dryRun"), numberVar("replicas")]; const variables = [
stringVar("environment"),
boolVar("dryRun"),
numberVar("replicas"),
];
const values = coerceVariables(variables, { const values = coerceVariables(variables, {
environment: ["staging"], environment: ["staging"],
dryRun: ["true"], dryRun: ["true"],
replicas: ["3"], replicas: ["3"],
}); });
assert.deepEqual(values, { environment: "staging", dryRun: true, replicas: 3 }); assert.deepEqual(values, {
environment: "staging",
dryRun: true,
replicas: 3,
});
}); });
test("coerceVariables rejects an invalid boolean/number", () => { test("coerceVariables rejects an invalid boolean/number", () => {
assert.throws(() => coerceVariables([boolVar("dryRun")], { dryRun: ["yes"] }), /must be 'true' or 'false'/); assert.throws(
assert.throws(() => coerceVariables([numberVar("replicas")], { replicas: ["abc"] }), /not a valid number/); () => coerceVariables([boolVar("dryRun")], { dryRun: ["yes"] }),
/must be 'true' or 'false'/,
);
assert.throws(
() => coerceVariables([numberVar("replicas")], { replicas: ["abc"] }),
/not a valid number/,
);
}); });
test("coerceVariables collects a multiselect variable's repeats into an array", () => { test("coerceVariables collects a multiselect variable's repeats into an array", () => {
@@ -85,7 +102,8 @@ test("coerceVariables collects a multiselect variable's repeats into an array",
test("coerceVariables rejects a non-multiselect variable given more than once", () => { test("coerceVariables rejects a non-multiselect variable given more than once", () => {
assert.throws( assert.throws(
() => coerceVariables([stringVar("environment")], { environment: ["a", "b"] }), () =>
coerceVariables([stringVar("environment")], { environment: ["a", "b"] }),
/given 2 times/, /given 2 times/,
); );
}); });
+13 -4
View File
@@ -7,7 +7,9 @@ export function parseVarFlags(pairs: string[]): Record<string, string[]> {
for (const pair of pairs) { for (const pair of pairs) {
const eq = pair.indexOf("="); const eq = pair.indexOf("=");
if (eq === -1) { if (eq === -1) {
throw new Error(`--var ${pair} is missing '=' - expected --var name=value`); throw new Error(
`--var ${pair} is missing '=' - expected --var name=value`,
);
} }
const name = pair.slice(0, eq); const name = pair.slice(0, eq);
const value = pair.slice(eq + 1); const value = pair.slice(eq + 1);
@@ -29,7 +31,9 @@ export function coerceVariables(
for (const name of Object.keys(grouped)) { for (const name of Object.keys(grouped)) {
if (!known.has(name)) { if (!known.has(name)) {
throw new Error(`--var ${name}=... does not match any variable on this script`); throw new Error(
`--var ${name}=... does not match any variable on this script`,
);
} }
} }
@@ -52,11 +56,16 @@ export function coerceVariables(
if (variable.type === "boolean") { if (variable.type === "boolean") {
if (value === "true") values[variable.name] = true; if (value === "true") values[variable.name] = true;
else if (value === "false") values[variable.name] = false; else if (value === "false") values[variable.name] = false;
else throw new Error(`--var ${variable.name}=${value} must be 'true' or 'false'`); else
throw new Error(
`--var ${variable.name}=${value} must be 'true' or 'false'`,
);
} else if (variable.type === "number") { } else if (variable.type === "number") {
const n = Number(value); const n = Number(value);
if (Number.isNaN(n)) { if (Number.isNaN(n)) {
throw new Error(`--var ${variable.name}=${value} is not a valid number`); throw new Error(
`--var ${variable.name}=${value} is not a valid number`,
);
} }
values[variable.name] = n; values[variable.name] = n;
} else { } else {
+3 -1
View File
@@ -4,6 +4,8 @@ import { resolveAppRoot } from "./lib/paths";
export function getVersion(): string { export function getVersion(): string {
const pkgPath = path.join(resolveAppRoot(), "package.json"); const pkgPath = path.join(resolveAppRoot(), "package.json");
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { version: string }; const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as {
version: string;
};
return pkg.version; return pkg.version;
} }
+1 -3
View File
@@ -36,9 +36,7 @@ export interface LoadedConfig {
export function resolveConfigPath(configPathArg?: string): string { export function resolveConfigPath(configPathArg?: string): string {
const candidate = const candidate =
configPathArg ?? configPathArg ?? process.env.TRIGGERSHELL_CONFIG_PATH ?? "triggershell.yml";
process.env.TRIGGERSHELL_CONFIG_PATH ??
"triggershell.yml";
return path.resolve(/*turbopackIgnore: true*/ candidate); return path.resolve(/*turbopackIgnore: true*/ candidate);
} }
+6 -3
View File
@@ -12,13 +12,15 @@ export const DOCS: DocMeta[] = [
{ {
slug: "api", slug: "api",
title: "API Reference", title: "API Reference",
description: "REST and WebSocket endpoints, auth, and request/response shapes.", description:
"REST and WebSocket endpoints, auth, and request/response shapes.",
file: "API.md", file: "API.md",
}, },
{ {
slug: "config", slug: "config",
title: "Config Reference", title: "Config Reference",
description: "Every field in triggershell.yml: server, auth, scripts, and variables.", description:
"Every field in triggershell.yml: server, auth, scripts, and variables.",
file: "CONFIG_REFERENCE.md", file: "CONFIG_REFERENCE.md",
}, },
]; ];
@@ -34,7 +36,8 @@ export function getDocMeta(slug: string): DocMeta | undefined {
// Falls back to a source-relative resolution for contexts where server.ts never ran (e.g. tests). // Falls back to a source-relative resolution for contexts where server.ts never ran (e.g. tests).
function docsDir(): string { function docsDir(): string {
const appRoot = const appRoot =
process.env.TRIGGERSHELL_APP_ROOT ?? path.resolve(import.meta.dirname, "..", ".."); process.env.TRIGGERSHELL_APP_ROOT ??
path.resolve(import.meta.dirname, "..", "..");
return path.resolve(appRoot, "docs"); return path.resolve(appRoot, "docs");
} }
+2 -1
View File
@@ -9,7 +9,8 @@ declare global {
* Anchored on `globalThis` because Next compiles Route Handlers through its own module graph, * Anchored on `globalThis` because Next compiles Route Handlers through its own module graph,
* separate from the modules `server.ts` requires directly via tsx - a plain module-level * separate from the modules `server.ts` requires directly via tsx - a plain module-level
* singleton would silently end up duplicated (one copy per graph) instead of shared. */ * singleton would silently end up duplicated (one copy per graph) instead of shared. */
export const runEvents: EventEmitter = globalThis.__triggershellRunEvents ?? new EventEmitter(); export const runEvents: EventEmitter =
globalThis.__triggershellRunEvents ?? new EventEmitter();
globalThis.__triggershellRunEvents = runEvents; globalThis.__triggershellRunEvents = runEvents;
runEvents.setMaxListeners(0); runEvents.setMaxListeners(0);
+2 -1
View File
@@ -13,7 +13,8 @@ declare global {
* Anchored on `globalThis` - see the comment in `runner/events.ts` for why a plain module-level * Anchored on `globalThis` - see the comment in `runner/events.ts` for why a plain module-level
* singleton isn't safe here (Next compiles Route Handlers through a separate module graph from * singleton isn't safe here (Next compiles Route Handlers through a separate module graph from
* what `server.ts` requires directly). */ * what `server.ts` requires directly). */
const handles: Map<string, RunHandle> = globalThis.__triggershellRunHandles ?? new Map(); const handles: Map<string, RunHandle> =
globalThis.__triggershellRunHandles ?? new Map();
globalThis.__triggershellRunHandles = handles; globalThis.__triggershellRunHandles = handles;
export function registerRun(handle: RunHandle) { export function registerRun(handle: RunHandle) {