Initial implementation of TriggerShell

A Python CLI (typer) that bootstraps Node/pnpm and launches a Next.js 16 web
app for running configured shell scripts: YAML config validated by a shared
Zod schema, dynamic per-script forms mapped to shadcn controls, argv-safe
execa execution with live WebSocket streaming, SQLite/Drizzle run history,
and optional argon2 session + API token auth.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 18:37:30 +02:00
co-authored by Claude Sonnet 5
commit ced99a8e75
117 changed files with 17367 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.eggs/
build/
dist/
.venv/
venv/
.pytest_cache/
.ruff_cache/
# Runtime data (created by `triggershell start/dev` in whatever directory the config lives in)
.triggershell/
# Local config files a developer might create while testing against this repo
/triggershell.config.yaml
# Editors / OS
.DS_Store
.idea/
.vscode/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 TriggerShell contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+179
View File
@@ -0,0 +1,179 @@
# TriggerShell
A CLI that launches a self-hosted web app for running your shell scripts: define scripts and
their parameters in a config file, get a generated form UI, run them asynchronously with live
streaming output, and keep a searchable history — all behind optional login, with a full REST/WS
API for automation.
## Features
- **One config file** defines the server, auth, and every script (command, working dir, timeout,
and typed/validated variables with defaults and choices).
- **Generated forms** — each variable renders as the right control (text, number, slider, select,
radio, checkbox, switch, multi-select) based on its type, with client- and server-side validation
from the same schema.
- **Safe execution** — scripts run via argv arrays, never a shell string. User input can never
break out into shell metacharacters.
- **Live monitoring** — stdout/stderr and status stream over WebSocket to an in-browser terminal
view; cancel a run from the UI or the API.
- **Run history** — every run's variables (secrets redacted), resolved command, status, exit code,
and full log are persisted in SQLite.
- **Optional auth** — built-in username/password sessions and API tokens, toggleable per config.
- **REST + WebSocket API** for scripting your script runner.
## Quickstart
```bash
pip install triggershell # or: pip install -e . from a checkout
triggershell init # scaffold triggershell.config.yaml in the current directory
triggershell users add admin # create a login (skip if you set auth.enabled: false)
triggershell dev # start in dev mode and open the browser
```
Edit `triggershell.config.yaml` to add your own scripts (see [Configuration](#configuration) below),
then run `triggershell start` for a production build.
## Requirements
- Python >= 3.9
- Node.js >= 20 (checked by the CLI; not auto-installed)
- pnpm (auto-provisioned via Corepack if missing and Corepack is available)
## Configuration
TriggerShell is driven entirely by one YAML file (default: `./triggershell.config.yaml`, override
with `-c/--config` or `TRIGGERSHELL_CONFIG_PATH`). Minimal example:
```yaml
server:
host: 127.0.0.1
port: 4173
auth:
enabled: true
sessionSecret: "${TRIGGERSHELL_SESSION_SECRET}" # >= 32 chars
users:
- username: admin
passwordHash: "$argon2id$..." # from `triggershell users add`
tokens: []
database:
path: .triggershell/triggershell.db
logs:
dir: .triggershell/logs
retentionDays: 30
scripts:
- id: deploy-service
name: Deploy Service
command: bash
args: ["./scripts/deploy.sh"]
timeoutSeconds: 600
variables:
- name: environment
type: enum
choices: [staging, production]
default: staging
required: true
passAs: arg
argName: --env
- name: dryRun
type: boolean
default: false
passAs: flag
argName: --dry-run
```
A full, richly-commented example lives at [`examples/triggershell.config.yaml`](examples/triggershell.config.yaml),
and the complete field-by-field reference is in [`docs/CONFIG_REFERENCE.md`](docs/CONFIG_REFERENCE.md).
Each variable's `type` (`string` / `number` / `boolean` / `enum` / `multiselect`) picks a sensible
default UI control; set `control` explicitly to override it (e.g. `slider` for a `number`, `radio`
for an `enum`). `passAs` (`arg` / `flag` / `env` / `stdin`) decides how the validated value reaches
the script — always as a discrete argv element or env var, never interpolated into a shell string.
## CLI Usage
| Command | Description |
|---|---|
| `triggershell init [PATH]` | Scaffold a new config file (`--port`, `--auth/--no-auth`, `--force`) |
| `triggershell validate [-c CONFIG]` | Validate a config file (fast Python pre-flight + full Node/Zod schema) |
| `triggershell dev [-c CONFIG] [--port] [--host] [--no-browser]` | Run in development mode (hot reload) |
| `triggershell start [-c CONFIG] [--port] [--host] [--no-browser] [--skip-build]` | Build (if stale) and run in production mode |
| `triggershell doctor` | Print environment/config diagnostics |
| `triggershell users add <username>` | Hash a password and print a config snippet for `auth.users` |
| `triggershell users add-token <name>` | Generate an API token and print a config snippet for `auth.tokens` |
## Web App Guide
- **Scripts** (`/`) — every configured script as a card; click through to its run form.
- **Run form** (`/scripts/:id`) — a generated form for the script's variables, validated before
submit.
- **Run detail** (`/runs/:id`) — live streaming terminal output, status, exit code, and a Cancel
button while the run is active.
- **History** (`/runs`) — every past run, filterable by script/status via the API (`?scriptId=`,
`?status=`).
## Authentication
Set `auth.enabled: false` for trusted/local-only use — the app then requires no login at all.
When enabled, sessions are signed/encrypted cookies (no server-side session store), and API
clients can instead send `Authorization: Bearer <token>` using a token from `users add-token`.
Passwords are hashed with argon2id; only the hash ever lives in the config file.
## API Reference
Full reference with request/response shapes and curl examples: [`docs/API.md`](docs/API.md).
| Method | Path | Notes |
|---|---|---|
| GET | `/api/healthz` | Unauthenticated readiness probe |
| POST | `/api/auth/login` | `{username, password}` → sets session cookie |
| POST | `/api/auth/logout` | Clears the session |
| GET | `/api/auth/session` | Current auth state |
| GET | `/api/scripts` | List configured scripts |
| GET | `/api/scripts/:scriptId` | Full script schema (variables, UI hints) |
| POST | `/api/scripts/:scriptId/runs` | Start a run: `{variables: {...}}` |
| GET | `/api/runs` | History: `?scriptId=&status=&limit=&cursor=` |
| GET | `/api/runs/:runId` | Single run's status/metadata |
| POST | `/api/runs/:runId/cancel` | Cancel an active run |
| 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` |
## Development
```bash
pnpm --dir app install
pnpm --dir app dev # or: triggershell dev, which wraps this
pnpm --dir app lint
pnpm --dir app typecheck
pnpm --dir app db:studio # browse the SQLite DB
```
Repo layout:
```
triggershell/ Python CLI (launcher/orchestrator only)
app/ Next.js app - all server logic (API, auth, script execution) lives here
examples/ A runnable example config + scripts
docs/ Config/architecture/API reference docs
```
See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how the pieces fit together.
## Security Notes
- 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:
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
user-controlled in that case.
- `secret: true` variables are masked in the UI and redacted from persisted run records; only the
live child process ever sees the real value.
- The server binds to `127.0.0.1` by default — set `server.host` explicitly to expose it further.
- Route Handlers check auth themselves (`requireAuth()`); `proxy.ts` is only a fast, optimistic
redirect layer, not the security boundary.
## License
MIT — see [LICENSE](LICENSE).
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+16
View File
@@ -0,0 +1,16 @@
# TriggerShell web app
This is the Next.js app that TriggerShell's Python CLI (`triggershell dev` / `triggershell start`)
launches — it's not meant to be run standalone with `next dev`/`next start` since it needs a
custom server (`server.ts`) for the WebSocket endpoint.
See the [repo root README](../README.md) for how to run TriggerShell end-to-end, and
[`../docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) for how this app is put together.
```bash
pnpm install
pnpm dev # tsx watch server.ts - reads TRIGGERSHELL_CONFIG_PATH from the environment
pnpm lint
pnpm typecheck
pnpm db:studio # browse the SQLite database
```
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+7
View File
@@ -0,0 +1,7 @@
import type { Config } from "drizzle-kit";
export default {
schema: "./src/lib/db/schema.ts",
out: "./drizzle",
dialect: "sqlite",
} satisfies Config;
+38
View File
@@ -0,0 +1,38 @@
CREATE TABLE `api_tokens` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`token_hash` text NOT NULL,
`created_at` integer NOT NULL,
`last_used_at` integer
);
--> statement-breakpoint
CREATE UNIQUE INDEX `api_tokens_name_unique` ON `api_tokens` (`name`);--> statement-breakpoint
CREATE TABLE `runs` (
`id` text PRIMARY KEY NOT NULL,
`script_id` text NOT NULL,
`script_name` text NOT NULL,
`status` text DEFAULT 'queued' NOT NULL,
`variables` text NOT NULL,
`resolved_command` text NOT NULL,
`pid` integer,
`exit_code` integer,
`started_at` integer,
`ended_at` integer,
`timeout_seconds` integer,
`triggered_by` text NOT NULL,
`log_file_path` text NOT NULL,
`error_message` text,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE INDEX `runs_script_id_idx` ON `runs` (`script_id`);--> statement-breakpoint
CREATE INDEX `runs_status_idx` ON `runs` (`status`);--> statement-breakpoint
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`username` text NOT NULL,
`password_hash` text NOT NULL,
`created_at` integer NOT NULL,
`last_login_at` integer
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
+247
View File
@@ -0,0 +1,247 @@
{
"version": "6",
"dialect": "sqlite",
"id": "293a5752-68a4-4e8a-89a6-fadaebd14e3b",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"api_tokens": {
"name": "api_tokens",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token_hash": {
"name": "token_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_used_at": {
"name": "last_used_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"api_tokens_name_unique": {
"name": "api_tokens_name_unique",
"columns": ["name"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"runs": {
"name": "runs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"script_id": {
"name": "script_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"script_name": {
"name": "script_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'queued'"
},
"variables": {
"name": "variables",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"resolved_command": {
"name": "resolved_command",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"pid": {
"name": "pid",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"exit_code": {
"name": "exit_code",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"started_at": {
"name": "started_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ended_at": {
"name": "ended_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"timeout_seconds": {
"name": "timeout_seconds",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"triggered_by": {
"name": "triggered_by",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"log_file_path": {
"name": "log_file_path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"error_message": {
"name": "error_message",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"runs_script_id_idx": {
"name": "runs_script_id_idx",
"columns": ["script_id"],
"isUnique": false
},
"runs_status_idx": {
"name": "runs_status_idx",
"columns": ["status"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_login_at": {
"name": "last_login_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": ["username"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1786809343994,
"tag": "0000_thick_reaper",
"breakpoints": true
}
]
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+5
View File
@@ -0,0 +1,5 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {/* config options here */};
export default nextConfig;
+60
View File
@@ -0,0 +1,60 @@
{
"name": "app",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch server.ts",
"build": "next build",
"start": "cross-env NODE_ENV=production tsx server.ts",
"lint": "eslint",
"typecheck": "tsc --noEmit",
"format": "prettier --write .",
"validate-config": "tsx scripts/validate-config.ts",
"db:generate": "drizzle-kit generate",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@base-ui/react": "^1.7.0",
"@hookform/resolvers": "^5.8.0",
"argon2": "^0.45.1",
"better-sqlite3": "^13.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"drizzle-orm": "^0.45.2",
"execa": "^10.0.1",
"iron-session": "^8.0.4",
"lucide-react": "^1.31.0",
"next": "16.3.1",
"next-themes": "^0.4.6",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-hook-form": "^7.85.0",
"shadcn": "^4.18.0",
"sonner": "^2.0.8",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
"ws": "^8.21.3",
"yaml": "^2.9.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^9.6.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/ws": "^8.18.1",
"cross-env": "^10.1.0",
"drizzle-kit": "^0.31.10",
"eslint": "^9",
"eslint-config-next": "16.3.1",
"prettier": "^3.9.6",
"prettier-plugin-tailwindcss": "^0.8.1",
"tailwindcss": "^4",
"tsx": "^4.23.12",
"typescript": "^5"
},
"packageManager": "pnpm@11.21.0"
}
+10162
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
allowBuilds:
argon2: true
better-sqlite3: true
esbuild: true
sharp: false
unrs-resolver: false
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+35
View File
@@ -0,0 +1,35 @@
import { loadConfig, ConfigError } from "../src/lib/config/load";
const configPathArg = process.argv[2];
try {
const { config, configPath } = loadConfig(configPathArg);
console.log(
JSON.stringify({
ok: true,
configPath,
scriptCount: config.scripts.length,
authEnabled: config.auth.enabled,
}),
);
process.exit(0);
} catch (error) {
if (error instanceof ConfigError) {
console.log(
JSON.stringify({
ok: false,
message: error.message,
issues: error.issues,
}),
);
} else {
console.log(
JSON.stringify({
ok: false,
message: (error as Error).message,
issues: [],
}),
);
}
process.exit(1);
}
+71
View File
@@ -0,0 +1,71 @@
import "./src/bootstrap/async-local-storage-polyfill";
import { createServer } from "node:http";
import { parse } from "node:url";
import next from "next";
import { WebSocketServer } from "ws";
import { getConfig } from "./src/lib/config/load";
import { migrateOnBoot } from "./src/lib/db/client";
import { syncAuthFromConfig } from "./src/lib/auth/sync";
import { reconcileOrphanedRuns } from "./src/lib/runner/engine";
import { killAllRuns } from "./src/lib/runner/registry";
import { attachWsServer, authenticateUpgrade } from "./src/lib/ws/server";
const dev = process.env.NODE_ENV !== "production";
const { config } = getConfig();
const port = Number(process.env.PORT ?? config.server.port);
const hostname = process.env.HOST ?? config.server.host;
migrateOnBoot();
syncAuthFromConfig();
reconcileOrphanedRuns();
const app = next({ dev, hostname, port });
const handle = app.getRequestHandler();
app.prepare().then(() => {
const httpServer = createServer((req, res) => {
handle(req, res, parse(req.url ?? "/", true));
});
const wss = new WebSocketServer({ noServer: true });
attachWsServer(wss);
httpServer.on("upgrade", (req, socket, head) => {
const { pathname } = parse(req.url ?? "/");
if (pathname !== "/ws/runs") {
socket.destroy();
return;
}
authenticateUpgrade(req)
.then((ok) => {
if (!ok) {
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
})
.catch(() => socket.destroy());
});
httpServer.listen(port, hostname, () => {
console.log(
`> triggershell ready on http://${hostname}:${port} (${dev ? "development" : "production"})`,
);
});
const shutdown = (signal: string) => {
console.log(`> received ${signal}, shutting down...`);
killAllRuns();
httpServer.close(() => process.exit(0));
// Force-exit if graceful shutdown hangs (e.g. a stuck WS connection).
setTimeout(() => process.exit(1), 5000).unref();
};
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
});
+23
View File
@@ -0,0 +1,23 @@
export const dynamic = "force-dynamic";
import { getConfig } from "@/lib/config/load";
import { requireAuth } from "@/lib/auth/guard";
import { Nav } from "@/components/layout/nav";
export default async function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
const { config } = getConfig();
const auth = await requireAuth();
return (
<div className="flex min-h-full flex-1 flex-col">
<Nav authEnabled={config.auth.enabled} username={auth.identity} />
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-8">
{children}
</main>
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import { ChevronRight, PlayCircle } from "lucide-react";
import { getConfig } from "@/lib/config/load";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export default function DashboardPage() {
const { config } = getConfig();
if (config.scripts.length === 0) {
return (
<div className="text-muted-foreground py-24 text-center">
No scripts configured yet. Add entries under{" "}
<code className="bg-muted rounded px-1.5 py-0.5">scripts:</code> in your
config file.
</div>
);
}
return (
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-semibold tracking-tight">Scripts</h1>
<div className="grid gap-3 sm:grid-cols-2">
{config.scripts.map((script) => (
<Link key={script.id} href={`/scripts/${script.id}`}>
<Card className="hover:border-foreground/30 h-full transition-colors">
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
<div className="flex items-center gap-2">
<PlayCircle className="text-muted-foreground size-4.5 shrink-0" />
<CardTitle className="text-base">{script.name}</CardTitle>
</div>
<ChevronRight className="text-muted-foreground size-4 shrink-0" />
</CardHeader>
{script.description && (
<CardContent className="text-muted-foreground text-sm">
{script.description}
</CardContent>
)}
</Card>
</Link>
))}
</div>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
export const dynamic = "force-dynamic";
import fs from "node:fs";
import { eq } from "drizzle-orm";
import { notFound } from "next/navigation";
import { getDb } from "@/lib/db/client";
import { runs } from "@/lib/db/schema";
import { RunTerminal } from "@/components/runs/run-terminal";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
const MAX_INITIAL_LOG_BYTES = 200_000;
function readTail(logFilePath: string): string {
if (!fs.existsSync(logFilePath)) return "";
const { size } = fs.statSync(logFilePath);
const start = Math.max(0, size - MAX_INITIAL_LOG_BYTES);
const fd = fs.openSync(logFilePath, "r");
try {
const buffer = Buffer.alloc(size - start);
fs.readSync(fd, buffer, 0, buffer.length, start);
return (start > 0 ? "... (truncated)\n" : "") + buffer.toString("utf-8");
} finally {
fs.closeSync(fd);
}
}
export default async function RunDetailPage({
params,
}: {
params: Promise<{ runId: string }>;
}) {
const { runId } = await params;
const db = getDb();
const run = db.select().from(runs).where(eq(runs.id, runId)).get();
if (!run) notFound();
const initialLog = readTail(run.logFilePath);
return (
<div className="mx-auto flex max-w-3xl flex-col gap-4">
<Card>
<CardHeader>
<CardTitle>{run.scriptName}</CardTitle>
<dl className="text-muted-foreground grid grid-cols-2 gap-x-4 gap-y-1 text-xs sm:grid-cols-4">
<div>
<dt className="font-medium">Triggered by</dt>
<dd>{run.triggeredBy}</dd>
</div>
<div>
<dt className="font-medium">Started</dt>
<dd>
{run.startedAt ? new Date(run.startedAt).toLocaleString() : "-"}
</dd>
</div>
<div>
<dt className="font-medium">Command</dt>
<dd className="truncate font-mono">{run.resolvedCommand}</dd>
</div>
<div>
<dt className="font-medium">Run ID</dt>
<dd className="truncate font-mono">{run.id}</dd>
</div>
</dl>
</CardHeader>
<CardContent>
<RunTerminal
runId={run.id}
initialStatus={run.status}
initialLog={initialLog}
initialExitCode={run.exitCode}
/>
</CardContent>
</Card>
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
export const dynamic = "force-dynamic";
import Link from "next/link";
import { desc } from "drizzle-orm";
import { getDb } from "@/lib/db/client";
import { runs } from "@/lib/db/schema";
import { RunStatusBadge } from "@/components/runs/run-status-badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
function formatDuration(startedAt: Date | null, endedAt: Date | null): string {
if (!startedAt) return "-";
const end = endedAt ?? new Date();
const seconds = Math.max(
0,
Math.round((end.getTime() - startedAt.getTime()) / 1000),
);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
return `${minutes}m ${seconds % 60}s`;
}
export default function RunsPage() {
const db = getDb();
const rows = db
.select()
.from(runs)
.orderBy(desc(runs.createdAt))
.limit(100)
.all();
return (
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-semibold tracking-tight">Run History</h1>
{rows.length === 0 ? (
<p className="text-muted-foreground py-12 text-center">No runs yet.</p>
) : (
<div className="overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Script</TableHead>
<TableHead>Status</TableHead>
<TableHead>Triggered by</TableHead>
<TableHead>Started</TableHead>
<TableHead>Duration</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((run) => (
<TableRow key={run.id} className="cursor-pointer">
<TableCell>
<Link
href={`/runs/${run.id}`}
className="font-medium hover:underline"
>
{run.scriptName}
</Link>
</TableCell>
<TableCell>
<RunStatusBadge status={run.status} />
</TableCell>
<TableCell className="text-muted-foreground">
{run.triggeredBy}
</TableCell>
<TableCell className="text-muted-foreground">
{run.startedAt
? new Date(run.startedAt).toLocaleString()
: "-"}
</TableCell>
<TableCell className="text-muted-foreground">
{formatDuration(run.startedAt, run.endedAt)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
);
}
@@ -0,0 +1,41 @@
export const dynamic = "force-dynamic";
import { notFound } from "next/navigation";
import { getScript } from "@/lib/config/load";
import { serializeScriptForClient } from "@/lib/config/serialize";
import { DynamicForm } from "@/components/forms/dynamic-form";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export default async function ScriptPage({
params,
}: {
params: Promise<{ scriptId: string }>;
}) {
const { scriptId } = await params;
const script = getScript(scriptId);
if (!script) notFound();
const clientScript = serializeScriptForClient(script);
return (
<div className="mx-auto max-w-2xl">
<Card>
<CardHeader>
<CardTitle>{script.name}</CardTitle>
{script.description && (
<CardDescription>{script.description}</CardDescription>
)}
</CardHeader>
<CardContent>
<DynamicForm script={clientScript} />
</CardContent>
</Card>
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
export const dynamic = "force-dynamic";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { getDb } from "@/lib/db/client";
import { users } from "@/lib/db/schema";
import { verifyPassword } from "@/lib/auth/password";
import { getSession } from "@/lib/auth/session";
import {
isRateLimited,
recordFailedAttempt,
clearAttempts,
} from "@/lib/auth/rate-limit";
const loginSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
export async function POST(request: Request) {
const rateLimitKey = request.headers.get("x-forwarded-for") ?? "local";
if (isRateLimited(rateLimitKey)) {
return Response.json(
{ error: "Too many attempts, try again later." },
{ status: 429 },
);
}
const body = await request.json().catch(() => null);
const parsed = loginSchema.safeParse(body);
if (!parsed.success) {
return Response.json({ error: "Invalid request body" }, { status: 400 });
}
const db = getDb();
const user = db
.select()
.from(users)
.where(eq(users.username, parsed.data.username))
.get();
if (
!user ||
!(await verifyPassword(user.passwordHash, parsed.data.password))
) {
recordFailedAttempt(rateLimitKey);
return Response.json({ error: "Invalid credentials" }, { status: 401 });
}
clearAttempts(rateLimitKey);
db.update(users)
.set({ lastLoginAt: new Date() })
.where(eq(users.id, user.id))
.run();
const session = await getSession();
session.userId = user.id;
session.username = user.username;
await session.save();
return Response.json({ user: { username: user.username } });
}
+9
View File
@@ -0,0 +1,9 @@
export const dynamic = "force-dynamic";
import { getSession } from "@/lib/auth/session";
export async function POST() {
const session = await getSession();
session.destroy();
return new Response(null, { status: 204 });
}
+16
View File
@@ -0,0 +1,16 @@
export const dynamic = "force-dynamic";
import { requireAuth } from "@/lib/auth/guard";
import { getConfig } from "@/lib/config/load";
export async function GET(request: Request) {
const { config } = getConfig();
const auth = await requireAuth(request);
return Response.json({
authRequired: config.auth.enabled,
authenticated: auth.authenticated,
user:
auth.authenticated && auth.identity ? { username: auth.identity } : null,
});
}
+8
View File
@@ -0,0 +1,8 @@
export const dynamic = "force-dynamic";
export async function GET() {
return Response.json({
ok: true,
version: process.env.npm_package_version ?? "dev",
});
}
@@ -0,0 +1,37 @@
export const dynamic = "force-dynamic";
import { eq } from "drizzle-orm";
import { requireAuth, unauthorizedResponse } from "@/lib/auth/guard";
import { getDb } from "@/lib/db/client";
import { runs } from "@/lib/db/schema";
import { cancelRun } from "@/lib/runner/registry";
export async function POST(
request: Request,
{ params }: { params: Promise<{ runId: string }> },
) {
const auth = await requireAuth(request);
if (!auth.authenticated) return unauthorizedResponse();
const { runId } = await params;
const db = getDb();
const run = db.select().from(runs).where(eq(runs.id, runId)).get();
if (!run) return Response.json({ error: "Run not found" }, { status: 404 });
if (run.status !== "queued" && run.status !== "running") {
return Response.json(
{ error: `Run already ${run.status}` },
{ status: 409 },
);
}
const cancelled = cancelRun(runId);
if (!cancelled) {
return Response.json(
{ error: "Run is not active in this server process" },
{ status: 409 },
);
}
return Response.json({ status: "cancelling" }, { status: 202 });
}
@@ -0,0 +1,40 @@
export const dynamic = "force-dynamic";
import fs from "node:fs";
import { eq } from "drizzle-orm";
import { requireAuth, unauthorizedResponse } from "@/lib/auth/guard";
import { getDb } from "@/lib/db/client";
import { runs } from "@/lib/db/schema";
export async function GET(
request: Request,
{ params }: { params: Promise<{ runId: string }> },
) {
const auth = await requireAuth(request);
if (!auth.authenticated) return unauthorizedResponse();
const { runId } = await params;
const db = getDb();
const run = db.select().from(runs).where(eq(runs.id, runId)).get();
if (!run) return Response.json({ error: "Run not found" }, { status: 404 });
let content = fs.existsSync(run.logFilePath)
? fs.readFileSync(run.logFilePath, "utf-8")
: "";
const url = new URL(request.url);
const tailParam = url.searchParams.get("tail");
if (tailParam) {
const tailLines = Math.max(1, Number(tailParam) || 500);
content = content.split("\n").slice(-tailLines).join("\n");
}
const headers: Record<string, string> = {
"Content-Type": "text/plain; charset=utf-8",
};
if (url.searchParams.get("download") === "1") {
headers["Content-Disposition"] = `attachment; filename="${runId}.log"`;
}
return new Response(content, { headers });
}
+21
View File
@@ -0,0 +1,21 @@
export const dynamic = "force-dynamic";
import { eq } from "drizzle-orm";
import { requireAuth, unauthorizedResponse } from "@/lib/auth/guard";
import { getDb } from "@/lib/db/client";
import { runs } from "@/lib/db/schema";
export async function GET(
request: Request,
{ params }: { params: Promise<{ runId: string }> },
) {
const auth = await requireAuth(request);
if (!auth.authenticated) return unauthorizedResponse();
const { runId } = await params;
const db = getDb();
const run = db.select().from(runs).where(eq(runs.id, runId)).get();
if (!run) return Response.json({ error: "Run not found" }, { status: 404 });
return Response.json(run);
}
+51
View File
@@ -0,0 +1,51 @@
export const dynamic = "force-dynamic";
import { and, desc, eq } from "drizzle-orm";
import { requireAuth, unauthorizedResponse } from "@/lib/auth/guard";
import { getDb } from "@/lib/db/client";
import { runs, runStatusValues, type RunStatus } from "@/lib/db/schema";
const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 200;
export async function GET(request: Request) {
const auth = await requireAuth(request);
if (!auth.authenticated) return unauthorizedResponse();
const url = new URL(request.url);
const scriptId = url.searchParams.get("scriptId") ?? undefined;
const statusParam = url.searchParams.get("status") ?? undefined;
const status =
statusParam && (runStatusValues as readonly string[]).includes(statusParam)
? (statusParam as RunStatus)
: undefined;
const limit = Math.min(
MAX_LIMIT,
Math.max(1, Number(url.searchParams.get("limit")) || DEFAULT_LIMIT),
);
const offset = Math.max(0, Number(url.searchParams.get("cursor")) || 0);
const db = getDb();
const conditions = [
scriptId ? eq(runs.scriptId, scriptId) : undefined,
status ? eq(runs.status, status) : undefined,
].filter((c): c is NonNullable<typeof c> => Boolean(c));
const whereClause = conditions.length ? and(...conditions) : undefined;
const rows = db
.select()
.from(runs)
.where(whereClause)
.orderBy(desc(runs.createdAt))
.limit(limit + 1)
.offset(offset)
.all();
const hasMore = rows.length > limit;
const page = hasMore ? rows.slice(0, limit) : rows;
return Response.json({
runs: page,
nextCursor: hasMore ? String(offset + limit) : null,
});
}
@@ -0,0 +1,20 @@
export const dynamic = "force-dynamic";
import { requireAuth, unauthorizedResponse } from "@/lib/auth/guard";
import { getScript } from "@/lib/config/load";
import { serializeScriptForClient } from "@/lib/config/serialize";
export async function GET(
request: Request,
{ params }: { params: Promise<{ scriptId: string }> },
) {
const auth = await requireAuth(request);
if (!auth.authenticated) return unauthorizedResponse();
const { scriptId } = await params;
const script = getScript(scriptId);
if (!script)
return Response.json({ error: "Script not found" }, { status: 404 });
return Response.json(serializeScriptForClient(script));
}
@@ -0,0 +1,45 @@
export const dynamic = "force-dynamic";
import { requireAuth, unauthorizedResponse } from "@/lib/auth/guard";
import { getScript } from "@/lib/config/load";
import { buildVariableSchema } from "@/lib/validation/variable-schema";
import { startRun } from "@/lib/runner/engine";
export async function POST(
request: Request,
{ params }: { params: Promise<{ scriptId: string }> },
) {
const auth = await requireAuth(request);
if (!auth.authenticated) return unauthorizedResponse();
const { scriptId } = await params;
const script = getScript(scriptId);
if (!script)
return Response.json({ error: "Script not found" }, { status: 404 });
const body = await request.json().catch(() => null);
const variablesInput =
(body && typeof body === "object" && "variables" in body
? body.variables
: body) ?? {};
const variableSchema = buildVariableSchema(script);
const parsed = variableSchema.safeParse(variablesInput);
if (!parsed.success) {
return Response.json(
{
error: "Validation failed",
fieldErrors: parsed.error.flatten().fieldErrors,
},
{ status: 400 },
);
}
const runId = await startRun({
scriptId: script.id,
variables: parsed.data,
triggeredBy: auth.identity ?? "anonymous",
});
return Response.json({ runId, status: "queued" }, { status: 201 });
}
+13
View File
@@ -0,0 +1,13 @@
export const dynamic = "force-dynamic";
import { requireAuth, unauthorizedResponse } from "@/lib/auth/guard";
import { getConfig } from "@/lib/config/load";
import { serializeScriptSummary } from "@/lib/config/serialize";
export async function GET(request: Request) {
const auth = await requireAuth(request);
if (!auth.authenticated) return unauthorizedResponse();
const { config } = getConfig();
return Response.json({ scripts: config.scripts.map(serializeScriptSummary) });
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+130
View File
@@ -0,0 +1,130 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
+40
View File
@@ -0,0 +1,40 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/sonner";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "TriggerShell",
description: "Run configured shell scripts from a web UI.",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="bg-background text-foreground flex min-h-full flex-col">
<TooltipProvider>
{children}
<Toaster />
</TooltipProvider>
</body>
</html>
);
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Terminal } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
export function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
setPending(true);
setError(null);
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (response.ok) {
const next = searchParams.get("next") ?? "/";
router.push(next);
router.refresh();
return;
}
const data = await response.json().catch(() => ({}));
setError(data.error ?? "Login failed");
setPending(false);
}
return (
<Card className="w-full max-w-sm">
<CardHeader>
<div className="flex items-center gap-2">
<Terminal className="size-5" />
<CardTitle>TriggerShell</CardTitle>
</div>
<CardDescription>
Sign in to run and monitor your configured scripts.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex flex-col gap-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
</div>
<Button type="submit" disabled={pending} className="mt-2">
{pending ? "Signing in..." : "Sign in"}
</Button>
</form>
</CardContent>
</Card>
);
}
+20
View File
@@ -0,0 +1,20 @@
export const dynamic = "force-dynamic";
import { redirect } from "next/navigation";
import { getConfig } from "@/lib/config/load";
import { getSession } from "@/lib/auth/session";
import { LoginForm } from "./login-form";
export default async function LoginPage() {
const { config } = getConfig();
if (!config.auth.enabled) redirect("/");
const session = await getSession();
if (session.userId) redirect("/");
return (
<div className="flex min-h-full flex-1 items-center justify-center px-4">
<LoginForm />
</div>
);
}
@@ -0,0 +1,9 @@
import { AsyncLocalStorage } from "node:async_hooks";
// Next's App Router needs this global set up before its own modules evaluate. `next start`/`next dev`
// do this via their CLI bootstrap; the programmatic `next()` API used by our custom server does not.
// Must be the first import in server.ts - ESM import evaluation order (not textual position) is what
// guarantees this runs before the `next` package's module graph does.
if (typeof globalThis.AsyncLocalStorage !== "function") {
globalThis.AsyncLocalStorage = AsyncLocalStorage;
}
@@ -0,0 +1,90 @@
"use client";
import { useState } from "react";
import { Check, ChevronsUpDown, X } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
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 MultiSelectProps {
choices: string[];
value: string[];
onChange: (value: string[]) => void;
}
export function MultiSelect({ choices, value, onChange }: MultiSelectProps) {
const [open, setOpen] = useState(false);
function toggle(choice: string) {
onChange(
value.includes(choice)
? value.filter((v) => v !== choice)
: [...value, choice],
);
}
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="flex flex-1 flex-wrap gap-1 text-left">
{value.length === 0 ? (
<span className="text-muted-foreground">Select...</span>
) : (
value.map((v) => (
<Badge key={v} variant="secondary" className="gap-1">
{v}
<X
className="size-3 cursor-pointer"
onClick={(event) => {
event.stopPropagation();
toggle(v);
}}
/>
</Badge>
))
)}
</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={() => toggle(choice)}>
<Check
className={cn(
"mr-2 size-4",
value.includes(choice) ? "opacity-100" : "opacity-0",
)}
/>
{choice}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Play, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Form } from "@/components/ui/form";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { buildVariableSchemaFromList } from "@/lib/validation/variable-schema";
import type { ClientScript } from "@/lib/config/serialize";
import { FieldRenderer } from "./field-renderer";
function emptyValueFor(variable: ClientScript["variables"][number]): unknown {
if (variable.type === "boolean") return false;
if (variable.type === "multiselect") return [];
return "";
}
function defaultValuesFor(script: ClientScript): Record<string, unknown> {
const values: Record<string, unknown> = {};
for (const variable of script.variables) {
values[variable.name] = variable.default ?? emptyValueFor(variable);
}
return values;
}
export function DynamicForm({ script }: { script: ClientScript }) {
const router = useRouter();
const [submitError, setSubmitError] = useState<string | null>(null);
const schema = buildVariableSchemaFromList(script.variables);
const form = useForm({
resolver: zodResolver(schema),
defaultValues: defaultValuesFor(script),
});
async function onSubmit(values: Record<string, unknown>) {
setSubmitError(null);
const response = await fetch(`/api/scripts/${script.id}/runs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ variables: values }),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
setSubmitError(data.error ?? "Failed to start run");
return;
}
const data = await response.json();
toast.success(`${script.name} started`);
router.push(`/runs/${data.runId}`);
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-5"
>
{submitError && (
<Alert variant="destructive">
<AlertDescription>{submitError}</AlertDescription>
</Alert>
)}
{script.variables.length === 0 && (
<p className="text-muted-foreground text-sm">
This script takes no parameters.
</p>
)}
{script.variables.map((variable) => (
<FieldRenderer key={variable.name} variable={variable} />
))}
<Button
type="submit"
disabled={form.formState.isSubmitting}
className="w-fit"
>
{form.formState.isSubmitting ? (
<Loader2 className="animate-spin" />
) : (
<Play />
)}
Run
</Button>
</form>
</Form>
);
}
+329
View File
@@ -0,0 +1,329 @@
"use client";
import { useFormContext } from "react-hook-form";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Switch } from "@/components/ui/switch";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { Label } from "@/components/ui/label";
import { MultiSelect } from "./controls/multi-select";
import type { ClientVariable } from "@/lib/config/serialize";
export function FieldRenderer({ variable }: { variable: ClientVariable }) {
const { control } = useFormContext();
const label = variable.label ?? variable.name;
return (
<FormField
control={control}
name={variable.name}
render={({ field }) => {
switch (variable.control) {
case "textarea":
return (
<FormItem>
<FormLabel>
{label}
{variable.required && (
<span className="text-destructive"> *</span>
)}
</FormLabel>
<FormControl>
<Textarea {...field} value={field.value ?? ""} />
</FormControl>
{variable.description && (
<FormDescription>{variable.description}</FormDescription>
)}
<FormMessage />
</FormItem>
);
case "password":
return (
<FormItem>
<FormLabel>
{label}
{variable.required && (
<span className="text-destructive"> *</span>
)}
</FormLabel>
<FormControl>
<Input
type="password"
autoComplete="off"
{...field}
value={field.value ?? ""}
/>
</FormControl>
{variable.description && (
<FormDescription>{variable.description}</FormDescription>
)}
<FormMessage />
</FormItem>
);
case "number":
return (
<FormItem>
<FormLabel>
{label}
{variable.required && (
<span className="text-destructive"> *</span>
)}
</FormLabel>
<FormControl>
<Input
type="number"
{...(variable.type === "number"
? {
min: variable.min,
max: variable.max,
step: variable.step,
}
: {})}
value={field.value ?? ""}
onChange={(event) =>
field.onChange(
event.target.value === ""
? undefined
: Number(event.target.value),
)
}
/>
</FormControl>
{variable.description && (
<FormDescription>{variable.description}</FormDescription>
)}
<FormMessage />
</FormItem>
);
case "slider": {
const min = variable.type === "number" ? (variable.min ?? 0) : 0;
const max =
variable.type === "number" ? (variable.max ?? 100) : 100;
const step = variable.type === "number" ? variable.step : undefined;
const current =
typeof field.value === "number" ? field.value : (min + max) / 2;
return (
<FormItem>
<FormLabel>
{label}
<span className="text-muted-foreground font-normal">
{current}
</span>
</FormLabel>
<FormControl>
<Slider
min={min}
max={max}
step={step}
value={current}
onValueChange={(next) => field.onChange(next)}
/>
</FormControl>
{variable.description && (
<FormDescription>{variable.description}</FormDescription>
)}
<FormMessage />
</FormItem>
);
}
case "checkbox":
return (
<FormItem className="flex flex-row items-center gap-2 space-y-0">
<FormControl>
<Checkbox
checked={!!field.value}
onCheckedChange={(checked) =>
field.onChange(checked === true)
}
/>
</FormControl>
<FormLabel className="font-normal">{label}</FormLabel>
<FormMessage />
</FormItem>
);
case "switch":
return (
<FormItem className="flex flex-row items-center justify-between gap-2 space-y-0">
<FormLabel className="font-normal">{label}</FormLabel>
<FormControl>
<Switch
checked={!!field.value}
onCheckedChange={(checked) => field.onChange(checked)}
/>
</FormControl>
<FormMessage />
</FormItem>
);
case "select":
return (
<FormItem>
<FormLabel>
{label}
{variable.required && (
<span className="text-destructive"> *</span>
)}
</FormLabel>
<FormControl>
<Select
value={field.value ?? ""}
onValueChange={(value) => field.onChange(value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select..." />
</SelectTrigger>
<SelectContent>
{variable.type === "enum" &&
variable.choices.map((choice) => (
<SelectItem key={choice} value={choice}>
{choice}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
{variable.description && (
<FormDescription>{variable.description}</FormDescription>
)}
<FormMessage />
</FormItem>
);
case "radio":
return (
<FormItem>
<FormLabel>
{label}
{variable.required && (
<span className="text-destructive"> *</span>
)}
</FormLabel>
<FormControl>
<RadioGroup
value={field.value ?? ""}
onValueChange={(value) => field.onChange(value)}
>
{variable.type === "enum" &&
variable.choices.map((choice) => (
<div key={choice} className="flex items-center gap-2">
<RadioGroupItem
value={choice}
id={`${variable.name}-${choice}`}
/>
<Label
htmlFor={`${variable.name}-${choice}`}
className="font-normal"
>
{choice}
</Label>
</div>
))}
</RadioGroup>
</FormControl>
{variable.description && (
<FormDescription>{variable.description}</FormDescription>
)}
<FormMessage />
</FormItem>
);
case "checkboxGroup":
return (
<FormItem>
<FormLabel>{label}</FormLabel>
<div className="flex flex-col gap-2">
{variable.type === "multiselect" &&
variable.choices.map((choice) => {
const values: string[] = Array.isArray(field.value)
? field.value
: [];
return (
<div key={choice} className="flex items-center gap-2">
<Checkbox
checked={values.includes(choice)}
onCheckedChange={(checked) =>
field.onChange(
checked === true
? [...values, choice]
: values.filter((v) => v !== choice),
)
}
/>
<Label className="font-normal">{choice}</Label>
</div>
);
})}
</div>
{variable.description && (
<FormDescription>{variable.description}</FormDescription>
)}
<FormMessage />
</FormItem>
);
case "multiselect":
return (
<FormItem>
<FormLabel>{label}</FormLabel>
<FormControl>
<MultiSelect
choices={
variable.type === "multiselect" ? variable.choices : []
}
value={Array.isArray(field.value) ? field.value : []}
onChange={(value) => field.onChange(value)}
/>
</FormControl>
{variable.description && (
<FormDescription>{variable.description}</FormDescription>
)}
<FormMessage />
</FormItem>
);
case "text":
default:
return (
<FormItem>
<FormLabel>
{label}
{variable.required && (
<span className="text-destructive"> *</span>
)}
</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ""} />
</FormControl>
{variable.description && (
<FormDescription>{variable.description}</FormDescription>
)}
<FormMessage />
</FormItem>
);
}
}}
/>
);
}
+69
View File
@@ -0,0 +1,69 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { Terminal, History, LogOut } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const links = [
{ href: "/", label: "Scripts", icon: Terminal },
{ href: "/runs", label: "History", icon: History },
];
export function Nav({
authEnabled,
username,
}: {
authEnabled: boolean;
username: string | null;
}) {
const pathname = usePathname();
const router = useRouter();
async function handleLogout() {
await fetch("/api/auth/logout", { method: "POST" });
router.push("/login");
router.refresh();
}
return (
<header className="border-b bg-background/95 sticky top-0 z-10 backdrop-blur">
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-4">
<div className="flex items-center gap-6">
<Link
href="/"
className="flex items-center gap-2 font-semibold tracking-tight"
>
<Terminal className="size-5" />
TriggerShell
</Link>
<nav className="flex items-center gap-1">
{links.map(({ href, label, icon: Icon }) => (
<Link
key={href}
href={href}
className={cn(
"text-muted-foreground hover:text-foreground flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
pathname === href && "bg-muted text-foreground",
)}
>
<Icon className="size-4" />
{label}
</Link>
))}
</nav>
</div>
{authEnabled && (
<div className="flex items-center gap-3">
<span className="text-muted-foreground text-sm">{username}</span>
<Button variant="ghost" size="sm" onClick={handleLogout}>
<LogOut className="size-4" />
Log out
</Button>
</div>
)}
</div>
</header>
);
}
@@ -0,0 +1,37 @@
import { Badge } from "@/components/ui/badge";
import type { RunStatus } from "@/lib/db/schema";
import { cn } from "@/lib/utils";
const styles: Record<RunStatus, string> = {
queued: "bg-muted text-muted-foreground",
running: "bg-blue-500/15 text-blue-600 dark:text-blue-400",
succeeded: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400",
failed: "bg-destructive/15 text-destructive",
cancelled: "bg-muted text-muted-foreground",
timed_out: "bg-amber-500/15 text-amber-600 dark:text-amber-400",
interrupted: "bg-amber-500/15 text-amber-600 dark:text-amber-400",
};
const labels: Record<RunStatus, string> = {
queued: "Queued",
running: "Running",
succeeded: "Succeeded",
failed: "Failed",
cancelled: "Cancelled",
timed_out: "Timed out",
interrupted: "Interrupted",
};
export function RunStatusBadge({ status }: { status: RunStatus }) {
return (
<Badge
className={cn("border-transparent font-medium", styles[status])}
variant="outline"
>
{status === "running" && (
<span className="mr-1 inline-block size-1.5 animate-pulse rounded-full bg-current" />
)}
{labels[status]}
</Badge>
);
}
+108
View File
@@ -0,0 +1,108 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Square } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useRunSocket } from "@/hooks/use-run-socket";
import type { RunStatus } from "@/lib/db/schema";
import type { ServerMessage } from "@/lib/ws/protocol";
import { RunStatusBadge } from "./run-status-badge";
interface RunTerminalProps {
runId: string;
initialStatus: RunStatus;
initialLog: string;
initialExitCode: number | null;
}
interface Line {
key: number;
stream: "stdout" | "stderr";
text: string;
}
export function RunTerminal({
runId,
initialStatus,
initialLog,
initialExitCode,
}: RunTerminalProps) {
const [status, setStatus] = useState<RunStatus>(initialStatus);
const [exitCode, setExitCode] = useState<number | null>(initialExitCode);
const [lines, setLines] = useState<Line[]>(() =>
initialLog ? [{ key: -1, stream: "stdout", text: initialLog }] : [],
);
const bottomRef = useRef<HTMLDivElement>(null);
const nextKey = useRef(0);
const { cancel } = useRunSocket(runId, (message: ServerMessage) => {
if (message.type === "output") {
setLines((prev) => [
...prev,
{ key: nextKey.current++, stream: message.stream, text: message.chunk },
]);
} else if (message.type === "status") {
setStatus(message.status);
setExitCode(message.exitCode ?? null);
if (message.status !== "queued" && message.status !== "running") {
toast.info(`Run ${message.status.replace("_", " ")}`);
}
} else if (message.type === "error") {
toast.error(message.message);
}
});
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
}, [lines]);
const isActive = status === "queued" || status === "running";
async function handleCancel() {
cancel();
await fetch(`/api/runs/${runId}/cancel`, { method: "POST" }).catch(
() => {},
);
}
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<RunStatusBadge status={status} />
{exitCode !== null && (
<span className="text-muted-foreground text-xs">
exit code {exitCode}
</span>
)}
</div>
{isActive && (
<Button variant="destructive" size="sm" onClick={handleCancel}>
<Square className="size-3.5" />
Cancel
</Button>
)}
</div>
<div className="max-h-[60vh] overflow-y-auto rounded-lg bg-zinc-950 p-4 font-mono text-xs leading-relaxed text-zinc-100">
{lines.length === 0 ? (
<span className="text-zinc-500">Waiting for output...</span>
) : (
lines.map((line) => (
<div
key={line.key}
className={cn(
"whitespace-pre-wrap break-all",
line.stream === "stderr" && "text-red-400",
)}
>
{line.text}
</div>
))
)}
<div ref={bottomRef} />
</div>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className,
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className,
)}
{...props}
/>
);
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-2 right-2", className)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription, AlertAction };
+52
View File
@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props,
),
render,
state: {
slot: "badge",
variant,
},
});
}
export { Badge, badgeVariants };
+58
View File
@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
+103
View File
@@ -0,0 +1,103 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className,
)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
className,
)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox";
import { cn } from "@/lib/utils";
import { CheckIcon } from "lucide-react";
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };
+193
View File
@@ -0,0 +1,193 @@
"use client";
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk";
import { cn } from "@/lib/utils";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group";
import { SearchIcon, CheckIcon } from "lucide-react";
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
className,
)}
{...props}
/>
);
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
...props
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
children: React.ReactNode;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
className,
)}
showCloseButton={showCloseButton}
>
{children}
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
<InputGroupAddon>
<SearchIcon className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
className,
)}
{...props}
/>
);
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
);
}
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className,
)}
{...props}
>
{children}
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
className,
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};
+159
View File
@@ -0,0 +1,159 @@
"use client";
import * as React from "react";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { XIcon } from "lucide-react";
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className,
)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className,
)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+272
View File
@@ -0,0 +1,272 @@
"use client";
import * as React from "react";
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
import { cn } from "@/lib/utils";
import { ChevronRightIcon, CheckIcon } from "lucide-react";
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn(
"z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
);
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className,
)}
{...props}
/>
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
);
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn(
"w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon />
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon />
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
);
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};
+146
View File
@@ -0,0 +1,146 @@
"use client";
import * as React from "react";
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
const Form = FormProvider;
interface FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> {
name: TName;
}
const FormFieldContext = React.createContext<FormFieldContextValue | null>(
null,
);
function FormField<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>(props: ControllerProps<TFieldValues, TName>) {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
}
const FormItemContext = React.createContext<{ id: string } | null>(null);
function useFormField() {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState } = useFormContext();
const formState = useFormState({ name: fieldContext?.name });
if (!fieldContext || !itemContext)
throw new Error("useFormField must be used within <FormItem>/<FormField>");
const fieldState = getFieldState(fieldContext.name, formState);
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
}
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("flex flex-col gap-1.5", className)}
{...props}
/>
</FormItemContext.Provider>
);
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
const { error, formItemId } = useFormField();
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
);
}
function FormControl({ ...props }: React.ComponentProps<"div">) {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<div
data-slot="form-control"
id={formItemId}
aria-describedby={
!error ? formDescriptionId : `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField();
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-xs", className)}
{...props}
/>
);
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? "") : props.children;
if (!body) return null;
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-xs font-medium", className)}
{...props}
>
{body}
</p>
);
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};
+158
View File
@@ -0,0 +1,158 @@
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className,
)}
{...props}
/>
);
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
},
);
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return;
}
e.currentTarget.parentElement?.querySelector("input")?.focus();
}}
{...props}
/>
);
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
},
);
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset";
}) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
);
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className,
)}
{...props}
/>
);
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className,
)}
{...props}
/>
);
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
};
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react";
import { Input as InputPrimitive } from "@base-ui/react/input";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Input };
+20
View File
@@ -0,0 +1,20 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };
+90
View File
@@ -0,0 +1,90 @@
"use client";
import * as React from "react";
import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
import { cn } from "@/lib/utils";
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
);
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)}
{...props}
/>
);
}
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
return (
<PopoverPrimitive.Title
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
);
}
function PopoverDescription({
className,
...props
}: PopoverPrimitive.Description.Props) {
return (
<PopoverPrimitive.Description
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
);
}
export {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
};
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { Radio as RadioPrimitive } from "@base-ui/react/radio";
import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group";
import { cn } from "@/lib/utils";
function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
return (
<RadioGroupPrimitive
data-slot="radio-group"
className={cn("grid w-full gap-2", className)}
{...props}
/>
);
}
function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
return (
<RadioPrimitive.Root
data-slot="radio-group-item"
className={cn(
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className,
)}
{...props}
>
<RadioPrimitive.Indicator
data-slot="radio-group-indicator"
className="flex size-4 items-center justify-center"
>
<span className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground" />
</RadioPrimitive.Indicator>
</RadioPrimitive.Root>
);
}
export { RadioGroup, RadioGroupItem };
+55
View File
@@ -0,0 +1,55 @@
"use client";
import * as React from "react";
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area";
import { cn } from "@/lib/utils";
function ScrollArea({
className,
children,
...props
}: ScrollAreaPrimitive.Root.Props) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className,
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.Scrollbar>
);
}
export { ScrollArea, ScrollBar };
+202
View File
@@ -0,0 +1,202 @@
"use client";
import * as React from "react";
import { Select as SelectPrimitive } from "@base-ui/react/select";
import { cn } from "@/lib/utils";
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react";
const Select = SelectPrimitive.Root;
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
);
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
);
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn(
"relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpArrow>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownArrow>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
+25
View File
@@ -0,0 +1,25 @@
"use client";
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
import { cn } from "@/lib/utils";
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className,
)}
{...props}
/>
);
}
export { Separator };
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
);
}
export { Skeleton };
+52
View File
@@ -0,0 +1,52 @@
import { Slider as SliderPrimitive } from "@base-ui/react/slider";
import { cn } from "@/lib/utils";
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: SliderPrimitive.Root.Props) {
const _values = Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max];
return (
<SliderPrimitive.Root
className={cn("data-horizontal:w-full data-vertical:h-full", className)}
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
thumbAlignment="edge"
{...props}
>
<SliderPrimitive.Control className="relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col">
<SliderPrimitive.Track
data-slot="slider-track"
className="relative grow overflow-hidden rounded-full bg-muted select-none data-horizontal:h-1 data-horizontal:w-full data-vertical:h-full data-vertical:w-1"
>
<SliderPrimitive.Indicator
data-slot="slider-range"
className="bg-primary select-none data-horizontal:h-full data-vertical:w-full"
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="relative block size-3 shrink-0 rounded-full border border-ring bg-white ring-ring/50 transition-[color,box-shadow] select-none after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Control>
</SliderPrimitive.Root>
);
}
export { Slider };
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
import {
CircleCheckIcon,
InfoIcon,
TriangleAlertIcon,
OctagonXIcon,
Loader2Icon,
} from "lucide-react";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
);
};
export { Toaster };
+32
View File
@@ -0,0 +1,32 @@
"use client";
import { Switch as SwitchPrimitive } from "@base-ui/react/switch";
import { cn } from "@/lib/utils";
function Switch({
className,
size = "default",
...props
}: SwitchPrimitive.Root.Props & {
size?: "sm" | "default";
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
);
}
export { Switch };
+116
View File
@@ -0,0 +1,116 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
);
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
);
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className,
)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className,
)}
{...props}
/>
);
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Textarea };
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
import { cn } from "@/lib/utils";
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
);
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+52
View File
@@ -0,0 +1,52 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { ServerMessage } from "@/lib/ws/protocol";
export function useRunSocket(
runId: string,
onMessage: (message: ServerMessage) => void,
) {
const wsRef = useRef<WebSocket | null>(null);
const [connected, setConnected] = useState(false);
const onMessageRef = useRef(onMessage);
useEffect(() => {
onMessageRef.current = onMessage;
}, [onMessage]);
useEffect(() => {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${protocol}//${window.location.host}/ws/runs`);
wsRef.current = ws;
ws.onopen = () => {
setConnected(true);
ws.send(JSON.stringify({ type: "subscribe", runId }));
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data) as ServerMessage;
if (message.runId === runId) onMessageRef.current(message);
} catch {
// ignore malformed frames
}
};
ws.onclose = () => setConnected(false);
return () => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "unsubscribe", runId }));
}
ws.close();
};
}, [runId]);
const cancel = useCallback(() => {
wsRef.current?.send(JSON.stringify({ type: "cancel", runId }));
}, [runId]);
return { connected, cancel };
}
+73
View File
@@ -0,0 +1,73 @@
import { eq } from "drizzle-orm";
import { getConfig } from "../config/load";
import { getDb } from "../db/client";
import { apiTokens } from "../db/schema";
import { getSession } from "./session";
import { hashToken, verifyTokenHash } from "./tokens";
export interface AuthContext {
authenticated: boolean;
/** display identity: a username, `api-token:<name>`, or "anonymous" when auth is disabled */
identity: string | null;
via: "session" | "token" | "disabled" | null;
}
const anonymous: AuthContext = {
authenticated: true,
identity: "anonymous",
via: "disabled",
};
function checkBearerToken(request: Request): AuthContext | null {
const header = request.headers.get("authorization");
if (!header?.startsWith("Bearer ")) return null;
const token = header.slice("Bearer ".length).trim();
if (!token) return null;
const candidateHash = hashToken(token);
const db = getDb();
const match = db
.select()
.from(apiTokens)
.all()
.find((row) => verifyTokenHash(candidateHash, row.tokenHash));
if (!match) return null;
db.update(apiTokens)
.set({ lastUsedAt: new Date() })
.where(eq(apiTokens.id, match.id))
.run();
return {
authenticated: true,
identity: `api-token:${match.name}`,
via: "token",
};
}
/** Called at the top of every Route Handler as defense-in-depth, independent of `proxy.ts`. */
export async function requireAuth(request?: Request): Promise<AuthContext> {
const { config } = getConfig();
if (!config.auth.enabled) return anonymous;
if (request) {
const tokenResult = checkBearerToken(request);
if (tokenResult) return tokenResult;
}
const session = await getSession();
if (session.userId) {
return {
authenticated: true,
identity: session.username ?? session.userId,
via: "session",
};
}
return { authenticated: false, identity: null, via: null };
}
export function unauthorizedResponse(): Response {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
+16
View File
@@ -0,0 +1,16 @@
import argon2 from "argon2";
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, { type: argon2.argon2id });
}
export async function verifyPassword(
hash: string,
password: string,
): Promise<boolean> {
try {
return await argon2.verify(hash, password);
} catch {
return false;
}
}
+26
View File
@@ -0,0 +1,26 @@
const attempts = new Map<string, { count: number; resetAt: number }>();
const MAX_ATTEMPTS = 10;
const WINDOW_MS = 5 * 60 * 1000;
/** Simple in-memory per-key rate limit; adequate for a local/trusted tool, no external store needed. */
export function isRateLimited(key: string): boolean {
const entry = attempts.get(key);
const now = Date.now();
if (!entry || entry.resetAt < now) return false;
return entry.count >= MAX_ATTEMPTS;
}
export function recordFailedAttempt(key: string) {
const now = Date.now();
const entry = attempts.get(key);
if (!entry || entry.resetAt < now) {
attempts.set(key, { count: 1, resetAt: now + WINDOW_MS });
return;
}
entry.count += 1;
}
export function clearAttempts(key: string) {
attempts.delete(key);
}
+62
View File
@@ -0,0 +1,62 @@
import { cookies } from "next/headers";
import { getIronSession, unsealData, type SessionOptions } from "iron-session";
import { getConfig } from "../config/load";
export interface SessionData {
userId?: string;
username?: string;
}
export const SESSION_COOKIE_NAME = "triggershell_session";
export function getSessionOptions(): SessionOptions {
const { config } = getConfig();
return {
cookieName: SESSION_COOKIE_NAME,
password: config.auth.sessionSecret ?? "",
ttl: Math.round(config.auth.sessionTtlHours * 3600),
cookieOptions: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
},
};
}
/** For use inside Route Handlers / Server Components / Server Actions only. */
export async function getSession() {
return getIronSession<SessionData>(await cookies(), getSessionOptions());
}
/** For contexts outside Next's request pipeline - the raw WS `upgrade` handler in server.ts. */
export async function verifySessionCookieValue(
sealed: string | undefined,
): Promise<SessionData | null> {
if (!sealed) return null;
try {
const options = getSessionOptions();
const data = await unsealData<SessionData>(sealed, {
password: options.password,
ttl: options.ttl,
});
return data.userId ? data : null;
} catch {
return null;
}
}
export function extractCookieValue(
cookieHeader: string | undefined,
name: string,
): string | undefined {
if (!cookieHeader) return undefined;
for (const part of cookieHeader.split(";")) {
const separatorIndex = part.indexOf("=");
if (separatorIndex === -1) continue;
const key = part.slice(0, separatorIndex).trim();
if (key === name)
return decodeURIComponent(part.slice(separatorIndex + 1).trim());
}
return undefined;
}
+56
View File
@@ -0,0 +1,56 @@
import { eq } from "drizzle-orm";
import { getDb } from "../db/client";
import { users, apiTokens } from "../db/schema";
import { getConfig } from "../config/load";
/** Config is the source of truth for who's allowed in; this materializes it into SQLite so the
* runtime auth-check path is uniform and `lastLoginAt`/`lastUsedAt` can be tracked. Called on boot. */
export function syncAuthFromConfig() {
const { config } = getConfig();
if (!config.auth.enabled) return;
const db = getDb();
for (const configUser of config.auth.users) {
const existing = db
.select()
.from(users)
.where(eq(users.username, configUser.username))
.get();
if (existing) {
if (existing.passwordHash !== configUser.passwordHash) {
db.update(users)
.set({ passwordHash: configUser.passwordHash })
.where(eq(users.id, existing.id))
.run();
}
} else {
db.insert(users)
.values({
username: configUser.username,
passwordHash: configUser.passwordHash,
})
.run();
}
}
for (const configToken of config.auth.tokens) {
const existing = db
.select()
.from(apiTokens)
.where(eq(apiTokens.name, configToken.name))
.get();
if (existing) {
if (existing.tokenHash !== configToken.tokenHash) {
db.update(apiTokens)
.set({ tokenHash: configToken.tokenHash })
.where(eq(apiTokens.id, existing.id))
.run();
}
} else {
db.insert(apiTokens)
.values({ name: configToken.name, tokenHash: configToken.tokenHash })
.run();
}
}
}
+20
View File
@@ -0,0 +1,20 @@
import crypto from "node:crypto";
/** Config/DB store `sha256:<hex>` - opaque tokens are never stored in reversible form. */
export function hashToken(token: string): string {
return `sha256:${crypto.createHash("sha256").update(token).digest("hex")}`;
}
export function verifyTokenHash(
candidateHash: string,
storedHash: string,
): boolean {
const a = Buffer.from(candidateHash);
const b = Buffer.from(storedHash);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
export function generateToken(): string {
return crypto.randomBytes(32).toString("hex");
}
+103
View File
@@ -0,0 +1,103 @@
import fs from "node:fs";
import path from "node:path";
import { parse as parseYaml } from "yaml";
import { configSchema, type TriggerShellConfig } from "./schema";
export class ConfigError extends Error {
issues: string[];
constructor(message: string, issues: string[] = []) {
super(message);
this.name = "ConfigError";
this.issues = issues;
}
}
/** Resolves `${VAR}` / `${VAR:-default}` references against process.env. */
function interpolateEnv(raw: string): string {
return raw.replace(
/\$\{([A-Z0-9_]+)(:-([^}]*))?\}/gi,
(_match, name: string, _hasDefault, fallback: string) => {
const value = process.env[name];
if (value !== undefined && value !== "") return value;
if (fallback !== undefined) return fallback;
return "";
},
);
}
export interface LoadedConfig {
config: TriggerShellConfig;
configPath: string;
configDir: string;
dbPath: string;
logsDir: string;
}
export function resolveConfigPath(configPathArg?: string): string {
const candidate =
configPathArg ??
process.env.TRIGGERSHELL_CONFIG_PATH ??
"triggershell.config.yaml";
return path.resolve(/*turbopackIgnore: true*/ candidate);
}
export function loadConfig(configPathArg?: string): LoadedConfig {
const configPath = resolveConfigPath(configPathArg);
// This path is resolved at runtime from a user-supplied config location, never known at build
// time - see the `--skip-build`/tracing note in docs/ARCHITECTURE.md.
if (!fs.existsSync(/*turbopackIgnore: true*/ configPath)) {
throw new ConfigError(`Config file not found at ${configPath}`);
}
const raw = fs.readFileSync(/*turbopackIgnore: true*/ configPath, "utf-8");
const interpolated = interpolateEnv(raw);
let parsedYaml: unknown;
try {
parsedYaml = parseYaml(interpolated);
} catch (error) {
throw new ConfigError(`Failed to parse YAML: ${(error as Error).message}`);
}
const result = configSchema.safeParse(parsedYaml);
if (!result.success) {
const issues = result.error.issues.map(
(issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`,
);
throw new ConfigError("Config validation failed", issues);
}
const configDir = path.dirname(configPath);
const config = result.data;
return {
config,
configPath,
configDir,
dbPath: path.resolve(configDir, config.database.path),
logsDir: path.resolve(configDir, config.logs.dir),
};
}
declare global {
var __triggershellConfig: LoadedConfig | 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, so a plain
// module-level singleton would reparse the config file a second time instead of reusing one.
/** Loads once per process and caches the result; the custom server restarts the process on config edits. */
export function getConfig(): LoadedConfig {
if (!globalThis.__triggershellConfig) {
globalThis.__triggershellConfig = loadConfig();
}
return globalThis.__triggershellConfig;
}
export function getScript(
scriptId: string,
): TriggerShellConfig["scripts"][number] | undefined {
return getConfig().config.scripts.find((script) => script.id === scriptId);
}
+271
View File
@@ -0,0 +1,271 @@
import { z } from "zod";
const identifier = z
.string()
.min(1)
.regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, {
message:
"must start with an alphanumeric character and contain only letters, numbers, - and _",
});
const controlSchema = z.enum([
"text",
"textarea",
"password",
"number",
"slider",
"checkbox",
"switch",
"select",
"radio",
"multiselect",
"checkboxGroup",
]);
export type ControlType = z.infer<typeof controlSchema>;
const baseVariable = z.object({
name: identifier,
label: z.string().min(1).optional(),
description: z.string().optional(),
required: z.boolean().default(false),
secret: z.boolean().default(false),
control: controlSchema.optional(),
passAs: z.enum(["arg", "flag", "env", "stdin"]).default("arg"),
argName: z.string().optional(),
envName: z.string().optional(),
joinWith: z.string().default(","),
});
const stringVariable = baseVariable.extend({
type: z.literal("string"),
default: z.string().optional(),
pattern: z.string().optional(),
minLength: z.number().int().nonnegative().optional(),
maxLength: z.number().int().nonnegative().optional(),
multiline: z.boolean().default(false),
});
const numberVariable = baseVariable.extend({
type: z.literal("number"),
default: z.number().optional(),
min: z.number().optional(),
max: z.number().optional(),
step: z.number().positive().optional(),
});
const booleanVariable = baseVariable.extend({
type: z.literal("boolean"),
default: z.boolean().default(false),
});
const enumVariable = baseVariable.extend({
type: z.literal("enum"),
default: z.string().optional(),
choices: z.array(z.string().min(1)).min(1),
});
const multiselectVariable = baseVariable.extend({
type: z.literal("multiselect"),
default: z.array(z.string()).default([]),
choices: z.array(z.string().min(1)).min(1),
});
export const variableSchema = z.discriminatedUnion("type", [
stringVariable,
numberVariable,
booleanVariable,
enumVariable,
multiselectVariable,
]);
export type VariableConfig = z.infer<typeof variableSchema>;
const variableWithChecks = variableSchema.superRefine((variable, ctx) => {
if (variable.type === "string" && variable.pattern) {
try {
new RegExp(variable.pattern);
} catch {
ctx.addIssue({
code: "custom",
message: `invalid regular expression: ${variable.pattern}`,
path: ["pattern"],
});
}
}
if (
variable.type === "number" &&
variable.min !== undefined &&
variable.max !== undefined
) {
if (variable.min > variable.max) {
ctx.addIssue({
code: "custom",
message: "min must be <= max",
path: ["min"],
});
}
}
if (variable.control === "slider" && variable.type !== "number") {
ctx.addIssue({
code: "custom",
message: "control 'slider' requires type 'number'",
path: ["control"],
});
}
if (
variable.control === "slider" &&
variable.type === "number" &&
(variable.min === undefined || variable.max === undefined)
) {
ctx.addIssue({
code: "custom",
message: "control 'slider' requires both min and max",
path: ["control"],
});
}
if (variable.passAs === "arg" && !variable.argName) {
ctx.addIssue({
code: "custom",
message: "passAs 'arg' requires argName",
path: ["argName"],
});
}
if (variable.passAs === "flag" && !variable.argName) {
ctx.addIssue({
code: "custom",
message: "passAs 'flag' requires argName",
path: ["argName"],
});
}
if (variable.passAs === "flag" && variable.type !== "boolean") {
ctx.addIssue({
code: "custom",
message: "passAs 'flag' requires type 'boolean'",
path: ["passAs"],
});
}
if (variable.passAs === "env" && !variable.envName) {
ctx.addIssue({
code: "custom",
message: "passAs 'env' requires envName",
path: ["envName"],
});
}
if (variable.secret && variable.type !== "string") {
ctx.addIssue({
code: "custom",
message: "secret variables must be of type 'string'",
path: ["secret"],
});
}
});
const scriptSchema = z
.object({
id: identifier,
name: z.string().min(1),
description: z.string().optional(),
command: z.string().min(1),
args: z.array(z.string()).default([]),
cwd: z.string().default("./"),
shell: z.boolean().default(false),
timeoutSeconds: z.number().int().positive().max(86400).default(1800),
variables: z.array(variableWithChecks).default([]),
})
.superRefine((script, ctx) => {
const seen = new Set<string>();
for (const [index, variable] of script.variables.entries()) {
if (seen.has(variable.name)) {
ctx.addIssue({
code: "custom",
message: `duplicate variable name '${variable.name}' in script '${script.id}'`,
path: ["variables", index, "name"],
});
}
seen.add(variable.name);
}
});
export type ScriptConfig = z.infer<typeof scriptSchema>;
const userSchema = z.object({
username: identifier,
passwordHash: z.string().min(1),
});
const tokenSchema = z.object({
name: identifier,
tokenHash: z.string().min(1),
});
const authSchema = z
.object({
enabled: z.boolean().default(true),
sessionSecret: z.string().optional(),
sessionTtlHours: z.number().positive().default(12),
users: z.array(userSchema).default([]),
tokens: z.array(tokenSchema).default([]),
})
.superRefine((auth, ctx) => {
if (auth.enabled) {
if (!auth.sessionSecret || auth.sessionSecret.length < 32) {
ctx.addIssue({
code: "custom",
message:
"auth.sessionSecret must be set and at least 32 characters when auth is enabled",
path: ["sessionSecret"],
});
}
if (auth.users.length === 0 && auth.tokens.length === 0) {
ctx.addIssue({
code: "custom",
message: "auth.enabled is true but no users or tokens are configured",
path: ["users"],
});
}
}
});
const serverSchema = z.object({
host: z.string().default("127.0.0.1"),
port: z.number().int().positive().max(65535).default(4173),
basePath: z.string().default(""),
});
const databaseSchema = z.object({
path: z.string().default(".triggershell/triggershell.db"),
});
const logsSchema = z.object({
dir: z.string().default(".triggershell/logs"),
retentionDays: z.number().int().positive().default(30),
});
export const configSchema = z
.object({
server: serverSchema.prefault({}),
auth: authSchema.prefault({}),
database: databaseSchema.prefault({}),
logs: logsSchema.prefault({}),
scripts: z.array(scriptSchema).default([]),
})
.superRefine((config, ctx) => {
const seen = new Set<string>();
for (const [index, script] of config.scripts.entries()) {
if (seen.has(script.id)) {
ctx.addIssue({
code: "custom",
message: `duplicate script id '${script.id}'`,
path: ["scripts", index, "id"],
});
}
seen.add(script.id);
}
});
export type TriggerShellConfig = z.infer<typeof configSchema>;
+32
View File
@@ -0,0 +1,32 @@
import type { ScriptConfig } from "./schema";
import { resolveControl } from "./ui-control-map";
/** Client-safe view of a script: strips defaults for `secret` variables so nothing sensitive
* ever reaches the browser, and resolves the effective UI control for each variable. */
export function serializeScriptForClient(script: ScriptConfig) {
return {
id: script.id,
name: script.name,
description: script.description ?? null,
variables: script.variables.map((variable) => {
const control = resolveControl(variable);
// `secret` is only ever true for the string variant (enforced by the config schema), so this
// narrows `variable` and keeps `default` type-correct for every other variant.
if (variable.type === "string" && variable.secret) {
return { ...variable, default: undefined, control };
}
return { ...variable, control };
}),
};
}
export type ClientScript = ReturnType<typeof serializeScriptForClient>;
export type ClientVariable = ClientScript["variables"][number];
export function serializeScriptSummary(script: ScriptConfig) {
return {
id: script.id,
name: script.name,
description: script.description ?? null,
};
}
+21
View File
@@ -0,0 +1,21 @@
import type { ControlType, VariableConfig } from "./schema";
/** Resolves the effective shadcn control for a variable: explicit `control` wins, otherwise a type-based default. */
export function resolveControl(variable: VariableConfig): ControlType {
if (variable.control) return variable.control;
switch (variable.type) {
case "string":
if (variable.secret) return "password";
if (variable.multiline) return "textarea";
return "text";
case "number":
return "number";
case "boolean":
return "checkbox";
case "enum":
return "select";
case "multiselect":
return "multiselect";
}
}
+40
View File
@@ -0,0 +1,40 @@
import fs from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import * as schema from "./schema";
import { getConfig } from "../config/load";
type Db = ReturnType<typeof drizzle<typeof schema>>;
declare global {
var __triggershellDb: Db | 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, so a plain
// module-level singleton would open a second, wasteful SQLite connection instead of reusing one.
export function getDb(): Db {
if (globalThis.__triggershellDb) return globalThis.__triggershellDb;
const { dbPath } = getConfig();
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
const sqlite = new Database(dbPath);
sqlite.pragma("journal_mode = WAL");
sqlite.pragma("foreign_keys = ON");
globalThis.__triggershellDb = drizzle(sqlite, { schema });
return globalThis.__triggershellDb;
}
/** Applies committed migrations; safe to call on every boot. */
export function migrateOnBoot() {
const db = getDb();
const migrationsFolder = path.resolve(
import.meta.dirname,
"../../../drizzle",
);
migrate(db, { migrationsFolder });
}
+74
View File
@@ -0,0 +1,74 @@
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
export const users = sqliteTable("users", {
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
username: text("username").notNull().unique(),
passwordHash: text("password_hash").notNull(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
lastLoginAt: integer("last_login_at", { mode: "timestamp" }),
});
export const runStatusValues = [
"queued",
"running",
"succeeded",
"failed",
"cancelled",
"timed_out",
"interrupted",
] as const;
export type RunStatus = (typeof runStatusValues)[number];
export const runs = sqliteTable(
"runs",
{
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
scriptId: text("script_id").notNull(),
scriptName: text("script_name").notNull(),
status: text("status", { enum: runStatusValues })
.notNull()
.default("queued"),
variables: text("variables", { mode: "json" })
.notNull()
.$type<Record<string, unknown>>(),
resolvedCommand: text("resolved_command").notNull(),
pid: integer("pid"),
exitCode: integer("exit_code"),
startedAt: integer("started_at", { mode: "timestamp" }),
endedAt: integer("ended_at", { mode: "timestamp" }),
timeoutSeconds: integer("timeout_seconds"),
triggeredBy: text("triggered_by").notNull(),
logFilePath: text("log_file_path").notNull(),
errorMessage: text("error_message"),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
},
(table) => [
index("runs_script_id_idx").on(table.scriptId),
index("runs_status_idx").on(table.status),
],
);
export const apiTokens = sqliteTable("api_tokens", {
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: text("name").notNull().unique(),
tokenHash: text("token_hash").notNull(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
lastUsedAt: integer("last_used_at", { mode: "timestamp" }),
});
export type Run = typeof runs.$inferSelect;
export type NewRun = typeof runs.$inferInsert;
export type User = typeof users.$inferSelect;
export type ApiToken = typeof apiTokens.$inferSelect;
+80
View File
@@ -0,0 +1,80 @@
import type { ScriptConfig } from "../config/schema";
export interface Invocation {
argv: string[];
env: Record<string, string>;
stdin?: string;
/** variable values with `secret: true` fields replaced, safe to persist/display */
redactedVariables: Record<string, unknown>;
/** human-readable command line with secrets redacted, safe to persist/display */
redactedCommandLine: string;
}
const REDACTED = "***";
function stringifyValue(value: unknown, joinWith: string): string {
if (Array.isArray(value)) return value.join(joinWith);
if (typeof value === "boolean") return String(value);
return String(value);
}
/** Builds an argv-array invocation from validated variable values. Never produces a shell string. */
export function buildInvocation(
script: ScriptConfig,
values: Record<string, unknown>,
): Invocation {
const argv = [...script.args];
const env: Record<string, string> = {};
const redactedArgv = [...script.args];
const redactedVariables: Record<string, unknown> = {};
for (const variable of script.variables) {
const raw = values[variable.name] ?? variable.default;
if (raw === undefined || raw === null || raw === "") {
redactedVariables[variable.name] = raw;
continue;
}
redactedVariables[variable.name] = variable.secret ? REDACTED : raw;
switch (variable.passAs) {
case "arg": {
const argName = variable.argName!;
const value = stringifyValue(raw, variable.joinWith);
argv.push(argName, value);
redactedArgv.push(argName, variable.secret ? REDACTED : value);
break;
}
case "flag": {
if (raw === true) {
argv.push(variable.argName!);
redactedArgv.push(variable.argName!);
}
break;
}
case "env": {
const value = stringifyValue(raw, variable.joinWith);
env[variable.envName!] = value;
break;
}
case "stdin": {
// handled by caller via the returned `stdin` field; only one stdin variable is meaningful
break;
}
}
}
const stdinVariable = script.variables.find(
(variable) => variable.passAs === "stdin",
);
const stdin = stdinVariable
? stringifyValue(
values[stdinVariable.name] ?? stdinVariable.default,
stdinVariable.joinWith,
)
: undefined;
const redactedCommandLine = [script.command, ...redactedArgv].join(" ");
return { argv, env, stdin, redactedVariables, redactedCommandLine };
}
+172
View File
@@ -0,0 +1,172 @@
import fs from "node:fs";
import path from "node:path";
import { execa } from "execa";
import { eq } from "drizzle-orm";
import { getDb } from "../db/client";
import { runs, type RunStatus } from "../db/schema";
import { getConfig, getScript } from "../config/load";
import type { ScriptConfig } from "../config/schema";
import { buildInvocation, type Invocation } from "./build-args";
import { registerRun, unregisterRun } from "./registry";
import { emitRunMessage } from "./events";
export class ScriptNotFoundError extends Error {}
interface StartRunOptions {
scriptId: string;
variables: Record<string, unknown>;
triggeredBy: string;
}
export async function startRun({
scriptId,
variables,
triggeredBy,
}: StartRunOptions): Promise<string> {
const script = getScript(scriptId);
if (!script) throw new ScriptNotFoundError(`Unknown script '${scriptId}'`);
const { logsDir } = getConfig();
fs.mkdirSync(logsDir, { recursive: true });
const invocation = buildInvocation(script, variables);
const db = getDb();
const runId = crypto.randomUUID();
const logFilePath = path.join(logsDir, `${runId}.log`);
db.insert(runs)
.values({
id: runId,
scriptId: script.id,
scriptName: script.name,
status: "queued",
variables: invocation.redactedVariables,
resolvedCommand: invocation.redactedCommandLine,
timeoutSeconds: script.timeoutSeconds,
triggeredBy,
logFilePath,
})
.run();
// Fire and forget - the caller gets the runId immediately, progress streams over WS/polling.
void executeRun(runId, script, invocation, logFilePath).catch((error) => {
console.error(`[runner] unhandled error executing run ${runId}:`, error);
});
return runId;
}
async function executeRun(
runId: string,
script: ScriptConfig,
invocation: Invocation,
logFilePath: string,
) {
const db = getDb();
const logStream = fs.createWriteStream(logFilePath, { flags: "a" });
const setStatus = (
status: RunStatus,
extra: Partial<typeof runs.$inferInsert> = {},
) => {
db.update(runs)
.set({ status, ...extra })
.where(eq(runs.id, runId))
.run();
emitRunMessage({
type: "status",
runId,
status,
exitCode: (extra.exitCode as number | null | undefined) ?? null,
ts: Date.now(),
});
};
const { configDir } = getConfig();
const cwd = path.resolve(configDir, script.cwd);
const controller = new AbortController();
let seq = 0;
const onChunk = (stream: "stdout" | "stderr") => (data: Buffer) => {
const chunk = data.toString("utf-8");
logStream.write(chunk);
emitRunMessage({
type: "output",
runId,
stream,
chunk,
seq: seq++,
ts: Date.now(),
});
};
registerRun({ runId, scriptId: script.id, controller });
setStatus("running", { startedAt: new Date() });
try {
const subprocess = execa(script.command, invocation.argv, {
cwd,
env: { ...process.env, ...invocation.env },
timeout: script.timeoutSeconds * 1000,
cancelSignal: controller.signal,
reject: false,
shell: script.shell,
input: invocation.stdin,
buffer: false,
});
subprocess.stdout?.on("data", onChunk("stdout"));
subprocess.stderr?.on("data", onChunk("stderr"));
const result = await subprocess;
let status: RunStatus;
if (result.isCanceled) status = "cancelled";
else if (result.timedOut) status = "timed_out";
else if (result.failed) status = "failed";
else status = "succeeded";
setStatus(status, {
exitCode: result.exitCode ?? null,
endedAt: new Date(),
errorMessage:
status === "failed" || status === "timed_out"
? (result.shortMessage ?? null)
: null,
});
} catch (error) {
setStatus("failed", {
endedAt: new Date(),
errorMessage: (error as Error).message,
});
} finally {
logStream.end();
unregisterRun(runId);
}
}
/** On boot, any DB row still `running`/`queued` has no live handle in this process - mark it interrupted
* rather than pretending we can resume streaming its output. */
export function reconcileOrphanedRuns() {
const db = getDb();
const now = new Date();
const orphaned = db
.update(runs)
.set({
status: "interrupted",
endedAt: now,
errorMessage: "Server restarted while this run was in progress.",
})
.where(eq(runs.status, "running"))
.run();
db.update(runs)
.set({
status: "interrupted",
endedAt: now,
errorMessage: "Server restarted before this run could start.",
})
.where(eq(runs.status, "queued"))
.run();
return orphaned;
}
+18
View File
@@ -0,0 +1,18 @@
import { EventEmitter } from "node:events";
import type { ServerMessage } from "../ws/protocol";
declare global {
var __triggershellRunEvents: EventEmitter | undefined;
}
/** Decouples the run engine from the WS transport: the engine emits, ws/server.ts broadcasts.
* 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
* singleton would silently end up duplicated (one copy per graph) instead of shared. */
export const runEvents: EventEmitter = globalThis.__triggershellRunEvents ?? new EventEmitter();
globalThis.__triggershellRunEvents = runEvents;
runEvents.setMaxListeners(0);
export function emitRunMessage(message: ServerMessage) {
runEvents.emit("message", message);
}
+47
View File
@@ -0,0 +1,47 @@
interface RunHandle {
runId: string;
scriptId: string;
controller: AbortController;
pid?: number;
}
declare global {
var __triggershellRunHandles: Map<string, RunHandle> | undefined;
}
/** In-memory only - live run handles cannot survive a process restart; see `reconcileOrphanedRuns`.
* 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
* what `server.ts` requires directly). */
const handles: Map<string, RunHandle> = globalThis.__triggershellRunHandles ?? new Map();
globalThis.__triggershellRunHandles = handles;
export function registerRun(handle: RunHandle) {
handles.set(handle.runId, handle);
}
export function unregisterRun(runId: string) {
handles.delete(runId);
}
export function getRunHandle(runId: string): RunHandle | undefined {
return handles.get(runId);
}
export function isRunLive(runId: string): boolean {
return handles.has(runId);
}
/** Requests cancellation; returns false if the run isn't tracked (already finished or not this process). */
export function cancelRun(runId: string): boolean {
const handle = handles.get(runId);
if (!handle) return false;
handle.controller.abort();
return true;
}
export function killAllRuns() {
for (const handle of handles.values()) {
handle.controller.abort();
}
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+57
View File
@@ -0,0 +1,57 @@
import { z } from "zod";
import type { ScriptConfig, VariableConfig } from "../config/schema";
function fieldSchema(variable: VariableConfig): z.ZodTypeAny {
let field: z.ZodTypeAny;
switch (variable.type) {
case "string": {
let s = z.string();
if (variable.minLength !== undefined) s = s.min(variable.minLength);
if (variable.maxLength !== undefined) s = s.max(variable.maxLength);
if (variable.pattern) s = s.regex(new RegExp(variable.pattern));
field = s;
break;
}
case "number": {
let n = z.number();
if (variable.min !== undefined) n = n.min(variable.min);
if (variable.max !== undefined) n = n.max(variable.max);
field = n;
break;
}
case "boolean":
field = z.boolean();
break;
case "enum":
field = z.enum(variable.choices as [string, ...string[]]);
break;
case "multiselect":
field = z.array(z.enum(variable.choices as [string, ...string[]]));
break;
}
if (!variable.required) {
field = field.optional().or(z.literal(""));
}
return field;
}
/** Builds one Zod object schema from a list of variable definitions - the single source of truth
* imported by both the client form resolver and the server-side run-creation handler. */
export function buildVariableSchemaFromList(
variables: readonly VariableConfig[],
) {
const shape: Record<string, z.ZodTypeAny> = {};
for (const variable of variables) {
shape[variable.name] = fieldSchema(variable);
}
return z.object(shape);
}
export function buildVariableSchema(script: ScriptConfig) {
return buildVariableSchemaFromList(script.variables);
}
export type VariableValues = Record<string, unknown>;
+35
View File
@@ -0,0 +1,35 @@
import type { RunStatus } from "../db/schema";
export type ClientMessage =
| { type: "subscribe"; runId: string }
| { type: "unsubscribe"; runId: string }
| { type: "cancel"; runId: string };
export type ServerMessage =
| {
type: "output";
runId: string;
stream: "stdout" | "stderr";
chunk: string;
seq: number;
ts: number;
}
| {
type: "status";
runId: string;
status: RunStatus;
exitCode?: number | null;
ts: number;
}
| { type: "error"; runId: string; message: string };
export function isClientMessage(value: unknown): value is ClientMessage {
if (typeof value !== "object" || value === null) return false;
const message = value as Record<string, unknown>;
return (
(message.type === "subscribe" ||
message.type === "unsubscribe" ||
message.type === "cancel") &&
typeof message.runId === "string"
);
}
+96
View File
@@ -0,0 +1,96 @@
import type { IncomingMessage } from "node:http";
import { WebSocketServer, WebSocket } from "ws";
import { getConfig } from "../config/load";
import {
extractCookieValue,
verifySessionCookieValue,
SESSION_COOKIE_NAME,
} from "../auth/session";
import { hashToken, verifyTokenHash } from "../auth/tokens";
import { getDb } from "../db/client";
import { apiTokens } from "../db/schema";
import { runEvents } from "../runner/events";
import { cancelRun } from "../runner/registry";
import { isClientMessage, type ServerMessage } from "./protocol";
const subscriptions = new Map<string, Set<WebSocket>>();
function subscribe(runId: string, ws: WebSocket) {
let set = subscriptions.get(runId);
if (!set) {
set = new Set();
subscriptions.set(runId, set);
}
set.add(ws);
}
function unsubscribe(runId: string, ws: WebSocket) {
subscriptions.get(runId)?.delete(ws);
}
function unsubscribeAll(ws: WebSocket) {
for (const set of subscriptions.values()) set.delete(ws);
}
runEvents.on("message", (message: ServerMessage) => {
const set = subscriptions.get(message.runId);
if (!set || set.size === 0) return;
const payload = JSON.stringify(message);
for (const ws of set) {
if (ws.readyState === WebSocket.OPEN) ws.send(payload);
}
});
/** Authenticates the WS upgrade outside Next's normal request pipeline: session cookie first,
* falling back to a `?token=` bearer-style query param for non-browser clients. */
export async function authenticateUpgrade(
req: IncomingMessage,
): Promise<boolean> {
const { config } = getConfig();
if (!config.auth.enabled) return true;
const url = new URL(req.url ?? "/", "http://internal");
const tokenParam = url.searchParams.get("token");
if (tokenParam) {
const candidateHash = hashToken(tokenParam);
const db = getDb();
const match = db
.select()
.from(apiTokens)
.all()
.some((row) => verifyTokenHash(candidateHash, row.tokenHash));
if (match) return true;
}
const sealed = extractCookieValue(req.headers.cookie, SESSION_COOKIE_NAME);
const session = await verifySessionCookieValue(sealed);
return Boolean(session?.userId);
}
export function attachWsServer(wss: WebSocketServer) {
wss.on("connection", (ws: WebSocket) => {
ws.on("message", (raw) => {
let parsed: unknown;
try {
parsed = JSON.parse(raw.toString());
} catch {
return;
}
if (!isClientMessage(parsed)) return;
switch (parsed.type) {
case "subscribe":
subscribe(parsed.runId, ws);
break;
case "unsubscribe":
unsubscribe(parsed.runId, ws);
break;
case "cancel":
cancelRun(parsed.runId);
break;
}
});
ws.on("close", () => unsubscribeAll(ws));
});
}
+54
View File
@@ -0,0 +1,54 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { getConfig } from "@/lib/config/load";
import { verifySessionCookieValue } from "@/lib/auth/session";
const PUBLIC_PATHS = [
"/login",
"/api/auth/login",
"/api/auth/session",
"/api/healthz",
];
/** Optimistic (cookie-only) check - centralizes redirect logic per the Next.js Proxy guidance.
* Every Route Handler also calls `requireAuth()` itself as the real, defense-in-depth check. */
export async function proxy(request: NextRequest) {
const { config } = getConfig();
if (!config.auth.enabled) return NextResponse.next();
const { pathname } = request.nextUrl;
if (
PUBLIC_PATHS.some(
(path) => pathname === path || pathname.startsWith("/_next"),
)
) {
return NextResponse.next();
}
const isApiRoute = pathname.startsWith("/api/");
if (isApiRoute) {
// Token-authed API clients won't have a session cookie; let the route handler's requireAuth()
// do the real check instead of rejecting here.
if (request.headers.get("authorization")?.startsWith("Bearer "))
return NextResponse.next();
}
const sealed = request.cookies.get("triggershell_session")?.value;
const session = await verifySessionCookieValue(sealed);
if (!session?.userId) {
if (isApiRoute) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("next", pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}

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