@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.
package/dist/client.js CHANGED
@@ -38,6 +38,10 @@ function timeoutReason(ms) {
38
38
  /**
39
39
  * A per-attempt abort budget: an `AbortController` fired by a `setTimeout`.
40
40
  *
41
+ * The budget spans the WHOLE attempt — time-to-headers **and** the response body read (#2546).
42
+ * Arming it only until headers arrive left `response.text()` / `response.arrayBuffer()` unbounded,
43
+ * so a server that answered with headers and then stalled its body hung the CLI forever.
44
+ *
41
45
  * Deliberately NOT `AbortSignal.timeout` (which the SDK prefers): that schedules on a timer the
42
46
  * test runner's fake timers cannot advance, so the transport's timeout would be untestable under
43
47
  * `vi.useFakeTimers()`. CLI requests are short-lived and single-shot, so the GC advantage
@@ -53,17 +57,36 @@ function createAttemptBudget(ms) {
53
57
  cleanup: () => clearTimeout(timer),
54
58
  };
55
59
  }
60
+ /** The `name` of an error-shaped value, or `undefined` when it carries none. */
61
+ const nameOf = (e) => typeof e === 'object' && e !== null && 'name' in e ? String(e.name) : undefined;
62
+ /** The `cause` of an error-shaped value, or `undefined` when it carries none. */
63
+ const causeOf = (e) => typeof e === 'object' && e !== null && 'cause' in e ? e.cause : undefined;
64
+ /**
65
+ * The abort-taxonomy name of a rejection, seeing through undici's mid-body wrapper.
66
+ *
67
+ * When a request is aborted while its BODY is streaming, undici does not reject with the abort
68
+ * reason itself — it rejects with `TypeError: terminated` carrying the reason on `.cause` (#2546).
69
+ * A name-only check would read that as a plain connection failure. Nothing that reached this
70
+ * function before #2546 carried a `.cause`, so every previously-asserted classification is
71
+ * unchanged; a `.cause` whose own name is not abort-shaped still falls through to `'connection'`.
72
+ */
73
+ function abortNameOf(err) {
74
+ const top = nameOf(err);
75
+ if (top === 'TimeoutError' || top === 'AbortError')
76
+ return top;
77
+ return nameOf(causeOf(err));
78
+ }
56
79
  /**
57
- * Map a `fetch` rejection (no HTTP response was produced) onto the SDK's `ByteKitConnectionError`
58
- * taxonomy, so a CLI failure carries the same `{name, code, cause}` a direct SDK call would.
80
+ * Map a `fetch` or body-read rejection (no usable HTTP payload was produced) onto the SDK's
81
+ * `ByteKitConnectionError` taxonomy, so a CLI failure carries the same `{name, code, cause}` a
82
+ * direct SDK call would.
59
83
  *
60
84
  * `timedOut` is true when this attempt's own budget fired the abort, which is the reliable
61
- * discriminator (undici rejects with the signal's reason, whose `name` we also inspect as a
62
- * fallback for a caller-style AbortError).
85
+ * discriminator (undici rejects with the signal's reason — or a wrapper carrying it, see
86
+ * {@link abortNameOf} — whose `name` we also inspect as a fallback for a caller-style AbortError).
63
87
  */
64
88
  function toConnectionError(err, method, path, timedOut) {
65
- const rawName = typeof err === 'object' && err !== null && 'name' in err ? err.name : undefined;
66
- const name = typeof rawName === 'string' ? rawName : '';
89
+ const name = abortNameOf(err) ?? '';
67
90
  let code;
68
91
  if (timedOut || name === 'TimeoutError') {
69
92
  code = 'timeout';
@@ -125,64 +148,57 @@ async function toApiError(response) {
125
148
  return new ByteKitError(response.status, code, message);
126
149
  }
127
150
  /**
128
- * Is this request targeting a `/v1/fetch` CONTENT endpoint (POST or GET), whose body IS the fetched
129
- * content rather than a JSON envelope?
151
+ * Perform the authenticated round-trip with a single bounded retry on 429, returning whatever the
152
+ * caller's `readBody` continuation makes of the successful response.
130
153
  *
131
- * Keyed off the request TARGET, never the response content-type: a fetched page can itself serve
132
- * `application/json`, and that body must still be returned raw, not parsed. The `/v1/fetch/bulk`
133
- * job endpoints are NOT content endpoints — they return a JSON envelope and keep parsing as JSON.
134
- */
135
- function isFetchContentPath(path) {
136
- return (path.split('?')[0] ?? path) === '/v1/fetch';
137
- }
138
- /** Title-Case an HTTP header name segment-by-segment: `x-fetch-status` → `X-Fetch-Status`. */
139
- function titleCaseHeader(name) {
140
- return name
141
- .split('-')
142
- .map((seg) => seg.charAt(0).toUpperCase() + seg.slice(1).toLowerCase())
143
- .join('-');
144
- }
145
- /** Collect the `X-Fetch-*` response headers into a Title-Cased metadata object. */
146
- function collectFetchMetadata(headers) {
147
- const meta = {};
148
- headers.forEach((value, key) => {
149
- if (key.toLowerCase().startsWith('x-fetch-')) {
150
- meta[titleCaseHeader(key)] = value;
151
- }
152
- });
153
- return meta;
154
- }
155
- /**
156
- * Build the CLI's HTTP transport: the SDK's `request` contract, plus a single bounded retry on 429.
154
+ * This is the CLI's one transport core: it owns auth, the per-attempt abort budget, the 429 retry,
155
+ * and the error taxonomy — mirroring the SDK's own private `send()`. Taking the body reader as a
156
+ * continuation (rather than handing the `Response` back with its body unread) is what lets the two
157
+ * public transports built on it ({@link createRetryingRequest} and {@link createRetryingRequestRaw})
158
+ * differ ONLY in how they decode a successful body, so no endpoint can ever ride one behavior and
159
+ * miss the other (#2535: `/v1/fetch` silently bypassed the retry because only `request` was
160
+ * overridden) — while keeping the abort budget armed ACROSS that read (#2546).
161
+ *
162
+ * The budget spans headers **and** body. Returning the `Response` used to end the guarded region
163
+ * at the headers boundary, leaving `response.text()` / `response.arrayBuffer()` — and
164
+ * `toApiError`'s error-body `response.json()` — unbounded: a server that sent headers and then
165
+ * stalled its body hung the CLI forever. `readBody` therefore MUST only consume the stream;
166
+ * `JSON.parse` / `TextDecoder` run in the callers, OUTSIDE the guard, so a malformed body still
167
+ * surfaces its own parse error instead of being remapped to a connection error.
157
168
  *
158
- * Why the CLI owns the `fetch` instead of wrapping the SDK's `request()`: the SDK throws a
169
+ * The budget stays PER ATTEMPT: `cleanup()` runs in an attempt-wide `finally`, and the 429 retry
170
+ * sleep happens after that `finally` — so a retry can never be cut short by time already spent on
171
+ * the first attempt.
172
+ *
173
+ * Why the CLI owns the `fetch` instead of wrapping the SDK's transports: the SDK throws a
159
174
  * header-less `ByteKitError` and discards the `Response`, so a delegating wrapper can see
160
175
  * `status === 429` but can never read `Retry-After`. Owning the fetch is the only way to honor the
161
176
  * server's advertised backoff without editing the SDK.
162
177
  *
163
- * Every SDK resource dereferences `this.client.request` at call time, so installing this as an own
164
- * property of the client (see {@link buildClient}) routes *all* commands through it from one seam.
165
- *
166
178
  * Retries apply to 429 only. Non-429 statuses and network rejections propagate untouched.
167
179
  */
168
- export function createRetryingRequest(apiKey, baseUrl, timeoutMs = DEFAULT_TIMEOUT_MS) {
169
- return async function request(method, path, body) {
170
- for (let attempt = 0;; attempt++) {
171
- const init = {
172
- method,
173
- headers: {
174
- Authorization: `Bearer ${apiKey}`,
175
- 'Content-Type': 'application/json',
176
- },
177
- };
178
- if (body !== undefined) {
179
- init.body = JSON.stringify(body);
180
- }
181
- // Per-attempt abort budget: a fresh timer for THIS fetch. A budget of 0 disables it.
182
- const budget = timeoutMs > 0 ? createAttemptBudget(timeoutMs) : undefined;
183
- if (budget !== undefined) {
184
- init.signal = budget.signal;
185
- }
180
+ async function sendWithRetry(apiKey, baseUrl, timeoutMs, method, path, body, readBody) {
181
+ for (let attempt = 0;; attempt++) {
182
+ const init = {
183
+ method,
184
+ headers: {
185
+ Authorization: `Bearer ${apiKey}`,
186
+ 'Content-Type': 'application/json',
187
+ },
188
+ };
189
+ if (body !== undefined) {
190
+ init.body = JSON.stringify(body);
191
+ }
192
+ // Per-attempt abort budget: a fresh timer for THIS attempt. A budget of 0 disables it.
193
+ const budget = timeoutMs > 0 ? createAttemptBudget(timeoutMs) : undefined;
194
+ if (budget !== undefined) {
195
+ init.signal = budget.signal;
196
+ }
197
+ // Set only by the 429-retry branch below — the one path that falls out of the `try` rather
198
+ // than returning or throwing. Left `undefined` on every other path, which never reaches the
199
+ // `sleep` after the `finally`.
200
+ let retryAfterMs;
201
+ try {
186
202
  let response;
187
203
  try {
188
204
  response = await fetch(`${baseUrl}${path}`, init);
@@ -193,36 +209,86 @@ export function createRetryingRequest(apiKey, baseUrl, timeoutMs = DEFAULT_TIMEO
193
209
  // no longer surfaces as a bare `Error: fetch failed`.
194
210
  throw toConnectionError(err, method, path, budget?.signal.aborted === true);
195
211
  }
196
- finally {
197
- // Clear the timer on every exit path — success, HTTP error, or abort — so a healthy
198
- // request leaves no dangling 120s timer.
199
- budget?.cleanup();
200
- }
201
212
  if (response.ok) {
202
- // A /v1/fetch content response body IS the fetched content (HTML/markdown/binary), with its
203
- // metadata in X-Fetch-* headers. Return the raw bytes untouched — JSON-parsing it is the
204
- // #2467 DOA crash. Every other endpoint returns a JSON envelope, parsed below.
205
- if (isFetchContentPath(path)) {
206
- const bodyBytes = Buffer.from(await response.arrayBuffer());
207
- const fetchResult = {
208
- __bytekitFetchBody: true,
209
- body: bodyBytes,
210
- metadata: collectFetchMetadata(response.headers),
211
- };
212
- return fetchResult;
213
+ try {
214
+ return await readBody(response);
213
215
  }
214
- const text = await response.text();
215
- if (response.status === 204 || text.trim().length === 0) {
216
- return undefined;
216
+ catch (err) {
217
+ // A body read that never completes is aborted by the same budget; undici then rejects
218
+ // the stream (#2546). Map it into the same taxonomy the headers phase uses.
219
+ throw toConnectionError(err, method, path, budget?.signal.aborted === true);
217
220
  }
218
- return JSON.parse(text);
219
221
  }
220
222
  if (response.status === 429 && attempt < MAX_RETRIES) {
221
- await sleep(parseRetryAfterMs(response.headers.get('Retry-After')));
222
- continue;
223
+ retryAfterMs = parseRetryAfterMs(response.headers.get('Retry-After'));
223
224
  }
224
- throw await toApiError(response);
225
+ else {
226
+ // `toApiError` reads the error body, so it too runs inside the budget.
227
+ throw await toApiError(response);
228
+ }
229
+ }
230
+ finally {
231
+ // Clear the timer on every exit path — success (body read included), HTTP error, or
232
+ // abort — so a healthy request leaves no dangling 120s timer.
233
+ budget?.cleanup();
234
+ }
235
+ // OUTSIDE the budget's `finally`: the backoff is not spent against any attempt's budget, and
236
+ // the next attempt arms a fresh one. The `??` is unreachable in practice (only the branch
237
+ // that assigns `retryAfterMs` falls through to here) and exists solely to keep the delay a
238
+ // `number` without an assertion; it uses the same value `parseRetryAfterMs(null)` returns.
239
+ await sleep(retryAfterMs ?? DEFAULT_RETRY_AFTER_MS);
240
+ }
241
+ }
242
+ /**
243
+ * Build the CLI's JSON transport: the SDK's `request` contract, plus the single bounded 429 retry.
244
+ *
245
+ * Every SDK resource dereferences `this.client.request` at call time, so installing this as an own
246
+ * property of the client (see {@link buildClient}) routes all JSON commands through it from one
247
+ * seam. `/v1/fetch` does NOT ride this transport — it rides `requestRaw` (see
248
+ * {@link createRetryingRequestRaw}); `/v1/fetch/bulk` is a JSON job envelope and does.
249
+ */
250
+ export function createRetryingRequest(apiKey, baseUrl, timeoutMs = DEFAULT_TIMEOUT_MS) {
251
+ return async function request(method, path, body) {
252
+ // The reader consumes the stream and nothing else, so the abort budget covers the read
253
+ // (#2546) while the 204/empty guard and `JSON.parse` stay OUTSIDE the guarded region —
254
+ // a malformed 200 body must still surface its own SyntaxError, never a connection error.
255
+ // Read-first ordering is preserved verbatim from before #2546: `.text()` is called
256
+ // unconditionally, and only then is the status inspected.
257
+ const { status, text } = await sendWithRetry(apiKey, baseUrl, timeoutMs, method, path, body, async (response) => ({ status: response.status, text: await response.text() }));
258
+ if (status === 204 || text.trim().length === 0) {
259
+ return undefined;
225
260
  }
261
+ return JSON.parse(text);
262
+ };
263
+ }
264
+ /**
265
+ * Build the CLI's RAW transport: the SDK's `requestRaw` contract, plus the same bounded 429 retry.
266
+ *
267
+ * `/v1/fetch` returns the fetched content AS the body (HTML, markdown, or true binary) with its
268
+ * metadata in `X-Fetch-*` headers, so the SDK routes it through `requestRaw` rather than the JSON
269
+ * `request`. The CLI must override BOTH seams or fetch commands silently bypass the retry and the
270
+ * CLI's error taxonomy — the #2535 defect, where only `request` was replaced.
271
+ *
272
+ * The returned shape is the SDK's `requestRaw` contract verbatim (`{body, bytes, headers, status}`)
273
+ * so `FetchResource` builds an ordinary `FetchResult` from it and the CLI never forks fetch
274
+ * handling away from the SDK. `bytes` is byte-exact (binary-safe); `body` is the best-effort UTF-8
275
+ * decode, matching the SDK's own semantics.
276
+ */
277
+ export function createRetryingRequestRaw(apiKey, baseUrl, timeoutMs = DEFAULT_TIMEOUT_MS) {
278
+ return async function requestRaw(method, path, body) {
279
+ // As in `createRetryingRequest`: the reader only drains the stream (so the abort budget
280
+ // covers it, #2546); the UTF-8 decode happens outside the guarded region.
281
+ const { bytes, headers, status } = await sendWithRetry(apiKey, baseUrl, timeoutMs, method, path, body, async (response) => ({
282
+ bytes: new Uint8Array(await response.arrayBuffer()),
283
+ headers: response.headers,
284
+ status: response.status,
285
+ }));
286
+ return {
287
+ body: new TextDecoder('utf-8').decode(bytes),
288
+ bytes,
289
+ headers,
290
+ status,
291
+ };
226
292
  };
227
293
  }
228
294
  /**
@@ -233,8 +299,10 @@ export function createRetryingRequest(apiKey, baseUrl, timeoutMs = DEFAULT_TIMEO
233
299
  * baseUrl: --base-url flag → BYTEKIT_BASE_URL → DEFAULT_BASE_URL.
234
300
  * Exits with a descriptive error when no key is available.
235
301
  *
236
- * The returned client's `request` is replaced by the retrying transport, so every command inherits
237
- * the 429 retry from this one seam.
302
+ * The returned client's `request` AND `requestRaw` are both replaced by the retrying transports, so
303
+ * every command inherits the 429 retry — JSON commands via `request`, the `/v1/fetch` content
304
+ * commands via `requestRaw`. Overriding only `request` is the #2535 defect: the SDK's fetch
305
+ * resource rides `requestRaw`, so fetch bypassed the retry and the CLI's error taxonomy entirely.
238
306
  */
239
307
  export function buildClient(opts) {
240
308
  const apiKey = opts.key ?? process.env['BYTEKIT_API_KEY'];
@@ -245,5 +313,6 @@ export function buildClient(opts) {
245
313
  const baseUrl = opts.baseUrl ?? process.env['BYTEKIT_BASE_URL'] ?? DEFAULT_BASE_URL;
246
314
  const client = new ByteKit({ apiKey, baseUrl });
247
315
  client.request = createRetryingRequest(apiKey, baseUrl);
316
+ client.requestRaw = createRetryingRequestRaw(apiKey, baseUrl);
248
317
  return client;
249
318
  }
@@ -1,5 +1,16 @@
1
1
  import { Command } from 'commander';
2
2
  import { type GlobalOptions } from '../client.js';
3
+ /**
4
+ * The `account` command group.
5
+ *
6
+ * Key management is DELIBERATELY absent (#2536). Those operations are mounted behind
7
+ * `clerkSession(db)` in the gateway and are annotated `x-internal: true` / `x-sdk-scope: internal`
8
+ * in the OpenAPI spec, so a bearer `sk_` key — the CLI's only credential — can never satisfy them.
9
+ * Keys are managed from the dashboard instead. Widening the gateway to accept bearer auth was
10
+ * rejected on security grounds: the reveal operation returns the live plaintext of any key on the
11
+ * account and there is no per-key scope model, so any leaked key would become a full
12
+ * account-credential factory. `internal-scope-guard.test.ts` mechanically enforces this boundary.
13
+ */
3
14
  export declare function makeAccountCommand(getGlobal: () => GlobalOptions & {
4
15
  json: boolean;
5
16
  }): Command;
@@ -1,136 +1,19 @@
1
1
  import { Command } from 'commander';
2
2
  import { buildClient } from '../client.js';
3
3
  import { printResult, handleError } from '../output.js';
4
- /** Visible lead-in of the masked form: the `sk_live_`/`sk_test_` prefix (or first 8 chars). */
5
- const MASK_PREFIX_CHARS = 8;
6
- /** Visible tail of the masked form, shown only when enough of the middle stays hidden. */
7
- const MASK_SUFFIX_CHARS = 4;
8
- /** Minimum number of characters the `…` must actually hide before a suffix may be shown. */
9
- const MIN_MASKED_CHARS = 8;
10
4
  /**
11
- * Mask a plaintext API key for human display: show the `sk_live_`/`sk_test_` prefix (or the first
12
- * 8 chars) and the last 4, with the secret middle replaced by `…`. The last-4 suffix is shown ONLY
13
- * when at least MIN_MASKED_CHARS of the middle stay hidden (key length >= 8 + 4 + 8 = 20): on a
14
- * 13–19-char key, `slice(0, 8) + slice(-4)` would hide as little as one character, and at 12 or
15
- * fewer the prefix and suffix overlap outright — either way the "mask" would leak most of a live
16
- * secret, so everything after the recognized prefix is masked instead (#2480).
5
+ * The `account` command group.
6
+ *
7
+ * Key management is DELIBERATELY absent (#2536). Those operations are mounted behind
8
+ * `clerkSession(db)` in the gateway and are annotated `x-internal: true` / `x-sdk-scope: internal`
9
+ * in the OpenAPI spec, so a bearer `sk_` key — the CLI's only credential — can never satisfy them.
10
+ * Keys are managed from the dashboard instead. Widening the gateway to accept bearer auth was
11
+ * rejected on security grounds: the reveal operation returns the live plaintext of any key on the
12
+ * account and there is no per-key scope model, so any leaked key would become a full
13
+ * account-credential factory. `internal-scope-guard.test.ts` mechanically enforces this boundary.
17
14
  */
18
- function maskKey(key) {
19
- const prefixMatch = /^(sk_(?:live|test)_)/.exec(key);
20
- if (key.length < MASK_PREFIX_CHARS + MASK_SUFFIX_CHARS + MIN_MASKED_CHARS) {
21
- const prefix = prefixMatch ? prefixMatch[1] : key.slice(0, MASK_PREFIX_CHARS);
22
- return `${prefix}…`;
23
- }
24
- return `${key.slice(0, MASK_PREFIX_CHARS)}…${key.slice(-MASK_SUFFIX_CHARS)}`;
25
- }
26
15
  export function makeAccountCommand(getGlobal) {
27
- const cmd = new Command('account').description('Account and API key operations');
28
- const apiKeysCmd = new Command('api-keys').description('API key management');
29
- apiKeysCmd
30
- .command('create')
31
- .description('Create a new API key')
32
- .requiredOption('--name <name>', 'Display name for the key')
33
- .action(async (opts) => {
34
- const g = getGlobal();
35
- const client = buildClient(g);
36
- try {
37
- // The API's field is `label`, not `name` — sending `{ name }` created an
38
- // UNLABELLED key, because the gateway ignored the unknown field (#2426:
39
- // the SDK's newly derived request type is what surfaced this).
40
- const result = await client.account.apiKeys.create({ label: opts.name });
41
- printResult(result, g.json);
42
- }
43
- catch (err) {
44
- handleError(err);
45
- }
46
- });
47
- apiKeysCmd
48
- .command('list')
49
- .description('List all API keys')
50
- .action(async () => {
51
- const g = getGlobal();
52
- const client = buildClient(g);
53
- try {
54
- const result = await client.account.apiKeys.list();
55
- printResult(result, g.json);
56
- }
57
- catch (err) {
58
- handleError(err);
59
- }
60
- });
61
- apiKeysCmd
62
- .command('get <id>')
63
- .description('Get an API key by ID')
64
- .action(async (id) => {
65
- const g = getGlobal();
66
- const client = buildClient(g);
67
- try {
68
- const result = await client.account.apiKeys.get(id);
69
- printResult(result, g.json);
70
- }
71
- catch (err) {
72
- handleError(err);
73
- }
74
- });
75
- apiKeysCmd
76
- .command('update <id>')
77
- .description('Update an API key')
78
- .requiredOption('--name <name>', 'New display name')
79
- .action(async (id, opts) => {
80
- const g = getGlobal();
81
- const client = buildClient(g);
82
- try {
83
- // As above: the API's field is `label`, so `--name` never actually
84
- // renamed the key.
85
- const result = await client.account.apiKeys.update(id, { label: opts.name });
86
- printResult(result, g.json);
87
- }
88
- catch (err) {
89
- handleError(err);
90
- }
91
- });
92
- apiKeysCmd
93
- .command('reveal <id>')
94
- .description('Reveal an API key value (masked by default; use --show for plaintext)')
95
- .option('--show', 'Print the full plaintext key instead of the masked form')
96
- .action(async (id, opts) => {
97
- const g = getGlobal();
98
- const client = buildClient(g);
99
- try {
100
- const result = await client.account.apiKeys.reveal(id);
101
- // Masking decision (#2480 E10): `reveal` prints a LIVE secret to a terminal that may be
102
- // logged or screen-shared, so plaintext is opt-in. Default (human) mode masks the key
103
- // (prefix + last-4, e.g. `sk_live_…2345`); `--show` prints the full plaintext; `--json`
104
- // emits the full envelope verbatim for machine consumption.
105
- if (g.json) {
106
- printResult(result, true);
107
- }
108
- else if (opts.show === true) {
109
- console.log(result.key);
110
- }
111
- else {
112
- console.log(maskKey(result.key));
113
- }
114
- }
115
- catch (err) {
116
- handleError(err);
117
- }
118
- });
119
- apiKeysCmd
120
- .command('revoke <id>')
121
- .description('Revoke an API key (idempotent)')
122
- .action(async (id) => {
123
- const g = getGlobal();
124
- const client = buildClient(g);
125
- try {
126
- const result = await client.account.apiKeys.revoke(id);
127
- printResult(result, g.json);
128
- }
129
- catch (err) {
130
- handleError(err);
131
- }
132
- });
133
- cmd.addCommand(apiKeysCmd);
16
+ const cmd = new Command('account').description('Account operations');
134
17
  cmd
135
18
  .command('get')
136
19
  .description('Get the authenticated account')
@@ -51,5 +51,19 @@ export function makeBulkCommand(getGlobal) {
51
51
  handleError(err);
52
52
  }
53
53
  });
54
+ cmd
55
+ .command('cancel <id>')
56
+ .description('Cancel an in-flight bulk job')
57
+ .action(async (id) => {
58
+ const g = getGlobal();
59
+ const client = buildClient(g);
60
+ try {
61
+ const result = await client.bulk.cancel(id);
62
+ printResult(result, g.json);
63
+ }
64
+ catch (err) {
65
+ handleError(err);
66
+ }
67
+ });
54
68
  return cmd;
55
69
  }
@@ -2,7 +2,10 @@ import { Command, InvalidArgumentError } from 'commander';
2
2
  import { buildClient } from '../client.js';
3
3
  import { printResult, emitFetchResult, handleError } from '../output.js';
4
4
  import { parseUrlLines } from '../lib/url-lines.js';
5
+ import { parseIntFlag, parseEnum, parseCacheTtl } from '../lib/parse-flags.js';
5
6
  const FETCH_FORMATS = ['markdown', 'html'];
7
+ /** The `FetchRequest.markdown_mode` spec enum (create/POST only — excluded from the GET query). */
8
+ const MARKDOWN_MODES = ['article', 'raw', 'llm'];
6
9
  /**
7
10
  * Validate `--format` locally, before any HTTP request is issued.
8
11
  *
@@ -59,15 +62,27 @@ export function makeFetchCommand(getGlobal) {
59
62
  .description('Fetch URL content (POST)')
60
63
  .requiredOption('--url <url>', 'URL to fetch')
61
64
  .option('--format <format>', 'Response format (markdown or html)', parseFormat)
65
+ .option('--country <cc>', 'Two-letter country code to route the request through (e.g. us, de)')
66
+ .option('--timeout-ms <n>', 'Per-request timeout in milliseconds', (v) => parseIntFlag('--timeout-ms', v))
67
+ .option('--cache-ttl <ttl>', 'Cache freshness: 0, Nh (hours), or Nd (days), e.g. 48h, 2d, 0 (omitted → server default)', (v) => parseCacheTtl('--cache-ttl', v))
68
+ // `--markdown-mode` is POST-only: FetchGetOpts excludes it, so adding it to `get` below would be
69
+ // a compile error — the create/get asymmetry is correct by construction.
70
+ .option('--markdown-mode <mode>', `Markdown mode: ${MARKDOWN_MODES.join(', ')}`, (v) => parseEnum('--markdown-mode', MARKDOWN_MODES, v))
71
+ .option('-o, --output <file>', 'Write the fetched body bytes to the given file path')
62
72
  .action(async (opts) => {
63
73
  const g = getGlobal();
64
74
  const client = buildClient(g);
65
75
  try {
66
76
  const result = await client.fetch.create({
67
77
  url: opts.url,
78
+ // Each optional field is spread ONLY when its flag is present (#2480 E1).
68
79
  ...(opts.format !== undefined ? { format: opts.format } : {}),
80
+ ...(opts.country !== undefined ? { country: opts.country } : {}),
81
+ ...(opts.timeoutMs !== undefined ? { timeout_ms: opts.timeoutMs } : {}),
82
+ ...(opts.cacheTtl !== undefined ? { cache_ttl: opts.cacheTtl } : {}),
83
+ ...(opts.markdownMode !== undefined ? { markdown_mode: opts.markdownMode } : {}),
69
84
  });
70
- emitFetchResult(result, g.json);
85
+ emitFetchResult(result, g.json, { file: opts.output });
71
86
  }
72
87
  catch (err) {
73
88
  handleError(err);
@@ -78,6 +93,10 @@ export function makeFetchCommand(getGlobal) {
78
93
  .description('Fetch URL content (GET via query params)')
79
94
  .requiredOption('--url <url>', 'URL to fetch')
80
95
  .option('--format <format>', 'Response format (markdown or html)', parseFormat)
96
+ .option('--country <cc>', 'Two-letter country code to route the request through (e.g. us, de)')
97
+ .option('--timeout-ms <n>', 'Per-request timeout in milliseconds', (v) => parseIntFlag('--timeout-ms', v))
98
+ .option('--cache-ttl <ttl>', 'Cache freshness: 0, Nh (hours), or Nd (days), e.g. 48h, 2d, 0 (omitted → server default)', (v) => parseCacheTtl('--cache-ttl', v))
99
+ .option('-o, --output <file>', 'Write the fetched body bytes to the given file path')
81
100
  .action(async (opts) => {
82
101
  const g = getGlobal();
83
102
  const client = buildClient(g);
@@ -85,8 +104,11 @@ export function makeFetchCommand(getGlobal) {
85
104
  const result = await client.fetch.get({
86
105
  url: opts.url,
87
106
  ...(opts.format !== undefined ? { format: opts.format } : {}),
107
+ ...(opts.country !== undefined ? { country: opts.country } : {}),
108
+ ...(opts.timeoutMs !== undefined ? { timeout_ms: opts.timeoutMs } : {}),
109
+ ...(opts.cacheTtl !== undefined ? { cache_ttl: opts.cacheTtl } : {}),
88
110
  });
89
- emitFetchResult(result, g.json);
111
+ emitFetchResult(result, g.json, { file: opts.output });
90
112
  }
91
113
  catch (err) {
92
114
  handleError(err);