@hunt-labs/bytekit-cli 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,48 @@ today, see [README.md](./README.md), which remains the authoritative usage guide
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.9.1] - 2026-08-27
14
+
15
+ ### Fixed
16
+
17
+ - **A rejected request now names the field that was rejected.** When the API answers
18
+ `422 validation_error`, its envelope carries `details.fieldErrors` — the field the server
19
+ refused and what it will accept. The CLI dropped it twice over: its own transport parser never
20
+ read `error.details` off the envelope (the SDK has read it since `@hunt-labs/bytekit-sdk@0.6.0`),
21
+ and `handleError` rendered only the message, code and status. The result was
22
+ `Error: Invalid request body. (validation_error, status: 422)` and nothing else, in human and
23
+ `--json` modes alike, leaving `curl` as the only way to find out which field was wrong (#3538).
24
+
25
+ ```console
26
+ $ bytekit scrape create --url https://example.com --country zzzz
27
+ Error: Invalid request body. (validation_error, status: 422)
28
+ Details:
29
+ fieldErrors.country: String must contain exactly 2 character(s)
30
+ ```
31
+
32
+ The block is rendered on **stderr**, below the existing one-liner, and is **bounded** — at most
33
+ ten lines of at most 200 characters, with a `… and <n> more` line when there are further
34
+ entries — so an oversized or deeply nested `details` can never become a multi-KB dump in a
35
+ terminal. Nothing else changed: the `Error: ` prefix, the `(<code>, status: <n>)` suffix, exit
36
+ code `1` and the silence of stdout are all as before, and an error carrying no `details` (or a
37
+ `details` that is not a JSON object) prints exactly the single line it printed in `0.9.0`.
38
+ `--json` stdout stays byte-for-byte pipe-clean, because every byte of this goes to stderr.
39
+
40
+ The bound covers **depth** as well as size: the renderer descends at most eight array levels into
41
+ a `details` value and elides anything deeper as `…`. The line and width caps are applied after
42
+ that walk has finished, so on their own they could not stop a `details` payload nested thousands
43
+ of arrays deep — a body `JSON.parse` accepts, so a server can send one — from overflowing the
44
+ stack and printing a Node `RangeError` trace in place of the one-line error (#3633). Every
45
+ `details` shape the API sends renders byte-identically: nothing real is more than one array level
46
+ deep.
47
+
48
+ The same bound now covers the **object** arm of that walk. A `details` value that is a JSON object
49
+ was serialized with a bare `JSON.stringify`, which is itself natively recursive and overflowed the
50
+ stack on its own: flattening the key by one level bounds WHICH values are rendered, not how deep
51
+ each one is serialized, so a deeply object-nested payload still printed a `RangeError` trace
52
+ (#3647). Objects nested more than eight levels below the flattened key now elide as `…`, exactly
53
+ as over-deep arrays do. Everything shallower renders byte-identically, both live shapes included.
54
+
13
55
  ## [0.9.0] - 2026-08-20
14
56
 
15
57
  ### 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,151 @@ 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 carries the SAME bound through {@link detailJson} (#3647), and every remaining arm
443
+ * terminates on its own.
444
+ */
445
+ function detailValueText(value, depth = 0) {
446
+ if (typeof value === 'string')
447
+ return value;
448
+ if (Array.isArray(value)) {
449
+ if (depth >= DETAIL_MAX_DEPTH)
450
+ return '…';
451
+ return value
452
+ .map((item) => detailValueText(item, depth + 1))
453
+ .filter((t) => t !== '')
454
+ .join('; ');
455
+ }
456
+ if (isDetailRecord(value))
457
+ return detailJson(value);
458
+ return String(value);
459
+ }
460
+ /**
461
+ * Compact JSON for an object detail value, bounded to {@link DETAIL_MAX_DEPTH} nesting levels
462
+ * (#3647).
463
+ *
464
+ * #3633 bounded the array descent above; this arm used a bare `JSON.stringify`, which is itself
465
+ * natively recursive and overflowed the stack on its own. The one-level flatten in
466
+ * {@link detailEntries} bounds the WALK, not the SERIALIZATION: `{a: {b: X}}` yields the single
467
+ * entry `a.b` whose value `X` was handed straight to `JSON.stringify`, which then recursed to the
468
+ * bottom of `X` — the same raw `RangeError` in place of the documented one-line error, from the
469
+ * arm next door.
470
+ *
471
+ * The bound is the same eight levels and is applied by a replacer, so everything shallower than it
472
+ * serializes through the untouched native path and renders byte-identically: escaping, key order,
473
+ * dropped `undefined`s, `toJSON`. `ancestors` is the chain of holders the replacer is currently
474
+ * inside — `this` is the holder of each visited key, so popping back to it yields the depth — and
475
+ * only a value that would RECURSE is elided, which is what keeps a leaf sitting at the bound
476
+ * rendering exactly as the array arm's leaf at the same depth does.
477
+ */
478
+ function detailJson(value) {
479
+ const ancestors = [];
480
+ return JSON.stringify(value, function (_key, entry) {
481
+ while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this)
482
+ ancestors.pop();
483
+ if (ancestors.length >= DETAIL_MAX_DEPTH && typeof entry === 'object' && entry !== null) {
484
+ return '…';
485
+ }
486
+ ancestors.push(entry);
487
+ return entry;
488
+ });
489
+ }
490
+ /**
491
+ * The `key → value` pairs to render, flattened by exactly ONE level.
492
+ *
493
+ * Two `details` shapes are live (both pinned verbatim from staging by the SDK's parser suite):
494
+ * a 422 `validation_error` carries zod's `flatten()` output — `{formErrors, fieldErrors}` — where
495
+ * the field name lives one level down under `fieldErrors`; a 400 `invalid_url` carries a flat
496
+ * record (`{url, field, reason}`). One level of flattening renders both with a single rule and no
497
+ * per-shape special case: `fieldErrors.formats: …` and `url: …` respectively.
498
+ */
499
+ function detailEntries(details) {
500
+ const entries = [];
501
+ for (const [key, value] of Object.entries(details)) {
502
+ if (isDetailRecord(value)) {
503
+ for (const [inner, innerValue] of Object.entries(value)) {
504
+ entries.push([`${key}.${inner}`, innerValue]);
505
+ }
506
+ }
507
+ else {
508
+ entries.push([key, value]);
509
+ }
510
+ }
511
+ return entries;
512
+ }
513
+ /**
514
+ * The stderr block that renders a `ByteKitError.details` (#3538), or `''` when there is nothing to
515
+ * render.
516
+ *
517
+ * A `422 validation_error` envelope carries `details.fieldErrors` naming the offending field and
518
+ * its allowed values; the CLI printed only `message`/`code`/`status`, so the one fact the caller
519
+ * needed was dropped and reproducing the call with curl was the only recourse.
520
+ *
521
+ * Returns `''` — leaving the rendered line byte-identical to before — whenever `details` is
522
+ * absent, is not a JSON object (the untyped spec field permits a string, an array, `null`), or
523
+ * carries no non-empty value. `formErrors: []` on a passing form is the ordinary case of the last
524
+ * one: an empty bag is not a reason.
525
+ */
526
+ function detailBlock(details) {
527
+ if (!isDetailRecord(details))
528
+ return '';
529
+ const lines = detailEntries(details).flatMap(([key, value]) => {
530
+ const text = detailValueText(value);
531
+ return text === '' ? [] : [` ${key}: ${text}`];
532
+ });
533
+ if (lines.length === 0)
534
+ return '';
535
+ const shown = lines.length > DETAIL_MAX_LINES
536
+ ? [
537
+ ...lines.slice(0, DETAIL_MAX_LINES - 1),
538
+ ` … and ${lines.length - (DETAIL_MAX_LINES - 1)} more`,
539
+ ]
540
+ : lines;
541
+ return [
542
+ 'Details:',
543
+ ...shown.map((line) => line.length > DETAIL_MAX_LINE_CHARS ? `${line.slice(0, DETAIL_MAX_LINE_CHARS - 1)}…` : line),
544
+ ].join('\n');
545
+ }
401
546
  /**
402
547
  * Handle a CLI command error: print to stderr and signal exit 1.
403
548
  *
@@ -427,6 +572,14 @@ function errorCode(err) {
427
572
  * error carrying an unrelated string `.code` (a Node errno error) gets NO code suffix. When a
428
573
  * status is present but no code (a hand-built error), the suffix is `(status: <n>)` with no empty
429
574
  * placeholder — never `(, status: <n>)`.
575
+ *
576
+ * When the error carries a structured `details` object — which a `422 validation_error` populates
577
+ * with zod's `fieldErrors`, naming WHICH field was rejected and what it accepts — a bounded
578
+ * `Details:` block follows on the SAME stderr write (#3538, see {@link detailBlock}). An error
579
+ * with no renderable `details` is byte-identical to before: one line, same text, same exit code.
580
+ *
581
+ * Everything goes to stderr, so `--json` stdout stays pipe-clean without this function needing to
582
+ * know which output mode is in effect.
430
583
  */
431
584
  export function handleError(err) {
432
585
  if (err instanceof Error) {
@@ -440,7 +593,8 @@ export function handleError(err) {
440
593
  else if (code !== undefined) {
441
594
  suffix = ` (${code})`;
442
595
  }
443
- console.error(`Error: ${err.message}${suffix}`);
596
+ const details = detailBlock(err.details);
597
+ console.error(`Error: ${err.message}${suffix}${details === '' ? '' : `\n${details}`}`);
444
598
  }
445
599
  else {
446
600
  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.1",
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",