@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 +57 -0
- package/dist/client.js +15 -1
- package/dist/output.d.ts +8 -0
- package/dist/output.js +165 -1
- package/package.json +2 -2
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
|
-
|
|
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.
|
|
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.
|
|
44
|
+
"@hunt-labs/bytekit-sdk": "^0.9.1"
|
|
45
45
|
},
|
|
46
46
|
"scripts": {
|
|
47
47
|
"build": "rm -rf dist tsconfig.tsbuildinfo && tsc",
|