kickload-watcher-mcp 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/README.md +233 -0
- package/compliance-poller.js +215 -0
- package/config.js +103 -0
- package/email-cooldown.js +23 -0
- package/email-sender.js +137 -0
- package/env-check.js +117 -0
- package/file-watcher.js +230 -0
- package/github-webhook.js +321 -0
- package/identity-map.js +75 -0
- package/index.js +270 -0
- package/kickload-client.js +254 -0
- package/logger.js +118 -0
- package/ngrok-manager.js +313 -0
- package/package.json +51 -0
- package/pipeline.js +448 -0
- package/server-detector.js +109 -0
- package/setup.js +201 -0
- package/test-generator.js +66 -0
- package/users.json +5 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// kickload-client.js
|
|
2
|
+
// Single source of truth for ALL Kickload API calls.
|
|
3
|
+
// Every endpoint verified against app.py source code.
|
|
4
|
+
//
|
|
5
|
+
// ⚠️ NEW BASE URL: https://kickload.neeyatai.com/api
|
|
6
|
+
// All routes are relative to this base, e.g.:
|
|
7
|
+
// POST https://kickload.neeyatai.com/api/generate-test-plan
|
|
8
|
+
// POST https://kickload.neeyatai.com/api/run-test/{filename}
|
|
9
|
+
// GET https://kickload.neeyatai.com/api/task-status/{id}
|
|
10
|
+
// POST https://kickload.neeyatai.com/api/analyzeJTL
|
|
11
|
+
// GET https://kickload.neeyatai.com/api/download/{filename}
|
|
12
|
+
|
|
13
|
+
import FormData from "form-data";
|
|
14
|
+
import fetch from "node-fetch";
|
|
15
|
+
import fs from "fs";
|
|
16
|
+
import path from "path";
|
|
17
|
+
|
|
18
|
+
const POLL_INTERVAL_MS = 5000; // 5s between polls — matches backend
|
|
19
|
+
const POLL_TIMEOUT_MS = 600000; // 10 minutes max
|
|
20
|
+
|
|
21
|
+
export class KickloadClient {
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} baseUrl — e.g. "https://kickload.neeyatai.com/api"
|
|
24
|
+
* @param {string} apiToken — X-API-Token value
|
|
25
|
+
*/
|
|
26
|
+
constructor(baseUrl, apiToken) {
|
|
27
|
+
// Normalize: strip trailing slash
|
|
28
|
+
this.baseUrl = baseUrl.replace(/\/$/, "");
|
|
29
|
+
this.token = apiToken;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ─── Private authenticated fetch ─────────────────────────────
|
|
33
|
+
async _fetch(method, endpoint, { body, headers = {}, isJson = false } = {}) {
|
|
34
|
+
const url = `${this.baseUrl}${endpoint}`;
|
|
35
|
+
|
|
36
|
+
const reqHeaders = {
|
|
37
|
+
"X-API-Token": this.token,
|
|
38
|
+
...headers,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
if (isJson) {
|
|
42
|
+
reqHeaders["Content-Type"] = "application/json";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const response = await fetch(url, {
|
|
46
|
+
method,
|
|
47
|
+
headers: reqHeaders,
|
|
48
|
+
body,
|
|
49
|
+
signal: AbortSignal.timeout(POLL_TIMEOUT_MS),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const text = await response.text();
|
|
53
|
+
|
|
54
|
+
let parsed;
|
|
55
|
+
try {
|
|
56
|
+
parsed = JSON.parse(text);
|
|
57
|
+
} catch {
|
|
58
|
+
parsed = { _raw: text };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
const err = new Error(
|
|
63
|
+
`Kickload ${method} ${endpoint} → ${response.status} ${response.statusText}\n` +
|
|
64
|
+
`URL: ${url}\n` +
|
|
65
|
+
`Response: ${text.substring(0, 500)}`
|
|
66
|
+
);
|
|
67
|
+
err.status = response.status;
|
|
68
|
+
err.body = text;
|
|
69
|
+
err.parsed = parsed;
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return parsed;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ══════════════════════════════════════════════════════════════
|
|
77
|
+
// STEP 1 — Generate Test Plan
|
|
78
|
+
// POST /generate-test-plan
|
|
79
|
+
// Source: app.py line 1260
|
|
80
|
+
//
|
|
81
|
+
// Send prompt OR JMX file OR both.
|
|
82
|
+
// Returns: { status: "success", jmx_filename: "test_plan_xxx.jmx" }
|
|
83
|
+
// ══════════════════════════════════════════════════════════════
|
|
84
|
+
async generateTestPlan({ prompt, jmxFilePath } = {}) {
|
|
85
|
+
if (!prompt && !jmxFilePath) {
|
|
86
|
+
throw new Error("generateTestPlan: provide at least a prompt or jmxFilePath");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const form = new FormData();
|
|
90
|
+
|
|
91
|
+
if (prompt) {
|
|
92
|
+
form.append("prompt", prompt);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (jmxFilePath) {
|
|
96
|
+
const resolved = path.resolve(jmxFilePath);
|
|
97
|
+
if (!fs.existsSync(resolved)) {
|
|
98
|
+
throw new Error(`JMX file not found: ${resolved}`);
|
|
99
|
+
}
|
|
100
|
+
form.append("file", fs.createReadStream(resolved), {
|
|
101
|
+
filename: path.basename(resolved),
|
|
102
|
+
contentType: "application/xml",
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
console.log("\n📋 STEP 1: Generating test plan...");
|
|
107
|
+
if (prompt) console.log(` Prompt : ${prompt.substring(0, 100)}`);
|
|
108
|
+
if (jmxFilePath) console.log(` JMX file: ${jmxFilePath}`);
|
|
109
|
+
|
|
110
|
+
const result = await this._fetch("POST", "/generate-test-plan", {
|
|
111
|
+
body: form,
|
|
112
|
+
headers: form.getHeaders(),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
if (result.status !== "success") {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`Test plan generation failed: ${result.message || JSON.stringify(result)}`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
console.log(`✅ STEP 1 done — JMX: ${result.jmx_filename}`);
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ══════════════════════════════════════════════════════════════
|
|
126
|
+
// STEP 2 — Run Test
|
|
127
|
+
// POST /run-test/{jmx_filename}
|
|
128
|
+
// Source: app.py line 1022
|
|
129
|
+
//
|
|
130
|
+
// Returns: { status: "started", task_id: "uuid-xxxx" }
|
|
131
|
+
// ══════════════════════════════════════════════════════════════
|
|
132
|
+
async runTest(jmxFilename, { numThreads, loopCount, rampTime } = {}) {
|
|
133
|
+
if (!jmxFilename) {
|
|
134
|
+
throw new Error("runTest: jmxFilename is required");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const body = {};
|
|
138
|
+
if (numThreads !== undefined) body.num_threads = numThreads;
|
|
139
|
+
if (loopCount !== undefined) body.loop_count = loopCount;
|
|
140
|
+
if (rampTime !== undefined) body.ramp_time = rampTime;
|
|
141
|
+
|
|
142
|
+
console.log(`\n▶️ STEP 2: Running test — ${jmxFilename}`);
|
|
143
|
+
console.log(` Overrides: ${JSON.stringify(body) || "none (using JMX defaults)"}`);
|
|
144
|
+
|
|
145
|
+
const result = await this._fetch(
|
|
146
|
+
"POST",
|
|
147
|
+
`/run-test/${encodeURIComponent(jmxFilename)}`,
|
|
148
|
+
{ body: JSON.stringify(body), isJson: true }
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
if (result.status !== "started") {
|
|
152
|
+
throw new Error(`Run test failed: ${result.message || JSON.stringify(result)}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
console.log(`✅ STEP 2 done — Task ID: ${result.task_id}`);
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ══════════════════════════════════════════════════════════════
|
|
160
|
+
// STEP 3 — Poll Task Status
|
|
161
|
+
// GET /task-status/{task_id}
|
|
162
|
+
// Source: app.py line 899
|
|
163
|
+
//
|
|
164
|
+
// Polls every 5s until status = "success" | "error"
|
|
165
|
+
// Returns: { status, result_file, pdf_file, summary_output }
|
|
166
|
+
// ══════════════════════════════════════════════════════════════
|
|
167
|
+
async pollUntilDone(taskId) {
|
|
168
|
+
if (!taskId) throw new Error("pollUntilDone: taskId is required");
|
|
169
|
+
|
|
170
|
+
console.log(`\n⏳ STEP 3: Polling task ${taskId}`);
|
|
171
|
+
const started = Date.now();
|
|
172
|
+
let polls = 0;
|
|
173
|
+
|
|
174
|
+
while (Date.now() - started < POLL_TIMEOUT_MS) {
|
|
175
|
+
await sleep(POLL_INTERVAL_MS);
|
|
176
|
+
polls++;
|
|
177
|
+
|
|
178
|
+
const result = await this._fetch("GET", `/task-status/${taskId}`);
|
|
179
|
+
const elapsed = Math.round((Date.now() - started) / 1000);
|
|
180
|
+
|
|
181
|
+
if (result.status === "running") {
|
|
182
|
+
process.stdout.write(`\r ⏳ Running... ${elapsed}s (poll #${polls})`);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (result.status === "success") {
|
|
187
|
+
console.log(`\n✅ STEP 3 done in ${elapsed}s`);
|
|
188
|
+
console.log(` JTL: ${result.result_file}`);
|
|
189
|
+
console.log(` PDF: ${result.pdf_file}`);
|
|
190
|
+
return result;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (result.status === "error") {
|
|
194
|
+
throw new Error(`Task ${taskId} failed: ${result.message}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
throw new Error(`Task ${taskId} unknown status: ${result.status}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
throw new Error(`Task ${taskId} timed out after ${POLL_TIMEOUT_MS / 60000} minutes`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ══════════════════════════════════════════════════════════════
|
|
204
|
+
// STEP 4 — Analyze JTL
|
|
205
|
+
// POST /analyzeJTL
|
|
206
|
+
// Source: app.py line 1127
|
|
207
|
+
//
|
|
208
|
+
// Returns: { filename: "analysis_xxx.pdf" }
|
|
209
|
+
// ══════════════════════════════════════════════════════════════
|
|
210
|
+
async analyzeJtl(jtlFilename) {
|
|
211
|
+
if (!jtlFilename) throw new Error("analyzeJtl: jtlFilename is required");
|
|
212
|
+
|
|
213
|
+
console.log(`\n🔬 STEP 4: Analyzing JTL — ${jtlFilename}`);
|
|
214
|
+
|
|
215
|
+
const result = await this._fetch("POST", "/analyzeJTL", {
|
|
216
|
+
body: JSON.stringify({ filename: jtlFilename }),
|
|
217
|
+
isJson: true,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
if (!result.filename) {
|
|
221
|
+
throw new Error(`JTL analysis returned no filename: ${JSON.stringify(result)}`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
console.log(`✅ STEP 4 done — Analysis PDF: ${result.filename}`);
|
|
225
|
+
return result;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ══════════════════════════════════════════════════════════════
|
|
229
|
+
// STEP 5 — Get Download URL
|
|
230
|
+
// GET /download/{filename}?mode=inline
|
|
231
|
+
// Source: app.py line 1472
|
|
232
|
+
//
|
|
233
|
+
// Returns: { status: "success", download_url: "https://s3.amazonaws.com/..." }
|
|
234
|
+
// ══════════════════════════════════════════════════════════════
|
|
235
|
+
async getDownloadUrl(filename) {
|
|
236
|
+
if (!filename) return null;
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
const result = await this._fetch(
|
|
240
|
+
"GET",
|
|
241
|
+
`/download/${encodeURIComponent(filename)}?mode=inline`
|
|
242
|
+
);
|
|
243
|
+
return result.download_url || null;
|
|
244
|
+
} catch (err) {
|
|
245
|
+
console.warn(`⚠️ Download URL failed for ${filename}: ${err.message}`);
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ─── Utility ──────────────────────────────────────────────────
|
|
252
|
+
function sleep(ms) {
|
|
253
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
254
|
+
}
|
package/logger.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// logger.js — Consistent logging across all KickLoad Watcher modules
|
|
2
|
+
// Color-coded, timestamped, and level-filtered.
|
|
3
|
+
|
|
4
|
+
import { config } from "./config.js";
|
|
5
|
+
|
|
6
|
+
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
7
|
+
const currentLevel = LEVELS[config.logLevel] ?? LEVELS.info;
|
|
8
|
+
|
|
9
|
+
// ANSI color codes
|
|
10
|
+
const colors = {
|
|
11
|
+
reset: "\x1b[0m",
|
|
12
|
+
dim: "\x1b[2m",
|
|
13
|
+
red: "\x1b[31m",
|
|
14
|
+
yellow: "\x1b[33m",
|
|
15
|
+
green: "\x1b[32m",
|
|
16
|
+
blue: "\x1b[34m",
|
|
17
|
+
cyan: "\x1b[36m",
|
|
18
|
+
white: "\x1b[37m",
|
|
19
|
+
orange: "\x1b[38;5;214m",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const levelConfig = {
|
|
23
|
+
debug: { color: colors.dim, icon: "⬜", label: "DEBUG" },
|
|
24
|
+
info: { color: colors.cyan, icon: "🔵", label: "INFO " },
|
|
25
|
+
warn: { color: colors.yellow, icon: "🟡", label: "WARN " },
|
|
26
|
+
error: { color: colors.red, icon: "🔴", label: "ERROR" },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function timestamp() {
|
|
30
|
+
return new Date().toLocaleTimeString("en-IN", {
|
|
31
|
+
timeZone: "Asia/Kolkata",
|
|
32
|
+
hour12: false,
|
|
33
|
+
hour: "2-digit",
|
|
34
|
+
minute: "2-digit",
|
|
35
|
+
second: "2-digit",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function log(level, module, message, data) {
|
|
40
|
+
if (LEVELS[level] < currentLevel) return;
|
|
41
|
+
|
|
42
|
+
const lc = levelConfig[level];
|
|
43
|
+
const ts = `${colors.dim}${timestamp()}${colors.reset}`;
|
|
44
|
+
const mod = module
|
|
45
|
+
? `${colors.orange}[${module}]${colors.reset}`
|
|
46
|
+
: "";
|
|
47
|
+
const lvl = `${lc.color}${lc.label}${colors.reset}`;
|
|
48
|
+
const msg = `${lc.color}${message}${colors.reset}`;
|
|
49
|
+
|
|
50
|
+
const line = `${ts} ${lc.icon} ${lvl} ${mod} ${msg}`;
|
|
51
|
+
console.log(line);
|
|
52
|
+
|
|
53
|
+
if (data !== undefined) {
|
|
54
|
+
console.log(
|
|
55
|
+
`${colors.dim}${JSON.stringify(data, null, 2)}${colors.reset}`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Create a module-specific logger.
|
|
62
|
+
* Usage: const logger = createLogger("kickload-runner");
|
|
63
|
+
* logger.info("Test started", { endpoint });
|
|
64
|
+
*/
|
|
65
|
+
export function createLogger(module) {
|
|
66
|
+
return {
|
|
67
|
+
debug: (msg, data) => log("debug", module, msg, data),
|
|
68
|
+
info: (msg, data) => log("info", module, msg, data),
|
|
69
|
+
warn: (msg, data) => log("warn", module, msg, data),
|
|
70
|
+
error: (msg, data) => log("error", module, msg, data),
|
|
71
|
+
|
|
72
|
+
// Structured event logging
|
|
73
|
+
event: (eventName, data) => {
|
|
74
|
+
log("info", module, `EVENT: ${eventName}`, data);
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
// Pipeline step logging
|
|
78
|
+
step: (stepName, detail) => {
|
|
79
|
+
log("info", module, ` ▶ ${stepName}${detail ? `: ${detail}` : ""}`);
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
// Success/failure
|
|
83
|
+
success: (msg, data) => log("info", module, `✅ ${msg}`, data),
|
|
84
|
+
failure: (msg, data) => log("error", module, `❌ ${msg}`, data),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Global logger (no module prefix)
|
|
89
|
+
export const logger = createLogger(null);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Log a full pipeline run summary.
|
|
93
|
+
*/
|
|
94
|
+
export function logPipelineSummary({
|
|
95
|
+
endpoint,
|
|
96
|
+
passed,
|
|
97
|
+
latency,
|
|
98
|
+
errorRate,
|
|
99
|
+
throughput,
|
|
100
|
+
durationSec,
|
|
101
|
+
emailSent,
|
|
102
|
+
devEmail,
|
|
103
|
+
}) {
|
|
104
|
+
const status = passed ? `${colors.green}✅ PASS${colors.reset}` : `${colors.red}❌ FAIL${colors.reset}`;
|
|
105
|
+
const separator = `${colors.dim}${"─".repeat(55)}${colors.reset}`;
|
|
106
|
+
|
|
107
|
+
console.log(`\n${separator}`);
|
|
108
|
+
console.log(` ${colors.orange}KICKLOAD WATCHER — PIPELINE COMPLETE${colors.reset}`);
|
|
109
|
+
console.log(separator);
|
|
110
|
+
console.log(` Endpoint: ${colors.cyan}${endpoint}${colors.reset}`);
|
|
111
|
+
console.log(` Result: ${status}`);
|
|
112
|
+
if (latency != null) console.log(` Latency: ${latency}ms`);
|
|
113
|
+
if (errorRate != null) console.log(` Error Rate: ${errorRate}%`);
|
|
114
|
+
if (throughput != null) console.log(` Throughput: ${throughput.toLocaleString()} req/s`);
|
|
115
|
+
if (durationSec != null) console.log(` Duration: ${durationSec}s`);
|
|
116
|
+
console.log(` Email: ${emailSent ? `${colors.green}Sent${colors.reset} → ${devEmail}` : `${colors.dim}Skipped${colors.reset}`}`);
|
|
117
|
+
console.log(`${separator}\n`);
|
|
118
|
+
}
|
package/ngrok-manager.js
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import fsp from "fs/promises";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import os from "os";
|
|
6
|
+
import https from "https";
|
|
7
|
+
import { createWriteStream } from "fs";
|
|
8
|
+
import fetch from "node-fetch";
|
|
9
|
+
import { createLogger } from "./logger.js";
|
|
10
|
+
import { config } from "./config.js";
|
|
11
|
+
|
|
12
|
+
const logger = createLogger("ngrok");
|
|
13
|
+
|
|
14
|
+
const NGROK_API_URL = "http://localhost:4040/api/tunnels";
|
|
15
|
+
const STARTUP_MS = 20_000; // slightly more generous
|
|
16
|
+
const FETCH_TIMEOUT_MS = 4_000;
|
|
17
|
+
const POLL_INTERVAL_MS = 500;
|
|
18
|
+
|
|
19
|
+
// ngrok 3 stable — equinox CDN pattern
|
|
20
|
+
const NGROK_CDN = "https://bin.equinox.io/c/bNyj1mQVY4c";
|
|
21
|
+
|
|
22
|
+
let activeProcess = null;
|
|
23
|
+
let activeUrl = null;
|
|
24
|
+
let startPromise = null;
|
|
25
|
+
|
|
26
|
+
// ── Public ────────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get or create a public ngrok URL for a local URL.
|
|
30
|
+
* PROBLEM 1: Called automatically for any localhost backend.
|
|
31
|
+
* PROBLEM 5: Caller (pipeline.js) retries this up to NGROK_MAX_RETRIES times.
|
|
32
|
+
*/
|
|
33
|
+
export async function getPublicUrl(localUrl) {
|
|
34
|
+
await ensureNgrokInstalled();
|
|
35
|
+
|
|
36
|
+
const port = extractPort(localUrl);
|
|
37
|
+
if (!port) throw new Error(`Cannot extract port from URL: ${localUrl}`);
|
|
38
|
+
|
|
39
|
+
// Check for an already-running tunnel
|
|
40
|
+
const existing = await fetchExistingTunnel(port);
|
|
41
|
+
if (existing) {
|
|
42
|
+
logger.info(`Reusing existing tunnel: ${existing}`);
|
|
43
|
+
activeUrl = existing;
|
|
44
|
+
return existing;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// De-duplicate concurrent start requests
|
|
48
|
+
if (startPromise) return startPromise;
|
|
49
|
+
|
|
50
|
+
startPromise = startTunnel(port).finally(() => { startPromise = null; });
|
|
51
|
+
return startPromise;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function stopNgrok() {
|
|
55
|
+
if (!activeProcess) return;
|
|
56
|
+
try {
|
|
57
|
+
activeProcess.kill("SIGTERM");
|
|
58
|
+
} catch { /* ignore */ }
|
|
59
|
+
activeProcess = null;
|
|
60
|
+
activeUrl = null;
|
|
61
|
+
logger.info("ngrok stopped.");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function getActiveUrl() { return activeUrl; }
|
|
65
|
+
|
|
66
|
+
// ── Binary management ─────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
export async function ensureNgrokInstalled() {
|
|
69
|
+
const bin = getBinPath();
|
|
70
|
+
if (binExists(bin)) return;
|
|
71
|
+
|
|
72
|
+
const dir = path.dirname(bin);
|
|
73
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
74
|
+
|
|
75
|
+
const { url, isZip } = getDownloadUrl();
|
|
76
|
+
const ext = isZip ? "zip" : "tgz";
|
|
77
|
+
const archive = path.join(dir, `ngrok-download.${ext}`);
|
|
78
|
+
|
|
79
|
+
logger.info(`Downloading ngrok binary...`);
|
|
80
|
+
logger.info(` Platform : ${process.platform}/${os.arch()}`);
|
|
81
|
+
logger.info(` Source : ${url}`);
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
await downloadFile(url, archive);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
await fsp.rm(archive, { force: true });
|
|
87
|
+
throw new Error(`ngrok download failed: ${err.message}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
await extractArchive(archive, dir, isZip, bin);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
await fsp.rm(archive, { force: true });
|
|
94
|
+
throw new Error(`ngrok extraction failed: ${err.message}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await fsp.rm(archive, { force: true });
|
|
98
|
+
|
|
99
|
+
if (process.platform !== "win32") {
|
|
100
|
+
await fsp.chmod(bin, 0o755);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!binExists(bin)) {
|
|
104
|
+
throw new Error(`ngrok binary not found at ${bin} after extraction. Please report this.`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
logger.info(`ngrok installed: ${bin}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function getBinPath() {
|
|
111
|
+
const ext = process.platform === "win32" ? ".exe" : "";
|
|
112
|
+
return path.resolve(config.ngrok.binDir || ".bin", `ngrok${ext}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function binExists(binPath) {
|
|
116
|
+
if (!fs.existsSync(binPath)) return false;
|
|
117
|
+
if (process.platform === "win32") return true;
|
|
118
|
+
try { fs.accessSync(binPath, fs.constants.X_OK); return true; } catch { return false; }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function getDownloadUrl() {
|
|
122
|
+
const plat = process.platform;
|
|
123
|
+
const arch = os.arch();
|
|
124
|
+
|
|
125
|
+
const map = {
|
|
126
|
+
linux: { x64: "linux-amd64", arm64: "linux-arm64", arm: "linux-arm" },
|
|
127
|
+
darwin: { x64: "darwin-amd64", arm64: "darwin-arm64" },
|
|
128
|
+
win32: { x64: "windows-amd64" },
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const target = map[plat]?.[arch];
|
|
132
|
+
if (!target) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`No pre-built ngrok binary for ${plat}/${arch}. ` +
|
|
135
|
+
`Download manually from https://ngrok.com/download and place at .bin/ngrok`
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const isZip = plat === "win32";
|
|
140
|
+
const ext = isZip ? "zip" : "tgz";
|
|
141
|
+
return { url: `${NGROK_CDN}/ngrok-v3-stable-${target}.${ext}`, isZip };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function downloadFile(url, dest) {
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
const file = createWriteStream(dest);
|
|
147
|
+
|
|
148
|
+
const request = (targetUrl) => {
|
|
149
|
+
https.get(targetUrl, (res) => {
|
|
150
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
151
|
+
return request(res.headers.location);
|
|
152
|
+
}
|
|
153
|
+
if (res.statusCode !== 200) {
|
|
154
|
+
return reject(new Error(`HTTP ${res.statusCode} from ${targetUrl}`));
|
|
155
|
+
}
|
|
156
|
+
res.pipe(file);
|
|
157
|
+
file.on("finish", () => file.close(resolve));
|
|
158
|
+
file.on("error", (e) => { fsp.rm(dest, { force: true }).catch(() => {}); reject(e); });
|
|
159
|
+
}).on("error", reject);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
request(url);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function extractArchive(archivePath, destDir, isZip, expectedBin) {
|
|
167
|
+
if (isZip) {
|
|
168
|
+
const unzipper = await import("unzipper");
|
|
169
|
+
await new Promise((resolve, reject) => {
|
|
170
|
+
fs.createReadStream(archivePath)
|
|
171
|
+
.pipe(unzipper.Extract({ path: destDir }))
|
|
172
|
+
.on("close", resolve)
|
|
173
|
+
.on("error", reject);
|
|
174
|
+
});
|
|
175
|
+
} else {
|
|
176
|
+
const tar = await import("tar");
|
|
177
|
+
await tar.x({
|
|
178
|
+
file: archivePath,
|
|
179
|
+
cwd: destDir,
|
|
180
|
+
filter: (p) => p === "ngrok" || p.endsWith("/ngrok"),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── Tunnel management ─────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
async function startTunnel(port) {
|
|
188
|
+
const bin = getBinPath();
|
|
189
|
+
const args = ["http", String(port), "--log", "stderr", "--log-format", "json"];
|
|
190
|
+
|
|
191
|
+
if (config.ngrok.authToken) {
|
|
192
|
+
args.push("--authtoken", config.ngrok.authToken);
|
|
193
|
+
} else {
|
|
194
|
+
// Attempt unauthenticated — ngrok allows limited free tunnels without auth on some versions
|
|
195
|
+
logger.warn("Starting ngrok without NGROK_AUTHTOKEN — may fail. Add token at https://dashboard.ngrok.com");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
logger.info(`Starting ngrok tunnel → localhost:${port}`);
|
|
199
|
+
|
|
200
|
+
return new Promise((resolve, reject) => {
|
|
201
|
+
let settled = false;
|
|
202
|
+
|
|
203
|
+
const done = (err, url) => {
|
|
204
|
+
if (settled) return;
|
|
205
|
+
settled = true;
|
|
206
|
+
clearTimeout(timer);
|
|
207
|
+
if (err) {
|
|
208
|
+
if (activeProcess) {
|
|
209
|
+
try { activeProcess.kill("SIGTERM"); } catch { /* ignore */ }
|
|
210
|
+
activeProcess = null;
|
|
211
|
+
}
|
|
212
|
+
reject(err);
|
|
213
|
+
} else {
|
|
214
|
+
activeUrl = url;
|
|
215
|
+
resolve(url);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const timer = setTimeout(() => {
|
|
220
|
+
done(new Error(
|
|
221
|
+
`ngrok tunnel did not start within ${STARTUP_MS / 1000}s. ` +
|
|
222
|
+
`Verify NGROK_AUTHTOKEN is set and network is accessible.`
|
|
223
|
+
));
|
|
224
|
+
}, STARTUP_MS);
|
|
225
|
+
|
|
226
|
+
let proc;
|
|
227
|
+
try {
|
|
228
|
+
proc = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"], detached: false });
|
|
229
|
+
} catch (err) {
|
|
230
|
+
return done(new Error(`Failed to spawn ngrok: ${err.message}`));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
activeProcess = proc;
|
|
234
|
+
|
|
235
|
+
proc.on("error", (err) => {
|
|
236
|
+
done(new Error(`ngrok spawn error: ${err.message}`));
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
proc.stderr.on("data", (data) => {
|
|
240
|
+
const msg = data.toString().trim();
|
|
241
|
+
// Parse JSON log lines for auth errors — surface them immediately
|
|
242
|
+
if (msg.startsWith("{")) {
|
|
243
|
+
try {
|
|
244
|
+
const parsed = JSON.parse(msg);
|
|
245
|
+
const errMsg = parsed.err || parsed.msg || "";
|
|
246
|
+
if (errMsg.toLowerCase().includes("authtoken") || errMsg.toLowerCase().includes("auth")) {
|
|
247
|
+
done(new Error(
|
|
248
|
+
`ngrok authentication failed: ${errMsg}\n` +
|
|
249
|
+
` Fix: add NGROK_AUTHTOKEN to .env — get it at https://dashboard.ngrok.com`
|
|
250
|
+
));
|
|
251
|
+
}
|
|
252
|
+
} catch { /* not JSON, ignore */ }
|
|
253
|
+
} else if (msg) {
|
|
254
|
+
logger.debug(`ngrok: ${msg}`);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
proc.on("exit", (code, signal) => {
|
|
259
|
+
activeProcess = null;
|
|
260
|
+
if (!settled) {
|
|
261
|
+
done(new Error(
|
|
262
|
+
`ngrok exited unexpectedly (code ${code}, signal ${signal}). ` +
|
|
263
|
+
`Check NGROK_AUTHTOKEN is valid at https://dashboard.ngrok.com`
|
|
264
|
+
));
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const poll = async (elapsed) => {
|
|
269
|
+
if (settled || elapsed >= STARTUP_MS) return;
|
|
270
|
+
const url = await fetchExistingTunnel(port);
|
|
271
|
+
if (url) { logger.info(`Tunnel ready: ${url}`); done(null, url); return; }
|
|
272
|
+
setTimeout(() => poll(elapsed + POLL_INTERVAL_MS), POLL_INTERVAL_MS);
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
setTimeout(() => poll(0), 800);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function fetchExistingTunnel(port) {
|
|
280
|
+
try {
|
|
281
|
+
const controller = new AbortController();
|
|
282
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
283
|
+
const res = await fetch(NGROK_API_URL, { signal: controller.signal });
|
|
284
|
+
clearTimeout(timer);
|
|
285
|
+
if (!res.ok) return null;
|
|
286
|
+
|
|
287
|
+
const data = await res.json();
|
|
288
|
+
const tunnels = data.tunnels || [];
|
|
289
|
+
const match = tunnels.find(t =>
|
|
290
|
+
t.proto === "https" && (
|
|
291
|
+
t.config?.addr?.includes(String(port)) ||
|
|
292
|
+
t.config?.addr === `localhost:${port}` ||
|
|
293
|
+
t.config?.addr === `http://localhost:${port}`
|
|
294
|
+
)
|
|
295
|
+
);
|
|
296
|
+
return match?.public_url || null;
|
|
297
|
+
} catch {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
function extractPort(url) {
|
|
305
|
+
try {
|
|
306
|
+
const parsed = new URL(url);
|
|
307
|
+
if (parsed.port) return parseInt(parsed.port);
|
|
308
|
+
return parsed.protocol === "https:" ? 443 : 80;
|
|
309
|
+
} catch {
|
|
310
|
+
const m = url.match(/:(\d+)/);
|
|
311
|
+
return m ? parseInt(m[1]) : null;
|
|
312
|
+
}
|
|
313
|
+
}
|