@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.
@@ -1,34 +1,73 @@
1
1
  import { readFileSync } from 'node:fs';
2
+ import { InvalidArgumentError } from 'commander';
2
3
  /**
3
- * Read a newline-delimited file of URLs and return a flat array of URL strings.
4
+ * Read a newline-delimited `--file` and return one {@link BulkLine} per non-blank line.
4
5
  *
5
- * Each non-blank line is either a plain URL or a JSON object of the form
6
- * `{"url":"https://..."}`. Both forms normalize to the bare URL string, because
7
- * the v1 bulk endpoints (`/v1/bulk`, `/v1/scrape/bulk`, `/v1/fetch/bulk`) expect
8
- * `urls` to be an array of URL strings — NOT an array of objects.
6
+ * Line grammar:
7
+ * - a non-JSON line -> the raw line (a plain URL string)
8
+ * - a JSON object whose ONLY key is `url` -> the bare url string
9
+ * - a JSON object carrying other keys too -> the object verbatim (per-item config)
10
+ * - a JSON object with a missing/non-string url -> `InvalidArgumentError` (rejected locally)
11
+ * - a JSON non-object (number, array, string) -> the raw line
9
12
  *
10
- * Throws on a missing file (ENOENT from readFileSync) — callers surface this via
11
- * their handleError path.
13
+ * The "only key is `url`" arm is what keeps the inverse case BYTE-IDENTICAL rather than merely
14
+ * equivalent: a file of url-only lines produces the exact same `{ urls, webhook_url }` body as
15
+ * before per-item config existed (#2596 E2).
16
+ *
17
+ * Rejecting a malformed `url` is a deliberate behavior change (#2596 DoD 8). The previous parser
18
+ * fell through to the raw line, so `{"url": 123}` was sent to the server AS a URL — a confusing
19
+ * 422 for what is a local input mistake. `InvalidArgumentError` surfaces through the same
20
+ * local-usage-error path as every argParser rejection (exit 1), before any request is issued.
21
+ *
22
+ * Throws on a missing file (ENOENT from readFileSync) — callers surface this via their
23
+ * handleError path.
24
+ *
25
+ * The rejection message for a malformed line (#2596 cr-1 / E12) names the 1-based PHYSICAL line
26
+ * NUMBER (i.e. the line's own position in the file, counting blank lines) and the line's own KEY
27
+ * NAMES only — never the raw line or any value. A `--file` line is where per-item `cookies`,
28
+ * `headers`, and auth-bearing `metadata` now live (#2596), so echoing the raw line back would
29
+ * leak session tokens and similar secrets to stderr, CI logs, and shell transcripts. Because the
30
+ * raw line is deliberately never echoed, the line number is the ONLY locator left in the message —
31
+ * it must be computed BEFORE blank lines are filtered out, or it silently mis-locates the bad
32
+ * entry whenever the file contains blank lines above it (#2596 cr-2).
12
33
  */
13
- export function parseUrlLines(file) {
34
+ export function parseBulkLines(file) {
14
35
  return readFileSync(file, 'utf8')
15
36
  .split('\n')
16
- .map((l) => l.trim())
17
- .filter((l) => l.length > 0)
18
- .map((line) => {
37
+ .map((raw, index) => ({ line: raw.trim(), lineNumber: index + 1 }))
38
+ .filter(({ line }) => line.length > 0)
39
+ .map(({ line, lineNumber }) => {
40
+ let parsed;
19
41
  try {
20
- const parsed = JSON.parse(line);
21
- if (parsed !== null && typeof parsed === 'object' && 'url' in parsed) {
22
- const url = parsed.url;
23
- if (typeof url === 'string')
24
- return url;
25
- }
26
- // Parsed as JSON but not a {url:string} object — fall back to the raw line.
27
- return line;
42
+ parsed = JSON.parse(line);
28
43
  }
29
44
  catch {
30
45
  // Not JSON — treat the whole line as a plain URL string.
31
46
  return line;
32
47
  }
48
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
49
+ // Parsed as JSON but not an object — fall back to the raw line, as before.
50
+ return line;
51
+ }
52
+ const obj = parsed;
53
+ if (typeof obj['url'] !== 'string' || obj['url'].length === 0) {
54
+ const keys = Object.keys(obj).join(', ') || '(none)';
55
+ throw new InvalidArgumentError(`--file line ${lineNumber} must carry a non-empty string "url" (keys present: ${keys})`);
56
+ }
57
+ // A line whose only key is `url` is indistinguishable from a plain URL line — keep it a
58
+ // bare string so url-only files stay byte-identical on the wire.
59
+ return Object.keys(obj).length === 1 ? obj['url'] : obj;
33
60
  });
34
61
  }
62
+ /**
63
+ * Promote every {@link BulkLine} to the `items[]` object shape used by `/v1/bulk` and
64
+ * `/v1/scrape/bulk`.
65
+ *
66
+ * Deliberately stamps NOTHING beyond `url`. `/v1/bulk` owns capture-kind precedence server-side
67
+ * (per-item value, then `defaults`, then the endpoint default) since #2641 made the per-item
68
+ * field optional; a CLI-side default would silently override a caller's `--defaults` and is
69
+ * grep-gated out (#2596 G7).
70
+ */
71
+ export function toBulkItems(lines) {
72
+ return lines.map((l) => (typeof l === 'string' ? { url: l } : l));
73
+ }
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;
@@ -293,7 +388,7 @@ const BYTEKIT_ERROR_NAMES = new Set(['ByteKitError', 'ByteKitConnectionError']);
293
388
  * A non-empty API/connection error `code`, or undefined when absent.
294
389
  *
295
390
  * Scoped by `err.name` to the ByteKit taxonomy: plenty of OTHER errors also carry a string
296
- * `.code` — Node fs/system errors (ENOENT from `parseUrlLines`'s readFileSync, EACCES on a `-o`
391
+ * `.code` — Node fs/system errors (ENOENT from `parseBulkLines`'s readFileSync, EACCES on a `-o`
297
392
  * write) — whose message already embeds the code, so suffixing them would render the redundant
298
393
  * `Error: ENOENT: … (ENOENT)`. Only `ByteKitError` / `ByteKitConnectionError` codes qualify.
299
394
  */
@@ -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.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "ByteKit CLI — wraps @hunt-labs/bytekit-sdk as shell subcommands",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -41,10 +41,10 @@
41
41
  ],
42
42
  "dependencies": {
43
43
  "commander": "^12.0.0",
44
- "@hunt-labs/bytekit-sdk": "^0.4.0"
44
+ "@hunt-labs/bytekit-sdk": "^0.6.0"
45
45
  },
46
46
  "scripts": {
47
- "build": "tsc",
47
+ "build": "rm -rf dist tsconfig.tsbuildinfo && tsc",
48
48
  "typecheck": "tsc --noEmit",
49
49
  "test": "vitest run"
50
50
  }