@hunt-labs/bytekit-cli 0.9.0 → 0.9.2

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/CHANGELOG.md CHANGED
@@ -10,6 +10,63 @@ today, see [README.md](./README.md), which remains the authoritative usage guide
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.9.2] - 2026-08-31
14
+
15
+ ### Changed
16
+
17
+ - Corrected the `detailValueText` doc comment in `src/output.ts`, which claimed the object arm of
18
+ the `details` renderer carried the SAME depth bound as the array arm through `detailJson`
19
+ (#3691). It carries the same **constant** (`DETAIL_MAX_DEPTH`), not the same **counter**:
20
+ `detailJson` takes no depth argument and starts a fresh budget from its own replacer chain. The
21
+ comment now records the two consequences — the arms agree level-for-level only on homogeneous
22
+ chains, and a mixed array/object chain can render up to `2 × DETAIL_MAX_DEPTH` levels — and why
23
+ threading `depth` into `detailJson` is not the fix: it would elide shapes that render today,
24
+ breaking the byte-identical inverse-case contract #3647 shipped under. **No consumer-visible
25
+ change** — this release touches one comment, and every `details` shape renders byte-identically
26
+ to 0.9.1.
27
+
28
+ ## [0.9.1] - 2026-08-27
29
+
30
+ ### Fixed
31
+
32
+ - **A rejected request now names the field that was rejected.** When the API answers
33
+ `422 validation_error`, its envelope carries `details.fieldErrors` — the field the server
34
+ refused and what it will accept. The CLI dropped it twice over: its own transport parser never
35
+ read `error.details` off the envelope (the SDK has read it since `@hunt-labs/bytekit-sdk@0.6.0`),
36
+ and `handleError` rendered only the message, code and status. The result was
37
+ `Error: Invalid request body. (validation_error, status: 422)` and nothing else, in human and
38
+ `--json` modes alike, leaving `curl` as the only way to find out which field was wrong (#3538).
39
+
40
+ ```console
41
+ $ bytekit scrape create --url https://example.com --country zzzz
42
+ Error: Invalid request body. (validation_error, status: 422)
43
+ Details:
44
+ fieldErrors.country: String must contain exactly 2 character(s)
45
+ ```
46
+
47
+ The block is rendered on **stderr**, below the existing one-liner, and is **bounded** — at most
48
+ ten lines of at most 200 characters, with a `… and <n> more` line when there are further
49
+ entries — so an oversized or deeply nested `details` can never become a multi-KB dump in a
50
+ terminal. Nothing else changed: the `Error: ` prefix, the `(<code>, status: <n>)` suffix, exit
51
+ code `1` and the silence of stdout are all as before, and an error carrying no `details` (or a
52
+ `details` that is not a JSON object) prints exactly the single line it printed in `0.9.0`.
53
+ `--json` stdout stays byte-for-byte pipe-clean, because every byte of this goes to stderr.
54
+
55
+ The bound covers **depth** as well as size: the renderer descends at most eight array levels into
56
+ a `details` value and elides anything deeper as `…`. The line and width caps are applied after
57
+ that walk has finished, so on their own they could not stop a `details` payload nested thousands
58
+ of arrays deep — a body `JSON.parse` accepts, so a server can send one — from overflowing the
59
+ stack and printing a Node `RangeError` trace in place of the one-line error (#3633). Every
60
+ `details` shape the API sends renders byte-identically: nothing real is more than one array level
61
+ deep.
62
+
63
+ The same bound now covers the **object** arm of that walk. A `details` value that is a JSON object
64
+ was serialized with a bare `JSON.stringify`, which is itself natively recursive and overflowed the
65
+ stack on its own: flattening the key by one level bounds WHICH values are rendered, not how deep
66
+ each one is serialized, so a deeply object-nested payload still printed a `RangeError` trace
67
+ (#3647). Objects nested more than eight levels below the flattened key now elide as `…`, exactly
68
+ as over-deep arrays do. Everything shallower renders byte-identically, both live shapes included.
69
+
13
70
  ## [0.9.0] - 2026-08-20
14
71
 
15
72
  ### Removed
package/dist/client.js CHANGED
@@ -231,15 +231,29 @@ function drainBestEffort(body, abort) {
231
231
  * envelope shape, so a flat 502 rendered `internal_error` instead of `search_provider_error`).
232
232
  *
233
233
  * The explicit `!== null` guard on the envelope check matters — `typeof null` is `'object'`.
234
+ *
235
+ * The envelope's structured `details` is mirrored too (#3538). The SDK grew that read in #2685 and
236
+ * this copy did not follow, so a `422 validation_error`'s `fieldErrors` — the one fact naming WHICH
237
+ * field the server rejected — was parsed away before `handleError()` could render it: the same
238
+ * drift class as #2586, in the same function. Only the envelope arm reads it, and only when it is a
239
+ * JSON object: the flat vendor-masked arm has no envelope to read from and must never grow a
240
+ * phantom `details`, and `ByteKitErrorDetails` promises something indexable, so an array or a
241
+ * scalar is dropped rather than surfaced. `error-parser-parity.test.ts` compares this field against
242
+ * the real SDK's parser on a shared corpus.
234
243
  */
235
244
  async function toApiError(response) {
236
245
  let code = 'internal_error';
237
246
  let message = response.statusText;
247
+ let details;
238
248
  try {
239
249
  const json = (await response.json());
240
250
  if (typeof json.error === 'object' && json.error !== null) {
241
251
  code = json.error.code ?? code;
242
252
  message = json.error.message ?? message;
253
+ const raw = json.error.details;
254
+ if (typeof raw === 'object' && raw !== null && !Array.isArray(raw)) {
255
+ details = raw;
256
+ }
243
257
  }
244
258
  else if (typeof json.error === 'string' && json.error.length > 0) {
245
259
  // The string IS the error code. `message` falls back to it only when the response carried
@@ -251,7 +265,7 @@ async function toApiError(response) {
251
265
  catch {
252
266
  // leave defaults
253
267
  }
254
- return new ByteKitError(response.status, code, message);
268
+ return new ByteKitError(response.status, code, message, details);
255
269
  }
256
270
  /**
257
271
  * Perform the authenticated round-trip with a single bounded retry on 429, returning whatever the
package/dist/output.d.ts CHANGED
@@ -204,6 +204,14 @@ export declare function printResult(data: unknown, jsonMode: boolean): void;
204
204
  * error carrying an unrelated string `.code` (a Node errno error) gets NO code suffix. When a
205
205
  * status is present but no code (a hand-built error), the suffix is `(status: <n>)` with no empty
206
206
  * placeholder — never `(, status: <n>)`.
207
+ *
208
+ * When the error carries a structured `details` object — which a `422 validation_error` populates
209
+ * with zod's `fieldErrors`, naming WHICH field was rejected and what it accepts — a bounded
210
+ * `Details:` block follows on the SAME stderr write (#3538, see {@link detailBlock}). An error
211
+ * with no renderable `details` is byte-identical to before: one line, same text, same exit code.
212
+ *
213
+ * Everything goes to stderr, so `--json` stdout stays pipe-clean without this function needing to
214
+ * know which output mode is in effect.
207
215
  */
208
216
  export declare function handleError(err: unknown): void;
209
217
  export {};
package/dist/output.js CHANGED
@@ -398,6 +398,161 @@ function errorCode(err) {
398
398
  const raw = err.code;
399
399
  return typeof raw === 'string' && raw.length > 0 ? raw : undefined;
400
400
  }
401
+ /**
402
+ * Human-mode bound on the rendered `details` block (#3538 E1).
403
+ *
404
+ * `details` is typed `unknown` on the wire — the API spec declares no schema for it — so nothing
405
+ * stops it from being arbitrarily large or deeply nested. Ten lines of at most 200 characters caps
406
+ * the block at ~2 KB: wider and longer than every validation envelope the API is observed to send
407
+ * (a handful of fields, one sentence each, so nothing real is ever truncated), and small enough
408
+ * that a pathological payload lands as a readable block rather than a multi-KB JSON dump.
409
+ */
410
+ const DETAIL_MAX_LINES = 10;
411
+ const DETAIL_MAX_LINE_CHARS = 200;
412
+ /**
413
+ * Bound on the descent inside {@link detailValueText}: the array walk (#3633) and the object
414
+ * serialization in {@link detailJson} (#3647), which are the function's two recursive arms.
415
+ *
416
+ * The two caps above bound the block that is PRINTED; they are applied in {@link detailBlock},
417
+ * after the walk that builds each line has already finished, so neither of them bounds the walk
418
+ * itself. A `details` payload nested a few thousand arrays deep survives `JSON.parse` on the CLI's
419
+ * Node runtime, so a server can deliver one, and the descent overflowed the stack — the CLI dying
420
+ * with a raw `RangeError` trace instead of its documented one-line error.
421
+ *
422
+ * Eight levels is far past anything with meaning to render: the live shapes bottom out at ONE
423
+ * array level below the key {@link detailEntries} flattens, and a field→messages list has nowhere
424
+ * deeper to go. Below the bound the descent stops and emits the ellipsis, so an over-deep value is
425
+ * elided in the same visible way an over-long one is — never dropped, which would render a
426
+ * legitimate list as silence.
427
+ */
428
+ const DETAIL_MAX_DEPTH = 8;
429
+ /** A JSON object — the only `details` shape that has field→messages structure to render. */
430
+ function isDetailRecord(value) {
431
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
432
+ }
433
+ /**
434
+ * One detail value as a single line of text.
435
+ *
436
+ * Arrays are the zod `fieldErrors` shape — several messages about one field — and are joined with
437
+ * `; `. An object at this depth is already the SECOND level of nesting (see {@link detailEntries})
438
+ * and is emitted as compact JSON rather than flattened further: past that depth the key path is
439
+ * longer than the value it names.
440
+ *
441
+ * `depth` counts array levels already descended and is bounded by {@link DETAIL_MAX_DEPTH} (#3633);
442
+ * the object arm reuses the same CONSTANT through {@link detailJson} (#3647) but spends its OWN
443
+ * INDEPENDENT budget, counted from that call, and every remaining arm terminates on its own.
444
+ *
445
+ * Two budgets, not one, so: the arms agree level-for-level only on HOMOGENEOUS chains — an object
446
+ * sitting at array-depth 8 is not elided, while an array sitting at object-depth 8 is — and a mixed
447
+ * array/object chain can render up to `2 × DETAIL_MAX_DEPTH` nesting levels rather than
448
+ * `DETAIL_MAX_DEPTH`.
449
+ *
450
+ * Threading `depth` into {@link detailJson} is NOT the fix. It elides shapes that render today
451
+ * (measured across a 101-case corpus: 15 cases change instead of 10), which breaks the
452
+ * byte-identical inverse-case contract #3647 shipped under. Stack safety does not need it either:
453
+ * the array arm never resumes below an object, so the total descent is bounded at 8 + 8.
454
+ */
455
+ function detailValueText(value, depth = 0) {
456
+ if (typeof value === 'string')
457
+ return value;
458
+ if (Array.isArray(value)) {
459
+ if (depth >= DETAIL_MAX_DEPTH)
460
+ return '…';
461
+ return value
462
+ .map((item) => detailValueText(item, depth + 1))
463
+ .filter((t) => t !== '')
464
+ .join('; ');
465
+ }
466
+ if (isDetailRecord(value))
467
+ return detailJson(value);
468
+ return String(value);
469
+ }
470
+ /**
471
+ * Compact JSON for an object detail value, bounded to {@link DETAIL_MAX_DEPTH} nesting levels
472
+ * (#3647).
473
+ *
474
+ * #3633 bounded the array descent above; this arm used a bare `JSON.stringify`, which is itself
475
+ * natively recursive and overflowed the stack on its own. The one-level flatten in
476
+ * {@link detailEntries} bounds the WALK, not the SERIALIZATION: `{a: {b: X}}` yields the single
477
+ * entry `a.b` whose value `X` was handed straight to `JSON.stringify`, which then recursed to the
478
+ * bottom of `X` — the same raw `RangeError` in place of the documented one-line error, from the
479
+ * arm next door.
480
+ *
481
+ * The bound is the same eight levels and is applied by a replacer, so everything shallower than it
482
+ * serializes through the untouched native path and renders byte-identically: escaping, key order,
483
+ * dropped `undefined`s, `toJSON`. `ancestors` is the chain of holders the replacer is currently
484
+ * inside — `this` is the holder of each visited key, so popping back to it yields the depth — and
485
+ * only a value that would RECURSE is elided, which is what keeps a leaf sitting at the bound
486
+ * rendering exactly as the array arm's leaf at the same depth does.
487
+ */
488
+ function detailJson(value) {
489
+ const ancestors = [];
490
+ return JSON.stringify(value, function (_key, entry) {
491
+ while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this)
492
+ ancestors.pop();
493
+ if (ancestors.length >= DETAIL_MAX_DEPTH && typeof entry === 'object' && entry !== null) {
494
+ return '…';
495
+ }
496
+ ancestors.push(entry);
497
+ return entry;
498
+ });
499
+ }
500
+ /**
501
+ * The `key → value` pairs to render, flattened by exactly ONE level.
502
+ *
503
+ * Two `details` shapes are live (both pinned verbatim from staging by the SDK's parser suite):
504
+ * a 422 `validation_error` carries zod's `flatten()` output — `{formErrors, fieldErrors}` — where
505
+ * the field name lives one level down under `fieldErrors`; a 400 `invalid_url` carries a flat
506
+ * record (`{url, field, reason}`). One level of flattening renders both with a single rule and no
507
+ * per-shape special case: `fieldErrors.formats: …` and `url: …` respectively.
508
+ */
509
+ function detailEntries(details) {
510
+ const entries = [];
511
+ for (const [key, value] of Object.entries(details)) {
512
+ if (isDetailRecord(value)) {
513
+ for (const [inner, innerValue] of Object.entries(value)) {
514
+ entries.push([`${key}.${inner}`, innerValue]);
515
+ }
516
+ }
517
+ else {
518
+ entries.push([key, value]);
519
+ }
520
+ }
521
+ return entries;
522
+ }
523
+ /**
524
+ * The stderr block that renders a `ByteKitError.details` (#3538), or `''` when there is nothing to
525
+ * render.
526
+ *
527
+ * A `422 validation_error` envelope carries `details.fieldErrors` naming the offending field and
528
+ * its allowed values; the CLI printed only `message`/`code`/`status`, so the one fact the caller
529
+ * needed was dropped and reproducing the call with curl was the only recourse.
530
+ *
531
+ * Returns `''` — leaving the rendered line byte-identical to before — whenever `details` is
532
+ * absent, is not a JSON object (the untyped spec field permits a string, an array, `null`), or
533
+ * carries no non-empty value. `formErrors: []` on a passing form is the ordinary case of the last
534
+ * one: an empty bag is not a reason.
535
+ */
536
+ function detailBlock(details) {
537
+ if (!isDetailRecord(details))
538
+ return '';
539
+ const lines = detailEntries(details).flatMap(([key, value]) => {
540
+ const text = detailValueText(value);
541
+ return text === '' ? [] : [` ${key}: ${text}`];
542
+ });
543
+ if (lines.length === 0)
544
+ return '';
545
+ const shown = lines.length > DETAIL_MAX_LINES
546
+ ? [
547
+ ...lines.slice(0, DETAIL_MAX_LINES - 1),
548
+ ` … and ${lines.length - (DETAIL_MAX_LINES - 1)} more`,
549
+ ]
550
+ : lines;
551
+ return [
552
+ 'Details:',
553
+ ...shown.map((line) => line.length > DETAIL_MAX_LINE_CHARS ? `${line.slice(0, DETAIL_MAX_LINE_CHARS - 1)}…` : line),
554
+ ].join('\n');
555
+ }
401
556
  /**
402
557
  * Handle a CLI command error: print to stderr and signal exit 1.
403
558
  *
@@ -427,6 +582,14 @@ function errorCode(err) {
427
582
  * error carrying an unrelated string `.code` (a Node errno error) gets NO code suffix. When a
428
583
  * status is present but no code (a hand-built error), the suffix is `(status: <n>)` with no empty
429
584
  * placeholder — never `(, status: <n>)`.
585
+ *
586
+ * When the error carries a structured `details` object — which a `422 validation_error` populates
587
+ * with zod's `fieldErrors`, naming WHICH field was rejected and what it accepts — a bounded
588
+ * `Details:` block follows on the SAME stderr write (#3538, see {@link detailBlock}). An error
589
+ * with no renderable `details` is byte-identical to before: one line, same text, same exit code.
590
+ *
591
+ * Everything goes to stderr, so `--json` stdout stays pipe-clean without this function needing to
592
+ * know which output mode is in effect.
430
593
  */
431
594
  export function handleError(err) {
432
595
  if (err instanceof Error) {
@@ -440,7 +603,8 @@ export function handleError(err) {
440
603
  else if (code !== undefined) {
441
604
  suffix = ` (${code})`;
442
605
  }
443
- console.error(`Error: ${err.message}${suffix}`);
606
+ const details = detailBlock(err.details);
607
+ console.error(`Error: ${err.message}${suffix}${details === '' ? '' : `\n${details}`}`);
444
608
  }
445
609
  else {
446
610
  console.error(`Error: ${String(err)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hunt-labs/bytekit-cli",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "ByteKit CLI — wraps @hunt-labs/bytekit-sdk as shell subcommands",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -41,7 +41,7 @@
41
41
  ],
42
42
  "dependencies": {
43
43
  "commander": "^12.0.0",
44
- "@hunt-labs/bytekit-sdk": "^0.9.0"
44
+ "@hunt-labs/bytekit-sdk": "^0.9.1"
45
45
  },
46
46
  "scripts": {
47
47
  "build": "rm -rf dist tsconfig.tsbuildinfo && tsc",