@saptools/cf-inspector 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,7 +22,7 @@ Built so an AI agent (or a CI job) can drive a debugger from a single shell comm
22
22
  - ✅ **Conditional breakpoints** — `--condition 'req.userId === "abc"'` only pauses when the predicate is truthy
23
23
  - 🎭 **Multi-breakpoint** — repeat `--bp` to race several locations; first hit wins
24
24
  - 📡 **Non-pausing logpoints** — `cf-inspector log --at file:line --expr 'JSON.stringify({…})'` streams JSON Lines as the line executes, **without ever pausing the inspectee** (safe for production traffic)
25
- - 🧠 **Agent-friendly** — JSON-by-default I/O, deterministic shape, sensitive-name redaction (`password`, `token`, `secret`, `cookie`, …) baked in
25
+ - 🧠 **Agent-friendly** — JSON-by-default I/O, deterministic shape, sensitive-name redaction (`password`, `credentials`, `token`, `secret`, `cookie`, …) baked in
26
26
  - 🧭 **Path mapping** — local `src/handler.ts:42` is matched against the remote URL via a `urlRegex`, with optional `--remote-root` literal or regex (same DSL as `cds-debug`)
27
27
  - 🔁 **Composes with `cf-debugger`** — pass `--app/--region/--org/--space` and the tunnel is opened automatically; pass `--port` to attach to anything CDP-speaking
28
28
  - 🪶 **Tiny dependency footprint** — `commander` + `ws` only, no heavy CDP framework
@@ -109,16 +109,26 @@ cf-inspector snapshot --port 9229 \
109
109
  | `--port <number>` | Local port the inspector or tunnel listens on. **Required** unless `--app/--region/--org/--space` are all set |
110
110
  | `--bp <file:line>` | **Required.** Source location to break at. Pass multiple times to race several locations — the first one to hit wins |
111
111
  | `--condition <expr>` | Only pause when this JS expression evaluates truthy in the paused frame. Errors in the condition are silently treated as `false` by V8 |
112
- | `--capture <expr,…>` | Comma-separated expressions to evaluate in the paused frame; results merged into the snapshot |
112
+ | `--capture <expr,…>` | Top-level comma-separated expressions to evaluate in the paused frame; nested commas inside objects, arrays, calls, or strings are preserved |
113
113
  | `--timeout <seconds>` | How long to wait for the breakpoint to hit (default: `30`) |
114
114
  | `--remote-root <value>` | Optional path-mapping anchor: literal path or `regex:<pattern>` / `/pattern/flags` |
115
115
  | `--no-json` | Print a human-readable summary instead of JSON |
116
- | `--keep-paused` | Skip the auto-resume after capture (useful for diagnostics) |
116
+ | `--keep-paused` | Skip `Debugger.resume` after capture; Node may resume when the CLI disconnects |
117
+ | `--fail-on-unmatched-pause` | Fail immediately if the target pauses somewhere else instead of waiting cooperatively |
117
118
 
118
119
  JSON output includes `pausedDurationMs`, the client-observed time from receiving
119
120
  the matching pause event until `Debugger.resume` completes. It does not include
120
121
  the time spent waiting for the breakpoint to hit. When `--keep-paused` is used,
121
- `pausedDurationMs` is `null` because the process intentionally remains paused.
122
+ `pausedDurationMs` is `null` because `cf-inspector` intentionally skips
123
+ `Debugger.resume`. Node may resume execution when this one-shot CLI disconnects,
124
+ so treat `--keep-paused` as a low-level diagnostic escape hatch, not a durable
125
+ paused-session mode.
126
+
127
+ If the target pauses somewhere else first, for example another debugger's
128
+ breakpoint or a `debugger;` statement, `snapshot` does not resume it by default.
129
+ It warns once, waits for `Debugger.resumed`, then continues waiting for its own
130
+ breakpoint within the remaining timeout. Use `--fail-on-unmatched-pause` when a
131
+ strict immediate error is preferred.
122
132
 
123
133
  For Cloud Foundry targets, replace `--port` with `--region/--org/--space/--app` (and optionally `--cf-timeout <seconds>` for the tunnel).
124
134
 
@@ -161,7 +171,7 @@ When the user expression throws, the event is emitted with `error` instead of `v
161
171
 
162
172
  ### 🧮 `cf-inspector eval`
163
173
 
164
- Evaluate one expression and print the result. If a breakpoint is currently paused, it runs in the top frame; otherwise it runs against `Runtime.evaluate` in the global scope.
174
+ Evaluate one expression with `Runtime.evaluate` in the global scope and print the result. For paused-frame values, use `snapshot --capture` or the programmatic `evaluateOnFrame(...)` API.
165
175
 
166
176
  ```bash
167
177
  cf-inspector eval --port 9229 --expr 'process.uptime()'
@@ -204,7 +214,11 @@ const bp = await setBreakpoint(session, {
204
214
  });
205
215
  const pause = await waitForPause(session, { timeoutMs: 30_000 });
206
216
  const snapshot = await captureSnapshot(session, pause);
207
- const customValue = await evaluateOnFrame(session, pause.callFrames[0]!.callFrameId, "this.user");
217
+ const topFrame = pause.callFrames[0];
218
+ if (topFrame === undefined) {
219
+ throw new Error("Breakpoint paused without a call frame");
220
+ }
221
+ const customValue = await evaluateOnFrame(session, topFrame.callFrameId, "this.user");
208
222
  await resume(session);
209
223
  await session.dispose();
210
224
 
@@ -247,6 +261,8 @@ console.log({ bp, snapshot, customValue });
247
261
  | `INSPECTOR_CONNECTION_FAILED` | WebSocket handshake failed, or the connection closed mid-request |
248
262
  | `CDP_REQUEST_FAILED` | A CDP method returned an error result, timed out, or failed to send |
249
263
  | `BREAKPOINT_NOT_HIT` | The breakpoint did not hit before the timeout elapsed |
264
+ | `UNRELATED_PAUSE` | The target paused somewhere else and `--fail-on-unmatched-pause` was enabled |
265
+ | `UNRELATED_PAUSE_TIMEOUT` | The target stayed paused somewhere else until the snapshot timeout elapsed |
250
266
  | `EVALUATION_FAILED` | Reserved for future use — current evaluation paths surface remote exceptions inline via `CapturedExpression.error` instead of throwing |
251
267
  | `MISSING_TARGET` | Neither `--port` nor a complete CF target (`--region/--org/--space/--app`) was provided |
252
268
  | `ABORTED` | Reserved for future use by long-running streams when an `AbortSignal` fires |
@@ -272,10 +288,10 @@ Path mapping uses CDP's first-class `urlRegex`:
272
288
 
273
289
  | `--remote-root` | Resulting urlRegex (line `42` of `src/handler.ts`) |
274
290
  | --- | --- |
275
- | _omitted_ | `(?:^|/)src/handler\.(?:ts\|js)$` |
276
- | `/home/vcap/app` (literal) | `^file:///home/vcap/app/src/handler\.(?:ts\|js)$` |
277
- | `regex:^/example-root-.*$` | `^file:///example-root-[^/]+/src/handler\.(?:ts\|js)$` |
278
- | `/^/example-root-.*$/` | same as above |
291
+ | _omitted_ | `(?:^|/)src/handler\.(?:ts\|js\|mts\|mjs\|cts\|cjs)$` |
292
+ | `/home/vcap/app` (literal) | `^file:///home/vcap/app/src/handler\.(?:ts\|js\|mts\|mjs\|cts\|cjs)$` |
293
+ | `regex:^/example-root-.*$` | `^file:///example-root-.*/src/handler\.(?:ts\|js\|mts\|mjs\|cts\|cjs)$` |
294
+ | `regex:^/(home/vcap/app\|example-root-.*)$` | `^file:///(home/vcap/app\|example-root-.*)/src/handler\.(?:ts\|js\|mts\|mjs\|cts\|cjs)$` |
279
295
 
280
296
  `.ts ↔ .js` is folded into the regex automatically because Node's V8 inspector normally serves both the source-mapped TypeScript URL and the runtime JavaScript URL — matching either is correct.
281
297
 
package/dist/cli.js CHANGED
@@ -118,12 +118,13 @@ var init_wsTransport = __esm({
118
118
  });
119
119
 
120
120
  // src/cli.ts
121
- import { performance } from "perf_hooks";
121
+ import { performance as performance2 } from "perf_hooks";
122
122
  import process from "process";
123
123
  import { Command } from "commander";
124
124
 
125
125
  // src/inspector.ts
126
126
  import { request } from "http";
127
+ import { performance } from "perf_hooks";
127
128
 
128
129
  // src/cdp.ts
129
130
  init_types();
@@ -430,6 +431,15 @@ function stripTrailingSlash(value) {
430
431
  }
431
432
  return value;
432
433
  }
434
+ function normalizeRegexRootPattern(pattern) {
435
+ const withoutStartAnchor = pattern.startsWith("^") ? pattern.slice(1) : pattern;
436
+ const withoutEndAnchor = withoutStartAnchor.endsWith("$") && !isEscaped(withoutStartAnchor, withoutStartAnchor.length - 1) ? withoutStartAnchor.slice(0, -1) : withoutStartAnchor;
437
+ return stripTrailingSlash(withoutEndAnchor);
438
+ }
439
+ function buildFileUrlRegex(rootPattern, tail) {
440
+ const separator = rootPattern.endsWith("/") ? "" : "/";
441
+ return `^file://${rootPattern}${separator}${tail}$`;
442
+ }
433
443
  function escapeRegExp(value) {
434
444
  return value.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
435
445
  }
@@ -456,10 +466,11 @@ function buildBreakpointUrlRegex(input) {
456
466
  }
457
467
  case "literal": {
458
468
  const escapedRoot = escapeRegExp(input.remoteRoot.value);
459
- return `^file://${escapedRoot}/${tail}$`;
469
+ return buildFileUrlRegex(escapedRoot, tail);
460
470
  }
461
471
  case "regex": {
462
- return `^file://${input.remoteRoot.pattern}/${tail}$`;
472
+ const rootPattern = normalizeRegexRootPattern(input.remoteRoot.pattern);
473
+ return buildFileUrlRegex(rootPattern, tail);
463
474
  }
464
475
  }
465
476
  }
@@ -608,21 +619,21 @@ async function connectInspector(options) {
608
619
  const PAUSE_BUFFER_LIMIT = 32;
609
620
  const pauseBuffer = [];
610
621
  const pauseWaitGate = { active: false };
622
+ const debuggerState = {};
611
623
  client.on("Debugger.paused", (raw) => {
612
624
  if (pauseWaitGate.active) {
613
625
  return;
614
626
  }
615
627
  const params = raw;
616
- const event = {
617
- reason: asString(params.reason),
618
- hitBreakpoints: Array.isArray(params.hitBreakpoints) ? params.hitBreakpoints.filter((id) => typeof id === "string") : [],
619
- callFrames: toCallFrames(params.callFrames)
620
- };
628
+ const event = toPauseEvent(params, performance.now());
621
629
  if (pauseBuffer.length >= PAUSE_BUFFER_LIMIT) {
622
630
  pauseBuffer.shift();
623
631
  }
624
632
  pauseBuffer.push(event);
625
633
  });
634
+ client.on("Debugger.resumed", () => {
635
+ debuggerState.lastResumedAtMs = performance.now();
636
+ });
626
637
  await client.send("Runtime.enable");
627
638
  await client.send("Debugger.enable");
628
639
  return {
@@ -631,6 +642,7 @@ async function connectInspector(options) {
631
642
  scripts,
632
643
  pauseBuffer,
633
644
  pauseWaitGate,
645
+ debuggerState,
634
646
  dispose: async () => {
635
647
  try {
636
648
  await client.send("Debugger.disable");
@@ -748,36 +760,121 @@ function pauseMatches(pause, breakpointIds) {
748
760
  }
749
761
  return pause.hitBreakpoints.some((id) => breakpointIds.includes(id));
750
762
  }
763
+ function toPauseEvent(params, receivedAtMs) {
764
+ return {
765
+ reason: asString(params.reason),
766
+ hitBreakpoints: Array.isArray(params.hitBreakpoints) ? params.hitBreakpoints.filter((id) => typeof id === "string") : [],
767
+ callFrames: toCallFrames(params.callFrames),
768
+ receivedAtMs
769
+ };
770
+ }
771
+ function remainingUntil(deadlineMs) {
772
+ return Math.max(0, deadlineMs - performance.now());
773
+ }
774
+ function topFrameLocation(pause) {
775
+ const top = pause.callFrames[0];
776
+ if (top === void 0) {
777
+ return "(no call frame)";
778
+ }
779
+ const url = top.url !== void 0 && top.url.length > 0 ? top.url : "(unknown)";
780
+ return `${url}:${(top.lineNumber + 1).toString()}:${(top.columnNumber + 1).toString()}`;
781
+ }
782
+ function pauseDetail(pause) {
783
+ return JSON.stringify({
784
+ reason: pause.reason,
785
+ hitBreakpoints: pause.hitBreakpoints,
786
+ topFrame: topFrameLocation(pause)
787
+ });
788
+ }
789
+ function hasResumedSincePause(session, pause) {
790
+ const pauseAt = pause.receivedAtMs;
791
+ const resumedAt = session.debuggerState.lastResumedAtMs;
792
+ return pauseAt !== void 0 && resumedAt !== void 0 && resumedAt >= pauseAt;
793
+ }
794
+ function throwBreakpointTimeout(timeoutMs) {
795
+ throw new CfInspectorError(
796
+ "BREAKPOINT_NOT_HIT",
797
+ `Timed out waiting for matching Debugger.paused after ${timeoutMs.toString()}ms`
798
+ );
799
+ }
800
+ function throwUnrelatedPauseTimeout(pause, timeoutMs) {
801
+ throw new CfInspectorError(
802
+ "UNRELATED_PAUSE_TIMEOUT",
803
+ `Target stayed paused by another debugger event before this command's breakpoint could hit within ${timeoutMs.toString()}ms`,
804
+ pauseDetail(pause)
805
+ );
806
+ }
807
+ async function waitForUnmatchedPauseToResume(session, pause, deadlineMs, timeoutMs) {
808
+ if (hasResumedSincePause(session, pause)) {
809
+ return;
810
+ }
811
+ const remainingMs = remainingUntil(deadlineMs);
812
+ if (remainingMs <= 0) {
813
+ throwUnrelatedPauseTimeout(pause, timeoutMs);
814
+ }
815
+ try {
816
+ await session.client.waitFor("Debugger.resumed", { timeoutMs: remainingMs });
817
+ session.debuggerState.lastResumedAtMs = performance.now();
818
+ } catch (err) {
819
+ if (err instanceof CfInspectorError && err.code === "BREAKPOINT_NOT_HIT") {
820
+ throwUnrelatedPauseTimeout(pause, timeoutMs);
821
+ }
822
+ throw err;
823
+ }
824
+ }
825
+ async function handleUnmatchedPause(session, pause, options, deadlineMs) {
826
+ if (options.unmatchedPausePolicy === "fail") {
827
+ throw new CfInspectorError(
828
+ "UNRELATED_PAUSE",
829
+ "Target paused before this command's breakpoint was reached",
830
+ pauseDetail(pause)
831
+ );
832
+ }
833
+ if (hasResumedSincePause(session, pause)) {
834
+ return;
835
+ }
836
+ options.onUnmatchedPause?.(pause);
837
+ await waitForUnmatchedPauseToResume(session, pause, deadlineMs, options.timeoutMs);
838
+ }
751
839
  async function waitForPause(session, options) {
840
+ const deadlineMs = performance.now() + options.timeoutMs;
752
841
  const buffer = session.pauseBuffer;
753
- while (buffer.length > 0) {
754
- const head = buffer.shift();
755
- if (head !== void 0 && pauseMatches(head, options.breakpointIds)) {
756
- return head;
842
+ while (buffer.length > 0 || remainingUntil(deadlineMs) > 0) {
843
+ while (buffer.length > 0) {
844
+ const buffered = buffer.shift();
845
+ if (buffered === void 0) {
846
+ continue;
847
+ }
848
+ if (pauseMatches(buffered, options.breakpointIds)) {
849
+ return buffered;
850
+ }
851
+ await handleUnmatchedPause(session, buffered, options, deadlineMs);
757
852
  }
758
- }
759
- session.pauseWaitGate.active = true;
760
- let params;
761
- try {
762
- const expected = options.breakpointIds;
763
- params = await session.client.waitFor("Debugger.paused", {
764
- timeoutMs: options.timeoutMs,
765
- predicate: (raw) => {
766
- if (expected === void 0 || expected.length === 0) {
853
+ const remainingMs = remainingUntil(deadlineMs);
854
+ if (remainingMs <= 0) {
855
+ throwBreakpointTimeout(options.timeoutMs);
856
+ }
857
+ session.pauseWaitGate.active = true;
858
+ let receivedAtMs;
859
+ let params;
860
+ try {
861
+ params = await session.client.waitFor("Debugger.paused", {
862
+ timeoutMs: remainingMs,
863
+ predicate: () => {
864
+ receivedAtMs = performance.now();
767
865
  return true;
768
866
  }
769
- const ids = Array.isArray(raw.hitBreakpoints) ? raw.hitBreakpoints.filter((id) => typeof id === "string") : [];
770
- return ids.some((id) => expected.includes(id));
771
- }
772
- });
773
- } finally {
774
- session.pauseWaitGate.active = false;
867
+ });
868
+ } finally {
869
+ session.pauseWaitGate.active = false;
870
+ }
871
+ const pause = toPauseEvent(params, receivedAtMs ?? performance.now());
872
+ if (pauseMatches(pause, options.breakpointIds)) {
873
+ return pause;
874
+ }
875
+ await handleUnmatchedPause(session, pause, options, deadlineMs);
775
876
  }
776
- return {
777
- reason: asString(params.reason),
778
- hitBreakpoints: Array.isArray(params.hitBreakpoints) ? params.hitBreakpoints.filter((id) => typeof id === "string") : [],
779
- callFrames: toCallFrames(params.callFrames)
780
- };
877
+ throwBreakpointTimeout(options.timeoutMs);
781
878
  }
782
879
  async function resume(session) {
783
880
  await session.client.send("Debugger.resume");
@@ -975,7 +1072,7 @@ var MAX_SCOPE_VARIABLES = 20;
975
1072
  var MAX_CHILD_VARIABLES = 8;
976
1073
  var MAX_VARIABLE_DEPTH = 2;
977
1074
  var MAX_VALUE_LENGTH = 240;
978
- var SENSITIVE_NAME_REGEX = /(pass(?:word)?|token|secret|api[_-]?key|authorization|cookie|session|private[_-]?key)/i;
1075
+ var SENSITIVE_NAME_REGEX = /(pass(?:word)?|credentials?|creds?|token|secret|api[_-]?key|authorization|cookie|session|private[_-]?key)/i;
979
1076
  var PRIORITY_BY_TYPE = {
980
1077
  local: 0,
981
1078
  arguments: 1,
@@ -1036,8 +1133,11 @@ function formatPrimitive(value) {
1036
1133
  }
1037
1134
  return String(value);
1038
1135
  }
1136
+ function isSensitiveName(name) {
1137
+ return SENSITIVE_NAME_REGEX.test(name);
1138
+ }
1039
1139
  function sanitizeValue(name, raw) {
1040
- if (SENSITIVE_NAME_REGEX.test(name)) {
1140
+ if (isSensitiveName(name)) {
1041
1141
  return "[REDACTED]";
1042
1142
  }
1043
1143
  if (raw.length <= MAX_VALUE_LENGTH) {
@@ -1055,8 +1155,9 @@ async function captureProperties(session, objectId, limit, depth) {
1055
1155
  limited.map(async (prop) => {
1056
1156
  const name = typeof prop.name === "string" ? prop.name : "?";
1057
1157
  const described = describeProperty(prop);
1158
+ const sensitive = isSensitiveName(name);
1058
1159
  let children;
1059
- if (depth > 0 && described.objectId !== void 0 && isExpandable(described.type)) {
1160
+ if (!sensitive && depth > 0 && described.objectId !== void 0 && isExpandable(described.type)) {
1060
1161
  try {
1061
1162
  const nested = await captureProperties(
1062
1163
  session,
@@ -1070,7 +1171,7 @@ async function captureProperties(session, objectId, limit, depth) {
1070
1171
  } catch {
1071
1172
  }
1072
1173
  }
1073
- const sanitizedValue = sanitizeValue(name, described.value);
1174
+ const sanitizedValue = sensitive ? "[REDACTED]" : sanitizeValue(name, described.value);
1074
1175
  const base = { name, value: sanitizedValue };
1075
1176
  const withType = described.type === void 0 ? base : { ...base, type: described.type };
1076
1177
  return children === void 0 ? withType : { ...withType, children };
@@ -1093,15 +1194,19 @@ async function captureScopes(session, frame) {
1093
1194
  if (objectId === void 0) {
1094
1195
  return { type: scope.type, variables: [] };
1095
1196
  }
1096
- const variables = await captureProperties(session, objectId, MAX_SCOPE_VARIABLES, MAX_VARIABLE_DEPTH);
1097
- return { type: scope.type, variables };
1197
+ try {
1198
+ const variables = await captureProperties(session, objectId, MAX_SCOPE_VARIABLES, MAX_VARIABLE_DEPTH);
1199
+ return { type: scope.type, variables };
1200
+ } catch {
1201
+ return { type: scope.type, variables: [] };
1202
+ }
1098
1203
  })
1099
1204
  );
1100
1205
  }
1101
1206
  function evalResultToCaptured(expression, result) {
1102
1207
  if (result.exceptionDetails !== void 0) {
1103
1208
  const text = typeof result.exceptionDetails.exception?.description === "string" ? result.exceptionDetails.exception.description : typeof result.exceptionDetails.text === "string" ? result.exceptionDetails.text : "evaluation failed";
1104
- return { expression, error: text };
1209
+ return { expression, error: sanitizeValue(expression, text) };
1105
1210
  }
1106
1211
  const inner = result.result;
1107
1212
  if (!inner) {
@@ -1248,7 +1353,82 @@ function parseCaptureList(raw) {
1248
1353
  if (raw === void 0 || raw.trim().length === 0) {
1249
1354
  return [];
1250
1355
  }
1251
- return raw.split(",").map((piece) => piece.trim()).filter((piece) => piece.length > 0);
1356
+ return splitCaptureExpressions(raw);
1357
+ }
1358
+ function isQuoteChar(value) {
1359
+ return value === "'" || value === '"' || value === "`";
1360
+ }
1361
+ function consumeQuotedChar(state, char) {
1362
+ if (state.quote === void 0) {
1363
+ return false;
1364
+ }
1365
+ if (state.escaped) {
1366
+ state.escaped = false;
1367
+ return true;
1368
+ }
1369
+ if (char === "\\") {
1370
+ state.escaped = true;
1371
+ return true;
1372
+ }
1373
+ if (char === state.quote) {
1374
+ state.quote = void 0;
1375
+ }
1376
+ return true;
1377
+ }
1378
+ function updateCaptureDepth(state, char) {
1379
+ if (char === "(") {
1380
+ state.parenDepth += 1;
1381
+ } else if (char === ")") {
1382
+ state.parenDepth = Math.max(0, state.parenDepth - 1);
1383
+ } else if (char === "[") {
1384
+ state.bracketDepth += 1;
1385
+ } else if (char === "]") {
1386
+ state.bracketDepth = Math.max(0, state.bracketDepth - 1);
1387
+ } else if (char === "{") {
1388
+ state.braceDepth += 1;
1389
+ } else if (char === "}") {
1390
+ state.braceDepth = Math.max(0, state.braceDepth - 1);
1391
+ }
1392
+ }
1393
+ function isTopLevel(state) {
1394
+ return state.parenDepth === 0 && state.bracketDepth === 0 && state.braceDepth === 0;
1395
+ }
1396
+ function appendCapturePiece(raw, state, end) {
1397
+ const piece = raw.slice(state.start, end).trim();
1398
+ if (piece.length > 0) {
1399
+ state.pieces.push(piece);
1400
+ }
1401
+ }
1402
+ function splitCaptureExpressions(raw) {
1403
+ const state = {
1404
+ escaped: false,
1405
+ parenDepth: 0,
1406
+ bracketDepth: 0,
1407
+ braceDepth: 0,
1408
+ quote: void 0,
1409
+ start: 0,
1410
+ pieces: []
1411
+ };
1412
+ for (let idx = 0; idx < raw.length; idx += 1) {
1413
+ const char = raw[idx];
1414
+ if (char === void 0) {
1415
+ continue;
1416
+ }
1417
+ if (consumeQuotedChar(state, char)) {
1418
+ continue;
1419
+ }
1420
+ if (isQuoteChar(char)) {
1421
+ state.quote = char;
1422
+ continue;
1423
+ }
1424
+ updateCaptureDepth(state, char);
1425
+ if (char === "," && isTopLevel(state)) {
1426
+ appendCapturePiece(raw, state, idx);
1427
+ state.start = idx + 1;
1428
+ }
1429
+ }
1430
+ appendCapturePiece(raw, state, raw.length);
1431
+ return state.pieces;
1252
1432
  }
1253
1433
  async function withSession(target, fn) {
1254
1434
  const tunnel = await openTarget(target);
@@ -1280,6 +1460,21 @@ function warnOnUnboundBreakpoints(handles) {
1280
1460
  function roundDurationMs(durationMs) {
1281
1461
  return Math.round(durationMs * 1e3) / 1e3;
1282
1462
  }
1463
+ function formatPauseLocation(pause) {
1464
+ const top = pause.callFrames[0];
1465
+ if (top === void 0) {
1466
+ return "(no call frame)";
1467
+ }
1468
+ const url = top.url !== void 0 && top.url.length > 0 ? top.url : "(unknown)";
1469
+ return `${url}:${(top.lineNumber + 1).toString()}:${(top.columnNumber + 1).toString()}`;
1470
+ }
1471
+ function warnOnUnmatchedPause(pause) {
1472
+ const reason = pause.reason.length > 0 ? pause.reason : "unknown";
1473
+ process.stderr.write(
1474
+ `[cf-inspector] warning: target is paused by another debugger event (${reason} at ${formatPauseLocation(pause)}); waiting for it to resume...
1475
+ `
1476
+ );
1477
+ }
1283
1478
  function withPausedDuration(snapshot, pausedDurationMs) {
1284
1479
  return {
1285
1480
  reason: snapshot.reason,
@@ -1291,7 +1486,7 @@ function withPausedDuration(snapshot, pausedDurationMs) {
1291
1486
  };
1292
1487
  }
1293
1488
  function writeHumanSnapshot(snapshot) {
1294
- const pausedDuration = snapshot.pausedDurationMs === null ? "still paused" : `${snapshot.pausedDurationMs.toFixed(1)}ms`;
1489
+ const pausedDuration = snapshot.pausedDurationMs === null ? "unknown" : `${snapshot.pausedDurationMs.toFixed(1)}ms`;
1295
1490
  const lines = [];
1296
1491
  lines.push(
1297
1492
  `Snapshot @ ${snapshot.capturedAt}`,
@@ -1352,8 +1547,20 @@ async function handleSnapshot(opts) {
1352
1547
  );
1353
1548
  warnOnUnboundBreakpoints(handles);
1354
1549
  const breakpointIds = handles.map((h) => h.breakpointId);
1355
- const pause = await waitForPause(session, { timeoutMs, breakpointIds });
1356
- const pausedStartedAt = performance.now();
1550
+ let warnedUnmatchedPause = false;
1551
+ const pause = await waitForPause(session, {
1552
+ timeoutMs,
1553
+ breakpointIds,
1554
+ unmatchedPausePolicy: opts.failOnUnmatchedPause === true ? "fail" : "wait-for-resume",
1555
+ onUnmatchedPause: (unmatchedPause) => {
1556
+ if (warnedUnmatchedPause || opts.failOnUnmatchedPause === true) {
1557
+ return;
1558
+ }
1559
+ warnedUnmatchedPause = true;
1560
+ warnOnUnmatchedPause(unmatchedPause);
1561
+ }
1562
+ });
1563
+ const pausedStartedAt = pause.receivedAtMs ?? performance2.now();
1357
1564
  const snapshot = await captureSnapshot(session, pause, { captures });
1358
1565
  if (opts.keepPaused === true) {
1359
1566
  return withPausedDuration(snapshot, null);
@@ -1362,9 +1569,12 @@ async function handleSnapshot(opts) {
1362
1569
  await resume(session);
1363
1570
  return withPausedDuration(
1364
1571
  snapshot,
1365
- roundDurationMs(performance.now() - pausedStartedAt)
1572
+ roundDurationMs(performance2.now() - pausedStartedAt)
1366
1573
  );
1367
1574
  } catch {
1575
+ process.stderr.write(
1576
+ "[cf-inspector] warning: Debugger.resume failed after snapshot; pausedDurationMs is unknown.\n"
1577
+ );
1368
1578
  return withPausedDuration(snapshot, null);
1369
1579
  }
1370
1580
  });
@@ -1381,6 +1591,9 @@ async function handleEval(opts) {
1381
1591
  });
1382
1592
  if (opts.json) {
1383
1593
  writeJson(result);
1594
+ if (result.exceptionDetails !== void 0) {
1595
+ process.exitCode = 1;
1596
+ }
1384
1597
  return;
1385
1598
  }
1386
1599
  if (result.exceptionDetails !== void 0) {
@@ -1518,10 +1731,10 @@ async function main(argv) {
1518
1731
  "Breakpoint location (repeatable; first hit wins), e.g. src/handler.ts:42",
1519
1732
  collectStrings,
1520
1733
  []
1521
- ).option("--capture <expr,\u2026>", "Comma-separated expressions to evaluate in the paused frame").option("--timeout <seconds>", "How long to wait for the breakpoint to hit (default: 30)").option("--remote-root <value>", "Path-mapping anchor: literal path or regex:<pattern> / /pattern/flags").option(
1734
+ ).option("--capture <expr,\u2026>", "Top-level comma-separated expressions to evaluate in the paused frame").option("--timeout <seconds>", "How long to wait for the breakpoint to hit (default: 30)").option("--remote-root <value>", "Path-mapping anchor: literal path or regex:<pattern> / /pattern/flags").option(
1522
1735
  "--condition <expr>",
1523
1736
  "Only pause when this JS expression evaluates truthy in the paused frame"
1524
- ).option("--no-json", "Print a human-readable summary instead of JSON").option("--keep-paused", "Skip the auto-resume after capture").action(async (opts) => {
1737
+ ).option("--no-json", "Print a human-readable summary instead of JSON").option("--keep-paused", "Skip Debugger.resume after capture; Node may resume when this CLI disconnects").option("--fail-on-unmatched-pause", "Fail immediately if the target pauses somewhere else").action(async (opts) => {
1525
1738
  await handleSnapshot(opts);
1526
1739
  });
1527
1740
  applyTargetOptions(