Replace the Python CLI with a Node CLI, add a systemd service command

The app is already 100% Node, so the Python launcher was pure overhead - it
existed mainly to bootstrap Node, which is circular. The CLI is now merged
into app/ (the single published npm package): `triggershell start` validates
the config and imports server.ts directly in-process, so server.ts's own
SIGTERM/SIGINT handling just works with no signal-relay/child-process layer
needed. `dev` is dropped from the public CLI (contributors use `pnpm --dir
app dev` directly); there's no `build` command either, since the package
ships a prebuilt `.next` via a `prepack` hook. Adds `triggershell service
install|uninstall|status` for running as a per-user or system systemd unit.

Also fixes two bugs found while wiring this up: server.ts resolved `.next`
relative to `process.cwd()`, which broke once the CLI could run from a
directory other than the app itself; and an explicitly-`files`-listed
package directory bypasses .npmignore for its subpaths, so `.next/cache`
was inflating the npm tarball to ~670MB (now stripped in `prepack`, ~7MB).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 11:03:25 +02:00
co-authored by Claude Sonnet 5
parent e2b6c6102c
commit 30350d80f4
40 changed files with 1154 additions and 1071 deletions
+1 -13
View File
@@ -1,16 +1,4 @@
# Python # Runtime data (created by `triggershell start` in whatever directory the config lives in)
__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/ .triggershell/
# Secrets loaded by `triggershell` via ${VAR} interpolation - never commit these # Secrets loaded by `triggershell` via ${VAR} interpolation - never commit these
+21 -14
View File
@@ -24,20 +24,22 @@ API for automation.
## Quickstart ## Quickstart
```bash ```bash
pip install triggershell # or: pip install -e . from a checkout npm install -g triggershell # or: npx triggershell <command> for one-off use
triggershell init # scaffold triggershell.yml (+ .env for secrets) in the current directory triggershell init # scaffold triggershell.yml (+ .env for secrets) in the current directory
triggershell users add admin # create a login (skip if you set auth.enabled: false) triggershell users add admin # create a login (skip if you set auth.enabled: false)
triggershell dev # start in dev mode and open the browser triggershell start # start the app and open the browser
``` ```
Edit `triggershell.yml` to add your own scripts (see [Configuration](#configuration) below), Edit `triggershell.yml` to add your own scripts (see [Configuration](#configuration) below), then
then run `triggershell start` for a production build. re-run `triggershell start`.
> The `triggershell` package isn't published to npm yet. Until it is, build and link a local copy
> instead: `pnpm --dir app install && pnpm --dir app build && pnpm --dir app link --global`.
## Requirements ## Requirements
- Python >= 3.9 - Node.js >= 20 — the only thing you need installed. Everything else `triggershell` needs ships
- Node.js >= 20 (checked by the CLI; not auto-installed) inside the package itself and is resolved automatically when you install it.
- pnpm (auto-provisioned via Corepack if missing and Corepack is available)
## Configuration ## Configuration
@@ -100,12 +102,14 @@ the script — always as a discrete argv element or env var, never interpolated
| Command | Description | | Command | Description |
|---|---| |---|---|
| `triggershell init [PATH]` | Scaffold a new config file + `.env` (`--port`, `--auth/--no-auth`, `--force`) | | `triggershell init [PATH]` | Scaffold a new config file + `.env` (`--port`, `--auth/--no-auth`, `--force`) |
| `triggershell validate [-c CONFIG]` | Validate a config file (fast Python pre-flight + full Node/Zod schema) | | `triggershell validate [-c CONFIG]` | Validate a config file against the full schema |
| `triggershell dev [-c CONFIG] [--port] [--host] [--no-browser]` | Run in development mode (hot reload) | | `triggershell start [-c CONFIG] [--port] [--host] [--no-browser]` | Run the web app |
| `triggershell start [-c CONFIG] [--port] [--host] [--no-browser] [--skip-build]` | Build (if stale) and run in production mode | | `triggershell doctor [-c CONFIG]` | Print environment/config diagnostics |
| `triggershell doctor` | Print environment/config diagnostics |
| `triggershell users add <username> [-c CONFIG] [--inline]` | Hash a password, store it in `.env`, and print a `${VAR}` snippet for `auth.users` (`--inline` prints the raw hash instead) | | `triggershell users add <username> [-c CONFIG] [--inline]` | Hash a password, store it in `.env`, and print a `${VAR}` snippet for `auth.users` (`--inline` prints the raw hash instead) |
| `triggershell users add-token <name> [-c CONFIG] [--inline]` | Generate an API token, store its hash in `.env`, and print a `${VAR}` snippet for `auth.tokens` (`--inline` prints the raw hash instead) | | `triggershell users add-token <name> [-c CONFIG] [--inline]` | Generate an API token, store its hash in `.env`, and print a `${VAR}` snippet for `auth.tokens` (`--inline` prints the raw hash instead) |
| `triggershell service install [--system]` | Install a systemd unit that runs `triggershell start` (per-user by default, Linux only) |
| `triggershell service uninstall [--system]` | Stop, disable, and remove the systemd unit |
| `triggershell service status [--system]` | Show the systemd unit's status |
## Web App Guide ## Web App Guide
@@ -145,19 +149,22 @@ Full reference with request/response shapes and curl examples: [`docs/API.md`](d
## Development ## Development
This is the workflow for working on TriggerShell itself, not for installing/running it — it
bypasses the CLI entirely and talks to `app/`'s own scripts directly, with hot reload:
```bash ```bash
pnpm --dir app install pnpm --dir app install
pnpm --dir app dev # or: triggershell dev, which wraps this pnpm --dir app dev # tsx watch server.ts - reads TRIGGERSHELL_CONFIG_PATH from the environment
pnpm --dir app lint pnpm --dir app lint
pnpm --dir app typecheck pnpm --dir app typecheck
pnpm --dir app test # CLI unit tests (src/cli/**/*.test.ts)
pnpm --dir app db:studio # browse the SQLite DB pnpm --dir app db:studio # browse the SQLite DB
``` ```
Repo layout: Repo layout:
``` ```
triggershell/ Python CLI (launcher/orchestrator only) app/ The published npm package: Next.js app + the `triggershell` CLI (bin/, src/cli/) in one
app/ Next.js app - all server logic (API, auth, script execution) lives here
examples/ A runnable example config + scripts examples/ A runnable example config + scripts
docs/ Config/architecture/API reference docs docs/ Config/architecture/API reference docs
``` ```
+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.
+8 -3
View File
@@ -1,8 +1,9 @@
# TriggerShell web app # TriggerShell web app
This is the Next.js app that TriggerShell's Python CLI (`triggershell dev` / `triggershell start`) This directory is both the Next.js app and the home of the `triggershell` CLI (`bin/triggershell.js`
launches — it's not meant to be run standalone with `next dev`/`next start` since it needs a `src/cli`) that launches it — together they're published as one npm package. The app is still not
custom server (`server.ts`) for the WebSocket endpoint. meant to be run standalone with `next dev`/`next start`, since it needs a custom server (`server.ts`)
for the WebSocket endpoint; use the CLI (`triggershell start`) or the `pnpm` scripts below instead.
See the [repo root README](../README.md) for how to run TriggerShell end-to-end, and 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. [`../docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) for how this app is put together.
@@ -12,5 +13,9 @@ pnpm install
pnpm dev # tsx watch server.ts - reads TRIGGERSHELL_CONFIG_PATH from the environment pnpm dev # tsx watch server.ts - reads TRIGGERSHELL_CONFIG_PATH from the environment
pnpm lint pnpm lint
pnpm typecheck pnpm typecheck
pnpm test # CLI unit tests (src/cli/**/*.test.ts)
pnpm db:studio # browse the SQLite database pnpm db:studio # browse the SQLite database
``` ```
Note: this is the contributor workflow for developing TriggerShell itself. End users install and
run the published `triggershell` CLI instead (see the root README).
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env node
import { register } from "tsx/esm/api";
register();
await import("../src/cli/index.ts");
+26 -5
View File
@@ -1,13 +1,31 @@
{ {
"name": "app", "name": "triggershell",
"version": "0.1.0", "version": "0.1.0",
"private": true, "license": "MIT",
"type": "module", "type": "module",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://dev.pivoine.art/valknar/triggershell.git", "url": "https://dev.pivoine.art/valknar/triggershell.git",
"directory": "app" "directory": "app"
}, },
"bin": {
"triggershell": "bin/triggershell.js"
},
"engines": {
"node": ">=20"
},
"files": [
"bin",
"src",
"templates",
"drizzle",
".next",
"server.ts",
"next.config.ts",
"next-env.d.ts",
"tsconfig.json",
"LICENSE"
],
"scripts": { "scripts": {
"dev": "tsx watch server.ts", "dev": "tsx watch server.ts",
"build": "next build", "build": "next build",
@@ -15,13 +33,15 @@
"lint": "eslint", "lint": "eslint",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"format": "prettier --write .", "format": "prettier --write .",
"validate-config": "tsx scripts/validate-config.ts", "test": "tsx --test \"src/cli/**/*.test.ts\"",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:studio": "drizzle-kit studio" "db:studio": "drizzle-kit studio",
"prepack": "rm -rf .next && next build && rm -rf .next/cache"
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.7.0", "@base-ui/react": "^1.7.0",
"@hookform/resolvers": "^5.8.0", "@hookform/resolvers": "^5.8.0",
"@inquirer/prompts": "^8.5.2",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"argon2": "^0.45.1", "argon2": "^0.45.1",
@@ -29,6 +49,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"commander": "^15.0.0",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"execa": "^10.0.1", "execa": "^10.0.1",
"iron-session": "^8.0.4", "iron-session": "^8.0.4",
@@ -43,6 +64,7 @@
"shadcn": "^4.18.0", "shadcn": "^4.18.0",
"sonner": "^2.0.8", "sonner": "^2.0.8",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tsx": "^4.23.12",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"ws": "^8.21.3", "ws": "^8.21.3",
"yaml": "^2.9.0", "yaml": "^2.9.0",
@@ -63,7 +85,6 @@
"prettier": "^3.9.6", "prettier": "^3.9.6",
"prettier-plugin-tailwindcss": "^0.8.1", "prettier-plugin-tailwindcss": "^0.8.1",
"tailwindcss": "^4", "tailwindcss": "^4",
"tsx": "^4.23.12",
"typescript": "^5" "typescript": "^5"
}, },
"packageManager": "pnpm@11.21.0" "packageManager": "pnpm@11.21.0"
+304 -3
View File
@@ -14,6 +14,9 @@ importers:
'@hookform/resolvers': '@hookform/resolvers':
specifier: ^5.8.0 specifier: ^5.8.0
version: 5.8.0(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(react-hook-form@7.85.0(react@19.2.8))(zod@4.4.3) version: 5.8.0(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(react-hook-form@7.85.0(react@19.2.8))(zod@4.4.3)
'@inquirer/prompts':
specifier: ^8.5.2
version: 8.5.2(@types/node@20.19.43)
'@xterm/addon-fit': '@xterm/addon-fit':
specifier: ^0.11.0 specifier: ^0.11.0
version: 0.11.0 version: 0.11.0
@@ -35,6 +38,9 @@ importers:
cmdk: cmdk:
specifier: ^1.1.1 specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) version: 1.1.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
commander:
specifier: ^15.0.0
version: 15.0.0
drizzle-orm: drizzle-orm:
specifier: ^0.45.2 specifier: ^0.45.2
version: 0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3) version: 0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)
@@ -77,6 +83,9 @@ importers:
tailwind-merge: tailwind-merge:
specifier: ^3.6.0 specifier: ^3.6.0
version: 3.6.0 version: 3.6.0
tsx:
specifier: ^4.23.12
version: 4.23.12
tw-animate-css: tw-animate-css:
specifier: ^1.4.0 specifier: ^1.4.0
version: 1.4.0 version: 1.4.0
@@ -132,9 +141,6 @@ importers:
tailwindcss: tailwindcss:
specifier: ^4 specifier: ^4
version: 4.3.3 version: 4.3.3
tsx:
specifier: ^4.23.12
version: 4.23.12
typescript: typescript:
specifier: ^5 specifier: ^5
version: 5.9.3 version: 5.9.3
@@ -1107,6 +1113,140 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@inquirer/ansi@2.0.7':
resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
'@inquirer/checkbox@5.2.1':
resolution: {integrity: sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/confirm@6.1.1':
resolution: {integrity: sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/core@11.2.1':
resolution: {integrity: sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/editor@5.2.2':
resolution: {integrity: sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/expand@5.1.1':
resolution: {integrity: sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/external-editor@3.0.3':
resolution: {integrity: sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/figures@2.0.7':
resolution: {integrity: sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
'@inquirer/input@5.1.2':
resolution: {integrity: sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/number@4.1.1':
resolution: {integrity: sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/password@5.1.1':
resolution: {integrity: sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/prompts@8.5.2':
resolution: {integrity: sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/rawlist@5.3.1':
resolution: {integrity: sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/search@4.2.1':
resolution: {integrity: sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/select@5.2.1':
resolution: {integrity: sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/type@4.0.7':
resolution: {integrity: sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@jridgewell/gen-mapping@0.3.13': '@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -1955,6 +2095,9 @@ packages:
character-reference-invalid@2.0.1: character-reference-invalid@2.0.1:
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
chardet@2.2.0:
resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==}
class-variance-authority@0.7.1: class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
@@ -1966,6 +2109,10 @@ packages:
resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==}
engines: {node: '>=6'} engines: {node: '>=6'}
cli-width@4.1.0:
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
engines: {node: '>= 12'}
client-only@0.0.1: client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
@@ -2000,6 +2147,10 @@ packages:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'} engines: {node: '>=20'}
commander@15.0.0:
resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==}
engines: {node: '>=22.12.0'}
concat-map@0.0.1: concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
@@ -2551,9 +2702,18 @@ packages:
fast-levenshtein@2.0.6: fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
fast-string-truncated-width@3.0.3:
resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==}
fast-string-width@3.0.2:
resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==}
fast-uri@3.1.5: fast-uri@3.1.5:
resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==}
fast-wrap-ansi@0.2.2:
resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==}
fastq@1.20.1: fastq@1.20.1:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
@@ -3381,6 +3541,10 @@ packages:
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
mute-stream@3.0.0:
resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==}
engines: {node: ^20.17.0 || >=22.9.0}
nanoid@3.3.18: nanoid@3.3.18:
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -5067,6 +5231,125 @@ snapshots:
'@img/sharp-win32-x64@0.35.3': '@img/sharp-win32-x64@0.35.3':
optional: true optional: true
'@inquirer/ansi@2.0.7': {}
'@inquirer/checkbox@5.2.1(@types/node@20.19.43)':
dependencies:
'@inquirer/ansi': 2.0.7
'@inquirer/core': 11.2.1(@types/node@20.19.43)
'@inquirer/figures': 2.0.7
'@inquirer/type': 4.0.7(@types/node@20.19.43)
optionalDependencies:
'@types/node': 20.19.43
'@inquirer/confirm@6.1.1(@types/node@20.19.43)':
dependencies:
'@inquirer/core': 11.2.1(@types/node@20.19.43)
'@inquirer/type': 4.0.7(@types/node@20.19.43)
optionalDependencies:
'@types/node': 20.19.43
'@inquirer/core@11.2.1(@types/node@20.19.43)':
dependencies:
'@inquirer/ansi': 2.0.7
'@inquirer/figures': 2.0.7
'@inquirer/type': 4.0.7(@types/node@20.19.43)
cli-width: 4.1.0
fast-wrap-ansi: 0.2.2
mute-stream: 3.0.0
signal-exit: 4.1.0
optionalDependencies:
'@types/node': 20.19.43
'@inquirer/editor@5.2.2(@types/node@20.19.43)':
dependencies:
'@inquirer/core': 11.2.1(@types/node@20.19.43)
'@inquirer/external-editor': 3.0.3(@types/node@20.19.43)
'@inquirer/type': 4.0.7(@types/node@20.19.43)
optionalDependencies:
'@types/node': 20.19.43
'@inquirer/expand@5.1.1(@types/node@20.19.43)':
dependencies:
'@inquirer/core': 11.2.1(@types/node@20.19.43)
'@inquirer/type': 4.0.7(@types/node@20.19.43)
optionalDependencies:
'@types/node': 20.19.43
'@inquirer/external-editor@3.0.3(@types/node@20.19.43)':
dependencies:
chardet: 2.2.0
iconv-lite: 0.7.3
optionalDependencies:
'@types/node': 20.19.43
'@inquirer/figures@2.0.7': {}
'@inquirer/input@5.1.2(@types/node@20.19.43)':
dependencies:
'@inquirer/core': 11.2.1(@types/node@20.19.43)
'@inquirer/type': 4.0.7(@types/node@20.19.43)
optionalDependencies:
'@types/node': 20.19.43
'@inquirer/number@4.1.1(@types/node@20.19.43)':
dependencies:
'@inquirer/core': 11.2.1(@types/node@20.19.43)
'@inquirer/type': 4.0.7(@types/node@20.19.43)
optionalDependencies:
'@types/node': 20.19.43
'@inquirer/password@5.1.1(@types/node@20.19.43)':
dependencies:
'@inquirer/ansi': 2.0.7
'@inquirer/core': 11.2.1(@types/node@20.19.43)
'@inquirer/type': 4.0.7(@types/node@20.19.43)
optionalDependencies:
'@types/node': 20.19.43
'@inquirer/prompts@8.5.2(@types/node@20.19.43)':
dependencies:
'@inquirer/checkbox': 5.2.1(@types/node@20.19.43)
'@inquirer/confirm': 6.1.1(@types/node@20.19.43)
'@inquirer/editor': 5.2.2(@types/node@20.19.43)
'@inquirer/expand': 5.1.1(@types/node@20.19.43)
'@inquirer/input': 5.1.2(@types/node@20.19.43)
'@inquirer/number': 4.1.1(@types/node@20.19.43)
'@inquirer/password': 5.1.1(@types/node@20.19.43)
'@inquirer/rawlist': 5.3.1(@types/node@20.19.43)
'@inquirer/search': 4.2.1(@types/node@20.19.43)
'@inquirer/select': 5.2.1(@types/node@20.19.43)
optionalDependencies:
'@types/node': 20.19.43
'@inquirer/rawlist@5.3.1(@types/node@20.19.43)':
dependencies:
'@inquirer/core': 11.2.1(@types/node@20.19.43)
'@inquirer/type': 4.0.7(@types/node@20.19.43)
optionalDependencies:
'@types/node': 20.19.43
'@inquirer/search@4.2.1(@types/node@20.19.43)':
dependencies:
'@inquirer/core': 11.2.1(@types/node@20.19.43)
'@inquirer/figures': 2.0.7
'@inquirer/type': 4.0.7(@types/node@20.19.43)
optionalDependencies:
'@types/node': 20.19.43
'@inquirer/select@5.2.1(@types/node@20.19.43)':
dependencies:
'@inquirer/ansi': 2.0.7
'@inquirer/core': 11.2.1(@types/node@20.19.43)
'@inquirer/figures': 2.0.7
'@inquirer/type': 4.0.7(@types/node@20.19.43)
optionalDependencies:
'@types/node': 20.19.43
'@inquirer/type@4.0.7(@types/node@20.19.43)':
optionalDependencies:
'@types/node': 20.19.43
'@jridgewell/gen-mapping@0.3.13': '@jridgewell/gen-mapping@0.3.13':
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
@@ -5851,6 +6134,8 @@ snapshots:
character-reference-invalid@2.0.1: {} character-reference-invalid@2.0.1: {}
chardet@2.2.0: {}
class-variance-authority@0.7.1: class-variance-authority@0.7.1:
dependencies: dependencies:
clsx: 2.1.1 clsx: 2.1.1
@@ -5861,6 +6146,8 @@ snapshots:
cli-spinners@2.9.2: {} cli-spinners@2.9.2: {}
cli-width@4.1.0: {}
client-only@0.0.1: {} client-only@0.0.1: {}
clsx@2.1.1: {} clsx@2.1.1: {}
@@ -5891,6 +6178,8 @@ snapshots:
commander@14.0.3: {} commander@14.0.3: {}
commander@15.0.0: {}
concat-map@0.0.1: {} concat-map@0.0.1: {}
conf@10.2.0: conf@10.2.0:
@@ -6610,8 +6899,18 @@ snapshots:
fast-levenshtein@2.0.6: {} fast-levenshtein@2.0.6: {}
fast-string-truncated-width@3.0.3: {}
fast-string-width@3.0.2:
dependencies:
fast-string-truncated-width: 3.0.3
fast-uri@3.1.5: {} fast-uri@3.1.5: {}
fast-wrap-ansi@0.2.2:
dependencies:
fast-string-width: 3.0.2
fastq@1.20.1: fastq@1.20.1:
dependencies: dependencies:
reusify: 1.1.0 reusify: 1.1.0
@@ -7581,6 +7880,8 @@ snapshots:
ms@2.1.3: {} ms@2.1.3: {}
mute-stream@3.0.0: {}
nanoid@3.3.18: {} nanoid@3.3.18: {}
napi-postinstall@0.3.4: {} napi-postinstall@0.3.4: {}
-35
View File
@@ -1,35 +0,0 @@
import { loadConfig, ConfigError } from "../src/lib/config/load";
const configPathArg = process.argv[2];
try {
const { config, configPath } = loadConfig(configPathArg);
console.log(
JSON.stringify({
ok: true,
configPath,
scriptCount: config.scripts.length,
authEnabled: config.auth.enabled,
}),
);
process.exit(0);
} catch (error) {
if (error instanceof ConfigError) {
console.log(
JSON.stringify({
ok: false,
message: error.message,
issues: error.issues,
}),
);
} else {
console.log(
JSON.stringify({
ok: false,
message: (error as Error).message,
issues: [],
}),
);
}
process.exit(1);
}
+6 -1
View File
@@ -1,5 +1,7 @@
import "./src/bootstrap/async-local-storage-polyfill"; import "./src/bootstrap/async-local-storage-polyfill";
import { createServer } from "node:http"; import { createServer } from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import next from "next"; import next from "next";
import { WebSocketServer } from "ws"; import { WebSocketServer } from "ws";
import { getConfig } from "./src/lib/config/load"; import { getConfig } from "./src/lib/config/load";
@@ -19,7 +21,10 @@ migrateOnBoot();
syncAuthFromConfig(); syncAuthFromConfig();
reconcileOrphanedRuns(); reconcileOrphanedRuns();
const app = next({ dev, hostname, port }); // `dir` must be this file's own directory, not `process.cwd()` - when launched by the installed
// `triggershell` CLI, the working directory is wherever the user's config lives, not the package.
const dir = path.dirname(fileURLToPath(import.meta.url));
const app = next({ dev, dir, hostname, port });
const handle = app.getRequestHandler(); const handle = app.getRequestHandler();
app.prepare().then(() => { app.prepare().then(() => {
+42
View File
@@ -0,0 +1,42 @@
import fs from "node:fs";
import { ConfigError, loadConfig } from "../../lib/config/load";
import { isPortFree } from "../lib/network";
import { resolveConfigPath } from "../lib/paths";
export interface DoctorOptions {
config?: string;
}
export async function doctorCommand(opts: DoctorOptions): Promise<void> {
const rows: [string, string][] = [];
rows.push(["Node.js", process.version]);
const configPath = resolveConfigPath(opts.config);
const exists = fs.existsSync(configPath);
rows.push(["Config path", `${configPath} ${exists ? "(exists)" : "(not found)"}`]);
if (exists) {
try {
const { config } = loadConfig(configPath);
const portFree = await isPortFree(config.server.host, config.server.port);
rows.push([
"Port available",
portFree ? "yes" : `no (${config.server.host}:${config.server.port} in use)`,
]);
rows.push(["Scripts configured", String(config.scripts.length)]);
rows.push(["Auth enabled", String(config.auth.enabled)]);
} catch (error) {
if (error instanceof ConfigError) {
rows.push(["Config", error.message]);
} else {
throw error;
}
}
}
const labelWidth = Math.max(...rows.map(([label]) => label.length));
for (const [label, value] of rows) {
console.log(`${label.padEnd(labelWidth)} ${value}`);
}
}
+50
View File
@@ -0,0 +1,50 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { resolveAppRoot } from "../lib/paths";
const DEFAULT_CONFIG_NAME = "triggershell.yml";
export interface InitOptions {
port: number;
auth: boolean;
force: boolean;
}
export async function initCommand(targetPath: string | undefined, opts: InitOptions): Promise<void> {
const targetDir = path.resolve(process.cwd(), targetPath ?? ".");
fs.mkdirSync(targetDir, { recursive: true });
const configPath = path.join(targetDir, DEFAULT_CONFIG_NAME);
if (fs.existsSync(configPath) && !opts.force) {
console.error(`${configPath} already exists. Use --force to overwrite.`);
process.exitCode = 1;
return;
}
const templatePath = path.join(resolveAppRoot(), "templates", DEFAULT_CONFIG_NAME);
const template = fs.readFileSync(templatePath, "utf-8");
const rendered = template
.replace("__PORT__", String(opts.port))
.replace("__AUTH_ENABLED__", opts.auth ? "true" : "false");
fs.writeFileSync(configPath, rendered);
console.log(`Created ${configPath}`);
const envPath = path.join(targetDir, ".env");
if (opts.auth) {
if (fs.existsSync(envPath)) {
console.log(
`${envPath} already exists - make sure it sets TRIGGERSHELL_SESSION_SECRET (>= 32 chars).`,
);
} else {
const sessionSecret = crypto.randomBytes(32).toString("hex");
fs.writeFileSync(envPath, `TRIGGERSHELL_SESSION_SECRET=${sessionSecret}\n`);
console.log(`Created ${envPath} (keep this out of version control)`);
}
console.log("\nAuth is enabled but no users are configured yet. Add one with:");
console.log(` triggershell users add <username> --config ${configPath}`);
}
console.log("\nStart the app with:");
console.log(` triggershell start --config ${configPath}`);
}
+111
View File
@@ -0,0 +1,111 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execa } from "execa";
import { resolveConfigPath, resolveInvokedBinPath } from "../lib/paths";
import {
isRoot,
renderUnit,
SERVICE_NAME,
systemUnitPath,
userUnitPath,
} from "../lib/systemd";
export interface ServiceInstallOptions {
config?: string;
port?: number;
host?: string;
system?: boolean;
}
export interface ServiceScopeOptions {
system?: boolean;
}
function scopeOf(opts: ServiceScopeOptions): "user" | "system" {
return opts.system ? "system" : "user";
}
export async function serviceInstallCommand(opts: ServiceInstallOptions): Promise<void> {
const scope = scopeOf(opts);
const configPath = resolveConfigPath(opts.config);
const unit = renderUnit({
execPath: process.execPath,
binPath: resolveInvokedBinPath(),
configPath,
configDir: path.dirname(configPath),
port: opts.port,
host: opts.host,
scope,
});
if (scope === "user") {
const unitPath = userUnitPath();
fs.mkdirSync(path.dirname(unitPath), { recursive: true });
fs.writeFileSync(unitPath, unit);
await execa("systemctl", ["--user", "daemon-reload"], { reject: false });
console.log(`Installed ${unitPath}`);
console.log("\nReview it, then start the service with:");
console.log(` systemctl --user enable --now ${SERVICE_NAME}`);
console.log("\nTail logs with:");
console.log(` journalctl --user -u ${SERVICE_NAME} -f`);
return;
}
if (isRoot()) {
const unitPath = systemUnitPath();
fs.writeFileSync(unitPath, unit);
await execa("systemctl", ["daemon-reload"], { reject: false });
console.log(`Installed ${unitPath}`);
console.log("\nReview it, then start the service with:");
console.log(` systemctl enable --now ${SERVICE_NAME}`);
console.log("\nTail logs with:");
console.log(` journalctl -u ${SERVICE_NAME} -f`);
return;
}
const scratchPath = path.join(os.tmpdir(), `${SERVICE_NAME}.service`);
fs.writeFileSync(scratchPath, unit);
console.log(`Not running as root - wrote the unit file to ${scratchPath} instead.`);
console.log("\nReview it, then run:");
console.log(` sudo install -m 644 ${scratchPath} ${systemUnitPath()}`);
console.log(" sudo systemctl daemon-reload");
console.log(` sudo systemctl enable --now ${SERVICE_NAME}`);
}
export async function serviceUninstallCommand(opts: ServiceScopeOptions): Promise<void> {
const scope = scopeOf(opts);
if (scope === "user") {
await execa("systemctl", ["--user", "disable", "--now", SERVICE_NAME], { reject: false });
const unitPath = userUnitPath();
if (fs.existsSync(unitPath)) fs.rmSync(unitPath);
await execa("systemctl", ["--user", "daemon-reload"], { reject: false });
console.log(`Removed ${unitPath}`);
return;
}
if (isRoot()) {
await execa("systemctl", ["disable", "--now", SERVICE_NAME], { reject: false });
const unitPath = systemUnitPath();
if (fs.existsSync(unitPath)) fs.rmSync(unitPath);
await execa("systemctl", ["daemon-reload"], { reject: false });
console.log(`Removed ${unitPath}`);
return;
}
console.log("Not running as root. Remove the system service manually with:");
console.log(` sudo systemctl disable --now ${SERVICE_NAME}`);
console.log(` sudo rm ${systemUnitPath()}`);
console.log(" sudo systemctl daemon-reload");
}
export async function serviceStatusCommand(opts: ServiceScopeOptions): Promise<void> {
const scope = scopeOf(opts);
const args =
scope === "user" ? ["--user", "status", SERVICE_NAME] : ["status", SERVICE_NAME];
const result = await execa("systemctl", args, { stdio: "inherit", reject: false });
process.exitCode = result.exitCode ?? 1;
}
+59
View File
@@ -0,0 +1,59 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
import { ConfigError, loadConfig } from "../../lib/config/load";
import { loadDotenv } from "../lib/env-file";
import { isPortFree, openBrowser, waitUntilReady } from "../lib/network";
import { resolveAppRoot, resolveConfigPath } from "../lib/paths";
export interface StartOptions {
config?: string;
port?: number;
host?: string;
noBrowser?: boolean;
}
export async function startCommand(opts: StartOptions): Promise<void> {
const configPath = resolveConfigPath(opts.config);
loadDotenv(path.join(path.dirname(configPath), ".env"));
let loaded;
try {
loaded = loadConfig(configPath);
} catch (error) {
if (error instanceof ConfigError) {
console.error(`Config error: ${error.message}`);
for (const issue of error.issues) console.error(` - ${issue}`);
process.exitCode = 1;
return;
}
throw error;
}
const effectiveHost = opts.host ?? loaded.config.server.host;
const effectivePort = opts.port ?? loaded.config.server.port;
if (!(await isPortFree(effectiveHost, effectivePort))) {
console.error(
`Port ${effectivePort} on ${effectiveHost} is already in use. Pass --port to use a different one.`,
);
process.exitCode = 1;
return;
}
process.env.TRIGGERSHELL_CONFIG_PATH = configPath;
process.env.PORT = String(effectivePort);
process.env.HOST = effectiveHost;
// Next's generated types mark NODE_ENV readonly; this is the one legitimate place that sets it
// (the CLI IS what decides production mode) before importing server.ts.
(process.env as { NODE_ENV: string }).NODE_ENV = "production";
const url = `http://${effectiveHost}:${effectivePort}`;
if (!opts.noBrowser) {
void waitUntilReady(`${url}/api/healthz`, 45_000).then((ready) => {
if (ready) openBrowser(url);
});
}
const serverEntry = pathToFileURL(path.join(resolveAppRoot(), "server.ts")).href;
await import(serverEntry);
}
+73
View File
@@ -0,0 +1,73 @@
import crypto from "node:crypto";
import path from "node:path";
import { password as promptPassword } from "@inquirer/prompts";
import { stringify } from "yaml";
import { hashPassword } from "../../lib/auth/password";
import { upsertEnvVar } from "../lib/env-file";
import { resolveConfigPath } from "../lib/paths";
import { slug } from "../lib/slug";
export interface UsersOptions {
config?: string;
inline?: boolean;
}
function printSnippet(heading: string, entry: Record<string, unknown>): void {
console.log(`\n${heading}\n`);
console.log(stringify([entry]));
}
async function promptNewPassword(): Promise<string> {
for (;;) {
const first = await promptPassword({ message: "Password", mask: true });
const second = await promptPassword({ message: "Confirm password", mask: true });
if (first === second) return first;
console.error("Passwords did not match, try again.\n");
}
}
export async function usersAddCommand(username: string, opts: UsersOptions): Promise<void> {
const password = await promptNewPassword();
const passwordHash = await hashPassword(password);
if (opts.inline) {
printSnippet("Add this under `auth.users:` in your config file:", { username, passwordHash });
return;
}
const configPath = resolveConfigPath(opts.config);
const envPath = path.join(path.dirname(configPath), ".env");
const varName = `TRIGGERSHELL_USER_${slug(username)}_PASSWORD_HASH`;
upsertEnvVar(envPath, varName, passwordHash);
console.log(`Stored ${varName} in ${envPath}`);
printSnippet("Add this under `auth.users:` in your config file:", {
username,
passwordHash: `\${${varName}}`,
});
}
export async function usersAddTokenCommand(name: string, opts: UsersOptions): Promise<void> {
const token = crypto.randomBytes(32).toString("hex");
const tokenHash = `sha256:${crypto.createHash("sha256").update(token).digest("hex")}`;
console.log("\nSave this token now - it will not be shown again:");
console.log(` ${token}`);
console.log(`Use it as: Authorization: Bearer ${token}`);
if (opts.inline) {
printSnippet("Add this under `auth.tokens:` in your config file:", { name, tokenHash });
return;
}
const configPath = resolveConfigPath(opts.config);
const envPath = path.join(path.dirname(configPath), ".env");
const varName = `TRIGGERSHELL_TOKEN_${slug(name)}_HASH`;
upsertEnvVar(envPath, varName, tokenHash);
console.log(`Stored ${varName} in ${envPath}`);
printSnippet("Add this under `auth.tokens:` in your config file:", {
name,
tokenHash: `\${${varName}}`,
});
}
+28
View File
@@ -0,0 +1,28 @@
import path from "node:path";
import { ConfigError, loadConfig } from "../../lib/config/load";
import { loadDotenv } from "../lib/env-file";
import { resolveConfigPath } from "../lib/paths";
export interface ValidateOptions {
config?: string;
}
export async function validateCommand(opts: ValidateOptions): Promise<void> {
const configPath = resolveConfigPath(opts.config);
loadDotenv(path.join(path.dirname(configPath), ".env"));
try {
const { config } = loadConfig(configPath);
console.log(`OK - ${configPath}`);
console.log(` ${config.scripts.length} script(s) configured`);
console.log(` auth.enabled: ${config.auth.enabled}`);
} catch (error) {
if (error instanceof ConfigError) {
console.error(`Config error: ${error.message}`);
for (const issue of error.issues) console.error(` - ${issue}`);
process.exitCode = 1;
return;
}
throw error;
}
}
+87
View File
@@ -0,0 +1,87 @@
import { Command } from "commander";
import { doctorCommand } from "./commands/doctor";
import { initCommand } from "./commands/init";
import { serviceInstallCommand, serviceStatusCommand, serviceUninstallCommand } from "./commands/service";
import { startCommand } from "./commands/start";
import { usersAddCommand, usersAddTokenCommand } from "./commands/users";
import { validateCommand } from "./commands/validate";
import { getVersion } from "./version";
const program = new Command("triggershell")
.version(getVersion())
.description("Launch the TriggerShell web app: run your configured shell scripts from a browser.");
program
.command("init [path]")
.description("Scaffold a new triggershell.yml (and .env, if auth is enabled)")
.option("--port <port>", "Port the web app will listen on.", (v) => Number(v), 4173)
.option("--no-auth", "Disable built-in login for the web app.")
.option("--force", "Overwrite an existing config file.", false)
.action(initCommand);
program
.command("validate")
.description("Validate a config file against the full schema.")
.option("-c, --config <path>", "Path to the config file.")
.action(validateCommand);
program
.command("start")
.description("Run the web app in production mode.")
.option("-c, --config <path>", "Path to the config file.")
.option("--port <port>", "Override the port from the config file.", (v) => Number(v))
.option("--host <host>", "Override the host from the config file.")
.option("--no-browser", "Don't open a browser automatically.")
.action(startCommand);
program
.command("doctor")
.description("Print diagnostic info about your environment and config.")
.option("-c, --config <path>", "Path to the config file.")
.action(doctorCommand);
const users = program.command("users").description("Manage auth users and API tokens defined in your config file.");
users
.command("add <username>")
.description("Hash a password with argon2id and wire it up for auth.users.")
.option("-c, --config <path>", "Path to the config file (used to locate .env).")
.option("--inline", "Print the raw hash to paste into the config instead of storing it in .env.", false)
.action(usersAddCommand);
users
.command("add-token <name>")
.description("Generate an API token and wire its hash up for auth.tokens.")
.option("-c, --config <path>", "Path to the config file (used to locate .env).")
.option("--inline", "Print the raw hash to paste into the config instead of storing it in .env.", false)
.action(usersAddTokenCommand);
const service = program.command("service").description("Manage the systemd service (Linux only).");
service
.command("install")
.description("Install a systemd unit that runs `triggershell start`.")
.option("-c, --config <path>", "Path to the config file.")
.option("--port <port>", "Override the port from the config file.", (v) => Number(v))
.option("--host <host>", "Override the host from the config file.")
.option("--system", "Install a system-wide unit instead of a per-user one.", false)
.action(serviceInstallCommand);
service
.command("uninstall")
.description("Stop, disable, and remove the systemd unit.")
.option("--system", "Target the system-wide unit instead of the per-user one.", false)
.action(serviceUninstallCommand);
service
.command("status")
.description("Show the systemd unit's status.")
.option("--system", "Target the system-wide unit instead of the per-user one.", false)
.action(serviceStatusCommand);
if (process.argv.length <= 2) {
program.outputHelp();
process.exit(1);
}
await program.parseAsync(process.argv);
+50
View File
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { test } from "node:test";
import { loadDotenv, upsertEnvVar } from "./env-file";
function tmpEnvPath(): string {
return path.join(fs.mkdtempSync(path.join(os.tmpdir(), "triggershell-env-")), ".env");
}
test("upsertEnvVar appends a new key", () => {
const envPath = tmpEnvPath();
upsertEnvVar(envPath, "FOO", "bar");
assert.equal(fs.readFileSync(envPath, "utf-8"), "FOO=bar\n");
});
test("upsertEnvVar replaces an existing key without duplicating the line", () => {
const envPath = tmpEnvPath();
upsertEnvVar(envPath, "FOO", "first");
upsertEnvVar(envPath, "FOO", "second");
const lines = fs.readFileSync(envPath, "utf-8").trim().split("\n");
assert.equal(lines.length, 1);
assert.equal(lines[0], "FOO=second");
});
test("upsertEnvVar preserves other existing keys", () => {
const envPath = tmpEnvPath();
upsertEnvVar(envPath, "FOO", "1");
upsertEnvVar(envPath, "BAR", "2");
const content = fs.readFileSync(envPath, "utf-8");
assert.match(content, /FOO=1/);
assert.match(content, /BAR=2/);
});
test("loadDotenv sets process.env without overriding an already-set var", () => {
const envPath = tmpEnvPath();
upsertEnvVar(envPath, "TRIGGERSHELL_TEST_ALREADY_SET", "from-file");
upsertEnvVar(envPath, "TRIGGERSHELL_TEST_NEW", "from-file");
process.env.TRIGGERSHELL_TEST_ALREADY_SET = "from-shell";
delete process.env.TRIGGERSHELL_TEST_NEW;
loadDotenv(envPath);
assert.equal(process.env.TRIGGERSHELL_TEST_ALREADY_SET, "from-shell");
assert.equal(process.env.TRIGGERSHELL_TEST_NEW, "from-file");
delete process.env.TRIGGERSHELL_TEST_ALREADY_SET;
delete process.env.TRIGGERSHELL_TEST_NEW;
});
+43
View File
@@ -0,0 +1,43 @@
import fs from "node:fs";
interface EnvEntry {
key: string;
value: string;
}
function parseEnvLines(content: string): EnvEntry[] {
const entries: EnvEntry[] = [];
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq === -1) continue;
entries.push({ key: trimmed.slice(0, eq), value: trimmed.slice(eq + 1) });
}
return entries;
}
/** Loads a `.env` file into `process.env`, without overriding vars already set. */
export function loadDotenv(envPath: string): void {
if (!fs.existsSync(envPath)) return;
for (const { key, value } of parseEnvLines(fs.readFileSync(envPath, "utf-8"))) {
if (process.env[key] === undefined) process.env[key] = value;
}
}
/** Sets `key=value` in a `.env` file, replacing an existing line for that key rather than duplicating it. */
export function upsertEnvVar(envPath: string, key: string, value: string): void {
const lines = fs.existsSync(envPath)
? fs.readFileSync(envPath, "utf-8").split("\n")
: [];
const prefix = `${key}=`;
const index = lines.findIndex((line) => line.startsWith(prefix));
const newLine = `${key}=${value}`;
if (index >= 0) {
lines[index] = newLine;
} else {
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
lines.push(newLine);
}
fs.writeFileSync(envPath, lines.join("\n") + "\n");
}
+48
View File
@@ -0,0 +1,48 @@
import { spawn } from "node:child_process";
import net from "node:net";
export function isPortFree(host: string, port: number): Promise<boolean> {
return new Promise((resolve) => {
const socket = net.connect({ host, port, timeout: 500 });
socket.once("connect", () => {
socket.destroy();
resolve(false);
});
socket.once("timeout", () => {
socket.destroy();
resolve(true);
});
socket.once("error", () => {
resolve(true);
});
});
}
export async function waitUntilReady(
url: string,
timeoutMs: number,
intervalMs = 400,
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const response = await fetch(url, { signal: AbortSignal.timeout(1500) });
if (response.status === 200) return true;
} catch {
// not ready yet
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
return false;
}
export function openBrowser(url: string): void {
const command =
process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
const args = process.platform === "win32" ? ["", url] : [url];
try {
spawn(command, args, { detached: true, stdio: "ignore", shell: process.platform === "win32" }).unref();
} catch {
// best-effort - not fatal if no browser opener is available
}
}
+23
View File
@@ -0,0 +1,23 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const DEFAULT_CONFIG_NAME = "triggershell.yml";
/** Root of the installed `triggershell` package - one level up from `src/cli/lib`. */
export function resolveAppRoot(): string {
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
}
export function resolveConfigPath(configArg?: string): string {
return path.resolve(process.cwd(), configArg ?? DEFAULT_CONFIG_NAME);
}
/**
* Absolute path to the script that was actually invoked (`node <this>`), with any symlink
* (as created by a global npm/pnpm install or `npm link`) resolved away. Used to build a
* `systemd` `ExecStart` line that keeps working regardless of how the CLI was installed.
*/
export function resolveInvokedBinPath(): string {
return fs.realpathSync(process.argv[1]);
}
+9
View File
@@ -0,0 +1,9 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { slug } from "./slug";
test("slug", () => {
assert.equal(slug("ci-bot"), "CI_BOT");
assert.equal(slug("Admin User"), "ADMIN_USER");
assert.equal(slug("__weird--name__"), "WEIRD_NAME");
});
+6
View File
@@ -0,0 +1,6 @@
export function slug(value: string): string {
return value
.replace(/[^A-Za-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.toUpperCase();
}
+35
View File
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { renderUnit } from "./systemd";
test("renderUnit builds an absolute-path ExecStart with the given args", () => {
const unit = renderUnit({
execPath: "/usr/bin/node",
binPath: "/home/user/.local/share/pnpm/global/5/node_modules/.bin/triggershell",
configPath: "/home/user/project/triggershell.yml",
configDir: "/home/user/project",
port: 8080,
host: "0.0.0.0",
scope: "user",
});
assert.match(
unit,
/ExecStart=\/usr\/bin\/node .*triggershell start --config \/home\/user\/project\/triggershell\.yml --no-browser --port 8080 --host 0\.0\.0\.0/,
);
assert.match(unit, /WorkingDirectory=\/home\/user\/project/);
assert.match(unit, /WantedBy=default\.target/);
});
test("renderUnit uses multi-user.target for the system scope", () => {
const unit = renderUnit({
execPath: "/usr/bin/node",
binPath: "/usr/lib/node_modules/triggershell/bin/triggershell.js",
configPath: "/etc/triggershell/triggershell.yml",
configDir: "/etc/triggershell",
scope: "system",
});
assert.match(unit, /WantedBy=multi-user\.target/);
assert.match(unit, /ExecStart=\/usr\/bin\/node .*start --config .*--no-browser$/m);
});
+54
View File
@@ -0,0 +1,54 @@
import os from "node:os";
import path from "node:path";
export const SERVICE_NAME = "triggershell";
export interface UnitOptions {
execPath: string;
binPath: string;
configPath: string;
configDir: string;
port?: number;
host?: string;
scope: "user" | "system";
}
export function renderUnit(opts: UnitOptions): string {
const args = ["start", "--config", opts.configPath, "--no-browser"];
if (opts.port !== undefined) args.push("--port", String(opts.port));
if (opts.host !== undefined) args.push("--host", opts.host);
const execStart = [opts.execPath, opts.binPath, ...args]
.map((part) => (part.includes(" ") ? `"${part}"` : part))
.join(" ");
const wantedBy = opts.scope === "user" ? "default.target" : "multi-user.target";
return `[Unit]
Description=TriggerShell - self-hosted script runner
After=network.target
[Service]
Type=simple
ExecStart=${execStart}
WorkingDirectory=${opts.configDir}
Restart=on-failure
RestartSec=2
Environment=NODE_ENV=production
[Install]
WantedBy=${wantedBy}
`;
}
export function userUnitPath(): string {
return path.join(os.homedir(), ".config", "systemd", "user", `${SERVICE_NAME}.service`);
}
export function systemUnitPath(): string {
return path.join("/etc", "systemd", "system", `${SERVICE_NAME}.service`);
}
export function isRoot(): boolean {
return process.getuid?.() === 0;
}
+9
View File
@@ -0,0 +1,9 @@
import fs from "node:fs";
import path from "node:path";
import { resolveAppRoot } from "./lib/paths";
export function getVersion(): string {
const pkgPath = path.join(resolveAppRoot(), "package.json");
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { version: string };
return pkg.version;
}
+1 -1
View File
@@ -56,7 +56,7 @@ export function loadConfig(configPathArg?: string): LoadedConfig {
let parsedYaml: unknown; let parsedYaml: unknown;
try { try {
parsedYaml = parseYaml(interpolated); parsedYaml = parseYaml(interpolated) ?? {};
} catch (error) { } catch (error) {
throw new ConfigError(`Failed to parse YAML: ${(error as Error).message}`); throw new ConfigError(`Failed to parse YAML: ${(error as Error).message}`);
} }
+32 -13
View File
@@ -1,14 +1,15 @@
# Architecture # Architecture
``` ```
triggershell (Python CLI) app/ (Next.js, all server logic) triggershell (Node CLI, src/cli) app/ (Next.js, all server logic)
─────────────────────── ────────────────────────────── ─────────────────────────────── ──────────────────────────────
triggershell dev|start server.ts (custom Node server) triggershell start server.ts (custom Node server)
1. resolve + pre-flight the config ├─ Next.js request handler (pages, API routes) 1. resolve + validate the config (loadConfig) ├─ Next.js request handler (pages, API routes)
2. check node/pnpm, `pnpm install` if stale ├─ ws.WebSocketServer on /ws/runs 2. check the port is free ├─ ws.WebSocketServer on /ws/runs
3. set TRIGGERSHELL_CONFIG_PATH/PORT/HOST env └─ boot: migrate DB, sync auth, reconcile runs 3. set TRIGGERSHELL_CONFIG_PATH/PORT/HOST/ └─ boot: migrate DB, sync auth, reconcile runs
4. spawn `pnpm run dev|start`, forward signals NODE_ENV
5. poll /api/healthz, open browser 4. import("./server.ts") — same process
5. poll /api/healthz, open browser │
┌──────────┴──────────┐ ┌──────────┴──────────┐
REST API WebSocket REST API WebSocket
(src/app/api/**) (src/lib/ws/server.ts) (src/app/api/**) (src/lib/ws/server.ts)
@@ -28,12 +29,30 @@ request handler in a plain `http.createServer` and attaches a `ws.WebSocketServe
`upgrade` event, scoped to `/ws/runs` with its own auth check (Route Handlers get auth via `upgrade` event, scoped to `/ws/runs` with its own auth check (Route Handlers get auth via
`next/headers`'s `cookies()`, which isn't available on a raw `http.IncomingMessage`). `next/headers`'s `cookies()`, which isn't available on a raw `http.IncomingMessage`).
## Why the Python CLI is thin ## Why the CLI and server share one process
Everything Node/pnpm/Next.js needs to do (serve pages, run scripts, stream output, enforce auth) `triggershell start` (`src/cli/commands/start.ts`) doesn't spawn `server.ts` as a child process —
is naturally a Node problem — `execa` for argv-safe spawning, `ws` for streaming, Next for the UI. it sets `process.env` (`TRIGGERSHELL_CONFIG_PATH`, `PORT`, `HOST`, `NODE_ENV`) and then dynamically
Python's job is just: get Node/pnpm ready, validate the config fast, and manage the child process's `import()`s `server.ts` directly, in the same Node process. `server.ts` reads that env and installs
lifecycle (signals, readiness, browser launch) — a CLI concern, not a web-server concern. its own `SIGTERM`/`SIGINT` handlers, so once it's imported, `Ctrl-C` or `systemctl stop` just work —
there's no parent process relaying signals to a child, no separate lifecycle to manage. The CLI's
`bin/triggershell.js` entry point registers `tsx`'s loader once for the whole process
(`tsx/esm/api`'s `register()`), so both the CLI's own `.ts` command files and `server.ts` run
straight from source, with no compile/bundle step for either.
## Running as a systemd service
`triggershell service install` renders a unit file (`src/cli/lib/systemd.ts`) whose `ExecStart`
line pins the exact `node` binary (`process.execPath`) and the exact, symlink-resolved path to the
installed CLI (`fs.realpathSync(process.argv[1])`) at install time — necessary because systemd
services run with a minimal `PATH` that may not include wherever Node actually lives. By default it
installs a per-user unit (`~/.config/systemd/user/triggershell.service`, no root required); `--system`
targets `/etc/systemd/system/` instead and prints the `sudo` commands to run if not already root.
`install` reloads the systemd daemon but does not enable/start the unit itself — that's a separate,
explicit `systemctl --user enable --now triggershell`, since it's the point where the service
actually starts listening and running scripts. `triggershell service status`/`uninstall` are thin
wrappers around `systemctl`; log tailing is just `journalctl --user -u triggershell -f` — not
reimplemented.
## Cross-module-graph state ## Cross-module-graph state
+2 -1
View File
@@ -11,7 +11,8 @@ secrets referenced via `${VAR}` don't have to be committed alongside the config.
init` scaffolds both files together. init` scaffolds both files together.
The canonical schema is the Zod schema at `app/src/lib/config/schema.ts` — this document mirrors The canonical schema is the Zod schema at `app/src/lib/config/schema.ts` — this document mirrors
it. `triggershell validate` runs the Python pre-flight checks below, then that full schema. it. `triggershell validate` loads and validates the config directly against the schema below (no
separate pre-flight step).
## `server` ## `server`
-48
View File
@@ -1,48 +0,0 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "triggershell"
version = "0.1.0"
description = "CLI launcher for the TriggerShell web app - run configured shell scripts from a browser."
readme = "README.md"
requires-python = ">=3.9"
license = "MIT"
authors = [{ name = "TriggerShell contributors" }]
dependencies = [
"typer>=0.12",
"rich>=13.7",
"pyyaml>=6.0",
"argon2-cffi>=23.1",
"python-dotenv>=1.0",
]
[project.urls]
Repository = "https://dev.pivoine.art/valknar/triggershell.git"
[project.scripts]
triggershell = "triggershell.cli:app"
[tool.hatch.build.targets.wheel]
packages = ["triggershell"]
[tool.hatch.build.targets.wheel.force-include]
"app" = "triggershell/_app"
[tool.hatch.build.targets.sdist]
include = ["triggershell", "app", "README.md"]
exclude = ["app/node_modules", "app/.next", "app/drizzle.config.ts.bak"]
[tool.ruff]
line-length = 120
target-version = "py39"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
# B008: typer's `= typer.Option(...)` / `= typer.Argument(...)` defaults are the intended API, not a bug.
# UP045: `X | None` needs 3.10 at runtime for typer's introspection; this project targets 3.9+.
ignore = ["B008", "UP045"]
[dependency-groups]
dev = ["ruff>=0.6", "pytest>=8.0"]
-150
View File
@@ -1,150 +0,0 @@
from pathlib import Path
from unittest.mock import patch
import pytest
from triggershell.bootstrap import (
BootstrapError,
check_node,
ensure_dependencies_installed,
load_dotenv_for_config,
needs_build,
record_build_stamp,
)
def _completed(stdout: str = "", returncode: int = 0):
class Result:
pass
result = Result()
result.stdout = stdout
result.returncode = returncode
return result
def test_check_node_missing_raises() -> None:
with patch("triggershell.bootstrap.shutil.which", return_value=None):
with pytest.raises(BootstrapError, match="not found"):
check_node()
def test_check_node_too_old_raises() -> None:
with patch("triggershell.bootstrap.shutil.which", return_value="/usr/bin/node"):
with patch("triggershell.bootstrap.subprocess.run", return_value=_completed("v18.0.0\n")):
with pytest.raises(BootstrapError, match="18"):
check_node()
def test_check_node_ok() -> None:
with patch("triggershell.bootstrap.shutil.which", return_value="/usr/bin/node"):
with patch("triggershell.bootstrap.subprocess.run", return_value=_completed("v20.11.0\n")):
assert check_node() == "20.11.0"
def test_ensure_dependencies_installed_skips_when_stamp_matches(tmp_path: Path) -> None:
app_dir = tmp_path / "app"
app_dir.mkdir()
lockfile = app_dir / "pnpm-lock.yaml"
lockfile.write_text("lockfile contents")
node_modules = app_dir / "node_modules"
node_modules.mkdir()
import hashlib
stamp = node_modules / ".triggershell-install-stamp"
stamp.write_text(hashlib.sha256(lockfile.read_bytes()).hexdigest())
with patch("triggershell.bootstrap.subprocess.run") as run:
ensure_dependencies_installed(app_dir)
run.assert_not_called()
def test_ensure_dependencies_installed_runs_when_stale(tmp_path: Path) -> None:
app_dir = tmp_path / "app"
app_dir.mkdir()
(app_dir / "pnpm-lock.yaml").write_text("v1")
with patch("triggershell.bootstrap.subprocess.run", return_value=_completed(returncode=0)) as run:
ensure_dependencies_installed(app_dir)
run.assert_called_once()
def test_needs_build_true_when_no_stamp(tmp_path: Path) -> None:
app_dir = tmp_path / "app"
(app_dir / "src").mkdir(parents=True)
config_path = tmp_path / "triggershell.yml"
config_path.write_text("scripts: []")
assert needs_build(app_dir, config_path) is True
def test_needs_build_false_after_stamp_recorded(tmp_path: Path) -> None:
app_dir = tmp_path / "app"
(app_dir / "src").mkdir(parents=True)
config_path = tmp_path / "triggershell.yml"
config_path.write_text("scripts: []")
record_build_stamp(app_dir, config_path)
assert needs_build(app_dir, config_path) is False
def test_needs_build_true_after_config_touched(tmp_path: Path) -> None:
import os
import time
app_dir = tmp_path / "app"
(app_dir / "src").mkdir(parents=True)
config_path = tmp_path / "triggershell.yml"
config_path.write_text("scripts: []")
record_build_stamp(app_dir, config_path)
assert needs_build(app_dir, config_path) is False
time.sleep(0.01)
config_path.write_text("scripts: []\n# touched")
future = time.time() + 5
os.utime(config_path, (future, future))
assert needs_build(app_dir, config_path) is True
def test_load_dotenv_for_config_noop_when_missing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
import os
monkeypatch.delenv("TRIGGERSHELL_SESSION_SECRET", raising=False)
config_path = tmp_path / "triggershell.yml"
config_path.write_text("scripts: []")
load_dotenv_for_config(config_path)
assert "TRIGGERSHELL_SESSION_SECRET" not in os.environ
def test_load_dotenv_for_config_loads_values(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
import os
monkeypatch.delenv("TRIGGERSHELL_SESSION_SECRET", raising=False)
config_path = tmp_path / "triggershell.yml"
config_path.write_text("scripts: []")
(tmp_path / ".env").write_text("TRIGGERSHELL_SESSION_SECRET=from-dotenv-file\n")
load_dotenv_for_config(config_path)
assert os.environ["TRIGGERSHELL_SESSION_SECRET"] == "from-dotenv-file"
def test_load_dotenv_for_config_does_not_override_existing_env(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("TRIGGERSHELL_SESSION_SECRET", "from-shell")
config_path = tmp_path / "triggershell.yml"
config_path.write_text("scripts: []")
(tmp_path / ".env").write_text("TRIGGERSHELL_SESSION_SECRET=from-dotenv-file\n")
load_dotenv_for_config(config_path)
import os
assert os.environ["TRIGGERSHELL_SESSION_SECRET"] == "from-shell"
-60
View File
@@ -1,60 +0,0 @@
from pathlib import Path
from dotenv import dotenv_values
from typer.testing import CliRunner
from triggershell.cli import _slug, app
runner = CliRunner()
def test_slug_normalizes_to_env_var_style() -> None:
assert _slug("ci-bot") == "CI_BOT"
assert _slug("Admin User") == "ADMIN_USER"
assert _slug("__weird--name__") == "WEIRD_NAME"
def test_users_add_stores_hash_in_dotenv(tmp_path: Path) -> None:
config_path = tmp_path / "triggershell.yml"
config_path.write_text("scripts: []\n")
result = runner.invoke(app, ["users", "add", "admin", "--config", str(config_path)], input="s3cret\ns3cret\n")
assert result.exit_code == 0, result.output
env_path = tmp_path / ".env"
assert env_path.exists()
values = dotenv_values(env_path)
assert values["TRIGGERSHELL_USER_ADMIN_PASSWORD_HASH"].startswith("$argon2id$")
assert "${TRIGGERSHELL_USER_ADMIN_PASSWORD_HASH}" in result.output
assert "$argon2id$" not in result.output.split("Stored")[0] # raw hash not printed pre-storage
def test_users_add_inline_does_not_touch_dotenv(tmp_path: Path) -> None:
config_path = tmp_path / "triggershell.yml"
config_path.write_text("scripts: []\n")
result = runner.invoke(
app, ["users", "add", "admin", "--config", str(config_path), "--inline"], input="s3cret\ns3cret\n"
)
assert result.exit_code == 0, result.output
assert not (tmp_path / ".env").exists()
assert "$argon2id$" in result.output
def test_users_add_token_overwrites_existing_var(tmp_path: Path) -> None:
config_path = tmp_path / "triggershell.yml"
config_path.write_text("scripts: []\n")
first = runner.invoke(app, ["users", "add-token", "ci-bot", "--config", str(config_path)])
assert first.exit_code == 0, first.output
first_hash = dotenv_values(tmp_path / ".env")["TRIGGERSHELL_TOKEN_CI_BOT_HASH"]
second = runner.invoke(app, ["users", "add-token", "ci-bot", "--config", str(config_path)])
assert second.exit_code == 0, second.output
values = dotenv_values(tmp_path / ".env")
second_hash = values["TRIGGERSHELL_TOKEN_CI_BOT_HASH"]
assert first_hash != second_hash
env_content = (tmp_path / ".env").read_text()
assert env_content.count("TRIGGERSHELL_TOKEN_CI_BOT_HASH") == 1
-70
View File
@@ -1,70 +0,0 @@
from pathlib import Path
import pytest
from triggershell.config import ConfigError, preflight_check
def write(tmp_path: Path, content: str) -> Path:
path = tmp_path / "triggershell.yml"
path.write_text(content)
return path
def test_missing_file_raises(tmp_path: Path) -> None:
with pytest.raises(ConfigError, match="not found"):
preflight_check(tmp_path / "missing.yaml")
def test_invalid_yaml_raises(tmp_path: Path) -> None:
path = write(tmp_path, "scripts: [this is not: valid: yaml")
with pytest.raises(ConfigError, match="parse YAML"):
preflight_check(path)
def test_non_mapping_root_raises(tmp_path: Path) -> None:
path = write(tmp_path, "- just\n- a\n- list\n")
with pytest.raises(ConfigError, match="mapping"):
preflight_check(path)
def test_empty_file_is_ok(tmp_path: Path) -> None:
path = write(tmp_path, "")
data = preflight_check(path)
assert data == {}
def test_script_missing_id_raises(tmp_path: Path) -> None:
path = write(tmp_path, "scripts:\n - name: no id here\n")
with pytest.raises(ConfigError, match="missing a non-empty 'id'"):
preflight_check(path)
def test_duplicate_script_id_raises(tmp_path: Path) -> None:
path = write(
tmp_path,
"""
scripts:
- id: dup
name: One
- id: dup
name: Two
""",
)
with pytest.raises(ConfigError, match="duplicate script id"):
preflight_check(path)
def test_valid_minimal_config_passes(tmp_path: Path) -> None:
path = write(
tmp_path,
"""
scripts:
- id: hello
name: Hello
command: echo
""",
)
data = preflight_check(path)
assert len(data["scripts"]) == 1
assert data["scripts"][0]["id"] == "hello"
-1
View File
@@ -1 +0,0 @@
__version__ = "0.1.0"
-4
View File
@@ -1,4 +0,0 @@
from triggershell.cli import app
if __name__ == "__main__":
app()
-170
View File
@@ -1,170 +0,0 @@
from __future__ import annotations
import hashlib
import os
import shutil
import subprocess
from pathlib import Path
from dotenv import dotenv_values
from rich.console import Console
console = Console()
MIN_NODE_MAJOR = 20
PNPM_VERSION = "10"
class BootstrapError(Exception):
pass
def load_dotenv_for_config(config_path: Path) -> None:
"""Loads a `.env` file next to the config file into this process's environment, so secrets
referenced as `${VAR}` in the config (e.g. `auth.sessionSecret`) don't have to be committed to
it. Merged into `os.environ` before the Node side spawns and interpolates the config, and
never overrides a variable the shell already exported."""
env_path = config_path.parent / ".env"
if not env_path.is_file():
return
for key, value in dotenv_values(env_path).items():
if value is not None and key not in os.environ:
os.environ[key] = value
def find_app_dir() -> Path:
"""Locates the bundled Next.js app: `_app` next to this package in an installed wheel,
or `../app` when running from the repo checkout (editable install / development)."""
here = Path(__file__).resolve().parent
packaged = here / "_app"
if packaged.is_dir():
return packaged
repo_layout = here.parent / "app"
if repo_layout.is_dir():
return repo_layout
raise BootstrapError(
"Could not locate the TriggerShell web app directory. "
"This is a broken installation - please reinstall the `triggershell` package."
)
def check_node() -> str:
node = shutil.which("node")
if not node:
raise BootstrapError(
"Node.js was not found on your PATH. TriggerShell requires Node.js "
f">= {MIN_NODE_MAJOR}. Install it from https://nodejs.org/ and try again."
)
result = subprocess.run([node, "--version"], capture_output=True, text=True, check=False)
version = result.stdout.strip().lstrip("v")
try:
major = int(version.split(".")[0])
except (ValueError, IndexError):
major = 0
if major < MIN_NODE_MAJOR:
raise BootstrapError(
f"Node.js {version or 'unknown'} was found, but TriggerShell requires "
f">= {MIN_NODE_MAJOR}. Please upgrade Node.js."
)
return version
def ensure_pnpm() -> str:
pnpm = shutil.which("pnpm")
if pnpm:
result = subprocess.run([pnpm, "--version"], capture_output=True, text=True, check=False)
return result.stdout.strip()
corepack = shutil.which("corepack")
if corepack:
console.print("[dim]pnpm not found - provisioning it via Corepack...[/dim]")
subprocess.run([corepack, "enable"], check=False)
activate = subprocess.run(
[corepack, "prepare", f"pnpm@{PNPM_VERSION}", "--activate"], check=False
)
if activate.returncode == 0 and shutil.which("pnpm"):
result = subprocess.run(
["pnpm", "--version"], capture_output=True, text=True, check=False
)
return result.stdout.strip()
raise BootstrapError(
"pnpm was not found on your PATH and could not be auto-provisioned via Corepack. "
"Install it manually: https://pnpm.io/installation"
)
def _hash_file(path: Path) -> str:
if not path.exists():
return ""
return hashlib.sha256(path.read_bytes()).hexdigest()
def ensure_dependencies_installed(app_dir: Path) -> None:
lockfile = app_dir / "pnpm-lock.yaml"
node_modules = app_dir / "node_modules"
stamp_file = node_modules / ".triggershell-install-stamp"
lockfile_hash = _hash_file(lockfile)
if node_modules.is_dir() and stamp_file.exists() and stamp_file.read_text().strip() == lockfile_hash:
return
console.print("[dim]Installing web app dependencies (pnpm install)...[/dim]")
result = subprocess.run(["pnpm", "install"], cwd=str(app_dir), check=False)
if result.returncode != 0:
raise BootstrapError("`pnpm install` failed - see output above.")
node_modules.mkdir(parents=True, exist_ok=True)
stamp_file.write_text(lockfile_hash)
def _newest_mtime(paths: list[Path]) -> float:
newest = 0.0
for path in paths:
if not path.exists():
continue
if path.is_file():
newest = max(newest, path.stat().st_mtime)
else:
for child in path.rglob("*"):
if child.is_file():
newest = max(newest, child.stat().st_mtime)
return newest
def needs_build(app_dir: Path, config_path: Path) -> bool:
"""Fingerprints source + config + lockfile mtimes against a stamp file so `start` can skip
a redundant `next build` when nothing relevant changed since the last one."""
build_output = app_dir / ".next"
stamp_file = build_output / "triggershell-build-stamp.txt"
fingerprint = str(
_newest_mtime(
[app_dir / "src", app_dir / "server.ts", config_path, app_dir / "pnpm-lock.yaml"]
)
)
if build_output.is_dir() and stamp_file.exists() and stamp_file.read_text().strip() == fingerprint:
return False
return True
def record_build_stamp(app_dir: Path, config_path: Path) -> None:
build_output = app_dir / ".next"
stamp_file = build_output / "triggershell-build-stamp.txt"
fingerprint = str(
_newest_mtime(
[app_dir / "src", app_dir / "server.ts", config_path, app_dir / "pnpm-lock.yaml"]
)
)
build_output.mkdir(parents=True, exist_ok=True)
stamp_file.write_text(fingerprint)
-353
View File
@@ -1,353 +0,0 @@
from __future__ import annotations
import hashlib
import os
import re
import secrets
from pathlib import Path
from typing import Optional
import typer
import yaml
from argon2 import PasswordHasher
from dotenv import set_key
from rich.console import Console
from rich.table import Table
from triggershell import __version__
from triggershell.bootstrap import (
BootstrapError,
check_node,
ensure_dependencies_installed,
ensure_pnpm,
find_app_dir,
load_dotenv_for_config,
needs_build,
record_build_stamp,
)
from triggershell.config import ConfigError, preflight_check
from triggershell.network import is_port_free, open_browser, wait_until_ready
from triggershell.process import run_and_wait, run_blocking
console = Console()
app = typer.Typer(
name="triggershell",
help="Launch the TriggerShell web app: run your configured shell scripts from a browser.",
no_args_is_help=True,
)
users_app = typer.Typer(help="Manage auth users and API tokens defined in your config file.")
app.add_typer(users_app, name="users")
DEFAULT_CONFIG_NAME = "triggershell.yml"
def _resolve_config_path(config: Optional[Path]) -> Path:
return (config or Path.cwd() / DEFAULT_CONFIG_NAME).resolve()
def _require_config(config_path: Path) -> dict:
try:
return preflight_check(config_path)
except ConfigError as e:
console.print(f"[red]Config error:[/red] {e}")
raise typer.Exit(code=1) from e
def _bootstrap_or_exit() -> tuple[str, str, Path]:
try:
node_version = check_node()
pnpm_version = ensure_pnpm()
app_dir = find_app_dir()
ensure_dependencies_installed(app_dir)
except BootstrapError as e:
console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(code=1) from e
return node_version, pnpm_version, app_dir
def _version_callback(value: bool) -> None:
if value:
console.print(f"triggershell {__version__}")
raise typer.Exit()
@app.callback()
def main(
version: bool = typer.Option(
False, "--version", callback=_version_callback, is_eager=True, help="Show the version and exit."
),
) -> None:
pass
@app.command()
def init(
path: Optional[Path] = typer.Argument(
None, help="Directory to create the config file in (default: current directory)."
),
port: int = typer.Option(4173, help="Port the web app will listen on."),
auth: bool = typer.Option(True, help="Enable built-in login for the web app."),
force: bool = typer.Option(False, help="Overwrite an existing config file."),
) -> None:
"""Scaffold a new `triggershell.yml`."""
target_dir = (path or Path.cwd()).resolve()
target_dir.mkdir(parents=True, exist_ok=True)
config_path = target_dir / DEFAULT_CONFIG_NAME
if config_path.exists() and not force:
console.print(f"[yellow]{config_path} already exists.[/yellow] Use --force to overwrite.")
raise typer.Exit(code=1)
template_path = Path(__file__).parent / "templates" / DEFAULT_CONFIG_NAME
template = template_path.read_text(encoding="utf-8")
rendered = template.replace("__PORT__", str(port)).replace("__AUTH_ENABLED__", "true" if auth else "false")
config_path.write_text(rendered, encoding="utf-8")
console.print(f"[green]Created[/green] {config_path}")
env_path = target_dir / ".env"
if auth:
if env_path.exists():
console.print(
f"[yellow]{env_path} already exists[/yellow] - make sure it sets TRIGGERSHELL_SESSION_SECRET "
"(>= 32 chars)."
)
else:
session_secret = secrets.token_hex(32)
env_path.write_text(f"TRIGGERSHELL_SESSION_SECRET={session_secret}\n", encoding="utf-8")
console.print(f"[green]Created[/green] {env_path} [dim](keep this out of version control)[/dim]")
console.print("\nAuth is enabled but no users are configured yet. Add one with:")
console.print(f" [bold]triggershell users add <username> --config {config_path}[/bold]")
console.print("\nStart the app with:")
console.print(f" [bold]triggershell dev --config {config_path}[/bold]")
@app.command()
def validate(
config: Optional[Path] = typer.Option(None, "-c", "--config", help="Path to the config file."),
) -> None:
"""Validate a config file: a fast Python pre-flight check, then the full Zod schema in Node."""
config_path = _resolve_config_path(config)
load_dotenv_for_config(config_path)
_require_config(config_path)
console.print("[green]OK[/green] (pre-flight checks passed)")
_, _, app_dir = _bootstrap_or_exit()
result = run_blocking(
["pnpm", "exec", "tsx", "scripts/validate-config.ts", str(config_path)],
cwd=app_dir,
)
raise typer.Exit(code=result)
@app.command()
def dev(
config: Optional[Path] = typer.Option(None, "-c", "--config", help="Path to the config file."),
port: Optional[int] = typer.Option(None, help="Override the port from the config file."),
host: Optional[str] = typer.Option(None, help="Override the host from the config file."),
no_browser: bool = typer.Option(False, "--no-browser", help="Don't open a browser automatically."),
) -> None:
"""Run the web app in development mode (hot reload)."""
_run_server(config, port, host, no_browser, production=False)
@app.command()
def start(
config: Optional[Path] = typer.Option(None, "-c", "--config", help="Path to the config file."),
port: Optional[int] = typer.Option(None, help="Override the port from the config file."),
host: Optional[str] = typer.Option(None, help="Override the host from the config file."),
no_browser: bool = typer.Option(False, "--no-browser", help="Don't open a browser automatically."),
skip_build: bool = typer.Option(False, "--skip-build", help="Skip `next build` even if it looks stale."),
) -> None:
"""Build (if needed) and run the web app in production mode."""
_run_server(config, port, host, no_browser, production=True, skip_build=skip_build)
def _run_server(
config: Optional[Path],
port: Optional[int],
host: Optional[str],
no_browser: bool,
production: bool,
skip_build: bool = False,
) -> None:
config_path = _resolve_config_path(config)
load_dotenv_for_config(config_path)
config_data = _require_config(config_path)
effective_host = host or (config_data.get("server") or {}).get("host") or "127.0.0.1"
effective_port = port or (config_data.get("server") or {}).get("port") or 4173
if not is_port_free(effective_host, effective_port):
console.print(
f"[red]Port {effective_port} on {effective_host} is already in use.[/red] "
"Pass --port to use a different one."
)
raise typer.Exit(code=1)
_, _, app_dir = _bootstrap_or_exit()
if production:
if skip_build:
console.print("[dim]Skipping build (--skip-build).[/dim]")
elif needs_build(app_dir, config_path):
console.print("[dim]Building web app for production (next build)...[/dim]")
result = run_blocking(["pnpm", "run", "build"], cwd=app_dir)
if result != 0:
raise typer.Exit(code=result)
record_build_stamp(app_dir, config_path)
else:
console.print("[dim]Build is up to date, skipping (pass --skip-build to force-skip anyway).[/dim]")
env = {
**os.environ,
"TRIGGERSHELL_CONFIG_PATH": str(config_path),
"PORT": str(effective_port),
"HOST": effective_host,
"NODE_ENV": "production" if production else "development",
}
script = "start" if production else "dev"
console.print(f"[dim]Starting web app ({'production' if production else 'development'} mode)...[/dim]")
url = f"http://{effective_host}:{effective_port}"
if not no_browser:
import threading
def open_when_ready() -> None:
if wait_until_ready(f"{url}/api/healthz", timeout=45):
open_browser(url)
threading.Thread(target=open_when_ready, daemon=True).start()
exit_code = run_and_wait(["pnpm", "run", script], cwd=app_dir, env=env)
raise typer.Exit(code=exit_code)
@app.command()
def doctor() -> None:
"""Print diagnostic info about your environment and config."""
table = Table(show_header=False, box=None)
try:
node_version = check_node()
table.add_row("Node.js", f"[green]{node_version}[/green]")
except BootstrapError as e:
table.add_row("Node.js", f"[red]{e}[/red]")
try:
pnpm_version = ensure_pnpm()
table.add_row("pnpm", f"[green]{pnpm_version}[/green]")
except BootstrapError as e:
table.add_row("pnpm", f"[red]{e}[/red]")
try:
app_dir = find_app_dir()
table.add_row("App directory", str(app_dir))
except BootstrapError as e:
table.add_row("App directory", f"[red]{e}[/red]")
config_path = _resolve_config_path(None)
exists_label = "[green](exists)[/green]" if config_path.exists() else "[yellow](not found)[/yellow]"
table.add_row("Config path", f"{config_path} {exists_label}")
if config_path.exists():
try:
data = preflight_check(config_path)
server = data.get("server") or {}
host, port = server.get("host", "127.0.0.1"), server.get("port", 4173)
port_free = is_port_free(host, port)
port_label = "[green]yes[/green]" if port_free else f"[yellow]no ({host}:{port} in use)[/yellow]"
table.add_row("Port available", port_label)
table.add_row("Scripts configured", str(len(data.get("scripts") or [])))
table.add_row("Auth enabled", str((data.get("auth") or {}).get("enabled", True)))
except ConfigError as e:
table.add_row("Config", f"[red]{e}[/red]")
console.print(table)
def _slug(value: str) -> str:
return re.sub(r"[^A-Za-z0-9]+", "_", value).strip("_").upper()
def _print_snippet(heading: str, entry: dict) -> None:
snippet = yaml.safe_dump([entry], sort_keys=False, width=1000)
console.print(f"\n[green]{heading}[/green]\n")
console.print(snippet, markup=False, highlight=False, soft_wrap=True)
@users_app.command("add")
def users_add(
username: str = typer.Argument(..., help="Username to create."),
config: Optional[Path] = typer.Option(
None, "-c", "--config", help="Path to the config file (used to locate .env)."
),
inline: bool = typer.Option(
False, "--inline", help="Print the raw hash to paste into the config instead of storing it in .env."
),
) -> None:
"""Hash a password with argon2id and wire it up for `auth.users`."""
password = typer.prompt("Password", hide_input=True, confirmation_prompt=True)
password_hash = PasswordHasher().hash(password)
if inline:
_print_snippet(
"Add this under `auth.users:` in your config file:",
{"username": username, "passwordHash": password_hash},
)
return
config_path = _resolve_config_path(config)
env_path = config_path.parent / ".env"
var_name = f"TRIGGERSHELL_USER_{_slug(username)}_PASSWORD_HASH"
set_key(str(env_path), var_name, password_hash)
console.print(f"[green]Stored[/green] {var_name} in {env_path}")
_print_snippet(
"Add this under `auth.users:` in your config file:",
{"username": username, "passwordHash": f"${{{var_name}}}"},
)
@users_app.command("add-token")
def users_add_token(
name: str = typer.Argument(..., help="A label for this token, e.g. 'ci-bot'."),
config: Optional[Path] = typer.Option(
None, "-c", "--config", help="Path to the config file (used to locate .env)."
),
inline: bool = typer.Option(
False, "--inline", help="Print the raw hash to paste into the config instead of storing it in .env."
),
) -> None:
"""Generate an API token and wire its hash up for `auth.tokens`."""
token = secrets.token_hex(32)
token_hash = f"sha256:{hashlib.sha256(token.encode()).hexdigest()}"
console.print("\n[yellow]Save this token now - it will not be shown again:[/yellow]")
console.print(f" [bold]{token}[/bold]")
console.print(f"Use it as: [dim]Authorization: Bearer {token}[/dim]")
if inline:
_print_snippet(
"Add this under `auth.tokens:` in your config file:",
{"name": name, "tokenHash": token_hash},
)
return
config_path = _resolve_config_path(config)
env_path = config_path.parent / ".env"
var_name = f"TRIGGERSHELL_TOKEN_{_slug(name)}_HASH"
set_key(str(env_path), var_name, token_hash)
console.print(f"[green]Stored[/green] {var_name} in {env_path}")
_print_snippet(
"Add this under `auth.tokens:` in your config file:",
{"name": name, "tokenHash": f"${{{var_name}}}"},
)
if __name__ == "__main__":
app()
-49
View File
@@ -1,49 +0,0 @@
"""Fast, dependency-light YAML pre-flight checks.
This intentionally does NOT re-implement the full config schema - that single source of truth
lives in `app/src/lib/config/schema.ts` (Zod) and is invoked via `triggershell validate`, which
shells out to Node. This module only catches obviously-broken configs (missing file, invalid
YAML, missing/duplicate script ids) fast, before we even touch Node/pnpm.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import yaml
class ConfigError(Exception):
pass
def preflight_check(config_path: Path) -> dict[str, Any]:
if not config_path.exists():
raise ConfigError(f"Config file not found at {config_path}")
try:
with config_path.open("r", encoding="utf-8") as f:
data = yaml.safe_load(f)
except yaml.YAMLError as e:
raise ConfigError(f"Failed to parse YAML: {e}") from e
if data is None:
data = {}
if not isinstance(data, dict):
raise ConfigError("Config file must be a YAML mapping at the top level")
scripts = data.get("scripts") or []
if not isinstance(scripts, list):
raise ConfigError("'scripts' must be a list")
seen: set[str] = set()
for index, script in enumerate(scripts):
if not isinstance(script, dict) or not script.get("id"):
raise ConfigError(f"scripts[{index}] is missing a non-empty 'id'")
script_id = script["id"]
if script_id in seen:
raise ConfigError(f"duplicate script id '{script_id}'")
seen.add(script_id)
return data
-30
View File
@@ -1,30 +0,0 @@
from __future__ import annotations
import socket
import time
import urllib.error
import urllib.request
import webbrowser
def is_port_free(host: str, port: int) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(0.5)
return sock.connect_ex((host, port)) != 0
def wait_until_ready(url: str, timeout: float = 30.0, interval: float = 0.4) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
with urllib.request.urlopen(url, timeout=1.5) as response: # noqa: S310
if response.status == 200:
return True
except (urllib.error.URLError, OSError, TimeoutError):
pass
time.sleep(interval)
return False
def open_browser(url: str) -> None:
webbrowser.open(url)
-47
View File
@@ -1,47 +0,0 @@
from __future__ import annotations
import signal
import subprocess
import sys
from pathlib import Path
def run_and_wait(cmd: list[str], cwd: Path, env: dict[str, str]) -> int:
"""Spawns `cmd`, forwards SIGINT/SIGTERM to it, and waits for it to exit.
The Node side (`server.ts`) is expected to handle SIGTERM itself: kill any in-flight script
subprocesses, close the HTTP/WS server, then exit - this just makes sure that happens instead
of the terminal's Ctrl-C leaving an orphaned Node process behind.
"""
process = subprocess.Popen(cmd, cwd=str(cwd), env=env)
def forward_signal(signum: int, _frame: object) -> None:
try:
process.send_signal(signum)
except ProcessLookupError:
return
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
signal.signal(signal.SIGINT, forward_signal)
signal.signal(signal.SIGTERM, forward_signal)
try:
return process.wait()
except KeyboardInterrupt:
forward_signal(signal.SIGINT, None)
return process.returncode if process.returncode is not None else 130
def run_blocking(cmd: list[str], cwd: Path, env: dict[str, str] | None = None) -> int:
result = subprocess.run(cmd, cwd=str(cwd), env=env, check=False)
return result.returncode
def die(message: str) -> None:
from rich.console import Console
Console().print(f"[red]Error:[/red] {message}")
sys.exit(1)