@hunt-labs/bytekit-cli 0.3.0 → 0.5.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.
@@ -14,3 +14,33 @@ export declare function parseIntFlag(flag: string, value: string): number;
14
14
  export declare function parseEnum<T extends string>(flag: string, allowed: readonly T[], value: string): T;
15
15
  /** Split a comma-separated flag value into a trimmed, non-empty string array. */
16
16
  export declare function parseCsv(value: string): string[];
17
+ /**
18
+ * Parse a finite decimal flag value (#2545). Unlike {@link parseIntFlag}, the `/^\d+(\.\d+)?$/`
19
+ * test accepts a fractional value such as `2.5` — required for spec `type: number` fields like
20
+ * `monitors change_threshold` (default 5, range 0–100) that `parseIntFlag`'s `/^\d+$/` would reject
21
+ * at parse time. Rejects `abc`, ``, `-5`, `1.` and `1e2` with the flag named in the message.
22
+ */
23
+ export declare function parseNumberFlag(flag: string, value: string): number;
24
+ /**
25
+ * Parse a `cache_ttl` flag value (#2545). Mirrors the gateway validator's own format grammar
26
+ * (`^(0|\d+[hd])$`, `packages/gateway/src/validators/shared.ts`) locally so an off-grammar value
27
+ * (`30m`, `soon`) is rejected at parse time with an actionable message instead of a server 422.
28
+ * The string is returned verbatim — the validator accepts the string `'0'`, and the range cap
29
+ * (7d/168h for scrape+fetch) stays a server-side concern.
30
+ */
31
+ export declare function parseCacheTtl(flag: string, value: string): string;
32
+ /**
33
+ * Parse a JSON-object- or JSON-array-valued flag (#2545). The ONLY `JSON.parse` call site for CLI
34
+ * flag values — every JSON-shaped flag (`--cookies`, `--headers`, `--custom`,
35
+ * `--webhook-headers`, `--options`, `--scrape-options`) routes through here, so command modules
36
+ * carry no raw `JSON.parse` (grep-gated). Rejects malformed JSON AND a value whose top-level shape
37
+ * does not match `expect`, with a copy-pasteable example in the message (E5).
38
+ */
39
+ export declare function parseJsonFlag(flag: string, value: string, expect: 'object' | 'array'): unknown;
40
+ /**
41
+ * Commander collector for a repeatable, CSV-aware `--format` flag (#2545). Each occurrence is
42
+ * CSV-split and appended to the accumulator, so `--format raw_html --format markdown` and
43
+ * `--format raw_html,markdown` both yield `['raw_html','markdown']`. Commander seeds `prev` with
44
+ * the option default (or the previous return) on each occurrence.
45
+ */
46
+ export declare function collectFormats(value: string, prev: string[] | undefined): string[];
@@ -30,3 +30,61 @@ export function parseCsv(value) {
30
30
  .map((s) => s.trim())
31
31
  .filter((s) => s.length > 0);
32
32
  }
33
+ /**
34
+ * Parse a finite decimal flag value (#2545). Unlike {@link parseIntFlag}, the `/^\d+(\.\d+)?$/`
35
+ * test accepts a fractional value such as `2.5` — required for spec `type: number` fields like
36
+ * `monitors change_threshold` (default 5, range 0–100) that `parseIntFlag`'s `/^\d+$/` would reject
37
+ * at parse time. Rejects `abc`, ``, `-5`, `1.` and `1e2` with the flag named in the message.
38
+ */
39
+ export function parseNumberFlag(flag, value) {
40
+ if (!/^\d+(\.\d+)?$/.test(value)) {
41
+ throw new InvalidArgumentError(`${flag} must be a number.`);
42
+ }
43
+ return Number(value);
44
+ }
45
+ /**
46
+ * Parse a `cache_ttl` flag value (#2545). Mirrors the gateway validator's own format grammar
47
+ * (`^(0|\d+[hd])$`, `packages/gateway/src/validators/shared.ts`) locally so an off-grammar value
48
+ * (`30m`, `soon`) is rejected at parse time with an actionable message instead of a server 422.
49
+ * The string is returned verbatim — the validator accepts the string `'0'`, and the range cap
50
+ * (7d/168h for scrape+fetch) stays a server-side concern.
51
+ */
52
+ export function parseCacheTtl(flag, value) {
53
+ if (!/^(0|\d+[hd])$/.test(value)) {
54
+ throw new InvalidArgumentError(`${flag} must be 0, Nh (hours), or Nd (days) — e.g. 48h, 2d, 0.`);
55
+ }
56
+ return value;
57
+ }
58
+ /**
59
+ * Parse a JSON-object- or JSON-array-valued flag (#2545). The ONLY `JSON.parse` call site for CLI
60
+ * flag values — every JSON-shaped flag (`--cookies`, `--headers`, `--custom`,
61
+ * `--webhook-headers`, `--options`, `--scrape-options`) routes through here, so command modules
62
+ * carry no raw `JSON.parse` (grep-gated). Rejects malformed JSON AND a value whose top-level shape
63
+ * does not match `expect`, with a copy-pasteable example in the message (E5).
64
+ */
65
+ export function parseJsonFlag(flag, value, expect) {
66
+ const example = expect === 'array' ? `[{"name":"a","value":"b"}]` : `{"X-Header":"value"}`;
67
+ let parsed;
68
+ try {
69
+ parsed = JSON.parse(value);
70
+ }
71
+ catch {
72
+ throw new InvalidArgumentError(`${flag} must be valid JSON (a ${expect}), e.g. '${example}'.`);
73
+ }
74
+ const matches = expect === 'array'
75
+ ? Array.isArray(parsed)
76
+ : typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed);
77
+ if (!matches) {
78
+ throw new InvalidArgumentError(`${flag} must be a JSON ${expect}, e.g. '${example}'.`);
79
+ }
80
+ return parsed;
81
+ }
82
+ /**
83
+ * Commander collector for a repeatable, CSV-aware `--format` flag (#2545). Each occurrence is
84
+ * CSV-split and appended to the accumulator, so `--format raw_html --format markdown` and
85
+ * `--format raw_html,markdown` both yield `['raw_html','markdown']`. Commander seeds `prev` with
86
+ * the option default (or the previous return) on each occurrence.
87
+ */
88
+ export function collectFormats(value, prev) {
89
+ return [...(prev ?? []), ...parseCsv(value)];
90
+ }
package/dist/output.d.ts CHANGED
@@ -22,6 +22,24 @@ export interface OutputFlags {
22
22
  raw?: boolean | undefined;
23
23
  output?: string | undefined;
24
24
  }
25
+ /**
26
+ * Serialize a value as the CLI's `--json` payload text. The single serializer EVERY `--json`
27
+ * branch in this module goes through, so no branch can regress to a raw `JSON.stringify`.
28
+ *
29
+ * `JSON.stringify` returns the VALUE `undefined` — not a string — for `undefined`, functions and
30
+ * symbols. Handed to `console.log` that prints the literal text `undefined`, which is NOT valid
31
+ * JSON and breaks every `| jq` pipeline; handed to `writeFileSync` it throws
32
+ * `ERR_INVALID_ARG_TYPE`. A `204 No Content` response resolves to `undefined` in the SDK
33
+ * (`monitors.delete` is typed `Promise<void>`), so this is the ORDINARY shape of
34
+ * `bytekit monitors delete <id> --json`, not an exotic edge case (#2544).
35
+ *
36
+ * The representation is JSON `null` — the canonical JSON spelling of "no content". Deliberately
37
+ * NOT a synthesized envelope such as `{"status":"deleted"}`: that is a shape the API never
38
+ * returned, and it would have to be re-invented (and kept consistent) per command. `null` is
39
+ * uniform across every 204-returning path, and `… --json | jq .` prints `null` and exits 0.
40
+ * The contract is documented in README (`Exit codes and output contracts`) and CHANGELOG.
41
+ */
42
+ export declare function toJsonText(data: unknown): string;
25
43
  /**
26
44
  * Resolve the output mode from the flags, and enforce the `--json` / `--raw` exclusion.
27
45
  *
@@ -75,10 +93,21 @@ interface QueuedEnvelope {
75
93
  * The user asked for a content body that does not exist yet — but the job was accepted, so losing
76
94
  * the handle (the old behaviour: throw `--raw: response contains no formats`, exit 1) is the real
77
95
  * 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.
96
+ * a usable handle, print a human note to STDERR explaining it is queued and how to poll, and signal
97
+ * {@link QUEUED_RAW_EXIT_CODE} — distinguishable from both success and error.
98
+ *
99
+ * The exit code is a SOFT `process.exitCode`, never a hard `process.exit` (#2544). Node documents
100
+ * `process.stdout` as ASYNCHRONOUS when it points at a pipe: a write that cannot be handed to the
101
+ * kernel in one go is queued in userspace and drained on a later event-loop turn, and
102
+ * `process.exit()` runs before any of those turns and DISCARDS the queue. That is exactly the
103
+ * `sc=$(bytekit scrape create … --async --raw)` case this branch exists to serve — the id would be
104
+ * truncated away whenever stdout is backlogged. Setting `process.exitCode` instead lets the process
105
+ * end naturally once stdout has drained, with the same observable exit code.
106
+ *
107
+ * Returns `void` (not `never`): callers MUST `return` after invoking it, since execution now
108
+ * continues past this function.
80
109
  */
81
- export declare function emitQueuedRaw(env: QueuedEnvelope): never;
110
+ export declare function emitQueuedRaw(env: QueuedEnvelope): void;
82
111
  /**
83
112
  * Emit a raw body to the target.
84
113
  *
@@ -101,18 +130,27 @@ export declare function emitRaw(body: string, target: OutputTarget): void;
101
130
  * `--json | jq` pipelines stay byte-identical.
102
131
  */
103
132
  export declare function emitResult(result: unknown, mode: OutputMode, target: OutputTarget, requestedFormat?: string): void;
133
+ /**
134
+ * Collect the `X-Fetch-*` response headers of a {@link FetchResult} into a Title-Cased object —
135
+ * the documented `--json` payload for a fetch command.
136
+ */
137
+ export declare function collectFetchMetadata(headers: Headers): Record<string, string>;
104
138
  /**
105
139
  * Render a `/v1/fetch` content result.
106
140
  *
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.
141
+ * The SDK's fetch resource resolves a {@link FetchResult} whose `bytes` ARE the fetched content and
142
+ * whose `headers` carry the `X-Fetch-*` metadata.
143
+ *
144
+ * - default — write the raw body BYTES verbatim (`process.stdout.write`, never `console.log`: no
145
+ * coercion, no appended newline, binary-safe). `bytes` is used rather than the `body` string
146
+ * because the string is a lossy UTF-8 decode for true binary passthrough (images, PDFs).
147
+ * - `--json` — print the `X-Fetch-*` metadata object instead of the body.
148
+ * - `target.file` — write the same bytes to that path instead of stdout, byte-for-byte.
111
149
  *
112
- * Falls back to {@link printResult} for any result that is not a fetch body (defensive — e.g. a
150
+ * Falls back to {@link printResult} for any result that is not a fetch result (defensive — e.g. a
113
151
  * bulk-fetch envelope routed here by mistake), so no path can silently emit nothing.
114
152
  */
115
- export declare function emitFetchResult(result: unknown, jsonMode: boolean): void;
153
+ export declare function emitFetchResult(result: unknown, jsonMode: boolean, target?: OutputTarget): void;
116
154
  /**
117
155
  * Print a result to stdout.
118
156
  *
package/dist/output.js CHANGED
@@ -1,7 +1,27 @@
1
1
  import { writeFileSync } from 'node:fs';
2
- import { isFetchBodyResult } from './lib/fetch-body.js';
3
2
  /** Text formats whose bodies are strings and can therefore be emitted raw. */
4
3
  const TEXT_FORMATS = 'markdown, html, raw_html';
4
+ /**
5
+ * Serialize a value as the CLI's `--json` payload text. The single serializer EVERY `--json`
6
+ * branch in this module goes through, so no branch can regress to a raw `JSON.stringify`.
7
+ *
8
+ * `JSON.stringify` returns the VALUE `undefined` — not a string — for `undefined`, functions and
9
+ * symbols. Handed to `console.log` that prints the literal text `undefined`, which is NOT valid
10
+ * JSON and breaks every `| jq` pipeline; handed to `writeFileSync` it throws
11
+ * `ERR_INVALID_ARG_TYPE`. A `204 No Content` response resolves to `undefined` in the SDK
12
+ * (`monitors.delete` is typed `Promise<void>`), so this is the ORDINARY shape of
13
+ * `bytekit monitors delete <id> --json`, not an exotic edge case (#2544).
14
+ *
15
+ * The representation is JSON `null` — the canonical JSON spelling of "no content". Deliberately
16
+ * NOT a synthesized envelope such as `{"status":"deleted"}`: that is a shape the API never
17
+ * returned, and it would have to be re-invented (and kept consistent) per command. `null` is
18
+ * uniform across every 204-returning path, and `… --json | jq .` prints `null` and exits 0.
19
+ * The contract is documented in README (`Exit codes and output contracts`) and CHANGELOG.
20
+ */
21
+ export function toJsonText(data) {
22
+ const text = JSON.stringify(data, null, 2);
23
+ return text === undefined ? 'null' : text;
24
+ }
5
25
  /**
6
26
  * Resolve the output mode from the flags, and enforce the `--json` / `--raw` exclusion.
7
27
  *
@@ -93,14 +113,25 @@ function isQueuedEnvelope(result) {
93
113
  * The user asked for a content body that does not exist yet — but the job was accepted, so losing
94
114
  * the handle (the old behaviour: throw `--raw: response contains no formats`, exit 1) is the real
95
115
  * defect. Instead: emit the `sc_` id to STDOUT so `$(bytekit scrape create … --raw)` still captures
96
- * a usable handle, print a human note to STDERR explaining it is queued and how to poll, and exit
97
- * with {@link QUEUED_RAW_EXIT_CODE} — distinguishable from both success and error.
116
+ * a usable handle, print a human note to STDERR explaining it is queued and how to poll, and signal
117
+ * {@link QUEUED_RAW_EXIT_CODE} — distinguishable from both success and error.
118
+ *
119
+ * The exit code is a SOFT `process.exitCode`, never a hard `process.exit` (#2544). Node documents
120
+ * `process.stdout` as ASYNCHRONOUS when it points at a pipe: a write that cannot be handed to the
121
+ * kernel in one go is queued in userspace and drained on a later event-loop turn, and
122
+ * `process.exit()` runs before any of those turns and DISCARDS the queue. That is exactly the
123
+ * `sc=$(bytekit scrape create … --async --raw)` case this branch exists to serve — the id would be
124
+ * truncated away whenever stdout is backlogged. Setting `process.exitCode` instead lets the process
125
+ * end naturally once stdout has drained, with the same observable exit code.
126
+ *
127
+ * Returns `void` (not `never`): callers MUST `return` after invoking it, since execution now
128
+ * continues past this function.
98
129
  */
99
130
  export function emitQueuedRaw(env) {
100
131
  console.error(`Note: scrape ${env.id} was queued (async) — no content body is available yet. ` +
101
132
  `Poll it with: bytekit scrape get ${env.id}`);
102
133
  process.stdout.write(`${env.id}\n`);
103
- process.exit(QUEUED_RAW_EXIT_CODE);
134
+ process.exitCode = QUEUED_RAW_EXIT_CODE;
104
135
  }
105
136
  /**
106
137
  * Emit a raw body to the target.
@@ -137,15 +168,20 @@ export function emitResult(result, mode, target, requestedFormat) {
137
168
  // of a queued envelope stays unchanged (falls through to printResult below). A `-o <file>`
138
169
  // target is deliberately ignored here (id always on STDOUT, never written to the file): there
139
170
  // is no content body yet, and a file holding a bare `sc_` id would masquerade as content.
171
+ // `emitQueuedRaw` signals via a soft `process.exitCode` and RETURNS (#2544), so this branch
172
+ // must stop explicitly. Falling through would run `extractRawBody` on a formats-less queued
173
+ // envelope, throwing `--raw: response contains no formats` and clobbering the queued signal
174
+ // with the generic exit 1.
140
175
  if (isQueuedEnvelope(result)) {
141
176
  emitQueuedRaw(result);
177
+ return;
142
178
  }
143
179
  emitRaw(extractRawBody(result, requestedFormat), target);
144
180
  return;
145
181
  }
146
182
  if (mode.kind === 'json') {
147
183
  if (target.file !== undefined) {
148
- writeFileSync(target.file, JSON.stringify(result, null, 2));
184
+ writeFileSync(target.file, toJsonText(result));
149
185
  return;
150
186
  }
151
187
  printResult(result, true);
@@ -154,24 +190,73 @@ export function emitResult(result, mode, target, requestedFormat) {
154
190
  // human — unreachable with a file destination (resolveOutputMode never yields it alongside one).
155
191
  printResult(result, false);
156
192
  }
193
+ /** Title-Case an HTTP header name segment-by-segment: `x-fetch-status` → `X-Fetch-Status`. */
194
+ function titleCaseHeader(name) {
195
+ return name
196
+ .split('-')
197
+ .map((seg) => seg.charAt(0).toUpperCase() + seg.slice(1).toLowerCase())
198
+ .join('-');
199
+ }
200
+ /**
201
+ * Collect the `X-Fetch-*` response headers of a {@link FetchResult} into a Title-Cased object —
202
+ * the documented `--json` payload for a fetch command.
203
+ */
204
+ export function collectFetchMetadata(headers) {
205
+ const meta = {};
206
+ headers.forEach((value, key) => {
207
+ if (key.toLowerCase().startsWith('x-fetch-')) {
208
+ meta[titleCaseHeader(key)] = value;
209
+ }
210
+ });
211
+ return meta;
212
+ }
213
+ /**
214
+ * Narrow an SDK response to the `FetchResult` the fetch resource resolves.
215
+ *
216
+ * Structural, not a branded tag: the shape is the SDK's PUBLIC contract (`bytes` + `headers`), so
217
+ * the guard stays true across SDK transport refactors. The CLI previously narrowed on a CLI-owned
218
+ * `__bytekitFetchBody` brand its own transport stamped — which stopped matching the moment the SDK
219
+ * moved `/v1/fetch` onto `requestRaw` (#2535), silently demoting every fetch to the key/value
220
+ * printer. `fetch-contract.test.ts` pins this guard against the real SDK.
221
+ */
222
+ function isFetchResult(value) {
223
+ if (typeof value !== 'object' || value === null)
224
+ return false;
225
+ const v = value;
226
+ return v.bytes instanceof Uint8Array && v.headers instanceof Headers;
227
+ }
157
228
  /**
158
229
  * Render a `/v1/fetch` content result.
159
230
  *
160
- * The transport hands back a {@link FetchBodyResult} whose body IS the fetched content and whose
161
- * metadata are the `X-Fetch-*` headers. Default: write the raw body bytes to stdout verbatim
162
- * (`process.stdout.write`, never `console.log` — no coercion, no appended newline, binary-safe).
163
- * With `--json`: print the `X-Fetch-*` metadata as JSON instead of the body.
231
+ * The SDK's fetch resource resolves a {@link FetchResult} whose `bytes` ARE the fetched content and
232
+ * whose `headers` carry the `X-Fetch-*` metadata.
233
+ *
234
+ * - default — write the raw body BYTES verbatim (`process.stdout.write`, never `console.log`: no
235
+ * coercion, no appended newline, binary-safe). `bytes` is used rather than the `body` string
236
+ * because the string is a lossy UTF-8 decode for true binary passthrough (images, PDFs).
237
+ * - `--json` — print the `X-Fetch-*` metadata object instead of the body.
238
+ * - `target.file` — write the same bytes to that path instead of stdout, byte-for-byte.
164
239
  *
165
- * Falls back to {@link printResult} for any result that is not a fetch body (defensive — e.g. a
240
+ * Falls back to {@link printResult} for any result that is not a fetch result (defensive — e.g. a
166
241
  * bulk-fetch envelope routed here by mistake), so no path can silently emit nothing.
167
242
  */
168
- export function emitFetchResult(result, jsonMode) {
169
- if (isFetchBodyResult(result)) {
243
+ export function emitFetchResult(result, jsonMode, target = {}) {
244
+ if (isFetchResult(result)) {
170
245
  if (jsonMode) {
171
- console.log(JSON.stringify(result.metadata, null, 2));
246
+ const metadata = toJsonText(collectFetchMetadata(result.headers));
247
+ if (target.file !== undefined) {
248
+ writeFileSync(target.file, metadata);
249
+ return;
250
+ }
251
+ console.log(metadata);
252
+ return;
253
+ }
254
+ const bytes = Buffer.from(result.bytes);
255
+ if (target.file !== undefined) {
256
+ writeFileSync(target.file, bytes);
172
257
  return;
173
258
  }
174
- process.stdout.write(result.body);
259
+ process.stdout.write(bytes);
175
260
  return;
176
261
  }
177
262
  printResult(result, jsonMode);
@@ -183,7 +268,9 @@ export function emitFetchResult(result, jsonMode) {
183
268
  */
184
269
  export function printResult(data, jsonMode) {
185
270
  if (jsonMode) {
186
- console.log(JSON.stringify(data, null, 2));
271
+ // Always via toJsonText: a 204 hands us `undefined`, whose raw JSON.stringify is the VALUE
272
+ // undefined and would print the literal, unparseable text `undefined` (#2544).
273
+ console.log(toJsonText(data));
187
274
  return;
188
275
  }
189
276
  if (data === null || data === undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hunt-labs/bytekit-cli",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "ByteKit CLI — wraps @hunt-labs/bytekit-sdk as shell subcommands",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -41,7 +41,7 @@
41
41
  ],
42
42
  "dependencies": {
43
43
  "commander": "^12.0.0",
44
- "@hunt-labs/bytekit-sdk": "^0.3.0"
44
+ "@hunt-labs/bytekit-sdk": "^0.4.0"
45
45
  },
46
46
  "scripts": {
47
47
  "build": "tsc",
@@ -1,18 +0,0 @@
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;
@@ -1,6 +0,0 @@
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
- }