@saptools/cf-debugger 0.1.14 → 0.1.15

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,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import process6 from "process";
4
+ import process7 from "process";
5
5
  import { Command } from "commander";
6
6
 
7
7
  // src/cloud-foundry/commands.ts
@@ -95,8 +95,9 @@ import { execFile } from "child_process";
95
95
  import { promisify } from "util";
96
96
  var execFileAsync = promisify(execFile);
97
97
  var MAX_BUFFER = 16 * 1024 * 1024;
98
- var DEFAULT_CF_COMMAND_TIMEOUT_MS = 18e4;
98
+ var DEFAULT_CF_COMMAND_TIMEOUT_MS = 3e5;
99
99
  var REDACTED_ARG = "<redacted>";
100
+ var MAX_RETRIES = 3;
100
101
  function buildEnv(cfHome) {
101
102
  return { ...process.env, CF_HOME: cfHome };
102
103
  }
@@ -112,30 +113,129 @@ function sensitiveArgs(args) {
112
113
  function redactText(text, values) {
113
114
  return values.reduce((current, value) => current.split(value).join(REDACTED_ARG), text);
114
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
+ }
115
135
  function formatArgsForError(args) {
116
136
  if (args[0] !== "auth") {
117
137
  return args.join(" ");
118
138
  }
119
139
  return args.map((arg, index) => index === 0 ? arg : REDACTED_ARG).join(" ");
120
140
  }
121
- async function runCf(args, context, timeoutMs = DEFAULT_CF_COMMAND_TIMEOUT_MS) {
122
- try {
123
- const { stdout } = await execFileAsync(resolveBin(context), [...args], {
124
- env: buildEnv(context.cfHome),
125
- maxBuffer: MAX_BUFFER,
126
- timeout: timeoutMs
127
- });
128
- return stdout;
129
- } catch (err) {
130
- const e = err;
131
- const redactionValues = sensitiveArgs(args);
132
- const stderr = redactText(e.stderr?.trim() ?? "", redactionValues);
133
- const fallbackMessage = redactText(e.message, redactionValues);
134
- throw new CfDebuggerError(
135
- "CF_CLI_FAILED",
136
- `cf ${formatArgsForError(args)} failed: ${stderr.length > 0 ? stderr : fallbackMessage}`,
137
- stderr
138
- );
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
+ }
139
239
  }
140
240
  }
141
241
 
@@ -143,6 +243,27 @@ async function runCf(args, context, timeoutMs = DEFAULT_CF_COMMAND_TIMEOUT_MS) {
143
243
  var execFileAsync2 = promisify2(execFile2);
144
244
  var CF_AUTH_MAX_ATTEMPTS = 3;
145
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
+ }
146
267
  async function cfApi(apiEndpoint, context) {
147
268
  await runCf(["api", apiEndpoint], context);
148
269
  }
@@ -150,14 +271,18 @@ async function cfAuth(email, password, context) {
150
271
  let lastError;
151
272
  for (let attempt = 0; attempt < CF_AUTH_MAX_ATTEMPTS; attempt++) {
152
273
  try {
153
- await runCf(["auth", email, password], context);
274
+ await runCf(["auth"], context, {
275
+ env: { CF_PASSWORD: password, CF_USERNAME: email },
276
+ sensitiveValues: [email, password]
277
+ });
154
278
  return;
155
279
  } catch (err) {
156
280
  lastError = err;
281
+ if (err instanceof CfDebuggerError && err.code === "ABORTED") {
282
+ throw err;
283
+ }
157
284
  if (attempt < CF_AUTH_MAX_ATTEMPTS - 1) {
158
- await new Promise((resolve) => {
159
- setTimeout(resolve, 1e3 * (attempt + 1));
160
- });
285
+ await waitForAuthRetry(1e3 * (attempt + 1), context.signal);
161
286
  }
162
287
  }
163
288
  }
@@ -171,6 +296,7 @@ async function cfLogin(apiEndpoint, email, password, context) {
171
296
  await cfApi(apiEndpoint, context);
172
297
  await cfAuth(email, password, context);
173
298
  } catch (err) {
299
+ rethrowCallerAbort(err);
174
300
  if (err instanceof CfDebuggerError) {
175
301
  throw new CfDebuggerError("CF_LOGIN_FAILED", err.message, err.stderr);
176
302
  }
@@ -181,6 +307,7 @@ async function cfTarget(org, space, context) {
181
307
  try {
182
308
  await runCf(["target", "-o", org, "-s", space], context);
183
309
  } catch (err) {
310
+ rethrowCallerAbort(err);
184
311
  if (err instanceof CfDebuggerError) {
185
312
  throw new CfDebuggerError("CF_TARGET_FAILED", err.message, err.stderr);
186
313
  }
@@ -191,7 +318,8 @@ async function cfSshEnabled(appName, context) {
191
318
  try {
192
319
  const stdout = await runCf(["ssh-enabled", appName], context);
193
320
  return stdout.toLowerCase().includes("ssh support is enabled");
194
- } catch {
321
+ } catch (err) {
322
+ rethrowCallerAbort(err);
195
323
  return false;
196
324
  }
197
325
  }
@@ -199,6 +327,7 @@ async function cfEnableSsh(appName, context) {
199
327
  try {
200
328
  await runCf(["enable-ssh", appName], context);
201
329
  } catch (err) {
330
+ rethrowCallerAbort(err);
202
331
  if (err instanceof CfDebuggerError) {
203
332
  throw new CfDebuggerError("SSH_NOT_ENABLED", err.message, err.stderr);
204
333
  }
@@ -257,7 +386,7 @@ function parseTargetFields(stdout) {
257
386
  line.slice(0, separator).trim().toLowerCase(),
258
387
  line.slice(separator + 1).trim()
259
388
  ];
260
- }).filter((field) => field !== void 0)
389
+ }).filter((field3) => field3 !== void 0)
261
390
  );
262
391
  }
263
392
  function regionKeyForApiEndpoint(apiEndpoint) {
@@ -281,73 +410,370 @@ function isPresent(value) {
281
410
 
282
411
  // src/cloud-foundry/ssh.ts
283
412
  import { spawn } from "child_process";
284
- async function cfSshOneShot(appName, command, context, timeoutMs = DEFAULT_CF_COMMAND_TIMEOUT_MS) {
285
- return await new Promise((resolve) => {
286
- const child = spawn(resolveBin(context), ["ssh", appName, "-c", command], {
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_inspector_owner() {",
534
+ ` socket_inode="$(awk '$2 ~ /:240D$/ && $4 == "0A" { print $10; exit }' /proc/net/tcp /proc/net/tcp6 2>/dev/null)"`,
535
+ ' [ -n "$socket_inode" ] || return 0',
536
+ " for pid_dir in /proc/[0-9]*; do",
537
+ ' [ -d "$pid_dir/fd" ] || continue',
538
+ ' for fd_path in "$pid_dir"/fd/*; do',
539
+ ' fd_target="$(readlink "$fd_path" 2>/dev/null || true)"',
540
+ ' if [ "$fd_target" = "socket:[$socket_inode]" ]; then',
541
+ ` printf '%s' "\${pid_dir##*/}"`,
542
+ " return 0",
543
+ " fi",
544
+ " done",
545
+ " done",
546
+ "}",
547
+ 'owner_pid="$(find_inspector_owner)"',
548
+ 'selected_pid=""',
549
+ 'if [ -n "$requested_node_pid" ]; then',
550
+ ' if ! is_node_pid "$requested_node_pid"; then',
551
+ ' echo "saptools-inspector-node-invalid=$requested_node_pid"',
552
+ " exit 0",
553
+ " fi",
554
+ ' selected_pid="$requested_node_pid"',
555
+ 'elif [ -n "$owner_pid" ] && is_node_pid "$owner_pid"; then',
556
+ ' selected_pid="$owner_pid"',
557
+ "else",
558
+ ' candidate_pids=""',
559
+ " candidate_count=0",
560
+ " for pid_dir in /proc/[0-9]*; do",
561
+ ' candidate_pid="${pid_dir##*/}"',
562
+ ' is_node_pid "$candidate_pid" || continue',
563
+ " candidate_count=$((candidate_count + 1))",
564
+ ' candidate_pids="${candidate_pids}${candidate_pids:+,}$candidate_pid"',
565
+ ' selected_pid="$candidate_pid"',
566
+ " done",
567
+ ' if [ "$candidate_count" -eq 0 ]; then echo saptools-inspector-node-not-found; exit 0; fi',
568
+ ' if [ "$candidate_count" -ne 1 ]; then echo "saptools-inspector-node-ambiguous=$candidate_pids"; exit 0; fi',
569
+ "fi",
570
+ 'if [ -n "$owner_pid" ]; then',
571
+ ' if [ "$owner_pid" != "$selected_pid" ]; then echo "saptools-inspector-owner-mismatch=$selected_pid:$owner_pid"; exit 0; fi',
572
+ ' echo "saptools-inspector-node-pid=$selected_pid"',
573
+ ' echo "saptools-inspector-owner-pid=$owner_pid"',
574
+ " echo saptools-inspector-ready",
575
+ " exit 0",
576
+ "fi",
577
+ 'if ! kill -USR1 "$selected_pid" 2>/dev/null; then echo "saptools-inspector-signal-failed=$selected_pid"; exit 0; fi',
578
+ "attempt=0",
579
+ 'while [ "$attempt" -lt 20 ]; do',
580
+ ' owner_pid="$(find_inspector_owner)"',
581
+ ' if [ -n "$owner_pid" ]; then',
582
+ ' if [ "$owner_pid" != "$selected_pid" ]; then echo "saptools-inspector-owner-mismatch=$selected_pid:$owner_pid"; exit 0; fi',
583
+ ' echo "saptools-inspector-node-pid=$selected_pid"',
584
+ ' echo "saptools-inspector-owner-pid=$owner_pid"',
585
+ " echo saptools-inspector-ready",
586
+ " exit 0",
587
+ " fi",
588
+ " attempt=$((attempt + 1))",
589
+ " sleep 0.25",
590
+ "done",
591
+ 'echo "saptools-inspector-not-ready=$selected_pid"'
592
+ ].join("\n");
593
+
594
+ // src/cloud-foundry/ssh.ts
595
+ var DEFAULT_MAX_OUTPUT_BYTES = 65536;
596
+ function buildCfSshArgs(appName, target, tail) {
597
+ const resolved = resolveNodeTarget(target);
598
+ const processArgs = resolved.process === DEFAULT_CF_PROCESS ? [] : ["--process", resolved.process];
599
+ return [
600
+ "ssh",
601
+ appName,
602
+ ...processArgs,
603
+ "-i",
604
+ resolved.instance.toString(),
605
+ ...tail
606
+ ];
607
+ }
608
+ async function cfSshOneShot(appName, command, context, rawOptions = DEFAULT_CF_COMMAND_TIMEOUT_MS) {
609
+ const options = resolveSshOptions(rawOptions);
610
+ const args = buildCfSshArgs(appName, options.target, [
611
+ "--disable-pseudo-tty",
612
+ "-c",
613
+ command
614
+ ]);
615
+ return await runSshOneShot(args, context, options);
616
+ }
617
+ function resolveSshOptions(raw) {
618
+ const input = typeof raw === "number" ? { timeoutMs: raw } : raw;
619
+ const timeoutMs = input.timeoutMs ?? DEFAULT_CF_COMMAND_TIMEOUT_MS;
620
+ const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
621
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
622
+ throw new RangeError("timeoutMs must be a positive safe integer");
623
+ }
624
+ if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes <= 0) {
625
+ throw new RangeError("maxOutputBytes must be a positive safe integer");
626
+ }
627
+ return { target: input, timeoutMs, maxOutputBytes };
628
+ }
629
+ function createBoundedOutput() {
630
+ return { chunks: [], bytes: 0, truncated: false };
631
+ }
632
+ function appendBounded(output, data, limit) {
633
+ const incoming = Buffer.isBuffer(data) ? data : Buffer.from(data);
634
+ const remaining = Math.max(0, limit - output.bytes);
635
+ if (incoming.byteLength > remaining) {
636
+ output.truncated = true;
637
+ }
638
+ if (remaining === 0) {
639
+ return;
640
+ }
641
+ const next = incoming.subarray(0, remaining);
642
+ output.chunks.push(next);
643
+ output.bytes += next.byteLength;
644
+ }
645
+ function outputText(output) {
646
+ return Buffer.concat(output.chunks, output.bytes).toString("utf8");
647
+ }
648
+ function createResult(exitCode, stdout, stderr) {
649
+ return {
650
+ exitCode,
651
+ stdout: outputText(stdout),
652
+ stderr: outputText(stderr),
653
+ outputTruncated: stdout.truncated || stderr.truncated
654
+ };
655
+ }
656
+ function createSshExecutionState() {
657
+ return {
658
+ stdout: createBoundedOutput(),
659
+ stderr: createBoundedOutput(),
660
+ settled: false,
661
+ aborted: false,
662
+ timedOut: false
663
+ };
664
+ }
665
+ function terminateSshExecution(child, state) {
666
+ signalChild(child, "SIGTERM");
667
+ state.forceKillTimer ??= setTimeout(() => {
668
+ signalChild(child, "SIGKILL");
669
+ }, 1e3);
670
+ }
671
+ function createSshSettler(state, options, signal, onAbort, resolve, reject) {
672
+ return (result) => {
673
+ if (state.settled) {
674
+ return;
675
+ }
676
+ state.settled = true;
677
+ if (state.timeoutTimer !== void 0) {
678
+ clearTimeout(state.timeoutTimer);
679
+ }
680
+ if (state.forceKillTimer !== void 0) {
681
+ clearTimeout(state.forceKillTimer);
682
+ }
683
+ signal?.removeEventListener("abort", onAbort);
684
+ if (state.aborted) {
685
+ reject(new CfDebuggerError("ABORTED", "Operation aborted by caller"));
686
+ return;
687
+ }
688
+ resolve(state.timedOut ? { ...result, timedOutAfterMs: options.timeoutMs } : result);
689
+ };
690
+ }
691
+ function attachSshExecution(child, context, options, resolve, reject) {
692
+ const state = createSshExecutionState();
693
+ const onAbort = () => {
694
+ state.aborted = true;
695
+ terminateSshExecution(child, state);
696
+ };
697
+ const settle = createSshSettler(state, options, context.signal, onAbort, resolve, reject);
698
+ state.timeoutTimer = setTimeout(() => {
699
+ state.timedOut = true;
700
+ terminateSshExecution(child, state);
701
+ }, options.timeoutMs);
702
+ if (context.signal?.aborted) {
703
+ onAbort();
704
+ } else {
705
+ context.signal?.addEventListener("abort", onAbort, { once: true });
706
+ }
707
+ child.stdout.on("data", (data) => {
708
+ appendBounded(state.stdout, data, options.maxOutputBytes);
709
+ });
710
+ child.stderr.on("data", (data) => {
711
+ appendBounded(state.stderr, data, options.maxOutputBytes);
712
+ });
713
+ child.on("close", (code, signal) => {
714
+ const base = createResult(code, state.stdout, state.stderr);
715
+ settle(state.timedOut || signal === null ? base : { ...base, signal });
716
+ });
717
+ child.on("error", (error) => {
718
+ appendBounded(state.stderr, error.message, options.maxOutputBytes);
719
+ settle(createResult(null, state.stdout, state.stderr));
720
+ });
721
+ }
722
+ function runSshOneShot(args, context, options) {
723
+ if (context.signal?.aborted) {
724
+ return Promise.reject(new CfDebuggerError("ABORTED", "Operation aborted by caller"));
725
+ }
726
+ return new Promise((resolve, reject) => {
727
+ const child = spawn(resolveBin(context), [...args], {
287
728
  env: buildEnv(context.cfHome),
729
+ detached: process.platform !== "win32",
288
730
  stdio: ["ignore", "pipe", "pipe"]
289
731
  });
290
- let stderrBuf = "";
291
- let settled = false;
292
- const timeout = setTimeout(() => {
293
- if (settled) {
294
- return;
295
- }
296
- settled = true;
297
- try {
298
- child.kill();
299
- } catch {
300
- }
301
- resolve({ exitCode: null, stderr: stderrBuf, timedOutAfterMs: timeoutMs });
302
- }, timeoutMs);
303
- child.stderr.on("data", (data) => {
304
- stderrBuf += data.toString();
305
- });
306
- child.on("close", (code, signal) => {
307
- if (settled) {
308
- return;
309
- }
310
- settled = true;
311
- clearTimeout(timeout);
312
- resolve(
313
- signal === null ? { exitCode: code, stderr: stderrBuf } : { exitCode: code, stderr: stderrBuf, signal }
314
- );
315
- });
316
- child.on("error", (err) => {
317
- if (settled) {
318
- return;
319
- }
320
- settled = true;
321
- clearTimeout(timeout);
322
- resolve({ exitCode: null, stderr: err.message });
323
- });
732
+ attachSshExecution(child, context, options, resolve, reject);
324
733
  });
325
734
  }
735
+ function signalChild(child, signal) {
736
+ if (process.platform !== "win32" && child.pid !== void 0) {
737
+ try {
738
+ process.kill(-child.pid, signal);
739
+ return;
740
+ } catch {
741
+ }
742
+ }
743
+ try {
744
+ child.kill(signal);
745
+ } catch {
746
+ }
747
+ }
326
748
  function isSshDisabledError(stderr) {
327
749
  const lower = stderr.toLowerCase();
328
750
  return lower.includes("not authorized") || lower.includes("ssh support is disabled");
329
751
  }
330
- function spawnSshTunnel(appName, localPort, remotePort, context) {
752
+ function spawnSshTunnel(appName, localPort, remotePort, context, target = {}) {
331
753
  const tunnelArg = `${localPort.toString()}:localhost:${remotePort.toString()}`;
332
- const isWindows = process.platform === "win32";
333
- return spawn(resolveBin(context), ["ssh", appName, "-N", "-L", tunnelArg], {
754
+ const args = buildCfSshArgs(appName, target, ["-N", "-L", tunnelArg]);
755
+ const child = spawn(resolveBin(context), [...args], {
334
756
  env: buildEnv(context.cfHome),
335
- shell: isWindows,
336
- detached: !isWindows
757
+ detached: process.platform !== "win32",
758
+ stdio: ["ignore", "pipe", "pipe"]
337
759
  });
760
+ child.stdout.resume();
761
+ child.stderr.resume();
762
+ return child;
338
763
  }
339
764
 
340
765
  // src/debug-session/start.ts
341
- import { mkdir as mkdir3, rm } from "fs/promises";
342
- import process4 from "process";
766
+ import { chmod as chmod3, mkdir as mkdir3, rm as rm2 } from "fs/promises";
767
+ import process5 from "process";
343
768
 
344
769
  // src/paths.ts
345
770
  import { homedir } from "os";
346
- import { join } from "path";
771
+ import { isAbsolute, join } from "path";
347
772
  var SAPTOOLS_DIR_NAME = ".saptools";
348
- var CF_DEBUGGER_STATE_FILENAME = "cf-debugger-state.json";
349
- var CF_DEBUGGER_LOCK_FILENAME = "cf-debugger-state.lock";
350
- var CF_DEBUGGER_HOMES_DIRNAME = "cf-debugger-homes";
773
+ var CF_DEBUGGER_STATE_FILENAME = "cf-debugger-state-v2.json";
774
+ var CF_DEBUGGER_LOCK_FILENAME = "cf-debugger-state-v2.lock";
775
+ var CF_DEBUGGER_HOMES_DIRNAME = "cf-debugger-homes-v2";
776
+ var SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
351
777
  function saptoolsDir() {
352
778
  return join(homedir(), SAPTOOLS_DIR_NAME);
353
779
  }
@@ -357,9 +783,21 @@ function stateFilePath() {
357
783
  function stateLockPath() {
358
784
  return join(saptoolsDir(), CF_DEBUGGER_LOCK_FILENAME);
359
785
  }
786
+ function isSafeSessionId(sessionId) {
787
+ return SESSION_ID_PATTERN.test(sessionId);
788
+ }
360
789
  function sessionCfHomeDir(sessionId) {
790
+ if (!isSafeSessionId(sessionId)) {
791
+ throw new Error("Invalid debugger session ID.");
792
+ }
361
793
  return join(saptoolsDir(), CF_DEBUGGER_HOMES_DIRNAME, sessionId);
362
794
  }
795
+ function isOwnedSessionCfHomeDir(sessionId, candidate) {
796
+ if (!isSafeSessionId(sessionId) || !isAbsolute(candidate)) {
797
+ return false;
798
+ }
799
+ return candidate === sessionCfHomeDir(sessionId);
800
+ }
363
801
 
364
802
  // src/network/ports.ts
365
803
  import { execFile as execFile3 } from "child_process";
@@ -500,182 +938,493 @@ async function isPortListening(port, timeoutMs = 200) {
500
938
  });
501
939
  });
502
940
  }
503
- async function probeTunnelReady(port, timeoutMs) {
941
+ function throwIfAborted(signal) {
942
+ if (signal?.aborted) {
943
+ throw new CfDebuggerError("ABORTED", "Operation aborted by caller");
944
+ }
945
+ }
946
+ function waitForNextProbe(delayMs, signal) {
947
+ throwIfAborted(signal);
948
+ return new Promise((resolve, reject) => {
949
+ const onAbort = () => {
950
+ clearTimeout(timer);
951
+ reject(new CfDebuggerError("ABORTED", "Operation aborted by caller"));
952
+ };
953
+ const timer = setTimeout(() => {
954
+ signal?.removeEventListener("abort", onAbort);
955
+ resolve();
956
+ }, delayMs);
957
+ signal?.addEventListener("abort", onAbort, { once: true });
958
+ });
959
+ }
960
+ async function probeTunnelReady(port, timeoutMs, signal) {
504
961
  const pollIntervalMs = 250;
505
962
  const started = Date.now();
963
+ throwIfAborted(signal);
506
964
  while (Date.now() - started < timeoutMs) {
507
965
  const connected = await isPortListening(port);
508
966
  if (connected) {
509
967
  return true;
510
968
  }
511
- await new Promise((resolve) => {
512
- setTimeout(resolve, pollIntervalMs);
513
- });
969
+ const remainingMs = timeoutMs - (Date.now() - started);
970
+ await waitForNextProbe(Math.min(pollIntervalMs, Math.max(0, remainingMs)), signal);
514
971
  }
972
+ throwIfAborted(signal);
515
973
  return false;
516
974
  }
517
975
  async function findListeningProcessId(port) {
518
976
  const pids = await findListeningPids(port);
519
977
  return pids[0];
520
978
  }
521
- async function killProcessOnPort(port) {
522
- const portStr = port.toString();
523
- if (process.platform === "win32") {
524
- try {
525
- const pids = await findListeningPidsWithNetstat(port);
526
- for (const pid of pids) {
527
- try {
528
- await execFileAsync3("taskkill", ["/F", "/PID", pid.toString()]);
529
- } catch {
530
- }
531
- }
532
- } catch {
533
- }
534
- return;
535
- }
536
- try {
537
- const { stdout } = await execFileAsync3("lsof", ["-t", "-i", `tcp:${portStr}`]);
538
- const lines = stdout.trim().split("\n").filter((line) => line.length > 0);
539
- for (const line of lines) {
540
- const pid = Number.parseInt(line, 10);
541
- if (Number.isNaN(pid)) {
542
- continue;
543
- }
544
- try {
545
- process.kill(pid, "SIGKILL");
546
- } catch {
547
- }
548
- }
549
- } catch {
550
- }
551
- }
552
979
 
553
980
  // src/session-state/store.ts
554
- import { randomUUID } from "crypto";
555
- import { mkdir as mkdir2, readFile as readFile2, rename, writeFile } from "fs/promises";
981
+ import { randomUUID as randomUUID2 } from "crypto";
982
+ import { chmod as chmod2, mkdir as mkdir2, readFile as readFile3, rename, unlink as unlink2, writeFile } from "fs/promises";
556
983
  import { hostname as getHostname } from "os";
557
- import { dirname as dirname2 } from "path";
558
- import process2 from "process";
984
+ import { dirname as dirname2, isAbsolute as isAbsolute3 } from "path";
985
+ import process3 from "process";
559
986
 
560
987
  // src/lock.ts
561
- import { mkdir, open, unlink } from "fs/promises";
988
+ import { randomUUID } from "crypto";
989
+ import { chmod, mkdir, open, readFile as readFile2, stat, unlink } from "fs/promises";
990
+ import { hostname } from "os";
562
991
  import { dirname } from "path";
992
+ import process2 from "process";
563
993
  var DEFAULT_POLL_MS = 50;
994
+ var DEFAULT_STALE_MS = 6e4;
564
995
  var DEFAULT_TIMEOUT_MS = 1e4;
565
996
  function sleep(ms) {
566
997
  return new Promise((resolve) => {
567
998
  setTimeout(resolve, ms);
568
999
  });
569
1000
  }
570
- async function acquireFileLock(lockPath, timeoutMs, pollMs) {
571
- const deadline = Date.now() + timeoutMs;
572
- await mkdir(dirname(lockPath), { recursive: true });
573
- for (; ; ) {
574
- try {
575
- return await open(lockPath, "wx");
576
- } catch (err) {
577
- const code = err.code;
578
- if (code !== "EEXIST") {
579
- throw err;
580
- }
581
- }
582
- if (Date.now() > deadline) {
583
- throw new Error(`Timed out acquiring file lock at ${lockPath}`);
584
- }
585
- await sleep(pollMs);
1001
+ function errorCode(error) {
1002
+ if (typeof error !== "object" || error === null) {
1003
+ return void 0;
586
1004
  }
1005
+ const code = Reflect.get(error, "code");
1006
+ return typeof code === "string" ? code : void 0;
587
1007
  }
588
- async function releaseFileLock(lockPath, handle) {
589
- await handle.close();
590
- await unlink(lockPath).catch((err) => {
591
- const code = err.code;
592
- if (code !== "ENOENT") {
593
- throw err;
594
- }
595
- });
596
- }
597
- async function withFileLock(lockPath, work, options) {
598
- const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
599
- const pollMs = options?.pollMs ?? DEFAULT_POLL_MS;
600
- const handle = await acquireFileLock(lockPath, timeoutMs, pollMs);
601
- try {
602
- return await work();
603
- } finally {
604
- await releaseFileLock(lockPath, handle);
605
- }
1008
+ function field(value, key) {
1009
+ return Reflect.get(value, key);
606
1010
  }
607
-
608
- // src/session-state/store.ts
609
- async function readJsonFile(path) {
610
- let raw;
611
- try {
612
- raw = await readFile2(path, "utf8");
613
- } catch (err) {
614
- const code = err.code;
615
- if (code === "ENOENT") {
616
- return void 0;
617
- }
618
- throw err;
619
- }
1011
+ function parseLockOwner(raw) {
1012
+ let value;
620
1013
  try {
621
- return JSON.parse(raw);
1014
+ value = JSON.parse(raw);
622
1015
  } catch {
623
- process2.stderr.write(
624
- `[cf-debugger] warning: state file at ${path} is not valid JSON; resetting to empty.
625
- `
626
- );
627
1016
  return void 0;
628
1017
  }
629
- }
630
- async function writeJsonFileAtomic(path, value) {
631
- const tempPath = `${path}.${randomUUID()}.tmp`;
632
- await mkdir2(dirname2(path), { recursive: true });
633
- await writeFile(tempPath, `${JSON.stringify(value, null, 2)}
634
- `, "utf8");
635
- await rename(tempPath, path);
636
- }
637
- function emptyState() {
638
- return { version: "1", sessions: [] };
639
- }
640
- function isValidState(value) {
641
1018
  if (typeof value !== "object" || value === null) {
642
- return false;
1019
+ return void 0;
1020
+ }
1021
+ const lockHostname = field(value, "hostname");
1022
+ const pid = field(value, "pid");
1023
+ const token = field(value, "token");
1024
+ const version = field(value, "version");
1025
+ if (typeof lockHostname !== "string" || typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0) {
1026
+ return void 0;
643
1027
  }
644
- const candidate = value;
645
- return candidate.version === "1" && Array.isArray(candidate.sessions);
1028
+ if (typeof token !== "string" || version !== "1") {
1029
+ return void 0;
1030
+ }
1031
+ return { hostname: lockHostname, pid, token, version };
646
1032
  }
647
- function isPidAlive(pid) {
1033
+ function isProcessAlive(pid) {
648
1034
  try {
649
1035
  process2.kill(pid, 0);
650
1036
  return true;
651
- } catch (err) {
652
- const code = err.code;
653
- if (code === "ESRCH") {
654
- return false;
1037
+ } catch (error) {
1038
+ return errorCode(error) !== "ESRCH";
1039
+ }
1040
+ }
1041
+ async function readLockOwner(lockPath) {
1042
+ try {
1043
+ await chmod(lockPath, 384);
1044
+ return parseLockOwner(await readFile2(lockPath, "utf8"));
1045
+ } catch (error) {
1046
+ if (errorCode(error) === "ENOENT") {
1047
+ return void 0;
655
1048
  }
656
- return true;
1049
+ throw error;
657
1050
  }
658
1051
  }
659
- async function isSessionHealthy(session, host) {
660
- if (session.hostname !== host) {
661
- return true;
1052
+ async function isStaleLock(lockPath, staleMs) {
1053
+ let modifiedAt;
1054
+ try {
1055
+ modifiedAt = (await stat(lockPath)).mtimeMs;
1056
+ } catch (error) {
1057
+ if (errorCode(error) === "ENOENT") {
1058
+ return true;
1059
+ }
1060
+ throw error;
662
1061
  }
663
- if (!isPidAlive(session.pid)) {
664
- return false;
1062
+ const owner = await readLockOwner(lockPath);
1063
+ if (owner?.hostname === hostname()) {
1064
+ return !isProcessAlive(owner.pid);
665
1065
  }
666
- if (session.status !== "ready") {
667
- return true;
1066
+ return owner === void 0 && Date.now() - modifiedAt > staleMs;
1067
+ }
1068
+ async function reclaimAbandonedRecoveryLock(recoveryPath, staleMs) {
1069
+ if (!await isStaleLock(recoveryPath, staleMs)) {
1070
+ return;
668
1071
  }
669
- if (!await isPortListening(session.localPort)) {
670
- return false;
1072
+ const owner = await readLockOwner(recoveryPath);
1073
+ if (owner !== void 0) {
1074
+ await removeOwnedLock(recoveryPath, owner.token);
1075
+ return;
671
1076
  }
672
- const listeningPid = await findListeningProcessId(session.localPort);
673
- return listeningPid === void 0 || listeningPid === session.pid;
1077
+ await unlink(recoveryPath).catch((error) => {
1078
+ if (errorCode(error) !== "ENOENT") {
1079
+ throw error;
1080
+ }
1081
+ });
674
1082
  }
675
- async function filterStaleSessions(sessions) {
676
- const host = getHostname();
677
- const checks = await Promise.all(
678
- sessions.map(async (session) => [
1083
+ async function reclaimStaleLock(lockPath, staleMs) {
1084
+ const recoveryPath = `${lockPath}.recovery`;
1085
+ let recoveryLock;
1086
+ try {
1087
+ recoveryLock = await createFileLock(recoveryPath);
1088
+ } catch (error) {
1089
+ if (errorCode(error) === "EEXIST") {
1090
+ await reclaimAbandonedRecoveryLock(recoveryPath, staleMs);
1091
+ return false;
1092
+ }
1093
+ throw error;
1094
+ }
1095
+ try {
1096
+ if (!await isStaleLock(lockPath, staleMs)) {
1097
+ return false;
1098
+ }
1099
+ try {
1100
+ await unlink(lockPath);
1101
+ } catch (error) {
1102
+ if (errorCode(error) !== "ENOENT") {
1103
+ throw error;
1104
+ }
1105
+ }
1106
+ return true;
1107
+ } finally {
1108
+ await releaseFileLock(recoveryPath, recoveryLock);
1109
+ }
1110
+ }
1111
+ async function removeOwnedLock(lockPath, token) {
1112
+ const owner = await readLockOwner(lockPath);
1113
+ if (owner?.token !== token) {
1114
+ return;
1115
+ }
1116
+ await unlink(lockPath).catch((error) => {
1117
+ if (errorCode(error) !== "ENOENT") {
1118
+ throw error;
1119
+ }
1120
+ });
1121
+ }
1122
+ async function createFileLock(lockPath) {
1123
+ const handle = await open(lockPath, "wx", 384);
1124
+ const token = randomUUID();
1125
+ try {
1126
+ await handle.writeFile(`${JSON.stringify({ hostname: hostname(), pid: process2.pid, token, version: "1" })}
1127
+ `, "utf8");
1128
+ return { handle, token };
1129
+ } catch (error) {
1130
+ await handle.close();
1131
+ await unlink(lockPath).catch(() => false);
1132
+ throw error;
1133
+ }
1134
+ }
1135
+ async function acquireFileLock(lockPath, timeoutMs, pollMs, staleMs) {
1136
+ const deadline = Date.now() + timeoutMs;
1137
+ const parentDir = dirname(lockPath);
1138
+ await mkdir(parentDir, { recursive: true, mode: 448 });
1139
+ await chmod(parentDir, 448);
1140
+ for (; ; ) {
1141
+ try {
1142
+ return await createFileLock(lockPath);
1143
+ } catch (error) {
1144
+ if (errorCode(error) !== "EEXIST") {
1145
+ throw error;
1146
+ }
1147
+ }
1148
+ if (await reclaimStaleLock(lockPath, staleMs)) {
1149
+ continue;
1150
+ }
1151
+ if (Date.now() > deadline) {
1152
+ throw new Error(`Timed out acquiring file lock at ${lockPath}`);
1153
+ }
1154
+ await sleep(pollMs);
1155
+ }
1156
+ }
1157
+ async function releaseFileLock(lockPath, lock) {
1158
+ await lock.handle.close();
1159
+ await removeOwnedLock(lockPath, lock.token);
1160
+ }
1161
+ async function withFileLock(lockPath, work, options) {
1162
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
1163
+ const pollMs = options?.pollMs ?? DEFAULT_POLL_MS;
1164
+ const staleMs = options?.staleMs ?? DEFAULT_STALE_MS;
1165
+ const lock = await acquireFileLock(lockPath, timeoutMs, pollMs, staleMs);
1166
+ try {
1167
+ return await work();
1168
+ } finally {
1169
+ await releaseFileLock(lockPath, lock);
1170
+ }
1171
+ }
1172
+
1173
+ // src/session-state/decoder.ts
1174
+ import { isAbsolute as isAbsolute2 } from "path";
1175
+ var INVALID_SESSION = new Error("Invalid persisted debugger session");
1176
+ var VALID_STATUSES = /* @__PURE__ */ new Set([
1177
+ "starting",
1178
+ "logging-in",
1179
+ "targeting",
1180
+ "ssh-enabling",
1181
+ "ssh-restarting",
1182
+ "signaling",
1183
+ "tunneling",
1184
+ "ready",
1185
+ "stopping",
1186
+ "stopped",
1187
+ "error"
1188
+ ]);
1189
+ function field2(value, key) {
1190
+ return Reflect.get(value, key);
1191
+ }
1192
+ function requireString(value, key) {
1193
+ const candidate = field2(value, key);
1194
+ if (typeof candidate !== "string" || candidate.length === 0) {
1195
+ throw INVALID_SESSION;
1196
+ }
1197
+ return candidate;
1198
+ }
1199
+ function optionalString2(value, key) {
1200
+ const candidate = field2(value, key);
1201
+ if (candidate === void 0) {
1202
+ return void 0;
1203
+ }
1204
+ if (typeof candidate !== "string") {
1205
+ throw INVALID_SESSION;
1206
+ }
1207
+ return candidate;
1208
+ }
1209
+ function requireInteger(value, key, minimum, maximum) {
1210
+ const candidate = field2(value, key);
1211
+ if (!Number.isSafeInteger(candidate) || typeof candidate !== "number") {
1212
+ throw INVALID_SESSION;
1213
+ }
1214
+ if (candidate < minimum || candidate > maximum) {
1215
+ throw INVALID_SESSION;
1216
+ }
1217
+ return candidate;
1218
+ }
1219
+ function optionalInteger(value, key, minimum) {
1220
+ const candidate = field2(value, key);
1221
+ if (candidate === void 0) {
1222
+ return void 0;
1223
+ }
1224
+ if (!Number.isSafeInteger(candidate) || typeof candidate !== "number" || candidate < minimum) {
1225
+ throw INVALID_SESSION;
1226
+ }
1227
+ return candidate;
1228
+ }
1229
+ function requireSessionId(value) {
1230
+ const sessionId = requireString(value, "sessionId");
1231
+ if (!isSafeSessionId(sessionId)) {
1232
+ throw INVALID_SESSION;
1233
+ }
1234
+ return sessionId;
1235
+ }
1236
+ function requireAbsolutePath(value, key) {
1237
+ const path = requireString(value, key);
1238
+ if (!isAbsolute2(path)) {
1239
+ throw INVALID_SESSION;
1240
+ }
1241
+ return path;
1242
+ }
1243
+ function requireTimestamp(value) {
1244
+ const timestamp = requireString(value, "startedAt");
1245
+ if (Number.isNaN(Date.parse(timestamp))) {
1246
+ throw INVALID_SESSION;
1247
+ }
1248
+ return timestamp;
1249
+ }
1250
+ function optionalTimestamp(value, key) {
1251
+ const timestamp = optionalString2(value, key);
1252
+ if (timestamp !== void 0 && Number.isNaN(Date.parse(timestamp))) {
1253
+ throw INVALID_SESSION;
1254
+ }
1255
+ return timestamp;
1256
+ }
1257
+ function isSessionStatus(value) {
1258
+ return VALID_STATUSES.has(value);
1259
+ }
1260
+ function requireStatus(value) {
1261
+ const status = requireString(value, "status");
1262
+ if (!isSessionStatus(status)) {
1263
+ throw INVALID_SESSION;
1264
+ }
1265
+ return status;
1266
+ }
1267
+ function decodeSession(value) {
1268
+ if (typeof value !== "object" || value === null) {
1269
+ return void 0;
1270
+ }
1271
+ try {
1272
+ const processName = requireString(value, "process");
1273
+ const instance = requireInteger(value, "instance", 0, Number.MAX_SAFE_INTEGER);
1274
+ const nodePid = optionalInteger(value, "nodePid", 1);
1275
+ const target = resolveNodeTarget({
1276
+ process: processName,
1277
+ instance,
1278
+ ...nodePid === void 0 ? {} : { nodePid }
1279
+ });
1280
+ const pid = requireInteger(value, "pid", 1, Number.MAX_SAFE_INTEGER);
1281
+ const controllerPid = requireInteger(value, "controllerPid", 1, Number.MAX_SAFE_INTEGER);
1282
+ const tunnelPid = optionalInteger(value, "tunnelPid", 1);
1283
+ const remoteNodePid = optionalInteger(value, "remoteNodePid", 1);
1284
+ const stopRequestedAt = optionalTimestamp(value, "stopRequestedAt");
1285
+ const message = optionalString2(value, "message");
1286
+ const status = requireStatus(value);
1287
+ if (pid !== (tunnelPid ?? controllerPid) || status === "ready" && tunnelPid === void 0) {
1288
+ throw INVALID_SESSION;
1289
+ }
1290
+ return {
1291
+ sessionId: requireSessionId(value),
1292
+ pid,
1293
+ controllerPid,
1294
+ ...tunnelPid === void 0 ? {} : { tunnelPid },
1295
+ hostname: requireString(value, "hostname"),
1296
+ region: requireString(value, "region"),
1297
+ org: requireString(value, "org"),
1298
+ space: requireString(value, "space"),
1299
+ app: requireString(value, "app"),
1300
+ process: target.process,
1301
+ instance: target.instance,
1302
+ ...nodePid === void 0 ? {} : { nodePid },
1303
+ apiEndpoint: requireString(value, "apiEndpoint"),
1304
+ localPort: requireInteger(value, "localPort", 1, 65535),
1305
+ remotePort: requireInteger(value, "remotePort", 1, 65535),
1306
+ cfHomeDir: requireAbsolutePath(value, "cfHomeDir"),
1307
+ startedAt: requireTimestamp(value),
1308
+ status,
1309
+ ...remoteNodePid === void 0 ? {} : { remoteNodePid },
1310
+ ...stopRequestedAt === void 0 ? {} : { stopRequestedAt },
1311
+ ...message === void 0 ? {} : { message }
1312
+ };
1313
+ } catch {
1314
+ return void 0;
1315
+ }
1316
+ }
1317
+ function decodeStateFile(value) {
1318
+ if (typeof value !== "object" || value === null || field2(value, "version") !== "2") {
1319
+ return void 0;
1320
+ }
1321
+ const rawSessions = field2(value, "sessions");
1322
+ if (!Array.isArray(rawSessions)) {
1323
+ return void 0;
1324
+ }
1325
+ const sessionIds = /* @__PURE__ */ new Set();
1326
+ const sessions = [];
1327
+ for (const rawSession of rawSessions) {
1328
+ const session = decodeSession(rawSession);
1329
+ if (session === void 0 || sessionIds.has(session.sessionId)) {
1330
+ return void 0;
1331
+ }
1332
+ sessionIds.add(session.sessionId);
1333
+ sessions.push(session);
1334
+ }
1335
+ return { version: "2", sessions };
1336
+ }
1337
+
1338
+ // src/session-state/store.ts
1339
+ function errorCode2(error) {
1340
+ if (typeof error !== "object" || error === null) {
1341
+ return void 0;
1342
+ }
1343
+ const code = Reflect.get(error, "code");
1344
+ return typeof code === "string" ? code : void 0;
1345
+ }
1346
+ async function readJsonFile(path) {
1347
+ let raw;
1348
+ try {
1349
+ await chmod2(path, 384);
1350
+ raw = await readFile3(path, "utf8");
1351
+ } catch (err) {
1352
+ if (errorCode2(err) === "ENOENT") {
1353
+ return void 0;
1354
+ }
1355
+ throw err;
1356
+ }
1357
+ try {
1358
+ return JSON.parse(raw);
1359
+ } catch {
1360
+ process3.stderr.write(
1361
+ `[cf-debugger] warning: state file at ${path} is not valid JSON; resetting to empty.
1362
+ `
1363
+ );
1364
+ return void 0;
1365
+ }
1366
+ }
1367
+ async function writeJsonFileAtomic(path, value) {
1368
+ const tempPath = `${path}.${randomUUID2()}.tmp`;
1369
+ const parentDir = dirname2(path);
1370
+ await mkdir2(parentDir, { recursive: true, mode: 448 });
1371
+ await chmod2(parentDir, 448);
1372
+ let renamed = false;
1373
+ try {
1374
+ await writeFile(tempPath, `${JSON.stringify(value, null, 2)}
1375
+ `, {
1376
+ encoding: "utf8",
1377
+ flag: "wx",
1378
+ mode: 384
1379
+ });
1380
+ await rename(tempPath, path);
1381
+ renamed = true;
1382
+ await chmod2(path, 384);
1383
+ } finally {
1384
+ if (!renamed) {
1385
+ await unlink2(tempPath).catch(() => false);
1386
+ }
1387
+ }
1388
+ }
1389
+ function emptyState() {
1390
+ return { version: "2", sessions: [] };
1391
+ }
1392
+ function isPidAlive(pid) {
1393
+ try {
1394
+ process3.kill(pid, 0);
1395
+ return true;
1396
+ } catch (err) {
1397
+ if (errorCode2(err) === "ESRCH") {
1398
+ return false;
1399
+ }
1400
+ return true;
1401
+ }
1402
+ }
1403
+ function isPidOrGroupAlive(pid) {
1404
+ if (isPidAlive(pid)) {
1405
+ return true;
1406
+ }
1407
+ return isProcessGroupAlive(pid);
1408
+ }
1409
+ function isProcessGroupAlive(pid) {
1410
+ return process3.platform !== "win32" && isPidAlive(-pid);
1411
+ }
1412
+ async function isSessionHealthy(session, host) {
1413
+ if (session.hostname !== host) {
1414
+ return false;
1415
+ }
1416
+ if (session.status !== "ready" && isPidAlive(session.controllerPid ?? session.pid)) {
1417
+ return true;
1418
+ }
1419
+ if (session.tunnelPid !== void 0 && isPidOrGroupAlive(session.tunnelPid)) {
1420
+ return true;
1421
+ }
1422
+ return await isPortListening(session.localPort);
1423
+ }
1424
+ async function filterStaleSessions(sessions) {
1425
+ const host = getHostname();
1426
+ const checks = await Promise.all(
1427
+ sessions.map(async (session) => [
679
1428
  session,
680
1429
  await isSessionHealthy(session, host)
681
1430
  ])
@@ -683,23 +1432,35 @@ async function filterStaleSessions(sessions) {
683
1432
  return checks.filter(([, healthy]) => healthy).map(([session]) => session);
684
1433
  }
685
1434
  async function readStateRaw() {
686
- const parsed = await readJsonFile(stateFilePath());
687
- if (!isValidState(parsed)) {
1435
+ const path = stateFilePath();
1436
+ const parsed = await readJsonFile(path);
1437
+ if (parsed === void 0) {
688
1438
  return emptyState();
689
1439
  }
690
- return parsed;
1440
+ const decoded = decodeStateFile(parsed);
1441
+ if (decoded !== void 0) {
1442
+ return decoded;
1443
+ }
1444
+ process3.stderr.write(
1445
+ `[cf-debugger] warning: state file at ${path} has an invalid structure; resetting to empty.
1446
+ `
1447
+ );
1448
+ return emptyState();
691
1449
  }
692
1450
  async function writeState(state) {
693
1451
  await writeJsonFileAtomic(stateFilePath(), state);
694
1452
  }
695
1453
  async function readAndPruneLocked() {
696
1454
  const raw = await readStateRaw();
697
- const pruned = await filterStaleSessions(raw.sessions);
698
- const removed = raw.sessions.filter(
1455
+ const host = getHostname();
1456
+ const remote = raw.sessions.filter((session) => session.hostname !== host);
1457
+ const local = raw.sessions.filter((session) => session.hostname === host);
1458
+ const pruned = await filterStaleSessions(local);
1459
+ const removed = local.filter(
699
1460
  (session) => !pruned.some((active) => active.sessionId === session.sessionId)
700
1461
  );
701
1462
  if (removed.length > 0) {
702
- await writeState({ version: "1", sessions: pruned });
1463
+ await writeState({ version: "2", sessions: [...remote, ...pruned] });
703
1464
  }
704
1465
  return { sessions: pruned, removed };
705
1466
  }
@@ -707,14 +1468,38 @@ async function readActiveSessions() {
707
1468
  const result = await withFileLock(stateLockPath(), readAndPruneLocked);
708
1469
  return result.sessions;
709
1470
  }
1471
+ async function readSessionSnapshot() {
1472
+ return await withFileLock(stateLockPath(), async () => {
1473
+ const raw = await readStateRaw();
1474
+ return raw.sessions;
1475
+ });
1476
+ }
710
1477
  async function readAndPruneActiveSessions() {
711
1478
  return await withFileLock(stateLockPath(), readAndPruneLocked);
712
1479
  }
713
1480
  function sessionKeyString(key) {
714
- return `${key.region}:${key.org}:${key.space}:${key.app}`;
1481
+ const base = `${key.region}:${key.org}:${key.space}:${key.app}`;
1482
+ if (key.process === void 0 && key.instance === void 0) {
1483
+ return base;
1484
+ }
1485
+ const target = resolveNodeTarget(key);
1486
+ return `${base}:${target.process}:${target.instance.toString()}`;
715
1487
  }
716
1488
  function matchesKey(session, key) {
717
- return session.region === key.region && session.org === key.org && session.space === key.space && session.app === key.app;
1489
+ const sessionTarget = resolveNodeTarget(session);
1490
+ const keyTarget = resolveNodeTarget(key);
1491
+ 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);
1492
+ }
1493
+ function matchesRegistrationTarget(session, input, target) {
1494
+ return matchesKey(session, {
1495
+ region: input.region,
1496
+ org: input.org,
1497
+ space: input.space,
1498
+ app: input.app,
1499
+ process: target.process,
1500
+ instance: target.instance,
1501
+ apiEndpoint: input.apiEndpoint
1502
+ });
718
1503
  }
719
1504
  var DEFAULT_BASE_PORT = 2e4;
720
1505
  var DEFAULT_MAX_PORT = 20999;
@@ -743,9 +1528,13 @@ async function pickPort(preferred, reserved, probe, basePort, maxPort) {
743
1528
  );
744
1529
  }
745
1530
  async function registerNewSession(input) {
1531
+ const target = resolveNodeTarget(input);
746
1532
  return await withFileLock(stateLockPath(), async () => {
747
1533
  const pruneResult = await readAndPruneLocked();
748
- const existing = pruneResult.sessions.find((session2) => matchesKey(session2, input));
1534
+ const persisted = await readStateRaw();
1535
+ const host = getHostname();
1536
+ const remoteSessions = persisted.sessions.filter((session2) => session2.hostname !== host);
1537
+ const existing = pruneResult.sessions.find((session2) => matchesRegistrationTarget(session2, input, target));
749
1538
  if (existing) {
750
1539
  return { session: existing, existing };
751
1540
  }
@@ -757,28 +1546,41 @@ async function registerNewSession(input) {
757
1546
  input.basePort ?? DEFAULT_BASE_PORT,
758
1547
  input.maxPort ?? DEFAULT_MAX_PORT
759
1548
  );
760
- const sessionId = (input.sessionIdFactory ?? randomUUID)();
761
- const cfHomeDir = input.cfHomeForSession(sessionId);
762
- const session = {
763
- sessionId,
764
- pid: process2.pid,
765
- hostname: getHostname(),
766
- region: input.region,
767
- org: input.org,
768
- space: input.space,
769
- app: input.app,
770
- apiEndpoint: input.apiEndpoint,
771
- localPort,
772
- remotePort: 9229,
773
- cfHomeDir,
774
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
775
- status: "starting"
776
- };
777
- const nextSessions = [...pruneResult.sessions, session];
778
- await writeState({ version: "1", sessions: nextSessions });
1549
+ const session = createRegisteredSession(input, target, localPort);
1550
+ const nextSessions = [...remoteSessions, ...pruneResult.sessions, session];
1551
+ await writeState({ version: "2", sessions: nextSessions });
779
1552
  return { session };
780
1553
  });
781
1554
  }
1555
+ function createRegisteredSession(input, target, localPort) {
1556
+ const sessionId = (input.sessionIdFactory ?? randomUUID2)();
1557
+ if (!isSafeSessionId(sessionId)) {
1558
+ throw new CfDebuggerError("UNSAFE_INPUT", "Generated debugger session ID is invalid.");
1559
+ }
1560
+ const cfHomeDir = input.cfHomeForSession(sessionId);
1561
+ if (!isAbsolute3(cfHomeDir)) {
1562
+ throw new CfDebuggerError("UNSAFE_INPUT", "Debugger CF home must be an absolute path.");
1563
+ }
1564
+ return {
1565
+ sessionId,
1566
+ pid: process3.pid,
1567
+ controllerPid: process3.pid,
1568
+ hostname: getHostname(),
1569
+ region: input.region,
1570
+ org: input.org,
1571
+ space: input.space,
1572
+ app: input.app,
1573
+ process: target.process,
1574
+ instance: target.instance,
1575
+ ...target.nodePid === void 0 ? {} : { nodePid: target.nodePid },
1576
+ apiEndpoint: input.apiEndpoint,
1577
+ localPort,
1578
+ remotePort: 9229,
1579
+ cfHomeDir,
1580
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1581
+ status: "starting"
1582
+ };
1583
+ }
782
1584
  async function updateSessionStatus(sessionId, status, message) {
783
1585
  return await withFileLock(stateLockPath(), async () => {
784
1586
  const raw = await readStateRaw();
@@ -787,32 +1589,62 @@ async function updateSessionStatus(sessionId, status, message) {
787
1589
  if (session.sessionId !== sessionId) {
788
1590
  return session;
789
1591
  }
790
- const base = {
791
- sessionId: session.sessionId,
792
- pid: session.pid,
793
- hostname: session.hostname,
794
- region: session.region,
795
- org: session.org,
796
- space: session.space,
797
- app: session.app,
798
- apiEndpoint: session.apiEndpoint,
799
- localPort: session.localPort,
800
- remotePort: session.remotePort,
801
- cfHomeDir: session.cfHomeDir,
802
- startedAt: session.startedAt,
803
- status
804
- };
805
- const next = message === void 0 ? base : { ...base, message };
1592
+ if (status !== "stopping" && startupMutationBlocked(session)) {
1593
+ updated = session;
1594
+ return session;
1595
+ }
1596
+ if (status === "ready" && session.tunnelPid === void 0) {
1597
+ throw new CfDebuggerError(
1598
+ "SESSION_STATE_CONFLICT",
1599
+ "A debugger session cannot become ready before its tunnel PID is recorded."
1600
+ );
1601
+ }
1602
+ const base = withoutMessage(session);
1603
+ const next = message === void 0 ? { ...base, status } : { ...base, status, message };
806
1604
  updated = next;
807
1605
  return next;
808
1606
  });
809
1607
  if (updated) {
810
- await writeState({ version: "1", sessions: nextSessions });
1608
+ await writeState({ version: "2", sessions: nextSessions });
811
1609
  }
812
1610
  return updated;
813
1611
  });
814
1612
  }
815
1613
  async function updateSessionPid(sessionId, pid) {
1614
+ if (!Number.isSafeInteger(pid) || pid <= 0) {
1615
+ throw new CfDebuggerError("UNSAFE_INPUT", "Tunnel PID must be a positive safe integer.");
1616
+ }
1617
+ return await withFileLock(stateLockPath(), async () => {
1618
+ const raw = await readStateRaw();
1619
+ let updated;
1620
+ const nextSessions = raw.sessions.map((session) => {
1621
+ if (session.sessionId !== sessionId) {
1622
+ return session;
1623
+ }
1624
+ if (startupMutationBlocked(session)) {
1625
+ updated = session;
1626
+ return session;
1627
+ }
1628
+ const next = { ...session, pid, tunnelPid: pid };
1629
+ updated = next;
1630
+ return next;
1631
+ });
1632
+ if (updated !== void 0) {
1633
+ await writeState({ version: "2", sessions: nextSessions });
1634
+ }
1635
+ return updated;
1636
+ });
1637
+ }
1638
+ function withoutMessage(session) {
1639
+ const { message, ...clone } = session;
1640
+ void message;
1641
+ return clone;
1642
+ }
1643
+ function startupMutationBlocked(session) {
1644
+ return session.status === "stopping" || session.stopRequestedAt !== void 0;
1645
+ }
1646
+ async function updateSessionRemoteNodePid(sessionId, remoteNodePid) {
1647
+ resolveNodeTarget({ nodePid: remoteNodePid });
816
1648
  return await withFileLock(stateLockPath(), async () => {
817
1649
  const raw = await readStateRaw();
818
1650
  let updated;
@@ -820,27 +1652,16 @@ async function updateSessionPid(sessionId, pid) {
820
1652
  if (session.sessionId !== sessionId) {
821
1653
  return session;
822
1654
  }
823
- const next = {
824
- sessionId: session.sessionId,
825
- pid,
826
- hostname: session.hostname,
827
- region: session.region,
828
- org: session.org,
829
- space: session.space,
830
- app: session.app,
831
- apiEndpoint: session.apiEndpoint,
832
- localPort: session.localPort,
833
- remotePort: session.remotePort,
834
- cfHomeDir: session.cfHomeDir,
835
- startedAt: session.startedAt,
836
- status: session.status,
837
- ...session.message === void 0 ? {} : { message: session.message }
838
- };
1655
+ if (startupMutationBlocked(session)) {
1656
+ updated = session;
1657
+ return session;
1658
+ }
1659
+ const next = { ...session, remoteNodePid };
839
1660
  updated = next;
840
1661
  return next;
841
1662
  });
842
1663
  if (updated !== void 0) {
843
- await writeState({ version: "1", sessions: nextSessions });
1664
+ await writeState({ version: "2", sessions: nextSessions });
844
1665
  }
845
1666
  return updated;
846
1667
  });
@@ -853,72 +1674,145 @@ async function removeSession(sessionId) {
853
1674
  return void 0;
854
1675
  }
855
1676
  const remaining = raw.sessions.filter((session) => session.sessionId !== sessionId);
856
- await writeState({ version: "1", sessions: remaining });
1677
+ await writeState({ version: "2", sessions: remaining });
857
1678
  return target;
858
1679
  });
859
1680
  }
1681
+ async function requestSessionStop(sessionId) {
1682
+ return await withFileLock(stateLockPath(), async () => {
1683
+ const raw = await readStateRaw();
1684
+ const target = raw.sessions.find((session) => session.sessionId === sessionId);
1685
+ if (target === void 0) {
1686
+ return void 0;
1687
+ }
1688
+ if (target.status === "ready" || target.stopRequestedAt !== void 0) {
1689
+ return { session: target, previousStatus: target.status };
1690
+ }
1691
+ const requested = { ...target, stopRequestedAt: (/* @__PURE__ */ new Date()).toISOString() };
1692
+ const sessions = raw.sessions.map(
1693
+ (session) => session.sessionId === sessionId ? requested : session
1694
+ );
1695
+ await writeState({ version: "2", sessions });
1696
+ return { session: requested, previousStatus: target.status };
1697
+ });
1698
+ }
860
1699
 
861
1700
  // src/debug-session/constants.ts
862
1701
  var DEFAULT_TUNNEL_READY_TIMEOUT_MS = 18e4;
863
1702
  var POST_USR1_DELAY_MS = 300;
864
- var PORT_CLEANUP_DELAY_MS = 600;
865
1703
  var CHILD_SIGTERM_GRACE_MS = 2e3;
866
- var PORT_RECLAIM_DELAY_MS = 250;
1704
+ var CHILD_SIGKILL_GRACE_MS = 1e3;
867
1705
  var PID_TERMINATION_POLL_MS = 100;
868
1706
 
869
1707
  // src/debug-session/orphans.ts
1708
+ import { rm } from "fs/promises";
870
1709
  import { hostname as getHostname2 } from "os";
871
1710
  async function pruneAndCleanupOrphans() {
872
1711
  const result = await readAndPruneActiveSessions();
873
1712
  const host = getHostname2();
874
1713
  for (const removed of result.removed) {
875
- if (removed.hostname === host) {
876
- void killProcessOnPort(removed.localPort);
1714
+ if (removed.hostname === host && isOwnedSessionCfHomeDir(removed.sessionId, removed.cfHomeDir)) {
1715
+ try {
1716
+ await rm(removed.cfHomeDir, { recursive: true, force: true });
1717
+ } catch {
1718
+ }
877
1719
  }
878
1720
  }
879
1721
  return result;
880
1722
  }
881
1723
 
882
1724
  // src/debug-session/processes.ts
883
- import process3 from "process";
884
- function signalPidOrGroup(pid, signal) {
885
- const isWindows = process3.platform === "win32";
886
- if (!isWindows) {
887
- try {
888
- process3.kill(-pid, signal);
889
- return;
890
- } catch {
891
- }
1725
+ import process4 from "process";
1726
+ async function terminatePidOrGroup(pid, timeoutMs = CHILD_SIGTERM_GRACE_MS, pinnedTarget) {
1727
+ const targetKind = pinnedTarget ?? (isProcessGroupAlive(pid) ? "group" : "pid");
1728
+ const targetAlive = () => targetKind === "group" ? isProcessGroupAlive(pid) : isPidAlive(pid);
1729
+ if (!targetAlive()) {
1730
+ return "terminated";
892
1731
  }
893
1732
  try {
894
- process3.kill(pid, signal);
1733
+ process4.kill(targetKind === "group" ? -pid : pid, "SIGTERM");
895
1734
  } catch {
896
1735
  }
897
- }
898
- async function terminatePidOrGroup(pid, timeoutMs = CHILD_SIGTERM_GRACE_MS) {
899
- if (!isPidAlive(pid)) {
900
- return;
901
- }
902
- signalPidOrGroup(pid, "SIGTERM");
903
1736
  const startedAt = Date.now();
904
1737
  while (Date.now() - startedAt < timeoutMs) {
905
- if (!isPidAlive(pid)) {
906
- return;
1738
+ if (!targetAlive()) {
1739
+ return "terminated";
1740
+ }
1741
+ await new Promise((resolve) => {
1742
+ setTimeout(resolve, PID_TERMINATION_POLL_MS);
1743
+ });
1744
+ }
1745
+ try {
1746
+ process4.kill(targetKind === "group" ? -pid : pid, "SIGKILL");
1747
+ } catch {
1748
+ }
1749
+ const forceStartedAt = Date.now();
1750
+ while (Date.now() - forceStartedAt < CHILD_SIGKILL_GRACE_MS) {
1751
+ if (!targetAlive()) {
1752
+ return "terminated";
907
1753
  }
908
1754
  await new Promise((resolve) => {
909
1755
  setTimeout(resolve, PID_TERMINATION_POLL_MS);
910
1756
  });
911
1757
  }
912
- signalPidOrGroup(pid, "SIGKILL");
1758
+ return targetAlive() ? "still-alive" : "terminated";
913
1759
  }
914
1760
  async function killProcessGroupOrProc(child, timeoutMs = CHILD_SIGTERM_GRACE_MS) {
915
1761
  if (child.pid === void 0) {
916
- return;
1762
+ return "terminated";
917
1763
  }
918
- if (child.exitCode !== null || child.signalCode !== null) {
919
- return;
1764
+ const childClosed = child.exitCode !== null || child.signalCode !== null;
1765
+ if (childClosed && process4.platform === "win32") {
1766
+ return "terminated";
1767
+ }
1768
+ return await terminatePidOrGroup(child.pid, timeoutMs, childClosed ? "group" : void 0);
1769
+ }
1770
+
1771
+ // src/debug-session/startup-cancellation.ts
1772
+ var STOP_REQUEST_POLL_MS = 50;
1773
+ function cancellationRequested(sessions, sessionId) {
1774
+ const session = sessions.find((candidate) => candidate.sessionId === sessionId);
1775
+ return session === void 0 || session.stopRequestedAt !== void 0;
1776
+ }
1777
+ function createStartupCancellation(sessionId, callerSignal) {
1778
+ const controller = new AbortController();
1779
+ let active = true;
1780
+ let timer;
1781
+ const onCallerAbort = () => {
1782
+ controller.abort();
1783
+ };
1784
+ const schedule = () => {
1785
+ timer = setTimeout(() => {
1786
+ void poll();
1787
+ }, STOP_REQUEST_POLL_MS);
1788
+ timer.unref();
1789
+ };
1790
+ const poll = async () => {
1791
+ try {
1792
+ const sessions = await readSessionSnapshot();
1793
+ if (active && cancellationRequested(sessions, sessionId)) {
1794
+ controller.abort();
1795
+ }
1796
+ } catch {
1797
+ }
1798
+ if (active && !controller.signal.aborted) {
1799
+ schedule();
1800
+ }
1801
+ };
1802
+ callerSignal?.addEventListener("abort", onCallerAbort, { once: true });
1803
+ if (callerSignal?.aborted === true) {
1804
+ controller.abort();
1805
+ } else {
1806
+ void poll();
920
1807
  }
921
- await terminatePidOrGroup(child.pid, timeoutMs);
1808
+ return {
1809
+ signal: controller.signal,
1810
+ dispose: () => {
1811
+ active = false;
1812
+ clearTimeout(timer);
1813
+ callerSignal?.removeEventListener("abort", onCallerAbort);
1814
+ }
1815
+ };
922
1816
  }
923
1817
 
924
1818
  // src/debug-session/start.ts
@@ -940,9 +1834,30 @@ function checkAbort(signal) {
940
1834
  throw new CfDebuggerError("ABORTED", "Operation aborted by caller");
941
1835
  }
942
1836
  }
1837
+ function requireStartupState(state, expectedStatus) {
1838
+ if (state === void 0) {
1839
+ throw new CfDebuggerError(
1840
+ "SESSION_STATE_LOST",
1841
+ "Debugger session ownership state disappeared during startup."
1842
+ );
1843
+ }
1844
+ if (state.stopRequestedAt !== void 0 || state.status === "stopping") {
1845
+ throw new CfDebuggerError("ABORTED", "Debugger session stop was requested during startup.");
1846
+ }
1847
+ if (expectedStatus !== void 0 && state.status !== expectedStatus) {
1848
+ throw new CfDebuggerError(
1849
+ "SESSION_STATE_CONFLICT",
1850
+ `Debugger session state did not transition to ${expectedStatus}.`
1851
+ );
1852
+ }
1853
+ return state;
1854
+ }
1855
+ async function transitionStartupStatus(sessionId, status, message) {
1856
+ return requireStartupState(await updateSessionStatus(sessionId, status, message), status);
1857
+ }
943
1858
  function requireCredentials(options) {
944
- const email = options.email ?? process4.env["SAP_EMAIL"];
945
- const password = options.password ?? process4.env["SAP_PASSWORD"];
1859
+ const email = options.email ?? process5.env["SAP_EMAIL"];
1860
+ const password = options.password ?? process5.env["SAP_PASSWORD"];
946
1861
  if (email === void 0 || email === "") {
947
1862
  throw new CfDebuggerError(
948
1863
  "MISSING_CREDENTIALS",
@@ -957,12 +1872,15 @@ function requireCredentials(options) {
957
1872
  }
958
1873
  return { email, password };
959
1874
  }
960
- async function registerSession(options, apiEndpoint) {
1875
+ async function registerSession(options, target, apiEndpoint) {
961
1876
  const registration = await registerNewSession({
962
1877
  region: options.region,
963
1878
  org: options.org,
964
1879
  space: options.space,
965
1880
  app: options.app,
1881
+ process: target.process,
1882
+ instance: target.instance,
1883
+ ...target.nodePid === void 0 ? {} : { nodePid: target.nodePid },
966
1884
  apiEndpoint,
967
1885
  ...options.preferredPort === void 0 ? {} : { preferredPort: options.preferredPort },
968
1886
  portProbe: isPortFree,
@@ -978,50 +1896,55 @@ async function registerSession(options, apiEndpoint) {
978
1896
  }
979
1897
  async function loginAndTarget(options, apiEndpoint, email, password, context, sessionId, emit) {
980
1898
  emit("logging-in");
981
- await updateSessionStatus(sessionId, "logging-in");
1899
+ await transitionStartupStatus(sessionId, "logging-in");
982
1900
  await cfLogin(apiEndpoint, email, password, context);
983
- checkAbort(options.signal);
1901
+ checkAbort(context.signal);
984
1902
  emit("targeting");
985
- await updateSessionStatus(sessionId, "targeting");
1903
+ await transitionStartupStatus(sessionId, "targeting");
986
1904
  await cfTarget(options.org, options.space, context);
987
- checkAbort(options.signal);
1905
+ checkAbort(context.signal);
988
1906
  }
989
- async function signalRemoteNode(options, context, sessionId, emit) {
1907
+ async function signalRemoteNode(options, target, context, sessionId, emit) {
990
1908
  emit("signaling");
991
- await updateSessionStatus(sessionId, "signaling");
992
- const signalResult = await cfSshOneShot(options.app, `kill -s USR1 $(pidof node)`, context);
1909
+ await transitionStartupStatus(sessionId, "signaling");
1910
+ const signalResult = await executeRemoteSignal(options.app, target, context);
993
1911
  if (!isSshDisabledError(signalResult.stderr)) {
994
- if (signalResult.exitCode === 0) {
995
- return;
996
- }
1912
+ return parseSignalResult(options.app, signalResult);
1913
+ }
1914
+ if (options.allowSshEnableRestart === false) {
997
1915
  throw new CfDebuggerError(
998
- "USR1_SIGNAL_FAILED",
999
- `Failed to send SIGUSR1 to the Node.js process on ${options.app}: ${signalFailureDetail(signalResult)}`,
1916
+ "SSH_NOT_ENABLED",
1917
+ `SSH is disabled for ${options.app}; automatic SSH enable and app restart are not allowed.`,
1000
1918
  signalResult.stderr
1001
1919
  );
1002
1920
  }
1921
+ await enableSshAndRestart(options, target, context, sessionId, emit);
1922
+ return await retryRemoteSignal(options, target, context, sessionId, emit);
1923
+ }
1924
+ async function enableSshAndRestart(options, target, context, sessionId, emit) {
1925
+ if (target.nodePid !== void 0) {
1926
+ throw new CfDebuggerError(
1927
+ "NODE_PID_RESTART_UNSAFE",
1928
+ `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.`
1929
+ );
1930
+ }
1003
1931
  const alreadyEnabled = await cfSshEnabled(options.app, context);
1004
1932
  if (!alreadyEnabled) {
1005
1933
  emit("ssh-enabling", "Enabling SSH on the app");
1006
- await updateSessionStatus(sessionId, "ssh-enabling");
1934
+ await transitionStartupStatus(sessionId, "ssh-enabling");
1007
1935
  await cfEnableSsh(options.app, context);
1008
1936
  }
1009
1937
  emit("ssh-restarting", "Restarting app so SSH becomes active");
1010
- await updateSessionStatus(sessionId, "ssh-restarting");
1938
+ await transitionStartupStatus(sessionId, "ssh-restarting");
1011
1939
  await cfRestartApp(options.app, context);
1012
- checkAbort(options.signal);
1013
- await retryRemoteSignal(options, context, sessionId, emit);
1940
+ checkAbort(context.signal);
1014
1941
  }
1015
- async function retryRemoteSignal(options, context, sessionId, emit) {
1942
+ async function retryRemoteSignal(options, target, context, sessionId, emit) {
1016
1943
  emit("signaling");
1017
- await updateSessionStatus(sessionId, "signaling");
1018
- const retrySignalResult = await cfSshOneShot(
1019
- options.app,
1020
- `kill -s USR1 $(pidof node)`,
1021
- context
1022
- );
1944
+ await transitionStartupStatus(sessionId, "signaling");
1945
+ const retrySignalResult = await executeRemoteSignal(options.app, target, context);
1023
1946
  if (retrySignalResult.exitCode === 0) {
1024
- return;
1947
+ return parseSignalResult(options.app, retrySignalResult);
1025
1948
  }
1026
1949
  throw new CfDebuggerError(
1027
1950
  "USR1_SIGNAL_FAILED",
@@ -1029,36 +1952,77 @@ async function retryRemoteSignal(options, context, sessionId, emit) {
1029
1952
  retrySignalResult.stderr
1030
1953
  );
1031
1954
  }
1032
- async function waitAfterSignal(signal) {
1033
- await new Promise((resolve) => {
1034
- setTimeout(resolve, POST_USR1_DELAY_MS);
1955
+ async function executeRemoteSignal(appName, target, context) {
1956
+ return await cfSshOneShot(appName, buildNodeInspectorCommand(target.nodePid), context, {
1957
+ process: target.process,
1958
+ instance: target.instance
1035
1959
  });
1036
- checkAbort(signal);
1037
1960
  }
1038
- async function ensurePortAvailable(localPort) {
1039
- if (await isPortFree(localPort)) {
1040
- return;
1961
+ function parseSignalResult(appName, result) {
1962
+ if (result.exitCode !== 0) {
1963
+ throw new CfDebuggerError(
1964
+ "USR1_SIGNAL_FAILED",
1965
+ `Failed to send SIGUSR1 to the Node.js process on ${appName}: ${signalFailureDetail(result)}`,
1966
+ result.stderr
1967
+ );
1968
+ }
1969
+ if (result.outputTruncated) {
1970
+ throw new CfDebuggerError(
1971
+ "INSPECTOR_OUTPUT_TOO_LARGE",
1972
+ "Inspector startup output exceeded the configured capture limit."
1973
+ );
1974
+ }
1975
+ return parseNodeInspectorMarkers(result.stdout).remoteNodePid;
1976
+ }
1977
+ async function waitAfterSignal(signal) {
1978
+ if (signal?.aborted) {
1979
+ throw new CfDebuggerError("ABORTED", "Operation aborted by caller");
1041
1980
  }
1042
- await killProcessOnPort(localPort);
1043
- await new Promise((resolve) => {
1044
- setTimeout(resolve, PORT_RECLAIM_DELAY_MS);
1981
+ await new Promise((resolve, reject) => {
1982
+ const onAbort = () => {
1983
+ clearTimeout(timer);
1984
+ reject(new CfDebuggerError("ABORTED", "Operation aborted by caller"));
1985
+ };
1986
+ const timer = setTimeout(() => {
1987
+ signal?.removeEventListener("abort", onAbort);
1988
+ resolve();
1989
+ }, POST_USR1_DELAY_MS);
1990
+ signal?.addEventListener("abort", onAbort, { once: true });
1045
1991
  });
1992
+ }
1993
+ async function ensurePortAvailable(localPort) {
1046
1994
  if (!await isPortFree(localPort)) {
1047
1995
  throw new CfDebuggerError(
1048
1996
  "PORT_UNAVAILABLE",
1049
- `Local port ${localPort.toString()} is in use and could not be reclaimed for the tunnel.`
1997
+ `Local port ${localPort.toString()} was taken before the tunnel could start.`
1050
1998
  );
1051
1999
  }
1052
2000
  }
1053
- async function openReadyTunnel(options, session, context, tunnelReadyTimeoutMs, onChild) {
2001
+ async function openReadyTunnel(options, target, session, context, tunnelReadyTimeoutMs, onChild) {
1054
2002
  await ensurePortAvailable(session.localPort);
1055
- const child = spawnSshTunnel(options.app, session.localPort, session.remotePort, context);
2003
+ checkAbort(context.signal);
2004
+ const child = spawnSshTunnel(options.app, session.localPort, session.remotePort, context, {
2005
+ process: target.process,
2006
+ instance: target.instance
2007
+ });
1056
2008
  onChild(child);
1057
- if (child.pid !== void 0) {
1058
- await updateSessionPid(session.sessionId, child.pid);
2009
+ const childPid = child.pid;
2010
+ if (childPid === void 0) {
2011
+ throw new CfDebuggerError("TUNNEL_PROCESS_MISSING", "The CF SSH tunnel process did not expose a PID.");
1059
2012
  }
1060
- const ready = await probeTunnelReady(session.localPort, tunnelReadyTimeoutMs);
1061
- checkAbort(options.signal);
2013
+ const pidState = requireStartupState(await updateSessionPid(session.sessionId, childPid));
2014
+ if (pidState.tunnelPid !== childPid || pidState.pid !== childPid) {
2015
+ throw new CfDebuggerError(
2016
+ "SESSION_STATE_CONFLICT",
2017
+ "Debugger session did not retain ownership of the spawned tunnel process."
2018
+ );
2019
+ }
2020
+ const ready = await probeTunnelReady(
2021
+ session.localPort,
2022
+ tunnelReadyTimeoutMs,
2023
+ context.signal
2024
+ );
2025
+ checkAbort(context.signal);
1062
2026
  if (!ready) {
1063
2027
  throw new CfDebuggerError(
1064
2028
  "TUNNEL_NOT_READY",
@@ -1066,15 +2030,21 @@ async function openReadyTunnel(options, session, context, tunnelReadyTimeoutMs,
1066
2030
  );
1067
2031
  }
1068
2032
  const listeningPid = await findListeningProcessId(session.localPort);
1069
- const activePid = listeningPid ?? child.pid ?? session.pid;
1070
- if (activePid !== session.pid) {
1071
- await updateSessionPid(session.sessionId, activePid);
2033
+ if (listeningPid === void 0) {
2034
+ throw new CfDebuggerError(
2035
+ "TUNNEL_OWNER_UNVERIFIED",
2036
+ `Could not verify the owner of local tunnel port ${session.localPort.toString()}.`
2037
+ );
2038
+ }
2039
+ if (listeningPid !== childPid) {
2040
+ throw new CfDebuggerError(
2041
+ "TUNNEL_OWNER_MISMATCH",
2042
+ `Local tunnel port ${session.localPort.toString()} is owned by an unexpected process.`
2043
+ );
1072
2044
  }
1073
- return { child, activePid };
1074
2045
  }
1075
- function attachTunnelEvents(child, markClosed, resolveExit, emit) {
2046
+ function attachTunnelEvents(child, resolveExit, emit) {
1076
2047
  child.on("close", (code) => {
1077
- markClosed();
1078
2048
  resolveExit(code);
1079
2049
  });
1080
2050
  child.on("error", (err) => {
@@ -1086,139 +2056,291 @@ function createHandle(session, emit, finalize, exitPromise) {
1086
2056
  return {
1087
2057
  session,
1088
2058
  dispose: async () => {
1089
- disposePromise ??= (async () => {
1090
- emit("stopping");
1091
- await updateSessionStatus(session.sessionId, "stopping");
1092
- await finalize();
2059
+ const attempt = disposePromise ?? (async () => {
2060
+ await runCleanupActions([
2061
+ () => {
2062
+ emit("stopping");
2063
+ },
2064
+ async () => {
2065
+ await updateSessionStatus(session.sessionId, "stopping");
2066
+ },
2067
+ finalize
2068
+ ], "Debugger disposal failed");
1093
2069
  })();
1094
- await disposePromise;
2070
+ disposePromise = attempt;
2071
+ try {
2072
+ await attempt;
2073
+ } catch (error) {
2074
+ if (disposePromise === attempt) {
2075
+ disposePromise = void 0;
2076
+ }
2077
+ throw error;
2078
+ }
1095
2079
  },
1096
2080
  waitForExit: async () => {
1097
2081
  return await exitPromise;
1098
2082
  }
1099
2083
  };
1100
2084
  }
1101
- async function startDebugger(options) {
1102
- const { email, password } = requireCredentials(options);
1103
- const apiEndpoint = resolveApiEndpoint(options.region, options.apiEndpoint);
1104
- const tunnelReadyTimeoutMs = options.tunnelReadyTimeoutMs ?? DEFAULT_TUNNEL_READY_TIMEOUT_MS;
1105
- const emit = (status, message) => {
1106
- options.onStatus?.(status, message);
1107
- };
1108
- checkAbort(options.signal);
1109
- await pruneAndCleanupOrphans();
1110
- const session = await registerSession(options, apiEndpoint);
1111
- const context = { cfHome: session.cfHomeDir };
2085
+ async function runCleanupActions(actions, aggregateMessage) {
2086
+ let errors = [];
2087
+ for (const action of actions) {
2088
+ try {
2089
+ await action();
2090
+ } catch (error) {
2091
+ errors = [...errors, error];
2092
+ }
2093
+ }
2094
+ if (errors.length === 1) {
2095
+ throw errors[0];
2096
+ }
2097
+ if (errors.length > 1) {
2098
+ throw new AggregateError(errors, aggregateMessage);
2099
+ }
2100
+ }
2101
+ async function prepareCfHome(cfHomeDir) {
2102
+ await mkdir3(cfHomeDir, { recursive: true, mode: 448 });
2103
+ await chmod3(cfHomeDir, 448);
2104
+ }
2105
+ function createTunnelLifecycle(session, emit) {
1112
2106
  let child;
1113
- let tunnelClosed = false;
1114
2107
  let exitResolve = (_code) => {
1115
2108
  throw new Error("Exit resolver was used before initialization");
1116
2109
  };
1117
2110
  const exitPromise = new Promise((resolve) => {
1118
2111
  exitResolve = resolve;
1119
2112
  });
2113
+ const observeChild = (tunnelChild) => {
2114
+ child = tunnelChild;
2115
+ attachTunnelEvents(tunnelChild, exitResolve, emit);
2116
+ };
1120
2117
  const finalize = async () => {
1121
- if (!tunnelClosed) {
1122
- tunnelClosed = true;
1123
- if (child) {
1124
- await killProcessGroupOrProc(child);
1125
- }
1126
- setTimeout(() => {
1127
- void killProcessOnPort(session.localPort);
1128
- }, PORT_CLEANUP_DELAY_MS);
2118
+ const termination = child === void 0 ? "terminated" : await killProcessGroupOrProc(child);
2119
+ const portListening = child !== void 0 && await isPortListening(session.localPort);
2120
+ if (termination === "still-alive" || portListening) {
2121
+ throw new CfDebuggerError(
2122
+ "TUNNEL_TERMINATION_FAILED",
2123
+ `Tunnel for session ${session.sessionId} did not terminate; state and CF home were retained.`
2124
+ );
1129
2125
  }
1130
- await removeSession(session.sessionId);
1131
- await cleanupFilesystem(session.cfHomeDir);
2126
+ await runCleanupActions([
2127
+ async () => {
2128
+ await removeSession(session.sessionId);
2129
+ },
2130
+ async () => {
2131
+ await cleanupFilesystem(session.cfHomeDir);
2132
+ }
2133
+ ], "Debugger resource cleanup failed");
1132
2134
  emit("stopped");
1133
2135
  };
2136
+ return { exitPromise, finalize, observeChild };
2137
+ }
2138
+ async function establishDebuggerSession(inputs) {
2139
+ const { options, target, session, context, credentials, timeoutMs, lifecycle, emit } = inputs;
2140
+ await prepareCfHome(session.cfHomeDir);
2141
+ await loginAndTarget(
2142
+ options,
2143
+ session.apiEndpoint,
2144
+ credentials.email,
2145
+ credentials.password,
2146
+ context,
2147
+ session.sessionId,
2148
+ emit
2149
+ );
2150
+ await ensurePortAvailable(session.localPort);
2151
+ const remoteNodePid = await signalRemoteNode(options, target, context, session.sessionId, emit);
2152
+ const remoteState = requireStartupState(
2153
+ await updateSessionRemoteNodePid(session.sessionId, remoteNodePid)
2154
+ );
2155
+ if (remoteState.remoteNodePid !== remoteNodePid) {
2156
+ throw new CfDebuggerError(
2157
+ "SESSION_STATE_CONFLICT",
2158
+ "Debugger session did not retain the selected remote Node PID."
2159
+ );
2160
+ }
2161
+ await waitAfterSignal(context.signal);
2162
+ emit("tunneling");
2163
+ await transitionStartupStatus(session.sessionId, "tunneling");
2164
+ await openReadyTunnel(
2165
+ options,
2166
+ target,
2167
+ session,
2168
+ context,
2169
+ timeoutMs,
2170
+ lifecycle.observeChild
2171
+ );
2172
+ emit("ready");
2173
+ return await transitionStartupStatus(session.sessionId, "ready");
2174
+ }
2175
+ async function failAfterStartupCleanup(error, finalize, emit) {
2176
+ try {
2177
+ await runCleanupActions([
2178
+ () => {
2179
+ emit("error", error instanceof Error ? error.message : String(error));
2180
+ },
2181
+ finalize
2182
+ ], "Debugger startup failure reporting and cleanup failed");
2183
+ } catch (cleanupError) {
2184
+ throw new AggregateError(
2185
+ [error, cleanupError],
2186
+ "Debugger startup failed and resource cleanup was incomplete",
2187
+ { cause: cleanupError }
2188
+ );
2189
+ }
2190
+ throw error;
2191
+ }
2192
+ async function startDebugger(options) {
2193
+ const target = resolveNodeTarget(options);
2194
+ const credentials = requireCredentials(options);
2195
+ const apiEndpoint = resolveApiEndpoint(options.region, options.apiEndpoint);
2196
+ const tunnelReadyTimeoutMs = options.tunnelReadyTimeoutMs ?? DEFAULT_TUNNEL_READY_TIMEOUT_MS;
2197
+ const emit = (status, message) => {
2198
+ options.onStatus?.(status, message);
2199
+ };
2200
+ checkAbort(options.signal);
2201
+ await pruneAndCleanupOrphans();
2202
+ const session = await registerSession(options, target, apiEndpoint);
2203
+ const cancellation = createStartupCancellation(session.sessionId, options.signal);
2204
+ const context = {
2205
+ cfHome: session.cfHomeDir,
2206
+ signal: cancellation.signal
2207
+ };
2208
+ const lifecycle = createTunnelLifecycle(session, emit);
1134
2209
  try {
1135
- await mkdir3(session.cfHomeDir, { recursive: true });
1136
- await loginAndTarget(options, apiEndpoint, email, password, context, session.sessionId, emit);
1137
- await killProcessOnPort(session.localPort);
1138
- await new Promise((resolve) => {
1139
- setTimeout(resolve, 200);
1140
- });
1141
- await signalRemoteNode(options, context, session.sessionId, emit);
1142
- await waitAfterSignal(options.signal);
1143
- emit("tunneling");
1144
- await updateSessionStatus(session.sessionId, "tunneling");
1145
- const tunnel = await openReadyTunnel(
2210
+ const activeSession = await establishDebuggerSession({
1146
2211
  options,
2212
+ target,
1147
2213
  session,
1148
2214
  context,
1149
- tunnelReadyTimeoutMs,
1150
- (tunnelChild) => {
1151
- child = tunnelChild;
1152
- attachTunnelEvents(tunnelChild, () => {
1153
- tunnelClosed = true;
1154
- }, exitResolve, emit);
1155
- }
1156
- );
1157
- child = tunnel.child;
1158
- emit("ready");
1159
- const readySession = await updateSessionStatus(session.sessionId, "ready");
1160
- const activeSession = readySession ?? { ...session, pid: tunnel.activePid, status: "ready" };
1161
- return createHandle(activeSession, emit, finalize, exitPromise);
2215
+ credentials,
2216
+ timeoutMs: tunnelReadyTimeoutMs,
2217
+ lifecycle,
2218
+ emit
2219
+ });
2220
+ cancellation.dispose();
2221
+ return createHandle(activeSession, emit, lifecycle.finalize, lifecycle.exitPromise);
1162
2222
  } catch (err) {
1163
- const message = err instanceof Error ? err.message : String(err);
1164
- emit("error", message);
1165
- await finalize();
1166
- throw err;
2223
+ cancellation.dispose();
2224
+ return await failAfterStartupCleanup(err, lifecycle.finalize, emit);
1167
2225
  }
1168
2226
  }
1169
2227
  async function cleanupFilesystem(cfHomeDir) {
1170
- try {
1171
- await rm(cfHomeDir, { recursive: true, force: true });
1172
- } catch {
1173
- }
2228
+ await rm2(cfHomeDir, { recursive: true, force: true });
1174
2229
  }
1175
2230
 
1176
2231
  // src/debug-session/sessions.ts
1177
- import { rm as rm2 } from "fs/promises";
1178
- import process5 from "process";
2232
+ import { rm as rm3 } from "fs/promises";
2233
+ import { hostname as getHostname3 } from "os";
2234
+ import process6 from "process";
1179
2235
  function findMatchingSession(sessions, options) {
1180
2236
  if (options.sessionId !== void 0) {
1181
2237
  return sessions.find((s) => s.sessionId === options.sessionId);
1182
2238
  }
1183
2239
  if (options.key !== void 0) {
1184
2240
  const key = options.key;
1185
- return sessions.find((s) => matchesKey(s, key));
2241
+ const matches = sessions.filter((session) => matchesKey(session, key));
2242
+ if (matches.length > 1) {
2243
+ throw new CfDebuggerError(
2244
+ "SESSION_AMBIGUOUS",
2245
+ "Multiple debugger sessions match this target. Pass an exact session ID, API endpoint, or Node PID."
2246
+ );
2247
+ }
2248
+ return matches[0];
1186
2249
  }
1187
2250
  return void 0;
1188
2251
  }
1189
- async function cleanupSession(target, stale) {
1190
- if (!stale && target.pid !== process5.pid) {
2252
+ async function ownsRecordedTunnel(target) {
2253
+ if (target.tunnelPid === void 0 || !await isPortListening(target.localPort)) {
2254
+ return false;
2255
+ }
2256
+ return await findListeningProcessId(target.localPort) === target.tunnelPid;
2257
+ }
2258
+ async function terminateVerifiedTunnel(target) {
2259
+ if (target.tunnelPid !== void 0 && target.tunnelPid !== process6.pid) {
1191
2260
  try {
1192
- await terminatePidOrGroup(target.pid);
2261
+ return await terminatePidOrGroup(target.tunnelPid);
1193
2262
  } catch {
2263
+ return "still-alive";
1194
2264
  }
1195
2265
  }
1196
- setTimeout(() => {
1197
- void killProcessOnPort(target.localPort);
1198
- }, PORT_CLEANUP_DELAY_MS);
1199
- const removed = stale ? void 0 : await removeSession(target.sessionId);
1200
- try {
1201
- await rm2(target.cfHomeDir, { recursive: true, force: true });
1202
- } catch {
2266
+ return target.tunnelPid === process6.pid ? "still-alive" : "terminated";
2267
+ }
2268
+ async function terminateVerifiedTunnelAndConfirm(target) {
2269
+ const termination = await terminateVerifiedTunnel(target);
2270
+ if (termination === "still-alive" || await ownsRecordedTunnel(target)) {
2271
+ throw new CfDebuggerError(
2272
+ "TUNNEL_TERMINATION_FAILED",
2273
+ `Tunnel process for session ${target.sessionId} did not terminate; state was retained.`
2274
+ );
2275
+ }
2276
+ }
2277
+ async function cleanupOwnedCfHome(target, locallyOwned) {
2278
+ if (locallyOwned && isOwnedSessionCfHomeDir(target.sessionId, target.cfHomeDir)) {
2279
+ try {
2280
+ await rm3(target.cfHomeDir, { recursive: true, force: true });
2281
+ } catch {
2282
+ }
2283
+ }
2284
+ }
2285
+ async function removeOwnedSession(target, stale) {
2286
+ const removed = await removeSession(target.sessionId);
2287
+ await cleanupOwnedCfHome(target, true);
2288
+ return { ...removed ?? target, stale, pending: false };
2289
+ }
2290
+ function ownershipError(target) {
2291
+ return new CfDebuggerError(
2292
+ "TUNNEL_OWNERSHIP_UNVERIFIED",
2293
+ `Cannot safely stop session ${target.sessionId}: local tunnel ownership could not be verified.`
2294
+ );
2295
+ }
2296
+ async function stopReadySession(target) {
2297
+ if (await ownsRecordedTunnel(target)) {
2298
+ await terminateVerifiedTunnelAndConfirm(target);
2299
+ return await removeOwnedSession(target, false);
2300
+ }
2301
+ const tunnelDead = target.tunnelPid !== void 0 && !isPidOrGroupAlive(target.tunnelPid);
2302
+ if (tunnelDead && !await isPortListening(target.localPort)) {
2303
+ return await removeOwnedSession(target, true);
2304
+ }
2305
+ throw ownershipError(target);
2306
+ }
2307
+ async function stopStartingSession(target) {
2308
+ if (target.status === "stopping" && target.tunnelPid !== void 0 && !isPidOrGroupAlive(target.tunnelPid) && !await isPortListening(target.localPort)) {
2309
+ return await removeOwnedSession(target, true);
2310
+ }
2311
+ if (isPidAlive(target.controllerPid ?? target.pid)) {
2312
+ return { ...target, stale: false, pending: true };
2313
+ }
2314
+ if (await ownsRecordedTunnel(target)) {
2315
+ await terminateVerifiedTunnelAndConfirm(target);
2316
+ return await removeOwnedSession(target, false);
2317
+ }
2318
+ const tunnelAlive = target.tunnelPid !== void 0 && isPidOrGroupAlive(target.tunnelPid);
2319
+ if (!tunnelAlive && !await isPortListening(target.localPort)) {
2320
+ return await removeOwnedSession(target, true);
1203
2321
  }
1204
- return { ...removed ?? target, stale };
2322
+ throw ownershipError(target);
1205
2323
  }
1206
2324
  async function stopDebugger(options) {
1207
- const pruneResult = await pruneAndCleanupOrphans();
1208
- const activeTarget = findMatchingSession(pruneResult.sessions, options);
1209
- if (activeTarget !== void 0) {
1210
- return await cleanupSession(activeTarget, false);
2325
+ const localSessions = (await readSessionSnapshot()).filter(
2326
+ (session) => session.hostname === getHostname3()
2327
+ );
2328
+ const target = findMatchingSession(localSessions, options);
2329
+ if (target === void 0) {
2330
+ return void 0;
1211
2331
  }
1212
- const staleTarget = findMatchingSession(pruneResult.removed, options);
1213
- if (staleTarget !== void 0) {
1214
- return await cleanupSession(staleTarget, true);
2332
+ const claim = await requestSessionStop(target.sessionId);
2333
+ if (claim === void 0) {
2334
+ return void 0;
1215
2335
  }
1216
- return void 0;
2336
+ return claim.previousStatus === "ready" ? await stopReadySession(claim.session) : await stopStartingSession(claim.session);
1217
2337
  }
1218
2338
  async function stopAllDebuggers() {
1219
- const pruneResult = await pruneAndCleanupOrphans();
1220
- let stopped = pruneResult.removed.length;
1221
- for (const session of pruneResult.sessions) {
2339
+ const sessions = (await readSessionSnapshot()).filter(
2340
+ (session) => session.hostname === getHostname3()
2341
+ );
2342
+ let stopped = 0;
2343
+ for (const session of sessions) {
1222
2344
  const result = await stopDebugger({ sessionId: session.sessionId });
1223
2345
  if (result) {
1224
2346
  stopped += 1;
@@ -1231,15 +2353,15 @@ async function listSessions() {
1231
2353
  }
1232
2354
  async function getSession(key) {
1233
2355
  const sessions = await readActiveSessions();
1234
- return sessions.find((s) => matchesKey(s, key));
2356
+ return findMatchingSession(sessions, { key });
1235
2357
  }
1236
2358
 
1237
2359
  // src/cli.ts
1238
2360
  function readRequiredOption(value, flag) {
1239
2361
  if (value === void 0 || value === "") {
1240
- process6.stderr.write(`Missing required option ${flag}
2362
+ process7.stderr.write(`Missing required option ${flag}
1241
2363
  `);
1242
- process6.exit(1);
2364
+ process7.exit(1);
1243
2365
  }
1244
2366
  return value;
1245
2367
  }
@@ -1249,9 +2371,9 @@ function parseOptionalPort(raw) {
1249
2371
  }
1250
2372
  const port = Number.parseInt(raw, 10);
1251
2373
  if (Number.isNaN(port) || port <= 0 || port > 65535) {
1252
- process6.stderr.write(`Invalid port: ${raw}
2374
+ process7.stderr.write(`Invalid port: ${raw}
1253
2375
  `);
1254
- process6.exit(1);
2376
+ process7.exit(1);
1255
2377
  }
1256
2378
  return port;
1257
2379
  }
@@ -1261,16 +2383,32 @@ function parseOptionalTimeout(raw) {
1261
2383
  }
1262
2384
  const seconds = Number.parseInt(raw, 10);
1263
2385
  if (Number.isNaN(seconds) || seconds <= 0) {
1264
- process6.stderr.write(`Invalid timeout: ${raw}
2386
+ process7.stderr.write(`Invalid timeout: ${raw}
1265
2387
  `);
1266
- process6.exit(1);
2388
+ process7.exit(1);
1267
2389
  }
1268
2390
  return seconds * 1e3;
1269
2391
  }
2392
+ function parseOptionalInteger(raw, label, minimum) {
2393
+ if (raw === void 0) {
2394
+ return void 0;
2395
+ }
2396
+ if (!/^\d+$/.test(raw)) {
2397
+ throw new CfDebuggerError("UNSAFE_INPUT", `${label} must be an integer.`);
2398
+ }
2399
+ const value = Number(raw);
2400
+ if (!Number.isSafeInteger(value) || value < minimum) {
2401
+ throw new CfDebuggerError(
2402
+ "UNSAFE_INPUT",
2403
+ `${label} must be at least ${minimum.toString()} and within the safe integer range.`
2404
+ );
2405
+ }
2406
+ return value;
2407
+ }
1270
2408
  function logStatus(verbose, status, message) {
1271
2409
  if (verbose) {
1272
2410
  const suffix = message === void 0 ? "" : `: ${message}`;
1273
- process6.stdout.write(`[cf-debugger] ${status}${suffix}
2411
+ process7.stdout.write(`[cf-debugger] ${status}${suffix}
1274
2412
  `);
1275
2413
  }
1276
2414
  }
@@ -1287,84 +2425,106 @@ function mergeSelector(selector, opts) {
1287
2425
  }
1288
2426
  throw new CfDebuggerError("UNSAFE_INPUT", "Invalid app selector format. Expected <app> or <region>/<org>/<space>/<app>.");
1289
2427
  }
1290
- async function handleStart(selector, rawOpts) {
1291
- const opts = mergeSelector(selector, rawOpts);
1292
- const app = readRequiredOption(opts.app, "--app or selector");
1293
- const key = await resolveSessionKey({ ...opts, app });
1294
- const verbose = opts.verbose ?? false;
1295
- const preferredPort = parseOptionalPort(opts.port);
1296
- const tunnelReadyTimeoutMs = parseOptionalTimeout(opts.timeout);
1297
- const abortController = new AbortController();
1298
- const onStartupSignal = (exitCode) => () => {
1299
- abortController.abort();
1300
- process6.stderr.write(`
2428
+ function startupAbort(app) {
2429
+ const controller = new AbortController();
2430
+ const handler = (exitCode) => () => {
2431
+ controller.abort();
2432
+ process7.stderr.write(`
1301
2433
  Aborting startup for ${app}...
1302
2434
  `);
1303
2435
  setTimeout(() => {
1304
- process6.exit(exitCode);
2436
+ process7.exit(exitCode);
1305
2437
  }, 5e3).unref();
1306
2438
  };
1307
- const startupSigint = onStartupSignal(130);
1308
- const startupSigterm = onStartupSignal(143);
1309
- process6.on("SIGINT", startupSigint);
1310
- process6.on("SIGTERM", startupSigterm);
1311
- let handle;
1312
- try {
1313
- handle = await startDebugger({
1314
- region: key.region,
1315
- org: key.org,
1316
- space: key.space,
1317
- app,
1318
- verbose,
1319
- signal: abortController.signal,
1320
- ...preferredPort === void 0 ? {} : { preferredPort },
1321
- ...tunnelReadyTimeoutMs === void 0 ? {} : { tunnelReadyTimeoutMs },
1322
- onStatus: (status, message) => {
1323
- logStatus(verbose, status, message);
1324
- }
1325
- });
1326
- } finally {
1327
- process6.off("SIGINT", startupSigint);
1328
- process6.off("SIGTERM", startupSigterm);
1329
- }
1330
- process6.stdout.write(
2439
+ const onSigint = handler(130);
2440
+ const onSigterm = handler(143);
2441
+ process7.on("SIGINT", onSigint);
2442
+ process7.on("SIGTERM", onSigterm);
2443
+ return {
2444
+ signal: controller.signal,
2445
+ dispose: () => {
2446
+ process7.off("SIGINT", onSigint);
2447
+ process7.off("SIGTERM", onSigterm);
2448
+ }
2449
+ };
2450
+ }
2451
+ function writeReady(app, key, handle) {
2452
+ process7.stdout.write(
1331
2453
  `Debugger ready for ${app} (${key.region}/${key.org}/${key.space}).
2454
+ Process: ${key.process}
2455
+ Instance: ${key.instance.toString()}
1332
2456
  Local port: ${handle.session.localPort.toString()}
1333
2457
  Remote port: ${handle.session.remotePort.toString()}
1334
2458
  Session id: ${handle.session.sessionId}
1335
- PID: ${handle.session.pid.toString()}
2459
+ Tunnel PID: ${handle.session.pid.toString()}
2460
+ Node PID: ${handle.session.remoteNodePid?.toString() ?? "unknown"}
1336
2461
  Press Ctrl+C to stop.
1337
2462
  `
1338
2463
  );
1339
- let disposePromise;
1340
- const dispose = async () => {
1341
- disposePromise ??= (async () => {
1342
- process6.stdout.write(`
2464
+ }
2465
+ function handleDisposer(app, handle) {
2466
+ let pending;
2467
+ return async () => {
2468
+ pending ??= (async () => {
2469
+ process7.stdout.write(`
1343
2470
  Stopping debugger for ${app}...
1344
2471
  `);
1345
2472
  try {
1346
2473
  await handle.dispose();
1347
- } catch (err) {
1348
- const msg = err instanceof Error ? err.message : String(err);
1349
- process6.stderr.write(`Error during stop: ${msg}
2474
+ } catch (error) {
2475
+ const message = error instanceof Error ? error.message : String(error);
2476
+ process7.stderr.write(`Error during stop: ${message}
1350
2477
  `);
1351
2478
  }
1352
2479
  })();
1353
- await disposePromise;
2480
+ await pending;
1354
2481
  };
1355
- process6.on("SIGINT", () => {
1356
- void dispose().then(() => {
1357
- process6.exit(130);
1358
- });
1359
- });
1360
- process6.on("SIGTERM", () => {
2482
+ }
2483
+ async function waitForHandle(app, handle) {
2484
+ const dispose = handleDisposer(app, handle);
2485
+ const stop = (exitCode) => () => {
1361
2486
  void dispose().then(() => {
1362
- process6.exit(143);
2487
+ process7.exit(exitCode);
1363
2488
  });
1364
- });
2489
+ };
2490
+ process7.on("SIGINT", stop(130));
2491
+ process7.on("SIGTERM", stop(143));
1365
2492
  const code = await handle.waitForExit();
1366
2493
  await dispose();
1367
- process6.exit(code ?? 0);
2494
+ process7.exit(code ?? 0);
2495
+ }
2496
+ async function handleStart(selector, rawOpts) {
2497
+ const opts = mergeSelector(selector, rawOpts);
2498
+ const app = readRequiredOption(opts.app, "--app or selector");
2499
+ const key = await resolveSessionKey({ ...opts, app });
2500
+ const verbose = opts.verbose ?? false;
2501
+ const preferredPort = parseOptionalPort(opts.port);
2502
+ const tunnelReadyTimeoutMs = parseOptionalTimeout(opts.timeout);
2503
+ const nodePid = parseOptionalInteger(opts.nodePid, "nodePid", 1);
2504
+ const abort = startupAbort(app);
2505
+ let handle;
2506
+ try {
2507
+ handle = await startDebugger({
2508
+ region: key.region,
2509
+ org: key.org,
2510
+ space: key.space,
2511
+ app,
2512
+ process: key.process,
2513
+ instance: key.instance,
2514
+ verbose,
2515
+ signal: abort.signal,
2516
+ ...preferredPort === void 0 ? {} : { preferredPort },
2517
+ ...tunnelReadyTimeoutMs === void 0 ? {} : { tunnelReadyTimeoutMs },
2518
+ ...nodePid === void 0 ? {} : { nodePid },
2519
+ onStatus: (status, message) => {
2520
+ logStatus(verbose, status, message);
2521
+ }
2522
+ });
2523
+ } finally {
2524
+ abort.dispose();
2525
+ }
2526
+ writeReady(app, key, handle);
2527
+ await waitForHandle(app, handle);
1368
2528
  }
1369
2529
  function hasText(value) {
1370
2530
  return optionalText(value) !== void 0;
@@ -1374,7 +2534,7 @@ function optionalText(value) {
1374
2534
  return trimmed === void 0 || trimmed.length === 0 ? void 0 : trimmed;
1375
2535
  }
1376
2536
  function currentCfOptions() {
1377
- const command = process6.env["CF_DEBUGGER_CF_BIN"];
2537
+ const command = process7.env["CF_DEBUGGER_CF_BIN"];
1378
2538
  return command === void 0 ? void 0 : { command };
1379
2539
  }
1380
2540
  async function resolveSessionKey(opts) {
@@ -1382,12 +2542,20 @@ async function resolveSessionKey(opts) {
1382
2542
  const region = optionalText(opts.region);
1383
2543
  const org = optionalText(opts.org);
1384
2544
  const space = optionalText(opts.space);
2545
+ const processName = opts.process;
2546
+ const instance = parseOptionalInteger(opts.instance, "instance", 0);
2547
+ const target = resolveNodeTarget({
2548
+ ...processName === void 0 ? {} : { process: processName },
2549
+ ...instance === void 0 ? {} : { instance }
2550
+ });
1385
2551
  if (region !== void 0 && org !== void 0 && space !== void 0) {
1386
2552
  return {
1387
2553
  region,
1388
2554
  org,
1389
2555
  space,
1390
- app
2556
+ app,
2557
+ process: target.process,
2558
+ instance: target.instance
1391
2559
  };
1392
2560
  }
1393
2561
  const current = await readCurrentCfTarget(currentCfOptions()).catch((error) => {
@@ -1407,7 +2575,9 @@ async function resolveSessionKey(opts) {
1407
2575
  region: region ?? requireCurrentCfRegion(current),
1408
2576
  org: org ?? current.org,
1409
2577
  space: space ?? current.space,
1410
- app
2578
+ app,
2579
+ process: target.process,
2580
+ instance: target.instance
1411
2581
  };
1412
2582
  }
1413
2583
  async function resolveOptionalSessionKey(opts) {
@@ -1423,7 +2593,7 @@ async function handleStop(selector, rawOpts) {
1423
2593
  const opts = mergeSelector(selector, rawOpts);
1424
2594
  if (opts.all === true) {
1425
2595
  const count = await stopAllDebuggers();
1426
- process6.stdout.write(`Stopped ${count.toString()} session(s).
2596
+ process7.stdout.write(`Stop requested for ${count.toString()} session(s).
1427
2597
  `);
1428
2598
  return;
1429
2599
  }
@@ -1433,68 +2603,83 @@ async function handleStop(selector, rawOpts) {
1433
2603
  ...key === void 0 ? {} : { key }
1434
2604
  });
1435
2605
  if (result === void 0) {
1436
- process6.stderr.write(
2606
+ process7.stderr.write(
1437
2607
  "No matching session found. Use `cf-debugger list` and pass --session-id or region/org/space/app if the current CF target differs.\n"
1438
2608
  );
1439
- process6.exit(1);
2609
+ process7.exit(1);
2610
+ }
2611
+ if (result.pending) {
2612
+ process7.stdout.write(
2613
+ `Stop requested for session ${result.sessionId} (${result.app}, startup phase ${result.status}).
2614
+ `
2615
+ );
2616
+ return;
1440
2617
  }
1441
2618
  if (result.stale) {
1442
- process6.stdout.write(
2619
+ process7.stdout.write(
1443
2620
  `Removed stale session ${result.sessionId} (${result.app}, port ${result.localPort.toString()}).
1444
2621
  `
1445
2622
  );
1446
2623
  return;
1447
2624
  }
1448
- process6.stdout.write(
2625
+ process7.stdout.write(
1449
2626
  `Stopped session ${result.sessionId} (${result.app}, port ${result.localPort.toString()}).
1450
2627
  `
1451
2628
  );
1452
2629
  }
1453
2630
  async function handleList() {
1454
2631
  const sessions = await listSessions();
1455
- process6.stdout.write(`${JSON.stringify(sessions, null, 2)}
2632
+ process7.stdout.write(`${JSON.stringify(sessions, null, 2)}
1456
2633
  `);
1457
2634
  }
1458
2635
  async function handleStatus(selector, rawOpts) {
1459
2636
  const opts = mergeSelector(selector, rawOpts);
1460
2637
  const session = await getSession(await resolveSessionKey(opts));
1461
- process6.stdout.write(`${JSON.stringify(session ?? null, null, 2)}
2638
+ process7.stdout.write(`${JSON.stringify(session ?? null, null, 2)}
1462
2639
  `);
1463
2640
  }
1464
- async function main(argv) {
1465
- const program = new Command();
1466
- program.name("cf-debugger").description("Open an SSH debug tunnel to a SAP BTP Cloud Foundry app's Node.js inspector");
1467
- 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("--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) => {
2641
+ function registerStartCommand(program) {
2642
+ 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) => {
1468
2643
  await handleStart(selector, opts);
1469
2644
  });
1470
- 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("--session-id <id>").option("--all", "Stop every active session", false).action(async (selector, opts) => {
2645
+ }
2646
+ function registerStopCommand(program) {
2647
+ 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) => {
1471
2648
  await handleStop(selector, opts);
1472
2649
  });
2650
+ }
2651
+ function registerReadCommands(program) {
1473
2652
  program.command("list").description("Print every active debugger session as JSON").action(async () => {
1474
2653
  await handleList();
1475
2654
  });
1476
- 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>").action(async (selector, opts) => {
2655
+ 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) => {
1477
2656
  await handleStatus(selector, opts);
1478
2657
  });
2658
+ }
2659
+ async function main(argv) {
2660
+ const program = new Command().name("cf-debugger").description("Open an SSH debug tunnel to a SAP BTP Cloud Foundry app's Node.js inspector");
2661
+ registerStartCommand(program);
2662
+ registerStopCommand(program);
2663
+ registerReadCommands(program);
1479
2664
  await program.parseAsync([...argv]);
1480
2665
  }
1481
2666
  try {
1482
- await main(process6.argv);
2667
+ await main(process7.argv);
1483
2668
  } catch (err) {
1484
2669
  if (err instanceof CfDebuggerError) {
1485
2670
  if (err.code === "ABORTED") {
1486
- process6.stderr.write(`Aborted: ${err.message}
2671
+ process7.stderr.write(`Aborted: ${err.message}
1487
2672
  `);
1488
- process6.exit(130);
2673
+ process7.exit(130);
1489
2674
  }
1490
- process6.stderr.write(`Error [${err.code}]: ${err.message}
2675
+ process7.stderr.write(`Error [${err.code}]: ${err.message}
1491
2676
  `);
1492
2677
  } else {
1493
2678
  const msg = err instanceof Error ? err.message : String(err);
1494
- process6.stderr.write(`Error: ${msg}
2679
+ process7.stderr.write(`Error: ${msg}
1495
2680
  `);
1496
2681
  }
1497
- process6.exit(1);
2682
+ process7.exit(1);
1498
2683
  }
1499
2684
  export {
1500
2685
  main