script-runner-kit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CODE_OF_CONDUCT.md +27 -0
- package/CONTRIBUTING.md +21 -0
- package/LICENSE +21 -0
- package/README.md +141 -0
- package/SECURITY.md +18 -0
- package/bin/script-runner-kit.js +5 -0
- package/docs/AUTHENTICATION.md +43 -0
- package/docs/RELEASE.md +64 -0
- package/package.json +50 -0
- package/script-runner.config.json +14 -0
- package/scripts/check-update.sh +7 -0
- package/src/cli.js +45 -0
- package/src/config.js +300 -0
- package/src/index.js +5 -0
- package/src/server.js +334 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Code of Conduct
|
|
2
|
+
|
|
3
|
+
## Our Pledge
|
|
4
|
+
|
|
5
|
+
We are committed to making participation in this project a harassment-free experience for everyone.
|
|
6
|
+
|
|
7
|
+
## Our Standards
|
|
8
|
+
|
|
9
|
+
Examples of behavior that contributes to a positive environment include:
|
|
10
|
+
|
|
11
|
+
- Being respectful and constructive
|
|
12
|
+
- Accepting feedback gracefully
|
|
13
|
+
- Focusing on what is best for the community
|
|
14
|
+
|
|
15
|
+
Examples of unacceptable behavior include:
|
|
16
|
+
|
|
17
|
+
- Harassment, trolling, or insulting language
|
|
18
|
+
- Personal attacks or discrimination
|
|
19
|
+
- Publishing others' private information without permission
|
|
20
|
+
|
|
21
|
+
## Enforcement
|
|
22
|
+
|
|
23
|
+
Project maintainers are responsible for clarifying and enforcing standards of acceptable behavior and may remove or edit contributions that violate this Code of Conduct.
|
|
24
|
+
|
|
25
|
+
## Scope
|
|
26
|
+
|
|
27
|
+
This Code of Conduct applies within all project spaces and public spaces when an individual is representing the project.
|
package/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Thanks for contributing!
|
|
4
|
+
|
|
5
|
+
## Development
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install
|
|
9
|
+
npm run check
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Pull Request Checklist
|
|
13
|
+
|
|
14
|
+
- Keep changes focused and small.
|
|
15
|
+
- Update docs when behavior changes.
|
|
16
|
+
- Run `npm run check` before opening PR.
|
|
17
|
+
- Add or update tests/checks for new behavior.
|
|
18
|
+
|
|
19
|
+
## Commit Style
|
|
20
|
+
|
|
21
|
+
Use clear commit messages that explain **why** the change is needed.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# script-runner-kit
|
|
2
|
+
|
|
3
|
+
A CLI tool for bot/webhook-triggered script execution with live output streaming. Supports `--config` and is ready for npm + `npx` usage.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Stream script stdout/stderr over Server-Sent Events (SSE)
|
|
8
|
+
- Prevent duplicate concurrent runs per script name
|
|
9
|
+
- Audit logs for every execution
|
|
10
|
+
- Config-driven scripts via `--config`
|
|
11
|
+
|
|
12
|
+
## Install / Run
|
|
13
|
+
|
|
14
|
+
### Use with npx
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npx script-runner-kit --config ./script-runner.config.json
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### Local development
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install
|
|
24
|
+
npm run check
|
|
25
|
+
node bin/script-runner-kit.js --config ./script-runner.config.json
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Server default URL:
|
|
29
|
+
|
|
30
|
+
```text
|
|
31
|
+
http://127.0.0.1:8088
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## CLI
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
script-runner-kit --config <path> [--port <number>]
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Options:
|
|
41
|
+
|
|
42
|
+
- `--config <path>`: required, supports `.json` / `.js` / `.cjs`
|
|
43
|
+
- `--port <number>`: optional, override config/env port
|
|
44
|
+
- `PORT` env: optional fallback if `--port` omitted
|
|
45
|
+
|
|
46
|
+
Port precedence:
|
|
47
|
+
|
|
48
|
+
1. `--port`
|
|
49
|
+
2. `PORT` environment variable
|
|
50
|
+
3. `port` from config file
|
|
51
|
+
4. default `8088`
|
|
52
|
+
|
|
53
|
+
## Configuration
|
|
54
|
+
|
|
55
|
+
Example `script-runner.config.json`:
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{
|
|
59
|
+
"port": 8088,
|
|
60
|
+
"auditDir": ".script-audit-logs",
|
|
61
|
+
"authTokens": ["global-secret-a", "global-secret-b"],
|
|
62
|
+
"scripts": {
|
|
63
|
+
"check-update": {
|
|
64
|
+
"scriptPath": "./scripts/check-update.sh",
|
|
65
|
+
"rootDir": ".",
|
|
66
|
+
"authTokens": ["check-secret-a", "check-secret-b"]
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Notes:
|
|
73
|
+
|
|
74
|
+
- `scriptPath` and `rootDir` are resolved relative to the config file directory.
|
|
75
|
+
- Each script supports either:
|
|
76
|
+
- `scriptPath` (execute via `bash <scriptPath>`)
|
|
77
|
+
- or `command` + optional `args`.
|
|
78
|
+
- Auth uses JWT and supports multiple secrets per script via `authTokens`.
|
|
79
|
+
|
|
80
|
+
### Auto package.json scripts
|
|
81
|
+
|
|
82
|
+
When starting in a directory containing `package.json`, this tool auto-discovers npm scripts and exposes them as runnable items:
|
|
83
|
+
|
|
84
|
+
- `<name>` (for example `build`)
|
|
85
|
+
- `npm:<name>` (for example `npm:build`)
|
|
86
|
+
|
|
87
|
+
Config-defined scripts take priority on name conflict.
|
|
88
|
+
|
|
89
|
+
### Authentication
|
|
90
|
+
|
|
91
|
+
Every API call requires a JWT token. Supported token sources:
|
|
92
|
+
|
|
93
|
+
- `Authorization: Bearer <token>` (recommended)
|
|
94
|
+
- `x-runner-token: <token>`
|
|
95
|
+
- query parameter `?token=<token>` (convenient for EventSource demos)
|
|
96
|
+
|
|
97
|
+
Verification rules:
|
|
98
|
+
|
|
99
|
+
- Uses `jsonwebtoken.verify()` with algorithms `HS256/HS384/HS512`
|
|
100
|
+
- Supports multiple secrets per script (`authTokens` array), useful for key rotation
|
|
101
|
+
- If a script has no local `authTokens`, top-level `authTokens` is used as fallback
|
|
102
|
+
|
|
103
|
+
## HTTP API
|
|
104
|
+
|
|
105
|
+
- `GET /` – minimal UI page
|
|
106
|
+
- `GET /api/<script-name>` – run script and stream SSE events
|
|
107
|
+
|
|
108
|
+
SSE events:
|
|
109
|
+
|
|
110
|
+
- `start`
|
|
111
|
+
- `log`
|
|
112
|
+
- `end`
|
|
113
|
+
- `error`
|
|
114
|
+
|
|
115
|
+
## Security Notes
|
|
116
|
+
|
|
117
|
+
- This tool executes shell scripts from your config. Only expose it inside trusted networks.
|
|
118
|
+
- Avoid putting untrusted script paths into config.
|
|
119
|
+
|
|
120
|
+
## Open Source Workflow
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
git tag v0.1.0
|
|
124
|
+
git push origin v0.1.0
|
|
125
|
+
gh release create v0.1.0 --title "v0.1.0" --notes "Release v0.1.0"
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
GitHub Actions workflow `.github/workflows/publish.yml` will publish to npm automatically.
|
|
129
|
+
It triggers on `v*` tags and supports npm Trusted Publishing (OIDC) or `NPM_TOKEN` secret.
|
|
130
|
+
|
|
131
|
+
4. Verify via:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
npx script-runner-kit --config ./script-runner.config.json --help
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Detailed release steps: see `docs/RELEASE.md`.
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
MIT
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported Versions
|
|
4
|
+
|
|
5
|
+
Only the latest minor release is actively supported with security fixes.
|
|
6
|
+
|
|
7
|
+
## Reporting a Vulnerability
|
|
8
|
+
|
|
9
|
+
Please do not open public issues for security vulnerabilities.
|
|
10
|
+
|
|
11
|
+
Instead, contact the maintainer privately and include:
|
|
12
|
+
|
|
13
|
+
- Affected version
|
|
14
|
+
- Reproduction steps
|
|
15
|
+
- Impact assessment
|
|
16
|
+
- Suggested mitigation (if any)
|
|
17
|
+
|
|
18
|
+
We will acknowledge the report as quickly as possible and work on a fix.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Authentication (JWT)
|
|
2
|
+
|
|
3
|
+
`script-runner-kit` protects script execution endpoints with JWT.
|
|
4
|
+
|
|
5
|
+
## Token transport
|
|
6
|
+
|
|
7
|
+
Supported sources (priority order):
|
|
8
|
+
|
|
9
|
+
1. `Authorization: Bearer <token>`
|
|
10
|
+
2. `x-runner-token: <token>`
|
|
11
|
+
3. Query param `?token=<token>`
|
|
12
|
+
|
|
13
|
+
## Config example
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"authTokens": ["global-secret-a", "global-secret-b"],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"deploy": {
|
|
20
|
+
"command": "npm",
|
|
21
|
+
"args": ["run", "deploy"],
|
|
22
|
+
"authTokens": ["deploy-secret-v2", "deploy-secret-v1"]
|
|
23
|
+
},
|
|
24
|
+
"build": {
|
|
25
|
+
"command": "npm",
|
|
26
|
+
"args": ["run", "build"]
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- Script-level `authTokens` are preferred.
|
|
33
|
+
- If missing, top-level `authTokens` is used.
|
|
34
|
+
|
|
35
|
+
## Key rotation
|
|
36
|
+
|
|
37
|
+
Use multiple secrets and keep new secret first:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
"authTokens": ["new-secret", "old-secret"]
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The runner tries each secret until verification succeeds.
|
package/docs/RELEASE.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Release Guide (GitHub + npm)
|
|
2
|
+
|
|
3
|
+
## 1) Prepare repository
|
|
4
|
+
|
|
5
|
+
1. Create a GitHub repo (for example: `script-runner-kit`).
|
|
6
|
+
2. Push this project to `main` branch.
|
|
7
|
+
3. Update `package.json` fields:
|
|
8
|
+
- `repository.url`
|
|
9
|
+
- `bugs.url`
|
|
10
|
+
- `homepage`
|
|
11
|
+
|
|
12
|
+
## 2) Verify before release
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm run check
|
|
16
|
+
npm pack --dry-run
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## 3) Publish to npm
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm login
|
|
23
|
+
npm publish --access public
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Publish via GitHub Actions (recommended)
|
|
27
|
+
|
|
28
|
+
1. Configure one of the following auth methods:
|
|
29
|
+
- Preferred: npm Trusted Publishing (OIDC)
|
|
30
|
+
- Compatible fallback: npm Automation Token in repo secret `NPM_TOKEN`
|
|
31
|
+
2. Push a version tag (or run workflow manually):
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
git tag v0.1.0
|
|
35
|
+
git push origin v0.1.0
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The workflow at `.github/workflows/publish.yml` will then:
|
|
39
|
+
|
|
40
|
+
- install dependencies
|
|
41
|
+
- run `npm run check`
|
|
42
|
+
- run `npm pack --dry-run`
|
|
43
|
+
- publish to npm with provenance
|
|
44
|
+
|
|
45
|
+
After publish succeeds, you can create a GitHub Release if needed:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
gh release create v0.1.0 --title "v0.1.0" --notes "Initial open-source release"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## 4) Verify npx usage
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npx script-runner-kit --help
|
|
55
|
+
npx script-runner-kit --config ./script-runner.config.json
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## 5) Create GitHub release
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
git tag v0.1.0
|
|
62
|
+
git push origin v0.1.0
|
|
63
|
+
gh release create v0.1.0 --title "v0.1.0" --notes "Initial open-source release"
|
|
64
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "script-runner-kit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Run project scripts via webhook-friendly HTTP endpoints",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"bin": {
|
|
8
|
+
"script-runner-kit": "bin/script-runner-kit.js"
|
|
9
|
+
},
|
|
10
|
+
"access": "public",
|
|
11
|
+
"main": "src/server.js",
|
|
12
|
+
"files": [
|
|
13
|
+
"bin",
|
|
14
|
+
"src",
|
|
15
|
+
"scripts",
|
|
16
|
+
"script-runner.config.json",
|
|
17
|
+
"README.md",
|
|
18
|
+
"docs",
|
|
19
|
+
"LICENSE",
|
|
20
|
+
"CONTRIBUTING.md",
|
|
21
|
+
"CODE_OF_CONDUCT.md",
|
|
22
|
+
"SECURITY.md"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"webhook",
|
|
29
|
+
"bot",
|
|
30
|
+
"script-runner",
|
|
31
|
+
"cli",
|
|
32
|
+
"npx"
|
|
33
|
+
],
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/GitaiQAQ/script-runner-kit.git"
|
|
37
|
+
},
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/GitaiQAQ/script-runner-kit/issues"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://github.com/GitaiQAQ/script-runner-kit#readme",
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"jsonwebtoken": "^9.0.2"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"check": "pnpm run check:syntax && pnpm run check:cli",
|
|
47
|
+
"check:syntax": "node --check src/index.js && node --check bin/script-runner-kit.js && node --check src/config.js && node --check src/server.js && node --check src/cli.js",
|
|
48
|
+
"check:cli": "node bin/script-runner-kit.js --help"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"port": 8088,
|
|
3
|
+
"auditDir": ".script-audit-logs",
|
|
4
|
+
"authTokens": [
|
|
5
|
+
"replace-with-strong-jwt-secret-1",
|
|
6
|
+
"replace-with-strong-jwt-secret-2"
|
|
7
|
+
],
|
|
8
|
+
"scripts": {
|
|
9
|
+
"check-update": {
|
|
10
|
+
"scriptPath": "./scripts/check-update.sh",
|
|
11
|
+
"rootDir": "."
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const { loadRunnerConfig, parseArgv, printHelp } = require("./config");
|
|
2
|
+
const { startServer } = require("./server");
|
|
3
|
+
|
|
4
|
+
function runCli(argv) {
|
|
5
|
+
let parsed;
|
|
6
|
+
try {
|
|
7
|
+
parsed = parseArgv(argv);
|
|
8
|
+
} catch (err) {
|
|
9
|
+
process.stderr.write(`Argument error: ${err.message}\n\n`);
|
|
10
|
+
printHelp();
|
|
11
|
+
process.exitCode = 1;
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (parsed.help) {
|
|
16
|
+
printHelp();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let config;
|
|
21
|
+
try {
|
|
22
|
+
config = loadRunnerConfig(parsed.configPath, process.cwd());
|
|
23
|
+
} catch (err) {
|
|
24
|
+
process.stderr.write(`Config error: ${err.message}\n`);
|
|
25
|
+
process.exitCode = 1;
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const envPort =
|
|
30
|
+
process.env.PORT && Number.isInteger(Number(process.env.PORT))
|
|
31
|
+
? Number(process.env.PORT)
|
|
32
|
+
: undefined;
|
|
33
|
+
|
|
34
|
+
const port = parsed.port || envPort || config.port || 8088;
|
|
35
|
+
|
|
36
|
+
startServer({
|
|
37
|
+
scripts: config.scripts,
|
|
38
|
+
auditDir: config.auditDir,
|
|
39
|
+
port,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = {
|
|
44
|
+
runCli,
|
|
45
|
+
};
|
package/src/config.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
|
|
4
|
+
function printHelp() {
|
|
5
|
+
process.stdout.write(
|
|
6
|
+
[
|
|
7
|
+
"Script Runner Kit",
|
|
8
|
+
"",
|
|
9
|
+
"Usage:",
|
|
10
|
+
" script-runner-kit --config <path-to-config>",
|
|
11
|
+
"",
|
|
12
|
+
"Options:",
|
|
13
|
+
" --config <path> Required. Path to runner config file (.json/.js/.cjs)",
|
|
14
|
+
" --port <number> Optional. Override port from config",
|
|
15
|
+
" -h, --help Show help",
|
|
16
|
+
"",
|
|
17
|
+
"Example:",
|
|
18
|
+
" npx script-runner-kit --config ./script-runner.config.json",
|
|
19
|
+
"",
|
|
20
|
+
].join("\n")
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseArgv(argv) {
|
|
25
|
+
const args = [...argv];
|
|
26
|
+
const parsed = {
|
|
27
|
+
configPath: "",
|
|
28
|
+
port: undefined,
|
|
29
|
+
help: false,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
while (args.length > 0) {
|
|
33
|
+
const current = args.shift();
|
|
34
|
+
|
|
35
|
+
if (!current) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (current === "-h" || current === "--help") {
|
|
40
|
+
parsed.help = true;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (current.startsWith("--config=")) {
|
|
45
|
+
parsed.configPath = current.slice("--config=".length).trim();
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (current === "--config") {
|
|
50
|
+
const value = args.shift();
|
|
51
|
+
if (!value) {
|
|
52
|
+
throw new Error("Missing value for --config");
|
|
53
|
+
}
|
|
54
|
+
parsed.configPath = value.trim();
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (current.startsWith("--port=")) {
|
|
59
|
+
const value = current.slice("--port=".length).trim();
|
|
60
|
+
const port = Number(value);
|
|
61
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
62
|
+
throw new Error(`Invalid --port value: ${value}`);
|
|
63
|
+
}
|
|
64
|
+
parsed.port = port;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (current === "--port") {
|
|
69
|
+
const value = args.shift();
|
|
70
|
+
if (!value) {
|
|
71
|
+
throw new Error("Missing value for --port");
|
|
72
|
+
}
|
|
73
|
+
const port = Number(value);
|
|
74
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
75
|
+
throw new Error(`Invalid --port value: ${value}`);
|
|
76
|
+
}
|
|
77
|
+
parsed.port = port;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
throw new Error(`Unknown argument: ${current}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return parsed;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function loadJsonConfig(configPath) {
|
|
88
|
+
const raw = fs.readFileSync(configPath, "utf8");
|
|
89
|
+
return JSON.parse(raw);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function loadJsConfig(configPath) {
|
|
93
|
+
const loaded = require(configPath);
|
|
94
|
+
if (loaded && typeof loaded === "object" && "default" in loaded) {
|
|
95
|
+
return loaded.default;
|
|
96
|
+
}
|
|
97
|
+
return loaded;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function normalizeAuthTokens(value, fieldPath) {
|
|
101
|
+
if (!Array.isArray(value)) {
|
|
102
|
+
throw new Error(`${fieldPath} must be an array of strings`);
|
|
103
|
+
}
|
|
104
|
+
const tokens = value.map((item) => String(item).trim()).filter(Boolean);
|
|
105
|
+
if (tokens.length === 0) {
|
|
106
|
+
throw new Error(`${fieldPath} must contain at least one non-empty token`);
|
|
107
|
+
}
|
|
108
|
+
return tokens;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function normalizeScriptFromConfig(configDir, scriptName, scriptConfig) {
|
|
112
|
+
if (!scriptConfig || typeof scriptConfig !== "object") {
|
|
113
|
+
throw new Error(`scripts.${scriptName} must be an object`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const resolvedRootDir = path.resolve(
|
|
117
|
+
configDir,
|
|
118
|
+
typeof scriptConfig.rootDir === "string" && scriptConfig.rootDir.trim()
|
|
119
|
+
? scriptConfig.rootDir
|
|
120
|
+
: "."
|
|
121
|
+
);
|
|
122
|
+
const authTokens =
|
|
123
|
+
scriptConfig.authTokens === undefined
|
|
124
|
+
? undefined
|
|
125
|
+
: normalizeAuthTokens(scriptConfig.authTokens, `scripts.${scriptName}.authTokens`);
|
|
126
|
+
|
|
127
|
+
if (typeof scriptConfig.scriptPath === "string" && scriptConfig.scriptPath.trim()) {
|
|
128
|
+
return {
|
|
129
|
+
rootDir: resolvedRootDir,
|
|
130
|
+
scriptPath: path.resolve(configDir, scriptConfig.scriptPath),
|
|
131
|
+
authTokens,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (typeof scriptConfig.command === "string" && scriptConfig.command.trim()) {
|
|
136
|
+
const args =
|
|
137
|
+
scriptConfig.args === undefined
|
|
138
|
+
? []
|
|
139
|
+
: Array.isArray(scriptConfig.args)
|
|
140
|
+
? scriptConfig.args
|
|
141
|
+
: null;
|
|
142
|
+
if (!args || args.some((item) => typeof item !== "string")) {
|
|
143
|
+
throw new Error(`scripts.${scriptName}.args must be an array of strings`);
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
rootDir: resolvedRootDir,
|
|
147
|
+
command: scriptConfig.command,
|
|
148
|
+
args,
|
|
149
|
+
authTokens,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
throw new Error(
|
|
154
|
+
`scripts.${scriptName} must provide either scriptPath or command`
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function loadPackageJsonScripts(cwd) {
|
|
159
|
+
const packageJsonPath = path.join(cwd, "package.json");
|
|
160
|
+
if (!fs.existsSync(packageJsonPath)) {
|
|
161
|
+
return {};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let pkg;
|
|
165
|
+
try {
|
|
166
|
+
pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
|
167
|
+
} catch (err) {
|
|
168
|
+
throw new Error(`Failed to parse package.json at ${packageJsonPath}: ${err.message}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!pkg || typeof pkg !== "object") {
|
|
172
|
+
return {};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const scripts = pkg.scripts;
|
|
176
|
+
if (!scripts || typeof scripts !== "object" || Array.isArray(scripts)) {
|
|
177
|
+
return {};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const discovered = {};
|
|
181
|
+
for (const scriptName of Object.keys(scripts)) {
|
|
182
|
+
discovered[scriptName] = {
|
|
183
|
+
rootDir: cwd,
|
|
184
|
+
command: "npm",
|
|
185
|
+
args: ["run", scriptName],
|
|
186
|
+
};
|
|
187
|
+
discovered[`npm:${scriptName}`] = {
|
|
188
|
+
rootDir: cwd,
|
|
189
|
+
command: "npm",
|
|
190
|
+
args: ["run", scriptName],
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return discovered;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function resolveScriptConfig(configPath, rawConfig, cwd) {
|
|
198
|
+
if (!rawConfig || typeof rawConfig !== "object") {
|
|
199
|
+
throw new Error("Config must be an object");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const configDir = path.dirname(configPath);
|
|
203
|
+
const globalAuthTokens =
|
|
204
|
+
rawConfig.authTokens === undefined
|
|
205
|
+
? undefined
|
|
206
|
+
: normalizeAuthTokens(rawConfig.authTokens, "authTokens");
|
|
207
|
+
const scripts = rawConfig.scripts || {};
|
|
208
|
+
if (typeof scripts !== "object" || Array.isArray(scripts)) {
|
|
209
|
+
throw new Error("Config field 'scripts' must be an object when provided");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const resolvedScripts = {};
|
|
213
|
+
for (const [scriptName, scriptConfig] of Object.entries(scripts)) {
|
|
214
|
+
resolvedScripts[scriptName] = normalizeScriptFromConfig(
|
|
215
|
+
configDir,
|
|
216
|
+
scriptName,
|
|
217
|
+
scriptConfig
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const discoveredScripts = loadPackageJsonScripts(cwd);
|
|
222
|
+
for (const [scriptName, scriptConfig] of Object.entries(discoveredScripts)) {
|
|
223
|
+
if (!resolvedScripts[scriptName]) {
|
|
224
|
+
resolvedScripts[scriptName] = scriptConfig;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (Object.keys(resolvedScripts).length === 0) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
"No scripts available. Add config.scripts or run in a directory with package.json scripts"
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
for (const [scriptName, scriptConfig] of Object.entries(resolvedScripts)) {
|
|
235
|
+
if (!Array.isArray(scriptConfig.authTokens) || scriptConfig.authTokens.length === 0) {
|
|
236
|
+
if (globalAuthTokens && globalAuthTokens.length > 0) {
|
|
237
|
+
resolvedScripts[scriptName] = {
|
|
238
|
+
...scriptConfig,
|
|
239
|
+
authTokens: globalAuthTokens,
|
|
240
|
+
};
|
|
241
|
+
} else {
|
|
242
|
+
throw new Error(
|
|
243
|
+
`Auth required for scripts.${scriptName}. Add scripts.${scriptName}.authTokens or top-level authTokens`
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const resolvedAuditDir = path.resolve(
|
|
250
|
+
configDir,
|
|
251
|
+
typeof rawConfig.auditDir === "string" && rawConfig.auditDir.trim()
|
|
252
|
+
? rawConfig.auditDir
|
|
253
|
+
: ".script-audit-logs"
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const configuredPort =
|
|
257
|
+
rawConfig.port !== undefined && rawConfig.port !== null
|
|
258
|
+
? Number(rawConfig.port)
|
|
259
|
+
: undefined;
|
|
260
|
+
if (
|
|
261
|
+
configuredPort !== undefined &&
|
|
262
|
+
(!Number.isInteger(configuredPort) || configuredPort <= 0)
|
|
263
|
+
) {
|
|
264
|
+
throw new Error("Config field 'port' must be a positive integer when provided");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
scripts: resolvedScripts,
|
|
269
|
+
auditDir: resolvedAuditDir,
|
|
270
|
+
port: configuredPort,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function loadRunnerConfig(configPathArg, cwd) {
|
|
275
|
+
if (!configPathArg || typeof configPathArg !== "string") {
|
|
276
|
+
throw new Error("--config is required");
|
|
277
|
+
}
|
|
278
|
+
const resolvedPath = path.resolve(cwd, configPathArg);
|
|
279
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
280
|
+
throw new Error(`Config file not found: ${resolvedPath}`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const ext = path.extname(resolvedPath).toLowerCase();
|
|
284
|
+
let rawConfig;
|
|
285
|
+
if (ext === ".json") {
|
|
286
|
+
rawConfig = loadJsonConfig(resolvedPath);
|
|
287
|
+
} else if (ext === ".js" || ext === ".cjs") {
|
|
288
|
+
rawConfig = loadJsConfig(resolvedPath);
|
|
289
|
+
} else {
|
|
290
|
+
throw new Error("Unsupported config extension. Use .json, .js, or .cjs");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return resolveScriptConfig(resolvedPath, rawConfig, cwd);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
module.exports = {
|
|
297
|
+
parseArgv,
|
|
298
|
+
printHelp,
|
|
299
|
+
loadRunnerConfig,
|
|
300
|
+
};
|
package/src/index.js
ADDED
package/src/server.js
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
const http = require("http");
|
|
2
|
+
const { spawn } = require("child_process");
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const jwt = require("jsonwebtoken");
|
|
6
|
+
|
|
7
|
+
function nowStamp() {
|
|
8
|
+
return new Date().toISOString().replaceAll(":", "-");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function fileSafeName(name) {
|
|
12
|
+
return name.replaceAll(/[^a-zA-Z0-9._-]/g, "-");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function escapeHtml(value) {
|
|
16
|
+
return value
|
|
17
|
+
.replaceAll("&", "&")
|
|
18
|
+
.replaceAll("<", "<")
|
|
19
|
+
.replaceAll(">", ">")
|
|
20
|
+
.replaceAll('"', """);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function send(res, status, body, contentType = "text/plain; charset=utf-8") {
|
|
24
|
+
res.writeHead(status, { "Content-Type": contentType });
|
|
25
|
+
res.end(body);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function sendSse(res, event, payload) {
|
|
29
|
+
res.write(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readTokenFromRequest(req, url) {
|
|
33
|
+
const auth = req.headers.authorization;
|
|
34
|
+
if (typeof auth === "string" && auth.startsWith("Bearer ")) {
|
|
35
|
+
const token = auth.slice("Bearer ".length).trim();
|
|
36
|
+
if (token) {
|
|
37
|
+
return token;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const headerToken = req.headers["x-runner-token"];
|
|
42
|
+
if (typeof headerToken === "string" && headerToken.trim()) {
|
|
43
|
+
return headerToken.trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const queryToken = url.searchParams.get("token");
|
|
47
|
+
if (typeof queryToken === "string" && queryToken.trim()) {
|
|
48
|
+
return queryToken.trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function verifyJwtWithSecrets(token, secrets) {
|
|
55
|
+
for (const secret of secrets) {
|
|
56
|
+
try {
|
|
57
|
+
const payload = jwt.verify(token, secret, {
|
|
58
|
+
algorithms: ["HS256", "HS384", "HS512"],
|
|
59
|
+
});
|
|
60
|
+
return { ok: true, payload };
|
|
61
|
+
} catch (err) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { ok: false };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function createAuditLogger(auditDir, scriptName, rootDir, target, remoteAddress) {
|
|
70
|
+
fs.mkdirSync(auditDir, { recursive: true });
|
|
71
|
+
const logFile = path.join(
|
|
72
|
+
auditDir,
|
|
73
|
+
`${nowStamp()}__${fileSafeName(scriptName)}.log`
|
|
74
|
+
);
|
|
75
|
+
const stream = fs.createWriteStream(logFile, { flags: "a" });
|
|
76
|
+
const write = (record) => {
|
|
77
|
+
stream.write(
|
|
78
|
+
`${JSON.stringify({ ts: new Date().toISOString(), ...record })}\n`
|
|
79
|
+
);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
write({
|
|
83
|
+
event: "start",
|
|
84
|
+
script: scriptName,
|
|
85
|
+
rootDir,
|
|
86
|
+
target,
|
|
87
|
+
remoteAddress,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
logFile,
|
|
92
|
+
write,
|
|
93
|
+
close: () => stream.end(),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function resolveExecutionTarget(scriptName, scriptConfig) {
|
|
98
|
+
const rootDir = path.resolve(scriptConfig.rootDir);
|
|
99
|
+
if (!fs.existsSync(rootDir)) {
|
|
100
|
+
return { error: `Script config invalid (rootDir missing): ${scriptName}` };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (typeof scriptConfig.scriptPath === "string" && scriptConfig.scriptPath.trim()) {
|
|
104
|
+
const scriptPath = path.resolve(scriptConfig.scriptPath);
|
|
105
|
+
if (!fs.existsSync(scriptPath)) {
|
|
106
|
+
return { error: `Script config invalid (scriptPath missing): ${scriptName}` };
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
rootDir,
|
|
110
|
+
target: scriptPath,
|
|
111
|
+
command: "bash",
|
|
112
|
+
args: [scriptPath],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (typeof scriptConfig.command === "string" && scriptConfig.command.trim()) {
|
|
117
|
+
return {
|
|
118
|
+
rootDir,
|
|
119
|
+
target: `${scriptConfig.command} ${(scriptConfig.args || []).join(" ")}`.trim(),
|
|
120
|
+
command: scriptConfig.command,
|
|
121
|
+
args: Array.isArray(scriptConfig.args) ? scriptConfig.args : [],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { error: `Script config invalid (no executable target): ${scriptName}` };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function renderHome(scripts) {
|
|
129
|
+
const options = Object.entries(scripts)
|
|
130
|
+
.map(([name, config]) => {
|
|
131
|
+
const safeName = escapeHtml(name);
|
|
132
|
+
const safeRoot = escapeHtml(config.rootDir);
|
|
133
|
+
return `<option value="${safeName}">${safeName} (${safeRoot})</option>`;
|
|
134
|
+
})
|
|
135
|
+
.join("");
|
|
136
|
+
|
|
137
|
+
return `<!doctype html>
|
|
138
|
+
<meta charset="utf-8">
|
|
139
|
+
<title>Script Runner</title>
|
|
140
|
+
<h1>Script Runner</h1>
|
|
141
|
+
<p>Use GET /api/<script-name> to trigger execution and stream output via SSE.</p>
|
|
142
|
+
<h2>Quick Usage</h2>
|
|
143
|
+
<ul>
|
|
144
|
+
<li>Start: <code>npx script-runner-kit --config ./script-runner.config.json</code></li>
|
|
145
|
+
<li>Trigger: <code>GET /api/<script-name></code> (for example <code>/api/check</code>)</li>
|
|
146
|
+
<li>Auto-loads scripts from local <code>package.json</code> (config entries take precedence on name conflicts)</li>
|
|
147
|
+
<li>Auth: send JWT via <code>Authorization: Bearer <token></code>, <code>x-runner-token</code>, or <code>?token=</code></li>
|
|
148
|
+
</ul>
|
|
149
|
+
<label>script:</label>
|
|
150
|
+
<select id="name">${options}</select>
|
|
151
|
+
<label>jwt token:</label>
|
|
152
|
+
<input id="token" type="password" placeholder="paste JWT token" style="min-width: 360px" />
|
|
153
|
+
<button id="run">Run</button>
|
|
154
|
+
<pre id="out"></pre>
|
|
155
|
+
<script>
|
|
156
|
+
const out = document.getElementById('out');
|
|
157
|
+
const name = document.getElementById('name');
|
|
158
|
+
const token = document.getElementById('token');
|
|
159
|
+
const run = document.getElementById('run');
|
|
160
|
+
let es;
|
|
161
|
+
function log(line) { out.textContent += line + '\\n'; }
|
|
162
|
+
run.onclick = () => {
|
|
163
|
+
out.textContent = '';
|
|
164
|
+
if (es) es.close();
|
|
165
|
+
const script = encodeURIComponent(name.value);
|
|
166
|
+
const t = token.value.trim();
|
|
167
|
+
const suffix = t ? ('?token=' + encodeURIComponent(t)) : '';
|
|
168
|
+
es = new EventSource('/api/' + script + suffix);
|
|
169
|
+
es.addEventListener('start', (e) => {
|
|
170
|
+
const m = JSON.parse(e.data);
|
|
171
|
+
log('[start] ' + m.script);
|
|
172
|
+
});
|
|
173
|
+
es.addEventListener('log', (e) => {
|
|
174
|
+
const m = JSON.parse(e.data);
|
|
175
|
+
log('[' + m.stream + '] ' + m.text);
|
|
176
|
+
});
|
|
177
|
+
es.addEventListener('end', (e) => {
|
|
178
|
+
const m = JSON.parse(e.data);
|
|
179
|
+
log('[end] exit=' + m.code + ' signal=' + m.signal);
|
|
180
|
+
es.close();
|
|
181
|
+
});
|
|
182
|
+
es.addEventListener('error', (e) => {
|
|
183
|
+
if (e.data) {
|
|
184
|
+
const m = JSON.parse(e.data);
|
|
185
|
+
log('[error] ' + m.message);
|
|
186
|
+
} else {
|
|
187
|
+
log('[error] connection closed');
|
|
188
|
+
}
|
|
189
|
+
if (es) es.close();
|
|
190
|
+
});
|
|
191
|
+
};
|
|
192
|
+
</script>`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function createServer({ scripts, auditDir }) {
|
|
196
|
+
const running = new Map();
|
|
197
|
+
|
|
198
|
+
return http.createServer((req, res) => {
|
|
199
|
+
if (!req.url) {
|
|
200
|
+
send(res, 400, "Bad Request");
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
|
|
205
|
+
|
|
206
|
+
if (req.method === "GET" && url.pathname === "/") {
|
|
207
|
+
send(res, 200, renderHome(scripts), "text/html; charset=utf-8");
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (req.method === "GET" && url.pathname.startsWith("/api/")) {
|
|
212
|
+
const scriptName = decodeURIComponent(
|
|
213
|
+
url.pathname.slice("/api/".length)
|
|
214
|
+
).trim();
|
|
215
|
+
const scriptConfig = scripts[scriptName];
|
|
216
|
+
|
|
217
|
+
if (!scriptName || !scriptConfig) {
|
|
218
|
+
send(res, 404, "Unknown script");
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const token = readTokenFromRequest(req, url);
|
|
223
|
+
if (!token) {
|
|
224
|
+
send(res, 401, "Unauthorized: missing JWT token");
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const authResult = verifyJwtWithSecrets(token, scriptConfig.authTokens || []);
|
|
229
|
+
if (!authResult.ok) {
|
|
230
|
+
send(res, 403, "Forbidden: invalid token");
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const targetSpec = resolveExecutionTarget(scriptName, scriptConfig);
|
|
235
|
+
if (targetSpec.error) {
|
|
236
|
+
send(res, 500, targetSpec.error);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const { rootDir, target, command, args } = targetSpec;
|
|
241
|
+
|
|
242
|
+
if (running.get(scriptName)) {
|
|
243
|
+
send(res, 409, `Script is already running: ${scriptName}`);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
res.writeHead(200, {
|
|
248
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
249
|
+
"Cache-Control": "no-cache, no-store, must-revalidate, no-transform",
|
|
250
|
+
Connection: "keep-alive",
|
|
251
|
+
Pragma: "no-cache",
|
|
252
|
+
Expires: "0",
|
|
253
|
+
"X-Accel-Buffering": "no",
|
|
254
|
+
});
|
|
255
|
+
res.flushHeaders();
|
|
256
|
+
res.write(": connected\n\n");
|
|
257
|
+
|
|
258
|
+
const audit = createAuditLogger(
|
|
259
|
+
auditDir,
|
|
260
|
+
scriptName,
|
|
261
|
+
rootDir,
|
|
262
|
+
target,
|
|
263
|
+
req.socket.remoteAddress || "unknown"
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
const child = spawn(command, args, {
|
|
267
|
+
cwd: rootDir,
|
|
268
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
269
|
+
env: process.env,
|
|
270
|
+
});
|
|
271
|
+
child.stdin.end();
|
|
272
|
+
running.set(scriptName, child);
|
|
273
|
+
|
|
274
|
+
sendSse(res, "start", {
|
|
275
|
+
script: scriptName,
|
|
276
|
+
rootDir,
|
|
277
|
+
target,
|
|
278
|
+
subject:
|
|
279
|
+
authResult.payload && typeof authResult.payload === "object"
|
|
280
|
+
? authResult.payload.sub || ""
|
|
281
|
+
: "",
|
|
282
|
+
logFile: audit.logFile,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
child.stdout.on("data", (chunk) => {
|
|
286
|
+
const text = String(chunk);
|
|
287
|
+
sendSse(res, "log", { stream: "stdout", text });
|
|
288
|
+
audit.write({ event: "log", stream: "stdout", text });
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
child.stderr.on("data", (chunk) => {
|
|
292
|
+
const text = String(chunk);
|
|
293
|
+
sendSse(res, "log", { stream: "stderr", text });
|
|
294
|
+
audit.write({ event: "log", stream: "stderr", text });
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
child.on("error", (err) => {
|
|
298
|
+
sendSse(res, "error", { message: err.message });
|
|
299
|
+
audit.write({ event: "error", message: err.message });
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
child.on("close", (code, signal) => {
|
|
303
|
+
running.delete(scriptName);
|
|
304
|
+
sendSse(res, "end", { code, signal });
|
|
305
|
+
audit.write({ event: "end", code, signal });
|
|
306
|
+
audit.close();
|
|
307
|
+
res.end();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
req.on("close", () => {
|
|
311
|
+
if (running.get(scriptName) === child) {
|
|
312
|
+
audit.write({ event: "client-disconnect" });
|
|
313
|
+
child.kill("SIGTERM");
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
send(res, 404, "Not Found");
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function startServer({ scripts, auditDir, port }) {
|
|
324
|
+
const server = createServer({ scripts, auditDir });
|
|
325
|
+
server.listen(port, () => {
|
|
326
|
+
process.stdout.write(`Server listening on http://0.0.0.0:${port}\n`);
|
|
327
|
+
});
|
|
328
|
+
return server;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
module.exports = {
|
|
332
|
+
createServer,
|
|
333
|
+
startServer,
|
|
334
|
+
};
|