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:
+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
|
||||
|
||||
This is the Next.js app that TriggerShell's Python CLI (`triggershell dev` / `triggershell start`)
|
||||
launches — it's not meant to be run standalone with `next dev`/`next start` since it needs a
|
||||
custom server (`server.ts`) for the WebSocket endpoint.
|
||||
This directory is both the Next.js app and the home of the `triggershell` CLI (`bin/triggershell.js`
|
||||
→ `src/cli`) that launches it — together they're published as one npm package. The app is still not
|
||||
meant to be run standalone with `next dev`/`next start`, since it needs a custom server (`server.ts`)
|
||||
for the WebSocket endpoint; use the CLI (`triggershell start`) or the `pnpm` scripts below instead.
|
||||
|
||||
See the [repo root README](../README.md) for how to run TriggerShell end-to-end, and
|
||||
[`../docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) for how this app is put together.
|
||||
@@ -12,5 +13,9 @@ pnpm install
|
||||
pnpm dev # tsx watch server.ts - reads TRIGGERSHELL_CONFIG_PATH from the environment
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
pnpm test # CLI unit tests (src/cli/**/*.test.ts)
|
||||
pnpm db:studio # browse the SQLite database
|
||||
```
|
||||
|
||||
Note: this is the contributor workflow for developing TriggerShell itself. End users install and
|
||||
run the published `triggershell` CLI instead (see the root README).
|
||||
|
||||
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",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://dev.pivoine.art/valknar/triggershell.git",
|
||||
"directory": "app"
|
||||
},
|
||||
"bin": {
|
||||
"triggershell": "bin/triggershell.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"files": [
|
||||
"bin",
|
||||
"src",
|
||||
"templates",
|
||||
"drizzle",
|
||||
".next",
|
||||
"server.ts",
|
||||
"next.config.ts",
|
||||
"next-env.d.ts",
|
||||
"tsconfig.json",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "tsx watch server.ts",
|
||||
"build": "next build",
|
||||
@@ -15,13 +33,15 @@
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"format": "prettier --write .",
|
||||
"validate-config": "tsx scripts/validate-config.ts",
|
||||
"test": "tsx --test \"src/cli/**/*.test.ts\"",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"prepack": "rm -rf .next && next build && rm -rf .next/cache"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.7.0",
|
||||
"@hookform/resolvers": "^5.8.0",
|
||||
"@inquirer/prompts": "^8.5.2",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"argon2": "^0.45.1",
|
||||
@@ -29,6 +49,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"commander": "^15.0.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"execa": "^10.0.1",
|
||||
"iron-session": "^8.0.4",
|
||||
@@ -43,6 +64,7 @@
|
||||
"shadcn": "^4.18.0",
|
||||
"sonner": "^2.0.8",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tsx": "^4.23.12",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"ws": "^8.21.3",
|
||||
"yaml": "^2.9.0",
|
||||
@@ -63,7 +85,6 @@
|
||||
"prettier": "^3.9.6",
|
||||
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.23.12",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"packageManager": "pnpm@11.21.0"
|
||||
|
||||
Generated
+304
-3
@@ -14,6 +14,9 @@ importers:
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.8.0
|
||||
version: 5.8.0(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(react-hook-form@7.85.0(react@19.2.8))(zod@4.4.3)
|
||||
'@inquirer/prompts':
|
||||
specifier: ^8.5.2
|
||||
version: 8.5.2(@types/node@20.19.43)
|
||||
'@xterm/addon-fit':
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0
|
||||
@@ -35,6 +38,9 @@ importers:
|
||||
cmdk:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
commander:
|
||||
specifier: ^15.0.0
|
||||
version: 15.0.0
|
||||
drizzle-orm:
|
||||
specifier: ^0.45.2
|
||||
version: 0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)
|
||||
@@ -77,6 +83,9 @@ importers:
|
||||
tailwind-merge:
|
||||
specifier: ^3.6.0
|
||||
version: 3.6.0
|
||||
tsx:
|
||||
specifier: ^4.23.12
|
||||
version: 4.23.12
|
||||
tw-animate-css:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
@@ -132,9 +141,6 @@ importers:
|
||||
tailwindcss:
|
||||
specifier: ^4
|
||||
version: 4.3.3
|
||||
tsx:
|
||||
specifier: ^4.23.12
|
||||
version: 4.23.12
|
||||
typescript:
|
||||
specifier: ^5
|
||||
version: 5.9.3
|
||||
@@ -1107,6 +1113,140 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@inquirer/ansi@2.0.7':
|
||||
resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
|
||||
'@inquirer/checkbox@5.2.1':
|
||||
resolution: {integrity: sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/confirm@6.1.1':
|
||||
resolution: {integrity: sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/core@11.2.1':
|
||||
resolution: {integrity: sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/editor@5.2.2':
|
||||
resolution: {integrity: sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/expand@5.1.1':
|
||||
resolution: {integrity: sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/external-editor@3.0.3':
|
||||
resolution: {integrity: sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/figures@2.0.7':
|
||||
resolution: {integrity: sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
|
||||
'@inquirer/input@5.1.2':
|
||||
resolution: {integrity: sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/number@4.1.1':
|
||||
resolution: {integrity: sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/password@5.1.1':
|
||||
resolution: {integrity: sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/prompts@8.5.2':
|
||||
resolution: {integrity: sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/rawlist@5.3.1':
|
||||
resolution: {integrity: sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/search@4.2.1':
|
||||
resolution: {integrity: sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/select@5.2.1':
|
||||
resolution: {integrity: sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/type@4.0.7':
|
||||
resolution: {integrity: sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||
|
||||
@@ -1955,6 +2095,9 @@ packages:
|
||||
character-reference-invalid@2.0.1:
|
||||
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
|
||||
|
||||
chardet@2.2.0:
|
||||
resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==}
|
||||
|
||||
class-variance-authority@0.7.1:
|
||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||
|
||||
@@ -1966,6 +2109,10 @@ packages:
|
||||
resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
cli-width@4.1.0:
|
||||
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
client-only@0.0.1:
|
||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||
|
||||
@@ -2000,6 +2147,10 @@ packages:
|
||||
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
commander@15.0.0:
|
||||
resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
|
||||
concat-map@0.0.1:
|
||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||
|
||||
@@ -2551,9 +2702,18 @@ packages:
|
||||
fast-levenshtein@2.0.6:
|
||||
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
||||
|
||||
fast-string-truncated-width@3.0.3:
|
||||
resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==}
|
||||
|
||||
fast-string-width@3.0.2:
|
||||
resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==}
|
||||
|
||||
fast-uri@3.1.5:
|
||||
resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==}
|
||||
|
||||
fast-wrap-ansi@0.2.2:
|
||||
resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==}
|
||||
|
||||
fastq@1.20.1:
|
||||
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
||||
|
||||
@@ -3381,6 +3541,10 @@ packages:
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
mute-stream@3.0.0:
|
||||
resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==}
|
||||
engines: {node: ^20.17.0 || >=22.9.0}
|
||||
|
||||
nanoid@3.3.18:
|
||||
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
@@ -5067,6 +5231,125 @@ snapshots:
|
||||
'@img/sharp-win32-x64@0.35.3':
|
||||
optional: true
|
||||
|
||||
'@inquirer/ansi@2.0.7': {}
|
||||
|
||||
'@inquirer/checkbox@5.2.1(@types/node@20.19.43)':
|
||||
dependencies:
|
||||
'@inquirer/ansi': 2.0.7
|
||||
'@inquirer/core': 11.2.1(@types/node@20.19.43)
|
||||
'@inquirer/figures': 2.0.7
|
||||
'@inquirer/type': 4.0.7(@types/node@20.19.43)
|
||||
optionalDependencies:
|
||||
'@types/node': 20.19.43
|
||||
|
||||
'@inquirer/confirm@6.1.1(@types/node@20.19.43)':
|
||||
dependencies:
|
||||
'@inquirer/core': 11.2.1(@types/node@20.19.43)
|
||||
'@inquirer/type': 4.0.7(@types/node@20.19.43)
|
||||
optionalDependencies:
|
||||
'@types/node': 20.19.43
|
||||
|
||||
'@inquirer/core@11.2.1(@types/node@20.19.43)':
|
||||
dependencies:
|
||||
'@inquirer/ansi': 2.0.7
|
||||
'@inquirer/figures': 2.0.7
|
||||
'@inquirer/type': 4.0.7(@types/node@20.19.43)
|
||||
cli-width: 4.1.0
|
||||
fast-wrap-ansi: 0.2.2
|
||||
mute-stream: 3.0.0
|
||||
signal-exit: 4.1.0
|
||||
optionalDependencies:
|
||||
'@types/node': 20.19.43
|
||||
|
||||
'@inquirer/editor@5.2.2(@types/node@20.19.43)':
|
||||
dependencies:
|
||||
'@inquirer/core': 11.2.1(@types/node@20.19.43)
|
||||
'@inquirer/external-editor': 3.0.3(@types/node@20.19.43)
|
||||
'@inquirer/type': 4.0.7(@types/node@20.19.43)
|
||||
optionalDependencies:
|
||||
'@types/node': 20.19.43
|
||||
|
||||
'@inquirer/expand@5.1.1(@types/node@20.19.43)':
|
||||
dependencies:
|
||||
'@inquirer/core': 11.2.1(@types/node@20.19.43)
|
||||
'@inquirer/type': 4.0.7(@types/node@20.19.43)
|
||||
optionalDependencies:
|
||||
'@types/node': 20.19.43
|
||||
|
||||
'@inquirer/external-editor@3.0.3(@types/node@20.19.43)':
|
||||
dependencies:
|
||||
chardet: 2.2.0
|
||||
iconv-lite: 0.7.3
|
||||
optionalDependencies:
|
||||
'@types/node': 20.19.43
|
||||
|
||||
'@inquirer/figures@2.0.7': {}
|
||||
|
||||
'@inquirer/input@5.1.2(@types/node@20.19.43)':
|
||||
dependencies:
|
||||
'@inquirer/core': 11.2.1(@types/node@20.19.43)
|
||||
'@inquirer/type': 4.0.7(@types/node@20.19.43)
|
||||
optionalDependencies:
|
||||
'@types/node': 20.19.43
|
||||
|
||||
'@inquirer/number@4.1.1(@types/node@20.19.43)':
|
||||
dependencies:
|
||||
'@inquirer/core': 11.2.1(@types/node@20.19.43)
|
||||
'@inquirer/type': 4.0.7(@types/node@20.19.43)
|
||||
optionalDependencies:
|
||||
'@types/node': 20.19.43
|
||||
|
||||
'@inquirer/password@5.1.1(@types/node@20.19.43)':
|
||||
dependencies:
|
||||
'@inquirer/ansi': 2.0.7
|
||||
'@inquirer/core': 11.2.1(@types/node@20.19.43)
|
||||
'@inquirer/type': 4.0.7(@types/node@20.19.43)
|
||||
optionalDependencies:
|
||||
'@types/node': 20.19.43
|
||||
|
||||
'@inquirer/prompts@8.5.2(@types/node@20.19.43)':
|
||||
dependencies:
|
||||
'@inquirer/checkbox': 5.2.1(@types/node@20.19.43)
|
||||
'@inquirer/confirm': 6.1.1(@types/node@20.19.43)
|
||||
'@inquirer/editor': 5.2.2(@types/node@20.19.43)
|
||||
'@inquirer/expand': 5.1.1(@types/node@20.19.43)
|
||||
'@inquirer/input': 5.1.2(@types/node@20.19.43)
|
||||
'@inquirer/number': 4.1.1(@types/node@20.19.43)
|
||||
'@inquirer/password': 5.1.1(@types/node@20.19.43)
|
||||
'@inquirer/rawlist': 5.3.1(@types/node@20.19.43)
|
||||
'@inquirer/search': 4.2.1(@types/node@20.19.43)
|
||||
'@inquirer/select': 5.2.1(@types/node@20.19.43)
|
||||
optionalDependencies:
|
||||
'@types/node': 20.19.43
|
||||
|
||||
'@inquirer/rawlist@5.3.1(@types/node@20.19.43)':
|
||||
dependencies:
|
||||
'@inquirer/core': 11.2.1(@types/node@20.19.43)
|
||||
'@inquirer/type': 4.0.7(@types/node@20.19.43)
|
||||
optionalDependencies:
|
||||
'@types/node': 20.19.43
|
||||
|
||||
'@inquirer/search@4.2.1(@types/node@20.19.43)':
|
||||
dependencies:
|
||||
'@inquirer/core': 11.2.1(@types/node@20.19.43)
|
||||
'@inquirer/figures': 2.0.7
|
||||
'@inquirer/type': 4.0.7(@types/node@20.19.43)
|
||||
optionalDependencies:
|
||||
'@types/node': 20.19.43
|
||||
|
||||
'@inquirer/select@5.2.1(@types/node@20.19.43)':
|
||||
dependencies:
|
||||
'@inquirer/ansi': 2.0.7
|
||||
'@inquirer/core': 11.2.1(@types/node@20.19.43)
|
||||
'@inquirer/figures': 2.0.7
|
||||
'@inquirer/type': 4.0.7(@types/node@20.19.43)
|
||||
optionalDependencies:
|
||||
'@types/node': 20.19.43
|
||||
|
||||
'@inquirer/type@4.0.7(@types/node@20.19.43)':
|
||||
optionalDependencies:
|
||||
'@types/node': 20.19.43
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
@@ -5851,6 +6134,8 @@ snapshots:
|
||||
|
||||
character-reference-invalid@2.0.1: {}
|
||||
|
||||
chardet@2.2.0: {}
|
||||
|
||||
class-variance-authority@0.7.1:
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
@@ -5861,6 +6146,8 @@ snapshots:
|
||||
|
||||
cli-spinners@2.9.2: {}
|
||||
|
||||
cli-width@4.1.0: {}
|
||||
|
||||
client-only@0.0.1: {}
|
||||
|
||||
clsx@2.1.1: {}
|
||||
@@ -5891,6 +6178,8 @@ snapshots:
|
||||
|
||||
commander@14.0.3: {}
|
||||
|
||||
commander@15.0.0: {}
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
|
||||
conf@10.2.0:
|
||||
@@ -6610,8 +6899,18 @@ snapshots:
|
||||
|
||||
fast-levenshtein@2.0.6: {}
|
||||
|
||||
fast-string-truncated-width@3.0.3: {}
|
||||
|
||||
fast-string-width@3.0.2:
|
||||
dependencies:
|
||||
fast-string-truncated-width: 3.0.3
|
||||
|
||||
fast-uri@3.1.5: {}
|
||||
|
||||
fast-wrap-ansi@0.2.2:
|
||||
dependencies:
|
||||
fast-string-width: 3.0.2
|
||||
|
||||
fastq@1.20.1:
|
||||
dependencies:
|
||||
reusify: 1.1.0
|
||||
@@ -7581,6 +7880,8 @@ snapshots:
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
mute-stream@3.0.0: {}
|
||||
|
||||
nanoid@3.3.18: {}
|
||||
|
||||
napi-postinstall@0.3.4: {}
|
||||
|
||||
@@ -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 { createServer } from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import next from "next";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { getConfig } from "./src/lib/config/load";
|
||||
@@ -19,7 +21,10 @@ migrateOnBoot();
|
||||
syncAuthFromConfig();
|
||||
reconcileOrphanedRuns();
|
||||
|
||||
const app = next({ dev, hostname, port });
|
||||
// `dir` must be this file's own directory, not `process.cwd()` - when launched by the installed
|
||||
// `triggershell` CLI, the working directory is wherever the user's config lives, not the package.
|
||||
const dir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const app = next({ dev, dir, hostname, port });
|
||||
const handle = app.getRequestHandler();
|
||||
|
||||
app.prepare().then(() => {
|
||||
|
||||
@@ -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;
|
||||
try {
|
||||
parsedYaml = parseYaml(interpolated);
|
||||
parsedYaml = parseYaml(interpolated) ?? {};
|
||||
} catch (error) {
|
||||
throw new ConfigError(`Failed to parse YAML: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# TriggerShell configuration.
|
||||
# Full reference: https://dev.pivoine.art/valknar/triggershell.git (see docs/CONFIG_REFERENCE.md)
|
||||
|
||||
server:
|
||||
host: 127.0.0.1
|
||||
port: __PORT__
|
||||
|
||||
auth:
|
||||
enabled: __AUTH_ENABLED__
|
||||
# Must be >= 32 characters if auth is enabled. Kept out of this file - `triggershell init`
|
||||
# generated one into .env (TRIGGERSHELL_SESSION_SECRET) alongside this config.
|
||||
sessionSecret: "${TRIGGERSHELL_SESSION_SECRET}"
|
||||
sessionTtlHours: 12
|
||||
users: []
|
||||
# Add a user with: triggershell users add <username>
|
||||
tokens: []
|
||||
# Add an API token with: triggershell users add-token <name>
|
||||
|
||||
database:
|
||||
path: .triggershell/triggershell.db
|
||||
|
||||
logs:
|
||||
dir: .triggershell/logs
|
||||
retentionDays: 30
|
||||
|
||||
scripts: []
|
||||
# Add scripts like:
|
||||
# scripts:
|
||||
# - id: hello-world
|
||||
# name: Hello World
|
||||
# description: A minimal example script.
|
||||
# command: echo
|
||||
# args: ["Hello, TriggerShell!"]
|
||||
# timeoutSeconds: 60
|
||||
# variables: []
|
||||
Reference in New Issue
Block a user