@hunt-labs/bytekit-cli 0.6.0 → 0.7.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,80 @@ today, see [README.md](./README.md), which remains the authoritative usage guide
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.7.1] - 2026-08-10
14
+
15
+ ### Changed
16
+
17
+ - Version-alignment release after the 2026-08-10 publish rescue: the published 0.7.0 artifact was built from the `main` promotion HEAD, which already contained every change documented under the 0.7.0 heading — but that commit postdates the one that introduced the 0.7.0 version string, so the `check-release-drift` gate (#2680) correctly reported shipped drift on every subsequent CI run (#2829). 0.7.1 realigns manifest ↔ registry ↔ version-introducing commit. **No consumer-visible change relative to the published 0.7.0.**
18
+
19
+ ## [0.7.0] - 2026-08-07
20
+
21
+ ### Breaking
22
+
23
+ - **A not-ready `screenshots get -o` / `screenshots create -o` now exits `2` and prints the bare
24
+ `ss_` id, instead of exiting `1` with `not ready yet: screenshot is pending`.** The two poll
25
+ commands disagreed: `scrape get --raw` on a still-queued scrape already wrote the bare `sc_` id
26
+ to stdout and exited `2` — a distinct, scriptable "accepted but not ready" signal — while the
27
+ screenshots side reported the same state as a failure and threw the handle away, so a polling
28
+ script could branch on "queued" for one command and not the other. The screenshots branch now
29
+ mirrors scrape exactly: bare `ss_` id on stdout, a poll note on stderr, exit `2`, and (as before)
30
+ no file written. A script that treated any non-zero exit as "this screenshot failed" now sees `2`
31
+ for a job that is merely still running — branch on the code (`2` = poll again, `1` = failed)
32
+ rather than on non-zero. Three states deliberately do **not** move: a terminally `failed`
33
+ screenshot stays exit `1` with its error surfaced (it will never complete, so a "poll again"
34
+ signal would loop a script forever), an envelope carrying no `ss_` id stays exit `1` (there is no
35
+ handle to hand back), and `--json` keeps the envelope at exit `0`.
36
+ - **`--json` on a `fetch` command now emits `X-Fetch-ID` and `X-Fetch-URL`, not `X-Fetch-Id` and
37
+ `X-Fetch-Url`.** The metadata keys were derived by a generic segment-wise Title-Caser, which
38
+ lowercases every non-initial character of a segment — so the two acronym-bearing headers came out
39
+ in a spelling the API reference never uses, and a `jq '."X-Fetch-ID"'` filter written from the
40
+ docs returned `null`. All 16 `X-Fetch-*` keys now match the spec names byte-for-byte. The
41
+ **values** are unchanged, the non-`X-Fetch-*` headers stay filtered out, and every other key
42
+ (`X-Fetch-Waf-Vendor`, `X-Fetch-Duration-Ms`, `X-Fetch-Fast-Path`, …) is spelled exactly as
43
+ before. A consumer reading `.["X-Fetch-Id"]` must switch to `.["X-Fetch-ID"]`.
44
+
45
+ ### Fixed
46
+
47
+ - **A `screenshots get -o` / `screenshots create -o` poll only says "poll again" for a `pending` or
48
+ `processing` screenshot.** The not-ready predicate decided from the presence of an `image_url`,
49
+ an `id` and a nested error alone — `status` was read only to phrase the message — so a
50
+ `completed` screenshot whose `image_url` never materialized, a status outside the closed enum,
51
+ and the flat `error_code`/`error_message` failed shape all received exit `2` and "no image
52
+ artifact is available yet. Poll it with: …" for a job that would never complete. Those states now
53
+ exit `1`: a terminal failure prints `screenshot failed: …`, and any other artifact-less state
54
+ prints `no image artifact available: screenshot is <status>`. Genuine `pending` / `processing`
55
+ polls keep the bare `ss_` id, the poll note and exit `2` exactly as before — **including** a
56
+ poll whose envelope carries an `error_code` from an attempt that failed and is being retried,
57
+ since the status, not the error, decides whether polling can still help. `--json` still prints
58
+ the envelope at exit `0`.
59
+ - **A terminally failed screenshot reported through the spec's flat `error_code`/`error_message`
60
+ fields now reaches stderr.** Only the nested `{error:{code,message}}` envelope was read, so the
61
+ shape `ScreenshotResponse` actually declares surfaced neither code nor message. It now prints
62
+ `screenshot failed: <message> (<code>)` — the formatting `scrape get --raw` already uses for a
63
+ failed scrape. The nested envelope's own message is unchanged.
64
+ - **`scrape get --raw` on a terminally failed scrape now reports why it failed.** It printed
65
+ `--raw: response contains no formats` — technically true of a `ScrapeErrorEnvelope`, and the one
66
+ diagnostic that cannot explain anything — while the envelope's own `error.code` and
67
+ `error.message` never reached the caller. Both now go to stderr (exit `1`, unchanged). The
68
+ failure is reported ahead of the format diagnostics, so an envelope carrying both a failure and
69
+ an empty `formats: {}` still says why it failed rather than pointing at a `--format` choice that
70
+ cannot help.
71
+ - **An empty-formats success now names the warning that explains it.** A completed scrape whose
72
+ persisted artifact could not be rehydrated comes back with `formats: {}` and an
73
+ `artifact_unavailable` warning; `--raw` rendered that as `--raw requires --format when the
74
+ response contains multiple formats`, which is false on both counts. It now names the warning's
75
+ code and message, or says plainly that the formats map is empty when no warning explains it.
76
+ - **Error paths no longer risk losing their own diagnostic.** `handleError` and the missing-API-key
77
+ path each wrote to stderr and then immediately hard-`process.exit(1)`. Node's stderr is
78
+ asynchronous when it points at a pipe, and `process.exit` discards whatever is still queued — so
79
+ the one line a script greps was the line most at risk of being thrown away, which is exactly the
80
+ defect this package's flush policy exists to prevent. Both now set `process.exitCode` and let the
81
+ process end naturally once stderr has drained. The exit codes and the rendered messages are
82
+ byte-identical.
83
+ - `--headers`, `--custom` and `--metadata` said `must be valid JSON (a object)` on a malformed
84
+ value; the article now agrees — `an object` / `an array`.
85
+ - The `[0.6.0]` link reference, missing from the bottom of this file, is restored.
86
+
13
87
  ## [0.6.0] - 2026-07-29
14
88
 
15
89
  ### Breaking
@@ -65,7 +139,12 @@ today, see [README.md](./README.md), which remains the authoritative usage guide
65
139
  - `--metadata` on `monitors create`, and `--metadata` + `--url` on `monitors update`. `--type`
66
140
  remains deliberately absent from `update` — it is immutable server-side.
67
141
 
68
- ## [0.5.1] - 2026-07-28
142
+ ## [0.5.1] - 2026-07-28 [NEVER PUBLISHED]
143
+
144
+ > **Never published to npm.** This version was cut in-tree but no `@hunt-labs/bytekit-cli@0.5.1`
145
+ > tarball exists — `npm i @hunt-labs/bytekit-cli@0.5.1` fails. Everything below shipped to users in
146
+ > **0.6.0**, which is the first published release containing it. Reconciled by issue #2680; the
147
+ > `release-drift` CI gate now blocks a new phantom heading from being introduced.
69
148
 
70
149
  ### Fixed
71
150
 
@@ -188,7 +267,12 @@ today, see [README.md](./README.md), which remains the authoritative usage guide
188
267
  `sc_` id** on stdout with a note on stderr (it does not print the queued job
189
268
  envelope). (#2544)
190
269
 
191
- ## [0.4.0] - 2026-07-23
270
+ ## [0.4.0] - 2026-07-23 [NEVER PUBLISHED]
271
+
272
+ > **Never published to npm.** This version was cut in-tree but no `@hunt-labs/bytekit-cli@0.4.0`
273
+ > tarball exists — `npm i @hunt-labs/bytekit-cli@0.4.0` fails. Everything below shipped to users in
274
+ > **0.5.0**, which is the first published release containing it. Reconciled by issue #2680; the
275
+ > `release-drift` CI gate now blocks a new phantom heading from being introduced.
192
276
 
193
277
  ### Removed (**BREAKING**)
194
278
 
@@ -209,7 +293,13 @@ today, see [README.md](./README.md), which remains the authoritative usage guide
209
293
  **Replacement:** manage API keys from the dashboard at
210
294
  [app.bytekit.com](https://app.bytekit.com). (#2536)
211
295
 
212
- ## [0.3.1] - 2026-07-22
296
+ ## [0.3.1] - 2026-07-22 [NEVER PUBLISHED]
297
+
298
+ > **Never published to npm.** This version was cut in-tree but no `@hunt-labs/bytekit-cli@0.3.1`
299
+ > tarball exists — `npm i @hunt-labs/bytekit-cli@0.3.1` fails. Everything below shipped to users in
300
+ > **0.5.0**, which is the first published release containing it (0.4.0 was never published either —
301
+ > see its heading above). Reconciled by issue #2680; the `release-drift` CI gate now blocks a new
302
+ > phantom heading from being introduced.
213
303
 
214
304
  ### Fixed
215
305
 
@@ -292,7 +382,12 @@ today, see [README.md](./README.md), which remains the authoritative usage guide
292
382
  rebuilding the shared `dist`), which is now fixed. No consumer-facing changes
293
383
  beyond 0.2.1.
294
384
 
295
- ## [0.2.1] - 2026-07-16
385
+ ## [0.2.1] - 2026-07-16 [NEVER PUBLISHED]
386
+
387
+ > **Never published to npm.** This version was cut in-tree but no `@hunt-labs/bytekit-cli@0.2.1`
388
+ > tarball exists — `npm i @hunt-labs/bytekit-cli@0.2.1` fails. Everything below shipped to users in
389
+ > **0.2.2**, which is the first published release containing it. Reconciled by issue #2680; the
390
+ > `release-drift` CI gate now blocks a new phantom heading from being introduced.
296
391
 
297
392
  ### Changed
298
393
 
@@ -342,6 +437,8 @@ Additive, lower-risk release — no breaking changes to existing commands.
342
437
  subcommands, published under the original `rapidcrawl-cli` name.
343
438
 
344
439
  [Unreleased]: https://github.com/Hunt-Labs-Inc/ByteKit/tree/main/packages/cli
440
+ [0.7.0]: https://github.com/Hunt-Labs-Inc/ByteKit/tree/main/packages/cli
441
+ [0.6.0]: https://github.com/Hunt-Labs-Inc/ByteKit/tree/main/packages/cli
345
442
  [0.5.1]: https://github.com/Hunt-Labs-Inc/ByteKit/tree/main/packages/cli
346
443
  [0.5.0]: https://github.com/Hunt-Labs-Inc/ByteKit/tree/main/packages/cli
347
444
  [0.4.0]: https://github.com/Hunt-Labs-Inc/ByteKit/tree/main/packages/cli
package/README.md CHANGED
@@ -172,6 +172,17 @@ response metadata instead of the body. `-o <file>` writes the body bytes to a
172
172
  file byte-for-byte (with `--json`, the metadata object goes to the file
173
173
  instead). `fetch bulk` returns an ordinary JSON job envelope.
174
174
 
175
+ The `--json` metadata keys are the **spec header names exactly** as the API
176
+ reference declares them, so a filter written from the docs works unchanged:
177
+
178
+ ```bash
179
+ bytekit fetch get --url https://example.com --json | jq -r '."X-Fetch-ID"'
180
+ ```
181
+
182
+ Note the acronym casing — `X-Fetch-ID` and `X-Fetch-URL`, not `X-Fetch-Id` /
183
+ `X-Fetch-Url`. (Versions up to 0.6.0 emitted the Title-Cased spellings; see the
184
+ CHANGELOG.)
185
+
175
186
  Both verbs accept `--country <cc>`, `--timeout-ms <n>`, and `--cache-ttl <ttl>`
176
187
  (`0`, `Nh`, or `Nd`), each omitted when absent. `--markdown-mode`
177
188
  (`article`, `raw`, `llm`) is `fetch create` only.
@@ -303,11 +314,34 @@ omitted when absent. `retry <id>` requeues a failed or exhausted delivery.
303
314
 
304
315
  ## Exit codes
305
316
 
306
- | Code | Meaning |
307
- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
308
- | `0` | Success. |
309
- | `1` | Usage error, or an API/connection error. |
310
- | `2` | The job was accepted but is still **queued**, so there is no content body or artifact to emit. Applies to `scrape create --async` with `--raw` or a bare `-o` (which writes the bare `sc_` id to stdout) and in human mode, and to `screenshots create --async` in human mode (including with `-o`, which deliberately writes no file). Not a failure: the job was accepted and billed. Poll it with `bytekit scrape get <id>` / `bytekit screenshots get <id>`. **`--json` keeps exit `0` in both cases** — the queued envelope is a complete machine-readable payload. |
317
+ | Code | Meaning |
318
+ | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
319
+ | `0` | Success. |
320
+ | `1` | Usage error, or an API/connection error. |
321
+ | `2` | The job was accepted but is **not ready**, so there is no content body or artifact to emit. Applies to `scrape create --async` with `--raw` or a bare `-o` (which writes the bare `sc_` id to stdout) and in human mode; to `scrape get --raw`/`-o` on a still-queued job (bare `sc_` id to stdout); to `screenshots create --async` in human mode (including with `-o`, which deliberately writes no file); and to `screenshots create -o` / `screenshots get -o` on a pending or processing screenshot (bare `ss_` id to stdout, no file written). Not a failure: the job was accepted and billed. Poll it with `bytekit scrape get <id>` / `bytekit screenshots get <id>`. **`--json` keeps exit `0` in every case** — the envelope is a complete machine-readable payload. |
322
+
323
+ A **terminally failed** job is NOT code `2` — it will never complete, so it is
324
+ exit `1` with the reason on stderr. `scrape get --raw` on a failed scrape prints
325
+ that scrape's own `error.code` and message, and `screenshots get -o` on a failed
326
+ screenshot prints `screenshot failed: …`.
327
+
328
+ Only `pending` and `processing` earn code `2`. Any other screenshot state that
329
+ still has no artifact — a `completed` screenshot whose `image_url` never
330
+ materialized, or a status this CLI build does not know — is exit `1` with
331
+ `no image artifact available: screenshot is <status>` on stderr, because
332
+ polling it again cannot change the outcome.
333
+
334
+ The status is what decides, not the presence of an error. A `pending` or
335
+ `processing` screenshot that reports an `error_code` from an attempt that failed
336
+ and is being **retried** is still code `2` with its `ss_` id — the job has not
337
+ finished, and abandoning it there would throw away a capture the server is still
338
+ working on.
339
+
340
+ A **human-mode poll without `-o`/`--raw`** stays exit `0` whatever the job's
341
+ state: `bytekit scrape get <id>` and `bytekit screenshots get <id>` were asked
342
+ for the envelope, and the envelope is exactly what they printed — the status
343
+ field in it is the answer. Code `2` is reserved for the case where you asked for
344
+ a **body or artifact** that does not exist yet.
311
345
 
312
346
  The stderr message for exit code `1` has two shapes, depending on where the
313
347
  error originates:
@@ -334,6 +368,18 @@ case $? in
334
368
  esac
335
369
  ```
336
370
 
371
+ The same branch works for a screenshot poll, because both commands now signal
372
+ "not ready" identically:
373
+
374
+ ```bash
375
+ ss_id=$(bytekit screenshots get "$ss_id" -o shot.png)
376
+ case $? in
377
+ 0) echo "downloaded shot.png" ;;
378
+ 2) echo "$ss_id not ready — poll again" ;;
379
+ *) exit 1 ;; # terminal failure, reason on stderr
380
+ esac
381
+ ```
382
+
337
383
  ## Documentation
338
384
 
339
385
  Full API reference: [bytekit.com/docs](https://bytekit.com/docs).
package/dist/client.d.ts CHANGED
@@ -167,7 +167,21 @@ export declare function createRetryingRequestWithResponse(apiKey: string, baseUr
167
167
  * Resolution order (explicit input beats environment):
168
168
  * apiKey: --key flag → BYTEKIT_API_KEY.
169
169
  * baseUrl: --base-url flag → BYTEKIT_BASE_URL → DEFAULT_BASE_URL.
170
- * Exits with a descriptive error when no key is available.
170
+ *
171
+ * THROWS a plain `Error` when no key is available (#2686 item 4). It used to `console.error` and
172
+ * then hard-exit — an async stderr write followed immediately by the one call that discards
173
+ * the userspace stdio queue, which is exactly the defect the package's #2544 flush policy exists to
174
+ * prevent. Throwing stops just as hard (there is no client to return, E4) while routing the message
175
+ * through `handleError`, whose soft `process.exitCode` lets stderr drain first. The rendered stderr
176
+ * line is byte-identical: `handleError` supplies the `Error: ` prefix, and a plain Error carries
177
+ * neither a status nor a ByteKit code, so no parenthesised suffix is added.
178
+ *
179
+ * Because it throws, EVERY caller invokes it from inside its action's `try` block. `index.ts` drives
180
+ * commander with `.parse()`, not `.parseAsync()`, so nothing awaits an action's promise: a throw
181
+ * raised above the try would escape as an unhandled rejection and print a raw stack trace instead of
182
+ * the clean one-liner — the same constraint `commands/scrape.ts` already documents for
183
+ * `resolveOutputMode`. `error-exit-contract.test.ts` pins that placement structurally, across every
184
+ * call site at once.
171
185
  *
172
186
  * The returned client's `request`, `requestRaw`, AND `requestWithResponse` are all replaced by the
173
187
  * retrying transports, so every command inherits the 429 retry — JSON commands via `request`, the
package/dist/client.js CHANGED
@@ -492,7 +492,21 @@ function normalizeBaseUrl(url) {
492
492
  * Resolution order (explicit input beats environment):
493
493
  * apiKey: --key flag → BYTEKIT_API_KEY.
494
494
  * baseUrl: --base-url flag → BYTEKIT_BASE_URL → DEFAULT_BASE_URL.
495
- * Exits with a descriptive error when no key is available.
495
+ *
496
+ * THROWS a plain `Error` when no key is available (#2686 item 4). It used to `console.error` and
497
+ * then hard-exit — an async stderr write followed immediately by the one call that discards
498
+ * the userspace stdio queue, which is exactly the defect the package's #2544 flush policy exists to
499
+ * prevent. Throwing stops just as hard (there is no client to return, E4) while routing the message
500
+ * through `handleError`, whose soft `process.exitCode` lets stderr drain first. The rendered stderr
501
+ * line is byte-identical: `handleError` supplies the `Error: ` prefix, and a plain Error carries
502
+ * neither a status nor a ByteKit code, so no parenthesised suffix is added.
503
+ *
504
+ * Because it throws, EVERY caller invokes it from inside its action's `try` block. `index.ts` drives
505
+ * commander with `.parse()`, not `.parseAsync()`, so nothing awaits an action's promise: a throw
506
+ * raised above the try would escape as an unhandled rejection and print a raw stack trace instead of
507
+ * the clean one-liner — the same constraint `commands/scrape.ts` already documents for
508
+ * `resolveOutputMode`. `error-exit-contract.test.ts` pins that placement structurally, across every
509
+ * call site at once.
496
510
  *
497
511
  * The returned client's `request`, `requestRaw`, AND `requestWithResponse` are all replaced by the
498
512
  * retrying transports, so every command inherits the 429 retry — JSON commands via `request`, the
@@ -511,8 +525,7 @@ function normalizeBaseUrl(url) {
511
525
  export function buildClient(opts) {
512
526
  const apiKey = opts.key ?? process.env['BYTEKIT_API_KEY'];
513
527
  if (!apiKey) {
514
- console.error('Error: API key required. Pass --key or set BYTEKIT_API_KEY.');
515
- process.exit(1);
528
+ throw new Error('API key required. Pass --key or set BYTEKIT_API_KEY.');
516
529
  }
517
530
  const baseUrl = normalizeBaseUrl(opts.baseUrl ?? process.env['BYTEKIT_BASE_URL'] ?? DEFAULT_BASE_URL);
518
531
  const client = new ByteKit({ apiKey, baseUrl });
@@ -19,8 +19,9 @@ export function makeAccountCommand(getGlobal) {
19
19
  .description('Get the authenticated account')
20
20
  .action(async () => {
21
21
  const g = getGlobal();
22
- const client = buildClient(g);
23
22
  try {
23
+ // Inside the try: buildClient() throws on a missing key (#2686 item 4) and the throw must reach handleError(). See its doc comment in client.ts.
24
+ const client = buildClient(g);
24
25
  const result = await client.account.get();
25
26
  printResult(result, g.json);
26
27
  }
@@ -11,8 +11,9 @@ export function makeBulkCommand(getGlobal) {
11
11
  .description('List screenshots in a bulk job')
12
12
  .action(async (id) => {
13
13
  const g = getGlobal();
14
- const client = buildClient(g);
15
14
  try {
15
+ // Inside the try: buildClient() throws on a missing key (#2686 item 4) and the throw must reach handleError(). See its doc comment in client.ts.
16
+ const client = buildClient(g);
16
17
  const result = await client.bulk.screenshots.list(id);
17
18
  printResult(result, g.json);
18
19
  }
@@ -33,8 +34,9 @@ export function makeBulkCommand(getGlobal) {
33
34
  // property (validators/bulk.ts:135), so the flag would 422 every call (#2596 drift D2).
34
35
  .action(async (opts) => {
35
36
  const g = getGlobal();
36
- const client = buildClient(g);
37
37
  try {
38
+ // Inside the try: buildClient() throws on a missing key (#2686 item 4) and the throw must reach handleError(). See its doc comment in client.ts.
39
+ const client = buildClient(g);
38
40
  const lines = parseBulkLines(opts.file);
39
41
  // `urls` XOR `items` (validators/bulk.ts:136-155) — send exactly one. A file of only
40
42
  // url-only lines keeps the pre-#2596 `{ urls, webhook_url }` body, key order included,
@@ -60,8 +62,9 @@ export function makeBulkCommand(getGlobal) {
60
62
  .description('Get a bulk job status')
61
63
  .action(async (id) => {
62
64
  const g = getGlobal();
63
- const client = buildClient(g);
64
65
  try {
66
+ // Inside the try: buildClient() throws on a missing key (#2686 item 4) and the throw must reach handleError(). See its doc comment in client.ts.
67
+ const client = buildClient(g);
65
68
  const result = await client.bulk.get(id);
66
69
  printResult(result, g.json);
67
70
  }
@@ -74,8 +77,9 @@ export function makeBulkCommand(getGlobal) {
74
77
  .description('Cancel an in-flight bulk job')
75
78
  .action(async (id) => {
76
79
  const g = getGlobal();
77
- const client = buildClient(g);
78
80
  try {
81
+ // Inside the try: buildClient() throws on a missing key (#2686 item 4) and the throw must reach handleError(). See its doc comment in client.ts.
82
+ const client = buildClient(g);
79
83
  const result = await client.bulk.cancel(id);
80
84
  printResult(result, g.json);
81
85
  }
@@ -41,8 +41,9 @@ export function makeFetchCommand(getGlobal) {
41
41
  // FetchBulkItemOverrideSchema reject unknown keys), so this top-level behavior is unchanged.
42
42
  .action(async (opts) => {
43
43
  const g = getGlobal();
44
- const client = buildClient(g);
45
44
  try {
45
+ // Inside the try: buildClient() throws on a missing key (#2686 item 4) and the throw must reach handleError(). See its doc comment in client.ts.
46
+ const client = buildClient(g);
46
47
  // /v1/fetch/bulk has NO `items` property — its `urls` is a
47
48
  // `string | FetchBulkItemOverride` union (validators/fetch-bulk.ts:57), so both line
48
49
  // shapes ride there directly and no promotion step is needed (#2596 drift D3).
@@ -66,8 +67,9 @@ export function makeFetchCommand(getGlobal) {
66
67
  .description('Get a bulk fetch job')
67
68
  .action(async (id) => {
68
69
  const g = getGlobal();
69
- const client = buildClient(g);
70
70
  try {
71
+ // Inside the try: buildClient() throws on a missing key (#2686 item 4) and the throw must reach handleError(). See its doc comment in client.ts.
72
+ const client = buildClient(g);
71
73
  const result = await client.fetch.bulk.get(id);
72
74
  printResult(result, g.json);
73
75
  }
@@ -101,7 +103,6 @@ export function makeFetchCommand(getGlobal) {
101
103
  .option('-o, --output <file>', 'Write the fetched body bytes to the given file path')
102
104
  .action(async (opts) => {
103
105
  const g = getGlobal();
104
- const client = buildClient(g);
105
106
  // Omit-when-absent body, in the `body: Record<string, unknown>` form every other command
106
107
  // file uses (scrape.ts:230-268 is the exemplar). The five pre-existing fields keep the
107
108
  // exact same wire output they had as inline spreads; a boolean is written ONLY on
@@ -140,6 +141,8 @@ export function makeFetchCommand(getGlobal) {
140
141
  if (opts.custom !== undefined)
141
142
  body['custom'] = opts.custom;
142
143
  try {
144
+ // Inside the try: buildClient() throws on a missing key (#2686 item 4) and the throw must reach handleError(). See its doc comment in client.ts.
145
+ const client = buildClient(g);
143
146
  const result = await client.fetch.create(body);
144
147
  emitFetchResult(result, g.json, { file: opts.output });
145
148
  }
@@ -158,8 +161,9 @@ export function makeFetchCommand(getGlobal) {
158
161
  .option('-o, --output <file>', 'Write the fetched body bytes to the given file path')
159
162
  .action(async (opts) => {
160
163
  const g = getGlobal();
161
- const client = buildClient(g);
162
164
  try {
165
+ // Inside the try: buildClient() throws on a missing key (#2686 item 4) and the throw must reach handleError(). See its doc comment in client.ts.
166
+ const client = buildClient(g);
163
167
  const result = await client.fetch.get({
164
168
  url: opts.url,
165
169
  ...(opts.format !== undefined ? { format: opts.format } : {}),
@@ -24,7 +24,6 @@ export function makeMonitorsCommand(getGlobal) {
24
24
  .option('--cursor <cursor>', 'Pagination cursor from a previous page')
25
25
  .action(async (id, opts) => {
26
26
  const g = getGlobal();
27
- const client = buildClient(g);
28
27
  // Same omit-when-absent rule as `monitors list` (#2480): build the params object with
29
28
  // ONLY the flags the caller passed, and call captures.list with a single argument (never
30
29
  // captures.list(id, {}) / captures.list(id, undefined)) when no query flag is passed, so
@@ -35,6 +34,8 @@ export function makeMonitorsCommand(getGlobal) {
35
34
  if (opts.cursor !== undefined)
36
35
  params.cursor = opts.cursor;
37
36
  try {
37
+ // Inside the try: buildClient() throws on a missing key (#2686 item 4) and the throw must reach handleError(). See its doc comment in client.ts.
38
+ const client = buildClient(g);
38
39
  const result = Object.keys(params).length > 0
39
40
  ? await client.monitors.captures.list(id, params)
40
41
  : await client.monitors.captures.list(id);
@@ -49,6 +50,14 @@ export function makeMonitorsCommand(getGlobal) {
49
50
  .command('create')
50
51
  .description('Create a page-change monitor')
51
52
  .requiredOption('--url <url>', 'URL to monitor')
53
+ // `--interval-type` is passed through VERBATIM and validated by the API, not locally — a
54
+ // deliberate choice recorded for #2686 item 5's open question, matching what `search`'s
55
+ // `--type`/`--date-range` already do. A local enum list is a second source of truth that
56
+ // drifts from the spec silently: the CLI would start rejecting a value the API has begun
57
+ // accepting, and the error text would come from a stale copy rather than the server. The
58
+ // siblings that DO validate locally (`--type`, `--markdown-mode`, `--device`, …) are the
59
+ // enums this package chose to pin; nothing here should be read as those being wrong, only as
60
+ // the pass-through being intentional rather than an oversight.
52
61
  .requiredOption('--interval-type <type>', 'Check interval: hourly, daily, weekly, or cron')
53
62
  .requiredOption('--webhook-url <url>', 'HTTPS webhook URL notified on change')
54
63
  .option('--cron <expr>', 'Cron expression (required when --interval-type is cron, e.g. "0 * * * *")')
@@ -64,7 +73,6 @@ export function makeMonitorsCommand(getGlobal) {
64
73
  .option('--metadata <json>', `Arbitrary metadata echoed on the monitor envelope, as a JSON object, e.g. '{"run":"7"}' (omitted → none)`, (v) => parseJsonFlag('--metadata', v, 'object'))
65
74
  .action(async (opts) => {
66
75
  const g = getGlobal();
67
- const client = buildClient(g);
68
76
  // Omit-when-absent body (same rule as scrape.ts): each key is written ONLY when its flag is
69
77
  // present, so a bare create never materializes a server default (#2480 E1). Typed via an
70
78
  // assertion at the call site — the wire body is pinned by request-shapes.test.ts.
@@ -92,6 +100,8 @@ export function makeMonitorsCommand(getGlobal) {
92
100
  if (opts.metadata !== undefined)
93
101
  body['metadata'] = opts.metadata;
94
102
  try {
103
+ // Inside the try: buildClient() throws on a missing key (#2686 item 4) and the throw must reach handleError(). See its doc comment in client.ts.
104
+ const client = buildClient(g);
95
105
  const result = await client.monitors.create(body);
96
106
  printResult(result, g.json);
97
107
  }
@@ -110,7 +120,6 @@ export function makeMonitorsCommand(getGlobal) {
110
120
  // with no runtime change.
111
121
  .action(async (opts) => {
112
122
  const g = getGlobal();
113
- const client = buildClient(g);
114
123
  // Build the params object with the same omit-when-absent rule as the body builders. When NO
115
124
  // query flag is passed, call list() with ZERO arguments (never list({}) / list(undefined)) so
116
125
  // the inverse-case request-shape assertion `monitors.list` args length 0 stays green (#2480).
@@ -122,6 +131,8 @@ export function makeMonitorsCommand(getGlobal) {
122
131
  if (opts.status !== undefined)
123
132
  params.status = opts.status;
124
133
  try {
134
+ // Inside the try: buildClient() throws on a missing key (#2686 item 4) and the throw must reach handleError(). See its doc comment in client.ts.
135
+ const client = buildClient(g);
125
136
  const result = Object.keys(params).length > 0
126
137
  ? await client.monitors.list(params)
127
138
  : await client.monitors.list();
@@ -136,8 +147,9 @@ export function makeMonitorsCommand(getGlobal) {
136
147
  .description('Get a monitor by ID')
137
148
  .action(async (id) => {
138
149
  const g = getGlobal();
139
- const client = buildClient(g);
140
150
  try {
151
+ // Inside the try: buildClient() throws on a missing key (#2686 item 4) and the throw must reach handleError(). See its doc comment in client.ts.
152
+ const client = buildClient(g);
141
153
  const result = await client.monitors.get(id);
142
154
  printResult(result, g.json);
143
155
  }
@@ -164,7 +176,6 @@ export function makeMonitorsCommand(getGlobal) {
164
176
  .option('--metadata <json>', `New metadata as a JSON object, e.g. '{"run":"7"}' (omitted → unchanged)`, (v) => parseJsonFlag('--metadata', v, 'object'))
165
177
  .action(async (id, opts) => {
166
178
  const g = getGlobal();
167
- const client = buildClient(g);
168
179
  const body = {};
169
180
  // `url` IS settable on PATCH (openapi.yaml MonitorUpdateRequest) — unlike `type`, which
170
181
  // stays immutable and deliberately has no flag (#1984).
@@ -193,6 +204,8 @@ export function makeMonitorsCommand(getGlobal) {
193
204
  if (opts.metadata !== undefined)
194
205
  body['metadata'] = opts.metadata;
195
206
  try {
207
+ // Inside the try: buildClient() throws on a missing key (#2686 item 4) and the throw must reach handleError(). See its doc comment in client.ts.
208
+ const client = buildClient(g);
196
209
  const result = await client.monitors.update(id, body);
197
210
  printResult(result, g.json);
198
211
  }
@@ -205,8 +218,9 @@ export function makeMonitorsCommand(getGlobal) {
205
218
  .description('Delete a monitor')
206
219
  .action(async (id) => {
207
220
  const g = getGlobal();
208
- const client = buildClient(g);
209
221
  try {
222
+ // Inside the try: buildClient() throws on a missing key (#2686 item 4) and the throw must reach handleError(). See its doc comment in client.ts.
223
+ const client = buildClient(g);
210
224
  const result = await client.monitors.delete(id);
211
225
  printResult(result, g.json);
212
226
  }
@@ -55,8 +55,9 @@ export function makeScrapeCommand(getGlobal) {
55
55
  .option('--custom <json>', `User payload echoed on every scrape entry, as a JSON object, e.g. '{"job":"1"}' (omitted → none; a per-entry value in --file wins)`, (v) => parseJsonFlag('--custom', v, 'object'))
56
56
  .action(async (opts) => {
57
57
  const g = getGlobal();
58
- const client = buildClient(g);
59
58
  try {
59
+ // Inside the try: buildClient() throws on a missing key (#2686 item 4) and the throw must reach handleError(). See its doc comment in client.ts.
60
+ const client = buildClient(g);
60
61
  // readFileSync throws ENOENT on missing file — caught by handleError below
61
62
  const lines = parseBulkLines(opts.file);
62
63
  // `urls` XOR `items` (validators/scrape-bulk.ts:91-98) — send exactly one, keeping the
@@ -84,8 +85,9 @@ export function makeScrapeCommand(getGlobal) {
84
85
  .description('Get a bulk scrape job')
85
86
  .action(async (id) => {
86
87
  const g = getGlobal();
87
- const client = buildClient(g);
88
88
  try {
89
+ // Inside the try: buildClient() throws on a missing key (#2686 item 4) and the throw must reach handleError(). See its doc comment in client.ts.
90
+ const client = buildClient(g);
89
91
  const result = await client.scrape.bulk.get(id);
90
92
  printResult(result, g.json);
91
93
  }
@@ -101,6 +103,12 @@ export function makeScrapeCommand(getGlobal) {
101
103
  // Repeatable AND CSV-aware: `--format raw_html --format markdown` and `--format raw_html,markdown`
102
104
  // both send formats: ['raw_html','markdown']. Sent as the `formats` array; omitted → server
103
105
  // default. `--raw`/`-o` require EXACTLY ONE format (validated pre-request; see the action).
106
+ //
107
+ // The format VALUES travel to the server unvalidated — deliberate, and recorded here for
108
+ // #2686 item 5's open question (same rationale as `monitors --interval-type` and `search`'s
109
+ // `--type`/`--date-range`). The persisted-format vocabulary grows with the spec, and a local
110
+ // enum list is a second source of truth that drifts silently: the CLI would reject a format
111
+ // the API has begun serving, and the error would come from a stale copy rather than the API.
104
112
  .option('--format <format>', 'Output format; repeat or comma-separate for multiple (e.g. --format raw_html --format markdown, or --format raw_html,markdown). Sent as the formats array; omitted → server default.', collectFormats)
105
113
  .option('--country <cc>', 'Two-letter country code to route the request through (e.g. us, de)')
106
114
  .option('--timeout-ms <n>', 'Per-request timeout in milliseconds', (v) => parseIntFlag('--timeout-ms', v))
@@ -137,7 +145,6 @@ export function makeScrapeCommand(getGlobal) {
137
145
  .option('-o, --output <file>', 'Write the output to the given file path (implies --raw unless --json is set)')
138
146
  .action(async (opts) => {
139
147
  const g = getGlobal();
140
- const client = buildClient(g);
141
148
  const body = { url: opts.url };
142
149
  // The v1 scrape validator expects `formats` (array). The --format collector already
143
150
  // accumulates repeated/CSV occurrences into an array; write it verbatim, omitting the key
@@ -206,6 +213,8 @@ export function makeScrapeCommand(getGlobal) {
206
213
  if (opts.mobile === true)
207
214
  body['mobile'] = true;
208
215
  try {
216
+ // Inside the try: buildClient() throws on a missing key (#2686 item 4) and the throw must reach handleError(). See its doc comment in client.ts.
217
+ const client = buildClient(g);
209
218
  // FIRST statement inside the try — deliberately NOT hoisted next to buildClient() where
210
219
  // the other handlers put pre-request logic. index.ts drives commander with .parse(), not
211
220
  // .parseAsync(), so nothing awaits this action's promise: a throw raised above the try
@@ -267,8 +276,9 @@ export function makeScrapeCommand(getGlobal) {
267
276
  .option('--format <format>', 'Which persisted format to emit — only takes effect with --raw/-o; ignored otherwise')
268
277
  .action(async (id, opts) => {
269
278
  const g = getGlobal();
270
- const client = buildClient(g);
271
279
  try {
280
+ // Inside the try: buildClient() throws on a missing key (#2686 item 4) and the throw must reach handleError(). See its doc comment in client.ts.
281
+ const client = buildClient(g);
272
282
  // Mirror `scrape create`'s output path: resolveOutputMode FIRST inside the try (so a
273
283
  // --json/--raw conflict rejects before the request), then emitResult. The request body is
274
284
  // unchanged — scrape.get(id) takes no options — so the inverse-case arg-length assertion in
@@ -37,16 +37,108 @@ export function parseClip(value) {
37
37
  * "queued" without parsing stderr.
38
38
  *
39
39
  * BREAKING (#2596): `screenshots create --async` previously exited 0. See the README exit-code
40
- * table and the CHANGELOG `[0.6.0]` note (this version's own section — `[Unreleased]` is kept
41
- * deliberately empty per `packaging.test.ts:203`; see #2620).
40
+ * table and the CHANGELOG `[0.6.0]` note (`[Unreleased]` is kept deliberately empty per
41
+ * `packaging.test.ts:203`; see #2620).
42
+ *
43
+ * Also carried by a NOT-READY `screenshots get -o` poll (#2686 item 1) — same meaning, same value:
44
+ * accepted and billed, artifact not materialized yet.
42
45
  */
43
46
  const QUEUED_EXIT_CODE = 2;
47
+ /**
48
+ * The ONLY statuses a screenshot can still reach `completed` from, and therefore the only ones
49
+ * worth telling a caller to poll (#2792). Every other value of `status` — the terminal ones, and
50
+ * anything the server may add later — is a state polling cannot improve.
51
+ */
52
+ const POLLABLE_STATUSES = ['pending', 'processing'];
53
+ /**
54
+ * A still-pollable screenshot poll that can hand its `ss_` handle back, or undefined.
55
+ *
56
+ * "Not ready" is `status === 'pending' || status === 'processing'` and nothing else, plus a usable
57
+ * `ss_` handle and no materialized `image_url` (#2792). Before that gate existed the predicate
58
+ * decided from `error`/`image_url`/`id` alone and read `status` only to LABEL the message, so a
59
+ * `completed` envelope with a null `image_url`, a status outside the enum, and the FLAT
60
+ * `error_code`/`error_message` failed shape that `serializeScreenshot` emits all received "poll
61
+ * again" advice for a job that would never complete — the exact loop the exclusion exists to
62
+ * prevent.
63
+ *
64
+ * Two shapes are excluded deliberately rather than incidentally:
65
+ *
66
+ * - **No `status` at all (#2792 E1).** Not treated as pending. `serializeScreenshot`
67
+ * (`gateway/src/routes/screenshots.ts:365`) always writes `status`, so a statusless envelope is
68
+ * not something the server produces; and an envelope carrying no evidence that work is in
69
+ * progress is exactly what must not be advertised as pollable. It takes the error path, which
70
+ * names the state instead of promising an artifact.
71
+ * - **A status outside the enum (#2792 E3).** Same reasoning, stated as the closed-set gate: a new
72
+ * server status is unknown to this build, so it is reported, never polled and never fallen
73
+ * through into the download path.
74
+ *
75
+ * And one shape is deliberately NOT excluded: a pollable status carrying a flat `error_code`. That
76
+ * is the ordinary RETRY envelope, not a verdict — see {@link flatScreenshotFailure}. Excluding it
77
+ * would abandon a job the server is actively retrying (#2792 B1).
78
+ *
79
+ * An envelope with no string `id` is excluded too — there is no handle to emit, so the ordinary
80
+ * error path (which at least names the status) is the honest outcome.
81
+ */
82
+ function notReadyScreenshot(result) {
83
+ if (typeof result !== 'object' || result === null)
84
+ return undefined;
85
+ const obj = result;
86
+ // The gate, not merely the label: `status` decides, and the closed list is the whole decision.
87
+ const status = POLLABLE_STATUSES.find((s) => s === obj.status);
88
+ if (status === undefined)
89
+ return undefined;
90
+ // The NESTED error envelope stays disqualifying, exactly as before this ticket. A flat
91
+ // `error_code` is NOT consulted here: on a pollable status it is a retry error (#2792 B1), and
92
+ // the nested shape can never co-occur with a pollable status to disagree — it is built only by
93
+ // `buildFailedScreenshotEnvelope`, under `status === 'failed'` (`routes/screenshots.ts:267`).
94
+ if (obj.error !== undefined && obj.error !== null)
95
+ return undefined;
96
+ if (typeof obj.image_url === 'string' && obj.image_url.length > 0)
97
+ return undefined;
98
+ if (typeof obj.id !== 'string' || obj.id.length === 0)
99
+ return undefined;
100
+ return { id: obj.id, status };
101
+ }
102
+ /**
103
+ * The failure carried by the FLAT `error_code`/`error_message` fields, or undefined (#2792).
104
+ *
105
+ * `serializeScreenshot` (gateway `routes/screenshots.ts:365`) — and therefore the spec's own
106
+ * `ScreenshotResponse` — reports a failure as top-level `error_code`/`error_message`, while
107
+ * `buildFailedScreenshotEnvelope` (`:295`, #2027) reports it NESTED as `{error:{code,message}}`.
108
+ * Only the nested one was ever read, so a flat failure surfaced neither code nor message.
109
+ *
110
+ * These two are NOT interchangeable, which is why this helper reads only one of them:
111
+ *
112
+ * - The nested envelope is built only under `status === 'failed'` (`routes/screenshots.ts:267`), so
113
+ * it is always terminal.
114
+ * - The flat columns are ALSO written by the non-terminal retry path — `job-queue.ts:346-356` sets
115
+ * `error_code`/`error_message` and `status = 'pending'` in one UPDATE, and `claim()` (`:276-284`)
116
+ * carries them into the next `processing` attempt. On a pollable status they describe the attempt
117
+ * that just failed, not the job's outcome (#2792 B1).
118
+ *
119
+ * So the caller consults this only once the status is no longer pollable.
120
+ */
121
+ function flatScreenshotFailure(obj) {
122
+ const detail = {};
123
+ if (typeof obj.error_code === 'string' && obj.error_code.length > 0)
124
+ detail.code = obj.error_code;
125
+ if (typeof obj.error_message === 'string' && obj.error_message.length > 0)
126
+ detail.message = obj.error_message;
127
+ return detail.code === undefined && detail.message === undefined ? undefined : detail;
128
+ }
44
129
  /**
45
130
  * Extract a non-empty string `image_url` from a screenshot envelope, or throw an actionable error.
46
131
  *
47
- * A pending/processing envelope has no `image_url` yet; a terminal-`failed` screenshot returns the
48
- * unified `Error` envelope on a 200 (#2027). Either way the artifact was never materialized, so we
49
- * throw rather than fetching `undefined` (#2480 E5).
132
+ * A pending/processing envelope has no `image_url` yet; a terminal-`failed` screenshot reports its
133
+ * failure either nested or flat. Either way the artifact was never materialized, so we throw rather
134
+ * than fetching `undefined` (#2480 E5).
135
+ *
136
+ * The NESTED shape's message is untouched by #2792 — the ticket's Specification pins it as an
137
+ * inverse case ("the nested-error failed shape keeps its current exit-1 message"), and it is now
138
+ * pinned by an exact-equality test rather than the substring assertions that would not have
139
+ * noticed. The FLAT shape, which previously reported nothing at all, names both of its fields as
140
+ * `<message> (<code>)` — `extractRawBody`'s format for a failed scrape, so the two poll commands
141
+ * read alike — degrading to whichever field exists.
50
142
  */
51
143
  function extractImageUrl(result) {
52
144
  if (typeof result === 'object' && result !== null) {
@@ -58,8 +150,28 @@ function extractImageUrl(result) {
58
150
  return obj.image_url;
59
151
  }
60
152
  const status = typeof obj.status === 'string' ? obj.status : 'unknown';
61
- throw new Error(`not ready yet: screenshot is ${status}`);
153
+ // "not ready yet" is reserved for a genuinely pollable state (#2792). It is still reachable
154
+ // here for a pending/processing envelope carrying no `ss_` id — `notReadyScreenshot` rejects
155
+ // those for want of a handle, and this message stays their wording. Reached BEFORE the flat
156
+ // failure below, because on a pollable status those columns describe the attempt that just
157
+ // failed and is being retried, not the job's outcome (#2792 B1).
158
+ if (POLLABLE_STATUSES.includes(status)) {
159
+ throw new Error(`not ready yet: screenshot is ${status}`);
160
+ }
161
+ const flat = flatScreenshotFailure(obj);
162
+ if (flat !== undefined) {
163
+ const reason = flat.message !== undefined && flat.code !== undefined
164
+ ? `${flat.message} (${flat.code})`
165
+ : (flat.message ?? flat.code ?? 'unknown error');
166
+ throw new Error(`screenshot failed: ${reason}`);
167
+ }
168
+ // Every other artifact-less state says so rather than implying the artifact soon will exist: a
169
+ // `completed` row with a null `image_url`, and any status this build does not know.
170
+ throw new Error(`no image artifact available: screenshot is ${status}`);
62
171
  }
172
+ // The non-object arm keeps its original wording, pinned by #2480 cr-5
173
+ // (`screenshot-download.test.ts:287`): this is not a screenshot envelope at all, so neither the
174
+ // status gate above nor its vocabulary applies.
63
175
  throw new Error('not ready yet: screenshot is unknown');
64
176
  }
65
177
  /**
@@ -89,6 +201,25 @@ async function emitScreenshot(result, opts) {
89
201
  return;
90
202
  }
91
203
  if (opts.output !== undefined) {
204
+ // A not-ready poll has no artifact to download — but the job WAS accepted and billed, so
205
+ // losing the handle behind a generic exit 1 is the defect (#2686 item 1). Mirror
206
+ // `scrape get --raw` on a queued envelope EXACTLY: the bare id on STDOUT (so
207
+ // `ss=$(bytekit screenshots get … -o -)` still captures a usable handle), a human note on
208
+ // STDERR, and QUEUED_EXIT_CODE. The `-o` file is deliberately never written — there is no
209
+ // image yet, and a file holding a bare id would masquerade as an artifact.
210
+ //
211
+ // Soft `process.exitCode`, never `process.exit`: a hard exit runs before Node drains an async
212
+ // piped stdout and would discard the very id this branch exists to deliver (#2544). Execution
213
+ // therefore continues past the assignment, so this branch must `return` explicitly — falling
214
+ // through to `extractImageUrl` would throw `not ready yet` and clobber the signal with exit 1.
215
+ const notReady = notReadyScreenshot(result);
216
+ if (notReady !== undefined) {
217
+ console.error(`Note: screenshot ${notReady.id} is ${notReady.status} — no image artifact is available ` +
218
+ `yet. Poll it with: bytekit screenshots get ${notReady.id}`);
219
+ process.stdout.write(`${notReady.id}\n`);
220
+ process.exitCode = QUEUED_EXIT_CODE;
221
+ return;
222
+ }
92
223
  await downloadArtifact(extractImageUrl(result), opts.output);
93
224
  return;
94
225
  }
@@ -129,7 +260,6 @@ export function makeScreenshotsCommand(getGlobal) {
129
260
  .option('-o, --output <file>', 'Download the captured image to this file path (use - for stdout). Not valid with --json.')
130
261
  .action(async (opts) => {
131
262
  const g = getGlobal();
132
- const client = buildClient(g);
133
263
  const body = { url: opts.url };
134
264
  // Each optional flag maps 1:1 to its snake_case ScreenshotRequest field, written ONLY when
135
265
  // present — a boolean never serializes `false` (#2480 E1).
@@ -180,6 +310,8 @@ export function makeScreenshotsCommand(getGlobal) {
180
310
  if (opts.metadata !== undefined)
181
311
  body['metadata'] = opts.metadata;
182
312
  try {
313
+ // Inside the try: buildClient() throws on a missing key (#2686 item 4) and the throw must reach handleError(). See its doc comment in client.ts.
314
+ const client = buildClient(g);
183
315
  // Reject --json + -o BEFORE issuing the request: -o downloads BINARY artifact bytes, so
184
316
  // it can never coexist with --json (which must stay a text envelope). Thrown here so no
185
317
  // request — and therefore no download fetch — is ever issued (#2480 E2).
@@ -208,8 +340,9 @@ export function makeScreenshotsCommand(getGlobal) {
208
340
  .option('-o, --output <file>', 'Download the captured image to this file path (use - for stdout). Not valid with --json.')
209
341
  .action(async (id, opts) => {
210
342
  const g = getGlobal();
211
- const client = buildClient(g);
212
343
  try {
344
+ // Inside the try: buildClient() throws on a missing key (#2686 item 4) and the throw must reach handleError(). See its doc comment in client.ts.
345
+ const client = buildClient(g);
213
346
  if (g.json && opts.output !== undefined) {
214
347
  throw new Error('-o cannot be used with --json');
215
348
  }
@@ -49,7 +49,6 @@ export function makeSearchCommand(getGlobal) {
49
49
  .option('--date-range <window>', 'Restrict results to a recency window: any (default), hour, day, week, month, or year')
50
50
  .action(async (query, opts) => {
51
51
  const g = getGlobal();
52
- const client = buildClient(g);
53
52
  // Build SearchOpts from the positional arg plus any provided flags.
54
53
  // Omit absent optionals so we never send undefined/NaN over the wire.
55
54
  // type/date_range are passed through verbatim; the API validates the
@@ -66,6 +65,8 @@ export function makeSearchCommand(getGlobal) {
66
65
  if (opts.dateRange !== undefined)
67
66
  body.date_range = opts.dateRange;
68
67
  try {
68
+ // Inside the try: buildClient() throws on a missing key (#2686 item 4) and the throw must reach handleError(). See its doc comment in client.ts.
69
+ const client = buildClient(g);
69
70
  const result = await client.search.query(body);
70
71
  if (g.json) {
71
72
  printResult(result, true);
@@ -24,7 +24,6 @@ export function makeSitemapCommand(getGlobal) {
24
24
  .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'))
25
25
  .action(async (opts) => {
26
26
  const g = getGlobal();
27
- const client = buildClient(g);
28
27
  // Omit-when-absent body; the remaining SitemapRequest fields keep their server defaults.
29
28
  // The generated request type marks defaulted fields required, so the call needs an explicit
30
29
  // assertion — type-level only; the wire body is pinned by request-shapes.test.ts.
@@ -50,6 +49,8 @@ export function makeSitemapCommand(getGlobal) {
50
49
  if (opts.metadata !== undefined)
51
50
  body['metadata'] = opts.metadata;
52
51
  try {
52
+ // Inside the try: buildClient() throws on a missing key (#2686 item 4) and the throw must reach handleError(). See its doc comment in client.ts.
53
+ const client = buildClient(g);
53
54
  const result = await client.sitemap.create(body);
54
55
  printResult(result, g.json);
55
56
  }
@@ -62,8 +63,9 @@ export function makeSitemapCommand(getGlobal) {
62
63
  .description('Get a sitemap job by ID')
63
64
  .action(async (id) => {
64
65
  const g = getGlobal();
65
- const client = buildClient(g);
66
66
  try {
67
+ // Inside the try: buildClient() throws on a missing key (#2686 item 4) and the throw must reach handleError(). See its doc comment in client.ts.
68
+ const client = buildClient(g);
67
69
  const result = await client.sitemap.get(id);
68
70
  printResult(result, g.json);
69
71
  }
@@ -16,8 +16,9 @@ export function makeUsageCommand(getGlobal) {
16
16
  .description('Get current billing-period usage summary')
17
17
  .action(async () => {
18
18
  const g = getGlobal();
19
- const client = buildClient(g);
20
19
  try {
20
+ // Inside the try: buildClient() throws on a missing key (#2686 item 4) and the throw must reach handleError(). See its doc comment in client.ts.
21
+ const client = buildClient(g);
21
22
  const result = await client.usage.get();
22
23
  printResult(result, g.json);
23
24
  }
@@ -33,7 +34,6 @@ export function makeUsageCommand(getGlobal) {
33
34
  .option('--api-key <ak_id>', 'Filter by API key prefixed ID (omitted → all keys)')
34
35
  .action(async (opts) => {
35
36
  const g = getGlobal();
36
- const client = buildClient(g);
37
37
  const params = {};
38
38
  if (opts.from !== undefined)
39
39
  params.from = opts.from;
@@ -42,6 +42,8 @@ export function makeUsageCommand(getGlobal) {
42
42
  if (opts.apiKey !== undefined)
43
43
  params.api_key = opts.apiKey;
44
44
  try {
45
+ // Inside the try: buildClient() throws on a missing key (#2686 item 4) and the throw must reach handleError(). See its doc comment in client.ts.
46
+ const client = buildClient(g);
45
47
  const result = Object.keys(params).length > 0
46
48
  ? await client.usage.daily(params)
47
49
  : await client.usage.daily();
@@ -59,7 +61,6 @@ export function makeUsageCommand(getGlobal) {
59
61
  .option('--api-key <ak_id>', 'Filter by API key prefixed ID (omitted → all keys)')
60
62
  .action(async (opts) => {
61
63
  const g = getGlobal();
62
- const client = buildClient(g);
63
64
  const params = {};
64
65
  if (opts.from !== undefined)
65
66
  params.from = opts.from;
@@ -68,6 +69,8 @@ export function makeUsageCommand(getGlobal) {
68
69
  if (opts.apiKey !== undefined)
69
70
  params.api_key = opts.apiKey;
70
71
  try {
72
+ // Inside the try: buildClient() throws on a missing key (#2686 item 4) and the throw must reach handleError(). See its doc comment in client.ts.
73
+ const client = buildClient(g);
71
74
  const result = Object.keys(params).length > 0
72
75
  ? await client.usage.byEndpoint(params)
73
76
  : await client.usage.byEndpoint();
@@ -21,7 +21,6 @@ export function makeWebhookDeliveriesCommand(getGlobal) {
21
21
  .option('--since <iso>', 'Only deliveries created after this ISO date-time')
22
22
  .action(async (opts) => {
23
23
  const g = getGlobal();
24
- const client = buildClient(g);
25
24
  const params = {};
26
25
  if (opts.limit !== undefined)
27
26
  params.limit = opts.limit;
@@ -32,6 +31,8 @@ export function makeWebhookDeliveriesCommand(getGlobal) {
32
31
  if (opts.since !== undefined)
33
32
  params.since = opts.since;
34
33
  try {
34
+ // Inside the try: buildClient() throws on a missing key (#2686 item 4) and the throw must reach handleError(). See its doc comment in client.ts.
35
+ const client = buildClient(g);
35
36
  const result = Object.keys(params).length > 0
36
37
  ? await client.webhooks.list(params)
37
38
  : await client.webhooks.list();
@@ -46,8 +47,9 @@ export function makeWebhookDeliveriesCommand(getGlobal) {
46
47
  .description('Requeue a failed or exhausted webhook delivery')
47
48
  .action(async (id) => {
48
49
  const g = getGlobal();
49
- const client = buildClient(g);
50
50
  try {
51
+ // Inside the try: buildClient() throws on a missing key (#2686 item 4) and the throw must reach handleError(). See its doc comment in client.ts.
52
+ const client = buildClient(g);
51
53
  const result = await client.webhooks.retry(id);
52
54
  printResult(result, g.json);
53
55
  }
@@ -82,8 +82,9 @@ export async function downloadArtifact(url, dest, timeoutMs = DEFAULT_TIMEOUT_MS
82
82
  // (mirroring client.ts's corrected 429-drain mechanism, cr-3), reasoning that a full
83
83
  // `text()` read lets undici return the connection to the pool. That reasoning does not
84
84
  // apply on THIS path: `commands/screenshots.ts` throws this error straight into
85
- // `handleError`, which `process.exit(1)`s immediately after — there is no next request in
86
- // this process for a reclaimed socket to serve. A drain here was bounded only by the FULL
85
+ // `handleError`, which is the last statement of the action's catch — the command then ends
86
+ // (signalling exit 1 via `process.exitCode` since #2686 item 4), so there is no next request
87
+ // in this process for a reclaimed socket to serve. A drain here was bounded only by the FULL
87
88
  // per-attempt abort budget (or, under `timeoutMs <= 0`, by nothing at all) for zero benefit,
88
89
  // so it is removed outright rather than merely capped.
89
90
  throw new Error(`artifact download failed: HTTP ${res.status}`);
@@ -69,7 +69,9 @@ export function parseJsonFlag(flag, value, expect) {
69
69
  parsed = JSON.parse(value);
70
70
  }
71
71
  catch {
72
- throw new InvalidArgumentError(`${flag} must be valid JSON (a ${expect}), e.g. '${example}'.`);
72
+ // "an", not "a": both shape words (`object`, `array`) begin with a vowel, so a hardcoded
73
+ // article read `(a object)` / `(a array)` (#2686 item 5).
74
+ throw new InvalidArgumentError(`${flag} must be valid JSON (an ${expect}), e.g. '${example}'.`);
73
75
  }
74
76
  const matches = expect === 'array'
75
77
  ? Array.isArray(parsed)
package/dist/output.d.ts CHANGED
@@ -72,6 +72,20 @@ export declare function resolveOutputMode(flags: OutputFlags): OutputMode;
72
72
  *
73
73
  * Errors — rather than guessing or stringifying — whenever the request is ambiguous or the body is
74
74
  * not text, so `--raw` can never print `[object Object]` or a format the caller did not ask for.
75
+ *
76
+ * Three states have no body but are NOT "the response contains no formats" (#2686 item 2), and each
77
+ * gets its own diagnostic ahead of the generic ones, because the caller's next action differs:
78
+ *
79
+ * - a terminal-FAILED scrape (`ScrapeErrorEnvelope`) → the envelope's own `error.code` + message.
80
+ * This is the scrape twin of the screenshots extractor's error-envelope branch (#2027). It is
81
+ * checked FIRST so a failure envelope that also carries `formats: {}` still reports why it failed
82
+ * rather than a format-selection problem the caller cannot act on.
83
+ * - a completed scrape with an EMPTY `formats: {}` bag and a warning explaining it (the #2675
84
+ * `artifact_unavailable` shape) → the warning's code and message.
85
+ * - an empty bag with no warning → says the bag is empty. It is not "no formats" (the key IS
86
+ * there), and `--format` cannot help, so the multi-format diagnostic would be a false lead.
87
+ *
88
+ * A queued envelope never reaches here — {@link emitResult} diverts it to {@link emitQueuedRaw}.
75
89
  */
76
90
  export declare function extractRawBody(response: unknown, requestedFormat?: string): string;
77
91
  /**
@@ -133,6 +147,10 @@ export declare function emitResult(result: unknown, mode: OutputMode, target: Ou
133
147
  /**
134
148
  * Collect the `X-Fetch-*` response headers of a {@link FetchResult} into a Title-Cased object —
135
149
  * the documented `--json` payload for a fetch command.
150
+ *
151
+ * Key names are the SPEC names: {@link FETCH_HEADER_CASING} first, {@link titleCaseHeader}
152
+ * otherwise. `fetch-metadata-keys.test.ts` pins the emitted set against the spec's own exhaustive
153
+ * header list. Values are passed through verbatim, and non-`X-Fetch-*` headers stay filtered out.
136
154
  */
137
155
  export declare function collectFetchMetadata(headers: Headers): Record<string, string>;
138
156
  /**
@@ -158,7 +176,20 @@ export declare function emitFetchResult(result: unknown, jsonMode: boolean, targ
158
176
  */
159
177
  export declare function printResult(data: unknown, jsonMode: boolean): void;
160
178
  /**
161
- * Handle a CLI command error: print to stderr and exit 1.
179
+ * Handle a CLI command error: print to stderr and signal exit 1.
180
+ *
181
+ * The signal is a SOFT `process.exitCode`, never a hard `process.exit` (#2686 item 4, restoring the
182
+ * #2544 policy this site contradicted). Node documents `process.stderr` as ASYNCHRONOUS when it
183
+ * points at a pipe: a write that cannot be handed to the kernel in one go is queued in userspace
184
+ * and drained on a later event-loop turn, and `process.exit()` runs before any of those turns and
185
+ * DISCARDS the queue. The one line a script greps was therefore the line most at risk of being
186
+ * thrown away. Setting `process.exitCode` lets the process end naturally once stderr has drained,
187
+ * with the same observable exit code.
188
+ *
189
+ * Returns `void` (not `never`): the hard exit used to provide an implicit stop, so every caller
190
+ * must be the LAST statement of its `catch` block, or `return` immediately after (#2686 E4). Every
191
+ * call site in this package is the final statement of an action's `catch`, so the action returns
192
+ * and nothing further runs.
162
193
  *
163
194
  * The message always begins `Error: ` (a prefix scripts grep on) followed by the error message and
164
195
  * an optional parenthesised suffix built from the two diagnostic axes the ByteKit errors carry:
@@ -174,5 +205,5 @@ export declare function printResult(data: unknown, jsonMode: boolean): void;
174
205
  * status is present but no code (a hand-built error), the suffix is `(status: <n>)` with no empty
175
206
  * placeholder — never `(, status: <n>)`.
176
207
  */
177
- export declare function handleError(err: unknown): never;
208
+ export declare function handleError(err: unknown): void;
178
209
  export {};
package/dist/output.js CHANGED
@@ -58,13 +58,77 @@ export function resolveOutputMode(flags) {
58
58
  return { kind: 'raw' };
59
59
  return { kind: 'human' };
60
60
  }
61
+ /**
62
+ * The `error` of a spec `ScrapeErrorEnvelope` (`{status:'failed', id, error:{code, message, …}}`),
63
+ * or undefined when the response is not a failure envelope.
64
+ *
65
+ * Keyed on the presence of a well-formed `error` object rather than on `status === 'failed'`: the
66
+ * envelope is what a 200 GET returns for a terminally-failed job, and a producer that ships the
67
+ * error without the discriminator still owes the caller its reason.
68
+ */
69
+ function scrapeErrorDetail(response) {
70
+ if (typeof response !== 'object' || response === null)
71
+ return undefined;
72
+ const err = response.error;
73
+ if (typeof err !== 'object' || err === null || Array.isArray(err))
74
+ return undefined;
75
+ const { code, message } = err;
76
+ const detail = {};
77
+ if (typeof code === 'string' && code.length > 0)
78
+ detail.code = code;
79
+ if (typeof message === 'string' && message.length > 0)
80
+ detail.message = message;
81
+ return detail;
82
+ }
83
+ /**
84
+ * The warning that best explains an empty `formats` bag — `artifact_unavailable` when present
85
+ * (the #2675 shape: a completed scrape whose persisted artifact could not be rehydrated),
86
+ * otherwise the first warning the envelope carries. Undefined when there are none.
87
+ */
88
+ function emptyFormatsWarning(response) {
89
+ if (typeof response !== 'object' || response === null)
90
+ return undefined;
91
+ const raw = response.warnings;
92
+ if (!Array.isArray(raw))
93
+ return undefined;
94
+ const parsed = raw.flatMap((w) => {
95
+ if (typeof w !== 'object' || w === null)
96
+ return [];
97
+ const { code, message } = w;
98
+ if (typeof code !== 'string' || code.length === 0)
99
+ return [];
100
+ return typeof message === 'string' && message.length > 0 ? [{ code, message }] : [{ code }];
101
+ });
102
+ return parsed.find((w) => w.code === 'artifact_unavailable') ?? parsed[0];
103
+ }
61
104
  /**
62
105
  * Narrow an SDK response (typed `unknown` today) down to the single format body to emit raw.
63
106
  *
64
107
  * Errors — rather than guessing or stringifying — whenever the request is ambiguous or the body is
65
108
  * not text, so `--raw` can never print `[object Object]` or a format the caller did not ask for.
109
+ *
110
+ * Three states have no body but are NOT "the response contains no formats" (#2686 item 2), and each
111
+ * gets its own diagnostic ahead of the generic ones, because the caller's next action differs:
112
+ *
113
+ * - a terminal-FAILED scrape (`ScrapeErrorEnvelope`) → the envelope's own `error.code` + message.
114
+ * This is the scrape twin of the screenshots extractor's error-envelope branch (#2027). It is
115
+ * checked FIRST so a failure envelope that also carries `formats: {}` still reports why it failed
116
+ * rather than a format-selection problem the caller cannot act on.
117
+ * - a completed scrape with an EMPTY `formats: {}` bag and a warning explaining it (the #2675
118
+ * `artifact_unavailable` shape) → the warning's code and message.
119
+ * - an empty bag with no warning → says the bag is empty. It is not "no formats" (the key IS
120
+ * there), and `--format` cannot help, so the multi-format diagnostic would be a false lead.
121
+ *
122
+ * A queued envelope never reaches here — {@link emitResult} diverts it to {@link emitQueuedRaw}.
66
123
  */
67
124
  export function extractRawBody(response, requestedFormat) {
125
+ const failure = scrapeErrorDetail(response);
126
+ if (failure !== undefined) {
127
+ const reason = failure.message !== undefined && failure.code !== undefined
128
+ ? `${failure.message} (${failure.code})`
129
+ : (failure.message ?? failure.code ?? 'unknown error');
130
+ throw new Error(`--raw: scrape failed: ${reason}`);
131
+ }
68
132
  const formats = typeof response === 'object' && response !== null
69
133
  ? response.formats
70
134
  : undefined;
@@ -73,6 +137,13 @@ export function extractRawBody(response, requestedFormat) {
73
137
  }
74
138
  const bag = formats;
75
139
  const keys = Object.keys(bag);
140
+ if (keys.length === 0) {
141
+ const warning = emptyFormatsWarning(response);
142
+ throw new Error(warning === undefined
143
+ ? '--raw: the response carries an empty formats map — no body to emit'
144
+ : `--raw: the response carries an empty formats map — ${warning.code}` +
145
+ (warning.message !== undefined ? `: ${warning.message}` : ''));
146
+ }
76
147
  let key;
77
148
  if (requestedFormat !== undefined) {
78
149
  key = requestedFormat;
@@ -197,15 +268,39 @@ function titleCaseHeader(name) {
197
268
  .map((seg) => seg.charAt(0).toUpperCase() + seg.slice(1).toLowerCase())
198
269
  .join('-');
199
270
  }
271
+ /**
272
+ * Spec header names that segment-wise Title-Casing cannot produce (#2686 item 3).
273
+ *
274
+ * `titleCaseHeader` lowercases every non-initial character of a segment, so `x-fetch-id` came out
275
+ * `X-Fetch-Id` and `x-fetch-url` came out `X-Fetch-Url` — while `docs/api/openapi.yaml` declares
276
+ * `X-Fetch-ID` and `X-Fetch-URL` (the gateway's CORS expose list, `lib/cors-config.ts`, mirrors the
277
+ * same spellings). A `jq '."X-Fetch-ID"'` filter written from the API reference therefore got
278
+ * `null` off `bytekit fetch … --json`.
279
+ *
280
+ * The exception lives HERE, in a lookup keyed by the lowercase wire name, and deliberately NOT
281
+ * inside `titleCaseHeader` (#2686 E3). That helper is a generic contract — an acronym rule baked
282
+ * into it would silently re-case names for any future caller, and would have to guess at segments
283
+ * like `Waf` (which the spec really does spell `X-Fetch-Waf-Vendor`, not `WAF`). Every header
284
+ * outside this map keeps the generic Title-Cased spelling it has always had.
285
+ */
286
+ const FETCH_HEADER_CASING = {
287
+ 'x-fetch-id': 'X-Fetch-ID',
288
+ 'x-fetch-url': 'X-Fetch-URL',
289
+ };
200
290
  /**
201
291
  * Collect the `X-Fetch-*` response headers of a {@link FetchResult} into a Title-Cased object —
202
292
  * the documented `--json` payload for a fetch command.
293
+ *
294
+ * Key names are the SPEC names: {@link FETCH_HEADER_CASING} first, {@link titleCaseHeader}
295
+ * otherwise. `fetch-metadata-keys.test.ts` pins the emitted set against the spec's own exhaustive
296
+ * header list. Values are passed through verbatim, and non-`X-Fetch-*` headers stay filtered out.
203
297
  */
204
298
  export function collectFetchMetadata(headers) {
205
299
  const meta = {};
206
300
  headers.forEach((value, key) => {
207
- if (key.toLowerCase().startsWith('x-fetch-')) {
208
- meta[titleCaseHeader(key)] = value;
301
+ const lower = key.toLowerCase();
302
+ if (lower.startsWith('x-fetch-')) {
303
+ meta[FETCH_HEADER_CASING[lower] ?? titleCaseHeader(key)] = value;
209
304
  }
210
305
  });
211
306
  return meta;
@@ -304,7 +399,20 @@ function errorCode(err) {
304
399
  return typeof raw === 'string' && raw.length > 0 ? raw : undefined;
305
400
  }
306
401
  /**
307
- * Handle a CLI command error: print to stderr and exit 1.
402
+ * Handle a CLI command error: print to stderr and signal exit 1.
403
+ *
404
+ * The signal is a SOFT `process.exitCode`, never a hard `process.exit` (#2686 item 4, restoring the
405
+ * #2544 policy this site contradicted). Node documents `process.stderr` as ASYNCHRONOUS when it
406
+ * points at a pipe: a write that cannot be handed to the kernel in one go is queued in userspace
407
+ * and drained on a later event-loop turn, and `process.exit()` runs before any of those turns and
408
+ * DISCARDS the queue. The one line a script greps was therefore the line most at risk of being
409
+ * thrown away. Setting `process.exitCode` lets the process end naturally once stderr has drained,
410
+ * with the same observable exit code.
411
+ *
412
+ * Returns `void` (not `never`): the hard exit used to provide an implicit stop, so every caller
413
+ * must be the LAST statement of its `catch` block, or `return` immediately after (#2686 E4). Every
414
+ * call site in this package is the final statement of an action's `catch`, so the action returns
415
+ * and nothing further runs.
308
416
  *
309
417
  * The message always begins `Error: ` (a prefix scripts grep on) followed by the error message and
310
418
  * an optional parenthesised suffix built from the two diagnostic axes the ByteKit errors carry:
@@ -337,5 +445,5 @@ export function handleError(err) {
337
445
  else {
338
446
  console.error(`Error: ${String(err)}`);
339
447
  }
340
- process.exit(1);
448
+ process.exitCode = 1;
341
449
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hunt-labs/bytekit-cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.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.5.0"
44
+ "@hunt-labs/bytekit-sdk": "^0.6.1"
45
45
  },
46
46
  "scripts": {
47
47
  "build": "rm -rf dist tsconfig.tsbuildinfo && tsc",