@hunt-labs/bytekit-cli 0.2.0 → 0.3.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,6 +1,9 @@
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';
5
+ /** The `listMonitors` `status` query enum. */
6
+ const MONITOR_STATUSES = ['active', 'processing', 'paused', 'cancelled', 'suspended'];
4
7
  export function makeMonitorsCommand(getGlobal) {
5
8
  const cmd = new Command('monitors').description('Page-change monitor operations');
6
9
  const capturesCmd = new Command('captures').description('Monitor capture sub-resources');
@@ -29,13 +32,18 @@ export function makeMonitorsCommand(getGlobal) {
29
32
  .action(async (opts) => {
30
33
  const g = getGlobal();
31
34
  const client = buildClient(g);
35
+ // Key-checked subset of the SDK's MonitorCreateRequest. The CLI intentionally omits the
36
+ // spec-required-with-@default fields (type, change_threshold, notify_on) and relies on
37
+ // the server-side defaults, so the final call needs an explicit assertion — type-level
38
+ // only; the wire body is unchanged (pinned by __tests__/request-shapes.test.ts).
32
39
  const body = {
33
40
  url: opts.url,
41
+ // Commander delivers a plain string; the server validates the enum (422 on a bad value).
34
42
  interval_type: opts.intervalType,
35
43
  webhook_url: opts.webhookUrl,
36
44
  };
37
45
  if (opts.cron !== undefined)
38
- body['cron_expression'] = opts.cron;
46
+ body.cron_expression = opts.cron;
39
47
  try {
40
48
  const result = await client.monitors.create(body);
41
49
  printResult(result, g.json);
@@ -47,11 +55,26 @@ export function makeMonitorsCommand(getGlobal) {
47
55
  cmd
48
56
  .command('list')
49
57
  .description('List all monitors')
50
- .action(async () => {
58
+ .option('--limit <n>', 'Maximum number of monitors to return', (v) => parseIntFlag('--limit', v))
59
+ .option('--cursor <cursor>', 'Pagination cursor from a previous page')
60
+ .option('--status <status>', `Filter by status: ${MONITOR_STATUSES.join(', ')} (default active)`, (v) => parseEnum('--status', MONITOR_STATUSES, v))
61
+ .action(async (opts) => {
51
62
  const g = getGlobal();
52
63
  const client = buildClient(g);
64
+ // Build the params object with the same omit-when-absent rule as the body builders. When NO
65
+ // query flag is passed, call list() with ZERO arguments (never list({}) / list(undefined)) so
66
+ // the inverse-case request-shape assertion `monitors.list` args length 0 stays green (#2480).
67
+ const params = {};
68
+ if (opts.limit !== undefined)
69
+ params.limit = opts.limit;
70
+ if (opts.cursor !== undefined)
71
+ params.cursor = opts.cursor;
72
+ if (opts.status !== undefined)
73
+ params.status = opts.status;
53
74
  try {
54
- const result = await client.monitors.list();
75
+ const result = Object.keys(params).length > 0
76
+ ? await client.monitors.list(params)
77
+ : await client.monitors.list();
55
78
  printResult(result, g.json);
56
79
  }
57
80
  catch (err) {
@@ -2,6 +2,34 @@ import { Command } from 'commander';
2
2
  import { buildClient } from '../client.js';
3
3
  import { printResult, handleError, resolveOutputMode, emitResult } from '../output.js';
4
4
  import { parseUrlLines } from '../lib/url-lines.js';
5
+ import { parseIntFlag, parseEnum, parseCsv } from '../lib/parse-flags.js';
6
+ /** The `ScrapeRequest.markdown_mode` spec enum. */
7
+ const MARKDOWN_MODES = ['article', 'raw', 'llm'];
8
+ /**
9
+ * Exit code when a queued (202 async) envelope reaches a non-JSON output mode.
10
+ *
11
+ * Mirrors the transport-hardening ticket's `QUEUED_RAW_EXIT_CODE` in `output.ts` (PR #2508)
12
+ * EXACTLY — deliberately a local constant, not an import, so this branch carries no dependency on
13
+ * that PR's symbols while the two guards still compose idempotently post-merge. Deliberately NOT 0
14
+ * (a content-body success) and NOT 1 (a usage/API error): the job WAS accepted and billed, there
15
+ * is simply no content body yet, and a distinct code lets a caller branch on "queued" without
16
+ * parsing stderr. Human mode shares the same code (soft `process.exitCode`) so "queued, not
17
+ * complete" is branchable there too, for consistency with the raw-mode signal.
18
+ */
19
+ const QUEUED_EXIT_CODE = 2;
20
+ /**
21
+ * A 202 ScrapeQueuedEnvelope: `status: 'queued'` plus a stable `sc_` id (openapi.yaml).
22
+ *
23
+ * The strict discriminator — identical to #2508's `output.ts` guard, so both guards agree on
24
+ * every input post-merge. A malformed queued-ish shape (no id, or a non-'queued' status) falls
25
+ * through to the normal output path and surfaces `output.ts`'s own diagnostics (#2480 E4s).
26
+ */
27
+ function isQueuedEnvelope(result) {
28
+ if (typeof result !== 'object' || result === null)
29
+ return false;
30
+ const obj = result;
31
+ return obj.status === 'queued' && typeof obj.id === 'string' && obj.id.length > 0;
32
+ }
5
33
  export function makeScrapeCommand(getGlobal) {
6
34
  const cmd = new Command('scrape').description('Scrape URL content');
7
35
  const bulkCmd = new Command('bulk').description('Bulk scrape operations');
@@ -43,6 +71,16 @@ export function makeScrapeCommand(getGlobal) {
43
71
  .description('Create a scrape job')
44
72
  .requiredOption('--url <url>', 'URL to scrape')
45
73
  .option('--format <format>', 'Output format (e.g. markdown, html); sent as the formats array')
74
+ .option('--country <cc>', 'Two-letter country code to route the request through (e.g. us, de)')
75
+ .option('--timeout-ms <n>', 'Per-request timeout in milliseconds', (v) => parseIntFlag('--timeout-ms', v))
76
+ .option('--markdown-mode <mode>', `Markdown extraction mode: ${MARKDOWN_MODES.join(', ')}`, (v) => parseEnum('--markdown-mode', MARKDOWN_MODES, v))
77
+ .option('--async', 'Queue the scrape and return a job envelope immediately (no wait)')
78
+ .option('--webhook-url <url>', 'HTTPS webhook URL notified when an async scrape completes')
79
+ .option('--token-budget <n>', 'Maximum tokens of markdown to return', (v) => parseIntFlag('--token-budget', v))
80
+ .option('--clean-markdown', 'Post-process markdown with the LLM cleaner (billed 3x)')
81
+ .option('--include-tags <csv>', 'Comma-separated HTML tags to keep (e.g. article,main)')
82
+ .option('--exclude-tags <csv>', 'Comma-separated HTML tags to drop (e.g. nav,footer)')
83
+ .option('--mobile', 'Render with a mobile viewport/user-agent')
46
84
  .option('--raw', 'Print the requested format body raw, with no JSON wrapper')
47
85
  .option('-o, --output <file>', 'Write the output to the given file path (implies --raw unless --json is set)')
48
86
  .action(async (opts) => {
@@ -54,6 +92,28 @@ export function makeScrapeCommand(getGlobal) {
54
92
  // formats: ["undefined"].
55
93
  if (opts.format !== undefined)
56
94
  body['formats'] = [opts.format];
95
+ // Each optional flag maps 1:1 to its snake_case ScrapeRequest field and is written ONLY
96
+ // when the flag is present — a boolean must never serialize `false` (#2480 E1).
97
+ if (opts.country !== undefined)
98
+ body['country'] = opts.country;
99
+ if (opts.timeoutMs !== undefined)
100
+ body['timeout_ms'] = opts.timeoutMs;
101
+ if (opts.markdownMode !== undefined)
102
+ body['markdown_mode'] = opts.markdownMode;
103
+ if (opts.async === true)
104
+ body['async'] = true;
105
+ if (opts.webhookUrl !== undefined)
106
+ body['webhook_url'] = opts.webhookUrl;
107
+ if (opts.tokenBudget !== undefined)
108
+ body['token_budget'] = opts.tokenBudget;
109
+ if (opts.cleanMarkdown === true)
110
+ body['clean_markdown'] = true;
111
+ if (opts.includeTags !== undefined)
112
+ body['include_tags'] = parseCsv(opts.includeTags);
113
+ if (opts.excludeTags !== undefined)
114
+ body['exclude_tags'] = parseCsv(opts.excludeTags);
115
+ if (opts.mobile === true)
116
+ body['mobile'] = true;
57
117
  try {
58
118
  // FIRST statement inside the try — deliberately NOT hoisted next to buildClient() where
59
119
  // the other handlers put pre-request logic. index.ts drives commander with .parse(), not
@@ -63,6 +123,34 @@ export function makeScrapeCommand(getGlobal) {
63
123
  // --json/--raw conflict here still happens before any request is issued.
64
124
  const mode = resolveOutputMode({ json: g.json, raw: opts.raw, output: opts.output });
65
125
  const result = await client.scrape.create(body);
126
+ // Queued-envelope precedence (#2480 E4s): a `--async` scrape returns a 202
127
+ // `{ id, status: 'queued' }` envelope with NO formats map. Detect that shape here —
128
+ // AFTER the request returns, BEFORE raw-format extraction — per the contract the
129
+ // transport-hardening ticket (#2508) establishes in `output.ts`, mirrored verbatim so
130
+ // the two guards compose idempotently post-merge (this one runs first and must be
131
+ // indistinguishable from that one):
132
+ // - raw mode (`--raw` or a bare `-o`): the bare sc_ id on STDOUT (a usable
133
+ // `$(bytekit scrape create … --raw)` handle), a human poll note on STDERR, exit
134
+ // QUEUED_EXIT_CODE. The `-o` file is deliberately NEVER written — there is no
135
+ // content body yet, and a file holding a bare id would masquerade as content.
136
+ // - json mode (`--json`, with or without `-o`): OUTSIDE the raw rule — fall through
137
+ // to emitResult, which emits the envelope verbatim (exit 0).
138
+ // - human default: keep the readable id/status dump, but signal queued-not-complete
139
+ // with the same exit code 2 — via soft `process.exitCode` (not `process.exit`) so
140
+ // the dump is guaranteed to flush before the process ends naturally.
141
+ if (isQueuedEnvelope(result)) {
142
+ if (mode.kind === 'raw') {
143
+ console.error(`Note: scrape ${result.id} was queued (async) — no content body is available yet. ` +
144
+ `Poll it with: bytekit scrape get ${result.id}`);
145
+ process.stdout.write(`${result.id}\n`);
146
+ process.exit(QUEUED_EXIT_CODE);
147
+ }
148
+ if (mode.kind === 'human') {
149
+ printResult(result, false);
150
+ process.exitCode = QUEUED_EXIT_CODE;
151
+ return;
152
+ }
153
+ }
66
154
  emitResult(result, mode, { file: opts.output }, opts.format);
67
155
  }
68
156
  catch (err) {
@@ -1,5 +1,17 @@
1
1
  import { Command } from 'commander';
2
2
  import { type GlobalOptions } from '../client.js';
3
+ /** Parse `--viewport WxH` (e.g. `1280x720`) into `{ width, height }`; reject malformed input. */
4
+ export declare function parseViewport(value: string): {
5
+ width: number;
6
+ height: number;
7
+ };
8
+ /** Parse `--clip x,y,w,h` into `{ x, y, width, height }`; reject malformed input. */
9
+ export declare function parseClip(value: string): {
10
+ x: number;
11
+ y: number;
12
+ width: number;
13
+ height: number;
14
+ };
3
15
  export declare function makeScreenshotsCommand(getGlobal: () => GlobalOptions & {
4
16
  json: boolean;
5
17
  }): Command;
@@ -1,6 +1,64 @@
1
- import { Command } from 'commander';
1
+ import { Command, InvalidArgumentError } from 'commander';
2
2
  import { buildClient } from '../client.js';
3
3
  import { printResult, handleError } from '../output.js';
4
+ import { downloadArtifact } from '../lib/download-artifact.js';
5
+ import { parseEnum, parseIntFlag } from '../lib/parse-flags.js';
6
+ /** `ScreenshotRequest` spec enums. */
7
+ const DEVICES = ['desktop', 'mobile'];
8
+ const FORMATS = ['jpeg', 'png'];
9
+ const WAIT_UNTILS = ['load', 'domcontentloaded', 'networkidle'];
10
+ /** Parse `--viewport WxH` (e.g. `1280x720`) into `{ width, height }`; reject malformed input. */
11
+ export function parseViewport(value) {
12
+ const m = /^(\d+)x(\d+)$/.exec(value);
13
+ if (!m)
14
+ throw new InvalidArgumentError('--viewport must be WIDTHxHEIGHT (e.g. 1280x720).');
15
+ return { width: Number(m[1]), height: Number(m[2]) };
16
+ }
17
+ /** Parse `--clip x,y,w,h` into `{ x, y, width, height }`; reject malformed input. */
18
+ export function parseClip(value) {
19
+ const m = /^(\d+),(\d+),(\d+),(\d+)$/.exec(value);
20
+ if (!m)
21
+ throw new InvalidArgumentError('--clip must be X,Y,WIDTH,HEIGHT (e.g. 0,0,800,600).');
22
+ return { x: Number(m[1]), y: Number(m[2]), width: Number(m[3]), height: Number(m[4]) };
23
+ }
24
+ /**
25
+ * Extract a non-empty string `image_url` from a screenshot envelope, or throw an actionable error.
26
+ *
27
+ * A pending/processing envelope has no `image_url` yet; a terminal-`failed` screenshot returns the
28
+ * unified `Error` envelope on a 200 (#2027). Either way the artifact was never materialized, so we
29
+ * throw rather than fetching `undefined` (#2480 E5).
30
+ */
31
+ function extractImageUrl(result) {
32
+ if (typeof result === 'object' && result !== null) {
33
+ const obj = result;
34
+ if (obj.error) {
35
+ throw new Error(`screenshot failed: ${obj.error.message ?? obj.error.code ?? 'unknown error'}`);
36
+ }
37
+ if (typeof obj.image_url === 'string' && obj.image_url.length > 0) {
38
+ return obj.image_url;
39
+ }
40
+ const status = typeof obj.status === 'string' ? obj.status : 'unknown';
41
+ throw new Error(`not ready yet: screenshot is ${status}`);
42
+ }
43
+ throw new Error('not ready yet: screenshot is unknown');
44
+ }
45
+ /**
46
+ * Render a screenshot result: with `-o` download the `image_url` artifact bytes (to a file, or to
47
+ * stdout with `-o -`); otherwise print the envelope. `--async` + `-o` prints the queued envelope
48
+ * and skips the download (queued-envelope precedence, #2480 E4). `--json` + `-o` is rejected by the
49
+ * caller BEFORE any request so `--json` can never emit binary (#2480 E2).
50
+ */
51
+ async function emitScreenshot(result, opts) {
52
+ if (opts.isAsync === true) {
53
+ printResult(result, opts.json);
54
+ return;
55
+ }
56
+ if (opts.output !== undefined) {
57
+ await downloadArtifact(extractImageUrl(result), opts.output);
58
+ return;
59
+ }
60
+ printResult(result, opts.json);
61
+ }
4
62
  export function makeScreenshotsCommand(getGlobal) {
5
63
  const cmd = new Command('screenshots').description('Screenshot operations');
6
64
  cmd
@@ -8,15 +66,52 @@ export function makeScreenshotsCommand(getGlobal) {
8
66
  .description('Capture a screenshot')
9
67
  .requiredOption('--url <url>', 'URL to screenshot')
10
68
  .option('--full-page', 'Capture the full page (default: viewport only)')
69
+ .option('--device <device>', `Device profile: ${DEVICES.join(', ')}`, (v) => parseEnum('--device', DEVICES, v))
70
+ .option('--viewport <WxH>', 'Viewport size WIDTHxHEIGHT (e.g. 1280x720)', parseViewport)
71
+ .option('--format <format>', `Image format: ${FORMATS.join(', ')}`, (v) => parseEnum('--format', FORMATS, v))
72
+ .option('--quality <n>', 'JPEG quality (1-100)', (v) => parseIntFlag('--quality', v))
73
+ .option('--wait-until <event>', `Wait condition: ${WAIT_UNTILS.join(', ')}`, (v) => parseEnum('--wait-until', WAIT_UNTILS, v))
74
+ .option('--clip <x,y,w,h>', 'Clip region X,Y,WIDTH,HEIGHT (e.g. 0,0,800,600)', parseClip)
75
+ .option('--async', 'Queue the screenshot and return a job envelope immediately (no wait)')
76
+ .option('-o, --output <file>', 'Download the captured image to this file path (use - for stdout). Not valid with --json.')
11
77
  .action(async (opts) => {
12
78
  const g = getGlobal();
13
79
  const client = buildClient(g);
14
80
  const body = { url: opts.url };
81
+ // Each optional flag maps 1:1 to its snake_case ScreenshotRequest field, written ONLY when
82
+ // present — a boolean never serializes `false` (#2480 E1).
15
83
  if (opts.fullPage === true)
16
- body.full_page = true;
84
+ body['full_page'] = true;
85
+ if (opts.device !== undefined)
86
+ body['device'] = opts.device;
87
+ if (opts.viewport !== undefined)
88
+ body['viewport'] = opts.viewport;
89
+ if (opts.format !== undefined)
90
+ body['format'] = opts.format;
91
+ if (opts.quality !== undefined)
92
+ body['quality'] = opts.quality;
93
+ if (opts.waitUntil !== undefined)
94
+ body['wait_until'] = opts.waitUntil;
95
+ if (opts.clip !== undefined)
96
+ body['clip'] = opts.clip;
17
97
  try {
18
- const result = await client.screenshots.create(body);
19
- printResult(result, g.json);
98
+ // Reject --json + -o BEFORE issuing the request: -o downloads BINARY artifact bytes, so
99
+ // it can never coexist with --json (which must stay a text envelope). Thrown here so no
100
+ // request — and therefore no download fetch — is ever issued (#2480 E2).
101
+ if (g.json && opts.output !== undefined) {
102
+ throw new Error('-o cannot be used with --json');
103
+ }
104
+ const typedBody = body;
105
+ // --async is a QUERY param; pass the second arg ONLY when present so existing single-arg
106
+ // callers stay byte-identical (#2480 inverse case).
107
+ const result = opts.async === true
108
+ ? await client.screenshots.create(typedBody, { async: true })
109
+ : await client.screenshots.create(typedBody);
110
+ await emitScreenshot(result, {
111
+ output: opts.output,
112
+ json: g.json,
113
+ isAsync: opts.async === true,
114
+ });
20
115
  }
21
116
  catch (err) {
22
117
  handleError(err);
@@ -25,12 +120,16 @@ export function makeScreenshotsCommand(getGlobal) {
25
120
  cmd
26
121
  .command('get <id>')
27
122
  .description('Get a screenshot by ID')
28
- .action(async (id) => {
123
+ .option('-o, --output <file>', 'Download the captured image to this file path (use - for stdout). Not valid with --json.')
124
+ .action(async (id, opts) => {
29
125
  const g = getGlobal();
30
126
  const client = buildClient(g);
31
127
  try {
128
+ if (g.json && opts.output !== undefined) {
129
+ throw new Error('-o cannot be used with --json');
130
+ }
32
131
  const result = await client.screenshots.get(id);
33
- printResult(result, g.json);
132
+ await emitScreenshot(result, { output: opts.output, json: g.json });
34
133
  }
35
134
  catch (err) {
36
135
  handleError(err);
@@ -11,7 +11,11 @@ function renderTable(response) {
11
11
  for (const result of response.results) {
12
12
  console.log(`${result.position}. ${result.title}`);
13
13
  console.log(` ${result.url}`);
14
- console.log(` ${result.snippet}`);
14
+ // `snippet` is typed as required but is OMITTED for `--type images` results (openapi.yaml),
15
+ // so guard against printing a literal `undefined` line when it is absent or empty.
16
+ if (typeof result.snippet === 'string' && result.snippet.length > 0) {
17
+ console.log(` ${result.snippet}`);
18
+ }
15
19
  }
16
20
  }
17
21
  const LIMIT_MIN = 1;
@@ -10,8 +10,15 @@ export function makeSitemapCommand(getGlobal) {
10
10
  .action(async (opts) => {
11
11
  const g = getGlobal();
12
12
  const client = buildClient(g);
13
+ // Key-checked subset of the SDK's SitemapCreateRequest (same pattern as monitors.ts).
14
+ // The CLI intentionally sends only `url` and relies on the server-side defaults the spec
15
+ // declares for the remaining SitemapRequest fields (strategy, max_depth, max_urls, …).
16
+ // The generated request type marks those fields required, so the final call needs an
17
+ // explicit assertion — type-level only; the wire body is unchanged (pinned by
18
+ // __tests__/request-shapes.test.ts).
19
+ const body = { url: opts.url };
13
20
  try {
14
- const result = await client.sitemap.create({ url: opts.url });
21
+ const result = await client.sitemap.create(body);
15
22
  printResult(result, g.json);
16
23
  }
17
24
  catch (err) {
package/dist/index.js CHANGED
@@ -5,7 +5,6 @@ import { createRequire } from 'node:module';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { makeScrapeCommand } from './commands/scrape.js';
7
7
  import { makeScreenshotsCommand } from './commands/screenshots.js';
8
- import { makeRecordingsCommand } from './commands/recordings.js';
9
8
  import { makeBulkCommand } from './commands/bulk.js';
10
9
  import { makeFetchCommand } from './commands/fetch.js';
11
10
  import { makeMonitorsCommand } from './commands/monitors.js';
@@ -25,8 +24,8 @@ export function buildProgram() {
25
24
  .name('bytekit')
26
25
  .description('ByteKit CLI — capture, scrape, monitor')
27
26
  .version(version)
28
- .option('--key <key>', 'API key (or set RAPIDCRAWL_API_KEY)')
29
- .option('--base-url <url>', 'Base URL override (or set RAPIDCRAWL_BASE_URL)')
27
+ .option('--key <key>', 'API key (or set BYTEKIT_API_KEY)')
28
+ .option('--base-url <url>', 'Base URL override (or set BYTEKIT_BASE_URL)')
30
29
  .option('--json', 'Output raw JSON');
31
30
  /** Read global options from the root program instance. */
32
31
  const getGlobal = () => {
@@ -39,7 +38,6 @@ export function buildProgram() {
39
38
  };
40
39
  program.addCommand(makeScrapeCommand(getGlobal));
41
40
  program.addCommand(makeScreenshotsCommand(getGlobal));
42
- program.addCommand(makeRecordingsCommand(getGlobal));
43
41
  program.addCommand(makeBulkCommand(getGlobal));
44
42
  program.addCommand(makeFetchCommand(getGlobal));
45
43
  program.addCommand(makeMonitorsCommand(getGlobal));
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Download a screenshot artifact from its `image_url` and write the raw bytes to `dest`.
3
+ *
4
+ * - `dest === '-'` writes the bytes to stdout (the conventional dash-is-stdout convention),
5
+ * never with `console.log` — binary must not be coerced or newline-appended.
6
+ * - any other `dest` is a file path written verbatim.
7
+ *
8
+ * `globalThis.fetch` is resolved at CALL time (never captured at module/construction load) so a
9
+ * test that stubs `globalThis.fetch` in a `beforeEach` — after this module was imported — still
10
+ * sees its stub (G-049). A non-2xx response throws with the HTTP status so `handleError` renders a
11
+ * clean one-liner and exits 1.
12
+ */
13
+ export declare function downloadArtifact(url: string, dest: string): Promise<void>;
@@ -0,0 +1,25 @@
1
+ import { writeFileSync } from 'node:fs';
2
+ /**
3
+ * Download a screenshot artifact from its `image_url` and write the raw bytes to `dest`.
4
+ *
5
+ * - `dest === '-'` writes the bytes to stdout (the conventional dash-is-stdout convention),
6
+ * never with `console.log` — binary must not be coerced or newline-appended.
7
+ * - any other `dest` is a file path written verbatim.
8
+ *
9
+ * `globalThis.fetch` is resolved at CALL time (never captured at module/construction load) so a
10
+ * test that stubs `globalThis.fetch` in a `beforeEach` — after this module was imported — still
11
+ * sees its stub (G-049). A non-2xx response throws with the HTTP status so `handleError` renders a
12
+ * clean one-liner and exits 1.
13
+ */
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}`);
18
+ }
19
+ const bytes = Buffer.from(await res.arrayBuffer());
20
+ if (dest === '-') {
21
+ process.stdout.write(bytes);
22
+ return;
23
+ }
24
+ writeFileSync(dest, bytes);
25
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * A raw (non-JSON) response body from a `/v1/fetch` content request, plus its `X-Fetch-*` metadata.
3
+ *
4
+ * The `/v1/fetch` content endpoints (POST and GET) return the fetched bytes AS the body — HTML,
5
+ * markdown, or arbitrary binary — with all metadata in `X-Fetch-*` response headers. Every OTHER
6
+ * endpoint (including `/v1/fetch/bulk`) returns a JSON envelope. The CLI transport tags a content
7
+ * result with this shape so the `fetch` command can print the body verbatim (default) or the
8
+ * metadata as JSON (`--json`), instead of JSON-parsing a body that was never JSON to begin with.
9
+ */
10
+ export interface FetchBodyResult {
11
+ readonly __bytekitFetchBody: true;
12
+ /** The raw response body bytes, uncorrupted — safe for binary content (no string coercion). */
13
+ readonly body: Buffer;
14
+ /** The `X-Fetch-*` response headers, Title-Cased (`x-fetch-status` → `X-Fetch-Status`). */
15
+ readonly metadata: Record<string, string>;
16
+ }
17
+ /** Narrow an arbitrary transport result to a {@link FetchBodyResult}. */
18
+ export declare function isFetchBodyResult(value: unknown): value is FetchBodyResult;
@@ -0,0 +1,6 @@
1
+ /** Narrow an arbitrary transport result to a {@link FetchBodyResult}. */
2
+ export function isFetchBodyResult(value) {
3
+ return (typeof value === 'object' &&
4
+ value !== null &&
5
+ value.__bytekitFetchBody === true);
6
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Shared commander argParser helpers for CLI flag values (#2480 cr-3). One home for the parser
3
+ * family that scrape.ts, screenshots.ts, and monitors.ts previously each held private copies of —
4
+ * every off-spec value is rejected locally, at parse time, with an actionable message, before any
5
+ * HTTP request is issued.
6
+ */
7
+ /**
8
+ * Parse a whole-number flag value. The strict `/^\d+$/` test deterministically rejects `abc`, ``,
9
+ * `1.5`, `-5`, `1e2`, so an off-spec value fails at parse time with an actionable message instead
10
+ * of a server 422 or a NaN on the wire.
11
+ */
12
+ export declare function parseIntFlag(flag: string, value: string): number;
13
+ /** Reject an off-spec enum value at parse time with the allowed set in the message. */
14
+ export declare function parseEnum<T extends string>(flag: string, allowed: readonly T[], value: string): T;
15
+ /** Split a comma-separated flag value into a trimmed, non-empty string array. */
16
+ export declare function parseCsv(value: string): string[];
@@ -0,0 +1,32 @@
1
+ import { InvalidArgumentError } from 'commander';
2
+ /**
3
+ * Shared commander argParser helpers for CLI flag values (#2480 cr-3). One home for the parser
4
+ * family that scrape.ts, screenshots.ts, and monitors.ts previously each held private copies of —
5
+ * every off-spec value is rejected locally, at parse time, with an actionable message, before any
6
+ * HTTP request is issued.
7
+ */
8
+ /**
9
+ * Parse a whole-number flag value. The strict `/^\d+$/` test deterministically rejects `abc`, ``,
10
+ * `1.5`, `-5`, `1e2`, so an off-spec value fails at parse time with an actionable message instead
11
+ * of a server 422 or a NaN on the wire.
12
+ */
13
+ export function parseIntFlag(flag, value) {
14
+ if (!/^\d+$/.test(value)) {
15
+ throw new InvalidArgumentError(`${flag} must be a whole number.`);
16
+ }
17
+ return Number(value);
18
+ }
19
+ /** Reject an off-spec enum value at parse time with the allowed set in the message. */
20
+ export function parseEnum(flag, allowed, value) {
21
+ if (!allowed.includes(value)) {
22
+ throw new InvalidArgumentError(`${flag} must be one of: ${allowed.join(', ')}.`);
23
+ }
24
+ return value;
25
+ }
26
+ /** Split a comma-separated flag value into a trimmed, non-empty string array. */
27
+ export function parseCsv(value) {
28
+ return value
29
+ .split(',')
30
+ .map((s) => s.trim())
31
+ .filter((s) => s.length > 0);
32
+ }
package/dist/output.d.ts CHANGED
@@ -56,11 +56,41 @@ export declare function resolveOutputMode(flags: OutputFlags): OutputMode;
56
56
  * not text, so `--raw` can never print `[object Object]` or a format the caller did not ask for.
57
57
  */
58
58
  export declare function extractRawBody(response: unknown, requestedFormat?: string): string;
59
+ /**
60
+ * Exit code for `--raw`/`-o` against a 202 queued (async) scrape.
61
+ *
62
+ * Deliberately NOT 0 (a full raw-body success) and NOT 1 (a usage/API error): the job WAS accepted
63
+ * and billed, there is simply no content body to emit yet. A distinct code lets a caller branch on
64
+ * "queued" without parsing stderr.
65
+ */
66
+ export declare const QUEUED_RAW_EXIT_CODE = 2;
67
+ /** A 202 ScrapeQueuedEnvelope: `status: 'queued'` plus a stable `sc_` id (openapi.yaml). */
68
+ interface QueuedEnvelope {
69
+ status: 'queued';
70
+ id: string;
71
+ }
72
+ /**
73
+ * Handle `--raw`/`-o` on a 202 queued envelope.
74
+ *
75
+ * The user asked for a content body that does not exist yet — but the job was accepted, so losing
76
+ * the handle (the old behaviour: throw `--raw: response contains no formats`, exit 1) is the real
77
+ * defect. Instead: emit the `sc_` id to STDOUT so `$(bytekit scrape create … --raw)` still captures
78
+ * a usable handle, print a human note to STDERR explaining it is queued and how to poll, and exit
79
+ * with {@link QUEUED_RAW_EXIT_CODE} — distinguishable from both success and error.
80
+ */
81
+ export declare function emitQueuedRaw(env: QueuedEnvelope): never;
59
82
  /**
60
83
  * Emit a raw body to the target.
61
84
  *
62
- * Uses `process.stdout.write`, never `console.log`: `console.log` appends a newline, which would
63
- * make the stdout bytes and the `--output` file bytes differ.
85
+ * Uses `process.stdout.write`, never `console.log`: `console.log` unconditionally appends a newline,
86
+ * which would make the stdout bytes and the `--output` file bytes differ.
87
+ *
88
+ * Trailing-newline rule (#2452 item 6): a body has no guaranteed trailing newline (a scraped markdown
89
+ * page rarely ends in one), so a naked `--raw` glued the shell prompt onto the last line. When stdout
90
+ * is an interactive TTY we append a single newline (idempotent — never doubling an existing one) so the
91
+ * prompt starts cleanly. Off a TTY (a pipe or `>`/`>>` redirect) we write the body verbatim, preserving
92
+ * the byte-exact invariant that `--raw > file` matches `--output file`. A FILE destination is never
93
+ * touched by this rule at all.
64
94
  */
65
95
  export declare function emitRaw(body: string, target: OutputTarget): void;
66
96
  /**
@@ -71,6 +101,18 @@ export declare function emitRaw(body: string, target: OutputTarget): void;
71
101
  * `--json | jq` pipelines stay byte-identical.
72
102
  */
73
103
  export declare function emitResult(result: unknown, mode: OutputMode, target: OutputTarget, requestedFormat?: string): void;
104
+ /**
105
+ * Render a `/v1/fetch` content result.
106
+ *
107
+ * The transport hands back a {@link FetchBodyResult} whose body IS the fetched content and whose
108
+ * metadata are the `X-Fetch-*` headers. Default: write the raw body bytes to stdout verbatim
109
+ * (`process.stdout.write`, never `console.log` — no coercion, no appended newline, binary-safe).
110
+ * With `--json`: print the `X-Fetch-*` metadata as JSON instead of the body.
111
+ *
112
+ * Falls back to {@link printResult} for any result that is not a fetch body (defensive — e.g. a
113
+ * bulk-fetch envelope routed here by mistake), so no path can silently emit nothing.
114
+ */
115
+ export declare function emitFetchResult(result: unknown, jsonMode: boolean): void;
74
116
  /**
75
117
  * Print a result to stdout.
76
118
  *
@@ -79,5 +121,20 @@ export declare function emitResult(result: unknown, mode: OutputMode, target: Ou
79
121
  export declare function printResult(data: unknown, jsonMode: boolean): void;
80
122
  /**
81
123
  * Handle a CLI command error: print to stderr and exit 1.
124
+ *
125
+ * The message always begins `Error: ` (a prefix scripts grep on) followed by the error message and
126
+ * an optional parenthesised suffix built from the two diagnostic axes the ByteKit errors carry:
127
+ *
128
+ * - a `ByteKitError` from the transport carries both a numeric `status` and an API `code`
129
+ * (`rate_limited`, `invalid_url`, …) → `Error: <message> (<code>, status: <n>)`;
130
+ * - a `ByteKitConnectionError` (client-side timeout / abort / socket failure) carries a `code`
131
+ * (`timeout` | `aborted` | `connection`) but NO status → `Error: <message> (<code>)`;
132
+ * - a plain error with neither → `Error: <message>`.
133
+ *
134
+ * The `code` axis is read only off the two ByteKit error classes (see {@link errorCode}) — a plain
135
+ * error carrying an unrelated string `.code` (a Node errno error) gets NO code suffix. When a
136
+ * status is present but no code (a hand-built error), the suffix is `(status: <n>)` with no empty
137
+ * placeholder — never `(, status: <n>)`.
82
138
  */
83
139
  export declare function handleError(err: unknown): never;
140
+ export {};