@hunt-labs/bytekit-cli 0.5.0 → 0.7.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.
@@ -2,7 +2,7 @@ import { Command, InvalidArgumentError } from 'commander';
2
2
  import { buildClient } from '../client.js';
3
3
  import { printResult, handleError } from '../output.js';
4
4
  import { downloadArtifact } from '../lib/download-artifact.js';
5
- import { parseEnum, parseIntFlag } from '../lib/parse-flags.js';
5
+ import { parseEnum, parseIntFlag, parseJsonFlag } from '../lib/parse-flags.js';
6
6
  /** `ScreenshotRequest` spec enums. */
7
7
  const DEVICES = ['desktop', 'mobile'];
8
8
  const FORMATS = ['jpeg', 'png'];
@@ -23,12 +23,122 @@ export function parseClip(value) {
23
23
  throw new InvalidArgumentError('--clip must be X,Y,WIDTH,HEIGHT (e.g. 0,0,800,600).');
24
24
  return { x: Number(m[1]), y: Number(m[2]), width: Number(m[3]), height: Number(m[4]) };
25
25
  }
26
+ /**
27
+ * Exit code when a queued (202 async) envelope reaches human output (#2596).
28
+ *
29
+ * Mirrors `scrape create`'s own `QUEUED_EXIT_CODE` (`commands/scrape.ts:40`) EXACTLY, and is
30
+ * likewise a LOCAL constant rather than an import — the two commands agree on the value, not on a
31
+ * shared symbol, and the guard stays where the ticket pins it (the screenshots async branch)
32
+ * instead of migrating into the shared printer, where it would silently set exit 2 for every
33
+ * command that prints a result.
34
+ *
35
+ * Deliberately NOT 0 (a completed capture) and NOT 1 (a usage/API error): the job WAS accepted and
36
+ * billed, the artifact simply does not exist yet, and a distinct code lets a caller branch on
37
+ * "queued" without parsing stderr.
38
+ *
39
+ * BREAKING (#2596): `screenshots create --async` previously exited 0. See the README exit-code
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.
45
+ */
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
+ }
26
129
  /**
27
130
  * Extract a non-empty string `image_url` from a screenshot envelope, or throw an actionable error.
28
131
  *
29
- * A pending/processing envelope has no `image_url` yet; a terminal-`failed` screenshot returns the
30
- * unified `Error` envelope on a 200 (#2027). Either way the artifact was never materialized, so we
31
- * 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.
32
142
  */
33
143
  function extractImageUrl(result) {
34
144
  if (typeof result === 'object' && result !== null) {
@@ -40,8 +150,28 @@ function extractImageUrl(result) {
40
150
  return obj.image_url;
41
151
  }
42
152
  const status = typeof obj.status === 'string' ? obj.status : 'unknown';
43
- 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}`);
44
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.
45
175
  throw new Error('not ready yet: screenshot is unknown');
46
176
  }
47
177
  /**
@@ -53,9 +183,43 @@ function extractImageUrl(result) {
53
183
  async function emitScreenshot(result, opts) {
54
184
  if (opts.isAsync === true) {
55
185
  printResult(result, opts.json);
186
+ // Discriminate on the --async FLAG, never on the response body. The screenshot 202 reports a
187
+ // PENDING status (gateway src/routes/screenshots.ts:138) — not the queued status that
188
+ // `scrape create`'s envelope guard (commands/scrape.ts:49-53) tests for. Reusing that guard
189
+ // here would never fire in production and this branch would silently no-op. Grep gate G11
190
+ // (#2596) enforces the absence of a response-status discriminator in this file, which is why
191
+ // neither the symbol nor the comparison is spelled out above.
192
+ //
193
+ // The assignment sits INSIDE this branch, ahead of the `-o` return below, so `--async -o`
194
+ // carries the same exit code as bare `--async` (#2596 DoD 15 / E20). `--json` keeps exit 0:
195
+ // the envelope is a complete machine-readable payload, identical to scrape's split
196
+ // (scrape.ts:313-317). Soft `process.exitCode`, never `process.exit` — a hard exit runs
197
+ // before Node drains an async piped stdout and would truncate the envelope just written
198
+ // (#2544).
199
+ if (!opts.json)
200
+ process.exitCode = QUEUED_EXIT_CODE;
56
201
  return;
57
202
  }
58
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
+ }
59
223
  await downloadArtifact(extractImageUrl(result), opts.output);
60
224
  return;
61
225
  }
@@ -89,11 +253,13 @@ export function makeScreenshotsCommand(getGlobal) {
89
253
  .option('--include-html', 'Include the rendered HTML alongside the screenshot')
90
254
  .option('--country <cc>', 'Two-letter country code to route the request through (e.g. us, de)')
91
255
  .option('--language <lang>', 'Accept-Language override (e.g. en-US)')
256
+ .option('--headers <json>', `Extra request headers as a JSON object, e.g. '{"X-A":"b"}' (omitted → none)`, (v) => parseJsonFlag('--headers', v, 'object'))
257
+ .option('--cookies <json>', `Cookies as a JSON array of {name,value,domain}, e.g. '[{"name":"a","value":"b","domain":"example.com"}]' (omitted → none)`, (v) => parseJsonFlag('--cookies', v, 'array'))
258
+ .option('--metadata <json>', `Arbitrary metadata echoed on the screenshot envelope, as a JSON object, e.g. '{"run":"7"}' (omitted → none)`, (v) => parseJsonFlag('--metadata', v, 'object'))
92
259
  .option('--async', 'Queue the screenshot and return a job envelope immediately (no wait)')
93
260
  .option('-o, --output <file>', 'Download the captured image to this file path (use - for stdout). Not valid with --json.')
94
261
  .action(async (opts) => {
95
262
  const g = getGlobal();
96
- const client = buildClient(g);
97
263
  const body = { url: opts.url };
98
264
  // Each optional flag maps 1:1 to its snake_case ScreenshotRequest field, written ONLY when
99
265
  // present — a boolean never serializes `false` (#2480 E1).
@@ -135,7 +301,17 @@ export function makeScreenshotsCommand(getGlobal) {
135
301
  body['country'] = opts.country;
136
302
  if (opts.language !== undefined)
137
303
  body['language'] = opts.language;
304
+ // The server defaults headers/cookies/metadata to {} / [] / {}; writing an empty
305
+ // container on a bare create would materialize those defaults (#2480 E1).
306
+ if (opts.headers !== undefined)
307
+ body['headers'] = opts.headers;
308
+ if (opts.cookies !== undefined)
309
+ body['cookies'] = opts.cookies;
310
+ if (opts.metadata !== undefined)
311
+ body['metadata'] = opts.metadata;
138
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);
139
315
  // Reject --json + -o BEFORE issuing the request: -o downloads BINARY artifact bytes, so
140
316
  // it can never coexist with --json (which must stay a text envelope). Thrown here so no
141
317
  // request — and therefore no download fetch — is ever issued (#2480 E2).
@@ -164,8 +340,9 @@ export function makeScreenshotsCommand(getGlobal) {
164
340
  .option('-o, --output <file>', 'Download the captured image to this file path (use - for stdout). Not valid with --json.')
165
341
  .action(async (id, opts) => {
166
342
  const g = getGlobal();
167
- const client = buildClient(g);
168
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);
169
346
  if (g.json && opts.output !== undefined) {
170
347
  throw new Error('-o cannot be used with --json');
171
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);
@@ -1,7 +1,7 @@
1
1
  import { Command } from 'commander';
2
2
  import { buildClient } from '../client.js';
3
3
  import { printResult, handleError } from '../output.js';
4
- import { parseEnum, parseIntFlag } from '../lib/parse-flags.js';
4
+ import { parseEnum, parseIntFlag, parseCacheTtl, parseJsonFlag } from '../lib/parse-flags.js';
5
5
  /** The `SitemapRequest.strategy` spec enum. */
6
6
  const SITEMAP_STRATEGIES = ['quick', 'standard', 'deep'];
7
7
  export function makeSitemapCommand(getGlobal) {
@@ -13,9 +13,17 @@ export function makeSitemapCommand(getGlobal) {
13
13
  .option('--strategy <strategy>', `Crawl strategy: ${SITEMAP_STRATEGIES.join(', ')} (omitted → server default standard)`, (v) => parseEnum('--strategy', SITEMAP_STRATEGIES, v))
14
14
  .option('--max-depth <n>', 'Maximum crawl depth 1-5 (omitted → server default 2)', (v) => parseIntFlag('--max-depth', v))
15
15
  .option('--max-urls <n>', 'Maximum URLs to collect (omitted → server default 5000)', (v) => parseIntFlag('--max-urls', v))
16
+ .option('--webhook-url <url>', 'HTTPS webhook URL notified when the crawl completes (omitted → poll-only, no delivery)')
17
+ .option('--webhook-secret <secret>', 'HMAC secret used to sign the completion webhook (omitted → unsigned)')
18
+ // Grammar-only validation, deliberately: sitemap caps cache_ttl at 90 DAYS
19
+ // (validators/sitemap.ts MAX_CACHE_TTL_SECONDS), not the 7d used by scrape/fetch. Mirroring
20
+ // the cap client-side would fork a second source of truth for a number only the server knows.
21
+ .option('--cache-ttl <ttl>', 'Cache freshness: 0, Nh (hours), or Nd (days), e.g. 48h, 30d, 0 (omitted → server default 30d; max 90d, enforced server-side)', (v) => parseCacheTtl('--cache-ttl', v))
22
+ .option('--compact', 'Return a compact result payload (omitted → server default off)')
23
+ .option('--process <json>', `Post-crawl processing directive as a JSON object, e.g. '{"action":"summarize"}' (omitted → none)`, (v) => parseJsonFlag('--process', v, 'object'))
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'))
16
25
  .action(async (opts) => {
17
26
  const g = getGlobal();
18
- const client = buildClient(g);
19
27
  // Omit-when-absent body; the remaining SitemapRequest fields keep their server defaults.
20
28
  // The generated request type marks defaulted fields required, so the call needs an explicit
21
29
  // assertion — type-level only; the wire body is pinned by request-shapes.test.ts.
@@ -26,7 +34,23 @@ export function makeSitemapCommand(getGlobal) {
26
34
  body['max_depth'] = opts.maxDepth;
27
35
  if (opts.maxUrls !== undefined)
28
36
  body['max_urls'] = opts.maxUrls;
37
+ if (opts.webhookUrl !== undefined)
38
+ body['webhook_url'] = opts.webhookUrl;
39
+ if (opts.webhookSecret !== undefined)
40
+ body['webhook_secret'] = opts.webhookSecret;
41
+ if (opts.cacheTtl !== undefined)
42
+ body['cache_ttl'] = opts.cacheTtl;
43
+ // `compact` server-defaults to FALSE, so it is written ONLY on `=== true` — writing
44
+ // `false` on a bare create would materialize a default (#2480 E1).
45
+ if (opts.compact === true)
46
+ body['compact'] = true;
47
+ if (opts.process !== undefined)
48
+ body['process'] = opts.process;
49
+ if (opts.metadata !== undefined)
50
+ body['metadata'] = opts.metadata;
29
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);
30
54
  const result = await client.sitemap.create(body);
31
55
  printResult(result, g.json);
32
56
  }
@@ -39,8 +63,9 @@ export function makeSitemapCommand(getGlobal) {
39
63
  .description('Get a sitemap job by ID')
40
64
  .action(async (id) => {
41
65
  const g = getGlobal();
42
- const client = buildClient(g);
43
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);
44
69
  const result = await client.sitemap.get(id);
45
70
  printResult(result, g.json);
46
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
  }
@@ -9,5 +9,34 @@
9
9
  * test that stubs `globalThis.fetch` in a `beforeEach` — after this module was imported — still
10
10
  * sees its stub (G-049). A non-2xx response throws with the HTTP status so `handleError` renders a
11
11
  * clean one-liner and exits 1.
12
+ *
13
+ * TIMEOUT (#2587): this path sat entirely outside the transport seam — no abort signal, no
14
+ * connection-error taxonomy — so a stalled artifact host (headers, then a body that never
15
+ * completes) hung the CLI forever, even though the 0.5.0 CHANGELOG's "a stalled body hung the CLI
16
+ * forever" note (#2546) reads as if every response body were covered. #2546 only covered API-call
17
+ * bodies read through `sendWithRetry`; this download never rode that seam. The fix REUSES
18
+ * `sendWithRetry`'s own per-attempt abort budget and connection-error taxonomy
19
+ * (`createAttemptBudget` / `toConnectionError`, exported from `../client.js`) rather than a second
20
+ * timeout implementation, and mirrors the same default (`DEFAULT_TIMEOUT_MS`, 120 s per attempt) —
21
+ * there is no retry here, since artifact GETs are not the API's 429 domain.
22
+ *
23
+ * PARTIAL-FILE POLICY (#2587 dod-1 / E1, decided + recorded, scoped to the DOWNLOAD phase only —
24
+ * cr-4): the response is buffered COMPLETELY in memory (`res.arrayBuffer()`) before either write
25
+ * path (`writeFileSync` / `process.stdout.write`) runs, so an ABORT — whether from the timeout or
26
+ * any other connection failure — always fails BEFORE a single byte is written; no partial file is
27
+ * ever left at `dest` from a download-side failure. This does NOT cover a failure DURING the write
28
+ * itself: `writeFileSync` opens `dest` with `O_TRUNC` and writes in a loop, so e.g. an `ENOSPC`
29
+ * partway through a large write leaves a created, truncated file at `dest` (any pre-existing file
30
+ * there already clobbered by the `O_TRUNC` open). That failure mode is accepted as-is for now (no
31
+ * write-to-temp-then-`renameSync` here) — the CLI still exits 1, and the write phase's tiny
32
+ * (post-buffering) failure window is a separate, unaddressed edge from the abort guarantee above.
33
+ *
34
+ * @param timeoutMs Per-attempt abort budget in ms; defaults to {@link DEFAULT_TIMEOUT_MS} (120 s).
35
+ * `<= 0` DISABLES the budget entirely (no `AbortSignal` is attached, restoring the exact
36
+ * unbounded-hang behavior this ticket fixes) rather than meaning "no override" — there is no
37
+ * sentinel for that. #2587 cr-8: no CLI command reaches this parameter today
38
+ * (`commands/screenshots.ts` always calls the 2-arg form), so a future caller — e.g. an
39
+ * anticipated `--timeout` flag threaded through here — must NOT pass `0` to mean "use the
40
+ * default"; it must omit the argument or pass `DEFAULT_TIMEOUT_MS` explicitly.
12
41
  */
13
- export declare function downloadArtifact(url: string, dest: string): Promise<void>;
42
+ export declare function downloadArtifact(url: string, dest: string, timeoutMs?: number): Promise<void>;
@@ -1,4 +1,25 @@
1
1
  import { writeFileSync } from 'node:fs';
2
+ import { createAttemptBudget, toConnectionError, DEFAULT_TIMEOUT_MS } from '../client.js';
3
+ /**
4
+ * Redact a URL for error-message rendering: origin + pathname only, query string dropped.
5
+ *
6
+ * #2587 cr-2: `image_url` for a completed screenshot is an AWS SigV4 PRESIGNED URL
7
+ * (`X-Amz-Credential=…&X-Amz-Signature=…`, `gateway/src/lib/signed-url.ts`), unlike every other
8
+ * `toConnectionError` call site in `client.ts` which passes a bare relative API path with no
9
+ * credential material. Rendering the full URL in `handleError`'s unmodified `err.message` would
10
+ * print a live 600s-valid signature to stderr on every new failure path this diff adds. On a
11
+ * malformed URL (should not happen — `image_url` is always an absolute URL from the API) fall
12
+ * back to the original string rather than throwing out of an error-handling path.
13
+ */
14
+ function redactedPath(url) {
15
+ try {
16
+ const u = new URL(url);
17
+ return `${u.origin}${u.pathname}`;
18
+ }
19
+ catch {
20
+ return url;
21
+ }
22
+ }
2
23
  /**
3
24
  * Download a screenshot artifact from its `image_url` and write the raw bytes to `dest`.
4
25
  *
@@ -10,13 +31,77 @@ import { writeFileSync } from 'node:fs';
10
31
  * test that stubs `globalThis.fetch` in a `beforeEach` — after this module was imported — still
11
32
  * sees its stub (G-049). A non-2xx response throws with the HTTP status so `handleError` renders a
12
33
  * clean one-liner and exits 1.
34
+ *
35
+ * TIMEOUT (#2587): this path sat entirely outside the transport seam — no abort signal, no
36
+ * connection-error taxonomy — so a stalled artifact host (headers, then a body that never
37
+ * completes) hung the CLI forever, even though the 0.5.0 CHANGELOG's "a stalled body hung the CLI
38
+ * forever" note (#2546) reads as if every response body were covered. #2546 only covered API-call
39
+ * bodies read through `sendWithRetry`; this download never rode that seam. The fix REUSES
40
+ * `sendWithRetry`'s own per-attempt abort budget and connection-error taxonomy
41
+ * (`createAttemptBudget` / `toConnectionError`, exported from `../client.js`) rather than a second
42
+ * timeout implementation, and mirrors the same default (`DEFAULT_TIMEOUT_MS`, 120 s per attempt) —
43
+ * there is no retry here, since artifact GETs are not the API's 429 domain.
44
+ *
45
+ * PARTIAL-FILE POLICY (#2587 dod-1 / E1, decided + recorded, scoped to the DOWNLOAD phase only —
46
+ * cr-4): the response is buffered COMPLETELY in memory (`res.arrayBuffer()`) before either write
47
+ * path (`writeFileSync` / `process.stdout.write`) runs, so an ABORT — whether from the timeout or
48
+ * any other connection failure — always fails BEFORE a single byte is written; no partial file is
49
+ * ever left at `dest` from a download-side failure. This does NOT cover a failure DURING the write
50
+ * itself: `writeFileSync` opens `dest` with `O_TRUNC` and writes in a loop, so e.g. an `ENOSPC`
51
+ * partway through a large write leaves a created, truncated file at `dest` (any pre-existing file
52
+ * there already clobbered by the `O_TRUNC` open). That failure mode is accepted as-is for now (no
53
+ * write-to-temp-then-`renameSync` here) — the CLI still exits 1, and the write phase's tiny
54
+ * (post-buffering) failure window is a separate, unaddressed edge from the abort guarantee above.
55
+ *
56
+ * @param timeoutMs Per-attempt abort budget in ms; defaults to {@link DEFAULT_TIMEOUT_MS} (120 s).
57
+ * `<= 0` DISABLES the budget entirely (no `AbortSignal` is attached, restoring the exact
58
+ * unbounded-hang behavior this ticket fixes) rather than meaning "no override" — there is no
59
+ * sentinel for that. #2587 cr-8: no CLI command reaches this parameter today
60
+ * (`commands/screenshots.ts` always calls the 2-arg form), so a future caller — e.g. an
61
+ * anticipated `--timeout` flag threaded through here — must NOT pass `0` to mean "use the
62
+ * default"; it must omit the argument or pass `DEFAULT_TIMEOUT_MS` explicitly.
13
63
  */
14
- export async function downloadArtifact(url, dest) {
15
- const res = await globalThis.fetch(url);
16
- if (!res.ok) {
17
- throw new Error(`artifact download failed: HTTP ${res.status}`);
64
+ export async function downloadArtifact(url, dest, timeoutMs = DEFAULT_TIMEOUT_MS) {
65
+ const budget = timeoutMs > 0 ? createAttemptBudget(timeoutMs) : undefined;
66
+ const safePath = redactedPath(url);
67
+ let bytes;
68
+ try {
69
+ let res;
70
+ try {
71
+ res = await globalThis.fetch(url, budget ? { signal: budget.signal } : {});
72
+ }
73
+ catch (err) {
74
+ // No HTTP response was produced (timeout, abort, socket/DNS/TLS) — re-throw as the SDK's own
75
+ // ByteKitConnectionError, exactly like the API transport, so a hung/broken artifact host can
76
+ // never hang the CLI and `handleError` renders the same taxonomy either way. `safePath` (not
77
+ // `url`) is what gets rendered — see {@link redactedPath}.
78
+ throw toConnectionError(err, 'GET', safePath, budget?.signal.aborted === true);
79
+ }
80
+ if (!res.ok) {
81
+ // #2587 cr2-1: no drain here, unlike client.ts's 429 path. cr-11 originally added one
82
+ // (mirroring client.ts's corrected 429-drain mechanism, cr-3), reasoning that a full
83
+ // `text()` read lets undici return the connection to the pool. That reasoning does not
84
+ // apply on THIS path: `commands/screenshots.ts` throws this error straight into
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
88
+ // per-attempt abort budget (or, under `timeoutMs <= 0`, by nothing at all) for zero benefit,
89
+ // so it is removed outright rather than merely capped.
90
+ throw new Error(`artifact download failed: HTTP ${res.status}`);
91
+ }
92
+ try {
93
+ bytes = Buffer.from(await res.arrayBuffer());
94
+ }
95
+ catch (err) {
96
+ // A body read that never completes is aborted by the same budget; undici then rejects the
97
+ // stream (mirrors #2546's body-read guard). Map it into the same taxonomy as above.
98
+ throw toConnectionError(err, 'GET', safePath, budget?.signal.aborted === true);
99
+ }
100
+ }
101
+ finally {
102
+ // Clear the timer on every exit path so a healthy download leaves no dangling budget timer.
103
+ budget?.cleanup();
18
104
  }
19
- const bytes = Buffer.from(await res.arrayBuffer());
20
105
  if (dest === '-') {
21
106
  process.stdout.write(bytes);
22
107
  return;
@@ -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)
@@ -1,12 +1,50 @@
1
1
  /**
2
- * Read a newline-delimited file of URLs and return a flat array of URL strings.
2
+ * One parsed `--file` line: a bare URL string, or a per-item config object carrying `url`.
3
3
  *
4
- * Each non-blank line is either a plain URL or a JSON object of the form
5
- * `{"url":"https://..."}`. Both forms normalize to the bare URL string, because
6
- * the v1 bulk endpoints (`/v1/bulk`, `/v1/scrape/bulk`, `/v1/fetch/bulk`) expect
7
- * `urls` to be an array of URL strings — NOT an array of objects.
4
+ * The two arms are not interchangeable on the wire. `/v1/bulk` and `/v1/scrape/bulk` accept
5
+ * `urls` (strings) XOR `items` (objects); `/v1/fetch/bulk` has no `items` property at all and
6
+ * takes a heterogeneous `urls` union. Callers pick the right shape — see {@link toBulkItems}.
7
+ */
8
+ export type BulkLine = string | Record<string, unknown>;
9
+ /**
10
+ * Read a newline-delimited `--file` and return one {@link BulkLine} per non-blank line.
11
+ *
12
+ * Line grammar:
13
+ * - a non-JSON line -> the raw line (a plain URL string)
14
+ * - a JSON object whose ONLY key is `url` -> the bare url string
15
+ * - a JSON object carrying other keys too -> the object verbatim (per-item config)
16
+ * - a JSON object with a missing/non-string url -> `InvalidArgumentError` (rejected locally)
17
+ * - a JSON non-object (number, array, string) -> the raw line
18
+ *
19
+ * The "only key is `url`" arm is what keeps the inverse case BYTE-IDENTICAL rather than merely
20
+ * equivalent: a file of url-only lines produces the exact same `{ urls, webhook_url }` body as
21
+ * before per-item config existed (#2596 E2).
22
+ *
23
+ * Rejecting a malformed `url` is a deliberate behavior change (#2596 DoD 8). The previous parser
24
+ * fell through to the raw line, so `{"url": 123}` was sent to the server AS a URL — a confusing
25
+ * 422 for what is a local input mistake. `InvalidArgumentError` surfaces through the same
26
+ * local-usage-error path as every argParser rejection (exit 1), before any request is issued.
27
+ *
28
+ * Throws on a missing file (ENOENT from readFileSync) — callers surface this via their
29
+ * handleError path.
30
+ *
31
+ * The rejection message for a malformed line (#2596 cr-1 / E12) names the 1-based PHYSICAL line
32
+ * NUMBER (i.e. the line's own position in the file, counting blank lines) and the line's own KEY
33
+ * NAMES only — never the raw line or any value. A `--file` line is where per-item `cookies`,
34
+ * `headers`, and auth-bearing `metadata` now live (#2596), so echoing the raw line back would
35
+ * leak session tokens and similar secrets to stderr, CI logs, and shell transcripts. Because the
36
+ * raw line is deliberately never echoed, the line number is the ONLY locator left in the message —
37
+ * it must be computed BEFORE blank lines are filtered out, or it silently mis-locates the bad
38
+ * entry whenever the file contains blank lines above it (#2596 cr-2).
39
+ */
40
+ export declare function parseBulkLines(file: string): BulkLine[];
41
+ /**
42
+ * Promote every {@link BulkLine} to the `items[]` object shape used by `/v1/bulk` and
43
+ * `/v1/scrape/bulk`.
8
44
  *
9
- * Throws on a missing file (ENOENT from readFileSync) — callers surface this via
10
- * their handleError path.
45
+ * Deliberately stamps NOTHING beyond `url`. `/v1/bulk` owns capture-kind precedence server-side
46
+ * (per-item value, then `defaults`, then the endpoint default) since #2641 made the per-item
47
+ * field optional; a CLI-side default would silently override a caller's `--defaults` and is
48
+ * grep-gated out (#2596 G7).
11
49
  */
12
- export declare function parseUrlLines(file: string): string[];
50
+ export declare function toBulkItems(lines: BulkLine[]): Array<Record<string, unknown>>;