From 30350d80f49abcfd9051107f12ab3e3896f75b4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Sun, 16 Aug 2026 11:03:25 +0200 Subject: [PATCH] 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 --- .gitignore | 14 +- README.md | 35 +- app/LICENSE | 21 ++ app/README.md | 11 +- app/bin/triggershell.js | 5 + app/package.json | 31 +- app/pnpm-lock.yaml | 307 ++++++++++++++- app/scripts/validate-config.ts | 35 -- app/server.ts | 7 +- app/src/cli/commands/doctor.ts | 42 +++ app/src/cli/commands/init.ts | 50 +++ app/src/cli/commands/service.ts | 111 ++++++ app/src/cli/commands/start.ts | 59 +++ app/src/cli/commands/users.ts | 73 ++++ app/src/cli/commands/validate.ts | 28 ++ app/src/cli/index.ts | 87 +++++ app/src/cli/lib/env-file.test.ts | 50 +++ app/src/cli/lib/env-file.ts | 43 +++ app/src/cli/lib/network.ts | 48 +++ app/src/cli/lib/paths.ts | 23 ++ app/src/cli/lib/slug.test.ts | 9 + app/src/cli/lib/slug.ts | 6 + app/src/cli/lib/systemd.test.ts | 35 ++ app/src/cli/lib/systemd.ts | 54 +++ app/src/cli/version.ts | 9 + app/src/lib/config/load.ts | 2 +- .../templates/triggershell.yml | 0 docs/ARCHITECTURE.md | 45 ++- docs/CONFIG_REFERENCE.md | 3 +- pyproject.toml | 48 --- tests/test_bootstrap.py | 150 -------- tests/test_cli.py | 60 --- tests/test_config.py | 70 ---- triggershell/__init__.py | 1 - triggershell/__main__.py | 4 - triggershell/bootstrap.py | 170 --------- triggershell/cli.py | 353 ------------------ triggershell/config.py | 49 --- triggershell/network.py | 30 -- triggershell/process.py | 47 --- 40 files changed, 1154 insertions(+), 1071 deletions(-) create mode 100644 app/LICENSE create mode 100755 app/bin/triggershell.js delete mode 100644 app/scripts/validate-config.ts create mode 100644 app/src/cli/commands/doctor.ts create mode 100644 app/src/cli/commands/init.ts create mode 100644 app/src/cli/commands/service.ts create mode 100644 app/src/cli/commands/start.ts create mode 100644 app/src/cli/commands/users.ts create mode 100644 app/src/cli/commands/validate.ts create mode 100644 app/src/cli/index.ts create mode 100644 app/src/cli/lib/env-file.test.ts create mode 100644 app/src/cli/lib/env-file.ts create mode 100644 app/src/cli/lib/network.ts create mode 100644 app/src/cli/lib/paths.ts create mode 100644 app/src/cli/lib/slug.test.ts create mode 100644 app/src/cli/lib/slug.ts create mode 100644 app/src/cli/lib/systemd.test.ts create mode 100644 app/src/cli/lib/systemd.ts create mode 100644 app/src/cli/version.ts rename {triggershell => app}/templates/triggershell.yml (100%) delete mode 100644 pyproject.toml delete mode 100644 tests/test_bootstrap.py delete mode 100644 tests/test_cli.py delete mode 100644 tests/test_config.py delete mode 100644 triggershell/__init__.py delete mode 100644 triggershell/__main__.py delete mode 100644 triggershell/bootstrap.py delete mode 100644 triggershell/cli.py delete mode 100644 triggershell/config.py delete mode 100644 triggershell/network.py delete mode 100644 triggershell/process.py diff --git a/.gitignore b/.gitignore index 3250938..333f864 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,4 @@ -# 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) +# Runtime data (created by `triggershell start` in whatever directory the config lives in) .triggershell/ # Secrets loaded by `triggershell` via ${VAR} interpolation - never commit these diff --git a/README.md b/README.md index 46a9bf9..a2bbc9f 100644 --- a/README.md +++ b/README.md @@ -24,20 +24,22 @@ API for automation. ## Quickstart ```bash -pip install triggershell # or: pip install -e . from a checkout +npm install -g triggershell # or: npx triggershell for one-off use 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 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), -then run `triggershell start` for a production build. +Edit `triggershell.yml` to add your own scripts (see [Configuration](#configuration) below), then +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 -- Python >= 3.9 -- Node.js >= 20 (checked by the CLI; not auto-installed) -- pnpm (auto-provisioned via Corepack if missing and Corepack is available) +- Node.js >= 20 — the only thing you need installed. Everything else `triggershell` needs ships + inside the package itself and is resolved automatically when you install it. ## Configuration @@ -100,12 +102,14 @@ the script — always as a discrete argv element or env var, never interpolated | Command | Description | |---|---| | `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 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 validate [-c CONFIG]` | Validate a config file against the full schema | +| `triggershell start [-c CONFIG] [--port] [--host] [--no-browser]` | Run the web app | +| `triggershell doctor [-c CONFIG]` | Print environment/config diagnostics | | `triggershell users add [-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 [-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 @@ -145,19 +149,22 @@ Full reference with request/response shapes and curl examples: [`docs/API.md`](d ## 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 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 typecheck +pnpm --dir app test # CLI unit tests (src/cli/**/*.test.ts) 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 +app/ The published npm package: Next.js app + the `triggershell` CLI (bin/, src/cli/) in one examples/ A runnable example config + scripts docs/ Config/architecture/API reference docs ``` diff --git a/app/LICENSE b/app/LICENSE new file mode 100644 index 0000000..87a0f8f --- /dev/null +++ b/app/LICENSE @@ -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. diff --git a/app/README.md b/app/README.md index 26b03fc..6006832 100644 --- a/app/README.md +++ b/app/README.md @@ -1,8 +1,9 @@ # 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. +This directory is both the Next.js app and the home of the `triggershell` CLI (`bin/triggershell.js` +→ `src/cli`) that launches it — together they're published as one npm package. The app is still not +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 [`../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 lint pnpm typecheck +pnpm test # CLI unit tests (src/cli/**/*.test.ts) 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). diff --git a/app/bin/triggershell.js b/app/bin/triggershell.js new file mode 100755 index 0000000..c92d4ed --- /dev/null +++ b/app/bin/triggershell.js @@ -0,0 +1,5 @@ +#!/usr/bin/env node +import { register } from "tsx/esm/api"; + +register(); +await import("../src/cli/index.ts"); diff --git a/app/package.json b/app/package.json index 84b7c2b..854b524 100644 --- a/app/package.json +++ b/app/package.json @@ -1,13 +1,31 @@ { - "name": "app", + "name": "triggershell", "version": "0.1.0", - "private": true, + "license": "MIT", "type": "module", "repository": { "type": "git", "url": "https://dev.pivoine.art/valknar/triggershell.git", "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": { "dev": "tsx watch server.ts", "build": "next build", @@ -15,13 +33,15 @@ "lint": "eslint", "typecheck": "tsc --noEmit", "format": "prettier --write .", - "validate-config": "tsx scripts/validate-config.ts", + "test": "tsx --test \"src/cli/**/*.test.ts\"", "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": { "@base-ui/react": "^1.7.0", "@hookform/resolvers": "^5.8.0", + "@inquirer/prompts": "^8.5.2", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "argon2": "^0.45.1", @@ -29,6 +49,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "commander": "^15.0.0", "drizzle-orm": "^0.45.2", "execa": "^10.0.1", "iron-session": "^8.0.4", @@ -43,6 +64,7 @@ "shadcn": "^4.18.0", "sonner": "^2.0.8", "tailwind-merge": "^3.6.0", + "tsx": "^4.23.12", "tw-animate-css": "^1.4.0", "ws": "^8.21.3", "yaml": "^2.9.0", @@ -63,7 +85,6 @@ "prettier": "^3.9.6", "prettier-plugin-tailwindcss": "^0.8.1", "tailwindcss": "^4", - "tsx": "^4.23.12", "typescript": "^5" }, "packageManager": "pnpm@11.21.0" diff --git a/app/pnpm-lock.yaml b/app/pnpm-lock.yaml index 47b6a73..0c9a49f 100644 --- a/app/pnpm-lock.yaml +++ b/app/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@hookform/resolvers': 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) + '@inquirer/prompts': + specifier: ^8.5.2 + version: 8.5.2(@types/node@20.19.43) '@xterm/addon-fit': specifier: ^0.11.0 version: 0.11.0 @@ -35,6 +38,9 @@ importers: cmdk: 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) + commander: + specifier: ^15.0.0 + version: 15.0.0 drizzle-orm: specifier: ^0.45.2 version: 0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3) @@ -77,6 +83,9 @@ importers: tailwind-merge: specifier: ^3.6.0 version: 3.6.0 + tsx: + specifier: ^4.23.12 + version: 4.23.12 tw-animate-css: specifier: ^1.4.0 version: 1.4.0 @@ -132,9 +141,6 @@ importers: tailwindcss: specifier: ^4 version: 4.3.3 - tsx: - specifier: ^4.23.12 - version: 4.23.12 typescript: specifier: ^5 version: 5.9.3 @@ -1107,6 +1113,140 @@ packages: cpu: [x64] 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': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1955,6 +2095,9 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chardet@2.2.0: + resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -1966,6 +2109,10 @@ packages: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -2000,6 +2147,10 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -2551,9 +2702,18 @@ packages: fast-levenshtein@2.0.6: 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: resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -3381,6 +3541,10 @@ packages: ms@2.1.3: 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: resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -5067,6 +5231,125 @@ snapshots: '@img/sharp-win32-x64@0.35.3': 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': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5851,6 +6134,8 @@ snapshots: character-reference-invalid@2.0.1: {} + chardet@2.2.0: {} + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -5861,6 +6146,8 @@ snapshots: cli-spinners@2.9.2: {} + cli-width@4.1.0: {} + client-only@0.0.1: {} clsx@2.1.1: {} @@ -5891,6 +6178,8 @@ snapshots: commander@14.0.3: {} + commander@15.0.0: {} + concat-map@0.0.1: {} conf@10.2.0: @@ -6610,8 +6899,18 @@ snapshots: 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-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -7581,6 +7880,8 @@ snapshots: ms@2.1.3: {} + mute-stream@3.0.0: {} + nanoid@3.3.18: {} napi-postinstall@0.3.4: {} diff --git a/app/scripts/validate-config.ts b/app/scripts/validate-config.ts deleted file mode 100644 index ddb9a63..0000000 --- a/app/scripts/validate-config.ts +++ /dev/null @@ -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); -} diff --git a/app/server.ts b/app/server.ts index f7714e8..6d59acc 100644 --- a/app/server.ts +++ b/app/server.ts @@ -1,5 +1,7 @@ import "./src/bootstrap/async-local-storage-polyfill"; import { createServer } from "node:http"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import next from "next"; import { WebSocketServer } from "ws"; import { getConfig } from "./src/lib/config/load"; @@ -19,7 +21,10 @@ migrateOnBoot(); syncAuthFromConfig(); 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(); app.prepare().then(() => { diff --git a/app/src/cli/commands/doctor.ts b/app/src/cli/commands/doctor.ts new file mode 100644 index 0000000..bbd300e --- /dev/null +++ b/app/src/cli/commands/doctor.ts @@ -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 { + 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}`); + } +} diff --git a/app/src/cli/commands/init.ts b/app/src/cli/commands/init.ts new file mode 100644 index 0000000..65e2604 --- /dev/null +++ b/app/src/cli/commands/init.ts @@ -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 { + 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 --config ${configPath}`); + } + + console.log("\nStart the app with:"); + console.log(` triggershell start --config ${configPath}`); +} diff --git a/app/src/cli/commands/service.ts b/app/src/cli/commands/service.ts new file mode 100644 index 0000000..32a6133 --- /dev/null +++ b/app/src/cli/commands/service.ts @@ -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 { + 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 { + 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 { + 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; +} diff --git a/app/src/cli/commands/start.ts b/app/src/cli/commands/start.ts new file mode 100644 index 0000000..1d1bc15 --- /dev/null +++ b/app/src/cli/commands/start.ts @@ -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 { + 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); +} diff --git a/app/src/cli/commands/users.ts b/app/src/cli/commands/users.ts new file mode 100644 index 0000000..7151f0e --- /dev/null +++ b/app/src/cli/commands/users.ts @@ -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): void { + console.log(`\n${heading}\n`); + console.log(stringify([entry])); +} + +async function promptNewPassword(): Promise { + 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 { + 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 { + 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}}`, + }); +} diff --git a/app/src/cli/commands/validate.ts b/app/src/cli/commands/validate.ts new file mode 100644 index 0000000..9f7882e --- /dev/null +++ b/app/src/cli/commands/validate.ts @@ -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 { + 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; + } +} diff --git a/app/src/cli/index.ts b/app/src/cli/index.ts new file mode 100644 index 0000000..c2e6c08 --- /dev/null +++ b/app/src/cli/index.ts @@ -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 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 to the config file.") + .action(validateCommand); + +program + .command("start") + .description("Run the web app in production mode.") + .option("-c, --config ", "Path to the config file.") + .option("--port ", "Override the port from the config file.", (v) => Number(v)) + .option("--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 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 ") + .description("Hash a password with argon2id and wire it up for auth.users.") + .option("-c, --config ", "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 ") + .description("Generate an API token and wire its hash up for auth.tokens.") + .option("-c, --config ", "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 to the config file.") + .option("--port ", "Override the port from the config file.", (v) => Number(v)) + .option("--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); diff --git a/app/src/cli/lib/env-file.test.ts b/app/src/cli/lib/env-file.test.ts new file mode 100644 index 0000000..3ef5523 --- /dev/null +++ b/app/src/cli/lib/env-file.test.ts @@ -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; +}); diff --git a/app/src/cli/lib/env-file.ts b/app/src/cli/lib/env-file.ts new file mode 100644 index 0000000..59c87e3 --- /dev/null +++ b/app/src/cli/lib/env-file.ts @@ -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"); +} diff --git a/app/src/cli/lib/network.ts b/app/src/cli/lib/network.ts new file mode 100644 index 0000000..7db6b99 --- /dev/null +++ b/app/src/cli/lib/network.ts @@ -0,0 +1,48 @@ +import { spawn } from "node:child_process"; +import net from "node:net"; + +export function isPortFree(host: string, port: number): Promise { + 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 { + 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 + } +} diff --git a/app/src/cli/lib/paths.ts b/app/src/cli/lib/paths.ts new file mode 100644 index 0000000..8031a10 --- /dev/null +++ b/app/src/cli/lib/paths.ts @@ -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 `), 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]); +} diff --git a/app/src/cli/lib/slug.test.ts b/app/src/cli/lib/slug.test.ts new file mode 100644 index 0000000..d979e2b --- /dev/null +++ b/app/src/cli/lib/slug.test.ts @@ -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"); +}); diff --git a/app/src/cli/lib/slug.ts b/app/src/cli/lib/slug.ts new file mode 100644 index 0000000..82240d8 --- /dev/null +++ b/app/src/cli/lib/slug.ts @@ -0,0 +1,6 @@ +export function slug(value: string): string { + return value + .replace(/[^A-Za-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + .toUpperCase(); +} diff --git a/app/src/cli/lib/systemd.test.ts b/app/src/cli/lib/systemd.test.ts new file mode 100644 index 0000000..0559c5f --- /dev/null +++ b/app/src/cli/lib/systemd.test.ts @@ -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); +}); diff --git a/app/src/cli/lib/systemd.ts b/app/src/cli/lib/systemd.ts new file mode 100644 index 0000000..fea3039 --- /dev/null +++ b/app/src/cli/lib/systemd.ts @@ -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; +} diff --git a/app/src/cli/version.ts b/app/src/cli/version.ts new file mode 100644 index 0000000..0501b1c --- /dev/null +++ b/app/src/cli/version.ts @@ -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; +} diff --git a/app/src/lib/config/load.ts b/app/src/lib/config/load.ts index 669e1eb..464c97c 100644 --- a/app/src/lib/config/load.ts +++ b/app/src/lib/config/load.ts @@ -56,7 +56,7 @@ export function loadConfig(configPathArg?: string): LoadedConfig { let parsedYaml: unknown; try { - parsedYaml = parseYaml(interpolated); + parsedYaml = parseYaml(interpolated) ?? {}; } catch (error) { throw new ConfigError(`Failed to parse YAML: ${(error as Error).message}`); } diff --git a/triggershell/templates/triggershell.yml b/app/templates/triggershell.yml similarity index 100% rename from triggershell/templates/triggershell.yml rename to app/templates/triggershell.yml diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1ee25a1..f938f22 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,14 +1,15 @@ # Architecture ``` - triggershell (Python CLI) app/ (Next.js, all server logic) - ─────────────────────── ────────────────────────────── - triggershell dev|start server.ts (custom Node server) - 1. resolve + pre-flight the config ├─ Next.js request handler (pages, API routes) - 2. check node/pnpm, `pnpm install` if stale ├─ ws.WebSocketServer on /ws/runs - 3. set TRIGGERSHELL_CONFIG_PATH/PORT/HOST env └─ boot: migrate DB, sync auth, reconcile runs - 4. spawn `pnpm run dev|start`, forward signals - 5. poll /api/healthz, open browser │ + triggershell (Node CLI, src/cli) app/ (Next.js, all server logic) + ─────────────────────────────── ────────────────────────────── + triggershell start server.ts (custom Node server) + 1. resolve + validate the config (loadConfig) ├─ Next.js request handler (pages, API routes) + 2. check the port is free ├─ ws.WebSocketServer on /ws/runs + 3. set TRIGGERSHELL_CONFIG_PATH/PORT/HOST/ └─ boot: migrate DB, sync auth, reconcile runs + NODE_ENV + 4. import("./server.ts") — same process │ + 5. poll /api/healthz, open browser │ ┌──────────┴──────────┐ REST API WebSocket (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 `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) -is naturally a Node problem — `execa` for argv-safe spawning, `ws` for streaming, Next for the UI. -Python's job is just: get Node/pnpm ready, validate the config fast, and manage the child process's -lifecycle (signals, readiness, browser launch) — a CLI concern, not a web-server concern. +`triggershell start` (`src/cli/commands/start.ts`) doesn't spawn `server.ts` as a child process — +it sets `process.env` (`TRIGGERSHELL_CONFIG_PATH`, `PORT`, `HOST`, `NODE_ENV`) and then dynamically +`import()`s `server.ts` directly, in the same Node process. `server.ts` reads that env and installs +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 diff --git a/docs/CONFIG_REFERENCE.md b/docs/CONFIG_REFERENCE.md index 0b8118e..b15ea7a 100644 --- a/docs/CONFIG_REFERENCE.md +++ b/docs/CONFIG_REFERENCE.md @@ -11,7 +11,8 @@ secrets referenced via `${VAR}` don't have to be committed alongside the config. init` scaffolds both files together. 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` diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 040d110..0000000 --- a/pyproject.toml +++ /dev/null @@ -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"] diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py deleted file mode 100644 index f23a6b3..0000000 --- a/tests/test_bootstrap.py +++ /dev/null @@ -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" diff --git a/tests/test_cli.py b/tests/test_cli.py deleted file mode 100644 index 1d5c102..0000000 --- a/tests/test_cli.py +++ /dev/null @@ -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 diff --git a/tests/test_config.py b/tests/test_config.py deleted file mode 100644 index 4518401..0000000 --- a/tests/test_config.py +++ /dev/null @@ -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" diff --git a/triggershell/__init__.py b/triggershell/__init__.py deleted file mode 100644 index 3dc1f76..0000000 --- a/triggershell/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = "0.1.0" diff --git a/triggershell/__main__.py b/triggershell/__main__.py deleted file mode 100644 index 84759a4..0000000 --- a/triggershell/__main__.py +++ /dev/null @@ -1,4 +0,0 @@ -from triggershell.cli import app - -if __name__ == "__main__": - app() diff --git a/triggershell/bootstrap.py b/triggershell/bootstrap.py deleted file mode 100644 index 1e4d6f6..0000000 --- a/triggershell/bootstrap.py +++ /dev/null @@ -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) diff --git a/triggershell/cli.py b/triggershell/cli.py deleted file mode 100644 index 7ae47cd..0000000 --- a/triggershell/cli.py +++ /dev/null @@ -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 --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() diff --git a/triggershell/config.py b/triggershell/config.py deleted file mode 100644 index b6bb680..0000000 --- a/triggershell/config.py +++ /dev/null @@ -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 diff --git a/triggershell/network.py b/triggershell/network.py deleted file mode 100644 index 0cdf920..0000000 --- a/triggershell/network.py +++ /dev/null @@ -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) diff --git a/triggershell/process.py b/triggershell/process.py deleted file mode 100644 index 956317d..0000000 --- a/triggershell/process.py +++ /dev/null @@ -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)