Replace the Python CLI with a Node CLI, add a systemd service command
The app is already 100% Node, so the Python launcher was pure overhead - it existed mainly to bootstrap Node, which is circular. The CLI is now merged into app/ (the single published npm package): `triggershell start` validates the config and imports server.ts directly in-process, so server.ts's own SIGTERM/SIGINT handling just works with no signal-relay/child-process layer needed. `dev` is dropped from the public CLI (contributors use `pnpm --dir app dev` directly); there's no `build` command either, since the package ships a prebuilt `.next` via a `prepack` hook. Adds `triggershell service install|uninstall|status` for running as a per-user or system systemd unit. Also fixes two bugs found while wiring this up: server.ts resolved `.next` relative to `process.cwd()`, which broke once the CLI could run from a directory other than the app itself; and an explicitly-`files`-listed package directory bypasses .npmignore for its subpaths, so `.next/cache` was inflating the npm tarball to ~670MB (now stripped in `prepack`, ~7MB). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+1
-13
@@ -1,16 +1,4 @@
|
|||||||
# Python
|
# Runtime data (created by `triggershell start` in whatever directory the config lives in)
|
||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
*.egg-info/
|
|
||||||
.eggs/
|
|
||||||
build/
|
|
||||||
dist/
|
|
||||||
.venv/
|
|
||||||
venv/
|
|
||||||
.pytest_cache/
|
|
||||||
.ruff_cache/
|
|
||||||
|
|
||||||
# Runtime data (created by `triggershell start/dev` in whatever directory the config lives in)
|
|
||||||
.triggershell/
|
.triggershell/
|
||||||
|
|
||||||
# Secrets loaded by `triggershell` via ${VAR} interpolation - never commit these
|
# Secrets loaded by `triggershell` via ${VAR} interpolation - never commit these
|
||||||
|
|||||||
@@ -24,20 +24,22 @@ API for automation.
|
|||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install triggershell # or: pip install -e . from a checkout
|
npm install -g triggershell # or: npx triggershell <command> for one-off use
|
||||||
triggershell init # scaffold triggershell.yml (+ .env for secrets) in the current directory
|
triggershell init # scaffold triggershell.yml (+ .env for secrets) in the current directory
|
||||||
triggershell users add admin # create a login (skip if you set auth.enabled: false)
|
triggershell users add admin # create a login (skip if you set auth.enabled: false)
|
||||||
triggershell dev # start in dev mode and open the browser
|
triggershell start # start the app and open the browser
|
||||||
```
|
```
|
||||||
|
|
||||||
Edit `triggershell.yml` to add your own scripts (see [Configuration](#configuration) below),
|
Edit `triggershell.yml` to add your own scripts (see [Configuration](#configuration) below), then
|
||||||
then run `triggershell start` for a production build.
|
re-run `triggershell start`.
|
||||||
|
|
||||||
|
> The `triggershell` package isn't published to npm yet. Until it is, build and link a local copy
|
||||||
|
> instead: `pnpm --dir app install && pnpm --dir app build && pnpm --dir app link --global`.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Python >= 3.9
|
- Node.js >= 20 — the only thing you need installed. Everything else `triggershell` needs ships
|
||||||
- Node.js >= 20 (checked by the CLI; not auto-installed)
|
inside the package itself and is resolved automatically when you install it.
|
||||||
- pnpm (auto-provisioned via Corepack if missing and Corepack is available)
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -100,12 +102,14 @@ the script — always as a discrete argv element or env var, never interpolated
|
|||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `triggershell init [PATH]` | Scaffold a new config file + `.env` (`--port`, `--auth/--no-auth`, `--force`) |
|
| `triggershell init [PATH]` | Scaffold a new config file + `.env` (`--port`, `--auth/--no-auth`, `--force`) |
|
||||||
| `triggershell validate [-c CONFIG]` | Validate a config file (fast Python pre-flight + full Node/Zod schema) |
|
| `triggershell validate [-c CONFIG]` | Validate a config file against the full schema |
|
||||||
| `triggershell dev [-c CONFIG] [--port] [--host] [--no-browser]` | Run in development mode (hot reload) |
|
| `triggershell start [-c CONFIG] [--port] [--host] [--no-browser]` | Run the web app |
|
||||||
| `triggershell start [-c CONFIG] [--port] [--host] [--no-browser] [--skip-build]` | Build (if stale) and run in production mode |
|
| `triggershell doctor [-c CONFIG]` | Print environment/config diagnostics |
|
||||||
| `triggershell doctor` | Print environment/config diagnostics |
|
|
||||||
| `triggershell users add <username> [-c CONFIG] [--inline]` | Hash a password, store it in `.env`, and print a `${VAR}` snippet for `auth.users` (`--inline` prints the raw hash instead) |
|
| `triggershell users add <username> [-c CONFIG] [--inline]` | Hash a password, store it in `.env`, and print a `${VAR}` snippet for `auth.users` (`--inline` prints the raw hash instead) |
|
||||||
| `triggershell users add-token <name> [-c CONFIG] [--inline]` | Generate an API token, store its hash in `.env`, and print a `${VAR}` snippet for `auth.tokens` (`--inline` prints the raw hash instead) |
|
| `triggershell users add-token <name> [-c CONFIG] [--inline]` | Generate an API token, store its hash in `.env`, and print a `${VAR}` snippet for `auth.tokens` (`--inline` prints the raw hash instead) |
|
||||||
|
| `triggershell service install [--system]` | Install a systemd unit that runs `triggershell start` (per-user by default, Linux only) |
|
||||||
|
| `triggershell service uninstall [--system]` | Stop, disable, and remove the systemd unit |
|
||||||
|
| `triggershell service status [--system]` | Show the systemd unit's status |
|
||||||
|
|
||||||
## Web App Guide
|
## Web App Guide
|
||||||
|
|
||||||
@@ -145,19 +149,22 @@ Full reference with request/response shapes and curl examples: [`docs/API.md`](d
|
|||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
This is the workflow for working on TriggerShell itself, not for installing/running it — it
|
||||||
|
bypasses the CLI entirely and talks to `app/`'s own scripts directly, with hot reload:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm --dir app install
|
pnpm --dir app install
|
||||||
pnpm --dir app dev # or: triggershell dev, which wraps this
|
pnpm --dir app dev # tsx watch server.ts - reads TRIGGERSHELL_CONFIG_PATH from the environment
|
||||||
pnpm --dir app lint
|
pnpm --dir app lint
|
||||||
pnpm --dir app typecheck
|
pnpm --dir app typecheck
|
||||||
|
pnpm --dir app test # CLI unit tests (src/cli/**/*.test.ts)
|
||||||
pnpm --dir app db:studio # browse the SQLite DB
|
pnpm --dir app db:studio # browse the SQLite DB
|
||||||
```
|
```
|
||||||
|
|
||||||
Repo layout:
|
Repo layout:
|
||||||
|
|
||||||
```
|
```
|
||||||
triggershell/ Python CLI (launcher/orchestrator only)
|
app/ The published npm package: Next.js app + the `triggershell` CLI (bin/, src/cli/) in one
|
||||||
app/ Next.js app - all server logic (API, auth, script execution) lives here
|
|
||||||
examples/ A runnable example config + scripts
|
examples/ A runnable example config + scripts
|
||||||
docs/ Config/architecture/API reference docs
|
docs/ Config/architecture/API reference docs
|
||||||
```
|
```
|
||||||
|
|||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 TriggerShell contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
+8
-3
@@ -1,8 +1,9 @@
|
|||||||
# TriggerShell web app
|
# TriggerShell web app
|
||||||
|
|
||||||
This is the Next.js app that TriggerShell's Python CLI (`triggershell dev` / `triggershell start`)
|
This directory is both the Next.js app and the home of the `triggershell` CLI (`bin/triggershell.js`
|
||||||
launches — it's not meant to be run standalone with `next dev`/`next start` since it needs a
|
→ `src/cli`) that launches it — together they're published as one npm package. The app is still not
|
||||||
custom server (`server.ts`) for the WebSocket endpoint.
|
meant to be run standalone with `next dev`/`next start`, since it needs a custom server (`server.ts`)
|
||||||
|
for the WebSocket endpoint; use the CLI (`triggershell start`) or the `pnpm` scripts below instead.
|
||||||
|
|
||||||
See the [repo root README](../README.md) for how to run TriggerShell end-to-end, and
|
See the [repo root README](../README.md) for how to run TriggerShell end-to-end, and
|
||||||
[`../docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) for how this app is put together.
|
[`../docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) for how this app is put together.
|
||||||
@@ -12,5 +13,9 @@ pnpm install
|
|||||||
pnpm dev # tsx watch server.ts - reads TRIGGERSHELL_CONFIG_PATH from the environment
|
pnpm dev # tsx watch server.ts - reads TRIGGERSHELL_CONFIG_PATH from the environment
|
||||||
pnpm lint
|
pnpm lint
|
||||||
pnpm typecheck
|
pnpm typecheck
|
||||||
|
pnpm test # CLI unit tests (src/cli/**/*.test.ts)
|
||||||
pnpm db:studio # browse the SQLite database
|
pnpm db:studio # browse the SQLite database
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Note: this is the contributor workflow for developing TriggerShell itself. End users install and
|
||||||
|
run the published `triggershell` CLI instead (see the root README).
|
||||||
|
|||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { register } from "tsx/esm/api";
|
||||||
|
|
||||||
|
register();
|
||||||
|
await import("../src/cli/index.ts");
|
||||||
+26
-5
@@ -1,13 +1,31 @@
|
|||||||
{
|
{
|
||||||
"name": "app",
|
"name": "triggershell",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://dev.pivoine.art/valknar/triggershell.git",
|
"url": "https://dev.pivoine.art/valknar/triggershell.git",
|
||||||
"directory": "app"
|
"directory": "app"
|
||||||
},
|
},
|
||||||
|
"bin": {
|
||||||
|
"triggershell": "bin/triggershell.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"bin",
|
||||||
|
"src",
|
||||||
|
"templates",
|
||||||
|
"drizzle",
|
||||||
|
".next",
|
||||||
|
"server.ts",
|
||||||
|
"next.config.ts",
|
||||||
|
"next-env.d.ts",
|
||||||
|
"tsconfig.json",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch server.ts",
|
"dev": "tsx watch server.ts",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
@@ -15,13 +33,15 @@
|
|||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"validate-config": "tsx scripts/validate-config.ts",
|
"test": "tsx --test \"src/cli/**/*.test.ts\"",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:studio": "drizzle-kit studio"
|
"db:studio": "drizzle-kit studio",
|
||||||
|
"prepack": "rm -rf .next && next build && rm -rf .next/cache"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.7.0",
|
"@base-ui/react": "^1.7.0",
|
||||||
"@hookform/resolvers": "^5.8.0",
|
"@hookform/resolvers": "^5.8.0",
|
||||||
|
"@inquirer/prompts": "^8.5.2",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"argon2": "^0.45.1",
|
"argon2": "^0.45.1",
|
||||||
@@ -29,6 +49,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"commander": "^15.0.0",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"execa": "^10.0.1",
|
"execa": "^10.0.1",
|
||||||
"iron-session": "^8.0.4",
|
"iron-session": "^8.0.4",
|
||||||
@@ -43,6 +64,7 @@
|
|||||||
"shadcn": "^4.18.0",
|
"shadcn": "^4.18.0",
|
||||||
"sonner": "^2.0.8",
|
"sonner": "^2.0.8",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tsx": "^4.23.12",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"ws": "^8.21.3",
|
"ws": "^8.21.3",
|
||||||
"yaml": "^2.9.0",
|
"yaml": "^2.9.0",
|
||||||
@@ -63,7 +85,6 @@
|
|||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
"prettier-plugin-tailwindcss": "^0.8.1",
|
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"tsx": "^4.23.12",
|
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.21.0"
|
"packageManager": "pnpm@11.21.0"
|
||||||
|
|||||||
Generated
+304
-3
@@ -14,6 +14,9 @@ importers:
|
|||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: ^5.8.0
|
specifier: ^5.8.0
|
||||||
version: 5.8.0(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(react-hook-form@7.85.0(react@19.2.8))(zod@4.4.3)
|
version: 5.8.0(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(react-hook-form@7.85.0(react@19.2.8))(zod@4.4.3)
|
||||||
|
'@inquirer/prompts':
|
||||||
|
specifier: ^8.5.2
|
||||||
|
version: 8.5.2(@types/node@20.19.43)
|
||||||
'@xterm/addon-fit':
|
'@xterm/addon-fit':
|
||||||
specifier: ^0.11.0
|
specifier: ^0.11.0
|
||||||
version: 0.11.0
|
version: 0.11.0
|
||||||
@@ -35,6 +38,9 @@ importers:
|
|||||||
cmdk:
|
cmdk:
|
||||||
specifier: ^1.1.1
|
specifier: ^1.1.1
|
||||||
version: 1.1.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
version: 1.1.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||||
|
commander:
|
||||||
|
specifier: ^15.0.0
|
||||||
|
version: 15.0.0
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.45.2
|
specifier: ^0.45.2
|
||||||
version: 0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)
|
version: 0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)
|
||||||
@@ -77,6 +83,9 @@ importers:
|
|||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.6.0
|
specifier: ^3.6.0
|
||||||
version: 3.6.0
|
version: 3.6.0
|
||||||
|
tsx:
|
||||||
|
specifier: ^4.23.12
|
||||||
|
version: 4.23.12
|
||||||
tw-animate-css:
|
tw-animate-css:
|
||||||
specifier: ^1.4.0
|
specifier: ^1.4.0
|
||||||
version: 1.4.0
|
version: 1.4.0
|
||||||
@@ -132,9 +141,6 @@ importers:
|
|||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4
|
specifier: ^4
|
||||||
version: 4.3.3
|
version: 4.3.3
|
||||||
tsx:
|
|
||||||
specifier: ^4.23.12
|
|
||||||
version: 4.23.12
|
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5
|
specifier: ^5
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
@@ -1107,6 +1113,140 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@inquirer/ansi@2.0.7':
|
||||||
|
resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==}
|
||||||
|
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||||
|
|
||||||
|
'@inquirer/checkbox@5.2.1':
|
||||||
|
resolution: {integrity: sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==}
|
||||||
|
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': '>=18'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@inquirer/confirm@6.1.1':
|
||||||
|
resolution: {integrity: sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==}
|
||||||
|
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': '>=18'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@inquirer/core@11.2.1':
|
||||||
|
resolution: {integrity: sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==}
|
||||||
|
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': '>=18'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@inquirer/editor@5.2.2':
|
||||||
|
resolution: {integrity: sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==}
|
||||||
|
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': '>=18'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@inquirer/expand@5.1.1':
|
||||||
|
resolution: {integrity: sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==}
|
||||||
|
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': '>=18'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@inquirer/external-editor@3.0.3':
|
||||||
|
resolution: {integrity: sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==}
|
||||||
|
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': '>=18'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@inquirer/figures@2.0.7':
|
||||||
|
resolution: {integrity: sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==}
|
||||||
|
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||||
|
|
||||||
|
'@inquirer/input@5.1.2':
|
||||||
|
resolution: {integrity: sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==}
|
||||||
|
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': '>=18'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@inquirer/number@4.1.1':
|
||||||
|
resolution: {integrity: sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==}
|
||||||
|
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': '>=18'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@inquirer/password@5.1.1':
|
||||||
|
resolution: {integrity: sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==}
|
||||||
|
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': '>=18'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@inquirer/prompts@8.5.2':
|
||||||
|
resolution: {integrity: sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==}
|
||||||
|
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': '>=18'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@inquirer/rawlist@5.3.1':
|
||||||
|
resolution: {integrity: sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==}
|
||||||
|
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': '>=18'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@inquirer/search@4.2.1':
|
||||||
|
resolution: {integrity: sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==}
|
||||||
|
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': '>=18'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@inquirer/select@5.2.1':
|
||||||
|
resolution: {integrity: sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==}
|
||||||
|
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': '>=18'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@inquirer/type@4.0.7':
|
||||||
|
resolution: {integrity: sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==}
|
||||||
|
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': '>=18'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||||
|
|
||||||
@@ -1955,6 +2095,9 @@ packages:
|
|||||||
character-reference-invalid@2.0.1:
|
character-reference-invalid@2.0.1:
|
||||||
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
|
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
|
||||||
|
|
||||||
|
chardet@2.2.0:
|
||||||
|
resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==}
|
||||||
|
|
||||||
class-variance-authority@0.7.1:
|
class-variance-authority@0.7.1:
|
||||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||||
|
|
||||||
@@ -1966,6 +2109,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==}
|
resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
cli-width@4.1.0:
|
||||||
|
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
|
||||||
|
engines: {node: '>= 12'}
|
||||||
|
|
||||||
client-only@0.0.1:
|
client-only@0.0.1:
|
||||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||||
|
|
||||||
@@ -2000,6 +2147,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
commander@15.0.0:
|
||||||
|
resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==}
|
||||||
|
engines: {node: '>=22.12.0'}
|
||||||
|
|
||||||
concat-map@0.0.1:
|
concat-map@0.0.1:
|
||||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||||
|
|
||||||
@@ -2551,9 +2702,18 @@ packages:
|
|||||||
fast-levenshtein@2.0.6:
|
fast-levenshtein@2.0.6:
|
||||||
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
||||||
|
|
||||||
|
fast-string-truncated-width@3.0.3:
|
||||||
|
resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==}
|
||||||
|
|
||||||
|
fast-string-width@3.0.2:
|
||||||
|
resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==}
|
||||||
|
|
||||||
fast-uri@3.1.5:
|
fast-uri@3.1.5:
|
||||||
resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==}
|
resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==}
|
||||||
|
|
||||||
|
fast-wrap-ansi@0.2.2:
|
||||||
|
resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==}
|
||||||
|
|
||||||
fastq@1.20.1:
|
fastq@1.20.1:
|
||||||
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
||||||
|
|
||||||
@@ -3381,6 +3541,10 @@ packages:
|
|||||||
ms@2.1.3:
|
ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
|
mute-stream@3.0.0:
|
||||||
|
resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==}
|
||||||
|
engines: {node: ^20.17.0 || >=22.9.0}
|
||||||
|
|
||||||
nanoid@3.3.18:
|
nanoid@3.3.18:
|
||||||
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
|
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
@@ -5067,6 +5231,125 @@ snapshots:
|
|||||||
'@img/sharp-win32-x64@0.35.3':
|
'@img/sharp-win32-x64@0.35.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@inquirer/ansi@2.0.7': {}
|
||||||
|
|
||||||
|
'@inquirer/checkbox@5.2.1(@types/node@20.19.43)':
|
||||||
|
dependencies:
|
||||||
|
'@inquirer/ansi': 2.0.7
|
||||||
|
'@inquirer/core': 11.2.1(@types/node@20.19.43)
|
||||||
|
'@inquirer/figures': 2.0.7
|
||||||
|
'@inquirer/type': 4.0.7(@types/node@20.19.43)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 20.19.43
|
||||||
|
|
||||||
|
'@inquirer/confirm@6.1.1(@types/node@20.19.43)':
|
||||||
|
dependencies:
|
||||||
|
'@inquirer/core': 11.2.1(@types/node@20.19.43)
|
||||||
|
'@inquirer/type': 4.0.7(@types/node@20.19.43)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 20.19.43
|
||||||
|
|
||||||
|
'@inquirer/core@11.2.1(@types/node@20.19.43)':
|
||||||
|
dependencies:
|
||||||
|
'@inquirer/ansi': 2.0.7
|
||||||
|
'@inquirer/figures': 2.0.7
|
||||||
|
'@inquirer/type': 4.0.7(@types/node@20.19.43)
|
||||||
|
cli-width: 4.1.0
|
||||||
|
fast-wrap-ansi: 0.2.2
|
||||||
|
mute-stream: 3.0.0
|
||||||
|
signal-exit: 4.1.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 20.19.43
|
||||||
|
|
||||||
|
'@inquirer/editor@5.2.2(@types/node@20.19.43)':
|
||||||
|
dependencies:
|
||||||
|
'@inquirer/core': 11.2.1(@types/node@20.19.43)
|
||||||
|
'@inquirer/external-editor': 3.0.3(@types/node@20.19.43)
|
||||||
|
'@inquirer/type': 4.0.7(@types/node@20.19.43)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 20.19.43
|
||||||
|
|
||||||
|
'@inquirer/expand@5.1.1(@types/node@20.19.43)':
|
||||||
|
dependencies:
|
||||||
|
'@inquirer/core': 11.2.1(@types/node@20.19.43)
|
||||||
|
'@inquirer/type': 4.0.7(@types/node@20.19.43)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 20.19.43
|
||||||
|
|
||||||
|
'@inquirer/external-editor@3.0.3(@types/node@20.19.43)':
|
||||||
|
dependencies:
|
||||||
|
chardet: 2.2.0
|
||||||
|
iconv-lite: 0.7.3
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 20.19.43
|
||||||
|
|
||||||
|
'@inquirer/figures@2.0.7': {}
|
||||||
|
|
||||||
|
'@inquirer/input@5.1.2(@types/node@20.19.43)':
|
||||||
|
dependencies:
|
||||||
|
'@inquirer/core': 11.2.1(@types/node@20.19.43)
|
||||||
|
'@inquirer/type': 4.0.7(@types/node@20.19.43)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 20.19.43
|
||||||
|
|
||||||
|
'@inquirer/number@4.1.1(@types/node@20.19.43)':
|
||||||
|
dependencies:
|
||||||
|
'@inquirer/core': 11.2.1(@types/node@20.19.43)
|
||||||
|
'@inquirer/type': 4.0.7(@types/node@20.19.43)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 20.19.43
|
||||||
|
|
||||||
|
'@inquirer/password@5.1.1(@types/node@20.19.43)':
|
||||||
|
dependencies:
|
||||||
|
'@inquirer/ansi': 2.0.7
|
||||||
|
'@inquirer/core': 11.2.1(@types/node@20.19.43)
|
||||||
|
'@inquirer/type': 4.0.7(@types/node@20.19.43)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 20.19.43
|
||||||
|
|
||||||
|
'@inquirer/prompts@8.5.2(@types/node@20.19.43)':
|
||||||
|
dependencies:
|
||||||
|
'@inquirer/checkbox': 5.2.1(@types/node@20.19.43)
|
||||||
|
'@inquirer/confirm': 6.1.1(@types/node@20.19.43)
|
||||||
|
'@inquirer/editor': 5.2.2(@types/node@20.19.43)
|
||||||
|
'@inquirer/expand': 5.1.1(@types/node@20.19.43)
|
||||||
|
'@inquirer/input': 5.1.2(@types/node@20.19.43)
|
||||||
|
'@inquirer/number': 4.1.1(@types/node@20.19.43)
|
||||||
|
'@inquirer/password': 5.1.1(@types/node@20.19.43)
|
||||||
|
'@inquirer/rawlist': 5.3.1(@types/node@20.19.43)
|
||||||
|
'@inquirer/search': 4.2.1(@types/node@20.19.43)
|
||||||
|
'@inquirer/select': 5.2.1(@types/node@20.19.43)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 20.19.43
|
||||||
|
|
||||||
|
'@inquirer/rawlist@5.3.1(@types/node@20.19.43)':
|
||||||
|
dependencies:
|
||||||
|
'@inquirer/core': 11.2.1(@types/node@20.19.43)
|
||||||
|
'@inquirer/type': 4.0.7(@types/node@20.19.43)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 20.19.43
|
||||||
|
|
||||||
|
'@inquirer/search@4.2.1(@types/node@20.19.43)':
|
||||||
|
dependencies:
|
||||||
|
'@inquirer/core': 11.2.1(@types/node@20.19.43)
|
||||||
|
'@inquirer/figures': 2.0.7
|
||||||
|
'@inquirer/type': 4.0.7(@types/node@20.19.43)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 20.19.43
|
||||||
|
|
||||||
|
'@inquirer/select@5.2.1(@types/node@20.19.43)':
|
||||||
|
dependencies:
|
||||||
|
'@inquirer/ansi': 2.0.7
|
||||||
|
'@inquirer/core': 11.2.1(@types/node@20.19.43)
|
||||||
|
'@inquirer/figures': 2.0.7
|
||||||
|
'@inquirer/type': 4.0.7(@types/node@20.19.43)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 20.19.43
|
||||||
|
|
||||||
|
'@inquirer/type@4.0.7(@types/node@20.19.43)':
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 20.19.43
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
@@ -5851,6 +6134,8 @@ snapshots:
|
|||||||
|
|
||||||
character-reference-invalid@2.0.1: {}
|
character-reference-invalid@2.0.1: {}
|
||||||
|
|
||||||
|
chardet@2.2.0: {}
|
||||||
|
|
||||||
class-variance-authority@0.7.1:
|
class-variance-authority@0.7.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
@@ -5861,6 +6146,8 @@ snapshots:
|
|||||||
|
|
||||||
cli-spinners@2.9.2: {}
|
cli-spinners@2.9.2: {}
|
||||||
|
|
||||||
|
cli-width@4.1.0: {}
|
||||||
|
|
||||||
client-only@0.0.1: {}
|
client-only@0.0.1: {}
|
||||||
|
|
||||||
clsx@2.1.1: {}
|
clsx@2.1.1: {}
|
||||||
@@ -5891,6 +6178,8 @@ snapshots:
|
|||||||
|
|
||||||
commander@14.0.3: {}
|
commander@14.0.3: {}
|
||||||
|
|
||||||
|
commander@15.0.0: {}
|
||||||
|
|
||||||
concat-map@0.0.1: {}
|
concat-map@0.0.1: {}
|
||||||
|
|
||||||
conf@10.2.0:
|
conf@10.2.0:
|
||||||
@@ -6610,8 +6899,18 @@ snapshots:
|
|||||||
|
|
||||||
fast-levenshtein@2.0.6: {}
|
fast-levenshtein@2.0.6: {}
|
||||||
|
|
||||||
|
fast-string-truncated-width@3.0.3: {}
|
||||||
|
|
||||||
|
fast-string-width@3.0.2:
|
||||||
|
dependencies:
|
||||||
|
fast-string-truncated-width: 3.0.3
|
||||||
|
|
||||||
fast-uri@3.1.5: {}
|
fast-uri@3.1.5: {}
|
||||||
|
|
||||||
|
fast-wrap-ansi@0.2.2:
|
||||||
|
dependencies:
|
||||||
|
fast-string-width: 3.0.2
|
||||||
|
|
||||||
fastq@1.20.1:
|
fastq@1.20.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
reusify: 1.1.0
|
reusify: 1.1.0
|
||||||
@@ -7581,6 +7880,8 @@ snapshots:
|
|||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
|
mute-stream@3.0.0: {}
|
||||||
|
|
||||||
nanoid@3.3.18: {}
|
nanoid@3.3.18: {}
|
||||||
|
|
||||||
napi-postinstall@0.3.4: {}
|
napi-postinstall@0.3.4: {}
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import { loadConfig, ConfigError } from "../src/lib/config/load";
|
|
||||||
|
|
||||||
const configPathArg = process.argv[2];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { config, configPath } = loadConfig(configPathArg);
|
|
||||||
console.log(
|
|
||||||
JSON.stringify({
|
|
||||||
ok: true,
|
|
||||||
configPath,
|
|
||||||
scriptCount: config.scripts.length,
|
|
||||||
authEnabled: config.auth.enabled,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
process.exit(0);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ConfigError) {
|
|
||||||
console.log(
|
|
||||||
JSON.stringify({
|
|
||||||
ok: false,
|
|
||||||
message: error.message,
|
|
||||||
issues: error.issues,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
JSON.stringify({
|
|
||||||
ok: false,
|
|
||||||
message: (error as Error).message,
|
|
||||||
issues: [],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
+6
-1
@@ -1,5 +1,7 @@
|
|||||||
import "./src/bootstrap/async-local-storage-polyfill";
|
import "./src/bootstrap/async-local-storage-polyfill";
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import next from "next";
|
import next from "next";
|
||||||
import { WebSocketServer } from "ws";
|
import { WebSocketServer } from "ws";
|
||||||
import { getConfig } from "./src/lib/config/load";
|
import { getConfig } from "./src/lib/config/load";
|
||||||
@@ -19,7 +21,10 @@ migrateOnBoot();
|
|||||||
syncAuthFromConfig();
|
syncAuthFromConfig();
|
||||||
reconcileOrphanedRuns();
|
reconcileOrphanedRuns();
|
||||||
|
|
||||||
const app = next({ dev, hostname, port });
|
// `dir` must be this file's own directory, not `process.cwd()` - when launched by the installed
|
||||||
|
// `triggershell` CLI, the working directory is wherever the user's config lives, not the package.
|
||||||
|
const dir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const app = next({ dev, dir, hostname, port });
|
||||||
const handle = app.getRequestHandler();
|
const handle = app.getRequestHandler();
|
||||||
|
|
||||||
app.prepare().then(() => {
|
app.prepare().then(() => {
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import { ConfigError, loadConfig } from "../../lib/config/load";
|
||||||
|
import { isPortFree } from "../lib/network";
|
||||||
|
import { resolveConfigPath } from "../lib/paths";
|
||||||
|
|
||||||
|
export interface DoctorOptions {
|
||||||
|
config?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function doctorCommand(opts: DoctorOptions): Promise<void> {
|
||||||
|
const rows: [string, string][] = [];
|
||||||
|
|
||||||
|
rows.push(["Node.js", process.version]);
|
||||||
|
|
||||||
|
const configPath = resolveConfigPath(opts.config);
|
||||||
|
const exists = fs.existsSync(configPath);
|
||||||
|
rows.push(["Config path", `${configPath} ${exists ? "(exists)" : "(not found)"}`]);
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
try {
|
||||||
|
const { config } = loadConfig(configPath);
|
||||||
|
const portFree = await isPortFree(config.server.host, config.server.port);
|
||||||
|
rows.push([
|
||||||
|
"Port available",
|
||||||
|
portFree ? "yes" : `no (${config.server.host}:${config.server.port} in use)`,
|
||||||
|
]);
|
||||||
|
rows.push(["Scripts configured", String(config.scripts.length)]);
|
||||||
|
rows.push(["Auth enabled", String(config.auth.enabled)]);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ConfigError) {
|
||||||
|
rows.push(["Config", error.message]);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelWidth = Math.max(...rows.map(([label]) => label.length));
|
||||||
|
for (const [label, value] of rows) {
|
||||||
|
console.log(`${label.padEnd(labelWidth)} ${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { resolveAppRoot } from "../lib/paths";
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG_NAME = "triggershell.yml";
|
||||||
|
|
||||||
|
export interface InitOptions {
|
||||||
|
port: number;
|
||||||
|
auth: boolean;
|
||||||
|
force: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initCommand(targetPath: string | undefined, opts: InitOptions): Promise<void> {
|
||||||
|
const targetDir = path.resolve(process.cwd(), targetPath ?? ".");
|
||||||
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
|
const configPath = path.join(targetDir, DEFAULT_CONFIG_NAME);
|
||||||
|
|
||||||
|
if (fs.existsSync(configPath) && !opts.force) {
|
||||||
|
console.error(`${configPath} already exists. Use --force to overwrite.`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const templatePath = path.join(resolveAppRoot(), "templates", DEFAULT_CONFIG_NAME);
|
||||||
|
const template = fs.readFileSync(templatePath, "utf-8");
|
||||||
|
const rendered = template
|
||||||
|
.replace("__PORT__", String(opts.port))
|
||||||
|
.replace("__AUTH_ENABLED__", opts.auth ? "true" : "false");
|
||||||
|
fs.writeFileSync(configPath, rendered);
|
||||||
|
console.log(`Created ${configPath}`);
|
||||||
|
|
||||||
|
const envPath = path.join(targetDir, ".env");
|
||||||
|
if (opts.auth) {
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
console.log(
|
||||||
|
`${envPath} already exists - make sure it sets TRIGGERSHELL_SESSION_SECRET (>= 32 chars).`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const sessionSecret = crypto.randomBytes(32).toString("hex");
|
||||||
|
fs.writeFileSync(envPath, `TRIGGERSHELL_SESSION_SECRET=${sessionSecret}\n`);
|
||||||
|
console.log(`Created ${envPath} (keep this out of version control)`);
|
||||||
|
}
|
||||||
|
console.log("\nAuth is enabled but no users are configured yet. Add one with:");
|
||||||
|
console.log(` triggershell users add <username> --config ${configPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\nStart the app with:");
|
||||||
|
console.log(` triggershell start --config ${configPath}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { execa } from "execa";
|
||||||
|
import { resolveConfigPath, resolveInvokedBinPath } from "../lib/paths";
|
||||||
|
import {
|
||||||
|
isRoot,
|
||||||
|
renderUnit,
|
||||||
|
SERVICE_NAME,
|
||||||
|
systemUnitPath,
|
||||||
|
userUnitPath,
|
||||||
|
} from "../lib/systemd";
|
||||||
|
|
||||||
|
export interface ServiceInstallOptions {
|
||||||
|
config?: string;
|
||||||
|
port?: number;
|
||||||
|
host?: string;
|
||||||
|
system?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceScopeOptions {
|
||||||
|
system?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeOf(opts: ServiceScopeOptions): "user" | "system" {
|
||||||
|
return opts.system ? "system" : "user";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serviceInstallCommand(opts: ServiceInstallOptions): Promise<void> {
|
||||||
|
const scope = scopeOf(opts);
|
||||||
|
const configPath = resolveConfigPath(opts.config);
|
||||||
|
const unit = renderUnit({
|
||||||
|
execPath: process.execPath,
|
||||||
|
binPath: resolveInvokedBinPath(),
|
||||||
|
configPath,
|
||||||
|
configDir: path.dirname(configPath),
|
||||||
|
port: opts.port,
|
||||||
|
host: opts.host,
|
||||||
|
scope,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (scope === "user") {
|
||||||
|
const unitPath = userUnitPath();
|
||||||
|
fs.mkdirSync(path.dirname(unitPath), { recursive: true });
|
||||||
|
fs.writeFileSync(unitPath, unit);
|
||||||
|
await execa("systemctl", ["--user", "daemon-reload"], { reject: false });
|
||||||
|
|
||||||
|
console.log(`Installed ${unitPath}`);
|
||||||
|
console.log("\nReview it, then start the service with:");
|
||||||
|
console.log(` systemctl --user enable --now ${SERVICE_NAME}`);
|
||||||
|
console.log("\nTail logs with:");
|
||||||
|
console.log(` journalctl --user -u ${SERVICE_NAME} -f`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRoot()) {
|
||||||
|
const unitPath = systemUnitPath();
|
||||||
|
fs.writeFileSync(unitPath, unit);
|
||||||
|
await execa("systemctl", ["daemon-reload"], { reject: false });
|
||||||
|
|
||||||
|
console.log(`Installed ${unitPath}`);
|
||||||
|
console.log("\nReview it, then start the service with:");
|
||||||
|
console.log(` systemctl enable --now ${SERVICE_NAME}`);
|
||||||
|
console.log("\nTail logs with:");
|
||||||
|
console.log(` journalctl -u ${SERVICE_NAME} -f`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scratchPath = path.join(os.tmpdir(), `${SERVICE_NAME}.service`);
|
||||||
|
fs.writeFileSync(scratchPath, unit);
|
||||||
|
console.log(`Not running as root - wrote the unit file to ${scratchPath} instead.`);
|
||||||
|
console.log("\nReview it, then run:");
|
||||||
|
console.log(` sudo install -m 644 ${scratchPath} ${systemUnitPath()}`);
|
||||||
|
console.log(" sudo systemctl daemon-reload");
|
||||||
|
console.log(` sudo systemctl enable --now ${SERVICE_NAME}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serviceUninstallCommand(opts: ServiceScopeOptions): Promise<void> {
|
||||||
|
const scope = scopeOf(opts);
|
||||||
|
|
||||||
|
if (scope === "user") {
|
||||||
|
await execa("systemctl", ["--user", "disable", "--now", SERVICE_NAME], { reject: false });
|
||||||
|
const unitPath = userUnitPath();
|
||||||
|
if (fs.existsSync(unitPath)) fs.rmSync(unitPath);
|
||||||
|
await execa("systemctl", ["--user", "daemon-reload"], { reject: false });
|
||||||
|
console.log(`Removed ${unitPath}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRoot()) {
|
||||||
|
await execa("systemctl", ["disable", "--now", SERVICE_NAME], { reject: false });
|
||||||
|
const unitPath = systemUnitPath();
|
||||||
|
if (fs.existsSync(unitPath)) fs.rmSync(unitPath);
|
||||||
|
await execa("systemctl", ["daemon-reload"], { reject: false });
|
||||||
|
console.log(`Removed ${unitPath}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Not running as root. Remove the system service manually with:");
|
||||||
|
console.log(` sudo systemctl disable --now ${SERVICE_NAME}`);
|
||||||
|
console.log(` sudo rm ${systemUnitPath()}`);
|
||||||
|
console.log(" sudo systemctl daemon-reload");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serviceStatusCommand(opts: ServiceScopeOptions): Promise<void> {
|
||||||
|
const scope = scopeOf(opts);
|
||||||
|
const args =
|
||||||
|
scope === "user" ? ["--user", "status", SERVICE_NAME] : ["status", SERVICE_NAME];
|
||||||
|
const result = await execa("systemctl", args, { stdio: "inherit", reject: false });
|
||||||
|
process.exitCode = result.exitCode ?? 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { ConfigError, loadConfig } from "../../lib/config/load";
|
||||||
|
import { loadDotenv } from "../lib/env-file";
|
||||||
|
import { isPortFree, openBrowser, waitUntilReady } from "../lib/network";
|
||||||
|
import { resolveAppRoot, resolveConfigPath } from "../lib/paths";
|
||||||
|
|
||||||
|
export interface StartOptions {
|
||||||
|
config?: string;
|
||||||
|
port?: number;
|
||||||
|
host?: string;
|
||||||
|
noBrowser?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startCommand(opts: StartOptions): Promise<void> {
|
||||||
|
const configPath = resolveConfigPath(opts.config);
|
||||||
|
loadDotenv(path.join(path.dirname(configPath), ".env"));
|
||||||
|
|
||||||
|
let loaded;
|
||||||
|
try {
|
||||||
|
loaded = loadConfig(configPath);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ConfigError) {
|
||||||
|
console.error(`Config error: ${error.message}`);
|
||||||
|
for (const issue of error.issues) console.error(` - ${issue}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveHost = opts.host ?? loaded.config.server.host;
|
||||||
|
const effectivePort = opts.port ?? loaded.config.server.port;
|
||||||
|
|
||||||
|
if (!(await isPortFree(effectiveHost, effectivePort))) {
|
||||||
|
console.error(
|
||||||
|
`Port ${effectivePort} on ${effectiveHost} is already in use. Pass --port to use a different one.`,
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env.TRIGGERSHELL_CONFIG_PATH = configPath;
|
||||||
|
process.env.PORT = String(effectivePort);
|
||||||
|
process.env.HOST = effectiveHost;
|
||||||
|
// Next's generated types mark NODE_ENV readonly; this is the one legitimate place that sets it
|
||||||
|
// (the CLI IS what decides production mode) before importing server.ts.
|
||||||
|
(process.env as { NODE_ENV: string }).NODE_ENV = "production";
|
||||||
|
|
||||||
|
const url = `http://${effectiveHost}:${effectivePort}`;
|
||||||
|
if (!opts.noBrowser) {
|
||||||
|
void waitUntilReady(`${url}/api/healthz`, 45_000).then((ready) => {
|
||||||
|
if (ready) openBrowser(url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverEntry = pathToFileURL(path.join(resolveAppRoot(), "server.ts")).href;
|
||||||
|
await import(serverEntry);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import path from "node:path";
|
||||||
|
import { password as promptPassword } from "@inquirer/prompts";
|
||||||
|
import { stringify } from "yaml";
|
||||||
|
import { hashPassword } from "../../lib/auth/password";
|
||||||
|
import { upsertEnvVar } from "../lib/env-file";
|
||||||
|
import { resolveConfigPath } from "../lib/paths";
|
||||||
|
import { slug } from "../lib/slug";
|
||||||
|
|
||||||
|
export interface UsersOptions {
|
||||||
|
config?: string;
|
||||||
|
inline?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printSnippet(heading: string, entry: Record<string, unknown>): void {
|
||||||
|
console.log(`\n${heading}\n`);
|
||||||
|
console.log(stringify([entry]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptNewPassword(): Promise<string> {
|
||||||
|
for (;;) {
|
||||||
|
const first = await promptPassword({ message: "Password", mask: true });
|
||||||
|
const second = await promptPassword({ message: "Confirm password", mask: true });
|
||||||
|
if (first === second) return first;
|
||||||
|
console.error("Passwords did not match, try again.\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function usersAddCommand(username: string, opts: UsersOptions): Promise<void> {
|
||||||
|
const password = await promptNewPassword();
|
||||||
|
const passwordHash = await hashPassword(password);
|
||||||
|
|
||||||
|
if (opts.inline) {
|
||||||
|
printSnippet("Add this under `auth.users:` in your config file:", { username, passwordHash });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configPath = resolveConfigPath(opts.config);
|
||||||
|
const envPath = path.join(path.dirname(configPath), ".env");
|
||||||
|
const varName = `TRIGGERSHELL_USER_${slug(username)}_PASSWORD_HASH`;
|
||||||
|
upsertEnvVar(envPath, varName, passwordHash);
|
||||||
|
|
||||||
|
console.log(`Stored ${varName} in ${envPath}`);
|
||||||
|
printSnippet("Add this under `auth.users:` in your config file:", {
|
||||||
|
username,
|
||||||
|
passwordHash: `\${${varName}}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function usersAddTokenCommand(name: string, opts: UsersOptions): Promise<void> {
|
||||||
|
const token = crypto.randomBytes(32).toString("hex");
|
||||||
|
const tokenHash = `sha256:${crypto.createHash("sha256").update(token).digest("hex")}`;
|
||||||
|
|
||||||
|
console.log("\nSave this token now - it will not be shown again:");
|
||||||
|
console.log(` ${token}`);
|
||||||
|
console.log(`Use it as: Authorization: Bearer ${token}`);
|
||||||
|
|
||||||
|
if (opts.inline) {
|
||||||
|
printSnippet("Add this under `auth.tokens:` in your config file:", { name, tokenHash });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configPath = resolveConfigPath(opts.config);
|
||||||
|
const envPath = path.join(path.dirname(configPath), ".env");
|
||||||
|
const varName = `TRIGGERSHELL_TOKEN_${slug(name)}_HASH`;
|
||||||
|
upsertEnvVar(envPath, varName, tokenHash);
|
||||||
|
|
||||||
|
console.log(`Stored ${varName} in ${envPath}`);
|
||||||
|
printSnippet("Add this under `auth.tokens:` in your config file:", {
|
||||||
|
name,
|
||||||
|
tokenHash: `\${${varName}}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { ConfigError, loadConfig } from "../../lib/config/load";
|
||||||
|
import { loadDotenv } from "../lib/env-file";
|
||||||
|
import { resolveConfigPath } from "../lib/paths";
|
||||||
|
|
||||||
|
export interface ValidateOptions {
|
||||||
|
config?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateCommand(opts: ValidateOptions): Promise<void> {
|
||||||
|
const configPath = resolveConfigPath(opts.config);
|
||||||
|
loadDotenv(path.join(path.dirname(configPath), ".env"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { config } = loadConfig(configPath);
|
||||||
|
console.log(`OK - ${configPath}`);
|
||||||
|
console.log(` ${config.scripts.length} script(s) configured`);
|
||||||
|
console.log(` auth.enabled: ${config.auth.enabled}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ConfigError) {
|
||||||
|
console.error(`Config error: ${error.message}`);
|
||||||
|
for (const issue of error.issues) console.error(` - ${issue}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { Command } from "commander";
|
||||||
|
import { doctorCommand } from "./commands/doctor";
|
||||||
|
import { initCommand } from "./commands/init";
|
||||||
|
import { serviceInstallCommand, serviceStatusCommand, serviceUninstallCommand } from "./commands/service";
|
||||||
|
import { startCommand } from "./commands/start";
|
||||||
|
import { usersAddCommand, usersAddTokenCommand } from "./commands/users";
|
||||||
|
import { validateCommand } from "./commands/validate";
|
||||||
|
import { getVersion } from "./version";
|
||||||
|
|
||||||
|
const program = new Command("triggershell")
|
||||||
|
.version(getVersion())
|
||||||
|
.description("Launch the TriggerShell web app: run your configured shell scripts from a browser.");
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("init [path]")
|
||||||
|
.description("Scaffold a new triggershell.yml (and .env, if auth is enabled)")
|
||||||
|
.option("--port <port>", "Port the web app will listen on.", (v) => Number(v), 4173)
|
||||||
|
.option("--no-auth", "Disable built-in login for the web app.")
|
||||||
|
.option("--force", "Overwrite an existing config file.", false)
|
||||||
|
.action(initCommand);
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("validate")
|
||||||
|
.description("Validate a config file against the full schema.")
|
||||||
|
.option("-c, --config <path>", "Path to the config file.")
|
||||||
|
.action(validateCommand);
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("start")
|
||||||
|
.description("Run the web app in production mode.")
|
||||||
|
.option("-c, --config <path>", "Path to the config file.")
|
||||||
|
.option("--port <port>", "Override the port from the config file.", (v) => Number(v))
|
||||||
|
.option("--host <host>", "Override the host from the config file.")
|
||||||
|
.option("--no-browser", "Don't open a browser automatically.")
|
||||||
|
.action(startCommand);
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("doctor")
|
||||||
|
.description("Print diagnostic info about your environment and config.")
|
||||||
|
.option("-c, --config <path>", "Path to the config file.")
|
||||||
|
.action(doctorCommand);
|
||||||
|
|
||||||
|
const users = program.command("users").description("Manage auth users and API tokens defined in your config file.");
|
||||||
|
|
||||||
|
users
|
||||||
|
.command("add <username>")
|
||||||
|
.description("Hash a password with argon2id and wire it up for auth.users.")
|
||||||
|
.option("-c, --config <path>", "Path to the config file (used to locate .env).")
|
||||||
|
.option("--inline", "Print the raw hash to paste into the config instead of storing it in .env.", false)
|
||||||
|
.action(usersAddCommand);
|
||||||
|
|
||||||
|
users
|
||||||
|
.command("add-token <name>")
|
||||||
|
.description("Generate an API token and wire its hash up for auth.tokens.")
|
||||||
|
.option("-c, --config <path>", "Path to the config file (used to locate .env).")
|
||||||
|
.option("--inline", "Print the raw hash to paste into the config instead of storing it in .env.", false)
|
||||||
|
.action(usersAddTokenCommand);
|
||||||
|
|
||||||
|
const service = program.command("service").description("Manage the systemd service (Linux only).");
|
||||||
|
|
||||||
|
service
|
||||||
|
.command("install")
|
||||||
|
.description("Install a systemd unit that runs `triggershell start`.")
|
||||||
|
.option("-c, --config <path>", "Path to the config file.")
|
||||||
|
.option("--port <port>", "Override the port from the config file.", (v) => Number(v))
|
||||||
|
.option("--host <host>", "Override the host from the config file.")
|
||||||
|
.option("--system", "Install a system-wide unit instead of a per-user one.", false)
|
||||||
|
.action(serviceInstallCommand);
|
||||||
|
|
||||||
|
service
|
||||||
|
.command("uninstall")
|
||||||
|
.description("Stop, disable, and remove the systemd unit.")
|
||||||
|
.option("--system", "Target the system-wide unit instead of the per-user one.", false)
|
||||||
|
.action(serviceUninstallCommand);
|
||||||
|
|
||||||
|
service
|
||||||
|
.command("status")
|
||||||
|
.description("Show the systemd unit's status.")
|
||||||
|
.option("--system", "Target the system-wide unit instead of the per-user one.", false)
|
||||||
|
.action(serviceStatusCommand);
|
||||||
|
|
||||||
|
if (process.argv.length <= 2) {
|
||||||
|
program.outputHelp();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
await program.parseAsync(process.argv);
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import net from "node:net";
|
||||||
|
|
||||||
|
export function isPortFree(host: string, port: number): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = net.connect({ host, port, timeout: 500 });
|
||||||
|
socket.once("connect", () => {
|
||||||
|
socket.destroy();
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
socket.once("timeout", () => {
|
||||||
|
socket.destroy();
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
socket.once("error", () => {
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitUntilReady(
|
||||||
|
url: string,
|
||||||
|
timeoutMs: number,
|
||||||
|
intervalMs = 400,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { signal: AbortSignal.timeout(1500) });
|
||||||
|
if (response.status === 200) return true;
|
||||||
|
} catch {
|
||||||
|
// not ready yet
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openBrowser(url: string): void {
|
||||||
|
const command =
|
||||||
|
process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
||||||
|
const args = process.platform === "win32" ? ["", url] : [url];
|
||||||
|
try {
|
||||||
|
spawn(command, args, { detached: true, stdio: "ignore", shell: process.platform === "win32" }).unref();
|
||||||
|
} catch {
|
||||||
|
// best-effort - not fatal if no browser opener is available
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG_NAME = "triggershell.yml";
|
||||||
|
|
||||||
|
/** Root of the installed `triggershell` package - one level up from `src/cli/lib`. */
|
||||||
|
export function resolveAppRoot(): string {
|
||||||
|
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveConfigPath(configArg?: string): string {
|
||||||
|
return path.resolve(process.cwd(), configArg ?? DEFAULT_CONFIG_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolute path to the script that was actually invoked (`node <this>`), with any symlink
|
||||||
|
* (as created by a global npm/pnpm install or `npm link`) resolved away. Used to build a
|
||||||
|
* `systemd` `ExecStart` line that keeps working regardless of how the CLI was installed.
|
||||||
|
*/
|
||||||
|
export function resolveInvokedBinPath(): string {
|
||||||
|
return fs.realpathSync(process.argv[1]);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export function slug(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/[^A-Za-z0-9]+/g, "_")
|
||||||
|
.replace(/^_+|_+$/g, "")
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -56,7 +56,7 @@ export function loadConfig(configPathArg?: string): LoadedConfig {
|
|||||||
|
|
||||||
let parsedYaml: unknown;
|
let parsedYaml: unknown;
|
||||||
try {
|
try {
|
||||||
parsedYaml = parseYaml(interpolated);
|
parsedYaml = parseYaml(interpolated) ?? {};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new ConfigError(`Failed to parse YAML: ${(error as Error).message}`);
|
throw new ConfigError(`Failed to parse YAML: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-12
@@ -1,13 +1,14 @@
|
|||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
triggershell (Python CLI) app/ (Next.js, all server logic)
|
triggershell (Node CLI, src/cli) app/ (Next.js, all server logic)
|
||||||
─────────────────────── ──────────────────────────────
|
─────────────────────────────── ──────────────────────────────
|
||||||
triggershell dev|start server.ts (custom Node server)
|
triggershell start server.ts (custom Node server)
|
||||||
1. resolve + pre-flight the config ├─ Next.js request handler (pages, API routes)
|
1. resolve + validate the config (loadConfig) ├─ Next.js request handler (pages, API routes)
|
||||||
2. check node/pnpm, `pnpm install` if stale ├─ ws.WebSocketServer on /ws/runs
|
2. check the port is free ├─ ws.WebSocketServer on /ws/runs
|
||||||
3. set TRIGGERSHELL_CONFIG_PATH/PORT/HOST env └─ boot: migrate DB, sync auth, reconcile runs
|
3. set TRIGGERSHELL_CONFIG_PATH/PORT/HOST/ └─ boot: migrate DB, sync auth, reconcile runs
|
||||||
4. spawn `pnpm run dev|start`, forward signals
|
NODE_ENV
|
||||||
|
4. import("./server.ts") — same process │
|
||||||
5. poll /api/healthz, open browser │
|
5. poll /api/healthz, open browser │
|
||||||
┌──────────┴──────────┐
|
┌──────────┴──────────┐
|
||||||
REST API WebSocket
|
REST API WebSocket
|
||||||
@@ -28,12 +29,30 @@ request handler in a plain `http.createServer` and attaches a `ws.WebSocketServe
|
|||||||
`upgrade` event, scoped to `/ws/runs` with its own auth check (Route Handlers get auth via
|
`upgrade` event, scoped to `/ws/runs` with its own auth check (Route Handlers get auth via
|
||||||
`next/headers`'s `cookies()`, which isn't available on a raw `http.IncomingMessage`).
|
`next/headers`'s `cookies()`, which isn't available on a raw `http.IncomingMessage`).
|
||||||
|
|
||||||
## Why the Python CLI is thin
|
## Why the CLI and server share one process
|
||||||
|
|
||||||
Everything Node/pnpm/Next.js needs to do (serve pages, run scripts, stream output, enforce auth)
|
`triggershell start` (`src/cli/commands/start.ts`) doesn't spawn `server.ts` as a child process —
|
||||||
is naturally a Node problem — `execa` for argv-safe spawning, `ws` for streaming, Next for the UI.
|
it sets `process.env` (`TRIGGERSHELL_CONFIG_PATH`, `PORT`, `HOST`, `NODE_ENV`) and then dynamically
|
||||||
Python's job is just: get Node/pnpm ready, validate the config fast, and manage the child process's
|
`import()`s `server.ts` directly, in the same Node process. `server.ts` reads that env and installs
|
||||||
lifecycle (signals, readiness, browser launch) — a CLI concern, not a web-server concern.
|
its own `SIGTERM`/`SIGINT` handlers, so once it's imported, `Ctrl-C` or `systemctl stop` just work —
|
||||||
|
there's no parent process relaying signals to a child, no separate lifecycle to manage. The CLI's
|
||||||
|
`bin/triggershell.js` entry point registers `tsx`'s loader once for the whole process
|
||||||
|
(`tsx/esm/api`'s `register()`), so both the CLI's own `.ts` command files and `server.ts` run
|
||||||
|
straight from source, with no compile/bundle step for either.
|
||||||
|
|
||||||
|
## Running as a systemd service
|
||||||
|
|
||||||
|
`triggershell service install` renders a unit file (`src/cli/lib/systemd.ts`) whose `ExecStart`
|
||||||
|
line pins the exact `node` binary (`process.execPath`) and the exact, symlink-resolved path to the
|
||||||
|
installed CLI (`fs.realpathSync(process.argv[1])`) at install time — necessary because systemd
|
||||||
|
services run with a minimal `PATH` that may not include wherever Node actually lives. By default it
|
||||||
|
installs a per-user unit (`~/.config/systemd/user/triggershell.service`, no root required); `--system`
|
||||||
|
targets `/etc/systemd/system/` instead and prints the `sudo` commands to run if not already root.
|
||||||
|
`install` reloads the systemd daemon but does not enable/start the unit itself — that's a separate,
|
||||||
|
explicit `systemctl --user enable --now triggershell`, since it's the point where the service
|
||||||
|
actually starts listening and running scripts. `triggershell service status`/`uninstall` are thin
|
||||||
|
wrappers around `systemctl`; log tailing is just `journalctl --user -u triggershell -f` — not
|
||||||
|
reimplemented.
|
||||||
|
|
||||||
## Cross-module-graph state
|
## Cross-module-graph state
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ secrets referenced via `${VAR}` don't have to be committed alongside the config.
|
|||||||
init` scaffolds both files together.
|
init` scaffolds both files together.
|
||||||
|
|
||||||
The canonical schema is the Zod schema at `app/src/lib/config/schema.ts` — this document mirrors
|
The canonical schema is the Zod schema at `app/src/lib/config/schema.ts` — this document mirrors
|
||||||
it. `triggershell validate` runs the Python pre-flight checks below, then that full schema.
|
it. `triggershell validate` loads and validates the config directly against the schema below (no
|
||||||
|
separate pre-flight step).
|
||||||
|
|
||||||
## `server`
|
## `server`
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
|
||||||
@@ -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"
|
|
||||||
@@ -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
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from triggershell.config import ConfigError, preflight_check
|
|
||||||
|
|
||||||
|
|
||||||
def write(tmp_path: Path, content: str) -> Path:
|
|
||||||
path = tmp_path / "triggershell.yml"
|
|
||||||
path.write_text(content)
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_file_raises(tmp_path: Path) -> None:
|
|
||||||
with pytest.raises(ConfigError, match="not found"):
|
|
||||||
preflight_check(tmp_path / "missing.yaml")
|
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_yaml_raises(tmp_path: Path) -> None:
|
|
||||||
path = write(tmp_path, "scripts: [this is not: valid: yaml")
|
|
||||||
with pytest.raises(ConfigError, match="parse YAML"):
|
|
||||||
preflight_check(path)
|
|
||||||
|
|
||||||
|
|
||||||
def test_non_mapping_root_raises(tmp_path: Path) -> None:
|
|
||||||
path = write(tmp_path, "- just\n- a\n- list\n")
|
|
||||||
with pytest.raises(ConfigError, match="mapping"):
|
|
||||||
preflight_check(path)
|
|
||||||
|
|
||||||
|
|
||||||
def test_empty_file_is_ok(tmp_path: Path) -> None:
|
|
||||||
path = write(tmp_path, "")
|
|
||||||
data = preflight_check(path)
|
|
||||||
assert data == {}
|
|
||||||
|
|
||||||
|
|
||||||
def test_script_missing_id_raises(tmp_path: Path) -> None:
|
|
||||||
path = write(tmp_path, "scripts:\n - name: no id here\n")
|
|
||||||
with pytest.raises(ConfigError, match="missing a non-empty 'id'"):
|
|
||||||
preflight_check(path)
|
|
||||||
|
|
||||||
|
|
||||||
def test_duplicate_script_id_raises(tmp_path: Path) -> None:
|
|
||||||
path = write(
|
|
||||||
tmp_path,
|
|
||||||
"""
|
|
||||||
scripts:
|
|
||||||
- id: dup
|
|
||||||
name: One
|
|
||||||
- id: dup
|
|
||||||
name: Two
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
with pytest.raises(ConfigError, match="duplicate script id"):
|
|
||||||
preflight_check(path)
|
|
||||||
|
|
||||||
|
|
||||||
def test_valid_minimal_config_passes(tmp_path: Path) -> None:
|
|
||||||
path = write(
|
|
||||||
tmp_path,
|
|
||||||
"""
|
|
||||||
scripts:
|
|
||||||
- id: hello
|
|
||||||
name: Hello
|
|
||||||
command: echo
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
data = preflight_check(path)
|
|
||||||
assert len(data["scripts"]) == 1
|
|
||||||
assert data["scripts"][0]["id"] == "hello"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
__version__ = "0.1.0"
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
from triggershell.cli import app
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
app()
|
|
||||||
@@ -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)
|
|
||||||
@@ -1,353 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import secrets
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import typer
|
|
||||||
import yaml
|
|
||||||
from argon2 import PasswordHasher
|
|
||||||
from dotenv import set_key
|
|
||||||
from rich.console import Console
|
|
||||||
from rich.table import Table
|
|
||||||
|
|
||||||
from triggershell import __version__
|
|
||||||
from triggershell.bootstrap import (
|
|
||||||
BootstrapError,
|
|
||||||
check_node,
|
|
||||||
ensure_dependencies_installed,
|
|
||||||
ensure_pnpm,
|
|
||||||
find_app_dir,
|
|
||||||
load_dotenv_for_config,
|
|
||||||
needs_build,
|
|
||||||
record_build_stamp,
|
|
||||||
)
|
|
||||||
from triggershell.config import ConfigError, preflight_check
|
|
||||||
from triggershell.network import is_port_free, open_browser, wait_until_ready
|
|
||||||
from triggershell.process import run_and_wait, run_blocking
|
|
||||||
|
|
||||||
console = Console()
|
|
||||||
app = typer.Typer(
|
|
||||||
name="triggershell",
|
|
||||||
help="Launch the TriggerShell web app: run your configured shell scripts from a browser.",
|
|
||||||
no_args_is_help=True,
|
|
||||||
)
|
|
||||||
users_app = typer.Typer(help="Manage auth users and API tokens defined in your config file.")
|
|
||||||
app.add_typer(users_app, name="users")
|
|
||||||
|
|
||||||
DEFAULT_CONFIG_NAME = "triggershell.yml"
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_config_path(config: Optional[Path]) -> Path:
|
|
||||||
return (config or Path.cwd() / DEFAULT_CONFIG_NAME).resolve()
|
|
||||||
|
|
||||||
|
|
||||||
def _require_config(config_path: Path) -> dict:
|
|
||||||
try:
|
|
||||||
return preflight_check(config_path)
|
|
||||||
except ConfigError as e:
|
|
||||||
console.print(f"[red]Config error:[/red] {e}")
|
|
||||||
raise typer.Exit(code=1) from e
|
|
||||||
|
|
||||||
|
|
||||||
def _bootstrap_or_exit() -> tuple[str, str, Path]:
|
|
||||||
try:
|
|
||||||
node_version = check_node()
|
|
||||||
pnpm_version = ensure_pnpm()
|
|
||||||
app_dir = find_app_dir()
|
|
||||||
ensure_dependencies_installed(app_dir)
|
|
||||||
except BootstrapError as e:
|
|
||||||
console.print(f"[red]Error:[/red] {e}")
|
|
||||||
raise typer.Exit(code=1) from e
|
|
||||||
return node_version, pnpm_version, app_dir
|
|
||||||
|
|
||||||
|
|
||||||
def _version_callback(value: bool) -> None:
|
|
||||||
if value:
|
|
||||||
console.print(f"triggershell {__version__}")
|
|
||||||
raise typer.Exit()
|
|
||||||
|
|
||||||
|
|
||||||
@app.callback()
|
|
||||||
def main(
|
|
||||||
version: bool = typer.Option(
|
|
||||||
False, "--version", callback=_version_callback, is_eager=True, help="Show the version and exit."
|
|
||||||
),
|
|
||||||
) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
|
||||||
def init(
|
|
||||||
path: Optional[Path] = typer.Argument(
|
|
||||||
None, help="Directory to create the config file in (default: current directory)."
|
|
||||||
),
|
|
||||||
port: int = typer.Option(4173, help="Port the web app will listen on."),
|
|
||||||
auth: bool = typer.Option(True, help="Enable built-in login for the web app."),
|
|
||||||
force: bool = typer.Option(False, help="Overwrite an existing config file."),
|
|
||||||
) -> None:
|
|
||||||
"""Scaffold a new `triggershell.yml`."""
|
|
||||||
target_dir = (path or Path.cwd()).resolve()
|
|
||||||
target_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
config_path = target_dir / DEFAULT_CONFIG_NAME
|
|
||||||
|
|
||||||
if config_path.exists() and not force:
|
|
||||||
console.print(f"[yellow]{config_path} already exists.[/yellow] Use --force to overwrite.")
|
|
||||||
raise typer.Exit(code=1)
|
|
||||||
|
|
||||||
template_path = Path(__file__).parent / "templates" / DEFAULT_CONFIG_NAME
|
|
||||||
template = template_path.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
rendered = template.replace("__PORT__", str(port)).replace("__AUTH_ENABLED__", "true" if auth else "false")
|
|
||||||
config_path.write_text(rendered, encoding="utf-8")
|
|
||||||
console.print(f"[green]Created[/green] {config_path}")
|
|
||||||
|
|
||||||
env_path = target_dir / ".env"
|
|
||||||
if auth:
|
|
||||||
if env_path.exists():
|
|
||||||
console.print(
|
|
||||||
f"[yellow]{env_path} already exists[/yellow] - make sure it sets TRIGGERSHELL_SESSION_SECRET "
|
|
||||||
"(>= 32 chars)."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
session_secret = secrets.token_hex(32)
|
|
||||||
env_path.write_text(f"TRIGGERSHELL_SESSION_SECRET={session_secret}\n", encoding="utf-8")
|
|
||||||
console.print(f"[green]Created[/green] {env_path} [dim](keep this out of version control)[/dim]")
|
|
||||||
console.print("\nAuth is enabled but no users are configured yet. Add one with:")
|
|
||||||
console.print(f" [bold]triggershell users add <username> --config {config_path}[/bold]")
|
|
||||||
|
|
||||||
console.print("\nStart the app with:")
|
|
||||||
console.print(f" [bold]triggershell dev --config {config_path}[/bold]")
|
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
|
||||||
def validate(
|
|
||||||
config: Optional[Path] = typer.Option(None, "-c", "--config", help="Path to the config file."),
|
|
||||||
) -> None:
|
|
||||||
"""Validate a config file: a fast Python pre-flight check, then the full Zod schema in Node."""
|
|
||||||
config_path = _resolve_config_path(config)
|
|
||||||
load_dotenv_for_config(config_path)
|
|
||||||
_require_config(config_path)
|
|
||||||
console.print("[green]OK[/green] (pre-flight checks passed)")
|
|
||||||
|
|
||||||
_, _, app_dir = _bootstrap_or_exit()
|
|
||||||
|
|
||||||
result = run_blocking(
|
|
||||||
["pnpm", "exec", "tsx", "scripts/validate-config.ts", str(config_path)],
|
|
||||||
cwd=app_dir,
|
|
||||||
)
|
|
||||||
raise typer.Exit(code=result)
|
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
|
||||||
def dev(
|
|
||||||
config: Optional[Path] = typer.Option(None, "-c", "--config", help="Path to the config file."),
|
|
||||||
port: Optional[int] = typer.Option(None, help="Override the port from the config file."),
|
|
||||||
host: Optional[str] = typer.Option(None, help="Override the host from the config file."),
|
|
||||||
no_browser: bool = typer.Option(False, "--no-browser", help="Don't open a browser automatically."),
|
|
||||||
) -> None:
|
|
||||||
"""Run the web app in development mode (hot reload)."""
|
|
||||||
_run_server(config, port, host, no_browser, production=False)
|
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
|
||||||
def start(
|
|
||||||
config: Optional[Path] = typer.Option(None, "-c", "--config", help="Path to the config file."),
|
|
||||||
port: Optional[int] = typer.Option(None, help="Override the port from the config file."),
|
|
||||||
host: Optional[str] = typer.Option(None, help="Override the host from the config file."),
|
|
||||||
no_browser: bool = typer.Option(False, "--no-browser", help="Don't open a browser automatically."),
|
|
||||||
skip_build: bool = typer.Option(False, "--skip-build", help="Skip `next build` even if it looks stale."),
|
|
||||||
) -> None:
|
|
||||||
"""Build (if needed) and run the web app in production mode."""
|
|
||||||
_run_server(config, port, host, no_browser, production=True, skip_build=skip_build)
|
|
||||||
|
|
||||||
|
|
||||||
def _run_server(
|
|
||||||
config: Optional[Path],
|
|
||||||
port: Optional[int],
|
|
||||||
host: Optional[str],
|
|
||||||
no_browser: bool,
|
|
||||||
production: bool,
|
|
||||||
skip_build: bool = False,
|
|
||||||
) -> None:
|
|
||||||
config_path = _resolve_config_path(config)
|
|
||||||
load_dotenv_for_config(config_path)
|
|
||||||
config_data = _require_config(config_path)
|
|
||||||
|
|
||||||
effective_host = host or (config_data.get("server") or {}).get("host") or "127.0.0.1"
|
|
||||||
effective_port = port or (config_data.get("server") or {}).get("port") or 4173
|
|
||||||
|
|
||||||
if not is_port_free(effective_host, effective_port):
|
|
||||||
console.print(
|
|
||||||
f"[red]Port {effective_port} on {effective_host} is already in use.[/red] "
|
|
||||||
"Pass --port to use a different one."
|
|
||||||
)
|
|
||||||
raise typer.Exit(code=1)
|
|
||||||
|
|
||||||
_, _, app_dir = _bootstrap_or_exit()
|
|
||||||
|
|
||||||
if production:
|
|
||||||
if skip_build:
|
|
||||||
console.print("[dim]Skipping build (--skip-build).[/dim]")
|
|
||||||
elif needs_build(app_dir, config_path):
|
|
||||||
console.print("[dim]Building web app for production (next build)...[/dim]")
|
|
||||||
result = run_blocking(["pnpm", "run", "build"], cwd=app_dir)
|
|
||||||
if result != 0:
|
|
||||||
raise typer.Exit(code=result)
|
|
||||||
record_build_stamp(app_dir, config_path)
|
|
||||||
else:
|
|
||||||
console.print("[dim]Build is up to date, skipping (pass --skip-build to force-skip anyway).[/dim]")
|
|
||||||
|
|
||||||
env = {
|
|
||||||
**os.environ,
|
|
||||||
"TRIGGERSHELL_CONFIG_PATH": str(config_path),
|
|
||||||
"PORT": str(effective_port),
|
|
||||||
"HOST": effective_host,
|
|
||||||
"NODE_ENV": "production" if production else "development",
|
|
||||||
}
|
|
||||||
|
|
||||||
script = "start" if production else "dev"
|
|
||||||
console.print(f"[dim]Starting web app ({'production' if production else 'development'} mode)...[/dim]")
|
|
||||||
|
|
||||||
url = f"http://{effective_host}:{effective_port}"
|
|
||||||
if not no_browser:
|
|
||||||
import threading
|
|
||||||
|
|
||||||
def open_when_ready() -> None:
|
|
||||||
if wait_until_ready(f"{url}/api/healthz", timeout=45):
|
|
||||||
open_browser(url)
|
|
||||||
|
|
||||||
threading.Thread(target=open_when_ready, daemon=True).start()
|
|
||||||
|
|
||||||
exit_code = run_and_wait(["pnpm", "run", script], cwd=app_dir, env=env)
|
|
||||||
raise typer.Exit(code=exit_code)
|
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
|
||||||
def doctor() -> None:
|
|
||||||
"""Print diagnostic info about your environment and config."""
|
|
||||||
table = Table(show_header=False, box=None)
|
|
||||||
|
|
||||||
try:
|
|
||||||
node_version = check_node()
|
|
||||||
table.add_row("Node.js", f"[green]{node_version}[/green]")
|
|
||||||
except BootstrapError as e:
|
|
||||||
table.add_row("Node.js", f"[red]{e}[/red]")
|
|
||||||
|
|
||||||
try:
|
|
||||||
pnpm_version = ensure_pnpm()
|
|
||||||
table.add_row("pnpm", f"[green]{pnpm_version}[/green]")
|
|
||||||
except BootstrapError as e:
|
|
||||||
table.add_row("pnpm", f"[red]{e}[/red]")
|
|
||||||
|
|
||||||
try:
|
|
||||||
app_dir = find_app_dir()
|
|
||||||
table.add_row("App directory", str(app_dir))
|
|
||||||
except BootstrapError as e:
|
|
||||||
table.add_row("App directory", f"[red]{e}[/red]")
|
|
||||||
|
|
||||||
config_path = _resolve_config_path(None)
|
|
||||||
exists_label = "[green](exists)[/green]" if config_path.exists() else "[yellow](not found)[/yellow]"
|
|
||||||
table.add_row("Config path", f"{config_path} {exists_label}")
|
|
||||||
|
|
||||||
if config_path.exists():
|
|
||||||
try:
|
|
||||||
data = preflight_check(config_path)
|
|
||||||
server = data.get("server") or {}
|
|
||||||
host, port = server.get("host", "127.0.0.1"), server.get("port", 4173)
|
|
||||||
port_free = is_port_free(host, port)
|
|
||||||
port_label = "[green]yes[/green]" if port_free else f"[yellow]no ({host}:{port} in use)[/yellow]"
|
|
||||||
table.add_row("Port available", port_label)
|
|
||||||
table.add_row("Scripts configured", str(len(data.get("scripts") or [])))
|
|
||||||
table.add_row("Auth enabled", str((data.get("auth") or {}).get("enabled", True)))
|
|
||||||
except ConfigError as e:
|
|
||||||
table.add_row("Config", f"[red]{e}[/red]")
|
|
||||||
|
|
||||||
console.print(table)
|
|
||||||
|
|
||||||
|
|
||||||
def _slug(value: str) -> str:
|
|
||||||
return re.sub(r"[^A-Za-z0-9]+", "_", value).strip("_").upper()
|
|
||||||
|
|
||||||
|
|
||||||
def _print_snippet(heading: str, entry: dict) -> None:
|
|
||||||
snippet = yaml.safe_dump([entry], sort_keys=False, width=1000)
|
|
||||||
console.print(f"\n[green]{heading}[/green]\n")
|
|
||||||
console.print(snippet, markup=False, highlight=False, soft_wrap=True)
|
|
||||||
|
|
||||||
|
|
||||||
@users_app.command("add")
|
|
||||||
def users_add(
|
|
||||||
username: str = typer.Argument(..., help="Username to create."),
|
|
||||||
config: Optional[Path] = typer.Option(
|
|
||||||
None, "-c", "--config", help="Path to the config file (used to locate .env)."
|
|
||||||
),
|
|
||||||
inline: bool = typer.Option(
|
|
||||||
False, "--inline", help="Print the raw hash to paste into the config instead of storing it in .env."
|
|
||||||
),
|
|
||||||
) -> None:
|
|
||||||
"""Hash a password with argon2id and wire it up for `auth.users`."""
|
|
||||||
password = typer.prompt("Password", hide_input=True, confirmation_prompt=True)
|
|
||||||
password_hash = PasswordHasher().hash(password)
|
|
||||||
|
|
||||||
if inline:
|
|
||||||
_print_snippet(
|
|
||||||
"Add this under `auth.users:` in your config file:",
|
|
||||||
{"username": username, "passwordHash": password_hash},
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
config_path = _resolve_config_path(config)
|
|
||||||
env_path = config_path.parent / ".env"
|
|
||||||
var_name = f"TRIGGERSHELL_USER_{_slug(username)}_PASSWORD_HASH"
|
|
||||||
set_key(str(env_path), var_name, password_hash)
|
|
||||||
|
|
||||||
console.print(f"[green]Stored[/green] {var_name} in {env_path}")
|
|
||||||
_print_snippet(
|
|
||||||
"Add this under `auth.users:` in your config file:",
|
|
||||||
{"username": username, "passwordHash": f"${{{var_name}}}"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@users_app.command("add-token")
|
|
||||||
def users_add_token(
|
|
||||||
name: str = typer.Argument(..., help="A label for this token, e.g. 'ci-bot'."),
|
|
||||||
config: Optional[Path] = typer.Option(
|
|
||||||
None, "-c", "--config", help="Path to the config file (used to locate .env)."
|
|
||||||
),
|
|
||||||
inline: bool = typer.Option(
|
|
||||||
False, "--inline", help="Print the raw hash to paste into the config instead of storing it in .env."
|
|
||||||
),
|
|
||||||
) -> None:
|
|
||||||
"""Generate an API token and wire its hash up for `auth.tokens`."""
|
|
||||||
token = secrets.token_hex(32)
|
|
||||||
token_hash = f"sha256:{hashlib.sha256(token.encode()).hexdigest()}"
|
|
||||||
|
|
||||||
console.print("\n[yellow]Save this token now - it will not be shown again:[/yellow]")
|
|
||||||
console.print(f" [bold]{token}[/bold]")
|
|
||||||
console.print(f"Use it as: [dim]Authorization: Bearer {token}[/dim]")
|
|
||||||
|
|
||||||
if inline:
|
|
||||||
_print_snippet(
|
|
||||||
"Add this under `auth.tokens:` in your config file:",
|
|
||||||
{"name": name, "tokenHash": token_hash},
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
config_path = _resolve_config_path(config)
|
|
||||||
env_path = config_path.parent / ".env"
|
|
||||||
var_name = f"TRIGGERSHELL_TOKEN_{_slug(name)}_HASH"
|
|
||||||
set_key(str(env_path), var_name, token_hash)
|
|
||||||
|
|
||||||
console.print(f"[green]Stored[/green] {var_name} in {env_path}")
|
|
||||||
_print_snippet(
|
|
||||||
"Add this under `auth.tokens:` in your config file:",
|
|
||||||
{"name": name, "tokenHash": f"${{{var_name}}}"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
app()
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
Reference in New Issue
Block a user