@hunt-labs/bytekit-cli 0.8.1 → 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,67 @@ 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
+
55
+ ## [0.9.0] - 2026-08-20
56
+
57
+ ### Removed
58
+
59
+ - **BREAKING: `--cookies` and `--headers` are gone from `scrape create` and `screenshots create`**
60
+ (#3242). The API no longer accepts caller-supplied cookies or request headers on any capture
61
+ endpoint, so passing either flag is now an unknown-option error before any request is issued,
62
+ and a body carrying those keys is rejected by the server with `400 validation_error`. There is
63
+ no deprecation window, feature flag, or compatibility header.
64
+
65
+ ```bash
66
+ # before
67
+ bytekit scrape create --url https://example.com --cookies '[{"name":"session","value":"abc"}]'
68
+ # after — the flag does not exist
69
+ bytekit scrape create --url https://example.com
70
+ ```
71
+
72
+ `--webhook-headers` on `monitors` is a different flag and is unaffected.
73
+
13
74
  ## [0.8.1] - 2026-08-13
14
75
 
15
76
  ### Fixed
@@ -75,6 +136,16 @@ today, see [README.md](./README.md), which remains the authoritative usage guide
75
136
  npm when the entries above landed, so they ride this same pending release rather than stranding
76
137
  it as a never-published version.
77
138
 
139
+ - **Corrected a false legacy-name claim carried since the rename (#2950).** The `0.8.0`-era note
140
+ below said the prior `rapidcrawl-cli` name "remains installable at its last version but is
141
+ deprecated." Verified 2026-08-13: `rapidcrawl-cli` returns npm `404` ("is not in this
142
+ registry"), and `npm deprecate rapidcrawl-cli@'*'` fails with the same `404` — there is no
143
+ published version left for a deprecation notice to attach to, so the claim never held. No
144
+ registry stub or deprecation was possible to publish (npm has nothing to act on); root
145
+ `CLAUDE.md` carried the same false claim and is corrected alongside this entry. Users of the old
146
+ name should install `@hunt-labs/bytekit-cli` directly. No command, flag, or runtime behavior
147
+ changes.
148
+
78
149
  ## [0.8.0] - 2026-08-11
79
150
 
80
151
  ### Added
@@ -511,8 +582,10 @@ Additive, lower-risk release — no breaking changes to existing commands.
511
582
  ### Changed
512
583
 
513
584
  - Package renamed to `@hunt-labs/bytekit-cli` (the `@bytekit` npm scope was
514
- unobtainable); the `bin` name stays `bytekit`. The prior `rapidcrawl-cli` name
515
- remains installable at its last version but is deprecated.
585
+ unobtainable); the `bin` name stays `bytekit`. ~~The prior `rapidcrawl-cli` name
586
+ remains installable at its last version but is deprecated.~~ **Corrected in
587
+ 0.8.1 (see below): that claim does not hold — `rapidcrawl-cli` 404s on npm and
588
+ there is no published version left to deprecate.**
516
589
  - Consumer call sites propagate the SDK's narrowed, spec-derived request types (#2426).
517
590
  - Publish-ready packaging metadata: dropped `main`, unpinned the SDK dependency to
518
591
  `workspace:^`, added `engines`, `keywords`, `homepage`, `bugs`, `README.md`,
package/README.md CHANGED
@@ -171,7 +171,7 @@ exits `0`.
171
171
 
172
172
  ```bash
173
173
  bytekit fetch create --url https://example.com
174
- bytekit fetch get --url https://example.com --format markdown
174
+ bytekit fetch get --url https://example.com
175
175
  bytekit fetch get --url https://example.com/logo.png -o logo.png
176
176
  bytekit fetch bulk create --file urls.txt --webhook-url https://your.app/hooks/bytekit
177
177
  ```
@@ -193,30 +193,11 @@ Note the acronym casing — `X-Fetch-ID` and `X-Fetch-URL`, not `X-Fetch-Id` /
193
193
  `X-Fetch-Url`. (Versions up to 0.6.0 emitted the Title-Cased spellings; see the
194
194
  CHANGELOG.)
195
195
 
196
- Both verbs **require** `--url <url>`. Both also accept `--format <format>`
197
- (`markdown` or `html`), `--country <cc>`, `--timeout-ms <n>`, `--cache-ttl <ttl>`
196
+ Both verbs **require** `--url <url>`. They accept `--country <cc>`, `--timeout-ms <n>`, `--cache-ttl <ttl>`
198
197
  (`0`, `Nh`, or `Nd`), and `-o, --output <file>` — each of those omitted from the
199
198
  request when absent.
200
199
 
201
- `fetch create` accepts the full markdown-pipeline surface on top of that, which
202
- `fetch get` does not:
203
-
204
- | Flag | Effect |
205
- | ----------------------------- | ---------------------------------------------------------------------------- |
206
- | `--markdown-mode <mode>` | Markdown extraction mode: `article`, `raw`, `llm`. |
207
- | `--markdown-query <q>` | BM25 query for relevance-ranked markdown filtering. Omitted → disabled. |
208
- | `--markdown-links <mode>` | Link rendering: `inline`, `references`, `none`, `text`. |
209
- | `--markdown-images <mode>` | Image retention: `inline`, `references`, `none`, `text`. |
210
- | `--with-links-summary` | Append a Links footer to the markdown. |
211
- | `--with-images-summary` | Append an Images footer to the markdown. |
212
- | `--markdown-compact` | Collapse excessive whitespace. |
213
- | `--markdown-filter-images` | Filter low-signal images. |
214
- | `--markdown-include-media` | Return rich link/image objects and a tables array. |
215
- | `--markdown-include-warnings` | Include markdown-pipeline and tag-filter warnings. |
216
- | `--markdown-include-stats` | Include a top-level stats object (chars, tokens, blocks). |
217
- | `--custom <json>` | User payload echoed on the envelope, as a JSON object, e.g. `'{"job":"1"}'`. |
218
-
219
- Each is omitted when absent, so a server default is never materialized.
200
+ `fetch create` and `fetch get` send only the raw-fetch options listed above.
220
201
 
221
202
  `fetch bulk create` takes the shared bulk-job flags — see
222
203
  [`bulk`](#bulk--bulk-screenshot-jobs).
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
@@ -1,19 +1,5 @@
1
1
  import { Command } from 'commander';
2
2
  import { type GlobalOptions } from '../client.js';
3
- /** The `/v1/fetch` spec union for `format` — mirrors `FetchOpts['format']` in the SDK. */
4
- type FetchFormat = 'markdown' | 'html';
5
- /**
6
- * Validate `--format` locally, before any HTTP request is issued.
7
- *
8
- * The SDK's `FetchOpts.format` is narrowed to the spec union `'markdown' | 'html'`
9
- * (#2475), so an off-spec value like `pdf` is a compile-time error at the call site.
10
- * Commander runs an option's argParser during parsing — ahead of the action callback —
11
- * so rejecting here means the client is never built and no request is issued, and the
12
- * caller gets an actionable message instead of a server 422 (same convention as
13
- * `parseLimit` in search.ts).
14
- */
15
- export declare function parseFormat(value: string): FetchFormat;
16
3
  export declare function makeFetchCommand(getGlobal: () => GlobalOptions & {
17
4
  json: boolean;
18
5
  }): Command;
19
- export {};
@@ -1,29 +1,8 @@
1
- import { Command, InvalidArgumentError } from 'commander';
1
+ import { Command } from 'commander';
2
2
  import { buildClient } from '../client.js';
3
3
  import { printResult, emitFetchResult, handleError } from '../output.js';
4
4
  import { parseBulkLines } from '../lib/url-lines.js';
5
- import { parseIntFlag, parseEnum, parseCacheTtl, parseJsonFlag } from '../lib/parse-flags.js';
6
- const FETCH_FORMATS = ['markdown', 'html'];
7
- /** The `FetchRequest.markdown_mode` spec enum (create/POST only — excluded from the GET query). */
8
- const MARKDOWN_MODES = ['article', 'raw', 'llm'];
9
- /** The `markdown_links` / `markdown_images` render-mode enum (shared with `scrape create`). */
10
- const MARKDOWN_RENDER_MODES = ['inline', 'references', 'none', 'text'];
11
- /**
12
- * Validate `--format` locally, before any HTTP request is issued.
13
- *
14
- * The SDK's `FetchOpts.format` is narrowed to the spec union `'markdown' | 'html'`
15
- * (#2475), so an off-spec value like `pdf` is a compile-time error at the call site.
16
- * Commander runs an option's argParser during parsing — ahead of the action callback —
17
- * so rejecting here means the client is never built and no request is issued, and the
18
- * caller gets an actionable message instead of a server 422 (same convention as
19
- * `parseLimit` in search.ts).
20
- */
21
- export function parseFormat(value) {
22
- if (!FETCH_FORMATS.includes(value)) {
23
- throw new InvalidArgumentError(`--format must be one of: ${FETCH_FORMATS.join(', ')}.`);
24
- }
25
- return value;
26
- }
5
+ import { parseIntFlag, parseCacheTtl, parseJsonFlag } from '../lib/parse-flags.js';
27
6
  export function makeFetchCommand(getGlobal) {
28
7
  const cmd = new Command('fetch').description('Fetch URL content operations');
29
8
  const bulkCmd = new Command('bulk').description('Bulk fetch operations');
@@ -82,23 +61,9 @@ export function makeFetchCommand(getGlobal) {
82
61
  .command('create')
83
62
  .description('Fetch URL content (POST)')
84
63
  .requiredOption('--url <url>', 'URL to fetch')
85
- .option('--format <format>', 'Response format (markdown or html)', parseFormat)
86
64
  .option('--country <cc>', 'Two-letter country code to route the request through (e.g. us, de)')
87
65
  .option('--timeout-ms <n>', 'Per-request timeout in milliseconds', (v) => parseIntFlag('--timeout-ms', v))
88
66
  .option('--cache-ttl <ttl>', 'Cache freshness: 0, Nh (hours), or Nd (days), e.g. 48h, 2d, 0 (omitted → server default)', (v) => parseCacheTtl('--cache-ttl', v))
89
- // The markdown surface below is POST-only: FetchGetOpts excludes it, so adding any of it to
90
- // `get` would be a compile error — the create/get asymmetry is correct by construction.
91
- .option('--markdown-mode <mode>', `Markdown mode: ${MARKDOWN_MODES.join(', ')}`, (v) => parseEnum('--markdown-mode', MARKDOWN_MODES, v))
92
- .option('--markdown-query <q>', 'BM25 query string for relevance-ranked markdown filtering (omitted → disabled)')
93
- .option('--markdown-links <mode>', `Link rendering: ${MARKDOWN_RENDER_MODES.join(', ')} (omitted → server default)`, (v) => parseEnum('--markdown-links', MARKDOWN_RENDER_MODES, v))
94
- .option('--markdown-images <mode>', `Image retention: ${MARKDOWN_RENDER_MODES.join(', ')} (omitted → server default)`, (v) => parseEnum('--markdown-images', MARKDOWN_RENDER_MODES, v))
95
- .option('--with-links-summary', 'Append a Links footer to the markdown output (omitted → off)')
96
- .option('--with-images-summary', 'Append an Images footer to the markdown output (omitted → off)')
97
- .option('--markdown-compact', 'Collapse excessive whitespace in the markdown output (omitted → off)')
98
- .option('--markdown-filter-images', 'Filter low-signal images from the markdown output (omitted → off)')
99
- .option('--markdown-include-media', 'Return rich link/image objects and a tables array (omitted → off)')
100
- .option('--markdown-include-warnings', 'Include markdown-pipeline and tag-filter warnings (omitted → off)')
101
- .option('--markdown-include-stats', 'Include a top-level stats object (chars, tokens, blocks) (omitted → off)')
102
67
  .option('--custom <json>', `User payload echoed on the envelope as a JSON object, e.g. '{"job":"1"}' (omitted → none)`, (v) => parseJsonFlag('--custom', v, 'object'))
103
68
  .option('-o, --output <file>', 'Write the fetched body bytes to the given file path')
104
69
  .action(async (opts) => {
@@ -108,36 +73,12 @@ export function makeFetchCommand(getGlobal) {
108
73
  // exact same wire output they had as inline spreads; a boolean is written ONLY on
109
74
  // `=== true`, so it never serializes `false` (#2480 E1).
110
75
  const body = { url: opts.url };
111
- if (opts.format !== undefined)
112
- body['format'] = opts.format;
113
76
  if (opts.country !== undefined)
114
77
  body['country'] = opts.country;
115
78
  if (opts.timeoutMs !== undefined)
116
79
  body['timeout_ms'] = opts.timeoutMs;
117
80
  if (opts.cacheTtl !== undefined)
118
81
  body['cache_ttl'] = opts.cacheTtl;
119
- if (opts.markdownMode !== undefined)
120
- body['markdown_mode'] = opts.markdownMode;
121
- if (opts.markdownQuery !== undefined)
122
- body['markdown_query'] = opts.markdownQuery;
123
- if (opts.markdownLinks !== undefined)
124
- body['markdown_links'] = opts.markdownLinks;
125
- if (opts.markdownImages !== undefined)
126
- body['markdown_images'] = opts.markdownImages;
127
- if (opts.withLinksSummary === true)
128
- body['with_links_summary'] = true;
129
- if (opts.withImagesSummary === true)
130
- body['with_images_summary'] = true;
131
- if (opts.markdownCompact === true)
132
- body['markdown_compact'] = true;
133
- if (opts.markdownFilterImages === true)
134
- body['markdown_filter_images'] = true;
135
- if (opts.markdownIncludeMedia === true)
136
- body['markdown_include_media'] = true;
137
- if (opts.markdownIncludeWarnings === true)
138
- body['markdown_include_warnings'] = true;
139
- if (opts.markdownIncludeStats === true)
140
- body['markdown_include_stats'] = true;
141
82
  if (opts.custom !== undefined)
142
83
  body['custom'] = opts.custom;
143
84
  try {
@@ -154,7 +95,6 @@ export function makeFetchCommand(getGlobal) {
154
95
  .command('get')
155
96
  .description('Fetch URL content (GET via query params)')
156
97
  .requiredOption('--url <url>', 'URL to fetch')
157
- .option('--format <format>', 'Response format (markdown or html)', parseFormat)
158
98
  .option('--country <cc>', 'Two-letter country code to route the request through (e.g. us, de)')
159
99
  .option('--timeout-ms <n>', 'Per-request timeout in milliseconds', (v) => parseIntFlag('--timeout-ms', v))
160
100
  .option('--cache-ttl <ttl>', 'Cache freshness: 0, Nh (hours), or Nd (days), e.g. 48h, 2d, 0 (omitted → server default)', (v) => parseCacheTtl('--cache-ttl', v))
@@ -166,7 +106,6 @@ export function makeFetchCommand(getGlobal) {
166
106
  const client = buildClient(g);
167
107
  const result = await client.fetch.get({
168
108
  url: opts.url,
169
- ...(opts.format !== undefined ? { format: opts.format } : {}),
170
109
  ...(opts.country !== undefined ? { country: opts.country } : {}),
171
110
  ...(opts.timeoutMs !== undefined ? { timeout_ms: opts.timeoutMs } : {}),
172
111
  ...(opts.cacheTtl !== undefined ? { cache_ttl: opts.cacheTtl } : {}),
@@ -48,7 +48,7 @@ export function makeScrapeCommand(getGlobal) {
48
48
  .requiredOption('--file <path>', 'Path to a newline-delimited file. Each line is a plain URL, or a JSON object carrying "url" plus per-item config. A line whose only key is "url" behaves exactly as before (sent as a bare URL string); any line with additional keys switches the payload to the per-item shape.')
49
49
  .requiredOption('--webhook-url <url>', 'HTTPS webhook URL notified when the job completes')
50
50
  .option('--webhook-secret <secret>', 'HMAC secret used to sign the completion webhook (omitted → unsigned)')
51
- .option('--defaults <json>', 'Defaults applied to every entry, as a JSON object (omitted → none). Unknown keys are rejected by /v1/bulk (422) and ignored by scrape bulk / fetch bulk; fetch bulk additionally rejects browser-only keys (cookies, wait_until, wait_for_selector, delay_ms) with a 400.', (v) => parseJsonFlag('--defaults', v, 'object'))
51
+ .option('--defaults <json>', 'Defaults applied to every entry, as a JSON object (omitted → none). Unknown keys are rejected by /v1/bulk (422) and ignored by scrape bulk / fetch bulk; fetch bulk additionally rejects browser-only keys (wait_until, wait_for_selector, delay_ms) with a 400. `cookies` and `headers` are no longer accepted by any capture endpoint and 400 (#3242).', (v) => parseJsonFlag('--defaults', v, 'object'))
52
52
  .option('--metadata <json>', `Arbitrary metadata echoed on the job envelope, as a JSON object, e.g. '{"run":"7"}' (omitted → none)`, (v) => parseJsonFlag('--metadata', v, 'object'))
53
53
  // /v1/scrape/bulk is the ONE bulk endpoint with a top-level `custom`
54
54
  // (validators/scrape-bulk.ts:88). Per-entry `custom` arrives through --file lines instead.
@@ -115,8 +115,6 @@ export function makeScrapeCommand(getGlobal) {
115
115
  .option('--markdown-mode <mode>', `Markdown extraction mode: ${MARKDOWN_MODES.join(', ')}`, (v) => parseEnum('--markdown-mode', MARKDOWN_MODES, v))
116
116
  .option('--markdown-query <q>', 'BM25 query string for relevance-ranked markdown filtering (omitted → disabled)')
117
117
  .option('--cache-ttl <ttl>', 'Cache freshness: 0, Nh (hours), or Nd (days), e.g. 48h, 2d, 0 (omitted → server default)', (v) => parseCacheTtl('--cache-ttl', v))
118
- .option('--cookies <json>', `Cookies as a JSON array, e.g. '[{"name":"a","value":"b"}]' (omitted → none)`, (v) => parseJsonFlag('--cookies', v, 'array'))
119
- .option('--headers <json>', `Extra request headers as a JSON object, e.g. '{"X-A":"b"}' (omitted → none)`, (v) => parseJsonFlag('--headers', v, 'object'))
120
118
  .option('--delay-ms <n>', 'Post-load delay in milliseconds before capture', (v) => parseIntFlag('--delay-ms', v))
121
119
  .option('--events <csv>', `Webhook events (comma-separated): ${SCRAPE_EVENTS.join(', ')} (omitted → server default)`, (v) => parseCsv(v).map((e) => parseEnum('--events', SCRAPE_EVENTS, e)))
122
120
  .option('--token-encoding <enc>', `Tokenizer for --token-budget: ${TOKEN_ENCODINGS.join(', ')} (omitted → server default)`, (v) => parseEnum('--token-encoding', TOKEN_ENCODINGS, v))
@@ -164,10 +162,6 @@ export function makeScrapeCommand(getGlobal) {
164
162
  body['markdown_query'] = opts.markdownQuery;
165
163
  if (opts.cacheTtl !== undefined)
166
164
  body['cache_ttl'] = opts.cacheTtl;
167
- if (opts.cookies !== undefined)
168
- body['cookies'] = opts.cookies;
169
- if (opts.headers !== undefined)
170
- body['headers'] = opts.headers;
171
165
  if (opts.delayMs !== undefined)
172
166
  body['delay_ms'] = opts.delayMs;
173
167
  if (opts.events !== undefined)
@@ -346,8 +346,6 @@ export function makeScreenshotsCommand(getGlobal) {
346
346
  .option('--include-html', 'Include the rendered HTML alongside the screenshot')
347
347
  .option('--country <cc>', 'Two-letter country code to route the request through (e.g. us, de)')
348
348
  .option('--language <lang>', 'Accept-Language override (e.g. en-US)')
349
- .option('--headers <json>', `Extra request headers as a JSON object, e.g. '{"X-A":"b"}' (omitted → none)`, (v) => parseJsonFlag('--headers', v, 'object'))
350
- .option('--cookies <json>', `Cookies as a JSON array of {name,value,domain}, e.g. '[{"name":"a","value":"b","domain":"example.com"}]' (omitted → none)`, (v) => parseJsonFlag('--cookies', v, 'array'))
351
349
  .option('--metadata <json>', `Arbitrary metadata echoed on the screenshot envelope, as a JSON object, e.g. '{"run":"7"}' (omitted → none)`, (v) => parseJsonFlag('--metadata', v, 'object'))
352
350
  .option('--async', 'Queue the screenshot and return a job envelope immediately (no wait)')
353
351
  .option('-o, --output <file>', 'Download the captured image to this file path (use - for stdout). Not valid with --json.')
@@ -394,12 +392,8 @@ export function makeScreenshotsCommand(getGlobal) {
394
392
  body['country'] = opts.country;
395
393
  if (opts.language !== undefined)
396
394
  body['language'] = opts.language;
397
- // The server defaults headers/cookies/metadata to {} / [] / {}; writing an empty
398
- // container on a bare create would materialize those defaults (#2480 E1).
399
- if (opts.headers !== undefined)
400
- body['headers'] = opts.headers;
401
- if (opts.cookies !== undefined)
402
- body['cookies'] = opts.cookies;
395
+ // The server defaults metadata to {}; writing an empty container on a bare
396
+ // create would materialize that default (#2480 E1).
403
397
  if (opts.metadata !== undefined)
404
398
  body['metadata'] = opts.metadata;
405
399
  try {
@@ -31,7 +31,7 @@ export declare function parseNumberFlag(flag: string, value: string): number;
31
31
  export declare function parseCacheTtl(flag: string, value: string): string;
32
32
  /**
33
33
  * Parse a JSON-object- or JSON-array-valued flag (#2545). The ONLY `JSON.parse` call site for CLI
34
- * flag values — every JSON-shaped flag (`--cookies`, `--headers`, `--custom`,
34
+ * flag values — every JSON-shaped flag (`--custom`, `--metadata`, `--defaults`,
35
35
  * `--webhook-headers`, `--options`, `--scrape-options`) routes through here, so command modules
36
36
  * carry no raw `JSON.parse` (grep-gated). Rejects malformed JSON AND a value whose top-level shape
37
37
  * does not match `expect`, with a copy-pasteable example in the message (E5).
@@ -57,7 +57,7 @@ export function parseCacheTtl(flag, value) {
57
57
  }
58
58
  /**
59
59
  * Parse a JSON-object- or JSON-array-valued flag (#2545). The ONLY `JSON.parse` call site for CLI
60
- * flag values — every JSON-shaped flag (`--cookies`, `--headers`, `--custom`,
60
+ * flag values — every JSON-shaped flag (`--custom`, `--metadata`, `--defaults`,
61
61
  * `--webhook-headers`, `--options`, `--scrape-options`) routes through here, so command modules
62
62
  * carry no raw `JSON.parse` (grep-gated). Rejects malformed JSON AND a value whose top-level shape
63
63
  * does not match `expect`, with a copy-pasteable example in the message (E5).
@@ -30,12 +30,12 @@ export type BulkLine = string | Record<string, unknown>;
30
30
  *
31
31
  * The rejection message for a malformed line (#2596 cr-1 / E12) names the 1-based PHYSICAL line
32
32
  * NUMBER (i.e. the line's own position in the file, counting blank lines) and the line's own KEY
33
- * NAMES only — never the raw line or any value. A `--file` line is where per-item `cookies`,
34
- * `headers`, and auth-bearing `metadata` now live (#2596), so echoing the raw line back would
35
- * leak session tokens and similar secrets to stderr, CI logs, and shell transcripts. Because the
36
- * raw line is deliberately never echoed, the line number is the ONLY locator left in the message —
37
- * it must be computed BEFORE blank lines are filtered out, or it silently mis-locates the bad
38
- * entry whenever the file contains blank lines above it (#2596 cr-2).
33
+ * NAMES only — never the raw line or any value. A `--file` line is where auth-bearing per-item
34
+ * `metadata` lives (#2596; per-item `cookies`/`headers` were removed in #3242), so echoing the
35
+ * raw line back would leak session tokens and similar secrets to stderr, CI logs, and shell
36
+ * transcripts. Because the raw line is deliberately never echoed, the line number is the ONLY
37
+ * locator left in the message — it must be computed BEFORE blank lines are filtered out, or it
38
+ * silently mis-locates the bad entry whenever the file contains blank lines above it (#2596 cr-2).
39
39
  */
40
40
  export declare function parseBulkLines(file: string): BulkLine[];
41
41
  /**
@@ -24,12 +24,12 @@ import { InvalidArgumentError } from 'commander';
24
24
  *
25
25
  * The rejection message for a malformed line (#2596 cr-1 / E12) names the 1-based PHYSICAL line
26
26
  * NUMBER (i.e. the line's own position in the file, counting blank lines) and the line's own KEY
27
- * NAMES only — never the raw line or any value. A `--file` line is where per-item `cookies`,
28
- * `headers`, and auth-bearing `metadata` now live (#2596), so echoing the raw line back would
29
- * leak session tokens and similar secrets to stderr, CI logs, and shell transcripts. Because the
30
- * raw line is deliberately never echoed, the line number is the ONLY locator left in the message —
31
- * it must be computed BEFORE blank lines are filtered out, or it silently mis-locates the bad
32
- * entry whenever the file contains blank lines above it (#2596 cr-2).
27
+ * NAMES only — never the raw line or any value. A `--file` line is where auth-bearing per-item
28
+ * `metadata` lives (#2596; per-item `cookies`/`headers` were removed in #3242), so echoing the
29
+ * raw line back would leak session tokens and similar secrets to stderr, CI logs, and shell
30
+ * transcripts. Because the raw line is deliberately never echoed, the line number is the ONLY
31
+ * locator left in the message — it must be computed BEFORE blank lines are filtered out, or it
32
+ * silently mis-locates the bad entry whenever the file contains blank lines above it (#2596 cr-2).
33
33
  */
34
34
  export function parseBulkLines(file) {
35
35
  return readFileSync(file, 'utf8')
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)}`);
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Build a scratch COPY of packages/cli (issue #3270), mirroring
3
+ * `packages/sdk-typescript/src/test-helpers/build-scratch.ts` (issue #2423 cr-2).
4
+ *
5
+ * `entrypoint.test.ts` and `queued-raw-flush.test.ts` both need a built `dist/index.js` and both
6
+ * used to rebuild the shared, workspace `packages/cli/dist` in place whenever it was missing —
7
+ * racing each other's build under vitest's default parallel file execution on a checkout with no
8
+ * prior `pnpm build` (one file's `existsSync` check passes while the other's build is still
9
+ * writing `dist/`).
10
+ *
11
+ * So instead: copy the tracked, on-disk source (never `dist/`, `tsconfig.tsbuildinfo`, or
12
+ * `node_modules` — those are the mutable/heavy bits) into a scratch tree that mirrors the real
13
+ * repo's relative layout (`<scratch>/tsconfig.base.json`, `<scratch>/packages/cli/**`), symlink in
14
+ * the real `node_modules` (read-only from the child process's point of view — nothing here writes
15
+ * back into it), and let the caller run the package's own `npm run build` / `npm pack` scripts
16
+ * against that copy. The real `packages/cli/dist` is never read and never written by either suite.
17
+ *
18
+ * @param pkgRoot packages/cli (the real, workspace-shared package root)
19
+ * @param repoRoot the monorepo root (for tsconfig.base.json, which the scratch tsconfig extends)
20
+ * @param scratchRoot a fresh tmp dir (e.g. from mkdtempSync) to build the scratch tree under
21
+ * @returns the scratch package dir (`<scratchRoot>/packages/cli`)
22
+ */
23
+ export declare function createBuildScratch(pkgRoot: string, repoRoot: string, scratchRoot: string): string;
@@ -0,0 +1,41 @@
1
+ // Test-only fixture, compiled like any other `src` file (not excluded from the tsc build) — same
2
+ // tradeoff sdk-typescript's own copy of this helper documents: a small compiled fixture lands in
3
+ // `dist/test-helpers/`, but excluding it breaks ESLint's typed-lint project-service resolution.
4
+ import { mkdirSync, cpSync, copyFileSync, symlinkSync } from 'node:fs';
5
+ import { basename, join } from 'node:path';
6
+ /**
7
+ * Build a scratch COPY of packages/cli (issue #3270), mirroring
8
+ * `packages/sdk-typescript/src/test-helpers/build-scratch.ts` (issue #2423 cr-2).
9
+ *
10
+ * `entrypoint.test.ts` and `queued-raw-flush.test.ts` both need a built `dist/index.js` and both
11
+ * used to rebuild the shared, workspace `packages/cli/dist` in place whenever it was missing —
12
+ * racing each other's build under vitest's default parallel file execution on a checkout with no
13
+ * prior `pnpm build` (one file's `existsSync` check passes while the other's build is still
14
+ * writing `dist/`).
15
+ *
16
+ * So instead: copy the tracked, on-disk source (never `dist/`, `tsconfig.tsbuildinfo`, or
17
+ * `node_modules` — those are the mutable/heavy bits) into a scratch tree that mirrors the real
18
+ * repo's relative layout (`<scratch>/tsconfig.base.json`, `<scratch>/packages/cli/**`), symlink in
19
+ * the real `node_modules` (read-only from the child process's point of view — nothing here writes
20
+ * back into it), and let the caller run the package's own `npm run build` / `npm pack` scripts
21
+ * against that copy. The real `packages/cli/dist` is never read and never written by either suite.
22
+ *
23
+ * @param pkgRoot packages/cli (the real, workspace-shared package root)
24
+ * @param repoRoot the monorepo root (for tsconfig.base.json, which the scratch tsconfig extends)
25
+ * @param scratchRoot a fresh tmp dir (e.g. from mkdtempSync) to build the scratch tree under
26
+ * @returns the scratch package dir (`<scratchRoot>/packages/cli`)
27
+ */
28
+ export function createBuildScratch(pkgRoot, repoRoot, scratchRoot) {
29
+ const pkgDest = join(scratchRoot, 'packages/cli');
30
+ mkdirSync(join(scratchRoot, 'packages'), { recursive: true });
31
+ copyFileSync(join(repoRoot, 'tsconfig.base.json'), join(scratchRoot, 'tsconfig.base.json'));
32
+ cpSync(pkgRoot, pkgDest, {
33
+ recursive: true,
34
+ filter: (src) => {
35
+ const name = basename(src);
36
+ return name !== 'node_modules' && name !== 'dist' && name !== 'tsconfig.tsbuildinfo';
37
+ },
38
+ });
39
+ symlinkSync(join(pkgRoot, 'node_modules'), join(pkgDest, 'node_modules'), 'dir');
40
+ return pkgDest;
41
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hunt-labs/bytekit-cli",
3
- "version": "0.8.1",
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.7.1"
44
+ "@hunt-labs/bytekit-sdk": "^0.9.1"
45
45
  },
46
46
  "scripts": {
47
47
  "build": "rm -rf dist tsconfig.tsbuildinfo && tsc",