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.
@@ -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.
@@ -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,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { runCli } = require("../src/cli");
4
+
5
+ runCli(process.argv.slice(2));
@@ -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.
@@ -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
+ }
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env bash
2
+
3
+ set -euo pipefail
4
+
5
+ echo "[check-update] started at $(date -u +%FT%TZ)"
6
+ echo "[check-update] no remote configured in demo script"
7
+ echo "[check-update] finished"
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
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { runCli } = require("./cli");
4
+
5
+ runCli(process.argv.slice(2));
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("&", "&amp;")
18
+ .replaceAll("<", "&lt;")
19
+ .replaceAll(">", "&gt;")
20
+ .replaceAll('"', "&quot;");
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/&lt;script-name&gt; 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/&lt;script-name&gt;</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 &lt;token&gt;</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
+ };