@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/CHANGELOG.md +123 -0
- package/README.md +56 -19
- package/dist/client.d.ts +18 -5
- package/dist/client.js +140 -12
- package/dist/commands/account.js +49 -0
- package/dist/commands/fetch.d.ts +14 -0
- package/dist/commands/fetch.js +23 -6
- package/dist/commands/monitors.js +26 -3
- package/dist/commands/scrape.js +88 -0
- package/dist/commands/screenshots.d.ts +12 -0
- package/dist/commands/screenshots.js +105 -6
- package/dist/commands/search.js +5 -1
- package/dist/commands/sitemap.js +8 -1
- package/dist/index.js +2 -4
- package/dist/lib/download-artifact.d.ts +13 -0
- package/dist/lib/download-artifact.js +25 -0
- package/dist/lib/fetch-body.d.ts +18 -0
- package/dist/lib/fetch-body.js +6 -0
- package/dist/lib/parse-flags.d.ts +16 -0
- package/dist/lib/parse-flags.js +32 -0
- package/dist/output.d.ts +59 -2
- package/dist/output.js +110 -8
- package/package.json +6 -9
- package/dist/commands/recordings.d.ts +0 -5
- package/dist/commands/recordings.js +0 -36
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@hunt-labs/bytekit-cli` are documented here.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
This file records only the delta between versions. For how to use the package
|
|
9
|
+
today, see [README.md](./README.md), which remains the authoritative usage guide.
|
|
10
|
+
|
|
11
|
+
## [0.3.0] - 2026-07-21
|
|
12
|
+
|
|
13
|
+
### Removed (**BREAKING**)
|
|
14
|
+
|
|
15
|
+
- The legacy `RAPIDCRAWL_API_KEY` and `RAPIDCRAWL_BASE_URL` environment-variable
|
|
16
|
+
fallbacks (kept as a one-minor-version bridge in #2421) are removed. Use
|
|
17
|
+
`BYTEKIT_API_KEY` and `BYTEKIT_BASE_URL` (or the `--key` / `--base-url` flags).
|
|
18
|
+
(#2473)
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- `bytekit fetch get` / `fetch create` no longer crash on non-JSON bodies. The
|
|
23
|
+
transport JSON-parsed every 2xx response, but `/v1/fetch` returns the fetched
|
|
24
|
+
content AS the body. The fetch command now prints the raw body by default, or
|
|
25
|
+
the `X-Fetch-*` metadata as JSON with `--json`; `fetch bulk` and every other
|
|
26
|
+
command still parse JSON. (#2467)
|
|
27
|
+
- `204 No Content` / empty-body responses (e.g. `monitors delete`,
|
|
28
|
+
`account api-keys revoke`) no longer throw a JSON-parse error, including after
|
|
29
|
+
a 429 retry. (#2467, #2468)
|
|
30
|
+
- `search --type images` no longer prints an `undefined` snippet line for
|
|
31
|
+
results that omit the field. (#2479)
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- `scrape create` accepts the full `ScrapeRequest` surface: `--country`,
|
|
36
|
+
`--timeout-ms`, `--markdown-mode`, `--async`, `--webhook-url`,
|
|
37
|
+
`--token-budget`, `--clean-markdown`, `--include-tags`, `--exclude-tags`,
|
|
38
|
+
`--mobile`. Each flag is omitted from the request when absent. (#2480)
|
|
39
|
+
- `screenshots create` accepts `--device`, `--viewport WxH`, `--format`,
|
|
40
|
+
`--quality`, `--wait-until`, `--clip x,y,w,h`, and `--async`; `-o <file>`
|
|
41
|
+
on `screenshots create`/`get` downloads the captured image bytes (`-o -` for
|
|
42
|
+
stdout). `-o` cannot be combined with `--json` on screenshots. (#2480)
|
|
43
|
+
- `monitors list` accepts `--limit`, `--cursor`, and `--status` query
|
|
44
|
+
filters. (#2480)
|
|
45
|
+
- `account api-keys reveal <ak_id>`: prints the key masked by default
|
|
46
|
+
(`sk_live_…2345`); pass `--show` for the full plaintext. (#2480)
|
|
47
|
+
- Per-attempt transport timeout (120s, mirroring the SDK default): a hung
|
|
48
|
+
request now fails with `code: 'timeout'` instead of hanging forever. Network
|
|
49
|
+
failures are mapped onto the SDK's connection-error taxonomy
|
|
50
|
+
(`timeout` / `aborted` / `connection`), and error output renders the API or
|
|
51
|
+
connection error code. (#2479)
|
|
52
|
+
- A queued async job (HTTP 202) under `--raw` prints the job envelope with its
|
|
53
|
+
`sc_` id instead of an empty body. (#2479)
|
|
54
|
+
|
|
55
|
+
### Changed (**BREAKING**)
|
|
56
|
+
|
|
57
|
+
- `fetch --format` is validated locally against the spec union
|
|
58
|
+
`'markdown' | 'html'`; off-spec values are rejected with a clear error before
|
|
59
|
+
a request is sent (previously forwarded as-is). (#2475)
|
|
60
|
+
|
|
61
|
+
## [0.2.2] - 2026-07-16
|
|
62
|
+
|
|
63
|
+
### Changed
|
|
64
|
+
|
|
65
|
+
- Re-release of 0.2.1. The 0.2.1 artifacts never reached npm — the prod publish's
|
|
66
|
+
`publish-sdk` step failed on a CI test-isolation flake (`cjs-require.test.ts`
|
|
67
|
+
rebuilding the shared `dist`), which is now fixed. No consumer-facing changes
|
|
68
|
+
beyond 0.2.1.
|
|
69
|
+
|
|
70
|
+
## [0.2.1] - 2026-07-16
|
|
71
|
+
|
|
72
|
+
### Changed
|
|
73
|
+
|
|
74
|
+
- `BYTEKIT_API_KEY` / `BYTEKIT_BASE_URL` are now the primary environment
|
|
75
|
+
variables (precedence: flag > `BYTEKIT_*` > legacy `RAPIDCRAWL_*`).
|
|
76
|
+
`RAPIDCRAWL_API_KEY` / `RAPIDCRAWL_BASE_URL` continue to work as silent
|
|
77
|
+
fallbacks. `--help` text, the missing-key error, and the README now reference
|
|
78
|
+
`BYTEKIT_*`. (#2421)
|
|
79
|
+
|
|
80
|
+
### Removed
|
|
81
|
+
|
|
82
|
+
- The `recordings` command (the endpoint is permanently `410` deactivated). (#2421)
|
|
83
|
+
|
|
84
|
+
## [0.2.0] - 2026-07-15
|
|
85
|
+
|
|
86
|
+
Additive, lower-risk release — no breaking changes to existing commands.
|
|
87
|
+
|
|
88
|
+
### Added
|
|
89
|
+
|
|
90
|
+
- `scrape create --raw` / `-o, --output <file>`: write the raw response body to
|
|
91
|
+
stdout or a file (#2425).
|
|
92
|
+
- Local `search --limit` validation via a commander `argParser`, so an invalid limit
|
|
93
|
+
is rejected before a request is sent (#2425).
|
|
94
|
+
- One automatic retry on HTTP 429, honoring a clamped `Retry-After` header, in the
|
|
95
|
+
CLI transport (#2425).
|
|
96
|
+
|
|
97
|
+
### Fixed
|
|
98
|
+
|
|
99
|
+
- Entrypoint guard canonicalized so the `bytekit` bin runs correctly when invoked
|
|
100
|
+
through an npm/npx symlink (#2418).
|
|
101
|
+
|
|
102
|
+
### Changed
|
|
103
|
+
|
|
104
|
+
- Package renamed to `@hunt-labs/bytekit-cli` (the `@bytekit` npm scope was
|
|
105
|
+
unobtainable); the `bin` name stays `bytekit`. The prior `rapidcrawl-cli` name
|
|
106
|
+
remains installable at its last version but is deprecated.
|
|
107
|
+
- Consumer call sites propagate the SDK's narrowed, spec-derived request types (#2426).
|
|
108
|
+
- Publish-ready packaging metadata: dropped `main`, unpinned the SDK dependency to
|
|
109
|
+
`workspace:^`, added `engines`, `keywords`, `homepage`, `bugs`, `README.md`,
|
|
110
|
+
`LICENSE`, and an MIT `license` field.
|
|
111
|
+
|
|
112
|
+
## [0.1.0] - 2026
|
|
113
|
+
|
|
114
|
+
### Added
|
|
115
|
+
|
|
116
|
+
- Initial release of the ByteKit CLI, wrapping the ByteKit SDK as shell
|
|
117
|
+
subcommands, published under the original `rapidcrawl-cli` name.
|
|
118
|
+
|
|
119
|
+
[0.3.0]: https://github.com/Hunt-Labs-Inc/ByteKit/tree/main/packages/cli
|
|
120
|
+
[0.2.2]: https://github.com/Hunt-Labs-Inc/ByteKit/tree/main/packages/cli
|
|
121
|
+
[0.2.1]: https://github.com/Hunt-Labs-Inc/ByteKit/tree/main/packages/cli
|
|
122
|
+
[0.2.0]: https://github.com/Hunt-Labs-Inc/ByteKit/tree/main/packages/cli
|
|
123
|
+
[0.1.0]: https://github.com/Hunt-Labs-Inc/ByteKit/tree/main/packages/cli
|
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ Create an API key at [app.bytekit.com](https://app.bytekit.com) (keys are
|
|
|
32
32
|
prefixed `sk_live_`), then either export it:
|
|
33
33
|
|
|
34
34
|
```bash
|
|
35
|
-
export
|
|
35
|
+
export BYTEKIT_API_KEY=sk_live_xxx
|
|
36
36
|
bytekit scrape create --url https://example.com
|
|
37
37
|
```
|
|
38
38
|
|
|
@@ -43,26 +43,28 @@ over the environment variable):
|
|
|
43
43
|
bytekit --key sk_live_xxx scrape create --url https://example.com
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
Precedence is `--key` flag → `BYTEKIT_API_KEY`.
|
|
47
|
+
|
|
48
|
+
Without a key, the CLI exits with `Error: API key required. Pass --key or set BYTEKIT_API_KEY.`
|
|
47
49
|
|
|
48
50
|
## Base-URL override
|
|
49
51
|
|
|
50
52
|
Requests go to `https://api.bytekit.com` by default. Point the CLI at staging, a
|
|
51
53
|
local gateway, or a proxy with the global `--base-url` flag or the
|
|
52
|
-
`
|
|
54
|
+
`BYTEKIT_BASE_URL` environment variable (the flag wins):
|
|
53
55
|
|
|
54
56
|
```bash
|
|
55
57
|
bytekit --base-url https://api-stg.bytekit.com scrape create --url https://example.com
|
|
56
|
-
export
|
|
58
|
+
export BYTEKIT_BASE_URL=http://localhost:3001
|
|
57
59
|
```
|
|
58
60
|
|
|
59
61
|
## Global options
|
|
60
62
|
|
|
61
|
-
| Flag | Env var
|
|
62
|
-
| ------------------ |
|
|
63
|
-
| `--key <key>` | `
|
|
64
|
-
| `--base-url <url>` | `
|
|
65
|
-
| `--json` | —
|
|
63
|
+
| Flag | Env var | Purpose |
|
|
64
|
+
| ------------------ | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
|
|
65
|
+
| `--key <key>` | `BYTEKIT_API_KEY` | API key used for every request. |
|
|
66
|
+
| `--base-url <url>` | `BYTEKIT_BASE_URL` | API base URL (default `https://api.bytekit.com`). |
|
|
67
|
+
| `--json` | — | Print the raw JSON response instead of the human-readable rendering (for `fetch`, prints the `X-Fetch-*` metadata instead of the body). |
|
|
66
68
|
|
|
67
69
|
## Commands
|
|
68
70
|
|
|
@@ -74,6 +76,27 @@ bytekit scrape get sc_123abc
|
|
|
74
76
|
bytekit scrape bulk create --file urls.txt --webhook-url https://your.app/hooks/bytekit
|
|
75
77
|
```
|
|
76
78
|
|
|
79
|
+
`scrape create` also accepts the full `ScrapeRequest` surface, each flag omitted
|
|
80
|
+
from the request when absent (a boolean flag never sends `false`):
|
|
81
|
+
|
|
82
|
+
| Flag | Effect |
|
|
83
|
+
| ------------------------ | ------------------------------------------------------- |
|
|
84
|
+
| `--country <cc>` | Route through a country (e.g. `us`, `de`). |
|
|
85
|
+
| `--timeout-ms <n>` | Per-request timeout in milliseconds. |
|
|
86
|
+
| `--markdown-mode <mode>` | Markdown extraction mode: `article`, `raw`, `llm`. |
|
|
87
|
+
| `--async` | Queue the scrape and return a job envelope immediately. |
|
|
88
|
+
| `--webhook-url <url>` | Webhook notified when an async scrape completes. |
|
|
89
|
+
| `--token-budget <n>` | Cap the markdown returned to `n` tokens. |
|
|
90
|
+
| `--clean-markdown` | LLM-clean the markdown (billed 3x). |
|
|
91
|
+
| `--include-tags <csv>` | Comma-separated HTML tags to keep. |
|
|
92
|
+
| `--exclude-tags <csv>` | Comma-separated HTML tags to drop. |
|
|
93
|
+
| `--mobile` | Render with a mobile viewport/user-agent. |
|
|
94
|
+
|
|
95
|
+
`--async` combined with `--raw` (or a bare `-o`) prints the queued job envelope
|
|
96
|
+
(the `sc_` id + status) rather than a format body, since a queued scrape has no
|
|
97
|
+
body yet. On `scrape`, `-o` writes the same text rendering the terminal would
|
|
98
|
+
show, so it is legal alongside `--json`.
|
|
99
|
+
|
|
77
100
|
### `fetch` — raw, unprocessed response bodies
|
|
78
101
|
|
|
79
102
|
```bash
|
|
@@ -91,9 +114,22 @@ bytekit search "ai web scraping" --limit 10 --country us
|
|
|
91
114
|
|
|
92
115
|
```bash
|
|
93
116
|
bytekit screenshots create --url https://example.com --full-page
|
|
94
|
-
bytekit screenshots
|
|
117
|
+
bytekit screenshots create --url https://example.com --device mobile --format png -o shot.png
|
|
118
|
+
bytekit screenshots get ss_123abc -o shot.png
|
|
95
119
|
```
|
|
96
120
|
|
|
121
|
+
`screenshots create` accepts `--device` (`desktop`, `mobile`), `--viewport WxH`
|
|
122
|
+
(e.g. `1280x720`), `--format` (`jpeg`, `png`), `--quality <n>`, `--wait-until`
|
|
123
|
+
(`load`, `domcontentloaded`, `networkidle`), `--clip x,y,w,h`, and `--async`
|
|
124
|
+
(queue and return a job envelope). Each flag is omitted from the request when
|
|
125
|
+
absent.
|
|
126
|
+
|
|
127
|
+
`-o <file>` downloads the captured image bytes to that file (use `-o -` for
|
|
128
|
+
stdout). Because `-o` writes BINARY on `screenshots`, it cannot be combined with
|
|
129
|
+
`--json` (that would emit binary into a JSON pipeline) — pass one or the other.
|
|
130
|
+
`--async` + `-o` prints the queued job envelope and skips the download (the
|
|
131
|
+
image is not ready yet).
|
|
132
|
+
|
|
97
133
|
### `bulk` — bulk screenshot jobs
|
|
98
134
|
|
|
99
135
|
```bash
|
|
@@ -110,11 +146,15 @@ bytekit bulk screenshots list "$bulk_id"
|
|
|
110
146
|
```bash
|
|
111
147
|
bytekit monitors create --url https://example.com/pricing --interval-type daily \
|
|
112
148
|
--webhook-url https://your.app/hooks/bytekit
|
|
113
|
-
bytekit monitors list
|
|
149
|
+
bytekit monitors list --limit 25 --status active
|
|
114
150
|
bytekit monitors captures list mon_123abc
|
|
115
151
|
bytekit monitors delete mon_123abc
|
|
116
152
|
```
|
|
117
153
|
|
|
154
|
+
`monitors list` accepts `--limit <n>`, `--cursor <cursor>`, and `--status`
|
|
155
|
+
(`active`, `processing`, `paused`, `cancelled`, `suspended`) as query filters,
|
|
156
|
+
each omitted when absent.
|
|
157
|
+
|
|
118
158
|
### `sitemap` — crawl a site's URL graph
|
|
119
159
|
|
|
120
160
|
```bash
|
|
@@ -128,16 +168,13 @@ bytekit sitemap get sm_123abc
|
|
|
128
168
|
bytekit account get
|
|
129
169
|
bytekit account api-keys list
|
|
130
170
|
bytekit account api-keys create --name "ci-key"
|
|
171
|
+
bytekit account api-keys reveal ak_123abc # masked by default
|
|
172
|
+
bytekit account api-keys reveal ak_123abc --show # full plaintext
|
|
131
173
|
```
|
|
132
174
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
bytekit recordings get rec_123abc
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
The `/v1/recordings` endpoints are deactivated server-side and return HTTP 410;
|
|
140
|
-
the subcommand remains only so existing scripts fail loudly rather than silently.
|
|
175
|
+
`account api-keys reveal` prints a live secret, so it MASKS the key by default
|
|
176
|
+
(`sk_live_…2345`). Pass `--show` for the full plaintext, or `--json` for the
|
|
177
|
+
full envelope (for scripts).
|
|
141
178
|
|
|
142
179
|
## Documentation
|
|
143
180
|
|
package/dist/client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ByteKit } from '@hunt-labs/bytekit-sdk';
|
|
2
2
|
export interface GlobalOptions {
|
|
3
3
|
key?: string | undefined;
|
|
4
4
|
baseUrl?: string | undefined;
|
|
@@ -22,6 +22,17 @@ export declare const MIN_RETRY_AFTER_MS = 1000;
|
|
|
22
22
|
export declare const MAX_RETRY_AFTER_MS = 60000;
|
|
23
23
|
/** Exactly one retry. A second 429 is terminal — no loop. */
|
|
24
24
|
export declare const MAX_RETRIES = 1;
|
|
25
|
+
/**
|
|
26
|
+
* Default CLIENT-SIDE abort budget for a single fetch attempt, in ms.
|
|
27
|
+
*
|
|
28
|
+
* Mirrors the SDK's own `DEFAULT_TIMEOUT_MS` (120s). Overriding `client.request` with this
|
|
29
|
+
* transport had discarded the SDK's default, so a hung server hung the CLI forever; this restores
|
|
30
|
+
* the same bound. The budget is **per attempt**, not per command: the single 429 retry (and its
|
|
31
|
+
* backoff sleep) gets a fresh budget, so the retry can never be cut short by time already spent on
|
|
32
|
+
* the first attempt. A future `--timeout` flag would override this default; today only the default
|
|
33
|
+
* exists.
|
|
34
|
+
*/
|
|
35
|
+
export declare const DEFAULT_TIMEOUT_MS = 120000;
|
|
25
36
|
/**
|
|
26
37
|
* Parse a `Retry-After` header into a clamped millisecond delay.
|
|
27
38
|
*
|
|
@@ -34,7 +45,7 @@ export declare function parseRetryAfterMs(raw: string | null): number;
|
|
|
34
45
|
* Build the CLI's HTTP transport: the SDK's `request` contract, plus a single bounded retry on 429.
|
|
35
46
|
*
|
|
36
47
|
* Why the CLI owns the `fetch` instead of wrapping the SDK's `request()`: the SDK throws a
|
|
37
|
-
* header-less `
|
|
48
|
+
* header-less `ByteKitError` and discards the `Response`, so a delegating wrapper can see
|
|
38
49
|
* `status === 429` but can never read `Retry-After`. Owning the fetch is the only way to honor the
|
|
39
50
|
* server's advertised backoff without editing the SDK.
|
|
40
51
|
*
|
|
@@ -43,14 +54,16 @@ export declare function parseRetryAfterMs(raw: string | null): number;
|
|
|
43
54
|
*
|
|
44
55
|
* Retries apply to 429 only. Non-429 statuses and network rejections propagate untouched.
|
|
45
56
|
*/
|
|
46
|
-
export declare function createRetryingRequest(apiKey: string, baseUrl: string):
|
|
57
|
+
export declare function createRetryingRequest(apiKey: string, baseUrl: string, timeoutMs?: number): ByteKit['request'];
|
|
47
58
|
/**
|
|
48
59
|
* Build an authenticated SDK client from global CLI options.
|
|
49
60
|
*
|
|
50
|
-
* Resolution order
|
|
61
|
+
* Resolution order (explicit input beats environment):
|
|
62
|
+
* apiKey: --key flag → BYTEKIT_API_KEY.
|
|
63
|
+
* baseUrl: --base-url flag → BYTEKIT_BASE_URL → DEFAULT_BASE_URL.
|
|
51
64
|
* Exits with a descriptive error when no key is available.
|
|
52
65
|
*
|
|
53
66
|
* The returned client's `request` is replaced by the retrying transport, so every command inherits
|
|
54
67
|
* the 429 retry from this one seam.
|
|
55
68
|
*/
|
|
56
|
-
export declare function buildClient(opts: GlobalOptions):
|
|
69
|
+
export declare function buildClient(opts: GlobalOptions): ByteKit;
|
package/dist/client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ByteKit, ByteKitError, ByteKitConnectionError } from '@hunt-labs/bytekit-sdk';
|
|
2
2
|
/**
|
|
3
3
|
* The API host the SDK itself defaults to. The SDK's copy is module-private, so the CLI declares
|
|
4
4
|
* its own and always passes an explicit `baseUrl` to the constructor — that way the SDK client and
|
|
@@ -18,6 +18,69 @@ export const MIN_RETRY_AFTER_MS = 1_000;
|
|
|
18
18
|
export const MAX_RETRY_AFTER_MS = 60_000;
|
|
19
19
|
/** Exactly one retry. A second 429 is terminal — no loop. */
|
|
20
20
|
export const MAX_RETRIES = 1;
|
|
21
|
+
/**
|
|
22
|
+
* Default CLIENT-SIDE abort budget for a single fetch attempt, in ms.
|
|
23
|
+
*
|
|
24
|
+
* Mirrors the SDK's own `DEFAULT_TIMEOUT_MS` (120s). Overriding `client.request` with this
|
|
25
|
+
* transport had discarded the SDK's default, so a hung server hung the CLI forever; this restores
|
|
26
|
+
* the same bound. The budget is **per attempt**, not per command: the single 429 retry (and its
|
|
27
|
+
* backoff sleep) gets a fresh budget, so the retry can never be cut short by time already spent on
|
|
28
|
+
* the first attempt. A future `--timeout` flag would override this default; today only the default
|
|
29
|
+
* exists.
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_TIMEOUT_MS = 120_000;
|
|
32
|
+
/** The abort reason used when a per-attempt budget elapses; `name` maps it to `code: 'timeout'`. */
|
|
33
|
+
function timeoutReason(ms) {
|
|
34
|
+
const reason = new Error(`ByteKit: request timed out after ${ms}ms`);
|
|
35
|
+
reason.name = 'TimeoutError';
|
|
36
|
+
return reason;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A per-attempt abort budget: an `AbortController` fired by a `setTimeout`.
|
|
40
|
+
*
|
|
41
|
+
* Deliberately NOT `AbortSignal.timeout` (which the SDK prefers): that schedules on a timer the
|
|
42
|
+
* test runner's fake timers cannot advance, so the transport's timeout would be untestable under
|
|
43
|
+
* `vi.useFakeTimers()`. CLI requests are short-lived and single-shot, so the GC advantage
|
|
44
|
+
* `AbortSignal.timeout` buys the long-running SDK does not apply here. The timer is `unref`'d so a
|
|
45
|
+
* pending budget never keeps the process alive, and `cleanup()` clears it on every exit path.
|
|
46
|
+
*/
|
|
47
|
+
function createAttemptBudget(ms) {
|
|
48
|
+
const controller = new AbortController();
|
|
49
|
+
const timer = setTimeout(() => controller.abort(timeoutReason(ms)), ms);
|
|
50
|
+
timer.unref?.();
|
|
51
|
+
return {
|
|
52
|
+
signal: controller.signal,
|
|
53
|
+
cleanup: () => clearTimeout(timer),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
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.
|
|
59
|
+
*
|
|
60
|
+
* `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).
|
|
63
|
+
*/
|
|
64
|
+
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 : '';
|
|
67
|
+
let code;
|
|
68
|
+
if (timedOut || name === 'TimeoutError') {
|
|
69
|
+
code = 'timeout';
|
|
70
|
+
}
|
|
71
|
+
else if (name === 'AbortError') {
|
|
72
|
+
code = 'aborted';
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
code = 'connection';
|
|
76
|
+
}
|
|
77
|
+
const detail = code === 'timeout'
|
|
78
|
+
? 'request timed out'
|
|
79
|
+
: code === 'aborted'
|
|
80
|
+
? 'request aborted'
|
|
81
|
+
: 'connection failed before a response was received';
|
|
82
|
+
return new ByteKitConnectionError(code, `ByteKit: ${method} ${path} — ${detail}`, { cause: err });
|
|
83
|
+
}
|
|
21
84
|
/**
|
|
22
85
|
* Parse a `Retry-After` header into a clamped millisecond delay.
|
|
23
86
|
*
|
|
@@ -44,7 +107,7 @@ function sleep(ms) {
|
|
|
44
107
|
/**
|
|
45
108
|
* Shape a failed response into the SDK's OWN error class, using the SDK's own body parsing.
|
|
46
109
|
*
|
|
47
|
-
* Reusing `
|
|
110
|
+
* Reusing `ByteKitError` keeps `{status, code, message}` identical to what a direct SDK call
|
|
48
111
|
* would throw, so `handleError()`'s `'status' in err` branch still renders
|
|
49
112
|
* `Error: <message> (status: <n>)` and exits 1.
|
|
50
113
|
*/
|
|
@@ -59,13 +122,41 @@ async function toApiError(response) {
|
|
|
59
122
|
catch {
|
|
60
123
|
// leave defaults
|
|
61
124
|
}
|
|
62
|
-
return new
|
|
125
|
+
return new ByteKitError(response.status, code, message);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
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?
|
|
130
|
+
*
|
|
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;
|
|
63
154
|
}
|
|
64
155
|
/**
|
|
65
156
|
* Build the CLI's HTTP transport: the SDK's `request` contract, plus a single bounded retry on 429.
|
|
66
157
|
*
|
|
67
158
|
* Why the CLI owns the `fetch` instead of wrapping the SDK's `request()`: the SDK throws a
|
|
68
|
-
* header-less `
|
|
159
|
+
* header-less `ByteKitError` and discards the `Response`, so a delegating wrapper can see
|
|
69
160
|
* `status === 429` but can never read `Retry-After`. Owning the fetch is the only way to honor the
|
|
70
161
|
* server's advertised backoff without editing the SDK.
|
|
71
162
|
*
|
|
@@ -74,7 +165,7 @@ async function toApiError(response) {
|
|
|
74
165
|
*
|
|
75
166
|
* Retries apply to 429 only. Non-429 statuses and network rejections propagate untouched.
|
|
76
167
|
*/
|
|
77
|
-
export function createRetryingRequest(apiKey, baseUrl) {
|
|
168
|
+
export function createRetryingRequest(apiKey, baseUrl, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
78
169
|
return async function request(method, path, body) {
|
|
79
170
|
for (let attempt = 0;; attempt++) {
|
|
80
171
|
const init = {
|
|
@@ -87,9 +178,44 @@ export function createRetryingRequest(apiKey, baseUrl) {
|
|
|
87
178
|
if (body !== undefined) {
|
|
88
179
|
init.body = JSON.stringify(body);
|
|
89
180
|
}
|
|
90
|
-
|
|
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
|
+
}
|
|
186
|
+
let response;
|
|
187
|
+
try {
|
|
188
|
+
response = await fetch(`${baseUrl}${path}`, init);
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
// No HTTP response was produced (timeout, abort, socket/DNS/TLS). Re-throw as the SDK's
|
|
192
|
+
// own ByteKitConnectionError so a hung server can never hang the CLI and a network failure
|
|
193
|
+
// no longer surfaces as a bare `Error: fetch failed`.
|
|
194
|
+
throw toConnectionError(err, method, path, budget?.signal.aborted === true);
|
|
195
|
+
}
|
|
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
|
+
}
|
|
91
201
|
if (response.ok) {
|
|
92
|
-
|
|
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
|
+
}
|
|
214
|
+
const text = await response.text();
|
|
215
|
+
if (response.status === 204 || text.trim().length === 0) {
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
return JSON.parse(text);
|
|
93
219
|
}
|
|
94
220
|
if (response.status === 429 && attempt < MAX_RETRIES) {
|
|
95
221
|
await sleep(parseRetryAfterMs(response.headers.get('Retry-After')));
|
|
@@ -102,20 +228,22 @@ export function createRetryingRequest(apiKey, baseUrl) {
|
|
|
102
228
|
/**
|
|
103
229
|
* Build an authenticated SDK client from global CLI options.
|
|
104
230
|
*
|
|
105
|
-
* Resolution order
|
|
231
|
+
* Resolution order (explicit input beats environment):
|
|
232
|
+
* apiKey: --key flag → BYTEKIT_API_KEY.
|
|
233
|
+
* baseUrl: --base-url flag → BYTEKIT_BASE_URL → DEFAULT_BASE_URL.
|
|
106
234
|
* Exits with a descriptive error when no key is available.
|
|
107
235
|
*
|
|
108
236
|
* The returned client's `request` is replaced by the retrying transport, so every command inherits
|
|
109
237
|
* the 429 retry from this one seam.
|
|
110
238
|
*/
|
|
111
239
|
export function buildClient(opts) {
|
|
112
|
-
const apiKey = opts.key ?? process.env['
|
|
240
|
+
const apiKey = opts.key ?? process.env['BYTEKIT_API_KEY'];
|
|
113
241
|
if (!apiKey) {
|
|
114
|
-
console.error('Error: API key required. Pass --key or set
|
|
242
|
+
console.error('Error: API key required. Pass --key or set BYTEKIT_API_KEY.');
|
|
115
243
|
process.exit(1);
|
|
116
244
|
}
|
|
117
|
-
const baseUrl = opts.baseUrl ?? process.env['
|
|
118
|
-
const client = new
|
|
245
|
+
const baseUrl = opts.baseUrl ?? process.env['BYTEKIT_BASE_URL'] ?? DEFAULT_BASE_URL;
|
|
246
|
+
const client = new ByteKit({ apiKey, baseUrl });
|
|
119
247
|
client.request = createRetryingRequest(apiKey, baseUrl);
|
|
120
248
|
return client;
|
|
121
249
|
}
|
package/dist/commands/account.js
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
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
|
+
/**
|
|
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).
|
|
17
|
+
*/
|
|
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
|
+
}
|
|
4
26
|
export function makeAccountCommand(getGlobal) {
|
|
5
27
|
const cmd = new Command('account').description('Account and API key operations');
|
|
6
28
|
const apiKeysCmd = new Command('api-keys').description('API key management');
|
|
@@ -67,6 +89,33 @@ export function makeAccountCommand(getGlobal) {
|
|
|
67
89
|
handleError(err);
|
|
68
90
|
}
|
|
69
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
|
+
});
|
|
70
119
|
apiKeysCmd
|
|
71
120
|
.command('revoke <id>')
|
|
72
121
|
.description('Revoke an API key (idempotent)')
|
package/dist/commands/fetch.d.ts
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import { type GlobalOptions } from '../client.js';
|
|
3
|
+
/** The `/v1/fetch` spec union for `format` — mirrors `FetchOpts['format']` in the SDK. */
|
|
4
|
+
type FetchFormat = 'markdown' | 'html';
|
|
5
|
+
/**
|
|
6
|
+
* Validate `--format` locally, before any HTTP request is issued.
|
|
7
|
+
*
|
|
8
|
+
* The SDK's `FetchOpts.format` is narrowed to the spec union `'markdown' | 'html'`
|
|
9
|
+
* (#2475), so an off-spec value like `pdf` is a compile-time error at the call site.
|
|
10
|
+
* Commander runs an option's argParser during parsing — ahead of the action callback —
|
|
11
|
+
* so rejecting here means the client is never built and no request is issued, and the
|
|
12
|
+
* caller gets an actionable message instead of a server 422 (same convention as
|
|
13
|
+
* `parseLimit` in search.ts).
|
|
14
|
+
*/
|
|
15
|
+
export declare function parseFormat(value: string): FetchFormat;
|
|
3
16
|
export declare function makeFetchCommand(getGlobal: () => GlobalOptions & {
|
|
4
17
|
json: boolean;
|
|
5
18
|
}): Command;
|
|
19
|
+
export {};
|
package/dist/commands/fetch.js
CHANGED
|
@@ -1,7 +1,24 @@
|
|
|
1
|
-
import { Command } from 'commander';
|
|
1
|
+
import { Command, InvalidArgumentError } from 'commander';
|
|
2
2
|
import { buildClient } from '../client.js';
|
|
3
|
-
import { printResult, handleError } from '../output.js';
|
|
3
|
+
import { printResult, emitFetchResult, handleError } from '../output.js';
|
|
4
4
|
import { parseUrlLines } from '../lib/url-lines.js';
|
|
5
|
+
const FETCH_FORMATS = ['markdown', 'html'];
|
|
6
|
+
/**
|
|
7
|
+
* Validate `--format` locally, before any HTTP request is issued.
|
|
8
|
+
*
|
|
9
|
+
* The SDK's `FetchOpts.format` is narrowed to the spec union `'markdown' | 'html'`
|
|
10
|
+
* (#2475), so an off-spec value like `pdf` is a compile-time error at the call site.
|
|
11
|
+
* Commander runs an option's argParser during parsing — ahead of the action callback —
|
|
12
|
+
* so rejecting here means the client is never built and no request is issued, and the
|
|
13
|
+
* caller gets an actionable message instead of a server 422 (same convention as
|
|
14
|
+
* `parseLimit` in search.ts).
|
|
15
|
+
*/
|
|
16
|
+
export function parseFormat(value) {
|
|
17
|
+
if (!FETCH_FORMATS.includes(value)) {
|
|
18
|
+
throw new InvalidArgumentError(`--format must be one of: ${FETCH_FORMATS.join(', ')}.`);
|
|
19
|
+
}
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
5
22
|
export function makeFetchCommand(getGlobal) {
|
|
6
23
|
const cmd = new Command('fetch').description('Fetch URL content operations');
|
|
7
24
|
const bulkCmd = new Command('bulk').description('Bulk fetch operations');
|
|
@@ -41,7 +58,7 @@ export function makeFetchCommand(getGlobal) {
|
|
|
41
58
|
.command('create')
|
|
42
59
|
.description('Fetch URL content (POST)')
|
|
43
60
|
.requiredOption('--url <url>', 'URL to fetch')
|
|
44
|
-
.option('--format <format>', 'Response format')
|
|
61
|
+
.option('--format <format>', 'Response format (markdown or html)', parseFormat)
|
|
45
62
|
.action(async (opts) => {
|
|
46
63
|
const g = getGlobal();
|
|
47
64
|
const client = buildClient(g);
|
|
@@ -50,7 +67,7 @@ export function makeFetchCommand(getGlobal) {
|
|
|
50
67
|
url: opts.url,
|
|
51
68
|
...(opts.format !== undefined ? { format: opts.format } : {}),
|
|
52
69
|
});
|
|
53
|
-
|
|
70
|
+
emitFetchResult(result, g.json);
|
|
54
71
|
}
|
|
55
72
|
catch (err) {
|
|
56
73
|
handleError(err);
|
|
@@ -60,7 +77,7 @@ export function makeFetchCommand(getGlobal) {
|
|
|
60
77
|
.command('get')
|
|
61
78
|
.description('Fetch URL content (GET via query params)')
|
|
62
79
|
.requiredOption('--url <url>', 'URL to fetch')
|
|
63
|
-
.option('--format <format>', 'Response format')
|
|
80
|
+
.option('--format <format>', 'Response format (markdown or html)', parseFormat)
|
|
64
81
|
.action(async (opts) => {
|
|
65
82
|
const g = getGlobal();
|
|
66
83
|
const client = buildClient(g);
|
|
@@ -69,7 +86,7 @@ export function makeFetchCommand(getGlobal) {
|
|
|
69
86
|
url: opts.url,
|
|
70
87
|
...(opts.format !== undefined ? { format: opts.format } : {}),
|
|
71
88
|
});
|
|
72
|
-
|
|
89
|
+
emitFetchResult(result, g.json);
|
|
73
90
|
}
|
|
74
91
|
catch (err) {
|
|
75
92
|
handleError(err);
|