@hunt-labs/bytekit-cli 0.5.0 → 0.6.0

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/dist/client.js CHANGED
@@ -18,6 +18,24 @@ export const MIN_RETRY_AFTER_MS = 1_000;
18
18
  export const MAX_RETRY_AFTER_MS = 60_000;
19
19
  /** Exactly one retry. A second 429 is terminal — no loop. */
20
20
  export const MAX_RETRIES = 1;
21
+ /**
22
+ * Cap on a best-effort, discard-only body drain (#2587 cr2-1). Independent of the per-attempt
23
+ * abort budget: the 429 drain below (`response.text().catch(() => {})`) exists ONLY so undici can
24
+ * return the socket to the connection pool — nothing downstream reads the drained bytes — but
25
+ * binding that read to the FULL per-attempt budget (120 s default, or, under `timeoutMs <= 0`,
26
+ * to no bound at all) let a stalled 429 body burn the whole budget, or hang forever, before a
27
+ * retry that needs nothing from that body. Short enough that a legitimately stalled 429 body costs
28
+ * the retry very little wall-clock time.
29
+ *
30
+ * #2587 cr3-2: past this cap, the read is not merely abandoned — {@link drainBestEffort} actively
31
+ * ABORTS its underlying request. Resolving the race without aborting anything (the earlier
32
+ * cr2-1 shape) let the retry proceed on schedule while the abandoned `response.text()` kept
33
+ * reading against a live socket forever, which — because the CLI exits via a soft
34
+ * `process.exitCode` (#2544) rather than `process.exit()` — kept the whole process alive
35
+ * indefinitely. `.catch`-guarding the read only made it safe to abandon the RESULT; it never made
36
+ * abandoning the underlying READ safe.
37
+ */
38
+ export const DRAIN_TIMEOUT_MS = 2_000;
21
39
  /**
22
40
  * Default CLIENT-SIDE abort budget for a single fetch attempt, in ms.
23
41
  *
@@ -36,25 +54,44 @@ function timeoutReason(ms) {
36
54
  return reason;
37
55
  }
38
56
  /**
39
- * A per-attempt abort budget: an `AbortController` fired by a `setTimeout`.
57
+ * A per-attempt abort budget: an `AbortController`, optionally fired by a `setTimeout`.
40
58
  *
41
59
  * The budget spans the WHOLE attempt — time-to-headers **and** the response body read (#2546).
42
60
  * Arming it only until headers arrive left `response.text()` / `response.arrayBuffer()` unbounded,
43
61
  * so a server that answered with headers and then stalled its body hung the CLI forever.
44
62
  *
63
+ * The controller ALWAYS exists, even when `ms <= 0` — only its own timeout timer is conditional
64
+ * on `ms > 0`. `sendWithRetry`'s 429-drain cap ({@link DRAIN_TIMEOUT_MS}, via the returned
65
+ * `abort()`) needs a live controller to tear down an abandoned body read regardless of whether the
66
+ * OVERALL per-attempt budget is enabled — `ms <= 0` must still mean "no full-attempt timeout", not
67
+ * "no controller to abort a stalled drain with" (#2587 cr3-2: the earlier drain fix abandoned the
68
+ * read entirely rather than aborting it, so the socket — and the event loop — stayed alive
69
+ * indefinitely, worse than the pre-fix baseline it replaced).
70
+ *
45
71
  * Deliberately NOT `AbortSignal.timeout` (which the SDK prefers): that schedules on a timer the
46
72
  * test runner's fake timers cannot advance, so the transport's timeout would be untestable under
47
73
  * `vi.useFakeTimers()`. CLI requests are short-lived and single-shot, so the GC advantage
48
74
  * `AbortSignal.timeout` buys the long-running SDK does not apply here. The timer is `unref`'d so a
49
75
  * pending budget never keeps the process alive, and `cleanup()` clears it on every exit path.
76
+ *
77
+ * Exported so `lib/download-artifact.ts` can reuse the same budget for the artifact-download path
78
+ * (#2587) rather than growing a second timeout implementation — that path sits entirely outside
79
+ * `sendWithRetry` (it is not a JSON/raw API call), so it would otherwise never inherit this bound.
50
80
  */
51
- function createAttemptBudget(ms) {
81
+ export function createAttemptBudget(ms) {
52
82
  const controller = new AbortController();
53
- const timer = setTimeout(() => controller.abort(timeoutReason(ms)), ms);
54
- timer.unref?.();
83
+ let timer;
84
+ if (ms > 0) {
85
+ timer = setTimeout(() => controller.abort(timeoutReason(ms)), ms);
86
+ timer.unref?.();
87
+ }
55
88
  return {
56
89
  signal: controller.signal,
57
- cleanup: () => clearTimeout(timer),
90
+ cleanup: () => {
91
+ if (timer !== undefined)
92
+ clearTimeout(timer);
93
+ },
94
+ abort: (reason) => controller.abort(reason),
58
95
  };
59
96
  }
60
97
  /** The `name` of an error-shaped value, or `undefined` when it carries none. */
@@ -84,8 +121,11 @@ function abortNameOf(err) {
84
121
  * `timedOut` is true when this attempt's own budget fired the abort, which is the reliable
85
122
  * discriminator (undici rejects with the signal's reason — or a wrapper carrying it, see
86
123
  * {@link abortNameOf} — whose `name` we also inspect as a fallback for a caller-style AbortError).
124
+ *
125
+ * Exported so `lib/download-artifact.ts` reuses the same taxonomy for the artifact-download path
126
+ * (#2587) instead of hand-rolling a second error mapping.
87
127
  */
88
- function toConnectionError(err, method, path, timedOut) {
128
+ export function toConnectionError(err, method, path, timedOut) {
89
129
  const name = abortNameOf(err) ?? '';
90
130
  let code;
91
131
  if (timedOut || name === 'TimeoutError') {
@@ -128,19 +168,84 @@ function sleep(ms) {
128
168
  });
129
169
  }
130
170
  /**
131
- * Shape a failed response into the SDK's OWN error class, using the SDK's own body parsing.
171
+ * Read (and discard) `body`, TEARING DOWN its underlying request after `DRAIN_TIMEOUT_MS` if it
172
+ * has not settled by then (#2587 cr3-2). `body` must already be guarded against rejection by its
173
+ * caller — this function only races it against a short local timer, it never itself decides what
174
+ * a rejection means.
175
+ *
176
+ * `abort` MUST be the abort function of the SAME `AbortController` the read's request was made
177
+ * with. Merely resolving this promise when the cap fires (the earlier, defective version of this
178
+ * function) let the retry proceed promptly while the abandoned `response.text()` kept running
179
+ * against a live socket forever — nothing else in the process held a reference to that read, but
180
+ * the open socket itself kept the event loop alive, so the CLI's soft `process.exitCode` exit
181
+ * (#2544) never actually fired. `response.body?.cancel()` cannot substitute for this: `text()` has
182
+ * already locked the stream by the time this function is called (property 4), so cancelling the
183
+ * body is not an option — aborting the ORIGINAL request's controller is the only way left to
184
+ * release a read already in flight. This is the same mechanism `sendWithRetry`'s headers+body
185
+ * budget already relies on to bound a normal successful read (#2546), so it is a proven pattern:
186
+ * undici rejects the pending read when its controller aborts, and tears down the socket rather
187
+ * than leaving it open.
188
+ *
189
+ * Deliberately a plain `setTimeout` (not `AbortSignal.timeout`), same reasoning as
190
+ * {@link createAttemptBudget}: a timer scheduled that way cannot be advanced by the test runner's
191
+ * fake timers, which would make this cap untestable under `vi.useFakeTimers()`. The cap timer is
192
+ * cleared as soon as `body` itself settles, so a fast drain never leaves a stray timer pending;
193
+ * conversely, if `body` settles only AFTER the cap fires, that later settlement is simply ignored
194
+ * — abandoning the VALUE is always safe because the caller already discards it either way (only
195
+ * the underlying READ itself must not be left running, which `abort` above now guarantees).
196
+ */
197
+ function drainBestEffort(body, abort) {
198
+ return new Promise((resolve) => {
199
+ let settled = false;
200
+ const cap = setTimeout(() => {
201
+ if (!settled) {
202
+ settled = true;
203
+ abort(timeoutReason(DRAIN_TIMEOUT_MS));
204
+ resolve();
205
+ }
206
+ }, DRAIN_TIMEOUT_MS);
207
+ cap.unref?.();
208
+ void body.then(() => {
209
+ if (!settled) {
210
+ settled = true;
211
+ clearTimeout(cap);
212
+ resolve();
213
+ }
214
+ });
215
+ });
216
+ }
217
+ /**
218
+ * Shape a failed response into the SDK's OWN error class, mirroring the SDK's own `send()` body
219
+ * parsing (`packages/sdk-typescript/src/client.ts`) EXACTLY — precedence rule included — so
220
+ * `{status, code, message}` stays identical to what a direct SDK call would throw and
221
+ * `handleError()`'s `'status' in err` branch renders the same `Error: <message> (<code>, status:
222
+ * <n>)` either way.
132
223
  *
133
- * Reusing `ByteKitError` keeps `{status, code, message}` identical to what a direct SDK call
134
- * would throw, so `handleError()`'s `'status' in err` branch still renders
135
- * `Error: <message> (status: <n>)` and exits 1.
224
+ * Two error-body shapes exist across the API:
225
+ * - the canonical unified envelope `{error: {code, message}}` — tested FIRST, so it always wins;
226
+ * - a flat vendor-masked shape `{error: "<code>"}` (e.g. `/v1/search` 502:
227
+ * `{"error":"search_provider_error"}`, docs/api/openapi.yaml §/v1/search 502) — the flat form
228
+ * is strictly a FALLBACK and can never override an envelope (#2541, mirrored here for #2586:
229
+ * the CLI's own copy of this parser had drifted from the SDK's and still only read the
230
+ * envelope shape, so a flat 502 rendered `internal_error` instead of `search_provider_error`).
231
+ *
232
+ * The explicit `!== null` guard on the envelope check matters — `typeof null` is `'object'`.
136
233
  */
137
234
  async function toApiError(response) {
138
235
  let code = 'internal_error';
139
236
  let message = response.statusText;
140
237
  try {
141
238
  const json = (await response.json());
142
- code = json.error?.code ?? code;
143
- message = json.error?.message ?? message;
239
+ if (typeof json.error === 'object' && json.error !== null) {
240
+ code = json.error.code ?? code;
241
+ message = json.error.message ?? message;
242
+ }
243
+ else if (typeof json.error === 'string' && json.error.length > 0) {
244
+ // The string IS the error code. `message` falls back to it only when the response carried
245
+ // no statusText, so the thrown error is never message-empty.
246
+ code = json.error;
247
+ message = message || json.error;
248
+ }
144
249
  }
145
250
  catch {
146
251
  // leave defaults
@@ -189,11 +294,12 @@ async function sendWithRetry(apiKey, baseUrl, timeoutMs, method, path, body, rea
189
294
  if (body !== undefined) {
190
295
  init.body = JSON.stringify(body);
191
296
  }
192
- // Per-attempt abort budget: a fresh timer for THIS attempt. A budget of 0 disables it.
193
- const budget = timeoutMs > 0 ? createAttemptBudget(timeoutMs) : undefined;
194
- if (budget !== undefined) {
195
- init.signal = budget.signal;
196
- }
297
+ // Per-attempt abort budget: a fresh controller for THIS attempt. `timeoutMs <= 0` disables
298
+ // the OVERALL attempt timer (unchanged), but the controller itself always exists — the 429
299
+ // drain below needs something to abort even when the full-attempt timeout is off (#2587
300
+ // cr3-2).
301
+ const budget = createAttemptBudget(timeoutMs);
302
+ init.signal = budget.signal;
197
303
  // Set only by the 429-retry branch below — the one path that falls out of the `try` rather
198
304
  // than returning or throwing. Left `undefined` on every other path, which never reaches the
199
305
  // `sleep` after the `finally`.
@@ -207,7 +313,7 @@ async function sendWithRetry(apiKey, baseUrl, timeoutMs, method, path, body, rea
207
313
  // No HTTP response was produced (timeout, abort, socket/DNS/TLS). Re-throw as the SDK's
208
314
  // own ByteKitConnectionError so a hung server can never hang the CLI and a network failure
209
315
  // no longer surfaces as a bare `Error: fetch failed`.
210
- throw toConnectionError(err, method, path, budget?.signal.aborted === true);
316
+ throw toConnectionError(err, method, path, budget.signal.aborted === true);
211
317
  }
212
318
  if (response.ok) {
213
319
  try {
@@ -216,11 +322,44 @@ async function sendWithRetry(apiKey, baseUrl, timeoutMs, method, path, body, rea
216
322
  catch (err) {
217
323
  // A body read that never completes is aborted by the same budget; undici then rejects
218
324
  // the stream (#2546). Map it into the same taxonomy the headers phase uses.
219
- throw toConnectionError(err, method, path, budget?.signal.aborted === true);
325
+ throw toConnectionError(err, method, path, budget.signal.aborted === true);
220
326
  }
221
327
  }
222
328
  if (response.status === 429 && attempt < MAX_RETRIES) {
223
329
  retryAfterMs = parseRetryAfterMs(response.headers.get('Retry-After'));
330
+ // This 429 body is never read on the retry path (only the headers/Retry-After matter), so
331
+ // it must be fully CONSUMED before the retry — not merely cancelled — for undici to
332
+ // reclaim the underlying socket for connection-pool reuse (#2587 dod-4).
333
+ //
334
+ // #2587 cr-3 correction: an earlier version of this fix used `response.body?.cancel()`,
335
+ // reasoning that cancelling let undici reclaim the socket. That is backwards —
336
+ // `cancel()` ABORTS the fetch and DESTROYS the socket rather than returning it to the
337
+ // pool. Measured against a real `node:http` server and real undici (5x 429->200
338
+ // sequences, counting `connection` events): `cancel()` was inert for the gateway's actual
339
+ // tiny-JSON 429 body (2 conns, identical to no drain at all) and strictly WORSE than no
340
+ // drain for a large body (7 conns vs 6). A full read via `response.text()` is what
341
+ // actually lets undici return the connection to the pool (2 conns at either size). The
342
+ // `.catch(() => {})` guard stays: a real body read can still reject on an already-errored
343
+ // or locked stream, and that must never turn a recoverable 429 into a hard failure.
344
+ //
345
+ // #2587 cr2-1 correction: the read above was previously awaited directly, bounded only by
346
+ // the FULL per-attempt abort budget (120 s default) or, under `timeoutMs <= 0`, by nothing
347
+ // at all — a stalled 429 body could burn the whole budget (or hang forever) before a retry
348
+ // that needs nothing from that body (`Retry-After` is already parsed above).
349
+ // {@link drainBestEffort} bounds the read by its own short, independent cap
350
+ // ({@link DRAIN_TIMEOUT_MS}) instead, so a stalled drain can never delay the retry by more
351
+ // than that cap regardless of the attempt budget's size or absence.
352
+ //
353
+ // #2587 cr3-2 correction: cr2-1's `drainBestEffort` bounded only how long the RETRY
354
+ // waited — the cap resolved the returned promise but never touched the abandoned
355
+ // `response.text()` call itself, which kept reading against a live (never-aborted) socket
356
+ // indefinitely. Because every CLI success path exits via a soft `process.exitCode`
357
+ // (#2544) rather than `process.exit()`, that orphaned read then kept the event loop open
358
+ // forever: strictly WORSE than the pre-#2587 baseline (which at least exited once its own
359
+ // unbounded read finally settled or errored). `drainBestEffort` now takes this attempt's
360
+ // own `budget.abort` and calls it when the cap fires, so the abandoned read is actually
361
+ // torn down — not merely ignored — and the socket (and the event loop) is released.
362
+ await drainBestEffort(response.text().catch(() => { }), budget.abort);
224
363
  }
225
364
  else {
226
365
  // `toApiError` reads the error body, so it too runs inside the budget.
@@ -230,7 +369,7 @@ async function sendWithRetry(apiKey, baseUrl, timeoutMs, method, path, body, rea
230
369
  finally {
231
370
  // Clear the timer on every exit path — success (body read included), HTTP error, or
232
371
  // abort — so a healthy request leaves no dangling 120s timer.
233
- budget?.cleanup();
372
+ budget.cleanup();
234
373
  }
235
374
  // OUTSIDE the budget's `finally`: the backoff is not spent against any attempt's budget, and
236
375
  // the next attempt arms a fresh one. The `??` is unreachable in practice (only the branch
@@ -246,9 +385,21 @@ async function sendWithRetry(apiKey, baseUrl, timeoutMs, method, path, body, rea
246
385
  * property of the client (see {@link buildClient}) routes all JSON commands through it from one
247
386
  * seam. `/v1/fetch` does NOT ride this transport — it rides `requestRaw` (see
248
387
  * {@link createRetryingRequestRaw}); `/v1/fetch/bulk` is a JSON job envelope and does.
388
+ *
389
+ * DECISION (#2586 dod-4): the 4th `RequestOptions` parameter of the SDK's contract is accepted
390
+ * here — so the signature matches `ByteKit['request']` exactly, closing the gap where a caller-typo
391
+ * of a 5th positional argument would silently no-op instead of a compile error — but deliberately
392
+ * NOT forwarded into `sendWithRetry`. Two reasons: (1) no CLI command constructs a `RequestOptions`
393
+ * today (no `--timeout` flag exists yet, and `signal` has no CLI-level source), so the parameter is
394
+ * always `undefined` in practice; (2) the CLI already owns a per-attempt abort budget with its own
395
+ * semantics (`createAttemptBudget`, `timeoutMs` above) that would need explicit composition with a
396
+ * caller `signal` to honor `RequestOptions` correctly (the SDK's `composeSignals`) — inventing that
397
+ * unexercised composition here, with no caller able to reach it, would be undertested surface for no
398
+ * present behavior change. If a future CLI flag needs per-call overrides, wire it through
399
+ * `sendWithRetry`'s `timeoutMs` (already per-attempt) at that point, with its own tests.
249
400
  */
250
401
  export function createRetryingRequest(apiKey, baseUrl, timeoutMs = DEFAULT_TIMEOUT_MS) {
251
- return async function request(method, path, body) {
402
+ return async function request(method, path, body, _options) {
252
403
  // The reader consumes the stream and nothing else, so the abort budget covers the read
253
404
  // (#2546) while the 204/empty guard and `JSON.parse` stay OUTSIDE the guarded region —
254
405
  // a malformed 200 body must still surface its own SyntaxError, never a connection error.
@@ -273,9 +424,14 @@ export function createRetryingRequest(apiKey, baseUrl, timeoutMs = DEFAULT_TIMEO
273
424
  * so `FetchResource` builds an ordinary `FetchResult` from it and the CLI never forks fetch
274
425
  * handling away from the SDK. `bytes` is byte-exact (binary-safe); `body` is the best-effort UTF-8
275
426
  * decode, matching the SDK's own semantics.
427
+ *
428
+ * The 4th `RequestOptions` parameter is accepted but not forwarded — see the DECISION note on
429
+ * {@link createRetryingRequest}, which applies here verbatim. `FetchResource.create`/`get` DO thread
430
+ * a `requestOptions` argument through to this seam (unlike every other resource), but no CLI `fetch`
431
+ * command constructs one today (no `--timeout` flag), so it is still always `undefined` in practice.
276
432
  */
277
433
  export function createRetryingRequestRaw(apiKey, baseUrl, timeoutMs = DEFAULT_TIMEOUT_MS) {
278
- return async function requestRaw(method, path, body) {
434
+ return async function requestRaw(method, path, body, _options) {
279
435
  // As in `createRetryingRequest`: the reader only drains the stream (so the abort budget
280
436
  // covers it, #2546); the UTF-8 decode happens outside the guarded region.
281
437
  const { bytes, headers, status } = await sendWithRetry(apiKey, baseUrl, timeoutMs, method, path, body, async (response) => ({
@@ -291,6 +447,45 @@ export function createRetryingRequestRaw(apiKey, baseUrl, timeoutMs = DEFAULT_TI
291
447
  };
292
448
  };
293
449
  }
450
+ /**
451
+ * Build the CLI's header-carrying JSON transport: the SDK's `requestWithResponse` contract
452
+ * (`{data, headers, status}`, #2547), plus the same bounded 429 retry every other CLI transport
453
+ * gets.
454
+ *
455
+ * Unused by any CLI command today — `screenshots.createWithResponse` / `getWithResponse` and
456
+ * `sitemap.getWithResponse` all exist on the SDK surface but no command builds on them yet. It is
457
+ * still installed (see {@link buildClient}) rather than left un-overridden: the transport-coverage
458
+ * contract test (#2586 dod-3) derives its method list from the real SDK prototype, and leaving a
459
+ * transport method un-overridden is exactly how #2535 shipped broken — the first command that DOES
460
+ * call a `*WithResponse` resource method must not silently ride the bare SDK transport and skip the
461
+ * CLI's retry/timeout/error taxonomy.
462
+ *
463
+ * Same body-read shape as {@link createRetryingRequest} (204/empty → `undefined`, `JSON.parse`
464
+ * otherwise), with `headers` and `status` threaded through from the successful response alongside
465
+ * the parsed `data`. The 4th `RequestOptions` parameter is accepted but not forwarded — see the
466
+ * DECISION note on {@link createRetryingRequest}.
467
+ */
468
+ export function createRetryingRequestWithResponse(apiKey, baseUrl, timeoutMs = DEFAULT_TIMEOUT_MS) {
469
+ return async function requestWithResponse(method, path, body, _options) {
470
+ const { status, text, headers } = await sendWithRetry(apiKey, baseUrl, timeoutMs, method, path, body, async (response) => ({
471
+ status: response.status,
472
+ text: await response.text(),
473
+ headers: response.headers,
474
+ }));
475
+ const data = status === 204 || text.trim().length === 0 ? undefined : JSON.parse(text);
476
+ return { data, headers, status };
477
+ };
478
+ }
479
+ /**
480
+ * Strip a trailing slash (or run of them) from a base URL, mirroring the SDK's own constructor
481
+ * normalization verbatim (`packages/sdk-typescript/src/client.ts`, `/\/+$/`, #2481) — so
482
+ * `baseUrl + path` (every CLI-owned path already starts with `/`) never yields a double-slash URL.
483
+ * Only the trailing slash is removed; a path-carrying baseUrl (e.g. a reverse-proxy prefix
484
+ * `…/bytekit`) is preserved.
485
+ */
486
+ function normalizeBaseUrl(url) {
487
+ return url.replace(/\/+$/, '');
488
+ }
294
489
  /**
295
490
  * Build an authenticated SDK client from global CLI options.
296
491
  *
@@ -299,10 +494,19 @@ export function createRetryingRequestRaw(apiKey, baseUrl, timeoutMs = DEFAULT_TI
299
494
  * baseUrl: --base-url flag → BYTEKIT_BASE_URL → DEFAULT_BASE_URL.
300
495
  * Exits with a descriptive error when no key is available.
301
496
  *
302
- * The returned client's `request` AND `requestRaw` are both replaced by the retrying transports, so
303
- * every command inherits the 429 retry — JSON commands via `request`, the `/v1/fetch` content
304
- * commands via `requestRaw`. Overriding only `request` is the #2535 defect: the SDK's fetch
305
- * resource rides `requestRaw`, so fetch bypassed the retry and the CLI's error taxonomy entirely.
497
+ * The returned client's `request`, `requestRaw`, AND `requestWithResponse` are all replaced by the
498
+ * retrying transports, so every command inherits the 429 retry — JSON commands via `request`, the
499
+ * `/v1/fetch` content commands via `requestRaw`, and the header-carrying `*WithResponse` surface via
500
+ * `requestWithResponse`. Overriding only `request` is the #2535 defect: the SDK's fetch resource
501
+ * rides `requestRaw`, so fetch bypassed the retry and the CLI's error taxonomy entirely; leaving
502
+ * `requestWithResponse` un-overridden is the same class of defect (#2586) — it is unused by any CLI
503
+ * command TODAY, but a future command built on `screenshots.createWithResponse` /
504
+ * `sitemap.getWithResponse` would otherwise silently ride the bare SDK transport.
505
+ *
506
+ * `baseUrl` is normalized ONCE, here, before it reaches either the SDK constructor or the CLI's own
507
+ * transport builders below — the CLI's transports build their request URL from this local variable
508
+ * directly (they do not call the SDK's own normalized `send()`), so a trailing-slash flag would
509
+ * otherwise double-slash every request the CLI makes (#2586).
306
510
  */
307
511
  export function buildClient(opts) {
308
512
  const apiKey = opts.key ?? process.env['BYTEKIT_API_KEY'];
@@ -310,9 +514,10 @@ export function buildClient(opts) {
310
514
  console.error('Error: API key required. Pass --key or set BYTEKIT_API_KEY.');
311
515
  process.exit(1);
312
516
  }
313
- const baseUrl = opts.baseUrl ?? process.env['BYTEKIT_BASE_URL'] ?? DEFAULT_BASE_URL;
517
+ const baseUrl = normalizeBaseUrl(opts.baseUrl ?? process.env['BYTEKIT_BASE_URL'] ?? DEFAULT_BASE_URL);
314
518
  const client = new ByteKit({ apiKey, baseUrl });
315
519
  client.request = createRetryingRequest(apiKey, baseUrl);
316
520
  client.requestRaw = createRetryingRequestRaw(apiKey, baseUrl);
521
+ client.requestWithResponse = createRetryingRequestWithResponse(apiKey, baseUrl);
317
522
  return client;
318
523
  }
@@ -1,7 +1,8 @@
1
1
  import { Command } from 'commander';
2
2
  import { buildClient } from '../client.js';
3
3
  import { printResult, handleError } from '../output.js';
4
- import { parseUrlLines } from '../lib/url-lines.js';
4
+ import { parseBulkLines, toBulkItems } from '../lib/url-lines.js';
5
+ import { parseJsonFlag } from '../lib/parse-flags.js';
5
6
  export function makeBulkCommand(getGlobal) {
6
7
  const cmd = new Command('bulk').description('Bulk screenshot operations');
7
8
  const screenshotsCmd = new Command('screenshots').description('Bulk screenshot sub-resources');
@@ -23,14 +24,31 @@ export function makeBulkCommand(getGlobal) {
23
24
  cmd
24
25
  .command('create')
25
26
  .description('Create a bulk screenshots job')
26
- .requiredOption('--file <path>', 'Path to newline-delimited file of URLs (plain URL or {"url":"..."} per line)')
27
+ .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.')
27
28
  .requiredOption('--webhook-url <url>', 'HTTPS webhook URL notified when the job completes')
29
+ .option('--webhook-secret <secret>', 'HMAC secret used to sign the completion webhook (omitted → unsigned)')
30
+ .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'))
31
+ .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'))
32
+ // Deliberately NO --custom: /v1/bulk is `.strict()` at the top level with no `custom`
33
+ // property (validators/bulk.ts:135), so the flag would 422 every call (#2596 drift D2).
28
34
  .action(async (opts) => {
29
35
  const g = getGlobal();
30
36
  const client = buildClient(g);
31
37
  try {
32
- const urls = parseUrlLines(opts.file);
33
- const result = await client.bulk.create({ urls, webhook_url: opts.webhookUrl });
38
+ const lines = parseBulkLines(opts.file);
39
+ // `urls` XOR `items` (validators/bulk.ts:136-155) — send exactly one. A file of only
40
+ // url-only lines keeps the pre-#2596 `{ urls, webhook_url }` body, key order included,
41
+ // so existing callers are byte-identical on the wire (E2).
42
+ const body = lines.every((l) => typeof l === 'string')
43
+ ? { urls: lines, webhook_url: opts.webhookUrl }
44
+ : { items: toBulkItems(lines), webhook_url: opts.webhookUrl };
45
+ if (opts.webhookSecret !== undefined)
46
+ body['webhook_secret'] = opts.webhookSecret;
47
+ if (opts.defaults !== undefined)
48
+ body['defaults'] = opts.defaults;
49
+ if (opts.metadata !== undefined)
50
+ body['metadata'] = opts.metadata;
51
+ const result = await client.bulk.create(body);
34
52
  printResult(result, g.json);
35
53
  }
36
54
  catch (err) {
@@ -1,11 +1,13 @@
1
1
  import { Command, InvalidArgumentError } from 'commander';
2
2
  import { buildClient } from '../client.js';
3
3
  import { printResult, emitFetchResult, handleError } from '../output.js';
4
- import { parseUrlLines } from '../lib/url-lines.js';
5
- import { parseIntFlag, parseEnum, parseCacheTtl } from '../lib/parse-flags.js';
4
+ import { parseBulkLines } from '../lib/url-lines.js';
5
+ import { parseIntFlag, parseEnum, parseCacheTtl, parseJsonFlag } from '../lib/parse-flags.js';
6
6
  const FETCH_FORMATS = ['markdown', 'html'];
7
7
  /** The `FetchRequest.markdown_mode` spec enum (create/POST only — excluded from the GET query). */
8
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'];
9
11
  /**
10
12
  * Validate `--format` locally, before any HTTP request is issued.
11
13
  *
@@ -28,14 +30,31 @@ export function makeFetchCommand(getGlobal) {
28
30
  bulkCmd
29
31
  .command('create')
30
32
  .description('Create a bulk fetch job')
31
- .requiredOption('--file <path>', 'Path to newline-delimited file of URLs (plain URL or {"url":"..."} per line)')
33
+ .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.')
32
34
  .requiredOption('--webhook-url <url>', 'HTTPS webhook URL notified when the job completes')
35
+ .option('--webhook-secret <secret>', 'HMAC secret used to sign the completion webhook (omitted → unsigned)')
36
+ .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'))
37
+ .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'))
38
+ // Deliberately NO --custom: /v1/fetch/bulk has no top-level `custom` property
39
+ // (validators/fetch-bulk.ts:52-62), so the flag would be silently stripped (#2596 drift D2).
40
+ // FetchBulkBodySchema itself stays non-strict (issue #2648 only makes the per-item
41
+ // FetchBulkItemOverrideSchema reject unknown keys), so this top-level behavior is unchanged.
33
42
  .action(async (opts) => {
34
43
  const g = getGlobal();
35
44
  const client = buildClient(g);
36
45
  try {
37
- const urls = parseUrlLines(opts.file);
38
- const result = await client.fetch.bulk.create({ urls, webhook_url: opts.webhookUrl });
46
+ // /v1/fetch/bulk has NO `items` property — its `urls` is a
47
+ // `string | FetchBulkItemOverride` union (validators/fetch-bulk.ts:57), so both line
48
+ // shapes ride there directly and no promotion step is needed (#2596 drift D3).
49
+ const urls = parseBulkLines(opts.file);
50
+ const body = { urls, webhook_url: opts.webhookUrl };
51
+ if (opts.webhookSecret !== undefined)
52
+ body['webhook_secret'] = opts.webhookSecret;
53
+ if (opts.defaults !== undefined)
54
+ body['defaults'] = opts.defaults;
55
+ if (opts.metadata !== undefined)
56
+ body['metadata'] = opts.metadata;
57
+ const result = await client.fetch.bulk.create(body);
39
58
  printResult(result, g.json);
40
59
  }
41
60
  catch (err) {
@@ -65,23 +84,63 @@ export function makeFetchCommand(getGlobal) {
65
84
  .option('--country <cc>', 'Two-letter country code to route the request through (e.g. us, de)')
66
85
  .option('--timeout-ms <n>', 'Per-request timeout in milliseconds', (v) => parseIntFlag('--timeout-ms', v))
67
86
  .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))
68
- // `--markdown-mode` is POST-only: FetchGetOpts excludes it, so adding it to `get` below would be
69
- // a compile error — the create/get asymmetry is correct by construction.
87
+ // The markdown surface below is POST-only: FetchGetOpts excludes it, so adding any of it to
88
+ // `get` would be a compile error — the create/get asymmetry is correct by construction.
70
89
  .option('--markdown-mode <mode>', `Markdown mode: ${MARKDOWN_MODES.join(', ')}`, (v) => parseEnum('--markdown-mode', MARKDOWN_MODES, v))
90
+ .option('--markdown-query <q>', 'BM25 query string for relevance-ranked markdown filtering (omitted → disabled)')
91
+ .option('--markdown-links <mode>', `Link rendering: ${MARKDOWN_RENDER_MODES.join(', ')} (omitted → server default)`, (v) => parseEnum('--markdown-links', MARKDOWN_RENDER_MODES, v))
92
+ .option('--markdown-images <mode>', `Image retention: ${MARKDOWN_RENDER_MODES.join(', ')} (omitted → server default)`, (v) => parseEnum('--markdown-images', MARKDOWN_RENDER_MODES, v))
93
+ .option('--with-links-summary', 'Append a Links footer to the markdown output (omitted → off)')
94
+ .option('--with-images-summary', 'Append an Images footer to the markdown output (omitted → off)')
95
+ .option('--markdown-compact', 'Collapse excessive whitespace in the markdown output (omitted → off)')
96
+ .option('--markdown-filter-images', 'Filter low-signal images from the markdown output (omitted → off)')
97
+ .option('--markdown-include-media', 'Return rich link/image objects and a tables array (omitted → off)')
98
+ .option('--markdown-include-warnings', 'Include markdown-pipeline and tag-filter warnings (omitted → off)')
99
+ .option('--markdown-include-stats', 'Include a top-level stats object (chars, tokens, blocks) (omitted → off)')
100
+ .option('--custom <json>', `User payload echoed on the envelope as a JSON object, e.g. '{"job":"1"}' (omitted → none)`, (v) => parseJsonFlag('--custom', v, 'object'))
71
101
  .option('-o, --output <file>', 'Write the fetched body bytes to the given file path')
72
102
  .action(async (opts) => {
73
103
  const g = getGlobal();
74
104
  const client = buildClient(g);
105
+ // Omit-when-absent body, in the `body: Record<string, unknown>` form every other command
106
+ // file uses (scrape.ts:230-268 is the exemplar). The five pre-existing fields keep the
107
+ // exact same wire output they had as inline spreads; a boolean is written ONLY on
108
+ // `=== true`, so it never serializes `false` (#2480 E1).
109
+ const body = { url: opts.url };
110
+ if (opts.format !== undefined)
111
+ body['format'] = opts.format;
112
+ if (opts.country !== undefined)
113
+ body['country'] = opts.country;
114
+ if (opts.timeoutMs !== undefined)
115
+ body['timeout_ms'] = opts.timeoutMs;
116
+ if (opts.cacheTtl !== undefined)
117
+ body['cache_ttl'] = opts.cacheTtl;
118
+ if (opts.markdownMode !== undefined)
119
+ body['markdown_mode'] = opts.markdownMode;
120
+ if (opts.markdownQuery !== undefined)
121
+ body['markdown_query'] = opts.markdownQuery;
122
+ if (opts.markdownLinks !== undefined)
123
+ body['markdown_links'] = opts.markdownLinks;
124
+ if (opts.markdownImages !== undefined)
125
+ body['markdown_images'] = opts.markdownImages;
126
+ if (opts.withLinksSummary === true)
127
+ body['with_links_summary'] = true;
128
+ if (opts.withImagesSummary === true)
129
+ body['with_images_summary'] = true;
130
+ if (opts.markdownCompact === true)
131
+ body['markdown_compact'] = true;
132
+ if (opts.markdownFilterImages === true)
133
+ body['markdown_filter_images'] = true;
134
+ if (opts.markdownIncludeMedia === true)
135
+ body['markdown_include_media'] = true;
136
+ if (opts.markdownIncludeWarnings === true)
137
+ body['markdown_include_warnings'] = true;
138
+ if (opts.markdownIncludeStats === true)
139
+ body['markdown_include_stats'] = true;
140
+ if (opts.custom !== undefined)
141
+ body['custom'] = opts.custom;
75
142
  try {
76
- const result = await client.fetch.create({
77
- url: opts.url,
78
- // Each optional field is spread ONLY when its flag is present (#2480 E1).
79
- ...(opts.format !== undefined ? { format: opts.format } : {}),
80
- ...(opts.country !== undefined ? { country: opts.country } : {}),
81
- ...(opts.timeoutMs !== undefined ? { timeout_ms: opts.timeoutMs } : {}),
82
- ...(opts.cacheTtl !== undefined ? { cache_ttl: opts.cacheTtl } : {}),
83
- ...(opts.markdownMode !== undefined ? { markdown_mode: opts.markdownMode } : {}),
84
- });
143
+ const result = await client.fetch.create(body);
85
144
  emitFetchResult(result, g.json, { file: opts.output });
86
145
  }
87
146
  catch (err) {
@@ -20,11 +20,24 @@ export function makeMonitorsCommand(getGlobal) {
20
20
  capturesCmd
21
21
  .command('list <id>')
22
22
  .description('List captures for a monitor')
23
- .action(async (id) => {
23
+ .option('--limit <n>', 'Maximum number of captures to return', (v) => parseIntFlag('--limit', v))
24
+ .option('--cursor <cursor>', 'Pagination cursor from a previous page')
25
+ .action(async (id, opts) => {
24
26
  const g = getGlobal();
25
27
  const client = buildClient(g);
28
+ // Same omit-when-absent rule as `monitors list` (#2480): build the params object with
29
+ // ONLY the flags the caller passed, and call captures.list with a single argument (never
30
+ // captures.list(id, {}) / captures.list(id, undefined)) when no query flag is passed, so
31
+ // the inverse-case request-shape assertion stays byte-identical to today (#2594 DoD-4).
32
+ const params = {};
33
+ if (opts.limit !== undefined)
34
+ params.limit = opts.limit;
35
+ if (opts.cursor !== undefined)
36
+ params.cursor = opts.cursor;
26
37
  try {
27
- const result = await client.monitors.captures.list(id);
38
+ const result = Object.keys(params).length > 0
39
+ ? await client.monitors.captures.list(id, params)
40
+ : await client.monitors.captures.list(id);
28
41
  printResult(result, g.json);
29
42
  }
30
43
  catch (err) {
@@ -48,6 +61,7 @@ export function makeMonitorsCommand(getGlobal) {
48
61
  .option('--webhook-headers <json>', `Extra webhook headers as a JSON object, e.g. '{"X-Sig":"abc"}' (omitted → none)`, (v) => parseJsonFlag('--webhook-headers', v, 'object'))
49
62
  .option('--options <json>', `Screenshot-monitor options as a JSON object (omitted → server defaults)`, (v) => parseJsonFlag('--options', v, 'object'))
50
63
  .option('--scrape-options <json>', `Scrape-monitor options as a JSON object (type=scrape only; omitted → server defaults)`, (v) => parseJsonFlag('--scrape-options', v, 'object'))
64
+ .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'))
51
65
  .action(async (opts) => {
52
66
  const g = getGlobal();
53
67
  const client = buildClient(g);
@@ -75,6 +89,8 @@ export function makeMonitorsCommand(getGlobal) {
75
89
  body['options'] = opts.options;
76
90
  if (opts.scrapeOptions !== undefined)
77
91
  body['scrape_options'] = opts.scrapeOptions;
92
+ if (opts.metadata !== undefined)
93
+ body['metadata'] = opts.metadata;
78
94
  try {
79
95
  const result = await client.monitors.create(body);
80
96
  printResult(result, g.json);
@@ -132,6 +148,7 @@ export function makeMonitorsCommand(getGlobal) {
132
148
  cmd
133
149
  .command('update <id>')
134
150
  .description('Update a monitor (including pause/resume via --status)')
151
+ .option('--url <url>', 'New URL to monitor (omitted → unchanged)')
135
152
  .option('--interval-type <type>', 'New check interval: hourly, daily, weekly, or cron')
136
153
  .option('--cron <expr>', 'New cron expression (when interval-type is cron, e.g. "0 * * * *")')
137
154
  .option('--webhook-url <url>', 'New HTTPS webhook URL')
@@ -144,10 +161,15 @@ export function makeMonitorsCommand(getGlobal) {
144
161
  .option('--webhook-headers <json>', `New webhook headers as a JSON object, e.g. '{"X-Sig":"abc"}'`, (v) => parseJsonFlag('--webhook-headers', v, 'object'))
145
162
  .option('--options <json>', `New screenshot-monitor options as a JSON object`, (v) => parseJsonFlag('--options', v, 'object'))
146
163
  .option('--scrape-options <json>', `New scrape-monitor options as a JSON object`, (v) => parseJsonFlag('--scrape-options', v, 'object'))
164
+ .option('--metadata <json>', `New metadata as a JSON object, e.g. '{"run":"7"}' (omitted → unchanged)`, (v) => parseJsonFlag('--metadata', v, 'object'))
147
165
  .action(async (id, opts) => {
148
166
  const g = getGlobal();
149
167
  const client = buildClient(g);
150
168
  const body = {};
169
+ // `url` IS settable on PATCH (openapi.yaml MonitorUpdateRequest) — unlike `type`, which
170
+ // stays immutable and deliberately has no flag (#1984).
171
+ if (opts.url !== undefined)
172
+ body['url'] = opts.url;
151
173
  if (opts.intervalType !== undefined)
152
174
  body['interval_type'] = opts.intervalType;
153
175
  if (opts.cron !== undefined)
@@ -168,6 +190,8 @@ export function makeMonitorsCommand(getGlobal) {
168
190
  body['options'] = opts.options;
169
191
  if (opts.scrapeOptions !== undefined)
170
192
  body['scrape_options'] = opts.scrapeOptions;
193
+ if (opts.metadata !== undefined)
194
+ body['metadata'] = opts.metadata;
171
195
  try {
172
196
  const result = await client.monitors.update(id, body);
173
197
  printResult(result, g.json);