@hunt-labs/bytekit-cli 0.2.2 → 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.
package/dist/output.js CHANGED
@@ -1,6 +1,27 @@
1
1
  import { writeFileSync } from 'node:fs';
2
2
  /** Text formats whose bodies are strings and can therefore be emitted raw. */
3
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
+ }
4
25
  /**
5
26
  * Resolve the output mode from the flags, and enforce the `--json` / `--raw` exclusion.
6
27
  *
@@ -71,6 +92,47 @@ export function extractRawBody(response, requestedFormat) {
71
92
  }
72
93
  return value;
73
94
  }
95
+ /**
96
+ * Exit code for `--raw`/`-o` against a 202 queued (async) scrape.
97
+ *
98
+ * Deliberately NOT 0 (a full raw-body success) and NOT 1 (a usage/API error): the job WAS accepted
99
+ * and billed, there is simply no content body to emit yet. A distinct code lets a caller branch on
100
+ * "queued" without parsing stderr.
101
+ */
102
+ export const QUEUED_RAW_EXIT_CODE = 2;
103
+ /** Is `result` a 202 queued scrape envelope carrying a job id? */
104
+ function isQueuedEnvelope(result) {
105
+ if (typeof result !== 'object' || result === null)
106
+ return false;
107
+ const obj = result;
108
+ return obj.status === 'queued' && typeof obj.id === 'string' && obj.id.length > 0;
109
+ }
110
+ /**
111
+ * Handle `--raw`/`-o` on a 202 queued envelope.
112
+ *
113
+ * The user asked for a content body that does not exist yet — but the job was accepted, so losing
114
+ * the handle (the old behaviour: throw `--raw: response contains no formats`, exit 1) is the real
115
+ * defect. Instead: emit the `sc_` id to STDOUT so `$(bytekit scrape create … --raw)` still captures
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.
129
+ */
130
+ export function emitQueuedRaw(env) {
131
+ console.error(`Note: scrape ${env.id} was queued (async) — no content body is available yet. ` +
132
+ `Poll it with: bytekit scrape get ${env.id}`);
133
+ process.stdout.write(`${env.id}\n`);
134
+ process.exitCode = QUEUED_RAW_EXIT_CODE;
135
+ }
74
136
  /**
75
137
  * Emit a raw body to the target.
76
138
  *
@@ -101,12 +163,25 @@ export function emitRaw(body, target) {
101
163
  */
102
164
  export function emitResult(result, mode, target, requestedFormat) {
103
165
  if (mode.kind === 'raw') {
166
+ // A 202 queued scrape has no format body to print — surface its `sc_` handle instead of
167
+ // throwing away the accepted (and billed) job. Scoped to raw mode only: json/human rendering
168
+ // of a queued envelope stays unchanged (falls through to printResult below). A `-o <file>`
169
+ // target is deliberately ignored here (id always on STDOUT, never written to the file): there
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.
175
+ if (isQueuedEnvelope(result)) {
176
+ emitQueuedRaw(result);
177
+ return;
178
+ }
104
179
  emitRaw(extractRawBody(result, requestedFormat), target);
105
180
  return;
106
181
  }
107
182
  if (mode.kind === 'json') {
108
183
  if (target.file !== undefined) {
109
- writeFileSync(target.file, JSON.stringify(result, null, 2));
184
+ writeFileSync(target.file, toJsonText(result));
110
185
  return;
111
186
  }
112
187
  printResult(result, true);
@@ -115,6 +190,77 @@ export function emitResult(result, mode, target, requestedFormat) {
115
190
  // human — unreachable with a file destination (resolveOutputMode never yields it alongside one).
116
191
  printResult(result, false);
117
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
+ }
228
+ /**
229
+ * Render a `/v1/fetch` content result.
230
+ *
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.
239
+ *
240
+ * Falls back to {@link printResult} for any result that is not a fetch result (defensive — e.g. a
241
+ * bulk-fetch envelope routed here by mistake), so no path can silently emit nothing.
242
+ */
243
+ export function emitFetchResult(result, jsonMode, target = {}) {
244
+ if (isFetchResult(result)) {
245
+ if (jsonMode) {
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);
257
+ return;
258
+ }
259
+ process.stdout.write(bytes);
260
+ return;
261
+ }
262
+ printResult(result, jsonMode);
263
+ }
118
264
  /**
119
265
  * Print a result to stdout.
120
266
  *
@@ -122,7 +268,9 @@ export function emitResult(result, mode, target, requestedFormat) {
122
268
  */
123
269
  export function printResult(data, jsonMode) {
124
270
  if (jsonMode) {
125
- 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));
126
274
  return;
127
275
  }
128
276
  if (data === null || data === undefined) {
@@ -139,19 +287,52 @@ export function printResult(data, jsonMode) {
139
287
  }
140
288
  console.log(JSON.stringify(data, null, 2));
141
289
  }
290
+ /** The SDK error classes whose `code` is a ByteKit diagnostic worth surfacing as a suffix. */
291
+ const BYTEKIT_ERROR_NAMES = new Set(['ByteKitError', 'ByteKitConnectionError']);
292
+ /**
293
+ * A non-empty API/connection error `code`, or undefined when absent.
294
+ *
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`
297
+ * write) — whose message already embeds the code, so suffixing them would render the redundant
298
+ * `Error: ENOENT: … (ENOENT)`. Only `ByteKitError` / `ByteKitConnectionError` codes qualify.
299
+ */
300
+ function errorCode(err) {
301
+ if (!BYTEKIT_ERROR_NAMES.has(err.name))
302
+ return undefined;
303
+ const raw = err.code;
304
+ return typeof raw === 'string' && raw.length > 0 ? raw : undefined;
305
+ }
142
306
  /**
143
307
  * Handle a CLI command error: print to stderr and exit 1.
308
+ *
309
+ * The message always begins `Error: ` (a prefix scripts grep on) followed by the error message and
310
+ * an optional parenthesised suffix built from the two diagnostic axes the ByteKit errors carry:
311
+ *
312
+ * - a `ByteKitError` from the transport carries both a numeric `status` and an API `code`
313
+ * (`rate_limited`, `invalid_url`, …) → `Error: <message> (<code>, status: <n>)`;
314
+ * - a `ByteKitConnectionError` (client-side timeout / abort / socket failure) carries a `code`
315
+ * (`timeout` | `aborted` | `connection`) but NO status → `Error: <message> (<code>)`;
316
+ * - a plain error with neither → `Error: <message>`.
317
+ *
318
+ * The `code` axis is read only off the two ByteKit error classes (see {@link errorCode}) — a plain
319
+ * error carrying an unrelated string `.code` (a Node errno error) gets NO code suffix. When a
320
+ * status is present but no code (a hand-built error), the suffix is `(status: <n>)` with no empty
321
+ * placeholder — never `(, status: <n>)`.
144
322
  */
145
323
  export function handleError(err) {
146
324
  if (err instanceof Error) {
147
- const hasStatus = 'status' in err && typeof err.status === 'number';
325
+ const status = err.status;
326
+ const hasStatus = typeof status === 'number';
327
+ const code = errorCode(err);
328
+ let suffix = '';
148
329
  if (hasStatus) {
149
- const apiErr = err;
150
- console.error(`Error: ${apiErr.message} (status: ${apiErr.status})`);
330
+ suffix = code !== undefined ? ` (${code}, status: ${status})` : ` (status: ${status})`;
151
331
  }
152
- else {
153
- console.error(`Error: ${err.message}`);
332
+ else if (code !== undefined) {
333
+ suffix = ` (${code})`;
154
334
  }
335
+ console.error(`Error: ${err.message}${suffix}`);
155
336
  }
156
337
  else {
157
338
  console.error(`Error: ${String(err)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hunt-labs/bytekit-cli",
3
- "version": "0.2.2",
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.2.2"
44
+ "@hunt-labs/bytekit-sdk": "^0.4.0"
45
45
  },
46
46
  "scripts": {
47
47
  "build": "tsc",