@botdocs/cli 0.10.2 → 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/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,8 +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';
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.
24
138
  const require = createRequire(import.meta.url);
25
139
  const pkg = require('../package.json');
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`);
152
+ }
153
+ }
154
+ catch {
155
+ // Defensive: never let a perms check crash CLI startup.
156
+ }
26
157
  const program = new Command();
27
158
  program
28
159
  .name('botdocs')
@@ -31,10 +162,14 @@ program
31
162
  .option('--json', 'Output results as JSON');
32
163
  program
33
164
  .command('init [name]')
34
- .description('Scaffold a new BotDoc directory with index.md template')
35
- .option('--title <title>', 'BotDoc title')
36
- .option('--category <category>', 'Category')
37
- .option('--canonical', 'Scaffold a multi-ecosystem skill (claude-code source + ecosystems list)')
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.')
38
173
  .action(async (name, opts) => {
39
174
  await init(name, { ...opts, json: program.opts().json });
40
175
  });
@@ -59,7 +194,8 @@ program
59
194
  .option('--tags <tags>', 'Comma-separated tags')
60
195
  .option('--license <license>', 'License (MIT, CC_BY_4_0, CC_BY_SA_4_0, CC0, ALL_RIGHTS_RESERVED)')
61
196
  .option('--no-compile', 'Skip auto-compile (publish whatever files are on disk as-is)')
62
- .option('--yes', 'Skip any confirmation prompt (reserved for future use)')
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)')
63
199
  .action(async (source, options) => {
64
200
  await publish(source, { ...options, json: program.opts().json });
65
201
  });
@@ -163,6 +299,8 @@ program
163
299
  .option('--auto', 'Skip the interactive selection and ingest everything discovery finds (zero-arg mode only)')
164
300
  .option('--force', 'Replace existing draft skills with the same name (won\'t touch published skills)')
165
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')
166
304
  .action(async (sourcePath, opts) => {
167
305
  // Commander's --no-ink convention sets opts.ink = false; flip to noInk
168
306
  // so downstream consumers don't have to handle the inverted boolean.
@@ -200,4 +338,12 @@ program
200
338
  const { backup, ...rest } = opts;
201
339
  await undo({ ...rest, noBackup: backup === false, json: program.opts().json });
202
340
  });
203
- program.parse();
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,13 +16,78 @@ 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
+ });
22
+ }
23
+ /** Thrown by apiFetch when the server replies 403 with `{ error: 'waitlisted',
24
+ * ... }` — meaning the caller is signed in but BotDocs is currently behind the
25
+ * waitlist gate and they haven't been admitted yet.
26
+ *
27
+ * Subclass of ApiError so existing `catch (err) { if (err instanceof ApiError)
28
+ * ... }` paths keep working; the index.ts top-level handler does an explicit
29
+ * `instanceof WaitlistError` check first to print a single friendly one-liner
30
+ * and exit 0 (this is not a user-facing error condition — the user is on the
31
+ * waitlist by design).
32
+ *
33
+ * Telemetry-style callers (`syncLibrary`) that already swallow errors will
34
+ * continue to swallow this one too — they catch every ApiError, so the new
35
+ * class lands inside the same catch arm.
36
+ */
37
+ export declare class WaitlistError extends ApiError {
38
+ constructor(message: string, body?: unknown);
15
39
  }
16
40
  interface FetchOptions {
17
41
  method?: string;
18
42
  body?: unknown;
19
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;
20
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. */
21
61
  export declare function apiFetch<T>(path: string, options?: FetchOptions): Promise<T>;
22
- export declare function fetchRawContent(rawUrl: string): Promise<string>;
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;
23
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
- return process.env.BOTDOCS_API_URL || DEFAULT_API_URL;
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,15 +59,67 @@ export function getApiUrl() {
15
59
  export class ApiError extends Error {
16
60
  status;
17
61
  body;
18
- constructor(status, message, body) {
19
- super(message);
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
+ }
81
+ /** Thrown by apiFetch when the server replies 403 with `{ error: 'waitlisted',
82
+ * ... }` — meaning the caller is signed in but BotDocs is currently behind the
83
+ * waitlist gate and they haven't been admitted yet.
84
+ *
85
+ * Subclass of ApiError so existing `catch (err) { if (err instanceof ApiError)
86
+ * ... }` paths keep working; the index.ts top-level handler does an explicit
87
+ * `instanceof WaitlistError` check first to print a single friendly one-liner
88
+ * and exit 0 (this is not a user-facing error condition — the user is on the
89
+ * waitlist by design).
90
+ *
91
+ * Telemetry-style callers (`syncLibrary`) that already swallow errors will
92
+ * continue to swallow this one too — they catch every ApiError, so the new
93
+ * class lands inside the same catch arm.
94
+ */
95
+ export class WaitlistError extends ApiError {
96
+ constructor(message, body) {
97
+ super(403, message, body);
98
+ this.name = 'WaitlistError';
99
+ }
100
+ }
101
+ /** Type guard for the `{ error: 'waitlisted', ... }` JSON shape returned by
102
+ * `requireAdmitted` on the web side. Matches by string discriminant so a
103
+ * server-side rename of the field would break this exactly once, here, rather
104
+ * than silently disabling the friendly path. */
105
+ function isWaitlistedBody(body) {
106
+ return (typeof body === 'object' &&
107
+ body !== null &&
108
+ body.error === 'waitlisted');
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. */
25
121
  export async function apiFetch(path, options = {}) {
26
- const { method = 'GET', body, auth = false } = options;
122
+ const { method = 'GET', body, auth = false, timeoutMs = DEFAULT_TIMEOUT_MS } = options;
27
123
  const baseUrl = getApiUrl();
28
124
  const url = `${baseUrl}${path}`;
29
125
  const headers = {
@@ -42,11 +138,37 @@ export async function apiFetch(path, options = {}) {
42
138
  }
43
139
  headers['Authorization'] = `Bearer ${config.token}`;
44
140
  }
45
- const response = await fetch(url, {
46
- method,
47
- headers,
48
- body: body ? JSON.stringify(body) : undefined,
49
- });
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
+ }
50
172
  if (!response.ok) {
51
173
  const text = await response.text();
52
174
  let message;
@@ -67,6 +189,13 @@ export async function apiFetch(path, options = {}) {
67
189
  if (response.status === 401 && auth) {
68
190
  message = 'Authentication failed. Run `botdocs login` to sign in again.';
69
191
  }
192
+ // Distinct error type for the launch-day waitlist. Lets the top-level
193
+ // CLI handler print "You're on the BotDocs waitlist..." instead of a raw
194
+ // 403 trace, and lets background telemetry (syncLibrary) silently swallow
195
+ // it without surfacing anything to the user.
196
+ if (response.status === 403 && isWaitlistedBody(parsedBody)) {
197
+ throw new WaitlistError(parsedBody.message ?? message, parsedBody);
198
+ }
70
199
  throw new ApiError(response.status, message, parsedBody);
71
200
  }
72
201
  const contentType = response.headers.get('content-type') || '';
@@ -75,12 +204,76 @@ export async function apiFetch(path, options = {}) {
75
204
  }
76
205
  return (await response.text());
77
206
  }
78
- export async function fetchRawContent(rawUrl) {
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;
79
222
  const baseUrl = getApiUrl();
80
223
  const url = rawUrl.startsWith('http') ? rawUrl : `${baseUrl}${rawUrl}`;
81
- const response = await fetch(url);
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
+ }
82
243
  if (!response.ok) {
83
244
  throw new ApiError(response.status, `Failed to fetch ${rawUrl}`);
84
245
  }
85
246
  return await response.text();
86
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
+ }