@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.
package/dist/output.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { writeFileSync } from 'node:fs';
2
+ import { isFetchBodyResult } from './lib/fetch-body.js';
2
3
  /** Text formats whose bodies are strings and can therefore be emitted raw. */
3
4
  const TEXT_FORMATS = 'markdown, html, raw_html';
4
5
  /**
@@ -71,18 +72,56 @@ export function extractRawBody(response, requestedFormat) {
71
72
  }
72
73
  return value;
73
74
  }
75
+ /**
76
+ * Exit code for `--raw`/`-o` against a 202 queued (async) scrape.
77
+ *
78
+ * Deliberately NOT 0 (a full raw-body success) and NOT 1 (a usage/API error): the job WAS accepted
79
+ * and billed, there is simply no content body to emit yet. A distinct code lets a caller branch on
80
+ * "queued" without parsing stderr.
81
+ */
82
+ export const QUEUED_RAW_EXIT_CODE = 2;
83
+ /** Is `result` a 202 queued scrape envelope carrying a job id? */
84
+ function isQueuedEnvelope(result) {
85
+ if (typeof result !== 'object' || result === null)
86
+ return false;
87
+ const obj = result;
88
+ return obj.status === 'queued' && typeof obj.id === 'string' && obj.id.length > 0;
89
+ }
90
+ /**
91
+ * Handle `--raw`/`-o` on a 202 queued envelope.
92
+ *
93
+ * The user asked for a content body that does not exist yet — but the job was accepted, so losing
94
+ * the handle (the old behaviour: throw `--raw: response contains no formats`, exit 1) is the real
95
+ * 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.
98
+ */
99
+ export function emitQueuedRaw(env) {
100
+ console.error(`Note: scrape ${env.id} was queued (async) — no content body is available yet. ` +
101
+ `Poll it with: bytekit scrape get ${env.id}`);
102
+ process.stdout.write(`${env.id}\n`);
103
+ process.exit(QUEUED_RAW_EXIT_CODE);
104
+ }
74
105
  /**
75
106
  * Emit a raw body to the target.
76
107
  *
77
- * Uses `process.stdout.write`, never `console.log`: `console.log` appends a newline, which would
78
- * make the stdout bytes and the `--output` file bytes differ.
108
+ * Uses `process.stdout.write`, never `console.log`: `console.log` unconditionally appends a newline,
109
+ * which would make the stdout bytes and the `--output` file bytes differ.
110
+ *
111
+ * Trailing-newline rule (#2452 item 6): a body has no guaranteed trailing newline (a scraped markdown
112
+ * page rarely ends in one), so a naked `--raw` glued the shell prompt onto the last line. When stdout
113
+ * is an interactive TTY we append a single newline (idempotent — never doubling an existing one) so the
114
+ * prompt starts cleanly. Off a TTY (a pipe or `>`/`>>` redirect) we write the body verbatim, preserving
115
+ * the byte-exact invariant that `--raw > file` matches `--output file`. A FILE destination is never
116
+ * touched by this rule at all.
79
117
  */
80
118
  export function emitRaw(body, target) {
81
119
  if (target.file !== undefined) {
82
120
  writeFileSync(target.file, body);
83
121
  return;
84
122
  }
85
- process.stdout.write(body);
123
+ const appendNewline = process.stdout.isTTY === true && !body.endsWith('\n');
124
+ process.stdout.write(appendNewline ? `${body}\n` : body);
86
125
  }
87
126
  /**
88
127
  * The single output dispatch a command calls — so no command needs `node:fs` and every rendering
@@ -93,6 +132,14 @@ export function emitRaw(body, target) {
93
132
  */
94
133
  export function emitResult(result, mode, target, requestedFormat) {
95
134
  if (mode.kind === 'raw') {
135
+ // A 202 queued scrape has no format body to print — surface its `sc_` handle instead of
136
+ // throwing away the accepted (and billed) job. Scoped to raw mode only: json/human rendering
137
+ // of a queued envelope stays unchanged (falls through to printResult below). A `-o <file>`
138
+ // target is deliberately ignored here (id always on STDOUT, never written to the file): there
139
+ // is no content body yet, and a file holding a bare `sc_` id would masquerade as content.
140
+ if (isQueuedEnvelope(result)) {
141
+ emitQueuedRaw(result);
142
+ }
96
143
  emitRaw(extractRawBody(result, requestedFormat), target);
97
144
  return;
98
145
  }
@@ -107,6 +154,28 @@ export function emitResult(result, mode, target, requestedFormat) {
107
154
  // human — unreachable with a file destination (resolveOutputMode never yields it alongside one).
108
155
  printResult(result, false);
109
156
  }
157
+ /**
158
+ * Render a `/v1/fetch` content result.
159
+ *
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.
164
+ *
165
+ * Falls back to {@link printResult} for any result that is not a fetch body (defensive — e.g. a
166
+ * bulk-fetch envelope routed here by mistake), so no path can silently emit nothing.
167
+ */
168
+ export function emitFetchResult(result, jsonMode) {
169
+ if (isFetchBodyResult(result)) {
170
+ if (jsonMode) {
171
+ console.log(JSON.stringify(result.metadata, null, 2));
172
+ return;
173
+ }
174
+ process.stdout.write(result.body);
175
+ return;
176
+ }
177
+ printResult(result, jsonMode);
178
+ }
110
179
  /**
111
180
  * Print a result to stdout.
112
181
  *
@@ -131,19 +200,52 @@ export function printResult(data, jsonMode) {
131
200
  }
132
201
  console.log(JSON.stringify(data, null, 2));
133
202
  }
203
+ /** The SDK error classes whose `code` is a ByteKit diagnostic worth surfacing as a suffix. */
204
+ const BYTEKIT_ERROR_NAMES = new Set(['ByteKitError', 'ByteKitConnectionError']);
205
+ /**
206
+ * A non-empty API/connection error `code`, or undefined when absent.
207
+ *
208
+ * Scoped by `err.name` to the ByteKit taxonomy: plenty of OTHER errors also carry a string
209
+ * `.code` — Node fs/system errors (ENOENT from `parseUrlLines`'s readFileSync, EACCES on a `-o`
210
+ * write) — whose message already embeds the code, so suffixing them would render the redundant
211
+ * `Error: ENOENT: … (ENOENT)`. Only `ByteKitError` / `ByteKitConnectionError` codes qualify.
212
+ */
213
+ function errorCode(err) {
214
+ if (!BYTEKIT_ERROR_NAMES.has(err.name))
215
+ return undefined;
216
+ const raw = err.code;
217
+ return typeof raw === 'string' && raw.length > 0 ? raw : undefined;
218
+ }
134
219
  /**
135
220
  * Handle a CLI command error: print to stderr and exit 1.
221
+ *
222
+ * The message always begins `Error: ` (a prefix scripts grep on) followed by the error message and
223
+ * an optional parenthesised suffix built from the two diagnostic axes the ByteKit errors carry:
224
+ *
225
+ * - a `ByteKitError` from the transport carries both a numeric `status` and an API `code`
226
+ * (`rate_limited`, `invalid_url`, …) → `Error: <message> (<code>, status: <n>)`;
227
+ * - a `ByteKitConnectionError` (client-side timeout / abort / socket failure) carries a `code`
228
+ * (`timeout` | `aborted` | `connection`) but NO status → `Error: <message> (<code>)`;
229
+ * - a plain error with neither → `Error: <message>`.
230
+ *
231
+ * The `code` axis is read only off the two ByteKit error classes (see {@link errorCode}) — a plain
232
+ * error carrying an unrelated string `.code` (a Node errno error) gets NO code suffix. When a
233
+ * status is present but no code (a hand-built error), the suffix is `(status: <n>)` with no empty
234
+ * placeholder — never `(, status: <n>)`.
136
235
  */
137
236
  export function handleError(err) {
138
237
  if (err instanceof Error) {
139
- const hasStatus = 'status' in err && typeof err.status === 'number';
238
+ const status = err.status;
239
+ const hasStatus = typeof status === 'number';
240
+ const code = errorCode(err);
241
+ let suffix = '';
140
242
  if (hasStatus) {
141
- const apiErr = err;
142
- console.error(`Error: ${apiErr.message} (status: ${apiErr.status})`);
243
+ suffix = code !== undefined ? ` (${code}, status: ${status})` : ` (status: ${status})`;
143
244
  }
144
- else {
145
- console.error(`Error: ${err.message}`);
245
+ else if (code !== undefined) {
246
+ suffix = ` (${code})`;
146
247
  }
248
+ console.error(`Error: ${err.message}${suffix}`);
147
249
  }
148
250
  else {
149
251
  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.0",
3
+ "version": "0.3.0",
4
4
  "description": "ByteKit CLI — wraps @hunt-labs/bytekit-sdk as shell subcommands",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,19 +32,16 @@
32
32
  ],
33
33
  "homepage": "https://bytekit.com/docs",
34
34
  "bugs": {
35
- "email": "support@bytekit.com"
35
+ "email": "support@bytekit.com",
36
+ "url": "https://bytekit.com/contact"
36
37
  },
37
38
  "files": [
38
- "dist"
39
+ "dist",
40
+ "CHANGELOG.md"
39
41
  ],
40
42
  "dependencies": {
41
43
  "commander": "^12.0.0",
42
- "@hunt-labs/bytekit-sdk": "^0.2.0"
43
- },
44
- "devDependencies": {
45
- "@types/node": "^22.0.0",
46
- "typescript": "^5.0.0",
47
- "vitest": "^1.0.0"
44
+ "@hunt-labs/bytekit-sdk": "^0.3.0"
48
45
  },
49
46
  "scripts": {
50
47
  "build": "tsc",
@@ -1,5 +0,0 @@
1
- import { Command } from 'commander';
2
- import { type GlobalOptions } from '../client.js';
3
- export declare function makeRecordingsCommand(getGlobal: () => GlobalOptions & {
4
- json: boolean;
5
- }): Command;
@@ -1,36 +0,0 @@
1
- import { Command } from 'commander';
2
- import { buildClient } from '../client.js';
3
- import { printResult, handleError } from '../output.js';
4
- export function makeRecordingsCommand(getGlobal) {
5
- const cmd = new Command('recordings').description('Recording operations');
6
- cmd
7
- .command('create')
8
- .description('Start a page recording')
9
- .requiredOption('--url <url>', 'URL to record')
10
- .action(async (opts) => {
11
- const g = getGlobal();
12
- const client = buildClient(g);
13
- try {
14
- const result = await client.recordings.create({ url: opts.url });
15
- printResult(result, g.json);
16
- }
17
- catch (err) {
18
- handleError(err);
19
- }
20
- });
21
- cmd
22
- .command('get <id>')
23
- .description('Get a recording by ID')
24
- .action(async (id) => {
25
- const g = getGlobal();
26
- const client = buildClient(g);
27
- try {
28
- const result = await client.recordings.get(id);
29
- printResult(result, g.json);
30
- }
31
- catch (err) {
32
- handleError(err);
33
- }
34
- });
35
- return cmd;
36
- }