@hunt-labs/bytekit-cli 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +109 -0
- package/README.md +28 -6
- package/dist/client.d.ts +111 -5
- package/dist/client.js +232 -27
- package/dist/commands/bulk.js +22 -4
- package/dist/commands/fetch.js +75 -16
- package/dist/commands/monitors.js +26 -2
- package/dist/commands/scrape.js +24 -5
- package/dist/commands/screenshots.js +45 -1
- package/dist/commands/sitemap.js +24 -1
- package/dist/lib/download-artifact.d.ts +30 -1
- package/dist/lib/download-artifact.js +89 -5
- package/dist/lib/url-lines.d.ts +46 -8
- package/dist/lib/url-lines.js +58 -19
- package/dist/output.js +1 -1
- package/package.json +3 -3
package/dist/commands/scrape.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
56
|
-
|
|
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
|
|
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
|
package/dist/commands/sitemap.js
CHANGED
|
@@ -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
|
|
16
|
-
|
|
17
|
-
|
|
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;
|
package/dist/lib/url-lines.d.ts
CHANGED
|
@@ -1,12 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* One parsed `--file` line: a bare URL string, or a per-item config object carrying `url`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* `
|
|
6
|
-
* the
|
|
7
|
-
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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
|
|
50
|
+
export declare function toBulkItems(lines: BulkLine[]): Array<Record<string, unknown>>;
|
package/dist/lib/url-lines.js
CHANGED
|
@@ -1,34 +1,73 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { InvalidArgumentError } from 'commander';
|
|
2
3
|
/**
|
|
3
|
-
* Read a newline-delimited file
|
|
4
|
+
* Read a newline-delimited `--file` and return one {@link BulkLine} per non-blank line.
|
|
4
5
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
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
|
|
34
|
+
export function parseBulkLines(file) {
|
|
14
35
|
return readFileSync(file, 'utf8')
|
|
15
36
|
.split('\n')
|
|
16
|
-
.map((
|
|
17
|
-
.filter((
|
|
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
|
-
|
|
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 `
|
|
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.
|
|
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.
|
|
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
|
}
|