@saptools/cf-debugger 0.1.16 → 0.2.1

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/dist/cli.js CHANGED
@@ -1,2412 +1,105 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ CLEANUP_FAILURE_EXIT_CODE,
4
+ CfDebuggerError,
5
+ DEFAULT_NODE_INSPECTOR_PORT,
6
+ cliErrorExitCode,
7
+ createStartupDeadline,
8
+ getDebuggerHandleTunnelError,
9
+ getSession,
10
+ hasTunnelTerminationFailure,
11
+ listSessions,
12
+ readCurrentCfTarget,
13
+ remainingStartupMs,
14
+ requireCurrentCfRegion,
15
+ resolveNodeTarget,
16
+ resolveStartupTimeoutMs,
17
+ runDoctor,
18
+ startDebuggerWithinDeadline,
19
+ startupTimeoutError,
20
+ stopAllDebuggers,
21
+ stopDebugger
22
+ } from "./chunk-AKQR4Y74.js";
2
23
 
3
24
  // src/cli.ts
4
- import process7 from "process";
25
+ import nodeProcess from "process";
5
26
  import { Command } from "commander";
6
27
 
7
- // src/cloud-foundry/commands.ts
8
- import { execFile as execFile2 } from "child_process";
9
- import { promisify as promisify2 } from "util";
10
-
11
- // src/regions.ts
12
- var REGION_API_ENDPOINTS = {
13
- ae01: "https://api.cf.ae01.hana.ondemand.com",
14
- ap01: "https://api.cf.ap01.hana.ondemand.com",
15
- ap10: "https://api.cf.ap10.hana.ondemand.com",
16
- ap11: "https://api.cf.ap11.hana.ondemand.com",
17
- ap12: "https://api.cf.ap12.hana.ondemand.com",
18
- ap20: "https://api.cf.ap20.hana.ondemand.com",
19
- ap21: "https://api.cf.ap21.hana.ondemand.com",
20
- ap30: "https://api.cf.ap30.hana.ondemand.com",
21
- br10: "https://api.cf.br10.hana.ondemand.com",
22
- br20: "https://api.cf.br20.hana.ondemand.com",
23
- br30: "https://api.cf.br30.hana.ondemand.com",
24
- ca10: "https://api.cf.ca10.hana.ondemand.com",
25
- ca20: "https://api.cf.ca20.hana.ondemand.com",
26
- ch20: "https://api.cf.ch20.hana.ondemand.com",
27
- cn20: "https://api.cf.cn20.platform.sapcloud.cn",
28
- cn40: "https://api.cf.cn40.platform.sapcloud.cn",
29
- eu01: "https://api.cf.eu01.hana.ondemand.com",
30
- eu02: "https://api.cf.eu02.hana.ondemand.com",
31
- eu10: "https://api.cf.eu10.hana.ondemand.com",
32
- "eu10-002": "https://api.cf.eu10-002.hana.ondemand.com",
33
- "eu10-003": "https://api.cf.eu10-003.hana.ondemand.com",
34
- "eu10-004": "https://api.cf.eu10-004.hana.ondemand.com",
35
- "eu10-005": "https://api.cf.eu10-005.hana.ondemand.com",
36
- eu11: "https://api.cf.eu11.hana.ondemand.com",
37
- eu13: "https://api.cf.eu13.hana.ondemand.com",
38
- eu20: "https://api.cf.eu20.hana.ondemand.com",
39
- "eu20-001": "https://api.cf.eu20-001.hana.ondemand.com",
40
- "eu20-002": "https://api.cf.eu20-002.hana.ondemand.com",
41
- eu22: "https://api.cf.eu22.hana.ondemand.com",
42
- eu30: "https://api.cf.eu30.hana.ondemand.com",
43
- il30: "https://api.cf.il30.hana.ondemand.com",
44
- in30: "https://api.cf.in30.hana.ondemand.com",
45
- jp01: "https://api.cf.jp01.hana.ondemand.com",
46
- jp10: "https://api.cf.jp10.hana.ondemand.com",
47
- jp20: "https://api.cf.jp20.hana.ondemand.com",
48
- jp30: "https://api.cf.jp30.hana.ondemand.com",
49
- jp31: "https://api.cf.jp31.hana.ondemand.com",
50
- sa30: "https://api.cf.sa30.hana.ondemand.com",
51
- sa31: "https://api.cf.sa31.hana.ondemand.com",
52
- uk20: "https://api.cf.uk20.hana.ondemand.com",
53
- us01: "https://api.cf.us01.hana.ondemand.com",
54
- us02: "https://api.cf.us02.hana.ondemand.com",
55
- us10: "https://api.cf.us10.hana.ondemand.com",
56
- "us10-001": "https://api.cf.us10-001.hana.ondemand.com",
57
- "us10-002": "https://api.cf.us10-002.hana.ondemand.com",
58
- us11: "https://api.cf.us11.hana.ondemand.com",
59
- us20: "https://api.cf.us20.hana.ondemand.com",
60
- us21: "https://api.cf.us21.hana.ondemand.com",
61
- us30: "https://api.cf.us30.hana.ondemand.com"
62
- };
63
- function resolveApiEndpoint(regionKey, override) {
64
- if (override !== void 0 && override !== "") {
65
- return override;
66
- }
67
- const endpoint = REGION_API_ENDPOINTS[regionKey];
68
- if (endpoint === void 0) {
69
- throw new Error(
70
- `Unknown region key: ${regionKey}. Pass \`apiEndpoint\` explicitly to override.`
71
- );
72
- }
73
- return endpoint;
74
- }
75
- function listKnownRegionKeys() {
76
- return Object.keys(REGION_API_ENDPOINTS);
77
- }
78
-
79
- // src/types.ts
80
- var CfDebuggerError = class extends Error {
81
- code;
82
- stderr;
83
- constructor(code, message, stderr) {
84
- super(message);
85
- this.name = "CfDebuggerError";
86
- this.code = code;
87
- if (stderr !== void 0) {
88
- this.stderr = stderr;
89
- }
90
- }
91
- };
92
-
93
- // src/cloud-foundry/execute.ts
94
- import { execFile } from "child_process";
95
- import { promisify } from "util";
96
- var execFileAsync = promisify(execFile);
97
- var MAX_BUFFER = 16 * 1024 * 1024;
98
- var DEFAULT_CF_COMMAND_TIMEOUT_MS = 3e5;
99
- var REDACTED_ARG = "<redacted>";
100
- var MAX_RETRIES = 3;
101
- function buildEnv(cfHome) {
102
- return { ...process.env, CF_HOME: cfHome };
103
- }
104
- function resolveBin(context) {
105
- return context.command ?? process.env["CF_DEBUGGER_CF_BIN"] ?? "cf";
106
- }
107
- function sensitiveArgs(args) {
108
- if (args[0] !== "auth") {
109
- return [];
110
- }
111
- return args.slice(1).filter((arg) => arg.length > 0);
112
- }
113
- function redactText(text, values) {
114
- return values.reduce((current, value) => current.split(value).join(REDACTED_ARG), text);
115
- }
116
- function normalizeSensitiveValues(values) {
117
- return [...new Set(values.filter((value) => value.length > 0))].sort((left, right) => right.length - left.length);
118
- }
119
- function waitForRetry(delayMs, signal) {
120
- if (signal?.aborted) {
121
- return Promise.reject(new CfDebuggerError("ABORTED", "Operation aborted by caller"));
122
- }
123
- return new Promise((resolve, reject) => {
124
- const onAbort = () => {
125
- clearTimeout(timer);
126
- reject(new CfDebuggerError("ABORTED", "Operation aborted by caller"));
127
- };
128
- const timer = setTimeout(() => {
129
- signal?.removeEventListener("abort", onAbort);
130
- resolve();
131
- }, delayMs);
132
- signal?.addEventListener("abort", onAbort, { once: true });
133
- });
134
- }
135
- function formatArgsForError(args) {
136
- if (args[0] !== "auth") {
137
- return args.join(" ");
138
- }
139
- return args.map((arg, index) => index === 0 ? arg : REDACTED_ARG).join(" ");
140
- }
141
- function optionalString(value) {
142
- return typeof value === "string" ? value : void 0;
143
- }
144
- function readFailureDetails(error) {
145
- const errorObject = typeof error === "object" && error !== null ? error : void 0;
146
- const field3 = (key) => errorObject === void 0 ? void 0 : Reflect.get(errorObject, key);
147
- return {
148
- code: optionalString(field3("code")),
149
- killed: field3("killed") === true,
150
- message: error instanceof Error ? error.message : String(error),
151
- stderr: optionalString(field3("stderr")),
152
- stdout: optionalString(field3("stdout"))
153
- };
154
- }
155
- function isTransientNetworkError(error) {
156
- if (error.killed && error.code === void 0) {
157
- return true;
158
- }
159
- const code = error.code ?? "";
160
- const networkCodes = ["ETIMEDOUT", "ECONNRESET", "ENOTFOUND", "ECONNREFUSED", "EHOSTUNREACH", "ENETUNREACH"];
161
- if (networkCodes.includes(code)) {
162
- return true;
163
- }
164
- const output = `${error.message} ${error.stderr ?? ""} ${error.stdout ?? ""}`.toLowerCase();
165
- const transientPhrases = [
166
- "error performing request",
167
- "timeout exceeded",
168
- "connection reset by peer",
169
- "service unavailable",
170
- "bad gateway",
171
- "gateway timeout",
172
- "dial tcp",
173
- "i/o timeout"
174
- ];
175
- if (transientPhrases.some((phrase) => output.includes(phrase))) {
176
- return true;
177
- }
178
- const transientRegexes = [/\b502\b/, /\b503\b/, /\b504\b/, /\btimeout\b/];
179
- return transientRegexes.some((regex) => regex.test(output));
180
- }
181
- function resolveRunOptions(args, input) {
182
- const options = typeof input === "number" ? { timeoutMs: input } : input;
183
- return {
184
- env: options.env,
185
- redactionValues: normalizeSensitiveValues([
186
- ...sensitiveArgs(args),
187
- ...options.sensitiveValues ?? []
188
- ]),
189
- timeoutMs: options.timeoutMs ?? DEFAULT_CF_COMMAND_TIMEOUT_MS
190
- };
191
- }
192
- async function executeCfAttempt(args, context, options) {
193
- const { stdout } = await execFileAsync(resolveBin(context), [...args], {
194
- env: { ...buildEnv(context.cfHome), ...options.env },
195
- maxBuffer: MAX_BUFFER,
196
- ...context.signal === void 0 ? {} : { signal: context.signal },
197
- timeout: options.timeoutMs
198
- });
199
- return stdout;
200
- }
201
- function retryDelayMs(failure, attempt) {
202
- if (!isTransientNetworkError(failure) || attempt > MAX_RETRIES) {
203
- return void 0;
204
- }
205
- return Math.min(1e3 * 2 ** (attempt - 1), 1e4);
206
- }
207
- function createCfCliError(args, failure, redactionValues) {
208
- const stderr = redactText(failure.stderr?.trim() ?? "", redactionValues);
209
- const fallbackMessage = redactText(failure.message, redactionValues);
210
- const detail = stderr.length > 0 ? stderr : fallbackMessage;
211
- return new CfDebuggerError(
212
- "CF_CLI_FAILED",
213
- `cf ${formatArgsForError(args)} failed: ${detail}`,
214
- stderr
215
- );
216
- }
217
- async function runCf(args, context, input = {}) {
218
- if (context.signal?.aborted) {
219
- throw new CfDebuggerError("ABORTED", "Operation aborted by caller");
220
- }
221
- const options = resolveRunOptions(args, input);
222
- let attempt = 0;
223
- for (; ; ) {
224
- try {
225
- return await executeCfAttempt(args, context, options);
226
- } catch (err) {
227
- attempt += 1;
228
- const failure = readFailureDetails(err);
229
- if (context.signal?.aborted || failure.code === "ABORT_ERR") {
230
- throw new CfDebuggerError("ABORTED", "Operation aborted by caller");
231
- }
232
- const delayMs = retryDelayMs(failure, attempt);
233
- if (delayMs !== void 0) {
234
- await waitForRetry(delayMs, context.signal);
235
- continue;
236
- }
237
- throw createCfCliError(args, failure, options.redactionValues);
238
- }
239
- }
240
- }
241
-
242
- // src/cloud-foundry/commands.ts
243
- var execFileAsync2 = promisify2(execFile2);
244
- var CF_AUTH_MAX_ATTEMPTS = 3;
245
- var CURRENT_TARGET_TIMEOUT_MS = 3e4;
246
- function rethrowCallerAbort(error) {
247
- if (error instanceof CfDebuggerError && error.code === "ABORTED") {
248
- throw error;
249
- }
250
- }
251
- function waitForAuthRetry(delayMs, signal) {
252
- if (signal?.aborted) {
253
- return Promise.reject(new CfDebuggerError("ABORTED", "Operation aborted by caller"));
254
- }
255
- return new Promise((resolve, reject) => {
256
- const onAbort = () => {
257
- clearTimeout(timer);
258
- reject(new CfDebuggerError("ABORTED", "Operation aborted by caller"));
259
- };
260
- const timer = setTimeout(() => {
261
- signal?.removeEventListener("abort", onAbort);
262
- resolve();
263
- }, delayMs);
264
- signal?.addEventListener("abort", onAbort, { once: true });
265
- });
266
- }
267
- async function cfApi(apiEndpoint, context) {
268
- await runCf(["api", apiEndpoint], context);
269
- }
270
- async function cfAuth(email, password, context) {
271
- let lastError;
272
- for (let attempt = 0; attempt < CF_AUTH_MAX_ATTEMPTS; attempt++) {
273
- try {
274
- await runCf(["auth"], context, {
275
- env: { CF_PASSWORD: password, CF_USERNAME: email },
276
- sensitiveValues: [email, password]
277
- });
278
- return;
279
- } catch (err) {
280
- lastError = err;
281
- if (err instanceof CfDebuggerError && err.code === "ABORTED") {
282
- throw err;
283
- }
284
- if (attempt < CF_AUTH_MAX_ATTEMPTS - 1) {
285
- await waitForAuthRetry(1e3 * (attempt + 1), context.signal);
286
- }
287
- }
288
- }
289
- if (lastError instanceof Error) {
290
- throw lastError;
291
- }
292
- throw new CfDebuggerError("CF_AUTH_FAILED", `cf auth failed: ${String(lastError)}`);
293
- }
294
- async function cfLogin(apiEndpoint, email, password, context) {
295
- try {
296
- await cfApi(apiEndpoint, context);
297
- await cfAuth(email, password, context);
298
- } catch (err) {
299
- rethrowCallerAbort(err);
300
- if (err instanceof CfDebuggerError) {
301
- throw new CfDebuggerError("CF_LOGIN_FAILED", err.message, err.stderr);
302
- }
303
- throw err;
304
- }
305
- }
306
- async function cfTarget(org, space, context) {
307
- try {
308
- await runCf(["target", "-o", org, "-s", space], context);
309
- } catch (err) {
310
- rethrowCallerAbort(err);
311
- if (err instanceof CfDebuggerError) {
312
- throw new CfDebuggerError("CF_TARGET_FAILED", err.message, err.stderr);
313
- }
314
- throw err;
315
- }
316
- }
317
- async function cfSshEnabled(appName, context) {
318
- try {
319
- const stdout = await runCf(["ssh-enabled", appName], context);
320
- return stdout.toLowerCase().includes("ssh support is enabled");
321
- } catch (err) {
322
- rethrowCallerAbort(err);
323
- return false;
324
- }
325
- }
326
- async function cfEnableSsh(appName, context) {
327
- try {
328
- await runCf(["enable-ssh", appName], context);
329
- } catch (err) {
330
- rethrowCallerAbort(err);
331
- if (err instanceof CfDebuggerError) {
332
- throw new CfDebuggerError("SSH_NOT_ENABLED", err.message, err.stderr);
333
- }
334
- throw err;
335
- }
336
- }
337
- async function cfRestartApp(appName, context) {
338
- await runCf(["restart", appName], context);
339
- }
340
- async function readCurrentCfTarget(options = {}) {
341
- try {
342
- const { stdout } = await execFileAsync2(options.command ?? process.env["CF_DEBUGGER_CF_BIN"] ?? "cf", ["target"], {
343
- env: options.env ?? process.env,
344
- maxBuffer: 16 * 1024 * 1024,
345
- timeout: options.timeoutMs ?? CURRENT_TARGET_TIMEOUT_MS
346
- });
347
- return parseCurrentCfTarget(stdout);
348
- } catch (error) {
349
- const message = error instanceof Error ? error.message : String(error);
350
- throw new CfDebuggerError("CF_TARGET_FAILED", `cf target failed: ${message}`);
351
- }
352
- }
353
- function parseCurrentCfTarget(stdout) {
354
- const fields = parseTargetFields(stdout);
355
- const apiEndpoint = fields.get("api endpoint");
356
- const org = fields.get("org");
357
- const space = fields.get("space");
358
- if (!isPresent(apiEndpoint) || !isPresent(org) || !isPresent(space)) {
359
- return void 0;
360
- }
361
- const region = regionKeyForApiEndpoint(apiEndpoint);
362
- return {
363
- apiEndpoint,
364
- ...region === void 0 ? {} : { region },
365
- org,
366
- space
367
- };
368
- }
369
- function requireCurrentCfRegion(target, instruction = "Pass --region explicitly.") {
370
- if (target.region !== void 0) {
371
- return target.region;
372
- }
373
- throw new CfDebuggerError(
374
- "CF_TARGET_FAILED",
375
- `Current CF API endpoint "${target.apiEndpoint}" does not match a known SAP region. ${instruction}`
376
- );
377
- }
378
- function parseTargetFields(stdout) {
379
- return new Map(
380
- stdout.split("\n").map((line) => {
381
- const separator = line.indexOf(":");
382
- if (separator < 0) {
383
- return void 0;
384
- }
385
- return [
386
- line.slice(0, separator).trim().toLowerCase(),
387
- line.slice(separator + 1).trim()
388
- ];
389
- }).filter((field3) => field3 !== void 0)
390
- );
391
- }
392
- function regionKeyForApiEndpoint(apiEndpoint) {
393
- const normalized = normalizeApiEndpoint(apiEndpoint);
394
- const known = listKnownRegionKeys().find((key) => normalizeApiEndpoint(resolveApiEndpoint(key)) === normalized);
395
- if (known !== void 0) {
396
- return known;
397
- }
398
- return regionKeyFromSapApiEndpoint(normalized);
399
- }
400
- function normalizeApiEndpoint(apiEndpoint) {
401
- return apiEndpoint.trim().replace(/\/+$/, "").toLowerCase();
402
- }
403
- function regionKeyFromSapApiEndpoint(apiEndpoint) {
404
- const match = /^https:\/\/api\.cf\.([a-z]{2}\d{2}(?:-\d{3})?)\.(?:hana\.ondemand\.com|platform\.sapcloud\.cn)$/.exec(apiEndpoint);
405
- return match?.[1];
406
- }
407
- function isPresent(value) {
408
- return value !== void 0 && value.length > 0;
409
- }
410
-
411
- // src/cloud-foundry/ssh.ts
412
- import { spawn } from "child_process";
413
-
414
- // src/cloud-foundry/node-process.ts
415
- var DEFAULT_CF_PROCESS = "web";
416
- var DEFAULT_CF_INSTANCE = 0;
417
- var MAX_MARKER_BYTES = 65536;
418
- var PID_LIST_PATTERN = /^\d+(?:,\d+)*$/;
419
- var PID_PATTERN = /^\d+$/;
420
- function validateProcessName(processName) {
421
- if (processName.length === 0 || processName.startsWith("-") || hasControlCharacter(processName)) {
422
- throw new CfDebuggerError(
423
- "UNSAFE_INPUT",
424
- "process must be non-empty, must not start with a hyphen, and must contain no control characters."
425
- );
426
- }
427
- }
428
- function validateInstance(instance) {
429
- if (!Number.isSafeInteger(instance) || instance < 0) {
430
- throw new CfDebuggerError("UNSAFE_INPUT", "instance must be a non-negative safe integer.");
431
- }
432
- }
433
- function validateNodePid(nodePid) {
434
- if (nodePid !== void 0 && (!Number.isSafeInteger(nodePid) || nodePid <= 0)) {
435
- throw new CfDebuggerError("UNSAFE_INPUT", "nodePid must be a positive safe integer.");
436
- }
437
- }
438
- function resolveNodeTarget(input) {
439
- const processName = (input.process ?? DEFAULT_CF_PROCESS).trim();
440
- const instance = input.instance ?? DEFAULT_CF_INSTANCE;
441
- validateProcessName(processName);
442
- validateInstance(instance);
443
- validateNodePid(input.nodePid);
444
- return input.nodePid === void 0 ? { process: processName, instance } : { process: processName, instance, nodePid: input.nodePid };
445
- }
446
- function hasControlCharacter(value) {
447
- for (let index = 0; index < value.length; index += 1) {
448
- const code = value.charCodeAt(index);
449
- if (code < 32 || code === 127) {
450
- return true;
451
- }
452
- }
453
- return false;
454
- }
455
- function buildNodeInspectorCommand(nodePid) {
456
- if (nodePid !== void 0 && (!Number.isSafeInteger(nodePid) || nodePid <= 0)) {
457
- throw new CfDebuggerError("UNSAFE_INPUT", "nodePid must be a positive safe integer.");
458
- }
459
- return NODE_INSPECTOR_SCRIPT.replace("__REQUESTED_NODE_PID__", nodePid?.toString() ?? "");
460
- }
461
- function parseNodeInspectorMarkers(stdout) {
462
- if (Buffer.byteLength(stdout, "utf8") > MAX_MARKER_BYTES) {
463
- throw new CfDebuggerError("INSPECTOR_OUTPUT_TOO_LARGE", "Inspector startup output exceeded 65536 bytes.");
464
- }
465
- const markers = parseMarkers(stdout);
466
- throwForFailureMarker(markers);
467
- const remoteNodePid = readMarkerPid(markers, "saptools-inspector-node-pid");
468
- const ownerPid = readMarkerPid(markers, "saptools-inspector-owner-pid");
469
- if (!markers.has("saptools-inspector-ready") || remoteNodePid === void 0 || ownerPid !== remoteNodePid) {
470
- throw new CfDebuggerError("INSPECTOR_NOT_READY", "Remote Node inspector did not report a verified owner.");
471
- }
472
- return { remoteNodePid };
473
- }
474
- function parseMarkers(stdout) {
475
- const markers = /* @__PURE__ */ new Map();
476
- for (const rawLine of stdout.split(/\r?\n/)) {
477
- const line = rawLine.trim();
478
- if (!line.startsWith("saptools-inspector-")) {
479
- continue;
480
- }
481
- const separator = line.indexOf("=");
482
- markers.set(separator < 0 ? line : line.slice(0, separator), separator < 0 ? "" : line.slice(separator + 1));
483
- }
484
- return markers;
485
- }
486
- function throwForFailureMarker(markers) {
487
- if (markers.has("saptools-inspector-node-not-found")) {
488
- throw new CfDebuggerError("NODE_PROCESS_NOT_FOUND", "No Node.js process was found in the selected CF instance.");
489
- }
490
- const ambiguous = markers.get("saptools-inspector-node-ambiguous");
491
- if (ambiguous !== void 0) {
492
- const candidates = PID_LIST_PATTERN.test(ambiguous) ? ambiguous.split(",").join(", ") : "unknown";
493
- throw new CfDebuggerError("NODE_PROCESS_AMBIGUOUS", `Multiple Node.js processes were found: ${candidates}. Pass nodePid explicitly.`);
494
- }
495
- const invalid = markers.get("saptools-inspector-node-invalid");
496
- if (invalid !== void 0) {
497
- throw new CfDebuggerError("NODE_PID_INVALID", `Remote PID ${safePidText(invalid)} is not a Node.js process.`);
498
- }
499
- throwForRuntimeFailure(markers);
500
- }
501
- function throwForRuntimeFailure(markers) {
502
- const mismatch = markers.get("saptools-inspector-owner-mismatch");
503
- if (mismatch !== void 0) {
504
- const [selected = "unknown", owner = "unknown"] = mismatch.split(":", 2).map(safePidText);
505
- throw new CfDebuggerError("INSPECTOR_OWNER_MISMATCH", `Selected Node PID ${selected}, but inspector port 9229 is owned by PID ${owner}.`);
506
- }
507
- const signalFailed = markers.get("saptools-inspector-signal-failed");
508
- if (signalFailed !== void 0) {
509
- throw new CfDebuggerError("USR1_SIGNAL_FAILED", `Failed to signal remote Node PID ${safePidText(signalFailed)}.`);
510
- }
511
- if (markers.has("saptools-inspector-not-ready")) {
512
- throw new CfDebuggerError("INSPECTOR_NOT_READY", "Remote Node inspector did not become ready on port 9229.");
513
- }
514
- }
515
- function readMarkerPid(markers, name) {
516
- const raw = markers.get(name);
517
- if (raw === void 0 || !PID_PATTERN.test(raw)) {
518
- return void 0;
519
- }
520
- const value = Number.parseInt(raw, 10);
521
- return Number.isSafeInteger(value) && value > 0 ? value : void 0;
522
- }
523
- function safePidText(raw) {
524
- return PID_PATTERN.test(raw) ? raw : "unknown";
525
- }
526
- var NODE_INSPECTOR_SCRIPT = [
527
- "requested_node_pid=__REQUESTED_NODE_PID__",
528
- "is_node_pid() {",
529
- ' candidate_pid="$1"',
530
- ' candidate_exe="$(readlink "/proc/$candidate_pid/exe" 2>/dev/null || true)"',
531
- ' [ "${candidate_exe##*/}" = node ] || [ "${candidate_exe##*/}" = nodejs ]',
532
- "}",
533
- "find_listener_pid() {",
534
- ' listener_hex="$1"',
535
- ' [ -n "$listener_hex" ] || return 0',
536
- ` socket_inode="$(awk -v ph=":$listener_hex" '$4 == "0A" && $2 ~ (ph "$") { print $10; exit }' /proc/net/tcp /proc/net/tcp6 2>/dev/null)"`,
537
- ' [ -n "$socket_inode" ] || return 0',
538
- " for pid_dir in /proc/[0-9]*; do",
539
- ' [ -d "$pid_dir/fd" ] || continue',
540
- ' for fd_path in "$pid_dir"/fd/*; do',
541
- ' fd_target="$(readlink "$fd_path" 2>/dev/null || true)"',
542
- ' if [ "$fd_target" = "socket:[$socket_inode]" ]; then',
543
- ` printf '%s' "\${pid_dir##*/}"`,
544
- " return 0",
545
- " fi",
546
- " done",
547
- " done",
548
- "}",
549
- "find_inspector_owner() {",
550
- " find_listener_pid 240D",
551
- "}",
552
- "find_app_port_listener() {",
553
- ' [ -n "${PORT:-}" ] || return 0',
554
- ` app_port_hex="$(printf '%04X' "$PORT" 2>/dev/null || true)"`,
555
- ' find_listener_pid "$app_port_hex"',
556
- "}",
557
- 'owner_pid="$(find_inspector_owner)"',
558
- 'selected_pid=""',
559
- 'if [ -n "$requested_node_pid" ]; then',
560
- ' if ! is_node_pid "$requested_node_pid"; then',
561
- ' echo "saptools-inspector-node-invalid=$requested_node_pid"',
562
- " exit 0",
563
- " fi",
564
- ' selected_pid="$requested_node_pid"',
565
- 'elif [ -n "$owner_pid" ] && is_node_pid "$owner_pid"; then',
566
- ' selected_pid="$owner_pid"',
567
- "else",
568
- ' candidate_pids=""',
569
- " candidate_count=0",
570
- " for pid_dir in /proc/[0-9]*; do",
571
- ' candidate_pid="${pid_dir##*/}"',
572
- ' is_node_pid "$candidate_pid" || continue',
573
- " candidate_count=$((candidate_count + 1))",
574
- ' candidate_pids="${candidate_pids}${candidate_pids:+,}$candidate_pid"',
575
- ' selected_pid="$candidate_pid"',
576
- " done",
577
- ' if [ "$candidate_count" -eq 0 ]; then echo saptools-inspector-node-not-found; exit 0; fi',
578
- ' if [ "$candidate_count" -ne 1 ]; then',
579
- ' app_port_pid="$(find_app_port_listener)"',
580
- ' if [ -n "$app_port_pid" ] && is_node_pid "$app_port_pid"; then',
581
- ' selected_pid="$app_port_pid"',
582
- " else",
583
- ' echo "saptools-inspector-node-ambiguous=$candidate_pids"; exit 0',
584
- " fi",
585
- " fi",
586
- "fi",
587
- 'if [ -n "$owner_pid" ]; then',
588
- ' if [ "$owner_pid" != "$selected_pid" ]; then echo "saptools-inspector-owner-mismatch=$selected_pid:$owner_pid"; exit 0; fi',
589
- ' echo "saptools-inspector-node-pid=$selected_pid"',
590
- ' echo "saptools-inspector-owner-pid=$owner_pid"',
591
- " echo saptools-inspector-ready",
592
- " exit 0",
593
- "fi",
594
- 'if ! kill -USR1 "$selected_pid" 2>/dev/null; then echo "saptools-inspector-signal-failed=$selected_pid"; exit 0; fi',
595
- "attempt=0",
596
- 'while [ "$attempt" -lt 20 ]; do',
597
- ' owner_pid="$(find_inspector_owner)"',
598
- ' if [ -n "$owner_pid" ]; then',
599
- ' if [ "$owner_pid" != "$selected_pid" ]; then echo "saptools-inspector-owner-mismatch=$selected_pid:$owner_pid"; exit 0; fi',
600
- ' echo "saptools-inspector-node-pid=$selected_pid"',
601
- ' echo "saptools-inspector-owner-pid=$owner_pid"',
602
- " echo saptools-inspector-ready",
603
- " exit 0",
604
- " fi",
605
- " attempt=$((attempt + 1))",
606
- " sleep 0.25",
607
- "done",
608
- 'echo "saptools-inspector-not-ready=$selected_pid"'
609
- ].join("\n");
610
-
611
- // src/cloud-foundry/ssh.ts
612
- var DEFAULT_MAX_OUTPUT_BYTES = 65536;
613
- function buildCfSshArgs(appName, target, tail) {
614
- const resolved = resolveNodeTarget(target);
615
- const processArgs = resolved.process === DEFAULT_CF_PROCESS ? [] : ["--process", resolved.process];
616
- return [
617
- "ssh",
618
- appName,
619
- ...processArgs,
620
- "-i",
621
- resolved.instance.toString(),
622
- ...tail
623
- ];
624
- }
625
- async function cfSshOneShot(appName, command, context, rawOptions = DEFAULT_CF_COMMAND_TIMEOUT_MS) {
626
- const options = resolveSshOptions(rawOptions);
627
- const args = buildCfSshArgs(appName, options.target, [
628
- "--disable-pseudo-tty",
629
- "-c",
630
- command
631
- ]);
632
- return await runSshOneShot(args, context, options);
633
- }
634
- function resolveSshOptions(raw) {
635
- const input = typeof raw === "number" ? { timeoutMs: raw } : raw;
636
- const timeoutMs = input.timeoutMs ?? DEFAULT_CF_COMMAND_TIMEOUT_MS;
637
- const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
638
- if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
639
- throw new RangeError("timeoutMs must be a positive safe integer");
640
- }
641
- if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes <= 0) {
642
- throw new RangeError("maxOutputBytes must be a positive safe integer");
643
- }
644
- return { target: input, timeoutMs, maxOutputBytes };
645
- }
646
- function createBoundedOutput() {
647
- return { chunks: [], bytes: 0, truncated: false };
648
- }
649
- function appendBounded(output, data, limit) {
650
- const incoming = Buffer.isBuffer(data) ? data : Buffer.from(data);
651
- const remaining = Math.max(0, limit - output.bytes);
652
- if (incoming.byteLength > remaining) {
653
- output.truncated = true;
654
- }
655
- if (remaining === 0) {
656
- return;
657
- }
658
- const next = incoming.subarray(0, remaining);
659
- output.chunks.push(next);
660
- output.bytes += next.byteLength;
661
- }
662
- function outputText(output) {
663
- return Buffer.concat(output.chunks, output.bytes).toString("utf8");
664
- }
665
- function createResult(exitCode, stdout, stderr) {
666
- return {
667
- exitCode,
668
- stdout: outputText(stdout),
669
- stderr: outputText(stderr),
670
- outputTruncated: stdout.truncated || stderr.truncated
671
- };
672
- }
673
- function createSshExecutionState() {
674
- return {
675
- stdout: createBoundedOutput(),
676
- stderr: createBoundedOutput(),
677
- settled: false,
678
- aborted: false,
679
- timedOut: false
680
- };
681
- }
682
- function terminateSshExecution(child, state) {
683
- signalChild(child, "SIGTERM");
684
- state.forceKillTimer ??= setTimeout(() => {
685
- signalChild(child, "SIGKILL");
686
- }, 1e3);
687
- }
688
- function createSshSettler(state, options, signal, onAbort, resolve, reject) {
689
- return (result) => {
690
- if (state.settled) {
691
- return;
692
- }
693
- state.settled = true;
694
- if (state.timeoutTimer !== void 0) {
695
- clearTimeout(state.timeoutTimer);
696
- }
697
- if (state.forceKillTimer !== void 0) {
698
- clearTimeout(state.forceKillTimer);
699
- }
700
- signal?.removeEventListener("abort", onAbort);
701
- if (state.aborted) {
702
- reject(new CfDebuggerError("ABORTED", "Operation aborted by caller"));
703
- return;
704
- }
705
- resolve(state.timedOut ? { ...result, timedOutAfterMs: options.timeoutMs } : result);
706
- };
707
- }
708
- function attachSshExecution(child, context, options, resolve, reject) {
709
- const state = createSshExecutionState();
710
- const onAbort = () => {
711
- state.aborted = true;
712
- terminateSshExecution(child, state);
713
- };
714
- const settle = createSshSettler(state, options, context.signal, onAbort, resolve, reject);
715
- state.timeoutTimer = setTimeout(() => {
716
- state.timedOut = true;
717
- terminateSshExecution(child, state);
718
- }, options.timeoutMs);
719
- if (context.signal?.aborted) {
720
- onAbort();
721
- } else {
722
- context.signal?.addEventListener("abort", onAbort, { once: true });
723
- }
724
- child.stdout.on("data", (data) => {
725
- appendBounded(state.stdout, data, options.maxOutputBytes);
726
- });
727
- child.stderr.on("data", (data) => {
728
- appendBounded(state.stderr, data, options.maxOutputBytes);
729
- });
730
- child.on("close", (code, signal) => {
731
- const base = createResult(code, state.stdout, state.stderr);
732
- settle(state.timedOut || signal === null ? base : { ...base, signal });
733
- });
734
- child.on("error", (error) => {
735
- appendBounded(state.stderr, error.message, options.maxOutputBytes);
736
- settle(createResult(null, state.stdout, state.stderr));
737
- });
738
- }
739
- function runSshOneShot(args, context, options) {
740
- if (context.signal?.aborted) {
741
- return Promise.reject(new CfDebuggerError("ABORTED", "Operation aborted by caller"));
742
- }
743
- return new Promise((resolve, reject) => {
744
- const child = spawn(resolveBin(context), [...args], {
745
- env: buildEnv(context.cfHome),
746
- detached: process.platform !== "win32",
747
- stdio: ["ignore", "pipe", "pipe"]
748
- });
749
- attachSshExecution(child, context, options, resolve, reject);
750
- });
751
- }
752
- function signalChild(child, signal) {
753
- if (process.platform !== "win32" && child.pid !== void 0) {
754
- try {
755
- process.kill(-child.pid, signal);
756
- return;
757
- } catch {
758
- }
759
- }
760
- try {
761
- child.kill(signal);
762
- } catch {
763
- }
764
- }
765
- function isSshDisabledError(stderr) {
766
- const lower = stderr.toLowerCase();
767
- return lower.includes("not authorized") || lower.includes("ssh support is disabled");
768
- }
769
- function spawnSshTunnel(appName, localPort, remotePort, context, target = {}) {
770
- const tunnelArg = `${localPort.toString()}:localhost:${remotePort.toString()}`;
771
- const args = buildCfSshArgs(appName, target, ["-N", "-L", tunnelArg]);
772
- const child = spawn(resolveBin(context), [...args], {
773
- env: buildEnv(context.cfHome),
774
- detached: process.platform !== "win32",
775
- stdio: ["ignore", "pipe", "pipe"]
776
- });
777
- child.stdout.resume();
778
- child.stderr.resume();
779
- return child;
780
- }
781
-
782
- // src/debug-session/start.ts
783
- import { chmod as chmod3, mkdir as mkdir3, rm as rm2 } from "fs/promises";
784
- import process5 from "process";
785
-
786
- // src/paths.ts
787
- import { homedir } from "os";
788
- import { isAbsolute, join } from "path";
789
- var SAPTOOLS_DIR_NAME = ".saptools";
790
- var CF_DEBUGGER_STATE_FILENAME = "cf-debugger-state-v2.json";
791
- var CF_DEBUGGER_LOCK_FILENAME = "cf-debugger-state-v2.lock";
792
- var CF_DEBUGGER_HOMES_DIRNAME = "cf-debugger-homes-v2";
793
- var SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
794
- function saptoolsDir() {
795
- return join(homedir(), SAPTOOLS_DIR_NAME);
796
- }
797
- function stateFilePath() {
798
- return join(saptoolsDir(), CF_DEBUGGER_STATE_FILENAME);
799
- }
800
- function stateLockPath() {
801
- return join(saptoolsDir(), CF_DEBUGGER_LOCK_FILENAME);
802
- }
803
- function isSafeSessionId(sessionId) {
804
- return SESSION_ID_PATTERN.test(sessionId);
805
- }
806
- function sessionCfHomeDir(sessionId) {
807
- if (!isSafeSessionId(sessionId)) {
808
- throw new Error("Invalid debugger session ID.");
809
- }
810
- return join(saptoolsDir(), CF_DEBUGGER_HOMES_DIRNAME, sessionId);
811
- }
812
- function isOwnedSessionCfHomeDir(sessionId, candidate) {
813
- if (!isSafeSessionId(sessionId) || !isAbsolute(candidate)) {
814
- return false;
815
- }
816
- return candidate === sessionCfHomeDir(sessionId);
817
- }
818
-
819
- // src/network/ports.ts
820
- import { execFile as execFile3 } from "child_process";
821
- import { readdir, readFile, readlink } from "fs/promises";
822
- import { createConnection, createServer } from "net";
823
- import { promisify as promisify3 } from "util";
824
- var execFileAsync3 = promisify3(execFile3);
825
- async function findListeningPidsWithNetstat(port) {
826
- try {
827
- const { stdout } = await execFileAsync3("netstat", ["-ano"]);
828
- const pids = /* @__PURE__ */ new Set();
829
- for (const line of stdout.split("\n")) {
830
- if (!line.includes(`:${port.toString()}`) || !line.includes("LISTENING")) {
831
- continue;
832
- }
833
- const parts = line.trim().split(/\s+/);
834
- const last = parts[parts.length - 1];
835
- if (last === void 0) {
836
- continue;
837
- }
838
- const pid = Number.parseInt(last, 10);
839
- if (!Number.isNaN(pid)) {
840
- pids.add(pid);
841
- }
842
- }
843
- return [...pids];
844
- } catch {
845
- return [];
846
- }
847
- }
848
- async function findListeningPidsWithLsof(port) {
849
- try {
850
- const { stdout } = await execFileAsync3("lsof", ["-nP", "-t", "-i", `tcp:${port.toString()}`, "-sTCP:LISTEN"]);
851
- return stdout.trim().split("\n").filter((line) => line.length > 0).map((line) => Number.parseInt(line, 10)).filter((pid) => !Number.isNaN(pid));
852
- } catch {
853
- return [];
854
- }
855
- }
856
- async function findListeningPidsWithProc(port) {
857
- const inodes = await findListeningSocketInodesWithProc(port);
858
- if (inodes.size === 0) {
859
- return [];
860
- }
861
- try {
862
- const entries = await readdir("/proc", { withFileTypes: true });
863
- const pids = /* @__PURE__ */ new Set();
864
- for (const entry of entries) {
865
- if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) {
866
- continue;
867
- }
868
- if (await processHasSocketInode(entry.name, inodes)) {
869
- pids.add(Number.parseInt(entry.name, 10));
870
- }
871
- }
872
- return [...pids];
873
- } catch {
874
- return [];
875
- }
876
- }
877
- async function findListeningSocketInodesWithProc(port) {
878
- const inodes = /* @__PURE__ */ new Set();
879
- await collectListeningSocketInodes("/proc/net/tcp", port, inodes);
880
- await collectListeningSocketInodes("/proc/net/tcp6", port, inodes);
881
- return inodes;
882
- }
883
- async function collectListeningSocketInodes(path, port, inodes) {
884
- try {
885
- const content = await readFile(path, "utf8");
886
- for (const line of content.split("\n").slice(1)) {
887
- const fields = line.trim().split(/\s+/);
888
- const localAddress = fields[1];
889
- const state = fields[3];
890
- const inode = fields[9];
891
- if (localAddress === void 0 || state === void 0 || inode === void 0) {
892
- continue;
893
- }
894
- const localPort = Number.parseInt(localAddress.split(":")[1] ?? "", 16);
895
- if (localPort === port && state === "0A") {
896
- inodes.add(inode);
897
- }
898
- }
899
- } catch {
900
- }
901
- }
902
- async function processHasSocketInode(pid, inodes) {
903
- try {
904
- const descriptors = await readdir(`/proc/${pid}/fd`);
905
- for (const descriptor of descriptors) {
906
- try {
907
- const link = await readlink(`/proc/${pid}/fd/${descriptor}`);
908
- const match = /^socket:\[(\d+)\]$/.exec(link);
909
- if (match?.[1] !== void 0 && inodes.has(match[1])) {
910
- return true;
911
- }
912
- } catch {
913
- }
914
- }
915
- } catch {
916
- }
917
- return false;
918
- }
919
- async function findListeningPids(port) {
920
- if (process.platform === "win32") {
921
- return await findListeningPidsWithNetstat(port);
922
- }
923
- const lsofPids = await findListeningPidsWithLsof(port);
924
- return lsofPids.length > 0 ? lsofPids : await findListeningPidsWithProc(port);
925
- }
926
- async function isPortFree(port) {
927
- return await new Promise((resolve) => {
928
- const server = createServer();
929
- server.once("error", () => {
930
- resolve(false);
931
- });
932
- server.once("listening", () => {
933
- server.close(() => {
934
- resolve(true);
935
- });
936
- });
937
- server.listen(port, "127.0.0.1");
938
- });
939
- }
940
- async function isPortListening(port, timeoutMs = 200) {
941
- return await new Promise((resolve) => {
942
- const socket = createConnection({ port, host: "127.0.0.1" });
943
- socket.setTimeout(timeoutMs);
944
- socket.once("connect", () => {
945
- socket.destroy();
946
- resolve(true);
947
- });
948
- socket.once("error", () => {
949
- socket.destroy();
950
- resolve(false);
951
- });
952
- socket.once("timeout", () => {
953
- socket.destroy();
954
- resolve(false);
955
- });
956
- });
957
- }
958
- function throwIfAborted(signal) {
959
- if (signal?.aborted) {
960
- throw new CfDebuggerError("ABORTED", "Operation aborted by caller");
961
- }
962
- }
963
- function waitForNextProbe(delayMs, signal) {
964
- throwIfAborted(signal);
965
- return new Promise((resolve, reject) => {
966
- const onAbort = () => {
967
- clearTimeout(timer);
968
- reject(new CfDebuggerError("ABORTED", "Operation aborted by caller"));
969
- };
970
- const timer = setTimeout(() => {
971
- signal?.removeEventListener("abort", onAbort);
972
- resolve();
973
- }, delayMs);
974
- signal?.addEventListener("abort", onAbort, { once: true });
975
- });
976
- }
977
- async function probeTunnelReady(port, timeoutMs, signal) {
978
- const pollIntervalMs = 250;
979
- const started = Date.now();
980
- throwIfAborted(signal);
981
- while (Date.now() - started < timeoutMs) {
982
- const connected = await isPortListening(port);
983
- if (connected) {
984
- return true;
985
- }
986
- const remainingMs = timeoutMs - (Date.now() - started);
987
- await waitForNextProbe(Math.min(pollIntervalMs, Math.max(0, remainingMs)), signal);
988
- }
989
- throwIfAborted(signal);
990
- return false;
991
- }
992
- async function findListeningProcessId(port) {
993
- const pids = await findListeningPids(port);
994
- return pids[0];
995
- }
996
-
997
- // src/session-state/store.ts
998
- import { randomUUID as randomUUID2 } from "crypto";
999
- import { chmod as chmod2, mkdir as mkdir2, readFile as readFile3, rename, unlink as unlink2, writeFile } from "fs/promises";
1000
- import { hostname as getHostname } from "os";
1001
- import { dirname as dirname2, isAbsolute as isAbsolute3 } from "path";
1002
- import process3 from "process";
1003
-
1004
- // src/lock.ts
1005
- import { randomUUID } from "crypto";
1006
- import { chmod, mkdir, open, readFile as readFile2, stat, unlink } from "fs/promises";
1007
- import { hostname } from "os";
1008
- import { dirname } from "path";
1009
- import process2 from "process";
1010
- var DEFAULT_POLL_MS = 50;
1011
- var DEFAULT_STALE_MS = 6e4;
1012
- var DEFAULT_TIMEOUT_MS = 1e4;
1013
- function sleep(ms) {
1014
- return new Promise((resolve) => {
1015
- setTimeout(resolve, ms);
1016
- });
1017
- }
1018
- function errorCode(error) {
1019
- if (typeof error !== "object" || error === null) {
1020
- return void 0;
1021
- }
1022
- const code = Reflect.get(error, "code");
1023
- return typeof code === "string" ? code : void 0;
1024
- }
1025
- function field(value, key) {
1026
- return Reflect.get(value, key);
1027
- }
1028
- function parseLockOwner(raw) {
1029
- let value;
1030
- try {
1031
- value = JSON.parse(raw);
1032
- } catch {
1033
- return void 0;
1034
- }
1035
- if (typeof value !== "object" || value === null) {
1036
- return void 0;
1037
- }
1038
- const lockHostname = field(value, "hostname");
1039
- const pid = field(value, "pid");
1040
- const token = field(value, "token");
1041
- const version = field(value, "version");
1042
- if (typeof lockHostname !== "string" || typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0) {
1043
- return void 0;
1044
- }
1045
- if (typeof token !== "string" || version !== "1") {
1046
- return void 0;
1047
- }
1048
- return { hostname: lockHostname, pid, token, version };
1049
- }
1050
- function isProcessAlive(pid) {
1051
- try {
1052
- process2.kill(pid, 0);
1053
- return true;
1054
- } catch (error) {
1055
- return errorCode(error) !== "ESRCH";
1056
- }
1057
- }
1058
- async function readLockOwner(lockPath) {
1059
- try {
1060
- await chmod(lockPath, 384);
1061
- return parseLockOwner(await readFile2(lockPath, "utf8"));
1062
- } catch (error) {
1063
- if (errorCode(error) === "ENOENT") {
1064
- return void 0;
1065
- }
1066
- throw error;
1067
- }
1068
- }
1069
- async function isStaleLock(lockPath, staleMs) {
1070
- let modifiedAt;
1071
- try {
1072
- modifiedAt = (await stat(lockPath)).mtimeMs;
1073
- } catch (error) {
1074
- if (errorCode(error) === "ENOENT") {
1075
- return true;
1076
- }
1077
- throw error;
1078
- }
1079
- const owner = await readLockOwner(lockPath);
1080
- if (owner?.hostname === hostname()) {
1081
- return !isProcessAlive(owner.pid);
1082
- }
1083
- return owner === void 0 && Date.now() - modifiedAt > staleMs;
1084
- }
1085
- async function reclaimAbandonedRecoveryLock(recoveryPath, staleMs) {
1086
- if (!await isStaleLock(recoveryPath, staleMs)) {
1087
- return;
1088
- }
1089
- const owner = await readLockOwner(recoveryPath);
1090
- if (owner !== void 0) {
1091
- await removeOwnedLock(recoveryPath, owner.token);
1092
- return;
1093
- }
1094
- await unlink(recoveryPath).catch((error) => {
1095
- if (errorCode(error) !== "ENOENT") {
1096
- throw error;
1097
- }
1098
- });
1099
- }
1100
- async function reclaimStaleLock(lockPath, staleMs) {
1101
- const recoveryPath = `${lockPath}.recovery`;
1102
- let recoveryLock;
1103
- try {
1104
- recoveryLock = await createFileLock(recoveryPath);
1105
- } catch (error) {
1106
- if (errorCode(error) === "EEXIST") {
1107
- await reclaimAbandonedRecoveryLock(recoveryPath, staleMs);
1108
- return false;
1109
- }
1110
- throw error;
1111
- }
1112
- try {
1113
- if (!await isStaleLock(lockPath, staleMs)) {
1114
- return false;
1115
- }
1116
- try {
1117
- await unlink(lockPath);
1118
- } catch (error) {
1119
- if (errorCode(error) !== "ENOENT") {
1120
- throw error;
1121
- }
1122
- }
1123
- return true;
1124
- } finally {
1125
- await releaseFileLock(recoveryPath, recoveryLock);
1126
- }
1127
- }
1128
- async function removeOwnedLock(lockPath, token) {
1129
- const owner = await readLockOwner(lockPath);
1130
- if (owner?.token !== token) {
1131
- return;
1132
- }
1133
- await unlink(lockPath).catch((error) => {
1134
- if (errorCode(error) !== "ENOENT") {
1135
- throw error;
1136
- }
1137
- });
1138
- }
1139
- async function createFileLock(lockPath) {
1140
- const handle = await open(lockPath, "wx", 384);
1141
- const token = randomUUID();
1142
- try {
1143
- await handle.writeFile(`${JSON.stringify({ hostname: hostname(), pid: process2.pid, token, version: "1" })}
1144
- `, "utf8");
1145
- return { handle, token };
1146
- } catch (error) {
1147
- await handle.close();
1148
- await unlink(lockPath).catch(() => false);
1149
- throw error;
1150
- }
1151
- }
1152
- async function acquireFileLock(lockPath, timeoutMs, pollMs, staleMs) {
1153
- const deadline = Date.now() + timeoutMs;
1154
- const parentDir = dirname(lockPath);
1155
- await mkdir(parentDir, { recursive: true, mode: 448 });
1156
- await chmod(parentDir, 448);
1157
- for (; ; ) {
1158
- try {
1159
- return await createFileLock(lockPath);
1160
- } catch (error) {
1161
- if (errorCode(error) !== "EEXIST") {
1162
- throw error;
1163
- }
1164
- }
1165
- if (await reclaimStaleLock(lockPath, staleMs)) {
1166
- continue;
1167
- }
1168
- if (Date.now() > deadline) {
1169
- throw new Error(`Timed out acquiring file lock at ${lockPath}`);
1170
- }
1171
- await sleep(pollMs);
1172
- }
1173
- }
1174
- async function releaseFileLock(lockPath, lock) {
1175
- await lock.handle.close();
1176
- await removeOwnedLock(lockPath, lock.token);
1177
- }
1178
- async function withFileLock(lockPath, work, options) {
1179
- const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
1180
- const pollMs = options?.pollMs ?? DEFAULT_POLL_MS;
1181
- const staleMs = options?.staleMs ?? DEFAULT_STALE_MS;
1182
- const lock = await acquireFileLock(lockPath, timeoutMs, pollMs, staleMs);
1183
- try {
1184
- return await work();
1185
- } finally {
1186
- await releaseFileLock(lockPath, lock);
1187
- }
1188
- }
1189
-
1190
- // src/session-state/decoder.ts
1191
- import { isAbsolute as isAbsolute2 } from "path";
1192
- var INVALID_SESSION = new Error("Invalid persisted debugger session");
1193
- var VALID_STATUSES = /* @__PURE__ */ new Set([
1194
- "starting",
1195
- "logging-in",
1196
- "targeting",
1197
- "ssh-enabling",
1198
- "ssh-restarting",
1199
- "signaling",
1200
- "tunneling",
1201
- "ready",
1202
- "stopping",
1203
- "stopped",
1204
- "error"
1205
- ]);
1206
- function field2(value, key) {
1207
- return Reflect.get(value, key);
1208
- }
1209
- function requireString(value, key) {
1210
- const candidate = field2(value, key);
1211
- if (typeof candidate !== "string" || candidate.length === 0) {
1212
- throw INVALID_SESSION;
1213
- }
1214
- return candidate;
1215
- }
1216
- function optionalString2(value, key) {
1217
- const candidate = field2(value, key);
1218
- if (candidate === void 0) {
1219
- return void 0;
1220
- }
1221
- if (typeof candidate !== "string") {
1222
- throw INVALID_SESSION;
1223
- }
1224
- return candidate;
1225
- }
1226
- function requireInteger(value, key, minimum, maximum) {
1227
- const candidate = field2(value, key);
1228
- if (!Number.isSafeInteger(candidate) || typeof candidate !== "number") {
1229
- throw INVALID_SESSION;
1230
- }
1231
- if (candidate < minimum || candidate > maximum) {
1232
- throw INVALID_SESSION;
1233
- }
1234
- return candidate;
1235
- }
1236
- function optionalInteger(value, key, minimum) {
1237
- const candidate = field2(value, key);
1238
- if (candidate === void 0) {
1239
- return void 0;
1240
- }
1241
- if (!Number.isSafeInteger(candidate) || typeof candidate !== "number" || candidate < minimum) {
1242
- throw INVALID_SESSION;
1243
- }
1244
- return candidate;
1245
- }
1246
- function requireSessionId(value) {
1247
- const sessionId = requireString(value, "sessionId");
1248
- if (!isSafeSessionId(sessionId)) {
1249
- throw INVALID_SESSION;
1250
- }
1251
- return sessionId;
1252
- }
1253
- function requireAbsolutePath(value, key) {
1254
- const path = requireString(value, key);
1255
- if (!isAbsolute2(path)) {
1256
- throw INVALID_SESSION;
1257
- }
1258
- return path;
1259
- }
1260
- function requireTimestamp(value) {
1261
- const timestamp = requireString(value, "startedAt");
1262
- if (Number.isNaN(Date.parse(timestamp))) {
1263
- throw INVALID_SESSION;
1264
- }
1265
- return timestamp;
1266
- }
1267
- function optionalTimestamp(value, key) {
1268
- const timestamp = optionalString2(value, key);
1269
- if (timestamp !== void 0 && Number.isNaN(Date.parse(timestamp))) {
1270
- throw INVALID_SESSION;
1271
- }
1272
- return timestamp;
1273
- }
1274
- function isSessionStatus(value) {
1275
- return VALID_STATUSES.has(value);
1276
- }
1277
- function requireStatus(value) {
1278
- const status = requireString(value, "status");
1279
- if (!isSessionStatus(status)) {
1280
- throw INVALID_SESSION;
1281
- }
1282
- return status;
1283
- }
1284
- function decodeSession(value) {
1285
- if (typeof value !== "object" || value === null) {
1286
- return void 0;
1287
- }
1288
- try {
1289
- const processName = requireString(value, "process");
1290
- const instance = requireInteger(value, "instance", 0, Number.MAX_SAFE_INTEGER);
1291
- const nodePid = optionalInteger(value, "nodePid", 1);
1292
- const target = resolveNodeTarget({
1293
- process: processName,
1294
- instance,
1295
- ...nodePid === void 0 ? {} : { nodePid }
1296
- });
1297
- const pid = requireInteger(value, "pid", 1, Number.MAX_SAFE_INTEGER);
1298
- const controllerPid = requireInteger(value, "controllerPid", 1, Number.MAX_SAFE_INTEGER);
1299
- const tunnelPid = optionalInteger(value, "tunnelPid", 1);
1300
- const remoteNodePid = optionalInteger(value, "remoteNodePid", 1);
1301
- const stopRequestedAt = optionalTimestamp(value, "stopRequestedAt");
1302
- const message = optionalString2(value, "message");
1303
- const status = requireStatus(value);
1304
- if (pid !== (tunnelPid ?? controllerPid) || status === "ready" && tunnelPid === void 0) {
1305
- throw INVALID_SESSION;
1306
- }
1307
- return {
1308
- sessionId: requireSessionId(value),
1309
- pid,
1310
- controllerPid,
1311
- ...tunnelPid === void 0 ? {} : { tunnelPid },
1312
- hostname: requireString(value, "hostname"),
1313
- region: requireString(value, "region"),
1314
- org: requireString(value, "org"),
1315
- space: requireString(value, "space"),
1316
- app: requireString(value, "app"),
1317
- process: target.process,
1318
- instance: target.instance,
1319
- ...nodePid === void 0 ? {} : { nodePid },
1320
- apiEndpoint: requireString(value, "apiEndpoint"),
1321
- localPort: requireInteger(value, "localPort", 1, 65535),
1322
- remotePort: requireInteger(value, "remotePort", 1, 65535),
1323
- cfHomeDir: requireAbsolutePath(value, "cfHomeDir"),
1324
- startedAt: requireTimestamp(value),
1325
- status,
1326
- ...remoteNodePid === void 0 ? {} : { remoteNodePid },
1327
- ...stopRequestedAt === void 0 ? {} : { stopRequestedAt },
1328
- ...message === void 0 ? {} : { message }
1329
- };
1330
- } catch {
1331
- return void 0;
1332
- }
1333
- }
1334
- function decodeStateFile(value) {
1335
- if (typeof value !== "object" || value === null || field2(value, "version") !== "2") {
1336
- return void 0;
1337
- }
1338
- const rawSessions = field2(value, "sessions");
1339
- if (!Array.isArray(rawSessions)) {
1340
- return void 0;
1341
- }
1342
- const sessionIds = /* @__PURE__ */ new Set();
1343
- const sessions = [];
1344
- for (const rawSession of rawSessions) {
1345
- const session = decodeSession(rawSession);
1346
- if (session === void 0 || sessionIds.has(session.sessionId)) {
1347
- return void 0;
1348
- }
1349
- sessionIds.add(session.sessionId);
1350
- sessions.push(session);
1351
- }
1352
- return { version: "2", sessions };
1353
- }
1354
-
1355
- // src/session-state/store.ts
1356
- function errorCode2(error) {
1357
- if (typeof error !== "object" || error === null) {
1358
- return void 0;
1359
- }
1360
- const code = Reflect.get(error, "code");
1361
- return typeof code === "string" ? code : void 0;
1362
- }
1363
- async function readJsonFile(path) {
1364
- let raw;
1365
- try {
1366
- await chmod2(path, 384);
1367
- raw = await readFile3(path, "utf8");
1368
- } catch (err) {
1369
- if (errorCode2(err) === "ENOENT") {
1370
- return void 0;
1371
- }
1372
- throw err;
1373
- }
1374
- try {
1375
- return JSON.parse(raw);
1376
- } catch {
1377
- process3.stderr.write(
1378
- `[cf-debugger] warning: state file at ${path} is not valid JSON; resetting to empty.
1379
- `
1380
- );
1381
- return void 0;
1382
- }
1383
- }
1384
- async function writeJsonFileAtomic(path, value) {
1385
- const tempPath = `${path}.${randomUUID2()}.tmp`;
1386
- const parentDir = dirname2(path);
1387
- await mkdir2(parentDir, { recursive: true, mode: 448 });
1388
- await chmod2(parentDir, 448);
1389
- let renamed = false;
1390
- try {
1391
- await writeFile(tempPath, `${JSON.stringify(value, null, 2)}
1392
- `, {
1393
- encoding: "utf8",
1394
- flag: "wx",
1395
- mode: 384
1396
- });
1397
- await rename(tempPath, path);
1398
- renamed = true;
1399
- await chmod2(path, 384);
1400
- } finally {
1401
- if (!renamed) {
1402
- await unlink2(tempPath).catch(() => false);
1403
- }
1404
- }
1405
- }
1406
- function emptyState() {
1407
- return { version: "2", sessions: [] };
1408
- }
1409
- function isPidAlive(pid) {
1410
- try {
1411
- process3.kill(pid, 0);
1412
- return true;
1413
- } catch (err) {
1414
- if (errorCode2(err) === "ESRCH") {
1415
- return false;
1416
- }
1417
- return true;
1418
- }
1419
- }
1420
- function isPidOrGroupAlive(pid) {
1421
- if (isPidAlive(pid)) {
1422
- return true;
1423
- }
1424
- return isProcessGroupAlive(pid);
1425
- }
1426
- function isProcessGroupAlive(pid) {
1427
- return process3.platform !== "win32" && isPidAlive(-pid);
1428
- }
1429
- async function isSessionHealthy(session, host) {
1430
- if (session.hostname !== host) {
1431
- return false;
1432
- }
1433
- if (session.status !== "ready" && isPidAlive(session.controllerPid ?? session.pid)) {
1434
- return true;
1435
- }
1436
- if (session.tunnelPid !== void 0 && isPidOrGroupAlive(session.tunnelPid)) {
1437
- return true;
1438
- }
1439
- return await isPortListening(session.localPort);
1440
- }
1441
- async function filterStaleSessions(sessions) {
1442
- const host = getHostname();
1443
- const checks = await Promise.all(
1444
- sessions.map(async (session) => [
1445
- session,
1446
- await isSessionHealthy(session, host)
1447
- ])
1448
- );
1449
- return checks.filter(([, healthy]) => healthy).map(([session]) => session);
1450
- }
1451
- async function readStateRaw() {
1452
- const path = stateFilePath();
1453
- const parsed = await readJsonFile(path);
1454
- if (parsed === void 0) {
1455
- return emptyState();
1456
- }
1457
- const decoded = decodeStateFile(parsed);
1458
- if (decoded !== void 0) {
1459
- return decoded;
1460
- }
1461
- process3.stderr.write(
1462
- `[cf-debugger] warning: state file at ${path} has an invalid structure; resetting to empty.
1463
- `
1464
- );
1465
- return emptyState();
1466
- }
1467
- async function writeState(state) {
1468
- await writeJsonFileAtomic(stateFilePath(), state);
1469
- }
1470
- async function readAndPruneLocked() {
1471
- const raw = await readStateRaw();
1472
- const host = getHostname();
1473
- const remote = raw.sessions.filter((session) => session.hostname !== host);
1474
- const local = raw.sessions.filter((session) => session.hostname === host);
1475
- const pruned = await filterStaleSessions(local);
1476
- const removed = local.filter(
1477
- (session) => !pruned.some((active) => active.sessionId === session.sessionId)
1478
- );
1479
- if (removed.length > 0) {
1480
- await writeState({ version: "2", sessions: [...remote, ...pruned] });
1481
- }
1482
- return { sessions: pruned, removed };
1483
- }
1484
- async function readActiveSessions() {
1485
- const result = await withFileLock(stateLockPath(), readAndPruneLocked);
1486
- return result.sessions;
1487
- }
1488
- async function readSessionSnapshot() {
1489
- return await withFileLock(stateLockPath(), async () => {
1490
- const raw = await readStateRaw();
1491
- return raw.sessions;
1492
- });
1493
- }
1494
- async function readAndPruneActiveSessions() {
1495
- return await withFileLock(stateLockPath(), readAndPruneLocked);
1496
- }
1497
- function sessionKeyString(key) {
1498
- const base = `${key.region}:${key.org}:${key.space}:${key.app}`;
1499
- if (key.process === void 0 && key.instance === void 0) {
1500
- return base;
1501
- }
1502
- const target = resolveNodeTarget(key);
1503
- return `${base}:${target.process}:${target.instance.toString()}`;
1504
- }
1505
- function matchesKey(session, key) {
1506
- const sessionTarget = resolveNodeTarget(session);
1507
- const keyTarget = resolveNodeTarget(key);
1508
- return session.region === key.region && session.org === key.org && session.space === key.space && session.app === key.app && sessionTarget.process === keyTarget.process && sessionTarget.instance === keyTarget.instance && (key.apiEndpoint === void 0 || session.apiEndpoint === key.apiEndpoint) && (key.nodePid === void 0 || sessionTarget.nodePid === keyTarget.nodePid);
1509
- }
1510
- function matchesRegistrationTarget(session, input, target) {
1511
- return matchesKey(session, {
1512
- region: input.region,
1513
- org: input.org,
1514
- space: input.space,
1515
- app: input.app,
1516
- process: target.process,
1517
- instance: target.instance,
1518
- apiEndpoint: input.apiEndpoint
1519
- });
1520
- }
1521
- var DEFAULT_BASE_PORT = 2e4;
1522
- var DEFAULT_MAX_PORT = 20999;
1523
- async function pickPort(preferred, reserved, probe, basePort, maxPort) {
1524
- const tryOrder = [];
1525
- if (preferred !== void 0) {
1526
- tryOrder.push(preferred);
1527
- }
1528
- for (let port = basePort; port <= maxPort; port++) {
1529
- if (port !== preferred) {
1530
- tryOrder.push(port);
1531
- }
1532
- }
1533
- for (const port of tryOrder) {
1534
- if (reserved.has(port)) {
1535
- continue;
1536
- }
1537
- const free = await probe(port);
1538
- if (free) {
1539
- return port;
1540
- }
1541
- }
1542
- throw new CfDebuggerError(
1543
- "PORT_UNAVAILABLE",
1544
- `No free local port available in range ${basePort.toString()}\u2013${maxPort.toString()}`
1545
- );
1546
- }
1547
- async function registerNewSession(input) {
1548
- const target = resolveNodeTarget(input);
1549
- return await withFileLock(stateLockPath(), async () => {
1550
- const pruneResult = await readAndPruneLocked();
1551
- const persisted = await readStateRaw();
1552
- const host = getHostname();
1553
- const remoteSessions = persisted.sessions.filter((session2) => session2.hostname !== host);
1554
- const existing = pruneResult.sessions.find((session2) => matchesRegistrationTarget(session2, input, target));
1555
- if (existing) {
1556
- return { session: existing, existing };
1557
- }
1558
- const reservedPorts = new Set(pruneResult.sessions.map((session2) => session2.localPort));
1559
- const localPort = await pickPort(
1560
- input.preferredPort,
1561
- reservedPorts,
1562
- input.portProbe,
1563
- input.basePort ?? DEFAULT_BASE_PORT,
1564
- input.maxPort ?? DEFAULT_MAX_PORT
1565
- );
1566
- const session = createRegisteredSession(input, target, localPort);
1567
- const nextSessions = [...remoteSessions, ...pruneResult.sessions, session];
1568
- await writeState({ version: "2", sessions: nextSessions });
1569
- return { session };
1570
- });
1571
- }
1572
- function createRegisteredSession(input, target, localPort) {
1573
- const sessionId = (input.sessionIdFactory ?? randomUUID2)();
1574
- if (!isSafeSessionId(sessionId)) {
1575
- throw new CfDebuggerError("UNSAFE_INPUT", "Generated debugger session ID is invalid.");
1576
- }
1577
- const cfHomeDir = input.cfHomeForSession(sessionId);
1578
- if (!isAbsolute3(cfHomeDir)) {
1579
- throw new CfDebuggerError("UNSAFE_INPUT", "Debugger CF home must be an absolute path.");
1580
- }
1581
- return {
1582
- sessionId,
1583
- pid: process3.pid,
1584
- controllerPid: process3.pid,
1585
- hostname: getHostname(),
1586
- region: input.region,
1587
- org: input.org,
1588
- space: input.space,
1589
- app: input.app,
1590
- process: target.process,
1591
- instance: target.instance,
1592
- ...target.nodePid === void 0 ? {} : { nodePid: target.nodePid },
1593
- apiEndpoint: input.apiEndpoint,
1594
- localPort,
1595
- remotePort: 9229,
1596
- cfHomeDir,
1597
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1598
- status: "starting"
1599
- };
1600
- }
1601
- async function updateSessionStatus(sessionId, status, message) {
1602
- return await withFileLock(stateLockPath(), async () => {
1603
- const raw = await readStateRaw();
1604
- let updated;
1605
- const nextSessions = raw.sessions.map((session) => {
1606
- if (session.sessionId !== sessionId) {
1607
- return session;
1608
- }
1609
- if (status !== "stopping" && startupMutationBlocked(session)) {
1610
- updated = session;
1611
- return session;
1612
- }
1613
- if (status === "ready" && session.tunnelPid === void 0) {
1614
- throw new CfDebuggerError(
1615
- "SESSION_STATE_CONFLICT",
1616
- "A debugger session cannot become ready before its tunnel PID is recorded."
1617
- );
1618
- }
1619
- const base = withoutMessage(session);
1620
- const next = message === void 0 ? { ...base, status } : { ...base, status, message };
1621
- updated = next;
1622
- return next;
1623
- });
1624
- if (updated) {
1625
- await writeState({ version: "2", sessions: nextSessions });
1626
- }
1627
- return updated;
1628
- });
1629
- }
1630
- async function updateSessionPid(sessionId, pid) {
1631
- if (!Number.isSafeInteger(pid) || pid <= 0) {
1632
- throw new CfDebuggerError("UNSAFE_INPUT", "Tunnel PID must be a positive safe integer.");
1633
- }
1634
- return await withFileLock(stateLockPath(), async () => {
1635
- const raw = await readStateRaw();
1636
- let updated;
1637
- const nextSessions = raw.sessions.map((session) => {
1638
- if (session.sessionId !== sessionId) {
1639
- return session;
1640
- }
1641
- if (startupMutationBlocked(session)) {
1642
- updated = session;
1643
- return session;
1644
- }
1645
- const next = { ...session, pid, tunnelPid: pid };
1646
- updated = next;
1647
- return next;
1648
- });
1649
- if (updated !== void 0) {
1650
- await writeState({ version: "2", sessions: nextSessions });
1651
- }
1652
- return updated;
1653
- });
1654
- }
1655
- function withoutMessage(session) {
1656
- const { message, ...clone } = session;
1657
- void message;
1658
- return clone;
1659
- }
1660
- function startupMutationBlocked(session) {
1661
- return session.status === "stopping" || session.stopRequestedAt !== void 0;
1662
- }
1663
- async function updateSessionRemoteNodePid(sessionId, remoteNodePid) {
1664
- resolveNodeTarget({ nodePid: remoteNodePid });
1665
- return await withFileLock(stateLockPath(), async () => {
1666
- const raw = await readStateRaw();
1667
- let updated;
1668
- const nextSessions = raw.sessions.map((session) => {
1669
- if (session.sessionId !== sessionId) {
1670
- return session;
1671
- }
1672
- if (startupMutationBlocked(session)) {
1673
- updated = session;
1674
- return session;
1675
- }
1676
- const next = { ...session, remoteNodePid };
1677
- updated = next;
1678
- return next;
1679
- });
1680
- if (updated !== void 0) {
1681
- await writeState({ version: "2", sessions: nextSessions });
1682
- }
1683
- return updated;
1684
- });
1685
- }
1686
- async function removeSession(sessionId) {
1687
- return await withFileLock(stateLockPath(), async () => {
1688
- const raw = await readStateRaw();
1689
- const target = raw.sessions.find((session) => session.sessionId === sessionId);
1690
- if (!target) {
1691
- return void 0;
1692
- }
1693
- const remaining = raw.sessions.filter((session) => session.sessionId !== sessionId);
1694
- await writeState({ version: "2", sessions: remaining });
1695
- return target;
1696
- });
1697
- }
1698
- async function requestSessionStop(sessionId) {
1699
- return await withFileLock(stateLockPath(), async () => {
1700
- const raw = await readStateRaw();
1701
- const target = raw.sessions.find((session) => session.sessionId === sessionId);
1702
- if (target === void 0) {
1703
- return void 0;
1704
- }
1705
- if (target.status === "ready" || target.stopRequestedAt !== void 0) {
1706
- return { session: target, previousStatus: target.status };
1707
- }
1708
- const requested = { ...target, stopRequestedAt: (/* @__PURE__ */ new Date()).toISOString() };
1709
- const sessions = raw.sessions.map(
1710
- (session) => session.sessionId === sessionId ? requested : session
1711
- );
1712
- await writeState({ version: "2", sessions });
1713
- return { session: requested, previousStatus: target.status };
1714
- });
1715
- }
1716
-
1717
- // src/debug-session/constants.ts
1718
- var DEFAULT_TUNNEL_READY_TIMEOUT_MS = 18e4;
1719
- var POST_USR1_DELAY_MS = 300;
1720
- var CHILD_SIGTERM_GRACE_MS = 2e3;
1721
- var CHILD_SIGKILL_GRACE_MS = 1e3;
1722
- var PID_TERMINATION_POLL_MS = 100;
1723
-
1724
- // src/debug-session/orphans.ts
1725
- import { rm } from "fs/promises";
1726
- import { hostname as getHostname2 } from "os";
1727
- async function pruneAndCleanupOrphans() {
1728
- const result = await readAndPruneActiveSessions();
1729
- const host = getHostname2();
1730
- for (const removed of result.removed) {
1731
- if (removed.hostname === host && isOwnedSessionCfHomeDir(removed.sessionId, removed.cfHomeDir)) {
1732
- try {
1733
- await rm(removed.cfHomeDir, { recursive: true, force: true });
1734
- } catch {
1735
- }
1736
- }
1737
- }
1738
- return result;
1739
- }
1740
-
1741
- // src/debug-session/processes.ts
1742
- import process4 from "process";
1743
- async function terminatePidOrGroup(pid, timeoutMs = CHILD_SIGTERM_GRACE_MS, pinnedTarget) {
1744
- const targetKind = pinnedTarget ?? (isProcessGroupAlive(pid) ? "group" : "pid");
1745
- const targetAlive = () => targetKind === "group" ? isProcessGroupAlive(pid) : isPidAlive(pid);
1746
- if (!targetAlive()) {
1747
- return "terminated";
1748
- }
1749
- try {
1750
- process4.kill(targetKind === "group" ? -pid : pid, "SIGTERM");
1751
- } catch {
1752
- }
1753
- const startedAt = Date.now();
1754
- while (Date.now() - startedAt < timeoutMs) {
1755
- if (!targetAlive()) {
1756
- return "terminated";
1757
- }
1758
- await new Promise((resolve) => {
1759
- setTimeout(resolve, PID_TERMINATION_POLL_MS);
1760
- });
1761
- }
1762
- try {
1763
- process4.kill(targetKind === "group" ? -pid : pid, "SIGKILL");
1764
- } catch {
1765
- }
1766
- const forceStartedAt = Date.now();
1767
- while (Date.now() - forceStartedAt < CHILD_SIGKILL_GRACE_MS) {
1768
- if (!targetAlive()) {
1769
- return "terminated";
1770
- }
1771
- await new Promise((resolve) => {
1772
- setTimeout(resolve, PID_TERMINATION_POLL_MS);
1773
- });
1774
- }
1775
- return targetAlive() ? "still-alive" : "terminated";
1776
- }
1777
- async function killProcessGroupOrProc(child, timeoutMs = CHILD_SIGTERM_GRACE_MS) {
1778
- if (child.pid === void 0) {
1779
- return "terminated";
1780
- }
1781
- const childClosed = child.exitCode !== null || child.signalCode !== null;
1782
- if (childClosed && process4.platform === "win32") {
1783
- return "terminated";
1784
- }
1785
- return await terminatePidOrGroup(child.pid, timeoutMs, childClosed ? "group" : void 0);
1786
- }
1787
-
1788
- // src/debug-session/startup-cancellation.ts
1789
- var STOP_REQUEST_POLL_MS = 50;
1790
- function cancellationRequested(sessions, sessionId) {
1791
- const session = sessions.find((candidate) => candidate.sessionId === sessionId);
1792
- return session === void 0 || session.stopRequestedAt !== void 0;
1793
- }
1794
- function createStartupCancellation(sessionId, callerSignal) {
1795
- const controller = new AbortController();
1796
- let active = true;
1797
- let timer;
1798
- const onCallerAbort = () => {
1799
- controller.abort();
1800
- };
1801
- const schedule = () => {
1802
- timer = setTimeout(() => {
1803
- void poll();
1804
- }, STOP_REQUEST_POLL_MS);
1805
- timer.unref();
1806
- };
1807
- const poll = async () => {
1808
- try {
1809
- const sessions = await readSessionSnapshot();
1810
- if (active && cancellationRequested(sessions, sessionId)) {
1811
- controller.abort();
1812
- }
1813
- } catch {
1814
- }
1815
- if (active && !controller.signal.aborted) {
1816
- schedule();
1817
- }
1818
- };
1819
- callerSignal?.addEventListener("abort", onCallerAbort, { once: true });
1820
- if (callerSignal?.aborted === true) {
1821
- controller.abort();
1822
- } else {
1823
- void poll();
1824
- }
1825
- return {
1826
- signal: controller.signal,
1827
- dispose: () => {
1828
- active = false;
1829
- clearTimeout(timer);
1830
- callerSignal?.removeEventListener("abort", onCallerAbort);
1831
- }
1832
- };
1833
- }
1834
-
1835
- // src/debug-session/start.ts
1836
- function signalFailureDetail(result) {
1837
- if (result.timedOutAfterMs !== void 0) {
1838
- return `timed out after ${(result.timedOutAfterMs / 1e3).toString()}s`;
1839
- }
1840
- const stderr = result.stderr.trim();
1841
- if (stderr.length > 0) {
1842
- return stderr;
1843
- }
1844
- if (result.signal !== void 0) {
1845
- return `terminated by signal ${result.signal}`;
1846
- }
1847
- return `exit code ${String(result.exitCode)}`;
1848
- }
1849
- function checkAbort(signal) {
1850
- if (signal?.aborted) {
1851
- throw new CfDebuggerError("ABORTED", "Operation aborted by caller");
1852
- }
1853
- }
1854
- function requireStartupState(state, expectedStatus) {
1855
- if (state === void 0) {
1856
- throw new CfDebuggerError(
1857
- "SESSION_STATE_LOST",
1858
- "Debugger session ownership state disappeared during startup."
1859
- );
1860
- }
1861
- if (state.stopRequestedAt !== void 0 || state.status === "stopping") {
1862
- throw new CfDebuggerError("ABORTED", "Debugger session stop was requested during startup.");
28
+ // package.json
29
+ var package_default = {
30
+ name: "@saptools/cf-debugger",
31
+ version: "0.2.1",
32
+ description: "Open an SSH debug tunnel to a SAP BTP Cloud Foundry app's Node.js inspector \u2014 from any terminal, no IDE required.",
33
+ type: "module",
34
+ publishConfig: {
35
+ access: "public",
36
+ registry: "https://registry.npmjs.org/"
37
+ },
38
+ bin: {
39
+ "cf-debugger": "dist/cli.js"
40
+ },
41
+ main: "./dist/index.js",
42
+ types: "./dist/index.d.ts",
43
+ exports: {
44
+ ".": {
45
+ types: "./dist/index.d.ts",
46
+ import: "./dist/index.js"
47
+ }
48
+ },
49
+ files: [
50
+ "dist"
51
+ ],
52
+ engines: {
53
+ node: ">=20.0.0"
54
+ },
55
+ scripts: {
56
+ build: "tsup",
57
+ typecheck: "tsc --noEmit",
58
+ lint: "eslint src tests",
59
+ "test:unit": "vitest run --coverage",
60
+ "test:e2e": "playwright test"
61
+ },
62
+ keywords: [
63
+ "sap",
64
+ "cloud-foundry",
65
+ "btp",
66
+ "debug",
67
+ "debugger",
68
+ "ssh-tunnel",
69
+ "node-inspector",
70
+ "cli",
71
+ "saptools"
72
+ ],
73
+ author: "Dong Tran",
74
+ license: "MIT",
75
+ repository: {
76
+ type: "git",
77
+ url: "git+https://github.com/dongitran/saptools.git",
78
+ directory: "packages/cf-debugger"
79
+ },
80
+ homepage: "https://github.com/dongitran/saptools/tree/main/packages/cf-debugger#readme",
81
+ bugs: {
82
+ url: "https://github.com/dongitran/saptools/issues"
83
+ },
84
+ dependencies: {
85
+ commander: "^13.0.0"
86
+ },
87
+ devDependencies: {
88
+ "@playwright/test": "^1.50.0",
89
+ "@vitest/coverage-v8": "^3.0.0",
90
+ tsup: "^8.3.0",
91
+ vitest: "^3.0.0"
1863
92
  }
1864
- if (expectedStatus !== void 0 && state.status !== expectedStatus) {
1865
- throw new CfDebuggerError(
1866
- "SESSION_STATE_CONFLICT",
1867
- `Debugger session state did not transition to ${expectedStatus}.`
1868
- );
1869
- }
1870
- return state;
1871
- }
1872
- async function transitionStartupStatus(sessionId, status, message) {
1873
- return requireStartupState(await updateSessionStatus(sessionId, status, message), status);
1874
- }
1875
- function requireCredentials(options) {
1876
- const email = options.email ?? process5.env["SAP_EMAIL"];
1877
- const password = options.password ?? process5.env["SAP_PASSWORD"];
1878
- if (email === void 0 || email === "") {
1879
- throw new CfDebuggerError(
1880
- "MISSING_CREDENTIALS",
1881
- "SAP email is required. Pass `email` or set SAP_EMAIL env var."
1882
- );
1883
- }
1884
- if (password === void 0 || password === "") {
1885
- throw new CfDebuggerError(
1886
- "MISSING_CREDENTIALS",
1887
- "SAP password is required. Pass `password` or set SAP_PASSWORD env var."
1888
- );
1889
- }
1890
- return { email, password };
1891
- }
1892
- async function registerSession(options, target, apiEndpoint) {
1893
- const registration = await registerNewSession({
1894
- region: options.region,
1895
- org: options.org,
1896
- space: options.space,
1897
- app: options.app,
1898
- process: target.process,
1899
- instance: target.instance,
1900
- ...target.nodePid === void 0 ? {} : { nodePid: target.nodePid },
1901
- apiEndpoint,
1902
- ...options.preferredPort === void 0 ? {} : { preferredPort: options.preferredPort },
1903
- portProbe: isPortFree,
1904
- cfHomeForSession: sessionCfHomeDir
1905
- });
1906
- if (registration.existing) {
1907
- throw new CfDebuggerError(
1908
- "SESSION_ALREADY_RUNNING",
1909
- `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\`.`
1910
- );
1911
- }
1912
- return registration.session;
1913
- }
1914
- async function loginAndTarget(options, apiEndpoint, email, password, context, sessionId, emit) {
1915
- emit("logging-in");
1916
- await transitionStartupStatus(sessionId, "logging-in");
1917
- await cfLogin(apiEndpoint, email, password, context);
1918
- checkAbort(context.signal);
1919
- emit("targeting");
1920
- await transitionStartupStatus(sessionId, "targeting");
1921
- await cfTarget(options.org, options.space, context);
1922
- checkAbort(context.signal);
1923
- }
1924
- async function signalRemoteNode(options, target, context, sessionId, emit) {
1925
- emit("signaling");
1926
- await transitionStartupStatus(sessionId, "signaling");
1927
- const signalResult = await executeRemoteSignal(options.app, target, context);
1928
- if (!isSshDisabledError(signalResult.stderr)) {
1929
- return parseSignalResult(options.app, signalResult);
1930
- }
1931
- if (options.allowSshEnableRestart === false) {
1932
- throw new CfDebuggerError(
1933
- "SSH_NOT_ENABLED",
1934
- `SSH is disabled for ${options.app}; automatic SSH enable and app restart are not allowed.`,
1935
- signalResult.stderr
1936
- );
1937
- }
1938
- await enableSshAndRestart(options, target, context, sessionId, emit);
1939
- return await retryRemoteSignal(options, target, context, sessionId, emit);
1940
- }
1941
- async function enableSshAndRestart(options, target, context, sessionId, emit) {
1942
- if (target.nodePid !== void 0) {
1943
- throw new CfDebuggerError(
1944
- "NODE_PID_RESTART_UNSAFE",
1945
- `Cannot automatically restart ${options.app} while targeting remote Node PID ${target.nodePid.toString()}. Enable SSH and restart the app first, then retry with its new PID.`
1946
- );
1947
- }
1948
- const alreadyEnabled = await cfSshEnabled(options.app, context);
1949
- if (!alreadyEnabled) {
1950
- emit("ssh-enabling", "Enabling SSH on the app");
1951
- await transitionStartupStatus(sessionId, "ssh-enabling");
1952
- await cfEnableSsh(options.app, context);
1953
- }
1954
- emit("ssh-restarting", "Restarting app so SSH becomes active");
1955
- await transitionStartupStatus(sessionId, "ssh-restarting");
1956
- await cfRestartApp(options.app, context);
1957
- checkAbort(context.signal);
1958
- }
1959
- async function retryRemoteSignal(options, target, context, sessionId, emit) {
1960
- emit("signaling");
1961
- await transitionStartupStatus(sessionId, "signaling");
1962
- const retrySignalResult = await executeRemoteSignal(options.app, target, context);
1963
- if (retrySignalResult.exitCode === 0) {
1964
- return parseSignalResult(options.app, retrySignalResult);
1965
- }
1966
- throw new CfDebuggerError(
1967
- "USR1_SIGNAL_FAILED",
1968
- `Failed to send SIGUSR1 to the Node.js process on ${options.app} after enabling SSH: ${signalFailureDetail(retrySignalResult)}`,
1969
- retrySignalResult.stderr
1970
- );
1971
- }
1972
- async function executeRemoteSignal(appName, target, context) {
1973
- return await cfSshOneShot(appName, buildNodeInspectorCommand(target.nodePid), context, {
1974
- process: target.process,
1975
- instance: target.instance
1976
- });
1977
- }
1978
- function parseSignalResult(appName, result) {
1979
- if (result.exitCode !== 0) {
1980
- throw new CfDebuggerError(
1981
- "USR1_SIGNAL_FAILED",
1982
- `Failed to send SIGUSR1 to the Node.js process on ${appName}: ${signalFailureDetail(result)}`,
1983
- result.stderr
1984
- );
1985
- }
1986
- if (result.outputTruncated) {
1987
- throw new CfDebuggerError(
1988
- "INSPECTOR_OUTPUT_TOO_LARGE",
1989
- "Inspector startup output exceeded the configured capture limit."
1990
- );
1991
- }
1992
- return parseNodeInspectorMarkers(result.stdout).remoteNodePid;
1993
- }
1994
- async function waitAfterSignal(signal) {
1995
- if (signal?.aborted) {
1996
- throw new CfDebuggerError("ABORTED", "Operation aborted by caller");
1997
- }
1998
- await new Promise((resolve, reject) => {
1999
- const onAbort = () => {
2000
- clearTimeout(timer);
2001
- reject(new CfDebuggerError("ABORTED", "Operation aborted by caller"));
2002
- };
2003
- const timer = setTimeout(() => {
2004
- signal?.removeEventListener("abort", onAbort);
2005
- resolve();
2006
- }, POST_USR1_DELAY_MS);
2007
- signal?.addEventListener("abort", onAbort, { once: true });
2008
- });
2009
- }
2010
- async function ensurePortAvailable(localPort) {
2011
- if (!await isPortFree(localPort)) {
2012
- throw new CfDebuggerError(
2013
- "PORT_UNAVAILABLE",
2014
- `Local port ${localPort.toString()} was taken before the tunnel could start.`
2015
- );
2016
- }
2017
- }
2018
- async function openReadyTunnel(options, target, session, context, tunnelReadyTimeoutMs, onChild) {
2019
- await ensurePortAvailable(session.localPort);
2020
- checkAbort(context.signal);
2021
- const child = spawnSshTunnel(options.app, session.localPort, session.remotePort, context, {
2022
- process: target.process,
2023
- instance: target.instance
2024
- });
2025
- onChild(child);
2026
- const childPid = child.pid;
2027
- if (childPid === void 0) {
2028
- throw new CfDebuggerError("TUNNEL_PROCESS_MISSING", "The CF SSH tunnel process did not expose a PID.");
2029
- }
2030
- const pidState = requireStartupState(await updateSessionPid(session.sessionId, childPid));
2031
- if (pidState.tunnelPid !== childPid || pidState.pid !== childPid) {
2032
- throw new CfDebuggerError(
2033
- "SESSION_STATE_CONFLICT",
2034
- "Debugger session did not retain ownership of the spawned tunnel process."
2035
- );
2036
- }
2037
- const ready = await probeTunnelReady(
2038
- session.localPort,
2039
- tunnelReadyTimeoutMs,
2040
- context.signal
2041
- );
2042
- checkAbort(context.signal);
2043
- if (!ready) {
2044
- throw new CfDebuggerError(
2045
- "TUNNEL_NOT_READY",
2046
- `SSH tunnel on port ${session.localPort.toString()} did not become ready within ${Math.round(tunnelReadyTimeoutMs / 1e3).toString()}s.`
2047
- );
2048
- }
2049
- const listeningPid = await findListeningProcessId(session.localPort);
2050
- if (listeningPid === void 0) {
2051
- throw new CfDebuggerError(
2052
- "TUNNEL_OWNER_UNVERIFIED",
2053
- `Could not verify the owner of local tunnel port ${session.localPort.toString()}.`
2054
- );
2055
- }
2056
- if (listeningPid !== childPid) {
2057
- throw new CfDebuggerError(
2058
- "TUNNEL_OWNER_MISMATCH",
2059
- `Local tunnel port ${session.localPort.toString()} is owned by an unexpected process.`
2060
- );
2061
- }
2062
- }
2063
- function attachTunnelEvents(child, resolveExit, emit) {
2064
- child.on("close", (code) => {
2065
- resolveExit(code);
2066
- });
2067
- child.on("error", (err) => {
2068
- emit("error", err.message);
2069
- });
2070
- }
2071
- function createHandle(session, emit, finalize, exitPromise) {
2072
- let disposePromise;
2073
- return {
2074
- session,
2075
- dispose: async () => {
2076
- const attempt = disposePromise ?? (async () => {
2077
- await runCleanupActions([
2078
- () => {
2079
- emit("stopping");
2080
- },
2081
- async () => {
2082
- await updateSessionStatus(session.sessionId, "stopping");
2083
- },
2084
- finalize
2085
- ], "Debugger disposal failed");
2086
- })();
2087
- disposePromise = attempt;
2088
- try {
2089
- await attempt;
2090
- } catch (error) {
2091
- if (disposePromise === attempt) {
2092
- disposePromise = void 0;
2093
- }
2094
- throw error;
2095
- }
2096
- },
2097
- waitForExit: async () => {
2098
- return await exitPromise;
2099
- }
2100
- };
2101
- }
2102
- async function runCleanupActions(actions, aggregateMessage) {
2103
- let errors = [];
2104
- for (const action of actions) {
2105
- try {
2106
- await action();
2107
- } catch (error) {
2108
- errors = [...errors, error];
2109
- }
2110
- }
2111
- if (errors.length === 1) {
2112
- throw errors[0];
2113
- }
2114
- if (errors.length > 1) {
2115
- throw new AggregateError(errors, aggregateMessage);
2116
- }
2117
- }
2118
- async function prepareCfHome(cfHomeDir) {
2119
- await mkdir3(cfHomeDir, { recursive: true, mode: 448 });
2120
- await chmod3(cfHomeDir, 448);
2121
- }
2122
- function createTunnelLifecycle(session, emit) {
2123
- let child;
2124
- let exitResolve = (_code) => {
2125
- throw new Error("Exit resolver was used before initialization");
2126
- };
2127
- const exitPromise = new Promise((resolve) => {
2128
- exitResolve = resolve;
2129
- });
2130
- const observeChild = (tunnelChild) => {
2131
- child = tunnelChild;
2132
- attachTunnelEvents(tunnelChild, exitResolve, emit);
2133
- };
2134
- const finalize = async () => {
2135
- const termination = child === void 0 ? "terminated" : await killProcessGroupOrProc(child);
2136
- const portListening = child !== void 0 && await isPortListening(session.localPort);
2137
- if (termination === "still-alive" || portListening) {
2138
- throw new CfDebuggerError(
2139
- "TUNNEL_TERMINATION_FAILED",
2140
- `Tunnel for session ${session.sessionId} did not terminate; state and CF home were retained.`
2141
- );
2142
- }
2143
- await runCleanupActions([
2144
- async () => {
2145
- await removeSession(session.sessionId);
2146
- },
2147
- async () => {
2148
- await cleanupFilesystem(session.cfHomeDir);
2149
- }
2150
- ], "Debugger resource cleanup failed");
2151
- emit("stopped");
2152
- };
2153
- return { exitPromise, finalize, observeChild };
2154
- }
2155
- async function establishDebuggerSession(inputs) {
2156
- const { options, target, session, context, credentials, timeoutMs, lifecycle, emit } = inputs;
2157
- await prepareCfHome(session.cfHomeDir);
2158
- await loginAndTarget(
2159
- options,
2160
- session.apiEndpoint,
2161
- credentials.email,
2162
- credentials.password,
2163
- context,
2164
- session.sessionId,
2165
- emit
2166
- );
2167
- await ensurePortAvailable(session.localPort);
2168
- const remoteNodePid = await signalRemoteNode(options, target, context, session.sessionId, emit);
2169
- const remoteState = requireStartupState(
2170
- await updateSessionRemoteNodePid(session.sessionId, remoteNodePid)
2171
- );
2172
- if (remoteState.remoteNodePid !== remoteNodePid) {
2173
- throw new CfDebuggerError(
2174
- "SESSION_STATE_CONFLICT",
2175
- "Debugger session did not retain the selected remote Node PID."
2176
- );
2177
- }
2178
- await waitAfterSignal(context.signal);
2179
- emit("tunneling");
2180
- await transitionStartupStatus(session.sessionId, "tunneling");
2181
- await openReadyTunnel(
2182
- options,
2183
- target,
2184
- session,
2185
- context,
2186
- timeoutMs,
2187
- lifecycle.observeChild
2188
- );
2189
- emit("ready");
2190
- return await transitionStartupStatus(session.sessionId, "ready");
2191
- }
2192
- async function failAfterStartupCleanup(error, finalize, emit) {
2193
- try {
2194
- await runCleanupActions([
2195
- () => {
2196
- emit("error", error instanceof Error ? error.message : String(error));
2197
- },
2198
- finalize
2199
- ], "Debugger startup failure reporting and cleanup failed");
2200
- } catch (cleanupError) {
2201
- throw new AggregateError(
2202
- [error, cleanupError],
2203
- "Debugger startup failed and resource cleanup was incomplete",
2204
- { cause: cleanupError }
2205
- );
2206
- }
2207
- throw error;
2208
- }
2209
- async function startDebugger(options) {
2210
- const target = resolveNodeTarget(options);
2211
- const credentials = requireCredentials(options);
2212
- const apiEndpoint = resolveApiEndpoint(options.region, options.apiEndpoint);
2213
- const tunnelReadyTimeoutMs = options.tunnelReadyTimeoutMs ?? DEFAULT_TUNNEL_READY_TIMEOUT_MS;
2214
- const emit = (status, message) => {
2215
- options.onStatus?.(status, message);
2216
- };
2217
- checkAbort(options.signal);
2218
- await pruneAndCleanupOrphans();
2219
- const session = await registerSession(options, target, apiEndpoint);
2220
- const cancellation = createStartupCancellation(session.sessionId, options.signal);
2221
- const context = {
2222
- cfHome: session.cfHomeDir,
2223
- signal: cancellation.signal
2224
- };
2225
- const lifecycle = createTunnelLifecycle(session, emit);
2226
- try {
2227
- const activeSession = await establishDebuggerSession({
2228
- options,
2229
- target,
2230
- session,
2231
- context,
2232
- credentials,
2233
- timeoutMs: tunnelReadyTimeoutMs,
2234
- lifecycle,
2235
- emit
2236
- });
2237
- cancellation.dispose();
2238
- return createHandle(activeSession, emit, lifecycle.finalize, lifecycle.exitPromise);
2239
- } catch (err) {
2240
- cancellation.dispose();
2241
- return await failAfterStartupCleanup(err, lifecycle.finalize, emit);
2242
- }
2243
- }
2244
- async function cleanupFilesystem(cfHomeDir) {
2245
- await rm2(cfHomeDir, { recursive: true, force: true });
2246
- }
2247
-
2248
- // src/debug-session/sessions.ts
2249
- import { rm as rm3 } from "fs/promises";
2250
- import { hostname as getHostname3 } from "os";
2251
- import process6 from "process";
2252
- function findMatchingSession(sessions, options) {
2253
- if (options.sessionId !== void 0) {
2254
- return sessions.find((s) => s.sessionId === options.sessionId);
2255
- }
2256
- if (options.key !== void 0) {
2257
- const key = options.key;
2258
- const matches = sessions.filter((session) => matchesKey(session, key));
2259
- if (matches.length > 1) {
2260
- throw new CfDebuggerError(
2261
- "SESSION_AMBIGUOUS",
2262
- "Multiple debugger sessions match this target. Pass an exact session ID, API endpoint, or Node PID."
2263
- );
2264
- }
2265
- return matches[0];
2266
- }
2267
- return void 0;
2268
- }
2269
- async function ownsRecordedTunnel(target) {
2270
- if (target.tunnelPid === void 0 || !await isPortListening(target.localPort)) {
2271
- return false;
2272
- }
2273
- return await findListeningProcessId(target.localPort) === target.tunnelPid;
2274
- }
2275
- async function terminateVerifiedTunnel(target) {
2276
- if (target.tunnelPid !== void 0 && target.tunnelPid !== process6.pid) {
2277
- try {
2278
- return await terminatePidOrGroup(target.tunnelPid);
2279
- } catch {
2280
- return "still-alive";
2281
- }
2282
- }
2283
- return target.tunnelPid === process6.pid ? "still-alive" : "terminated";
2284
- }
2285
- async function terminateVerifiedTunnelAndConfirm(target) {
2286
- const termination = await terminateVerifiedTunnel(target);
2287
- if (termination === "still-alive" || await ownsRecordedTunnel(target)) {
2288
- throw new CfDebuggerError(
2289
- "TUNNEL_TERMINATION_FAILED",
2290
- `Tunnel process for session ${target.sessionId} did not terminate; state was retained.`
2291
- );
2292
- }
2293
- }
2294
- async function cleanupOwnedCfHome(target, locallyOwned) {
2295
- if (locallyOwned && isOwnedSessionCfHomeDir(target.sessionId, target.cfHomeDir)) {
2296
- try {
2297
- await rm3(target.cfHomeDir, { recursive: true, force: true });
2298
- } catch {
2299
- }
2300
- }
2301
- }
2302
- async function removeOwnedSession(target, stale) {
2303
- const removed = await removeSession(target.sessionId);
2304
- await cleanupOwnedCfHome(target, true);
2305
- return { ...removed ?? target, stale, pending: false };
2306
- }
2307
- function ownershipError(target) {
2308
- return new CfDebuggerError(
2309
- "TUNNEL_OWNERSHIP_UNVERIFIED",
2310
- `Cannot safely stop session ${target.sessionId}: local tunnel ownership could not be verified.`
2311
- );
2312
- }
2313
- async function stopReadySession(target) {
2314
- if (await ownsRecordedTunnel(target)) {
2315
- await terminateVerifiedTunnelAndConfirm(target);
2316
- return await removeOwnedSession(target, false);
2317
- }
2318
- const tunnelDead = target.tunnelPid !== void 0 && !isPidOrGroupAlive(target.tunnelPid);
2319
- if (tunnelDead && !await isPortListening(target.localPort)) {
2320
- return await removeOwnedSession(target, true);
2321
- }
2322
- throw ownershipError(target);
2323
- }
2324
- async function stopStartingSession(target) {
2325
- if (target.status === "stopping" && target.tunnelPid !== void 0 && !isPidOrGroupAlive(target.tunnelPid) && !await isPortListening(target.localPort)) {
2326
- return await removeOwnedSession(target, true);
2327
- }
2328
- if (isPidAlive(target.controllerPid ?? target.pid)) {
2329
- return { ...target, stale: false, pending: true };
2330
- }
2331
- if (await ownsRecordedTunnel(target)) {
2332
- await terminateVerifiedTunnelAndConfirm(target);
2333
- return await removeOwnedSession(target, false);
2334
- }
2335
- const tunnelAlive = target.tunnelPid !== void 0 && isPidOrGroupAlive(target.tunnelPid);
2336
- if (!tunnelAlive && !await isPortListening(target.localPort)) {
2337
- return await removeOwnedSession(target, true);
2338
- }
2339
- throw ownershipError(target);
2340
- }
2341
- async function stopDebugger(options) {
2342
- const localSessions = (await readSessionSnapshot()).filter(
2343
- (session) => session.hostname === getHostname3()
2344
- );
2345
- const target = findMatchingSession(localSessions, options);
2346
- if (target === void 0) {
2347
- return void 0;
2348
- }
2349
- const claim = await requestSessionStop(target.sessionId);
2350
- if (claim === void 0) {
2351
- return void 0;
2352
- }
2353
- return claim.previousStatus === "ready" ? await stopReadySession(claim.session) : await stopStartingSession(claim.session);
2354
- }
2355
- async function stopAllDebuggers() {
2356
- const sessions = (await readSessionSnapshot()).filter(
2357
- (session) => session.hostname === getHostname3()
2358
- );
2359
- let stopped = 0;
2360
- for (const session of sessions) {
2361
- const result = await stopDebugger({ sessionId: session.sessionId });
2362
- if (result) {
2363
- stopped += 1;
2364
- }
2365
- }
2366
- return stopped;
2367
- }
2368
- async function listSessions() {
2369
- return await readActiveSessions();
2370
- }
2371
- async function getSession(key) {
2372
- const sessions = await readActiveSessions();
2373
- return findMatchingSession(sessions, { key });
2374
- }
93
+ };
2375
94
 
2376
95
  // src/cli.ts
2377
96
  function readRequiredOption(value, flag) {
2378
- if (value === void 0 || value === "") {
2379
- process7.stderr.write(`Missing required option ${flag}
2380
- `);
2381
- process7.exit(1);
97
+ if (value === void 0 || value.trim().length === 0) {
98
+ throw new CfDebuggerError("UNSAFE_INPUT", `Missing required option ${flag}.`);
2382
99
  }
2383
100
  return value;
2384
101
  }
2385
- function parseOptionalPort(raw) {
2386
- if (raw === void 0) {
2387
- return void 0;
2388
- }
2389
- const port = Number.parseInt(raw, 10);
2390
- if (Number.isNaN(port) || port <= 0 || port > 65535) {
2391
- process7.stderr.write(`Invalid port: ${raw}
2392
- `);
2393
- process7.exit(1);
2394
- }
2395
- return port;
2396
- }
2397
- function parseOptionalTimeout(raw) {
2398
- if (raw === void 0) {
2399
- return void 0;
2400
- }
2401
- const seconds = Number.parseInt(raw, 10);
2402
- if (Number.isNaN(seconds) || seconds <= 0) {
2403
- process7.stderr.write(`Invalid timeout: ${raw}
2404
- `);
2405
- process7.exit(1);
2406
- }
2407
- return seconds * 1e3;
2408
- }
2409
- function parseOptionalInteger(raw, label, minimum) {
102
+ function parseOptionalInteger(raw, label, minimum, maximum = Number.MAX_SAFE_INTEGER) {
2410
103
  if (raw === void 0) {
2411
104
  return void 0;
2412
105
  }
@@ -2414,59 +107,84 @@ function parseOptionalInteger(raw, label, minimum) {
2414
107
  throw new CfDebuggerError("UNSAFE_INPUT", `${label} must be an integer.`);
2415
108
  }
2416
109
  const value = Number(raw);
2417
- if (!Number.isSafeInteger(value) || value < minimum) {
110
+ if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
2418
111
  throw new CfDebuggerError(
2419
112
  "UNSAFE_INPUT",
2420
- `${label} must be at least ${minimum.toString()} and within the safe integer range.`
113
+ `${label} must be from ${minimum.toString()} to ${maximum.toString()}.`
2421
114
  );
2422
115
  }
2423
116
  return value;
2424
117
  }
118
+ function parseSeconds(raw, label) {
119
+ const seconds = parseOptionalInteger(raw, label, 1);
120
+ return seconds === void 0 ? void 0 : seconds * 1e3;
121
+ }
122
+ var STOP_SCOPE_OPTION_NAMES = [
123
+ "apiEndpoint",
124
+ "app",
125
+ "instance",
126
+ "nodePid",
127
+ "org",
128
+ "process",
129
+ "region",
130
+ "sessionId",
131
+ "space"
132
+ ];
2425
133
  function logStatus(verbose, status, message) {
2426
- if (verbose) {
2427
- const suffix = message === void 0 ? "" : `: ${message}`;
2428
- process7.stdout.write(`[cf-debugger] ${status}${suffix}
2429
- `);
134
+ if (!verbose) {
135
+ return;
2430
136
  }
137
+ const suffix = message === void 0 ? "" : `: ${message}`;
138
+ nodeProcess.stdout.write(`[cf-debugger] ${status}${suffix}
139
+ `);
2431
140
  }
2432
- function mergeSelector(selector, opts) {
141
+ function mergeSelector(selector, options) {
2433
142
  if (selector === void 0) {
2434
- return opts;
143
+ return options;
2435
144
  }
2436
145
  const parts = selector.split("/");
2437
- if (parts.length === 4) {
2438
- return { ...opts, region: opts.region ?? parts[0], org: opts.org ?? parts[1], space: opts.space ?? parts[2], app: opts.app ?? parts[3] };
146
+ if (parts.length === 1 && parts[0]?.length !== 0) {
147
+ return { ...options, app: options.app ?? parts[0] };
2439
148
  }
2440
- if (parts.length === 1) {
2441
- return { ...opts, app: opts.app ?? parts[0] };
149
+ if (parts.length === 4 && parts.every((part) => part.length > 0)) {
150
+ return {
151
+ ...options,
152
+ region: options.region ?? parts[0],
153
+ org: options.org ?? parts[1],
154
+ space: options.space ?? parts[2],
155
+ app: options.app ?? parts[3]
156
+ };
2442
157
  }
2443
- throw new CfDebuggerError("UNSAFE_INPUT", "Invalid app selector format. Expected <app> or <region>/<org>/<space>/<app>.");
158
+ throw new CfDebuggerError(
159
+ "UNSAFE_INPUT",
160
+ "Invalid app selector. Expected <app> or non-empty <region>/<org>/<space>/<app> segments."
161
+ );
2444
162
  }
2445
163
  function startupAbort(app) {
2446
164
  const controller = new AbortController();
165
+ let requestedExitCode;
2447
166
  const handler = (exitCode) => () => {
167
+ requestedExitCode ??= exitCode;
2448
168
  controller.abort();
2449
- process7.stderr.write(`
169
+ nodeProcess.stderr.write(`
2450
170
  Aborting startup for ${app}...
2451
171
  `);
2452
- setTimeout(() => {
2453
- process7.exit(exitCode);
2454
- }, 5e3).unref();
2455
172
  };
2456
173
  const onSigint = handler(130);
2457
174
  const onSigterm = handler(143);
2458
- process7.on("SIGINT", onSigint);
2459
- process7.on("SIGTERM", onSigterm);
175
+ nodeProcess.on("SIGINT", onSigint);
176
+ nodeProcess.on("SIGTERM", onSigterm);
2460
177
  return {
2461
178
  signal: controller.signal,
179
+ requestedExitCode: () => requestedExitCode,
2462
180
  dispose: () => {
2463
- process7.off("SIGINT", onSigint);
2464
- process7.off("SIGTERM", onSigterm);
181
+ nodeProcess.off("SIGINT", onSigint);
182
+ nodeProcess.off("SIGTERM", onSigterm);
2465
183
  }
2466
184
  };
2467
185
  }
2468
186
  function writeReady(app, key, handle) {
2469
- process7.stdout.write(
187
+ nodeProcess.stdout.write(
2470
188
  `Debugger ready for ${app} (${key.region}/${key.org}/${key.space}).
2471
189
  Process: ${key.process}
2472
190
  Instance: ${key.instance.toString()}
@@ -2479,91 +197,160 @@ Press Ctrl+C to stop.
2479
197
  `
2480
198
  );
2481
199
  }
2482
- function handleDisposer(app, handle) {
2483
- let pending;
2484
- return async () => {
2485
- pending ??= (async () => {
2486
- process7.stdout.write(`
2487
- Stopping debugger for ${app}...
200
+ function writeCleanupError(error) {
201
+ const message = error instanceof Error ? error.message : String(error);
202
+ nodeProcess.stderr.write(`Error during stop: ${message}
2488
203
  `);
2489
- try {
2490
- await handle.dispose();
2491
- } catch (error) {
2492
- const message = error instanceof Error ? error.message : String(error);
2493
- process7.stderr.write(`Error during stop: ${message}
204
+ }
205
+ async function waitForHandle(app, handle) {
206
+ let pendingDispose;
207
+ let requestedExitCode;
208
+ const dispose = () => {
209
+ pendingDispose ??= (async () => {
210
+ nodeProcess.stdout.write(`
211
+ Stopping debugger for ${app}...
2494
212
  `);
2495
- }
213
+ await handle.dispose();
2496
214
  })();
2497
- await pending;
215
+ return pendingDispose;
2498
216
  };
2499
- }
2500
- async function waitForHandle(app, handle) {
2501
- const dispose = handleDisposer(app, handle);
2502
217
  const stop = (exitCode) => () => {
2503
- void dispose().then(() => {
2504
- process7.exit(exitCode);
218
+ requestedExitCode = exitCode;
219
+ void dispose().catch((error) => {
220
+ writeCleanupError(error);
221
+ nodeProcess.exit(CLEANUP_FAILURE_EXIT_CODE);
2505
222
  });
2506
223
  };
2507
- process7.on("SIGINT", stop(130));
2508
- process7.on("SIGTERM", stop(143));
224
+ nodeProcess.on("SIGINT", stop(130));
225
+ nodeProcess.on("SIGTERM", stop(143));
2509
226
  const code = await handle.waitForExit();
2510
- await dispose();
2511
- process7.exit(code ?? 0);
227
+ const tunnelError = getDebuggerHandleTunnelError(handle);
228
+ if (tunnelError !== void 0) {
229
+ nodeProcess.stderr.write(
230
+ `Error [${tunnelError.code}]: ${tunnelError.message}
231
+ `
232
+ );
233
+ if (tunnelError.stderr !== void 0 && tunnelError.stderr.trim().length > 0) {
234
+ nodeProcess.stderr.write(`[cf-debugger transport]
235
+ ${tunnelError.stderr.trim()}
236
+ `);
237
+ }
238
+ }
239
+ try {
240
+ await dispose();
241
+ } catch (error) {
242
+ writeCleanupError(error);
243
+ nodeProcess.exit(CLEANUP_FAILURE_EXIT_CODE);
244
+ }
245
+ nodeProcess.exit(requestedExitCode ?? code ?? 0);
246
+ }
247
+ function parseRestartEnvironment() {
248
+ const value = nodeProcess.env["CF_DEBUGGER_ALLOW_RESTART"];
249
+ if (value === void 0) {
250
+ return "unset";
251
+ }
252
+ if (value === "0") {
253
+ return "forbid";
254
+ }
255
+ if (value === "1") {
256
+ return "allow";
257
+ }
258
+ throw new CfDebuggerError(
259
+ "UNSAFE_INPUT",
260
+ "CF_DEBUGGER_ALLOW_RESTART must be 0 or 1."
261
+ );
262
+ }
263
+ function buildStartOptions(options, key, app, signal, startupTimeoutMs) {
264
+ const preferredPort = parseOptionalInteger(options.port, "port", 1, 65535);
265
+ const remotePort = parseOptionalInteger(options.remotePort, "remotePort", 1, 65535);
266
+ const nodePid = parseOptionalInteger(options.nodePid, "nodePid", 1);
267
+ const tunnelReadyTimeoutMs = parseSeconds(options.timeout, "timeout");
268
+ const restartEnvironment = parseRestartEnvironment();
269
+ const allowSshEnableRestart = options.sshEnableRestart !== false && restartEnvironment !== "forbid" && (options.allowSshEnableRestart === true || restartEnvironment === "allow");
270
+ return {
271
+ region: key.region,
272
+ org: key.org,
273
+ space: key.space,
274
+ app,
275
+ process: key.process,
276
+ instance: key.instance,
277
+ signal,
278
+ allowSshEnableRestart,
279
+ ...options.apiEndpoint === void 0 ? {} : { apiEndpoint: options.apiEndpoint },
280
+ ...preferredPort === void 0 ? {} : { preferredPort },
281
+ ...remotePort === void 0 ? {} : { remotePort },
282
+ ...nodePid === void 0 ? {} : { nodePid },
283
+ ...tunnelReadyTimeoutMs === void 0 ? {} : { tunnelReadyTimeoutMs },
284
+ startupTimeoutMs
285
+ };
2512
286
  }
2513
- async function handleStart(selector, rawOpts) {
2514
- const opts = mergeSelector(selector, rawOpts);
2515
- const app = readRequiredOption(opts.app, "--app or selector");
2516
- const key = await resolveSessionKey({ ...opts, app });
2517
- const verbose = opts.verbose ?? false;
2518
- const preferredPort = parseOptionalPort(opts.port);
2519
- const tunnelReadyTimeoutMs = parseOptionalTimeout(opts.timeout);
2520
- const nodePid = parseOptionalInteger(opts.nodePid, "nodePid", 1);
287
+ function deadlineTimedOut(deadline) {
288
+ return remainingStartupMs(deadline.expiresAt) === 0 || deadline.signal.reason instanceof CfDebuggerError && deadline.signal.reason.code === "STARTUP_TIMEOUT";
289
+ }
290
+ async function handleStart(selector, rawOptions) {
291
+ const options = mergeSelector(selector, rawOptions);
292
+ const app = readRequiredOption(options.app, "--app or selector");
293
+ const verbose = options.verbose ?? false;
294
+ const startupTimeoutMs = resolveStartupTimeoutMs(
295
+ parseSeconds(options.startupTimeout, "startupTimeout")
296
+ );
2521
297
  const abort = startupAbort(app);
298
+ const deadline = createStartupDeadline(startupTimeoutMs, abort.signal);
2522
299
  let handle;
300
+ let phase = "current CF target discovery";
301
+ let key;
2523
302
  try {
2524
- handle = await startDebugger({
2525
- region: key.region,
2526
- org: key.org,
2527
- space: key.space,
2528
- app,
2529
- process: key.process,
2530
- instance: key.instance,
303
+ key = await resolveSessionKey({ ...options, app }, deadline.signal);
304
+ if (deadlineTimedOut(deadline)) {
305
+ throw startupTimeoutError(startupTimeoutMs, phase);
306
+ }
307
+ phase = "startup";
308
+ handle = await startDebuggerWithinDeadline({
309
+ ...buildStartOptions(options, key, app, deadline.signal, startupTimeoutMs),
2531
310
  verbose,
2532
- signal: abort.signal,
2533
- ...preferredPort === void 0 ? {} : { preferredPort },
2534
- ...tunnelReadyTimeoutMs === void 0 ? {} : { tunnelReadyTimeoutMs },
2535
- ...nodePid === void 0 ? {} : { nodePid },
2536
311
  onStatus: (status, message) => {
2537
312
  logStatus(verbose, status, message);
2538
313
  }
2539
- });
314
+ }, deadline);
315
+ } catch (error) {
316
+ const normalized = deadlineTimedOut(deadline) && !(error instanceof CfDebuggerError && error.code === "STARTUP_TIMEOUT") ? startupTimeoutError(startupTimeoutMs, phase) : error;
317
+ const exitCode = abort.requestedExitCode();
318
+ if (exitCode !== void 0 && typeof normalized === "object" && normalized !== null) {
319
+ Reflect.set(normalized, "cliExitCode", exitCode);
320
+ }
321
+ throw normalized;
2540
322
  } finally {
323
+ deadline.dispose();
2541
324
  abort.dispose();
2542
325
  }
2543
326
  writeReady(app, key, handle);
2544
327
  await waitForHandle(app, handle);
2545
328
  }
2546
- function hasText(value) {
2547
- return optionalText(value) !== void 0;
2548
- }
2549
329
  function optionalText(value) {
2550
330
  const trimmed = value?.trim();
2551
331
  return trimmed === void 0 || trimmed.length === 0 ? void 0 : trimmed;
2552
332
  }
2553
- function currentCfOptions() {
2554
- const command = process7.env["CF_DEBUGGER_CF_BIN"];
2555
- return command === void 0 ? void 0 : { command };
333
+ function currentCfOptions(signal) {
334
+ const command = nodeProcess.env["CF_DEBUGGER_CF_BIN"];
335
+ if (command === void 0 && signal === void 0) {
336
+ return void 0;
337
+ }
338
+ return {
339
+ ...command === void 0 ? {} : { command },
340
+ ...signal === void 0 ? {} : { signal }
341
+ };
2556
342
  }
2557
- async function resolveSessionKey(opts) {
2558
- const app = readRequiredOption(opts.app, "--app or selector");
2559
- const region = optionalText(opts.region);
2560
- const org = optionalText(opts.org);
2561
- const space = optionalText(opts.space);
2562
- const processName = opts.process;
2563
- const instance = parseOptionalInteger(opts.instance, "instance", 0);
343
+ async function resolveSessionKey(options, signal) {
344
+ const app = readRequiredOption(options.app, "--app or selector");
345
+ const region = optionalText(options.region);
346
+ const org = optionalText(options.org);
347
+ const space = optionalText(options.space);
348
+ const nodePid = parseOptionalInteger(options.nodePid, "nodePid", 1);
349
+ const instance = parseOptionalInteger(options.instance, "instance", 0);
2564
350
  const target = resolveNodeTarget({
2565
- ...processName === void 0 ? {} : { process: processName },
2566
- ...instance === void 0 ? {} : { instance }
351
+ ...options.process === void 0 ? {} : { process: options.process },
352
+ ...instance === void 0 ? {} : { instance },
353
+ ...nodePid === void 0 ? {} : { nodePid }
2567
354
  });
2568
355
  if (region !== void 0 && org !== void 0 && space !== void 0) {
2569
356
  return {
@@ -2572,20 +359,25 @@ async function resolveSessionKey(opts) {
2572
359
  space,
2573
360
  app,
2574
361
  process: target.process,
2575
- instance: target.instance
362
+ instance: target.instance,
363
+ ...options.apiEndpoint === void 0 ? {} : { apiEndpoint: options.apiEndpoint },
364
+ ...target.nodePid === void 0 ? {} : { nodePid: target.nodePid }
2576
365
  };
2577
366
  }
2578
- const current = await readCurrentCfTarget(currentCfOptions()).catch((error) => {
367
+ const current = await readCurrentCfTarget(currentCfOptions(signal)).catch((error) => {
368
+ if (error instanceof CfDebuggerError && (error.code === "ABORTED" || error.code === "STARTUP_TIMEOUT")) {
369
+ throw error;
370
+ }
2579
371
  throw new CfDebuggerError(
2580
372
  "CF_TARGET_FAILED",
2581
- "No current CF target found. Run `cf target -o <org> -s <space>` or pass --region/--org/--space.",
373
+ "No current CF target. Run `cf target -o <org> -s <space>` or pass --region/--org/--space.",
2582
374
  error instanceof Error ? error.message : String(error)
2583
375
  );
2584
376
  });
2585
377
  if (current === void 0) {
2586
378
  throw new CfDebuggerError(
2587
379
  "CF_TARGET_FAILED",
2588
- "No current CF target found. Run `cf target -o <org> -s <space>` or pass --region/--org/--space."
380
+ "No current CF target. Run `cf target -o <org> -s <space>` or pass --region/--org/--space."
2589
381
  );
2590
382
  }
2591
383
  return {
@@ -2594,109 +386,189 @@ async function resolveSessionKey(opts) {
2594
386
  space: space ?? current.space,
2595
387
  app,
2596
388
  process: target.process,
2597
- instance: target.instance
389
+ instance: target.instance,
390
+ ...options.apiEndpoint === void 0 ? {} : { apiEndpoint: options.apiEndpoint },
391
+ ...target.nodePid === void 0 ? {} : { nodePid: target.nodePid }
2598
392
  };
2599
393
  }
2600
- async function resolveOptionalSessionKey(opts) {
2601
- if (!hasText(opts.app)) {
2602
- if (hasText(opts.region) || hasText(opts.org) || hasText(opts.space)) {
2603
- readRequiredOption(opts.app, "--app");
394
+ async function resolveOptionalSessionKey(options) {
395
+ if (optionalText(options.app) === void 0) {
396
+ if (optionalText(options.region) !== void 0 || optionalText(options.org) !== void 0 || optionalText(options.space) !== void 0) {
397
+ readRequiredOption(options.app, "--app");
2604
398
  }
2605
399
  return void 0;
2606
400
  }
2607
- return await resolveSessionKey(opts);
401
+ return await resolveSessionKey(options);
2608
402
  }
2609
- async function handleStop(selector, rawOpts) {
2610
- const opts = mergeSelector(selector, rawOpts);
2611
- if (opts.all === true) {
2612
- const count = await stopAllDebuggers();
2613
- process7.stdout.write(`Stop requested for ${count.toString()} session(s).
2614
- `);
403
+ function writeStopResult(result) {
404
+ if (result === void 0) {
2615
405
  return;
2616
406
  }
2617
- const key = await resolveOptionalSessionKey(opts);
2618
- const result = await stopDebugger({
2619
- ...opts.sessionId === void 0 ? {} : { sessionId: opts.sessionId },
2620
- ...key === void 0 ? {} : { key }
2621
- });
2622
- if (result === void 0) {
2623
- process7.stderr.write(
2624
- "No matching session found. Use `cf-debugger list` and pass --session-id or region/org/space/app if the current CF target differs.\n"
2625
- );
2626
- process7.exit(1);
407
+ if (result.warning !== void 0) {
408
+ nodeProcess.stderr.write(`[cf-debugger] warning: ${result.warning}
409
+ `);
2627
410
  }
2628
411
  if (result.pending) {
2629
- process7.stdout.write(
2630
- `Stop requested for session ${result.sessionId} (${result.app}, startup phase ${result.status}).
412
+ nodeProcess.stdout.write(
413
+ `Stop requested for session ${result.sessionId} (${result.app}, phase ${result.status}).
2631
414
  `
2632
415
  );
2633
- return;
416
+ } else if (result.stale) {
417
+ nodeProcess.stdout.write(
418
+ `Removed ${result.forced ? "forced " : ""}stale session ${result.sessionId} (${result.app}, port ${result.localPort.toString()}).
419
+ `
420
+ );
421
+ } else {
422
+ nodeProcess.stdout.write(
423
+ `Stopped session ${result.sessionId} (${result.app}, port ${result.localPort.toString()}).
424
+ `
425
+ );
426
+ }
427
+ }
428
+ async function handleStop(selector, rawOptions, command) {
429
+ if (rawOptions.all === true && (selector !== void 0 || STOP_SCOPE_OPTION_NAMES.some((name) => command.getOptionValueSource(name) === "cli"))) {
430
+ throw new CfDebuggerError(
431
+ "UNSAFE_INPUT",
432
+ "--all cannot be combined with a selector, --session-id, or target selector options."
433
+ );
2634
434
  }
2635
- if (result.stale) {
2636
- process7.stdout.write(
2637
- `Removed stale session ${result.sessionId} (${result.app}, port ${result.localPort.toString()}).
435
+ const options = mergeSelector(selector, rawOptions);
436
+ if (options.all === true) {
437
+ const summary = await stopAllDebuggers(options.force === true);
438
+ for (const outcome of summary.outcomes) {
439
+ if (outcome.error === void 0) {
440
+ writeStopResult(outcome.result);
441
+ } else {
442
+ nodeProcess.stderr.write(
443
+ `Failed ${outcome.sessionId} (${outcome.app}): ${outcome.error.message}
444
+ `
445
+ );
446
+ }
447
+ }
448
+ nodeProcess.stdout.write(
449
+ `Stop summary: ${summary.stopped.toString()} stopped, ${summary.stale.toString()} stale, ${summary.pending.toString()} pending, ${summary.failed.toString()} failed.
2638
450
  `
2639
451
  );
452
+ if (summary.failed > 0) {
453
+ nodeProcess.exitCode = 1;
454
+ }
2640
455
  return;
2641
456
  }
2642
- process7.stdout.write(
2643
- `Stopped session ${result.sessionId} (${result.app}, port ${result.localPort.toString()}).
2644
- `
2645
- );
457
+ const key = await resolveOptionalSessionKey(options);
458
+ const result = await stopDebugger({
459
+ ...options.sessionId === void 0 ? {} : { sessionId: options.sessionId },
460
+ ...key === void 0 ? {} : { key },
461
+ force: options.force === true
462
+ });
463
+ if (result === void 0) {
464
+ throw new CfDebuggerError(
465
+ "SESSION_NOT_FOUND",
466
+ "No matching session. Use `cf-debugger list` and pass --session-id or an exact key."
467
+ );
468
+ }
469
+ writeStopResult(result);
2646
470
  }
2647
471
  async function handleList() {
2648
- const sessions = await listSessions();
2649
- process7.stdout.write(`${JSON.stringify(sessions, null, 2)}
472
+ nodeProcess.stdout.write(`${JSON.stringify(await listSessions(), null, 2)}
473
+ `);
474
+ }
475
+ async function handleStatus(selector, rawOptions) {
476
+ const options = mergeSelector(selector, rawOptions);
477
+ const session = options.sessionId === void 0 ? await getSession(await resolveSessionKey(options)) : (await listSessions()).find((candidate) => candidate.sessionId === options.sessionId);
478
+ nodeProcess.stdout.write(`${JSON.stringify(session ?? null, null, 2)}
2650
479
  `);
2651
480
  }
2652
- async function handleStatus(selector, rawOpts) {
2653
- const opts = mergeSelector(selector, rawOpts);
2654
- const session = await getSession(await resolveSessionKey(opts));
2655
- process7.stdout.write(`${JSON.stringify(session ?? null, null, 2)}
481
+ async function handleDoctor(cleanup) {
482
+ const report = await runDoctor({ cleanup });
483
+ nodeProcess.stdout.write(`${JSON.stringify(report, null, 2)}
2656
484
  `);
2657
485
  }
2658
486
  function registerStartCommand(program) {
2659
- program.command("start").description("Open a debug tunnel for one app").argument("[selector]", "Optional app selector: `<app>` or `region/org/space/app`").option("--region <key>", "CF region key (default: current cf target)").option("--org <name>", "CF org name (default: current cf target)").option("--space <name>", "CF space name (default: current cf target)").option("--app <name>", "CF app name").option("--process <name>", "CF process name", "web").option("-i, --instance <index>", "CF process instance index", "0").option("--node-pid <pid>", "Explicit remote Node.js PID").option("--port <number>", "Preferred local port (auto-assigned if omitted)").option("--timeout <seconds>", "Tunnel-ready timeout in seconds (default: 180)").option("--verbose", "Print status transitions", false).action(async (selector, opts) => {
2660
- await handleStart(selector, opts);
2661
- });
487
+ program.command("start").description("Open a debug tunnel for one app").argument("[selector]", "Optional <app> or <region>/<org>/<space>/<app>").option("--region <key>", "CF region key (default: current cf target)").option("--api-endpoint <url>", "Explicit CF API endpoint override").option("--org <name>", "CF org (default: current cf target)").option("--space <name>", "CF space (default: current cf target)").option("--app <name>", "CF app name").option("--process <name>", "CF process name", "web").option("-i, --instance <index>", "CF process instance", "0").option("--node-pid <pid>", "Explicit remote Node.js PID").option("--port <number>", "Preferred local port").option(
488
+ "--remote-port <number>",
489
+ "Remote inspector port",
490
+ DEFAULT_NODE_INSPECTOR_PORT.toString()
491
+ ).option("--timeout <seconds>", "Tunnel/inspector readiness timeout", "180").option("--startup-timeout <seconds>", "Overall startup deadline", "300").option(
492
+ "--allow-ssh-enable-restart",
493
+ "Allow app-level SSH enablement and an app restart (opt-in)"
494
+ ).option(
495
+ "--no-ssh-enable-restart",
496
+ "Forbid app-level SSH enablement and restart, overriding flag/environment"
497
+ ).option("--verbose", "Print status transitions and tunnel diagnostics", false).action(async (selector, options) => {
498
+ await handleStart(selector, options);
499
+ });
500
+ }
501
+ function addKeyOptions(command) {
502
+ return command.option("--region <key>").option("--api-endpoint <url>", "Exact API endpoint selector").option("--org <name>").option("--space <name>").option("--app <name>").option("--process <name>", "CF process name", "web").option("-i, --instance <index>", "CF process instance", "0").option("--node-pid <pid>", "Exact remote Node PID selector");
2662
503
  }
2663
504
  function registerStopCommand(program) {
2664
- program.command("stop").description("Stop one session (by key or id) or all sessions with --all").argument("[selector]", "Optional app selector: `<app>` or `region/org/space/app`").option("--region <key>").option("--org <name>").option("--space <name>").option("--app <name>").option("--process <name>", "CF process name", "web").option("-i, --instance <index>", "CF process instance index", "0").option("--session-id <id>").option("--all", "Stop every active session", false).action(async (selector, opts) => {
2665
- await handleStop(selector, opts);
2666
- });
505
+ addKeyOptions(
506
+ program.command("stop").description("Stop one session by exact key/id, or every session with --all").argument("[selector]", "Optional <app> or <region>/<org>/<space>/<app>")
507
+ ).option("--session-id <id>").option(
508
+ "--all",
509
+ "Attempt every active session; cannot be combined with session selectors",
510
+ false
511
+ ).option(
512
+ "--force",
513
+ "Forget unverifiable state and its owned CF home; never signal an unverified process",
514
+ false
515
+ ).action(
516
+ async (selector, options, command) => {
517
+ await handleStop(selector, options, command);
518
+ }
519
+ );
2667
520
  }
2668
521
  function registerReadCommands(program) {
2669
522
  program.command("list").description("Print every active debugger session as JSON").action(async () => {
2670
523
  await handleList();
2671
524
  });
2672
- program.command("status").description("Print one session by key as JSON (null if not active)").argument("[selector]", "Optional app selector: `<app>` or `region/org/space/app`").option("--region <key>").option("--org <name>").option("--space <name>").option("--app <name>").option("--process <name>", "CF process name", "web").option("-i, --instance <index>", "CF process instance index", "0").action(async (selector, opts) => {
2673
- await handleStatus(selector, opts);
525
+ addKeyOptions(
526
+ program.command("status").description("Print one exact session as JSON; null only when no session matches").argument("[selector]", "Optional <app> or <region>/<org>/<space>/<app>")
527
+ ).option("--session-id <id>", "Exact session ID selector").action(async (selector, options) => {
528
+ await handleStatus(selector, options);
529
+ });
530
+ program.command("doctor").description("Report session health, orphan homes/ports, temp files, and legacy credentials").option("--cleanup", "Remove only safe orphan v2 homes and stale v2 temp/lock files", false).action(async (options) => {
531
+ await handleDoctor(options.cleanup === true);
2674
532
  });
2675
533
  }
2676
534
  async function main(argv) {
2677
- const program = new Command().name("cf-debugger").description("Open an SSH debug tunnel to a SAP BTP Cloud Foundry app's Node.js inspector");
535
+ const program = new Command().name("cf-debugger").description("Open an SSH debug tunnel to a SAP BTP Cloud Foundry Node.js inspector").version(package_default.version);
2678
536
  registerStartCommand(program);
2679
537
  registerStopCommand(program);
2680
538
  registerReadCommands(program);
2681
539
  await program.parseAsync([...argv]);
2682
540
  }
2683
- try {
2684
- await main(process7.argv);
2685
- } catch (err) {
2686
- if (err instanceof CfDebuggerError) {
2687
- if (err.code === "ABORTED") {
2688
- process7.stderr.write(`Aborted: ${err.message}
541
+ function writeTopLevelError(error) {
542
+ if (error instanceof CfDebuggerError) {
543
+ if (error.code === "ABORTED") {
544
+ nodeProcess.stderr.write(`Aborted: ${error.message}
2689
545
  `);
2690
- process7.exit(130);
546
+ nodeProcess.exit(cliErrorExitCode(error));
2691
547
  }
2692
- process7.stderr.write(`Error [${err.code}]: ${err.message}
548
+ nodeProcess.stderr.write(`Error [${error.code}]: ${error.message}
2693
549
  `);
2694
- } else {
2695
- const msg = err instanceof Error ? err.message : String(err);
2696
- process7.stderr.write(`Error: ${msg}
550
+ if (error.stderr !== void 0 && error.stderr.trim().length > 0) {
551
+ nodeProcess.stderr.write(`[cf-debugger transport]
552
+ ${error.stderr.trim()}
553
+ `);
554
+ }
555
+ nodeProcess.exit(cliErrorExitCode(error));
556
+ }
557
+ if (hasTunnelTerminationFailure(error)) {
558
+ const message2 = error instanceof Error ? error.message : String(error);
559
+ nodeProcess.stderr.write(`Error during cleanup: ${message2}
2697
560
  `);
561
+ nodeProcess.exit(CLEANUP_FAILURE_EXIT_CODE);
2698
562
  }
2699
- process7.exit(1);
563
+ const message = error instanceof Error ? error.message : String(error);
564
+ nodeProcess.stderr.write(`Error: ${message}
565
+ `);
566
+ nodeProcess.exit(cliErrorExitCode(error));
567
+ }
568
+ try {
569
+ await main(nodeProcess.argv);
570
+ } catch (error) {
571
+ writeTopLevelError(error);
2700
572
  }
2701
573
  export {
2702
574
  main