@botdocs/cli 0.10.3 → 0.11.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/README.md +7 -5
- package/bin/botdocs.cjs +78 -0
- package/dist/commands/check-updates.d.ts +22 -0
- package/dist/commands/check-updates.js +73 -18
- package/dist/commands/edit.js +10 -2
- package/dist/commands/ingest.d.ts +20 -0
- package/dist/commands/ingest.js +264 -11
- package/dist/commands/init.d.ts +24 -0
- package/dist/commands/init.js +43 -6
- package/dist/commands/install.js +146 -5
- package/dist/commands/list.js +62 -0
- package/dist/commands/login.js +56 -2
- package/dist/commands/publish.d.ts +30 -3
- package/dist/commands/publish.js +353 -40
- package/dist/commands/sync.js +252 -40
- package/dist/commands/uninstall.js +12 -0
- package/dist/commands/validate.js +82 -8
- package/dist/index.js +151 -26
- package/dist/lib/api.d.ts +55 -2
- package/dist/lib/api.js +168 -11
- package/dist/lib/auto-detect.js +70 -30
- package/dist/lib/config.d.ts +15 -0
- package/dist/lib/config.js +83 -2
- package/dist/lib/ingest-session-client.d.ts +93 -0
- package/dist/lib/ingest-session-client.js +217 -0
- package/dist/lib/lockfile.d.ts +13 -0
- package/dist/lib/manifest.d.ts +12 -0
- package/dist/lib/manifest.js +29 -2
- package/dist/lib/node-preflight.d.ts +20 -0
- package/dist/lib/node-preflight.js +11 -0
- package/dist/lib/skill-caps.d.ts +17 -0
- package/dist/lib/skill-caps.js +19 -0
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -1,5 +1,115 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// ─── Node version preflight ────────────────────────────────────────────────
|
|
3
|
+
// The authoritative gate lives in `bin/botdocs.cjs` (a CJS shim that runs
|
|
4
|
+
// BEFORE this ESM module is loaded). The shim refuses to hand off to dist/
|
|
5
|
+
// when Node major < 20, so a user on Node 16/18 sees one clean instruction
|
|
6
|
+
// instead of a `SyntaxError` from any modern-syntax dep (commander, Ink,
|
|
7
|
+
// the OpenAI SDK) that ESM's eager module-graph evaluation would otherwise
|
|
8
|
+
// trigger before this file's top-level statements get a chance to run.
|
|
9
|
+
//
|
|
10
|
+
// The inline check here is kept as a belt-and-suspenders no-op for anyone
|
|
11
|
+
// who invokes `node dist/index.js` directly (skipping the shim, e.g. tests
|
|
12
|
+
// or a malformed install). The shared `checkNodeVersion` helper lives in
|
|
13
|
+
// `./lib/node-preflight.js` so it's unit-testable across versions without
|
|
14
|
+
// spawning a subprocess.
|
|
15
|
+
import { checkNodeVersion } from './lib/node-preflight.js';
|
|
16
|
+
{
|
|
17
|
+
const preflight = checkNodeVersion(process.versions.node);
|
|
18
|
+
if (!preflight.ok) {
|
|
19
|
+
process.stderr.write(`${preflight.message}\n`);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// ─── Global error handlers ─────────────────────────────────────────────────
|
|
24
|
+
// Registered before the rest of the program so any async throw in commander
|
|
25
|
+
// dispatch, an unhandled rejection from a fire-and-forget call, or a sync
|
|
26
|
+
// crash in module init lands here instead of dumping a Node stack to stderr.
|
|
27
|
+
//
|
|
28
|
+
// Both handlers print a single friendly line and exit 1 — except for known
|
|
29
|
+
// network-error codes, which get an actionable hint (try again / check DNS),
|
|
30
|
+
// and WaitlistError, which exits 0 because being on the waitlist is the
|
|
31
|
+
// expected state, not a malformed command.
|
|
32
|
+
process.on('unhandledRejection', (reason) => {
|
|
33
|
+
handleFatal(reason);
|
|
34
|
+
});
|
|
35
|
+
process.on('uncaughtException', (err) => {
|
|
36
|
+
handleFatal(err);
|
|
37
|
+
});
|
|
38
|
+
/** Friendly stderr line + exit. Pulled out so both the unhandledRejection and
|
|
39
|
+
* uncaughtException paths produce identical output. Stack traces are gated
|
|
40
|
+
* behind `BOTDOCS_DEBUG=1` so the default UX stays clean; setting that env
|
|
41
|
+
* var is the documented escape hatch when filing a bug.
|
|
42
|
+
*
|
|
43
|
+
* WaitlistError checking is done by name string rather than `instanceof`
|
|
44
|
+
* because the class lives in a sibling module whose import order is
|
|
45
|
+
* arbitrary; comparing the constructor name keeps this handler independent
|
|
46
|
+
* of import-graph timing.
|
|
47
|
+
*/
|
|
48
|
+
function handleFatal(reason) {
|
|
49
|
+
if (reason instanceof Error &&
|
|
50
|
+
reason.name === 'WaitlistError') {
|
|
51
|
+
process.stdout.write("You're on the BotDocs waitlist. We'll email you when there's space.\n" +
|
|
52
|
+
'(Check https://botdocs.ai/waitlist for status.)\n');
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}
|
|
55
|
+
const code = extractErrorCode(reason);
|
|
56
|
+
const message = friendlyNetworkMessage(code) ?? genericFriendlyMessage(reason);
|
|
57
|
+
process.stderr.write(`${message}\n`);
|
|
58
|
+
if (process.env.BOTDOCS_DEBUG === '1' && reason instanceof Error && reason.stack) {
|
|
59
|
+
process.stderr.write(`\n${reason.stack}\n`);
|
|
60
|
+
}
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
/** Walk the error chain looking for a Node `code` (errno-style string like
|
|
64
|
+
* `ENOTFOUND`). fetch wraps DNS/connection errors twice — once in the cause
|
|
65
|
+
* chain, once as the AggregateError on dual-stack lookups — so we peek
|
|
66
|
+
* through both. */
|
|
67
|
+
function extractErrorCode(reason) {
|
|
68
|
+
let cur = reason;
|
|
69
|
+
for (let depth = 0; depth < 5 && cur != null; depth++) {
|
|
70
|
+
if (typeof cur === 'object') {
|
|
71
|
+
const code = cur.code;
|
|
72
|
+
if (typeof code === 'string')
|
|
73
|
+
return code;
|
|
74
|
+
const errors = cur.errors;
|
|
75
|
+
if (Array.isArray(errors) && errors.length > 0) {
|
|
76
|
+
for (const e of errors) {
|
|
77
|
+
const c = e?.code;
|
|
78
|
+
if (typeof c === 'string')
|
|
79
|
+
return c;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
cur = cur.cause;
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
/** Map a Node network errno to a human sentence. Returns undefined for codes
|
|
91
|
+
* we don't recognize so the caller falls through to the generic message. */
|
|
92
|
+
function friendlyNetworkMessage(code) {
|
|
93
|
+
switch (code) {
|
|
94
|
+
case 'ENOTFOUND':
|
|
95
|
+
case 'EAI_AGAIN':
|
|
96
|
+
return "Couldn't resolve botdocs.ai. Check your network or DNS and try again.";
|
|
97
|
+
case 'ECONNREFUSED':
|
|
98
|
+
return "Couldn't connect to botdocs.ai. The service may be down — try again in a minute.";
|
|
99
|
+
case 'ECONNRESET':
|
|
100
|
+
return 'Connection reset. Try again.';
|
|
101
|
+
case 'ETIMEDOUT':
|
|
102
|
+
return 'Request timed out. Try again.';
|
|
103
|
+
default:
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function genericFriendlyMessage(reason) {
|
|
108
|
+
const msg = reason instanceof Error ? reason.message : String(reason);
|
|
109
|
+
return `Unexpected error: ${msg}. Please file an issue at https://github.com/trev91/Botdocs/issues`;
|
|
110
|
+
}
|
|
2
111
|
import { Command } from 'commander';
|
|
112
|
+
import { checkAuthFilePerms } from './lib/config.js';
|
|
3
113
|
import { search } from './commands/search.js';
|
|
4
114
|
import { publish } from './commands/publish.js';
|
|
5
115
|
import { unpublish } from './commands/unpublish.js';
|
|
@@ -21,29 +131,29 @@ import { registerTeamCommands } from './commands/team.js';
|
|
|
21
131
|
import { registerBackupCommands } from './commands/backups.js';
|
|
22
132
|
import { undo } from './commands/undo.js';
|
|
23
133
|
import { createRequire } from 'node:module';
|
|
24
|
-
|
|
134
|
+
// WaitlistError is re-checked inside handleFatal() (via lazy require) so we
|
|
135
|
+
// don't need a static import here — keeping the import surface minimal also
|
|
136
|
+
// means the top-of-file Node-version preflight can't be defeated by an
|
|
137
|
+
// import-time crash in api.js on an unsupported runtime.
|
|
25
138
|
const require = createRequire(import.meta.url);
|
|
26
139
|
const pkg = require('../package.json');
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (
|
|
38
|
-
process.
|
|
39
|
-
'(Check https://botdocs.ai/waitlist for status.)\n');
|
|
40
|
-
process.exit(0);
|
|
140
|
+
// NOTE: The previous `unhandledRejection` handler that only special-cased
|
|
141
|
+
// WaitlistError has been replaced by the top-of-file `handleFatal` plumbing,
|
|
142
|
+
// which covers waitlist, friendly network-error codes, and the generic
|
|
143
|
+
// "please file an issue" fallback in one place.
|
|
144
|
+
// P1-G: One-time migration check. If auth.json was written by a pre-hardening
|
|
145
|
+
// CLI (default umask 022 → mode 0644 / world-readable), tighten it in place
|
|
146
|
+
// and warn the user once. Non-fatal: a chmod failure or anything weird here
|
|
147
|
+
// shouldn't block the CLI from running.
|
|
148
|
+
try {
|
|
149
|
+
const permsCheck = checkAuthFilePerms();
|
|
150
|
+
if (permsCheck.warning) {
|
|
151
|
+
process.stderr.write(`${permsCheck.warning}\n`);
|
|
41
152
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
});
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// Defensive: never let a perms check crash CLI startup.
|
|
156
|
+
}
|
|
47
157
|
const program = new Command();
|
|
48
158
|
program
|
|
49
159
|
.name('botdocs')
|
|
@@ -52,10 +162,14 @@ program
|
|
|
52
162
|
.option('--json', 'Output results as JSON');
|
|
53
163
|
program
|
|
54
164
|
.command('init [name]')
|
|
55
|
-
.description('Scaffold a new
|
|
56
|
-
.option('--title <title>', '
|
|
57
|
-
.option('--category <category>', 'Category')
|
|
58
|
-
.
|
|
165
|
+
.description('Scaffold a new skill directory (canonical multi-ecosystem layout by default; pass --spec for the legacy SPEC scaffold)')
|
|
166
|
+
.option('--title <title>', 'Skill title')
|
|
167
|
+
.option('--category <category>', 'Category (SPEC scaffold only)')
|
|
168
|
+
// Canonical is the default now. The flag is kept as a no-op alias so older
|
|
169
|
+
// docs/scripts that still pass it continue to work; the action handler
|
|
170
|
+
// ignores it. Hidden from help to discourage new callers.
|
|
171
|
+
.option('--canonical', 'Deprecated no-op — canonical is the default. Pass --spec for the legacy layout.')
|
|
172
|
+
.option('--spec', 'Scaffold the legacy SPEC layout (single index.md + minimal manifest, no skill type). Most authors want the default.')
|
|
59
173
|
.action(async (name, opts) => {
|
|
60
174
|
await init(name, { ...opts, json: program.opts().json });
|
|
61
175
|
});
|
|
@@ -80,7 +194,8 @@ program
|
|
|
80
194
|
.option('--tags <tags>', 'Comma-separated tags')
|
|
81
195
|
.option('--license <license>', 'License (MIT, CC_BY_4_0, CC_BY_SA_4_0, CC0, ALL_RIGHTS_RESERVED)')
|
|
82
196
|
.option('--no-compile', 'Skip auto-compile (publish whatever files are on disk as-is)')
|
|
83
|
-
.option('--
|
|
197
|
+
.option('--dry-run', 'Preview the publish payload (title, description, files, total size) without uploading')
|
|
198
|
+
.option('--yes', 'Skip the y/N confirmation prompt (for scripts)')
|
|
84
199
|
.action(async (source, options) => {
|
|
85
200
|
await publish(source, { ...options, json: program.opts().json });
|
|
86
201
|
});
|
|
@@ -184,6 +299,8 @@ program
|
|
|
184
299
|
.option('--auto', 'Skip the interactive selection and ingest everything discovery finds (zero-arg mode only)')
|
|
185
300
|
.option('--force', 'Replace existing draft skills with the same name (won\'t touch published skills)')
|
|
186
301
|
.option('--no-ink', 'Disable the interactive TUI; use plain output (zero-arg mode only)')
|
|
302
|
+
.option('--pair', "Pair with the web onboarding step so it can mirror this ingest live; prompts for a BD-XXXXX code")
|
|
303
|
+
.option('--pair-code <code>', 'Pre-supply the pairing code (e.g. BD-A4F8K) — skips the interactive prompt; implies --pair')
|
|
187
304
|
.action(async (sourcePath, opts) => {
|
|
188
305
|
// Commander's --no-ink convention sets opts.ink = false; flip to noInk
|
|
189
306
|
// so downstream consumers don't have to handle the inverted boolean.
|
|
@@ -221,4 +338,12 @@ program
|
|
|
221
338
|
const { backup, ...rest } = opts;
|
|
222
339
|
await undo({ ...rest, noBackup: backup === false, json: program.opts().json });
|
|
223
340
|
});
|
|
224
|
-
|
|
341
|
+
// Use parseAsync so any rejected promise from an action surfaces through the
|
|
342
|
+
// try/catch into handleFatal. Without this, commander dispatches actions as
|
|
343
|
+
// fire-and-forget and an action's rejection would only land in
|
|
344
|
+
// `unhandledRejection` — which is fine, but the explicit catch here also
|
|
345
|
+
// covers commander's own synchronous errors (unknown command, missing
|
|
346
|
+
// argument, etc.) with the same friendly output path.
|
|
347
|
+
program.parseAsync().catch((err) => {
|
|
348
|
+
handleFatal(err);
|
|
349
|
+
});
|
package/dist/lib/api.d.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/** Default per-request timeout (ms). Exposed so tests can override without
|
|
2
|
+
* mocking `setTimeout` globally, and so callers can read the value when
|
|
3
|
+
* composing their own friendly messages. 30 s matches the longest-tolerable
|
|
4
|
+
* wait before a human assumes the CLI has hung. */
|
|
5
|
+
export declare const DEFAULT_TIMEOUT_MS = 30000;
|
|
1
6
|
export declare function getApiUrl(): string;
|
|
2
7
|
/** Thrown by apiFetch when the server returns a non-2xx response. Carries the
|
|
3
8
|
* status code and the server-provided message so callers can branch on
|
|
@@ -11,7 +16,9 @@ export declare function getApiUrl(): string;
|
|
|
11
16
|
export declare class ApiError extends Error {
|
|
12
17
|
readonly status: number;
|
|
13
18
|
readonly body?: unknown;
|
|
14
|
-
constructor(status: number, message: string, body?: unknown
|
|
19
|
+
constructor(status: number, message: string, body?: unknown, options?: {
|
|
20
|
+
cause?: unknown;
|
|
21
|
+
});
|
|
15
22
|
}
|
|
16
23
|
/** Thrown by apiFetch when the server replies 403 with `{ error: 'waitlisted',
|
|
17
24
|
* ... }` — meaning the caller is signed in but BotDocs is currently behind the
|
|
@@ -34,7 +41,53 @@ interface FetchOptions {
|
|
|
34
41
|
method?: string;
|
|
35
42
|
body?: unknown;
|
|
36
43
|
auth?: boolean;
|
|
44
|
+
/** Per-call override of the default request timeout (ms). When the timer
|
|
45
|
+
* fires we abort the underlying fetch and throw an ApiError(0, "timed out…").
|
|
46
|
+
* Defaults to {@link DEFAULT_TIMEOUT_MS}. Set higher for known-slow paths
|
|
47
|
+
* (e.g. large uploads) or lower in tests to keep the suite snappy. */
|
|
48
|
+
timeoutMs?: number;
|
|
37
49
|
}
|
|
50
|
+
/** Make an HTTP call to the BotDocs API with a hard timeout and friendly
|
|
51
|
+
* error normalization.
|
|
52
|
+
*
|
|
53
|
+
* Network failures (DNS, ECONNREFUSED, socket reset, abort) are converted to
|
|
54
|
+
* `ApiError(0, ...)` so command-level handlers that already branch on
|
|
55
|
+
* `err instanceof ApiError` print a clean one-liner instead of leaking a Node
|
|
56
|
+
* stack trace. The `status === 0` sentinel lets callers tell network failure
|
|
57
|
+
* apart from a real HTTP error.
|
|
58
|
+
*
|
|
59
|
+
* Non-2xx HTTP responses still throw `ApiError(status, message, body)` as
|
|
60
|
+
* before. */
|
|
38
61
|
export declare function apiFetch<T>(path: string, options?: FetchOptions): Promise<T>;
|
|
39
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Fetch a raw file body by URL (absolute or API-relative).
|
|
64
|
+
*
|
|
65
|
+
* Used by install/edit/sync to pull individual file contents that the
|
|
66
|
+
* registry exposes outside the JSON API (e.g. the raw markdown for a
|
|
67
|
+
* single SKILL.md). Previously this used a bare `fetch(url)` with no
|
|
68
|
+
* timeout, so a flaky network could hang any of those commands forever.
|
|
69
|
+
*
|
|
70
|
+
* Mirrors {@link apiFetch}'s AbortController + DEFAULT_TIMEOUT_MS pattern:
|
|
71
|
+
* network failures and timeouts both surface as `ApiError(0, …)` so the
|
|
72
|
+
* callers don't need to special-case fetchRawContent vs apiFetch in
|
|
73
|
+
* their `instanceof ApiError` catch arms.
|
|
74
|
+
*/
|
|
75
|
+
export declare function fetchRawContent(rawUrl: string, options?: {
|
|
76
|
+
timeoutMs?: number;
|
|
77
|
+
}): Promise<string>;
|
|
78
|
+
/** Translate an ApiError status into a short, human-readable one-liner for
|
|
79
|
+
* the "everything else" 4xx/5xx case — the cases each command doesn't already
|
|
80
|
+
* handle structurally (e.g. 401 fatal, 409 ALREADY_EXISTS, 410 with hint, 413
|
|
81
|
+
* size cap). Promoting this here (rather than duplicating it across
|
|
82
|
+
* sync/install/publish/edit) keeps the error vocabulary consistent across
|
|
83
|
+
* commands and means a single edit changes how every command surfaces
|
|
84
|
+
* "permission denied" / "rate-limited" / "server error".
|
|
85
|
+
*
|
|
86
|
+
* `ref` is optional context — when present and the status is 404, we
|
|
87
|
+
* recommend `botdocs uninstall <ref>` so the user has an immediate next
|
|
88
|
+
* action (mirrors the 410 hint in sync). 403 wording assumes the most
|
|
89
|
+
* common cause is lost team membership rather than a stale token (real
|
|
90
|
+
* stale-token cases are 401, which is handled upstream as "Authentication
|
|
91
|
+
* failed"). */
|
|
92
|
+
export declare function friendlyApiErrorDetail(err: ApiError, ref?: string): string;
|
|
40
93
|
export {};
|
package/dist/lib/api.js
CHANGED
|
@@ -1,7 +1,51 @@
|
|
|
1
1
|
import { loadAuth } from './config.js';
|
|
2
2
|
const DEFAULT_API_URL = 'https://www.botdocs.ai';
|
|
3
|
+
/** Default per-request timeout (ms). Exposed so tests can override without
|
|
4
|
+
* mocking `setTimeout` globally, and so callers can read the value when
|
|
5
|
+
* composing their own friendly messages. 30 s matches the longest-tolerable
|
|
6
|
+
* wait before a human assumes the CLI has hung. */
|
|
7
|
+
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
8
|
+
/** P3 #17: localhost overrides for `BOTDOCS_API_URL` are allowed without TLS
|
|
9
|
+
* because dev servers default to `http://localhost:3000`. Anything else MUST
|
|
10
|
+
* be https — we refuse to send bearer tokens over plaintext to a non-loopback
|
|
11
|
+
* host, since a passive observer would harvest the bearer + every uploaded
|
|
12
|
+
* skill file. */
|
|
13
|
+
function isLocalhostUrl(u) {
|
|
14
|
+
try {
|
|
15
|
+
const parsed = new URL(u);
|
|
16
|
+
if (parsed.protocol === 'https:')
|
|
17
|
+
return false;
|
|
18
|
+
return (parsed.hostname === 'localhost' ||
|
|
19
|
+
parsed.hostname === '127.0.0.1' ||
|
|
20
|
+
parsed.hostname === '0.0.0.0' ||
|
|
21
|
+
parsed.hostname === '[::1]');
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Track whether we've already printed the override-warning so the CLI doesn't
|
|
28
|
+
* spam stderr across every API call in a single process run. */
|
|
29
|
+
let overrideWarnPrinted = false;
|
|
3
30
|
export function getApiUrl() {
|
|
4
|
-
|
|
31
|
+
const override = process.env.BOTDOCS_API_URL;
|
|
32
|
+
if (!override)
|
|
33
|
+
return DEFAULT_API_URL;
|
|
34
|
+
// P3 #17: reject plaintext overrides to non-loopback hosts. A misconfigured
|
|
35
|
+
// env var pointing at `http://evil.example.com` would otherwise leak the
|
|
36
|
+
// bearer token over the wire on every API call.
|
|
37
|
+
if (!override.startsWith('https://') && !isLocalhostUrl(override)) {
|
|
38
|
+
throw new Error(`BOTDOCS_API_URL must be https:// (or http://localhost for development). ` +
|
|
39
|
+
`Got: ${override}. Refusing to send credentials over plaintext.`);
|
|
40
|
+
}
|
|
41
|
+
if (!overrideWarnPrinted) {
|
|
42
|
+
overrideWarnPrinted = true;
|
|
43
|
+
// Single line to stderr so it doesn't trash the CLI's Ink-rendered stdout.
|
|
44
|
+
if (process.stderr.isTTY) {
|
|
45
|
+
process.stderr.write(`\n [botdocs] using BOTDOCS_API_URL=${override} (env override)\n`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return override;
|
|
5
49
|
}
|
|
6
50
|
/** Thrown by apiFetch when the server returns a non-2xx response. Carries the
|
|
7
51
|
* status code and the server-provided message so callers can branch on
|
|
@@ -15,13 +59,25 @@ export function getApiUrl() {
|
|
|
15
59
|
export class ApiError extends Error {
|
|
16
60
|
status;
|
|
17
61
|
body;
|
|
18
|
-
constructor(status, message, body) {
|
|
19
|
-
super
|
|
62
|
+
constructor(status, message, body, options) {
|
|
63
|
+
// Pass `cause` through to the Error super so devs running with
|
|
64
|
+
// `BOTDOCS_DEBUG=1` see the underlying network/abort error in the chain
|
|
65
|
+
// instead of just the friendly wrapper message.
|
|
66
|
+
super(message, options);
|
|
20
67
|
this.name = 'ApiError';
|
|
21
68
|
this.status = status;
|
|
22
69
|
this.body = body;
|
|
23
70
|
}
|
|
24
71
|
}
|
|
72
|
+
/** True when `err` looks like a fetch AbortError. Node's fetch surfaces it as
|
|
73
|
+
* a DOMException with `name === 'AbortError'`; we also accept the legacy
|
|
74
|
+
* `AbortError` Error subclass shape to be safe across Node versions. */
|
|
75
|
+
function isAbortError(err) {
|
|
76
|
+
if (err == null || typeof err !== 'object')
|
|
77
|
+
return false;
|
|
78
|
+
const name = err.name;
|
|
79
|
+
return name === 'AbortError';
|
|
80
|
+
}
|
|
25
81
|
/** Thrown by apiFetch when the server replies 403 with `{ error: 'waitlisted',
|
|
26
82
|
* ... }` — meaning the caller is signed in but BotDocs is currently behind the
|
|
27
83
|
* waitlist gate and they haven't been admitted yet.
|
|
@@ -51,8 +107,19 @@ function isWaitlistedBody(body) {
|
|
|
51
107
|
body !== null &&
|
|
52
108
|
body.error === 'waitlisted');
|
|
53
109
|
}
|
|
110
|
+
/** Make an HTTP call to the BotDocs API with a hard timeout and friendly
|
|
111
|
+
* error normalization.
|
|
112
|
+
*
|
|
113
|
+
* Network failures (DNS, ECONNREFUSED, socket reset, abort) are converted to
|
|
114
|
+
* `ApiError(0, ...)` so command-level handlers that already branch on
|
|
115
|
+
* `err instanceof ApiError` print a clean one-liner instead of leaking a Node
|
|
116
|
+
* stack trace. The `status === 0` sentinel lets callers tell network failure
|
|
117
|
+
* apart from a real HTTP error.
|
|
118
|
+
*
|
|
119
|
+
* Non-2xx HTTP responses still throw `ApiError(status, message, body)` as
|
|
120
|
+
* before. */
|
|
54
121
|
export async function apiFetch(path, options = {}) {
|
|
55
|
-
const { method = 'GET', body, auth = false } = options;
|
|
122
|
+
const { method = 'GET', body, auth = false, timeoutMs = DEFAULT_TIMEOUT_MS } = options;
|
|
56
123
|
const baseUrl = getApiUrl();
|
|
57
124
|
const url = `${baseUrl}${path}`;
|
|
58
125
|
const headers = {
|
|
@@ -71,11 +138,37 @@ export async function apiFetch(path, options = {}) {
|
|
|
71
138
|
}
|
|
72
139
|
headers['Authorization'] = `Bearer ${config.token}`;
|
|
73
140
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
141
|
+
// AbortController + setTimeout is the lowest-overhead way to enforce a hard
|
|
142
|
+
// per-request deadline on Node's fetch — there's no built-in `timeout`
|
|
143
|
+
// option. We clear the timer in `finally` to avoid keeping the event loop
|
|
144
|
+
// alive after the request settles, which would delay process exit by up to
|
|
145
|
+
// `timeoutMs`.
|
|
146
|
+
const controller = new AbortController();
|
|
147
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
148
|
+
let response;
|
|
149
|
+
try {
|
|
150
|
+
response = await fetch(url, {
|
|
151
|
+
method,
|
|
152
|
+
headers,
|
|
153
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
154
|
+
signal: controller.signal,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
// Translate low-level network failures into the existing ApiError shape so
|
|
159
|
+
// command-level catch arms (`if (err instanceof ApiError)`) handle them
|
|
160
|
+
// without each command needing its own try/catch around `fetch`. Status 0
|
|
161
|
+
// is the sentinel for "never got a response".
|
|
162
|
+
if (isAbortError(err)) {
|
|
163
|
+
const seconds = Math.round(timeoutMs / 1000);
|
|
164
|
+
throw new ApiError(0, `Request to ${baseUrl} timed out after ${seconds}s — check your network and try again.`, undefined, { cause: err });
|
|
165
|
+
}
|
|
166
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
167
|
+
throw new ApiError(0, `Network error contacting ${baseUrl}: ${message}`, undefined, { cause: err });
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
clearTimeout(timer);
|
|
171
|
+
}
|
|
79
172
|
if (!response.ok) {
|
|
80
173
|
const text = await response.text();
|
|
81
174
|
let message;
|
|
@@ -111,12 +204,76 @@ export async function apiFetch(path, options = {}) {
|
|
|
111
204
|
}
|
|
112
205
|
return (await response.text());
|
|
113
206
|
}
|
|
114
|
-
|
|
207
|
+
/**
|
|
208
|
+
* Fetch a raw file body by URL (absolute or API-relative).
|
|
209
|
+
*
|
|
210
|
+
* Used by install/edit/sync to pull individual file contents that the
|
|
211
|
+
* registry exposes outside the JSON API (e.g. the raw markdown for a
|
|
212
|
+
* single SKILL.md). Previously this used a bare `fetch(url)` with no
|
|
213
|
+
* timeout, so a flaky network could hang any of those commands forever.
|
|
214
|
+
*
|
|
215
|
+
* Mirrors {@link apiFetch}'s AbortController + DEFAULT_TIMEOUT_MS pattern:
|
|
216
|
+
* network failures and timeouts both surface as `ApiError(0, …)` so the
|
|
217
|
+
* callers don't need to special-case fetchRawContent vs apiFetch in
|
|
218
|
+
* their `instanceof ApiError` catch arms.
|
|
219
|
+
*/
|
|
220
|
+
export async function fetchRawContent(rawUrl, options = {}) {
|
|
221
|
+
const { timeoutMs = DEFAULT_TIMEOUT_MS } = options;
|
|
115
222
|
const baseUrl = getApiUrl();
|
|
116
223
|
const url = rawUrl.startsWith('http') ? rawUrl : `${baseUrl}${rawUrl}`;
|
|
117
|
-
|
|
224
|
+
// Same pattern as apiFetch — AbortController + clearTimeout in finally so
|
|
225
|
+
// the timer never outlives the request and blocks process exit.
|
|
226
|
+
const controller = new AbortController();
|
|
227
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
228
|
+
let response;
|
|
229
|
+
try {
|
|
230
|
+
response = await fetch(url, { signal: controller.signal });
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
if (isAbortError(err)) {
|
|
234
|
+
const seconds = Math.round(timeoutMs / 1000);
|
|
235
|
+
throw new ApiError(0, `Request to ${baseUrl} timed out after ${seconds}s — check your network and try again.`, undefined, { cause: err });
|
|
236
|
+
}
|
|
237
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
238
|
+
throw new ApiError(0, `Network error contacting ${baseUrl}: ${message}`, undefined, { cause: err });
|
|
239
|
+
}
|
|
240
|
+
finally {
|
|
241
|
+
clearTimeout(timer);
|
|
242
|
+
}
|
|
118
243
|
if (!response.ok) {
|
|
119
244
|
throw new ApiError(response.status, `Failed to fetch ${rawUrl}`);
|
|
120
245
|
}
|
|
121
246
|
return await response.text();
|
|
122
247
|
}
|
|
248
|
+
/** Translate an ApiError status into a short, human-readable one-liner for
|
|
249
|
+
* the "everything else" 4xx/5xx case — the cases each command doesn't already
|
|
250
|
+
* handle structurally (e.g. 401 fatal, 409 ALREADY_EXISTS, 410 with hint, 413
|
|
251
|
+
* size cap). Promoting this here (rather than duplicating it across
|
|
252
|
+
* sync/install/publish/edit) keeps the error vocabulary consistent across
|
|
253
|
+
* commands and means a single edit changes how every command surfaces
|
|
254
|
+
* "permission denied" / "rate-limited" / "server error".
|
|
255
|
+
*
|
|
256
|
+
* `ref` is optional context — when present and the status is 404, we
|
|
257
|
+
* recommend `botdocs uninstall <ref>` so the user has an immediate next
|
|
258
|
+
* action (mirrors the 410 hint in sync). 403 wording assumes the most
|
|
259
|
+
* common cause is lost team membership rather than a stale token (real
|
|
260
|
+
* stale-token cases are 401, which is handled upstream as "Authentication
|
|
261
|
+
* failed"). */
|
|
262
|
+
export function friendlyApiErrorDetail(err, ref) {
|
|
263
|
+
if (err.status === 403) {
|
|
264
|
+
return 'permission denied — you may have lost access to this skill (e.g. removed from a team). Contact the owner.';
|
|
265
|
+
}
|
|
266
|
+
if (err.status === 404) {
|
|
267
|
+
if (ref)
|
|
268
|
+
return `no longer found — run \`botdocs uninstall ${ref}\` to clean up.`;
|
|
269
|
+
return 'no longer found';
|
|
270
|
+
}
|
|
271
|
+
if (err.status === 429)
|
|
272
|
+
return 'rate-limited — try again in a moment';
|
|
273
|
+
if (err.status >= 500 && err.status < 600) {
|
|
274
|
+
return err.message ? `server error: ${err.message}` : 'server error';
|
|
275
|
+
}
|
|
276
|
+
if (err.message)
|
|
277
|
+
return err.message;
|
|
278
|
+
return `request failed (${err.status})`;
|
|
279
|
+
}
|