@saptools/cf-debugger 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 +333 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +1088 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +890 -0
- package/dist/index.js.map +1 -0
- package/package.json +65 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1088 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import process4 from "process";
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
|
|
7
|
+
// src/debugger.ts
|
|
8
|
+
import { mkdir as mkdir3, rm } from "fs/promises";
|
|
9
|
+
import { hostname as getHostname2 } from "os";
|
|
10
|
+
import process3 from "process";
|
|
11
|
+
|
|
12
|
+
// src/cf.ts
|
|
13
|
+
import { execFile, spawn } from "child_process";
|
|
14
|
+
import { promisify } from "util";
|
|
15
|
+
|
|
16
|
+
// src/types.ts
|
|
17
|
+
var CfDebuggerError = class extends Error {
|
|
18
|
+
code;
|
|
19
|
+
stderr;
|
|
20
|
+
constructor(code, message, stderr) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = "CfDebuggerError";
|
|
23
|
+
this.code = code;
|
|
24
|
+
if (stderr !== void 0) {
|
|
25
|
+
this.stderr = stderr;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// src/cf.ts
|
|
31
|
+
var execFileAsync = promisify(execFile);
|
|
32
|
+
var MAX_BUFFER = 16 * 1024 * 1024;
|
|
33
|
+
var CF_CLI_TIMEOUT_MS = 3e4;
|
|
34
|
+
var CF_RESTART_TIMEOUT_MS = 12e4;
|
|
35
|
+
var CF_SSH_SIGNAL_TIMEOUT_MS = 15e3;
|
|
36
|
+
var CF_AUTH_MAX_ATTEMPTS = 3;
|
|
37
|
+
function buildEnv(cfHome) {
|
|
38
|
+
return { ...process.env, CF_HOME: cfHome };
|
|
39
|
+
}
|
|
40
|
+
function resolveBin(context) {
|
|
41
|
+
return context.command ?? process.env["CF_DEBUGGER_CF_BIN"] ?? "cf";
|
|
42
|
+
}
|
|
43
|
+
async function runCf(args, context, timeoutMs = CF_CLI_TIMEOUT_MS) {
|
|
44
|
+
try {
|
|
45
|
+
const { stdout } = await execFileAsync(resolveBin(context), [...args], {
|
|
46
|
+
env: buildEnv(context.cfHome),
|
|
47
|
+
maxBuffer: MAX_BUFFER,
|
|
48
|
+
timeout: timeoutMs
|
|
49
|
+
});
|
|
50
|
+
return stdout;
|
|
51
|
+
} catch (err) {
|
|
52
|
+
const e = err;
|
|
53
|
+
const stderr = e.stderr?.trim() ?? "";
|
|
54
|
+
throw new CfDebuggerError(
|
|
55
|
+
"CF_CLI_FAILED",
|
|
56
|
+
`cf ${args.join(" ")} failed: ${stderr.length > 0 ? stderr : e.message}`,
|
|
57
|
+
stderr
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function cfApi(apiEndpoint, context) {
|
|
62
|
+
await runCf(["api", apiEndpoint], context);
|
|
63
|
+
}
|
|
64
|
+
async function cfAuth(email, password, context) {
|
|
65
|
+
let lastError;
|
|
66
|
+
for (let attempt = 0; attempt < CF_AUTH_MAX_ATTEMPTS; attempt++) {
|
|
67
|
+
try {
|
|
68
|
+
await runCf(["auth", email, password], context);
|
|
69
|
+
return;
|
|
70
|
+
} catch (err) {
|
|
71
|
+
lastError = err;
|
|
72
|
+
if (attempt < CF_AUTH_MAX_ATTEMPTS - 1) {
|
|
73
|
+
await new Promise((resolve) => {
|
|
74
|
+
setTimeout(resolve, 1e3 * (attempt + 1));
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (lastError instanceof Error) {
|
|
80
|
+
throw lastError;
|
|
81
|
+
}
|
|
82
|
+
throw new CfDebuggerError("CF_AUTH_FAILED", `cf auth failed: ${String(lastError)}`);
|
|
83
|
+
}
|
|
84
|
+
async function cfLogin(apiEndpoint, email, password, context) {
|
|
85
|
+
try {
|
|
86
|
+
await cfApi(apiEndpoint, context);
|
|
87
|
+
await cfAuth(email, password, context);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
if (err instanceof CfDebuggerError) {
|
|
90
|
+
throw new CfDebuggerError("CF_LOGIN_FAILED", err.message, err.stderr);
|
|
91
|
+
}
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async function cfTarget(org, space, context) {
|
|
96
|
+
try {
|
|
97
|
+
await runCf(["target", "-o", org, "-s", space], context);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
if (err instanceof CfDebuggerError) {
|
|
100
|
+
throw new CfDebuggerError("CF_TARGET_FAILED", err.message, err.stderr);
|
|
101
|
+
}
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async function cfSshEnabled(appName, context) {
|
|
106
|
+
try {
|
|
107
|
+
const stdout = await runCf(["ssh-enabled", appName], context);
|
|
108
|
+
return stdout.toLowerCase().includes("ssh support is enabled");
|
|
109
|
+
} catch {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async function cfEnableSsh(appName, context) {
|
|
114
|
+
try {
|
|
115
|
+
await runCf(["enable-ssh", appName], context);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
if (err instanceof CfDebuggerError) {
|
|
118
|
+
throw new CfDebuggerError("SSH_NOT_ENABLED", err.message, err.stderr);
|
|
119
|
+
}
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
async function cfRestartApp(appName, context) {
|
|
124
|
+
await runCf(["restart", appName], context, CF_RESTART_TIMEOUT_MS);
|
|
125
|
+
}
|
|
126
|
+
async function cfSshOneShot(appName, command, context) {
|
|
127
|
+
return await new Promise((resolve) => {
|
|
128
|
+
const child = spawn(resolveBin(context), ["ssh", appName, "-c", command], {
|
|
129
|
+
env: buildEnv(context.cfHome),
|
|
130
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
131
|
+
});
|
|
132
|
+
let stderrBuf = "";
|
|
133
|
+
let settled = false;
|
|
134
|
+
const timeout = setTimeout(() => {
|
|
135
|
+
if (settled) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
settled = true;
|
|
139
|
+
try {
|
|
140
|
+
child.kill();
|
|
141
|
+
} catch {
|
|
142
|
+
}
|
|
143
|
+
resolve({ exitCode: null, stderr: stderrBuf });
|
|
144
|
+
}, CF_SSH_SIGNAL_TIMEOUT_MS);
|
|
145
|
+
child.stderr.on("data", (data) => {
|
|
146
|
+
stderrBuf += data.toString();
|
|
147
|
+
});
|
|
148
|
+
child.on("close", (code) => {
|
|
149
|
+
if (settled) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
settled = true;
|
|
153
|
+
clearTimeout(timeout);
|
|
154
|
+
resolve({ exitCode: code, stderr: stderrBuf });
|
|
155
|
+
});
|
|
156
|
+
child.on("error", (err) => {
|
|
157
|
+
if (settled) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
settled = true;
|
|
161
|
+
clearTimeout(timeout);
|
|
162
|
+
resolve({ exitCode: null, stderr: err.message });
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
function isSshDisabledError(stderr) {
|
|
167
|
+
const lower = stderr.toLowerCase();
|
|
168
|
+
return lower.includes("not authorized") || lower.includes("ssh support is disabled");
|
|
169
|
+
}
|
|
170
|
+
function spawnSshTunnel(appName, localPort, remotePort, context) {
|
|
171
|
+
const tunnelArg = `${localPort.toString()}:localhost:${remotePort.toString()}`;
|
|
172
|
+
const isWindows = process.platform === "win32";
|
|
173
|
+
return spawn(resolveBin(context), ["ssh", appName, "-N", "-L", tunnelArg], {
|
|
174
|
+
env: buildEnv(context.cfHome),
|
|
175
|
+
shell: isWindows,
|
|
176
|
+
detached: !isWindows
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// src/paths.ts
|
|
181
|
+
import { homedir } from "os";
|
|
182
|
+
import { join } from "path";
|
|
183
|
+
var SAPTOOLS_DIR_NAME = ".saptools";
|
|
184
|
+
var CF_DEBUGGER_STATE_FILENAME = "cf-debugger-state.json";
|
|
185
|
+
var CF_DEBUGGER_LOCK_FILENAME = "cf-debugger-state.lock";
|
|
186
|
+
var CF_DEBUGGER_HOMES_DIRNAME = "cf-debugger-homes";
|
|
187
|
+
function saptoolsDir() {
|
|
188
|
+
return join(homedir(), SAPTOOLS_DIR_NAME);
|
|
189
|
+
}
|
|
190
|
+
function stateFilePath() {
|
|
191
|
+
return join(saptoolsDir(), CF_DEBUGGER_STATE_FILENAME);
|
|
192
|
+
}
|
|
193
|
+
function stateLockPath() {
|
|
194
|
+
return join(saptoolsDir(), CF_DEBUGGER_LOCK_FILENAME);
|
|
195
|
+
}
|
|
196
|
+
function sessionCfHomeDir(sessionId) {
|
|
197
|
+
return join(saptoolsDir(), CF_DEBUGGER_HOMES_DIRNAME, sessionId);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// src/port.ts
|
|
201
|
+
import { execFile as execFile2 } from "child_process";
|
|
202
|
+
import { createConnection, createServer } from "net";
|
|
203
|
+
import { promisify as promisify2 } from "util";
|
|
204
|
+
var execFileAsync2 = promisify2(execFile2);
|
|
205
|
+
async function isPortFree(port) {
|
|
206
|
+
return await new Promise((resolve) => {
|
|
207
|
+
const server = createServer();
|
|
208
|
+
server.once("error", () => {
|
|
209
|
+
resolve(false);
|
|
210
|
+
});
|
|
211
|
+
server.once("listening", () => {
|
|
212
|
+
server.close(() => {
|
|
213
|
+
resolve(true);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
server.listen(port, "127.0.0.1");
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
async function probeTunnelReady(port, timeoutMs) {
|
|
220
|
+
const pollIntervalMs = 250;
|
|
221
|
+
const started = Date.now();
|
|
222
|
+
while (Date.now() - started < timeoutMs) {
|
|
223
|
+
const connected = await new Promise((resolve) => {
|
|
224
|
+
const socket = createConnection({ port, host: "127.0.0.1" });
|
|
225
|
+
socket.setTimeout(200);
|
|
226
|
+
socket.once("connect", () => {
|
|
227
|
+
socket.destroy();
|
|
228
|
+
resolve(true);
|
|
229
|
+
});
|
|
230
|
+
socket.once("error", () => {
|
|
231
|
+
socket.destroy();
|
|
232
|
+
resolve(false);
|
|
233
|
+
});
|
|
234
|
+
socket.once("timeout", () => {
|
|
235
|
+
socket.destroy();
|
|
236
|
+
resolve(false);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
if (connected) {
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
await new Promise((resolve) => {
|
|
243
|
+
setTimeout(resolve, pollIntervalMs);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
async function killProcessOnPort(port) {
|
|
249
|
+
const portStr = port.toString();
|
|
250
|
+
if (process.platform === "win32") {
|
|
251
|
+
try {
|
|
252
|
+
const { stdout } = await execFileAsync2("netstat", ["-ano"]);
|
|
253
|
+
const pids = /* @__PURE__ */ new Set();
|
|
254
|
+
for (const line of stdout.split("\n")) {
|
|
255
|
+
if (line.includes(`:${portStr}`) && line.includes("LISTENING")) {
|
|
256
|
+
const parts = line.trim().split(/\s+/);
|
|
257
|
+
const last = parts[parts.length - 1];
|
|
258
|
+
if (last !== void 0) {
|
|
259
|
+
const pid = Number.parseInt(last, 10);
|
|
260
|
+
if (!Number.isNaN(pid)) {
|
|
261
|
+
pids.add(pid);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
for (const pid of pids) {
|
|
267
|
+
try {
|
|
268
|
+
await execFileAsync2("taskkill", ["/F", "/PID", pid.toString()]);
|
|
269
|
+
} catch {
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
} catch {
|
|
273
|
+
}
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
const { stdout } = await execFileAsync2("lsof", ["-t", "-i", `tcp:${portStr}`]);
|
|
278
|
+
const lines = stdout.trim().split("\n").filter((l) => l.length > 0);
|
|
279
|
+
for (const pidStr of lines) {
|
|
280
|
+
const pid = Number.parseInt(pidStr, 10);
|
|
281
|
+
if (!Number.isNaN(pid)) {
|
|
282
|
+
try {
|
|
283
|
+
process.kill(pid, "SIGKILL");
|
|
284
|
+
} catch {
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
} catch {
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// src/regions.ts
|
|
293
|
+
var REGION_API_ENDPOINTS = {
|
|
294
|
+
ae01: "https://api.cf.ae01.hana.ondemand.com",
|
|
295
|
+
ap01: "https://api.cf.ap01.hana.ondemand.com",
|
|
296
|
+
ap10: "https://api.cf.ap10.hana.ondemand.com",
|
|
297
|
+
ap11: "https://api.cf.ap11.hana.ondemand.com",
|
|
298
|
+
ap12: "https://api.cf.ap12.hana.ondemand.com",
|
|
299
|
+
ap20: "https://api.cf.ap20.hana.ondemand.com",
|
|
300
|
+
ap21: "https://api.cf.ap21.hana.ondemand.com",
|
|
301
|
+
ap30: "https://api.cf.ap30.hana.ondemand.com",
|
|
302
|
+
br10: "https://api.cf.br10.hana.ondemand.com",
|
|
303
|
+
br20: "https://api.cf.br20.hana.ondemand.com",
|
|
304
|
+
br30: "https://api.cf.br30.hana.ondemand.com",
|
|
305
|
+
ca10: "https://api.cf.ca10.hana.ondemand.com",
|
|
306
|
+
ca20: "https://api.cf.ca20.hana.ondemand.com",
|
|
307
|
+
ch20: "https://api.cf.ch20.hana.ondemand.com",
|
|
308
|
+
eu10: "https://api.cf.eu10.hana.ondemand.com",
|
|
309
|
+
eu11: "https://api.cf.eu11.hana.ondemand.com",
|
|
310
|
+
eu12: "https://api.cf.eu12.hana.ondemand.com",
|
|
311
|
+
eu20: "https://api.cf.eu20.hana.ondemand.com",
|
|
312
|
+
eu21: "https://api.cf.eu21.hana.ondemand.com",
|
|
313
|
+
eu30: "https://api.cf.eu30.hana.ondemand.com",
|
|
314
|
+
eu31: "https://api.cf.eu31.hana.ondemand.com",
|
|
315
|
+
in30: "https://api.cf.in30.hana.ondemand.com",
|
|
316
|
+
jp10: "https://api.cf.jp10.hana.ondemand.com",
|
|
317
|
+
jp20: "https://api.cf.jp20.hana.ondemand.com",
|
|
318
|
+
jp30: "https://api.cf.jp30.hana.ondemand.com",
|
|
319
|
+
kr30: "https://api.cf.kr30.hana.ondemand.com",
|
|
320
|
+
us10: "https://api.cf.us10.hana.ondemand.com",
|
|
321
|
+
us11: "https://api.cf.us11.hana.ondemand.com",
|
|
322
|
+
us20: "https://api.cf.us20.hana.ondemand.com",
|
|
323
|
+
us21: "https://api.cf.us21.hana.ondemand.com",
|
|
324
|
+
us30: "https://api.cf.us30.hana.ondemand.com",
|
|
325
|
+
us31: "https://api.cf.us31.hana.ondemand.com"
|
|
326
|
+
};
|
|
327
|
+
function resolveApiEndpoint(regionKey, override) {
|
|
328
|
+
if (override !== void 0 && override !== "") {
|
|
329
|
+
return override;
|
|
330
|
+
}
|
|
331
|
+
const endpoint = REGION_API_ENDPOINTS[regionKey];
|
|
332
|
+
if (endpoint === void 0) {
|
|
333
|
+
throw new Error(
|
|
334
|
+
`Unknown region key: ${regionKey}. Pass \`apiEndpoint\` explicitly to override.`
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
return endpoint;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// src/state.ts
|
|
341
|
+
import { randomUUID } from "crypto";
|
|
342
|
+
import { mkdir as mkdir2, readFile, rename, writeFile } from "fs/promises";
|
|
343
|
+
import { hostname as getHostname } from "os";
|
|
344
|
+
import { dirname as dirname2 } from "path";
|
|
345
|
+
import process2 from "process";
|
|
346
|
+
|
|
347
|
+
// src/lock.ts
|
|
348
|
+
import { mkdir, open, unlink } from "fs/promises";
|
|
349
|
+
import { dirname } from "path";
|
|
350
|
+
var DEFAULT_POLL_MS = 50;
|
|
351
|
+
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
352
|
+
function sleep(ms) {
|
|
353
|
+
return new Promise((resolve) => {
|
|
354
|
+
setTimeout(resolve, ms);
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
async function acquireFileLock(lockPath, timeoutMs, pollMs) {
|
|
358
|
+
const deadline = Date.now() + timeoutMs;
|
|
359
|
+
await mkdir(dirname(lockPath), { recursive: true });
|
|
360
|
+
for (; ; ) {
|
|
361
|
+
try {
|
|
362
|
+
return await open(lockPath, "wx");
|
|
363
|
+
} catch (err) {
|
|
364
|
+
const code = err.code;
|
|
365
|
+
if (code !== "EEXIST") {
|
|
366
|
+
throw err;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (Date.now() > deadline) {
|
|
370
|
+
throw new Error(`Timed out acquiring file lock at ${lockPath}`);
|
|
371
|
+
}
|
|
372
|
+
await sleep(pollMs);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
async function releaseFileLock(lockPath, handle) {
|
|
376
|
+
await handle.close();
|
|
377
|
+
await unlink(lockPath).catch((err) => {
|
|
378
|
+
const code = err.code;
|
|
379
|
+
if (code !== "ENOENT") {
|
|
380
|
+
throw err;
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
async function withFileLock(lockPath, work, options) {
|
|
385
|
+
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
386
|
+
const pollMs = options?.pollMs ?? DEFAULT_POLL_MS;
|
|
387
|
+
const handle = await acquireFileLock(lockPath, timeoutMs, pollMs);
|
|
388
|
+
try {
|
|
389
|
+
return await work();
|
|
390
|
+
} finally {
|
|
391
|
+
await releaseFileLock(lockPath, handle);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// src/state.ts
|
|
396
|
+
async function readJsonFile(path) {
|
|
397
|
+
let raw;
|
|
398
|
+
try {
|
|
399
|
+
raw = await readFile(path, "utf8");
|
|
400
|
+
} catch (err) {
|
|
401
|
+
const code = err.code;
|
|
402
|
+
if (code === "ENOENT") {
|
|
403
|
+
return void 0;
|
|
404
|
+
}
|
|
405
|
+
throw err;
|
|
406
|
+
}
|
|
407
|
+
try {
|
|
408
|
+
return JSON.parse(raw);
|
|
409
|
+
} catch {
|
|
410
|
+
process2.stderr.write(
|
|
411
|
+
`[cf-debugger] warning: state file at ${path} is not valid JSON; resetting to empty.
|
|
412
|
+
`
|
|
413
|
+
);
|
|
414
|
+
return void 0;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
async function writeJsonFileAtomic(path, value) {
|
|
418
|
+
const tempPath = `${path}.${randomUUID()}.tmp`;
|
|
419
|
+
await mkdir2(dirname2(path), { recursive: true });
|
|
420
|
+
await writeFile(tempPath, `${JSON.stringify(value, null, 2)}
|
|
421
|
+
`, "utf8");
|
|
422
|
+
await rename(tempPath, path);
|
|
423
|
+
}
|
|
424
|
+
function emptyState() {
|
|
425
|
+
return { version: "1", sessions: [] };
|
|
426
|
+
}
|
|
427
|
+
function isValidState(value) {
|
|
428
|
+
if (typeof value !== "object" || value === null) {
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
const candidate = value;
|
|
432
|
+
return candidate.version === "1" && Array.isArray(candidate.sessions);
|
|
433
|
+
}
|
|
434
|
+
function isPidAlive(pid) {
|
|
435
|
+
try {
|
|
436
|
+
process2.kill(pid, 0);
|
|
437
|
+
return true;
|
|
438
|
+
} catch (err) {
|
|
439
|
+
const code = err.code;
|
|
440
|
+
if (code === "ESRCH") {
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
function filterStaleSessions(sessions) {
|
|
447
|
+
const host = getHostname();
|
|
448
|
+
return sessions.filter((session) => {
|
|
449
|
+
if (session.hostname !== host) {
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
return isPidAlive(session.pid);
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
async function readStateRaw() {
|
|
456
|
+
const parsed = await readJsonFile(stateFilePath());
|
|
457
|
+
if (!isValidState(parsed)) {
|
|
458
|
+
return emptyState();
|
|
459
|
+
}
|
|
460
|
+
return parsed;
|
|
461
|
+
}
|
|
462
|
+
async function writeState(state) {
|
|
463
|
+
await writeJsonFileAtomic(stateFilePath(), state);
|
|
464
|
+
}
|
|
465
|
+
async function readAndPruneLocked() {
|
|
466
|
+
const raw = await readStateRaw();
|
|
467
|
+
const pruned = filterStaleSessions(raw.sessions);
|
|
468
|
+
const removed = raw.sessions.filter(
|
|
469
|
+
(session) => !pruned.some((active) => active.sessionId === session.sessionId)
|
|
470
|
+
);
|
|
471
|
+
if (removed.length > 0) {
|
|
472
|
+
await writeState({ version: "1", sessions: pruned });
|
|
473
|
+
}
|
|
474
|
+
return { sessions: pruned, removed };
|
|
475
|
+
}
|
|
476
|
+
async function readAndPruneActiveSessions() {
|
|
477
|
+
return await withFileLock(stateLockPath(), readAndPruneLocked);
|
|
478
|
+
}
|
|
479
|
+
function sessionKeyString(key) {
|
|
480
|
+
return `${key.region}:${key.org}:${key.space}:${key.app}`;
|
|
481
|
+
}
|
|
482
|
+
function matchesKey(session, key) {
|
|
483
|
+
return session.region === key.region && session.org === key.org && session.space === key.space && session.app === key.app;
|
|
484
|
+
}
|
|
485
|
+
var DEFAULT_BASE_PORT = 2e4;
|
|
486
|
+
var DEFAULT_MAX_PORT = 20999;
|
|
487
|
+
async function pickPort(preferred, reserved, probe, basePort, maxPort) {
|
|
488
|
+
const tryOrder = [];
|
|
489
|
+
if (preferred !== void 0) {
|
|
490
|
+
tryOrder.push(preferred);
|
|
491
|
+
}
|
|
492
|
+
for (let port = basePort; port <= maxPort; port++) {
|
|
493
|
+
if (port !== preferred) {
|
|
494
|
+
tryOrder.push(port);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
for (const port of tryOrder) {
|
|
498
|
+
if (reserved.has(port)) {
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
const free = await probe(port);
|
|
502
|
+
if (free) {
|
|
503
|
+
return port;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
throw new CfDebuggerError(
|
|
507
|
+
"PORT_UNAVAILABLE",
|
|
508
|
+
`No free local port available in range ${basePort.toString()}\u2013${maxPort.toString()}`
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
async function registerNewSession(input) {
|
|
512
|
+
return await withFileLock(stateLockPath(), async () => {
|
|
513
|
+
const pruneResult = await readAndPruneLocked();
|
|
514
|
+
const existing = pruneResult.sessions.find((session2) => matchesKey(session2, input));
|
|
515
|
+
if (existing) {
|
|
516
|
+
return { session: existing, existing };
|
|
517
|
+
}
|
|
518
|
+
const reservedPorts = new Set(pruneResult.sessions.map((session2) => session2.localPort));
|
|
519
|
+
const localPort = await pickPort(
|
|
520
|
+
input.preferredPort,
|
|
521
|
+
reservedPorts,
|
|
522
|
+
input.portProbe,
|
|
523
|
+
input.basePort ?? DEFAULT_BASE_PORT,
|
|
524
|
+
input.maxPort ?? DEFAULT_MAX_PORT
|
|
525
|
+
);
|
|
526
|
+
const sessionId = (input.sessionIdFactory ?? randomUUID)();
|
|
527
|
+
const cfHomeDir = input.cfHomeForSession(sessionId);
|
|
528
|
+
const session = {
|
|
529
|
+
sessionId,
|
|
530
|
+
pid: process2.pid,
|
|
531
|
+
hostname: getHostname(),
|
|
532
|
+
region: input.region,
|
|
533
|
+
org: input.org,
|
|
534
|
+
space: input.space,
|
|
535
|
+
app: input.app,
|
|
536
|
+
apiEndpoint: input.apiEndpoint,
|
|
537
|
+
localPort,
|
|
538
|
+
remotePort: 9229,
|
|
539
|
+
cfHomeDir,
|
|
540
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
541
|
+
status: "starting"
|
|
542
|
+
};
|
|
543
|
+
const nextSessions = [...pruneResult.sessions, session];
|
|
544
|
+
await writeState({ version: "1", sessions: nextSessions });
|
|
545
|
+
return { session };
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
async function updateSessionStatus(sessionId, status, message) {
|
|
549
|
+
return await withFileLock(stateLockPath(), async () => {
|
|
550
|
+
const raw = await readStateRaw();
|
|
551
|
+
let updated;
|
|
552
|
+
const nextSessions = raw.sessions.map((session) => {
|
|
553
|
+
if (session.sessionId !== sessionId) {
|
|
554
|
+
return session;
|
|
555
|
+
}
|
|
556
|
+
const base = {
|
|
557
|
+
sessionId: session.sessionId,
|
|
558
|
+
pid: session.pid,
|
|
559
|
+
hostname: session.hostname,
|
|
560
|
+
region: session.region,
|
|
561
|
+
org: session.org,
|
|
562
|
+
space: session.space,
|
|
563
|
+
app: session.app,
|
|
564
|
+
apiEndpoint: session.apiEndpoint,
|
|
565
|
+
localPort: session.localPort,
|
|
566
|
+
remotePort: session.remotePort,
|
|
567
|
+
cfHomeDir: session.cfHomeDir,
|
|
568
|
+
startedAt: session.startedAt,
|
|
569
|
+
status
|
|
570
|
+
};
|
|
571
|
+
const next = message === void 0 ? base : { ...base, message };
|
|
572
|
+
updated = next;
|
|
573
|
+
return next;
|
|
574
|
+
});
|
|
575
|
+
if (updated) {
|
|
576
|
+
await writeState({ version: "1", sessions: nextSessions });
|
|
577
|
+
}
|
|
578
|
+
return updated;
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
async function removeSession(sessionId) {
|
|
582
|
+
return await withFileLock(stateLockPath(), async () => {
|
|
583
|
+
const raw = await readStateRaw();
|
|
584
|
+
const target = raw.sessions.find((session) => session.sessionId === sessionId);
|
|
585
|
+
if (!target) {
|
|
586
|
+
return void 0;
|
|
587
|
+
}
|
|
588
|
+
const remaining = raw.sessions.filter((session) => session.sessionId !== sessionId);
|
|
589
|
+
await writeState({ version: "1", sessions: remaining });
|
|
590
|
+
return target;
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// src/debugger.ts
|
|
595
|
+
var DEFAULT_TUNNEL_READY_TIMEOUT_MS = 3e4;
|
|
596
|
+
var POST_USR1_DELAY_MS = 300;
|
|
597
|
+
var PORT_CLEANUP_DELAY_MS = 600;
|
|
598
|
+
var CHILD_SIGTERM_GRACE_MS = 2e3;
|
|
599
|
+
var PORT_RECLAIM_DELAY_MS = 250;
|
|
600
|
+
async function killProcessGroupOrProc(child, timeoutMs = CHILD_SIGTERM_GRACE_MS) {
|
|
601
|
+
if (child.pid === void 0) {
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const isWindows = process3.platform === "win32";
|
|
608
|
+
const send = (sig) => {
|
|
609
|
+
try {
|
|
610
|
+
if (!isWindows && child.pid !== void 0) {
|
|
611
|
+
process3.kill(-child.pid, sig);
|
|
612
|
+
} else {
|
|
613
|
+
child.kill(sig);
|
|
614
|
+
}
|
|
615
|
+
} catch {
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
send("SIGTERM");
|
|
619
|
+
const closed = await new Promise((resolve) => {
|
|
620
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
621
|
+
resolve(true);
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
const t = setTimeout(() => {
|
|
625
|
+
resolve(false);
|
|
626
|
+
}, timeoutMs);
|
|
627
|
+
child.once("close", () => {
|
|
628
|
+
clearTimeout(t);
|
|
629
|
+
resolve(true);
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
if (!closed) {
|
|
633
|
+
send("SIGKILL");
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
async function pruneAndCleanupOrphans() {
|
|
637
|
+
const result = await readAndPruneActiveSessions();
|
|
638
|
+
const host = getHostname2();
|
|
639
|
+
for (const removed of result.removed) {
|
|
640
|
+
if (removed.hostname === host) {
|
|
641
|
+
void killProcessOnPort(removed.localPort);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return result.sessions;
|
|
645
|
+
}
|
|
646
|
+
function checkAbort(signal) {
|
|
647
|
+
if (signal?.aborted) {
|
|
648
|
+
throw new CfDebuggerError("ABORTED", "Operation aborted by caller");
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
function requireCredentials(options) {
|
|
652
|
+
const email = options.email ?? process3.env["SAP_EMAIL"];
|
|
653
|
+
const password = options.password ?? process3.env["SAP_PASSWORD"];
|
|
654
|
+
if (email === void 0 || email === "") {
|
|
655
|
+
throw new CfDebuggerError(
|
|
656
|
+
"MISSING_CREDENTIALS",
|
|
657
|
+
"SAP email is required. Pass `email` or set SAP_EMAIL env var."
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
if (password === void 0 || password === "") {
|
|
661
|
+
throw new CfDebuggerError(
|
|
662
|
+
"MISSING_CREDENTIALS",
|
|
663
|
+
"SAP password is required. Pass `password` or set SAP_PASSWORD env var."
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
return { email, password };
|
|
667
|
+
}
|
|
668
|
+
async function startDebugger(options) {
|
|
669
|
+
const { email, password } = requireCredentials(options);
|
|
670
|
+
const apiEndpoint = resolveApiEndpoint(options.region, options.apiEndpoint);
|
|
671
|
+
const tunnelReadyTimeoutMs = options.tunnelReadyTimeoutMs ?? DEFAULT_TUNNEL_READY_TIMEOUT_MS;
|
|
672
|
+
const emit = (status, message) => {
|
|
673
|
+
options.onStatus?.(status, message);
|
|
674
|
+
};
|
|
675
|
+
checkAbort(options.signal);
|
|
676
|
+
await pruneAndCleanupOrphans();
|
|
677
|
+
const registration = await registerNewSession({
|
|
678
|
+
region: options.region,
|
|
679
|
+
org: options.org,
|
|
680
|
+
space: options.space,
|
|
681
|
+
app: options.app,
|
|
682
|
+
apiEndpoint,
|
|
683
|
+
...options.preferredPort === void 0 ? {} : { preferredPort: options.preferredPort },
|
|
684
|
+
portProbe: isPortFree,
|
|
685
|
+
cfHomeForSession: sessionCfHomeDir
|
|
686
|
+
});
|
|
687
|
+
if (registration.existing) {
|
|
688
|
+
throw new CfDebuggerError(
|
|
689
|
+
"SESSION_ALREADY_RUNNING",
|
|
690
|
+
`A debugger session is already running for ${sessionKeyString(options)} on port ${registration.existing.localPort.toString()} (pid ${registration.existing.pid.toString()}, sessionId ${registration.existing.sessionId}). Stop it first with \`cf-debugger stop\`.`
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
const session = registration.session;
|
|
694
|
+
const context = { cfHome: session.cfHomeDir };
|
|
695
|
+
let child;
|
|
696
|
+
let tunnelClosed = false;
|
|
697
|
+
let exitResolve;
|
|
698
|
+
const exitPromise = new Promise((resolve) => {
|
|
699
|
+
exitResolve = resolve;
|
|
700
|
+
});
|
|
701
|
+
const cleanupFilesystem = async () => {
|
|
702
|
+
try {
|
|
703
|
+
await rm(session.cfHomeDir, { recursive: true, force: true });
|
|
704
|
+
} catch {
|
|
705
|
+
}
|
|
706
|
+
};
|
|
707
|
+
const finalize = async () => {
|
|
708
|
+
if (!tunnelClosed) {
|
|
709
|
+
tunnelClosed = true;
|
|
710
|
+
if (child) {
|
|
711
|
+
await killProcessGroupOrProc(child);
|
|
712
|
+
}
|
|
713
|
+
setTimeout(() => {
|
|
714
|
+
void killProcessOnPort(session.localPort);
|
|
715
|
+
}, PORT_CLEANUP_DELAY_MS);
|
|
716
|
+
}
|
|
717
|
+
await removeSession(session.sessionId);
|
|
718
|
+
await cleanupFilesystem();
|
|
719
|
+
emit("stopped");
|
|
720
|
+
};
|
|
721
|
+
try {
|
|
722
|
+
await mkdir3(session.cfHomeDir, { recursive: true });
|
|
723
|
+
emit("logging-in");
|
|
724
|
+
await updateSessionStatus(session.sessionId, "logging-in");
|
|
725
|
+
await cfLogin(apiEndpoint, email, password, context);
|
|
726
|
+
checkAbort(options.signal);
|
|
727
|
+
emit("targeting");
|
|
728
|
+
await updateSessionStatus(session.sessionId, "targeting");
|
|
729
|
+
await cfTarget(options.org, options.space, context);
|
|
730
|
+
checkAbort(options.signal);
|
|
731
|
+
await killProcessOnPort(session.localPort);
|
|
732
|
+
await new Promise((resolve) => {
|
|
733
|
+
setTimeout(resolve, 200);
|
|
734
|
+
});
|
|
735
|
+
emit("signaling");
|
|
736
|
+
await updateSessionStatus(session.sessionId, "signaling");
|
|
737
|
+
const signalResult = await cfSshOneShot(
|
|
738
|
+
options.app,
|
|
739
|
+
`kill -s USR1 $(pidof node)`,
|
|
740
|
+
context
|
|
741
|
+
);
|
|
742
|
+
if (isSshDisabledError(signalResult.stderr)) {
|
|
743
|
+
const alreadyEnabled = await cfSshEnabled(options.app, context);
|
|
744
|
+
if (!alreadyEnabled) {
|
|
745
|
+
emit("ssh-enabling", "Enabling SSH on the app");
|
|
746
|
+
await updateSessionStatus(session.sessionId, "ssh-enabling");
|
|
747
|
+
await cfEnableSsh(options.app, context);
|
|
748
|
+
}
|
|
749
|
+
emit("ssh-restarting", "Restarting app so SSH becomes active");
|
|
750
|
+
await updateSessionStatus(session.sessionId, "ssh-restarting");
|
|
751
|
+
await cfRestartApp(options.app, context);
|
|
752
|
+
checkAbort(options.signal);
|
|
753
|
+
emit("signaling");
|
|
754
|
+
await updateSessionStatus(session.sessionId, "signaling");
|
|
755
|
+
const retrySignalResult = await cfSshOneShot(
|
|
756
|
+
options.app,
|
|
757
|
+
`kill -s USR1 $(pidof node)`,
|
|
758
|
+
context
|
|
759
|
+
);
|
|
760
|
+
if (retrySignalResult.exitCode !== 0) {
|
|
761
|
+
const detail = retrySignalResult.stderr.trim().length > 0 ? retrySignalResult.stderr.trim() : `exit code ${String(retrySignalResult.exitCode)}`;
|
|
762
|
+
throw new CfDebuggerError(
|
|
763
|
+
"USR1_SIGNAL_FAILED",
|
|
764
|
+
`Failed to send SIGUSR1 to the Node.js process on ${options.app} after enabling SSH: ${detail}`,
|
|
765
|
+
retrySignalResult.stderr
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
} else if (signalResult.exitCode !== 0) {
|
|
769
|
+
const detail = signalResult.stderr.trim().length > 0 ? signalResult.stderr.trim() : `exit code ${String(signalResult.exitCode)}`;
|
|
770
|
+
throw new CfDebuggerError(
|
|
771
|
+
"USR1_SIGNAL_FAILED",
|
|
772
|
+
`Failed to send SIGUSR1 to the Node.js process on ${options.app}: ${detail}`,
|
|
773
|
+
signalResult.stderr
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
await new Promise((resolve) => {
|
|
777
|
+
setTimeout(resolve, POST_USR1_DELAY_MS);
|
|
778
|
+
});
|
|
779
|
+
checkAbort(options.signal);
|
|
780
|
+
emit("tunneling");
|
|
781
|
+
await updateSessionStatus(session.sessionId, "tunneling");
|
|
782
|
+
if (!await isPortFree(session.localPort)) {
|
|
783
|
+
await killProcessOnPort(session.localPort);
|
|
784
|
+
await new Promise((resolve) => {
|
|
785
|
+
setTimeout(resolve, PORT_RECLAIM_DELAY_MS);
|
|
786
|
+
});
|
|
787
|
+
if (!await isPortFree(session.localPort)) {
|
|
788
|
+
throw new CfDebuggerError(
|
|
789
|
+
"PORT_UNAVAILABLE",
|
|
790
|
+
`Local port ${session.localPort.toString()} is in use and could not be reclaimed for the tunnel.`
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
child = spawnSshTunnel(options.app, session.localPort, session.remotePort, context);
|
|
795
|
+
child.on("close", (code) => {
|
|
796
|
+
tunnelClosed = true;
|
|
797
|
+
exitResolve?.(code);
|
|
798
|
+
});
|
|
799
|
+
child.on("error", (err) => {
|
|
800
|
+
emit("error", err.message);
|
|
801
|
+
});
|
|
802
|
+
const ready = await probeTunnelReady(session.localPort, tunnelReadyTimeoutMs);
|
|
803
|
+
checkAbort(options.signal);
|
|
804
|
+
if (!ready) {
|
|
805
|
+
throw new CfDebuggerError(
|
|
806
|
+
"TUNNEL_NOT_READY",
|
|
807
|
+
`SSH tunnel on port ${session.localPort.toString()} did not become ready within ${Math.round(tunnelReadyTimeoutMs / 1e3).toString()}s.`
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
emit("ready");
|
|
811
|
+
const readySession = await updateSessionStatus(session.sessionId, "ready");
|
|
812
|
+
const activeSession = readySession ?? { ...session, status: "ready" };
|
|
813
|
+
let disposePromise;
|
|
814
|
+
const handle = {
|
|
815
|
+
session: activeSession,
|
|
816
|
+
dispose: async () => {
|
|
817
|
+
disposePromise ??= (async () => {
|
|
818
|
+
emit("stopping");
|
|
819
|
+
await updateSessionStatus(session.sessionId, "stopping");
|
|
820
|
+
await finalize();
|
|
821
|
+
})();
|
|
822
|
+
await disposePromise;
|
|
823
|
+
},
|
|
824
|
+
waitForExit: async () => {
|
|
825
|
+
return await exitPromise;
|
|
826
|
+
}
|
|
827
|
+
};
|
|
828
|
+
return handle;
|
|
829
|
+
} catch (err) {
|
|
830
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
831
|
+
emit("error", message);
|
|
832
|
+
await finalize();
|
|
833
|
+
throw err;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
async function stopDebugger(options) {
|
|
837
|
+
const sessions = await pruneAndCleanupOrphans();
|
|
838
|
+
let target;
|
|
839
|
+
if (options.sessionId !== void 0) {
|
|
840
|
+
target = sessions.find((s) => s.sessionId === options.sessionId);
|
|
841
|
+
} else if (options.key !== void 0) {
|
|
842
|
+
const key = options.key;
|
|
843
|
+
target = sessions.find((s) => matchesKey(s, key));
|
|
844
|
+
}
|
|
845
|
+
if (target === void 0) {
|
|
846
|
+
return void 0;
|
|
847
|
+
}
|
|
848
|
+
if (target.pid !== process3.pid) {
|
|
849
|
+
try {
|
|
850
|
+
process3.kill(target.pid, "SIGTERM");
|
|
851
|
+
} catch {
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
setTimeout(() => {
|
|
855
|
+
void killProcessOnPort(target.localPort);
|
|
856
|
+
}, PORT_CLEANUP_DELAY_MS);
|
|
857
|
+
const removed = await removeSession(target.sessionId);
|
|
858
|
+
try {
|
|
859
|
+
await rm(target.cfHomeDir, { recursive: true, force: true });
|
|
860
|
+
} catch {
|
|
861
|
+
}
|
|
862
|
+
return removed;
|
|
863
|
+
}
|
|
864
|
+
async function stopAllDebuggers() {
|
|
865
|
+
const sessions = await pruneAndCleanupOrphans();
|
|
866
|
+
let stopped = 0;
|
|
867
|
+
for (const session of sessions) {
|
|
868
|
+
const result = await stopDebugger({ sessionId: session.sessionId });
|
|
869
|
+
if (result) {
|
|
870
|
+
stopped += 1;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
return stopped;
|
|
874
|
+
}
|
|
875
|
+
async function listSessions() {
|
|
876
|
+
return await pruneAndCleanupOrphans();
|
|
877
|
+
}
|
|
878
|
+
async function getSession(key) {
|
|
879
|
+
const sessions = await pruneAndCleanupOrphans();
|
|
880
|
+
return sessions.find((s) => matchesKey(s, key));
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// src/cli.ts
|
|
884
|
+
function readRequiredOption(value, flag) {
|
|
885
|
+
if (value === void 0 || value === "") {
|
|
886
|
+
process4.stderr.write(`Missing required option ${flag}
|
|
887
|
+
`);
|
|
888
|
+
process4.exit(1);
|
|
889
|
+
}
|
|
890
|
+
return value;
|
|
891
|
+
}
|
|
892
|
+
function parseOptionalPort(raw) {
|
|
893
|
+
if (raw === void 0) {
|
|
894
|
+
return void 0;
|
|
895
|
+
}
|
|
896
|
+
const port = Number.parseInt(raw, 10);
|
|
897
|
+
if (Number.isNaN(port) || port <= 0 || port > 65535) {
|
|
898
|
+
process4.stderr.write(`Invalid port: ${raw}
|
|
899
|
+
`);
|
|
900
|
+
process4.exit(1);
|
|
901
|
+
}
|
|
902
|
+
return port;
|
|
903
|
+
}
|
|
904
|
+
function parseOptionalTimeout(raw) {
|
|
905
|
+
if (raw === void 0) {
|
|
906
|
+
return void 0;
|
|
907
|
+
}
|
|
908
|
+
const seconds = Number.parseInt(raw, 10);
|
|
909
|
+
if (Number.isNaN(seconds) || seconds <= 0) {
|
|
910
|
+
process4.stderr.write(`Invalid timeout: ${raw}
|
|
911
|
+
`);
|
|
912
|
+
process4.exit(1);
|
|
913
|
+
}
|
|
914
|
+
return seconds * 1e3;
|
|
915
|
+
}
|
|
916
|
+
function logStatus(verbose, status, message) {
|
|
917
|
+
if (verbose) {
|
|
918
|
+
const suffix = message === void 0 ? "" : `: ${message}`;
|
|
919
|
+
process4.stdout.write(`[cf-debugger] ${status}${suffix}
|
|
920
|
+
`);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
async function handleStart(opts) {
|
|
924
|
+
const region = readRequiredOption(opts.region, "--region");
|
|
925
|
+
const org = readRequiredOption(opts.org, "--org");
|
|
926
|
+
const space = readRequiredOption(opts.space, "--space");
|
|
927
|
+
const app = readRequiredOption(opts.app, "--app");
|
|
928
|
+
const verbose = opts.verbose ?? false;
|
|
929
|
+
const preferredPort = parseOptionalPort(opts.port);
|
|
930
|
+
const tunnelReadyTimeoutMs = parseOptionalTimeout(opts.timeout);
|
|
931
|
+
const abortController = new AbortController();
|
|
932
|
+
const onStartupSignal = (exitCode) => () => {
|
|
933
|
+
abortController.abort();
|
|
934
|
+
process4.stderr.write(`
|
|
935
|
+
Aborting startup for ${app}...
|
|
936
|
+
`);
|
|
937
|
+
setTimeout(() => {
|
|
938
|
+
process4.exit(exitCode);
|
|
939
|
+
}, 5e3).unref();
|
|
940
|
+
};
|
|
941
|
+
const startupSigint = onStartupSignal(130);
|
|
942
|
+
const startupSigterm = onStartupSignal(143);
|
|
943
|
+
process4.on("SIGINT", startupSigint);
|
|
944
|
+
process4.on("SIGTERM", startupSigterm);
|
|
945
|
+
let handle;
|
|
946
|
+
try {
|
|
947
|
+
handle = await startDebugger({
|
|
948
|
+
region,
|
|
949
|
+
org,
|
|
950
|
+
space,
|
|
951
|
+
app,
|
|
952
|
+
verbose,
|
|
953
|
+
signal: abortController.signal,
|
|
954
|
+
...preferredPort === void 0 ? {} : { preferredPort },
|
|
955
|
+
...tunnelReadyTimeoutMs === void 0 ? {} : { tunnelReadyTimeoutMs },
|
|
956
|
+
onStatus: (status, message) => {
|
|
957
|
+
logStatus(verbose, status, message);
|
|
958
|
+
}
|
|
959
|
+
});
|
|
960
|
+
} finally {
|
|
961
|
+
process4.off("SIGINT", startupSigint);
|
|
962
|
+
process4.off("SIGTERM", startupSigterm);
|
|
963
|
+
}
|
|
964
|
+
process4.stdout.write(
|
|
965
|
+
`Debugger ready for ${app} (${region}/${org}/${space}).
|
|
966
|
+
Local port: ${handle.session.localPort.toString()}
|
|
967
|
+
Remote port: ${handle.session.remotePort.toString()}
|
|
968
|
+
Session id: ${handle.session.sessionId}
|
|
969
|
+
PID: ${handle.session.pid.toString()}
|
|
970
|
+
Press Ctrl+C to stop.
|
|
971
|
+
`
|
|
972
|
+
);
|
|
973
|
+
let disposePromise;
|
|
974
|
+
const dispose = async () => {
|
|
975
|
+
disposePromise ??= (async () => {
|
|
976
|
+
process4.stdout.write(`
|
|
977
|
+
Stopping debugger for ${app}...
|
|
978
|
+
`);
|
|
979
|
+
try {
|
|
980
|
+
await handle.dispose();
|
|
981
|
+
} catch (err) {
|
|
982
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
983
|
+
process4.stderr.write(`Error during stop: ${msg}
|
|
984
|
+
`);
|
|
985
|
+
}
|
|
986
|
+
})();
|
|
987
|
+
await disposePromise;
|
|
988
|
+
};
|
|
989
|
+
process4.on("SIGINT", () => {
|
|
990
|
+
void dispose().then(() => {
|
|
991
|
+
process4.exit(130);
|
|
992
|
+
});
|
|
993
|
+
});
|
|
994
|
+
process4.on("SIGTERM", () => {
|
|
995
|
+
void dispose().then(() => {
|
|
996
|
+
process4.exit(143);
|
|
997
|
+
});
|
|
998
|
+
});
|
|
999
|
+
const code = await handle.waitForExit();
|
|
1000
|
+
await dispose();
|
|
1001
|
+
process4.exit(code ?? 0);
|
|
1002
|
+
}
|
|
1003
|
+
function resolveKeyFromOpts(opts) {
|
|
1004
|
+
if (opts.region !== void 0 && opts.org !== void 0 && opts.space !== void 0 && opts.app !== void 0) {
|
|
1005
|
+
return {
|
|
1006
|
+
region: opts.region,
|
|
1007
|
+
org: opts.org,
|
|
1008
|
+
space: opts.space,
|
|
1009
|
+
app: opts.app
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
return void 0;
|
|
1013
|
+
}
|
|
1014
|
+
async function handleStop(opts) {
|
|
1015
|
+
if (opts.all === true) {
|
|
1016
|
+
const count = await stopAllDebuggers();
|
|
1017
|
+
process4.stdout.write(`Stopped ${count.toString()} session(s).
|
|
1018
|
+
`);
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
const key = resolveKeyFromOpts(opts);
|
|
1022
|
+
const result = await stopDebugger({
|
|
1023
|
+
...opts.sessionId === void 0 ? {} : { sessionId: opts.sessionId },
|
|
1024
|
+
...key === void 0 ? {} : { key }
|
|
1025
|
+
});
|
|
1026
|
+
if (result === void 0) {
|
|
1027
|
+
process4.stderr.write("No matching session found.\n");
|
|
1028
|
+
process4.exit(1);
|
|
1029
|
+
}
|
|
1030
|
+
process4.stdout.write(
|
|
1031
|
+
`Stopped session ${result.sessionId} (${result.app}, port ${result.localPort.toString()}).
|
|
1032
|
+
`
|
|
1033
|
+
);
|
|
1034
|
+
}
|
|
1035
|
+
async function handleList() {
|
|
1036
|
+
const sessions = await listSessions();
|
|
1037
|
+
process4.stdout.write(`${JSON.stringify(sessions, null, 2)}
|
|
1038
|
+
`);
|
|
1039
|
+
}
|
|
1040
|
+
async function handleStatus(opts) {
|
|
1041
|
+
const session = await getSession({
|
|
1042
|
+
region: opts.region,
|
|
1043
|
+
org: opts.org,
|
|
1044
|
+
space: opts.space,
|
|
1045
|
+
app: opts.app
|
|
1046
|
+
});
|
|
1047
|
+
process4.stdout.write(`${JSON.stringify(session ?? null, null, 2)}
|
|
1048
|
+
`);
|
|
1049
|
+
}
|
|
1050
|
+
async function main(argv) {
|
|
1051
|
+
const program = new Command();
|
|
1052
|
+
program.name("cf-debugger").description("Open an SSH debug tunnel to a SAP BTP Cloud Foundry app's Node.js inspector");
|
|
1053
|
+
program.command("start").description("Open a debug tunnel for one app").requiredOption("--region <key>", "CF region key (e.g. eu10)").requiredOption("--org <name>", "CF org name").requiredOption("--space <name>", "CF space name").requiredOption("--app <name>", "CF app name").option("--port <number>", "Preferred local port (auto-assigned if omitted)").option("--timeout <seconds>", "Tunnel-ready timeout in seconds (default: 30)").option("--verbose", "Print status transitions", false).action(async (opts) => {
|
|
1054
|
+
await handleStart(opts);
|
|
1055
|
+
});
|
|
1056
|
+
program.command("stop").description("Stop one session (by key or id) or all sessions with --all").option("--region <key>").option("--org <name>").option("--space <name>").option("--app <name>").option("--session-id <id>").option("--all", "Stop every active session", false).action(async (opts) => {
|
|
1057
|
+
await handleStop(opts);
|
|
1058
|
+
});
|
|
1059
|
+
program.command("list").description("Print every active debugger session as JSON").action(async () => {
|
|
1060
|
+
await handleList();
|
|
1061
|
+
});
|
|
1062
|
+
program.command("status").description("Print one session by key as JSON (null if not active)").requiredOption("--region <key>").requiredOption("--org <name>").requiredOption("--space <name>").requiredOption("--app <name>").action(async (opts) => {
|
|
1063
|
+
await handleStatus(opts);
|
|
1064
|
+
});
|
|
1065
|
+
await program.parseAsync([...argv]);
|
|
1066
|
+
}
|
|
1067
|
+
try {
|
|
1068
|
+
await main(process4.argv);
|
|
1069
|
+
} catch (err) {
|
|
1070
|
+
if (err instanceof CfDebuggerError) {
|
|
1071
|
+
if (err.code === "ABORTED") {
|
|
1072
|
+
process4.stderr.write(`Aborted: ${err.message}
|
|
1073
|
+
`);
|
|
1074
|
+
process4.exit(130);
|
|
1075
|
+
}
|
|
1076
|
+
process4.stderr.write(`Error [${err.code}]: ${err.message}
|
|
1077
|
+
`);
|
|
1078
|
+
} else {
|
|
1079
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1080
|
+
process4.stderr.write(`Error: ${msg}
|
|
1081
|
+
`);
|
|
1082
|
+
}
|
|
1083
|
+
process4.exit(1);
|
|
1084
|
+
}
|
|
1085
|
+
export {
|
|
1086
|
+
main
|
|
1087
|
+
};
|
|
1088
|
+
//# sourceMappingURL=cli.js.map
|