@saptools/cf-debugger 0.1.15 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4734 @@
1
+ // src/types.ts
2
+ var CfDebuggerError = class extends Error {
3
+ code;
4
+ stderr;
5
+ constructor(code, message, stderr) {
6
+ super(message);
7
+ this.name = "CfDebuggerError";
8
+ this.code = code;
9
+ if (stderr !== void 0) {
10
+ this.stderr = stderr;
11
+ }
12
+ }
13
+ };
14
+
15
+ // src/regions.ts
16
+ var REGION_KEY_PATTERN = /^[a-z]{2}\d{2}(?:-\d{3})?$/;
17
+ var REGION_API_ENDPOINTS = {
18
+ ae01: "https://api.cf.ae01.hana.ondemand.com",
19
+ ap01: "https://api.cf.ap01.hana.ondemand.com",
20
+ ap10: "https://api.cf.ap10.hana.ondemand.com",
21
+ ap11: "https://api.cf.ap11.hana.ondemand.com",
22
+ ap12: "https://api.cf.ap12.hana.ondemand.com",
23
+ ap20: "https://api.cf.ap20.hana.ondemand.com",
24
+ ap21: "https://api.cf.ap21.hana.ondemand.com",
25
+ ap30: "https://api.cf.ap30.hana.ondemand.com",
26
+ ap31: "https://api.cf.ap31.hana.ondemand.com",
27
+ br10: "https://api.cf.br10.hana.ondemand.com",
28
+ br20: "https://api.cf.br20.hana.ondemand.com",
29
+ br30: "https://api.cf.br30.hana.ondemand.com",
30
+ ca10: "https://api.cf.ca10.hana.ondemand.com",
31
+ ca20: "https://api.cf.ca20.hana.ondemand.com",
32
+ ch20: "https://api.cf.ch20.hana.ondemand.com",
33
+ cn20: "https://api.cf.cn20.platform.sapcloud.cn",
34
+ cn40: "https://api.cf.cn40.platform.sapcloud.cn",
35
+ eu01: "https://api.cf.eu01.hana.ondemand.com",
36
+ eu02: "https://api.cf.eu02.hana.ondemand.com",
37
+ eu10: "https://api.cf.eu10.hana.ondemand.com",
38
+ "eu10-002": "https://api.cf.eu10-002.hana.ondemand.com",
39
+ "eu10-003": "https://api.cf.eu10-003.hana.ondemand.com",
40
+ "eu10-004": "https://api.cf.eu10-004.hana.ondemand.com",
41
+ "eu10-005": "https://api.cf.eu10-005.hana.ondemand.com",
42
+ "eu10-006": "https://api.cf.eu10-006.hana.ondemand.com",
43
+ eu11: "https://api.cf.eu11.hana.ondemand.com",
44
+ eu12: "https://api.cf.eu12.hana.ondemand.com",
45
+ eu13: "https://api.cf.eu13.hana.ondemand.com",
46
+ eu20: "https://api.cf.eu20.hana.ondemand.com",
47
+ "eu20-001": "https://api.cf.eu20-001.hana.ondemand.com",
48
+ "eu20-002": "https://api.cf.eu20-002.hana.ondemand.com",
49
+ eu21: "https://api.cf.eu21.hana.ondemand.com",
50
+ eu22: "https://api.cf.eu22.hana.ondemand.com",
51
+ eu30: "https://api.cf.eu30.hana.ondemand.com",
52
+ eu31: "https://api.cf.eu31.hana.ondemand.com",
53
+ il30: "https://api.cf.il30.hana.ondemand.com",
54
+ in30: "https://api.cf.in30.hana.ondemand.com",
55
+ jp01: "https://api.cf.jp01.hana.ondemand.com",
56
+ jp10: "https://api.cf.jp10.hana.ondemand.com",
57
+ jp20: "https://api.cf.jp20.hana.ondemand.com",
58
+ jp30: "https://api.cf.jp30.hana.ondemand.com",
59
+ jp31: "https://api.cf.jp31.hana.ondemand.com",
60
+ kr30: "https://api.cf.kr30.hana.ondemand.com",
61
+ sa30: "https://api.cf.sa30.hana.ondemand.com",
62
+ sa31: "https://api.cf.sa31.hana.ondemand.com",
63
+ uk20: "https://api.cf.uk20.hana.ondemand.com",
64
+ us01: "https://api.cf.us01.hana.ondemand.com",
65
+ us02: "https://api.cf.us02.hana.ondemand.com",
66
+ us10: "https://api.cf.us10.hana.ondemand.com",
67
+ "us10-001": "https://api.cf.us10-001.hana.ondemand.com",
68
+ "us10-002": "https://api.cf.us10-002.hana.ondemand.com",
69
+ "us10-003": "https://api.cf.us10-003.hana.ondemand.com",
70
+ us11: "https://api.cf.us11.hana.ondemand.com",
71
+ us20: "https://api.cf.us20.hana.ondemand.com",
72
+ us21: "https://api.cf.us21.hana.ondemand.com",
73
+ "us21-001": "https://api.cf.us21-001.hana.ondemand.com",
74
+ us22: "https://api.cf.us22.hana.ondemand.com",
75
+ us30: "https://api.cf.us30.hana.ondemand.com",
76
+ us31: "https://api.cf.us31.hana.ondemand.com",
77
+ us32: "https://api.cf.us32.hana.ondemand.com"
78
+ };
79
+ function synthesizeApiEndpoint(regionKey) {
80
+ const domain = regionKey.startsWith("cn") ? "platform.sapcloud.cn" : "hana.ondemand.com";
81
+ return `https://api.cf.${regionKey}.${domain}`;
82
+ }
83
+ function resolveApiEndpoint(regionKey, override, onWarning) {
84
+ if (override !== void 0 && override !== "") {
85
+ return override;
86
+ }
87
+ if (!REGION_KEY_PATTERN.test(regionKey)) {
88
+ throw new CfDebuggerError(
89
+ "UNKNOWN_REGION",
90
+ `Unknown region key: ${JSON.stringify(regionKey)}. Expected a key matching aa00 or aa00-000, or pass --api-endpoint <url>.`
91
+ );
92
+ }
93
+ const endpoint = Object.hasOwn(REGION_API_ENDPOINTS, regionKey) ? REGION_API_ENDPOINTS[regionKey] : void 0;
94
+ if (endpoint !== void 0) {
95
+ return endpoint;
96
+ }
97
+ const synthesized = synthesizeApiEndpoint(regionKey);
98
+ onWarning?.(
99
+ `Region key ${regionKey} is not in the curated region list; using synthesized API endpoint ${synthesized}. Verify it or pass --api-endpoint <url>.`
100
+ );
101
+ return synthesized;
102
+ }
103
+ function listKnownRegionKeys() {
104
+ return Object.keys(REGION_API_ENDPOINTS);
105
+ }
106
+
107
+ // src/cloud-foundry/execute.ts
108
+ import { execFile } from "child_process";
109
+ import nodeProcess from "process";
110
+ var MAX_BUFFER = 16 * 1024 * 1024;
111
+ var DEFAULT_CF_COMMAND_TIMEOUT_MS = 6e4;
112
+ var DEFAULT_CF_OPERATION_TIMEOUT_MS = 3e5;
113
+ var REDACTED_ARG = "<redacted>";
114
+ var MAX_RETRY_DELAY_MS = 1e4;
115
+ function buildEnv(cfHome) {
116
+ return { ...nodeProcess.env, CF_COLOR: "false", CF_HOME: cfHome };
117
+ }
118
+ function resolveBin(context) {
119
+ return context.command ?? nodeProcess.env["CF_DEBUGGER_CF_BIN"] ?? "cf";
120
+ }
121
+ function redactSensitiveText(text, values) {
122
+ return normalizeSensitiveValues(values).reduce((current, value) => current.split(value).join(REDACTED_ARG), text);
123
+ }
124
+ function normalizeSensitiveValues(values) {
125
+ return [...new Set(values.filter((value) => value.length > 0))].sort((left, right) => right.length - left.length);
126
+ }
127
+ function abortError(context) {
128
+ if (context.deadlineAt !== void 0 && Date.now() >= context.deadlineAt) {
129
+ const timeoutMs = context.startupTimeoutMs;
130
+ const duration = timeoutMs === void 0 ? "configured" : `${(timeoutMs / 1e3).toString()}s`;
131
+ return new CfDebuggerError(
132
+ "STARTUP_TIMEOUT",
133
+ `Debugger startup exceeded its ${duration} deadline during ${context.phase ?? "a CF command"}.`
134
+ );
135
+ }
136
+ return new CfDebuggerError("ABORTED", "Operation aborted by caller");
137
+ }
138
+ function waitForRetry(delayMs, context) {
139
+ if (context.signal?.aborted) {
140
+ return Promise.reject(abortError(context));
141
+ }
142
+ return new Promise((resolve, reject) => {
143
+ const onAbort = () => {
144
+ clearTimeout(timer);
145
+ reject(abortError(context));
146
+ };
147
+ const timer = setTimeout(() => {
148
+ context.signal?.removeEventListener("abort", onAbort);
149
+ resolve();
150
+ }, delayMs);
151
+ context.signal?.addEventListener("abort", onAbort, { once: true });
152
+ });
153
+ }
154
+ function formatArgsForError(args, sensitiveValues) {
155
+ return redactSensitiveText(args.join(" "), sensitiveValues);
156
+ }
157
+ function optionalString(value) {
158
+ return typeof value === "string" ? value : void 0;
159
+ }
160
+ function readFailureDetails(error) {
161
+ const object = typeof error === "object" && error !== null ? error : void 0;
162
+ const field3 = (key) => object === void 0 ? void 0 : Reflect.get(object, key);
163
+ return {
164
+ code: optionalString(field3("code")),
165
+ killed: field3("killed") === true,
166
+ message: error instanceof Error ? error.message : String(error),
167
+ stderr: optionalString(field3("stderr"))
168
+ };
169
+ }
170
+ function hasTransientDiagnostic(error) {
171
+ const diagnostic = error.stderr?.toLowerCase() ?? "";
172
+ const phrases = [
173
+ "error performing request",
174
+ "connection reset by peer",
175
+ "service unavailable",
176
+ "bad gateway",
177
+ "gateway timeout",
178
+ "dial tcp",
179
+ "i/o timeout"
180
+ ];
181
+ return phrases.some((phrase) => diagnostic.includes(phrase));
182
+ }
183
+ function isCredentialRejection(args, error) {
184
+ if (args[0] !== "auth") {
185
+ return false;
186
+ }
187
+ const diagnostic = `${error.stderr ?? ""} ${error.message}`.toLowerCase();
188
+ const phrases = [
189
+ "authentication failed",
190
+ "credentials were rejected",
191
+ "invalid credentials",
192
+ "invalid email",
193
+ "invalid password",
194
+ "invalid username",
195
+ "incorrect password",
196
+ "not authorized",
197
+ "unauthorized"
198
+ ];
199
+ return phrases.some((phrase) => diagnostic.includes(phrase)) || /\b(?:401|403)\b/.test(diagnostic);
200
+ }
201
+ function isTransientNetworkError(error, attemptTimedOut) {
202
+ if (attemptTimedOut) {
203
+ return true;
204
+ }
205
+ const networkCodes = [
206
+ "ETIMEDOUT",
207
+ "ECONNRESET",
208
+ "ENOTFOUND",
209
+ "ECONNREFUSED",
210
+ "EHOSTUNREACH",
211
+ "ENETUNREACH"
212
+ ];
213
+ return error.code !== void 0 && networkCodes.includes(error.code) || hasTransientDiagnostic(error);
214
+ }
215
+ function requirePositiveMilliseconds(value, name) {
216
+ if (!Number.isSafeInteger(value) || value <= 0) {
217
+ throw new CfDebuggerError(
218
+ "UNSAFE_INPUT",
219
+ `${name} must be a positive safe integer.`
220
+ );
221
+ }
222
+ return value;
223
+ }
224
+ function resolveRunOptions(context, input) {
225
+ const options = typeof input === "number" ? { timeoutMs: input } : input;
226
+ const retryBudgetMs = requirePositiveMilliseconds(
227
+ options.retryBudgetMs ?? DEFAULT_CF_OPERATION_TIMEOUT_MS,
228
+ "retryBudgetMs"
229
+ );
230
+ const attemptTimeoutMs = requirePositiveMilliseconds(
231
+ options.timeoutMs ?? DEFAULT_CF_COMMAND_TIMEOUT_MS,
232
+ "timeoutMs"
233
+ );
234
+ const deadlineAt = context.deadlineAt ?? Date.now() + retryBudgetMs;
235
+ if (!Number.isSafeInteger(deadlineAt) || deadlineAt <= 0) {
236
+ throw new CfDebuggerError(
237
+ "UNSAFE_INPUT",
238
+ "deadlineAt must be a positive safe integer timestamp."
239
+ );
240
+ }
241
+ return {
242
+ env: options.env,
243
+ redactionValues: normalizeSensitiveValues([
244
+ ...context.sensitiveValues ?? [],
245
+ ...options.sensitiveValues ?? []
246
+ ]),
247
+ attemptTimeoutMs,
248
+ deadlineAt
249
+ };
250
+ }
251
+ async function executeCfAttempt(args, context, options, timeoutMs) {
252
+ const result = await executeFileBounded(resolveBin(context), args, {
253
+ env: {
254
+ ...buildEnv(context.cfHome),
255
+ ...options.env,
256
+ CF_COLOR: "false",
257
+ CF_HOME: context.cfHome
258
+ },
259
+ maxBuffer: MAX_BUFFER,
260
+ timeoutMs,
261
+ ...context.signal === void 0 ? {} : { signal: context.signal }
262
+ });
263
+ return result.stdout;
264
+ }
265
+ function signalCfChild(child, signal) {
266
+ try {
267
+ child.kill(signal);
268
+ } catch {
269
+ }
270
+ }
271
+ async function executeFileBounded(command, args, options) {
272
+ return await new Promise((resolve, reject) => {
273
+ let settled = false;
274
+ const cleanup = () => {
275
+ clearTimeout(timer);
276
+ options.signal?.removeEventListener("abort", onAbort);
277
+ };
278
+ const rejectOnce = (error) => {
279
+ if (settled) {
280
+ return;
281
+ }
282
+ settled = true;
283
+ cleanup();
284
+ reject(error);
285
+ };
286
+ const child = execFile(command, [...args], {
287
+ env: options.env,
288
+ maxBuffer: options.maxBuffer ?? MAX_BUFFER,
289
+ encoding: "utf8"
290
+ }, (error, stdout, stderr) => {
291
+ if (settled) {
292
+ return;
293
+ }
294
+ settled = true;
295
+ cleanup();
296
+ if (error === null && options.signal?.aborted !== true) {
297
+ resolve({ stderr, stdout });
298
+ return;
299
+ }
300
+ const failure = error ?? new Error("Command was aborted.");
301
+ Reflect.set(failure, "stderr", stderr);
302
+ reject(failure);
303
+ });
304
+ const terminateAndReject = (code, message) => {
305
+ if (settled) {
306
+ return;
307
+ }
308
+ signalCfChild(child, "SIGKILL");
309
+ child.stdout?.destroy();
310
+ child.stderr?.destroy();
311
+ const failure = new Error(message);
312
+ Reflect.set(failure, "code", code);
313
+ Reflect.set(failure, "killed", true);
314
+ Reflect.set(failure, "stderr", "");
315
+ rejectOnce(failure);
316
+ };
317
+ const onAbort = () => {
318
+ terminateAndReject("ABORT_ERR", "Command was aborted.");
319
+ };
320
+ const timer = setTimeout(() => {
321
+ terminateAndReject("ETIMEDOUT", "Command exceeded its execution deadline.");
322
+ }, options.timeoutMs);
323
+ options.signal?.addEventListener("abort", onAbort, { once: true });
324
+ if (options.signal?.aborted) {
325
+ onAbort();
326
+ }
327
+ });
328
+ }
329
+ function retryDelayMs(attempt) {
330
+ return Math.min(1e3 * 2 ** Math.min(attempt - 1, 10), MAX_RETRY_DELAY_MS);
331
+ }
332
+ function createCfCliError(args, failure, redactionValues) {
333
+ const stderr = redactSensitiveText(failure.stderr?.trim() ?? "", redactionValues);
334
+ const fallback = redactSensitiveText(failure.message, redactionValues);
335
+ const detail = stderr.length > 0 ? stderr : fallback;
336
+ return new CfDebuggerError(
337
+ "CF_CLI_FAILED",
338
+ `cf ${formatArgsForError(args, redactionValues)} failed: ${detail}`,
339
+ stderr
340
+ );
341
+ }
342
+ function timeoutError(context, args, redactionValues) {
343
+ if (context.deadlineAt !== void 0) {
344
+ const timeoutMs = context.startupTimeoutMs;
345
+ const duration = timeoutMs === void 0 ? "configured" : `${(timeoutMs / 1e3).toString()}s`;
346
+ return new CfDebuggerError(
347
+ "STARTUP_TIMEOUT",
348
+ `Debugger startup could not complete within its ${duration} deadline during ${context.phase ?? "a CF command"}.`
349
+ );
350
+ }
351
+ return new CfDebuggerError(
352
+ "CF_CLI_TIMEOUT",
353
+ `cf ${formatArgsForError(args, redactionValues)} exceeded its overall retry budget.`
354
+ );
355
+ }
356
+ function reportRetry(context, args, attempt, delayMs, remainingMs, redactionValues) {
357
+ context.onRetry?.({
358
+ attempt,
359
+ command: `cf ${formatArgsForError(args, redactionValues)}`,
360
+ delayMs,
361
+ remainingMs
362
+ });
363
+ }
364
+ async function runCf(args, context, input = {}) {
365
+ if (context.signal?.aborted) {
366
+ throw abortError(context);
367
+ }
368
+ const options = resolveRunOptions(context, input);
369
+ let attempt = 0;
370
+ for (; ; ) {
371
+ const remainingMs = options.deadlineAt - Date.now();
372
+ if (remainingMs <= 0) {
373
+ throw timeoutError(context, args, options.redactionValues);
374
+ }
375
+ attempt += 1;
376
+ const attemptTimeoutMs = Math.max(1, Math.min(options.attemptTimeoutMs, remainingMs));
377
+ const startedAt = Date.now();
378
+ try {
379
+ return await executeCfAttempt(args, context, options, attemptTimeoutMs);
380
+ } catch (error) {
381
+ const failure = readFailureDetails(error);
382
+ if (context.signal?.aborted || failure.code === "ABORT_ERR") {
383
+ throw abortError(context);
384
+ }
385
+ const elapsedMs = Date.now() - startedAt;
386
+ const attemptTimedOut = failure.killed && elapsedMs + 100 >= attemptTimeoutMs;
387
+ if (isCredentialRejection(args, failure) || !isTransientNetworkError(failure, attemptTimedOut)) {
388
+ throw createCfCliError(args, failure, options.redactionValues);
389
+ }
390
+ const delayMs = retryDelayMs(attempt);
391
+ const afterFailureMs = options.deadlineAt - Date.now();
392
+ if (afterFailureMs <= delayMs) {
393
+ throw timeoutError(context, args, options.redactionValues);
394
+ }
395
+ reportRetry(
396
+ context,
397
+ args,
398
+ attempt,
399
+ delayMs,
400
+ afterFailureMs,
401
+ options.redactionValues
402
+ );
403
+ await waitForRetry(delayMs, context);
404
+ }
405
+ }
406
+ }
407
+
408
+ // src/cloud-foundry/commands.ts
409
+ var CURRENT_TARGET_TIMEOUT_MS = 3e4;
410
+ function rethrowControlFlowError(error) {
411
+ if (error instanceof CfDebuggerError && (error.code === "ABORTED" || error.code === "STARTUP_TIMEOUT")) {
412
+ throw error;
413
+ }
414
+ }
415
+ async function cfApi(apiEndpoint, context) {
416
+ await runCf(["api", apiEndpoint], context);
417
+ }
418
+ async function cfAuth(email, password, context) {
419
+ try {
420
+ await runCf(["auth"], context, {
421
+ env: { CF_PASSWORD: password, CF_USERNAME: email },
422
+ sensitiveValues: [email, password]
423
+ });
424
+ } catch (error) {
425
+ rethrowControlFlowError(error);
426
+ if (error instanceof CfDebuggerError) {
427
+ throw new CfDebuggerError(
428
+ "CF_AUTH_FAILED",
429
+ `${error.message}. Check SAP_EMAIL and SAP_PASSWORD before retrying.`,
430
+ error.stderr
431
+ );
432
+ }
433
+ throw error;
434
+ }
435
+ }
436
+ async function cfLogin(apiEndpoint, email, password, context) {
437
+ try {
438
+ await cfApi(apiEndpoint, context);
439
+ } catch (error) {
440
+ rethrowControlFlowError(error);
441
+ if (error instanceof CfDebuggerError) {
442
+ throw new CfDebuggerError("CF_LOGIN_FAILED", error.message, error.stderr);
443
+ }
444
+ throw error;
445
+ }
446
+ await cfAuth(email, password, context);
447
+ }
448
+ async function cfTarget(org, space, context) {
449
+ try {
450
+ await runCf(["target", "-o", org, "-s", space], context);
451
+ } catch (err) {
452
+ rethrowControlFlowError(err);
453
+ if (err instanceof CfDebuggerError) {
454
+ throw new CfDebuggerError("CF_TARGET_FAILED", err.message, err.stderr);
455
+ }
456
+ throw err;
457
+ }
458
+ }
459
+ async function cfAppExists(appName, context) {
460
+ try {
461
+ await runCf(["app", appName], context);
462
+ return true;
463
+ } catch (err) {
464
+ rethrowControlFlowError(err);
465
+ const stderr = err instanceof CfDebuggerError ? err.stderr ?? "" : "";
466
+ if (stderr.toLowerCase().includes("not found")) {
467
+ return false;
468
+ }
469
+ throw err;
470
+ }
471
+ }
472
+ async function cfSshEnabled(appName, context) {
473
+ try {
474
+ const stdout = await runCf(["ssh-enabled", appName], context);
475
+ const normalized = stdout.toLowerCase();
476
+ if (normalized.includes("ssh support is enabled")) {
477
+ return "enabled";
478
+ }
479
+ if (normalized.includes("ssh support is disabled")) {
480
+ return "disabled";
481
+ }
482
+ return "unknown";
483
+ } catch (err) {
484
+ rethrowControlFlowError(err);
485
+ return "unknown";
486
+ }
487
+ }
488
+ async function cfEnableSsh(appName, context) {
489
+ try {
490
+ await runCf(["enable-ssh", appName], context);
491
+ } catch (err) {
492
+ rethrowControlFlowError(err);
493
+ if (err instanceof CfDebuggerError) {
494
+ throw new CfDebuggerError("SSH_NOT_ENABLED", err.message, err.stderr);
495
+ }
496
+ throw err;
497
+ }
498
+ }
499
+ async function cfRestartApp(appName, context) {
500
+ await runCf(["restart", appName], context);
501
+ }
502
+ async function readCurrentCfTarget(options = {}) {
503
+ try {
504
+ const { stdout } = await executeFileBounded(
505
+ options.command ?? process.env["CF_DEBUGGER_CF_BIN"] ?? "cf",
506
+ ["target"],
507
+ {
508
+ env: { ...process.env, ...options.env, CF_COLOR: "false" },
509
+ maxBuffer: 16 * 1024 * 1024,
510
+ timeoutMs: options.timeoutMs ?? CURRENT_TARGET_TIMEOUT_MS,
511
+ ...options.signal === void 0 ? {} : { signal: options.signal }
512
+ }
513
+ );
514
+ return parseCurrentCfTarget(stdout);
515
+ } catch (error) {
516
+ if (options.signal?.aborted === true || typeof error === "object" && error !== null && Reflect.get(error, "code") === "ABORT_ERR") {
517
+ throw new CfDebuggerError("ABORTED", "Current CF target discovery was aborted.");
518
+ }
519
+ const message = error instanceof Error ? error.message : String(error);
520
+ throw new CfDebuggerError("CF_TARGET_FAILED", `cf target failed: ${message}`);
521
+ }
522
+ }
523
+ function parseCurrentCfTarget(stdout) {
524
+ const fields = parseTargetFields(stdout);
525
+ const apiEndpoint = fields.get("api endpoint");
526
+ const org = fields.get("org");
527
+ const space = fields.get("space");
528
+ if (!isPresent(apiEndpoint) || !isPresent(org) || !isPresent(space)) {
529
+ return void 0;
530
+ }
531
+ const region = regionKeyForApiEndpoint(apiEndpoint);
532
+ return {
533
+ apiEndpoint,
534
+ ...region === void 0 ? {} : { region },
535
+ org,
536
+ space
537
+ };
538
+ }
539
+ function requireCurrentCfRegion(target, instruction = "Pass --region explicitly.") {
540
+ if (target.region !== void 0) {
541
+ return target.region;
542
+ }
543
+ throw new CfDebuggerError(
544
+ "CF_TARGET_FAILED",
545
+ `Current CF API endpoint "${target.apiEndpoint}" does not match a known SAP region. ${instruction}`
546
+ );
547
+ }
548
+ function parseTargetFields(stdout) {
549
+ return new Map(
550
+ stdout.split("\n").map((line) => {
551
+ const separator = line.indexOf(":");
552
+ if (separator < 0) {
553
+ return void 0;
554
+ }
555
+ return [
556
+ line.slice(0, separator).trim().toLowerCase(),
557
+ line.slice(separator + 1).trim()
558
+ ];
559
+ }).filter((field3) => field3 !== void 0)
560
+ );
561
+ }
562
+ function regionKeyForApiEndpoint(apiEndpoint) {
563
+ const normalized = normalizeApiEndpoint(apiEndpoint);
564
+ const known = listKnownRegionKeys().find((key) => normalizeApiEndpoint(resolveApiEndpoint(key)) === normalized);
565
+ if (known !== void 0) {
566
+ return known;
567
+ }
568
+ return regionKeyFromSapApiEndpoint(normalized);
569
+ }
570
+ function normalizeApiEndpoint(apiEndpoint) {
571
+ return apiEndpoint.trim().replace(/\/+$/, "").toLowerCase();
572
+ }
573
+ function regionKeyFromSapApiEndpoint(apiEndpoint) {
574
+ const match = /^https:\/\/api\.cf\.([a-z]{2}\d{2}(?:-\d{3})?)\.(hana\.ondemand\.com|platform\.sapcloud\.cn)$/.exec(apiEndpoint);
575
+ const regionKey = match?.[1];
576
+ const domain = match?.[2];
577
+ if (regionKey === void 0 || domain === void 0) {
578
+ return void 0;
579
+ }
580
+ const expectedDomain = regionKey.startsWith("cn") ? "platform.sapcloud.cn" : "hana.ondemand.com";
581
+ return domain === expectedDomain ? regionKey : void 0;
582
+ }
583
+ function isPresent(value) {
584
+ return value !== void 0 && value.length > 0;
585
+ }
586
+
587
+ // src/cloud-foundry/node-process.ts
588
+ var DEFAULT_CF_PROCESS = "web";
589
+ var DEFAULT_CF_INSTANCE = 0;
590
+ var DEFAULT_NODE_INSPECTOR_PORT = 9229;
591
+ var MAX_MARKER_BYTES = 65536;
592
+ var PID_LIST_PATTERN = /^\d+(?:,\d+)*$/;
593
+ var PID_PATTERN = /^\d+$/;
594
+ function validateProcessName(processName) {
595
+ if (processName.length === 0 || processName.startsWith("-") || hasControlCharacter(processName)) {
596
+ throw new CfDebuggerError(
597
+ "UNSAFE_INPUT",
598
+ "process must be non-empty, must not start with a hyphen, and must contain no control characters."
599
+ );
600
+ }
601
+ }
602
+ function validateInstance(instance) {
603
+ if (!Number.isSafeInteger(instance) || instance < 0) {
604
+ throw new CfDebuggerError("UNSAFE_INPUT", "instance must be a non-negative safe integer.");
605
+ }
606
+ }
607
+ function validateNodePid(nodePid) {
608
+ if (nodePid !== void 0 && (!Number.isSafeInteger(nodePid) || nodePid <= 0)) {
609
+ throw new CfDebuggerError("UNSAFE_INPUT", "nodePid must be a positive safe integer.");
610
+ }
611
+ }
612
+ function validateRemotePort(remotePort) {
613
+ if (!Number.isSafeInteger(remotePort) || remotePort < 1 || remotePort > 65535) {
614
+ throw new CfDebuggerError("UNSAFE_INPUT", "remotePort must be an integer between 1 and 65535.");
615
+ }
616
+ }
617
+ function resolveNodeTarget(input) {
618
+ const processName = (input.process ?? DEFAULT_CF_PROCESS).trim();
619
+ const instance = input.instance ?? DEFAULT_CF_INSTANCE;
620
+ validateProcessName(processName);
621
+ validateInstance(instance);
622
+ validateNodePid(input.nodePid);
623
+ return input.nodePid === void 0 ? { process: processName, instance } : { process: processName, instance, nodePid: input.nodePid };
624
+ }
625
+ function hasControlCharacter(value) {
626
+ for (let index = 0; index < value.length; index += 1) {
627
+ const code = value.charCodeAt(index);
628
+ if (code < 32 || code === 127) {
629
+ return true;
630
+ }
631
+ }
632
+ return false;
633
+ }
634
+ function buildNodeInspectorCommand(nodePid, remotePort = DEFAULT_NODE_INSPECTOR_PORT) {
635
+ validateNodePid(nodePid);
636
+ validateRemotePort(remotePort);
637
+ const remotePortHex = remotePort.toString(16).toUpperCase().padStart(4, "0");
638
+ return NODE_INSPECTOR_SCRIPT.replace("__REQUESTED_NODE_PID__", nodePid?.toString() ?? "").replaceAll("__INSPECTOR_PORT_HEX__", remotePortHex);
639
+ }
640
+ function parseNodeInspectorMarkers(stdout, remotePort = DEFAULT_NODE_INSPECTOR_PORT) {
641
+ validateRemotePort(remotePort);
642
+ if (Buffer.byteLength(stdout, "utf8") > MAX_MARKER_BYTES) {
643
+ throw new CfDebuggerError("INSPECTOR_OUTPUT_TOO_LARGE", "Inspector startup output exceeded 65536 bytes.");
644
+ }
645
+ const markers = parseMarkers(stdout);
646
+ throwForFailureMarker(markers, remotePort);
647
+ const remoteNodePid = readMarkerPid(markers, "saptools-inspector-node-pid");
648
+ const ownerPid = readMarkerPid(markers, "saptools-inspector-owner-pid");
649
+ if (!markers.has("saptools-inspector-ready") || remoteNodePid === void 0 || ownerPid !== remoteNodePid) {
650
+ throw new CfDebuggerError("INSPECTOR_NOT_READY", "Remote Node inspector did not report a verified owner.");
651
+ }
652
+ return { remoteNodePid };
653
+ }
654
+ function parseMarkers(stdout) {
655
+ const markers = /* @__PURE__ */ new Map();
656
+ for (const rawLine of stdout.split(/\r?\n/)) {
657
+ const line = rawLine.trim();
658
+ if (!line.startsWith("saptools-inspector-")) {
659
+ continue;
660
+ }
661
+ const separator = line.indexOf("=");
662
+ markers.set(separator < 0 ? line : line.slice(0, separator), separator < 0 ? "" : line.slice(separator + 1));
663
+ }
664
+ return markers;
665
+ }
666
+ function throwForFailureMarker(markers, remotePort) {
667
+ if (markers.has("saptools-inspector-node-not-found")) {
668
+ throw new CfDebuggerError("NODE_PROCESS_NOT_FOUND", "No Node.js process was found in the selected CF instance.");
669
+ }
670
+ const ambiguous = markers.get("saptools-inspector-node-ambiguous");
671
+ if (ambiguous !== void 0) {
672
+ const candidates = PID_LIST_PATTERN.test(ambiguous) ? ambiguous.split(",").join(", ") : "unknown";
673
+ throw new CfDebuggerError("NODE_PROCESS_AMBIGUOUS", `Multiple Node.js processes were found: ${candidates}. Pass nodePid explicitly.`);
674
+ }
675
+ const invalid = markers.get("saptools-inspector-node-invalid");
676
+ if (invalid !== void 0) {
677
+ throw new CfDebuggerError("NODE_PID_INVALID", `Remote PID ${safePidText(invalid)} is not a Node.js process.`);
678
+ }
679
+ throwForRuntimeFailure(markers, remotePort);
680
+ }
681
+ function throwForRuntimeFailure(markers, remotePort) {
682
+ const mismatch = markers.get("saptools-inspector-owner-mismatch");
683
+ if (mismatch !== void 0) {
684
+ const [selected = "unknown", owner = "unknown"] = mismatch.split(":", 2).map(safePidText);
685
+ throw new CfDebuggerError(
686
+ "INSPECTOR_OWNER_MISMATCH",
687
+ `Selected Node PID ${selected}, but inspector port ${remotePort.toString()} is owned by PID ${owner}.`
688
+ );
689
+ }
690
+ const signalFailed = markers.get("saptools-inspector-signal-failed");
691
+ if (signalFailed !== void 0) {
692
+ throw new CfDebuggerError("USR1_SIGNAL_FAILED", `Failed to signal remote Node PID ${safePidText(signalFailed)}.`);
693
+ }
694
+ if (markers.has("saptools-inspector-not-ready")) {
695
+ throw new CfDebuggerError(
696
+ "INSPECTOR_NOT_READY",
697
+ `Remote Node inspector did not become ready on port ${remotePort.toString()}.`
698
+ );
699
+ }
700
+ }
701
+ function readMarkerPid(markers, name) {
702
+ const raw = markers.get(name);
703
+ if (raw === void 0 || !PID_PATTERN.test(raw)) {
704
+ return void 0;
705
+ }
706
+ const value = Number.parseInt(raw, 10);
707
+ return Number.isSafeInteger(value) && value > 0 ? value : void 0;
708
+ }
709
+ function safePidText(raw) {
710
+ return PID_PATTERN.test(raw) ? raw : "unknown";
711
+ }
712
+ var NODE_INSPECTOR_SCRIPT = [
713
+ "requested_node_pid=__REQUESTED_NODE_PID__",
714
+ "inspector_port_hex=__INSPECTOR_PORT_HEX__",
715
+ "is_node_pid() {",
716
+ ' candidate_pid="$1"',
717
+ ' candidate_exe="$(readlink "/proc/$candidate_pid/exe" 2>/dev/null || true)"',
718
+ ' [ "${candidate_exe##*/}" = node ] || [ "${candidate_exe##*/}" = nodejs ]',
719
+ "}",
720
+ "find_listener_inode() {",
721
+ ' listener_hex="$1"',
722
+ ' [ -n "$listener_hex" ] || return 0',
723
+ ` awk -v ph=":$listener_hex" '$4 == "0A" && $2 ~ (ph "$") { print $10; exit }' /proc/net/tcp /proc/net/tcp6 2>/dev/null`,
724
+ "}",
725
+ "find_inode_owner_fallback() {",
726
+ ' socket_inode="$1"',
727
+ ' [ -n "$socket_inode" ] || return 0',
728
+ " for pid_dir in /proc/[0-9]*; do",
729
+ ' [ -d "$pid_dir/fd" ] || continue',
730
+ ' for fd_path in "$pid_dir"/fd/*; do',
731
+ ' fd_target="$(readlink "$fd_path" 2>/dev/null || true)"',
732
+ ' if [ "$fd_target" = "socket:[$socket_inode]" ]; then',
733
+ ` printf '%s' "\${pid_dir##*/}"`,
734
+ " return 0",
735
+ " fi",
736
+ " done",
737
+ " done",
738
+ "}",
739
+ "find_inode_owner() {",
740
+ ' socket_inode="$1"',
741
+ ' [ -n "$socket_inode" ] || return 0',
742
+ ' if [ "$find_supports_lname" = true ]; then',
743
+ ' fd_path="$(find /proc/[0-9]*/fd -lname "socket:\\[$socket_inode\\]" -print -quit 2>/dev/null || true)"',
744
+ ' [ -n "$fd_path" ] || return 0',
745
+ ' pid_path="${fd_path#/proc/}"',
746
+ ` printf '%s' "\${pid_path%%/*}"`,
747
+ " return 0",
748
+ " fi",
749
+ ' find_inode_owner_fallback "$socket_inode"',
750
+ "}",
751
+ "pid_owns_inode() {",
752
+ ' candidate_pid="$1"',
753
+ ' socket_inode="$2"',
754
+ ' [ -d "/proc/$candidate_pid/fd" ] || return 1',
755
+ ' if [ "$find_supports_lname" = true ]; then',
756
+ ' fd_path="$(find "/proc/$candidate_pid/fd" -lname "socket:\\[$socket_inode\\]" -print -quit 2>/dev/null || true)"',
757
+ ' [ -n "$fd_path" ]',
758
+ " return",
759
+ " fi",
760
+ ' for fd_path in "/proc/$candidate_pid/fd"/*; do',
761
+ ' fd_target="$(readlink "$fd_path" 2>/dev/null || true)"',
762
+ ' [ "$fd_target" = "socket:[$socket_inode]" ] && return 0',
763
+ " done",
764
+ " return 1",
765
+ "}",
766
+ "find_listener_pid() {",
767
+ ' socket_inode="$(find_listener_inode "$1")"',
768
+ ' find_inode_owner "$socket_inode"',
769
+ "}",
770
+ "find_inspector_owner() {",
771
+ ' find_listener_pid "$inspector_port_hex"',
772
+ "}",
773
+ "find_selected_inspector_owner() {",
774
+ ' candidate_pid="$1"',
775
+ ' socket_inode="$(find_listener_inode "$inspector_port_hex")"',
776
+ ' [ -n "$socket_inode" ] || return 0',
777
+ ' if pid_owns_inode "$candidate_pid" "$socket_inode"; then',
778
+ ` printf '%s' "$candidate_pid"`,
779
+ " return 0",
780
+ " fi",
781
+ ' find_inode_owner "$socket_inode"',
782
+ "}",
783
+ "find_app_port_listener() {",
784
+ ' [ -n "${PORT:-}" ] || return 0',
785
+ ` app_port_hex="$(printf '%04X' "$PORT" 2>/dev/null || true)"`,
786
+ ' find_listener_pid "$app_port_hex"',
787
+ "}",
788
+ "find_supports_lname=false",
789
+ "if find /proc/self/fd -lname __saptools_no_match__ -print -quit >/dev/null 2>&1; then",
790
+ " find_supports_lname=true",
791
+ "fi",
792
+ 'owner_pid="$(find_inspector_owner)"',
793
+ 'selected_pid=""',
794
+ 'if [ -n "$requested_node_pid" ]; then',
795
+ ' if ! is_node_pid "$requested_node_pid"; then',
796
+ ' echo "saptools-inspector-node-invalid=$requested_node_pid"',
797
+ " exit 0",
798
+ " fi",
799
+ ' selected_pid="$requested_node_pid"',
800
+ 'elif [ -n "$owner_pid" ] && is_node_pid "$owner_pid"; then',
801
+ ' selected_pid="$owner_pid"',
802
+ "else",
803
+ ' candidate_pids=""',
804
+ " candidate_count=0",
805
+ " for pid_dir in /proc/[0-9]*; do",
806
+ ' candidate_pid="${pid_dir##*/}"',
807
+ ' is_node_pid "$candidate_pid" || continue',
808
+ " candidate_count=$((candidate_count + 1))",
809
+ ' candidate_pids="${candidate_pids}${candidate_pids:+,}$candidate_pid"',
810
+ ' selected_pid="$candidate_pid"',
811
+ " done",
812
+ ' if [ "$candidate_count" -eq 0 ]; then echo saptools-inspector-node-not-found; exit 0; fi',
813
+ ' if [ "$candidate_count" -ne 1 ]; then',
814
+ ' app_port_pid="$(find_app_port_listener)"',
815
+ ' if [ -n "$app_port_pid" ] && is_node_pid "$app_port_pid"; then',
816
+ ' selected_pid="$app_port_pid"',
817
+ " else",
818
+ ' echo "saptools-inspector-node-ambiguous=$candidate_pids"; exit 0',
819
+ " fi",
820
+ " fi",
821
+ "fi",
822
+ 'if [ -n "$owner_pid" ]; then',
823
+ ' if [ "$owner_pid" != "$selected_pid" ]; then echo "saptools-inspector-owner-mismatch=$selected_pid:$owner_pid"; exit 0; fi',
824
+ ' echo "saptools-inspector-node-pid=$selected_pid"',
825
+ ' echo "saptools-inspector-owner-pid=$owner_pid"',
826
+ " echo saptools-inspector-ready",
827
+ " exit 0",
828
+ "fi",
829
+ 'if ! kill -USR1 "$selected_pid" 2>/dev/null; then echo "saptools-inspector-signal-failed=$selected_pid"; exit 0; fi',
830
+ "attempt=0",
831
+ 'while [ "$attempt" -lt 20 ]; do',
832
+ ' owner_pid="$(find_selected_inspector_owner "$selected_pid")"',
833
+ ' if [ -n "$owner_pid" ]; then',
834
+ ' if [ "$owner_pid" != "$selected_pid" ]; then echo "saptools-inspector-owner-mismatch=$selected_pid:$owner_pid"; exit 0; fi',
835
+ ' echo "saptools-inspector-node-pid=$selected_pid"',
836
+ ' echo "saptools-inspector-owner-pid=$owner_pid"',
837
+ " echo saptools-inspector-ready",
838
+ " exit 0",
839
+ " fi",
840
+ " attempt=$((attempt + 1))",
841
+ // CF Linux stacks accept fractional sleep; retain a one-second POSIX fallback.
842
+ " sleep 0.25 2>/dev/null || sleep 1",
843
+ "done",
844
+ 'echo "saptools-inspector-not-ready=$selected_pid"'
845
+ ].join("\n");
846
+
847
+ // src/cloud-foundry/ssh.ts
848
+ import { spawn } from "child_process";
849
+ import nodeProcess2 from "process";
850
+ import { StringDecoder } from "string_decoder";
851
+ var DEFAULT_MAX_OUTPUT_BYTES = 65536;
852
+ var LIVE_LINE_LIMIT_BYTES = 65536;
853
+ var MAX_REDACTION_OVERLAP_BYTES = 4096;
854
+ var SENSITIVE_OUTPUT_OMITTED = "[diagnostic output omitted to protect a sensitive value]";
855
+ var tunnelCaptures = /* @__PURE__ */ new WeakMap();
856
+ function buildCfSshArgs(appName, target, tail) {
857
+ const resolved = resolveNodeTarget(target);
858
+ const processArgs = resolved.process === DEFAULT_CF_PROCESS ? [] : ["--process", resolved.process];
859
+ return [
860
+ "ssh",
861
+ appName,
862
+ ...processArgs,
863
+ "-i",
864
+ resolved.instance.toString(),
865
+ ...tail
866
+ ];
867
+ }
868
+ async function cfSshOneShot(appName, command, context, rawOptions = DEFAULT_CF_COMMAND_TIMEOUT_MS) {
869
+ if (context.signal?.aborted || isDeadlineExpired(context)) {
870
+ throw sshAbortError(context);
871
+ }
872
+ const options = resolveSshOptions(rawOptions, context);
873
+ const args = buildCfSshArgs(appName, options.target, [
874
+ "--disable-pseudo-tty",
875
+ "-c",
876
+ command
877
+ ]);
878
+ return await runSshOneShot(args, context, options);
879
+ }
880
+ function resolveSshOptions(raw, context) {
881
+ const input = typeof raw === "number" ? { timeoutMs: raw } : raw;
882
+ const requestedTimeoutMs = input.timeoutMs ?? DEFAULT_CF_COMMAND_TIMEOUT_MS;
883
+ const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
884
+ if (!Number.isSafeInteger(requestedTimeoutMs) || requestedTimeoutMs <= 0) {
885
+ throw new CfDebuggerError("UNSAFE_INPUT", "timeoutMs must be a positive safe integer.");
886
+ }
887
+ if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes <= 0) {
888
+ throw new CfDebuggerError("UNSAFE_INPUT", "maxOutputBytes must be a positive safe integer.");
889
+ }
890
+ const remainingMs = context.deadlineAt === void 0 ? requestedTimeoutMs : Math.max(1, context.deadlineAt - Date.now());
891
+ return {
892
+ target: input,
893
+ timeoutMs: Math.min(requestedTimeoutMs, remainingMs),
894
+ maxOutputBytes
895
+ };
896
+ }
897
+ function createBoundedOutput() {
898
+ return { chunks: [], bytes: 0, truncated: false };
899
+ }
900
+ function appendHead(output, data, outputLimit, retainedLimit) {
901
+ const incoming = Buffer.isBuffer(data) ? data : Buffer.from(data);
902
+ if (incoming.byteLength === 0) {
903
+ return;
904
+ }
905
+ output.truncated ||= output.bytes + incoming.byteLength > outputLimit;
906
+ const remaining = Math.max(0, retainedLimit - output.bytes);
907
+ if (remaining === 0) {
908
+ return;
909
+ }
910
+ const next = incoming.byteLength <= remaining ? incoming : Buffer.from(incoming.subarray(0, remaining));
911
+ output.chunks.push(next);
912
+ output.bytes += next.byteLength;
913
+ }
914
+ function appendTail(output, data, outputLimit, retainedLimit) {
915
+ const incoming = Buffer.isBuffer(data) ? data : Buffer.from(data);
916
+ if (incoming.byteLength === 0) {
917
+ return;
918
+ }
919
+ output.chunks.push(incoming);
920
+ output.bytes += incoming.byteLength;
921
+ output.truncated ||= output.bytes > outputLimit;
922
+ while (output.bytes > retainedLimit && output.chunks.length > 0) {
923
+ const first = output.chunks[0];
924
+ if (first === void 0) {
925
+ break;
926
+ }
927
+ const excess = output.bytes - retainedLimit;
928
+ if (first.byteLength <= excess) {
929
+ output.chunks.shift();
930
+ output.bytes -= first.byteLength;
931
+ } else {
932
+ output.chunks[0] = Buffer.from(first.subarray(excess));
933
+ output.bytes -= excess;
934
+ }
935
+ }
936
+ }
937
+ function outputText(output) {
938
+ return Buffer.concat(output.chunks, output.bytes).toString("utf8");
939
+ }
940
+ function createRedactionPolicy(values) {
941
+ const sensitiveValues = normalizeSensitiveValues(values);
942
+ const overlapBytes = sensitiveValues.reduce(
943
+ (largest, value) => Math.max(largest, Buffer.byteLength(value)),
944
+ 0
945
+ );
946
+ return {
947
+ safeToSurface: overlapBytes <= MAX_REDACTION_OVERLAP_BYTES && !sensitiveValues.some((value) => /[\r\n]/.test(value)),
948
+ sensitiveValues,
949
+ overlapBytes: Math.min(overlapBytes, MAX_REDACTION_OVERLAP_BYTES)
950
+ };
951
+ }
952
+ function limitText(text, limit, keep) {
953
+ const buffer = Buffer.from(text);
954
+ if (buffer.byteLength <= limit) {
955
+ return text;
956
+ }
957
+ const limited = keep === "head" ? buffer.subarray(0, limit) : buffer.subarray(buffer.byteLength - limit);
958
+ return limited.toString("utf8");
959
+ }
960
+ function safeOutputText(output, policy, limit, keep) {
961
+ if (!policy.safeToSurface && output.bytes > 0) {
962
+ return SENSITIVE_OUTPUT_OMITTED;
963
+ }
964
+ const redacted = redactSensitiveText(outputText(output), policy.sensitiveValues);
965
+ return limitText(redacted, limit, keep);
966
+ }
967
+ function createResult(exitCode, state) {
968
+ const stdout = safeOutputText(state.stdout, state.redaction, state.outputLimit, "head");
969
+ const stderr = safeOutputText(state.stderr, state.redaction, state.outputLimit, "head");
970
+ return {
971
+ exitCode,
972
+ stdout,
973
+ stderr,
974
+ outputTruncated: state.stdout.truncated || state.stderr.truncated,
975
+ stdoutTruncated: state.stdout.truncated,
976
+ stderrTruncated: state.stderr.truncated
977
+ };
978
+ }
979
+ function createSshExecutionState(context, outputLimit) {
980
+ const redaction = createRedactionPolicy(context.sensitiveValues ?? []);
981
+ return {
982
+ stdout: createBoundedOutput(),
983
+ stderr: createBoundedOutput(),
984
+ outputLimit,
985
+ retainedLimit: outputLimit + redaction.overlapBytes,
986
+ redaction,
987
+ settled: false,
988
+ aborted: false,
989
+ timedOut: false
990
+ };
991
+ }
992
+ function terminateSshExecution(child, state) {
993
+ signalChild(child, "SIGTERM");
994
+ state.forceKillTimer ??= setTimeout(() => {
995
+ signalChild(child, "SIGKILL");
996
+ }, 1e3);
997
+ }
998
+ function sshAbortError(context) {
999
+ if (context.deadlineAt !== void 0 && Date.now() >= context.deadlineAt) {
1000
+ return new CfDebuggerError(
1001
+ "STARTUP_TIMEOUT",
1002
+ `Debugger startup exceeded its configured deadline during ${context.phase ?? "remote signalling"}.`
1003
+ );
1004
+ }
1005
+ return new CfDebuggerError("ABORTED", "Operation aborted by caller");
1006
+ }
1007
+ function isDeadlineExpired(context) {
1008
+ return context.deadlineAt !== void 0 && Date.now() >= context.deadlineAt;
1009
+ }
1010
+ function createSshSettler(state, options, context, onAbort, resolve, reject) {
1011
+ return (result) => {
1012
+ if (state.settled) {
1013
+ return;
1014
+ }
1015
+ state.settled = true;
1016
+ clearTimeout(state.timeoutTimer);
1017
+ clearTimeout(state.forceKillTimer);
1018
+ context.signal?.removeEventListener("abort", onAbort);
1019
+ if (state.aborted) {
1020
+ reject(sshAbortError(context));
1021
+ return;
1022
+ }
1023
+ resolve(state.timedOut ? { ...result, timedOutAfterMs: options.timeoutMs } : result);
1024
+ };
1025
+ }
1026
+ function attachSshExecution(child, context, options, resolve, reject) {
1027
+ const state = createSshExecutionState(context, options.maxOutputBytes);
1028
+ const onAbort = () => {
1029
+ state.aborted = true;
1030
+ terminateSshExecution(child, state);
1031
+ };
1032
+ const settle = createSshSettler(state, options, context, onAbort, resolve, reject);
1033
+ state.timeoutTimer = setTimeout(() => {
1034
+ state.timedOut = true;
1035
+ terminateSshExecution(child, state);
1036
+ }, options.timeoutMs);
1037
+ if (context.signal?.aborted) {
1038
+ onAbort();
1039
+ } else {
1040
+ context.signal?.addEventListener("abort", onAbort, { once: true });
1041
+ }
1042
+ child.stdout.on("data", (data) => {
1043
+ appendHead(state.stdout, data, state.outputLimit, state.retainedLimit);
1044
+ });
1045
+ child.stderr.on("data", (data) => {
1046
+ appendHead(state.stderr, data, state.outputLimit, state.retainedLimit);
1047
+ });
1048
+ child.on("close", (code, signal) => {
1049
+ const base = createResult(code, state);
1050
+ settle(state.timedOut || signal === null ? base : { ...base, signal });
1051
+ });
1052
+ child.on("error", (error) => {
1053
+ appendHead(state.stderr, error.message, state.outputLimit, state.retainedLimit);
1054
+ settle(createResult(null, state));
1055
+ });
1056
+ }
1057
+ function runSshOneShot(args, context, options) {
1058
+ if (context.signal?.aborted || isDeadlineExpired(context)) {
1059
+ return Promise.reject(sshAbortError(context));
1060
+ }
1061
+ return new Promise((resolve, reject) => {
1062
+ const child = spawn(resolveBin(context), [...args], {
1063
+ env: buildEnv(context.cfHome),
1064
+ detached: nodeProcess2.platform !== "win32",
1065
+ stdio: ["ignore", "pipe", "pipe"]
1066
+ });
1067
+ attachSshExecution(child, context, options, resolve, reject);
1068
+ });
1069
+ }
1070
+ function signalChild(child, signal) {
1071
+ if (nodeProcess2.platform !== "win32" && child.pid !== void 0) {
1072
+ try {
1073
+ nodeProcess2.kill(-child.pid, signal);
1074
+ return;
1075
+ } catch {
1076
+ }
1077
+ }
1078
+ try {
1079
+ child.kill(signal);
1080
+ } catch {
1081
+ }
1082
+ }
1083
+ function isSshDisabledError(stderr) {
1084
+ return stderr.toLowerCase().includes("ssh support is disabled");
1085
+ }
1086
+ function isSshPermissionError(stderr) {
1087
+ return stderr.toLowerCase().includes("not authorized");
1088
+ }
1089
+ function liveOutputText(capture, line) {
1090
+ if (!capture.redaction.safeToSurface && line.length > 0) {
1091
+ return SENSITIVE_OUTPUT_OMITTED;
1092
+ }
1093
+ if (Buffer.byteLength(line) > LIVE_LINE_LIMIT_BYTES) {
1094
+ return "[output line omitted: exceeded live diagnostic limit]";
1095
+ }
1096
+ return redactSensitiveText(line, capture.redaction.sensitiveValues);
1097
+ }
1098
+ function skipSuppressedLine(capture, stream, text) {
1099
+ const suppressedKey = stream === "stdout" ? "stdoutSuppressed" : "stderrSuppressed";
1100
+ if (!capture[suppressedKey]) {
1101
+ return text;
1102
+ }
1103
+ const newline = text.indexOf("\n");
1104
+ if (newline < 0) {
1105
+ return "";
1106
+ }
1107
+ capture[suppressedKey] = false;
1108
+ return text.slice(newline + 1);
1109
+ }
1110
+ function emitCompleteLines(capture, stream, data, emit) {
1111
+ if (emit === void 0) {
1112
+ return;
1113
+ }
1114
+ const key = stream === "stdout" ? "stdoutCarry" : "stderrCarry";
1115
+ const suppressedKey = stream === "stdout" ? "stdoutSuppressed" : "stderrSuppressed";
1116
+ const decoder = stream === "stdout" ? capture.stdoutDecoder : capture.stderrDecoder;
1117
+ const incoming = decoder.write(Buffer.isBuffer(data) ? data : Buffer.from(data));
1118
+ const combined = `${capture[key]}${skipSuppressedLine(capture, stream, incoming)}`;
1119
+ const lines = combined.split("\n");
1120
+ capture[key] = lines.pop() ?? "";
1121
+ for (const line of lines) {
1122
+ emit(stream, liveOutputText(capture, line.endsWith("\r") ? line.slice(0, -1) : line));
1123
+ }
1124
+ if (Buffer.byteLength(capture[key]) > LIVE_LINE_LIMIT_BYTES) {
1125
+ capture[key] = "";
1126
+ capture[suppressedKey] = true;
1127
+ emit(stream, "[output line omitted: exceeded live diagnostic limit]");
1128
+ }
1129
+ }
1130
+ function flushLiveCarry(capture, stream, emit) {
1131
+ if (emit === void 0) {
1132
+ return;
1133
+ }
1134
+ const key = stream === "stdout" ? "stdoutCarry" : "stderrCarry";
1135
+ const suppressedKey = stream === "stdout" ? "stdoutSuppressed" : "stderrSuppressed";
1136
+ if (capture[suppressedKey]) {
1137
+ capture[suppressedKey] = false;
1138
+ capture[key] = "";
1139
+ return;
1140
+ }
1141
+ if (capture[key].length === 0) {
1142
+ return;
1143
+ }
1144
+ emit(stream, liveOutputText(capture, capture[key]));
1145
+ capture[key] = "";
1146
+ }
1147
+ function attachTunnelCapture(child, context) {
1148
+ const redaction = createRedactionPolicy(context.sensitiveValues ?? []);
1149
+ const capture = {
1150
+ stdout: createBoundedOutput(),
1151
+ stderr: createBoundedOutput(),
1152
+ outputLimit: DEFAULT_MAX_OUTPUT_BYTES,
1153
+ retainedLimit: DEFAULT_MAX_OUTPUT_BYTES + redaction.overlapBytes,
1154
+ redaction,
1155
+ stderrDecoder: new StringDecoder("utf8"),
1156
+ stdoutDecoder: new StringDecoder("utf8"),
1157
+ stdoutCarry: "",
1158
+ stdoutSuppressed: false,
1159
+ stderrCarry: "",
1160
+ stderrSuppressed: false
1161
+ };
1162
+ tunnelCaptures.set(child, capture);
1163
+ child.stdout.on("data", (data) => {
1164
+ appendTail(capture.stdout, data, capture.outputLimit, capture.retainedLimit);
1165
+ emitCompleteLines(capture, "stdout", data, context.onTunnelOutput);
1166
+ });
1167
+ child.stderr.on("data", (data) => {
1168
+ appendTail(capture.stderr, data, capture.outputLimit, capture.retainedLimit);
1169
+ emitCompleteLines(capture, "stderr", data, context.onTunnelOutput);
1170
+ });
1171
+ child.on("error", (error) => {
1172
+ const diagnostic = `${error.message}
1173
+ `;
1174
+ appendTail(capture.stderr, diagnostic, capture.outputLimit, capture.retainedLimit);
1175
+ emitCompleteLines(capture, "stderr", diagnostic, context.onTunnelOutput);
1176
+ });
1177
+ child.once("close", () => {
1178
+ emitCompleteLines(
1179
+ capture,
1180
+ "stdout",
1181
+ capture.stdoutDecoder.end(),
1182
+ context.onTunnelOutput
1183
+ );
1184
+ emitCompleteLines(
1185
+ capture,
1186
+ "stderr",
1187
+ capture.stderrDecoder.end(),
1188
+ context.onTunnelOutput
1189
+ );
1190
+ flushLiveCarry(capture, "stdout", context.onTunnelOutput);
1191
+ flushLiveCarry(capture, "stderr", context.onTunnelOutput);
1192
+ });
1193
+ }
1194
+ function getTunnelDiagnostics(child) {
1195
+ const capture = tunnelCaptures.get(child);
1196
+ if (capture === void 0) {
1197
+ return {
1198
+ stdout: "",
1199
+ stderr: "",
1200
+ stdoutTruncated: false,
1201
+ stderrTruncated: false
1202
+ };
1203
+ }
1204
+ return {
1205
+ stdout: safeOutputText(
1206
+ capture.stdout,
1207
+ capture.redaction,
1208
+ capture.outputLimit,
1209
+ "tail"
1210
+ ),
1211
+ stderr: safeOutputText(
1212
+ capture.stderr,
1213
+ capture.redaction,
1214
+ capture.outputLimit,
1215
+ "tail"
1216
+ ),
1217
+ stdoutTruncated: capture.stdout.truncated,
1218
+ stderrTruncated: capture.stderr.truncated
1219
+ };
1220
+ }
1221
+ function formatTunnelDiagnostics(diagnostics) {
1222
+ const sections = [];
1223
+ const stdout = diagnostics.stdout.trim();
1224
+ const stderr = diagnostics.stderr.trim();
1225
+ if (stdout.length > 0) {
1226
+ sections.push(`[tunnel stdout]
1227
+ ${stdout}`);
1228
+ }
1229
+ if (diagnostics.stdoutTruncated) {
1230
+ sections.push("[tunnel stdout tail was truncated]");
1231
+ }
1232
+ if (stderr.length > 0) {
1233
+ sections.push(`[tunnel stderr]
1234
+ ${stderr}`);
1235
+ }
1236
+ if (diagnostics.stderrTruncated) {
1237
+ sections.push("[tunnel stderr tail was truncated]");
1238
+ }
1239
+ return sections.length === 0 ? void 0 : sections.join("\n");
1240
+ }
1241
+ function spawnSshTunnel(appName, localPort, remotePort, context, target = {}) {
1242
+ if (context.signal?.aborted || isDeadlineExpired(context)) {
1243
+ throw sshAbortError(context);
1244
+ }
1245
+ const tunnelArg = `${localPort.toString()}:localhost:${remotePort.toString()}`;
1246
+ const args = buildCfSshArgs(appName, target, ["-N", "-L", tunnelArg]);
1247
+ const child = spawn(resolveBin(context), [...args], {
1248
+ env: buildEnv(context.cfHome),
1249
+ detached: nodeProcess2.platform !== "win32",
1250
+ stdio: ["ignore", "pipe", "pipe"]
1251
+ });
1252
+ attachTunnelCapture(child, context);
1253
+ return child;
1254
+ }
1255
+
1256
+ // src/session-state/store.ts
1257
+ import { randomUUID as randomUUID2 } from "crypto";
1258
+ import { chmod as chmod3, mkdir as mkdir3, readFile as readFile4, rename, unlink as unlink3, writeFile as writeFile2 } from "fs/promises";
1259
+ import { hostname as getHostname2 } from "os";
1260
+ import { dirname as dirname2, isAbsolute as isAbsolute3 } from "path";
1261
+ import nodeProcess5 from "process";
1262
+
1263
+ // src/debug-session/constants.ts
1264
+ var DEFAULT_TUNNEL_READY_TIMEOUT_MS = 18e4;
1265
+ var DEFAULT_STARTUP_TIMEOUT_MS = 3e5;
1266
+ var MAX_STARTUP_TIMEOUT_MS = 18e5;
1267
+ var STARTUP_STALE_SLACK_MS = 6e4;
1268
+ var CHILD_SIGTERM_GRACE_MS = 2e3;
1269
+ var CHILD_SIGKILL_GRACE_MS = 1e3;
1270
+ var PID_TERMINATION_POLL_MS = 100;
1271
+
1272
+ // src/debug-session/process-identity.ts
1273
+ import { execFile as execFile2 } from "child_process";
1274
+ import { readFile } from "fs/promises";
1275
+ import nodeProcess3 from "process";
1276
+ import { promisify } from "util";
1277
+ var execFileAsync = promisify(execFile2);
1278
+ function errorCode(error) {
1279
+ if (typeof error !== "object" || error === null) {
1280
+ return void 0;
1281
+ }
1282
+ const code = Reflect.get(error, "code");
1283
+ return typeof code === "string" ? code : void 0;
1284
+ }
1285
+ function parseLinuxProcessStartTime(statLine) {
1286
+ const commandEnd = statLine.lastIndexOf(")");
1287
+ if (commandEnd < 0) {
1288
+ return void 0;
1289
+ }
1290
+ const fieldsAfterCommand = statLine.slice(commandEnd + 1).trim().split(/\s+/);
1291
+ const startTime = fieldsAfterCommand[19];
1292
+ return startTime !== void 0 && /^\d+$/.test(startTime) ? startTime : void 0;
1293
+ }
1294
+ function throwIfAborted(signal) {
1295
+ if (signal?.aborted) {
1296
+ throw new CfDebuggerError("ABORTED", "Process identity inspection was aborted.");
1297
+ }
1298
+ }
1299
+ async function linuxProcessIdentity(pid, signal) {
1300
+ try {
1301
+ const statLine = await readFile(`/proc/${pid.toString()}/stat`, {
1302
+ encoding: "utf8",
1303
+ ...signal === void 0 ? {} : { signal }
1304
+ });
1305
+ const startTime = parseLinuxProcessStartTime(statLine);
1306
+ return startTime === void 0 ? void 0 : `linux:${startTime}`;
1307
+ } catch (error) {
1308
+ throwIfAborted(signal);
1309
+ if (errorCode(error) === "ENOENT") {
1310
+ return void 0;
1311
+ }
1312
+ return void 0;
1313
+ }
1314
+ }
1315
+ async function darwinProcessIdentity(pid, signal) {
1316
+ try {
1317
+ const { stdout } = await execFileAsync("ps", ["-p", pid.toString(), "-o", "lstart="], {
1318
+ ...signal === void 0 ? {} : { signal },
1319
+ timeout: 2e3
1320
+ });
1321
+ const startedAt = stdout.trim();
1322
+ return startedAt.length === 0 ? void 0 : `darwin:${startedAt}`;
1323
+ } catch {
1324
+ throwIfAborted(signal);
1325
+ return void 0;
1326
+ }
1327
+ }
1328
+ async function readProcessIdentity(pid, signal) {
1329
+ throwIfAborted(signal);
1330
+ if (!Number.isSafeInteger(pid) || pid <= 0) {
1331
+ return void 0;
1332
+ }
1333
+ if (nodeProcess3.platform === "linux") {
1334
+ return await linuxProcessIdentity(pid, signal);
1335
+ }
1336
+ if (nodeProcess3.platform === "darwin") {
1337
+ return await darwinProcessIdentity(pid, signal);
1338
+ }
1339
+ return void 0;
1340
+ }
1341
+ async function inspectProcessIdentity(pid, expected, signal) {
1342
+ throwIfAborted(signal);
1343
+ if (expected === void 0) {
1344
+ return "match";
1345
+ }
1346
+ const current = await readProcessIdentity(pid, signal);
1347
+ if (current === void 0) {
1348
+ return "unavailable";
1349
+ }
1350
+ return current === expected ? "match" : "mismatch";
1351
+ }
1352
+
1353
+ // src/lock.ts
1354
+ import { randomUUID } from "crypto";
1355
+ import { chmod, mkdir, open, readFile as readFile2, stat, unlink } from "fs/promises";
1356
+ import { hostname } from "os";
1357
+ import { dirname } from "path";
1358
+ import process2 from "process";
1359
+ var DEFAULT_POLL_MS = 50;
1360
+ var DEFAULT_STALE_MS = 6e4;
1361
+ var DEFAULT_TIMEOUT_MS = 1e4;
1362
+ var FOREIGN_HOST_STALE_MULTIPLIER = 10;
1363
+ var LEGACY_SAME_HOST_STALE_MULTIPLIER = 24 * 60;
1364
+ function sleep(ms, signal) {
1365
+ if (signal?.aborted) {
1366
+ return Promise.reject(new CfDebuggerError("ABORTED", "State lock acquisition was aborted."));
1367
+ }
1368
+ return new Promise((resolve, reject) => {
1369
+ const onAbort = () => {
1370
+ clearTimeout(timer);
1371
+ reject(new CfDebuggerError("ABORTED", "State lock acquisition was aborted."));
1372
+ };
1373
+ const timer = setTimeout(() => {
1374
+ signal?.removeEventListener("abort", onAbort);
1375
+ resolve();
1376
+ }, ms);
1377
+ signal?.addEventListener("abort", onAbort, { once: true });
1378
+ });
1379
+ }
1380
+ function errorCode2(error) {
1381
+ if (typeof error !== "object" || error === null) {
1382
+ return void 0;
1383
+ }
1384
+ const code = Reflect.get(error, "code");
1385
+ return typeof code === "string" ? code : void 0;
1386
+ }
1387
+ function field(value, key) {
1388
+ return Reflect.get(value, key);
1389
+ }
1390
+ function parseLockOwner(raw) {
1391
+ let value;
1392
+ try {
1393
+ value = JSON.parse(raw);
1394
+ } catch {
1395
+ return void 0;
1396
+ }
1397
+ if (typeof value !== "object" || value === null) {
1398
+ return void 0;
1399
+ }
1400
+ const lockHostname = field(value, "hostname");
1401
+ const pid = field(value, "pid");
1402
+ const token = field(value, "token");
1403
+ const version = field(value, "version");
1404
+ const processIdentity = field(value, "processIdentity");
1405
+ if (typeof lockHostname !== "string" || typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0) {
1406
+ return void 0;
1407
+ }
1408
+ if (typeof token !== "string" || version !== "1") {
1409
+ return void 0;
1410
+ }
1411
+ if (processIdentity !== void 0 && (typeof processIdentity !== "string" || processIdentity.length === 0)) {
1412
+ return void 0;
1413
+ }
1414
+ return {
1415
+ hostname: lockHostname,
1416
+ pid,
1417
+ ...processIdentity === void 0 ? {} : { processIdentity },
1418
+ token,
1419
+ version
1420
+ };
1421
+ }
1422
+ function isProcessAlive(pid) {
1423
+ try {
1424
+ process2.kill(pid, 0);
1425
+ return true;
1426
+ } catch (error) {
1427
+ return errorCode2(error) !== "ESRCH";
1428
+ }
1429
+ }
1430
+ async function readLockOwner(lockPath) {
1431
+ try {
1432
+ await chmod(lockPath, 384);
1433
+ return parseLockOwner(await readFile2(lockPath, "utf8"));
1434
+ } catch (error) {
1435
+ if (errorCode2(error) === "ENOENT") {
1436
+ return void 0;
1437
+ }
1438
+ throw error;
1439
+ }
1440
+ }
1441
+ async function isStaleLock(lockPath, staleMs) {
1442
+ let modifiedAt2;
1443
+ try {
1444
+ modifiedAt2 = (await stat(lockPath)).mtimeMs;
1445
+ } catch (error) {
1446
+ if (errorCode2(error) === "ENOENT") {
1447
+ return true;
1448
+ }
1449
+ throw error;
1450
+ }
1451
+ const owner = await readLockOwner(lockPath);
1452
+ const ageMs = Date.now() - modifiedAt2;
1453
+ if (owner?.hostname === hostname()) {
1454
+ if (!isProcessAlive(owner.pid)) {
1455
+ return true;
1456
+ }
1457
+ if (owner.processIdentity !== void 0) {
1458
+ const identity = await inspectProcessIdentity(owner.pid, owner.processIdentity);
1459
+ if (identity === "mismatch") {
1460
+ return true;
1461
+ }
1462
+ if (identity === "match") {
1463
+ return false;
1464
+ }
1465
+ }
1466
+ return ageMs > staleMs * LEGACY_SAME_HOST_STALE_MULTIPLIER;
1467
+ }
1468
+ if (owner === void 0) {
1469
+ return ageMs > staleMs;
1470
+ }
1471
+ return ageMs > staleMs * FOREIGN_HOST_STALE_MULTIPLIER;
1472
+ }
1473
+ async function reclaimAbandonedRecoveryLock(recoveryPath, staleMs) {
1474
+ if (!await isStaleLock(recoveryPath, staleMs)) {
1475
+ return;
1476
+ }
1477
+ const owner = await readLockOwner(recoveryPath);
1478
+ if (owner !== void 0) {
1479
+ await removeOwnedLock(recoveryPath, owner.token);
1480
+ return;
1481
+ }
1482
+ await unlink(recoveryPath).catch((error) => {
1483
+ if (errorCode2(error) !== "ENOENT") {
1484
+ throw error;
1485
+ }
1486
+ });
1487
+ }
1488
+ async function reclaimStaleLock(lockPath, staleMs) {
1489
+ const recoveryPath = `${lockPath}.recovery`;
1490
+ let recoveryLock;
1491
+ try {
1492
+ recoveryLock = await createFileLock(recoveryPath);
1493
+ } catch (error) {
1494
+ if (errorCode2(error) === "EEXIST") {
1495
+ await reclaimAbandonedRecoveryLock(recoveryPath, staleMs);
1496
+ return false;
1497
+ }
1498
+ throw error;
1499
+ }
1500
+ try {
1501
+ if (!await isStaleLock(lockPath, staleMs)) {
1502
+ return false;
1503
+ }
1504
+ try {
1505
+ await unlink(lockPath);
1506
+ } catch (error) {
1507
+ if (errorCode2(error) !== "ENOENT") {
1508
+ throw error;
1509
+ }
1510
+ }
1511
+ return true;
1512
+ } finally {
1513
+ await releaseFileLock(recoveryPath, recoveryLock);
1514
+ }
1515
+ }
1516
+ async function removeOwnedLock(lockPath, token) {
1517
+ const owner = await readLockOwner(lockPath);
1518
+ if (owner?.token !== token) {
1519
+ return;
1520
+ }
1521
+ await unlink(lockPath).catch((error) => {
1522
+ if (errorCode2(error) !== "ENOENT") {
1523
+ throw error;
1524
+ }
1525
+ });
1526
+ }
1527
+ async function createFileLock(lockPath) {
1528
+ const processIdentity = await readProcessIdentity(process2.pid);
1529
+ const handle = await open(lockPath, "wx", 384);
1530
+ const token = randomUUID();
1531
+ try {
1532
+ await handle.writeFile(`${JSON.stringify({
1533
+ hostname: hostname(),
1534
+ pid: process2.pid,
1535
+ ...processIdentity === void 0 ? {} : { processIdentity },
1536
+ token,
1537
+ version: "1"
1538
+ })}
1539
+ `, "utf8");
1540
+ return { handle, token };
1541
+ } catch (error) {
1542
+ await handle.close();
1543
+ await unlink(lockPath).catch(() => false);
1544
+ throw error;
1545
+ }
1546
+ }
1547
+ async function acquireFileLock(lockPath, timeoutMs, pollMs, staleMs, signal) {
1548
+ const deadline = Date.now() + timeoutMs;
1549
+ const parentDir = dirname(lockPath);
1550
+ await mkdir(parentDir, { recursive: true, mode: 448 });
1551
+ await chmod(parentDir, 448);
1552
+ for (; ; ) {
1553
+ if (signal?.aborted) {
1554
+ throw new CfDebuggerError("ABORTED", "State lock acquisition was aborted.");
1555
+ }
1556
+ try {
1557
+ return await createFileLock(lockPath);
1558
+ } catch (error) {
1559
+ if (errorCode2(error) !== "EEXIST") {
1560
+ throw error;
1561
+ }
1562
+ }
1563
+ if (await reclaimStaleLock(lockPath, staleMs)) {
1564
+ continue;
1565
+ }
1566
+ if (Date.now() > deadline) {
1567
+ throw new CfDebuggerError(
1568
+ "STATE_LOCK_TIMEOUT",
1569
+ `Timed out acquiring debugger state lock at ${lockPath}. Keep ~/.saptools on a local filesystem and retry.`
1570
+ );
1571
+ }
1572
+ await sleep(pollMs, signal);
1573
+ }
1574
+ }
1575
+ async function releaseFileLock(lockPath, lock) {
1576
+ await lock.handle.close();
1577
+ await removeOwnedLock(lockPath, lock.token);
1578
+ }
1579
+ async function withFileLock(lockPath, work, options) {
1580
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
1581
+ const pollMs = options?.pollMs ?? DEFAULT_POLL_MS;
1582
+ const staleMs = options?.staleMs ?? DEFAULT_STALE_MS;
1583
+ const lock = await acquireFileLock(
1584
+ lockPath,
1585
+ timeoutMs,
1586
+ pollMs,
1587
+ staleMs,
1588
+ options?.signal
1589
+ );
1590
+ try {
1591
+ return await work();
1592
+ } finally {
1593
+ await releaseFileLock(lockPath, lock);
1594
+ }
1595
+ }
1596
+
1597
+ // src/paths.ts
1598
+ import { homedir } from "os";
1599
+ import { isAbsolute, join } from "path";
1600
+ var SAPTOOLS_DIR_NAME = ".saptools";
1601
+ var CF_DEBUGGER_STATE_FILENAME = "cf-debugger-state-v2.json";
1602
+ var CF_DEBUGGER_LOCK_FILENAME = "cf-debugger-state-v2.lock";
1603
+ var CF_DEBUGGER_HOMES_DIRNAME = "cf-debugger-homes-v2";
1604
+ var CF_DEBUGGER_STOP_INTENT_PREFIX = "cf-debugger-stop-v2-";
1605
+ var SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
1606
+ function saptoolsDir() {
1607
+ return join(homedir(), SAPTOOLS_DIR_NAME);
1608
+ }
1609
+ function stateFilePath() {
1610
+ return join(saptoolsDir(), CF_DEBUGGER_STATE_FILENAME);
1611
+ }
1612
+ function stateLockPath() {
1613
+ return join(saptoolsDir(), CF_DEBUGGER_LOCK_FILENAME);
1614
+ }
1615
+ function sessionStopIntentPath(sessionId) {
1616
+ if (!isSafeSessionId(sessionId)) {
1617
+ throw new CfDebuggerError("UNSAFE_INPUT", "Invalid debugger session ID.");
1618
+ }
1619
+ return join(saptoolsDir(), `${CF_DEBUGGER_STOP_INTENT_PREFIX}${sessionId}.stop`);
1620
+ }
1621
+ function isSafeSessionId(sessionId) {
1622
+ return SESSION_ID_PATTERN.test(sessionId);
1623
+ }
1624
+ function sessionCfHomeDir(sessionId) {
1625
+ if (!isSafeSessionId(sessionId)) {
1626
+ throw new CfDebuggerError("UNSAFE_INPUT", "Invalid debugger session ID.");
1627
+ }
1628
+ return join(saptoolsDir(), CF_DEBUGGER_HOMES_DIRNAME, sessionId);
1629
+ }
1630
+ function isOwnedSessionCfHomeDir(sessionId, candidate) {
1631
+ if (!isSafeSessionId(sessionId) || !isAbsolute(candidate)) {
1632
+ return false;
1633
+ }
1634
+ return candidate === sessionCfHomeDir(sessionId);
1635
+ }
1636
+
1637
+ // src/session-state/decoder.ts
1638
+ import { isAbsolute as isAbsolute2 } from "path";
1639
+ var INVALID_SESSION = new Error("Invalid persisted debugger session");
1640
+ var VALID_STATUSES = /* @__PURE__ */ new Set([
1641
+ "starting",
1642
+ "logging-in",
1643
+ "targeting",
1644
+ "ssh-enabling",
1645
+ "ssh-restarting",
1646
+ "signaling",
1647
+ "tunneling",
1648
+ "ready",
1649
+ "stopping",
1650
+ "stopped",
1651
+ "error"
1652
+ ]);
1653
+ function field2(value, key) {
1654
+ return Reflect.get(value, key);
1655
+ }
1656
+ function requireString(value, key) {
1657
+ const candidate = field2(value, key);
1658
+ if (typeof candidate !== "string" || candidate.length === 0) {
1659
+ throw INVALID_SESSION;
1660
+ }
1661
+ return candidate;
1662
+ }
1663
+ function optionalString2(value, key) {
1664
+ const candidate = field2(value, key);
1665
+ if (candidate === void 0) {
1666
+ return void 0;
1667
+ }
1668
+ if (typeof candidate !== "string") {
1669
+ throw INVALID_SESSION;
1670
+ }
1671
+ return candidate;
1672
+ }
1673
+ function optionalNonEmptyString(value, key) {
1674
+ const candidate = optionalString2(value, key);
1675
+ if (candidate?.length === 0) {
1676
+ throw INVALID_SESSION;
1677
+ }
1678
+ return candidate;
1679
+ }
1680
+ function requireInteger(value, key, minimum, maximum) {
1681
+ const candidate = field2(value, key);
1682
+ if (typeof candidate !== "number" || !Number.isSafeInteger(candidate) || candidate < minimum || candidate > maximum) {
1683
+ throw INVALID_SESSION;
1684
+ }
1685
+ return candidate;
1686
+ }
1687
+ function optionalInteger(value, key, minimum, maximum = Number.MAX_SAFE_INTEGER) {
1688
+ const candidate = field2(value, key);
1689
+ if (candidate === void 0) {
1690
+ return void 0;
1691
+ }
1692
+ if (typeof candidate !== "number" || !Number.isSafeInteger(candidate) || candidate < minimum || candidate > maximum) {
1693
+ throw INVALID_SESSION;
1694
+ }
1695
+ return candidate;
1696
+ }
1697
+ function requireSessionId(value) {
1698
+ const sessionId = requireString(value, "sessionId");
1699
+ if (!isSafeSessionId(sessionId)) {
1700
+ throw INVALID_SESSION;
1701
+ }
1702
+ return sessionId;
1703
+ }
1704
+ function requireAbsolutePath(value, key) {
1705
+ const path = requireString(value, key);
1706
+ if (!isAbsolute2(path)) {
1707
+ throw INVALID_SESSION;
1708
+ }
1709
+ return path;
1710
+ }
1711
+ function requireTimestamp(value) {
1712
+ const timestamp = requireString(value, "startedAt");
1713
+ if (Number.isNaN(Date.parse(timestamp))) {
1714
+ throw INVALID_SESSION;
1715
+ }
1716
+ return timestamp;
1717
+ }
1718
+ function optionalTimestamp(value, key) {
1719
+ const timestamp = optionalString2(value, key);
1720
+ if (timestamp !== void 0 && Number.isNaN(Date.parse(timestamp))) {
1721
+ throw INVALID_SESSION;
1722
+ }
1723
+ return timestamp;
1724
+ }
1725
+ function isSessionStatus(value) {
1726
+ return VALID_STATUSES.has(value);
1727
+ }
1728
+ function requireStatus(value) {
1729
+ const status = requireString(value, "status");
1730
+ if (!isSessionStatus(status)) {
1731
+ throw INVALID_SESSION;
1732
+ }
1733
+ return status;
1734
+ }
1735
+ function optionalFields(value) {
1736
+ const controllerProcessIdentity = optionalNonEmptyString(value, "controllerProcessIdentity");
1737
+ const tunnelProcessIdentity = optionalNonEmptyString(value, "tunnelProcessIdentity");
1738
+ const startupTimeoutMs = optionalInteger(value, "startupTimeoutMs", 1, MAX_STARTUP_TIMEOUT_MS);
1739
+ return {
1740
+ ...controllerProcessIdentity === void 0 ? {} : { controllerProcessIdentity },
1741
+ ...tunnelProcessIdentity === void 0 ? {} : { tunnelProcessIdentity },
1742
+ ...startupTimeoutMs === void 0 ? {} : { startupTimeoutMs }
1743
+ };
1744
+ }
1745
+ function decodeTarget(value) {
1746
+ const nodePid = optionalInteger(value, "nodePid", 1);
1747
+ return resolveNodeTarget({
1748
+ process: requireString(value, "process"),
1749
+ instance: requireInteger(value, "instance", 0, Number.MAX_SAFE_INTEGER),
1750
+ ...nodePid === void 0 ? {} : { nodePid }
1751
+ });
1752
+ }
1753
+ function decodeSession(value) {
1754
+ if (typeof value !== "object" || value === null) {
1755
+ return void 0;
1756
+ }
1757
+ try {
1758
+ const target = decodeTarget(value);
1759
+ const pid = requireInteger(value, "pid", 1, Number.MAX_SAFE_INTEGER);
1760
+ const tunnelPid = optionalInteger(value, "tunnelPid", 1);
1761
+ const controllerPid = optionalInteger(value, "controllerPid", 1) ?? pid;
1762
+ const remoteNodePid = optionalInteger(value, "remoteNodePid", 1);
1763
+ const stopRequestedAt = optionalTimestamp(value, "stopRequestedAt");
1764
+ const message = optionalString2(value, "message");
1765
+ const status = requireStatus(value);
1766
+ if (pid !== (tunnelPid ?? controllerPid) || status === "ready" && tunnelPid === void 0) {
1767
+ throw INVALID_SESSION;
1768
+ }
1769
+ return {
1770
+ sessionId: requireSessionId(value),
1771
+ pid,
1772
+ controllerPid,
1773
+ ...optionalFields(value),
1774
+ ...tunnelPid === void 0 ? {} : { tunnelPid },
1775
+ hostname: requireString(value, "hostname"),
1776
+ region: requireString(value, "region"),
1777
+ org: requireString(value, "org"),
1778
+ space: requireString(value, "space"),
1779
+ app: requireString(value, "app"),
1780
+ process: target.process,
1781
+ instance: target.instance,
1782
+ ...target.nodePid === void 0 ? {} : { nodePid: target.nodePid },
1783
+ apiEndpoint: requireString(value, "apiEndpoint"),
1784
+ localPort: requireInteger(value, "localPort", 1, 65535),
1785
+ remotePort: requireInteger(value, "remotePort", 1, 65535),
1786
+ cfHomeDir: requireAbsolutePath(value, "cfHomeDir"),
1787
+ startedAt: requireTimestamp(value),
1788
+ status,
1789
+ ...remoteNodePid === void 0 ? {} : { remoteNodePid },
1790
+ ...stopRequestedAt === void 0 ? {} : { stopRequestedAt },
1791
+ ...message === void 0 ? {} : { message }
1792
+ };
1793
+ } catch {
1794
+ return void 0;
1795
+ }
1796
+ }
1797
+ function duplicateSessionIds(sessions) {
1798
+ const seen = /* @__PURE__ */ new Set();
1799
+ const duplicates = /* @__PURE__ */ new Set();
1800
+ for (const session of sessions) {
1801
+ if (seen.has(session.sessionId)) {
1802
+ duplicates.add(session.sessionId);
1803
+ }
1804
+ seen.add(session.sessionId);
1805
+ }
1806
+ return duplicates;
1807
+ }
1808
+ function decodeStateFileDetailed(value) {
1809
+ if (typeof value !== "object" || value === null) {
1810
+ return { kind: "invalid-file", reason: "root is not an object" };
1811
+ }
1812
+ if (field2(value, "version") !== "2") {
1813
+ return { kind: "invalid-file", reason: "unsupported or missing version" };
1814
+ }
1815
+ const rawSessions = field2(value, "sessions");
1816
+ if (!Array.isArray(rawSessions)) {
1817
+ return { kind: "invalid-file", reason: "sessions is not an array" };
1818
+ }
1819
+ const decoded = rawSessions.map((raw) => decodeSession(raw));
1820
+ const valid = decoded.filter((session) => session !== void 0);
1821
+ const duplicates = duplicateSessionIds(valid);
1822
+ const sessions = valid.filter((session) => !duplicates.has(session.sessionId));
1823
+ const dropped = decoded.flatMap((session, index) => {
1824
+ if (session === void 0) {
1825
+ return [`session[${index.toString()}]: invalid entry`];
1826
+ }
1827
+ return duplicates.has(session.sessionId) ? [`session[${index.toString()}]: duplicate sessionId ${session.sessionId}`] : [];
1828
+ });
1829
+ return {
1830
+ kind: "decoded",
1831
+ state: { version: "2", sessions },
1832
+ dropped
1833
+ };
1834
+ }
1835
+
1836
+ // src/session-state/health.ts
1837
+ import { hostname as getHostname } from "os";
1838
+ import nodeProcess4 from "process";
1839
+
1840
+ // src/network/ports.ts
1841
+ import { execFile as execFile3 } from "child_process";
1842
+ import { readdir, readFile as readFile3, readlink } from "fs/promises";
1843
+ import { get as httpGet } from "http";
1844
+ import { createConnection, createServer } from "net";
1845
+ import { promisify as promisify2 } from "util";
1846
+ var execFileAsync2 = promisify2(execFile3);
1847
+ var PROBE_INTERVAL_MS = 250;
1848
+ var INSPECTOR_ATTEMPT_TIMEOUT_MS = 2500;
1849
+ var MAX_INSPECTOR_RESPONSE_BYTES = 64 * 1024;
1850
+ var OWNER_COMMAND_TIMEOUT_MS = 5e3;
1851
+ function sortedUniquePids(pids) {
1852
+ return [...new Set(pids)].sort((left, right) => left - right);
1853
+ }
1854
+ function parseAddressPort(address) {
1855
+ const separator = address.lastIndexOf(":");
1856
+ const portText = separator >= 0 ? address.slice(separator + 1) : "";
1857
+ if (!/^\d+$/.test(portText)) {
1858
+ return void 0;
1859
+ }
1860
+ return Number.parseInt(portText, 10);
1861
+ }
1862
+ function parseWindowsNetstatListeningPids(output, port) {
1863
+ const pids = /* @__PURE__ */ new Set();
1864
+ for (const line of output.split(/\r?\n/)) {
1865
+ const fields = line.trim().split(/\s+/);
1866
+ const protocol = fields[0]?.toUpperCase();
1867
+ const localAddress = fields[1];
1868
+ const state = fields[3]?.toUpperCase();
1869
+ const pidText = fields[4];
1870
+ if (protocol !== "TCP" || localAddress === void 0 || state !== "LISTENING" || pidText === void 0 || parseAddressPort(localAddress) !== port) {
1871
+ continue;
1872
+ }
1873
+ const pid = Number.parseInt(pidText, 10);
1874
+ if (Number.isSafeInteger(pid) && pid > 0) {
1875
+ pids.add(pid);
1876
+ }
1877
+ }
1878
+ return sortedUniquePids(pids);
1879
+ }
1880
+ function errorCode3(error) {
1881
+ if (typeof error !== "object" || error === null || !("code" in error)) {
1882
+ return void 0;
1883
+ }
1884
+ const code = error.code;
1885
+ return typeof code === "string" || typeof code === "number" ? code : void 0;
1886
+ }
1887
+ function errorStderr(error) {
1888
+ if (typeof error !== "object" || error === null || !("stderr" in error)) {
1889
+ return "";
1890
+ }
1891
+ return typeof error.stderr === "string" ? error.stderr.trim() : "";
1892
+ }
1893
+ async function findListeningPidsWithNetstat(port, signal) {
1894
+ try {
1895
+ const { stdout } = await execFileAsync2("netstat", ["-ano"], {
1896
+ ...signal === void 0 ? {} : { signal },
1897
+ timeout: OWNER_COMMAND_TIMEOUT_MS
1898
+ });
1899
+ return {
1900
+ available: true,
1901
+ pids: parseWindowsNetstatListeningPids(stdout, port)
1902
+ };
1903
+ } catch (error) {
1904
+ throwIfAborted2(signal);
1905
+ return {
1906
+ available: false,
1907
+ pids: [],
1908
+ reason: `Unable to inspect listening ports with netstat (${String(errorCode3(error) ?? "unknown error")}).`
1909
+ };
1910
+ }
1911
+ }
1912
+ async function findListeningPidsWithLsof(port, signal) {
1913
+ try {
1914
+ const { stdout } = await execFileAsync2("lsof", [
1915
+ "-nP",
1916
+ "-t",
1917
+ "-i",
1918
+ `tcp:${port.toString()}`,
1919
+ "-sTCP:LISTEN"
1920
+ ], {
1921
+ ...signal === void 0 ? {} : { signal },
1922
+ timeout: OWNER_COMMAND_TIMEOUT_MS
1923
+ });
1924
+ const pids = stdout.trim().split("\n").filter((line) => line.length > 0).map((line) => Number.parseInt(line, 10)).filter((pid) => Number.isSafeInteger(pid) && pid > 0);
1925
+ return { available: true, pids: sortedUniquePids(pids) };
1926
+ } catch (error) {
1927
+ throwIfAborted2(signal);
1928
+ if (errorCode3(error) === 1 && errorStderr(error).length === 0) {
1929
+ return { available: true, pids: [] };
1930
+ }
1931
+ const missing = errorCode3(error) === "ENOENT";
1932
+ return {
1933
+ available: false,
1934
+ pids: [],
1935
+ reason: missing ? "The lsof command is required to verify tunnel ownership on this platform." : `lsof could not inspect the listening port (${String(errorCode3(error) ?? "unknown error")}).`
1936
+ };
1937
+ }
1938
+ }
1939
+ async function collectListeningSocketInodes(path, port, inodes) {
1940
+ try {
1941
+ const content = await readFile3(path, "utf8");
1942
+ for (const line of content.split("\n").slice(1)) {
1943
+ const fields = line.trim().split(/\s+/);
1944
+ const localAddress = fields[1];
1945
+ const state = fields[3];
1946
+ const inode = fields[9];
1947
+ if (localAddress === void 0 || state === void 0 || inode === void 0) {
1948
+ continue;
1949
+ }
1950
+ const localPort = Number.parseInt(localAddress.split(":")[1] ?? "", 16);
1951
+ if (localPort === port && state === "0A") {
1952
+ inodes.add(inode);
1953
+ }
1954
+ }
1955
+ return true;
1956
+ } catch {
1957
+ return false;
1958
+ }
1959
+ }
1960
+ async function findListeningSocketInodesWithProc(port) {
1961
+ const inodes = /* @__PURE__ */ new Set();
1962
+ const tcpAvailable = await collectListeningSocketInodes("/proc/net/tcp", port, inodes);
1963
+ const tcp6Available = await collectListeningSocketInodes("/proc/net/tcp6", port, inodes);
1964
+ return { available: tcpAvailable || tcp6Available, inodes };
1965
+ }
1966
+ async function processHasSocketInode(pid, inodes) {
1967
+ try {
1968
+ const descriptors = await readdir(`/proc/${pid}/fd`);
1969
+ for (const descriptor of descriptors) {
1970
+ try {
1971
+ const link = await readlink(`/proc/${pid}/fd/${descriptor}`);
1972
+ const match = /^socket:\[(\d+)\]$/.exec(link);
1973
+ if (match?.[1] !== void 0 && inodes.has(match[1])) {
1974
+ return true;
1975
+ }
1976
+ } catch {
1977
+ }
1978
+ }
1979
+ } catch {
1980
+ }
1981
+ return false;
1982
+ }
1983
+ async function findListeningPidsWithProc(port, signal) {
1984
+ throwIfAborted2(signal);
1985
+ const socketResult = await findListeningSocketInodesWithProc(port);
1986
+ if (!socketResult.available) {
1987
+ return {
1988
+ available: false,
1989
+ listenerFound: false,
1990
+ pids: [],
1991
+ reason: "The /proc socket tables are unavailable."
1992
+ };
1993
+ }
1994
+ if (socketResult.inodes.size === 0) {
1995
+ return { available: true, listenerFound: false, pids: [] };
1996
+ }
1997
+ try {
1998
+ const entries = await readdir("/proc", { withFileTypes: true });
1999
+ const pids = /* @__PURE__ */ new Set();
2000
+ for (const entry of entries) {
2001
+ throwIfAborted2(signal);
2002
+ if (entry.isDirectory() && /^\d+$/.test(entry.name) && await processHasSocketInode(entry.name, socketResult.inodes)) {
2003
+ pids.add(Number.parseInt(entry.name, 10));
2004
+ }
2005
+ }
2006
+ return {
2007
+ available: true,
2008
+ listenerFound: true,
2009
+ pids: sortedUniquePids(pids)
2010
+ };
2011
+ } catch {
2012
+ throwIfAborted2(signal);
2013
+ return {
2014
+ available: false,
2015
+ listenerFound: true,
2016
+ pids: [],
2017
+ reason: "A listener exists, but its owner could not be inspected through /proc."
2018
+ };
2019
+ }
2020
+ }
2021
+ function inspectionFromPids(pids) {
2022
+ return pids.length > 0 ? { status: "found", pids } : { status: "not-listening", pids };
2023
+ }
2024
+ async function inspectListeningProcesses(port, signal) {
2025
+ throwIfAborted2(signal);
2026
+ if (process.platform === "win32") {
2027
+ const netstat = await findListeningPidsWithNetstat(port, signal);
2028
+ return netstat.available ? inspectionFromPids(netstat.pids) : { status: "unverified", reason: netstat.reason ?? "netstat is unavailable." };
2029
+ }
2030
+ const lsof = await findListeningPidsWithLsof(port, signal);
2031
+ if (lsof.pids.length > 0) {
2032
+ return { status: "found", pids: lsof.pids };
2033
+ }
2034
+ if (process.platform === "darwin") {
2035
+ return lsof.available ? { status: "not-listening", pids: [] } : { status: "unverified", reason: lsof.reason ?? "lsof is unavailable." };
2036
+ }
2037
+ const proc = await findListeningPidsWithProc(port, signal);
2038
+ if (proc.pids.length > 0) {
2039
+ return { status: "found", pids: proc.pids };
2040
+ }
2041
+ if (proc.listenerFound || !proc.available && !lsof.available) {
2042
+ return {
2043
+ status: "unverified",
2044
+ reason: proc.reason ?? lsof.reason ?? "The listener owner could not be verified."
2045
+ };
2046
+ }
2047
+ return { status: "not-listening", pids: [] };
2048
+ }
2049
+ function classifyPortOwnership(pids, expectedPid) {
2050
+ return pids.includes(expectedPid) ? { status: "owned", pids } : { status: "not-owned", pids };
2051
+ }
2052
+ async function inspectPortOwnership(port, expectedPid, signal) {
2053
+ const inspection = await inspectListeningProcesses(port, signal);
2054
+ if (inspection.status === "unverified") {
2055
+ return inspection;
2056
+ }
2057
+ if (inspection.status === "not-listening") {
2058
+ return inspection;
2059
+ }
2060
+ return classifyPortOwnership(inspection.pids, expectedPid);
2061
+ }
2062
+ async function isPortFree(port, signal) {
2063
+ throwIfAborted2(signal);
2064
+ return await new Promise((resolve, reject) => {
2065
+ const server = createServer();
2066
+ let settled = false;
2067
+ const finish = (available) => {
2068
+ if (settled) {
2069
+ return;
2070
+ }
2071
+ settled = true;
2072
+ signal?.removeEventListener("abort", onAbort);
2073
+ resolve(available);
2074
+ };
2075
+ const onAbort = () => {
2076
+ if (settled) {
2077
+ return;
2078
+ }
2079
+ settled = true;
2080
+ if (server.listening) {
2081
+ server.close();
2082
+ }
2083
+ signal?.removeEventListener("abort", onAbort);
2084
+ reject(new CfDebuggerError("ABORTED", "Operation aborted by caller"));
2085
+ };
2086
+ server.once("error", () => {
2087
+ finish(false);
2088
+ });
2089
+ server.once("listening", () => {
2090
+ server.close(() => {
2091
+ finish(true);
2092
+ });
2093
+ });
2094
+ signal?.addEventListener("abort", onAbort, { once: true });
2095
+ if (signal?.aborted) {
2096
+ onAbort();
2097
+ return;
2098
+ }
2099
+ server.listen(port, "127.0.0.1");
2100
+ });
2101
+ }
2102
+ async function isPortListening(port, timeoutMs = 200) {
2103
+ return await new Promise((resolve) => {
2104
+ const socket = createConnection({ port, host: "127.0.0.1" });
2105
+ socket.setTimeout(timeoutMs);
2106
+ socket.once("connect", () => {
2107
+ socket.destroy();
2108
+ resolve(true);
2109
+ });
2110
+ socket.once("error", () => {
2111
+ socket.destroy();
2112
+ resolve(false);
2113
+ });
2114
+ socket.once("timeout", () => {
2115
+ socket.destroy();
2116
+ resolve(false);
2117
+ });
2118
+ });
2119
+ }
2120
+ function throwIfAborted2(signal) {
2121
+ if (signal?.aborted) {
2122
+ throw new CfDebuggerError("ABORTED", "Operation aborted by caller");
2123
+ }
2124
+ }
2125
+ function waitForNextProbe(delayMs, signal) {
2126
+ throwIfAborted2(signal);
2127
+ return new Promise((resolve, reject) => {
2128
+ const onAbort = () => {
2129
+ clearTimeout(timer);
2130
+ reject(new CfDebuggerError("ABORTED", "Operation aborted by caller"));
2131
+ };
2132
+ const timer = setTimeout(() => {
2133
+ signal?.removeEventListener("abort", onAbort);
2134
+ resolve();
2135
+ }, delayMs);
2136
+ signal?.addEventListener("abort", onAbort, { once: true });
2137
+ if (signal?.aborted) {
2138
+ onAbort();
2139
+ }
2140
+ });
2141
+ }
2142
+ async function probeTunnelReady(port, timeoutMs, signal) {
2143
+ const deadline = Date.now() + timeoutMs;
2144
+ throwIfAborted2(signal);
2145
+ while (Date.now() < deadline) {
2146
+ const remainingMs = deadline - Date.now();
2147
+ if (await isPortListening(port, Math.min(200, remainingMs))) {
2148
+ return true;
2149
+ }
2150
+ const waitMs = Math.min(PROBE_INTERVAL_MS, Math.max(0, deadline - Date.now()));
2151
+ if (waitMs > 0) {
2152
+ await waitForNextProbe(waitMs, signal);
2153
+ }
2154
+ }
2155
+ throwIfAborted2(signal);
2156
+ return false;
2157
+ }
2158
+ function parseJson(body) {
2159
+ return JSON.parse(body);
2160
+ }
2161
+ function isUnknownRecord(value) {
2162
+ return typeof value === "object" && value !== null;
2163
+ }
2164
+ function isUnknownArray(value) {
2165
+ return Array.isArray(value);
2166
+ }
2167
+ function hasAttachableInspectorTarget(body) {
2168
+ let parsed;
2169
+ try {
2170
+ parsed = parseJson(body);
2171
+ } catch {
2172
+ return false;
2173
+ }
2174
+ if (!isUnknownArray(parsed) || parsed.length === 0) {
2175
+ return false;
2176
+ }
2177
+ const first = parsed[0];
2178
+ if (!isUnknownRecord(first)) {
2179
+ return false;
2180
+ }
2181
+ const candidate = first["webSocketDebuggerUrl"];
2182
+ if (typeof candidate !== "string") {
2183
+ return false;
2184
+ }
2185
+ try {
2186
+ const url = new URL(candidate);
2187
+ return (url.protocol === "ws:" || url.protocol === "wss:") && url.hostname.length > 0;
2188
+ } catch {
2189
+ return false;
2190
+ }
2191
+ }
2192
+ function readInspectorResponse(response, finish) {
2193
+ if (response.statusCode !== 200) {
2194
+ response.destroy();
2195
+ finish(false);
2196
+ return;
2197
+ }
2198
+ const chunks = [];
2199
+ let totalBytes = 0;
2200
+ response.on("data", (chunk) => {
2201
+ totalBytes += chunk.length;
2202
+ if (totalBytes > MAX_INSPECTOR_RESPONSE_BYTES) {
2203
+ response.destroy();
2204
+ finish(false);
2205
+ return;
2206
+ }
2207
+ chunks.push(chunk);
2208
+ });
2209
+ response.once("end", () => {
2210
+ finish(hasAttachableInspectorTarget(Buffer.concat(chunks).toString("utf8")));
2211
+ });
2212
+ response.once("error", () => {
2213
+ finish(false);
2214
+ });
2215
+ }
2216
+ function probeInspectorAttempt(port, timeoutMs, signal) {
2217
+ throwIfAborted2(signal);
2218
+ return new Promise((resolve, reject) => {
2219
+ const state = { settled: false };
2220
+ const finish = (ready) => {
2221
+ if (!state.settled) {
2222
+ state.settled = true;
2223
+ clearTimeout(state.timer);
2224
+ state.removeAbortListener?.();
2225
+ resolve(ready);
2226
+ }
2227
+ };
2228
+ const request = httpGet({ host: "127.0.0.1", port, path: "/json/list" }, (response) => {
2229
+ readInspectorResponse(response, finish);
2230
+ });
2231
+ const onAbort = () => {
2232
+ request.destroy();
2233
+ if (!state.settled) {
2234
+ state.settled = true;
2235
+ clearTimeout(state.timer);
2236
+ state.removeAbortListener?.();
2237
+ reject(new CfDebuggerError("ABORTED", "Operation aborted by caller"));
2238
+ }
2239
+ };
2240
+ state.removeAbortListener = () => {
2241
+ signal?.removeEventListener("abort", onAbort);
2242
+ };
2243
+ signal?.addEventListener("abort", onAbort, { once: true });
2244
+ if (signal?.aborted) {
2245
+ onAbort();
2246
+ return;
2247
+ }
2248
+ state.timer = setTimeout(() => {
2249
+ request.destroy();
2250
+ finish(false);
2251
+ }, timeoutMs);
2252
+ request.once("error", () => {
2253
+ if (signal?.aborted) {
2254
+ onAbort();
2255
+ return;
2256
+ }
2257
+ finish(false);
2258
+ });
2259
+ });
2260
+ }
2261
+ async function probeInspectorReady(port, timeoutMs, signal) {
2262
+ const deadline = Date.now() + timeoutMs;
2263
+ throwIfAborted2(signal);
2264
+ while (Date.now() < deadline) {
2265
+ const remainingMs = deadline - Date.now();
2266
+ const attemptTimeoutMs = Math.max(
2267
+ 1,
2268
+ Math.min(INSPECTOR_ATTEMPT_TIMEOUT_MS, remainingMs)
2269
+ );
2270
+ if (await probeInspectorAttempt(port, attemptTimeoutMs, signal)) {
2271
+ return { status: "ready" };
2272
+ }
2273
+ const waitMs = Math.min(PROBE_INTERVAL_MS, Math.max(0, deadline - Date.now()));
2274
+ if (waitMs > 0) {
2275
+ await waitForNextProbe(waitMs, signal);
2276
+ }
2277
+ }
2278
+ throwIfAborted2(signal);
2279
+ return { status: "unreachable" };
2280
+ }
2281
+
2282
+ // src/session-state/health.ts
2283
+ function errorCode4(error) {
2284
+ if (typeof error !== "object" || error === null) {
2285
+ return void 0;
2286
+ }
2287
+ const code = Reflect.get(error, "code");
2288
+ return typeof code === "string" ? code : void 0;
2289
+ }
2290
+ function isPidAlive(pid) {
2291
+ try {
2292
+ nodeProcess4.kill(pid, 0);
2293
+ return true;
2294
+ } catch (error) {
2295
+ return errorCode4(error) !== "ESRCH";
2296
+ }
2297
+ }
2298
+ function isProcessGroupAlive(pid) {
2299
+ return nodeProcess4.platform !== "win32" && isPidAlive(-pid);
2300
+ }
2301
+ function isPidOrGroupAlive(pid) {
2302
+ return isPidAlive(pid) || isProcessGroupAlive(pid);
2303
+ }
2304
+ function startupAgeLimit(session) {
2305
+ return (session.startupTimeoutMs ?? MAX_STARTUP_TIMEOUT_MS) + STARTUP_STALE_SLACK_MS;
2306
+ }
2307
+ function startupExpired(session) {
2308
+ const startedAt = Date.parse(session.startedAt);
2309
+ return Number.isNaN(startedAt) || Date.now() - startedAt > startupAgeLimit(session);
2310
+ }
2311
+ async function inspectRecordedProcess(pid, identity, signal) {
2312
+ if (signal?.aborted) {
2313
+ throw new CfDebuggerError("ABORTED", "Session health inspection was aborted.");
2314
+ }
2315
+ if (!isPidAlive(pid)) {
2316
+ return "dead";
2317
+ }
2318
+ return await inspectProcessIdentity(pid, identity, signal);
2319
+ }
2320
+ async function readySessionHealth(session, signal) {
2321
+ const tunnelPid = session.tunnelPid;
2322
+ if (tunnelPid === void 0) {
2323
+ return { status: "stale", reason: "recorded tunnel PID is missing" };
2324
+ }
2325
+ const processVerdict = await inspectRecordedProcess(
2326
+ tunnelPid,
2327
+ session.tunnelProcessIdentity,
2328
+ signal
2329
+ );
2330
+ if (processVerdict === "dead" && isProcessGroupAlive(tunnelPid)) {
2331
+ return {
2332
+ status: "unverified",
2333
+ reason: "tunnel leader exited while its process group is still alive"
2334
+ };
2335
+ }
2336
+ if (processVerdict === "dead" || processVerdict === "mismatch") {
2337
+ return { status: "stale", reason: "recorded tunnel process is gone or was reused" };
2338
+ }
2339
+ if (processVerdict === "unavailable") {
2340
+ return { status: "unverified", reason: "recorded tunnel identity could not be inspected" };
2341
+ }
2342
+ const ownership = await inspectPortOwnership(session.localPort, tunnelPid, signal);
2343
+ if (ownership.status === "owned") {
2344
+ return { status: "healthy", reason: "recorded tunnel owns the local port" };
2345
+ }
2346
+ if (ownership.status === "unverified") {
2347
+ return { status: "unverified", reason: ownership.reason };
2348
+ }
2349
+ return {
2350
+ status: "unverified",
2351
+ reason: ownership.status === "not-owned" ? `recorded live tunnel no longer owns the local port; observed PID(s) ${ownership.pids.join(", ")}` : "recorded live tunnel no longer has a listening local port"
2352
+ };
2353
+ }
2354
+ async function startingSessionHealth(session, signal) {
2355
+ if (startupExpired(session)) {
2356
+ if (session.tunnelPid !== void 0) {
2357
+ return await readySessionHealth(session, signal);
2358
+ }
2359
+ return { status: "stale", reason: "startup exceeded its maximum supported age" };
2360
+ }
2361
+ const controllerPid = session.controllerPid ?? session.pid;
2362
+ const processVerdict = await inspectRecordedProcess(
2363
+ controllerPid,
2364
+ session.controllerProcessIdentity,
2365
+ signal
2366
+ );
2367
+ if (processVerdict === "match") {
2368
+ return { status: "healthy", reason: "startup controller identity matches" };
2369
+ }
2370
+ if (processVerdict === "unavailable") {
2371
+ return { status: "unverified", reason: "startup controller identity could not be inspected" };
2372
+ }
2373
+ if (session.tunnelPid === void 0) {
2374
+ return { status: "stale", reason: "startup controller is gone or was reused" };
2375
+ }
2376
+ return await readySessionHealth(session, signal);
2377
+ }
2378
+ async function inspectSessionHealth(session, host = getHostname(), signal) {
2379
+ if (session.hostname !== host) {
2380
+ return { status: "stale", reason: "session belongs to another host" };
2381
+ }
2382
+ return session.status === "ready" ? await readySessionHealth(session, signal) : await startingSessionHealth(session, signal);
2383
+ }
2384
+ async function filterStaleSessions(sessions, signal) {
2385
+ const host = getHostname();
2386
+ const verdicts = await Promise.all(
2387
+ sessions.map(async (session) => [
2388
+ session,
2389
+ await inspectSessionHealth(session, host, signal)
2390
+ ])
2391
+ );
2392
+ return verdicts.filter(([, verdict]) => verdict.status !== "stale").map(([session]) => session);
2393
+ }
2394
+
2395
+ // src/session-state/stop-intent.ts
2396
+ import { access, chmod as chmod2, mkdir as mkdir2, unlink as unlink2, writeFile } from "fs/promises";
2397
+ function errorCode5(error) {
2398
+ if (typeof error !== "object" || error === null) {
2399
+ return void 0;
2400
+ }
2401
+ const code = Reflect.get(error, "code");
2402
+ return typeof code === "string" ? code : void 0;
2403
+ }
2404
+ async function writeSessionStopIntent(sessionId) {
2405
+ const directory = saptoolsDir();
2406
+ await mkdir2(directory, { recursive: true, mode: 448 });
2407
+ await chmod2(directory, 448);
2408
+ try {
2409
+ await writeFile(sessionStopIntentPath(sessionId), "", {
2410
+ encoding: "utf8",
2411
+ flag: "wx",
2412
+ mode: 384
2413
+ });
2414
+ } catch (error) {
2415
+ if (errorCode5(error) !== "EEXIST") {
2416
+ throw error;
2417
+ }
2418
+ }
2419
+ }
2420
+ async function hasSessionStopIntent(sessionId) {
2421
+ try {
2422
+ await access(sessionStopIntentPath(sessionId));
2423
+ return true;
2424
+ } catch (error) {
2425
+ if (errorCode5(error) === "ENOENT") {
2426
+ return false;
2427
+ }
2428
+ throw error;
2429
+ }
2430
+ }
2431
+ async function clearSessionStopIntent(sessionId) {
2432
+ await unlink2(sessionStopIntentPath(sessionId)).catch((error) => {
2433
+ if (errorCode5(error) !== "ENOENT") {
2434
+ throw error;
2435
+ }
2436
+ });
2437
+ }
2438
+
2439
+ // src/session-state/store.ts
2440
+ var DEFAULT_BASE_PORT = 2e4;
2441
+ var DEFAULT_MAX_PORT = 20999;
2442
+ function errorCode6(error) {
2443
+ if (typeof error !== "object" || error === null) {
2444
+ return void 0;
2445
+ }
2446
+ const code = Reflect.get(error, "code");
2447
+ return typeof code === "string" ? code : void 0;
2448
+ }
2449
+ async function readFileIfPresent(path) {
2450
+ try {
2451
+ await chmod3(path, 384);
2452
+ return await readFile4(path, "utf8");
2453
+ } catch (error) {
2454
+ if (errorCode6(error) === "ENOENT") {
2455
+ return void 0;
2456
+ }
2457
+ throw error;
2458
+ }
2459
+ }
2460
+ async function writeJsonFileAtomic(path, value) {
2461
+ const tempPath = `${path}.${randomUUID2()}.tmp`;
2462
+ const parentDir = dirname2(path);
2463
+ await mkdir3(parentDir, { recursive: true, mode: 448 });
2464
+ await chmod3(parentDir, 448);
2465
+ let renamed = false;
2466
+ try {
2467
+ await writeFile2(tempPath, `${JSON.stringify(value, null, 2)}
2468
+ `, {
2469
+ encoding: "utf8",
2470
+ flag: "wx",
2471
+ mode: 384
2472
+ });
2473
+ await rename(tempPath, path);
2474
+ renamed = true;
2475
+ await chmod3(path, 384);
2476
+ } finally {
2477
+ if (!renamed) {
2478
+ await unlink3(tempPath).catch(() => false);
2479
+ }
2480
+ }
2481
+ }
2482
+ function emptyState() {
2483
+ return { version: "2", sessions: [] };
2484
+ }
2485
+ function corruptBackupPath(path) {
2486
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replaceAll(":", "-");
2487
+ return `${path}.corrupt-${timestamp}-${randomUUID2()}`;
2488
+ }
2489
+ async function preserveCorruptState(path, reason) {
2490
+ const backup = corruptBackupPath(path);
2491
+ await rename(path, backup);
2492
+ await chmod3(backup, 384);
2493
+ nodeProcess5.stderr.write(
2494
+ `[cf-debugger] warning: preserved invalid state at ${backup} (${reason}).
2495
+ `
2496
+ );
2497
+ return backup;
2498
+ }
2499
+ async function resetInvalidState(path, reason) {
2500
+ await preserveCorruptState(path, reason);
2501
+ const state = emptyState();
2502
+ await writeJsonFileAtomic(path, state);
2503
+ return state;
2504
+ }
2505
+ async function readStateRaw() {
2506
+ const path = stateFilePath();
2507
+ const raw = await readFileIfPresent(path);
2508
+ if (raw === void 0) {
2509
+ return emptyState();
2510
+ }
2511
+ let parsed;
2512
+ try {
2513
+ parsed = JSON.parse(raw);
2514
+ } catch {
2515
+ return await resetInvalidState(path, "invalid JSON");
2516
+ }
2517
+ const decoded = decodeStateFileDetailed(parsed);
2518
+ if (decoded.kind === "invalid-file") {
2519
+ return await resetInvalidState(path, decoded.reason);
2520
+ }
2521
+ if (decoded.dropped.length > 0) {
2522
+ await preserveCorruptState(path, decoded.dropped.join("; "));
2523
+ await writeJsonFileAtomic(path, decoded.state);
2524
+ nodeProcess5.stderr.write(
2525
+ `[cf-debugger] warning: dropped ${decoded.dropped.length.toString()} invalid state entr${decoded.dropped.length === 1 ? "y" : "ies"}; valid sessions were retained.
2526
+ `
2527
+ );
2528
+ }
2529
+ return decoded.state;
2530
+ }
2531
+ async function writeState(state) {
2532
+ await writeJsonFileAtomic(stateFilePath(), state);
2533
+ }
2534
+ async function readAndPruneLocked(signal) {
2535
+ const raw = await readStateRaw();
2536
+ const host = getHostname2();
2537
+ const remote = raw.sessions.filter((session) => session.hostname !== host);
2538
+ const local = raw.sessions.filter((session) => session.hostname === host);
2539
+ const sessions = await filterStaleSessions(local, signal);
2540
+ const activeIds = new Set(sessions.map((session) => session.sessionId));
2541
+ const removed = local.filter((session) => !activeIds.has(session.sessionId));
2542
+ const persisted = { version: "2", sessions: [...remote, ...sessions] };
2543
+ if (removed.length > 0) {
2544
+ await writeState(persisted);
2545
+ }
2546
+ return { sessions, removed, persisted };
2547
+ }
2548
+ async function readActiveSessions() {
2549
+ const result = await withFileLock(stateLockPath(), readAndPruneLocked);
2550
+ return result.sessions;
2551
+ }
2552
+ async function readSessionSnapshot(options) {
2553
+ return await withFileLock(stateLockPath(), async () => {
2554
+ return (await readStateRaw()).sessions;
2555
+ }, options);
2556
+ }
2557
+ async function readAndPruneActiveSessions(options) {
2558
+ const result = await withFileLock(
2559
+ stateLockPath(),
2560
+ async () => await readAndPruneLocked(options?.signal),
2561
+ options
2562
+ );
2563
+ return { sessions: result.sessions, removed: result.removed };
2564
+ }
2565
+ function sessionKeyString(key) {
2566
+ const base = `${key.region}:${key.org}:${key.space}:${key.app}`;
2567
+ if (key.process === void 0 && key.instance === void 0) {
2568
+ return base;
2569
+ }
2570
+ const target = resolveNodeTarget(key);
2571
+ return `${base}:${target.process}:${target.instance.toString()}`;
2572
+ }
2573
+ function matchesSelectedNodePid(session, requestedNodePid) {
2574
+ if (requestedNodePid === void 0) {
2575
+ return true;
2576
+ }
2577
+ const remoteNodePid = Reflect.get(session, "remoteNodePid");
2578
+ return session.nodePid === requestedNodePid || remoteNodePid === requestedNodePid;
2579
+ }
2580
+ function matchesKey(session, key) {
2581
+ const sessionTarget = resolveNodeTarget(session);
2582
+ const keyTarget = resolveNodeTarget(key);
2583
+ 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) && matchesSelectedNodePid(session, keyTarget.nodePid);
2584
+ }
2585
+ function matchesRegistrationTarget(session, input, target) {
2586
+ return matchesKey(session, {
2587
+ region: input.region,
2588
+ org: input.org,
2589
+ space: input.space,
2590
+ app: input.app,
2591
+ process: target.process,
2592
+ instance: target.instance,
2593
+ apiEndpoint: input.apiEndpoint
2594
+ });
2595
+ }
2596
+ async function pickPort(preferred, reserved, probe, basePort, maxPort) {
2597
+ const candidates = preferred === void 0 ? [] : [preferred];
2598
+ for (let port = basePort; port <= maxPort; port += 1) {
2599
+ if (port !== preferred) {
2600
+ candidates.push(port);
2601
+ }
2602
+ }
2603
+ for (const port of candidates) {
2604
+ if (!reserved.has(port) && await probe(port)) {
2605
+ return port;
2606
+ }
2607
+ }
2608
+ throw new CfDebuggerError(
2609
+ "PORT_UNAVAILABLE",
2610
+ `No free local port available in range ${basePort.toString()}\u2013${maxPort.toString()}`
2611
+ );
2612
+ }
2613
+ async function selectRegistrationCandidate(input, target, excluded) {
2614
+ return await withFileLock(stateLockPath(), async () => {
2615
+ const current = await readAndPruneLocked(input.stateAccess?.signal);
2616
+ const existing = current.sessions.find(
2617
+ (session) => matchesRegistrationTarget(session, input, target)
2618
+ );
2619
+ if (existing !== void 0) {
2620
+ return { existing };
2621
+ }
2622
+ const reserved = /* @__PURE__ */ new Set([
2623
+ ...current.sessions.map((session) => session.localPort),
2624
+ ...excluded
2625
+ ]);
2626
+ return {
2627
+ candidate: await pickPort(
2628
+ input.preferredPort,
2629
+ reserved,
2630
+ () => Promise.resolve(true),
2631
+ input.basePort ?? DEFAULT_BASE_PORT,
2632
+ input.maxPort ?? DEFAULT_MAX_PORT
2633
+ )
2634
+ };
2635
+ }, input.stateAccess);
2636
+ }
2637
+ function validateSessionInput(input) {
2638
+ const remotePort = input.remotePort ?? DEFAULT_NODE_INSPECTOR_PORT;
2639
+ if (!Number.isSafeInteger(remotePort) || remotePort <= 0 || remotePort > 65535) {
2640
+ throw new CfDebuggerError("UNSAFE_INPUT", "Remote inspector port must be from 1 to 65535.");
2641
+ }
2642
+ if (input.startupTimeoutMs !== void 0 && (!Number.isSafeInteger(input.startupTimeoutMs) || input.startupTimeoutMs <= 0 || input.startupTimeoutMs > MAX_STARTUP_TIMEOUT_MS)) {
2643
+ throw new CfDebuggerError("UNSAFE_INPUT", "Persisted startup timeout is outside the supported range.");
2644
+ }
2645
+ }
2646
+ function createRegisteredSession(input, target, localPort, controllerProcessIdentity) {
2647
+ const sessionId = (input.sessionIdFactory ?? randomUUID2)();
2648
+ if (!isSafeSessionId(sessionId)) {
2649
+ throw new CfDebuggerError("UNSAFE_INPUT", "Generated debugger session ID is invalid.");
2650
+ }
2651
+ const cfHomeDir = input.cfHomeForSession(sessionId);
2652
+ if (!isAbsolute3(cfHomeDir)) {
2653
+ throw new CfDebuggerError("UNSAFE_INPUT", "Debugger CF home must be an absolute path.");
2654
+ }
2655
+ return {
2656
+ sessionId,
2657
+ pid: nodeProcess5.pid,
2658
+ controllerPid: nodeProcess5.pid,
2659
+ ...controllerProcessIdentity === void 0 ? {} : { controllerProcessIdentity },
2660
+ hostname: getHostname2(),
2661
+ region: input.region,
2662
+ org: input.org,
2663
+ space: input.space,
2664
+ app: input.app,
2665
+ process: target.process,
2666
+ instance: target.instance,
2667
+ ...target.nodePid === void 0 ? {} : { nodePid: target.nodePid },
2668
+ apiEndpoint: input.apiEndpoint,
2669
+ localPort,
2670
+ remotePort: input.remotePort ?? DEFAULT_NODE_INSPECTOR_PORT,
2671
+ cfHomeDir,
2672
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
2673
+ status: "starting",
2674
+ ...input.startupTimeoutMs === void 0 ? {} : { startupTimeoutMs: input.startupTimeoutMs }
2675
+ };
2676
+ }
2677
+ async function persistRegistration(input, target, candidate, controllerProcessIdentity) {
2678
+ return await withFileLock(stateLockPath(), async () => {
2679
+ const current = await readAndPruneLocked(input.stateAccess?.signal);
2680
+ const existing = current.sessions.find(
2681
+ (session2) => matchesRegistrationTarget(session2, input, target)
2682
+ );
2683
+ if (existing !== void 0) {
2684
+ return { session: existing, existing };
2685
+ }
2686
+ const reserved = current.sessions.some((session2) => session2.localPort === candidate);
2687
+ if (reserved || !await input.portProbe(candidate)) {
2688
+ return void 0;
2689
+ }
2690
+ const session = createRegisteredSession(input, target, candidate, controllerProcessIdentity);
2691
+ await writeState({
2692
+ version: "2",
2693
+ sessions: [...current.persisted.sessions, session]
2694
+ });
2695
+ return { session };
2696
+ }, input.stateAccess);
2697
+ }
2698
+ async function registerNewSession(input) {
2699
+ validateSessionInput(input);
2700
+ const target = resolveNodeTarget(input);
2701
+ const controllerIdentity = await readProcessIdentity(
2702
+ nodeProcess5.pid,
2703
+ input.stateAccess?.signal
2704
+ );
2705
+ const excluded = /* @__PURE__ */ new Set();
2706
+ const maximumAttempts = (input.maxPort ?? DEFAULT_MAX_PORT) - (input.basePort ?? DEFAULT_BASE_PORT) + 2;
2707
+ for (let attempt = 0; attempt < maximumAttempts; attempt += 1) {
2708
+ const selection = await selectRegistrationCandidate(input, target, excluded);
2709
+ if (selection.existing !== void 0) {
2710
+ return { session: selection.existing, existing: selection.existing };
2711
+ }
2712
+ const candidate = selection.candidate;
2713
+ if (candidate === void 0 || !await input.portProbe(candidate)) {
2714
+ if (candidate !== void 0) {
2715
+ excluded.add(candidate);
2716
+ }
2717
+ continue;
2718
+ }
2719
+ const result = await persistRegistration(input, target, candidate, controllerIdentity);
2720
+ if (result !== void 0) {
2721
+ return result;
2722
+ }
2723
+ excluded.add(candidate);
2724
+ }
2725
+ throw new CfDebuggerError("PORT_UNAVAILABLE", "No free local debugger port remained available.");
2726
+ }
2727
+ function withoutMessage(session) {
2728
+ const { message, ...clone } = session;
2729
+ void message;
2730
+ return clone;
2731
+ }
2732
+ function startupMutationBlocked(session) {
2733
+ return session.status === "stopping" || session.stopRequestedAt !== void 0;
2734
+ }
2735
+ function replaceSession(sessions, replacement) {
2736
+ return sessions.map(
2737
+ (session) => session.sessionId === replacement.sessionId ? replacement : session
2738
+ );
2739
+ }
2740
+ async function updateSessionStatus(sessionId, status, message, access2) {
2741
+ return await withFileLock(stateLockPath(), async () => {
2742
+ const raw = await readStateRaw();
2743
+ const target = raw.sessions.find((session) => session.sessionId === sessionId);
2744
+ if (target === void 0 || status !== "stopping" && startupMutationBlocked(target)) {
2745
+ return target;
2746
+ }
2747
+ if (status === "ready" && target.tunnelPid === void 0) {
2748
+ throw new CfDebuggerError(
2749
+ "SESSION_STATE_CONFLICT",
2750
+ "A debugger session cannot become ready before its tunnel PID is recorded."
2751
+ );
2752
+ }
2753
+ const base = withoutMessage(target);
2754
+ const next = message === void 0 ? { ...base, status } : { ...base, status, message };
2755
+ if (JSON.stringify(next) !== JSON.stringify(target)) {
2756
+ await writeState({ version: "2", sessions: replaceSession(raw.sessions, next) });
2757
+ }
2758
+ return next;
2759
+ }, access2);
2760
+ }
2761
+ async function updateSessionPid(sessionId, pid, access2) {
2762
+ if (!Number.isSafeInteger(pid) || pid <= 0) {
2763
+ throw new CfDebuggerError("UNSAFE_INPUT", "Tunnel PID must be a positive safe integer.");
2764
+ }
2765
+ const tunnelProcessIdentity = await readProcessIdentity(pid, access2?.signal);
2766
+ return await withFileLock(stateLockPath(), async () => {
2767
+ const raw = await readStateRaw();
2768
+ const target = raw.sessions.find((session) => session.sessionId === sessionId);
2769
+ if (target === void 0 || startupMutationBlocked(target)) {
2770
+ return target;
2771
+ }
2772
+ const { tunnelProcessIdentity: previousIdentity, ...base } = target;
2773
+ void previousIdentity;
2774
+ const next = {
2775
+ ...base,
2776
+ pid,
2777
+ tunnelPid: pid,
2778
+ ...tunnelProcessIdentity === void 0 ? {} : { tunnelProcessIdentity }
2779
+ };
2780
+ if (JSON.stringify(next) !== JSON.stringify(target)) {
2781
+ await writeState({ version: "2", sessions: replaceSession(raw.sessions, next) });
2782
+ }
2783
+ return next;
2784
+ }, access2);
2785
+ }
2786
+ async function updateSessionRemoteNodePid(sessionId, remoteNodePid, access2) {
2787
+ resolveNodeTarget({ nodePid: remoteNodePid });
2788
+ return await withFileLock(stateLockPath(), async () => {
2789
+ const raw = await readStateRaw();
2790
+ const target = raw.sessions.find((session) => session.sessionId === sessionId);
2791
+ if (target === void 0 || startupMutationBlocked(target)) {
2792
+ return target;
2793
+ }
2794
+ const next = { ...target, remoteNodePid };
2795
+ if (target.remoteNodePid !== remoteNodePid) {
2796
+ await writeState({ version: "2", sessions: replaceSession(raw.sessions, next) });
2797
+ }
2798
+ return next;
2799
+ }, access2);
2800
+ }
2801
+ async function removeSession(sessionId) {
2802
+ return await withFileLock(stateLockPath(), async () => {
2803
+ const raw = await readStateRaw();
2804
+ const target = raw.sessions.find((session) => session.sessionId === sessionId);
2805
+ if (target === void 0) {
2806
+ return void 0;
2807
+ }
2808
+ await writeState({
2809
+ version: "2",
2810
+ sessions: raw.sessions.filter((session) => session.sessionId !== sessionId)
2811
+ });
2812
+ return target;
2813
+ });
2814
+ }
2815
+ async function requestSessionStop(sessionId) {
2816
+ return await withFileLock(stateLockPath(), async () => {
2817
+ const raw = await readStateRaw();
2818
+ const target = raw.sessions.find((session) => session.sessionId === sessionId);
2819
+ if (target === void 0) {
2820
+ return void 0;
2821
+ }
2822
+ if (target.status === "ready") {
2823
+ return { session: target, previousStatus: target.status };
2824
+ }
2825
+ await writeSessionStopIntent(sessionId);
2826
+ if (target.stopRequestedAt !== void 0) {
2827
+ return { session: target, previousStatus: target.status };
2828
+ }
2829
+ const requested = { ...target, stopRequestedAt: (/* @__PURE__ */ new Date()).toISOString() };
2830
+ await writeState({
2831
+ version: "2",
2832
+ sessions: raw.sessions.map(
2833
+ (session) => session.sessionId === sessionId ? requested : session
2834
+ )
2835
+ });
2836
+ return { session: requested, previousStatus: target.status };
2837
+ });
2838
+ }
2839
+
2840
+ // src/cli-errors.ts
2841
+ var CLEANUP_FAILURE_EXIT_CODE = 70;
2842
+ var CleanupFailureError = class extends AggregateError {
2843
+ constructor(errors, cause) {
2844
+ super(
2845
+ errors,
2846
+ "Debugger startup failed and resource cleanup was incomplete",
2847
+ { cause }
2848
+ );
2849
+ this.name = "CleanupFailureError";
2850
+ }
2851
+ };
2852
+ function requestedCliExitCode(error) {
2853
+ if (typeof error !== "object" || error === null) {
2854
+ return void 0;
2855
+ }
2856
+ const value = Reflect.get(error, "cliExitCode");
2857
+ return value === 130 || value === 143 ? value : void 0;
2858
+ }
2859
+ function hasTunnelTerminationFailure(error) {
2860
+ if (error instanceof CfDebuggerError) {
2861
+ return error.code === "TUNNEL_TERMINATION_FAILED";
2862
+ }
2863
+ return error instanceof AggregateError && error.errors.some((nested) => hasTunnelTerminationFailure(nested));
2864
+ }
2865
+ function hasCleanupFailure(error) {
2866
+ return error instanceof CleanupFailureError || hasTunnelTerminationFailure(error);
2867
+ }
2868
+ function cliErrorExitCode(error) {
2869
+ const requested = requestedCliExitCode(error);
2870
+ if (requested !== void 0) {
2871
+ return requested;
2872
+ }
2873
+ if (error instanceof CfDebuggerError && error.code === "ABORTED") {
2874
+ return 130;
2875
+ }
2876
+ return hasCleanupFailure(error) ? CLEANUP_FAILURE_EXIT_CODE : 1;
2877
+ }
2878
+
2879
+ // src/debug-session/lifecycle.ts
2880
+ import { constants as osConstants } from "os";
2881
+
2882
+ // src/debug-session/processes.ts
2883
+ import process3 from "process";
2884
+ async function terminatePidOrGroup(pid, timeoutMs = CHILD_SIGTERM_GRACE_MS, pinnedTarget, verifyBeforeSignal) {
2885
+ const targetKind = pinnedTarget ?? (isProcessGroupAlive(pid) ? "group" : "pid");
2886
+ const targetAlive = () => targetKind === "group" ? isProcessGroupAlive(pid) : isPidAlive(pid);
2887
+ if (!targetAlive()) {
2888
+ return "terminated";
2889
+ }
2890
+ if (verifyBeforeSignal !== void 0 && !await verifyBeforeSignal("SIGTERM")) {
2891
+ return "ownership-lost";
2892
+ }
2893
+ try {
2894
+ process3.kill(targetKind === "group" ? -pid : pid, "SIGTERM");
2895
+ } catch {
2896
+ }
2897
+ const startedAt = Date.now();
2898
+ while (Date.now() - startedAt < timeoutMs) {
2899
+ if (!targetAlive()) {
2900
+ return "terminated";
2901
+ }
2902
+ await new Promise((resolve) => {
2903
+ setTimeout(resolve, PID_TERMINATION_POLL_MS);
2904
+ });
2905
+ }
2906
+ if (verifyBeforeSignal !== void 0 && !await verifyBeforeSignal("SIGKILL")) {
2907
+ return "ownership-lost";
2908
+ }
2909
+ try {
2910
+ process3.kill(targetKind === "group" ? -pid : pid, "SIGKILL");
2911
+ } catch {
2912
+ }
2913
+ const forceStartedAt = Date.now();
2914
+ while (Date.now() - forceStartedAt < CHILD_SIGKILL_GRACE_MS) {
2915
+ if (!targetAlive()) {
2916
+ return "terminated";
2917
+ }
2918
+ await new Promise((resolve) => {
2919
+ setTimeout(resolve, PID_TERMINATION_POLL_MS);
2920
+ });
2921
+ }
2922
+ return targetAlive() ? "still-alive" : "terminated";
2923
+ }
2924
+ async function killProcessGroupOrProc(child, verifyBeforeSignal, timeoutMs = CHILD_SIGTERM_GRACE_MS) {
2925
+ if (child.pid === void 0) {
2926
+ return "terminated";
2927
+ }
2928
+ const childClosed = child.exitCode !== null || child.signalCode !== null;
2929
+ if (childClosed && process3.platform === "win32") {
2930
+ return "terminated";
2931
+ }
2932
+ return await terminatePidOrGroup(
2933
+ child.pid,
2934
+ timeoutMs,
2935
+ childClosed ? "group" : void 0,
2936
+ verifyBeforeSignal
2937
+ );
2938
+ }
2939
+
2940
+ // src/debug-session/session-home.ts
2941
+ import { rm } from "fs/promises";
2942
+ async function removeOwnedSessionCfHome(sessionId, candidate) {
2943
+ if (!isOwnedSessionCfHomeDir(sessionId, candidate)) {
2944
+ throw new CfDebuggerError(
2945
+ "UNSAFE_INPUT",
2946
+ `Refusing to remove unowned debugger CF home for session ${sessionId}.`
2947
+ );
2948
+ }
2949
+ await rm(candidate, { recursive: true, force: true });
2950
+ }
2951
+ async function tryRemoveOwnedSessionCfHome(sessionId, candidate) {
2952
+ if (!isOwnedSessionCfHomeDir(sessionId, candidate)) {
2953
+ return false;
2954
+ }
2955
+ try {
2956
+ await removeOwnedSessionCfHome(sessionId, candidate);
2957
+ return true;
2958
+ } catch {
2959
+ return false;
2960
+ }
2961
+ }
2962
+
2963
+ // src/debug-session/lifecycle.ts
2964
+ var handleLifecycles = /* @__PURE__ */ new WeakMap();
2965
+ function childIsOpen(child) {
2966
+ return child.exitCode === null && child.signalCode === null;
2967
+ }
2968
+ function signalExitCode(signal) {
2969
+ return 128 + osConstants.signals[signal];
2970
+ }
2971
+ function unexpectedExitCode(code, signal) {
2972
+ if (signal !== null) {
2973
+ return signalExitCode(signal);
2974
+ }
2975
+ return code === null || code === 0 ? 1 : code;
2976
+ }
2977
+ function emitSafely(emit, status, message) {
2978
+ try {
2979
+ emit(status, message);
2980
+ } catch {
2981
+ }
2982
+ }
2983
+ async function runCleanupActions(actions, aggregateMessage) {
2984
+ const errors = [];
2985
+ for (const action of actions) {
2986
+ try {
2987
+ await action();
2988
+ } catch (error) {
2989
+ errors.push(error);
2990
+ }
2991
+ }
2992
+ if (errors.length === 1) {
2993
+ throw errors[0];
2994
+ }
2995
+ if (errors.length > 1) {
2996
+ throw new AggregateError(errors, aggregateMessage);
2997
+ }
2998
+ }
2999
+ async function handleTunnelClose(sessionId, code, signal, child, resolveExit, isFinalizing, hasFailed, markFailed, emit) {
3000
+ const stopRequested = await hasSessionStopIntent(sessionId);
3001
+ const expected = isFinalizing() || stopRequested;
3002
+ if (stopRequested) {
3003
+ await clearSessionStopIntent(sessionId);
3004
+ }
3005
+ if (!expected && !hasFailed()) {
3006
+ const detail = signal === null ? code === null ? "no exit status" : `exit code ${code.toString()}` : `signal ${signal}`;
3007
+ const diagnostics = formatTunnelDiagnostics(getTunnelDiagnostics(child));
3008
+ const error = new CfDebuggerError(
3009
+ "TUNNEL_EXITED",
3010
+ `SSH tunnel ended unexpectedly with ${detail}.`,
3011
+ diagnostics
3012
+ );
3013
+ markFailed(error);
3014
+ emitSafely(emit, "error", error.message);
3015
+ }
3016
+ resolveExit(expected && !hasFailed() ? 0 : unexpectedExitCode(code, signal));
3017
+ }
3018
+ function attachTunnelEvents(sessionId, child, resolveExit, isFinalizing, hasFailed, markFailed, emit) {
3019
+ child.once("close", (code, signal) => {
3020
+ void handleTunnelClose(
3021
+ sessionId,
3022
+ code,
3023
+ signal,
3024
+ child,
3025
+ resolveExit,
3026
+ isFinalizing,
3027
+ hasFailed,
3028
+ markFailed,
3029
+ emit
3030
+ ).catch((error) => {
3031
+ const failure = new CfDebuggerError(
3032
+ "TUNNEL_EXITED",
3033
+ `SSH tunnel ended and its stop intent could not be verified: ${error instanceof Error ? error.message : String(error)}`,
3034
+ formatTunnelDiagnostics(getTunnelDiagnostics(child))
3035
+ );
3036
+ markFailed(failure);
3037
+ emitSafely(emit, "error", failure.message);
3038
+ resolveExit(unexpectedExitCode(code, signal));
3039
+ });
3040
+ });
3041
+ child.once("error", (error) => {
3042
+ const failure = new CfDebuggerError(
3043
+ "TUNNEL_EXITED",
3044
+ error.message,
3045
+ formatTunnelDiagnostics(getTunnelDiagnostics(child))
3046
+ );
3047
+ markFailed(failure);
3048
+ emitSafely(emit, "error", failure.message);
3049
+ });
3050
+ }
3051
+ async function expectedTunnelStillOwned(session, child) {
3052
+ const childPid = child?.pid;
3053
+ if (childPid === void 0) {
3054
+ return false;
3055
+ }
3056
+ const ownership = await inspectPortOwnership(session.localPort, childPid);
3057
+ return ownership.status === "unverified" ? "unverified" : ownership.status === "owned";
3058
+ }
3059
+ async function cleanupFinishedSession(session) {
3060
+ await removeOwnedSessionCfHome(session.sessionId, session.cfHomeDir);
3061
+ await removeSession(session.sessionId);
3062
+ await clearSessionStopIntent(session.sessionId);
3063
+ }
3064
+ function createTunnelLifecycle(session, emit) {
3065
+ let child;
3066
+ let childIdentity;
3067
+ let childFailed = false;
3068
+ let tunnelError;
3069
+ let finalizing = false;
3070
+ let exitResolve = (_code) => {
3071
+ throw new Error("Tunnel exit resolver was used before initialization.");
3072
+ };
3073
+ const exitPromise = new Promise((resolve) => {
3074
+ exitResolve = resolve;
3075
+ });
3076
+ const observeChild = (tunnelChild) => {
3077
+ child = tunnelChild;
3078
+ childIdentity = tunnelChild.pid === void 0 ? void 0 : readProcessIdentity(tunnelChild.pid);
3079
+ attachTunnelEvents(
3080
+ session.sessionId,
3081
+ tunnelChild,
3082
+ exitResolve,
3083
+ () => finalizing,
3084
+ () => childFailed,
3085
+ (error) => {
3086
+ if (!childFailed) {
3087
+ childFailed = true;
3088
+ tunnelError = error;
3089
+ }
3090
+ },
3091
+ emit
3092
+ );
3093
+ };
3094
+ const verifyBeforeSignal = async (signal) => {
3095
+ const tunnelChild = child;
3096
+ const childPid = tunnelChild?.pid;
3097
+ if (tunnelChild === void 0 || childPid === void 0 || !childIsOpen(tunnelChild)) {
3098
+ return false;
3099
+ }
3100
+ const expectedIdentity = await childIdentity;
3101
+ if (expectedIdentity !== void 0) {
3102
+ return await inspectProcessIdentity(childPid, expectedIdentity) === "match" && childIsOpen(tunnelChild);
3103
+ }
3104
+ if (signal === "SIGKILL") {
3105
+ return await expectedTunnelStillOwned(session, tunnelChild) === true;
3106
+ }
3107
+ return true;
3108
+ };
3109
+ const finalize = async (emitStopped) => {
3110
+ finalizing = true;
3111
+ const termination = child === void 0 ? "terminated" : await killProcessGroupOrProc(child, verifyBeforeSignal);
3112
+ const stillOwned = await expectedTunnelStillOwned(session, child);
3113
+ if (termination !== "terminated" || stillOwned === true || stillOwned === "unverified") {
3114
+ const stderr = child === void 0 ? void 0 : formatTunnelDiagnostics(getTunnelDiagnostics(child));
3115
+ throw new CfDebuggerError(
3116
+ "TUNNEL_TERMINATION_FAILED",
3117
+ `Tunnel for session ${session.sessionId} did not terminate cleanly; state and CF home were retained.`,
3118
+ stderr
3119
+ );
3120
+ }
3121
+ await cleanupFinishedSession(session);
3122
+ if (emitStopped) {
3123
+ emitSafely(emit, "stopped");
3124
+ }
3125
+ };
3126
+ return {
3127
+ exitPromise,
3128
+ assertRunning: () => {
3129
+ if (child === void 0 || !childIsOpen(child) || childFailed) {
3130
+ throw new CfDebuggerError(
3131
+ "TUNNEL_NOT_READY",
3132
+ `SSH tunnel for session ${session.sessionId} exited before readiness could be committed.`,
3133
+ child === void 0 ? void 0 : formatTunnelDiagnostics(getTunnelDiagnostics(child))
3134
+ );
3135
+ }
3136
+ },
3137
+ finalize,
3138
+ observeChild,
3139
+ failed: () => childFailed,
3140
+ error: () => tunnelError
3141
+ };
3142
+ }
3143
+ function createDebuggerHandle(session, emit, lifecycle) {
3144
+ let disposePromise;
3145
+ const handle = {
3146
+ session,
3147
+ dispose: async () => {
3148
+ const attempt = disposePromise ?? (async () => {
3149
+ const reportLifecycle = !lifecycle.failed();
3150
+ await runCleanupActions([
3151
+ async () => {
3152
+ if (reportLifecycle) {
3153
+ emitSafely(emit, "stopping");
3154
+ await updateSessionStatus(session.sessionId, "stopping");
3155
+ }
3156
+ },
3157
+ async () => {
3158
+ await lifecycle.finalize(reportLifecycle);
3159
+ }
3160
+ ], "Debugger disposal failed");
3161
+ })();
3162
+ disposePromise = attempt;
3163
+ try {
3164
+ await attempt;
3165
+ } catch (error) {
3166
+ if (disposePromise === attempt) {
3167
+ disposePromise = void 0;
3168
+ }
3169
+ throw error;
3170
+ }
3171
+ },
3172
+ waitForExit: async () => await lifecycle.exitPromise
3173
+ };
3174
+ handleLifecycles.set(handle, lifecycle);
3175
+ return handle;
3176
+ }
3177
+ function getDebuggerHandleTunnelError(handle) {
3178
+ return handleLifecycles.get(handle)?.error();
3179
+ }
3180
+ async function cleanupFailedStartup(error, lifecycle, emit) {
3181
+ emitSafely(emit, "error", error instanceof Error ? error.message : String(error));
3182
+ try {
3183
+ await lifecycle.finalize(false);
3184
+ } catch (cleanupError) {
3185
+ throw new CleanupFailureError(
3186
+ [error, cleanupError],
3187
+ cleanupError
3188
+ );
3189
+ }
3190
+ throw error;
3191
+ }
3192
+
3193
+ // src/debug-session/startup-deadline.ts
3194
+ function resolveStartupTimeoutMs(value) {
3195
+ const timeoutMs = value ?? DEFAULT_STARTUP_TIMEOUT_MS;
3196
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_STARTUP_TIMEOUT_MS) {
3197
+ throw new CfDebuggerError(
3198
+ "UNSAFE_INPUT",
3199
+ `Startup timeout must be an integer from 1 to ${MAX_STARTUP_TIMEOUT_MS.toString()} milliseconds.`
3200
+ );
3201
+ }
3202
+ return timeoutMs;
3203
+ }
3204
+ function remainingStartupMs(expiresAt) {
3205
+ return Math.max(0, expiresAt - Date.now());
3206
+ }
3207
+ function startupTimeoutError(timeoutMs, phase) {
3208
+ return new CfDebuggerError(
3209
+ "STARTUP_TIMEOUT",
3210
+ `Debugger startup exceeded its ${(timeoutMs / 1e3).toString()}s deadline during ${phase}.`
3211
+ );
3212
+ }
3213
+ function createStartupDeadline(timeoutMs, callerSignal) {
3214
+ const controller = new AbortController();
3215
+ const expiresAt = Date.now() + timeoutMs;
3216
+ const onCallerAbort = () => {
3217
+ controller.abort(callerSignal?.reason);
3218
+ };
3219
+ const timer = setTimeout(() => {
3220
+ controller.abort(startupTimeoutError(timeoutMs, "startup"));
3221
+ }, timeoutMs);
3222
+ timer.unref();
3223
+ callerSignal?.addEventListener("abort", onCallerAbort, { once: true });
3224
+ if (callerSignal?.aborted === true) {
3225
+ controller.abort(callerSignal.reason);
3226
+ }
3227
+ return {
3228
+ expiresAt,
3229
+ signal: controller.signal,
3230
+ timeoutMs,
3231
+ dispose: () => {
3232
+ clearTimeout(timer);
3233
+ callerSignal?.removeEventListener("abort", onCallerAbort);
3234
+ }
3235
+ };
3236
+ }
3237
+ function throwIfStartupAborted(signal, expiresAt, timeoutMs, phase) {
3238
+ if (remainingStartupMs(expiresAt) === 0) {
3239
+ throw startupTimeoutError(timeoutMs, phase);
3240
+ }
3241
+ if (signal?.aborted) {
3242
+ throw new CfDebuggerError("ABORTED", "Operation aborted by caller");
3243
+ }
3244
+ }
3245
+
3246
+ // src/debug-session/start.ts
3247
+ import { chmod as chmod4, mkdir as mkdir4 } from "fs/promises";
3248
+ import nodeProcess6 from "process";
3249
+
3250
+ // src/debug-session/orphans.ts
3251
+ import { readdir as readdir2, stat as stat2, unlink as unlink4 } from "fs/promises";
3252
+ import { hostname as getHostname3 } from "os";
3253
+ import { join as join2 } from "path";
3254
+ var STALE_TEMP_AGE_MS = 24 * 60 * 60 * 1e3;
3255
+ async function modifiedAt(path) {
3256
+ try {
3257
+ return (await stat2(path)).mtimeMs;
3258
+ } catch {
3259
+ return void 0;
3260
+ }
3261
+ }
3262
+ async function cleanupStaleStateTemps() {
3263
+ const root = saptoolsDir();
3264
+ const entries = await readdir2(root, { withFileTypes: true }).catch(() => []);
3265
+ const now = Date.now();
3266
+ for (const entry of entries) {
3267
+ if (!entry.isFile() || !entry.name.startsWith(`${CF_DEBUGGER_STATE_FILENAME}.`) || !entry.name.endsWith(".tmp")) {
3268
+ continue;
3269
+ }
3270
+ const path = join2(root, entry.name);
3271
+ const mtimeMs = await modifiedAt(path);
3272
+ if (mtimeMs !== void 0 && now - mtimeMs >= STALE_TEMP_AGE_MS) {
3273
+ await unlink4(path).catch(() => false);
3274
+ }
3275
+ }
3276
+ }
3277
+ async function pruneAndCleanupOrphans(stateAccess) {
3278
+ const result = await readAndPruneActiveSessions(stateAccess);
3279
+ const host = getHostname3();
3280
+ for (const removed of result.removed) {
3281
+ if (removed.hostname === host) {
3282
+ await tryRemoveOwnedSessionCfHome(removed.sessionId, removed.cfHomeDir);
3283
+ }
3284
+ }
3285
+ await cleanupStaleStateTemps();
3286
+ return result;
3287
+ }
3288
+
3289
+ // src/debug-session/startup-cancellation.ts
3290
+ var STOP_REQUEST_POLL_MS = 500;
3291
+ function createStartupCancellation(sessionId, callerSignal) {
3292
+ const controller = new AbortController();
3293
+ let active = true;
3294
+ let timer;
3295
+ const onCallerAbort = () => {
3296
+ controller.abort(callerSignal?.reason);
3297
+ };
3298
+ const schedule = () => {
3299
+ timer = setTimeout(() => {
3300
+ void poll();
3301
+ }, STOP_REQUEST_POLL_MS);
3302
+ timer.unref();
3303
+ };
3304
+ const poll = async () => {
3305
+ try {
3306
+ if (active && await hasSessionStopIntent(sessionId)) {
3307
+ controller.abort();
3308
+ }
3309
+ } catch {
3310
+ }
3311
+ if (active && !controller.signal.aborted) {
3312
+ schedule();
3313
+ }
3314
+ };
3315
+ callerSignal?.addEventListener("abort", onCallerAbort, { once: true });
3316
+ if (callerSignal?.aborted === true) {
3317
+ controller.abort(callerSignal.reason);
3318
+ } else {
3319
+ void poll();
3320
+ }
3321
+ return {
3322
+ signal: controller.signal,
3323
+ dispose: () => {
3324
+ active = false;
3325
+ clearTimeout(timer);
3326
+ callerSignal?.removeEventListener("abort", onCallerAbort);
3327
+ controller.abort();
3328
+ }
3329
+ };
3330
+ }
3331
+
3332
+ // src/debug-session/startup-remote.ts
3333
+ function signalFailureDetail(result) {
3334
+ if (result.timedOutAfterMs !== void 0) {
3335
+ return `timed out after ${(result.timedOutAfterMs / 1e3).toString()}s`;
3336
+ }
3337
+ const stderr = result.stderr.trim();
3338
+ if (stderr.length > 0) {
3339
+ return stderr;
3340
+ }
3341
+ if (result.signal !== void 0) {
3342
+ return `terminated by signal ${result.signal}`;
3343
+ }
3344
+ return `exit code ${String(result.exitCode)}`;
3345
+ }
3346
+ function warnOnTruncatedStderr(inputs, result) {
3347
+ if (result.stderrTruncated) {
3348
+ inputs.warn(
3349
+ "Remote SSH stderr diagnostics were truncated; inspector marker parsing used complete stdout."
3350
+ );
3351
+ }
3352
+ }
3353
+ function parseSignalResult(appName, remotePort, result) {
3354
+ if (result.exitCode !== 0) {
3355
+ throw new CfDebuggerError(
3356
+ "USR1_SIGNAL_FAILED",
3357
+ `Failed to send SIGUSR1 to the Node.js process on ${appName}: ${signalFailureDetail(result)}`,
3358
+ result.stderr
3359
+ );
3360
+ }
3361
+ if (result.stdoutTruncated) {
3362
+ throw new CfDebuggerError(
3363
+ "INSPECTOR_OUTPUT_TOO_LARGE",
3364
+ "Inspector startup stdout exceeded the configured capture limit.",
3365
+ result.stderr
3366
+ );
3367
+ }
3368
+ return parseNodeInspectorMarkers(result.stdout, remotePort).remoteNodePid;
3369
+ }
3370
+ async function executeRemoteSignal(inputs) {
3371
+ const { options, target, context } = inputs;
3372
+ return await cfSshOneShot(
3373
+ options.app,
3374
+ buildNodeInspectorCommand(target.nodePid, options.remotePort),
3375
+ { ...context, phase: "remote inspector signalling" },
3376
+ {
3377
+ process: target.process,
3378
+ instance: target.instance
3379
+ }
3380
+ );
3381
+ }
3382
+ function restartRefusal(options, stderr) {
3383
+ return new CfDebuggerError(
3384
+ "SSH_NOT_ENABLED",
3385
+ `SSH is disabled for ${options.app}. Re-run with --allow-ssh-enable-restart, or run \`cf enable-ssh ${options.app} && cf restart ${options.app}\` manually.`,
3386
+ stderr
3387
+ );
3388
+ }
3389
+ function ensureRestartSafe(inputs, stderr) {
3390
+ if (inputs.options.allowSshEnableRestart !== true) {
3391
+ throw restartRefusal(inputs.options, stderr);
3392
+ }
3393
+ if (inputs.target.nodePid !== void 0) {
3394
+ throw new CfDebuggerError(
3395
+ "NODE_PID_RESTART_UNSAFE",
3396
+ `Cannot restart ${inputs.options.app} while targeting Node PID ${inputs.target.nodePid.toString()}; restart manually and select its new PID.`,
3397
+ stderr
3398
+ );
3399
+ }
3400
+ }
3401
+ async function enableAppSsh(inputs, stderr) {
3402
+ ensureRestartSafe(inputs, stderr);
3403
+ const state = await cfSshEnabled(
3404
+ inputs.options.app,
3405
+ { ...inputs.context, phase: "checking app SSH state" }
3406
+ );
3407
+ if (state === "enabled") {
3408
+ throw new CfDebuggerError(
3409
+ "SSH_NOT_ENABLED",
3410
+ `SSH is already enabled for ${inputs.options.app}, so restarting it is not a remedy. Check space-level SSH policy and your Developer role.`,
3411
+ stderr
3412
+ );
3413
+ }
3414
+ if (state === "unknown") {
3415
+ throw new CfDebuggerError(
3416
+ "SSH_STATE_UNKNOWN",
3417
+ `Could not verify whether SSH is enabled for ${inputs.options.app}; no deployment change was made.`,
3418
+ stderr
3419
+ );
3420
+ }
3421
+ inputs.emit("ssh-enabling", "Enabling app-level SSH");
3422
+ await inputs.transition("ssh-enabling", "Enabling app-level SSH");
3423
+ await cfEnableSsh(
3424
+ inputs.options.app,
3425
+ { ...inputs.context, phase: "enabling app SSH" }
3426
+ );
3427
+ const confirmed = await cfSshEnabled(
3428
+ inputs.options.app,
3429
+ { ...inputs.context, phase: "confirming app SSH state" }
3430
+ );
3431
+ if (confirmed !== "enabled") {
3432
+ throw new CfDebuggerError(
3433
+ "SSH_STATE_UNKNOWN",
3434
+ `App-level SSH enablement for ${inputs.options.app} could not be confirmed; no restart was attempted.`
3435
+ );
3436
+ }
3437
+ }
3438
+ async function enableAndRestart(inputs, stderr) {
3439
+ await enableAppSsh(inputs, stderr);
3440
+ const { app, org, space } = inputs.options;
3441
+ inputs.warn(
3442
+ `Restarting app ${app} in ${org}/${space} so newly enabled SSH becomes active.`
3443
+ );
3444
+ inputs.emit("ssh-restarting", "Restarting app so SSH becomes active");
3445
+ await inputs.transition("ssh-restarting", "Restarting app so SSH becomes active");
3446
+ await cfRestartApp(app, { ...inputs.context, phase: "restarting the app" });
3447
+ }
3448
+ async function retrySignal(inputs) {
3449
+ inputs.emit("signaling");
3450
+ await inputs.transition("signaling");
3451
+ const result = await executeRemoteSignal(inputs);
3452
+ warnOnTruncatedStderr(inputs, result);
3453
+ if (result.exitCode !== 0) {
3454
+ throw new CfDebuggerError(
3455
+ "USR1_SIGNAL_FAILED",
3456
+ `Failed to signal ${inputs.options.app} after enabling SSH: ${signalFailureDetail(result)}`,
3457
+ result.stderr
3458
+ );
3459
+ }
3460
+ return parseSignalResult(
3461
+ inputs.options.app,
3462
+ inputs.options.remotePort ?? DEFAULT_NODE_INSPECTOR_PORT,
3463
+ result
3464
+ );
3465
+ }
3466
+ async function signalRemoteNode(inputs) {
3467
+ inputs.emit("signaling");
3468
+ await inputs.transition("signaling");
3469
+ const result = await executeRemoteSignal(inputs);
3470
+ warnOnTruncatedStderr(inputs, result);
3471
+ const remotePort = inputs.options.remotePort ?? DEFAULT_NODE_INSPECTOR_PORT;
3472
+ if (result.exitCode === 0) {
3473
+ return parseSignalResult(inputs.options.app, remotePort, result);
3474
+ }
3475
+ if (isSshPermissionError(result.stderr)) {
3476
+ throw new CfDebuggerError(
3477
+ "SSH_PERMISSION_DENIED",
3478
+ `CF SSH permission was denied for ${inputs.options.app}. Check your space role and SSH policy.`,
3479
+ result.stderr
3480
+ );
3481
+ }
3482
+ if (!isSshDisabledError(result.stderr)) {
3483
+ return parseSignalResult(inputs.options.app, remotePort, result);
3484
+ }
3485
+ await enableAndRestart(inputs, result.stderr);
3486
+ return await retrySignal(inputs);
3487
+ }
3488
+
3489
+ // src/debug-session/startup-tunnel.ts
3490
+ function linkAbortSignals(signals) {
3491
+ const controller = new AbortController();
3492
+ const subscriptions = [];
3493
+ for (const signal of signals) {
3494
+ const abort = () => {
3495
+ controller.abort(signal.reason);
3496
+ };
3497
+ if (signal.aborted) {
3498
+ abort();
3499
+ break;
3500
+ }
3501
+ signal.addEventListener("abort", abort, { once: true });
3502
+ subscriptions.push([signal, abort]);
3503
+ }
3504
+ return {
3505
+ signal: controller.signal,
3506
+ dispose: () => {
3507
+ for (const [signal, abort] of subscriptions) {
3508
+ signal.removeEventListener("abort", abort);
3509
+ }
3510
+ }
3511
+ };
3512
+ }
3513
+ function diagnosticsStderr(child) {
3514
+ return formatTunnelDiagnostics(getTunnelDiagnostics(child));
3515
+ }
3516
+ async function ensurePortAvailable(localPort, signal) {
3517
+ if (!await isPortFree(localPort, signal)) {
3518
+ throw new CfDebuggerError(
3519
+ "PORT_UNAVAILABLE",
3520
+ `Local port ${localPort.toString()} was taken before the tunnel could start.`
3521
+ );
3522
+ }
3523
+ }
3524
+ function remainingReadyTimeout(inputs) {
3525
+ if (inputs.context.deadlineAt === void 0) {
3526
+ return inputs.tunnelReadyTimeoutMs;
3527
+ }
3528
+ return Math.max(
3529
+ 1,
3530
+ Math.min(inputs.tunnelReadyTimeoutMs, inputs.context.deadlineAt - Date.now())
3531
+ );
3532
+ }
3533
+ function startupStateAccess(inputs) {
3534
+ return {
3535
+ ...inputs.context.signal === void 0 ? {} : { signal: inputs.context.signal },
3536
+ ...inputs.context.deadlineAt === void 0 ? {} : { timeoutMs: Math.max(1, inputs.context.deadlineAt - Date.now()) }
3537
+ };
3538
+ }
3539
+ function ownerVerificationError(localPort, inspection, child) {
3540
+ const stderr = diagnosticsStderr(child);
3541
+ if (inspection.status === "unverified") {
3542
+ return new CfDebuggerError(
3543
+ "TUNNEL_OWNER_UNVERIFIED",
3544
+ `Could not verify local tunnel port ${localPort.toString()}: ${inspection.reason}`,
3545
+ stderr
3546
+ );
3547
+ }
3548
+ const owners = inspection.status === "not-owned" ? inspection.pids.join(", ") : "none";
3549
+ return new CfDebuggerError(
3550
+ "TUNNEL_OWNER_MISMATCH",
3551
+ `Local tunnel port ${localPort.toString()} is not owned by the spawned tunnel (observed PID(s): ${owners}).`,
3552
+ stderr
3553
+ );
3554
+ }
3555
+ async function waitForLocalTunnel(inputs, child, timeoutMs) {
3556
+ const childEnded = new AbortController();
3557
+ const linked = linkAbortSignals([
3558
+ ...inputs.context.signal === void 0 ? [] : [inputs.context.signal],
3559
+ childEnded.signal
3560
+ ]);
3561
+ try {
3562
+ const probe = probeTunnelReady(inputs.session.localPort, timeoutMs, linked.signal);
3563
+ const winner = await Promise.race([
3564
+ probe.then((ready) => ({ kind: "probe", ready })),
3565
+ childExitPromise(child).then(() => ({ kind: "child-exit" }))
3566
+ ]);
3567
+ if (winner.kind === "probe" && winner.ready) {
3568
+ return;
3569
+ }
3570
+ if (winner.kind === "child-exit") {
3571
+ childEnded.abort();
3572
+ await probe.catch(() => false);
3573
+ }
3574
+ } finally {
3575
+ linked.dispose();
3576
+ }
3577
+ throw new CfDebuggerError(
3578
+ "TUNNEL_NOT_READY",
3579
+ `SSH tunnel on local port ${inputs.session.localPort.toString()} did not bind within ${Math.round(timeoutMs / 1e3).toString()}s.`,
3580
+ diagnosticsStderr(child)
3581
+ );
3582
+ }
3583
+ async function verifyLocalOwner(inputs, child, childPid) {
3584
+ const ownership = await inspectPortOwnership(
3585
+ inputs.session.localPort,
3586
+ childPid,
3587
+ inputs.context.signal
3588
+ );
3589
+ if (ownership.status !== "owned") {
3590
+ throw ownerVerificationError(inputs.session.localPort, ownership, child);
3591
+ }
3592
+ }
3593
+ function childExitPromise(child) {
3594
+ if (child.exitCode !== null || child.signalCode !== null) {
3595
+ return Promise.resolve();
3596
+ }
3597
+ return new Promise((resolve) => {
3598
+ child.once("close", () => {
3599
+ resolve();
3600
+ });
3601
+ });
3602
+ }
3603
+ async function verifyInspector(inputs, child, timeoutMs) {
3604
+ const childEnded = new AbortController();
3605
+ const linked = linkAbortSignals([
3606
+ ...inputs.context.signal === void 0 ? [] : [inputs.context.signal],
3607
+ childEnded.signal
3608
+ ]);
3609
+ try {
3610
+ const probe = probeInspectorReady(inputs.session.localPort, timeoutMs, linked.signal);
3611
+ const winner = await Promise.race([
3612
+ probe.then((result) => ({ kind: "probe", result })),
3613
+ childExitPromise(child).then(() => ({ kind: "child-exit" }))
3614
+ ]);
3615
+ if (winner.kind === "probe" && winner.result.status === "ready") {
3616
+ return;
3617
+ }
3618
+ if (winner.kind === "child-exit") {
3619
+ childEnded.abort();
3620
+ await probe.catch(() => ({ status: "unreachable" }));
3621
+ }
3622
+ } finally {
3623
+ linked.dispose();
3624
+ }
3625
+ throw new CfDebuggerError(
3626
+ "INSPECTOR_UNREACHABLE",
3627
+ `The inspector did not answer through local port ${inputs.session.localPort.toString()} to remote port ${inputs.session.remotePort.toString()}. The inspector may not have opened, the app or container may have restarted, or a different Node PID may own it.`,
3628
+ diagnosticsStderr(child)
3629
+ );
3630
+ }
3631
+ function requireChildPid(child) {
3632
+ if (child.pid === void 0) {
3633
+ throw new CfDebuggerError(
3634
+ "TUNNEL_PROCESS_MISSING",
3635
+ "The CF SSH tunnel process did not expose a PID."
3636
+ );
3637
+ }
3638
+ return child.pid;
3639
+ }
3640
+ async function recordTunnelPid(inputs, childPid) {
3641
+ const state = await updateSessionPid(
3642
+ inputs.session.sessionId,
3643
+ childPid,
3644
+ startupStateAccess(inputs)
3645
+ );
3646
+ if (state === void 0 || state.stopRequestedAt !== void 0 || state.status === "stopping") {
3647
+ throw new CfDebuggerError(
3648
+ state === void 0 ? "SESSION_STATE_LOST" : "ABORTED",
3649
+ state === void 0 ? "Debugger session ownership state disappeared while recording the tunnel PID." : "Debugger session stop was requested while recording the tunnel PID."
3650
+ );
3651
+ }
3652
+ if (state.tunnelPid !== childPid || state.pid !== childPid) {
3653
+ throw new CfDebuggerError(
3654
+ "SESSION_STATE_CONFLICT",
3655
+ "Debugger session did not retain ownership of the spawned tunnel process."
3656
+ );
3657
+ }
3658
+ }
3659
+ function ensureStartupActive(inputs, phase) {
3660
+ throwIfStartupAborted(
3661
+ inputs.context.signal,
3662
+ inputs.context.deadlineAt ?? Number.MAX_SAFE_INTEGER,
3663
+ inputs.context.startupTimeoutMs ?? Number.MAX_SAFE_INTEGER,
3664
+ phase
3665
+ );
3666
+ }
3667
+ async function openReadyTunnel(inputs) {
3668
+ await ensurePortAvailable(inputs.session.localPort, inputs.context.signal);
3669
+ ensureStartupActive(inputs, "the final local-port check");
3670
+ const child = spawnSshTunnel(
3671
+ inputs.options.app,
3672
+ inputs.session.localPort,
3673
+ inputs.session.remotePort,
3674
+ { ...inputs.context, phase: "opening the SSH tunnel" },
3675
+ { process: inputs.target.process, instance: inputs.target.instance }
3676
+ );
3677
+ inputs.onChild(child);
3678
+ const childPid = requireChildPid(child);
3679
+ await recordTunnelPid(inputs, childPid);
3680
+ const timeoutMs = remainingReadyTimeout(inputs);
3681
+ const readyDeadline = Date.now() + timeoutMs;
3682
+ await waitForLocalTunnel(inputs, child, timeoutMs);
3683
+ ensureStartupActive(inputs, "local tunnel binding");
3684
+ await verifyLocalOwner(inputs, child, childPid);
3685
+ ensureStartupActive(inputs, "local tunnel ownership verification");
3686
+ await verifyInspector(inputs, child, Math.max(1, readyDeadline - Date.now()));
3687
+ ensureStartupActive(inputs, "inspector readiness verification");
3688
+ await verifyLocalOwner(inputs, child, childPid);
3689
+ ensureStartupActive(inputs, "final local tunnel ownership verification");
3690
+ if (child.exitCode !== null || child.signalCode !== null) {
3691
+ throw new CfDebuggerError(
3692
+ "TUNNEL_NOT_READY",
3693
+ `SSH tunnel on local port ${inputs.session.localPort.toString()} exited during readiness verification.`,
3694
+ diagnosticsStderr(child)
3695
+ );
3696
+ }
3697
+ }
3698
+
3699
+ // src/debug-session/start.ts
3700
+ function requireCredentials(options) {
3701
+ const email = options.email ?? nodeProcess6.env["SAP_EMAIL"];
3702
+ const password = options.password ?? nodeProcess6.env["SAP_PASSWORD"];
3703
+ if (email === void 0 || email.length === 0) {
3704
+ throw new CfDebuggerError(
3705
+ "MISSING_CREDENTIALS",
3706
+ "SAP email is required. Pass `email` or set SAP_EMAIL."
3707
+ );
3708
+ }
3709
+ if (password === void 0 || password.length === 0) {
3710
+ throw new CfDebuggerError(
3711
+ "MISSING_CREDENTIALS",
3712
+ "SAP password is required. Pass `password` or set SAP_PASSWORD."
3713
+ );
3714
+ }
3715
+ return { email, password };
3716
+ }
3717
+ function resolveTunnelReadyTimeoutMs(value) {
3718
+ const timeoutMs = value ?? DEFAULT_TUNNEL_READY_TIMEOUT_MS;
3719
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
3720
+ throw new CfDebuggerError(
3721
+ "UNSAFE_INPUT",
3722
+ "Tunnel-ready timeout must be a positive integer number of milliseconds."
3723
+ );
3724
+ }
3725
+ return timeoutMs;
3726
+ }
3727
+ async function registerSession(options, target, apiEndpoint, deadline) {
3728
+ const portProbe = async (port) => {
3729
+ throwIfStartupAborted(
3730
+ deadline.signal,
3731
+ deadline.expiresAt,
3732
+ deadline.timeoutMs,
3733
+ "local port selection"
3734
+ );
3735
+ const available = await isPortFree(port, deadline.signal);
3736
+ throwIfStartupAborted(
3737
+ deadline.signal,
3738
+ deadline.expiresAt,
3739
+ deadline.timeoutMs,
3740
+ "local port selection"
3741
+ );
3742
+ return available;
3743
+ };
3744
+ const registration = await registerNewSession({
3745
+ region: options.region,
3746
+ org: options.org,
3747
+ space: options.space,
3748
+ app: options.app,
3749
+ process: target.process,
3750
+ instance: target.instance,
3751
+ ...target.nodePid === void 0 ? {} : { nodePid: target.nodePid },
3752
+ apiEndpoint,
3753
+ remotePort: options.remotePort ?? DEFAULT_NODE_INSPECTOR_PORT,
3754
+ startupTimeoutMs: deadline.timeoutMs,
3755
+ ...options.preferredPort === void 0 ? {} : { preferredPort: options.preferredPort },
3756
+ portProbe,
3757
+ cfHomeForSession: sessionCfHomeDir,
3758
+ stateAccess: startupStateAccess2(deadline.signal, deadline.expiresAt)
3759
+ });
3760
+ if (registration.existing !== void 0) {
3761
+ throw new CfDebuggerError(
3762
+ "SESSION_ALREADY_RUNNING",
3763
+ `A debugger session already exists for ${sessionKeyString(options)} on port ${registration.existing.localPort.toString()} (session ${registration.existing.sessionId}).`
3764
+ );
3765
+ }
3766
+ return registration.session;
3767
+ }
3768
+ function requireStartupState(state, expectedStatus) {
3769
+ if (state === void 0) {
3770
+ throw new CfDebuggerError(
3771
+ "SESSION_STATE_LOST",
3772
+ "Debugger session ownership state disappeared during startup."
3773
+ );
3774
+ }
3775
+ if (state.stopRequestedAt !== void 0 || state.status === "stopping") {
3776
+ throw new CfDebuggerError("ABORTED", "Debugger session stop was requested during startup.");
3777
+ }
3778
+ if (expectedStatus !== void 0 && state.status !== expectedStatus) {
3779
+ throw new CfDebuggerError(
3780
+ "SESSION_STATE_CONFLICT",
3781
+ `Debugger session state did not transition to ${expectedStatus}.`
3782
+ );
3783
+ }
3784
+ return state;
3785
+ }
3786
+ function startupStateAccess2(signal, expiresAt) {
3787
+ return {
3788
+ ...signal === void 0 ? {} : { signal },
3789
+ ...expiresAt === void 0 ? {} : { timeoutMs: Math.max(1, remainingStartupMs(expiresAt)) }
3790
+ };
3791
+ }
3792
+ function createTransition(sessionId, context) {
3793
+ return async (status, message) => {
3794
+ return requireStartupState(
3795
+ await updateSessionStatus(
3796
+ sessionId,
3797
+ status,
3798
+ message,
3799
+ startupStateAccess2(context.signal, context.deadlineAt)
3800
+ ),
3801
+ status
3802
+ );
3803
+ };
3804
+ }
3805
+ async function prepareCfHome(cfHomeDir) {
3806
+ await mkdir4(cfHomeDir, { recursive: true, mode: 448 });
3807
+ await chmod4(cfHomeDir, 448);
3808
+ }
3809
+ async function loginAndTarget(inputs) {
3810
+ const { options, context, credentials, emit, transition } = inputs;
3811
+ emit("logging-in");
3812
+ await transition("logging-in");
3813
+ await cfLogin(
3814
+ inputs.session.apiEndpoint,
3815
+ credentials.email,
3816
+ credentials.password,
3817
+ { ...context, phase: "Cloud Foundry login" }
3818
+ );
3819
+ emit("targeting");
3820
+ await transition("targeting");
3821
+ const targetingContext = { ...context, phase: "Cloud Foundry target selection" };
3822
+ await cfTarget(options.org, options.space, targetingContext);
3823
+ if (!await cfAppExists(options.app, targetingContext)) {
3824
+ throw new CfDebuggerError(
3825
+ "APP_NOT_FOUND",
3826
+ `Cloud Foundry app ${options.app} was not found in ${options.org}/${options.space}.`
3827
+ );
3828
+ }
3829
+ }
3830
+ async function ensurePortAvailable2(localPort, context) {
3831
+ if (!await isPortFree(localPort, context.signal)) {
3832
+ throw new CfDebuggerError(
3833
+ "PORT_UNAVAILABLE",
3834
+ `Local port ${localPort.toString()} was taken before remote signalling began.`
3835
+ );
3836
+ }
3837
+ }
3838
+ async function recordRemoteNodePid(sessionId, remoteNodePid, context) {
3839
+ const state = requireStartupState(
3840
+ await updateSessionRemoteNodePid(
3841
+ sessionId,
3842
+ remoteNodePid,
3843
+ startupStateAccess2(context.signal, context.deadlineAt)
3844
+ )
3845
+ );
3846
+ if (state.remoteNodePid !== remoteNodePid) {
3847
+ throw new CfDebuggerError(
3848
+ "SESSION_STATE_CONFLICT",
3849
+ "Debugger session did not retain the selected remote Node PID."
3850
+ );
3851
+ }
3852
+ }
3853
+ async function establishDebuggerSession(inputs) {
3854
+ await prepareCfHome(inputs.session.cfHomeDir);
3855
+ await loginAndTarget(inputs);
3856
+ await ensurePortAvailable2(inputs.session.localPort, inputs.context);
3857
+ const remoteNodePid = await signalRemoteNode({
3858
+ options: inputs.options,
3859
+ target: inputs.target,
3860
+ context: inputs.context,
3861
+ transition: inputs.transition,
3862
+ emit: inputs.emit,
3863
+ warn: writeWarning
3864
+ });
3865
+ throwIfStartupAborted(
3866
+ inputs.context.signal,
3867
+ inputs.context.deadlineAt ?? Number.MAX_SAFE_INTEGER,
3868
+ inputs.context.startupTimeoutMs ?? Number.MAX_SAFE_INTEGER,
3869
+ "remote inspector signalling"
3870
+ );
3871
+ await recordRemoteNodePid(inputs.session.sessionId, remoteNodePid, inputs.context);
3872
+ inputs.emit("tunneling");
3873
+ await inputs.transition("tunneling");
3874
+ await openReadyTunnel({
3875
+ options: inputs.options,
3876
+ target: inputs.target,
3877
+ session: inputs.session,
3878
+ context: inputs.context,
3879
+ tunnelReadyTimeoutMs: inputs.tunnelReadyTimeoutMs,
3880
+ onChild: inputs.lifecycle.observeChild
3881
+ });
3882
+ const readySession = await inputs.transition("ready");
3883
+ inputs.lifecycle.assertRunning();
3884
+ inputs.emit("ready");
3885
+ return readySession;
3886
+ }
3887
+ function writeWarning(message) {
3888
+ nodeProcess6.stderr.write(`[cf-debugger] warning: ${message}
3889
+ `);
3890
+ }
3891
+ function writeTunnelOutput(stream, text) {
3892
+ nodeProcess6.stderr.write(`[cf-debugger tunnel ${stream}] ${text}
3893
+ `);
3894
+ }
3895
+ function retryMessage(status) {
3896
+ const remainingSeconds = Math.ceil(status.remainingMs / 1e3);
3897
+ return `${status.command} attempt ${status.attempt.toString()} failed; retrying in ${status.delayMs.toString()}ms (${remainingSeconds.toString()}s startup budget left)`;
3898
+ }
3899
+ function createStatusTracker(options) {
3900
+ let currentStatus = "starting";
3901
+ return {
3902
+ current: () => currentStatus,
3903
+ emit: (status, message) => {
3904
+ currentStatus = status;
3905
+ try {
3906
+ options.onStatus?.(status, message);
3907
+ } catch {
3908
+ writeWarning("The onStatus callback threw; lifecycle cleanup will continue.");
3909
+ }
3910
+ }
3911
+ };
3912
+ }
3913
+ function createCfContext(session, credentials, cancellation, deadline, tracker, verbose) {
3914
+ return {
3915
+ cfHome: session.cfHomeDir,
3916
+ signal: cancellation.signal,
3917
+ deadlineAt: deadline.expiresAt,
3918
+ startupTimeoutMs: deadline.timeoutMs,
3919
+ sensitiveValues: [credentials.email, credentials.password],
3920
+ ...verbose ? {
3921
+ onRetry: (status) => {
3922
+ tracker.emit(tracker.current(), retryMessage(status));
3923
+ },
3924
+ onTunnelOutput: writeTunnelOutput
3925
+ } : {}
3926
+ };
3927
+ }
3928
+ function normalizeStartupError(error, expiresAt, timeoutMs) {
3929
+ if (error instanceof CfDebuggerError && error.code === "ABORTED" && remainingStartupMs(expiresAt) === 0) {
3930
+ return startupTimeoutError(timeoutMs, "startup");
3931
+ }
3932
+ return error;
3933
+ }
3934
+ async function startDebuggerUsingDeadline(options, deadline) {
3935
+ const target = resolveNodeTarget(options);
3936
+ const credentials = requireCredentials(options);
3937
+ const apiEndpoint = resolveApiEndpoint(options.region, options.apiEndpoint, writeWarning);
3938
+ const startupTimeoutMs = deadline.timeoutMs;
3939
+ const tunnelReadyTimeoutMs = resolveTunnelReadyTimeoutMs(options.tunnelReadyTimeoutMs);
3940
+ const tracker = createStatusTracker(options);
3941
+ tracker.emit("starting");
3942
+ let cancellation;
3943
+ let lifecycle;
3944
+ try {
3945
+ throwIfStartupAborted(deadline.signal, deadline.expiresAt, startupTimeoutMs, "initialization");
3946
+ await pruneAndCleanupOrphans(
3947
+ startupStateAccess2(deadline.signal, deadline.expiresAt)
3948
+ );
3949
+ throwIfStartupAborted(deadline.signal, deadline.expiresAt, startupTimeoutMs, "state cleanup");
3950
+ const session = await registerSession(options, target, apiEndpoint, deadline);
3951
+ cancellation = createStartupCancellation(session.sessionId, deadline.signal);
3952
+ const context = createCfContext(
3953
+ session,
3954
+ credentials,
3955
+ cancellation,
3956
+ deadline,
3957
+ tracker,
3958
+ options.verbose === true
3959
+ );
3960
+ lifecycle = createTunnelLifecycle(session, tracker.emit);
3961
+ const activeSession = await establishDebuggerSession({
3962
+ options: { ...options, remotePort: session.remotePort },
3963
+ target,
3964
+ session,
3965
+ context,
3966
+ credentials,
3967
+ tunnelReadyTimeoutMs,
3968
+ emit: tracker.emit,
3969
+ transition: createTransition(session.sessionId, context),
3970
+ lifecycle
3971
+ });
3972
+ cancellation.dispose();
3973
+ deadline.dispose();
3974
+ return createDebuggerHandle(activeSession, tracker.emit, lifecycle);
3975
+ } catch (error) {
3976
+ cancellation?.dispose();
3977
+ deadline.dispose();
3978
+ const normalized = normalizeStartupError(error, deadline.expiresAt, startupTimeoutMs);
3979
+ if (lifecycle === void 0) {
3980
+ tracker.emit(
3981
+ "error",
3982
+ normalized instanceof Error ? normalized.message : String(normalized)
3983
+ );
3984
+ throw normalized;
3985
+ }
3986
+ return await cleanupFailedStartup(normalized, lifecycle, tracker.emit);
3987
+ }
3988
+ }
3989
+ async function startDebuggerWithinDeadline(options, deadline) {
3990
+ try {
3991
+ return await startDebuggerUsingDeadline(options, deadline);
3992
+ } catch (error) {
3993
+ deadline.dispose();
3994
+ throw error;
3995
+ }
3996
+ }
3997
+ async function startDebugger(options) {
3998
+ const startupTimeoutMs = resolveStartupTimeoutMs(options.startupTimeoutMs);
3999
+ const deadline = createStartupDeadline(startupTimeoutMs, options.signal);
4000
+ return await startDebuggerWithinDeadline(options, deadline);
4001
+ }
4002
+
4003
+ // src/debug-session/doctor.ts
4004
+ import {
4005
+ lstat,
4006
+ readFile as readFile5,
4007
+ readdir as readdir3,
4008
+ unlink as unlink5
4009
+ } from "fs/promises";
4010
+ import { hostname as hostname2 } from "os";
4011
+ import { join as join3 } from "path";
4012
+ import nodeProcess7 from "process";
4013
+ var MANAGED_PORT_MIN = 2e4;
4014
+ var MANAGED_PORT_MAX = 20999;
4015
+ var PORT_SCAN_CONCURRENCY = 32;
4016
+ var STALE_TEMP_AGE_MS2 = 24 * 60 * 60 * 1e3;
4017
+ var STALE_LOCK_AGE_MS = 60 * 60 * 1e3;
4018
+ var FOREIGN_OR_MALFORMED_LOCK_AGE_MS = 24 * 60 * 60 * 1e3;
4019
+ var LEGACY_STATE_FILENAME = "cf-debugger-state.json";
4020
+ var LEGACY_LOCK_FILENAME = "cf-debugger-state.lock";
4021
+ var LEGACY_HOMES_DIRNAME = "cf-debugger-homes";
4022
+ function errorCode7(error) {
4023
+ if (typeof error !== "object" || error === null) {
4024
+ return void 0;
4025
+ }
4026
+ const value = Reflect.get(error, "code");
4027
+ return typeof value === "string" ? value : void 0;
4028
+ }
4029
+ function errorMessage(error) {
4030
+ return error instanceof Error ? error.message : String(error);
4031
+ }
4032
+ function emptyState2() {
4033
+ return { version: "2", sessions: [] };
4034
+ }
4035
+ async function readDoctorState() {
4036
+ let raw;
4037
+ try {
4038
+ raw = await readFile5(stateFilePath(), "utf8");
4039
+ } catch (error) {
4040
+ if (errorCode7(error) === "ENOENT") {
4041
+ return { state: emptyState2(), warnings: [], homeCleanupSafe: true };
4042
+ }
4043
+ return {
4044
+ state: emptyState2(),
4045
+ warnings: [`Could not read debugger state: ${errorMessage(error)}`],
4046
+ homeCleanupSafe: false
4047
+ };
4048
+ }
4049
+ let parsed;
4050
+ try {
4051
+ parsed = JSON.parse(raw);
4052
+ } catch {
4053
+ return {
4054
+ state: emptyState2(),
4055
+ warnings: ["Debugger state contains invalid JSON."],
4056
+ homeCleanupSafe: false
4057
+ };
4058
+ }
4059
+ const decoded = decodeStateFileDetailed(parsed);
4060
+ if (decoded.kind === "invalid-file") {
4061
+ return {
4062
+ state: emptyState2(),
4063
+ warnings: [`Debugger state is invalid: ${decoded.reason}.`],
4064
+ homeCleanupSafe: false
4065
+ };
4066
+ }
4067
+ return {
4068
+ state: decoded.state,
4069
+ warnings: decoded.dropped.map((reason) => `Dropped state entry: ${reason}.`),
4070
+ homeCleanupSafe: decoded.dropped.length === 0
4071
+ };
4072
+ }
4073
+ async function inspectSessions(sessions) {
4074
+ const local = sessions.filter((session) => session.hostname === hostname2());
4075
+ return await Promise.all(local.map(async (session) => {
4076
+ try {
4077
+ return { session, health: await inspectSessionHealth(session) };
4078
+ } catch (error) {
4079
+ return {
4080
+ session,
4081
+ health: {
4082
+ status: "unverified",
4083
+ reason: `health inspection failed: ${errorMessage(error)}`
4084
+ }
4085
+ };
4086
+ }
4087
+ }));
4088
+ }
4089
+ async function readDirectory(path) {
4090
+ try {
4091
+ return await readdir3(path, { withFileTypes: true });
4092
+ } catch (error) {
4093
+ if (errorCode7(error) === "ENOENT") {
4094
+ return [];
4095
+ }
4096
+ throw error;
4097
+ }
4098
+ }
4099
+ async function findOrphanHomes(sessions, cleanup) {
4100
+ const root = join3(saptoolsDir(), CF_DEBUGGER_HOMES_DIRNAME);
4101
+ const claimed = new Set(sessions.map((session) => session.sessionId));
4102
+ const entries = await readDirectory(root);
4103
+ const orphans = entries.filter((entry) => entry.isDirectory() && !claimed.has(entry.name)).sort((left, right) => left.name.localeCompare(right.name));
4104
+ return await Promise.all(orphans.map(async (entry) => {
4105
+ const path = join3(root, entry.name);
4106
+ const cleanupEligible = isOwnedSessionCfHomeDir(entry.name, path);
4107
+ if (!cleanup) {
4108
+ return { sessionId: entry.name, path, cleanupEligible, cleanupStatus: "not-requested" };
4109
+ }
4110
+ if (!cleanupEligible) {
4111
+ return { sessionId: entry.name, path, cleanupEligible, cleanupStatus: "not-eligible" };
4112
+ }
4113
+ const cleanupStatus = await cleanupOrphanHome(entry.name, path);
4114
+ return {
4115
+ sessionId: entry.name,
4116
+ path,
4117
+ cleanupEligible,
4118
+ cleanupStatus
4119
+ };
4120
+ }));
4121
+ }
4122
+ async function cleanupOrphanHome(sessionId, path) {
4123
+ try {
4124
+ return await withFileLock(
4125
+ stateLockPath(),
4126
+ async () => {
4127
+ const current = await readDoctorState();
4128
+ if (!current.homeCleanupSafe || current.state.sessions.some((session) => session.sessionId === sessionId)) {
4129
+ return "skipped";
4130
+ }
4131
+ return await tryRemoveOwnedSessionCfHome(sessionId, path) ? "removed" : "failed";
4132
+ }
4133
+ );
4134
+ } catch {
4135
+ return "failed";
4136
+ }
4137
+ }
4138
+ async function mapConcurrent(values, concurrency, work) {
4139
+ const results = [];
4140
+ let nextIndex = 0;
4141
+ const worker = async () => {
4142
+ for (; ; ) {
4143
+ const index = nextIndex;
4144
+ nextIndex += 1;
4145
+ const value = values[index];
4146
+ if (value === void 0) {
4147
+ return;
4148
+ }
4149
+ const result = await work(value);
4150
+ if (result !== void 0) {
4151
+ results.push(result);
4152
+ }
4153
+ }
4154
+ };
4155
+ const workerCount = Math.min(concurrency, values.length);
4156
+ await Promise.all(Array.from({ length: workerCount }, worker));
4157
+ return results;
4158
+ }
4159
+ function unclaimedManagedPorts(sessions) {
4160
+ const localClaims = new Set(
4161
+ sessions.filter((session) => session.hostname === hostname2()).map((session) => session.localPort)
4162
+ );
4163
+ const ports = [];
4164
+ for (let port = MANAGED_PORT_MIN; port <= MANAGED_PORT_MAX; port += 1) {
4165
+ if (!localClaims.has(port)) {
4166
+ ports.push(port);
4167
+ }
4168
+ }
4169
+ return ports;
4170
+ }
4171
+ async function inspectUnclaimedPort(port) {
4172
+ if (!await isPortListening(port)) {
4173
+ return void 0;
4174
+ }
4175
+ const inspection = await inspectListeningProcesses(port);
4176
+ if (inspection.status === "found") {
4177
+ return { port, pids: inspection.pids, ownerStatus: "found" };
4178
+ }
4179
+ if (inspection.status === "unverified") {
4180
+ return {
4181
+ port,
4182
+ pids: [],
4183
+ ownerStatus: "unverified",
4184
+ reason: inspection.reason
4185
+ };
4186
+ }
4187
+ return void 0;
4188
+ }
4189
+ async function findUnclaimedPorts(sessions) {
4190
+ const findings = await mapConcurrent(
4191
+ unclaimedManagedPorts(sessions),
4192
+ PORT_SCAN_CONCURRENCY,
4193
+ inspectUnclaimedPort
4194
+ );
4195
+ return [...findings].sort((left, right) => left.port - right.port);
4196
+ }
4197
+ function artifactKind(name) {
4198
+ if (name === CF_DEBUGGER_LOCK_FILENAME) {
4199
+ return "state-lock";
4200
+ }
4201
+ if (name === `${CF_DEBUGGER_LOCK_FILENAME}.recovery`) {
4202
+ return "state-recovery";
4203
+ }
4204
+ if (name.startsWith(`${CF_DEBUGGER_STATE_FILENAME}.`) && name.endsWith(".tmp")) {
4205
+ return "state-temp";
4206
+ }
4207
+ if (name.startsWith(CF_DEBUGGER_STOP_INTENT_PREFIX) && name.endsWith(".stop")) {
4208
+ return "stop-intent";
4209
+ }
4210
+ return name.startsWith(`${CF_DEBUGGER_STATE_FILENAME}.corrupt-`) ? "corrupt-backup" : void 0;
4211
+ }
4212
+ function parseLockOwner2(raw) {
4213
+ let parsed;
4214
+ try {
4215
+ parsed = JSON.parse(raw);
4216
+ } catch {
4217
+ return void 0;
4218
+ }
4219
+ if (typeof parsed !== "object" || parsed === null) {
4220
+ return void 0;
4221
+ }
4222
+ const ownerHostname = Reflect.get(parsed, "hostname");
4223
+ const pid = Reflect.get(parsed, "pid");
4224
+ const processIdentity = Reflect.get(parsed, "processIdentity");
4225
+ const token = Reflect.get(parsed, "token");
4226
+ if (typeof ownerHostname !== "string" || typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0 || processIdentity !== void 0 && (typeof processIdentity !== "string" || processIdentity.length === 0) || typeof token !== "string") {
4227
+ return void 0;
4228
+ }
4229
+ return {
4230
+ hostname: ownerHostname,
4231
+ pid,
4232
+ ...processIdentity === void 0 ? {} : { processIdentity },
4233
+ token
4234
+ };
4235
+ }
4236
+ function isPidAlive2(pid) {
4237
+ try {
4238
+ nodeProcess7.kill(pid, 0);
4239
+ return true;
4240
+ } catch (error) {
4241
+ return errorCode7(error) !== "ESRCH";
4242
+ }
4243
+ }
4244
+ async function readLockOwner2(path) {
4245
+ try {
4246
+ return parseLockOwner2(await readFile5(path, "utf8"));
4247
+ } catch {
4248
+ return void 0;
4249
+ }
4250
+ }
4251
+ async function lockCleanupEligible(path, ageMs) {
4252
+ if (ageMs < STALE_LOCK_AGE_MS) {
4253
+ return false;
4254
+ }
4255
+ const owner = await readLockOwner2(path);
4256
+ if (owner?.hostname === hostname2()) {
4257
+ if (!isPidAlive2(owner.pid)) {
4258
+ return true;
4259
+ }
4260
+ if (owner.processIdentity !== void 0) {
4261
+ const identity = await inspectProcessIdentity(owner.pid, owner.processIdentity);
4262
+ if (identity === "mismatch") {
4263
+ return true;
4264
+ }
4265
+ if (identity === "match") {
4266
+ return false;
4267
+ }
4268
+ }
4269
+ return ageMs >= FOREIGN_OR_MALFORMED_LOCK_AGE_MS;
4270
+ }
4271
+ return ageMs >= FOREIGN_OR_MALFORMED_LOCK_AGE_MS;
4272
+ }
4273
+ function artifactFingerprint(stats) {
4274
+ return [
4275
+ stats.dev.toString(),
4276
+ stats.ino.toString(),
4277
+ stats.size.toString(),
4278
+ stats.mtimeMs.toString()
4279
+ ].join(":");
4280
+ }
4281
+ async function inspectArtifact(entry, now, claimedSessionIds, stopIntentCleanupSafe) {
4282
+ const kind = artifactKind(entry.name);
4283
+ if (kind === void 0) {
4284
+ return void 0;
4285
+ }
4286
+ const path = join3(saptoolsDir(), entry.name);
4287
+ const stats = await lstat(path);
4288
+ const ageMs = Math.max(0, now - stats.mtimeMs);
4289
+ const regularFile = stats.isFile();
4290
+ let sessionId;
4291
+ let cleanupEligible = regularFile && kind === "state-temp" && ageMs >= STALE_TEMP_AGE_MS2;
4292
+ if (regularFile && kind === "stop-intent") {
4293
+ sessionId = entry.name.slice(
4294
+ CF_DEBUGGER_STOP_INTENT_PREFIX.length,
4295
+ -".stop".length
4296
+ );
4297
+ cleanupEligible = stopIntentCleanupSafe && ageMs >= STALE_TEMP_AGE_MS2 && !claimedSessionIds.has(sessionId);
4298
+ }
4299
+ if (regularFile && (kind === "state-lock" || kind === "state-recovery")) {
4300
+ cleanupEligible = await lockCleanupEligible(path, ageMs);
4301
+ }
4302
+ return {
4303
+ kind,
4304
+ path,
4305
+ ageMs,
4306
+ cleanupEligible,
4307
+ fingerprint: artifactFingerprint(stats),
4308
+ ...sessionId === void 0 ? {} : { sessionId }
4309
+ };
4310
+ }
4311
+ async function candidateStillMatches(candidate) {
4312
+ try {
4313
+ const stats = await lstat(candidate.path);
4314
+ return artifactFingerprint(stats) === candidate.fingerprint;
4315
+ } catch {
4316
+ return false;
4317
+ }
4318
+ }
4319
+ async function cleanupArtifact(candidate) {
4320
+ if (!candidate.cleanupEligible) {
4321
+ return { status: "not-eligible" };
4322
+ }
4323
+ try {
4324
+ if (candidate.kind === "stop-intent" && candidate.sessionId !== void 0) {
4325
+ return await withFileLock(
4326
+ stateLockPath(),
4327
+ async () => {
4328
+ const current = await readDoctorState();
4329
+ if (!current.homeCleanupSafe || current.state.sessions.some(
4330
+ (session) => session.sessionId === candidate.sessionId
4331
+ ) || !await candidateStillMatches(candidate)) {
4332
+ return { status: "skipped" };
4333
+ }
4334
+ await unlink5(candidate.path);
4335
+ return { status: "removed" };
4336
+ }
4337
+ );
4338
+ }
4339
+ if (!await candidateStillMatches(candidate)) {
4340
+ return { status: "skipped" };
4341
+ }
4342
+ await unlink5(candidate.path);
4343
+ return { status: "removed" };
4344
+ } catch (error) {
4345
+ if (errorCode7(error) === "ENOENT") {
4346
+ return { status: "skipped" };
4347
+ }
4348
+ return { status: "failed", error: errorMessage(error) };
4349
+ }
4350
+ }
4351
+ async function findArtifacts(cleanup, sessions, stopIntentCleanupSafe) {
4352
+ const entries = await readDirectory(saptoolsDir());
4353
+ const claimedSessionIds = new Set(sessions.map((session) => session.sessionId));
4354
+ const inspected = await Promise.all(
4355
+ entries.map(
4356
+ async (entry) => await inspectArtifact(entry, Date.now(), claimedSessionIds, stopIntentCleanupSafe)
4357
+ )
4358
+ );
4359
+ const candidates = inspected.filter(
4360
+ (candidate) => candidate !== void 0
4361
+ ).sort((left, right) => left.path.localeCompare(right.path));
4362
+ const findings = [];
4363
+ for (const candidate of candidates) {
4364
+ const result = cleanup ? await cleanupArtifact(candidate) : {
4365
+ status: candidate.cleanupEligible ? "not-requested" : "not-eligible"
4366
+ };
4367
+ findings.push({
4368
+ kind: candidate.kind,
4369
+ path: candidate.path,
4370
+ ageMs: candidate.ageMs,
4371
+ cleanupEligible: candidate.cleanupEligible,
4372
+ cleanupStatus: result.status,
4373
+ ...result.error === void 0 ? {} : { cleanupError: result.error }
4374
+ });
4375
+ }
4376
+ return findings;
4377
+ }
4378
+ async function pathExists(path) {
4379
+ try {
4380
+ await lstat(path);
4381
+ return true;
4382
+ } catch (error) {
4383
+ if (errorCode7(error) === "ENOENT") {
4384
+ return false;
4385
+ }
4386
+ throw error;
4387
+ }
4388
+ }
4389
+ function shellQuote(value) {
4390
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
4391
+ }
4392
+ async function findLegacyArtifacts() {
4393
+ const statePath = join3(saptoolsDir(), LEGACY_STATE_FILENAME);
4394
+ const lockPath = join3(saptoolsDir(), LEGACY_LOCK_FILENAME);
4395
+ const homesPath = join3(saptoolsDir(), LEGACY_HOMES_DIRNAME);
4396
+ const [statePresent, homesPresent] = await Promise.all([
4397
+ pathExists(statePath),
4398
+ pathExists(homesPath)
4399
+ ]);
4400
+ if (!statePresent && !homesPresent) {
4401
+ return { statePath, statePresent, homesPath, homesPresent };
4402
+ }
4403
+ const command = `rm -rf -- ${shellQuote(homesPath)} ${shellQuote(statePath)} ${shellQuote(lockPath)}`;
4404
+ return {
4405
+ statePath,
4406
+ statePresent,
4407
+ homesPath,
4408
+ homesPresent,
4409
+ warning: "Legacy v1 debugger homes may contain live CF refresh and access tokens. Confirm no v1 tunnel is running before removing them.",
4410
+ manualRemovalCommand: command
4411
+ };
4412
+ }
4413
+ function reportWarnings(stateWarnings, orphanHomes, unclaimedPorts, legacy) {
4414
+ const warnings = [...stateWarnings];
4415
+ if (orphanHomes.length > 0) {
4416
+ warnings.push(`${orphanHomes.length.toString()} orphaned v2 CF home(s) found.`);
4417
+ }
4418
+ if (unclaimedPorts.length > 0) {
4419
+ warnings.push(`${unclaimedPorts.length.toString()} unclaimed managed-port listener(s) found.`);
4420
+ }
4421
+ if (legacy.warning !== void 0) {
4422
+ warnings.push(legacy.warning);
4423
+ }
4424
+ return warnings;
4425
+ }
4426
+ async function runDoctor(options = {}) {
4427
+ const cleanup = options.cleanup === true;
4428
+ const stateResult = await readDoctorState();
4429
+ const homeCleanup = cleanup && stateResult.homeCleanupSafe;
4430
+ const [sessions, orphanHomes, unclaimedPorts, artifacts, legacy] = await Promise.all([
4431
+ inspectSessions(stateResult.state.sessions),
4432
+ findOrphanHomes(stateResult.state.sessions, homeCleanup),
4433
+ findUnclaimedPorts(stateResult.state.sessions),
4434
+ findArtifacts(cleanup, stateResult.state.sessions, stateResult.homeCleanupSafe),
4435
+ findLegacyArtifacts()
4436
+ ]);
4437
+ const cleanedPaths = [
4438
+ ...orphanHomes.filter((finding) => finding.cleanupStatus === "removed").map((finding) => finding.path),
4439
+ ...artifacts.filter((finding) => finding.cleanupStatus === "removed").map((finding) => finding.path)
4440
+ ];
4441
+ return {
4442
+ sessions,
4443
+ orphanHomes,
4444
+ unclaimedPorts,
4445
+ artifacts,
4446
+ legacy,
4447
+ warnings: [
4448
+ ...reportWarnings(stateResult.warnings, orphanHomes, unclaimedPorts, legacy),
4449
+ ...cleanup && !stateResult.homeCleanupSafe ? [
4450
+ "Skipped orphan-home and stop-intent cleanup because debugger state was incomplete or invalid."
4451
+ ] : []
4452
+ ],
4453
+ cleanedPaths
4454
+ };
4455
+ }
4456
+
4457
+ // src/debug-session/sessions.ts
4458
+ import { hostname as getHostname4 } from "os";
4459
+ import nodeProcess8 from "process";
4460
+ function findMatchingSession(sessions, options) {
4461
+ if (options.sessionId !== void 0) {
4462
+ return sessions.find((session) => session.sessionId === options.sessionId);
4463
+ }
4464
+ if (options.key === void 0) {
4465
+ return void 0;
4466
+ }
4467
+ const key = options.key;
4468
+ const matches = sessions.filter((session) => matchesKey(session, key));
4469
+ if (matches.length > 1) {
4470
+ throw new CfDebuggerError(
4471
+ "SESSION_AMBIGUOUS",
4472
+ "Multiple debugger sessions match this target. Pass --session-id, --api-endpoint, or --node-pid."
4473
+ );
4474
+ }
4475
+ return matches[0];
4476
+ }
4477
+ function startupAgeLimit2(session) {
4478
+ return (session.startupTimeoutMs ?? MAX_STARTUP_TIMEOUT_MS) + STARTUP_STALE_SLACK_MS;
4479
+ }
4480
+ function startupExpired2(session) {
4481
+ const startedAt = Date.parse(session.startedAt);
4482
+ return Number.isNaN(startedAt) || Date.now() - startedAt > startupAgeLimit2(session);
4483
+ }
4484
+ async function inspectRecordedProcess2(pid, identity) {
4485
+ if (!isPidAlive(pid)) {
4486
+ return "dead";
4487
+ }
4488
+ return await inspectProcessIdentity(pid, identity);
4489
+ }
4490
+ async function inspectController(target) {
4491
+ const controllerPid = target.controllerPid ?? target.pid;
4492
+ return await inspectRecordedProcess2(controllerPid, target.controllerProcessIdentity);
4493
+ }
4494
+ async function ownsRecordedTunnel(target) {
4495
+ const tunnelPid = target.tunnelPid;
4496
+ if (tunnelPid === void 0) {
4497
+ return false;
4498
+ }
4499
+ const processVerdict = await inspectRecordedProcess2(
4500
+ tunnelPid,
4501
+ target.tunnelProcessIdentity
4502
+ );
4503
+ if (processVerdict !== "match") {
4504
+ return false;
4505
+ }
4506
+ return (await inspectPortOwnership(target.localPort, tunnelPid)).status === "owned";
4507
+ }
4508
+ async function terminateVerifiedTunnel(target) {
4509
+ const tunnelPid = target.tunnelPid;
4510
+ if (tunnelPid === void 0 || tunnelPid === nodeProcess8.pid) {
4511
+ return tunnelPid === nodeProcess8.pid ? "still-alive" : "terminated";
4512
+ }
4513
+ try {
4514
+ const verifyBeforeSignal = async (signal) => {
4515
+ if (signal === "SIGTERM" || target.tunnelProcessIdentity === void 0) {
4516
+ return await ownsRecordedTunnel(target);
4517
+ }
4518
+ return await inspectRecordedProcess2(
4519
+ tunnelPid,
4520
+ target.tunnelProcessIdentity
4521
+ ) === "match";
4522
+ };
4523
+ return await terminatePidOrGroup(
4524
+ tunnelPid,
4525
+ void 0,
4526
+ void 0,
4527
+ verifyBeforeSignal
4528
+ );
4529
+ } catch {
4530
+ return "still-alive";
4531
+ }
4532
+ }
4533
+ async function terminateVerifiedTunnelAndConfirm(target, recordExpectedStop = false) {
4534
+ if (recordExpectedStop) {
4535
+ await writeSessionStopIntent(target.sessionId);
4536
+ }
4537
+ if (!await ownsRecordedTunnel(target)) {
4538
+ throw ownershipError(target, "recorded tunnel no longer owns the local port");
4539
+ }
4540
+ const termination = await terminateVerifiedTunnel(target);
4541
+ if (termination !== "terminated" || await ownsRecordedTunnel(target)) {
4542
+ throw new CfDebuggerError(
4543
+ "TUNNEL_TERMINATION_FAILED",
4544
+ termination === "ownership-lost" ? `Tunnel ownership for session ${target.sessionId} could not be reverified during termination; no unverified process was signalled and state was retained.` : `Tunnel process for session ${target.sessionId} did not terminate; state was retained.`
4545
+ );
4546
+ }
4547
+ }
4548
+ async function removeOwnedSession(target, stale, forced = false, warning, preserveStopIntent = false) {
4549
+ let resultWarning = warning;
4550
+ if (isOwnedSessionCfHomeDir(target.sessionId, target.cfHomeDir)) {
4551
+ await removeOwnedSessionCfHome(target.sessionId, target.cfHomeDir);
4552
+ } else {
4553
+ const skipped = `State referenced unowned CF home ${target.cfHomeDir}; it was not deleted.`;
4554
+ resultWarning = resultWarning === void 0 ? skipped : `${resultWarning} ${skipped}`;
4555
+ }
4556
+ const removed = await removeSession(target.sessionId);
4557
+ if (!preserveStopIntent) {
4558
+ await clearSessionStopIntent(target.sessionId);
4559
+ }
4560
+ return {
4561
+ ...removed ?? target,
4562
+ stale,
4563
+ pending: false,
4564
+ forced,
4565
+ ...resultWarning === void 0 ? {} : { warning: resultWarning }
4566
+ };
4567
+ }
4568
+ function ownershipError(target, detail) {
4569
+ return new CfDebuggerError(
4570
+ "TUNNEL_OWNERSHIP_UNVERIFIED",
4571
+ `Cannot safely stop session ${target.sessionId}: ${detail}. No process was signalled. Retry with --force to forget the record and its owned CF home.`
4572
+ );
4573
+ }
4574
+ function forcedWarning(target, detail) {
4575
+ return `Forced state cleanup abandoned PID ${String(target.tunnelPid ?? target.controllerPid ?? target.pid)} and local port ${target.localPort.toString()}: ${detail}. No unverified process was signalled.`;
4576
+ }
4577
+ async function forceRemoveUnverified(target, detail, preserveStopIntent = false) {
4578
+ return await removeOwnedSession(
4579
+ target,
4580
+ true,
4581
+ true,
4582
+ forcedWarning(target, detail),
4583
+ preserveStopIntent
4584
+ );
4585
+ }
4586
+ async function stopReadySession(target, force) {
4587
+ if (await ownsRecordedTunnel(target)) {
4588
+ await terminateVerifiedTunnelAndConfirm(target, true);
4589
+ return await removeOwnedSession(target, false, false, void 0, true);
4590
+ }
4591
+ const tunnelVerdict = target.tunnelPid === void 0 ? "dead" : await inspectRecordedProcess2(target.tunnelPid, target.tunnelProcessIdentity);
4592
+ const tunnelDead = target.tunnelPid === void 0 || !isPidOrGroupAlive(target.tunnelPid) || tunnelVerdict === "mismatch";
4593
+ const ownership = target.tunnelPid === void 0 ? await inspectListeningProcesses(target.localPort) : await inspectPortOwnership(target.localPort, target.tunnelPid);
4594
+ if (tunnelDead && ownership.status === "not-listening") {
4595
+ return await removeOwnedSession(target, true, false, void 0, true);
4596
+ }
4597
+ const detail = ownership.status === "unverified" ? ownership.reason : ownership.status === "not-owned" ? `local port ${target.localPort.toString()} belongs to PID(s) ${ownership.pids.join(", ")}` : tunnelVerdict === "unavailable" ? "the recorded tunnel identity could not be inspected" : "the recorded tunnel could not be proven dead and owned";
4598
+ if (force) {
4599
+ return await forceRemoveUnverified(target, detail, true);
4600
+ }
4601
+ throw ownershipError(target, detail);
4602
+ }
4603
+ async function stopStartingSession(target, force) {
4604
+ const expired = startupExpired2(target);
4605
+ const controllerVerdict = await inspectController(target);
4606
+ if (!force && !expired && controllerVerdict === "match") {
4607
+ return { ...target, stale: false, pending: true, forced: false };
4608
+ }
4609
+ if (await ownsRecordedTunnel(target)) {
4610
+ await terminateVerifiedTunnelAndConfirm(target);
4611
+ return await removeOwnedSession(target, false, false, void 0, true);
4612
+ }
4613
+ const tunnelAlive = target.tunnelPid !== void 0 && isPidOrGroupAlive(target.tunnelPid);
4614
+ const listening = await inspectListeningProcesses(target.localPort);
4615
+ const detail = listening.status === "unverified" ? listening.reason : listening.status === "found" ? `local port ${target.localPort.toString()} belongs to PID(s) ${listening.pids.join(", ")}` : controllerVerdict === "unavailable" ? "the startup controller identity could not be inspected" : "the recorded startup owner could not be verified as a tunnel";
4616
+ if (force) {
4617
+ return await forceRemoveUnverified(
4618
+ target,
4619
+ detail,
4620
+ controllerVerdict === "match"
4621
+ );
4622
+ }
4623
+ if (!tunnelAlive && listening.status === "not-listening" && (expired || controllerVerdict !== "unavailable")) {
4624
+ return await removeOwnedSession(target, true);
4625
+ }
4626
+ throw ownershipError(target, detail);
4627
+ }
4628
+ async function stopDebugger(options) {
4629
+ const localSessions = (await readSessionSnapshot()).filter(
4630
+ (session) => session.hostname === getHostname4()
4631
+ );
4632
+ const target = findMatchingSession(localSessions, options);
4633
+ if (target === void 0) {
4634
+ return void 0;
4635
+ }
4636
+ const claim = await requestSessionStop(target.sessionId);
4637
+ if (claim === void 0) {
4638
+ return void 0;
4639
+ }
4640
+ return claim.previousStatus === "ready" ? await stopReadySession(claim.session, options.force === true) : await stopStartingSession(claim.session, options.force === true);
4641
+ }
4642
+ function outcomeForResult(result) {
4643
+ return {
4644
+ sessionId: result.sessionId,
4645
+ app: result.app,
4646
+ status: result.pending ? "pending" : result.stale ? "stale" : "stopped",
4647
+ result
4648
+ };
4649
+ }
4650
+ function outcomeForError(session, error) {
4651
+ const normalized = error instanceof CfDebuggerError ? error : new CfDebuggerError(
4652
+ "STOP_FAILED",
4653
+ error instanceof Error ? error.message : String(error)
4654
+ );
4655
+ return {
4656
+ sessionId: session.sessionId,
4657
+ app: session.app,
4658
+ status: "failed",
4659
+ error: normalized
4660
+ };
4661
+ }
4662
+ function outcomeForMissing(session) {
4663
+ return {
4664
+ sessionId: session.sessionId,
4665
+ app: session.app,
4666
+ status: "stale"
4667
+ };
4668
+ }
4669
+ function summarizeOutcomes(outcomes) {
4670
+ const count = (status) => outcomes.filter((outcome) => outcome.status === status).length;
4671
+ return {
4672
+ outcomes,
4673
+ failed: count("failed"),
4674
+ pending: count("pending"),
4675
+ stale: count("stale"),
4676
+ stopped: count("stopped")
4677
+ };
4678
+ }
4679
+ async function stopAllDebuggers(force = false) {
4680
+ const sessions = (await readSessionSnapshot()).filter(
4681
+ (session) => session.hostname === getHostname4()
4682
+ );
4683
+ const outcomes = [];
4684
+ for (const session of sessions) {
4685
+ try {
4686
+ const result = await stopDebugger({ sessionId: session.sessionId, force });
4687
+ outcomes.push(
4688
+ result === void 0 ? outcomeForMissing(session) : outcomeForResult(result)
4689
+ );
4690
+ } catch (error) {
4691
+ outcomes.push(outcomeForError(session, error));
4692
+ }
4693
+ }
4694
+ return summarizeOutcomes(outcomes);
4695
+ }
4696
+ async function listSessions() {
4697
+ return await readActiveSessions();
4698
+ }
4699
+ async function getSession(key) {
4700
+ const sessions = await readActiveSessions();
4701
+ return findMatchingSession(sessions, { key });
4702
+ }
4703
+
4704
+ export {
4705
+ CfDebuggerError,
4706
+ resolveApiEndpoint,
4707
+ listKnownRegionKeys,
4708
+ readCurrentCfTarget,
4709
+ parseCurrentCfTarget,
4710
+ requireCurrentCfRegion,
4711
+ regionKeyForApiEndpoint,
4712
+ regionKeyFromSapApiEndpoint,
4713
+ DEFAULT_NODE_INSPECTOR_PORT,
4714
+ resolveNodeTarget,
4715
+ buildNodeInspectorCommand,
4716
+ parseNodeInspectorMarkers,
4717
+ sessionKeyString,
4718
+ CLEANUP_FAILURE_EXIT_CODE,
4719
+ hasTunnelTerminationFailure,
4720
+ cliErrorExitCode,
4721
+ getDebuggerHandleTunnelError,
4722
+ resolveStartupTimeoutMs,
4723
+ remainingStartupMs,
4724
+ startupTimeoutError,
4725
+ createStartupDeadline,
4726
+ startDebuggerWithinDeadline,
4727
+ startDebugger,
4728
+ runDoctor,
4729
+ stopDebugger,
4730
+ stopAllDebuggers,
4731
+ listSessions,
4732
+ getSession
4733
+ };
4734
+ //# sourceMappingURL=chunk-ZWAQD7KL.js.map