@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.
@@ -1,7 +1,7 @@
1
1
  import { Command } from 'commander';
2
2
  import { buildClient } from '../client.js';
3
3
  import { printResult, handleError, resolveOutputMode, emitResult } from '../output.js';
4
- import { parseUrlLines } from '../lib/url-lines.js';
4
+ import { parseBulkLines, toBulkItems } from '../lib/url-lines.js';
5
5
  import { parseIntFlag, parseEnum, parseCsv, parseCacheTtl, parseJsonFlag, collectFormats, } from '../lib/parse-flags.js';
6
6
  /** The `ScrapeRequest.markdown_mode` spec enum. */
7
7
  const MARKDOWN_MODES = ['article', 'raw', 'llm'];
@@ -45,15 +45,34 @@ export function makeScrapeCommand(getGlobal) {
45
45
  bulkCmd
46
46
  .command('create')
47
47
  .description('Create a bulk scrape job')
48
- .requiredOption('--file <path>', 'Path to newline-delimited file of URLs (plain URL or {"url":"..."} per line)')
48
+ .requiredOption('--file <path>', 'Path to a newline-delimited file. Each line is a plain URL, or a JSON object carrying "url" plus per-item config. A line whose only key is "url" behaves exactly as before (sent as a bare URL string); any line with additional keys switches the payload to the per-item shape.')
49
49
  .requiredOption('--webhook-url <url>', 'HTTPS webhook URL notified when the job completes')
50
+ .option('--webhook-secret <secret>', 'HMAC secret used to sign the completion webhook (omitted → unsigned)')
51
+ .option('--defaults <json>', 'Defaults applied to every entry, as a JSON object (omitted → none). Unknown keys are rejected by /v1/bulk (422) and ignored by scrape bulk / fetch bulk; fetch bulk additionally rejects browser-only keys (cookies, wait_until, wait_for_selector, delay_ms) with a 400.', (v) => parseJsonFlag('--defaults', v, 'object'))
52
+ .option('--metadata <json>', `Arbitrary metadata echoed on the job envelope, as a JSON object, e.g. '{"run":"7"}' (omitted → none)`, (v) => parseJsonFlag('--metadata', v, 'object'))
53
+ // /v1/scrape/bulk is the ONE bulk endpoint with a top-level `custom`
54
+ // (validators/scrape-bulk.ts:88). Per-entry `custom` arrives through --file lines instead.
55
+ .option('--custom <json>', `User payload echoed on every scrape entry, as a JSON object, e.g. '{"job":"1"}' (omitted → none; a per-entry value in --file wins)`, (v) => parseJsonFlag('--custom', v, 'object'))
50
56
  .action(async (opts) => {
51
57
  const g = getGlobal();
52
58
  const client = buildClient(g);
53
59
  try {
54
60
  // readFileSync throws ENOENT on missing file — caught by handleError below
55
- const urls = parseUrlLines(opts.file);
56
- const result = await client.scrape.bulk.create({ urls, webhook_url: opts.webhookUrl });
61
+ const lines = parseBulkLines(opts.file);
62
+ // `urls` XOR `items` (validators/scrape-bulk.ts:91-98) — send exactly one, keeping the
63
+ // pre-#2596 `{ urls, webhook_url }` body byte-identical for url-only files (E2).
64
+ const body = lines.every((l) => typeof l === 'string')
65
+ ? { urls: lines, webhook_url: opts.webhookUrl }
66
+ : { items: toBulkItems(lines), webhook_url: opts.webhookUrl };
67
+ if (opts.webhookSecret !== undefined)
68
+ body['webhook_secret'] = opts.webhookSecret;
69
+ if (opts.defaults !== undefined)
70
+ body['defaults'] = opts.defaults;
71
+ if (opts.metadata !== undefined)
72
+ body['metadata'] = opts.metadata;
73
+ if (opts.custom !== undefined)
74
+ body['custom'] = opts.custom;
75
+ const result = await client.scrape.bulk.create(body);
57
76
  printResult(result, g.json);
58
77
  }
59
78
  catch (err) {
@@ -245,7 +264,7 @@ export function makeScrapeCommand(getGlobal) {
245
264
  .description('Get a scrape job by ID (use --raw/-o/--format to retrieve a completed body)')
246
265
  .option('--raw', 'Print the requested format body raw, with no JSON wrapper')
247
266
  .option('-o, --output <file>', 'Write the output to the given file path (implies --raw unless --json is set)')
248
- .option('--format <format>', 'Which persisted format to emit raw when several are present')
267
+ .option('--format <format>', 'Which persisted format to emit — only takes effect with --raw/-o; ignored otherwise')
249
268
  .action(async (id, opts) => {
250
269
  const g = getGlobal();
251
270
  const client = buildClient(g);
@@ -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,6 +23,24 @@ 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 (this version's own section — `[Unreleased]` is kept
41
+ * deliberately empty per `packaging.test.ts:203`; see #2620).
42
+ */
43
+ const QUEUED_EXIT_CODE = 2;
26
44
  /**
27
45
  * Extract a non-empty string `image_url` from a screenshot envelope, or throw an actionable error.
28
46
  *
@@ -53,6 +71,21 @@ function extractImageUrl(result) {
53
71
  async function emitScreenshot(result, opts) {
54
72
  if (opts.isAsync === true) {
55
73
  printResult(result, opts.json);
74
+ // Discriminate on the --async FLAG, never on the response body. The screenshot 202 reports a
75
+ // PENDING status (gateway src/routes/screenshots.ts:138) — not the queued status that
76
+ // `scrape create`'s envelope guard (commands/scrape.ts:49-53) tests for. Reusing that guard
77
+ // here would never fire in production and this branch would silently no-op. Grep gate G11
78
+ // (#2596) enforces the absence of a response-status discriminator in this file, which is why
79
+ // neither the symbol nor the comparison is spelled out above.
80
+ //
81
+ // The assignment sits INSIDE this branch, ahead of the `-o` return below, so `--async -o`
82
+ // carries the same exit code as bare `--async` (#2596 DoD 15 / E20). `--json` keeps exit 0:
83
+ // the envelope is a complete machine-readable payload, identical to scrape's split
84
+ // (scrape.ts:313-317). Soft `process.exitCode`, never `process.exit` — a hard exit runs
85
+ // before Node drains an async piped stdout and would truncate the envelope just written
86
+ // (#2544).
87
+ if (!opts.json)
88
+ process.exitCode = QUEUED_EXIT_CODE;
56
89
  return;
57
90
  }
58
91
  if (opts.output !== undefined) {
@@ -89,6 +122,9 @@ export function makeScreenshotsCommand(getGlobal) {
89
122
  .option('--include-html', 'Include the rendered HTML alongside the screenshot')
90
123
  .option('--country <cc>', 'Two-letter country code to route the request through (e.g. us, de)')
91
124
  .option('--language <lang>', 'Accept-Language override (e.g. en-US)')
125
+ .option('--headers <json>', `Extra request headers as a JSON object, e.g. '{"X-A":"b"}' (omitted → none)`, (v) => parseJsonFlag('--headers', v, 'object'))
126
+ .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'))
127
+ .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
128
  .option('--async', 'Queue the screenshot and return a job envelope immediately (no wait)')
93
129
  .option('-o, --output <file>', 'Download the captured image to this file path (use - for stdout). Not valid with --json.')
94
130
  .action(async (opts) => {
@@ -135,6 +171,14 @@ export function makeScreenshotsCommand(getGlobal) {
135
171
  body['country'] = opts.country;
136
172
  if (opts.language !== undefined)
137
173
  body['language'] = opts.language;
174
+ // The server defaults headers/cookies/metadata to {} / [] / {}; writing an empty
175
+ // container on a bare create would materialize those defaults (#2480 E1).
176
+ if (opts.headers !== undefined)
177
+ body['headers'] = opts.headers;
178
+ if (opts.cookies !== undefined)
179
+ body['cookies'] = opts.cookies;
180
+ if (opts.metadata !== undefined)
181
+ body['metadata'] = opts.metadata;
138
182
  try {
139
183
  // Reject --json + -o BEFORE issuing the request: -o downloads BINARY artifact bytes, so
140
184
  // it can never coexist with --json (which must stay a text envelope). Thrown here so no
@@ -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,6 +13,15 @@ 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
27
  const client = buildClient(g);
@@ -26,6 +35,20 @@ export function makeSitemapCommand(getGlobal) {
26
35
  body['max_depth'] = opts.maxDepth;
27
36
  if (opts.maxUrls !== undefined)
28
37
  body['max_urls'] = opts.maxUrls;
38
+ if (opts.webhookUrl !== undefined)
39
+ body['webhook_url'] = opts.webhookUrl;
40
+ if (opts.webhookSecret !== undefined)
41
+ body['webhook_secret'] = opts.webhookSecret;
42
+ if (opts.cacheTtl !== undefined)
43
+ body['cache_ttl'] = opts.cacheTtl;
44
+ // `compact` server-defaults to FALSE, so it is written ONLY on `=== true` — writing
45
+ // `false` on a bare create would materialize a default (#2480 E1).
46
+ if (opts.compact === true)
47
+ body['compact'] = true;
48
+ if (opts.process !== undefined)
49
+ body['process'] = opts.process;
50
+ if (opts.metadata !== undefined)
51
+ body['metadata'] = opts.metadata;
29
52
  try {
30
53
  const result = await client.sitemap.create(body);
31
54
  printResult(result, g.json);
@@ -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,76 @@ 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 `process.exit(1)`s immediately after — there is no next request in
86
+ // this process for a reclaimed socket to serve. A drain here was bounded only by the FULL
87
+ // per-attempt abort budget (or, under `timeoutMs <= 0`, by nothing at all) for zero benefit,
88
+ // so it is removed outright rather than merely capped.
89
+ throw new Error(`artifact download failed: HTTP ${res.status}`);
90
+ }
91
+ try {
92
+ bytes = Buffer.from(await res.arrayBuffer());
93
+ }
94
+ catch (err) {
95
+ // A body read that never completes is aborted by the same budget; undici then rejects the
96
+ // stream (mirrors #2546's body-read guard). Map it into the same taxonomy as above.
97
+ throw toConnectionError(err, 'GET', safePath, budget?.signal.aborted === true);
98
+ }
99
+ }
100
+ finally {
101
+ // Clear the timer on every exit path so a healthy download leaves no dangling budget timer.
102
+ budget?.cleanup();
18
103
  }
19
- const bytes = Buffer.from(await res.arrayBuffer());
20
104
  if (dest === '-') {
21
105
  process.stdout.write(bytes);
22
106
  return;
@@ -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>>;
@@ -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.js CHANGED
@@ -293,7 +293,7 @@ const BYTEKIT_ERROR_NAMES = new Set(['ByteKitError', 'ByteKitConnectionError']);
293
293
  * A non-empty API/connection error `code`, or undefined when absent.
294
294
  *
295
295
  * 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`
296
+ * `.code` — Node fs/system errors (ENOENT from `parseBulkLines`'s readFileSync, EACCES on a `-o`
297
297
  * write) — whose message already embeds the code, so suffixing them would render the redundant
298
298
  * `Error: ENOENT: … (ENOENT)`. Only `ByteKitError` / `ByteKitConnectionError` codes qualify.
299
299
  */
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.6.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.5.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
  }