@botdocs/cli 0.13.0 → 0.14.1
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/commands/ingest.js +32 -1
- package/dist/commands/login.js +90 -11
- package/dist/commands/publish.d.ts +12 -12
- package/dist/commands/publish.js +27 -23
- package/dist/commands/unpublish.d.ts +7 -7
- package/dist/commands/unpublish.js +11 -11
- package/dist/index.js +3 -3
- package/dist/lib/api.d.ts +19 -0
- package/dist/lib/api.js +92 -1
- package/dist/lib/config.d.ts +34 -0
- package/dist/lib/config.js +40 -0
- package/dist/lib/library-sync.d.ts +8 -2
- package/dist/lib/library-sync.js +66 -7
- package/package.json +1 -1
package/dist/commands/ingest.js
CHANGED
|
@@ -4,7 +4,7 @@ import os from 'node:os';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { render } from 'ink';
|
|
6
6
|
import open from 'open';
|
|
7
|
-
import { ApiError, apiFetch, getApiUrl } from '../lib/api.js';
|
|
7
|
+
import { ApiError, apiFetch, getApiUrl, friendlyFreeTierError } from '../lib/api.js';
|
|
8
8
|
import { loadAuth } from '../lib/config.js';
|
|
9
9
|
import { IngestSessionClient, PairingClaimError } from '../lib/ingest-session-client.js';
|
|
10
10
|
import { discoverSkills, ecosystemLabel, formatBytes, STUB_BYTE_THRESHOLD, summaryKey, titleFromContent, } from '../lib/ingest-discover.js';
|
|
@@ -643,6 +643,29 @@ function reportSizeCap(err, options) {
|
|
|
643
643
|
console.error(`\n ✗ ${detailMessage ?? err.message}\n`);
|
|
644
644
|
process.exit(1);
|
|
645
645
|
}
|
|
646
|
+
/** Print an actionable 403 skill-cap message and exit non-zero. Honors
|
|
647
|
+
* `--json` by emitting `{ ok: false, status: 403, error, limit, current }` so
|
|
648
|
+
* scripts can branch on the cap. The human line comes from the shared
|
|
649
|
+
* `friendlyFreeTierError` mapper so the copy stays identical to publish/install
|
|
650
|
+
* (honest free-limit wording, no fake checkout). */
|
|
651
|
+
function reportSkillCap(err, options) {
|
|
652
|
+
const body = err.body;
|
|
653
|
+
if (options.json) {
|
|
654
|
+
console.log(JSON.stringify({
|
|
655
|
+
ok: false,
|
|
656
|
+
status: 403,
|
|
657
|
+
error: 'skill_cap_exceeded',
|
|
658
|
+
limit: body?.limit ?? null,
|
|
659
|
+
current: body?.current ?? null,
|
|
660
|
+
}));
|
|
661
|
+
process.exit(1);
|
|
662
|
+
}
|
|
663
|
+
// friendlyFreeTierError returns the honest cap CTA for this code; fall back
|
|
664
|
+
// to the raw server message only in the (impossible) null case so we never
|
|
665
|
+
// print an empty line.
|
|
666
|
+
console.error(`\n ✗ ${friendlyFreeTierError(err) ?? err.message}\n`);
|
|
667
|
+
process.exit(1);
|
|
668
|
+
}
|
|
646
669
|
async function uploadSkills(skills, options, pair) {
|
|
647
670
|
// Optional: announce intent before the API call so the paired web UI
|
|
648
671
|
// shows "uploading…" rows immediately. The events POST is batched
|
|
@@ -686,6 +709,14 @@ async function uploadSkills(skills, options, pair) {
|
|
|
686
709
|
if (conflicts)
|
|
687
710
|
reportConflicts(conflicts, options);
|
|
688
711
|
}
|
|
712
|
+
// 403 skill_cap_exceeded — the whole batch was rejected because it would
|
|
713
|
+
// push the account over the free per-account skill limit. Surface the
|
|
714
|
+
// honest cap CTA (no fake checkout) and exit non-zero.
|
|
715
|
+
if (err instanceof ApiError &&
|
|
716
|
+
err.status === 403 &&
|
|
717
|
+
err.body?.error === 'skill_cap_exceeded') {
|
|
718
|
+
reportSkillCap(err, options);
|
|
719
|
+
}
|
|
689
720
|
// 413 PAYLOAD_TOO_LARGE — server's `validateSkillCaps` rejected one of
|
|
690
721
|
// the skills in the payload. The body's `detail.message` is the human
|
|
691
722
|
// line ("file foo/bar.md is 71 KB, cap is 64 KB per file"); fall back
|
package/dist/commands/login.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
+
import os from 'node:os';
|
|
2
3
|
import { randomBytes } from 'node:crypto';
|
|
3
4
|
import open from 'open';
|
|
4
5
|
import { render } from 'ink';
|
|
5
6
|
import * as p from '@clack/prompts';
|
|
6
|
-
import { saveAuth } from '../lib/config.js';
|
|
7
|
-
import { getApiUrl } from '../lib/api.js';
|
|
7
|
+
import { saveAuth, getOrCreateDeviceId } from '../lib/config.js';
|
|
8
|
+
import { ApiError, getApiUrl, friendlyFreeTierError } from '../lib/api.js';
|
|
8
9
|
import { LoginApp } from './views/login-app.js';
|
|
9
10
|
/** Total wall-clock budget for the polling loop. After this we tell the user
|
|
10
11
|
* the request expired and exit 1. Mirrors the server-side state TTL. */
|
|
@@ -36,11 +37,22 @@ async function loginWithToken(token, syncLibrary) {
|
|
|
36
37
|
process.exit(1);
|
|
37
38
|
}
|
|
38
39
|
const baseUrl = getApiUrl();
|
|
40
|
+
// Stamp the device identity on the validating call so the token's device
|
|
41
|
+
// registers (and the free-tier device/session caps bind) the moment it's
|
|
42
|
+
// saved — not just on the next command. Best-effort: a failure to resolve
|
|
43
|
+
// the id never blocks login.
|
|
44
|
+
const { deviceId, machineName } = deviceIdentity();
|
|
45
|
+
const headers = {
|
|
46
|
+
Authorization: `Bearer ${token}`,
|
|
47
|
+
Accept: 'application/json',
|
|
48
|
+
};
|
|
49
|
+
if (deviceId)
|
|
50
|
+
headers['X-Device-Id'] = deviceId;
|
|
51
|
+
if (machineName)
|
|
52
|
+
headers['X-Machine-Name'] = machineName;
|
|
39
53
|
let res;
|
|
40
54
|
try {
|
|
41
|
-
res = await fetch(`${baseUrl}/api/cli/whoami`, {
|
|
42
|
-
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
43
|
-
});
|
|
55
|
+
res = await fetch(`${baseUrl}/api/cli/whoami`, { headers });
|
|
44
56
|
}
|
|
45
57
|
catch (err) {
|
|
46
58
|
console.error(`Failed to contact ${baseUrl}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -51,21 +63,70 @@ async function loginWithToken(token, syncLibrary) {
|
|
|
51
63
|
process.exit(1);
|
|
52
64
|
}
|
|
53
65
|
if (!res.ok) {
|
|
54
|
-
|
|
66
|
+
// Surface the standard free-tier frictions with an HONEST CTA when the
|
|
67
|
+
// server gates this device on the way in:
|
|
68
|
+
// - 403 device_cap_exceeded → "you've reached N devices…"
|
|
69
|
+
// - 429 session_limit_exceeded → "N active sessions already…"
|
|
70
|
+
// The shared mapper reads the JSON body's `error`/`limit` fields and
|
|
71
|
+
// returns null for anything it doesn't recognize, in which case we fall
|
|
72
|
+
// back to the generic "validation failed" line. Parsing is best-effort:
|
|
73
|
+
// a non-JSON body just falls through to the generic message.
|
|
74
|
+
const body = (await res.json().catch(() => undefined));
|
|
75
|
+
const friendly = friendlyFreeTierError(new ApiError(res.status, '', body));
|
|
76
|
+
if (friendly) {
|
|
77
|
+
console.error(friendly);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
console.error(`Token validation failed (${res.status}). Try again later.`);
|
|
81
|
+
}
|
|
55
82
|
process.exit(1);
|
|
56
83
|
}
|
|
57
84
|
const user = (await res.json());
|
|
85
|
+
// Persist the SAME device id we sent on the whoami header so the device that
|
|
86
|
+
// just registered server-side matches every subsequent request.
|
|
58
87
|
saveAuth({
|
|
59
88
|
token,
|
|
60
89
|
username: user.username,
|
|
61
90
|
displayName: user.displayName,
|
|
62
91
|
syncLibrary,
|
|
92
|
+
...persistedDeviceField(deviceId),
|
|
63
93
|
});
|
|
64
94
|
printSignedIn(user.username, syncLibrary);
|
|
65
95
|
}
|
|
96
|
+
/** Best-effort stable device identity for this CLI install. Mirrors
|
|
97
|
+
* `attachDeviceHeaders` in lib/api.ts, but the login flows can't reuse it: they
|
|
98
|
+
* use raw `fetch` (there's no bearer to attach yet) and they ALSO need the id
|
|
99
|
+
* for the browser auth URL. Returns nulls if it can't be resolved (e.g. a
|
|
100
|
+
* read-only home dir) so login never hard-fails on device identity.
|
|
101
|
+
*
|
|
102
|
+
* Resolve this ONCE per login and thread the same id into both the request
|
|
103
|
+
* (header/URL) and `persistedDeviceField` — the server registers the device
|
|
104
|
+
* under the forwarded id, so the persisted id must match or the next command
|
|
105
|
+
* would present as a different device. */
|
|
106
|
+
function deviceIdentity() {
|
|
107
|
+
try {
|
|
108
|
+
return { deviceId: getOrCreateDeviceId(), machineName: os.hostname() };
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return { deviceId: null, machineName: null };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** The `auth.json` fragment carrying the device id to persist. `getOrCreateDeviceId`
|
|
115
|
+
* only persists when an auth file already exists, but at login time we're
|
|
116
|
+
* writing that file for the first time — so we fold the id straight into the
|
|
117
|
+
* `saveAuth` payload. A `BOTDOCS_DEVICE_ID` override always wins at read time
|
|
118
|
+
* and is intentionally NOT persisted (so auth.json stays portable across CI
|
|
119
|
+
* runners), so we omit it in that case. */
|
|
120
|
+
function persistedDeviceField(deviceId) {
|
|
121
|
+
if (process.env.BOTDOCS_DEVICE_ID?.trim())
|
|
122
|
+
return {};
|
|
123
|
+
return deviceId ? { deviceId } : {};
|
|
124
|
+
}
|
|
66
125
|
/** Helper: register a new state with the server and return the public auth
|
|
67
|
-
* URL plus the last-6-chars suffix the user can verify in their browser.
|
|
68
|
-
|
|
126
|
+
* URL plus the last-6-chars suffix the user can verify in their browser. The
|
|
127
|
+
* caller passes the resolved device identity so the SAME id is forwarded in
|
|
128
|
+
* the URL and persisted to auth.json. */
|
|
129
|
+
async function initLoginState(baseUrl, deviceId, machineName) {
|
|
69
130
|
const state = randomBytes(32).toString('hex');
|
|
70
131
|
const initRes = await fetch(`${baseUrl}/api/cli/auth/init`, {
|
|
71
132
|
method: 'POST',
|
|
@@ -99,9 +160,19 @@ async function initLoginState(baseUrl) {
|
|
|
99
160
|
// state carried in the URL hash fragment (#state=...), which browsers
|
|
100
161
|
// never send to the server and never write to history. That refactor is
|
|
101
162
|
// out of scope for the P3 bucket — tracked as a separate item.
|
|
163
|
+
//
|
|
164
|
+
// Forward this install's device identity in the URL. The browser that lands
|
|
165
|
+
// /cli-auth has no X-Device-Id header of its own, so the page relays these
|
|
166
|
+
// into the grant POST body — that's what registers the device + binds the
|
|
167
|
+
// free-tier caps at login time (rather than only on the next CLI command).
|
|
168
|
+
const params = new URLSearchParams({ state });
|
|
169
|
+
if (deviceId)
|
|
170
|
+
params.set('device', deviceId);
|
|
171
|
+
if (machineName)
|
|
172
|
+
params.set('machine', machineName);
|
|
102
173
|
return {
|
|
103
174
|
state,
|
|
104
|
-
authUrl: `${baseUrl}/cli-auth
|
|
175
|
+
authUrl: `${baseUrl}/cli-auth?${params.toString()}`,
|
|
105
176
|
tail: state.slice(-6),
|
|
106
177
|
};
|
|
107
178
|
}
|
|
@@ -112,6 +183,9 @@ async function initLoginState(baseUrl) {
|
|
|
112
183
|
* the component's effect call `useApp().exit()` to unmount. */
|
|
113
184
|
async function loginInkInteractive(syncLibrary) {
|
|
114
185
|
const baseUrl = getApiUrl();
|
|
186
|
+
// Resolve the device identity ONCE — forwarded to the browser via the auth
|
|
187
|
+
// URL and persisted (below) under the same id.
|
|
188
|
+
const { deviceId, machineName } = deviceIdentity();
|
|
115
189
|
let status = 'initializing';
|
|
116
190
|
let authUrl;
|
|
117
191
|
let stateTail;
|
|
@@ -145,7 +219,7 @@ async function loginInkInteractive(syncLibrary) {
|
|
|
145
219
|
// Phase 1: register state with the server.
|
|
146
220
|
let init;
|
|
147
221
|
try {
|
|
148
|
-
init = await initLoginState(baseUrl);
|
|
222
|
+
init = await initLoginState(baseUrl, deviceId, machineName);
|
|
149
223
|
}
|
|
150
224
|
catch (err) {
|
|
151
225
|
status = 'error';
|
|
@@ -182,6 +256,7 @@ async function loginInkInteractive(syncLibrary) {
|
|
|
182
256
|
username: granted.username,
|
|
183
257
|
displayName: granted.displayName,
|
|
184
258
|
syncLibrary,
|
|
259
|
+
...persistedDeviceField(deviceId),
|
|
185
260
|
});
|
|
186
261
|
resolvedUsername = granted.username;
|
|
187
262
|
status = 'success';
|
|
@@ -197,9 +272,12 @@ async function loginInkInteractive(syncLibrary) {
|
|
|
197
272
|
* polling indicator; the spinner degrades gracefully when stdout isn't a TTY. */
|
|
198
273
|
async function loginPlainInteractive(syncLibrary) {
|
|
199
274
|
const baseUrl = getApiUrl();
|
|
275
|
+
// Resolve the device identity ONCE — forwarded to the browser via the auth
|
|
276
|
+
// URL and persisted (below) under the same id.
|
|
277
|
+
const { deviceId, machineName } = deviceIdentity();
|
|
200
278
|
let init;
|
|
201
279
|
try {
|
|
202
|
-
init = await initLoginState(baseUrl);
|
|
280
|
+
init = await initLoginState(baseUrl, deviceId, machineName);
|
|
203
281
|
}
|
|
204
282
|
catch (err) {
|
|
205
283
|
console.error(err instanceof Error ? err.message : String(err));
|
|
@@ -230,6 +308,7 @@ async function loginPlainInteractive(syncLibrary) {
|
|
|
230
308
|
username: granted.username,
|
|
231
309
|
displayName: granted.displayName,
|
|
232
310
|
syncLibrary,
|
|
311
|
+
...persistedDeviceField(deviceId),
|
|
233
312
|
});
|
|
234
313
|
printSyncLibraryHint(syncLibrary);
|
|
235
314
|
}
|
|
@@ -6,16 +6,16 @@ interface PublishOptions {
|
|
|
6
6
|
license?: string;
|
|
7
7
|
json?: boolean;
|
|
8
8
|
noCompile?: boolean;
|
|
9
|
-
/** Skip the pre-POST y/N confirmation prompt.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* `--
|
|
13
|
-
* because JSON mode is for non-interactive consumers. */
|
|
9
|
+
/** Skip the pre-POST y/N confirmation prompt. The path-form publish asks
|
|
10
|
+
* "Add @<user>/<slug> to your library? [y/N]" by default so first-time
|
|
11
|
+
* authors realize it uploads immediately. `--yes` bypasses it for scripted
|
|
12
|
+
* runs; `--json` skips it implicitly because JSON mode is non-interactive. */
|
|
14
13
|
yes?: boolean;
|
|
15
14
|
/** Build the payload, print what would be published, exit 0 without POSTing.
|
|
16
15
|
* Lets authors preview title/description/file list + total size before they
|
|
17
|
-
* commit to
|
|
18
|
-
*
|
|
16
|
+
* commit to uploading. Skipped entirely on ref-form publish (flipping a
|
|
17
|
+
* skill's CLI-discoverable flag is already cheap and reversible via
|
|
18
|
+
* `unpublish`). */
|
|
19
19
|
dryRun?: boolean;
|
|
20
20
|
}
|
|
21
21
|
export interface FileEntry {
|
|
@@ -25,13 +25,13 @@ export interface FileEntry {
|
|
|
25
25
|
}
|
|
26
26
|
export declare function publish(source: string, options: PublishOptions): Promise<void>;
|
|
27
27
|
/**
|
|
28
|
-
*
|
|
28
|
+
* Make an existing skill discoverable from the CLI (install/pull/list) via the
|
|
29
|
+
* API — sets `cli_discoverable = true` server-side. The inverse of `unpublish`.
|
|
29
30
|
*
|
|
30
31
|
* Called from `publish()` when the source argument looks like a ref
|
|
31
|
-
* (`@user/slug` or `user/slug`) instead of a local path.
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* mistake is one CLI call away.
|
|
32
|
+
* (`@user/slug` or `user/slug`) instead of a local path. No prompt — exposing
|
|
33
|
+
* your own skill to your own CLI is harmless, and `unpublish` hides it again in
|
|
34
|
+
* one call. Skills are never public; this only controls CLI reach.
|
|
35
35
|
*/
|
|
36
36
|
declare function publishRef(rawRef: string, options: PublishOptions): Promise<void>;
|
|
37
37
|
/**
|
package/dist/commands/publish.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import AdmZip from 'adm-zip';
|
|
4
4
|
import * as p from '@clack/prompts';
|
|
5
|
-
import { ApiError, apiFetch, getApiUrl, friendlyApiErrorDetail } from '../lib/api.js';
|
|
5
|
+
import { ApiError, apiFetch, getApiUrl, friendlyApiErrorDetail, friendlyFreeTierError } from '../lib/api.js';
|
|
6
6
|
import { parseManifest } from '../lib/manifest.js';
|
|
7
7
|
import { compile } from './compile.js';
|
|
8
8
|
import { ecosystemDestination } from '../lib/canonical.js';
|
|
@@ -60,9 +60,9 @@ export async function publish(source, options) {
|
|
|
60
60
|
// user isn't logged in. The 401 would otherwise only surface after the POST,
|
|
61
61
|
// making large publishes feel slow and confusing.
|
|
62
62
|
requireAuth(options);
|
|
63
|
-
// Ref-form (e.g. "@user/slug" or "user/slug") →
|
|
64
|
-
//
|
|
65
|
-
// below. Overloading by argument shape (rather than introducing
|
|
63
|
+
// Ref-form (e.g. "@user/slug" or "user/slug") → make that existing skill
|
|
64
|
+
// discoverable from the CLI via the API. Path-form continues to the upload
|
|
65
|
+
// flow below. Overloading by argument shape (rather than introducing
|
|
66
66
|
// `botdocs publish-ref` or a `--ref` flag) keeps the verb intuitive:
|
|
67
67
|
// a user typing `botdocs publish @me/foo` doesn't have to know it's a
|
|
68
68
|
// different code path under the hood.
|
|
@@ -160,11 +160,11 @@ export async function publish(source, options) {
|
|
|
160
160
|
console.error('Description is required. Use --description "..." or set "description" in botdocs.json.');
|
|
161
161
|
process.exit(1);
|
|
162
162
|
}
|
|
163
|
-
// Pre-POST confirmation.
|
|
164
|
-
//
|
|
165
|
-
// y/N (defaulting to N) before sending. Skipped
|
|
166
|
-
// runs that opt in) and --json (non-interactive
|
|
167
|
-
// gives back-compat for callers that don't have a TTY).
|
|
163
|
+
// Pre-POST confirmation. The upload is immediate (the skill lands in your
|
|
164
|
+
// library, private, discoverable from the CLI) — first-time authors don't
|
|
165
|
+
// always know that, so we ask y/N (defaulting to N) before sending. Skipped
|
|
166
|
+
// under --yes (scripted runs that opt in) and --json (non-interactive
|
|
167
|
+
// consumers, which also gives back-compat for callers that don't have a TTY).
|
|
168
168
|
if (!options.yes && !options.json) {
|
|
169
169
|
const auth = loadAuth();
|
|
170
170
|
// requireAuth() ran first, so auth is non-null here. The fallback is
|
|
@@ -172,11 +172,11 @@ export async function publish(source, options) {
|
|
|
172
172
|
const username = auth?.username ?? 'me';
|
|
173
173
|
const slugGuess = derivePublishSlug(source);
|
|
174
174
|
const confirmed = await p.confirm({
|
|
175
|
-
message: `
|
|
175
|
+
message: `Add @${username}/${slugGuess} to your library? (private — discoverable from your CLI)`,
|
|
176
176
|
initialValue: false,
|
|
177
177
|
});
|
|
178
178
|
if (p.isCancel(confirmed) || confirmed !== true) {
|
|
179
|
-
console.log('
|
|
179
|
+
console.log(' Cancelled.');
|
|
180
180
|
return;
|
|
181
181
|
}
|
|
182
182
|
}
|
|
@@ -211,7 +211,7 @@ export async function publish(source, options) {
|
|
|
211
211
|
console.log(JSON.stringify({ ...result, url: absoluteUrl }));
|
|
212
212
|
}
|
|
213
213
|
else {
|
|
214
|
-
console.log(`\
|
|
214
|
+
console.log(`\nAdded to your library: ${absoluteUrl}`);
|
|
215
215
|
}
|
|
216
216
|
}
|
|
217
217
|
/** Translate path-publish API errors into actionable CLI messages.
|
|
@@ -284,10 +284,14 @@ function handlePathPublishError(err, options) {
|
|
|
284
284
|
console.error(`\n ✗ ${err.message}\n`);
|
|
285
285
|
}
|
|
286
286
|
else {
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
|
|
287
|
+
// Free-tier frictions (e.g. 403 skill_cap_exceeded) get an HONEST CTA
|
|
288
|
+
// from the shared mapper before the generic 403/429/5xx wording. The
|
|
289
|
+
// mapper returns null for anything it doesn't recognize, so the
|
|
290
|
+
// friendlyApiErrorDetail fallback still covers "permission denied" /
|
|
291
|
+
// "rate-limited" / server errors. Keeps the cap message identical to
|
|
292
|
+
// the one ingest/install render.
|
|
293
|
+
const friendly = friendlyFreeTierError(err);
|
|
294
|
+
console.error(`\n ✗ ${friendly ?? friendlyApiErrorDetail(err)}\n`);
|
|
291
295
|
}
|
|
292
296
|
process.exit(1);
|
|
293
297
|
}
|
|
@@ -302,13 +306,13 @@ function extractDetailMessage(detail) {
|
|
|
302
306
|
return undefined;
|
|
303
307
|
}
|
|
304
308
|
/**
|
|
305
|
-
*
|
|
309
|
+
* Make an existing skill discoverable from the CLI (install/pull/list) via the
|
|
310
|
+
* API — sets `cli_discoverable = true` server-side. The inverse of `unpublish`.
|
|
306
311
|
*
|
|
307
312
|
* Called from `publish()` when the source argument looks like a ref
|
|
308
|
-
* (`@user/slug` or `user/slug`) instead of a local path.
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
* mistake is one CLI call away.
|
|
313
|
+
* (`@user/slug` or `user/slug`) instead of a local path. No prompt — exposing
|
|
314
|
+
* your own skill to your own CLI is harmless, and `unpublish` hides it again in
|
|
315
|
+
* one call. Skills are never public; this only controls CLI reach.
|
|
312
316
|
*/
|
|
313
317
|
async function publishRef(rawRef, options) {
|
|
314
318
|
// Preflight: same as the path-form publish. publish() already called
|
|
@@ -337,10 +341,10 @@ async function publishRef(rawRef, options) {
|
|
|
337
341
|
return;
|
|
338
342
|
}
|
|
339
343
|
if (options.json) {
|
|
340
|
-
console.log(JSON.stringify({ ok: true, ref: refLabel, status: '
|
|
344
|
+
console.log(JSON.stringify({ ok: true, ref: refLabel, status: 'cli-visible' }));
|
|
341
345
|
}
|
|
342
346
|
else {
|
|
343
|
-
console.log(`✓
|
|
347
|
+
console.log(`✓ ${refLabel} is now discoverable from the CLI.`);
|
|
344
348
|
}
|
|
345
349
|
}
|
|
346
350
|
/**
|
|
@@ -3,14 +3,14 @@ interface UnpublishOptions {
|
|
|
3
3
|
json?: boolean;
|
|
4
4
|
}
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Hide a skill from the CLI (sets `cli_discoverable = false` server-side): it
|
|
7
|
+
* stays in the author's web library, but `botdocs install`/`pull`/`list` can
|
|
8
|
+
* no longer see it. The inverse of `publish @user/slug`.
|
|
8
9
|
*
|
|
9
|
-
* Idempotent server-side —
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* callers don't deadlock on the prompt.
|
|
10
|
+
* Idempotent server-side — hiding an already-hidden skill is a no-op. Prompts
|
|
11
|
+
* for confirmation by default (so a stray `unpublish` doesn't quietly pull a
|
|
12
|
+
* skill out of your agents); skip with `--yes` for scripting/CI. `--json`
|
|
13
|
+
* implies `--yes` so machine-driven callers don't deadlock on the prompt.
|
|
14
14
|
*/
|
|
15
15
|
export declare function unpublish(rawRef: string, options: UnpublishOptions): Promise<void>;
|
|
16
16
|
export {};
|
|
@@ -3,14 +3,14 @@ import { apiFetch } from '../lib/api.js';
|
|
|
3
3
|
import { parseRef } from '../lib/ref.js';
|
|
4
4
|
import { handlePublishToggleError } from './publish.js';
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Hide a skill from the CLI (sets `cli_discoverable = false` server-side): it
|
|
7
|
+
* stays in the author's web library, but `botdocs install`/`pull`/`list` can
|
|
8
|
+
* no longer see it. The inverse of `publish @user/slug`.
|
|
8
9
|
*
|
|
9
|
-
* Idempotent server-side —
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* callers don't deadlock on the prompt.
|
|
10
|
+
* Idempotent server-side — hiding an already-hidden skill is a no-op. Prompts
|
|
11
|
+
* for confirmation by default (so a stray `unpublish` doesn't quietly pull a
|
|
12
|
+
* skill out of your agents); skip with `--yes` for scripting/CI. `--json`
|
|
13
|
+
* implies `--yes` so machine-driven callers don't deadlock on the prompt.
|
|
14
14
|
*/
|
|
15
15
|
export async function unpublish(rawRef, options) {
|
|
16
16
|
let parsed;
|
|
@@ -25,8 +25,8 @@ export async function unpublish(rawRef, options) {
|
|
|
25
25
|
const refLabel = `@${username}/${slug}`;
|
|
26
26
|
if (!options.yes && !options.json) {
|
|
27
27
|
const confirmed = await p.confirm({
|
|
28
|
-
message: `
|
|
29
|
-
`
|
|
28
|
+
message: `Hide ${refLabel} from the CLI? It stays in your web library, but ` +
|
|
29
|
+
`the CLI won't be able to install, pull, or list it.`,
|
|
30
30
|
initialValue: false,
|
|
31
31
|
});
|
|
32
32
|
if (p.isCancel(confirmed) || !confirmed) {
|
|
@@ -45,9 +45,9 @@ export async function unpublish(rawRef, options) {
|
|
|
45
45
|
return;
|
|
46
46
|
}
|
|
47
47
|
if (options.json) {
|
|
48
|
-
console.log(JSON.stringify({ ok: true, ref: refLabel, status: '
|
|
48
|
+
console.log(JSON.stringify({ ok: true, ref: refLabel, status: 'cli-hidden' }));
|
|
49
49
|
}
|
|
50
50
|
else {
|
|
51
|
-
console.log(`✓
|
|
51
|
+
console.log(`✓ ${refLabel} is now hidden from the CLI (still in your web library).`);
|
|
52
52
|
}
|
|
53
53
|
}
|
package/dist/index.js
CHANGED
|
@@ -182,7 +182,7 @@ program
|
|
|
182
182
|
});
|
|
183
183
|
program
|
|
184
184
|
.command('publish <source>')
|
|
185
|
-
.description('
|
|
185
|
+
.description('Add a skill to your library — pass a local path to upload, or @user/slug to make an existing skill discoverable from the CLI')
|
|
186
186
|
.option('--title <title>', 'BotDoc title')
|
|
187
187
|
.option('--description <description>', 'BotDoc description')
|
|
188
188
|
.option('--category <category>', 'Category (knowledge_management, dev_workflow, automation, agent_config, project_scaffold, other)')
|
|
@@ -196,14 +196,14 @@ program
|
|
|
196
196
|
});
|
|
197
197
|
program
|
|
198
198
|
.command('unpublish <ref>')
|
|
199
|
-
.description('Hide a
|
|
199
|
+
.description('Hide a skill from the CLI (it stays in your web library)')
|
|
200
200
|
.option('--yes', 'Skip the confirmation prompt')
|
|
201
201
|
.action(async (ref, options) => {
|
|
202
202
|
await unpublish(ref, { ...options, json: program.opts().json });
|
|
203
203
|
});
|
|
204
204
|
program
|
|
205
205
|
.command('delete <ref>')
|
|
206
|
-
.description('Delete a
|
|
206
|
+
.description('Delete a skill — fresh skills (no installs/history) are hard-deleted with cascade; ones with history are soft-deleted (hidden, version history preserved)')
|
|
207
207
|
.option('--yes', 'Skip the confirmation prompt')
|
|
208
208
|
.action(async (ref, options) => {
|
|
209
209
|
await deleteCmd(ref, { ...options, json: program.opts().json });
|
package/dist/lib/api.d.ts
CHANGED
|
@@ -105,4 +105,23 @@ export declare function fetchRawContent(rawUrl: string, options?: {
|
|
|
105
105
|
* stale-token cases are 401, which is handled upstream as "Authentication
|
|
106
106
|
* failed"). */
|
|
107
107
|
export declare function friendlyApiErrorDetail(err: ApiError, ref?: string): string;
|
|
108
|
+
/** The free-tier friction error codes the server returns in the JSON body's
|
|
109
|
+
* `error` field. Commands branch on these to print a consistent, HONEST CTA
|
|
110
|
+
* (no fake purchase flow — all accounts are free today). Kept as a union so a
|
|
111
|
+
* server-side rename breaks the mapping exactly once, here. */
|
|
112
|
+
export type FreeTierErrorCode = 'device_cap_exceeded' | 'session_limit_exceeded' | 'skill_cap_exceeded' | 'library_conflict';
|
|
113
|
+
/**
|
|
114
|
+
* Map a server error to a friendly, HONEST one-liner for the free-tier
|
|
115
|
+
* frictions (and the expired-token 401). Returns null when `err` isn't one of
|
|
116
|
+
* the handled cases, so callers fall back to `friendlyApiErrorDetail`.
|
|
117
|
+
*
|
|
118
|
+
* Honesty rule (locked product decision): there is NO purchase/upgrade flow.
|
|
119
|
+
* Messages communicate the free limit and point at "learn more" docs — they
|
|
120
|
+
* never promise a working checkout. Higher limits for teams are described as
|
|
121
|
+
* "coming".
|
|
122
|
+
*
|
|
123
|
+
* Centralized here so `install` / `create` / `sync` / `ingest` render the same
|
|
124
|
+
* vocabulary; a copy tweak is a single edit.
|
|
125
|
+
*/
|
|
126
|
+
export declare function friendlyFreeTierError(err: ApiError): string | null;
|
|
108
127
|
export {};
|
package/dist/lib/api.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import { loadAuth, getOrCreateDeviceId } from './config.js';
|
|
2
3
|
const DEFAULT_API_URL = 'https://www.botdocs.ai';
|
|
3
4
|
/** Default per-request timeout (ms). Exposed so tests can override without
|
|
4
5
|
* mocking `setTimeout` globally, and so callers can read the value when
|
|
@@ -27,6 +28,23 @@ function isLocalhostUrl(u) {
|
|
|
27
28
|
/** Track whether we've already printed the override-warning so the CLI doesn't
|
|
28
29
|
* spam stderr across every API call in a single process run. */
|
|
29
30
|
let overrideWarnPrinted = false;
|
|
31
|
+
/** Attach the device-identity headers to an authenticated request.
|
|
32
|
+
*
|
|
33
|
+
* `X-Device-Id` is the stable per-install id (or the `BOTDOCS_DEVICE_ID`
|
|
34
|
+
* override); `X-Machine-Name` is a display-only hint the server stores on the
|
|
35
|
+
* device row. Called from the SAME choke point as the bearer so every
|
|
36
|
+
* authenticated request carries the device identity — the free-tier
|
|
37
|
+
* device/session caps depend on it. Best-effort: a failure to resolve the id
|
|
38
|
+
* (e.g. a read-only home dir) never blocks the request. */
|
|
39
|
+
function attachDeviceHeaders(headers) {
|
|
40
|
+
try {
|
|
41
|
+
headers['X-Device-Id'] = getOrCreateDeviceId();
|
|
42
|
+
headers['X-Machine-Name'] = os.hostname();
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Best-effort — never fail a request because we couldn't stamp a device id.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
30
48
|
export function getApiUrl() {
|
|
31
49
|
const override = process.env.BOTDOCS_API_URL;
|
|
32
50
|
if (!override)
|
|
@@ -148,6 +166,9 @@ export async function apiFetch(path, options = {}) {
|
|
|
148
166
|
}
|
|
149
167
|
else {
|
|
150
168
|
headers['Authorization'] = `Bearer ${config.token}`;
|
|
169
|
+
// Same choke point as the bearer: stamp the device identity so the
|
|
170
|
+
// server's free-tier device/session gates see this request.
|
|
171
|
+
attachDeviceHeaders(headers);
|
|
151
172
|
}
|
|
152
173
|
}
|
|
153
174
|
// AbortController + setTimeout is the lowest-overhead way to enforce a hard
|
|
@@ -245,6 +266,7 @@ export async function fetchRawContent(rawUrl, options = {}) {
|
|
|
245
266
|
const config = loadAuth();
|
|
246
267
|
if (config?.token) {
|
|
247
268
|
headers['Authorization'] = `Bearer ${config.token}`;
|
|
269
|
+
attachDeviceHeaders(headers);
|
|
248
270
|
}
|
|
249
271
|
// Same pattern as apiFetch — AbortController + clearTimeout in finally so
|
|
250
272
|
// the timer never outlives the request and blocks process exit.
|
|
@@ -302,3 +324,72 @@ export function friendlyApiErrorDetail(err, ref) {
|
|
|
302
324
|
return err.message;
|
|
303
325
|
return `request failed (${err.status})`;
|
|
304
326
|
}
|
|
327
|
+
/** Narrow an `ApiError.body` to the free-tier error shape when it carries one
|
|
328
|
+
* of the known codes. Returns null for any other body. */
|
|
329
|
+
function asFreeTierErrorBody(body) {
|
|
330
|
+
if (typeof body !== 'object' || body === null)
|
|
331
|
+
return null;
|
|
332
|
+
const code = body.error;
|
|
333
|
+
if (code === 'device_cap_exceeded' ||
|
|
334
|
+
code === 'session_limit_exceeded' ||
|
|
335
|
+
code === 'skill_cap_exceeded' ||
|
|
336
|
+
code === 'library_conflict') {
|
|
337
|
+
return { ...body, error: code };
|
|
338
|
+
}
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Map a server error to a friendly, HONEST one-liner for the free-tier
|
|
343
|
+
* frictions (and the expired-token 401). Returns null when `err` isn't one of
|
|
344
|
+
* the handled cases, so callers fall back to `friendlyApiErrorDetail`.
|
|
345
|
+
*
|
|
346
|
+
* Honesty rule (locked product decision): there is NO purchase/upgrade flow.
|
|
347
|
+
* Messages communicate the free limit and point at "learn more" docs — they
|
|
348
|
+
* never promise a working checkout. Higher limits for teams are described as
|
|
349
|
+
* "coming".
|
|
350
|
+
*
|
|
351
|
+
* Centralized here so `install` / `create` / `sync` / `ingest` render the same
|
|
352
|
+
* vocabulary; a copy tweak is a single edit.
|
|
353
|
+
*/
|
|
354
|
+
export function friendlyFreeTierError(err) {
|
|
355
|
+
// Expired-token 401: the server already 401s an expired bearer; surface the
|
|
356
|
+
// re-login CTA. (Generic 401s are handled upstream in apiFetch with the same
|
|
357
|
+
// wording; this keeps the mapping in one place for callers that inspect the
|
|
358
|
+
// raw ApiError.)
|
|
359
|
+
if (err.status === 401) {
|
|
360
|
+
return 'Your session has expired. Run `botdocs login` to sign in again.';
|
|
361
|
+
}
|
|
362
|
+
const body = asFreeTierErrorBody(err.body);
|
|
363
|
+
if (!body)
|
|
364
|
+
return null;
|
|
365
|
+
switch (body.error) {
|
|
366
|
+
case 'device_cap_exceeded': {
|
|
367
|
+
const limit = body.limit;
|
|
368
|
+
const limitText = typeof limit === 'number' ? `${limit} devices` : 'a limited number of devices';
|
|
369
|
+
return (`Free accounts are limited to ${limitText}, and you've reached that limit. ` +
|
|
370
|
+
`Revoke a device you no longer use at https://botdocs.ai/settings, or set ` +
|
|
371
|
+
`BOTDOCS_DEVICE_ID to reuse one identity in CI. Higher limits for teams are coming — ` +
|
|
372
|
+
`learn more at https://botdocs.ai/teams.`);
|
|
373
|
+
}
|
|
374
|
+
case 'session_limit_exceeded': {
|
|
375
|
+
const limit = body.limit;
|
|
376
|
+
const limitText = typeof limit === 'number' ? `${limit} devices` : 'a limited number of devices';
|
|
377
|
+
return (`Free accounts can be active on ${limitText} at once, and you're over that limit right now. ` +
|
|
378
|
+
`Wait a few minutes for another device to go idle, or set BOTDOCS_DEVICE_ID to share one ` +
|
|
379
|
+
`identity in CI. Higher limits for teams are coming — learn more at https://botdocs.ai/teams.`);
|
|
380
|
+
}
|
|
381
|
+
case 'skill_cap_exceeded': {
|
|
382
|
+
const limit = body.limit;
|
|
383
|
+
const limitText = typeof limit === 'number' ? `${limit} skills` : 'a limited number of skills';
|
|
384
|
+
return (`Free accounts are limited to ${limitText}, and you've reached that limit. ` +
|
|
385
|
+
`Remove a skill you no longer need, or learn about higher limits for teams (coming soon) ` +
|
|
386
|
+
`at https://botdocs.ai/teams.`);
|
|
387
|
+
}
|
|
388
|
+
case 'library_conflict': {
|
|
389
|
+
const device = body.conflictingDevice;
|
|
390
|
+
const who = device ? ` from "${device}"` : '';
|
|
391
|
+
return (`Your library was changed by another device${who} since this one last synced. ` +
|
|
392
|
+
`Run \`botdocs sync\` to pull the latest, then retry.`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -10,10 +10,44 @@ interface AuthConfig {
|
|
|
10
10
|
username: string;
|
|
11
11
|
displayName: string;
|
|
12
12
|
syncLibrary?: boolean;
|
|
13
|
+
/** Stable per-install device identity (a v4 UUID) sent to the API as
|
|
14
|
+
* `X-Device-Id` on every authenticated request. Generated once by
|
|
15
|
+
* `getOrCreateDeviceId` and NEVER rotated — the free-tier device/session
|
|
16
|
+
* caps key off it, so rotating would register a phantom new device. CI
|
|
17
|
+
* runners override it via the `BOTDOCS_DEVICE_ID` env var (see
|
|
18
|
+
* `getOrCreateDeviceId`) so they don't blow the cap across ephemeral
|
|
19
|
+
* machines. Optional for backward-compat: auth.json files written before
|
|
20
|
+
* this field existed lazily gain one on the next `getOrCreateDeviceId`. */
|
|
21
|
+
deviceId?: string;
|
|
22
|
+
/** Last server-acknowledged library generation counter (the `version`
|
|
23
|
+
* returned by POST /api/library/lockfile-sync). The CLI sends it back as
|
|
24
|
+
* `baseVersion` on the next sync so the server can detect that ANOTHER
|
|
25
|
+
* device changed the shelf in between and return a 409 `library_conflict`
|
|
26
|
+
* instead of silently clobbering. Absent until the first successful sync;
|
|
27
|
+
* a missing value is treated as "no base" (the server force-writes and
|
|
28
|
+
* stamps a fresh version). */
|
|
29
|
+
libraryVersion?: number;
|
|
13
30
|
}
|
|
14
31
|
export declare function saveAuth(config: AuthConfig): void;
|
|
15
32
|
export declare function loadAuth(): AuthConfig | null;
|
|
16
33
|
export declare function clearAuth(): void;
|
|
34
|
+
/**
|
|
35
|
+
* Get the stable device identity for this CLI install, creating it if needed.
|
|
36
|
+
*
|
|
37
|
+
* Resolution order:
|
|
38
|
+
* 1. `BOTDOCS_DEVICE_ID` env var (CI/automation escape hatch) — wins when
|
|
39
|
+
* set and non-blank; not persisted.
|
|
40
|
+
* 2. The `deviceId` already saved in ~/.botdocs/auth.json.
|
|
41
|
+
* 3. A freshly generated v4 UUID, persisted back into auth.json (when an
|
|
42
|
+
* auth config exists) so it's stable across runs.
|
|
43
|
+
*
|
|
44
|
+
* The id is NEVER rotated once persisted — the server's device/session caps
|
|
45
|
+
* key off it. If there's no auth.json yet (user not logged in) we still return
|
|
46
|
+
* a generated id for the current process but can't persist it; `login` calls
|
|
47
|
+
* this AFTER saving auth so the id lands in the file. Returns `null` only when
|
|
48
|
+
* generation itself is impossible (never, in practice).
|
|
49
|
+
*/
|
|
50
|
+
export declare function getOrCreateDeviceId(): string;
|
|
17
51
|
/** One-time startup migration check. If auth.json exists and was created by
|
|
18
52
|
* a pre-P1-G CLI (mode 0644 / world-readable), tighten it in place AND
|
|
19
53
|
* warn the user — the token may have been read by another local user, so
|
package/dist/lib/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
4
5
|
// POSIX modes used for the CLI's credential storage. The token lives in
|
|
5
6
|
// auth.json and is the bearer for every authenticated API call — a default
|
|
6
7
|
// umask of 022 leaves the file at 0644 (world-readable) on shared dev
|
|
@@ -78,6 +79,45 @@ export function clearAuth() {
|
|
|
78
79
|
fs.unlinkSync(file);
|
|
79
80
|
}
|
|
80
81
|
}
|
|
82
|
+
/** Env var that pins a stable device identity, overriding the persisted one.
|
|
83
|
+
* Documented escape hatch for CI/automation: a fleet of ephemeral runners can
|
|
84
|
+
* export the same `BOTDOCS_DEVICE_ID` so they all present as one device and
|
|
85
|
+
* don't blow the free-tier device/concurrent-session caps. When set (and
|
|
86
|
+
* non-blank) it ALWAYS wins and is never persisted. */
|
|
87
|
+
const DEVICE_ID_ENV = 'BOTDOCS_DEVICE_ID';
|
|
88
|
+
/**
|
|
89
|
+
* Get the stable device identity for this CLI install, creating it if needed.
|
|
90
|
+
*
|
|
91
|
+
* Resolution order:
|
|
92
|
+
* 1. `BOTDOCS_DEVICE_ID` env var (CI/automation escape hatch) — wins when
|
|
93
|
+
* set and non-blank; not persisted.
|
|
94
|
+
* 2. The `deviceId` already saved in ~/.botdocs/auth.json.
|
|
95
|
+
* 3. A freshly generated v4 UUID, persisted back into auth.json (when an
|
|
96
|
+
* auth config exists) so it's stable across runs.
|
|
97
|
+
*
|
|
98
|
+
* The id is NEVER rotated once persisted — the server's device/session caps
|
|
99
|
+
* key off it. If there's no auth.json yet (user not logged in) we still return
|
|
100
|
+
* a generated id for the current process but can't persist it; `login` calls
|
|
101
|
+
* this AFTER saving auth so the id lands in the file. Returns `null` only when
|
|
102
|
+
* generation itself is impossible (never, in practice).
|
|
103
|
+
*/
|
|
104
|
+
export function getOrCreateDeviceId() {
|
|
105
|
+
const fromEnv = process.env[DEVICE_ID_ENV]?.trim();
|
|
106
|
+
if (fromEnv)
|
|
107
|
+
return fromEnv;
|
|
108
|
+
const config = loadAuth();
|
|
109
|
+
if (config?.deviceId && config.deviceId.trim().length > 0) {
|
|
110
|
+
return config.deviceId;
|
|
111
|
+
}
|
|
112
|
+
const generated = randomUUID();
|
|
113
|
+
// Persist back onto the existing auth config so the id is stable across
|
|
114
|
+
// runs. If there's no auth.json (not logged in), we can't persist — return
|
|
115
|
+
// the generated id for this process; `login` persists it once auth exists.
|
|
116
|
+
if (config) {
|
|
117
|
+
saveAuth({ ...config, deviceId: generated });
|
|
118
|
+
}
|
|
119
|
+
return generated;
|
|
120
|
+
}
|
|
81
121
|
export function checkAuthFilePerms() {
|
|
82
122
|
if (process.platform === 'win32')
|
|
83
123
|
return { changed: false };
|
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
* - file contents (lockfile never contained them, but the route also rejects them)
|
|
4
4
|
* - install destinations (private to the user's machine)
|
|
5
5
|
* - fingerprints (not useful server-side; minimize what we send)
|
|
6
|
-
* Only refs + versions + types remain.
|
|
7
|
-
*
|
|
6
|
+
* Only refs + versions + types remain.
|
|
7
|
+
*
|
|
8
|
+
* Sends the last server-acknowledged generation counter as `baseVersion` so
|
|
9
|
+
* the server can detect a concurrent change from another device and reply 409
|
|
10
|
+
* `library_conflict` instead of clobbering. On success we persist the new
|
|
11
|
+
* `version`. On conflict we surface a single honest notice (keep-local +
|
|
12
|
+
* advise `botdocs sync`). All OTHER failures stay silent so install/sync
|
|
13
|
+
* flows never break on a telemetry hiccup. */
|
|
8
14
|
export declare function syncLibrary(): Promise<void>;
|
package/dist/lib/library-sync.js
CHANGED
|
@@ -1,13 +1,49 @@
|
|
|
1
|
-
import { apiFetch } from './api.js';
|
|
1
|
+
import { ApiError, apiFetch } from './api.js';
|
|
2
2
|
import { loadLockfile } from './lockfile.js';
|
|
3
|
-
import { loadAuth } from './config.js';
|
|
3
|
+
import { loadAuth, saveAuth } from './config.js';
|
|
4
|
+
/** Narrow an ApiError body to the standard `library_conflict` 409 shape. The
|
|
5
|
+
* server sends `conflictingDevice` (the device that last touched the shelf);
|
|
6
|
+
* it may be null when the device name isn't known. */
|
|
7
|
+
function asLibraryConflict(body) {
|
|
8
|
+
if (typeof body !== 'object' || body === null)
|
|
9
|
+
return null;
|
|
10
|
+
if (body.error !== 'library_conflict')
|
|
11
|
+
return null;
|
|
12
|
+
return body;
|
|
13
|
+
}
|
|
14
|
+
/** Module-scoped guard so a single CLI invocation (which may call syncLibrary
|
|
15
|
+
* several times — e.g. install loops) prints the conflict notice at most once.
|
|
16
|
+
* Reset is unnecessary: the process is short-lived. */
|
|
17
|
+
let conflictNoticePrinted = false;
|
|
18
|
+
/** Print the honest library-conflict CTA to stderr exactly once per process.
|
|
19
|
+
*
|
|
20
|
+
* MVP policy (no auto-merge): we KEEP the local shelf untouched — the server
|
|
21
|
+
* already refused the write, so there's nothing to roll back here — and advise
|
|
22
|
+
* the user to pull the latest with `botdocs sync` before retrying. Per-member
|
|
23
|
+
* shelves are mentioned as a coming team feature (honest: no fake purchase).
|
|
24
|
+
* Goes to stderr so it never disturbs a command's stdout-rendered output. */
|
|
25
|
+
function printConflictNotice(conflictingDevice) {
|
|
26
|
+
if (conflictNoticePrinted)
|
|
27
|
+
return;
|
|
28
|
+
conflictNoticePrinted = true;
|
|
29
|
+
const who = conflictingDevice ? ` from "${conflictingDevice}"` : '';
|
|
30
|
+
process.stderr.write(`\n ⚠ Your library changed on another device${who} since this one last synced.\n` +
|
|
31
|
+
` Kept your local copy as-is. Run \`botdocs sync\` to pull the latest, then retry.\n` +
|
|
32
|
+
` (Per-member shelves are a coming team feature.)\n`);
|
|
33
|
+
}
|
|
4
34
|
/** Posts a sanitized snapshot of the lockfile to BotDocs if the user has
|
|
5
35
|
* opted-in via `botdocs login --sync-library`. Sanitization strips:
|
|
6
36
|
* - file contents (lockfile never contained them, but the route also rejects them)
|
|
7
37
|
* - install destinations (private to the user's machine)
|
|
8
38
|
* - fingerprints (not useful server-side; minimize what we send)
|
|
9
|
-
* Only refs + versions + types remain.
|
|
10
|
-
*
|
|
39
|
+
* Only refs + versions + types remain.
|
|
40
|
+
*
|
|
41
|
+
* Sends the last server-acknowledged generation counter as `baseVersion` so
|
|
42
|
+
* the server can detect a concurrent change from another device and reply 409
|
|
43
|
+
* `library_conflict` instead of clobbering. On success we persist the new
|
|
44
|
+
* `version`. On conflict we surface a single honest notice (keep-local +
|
|
45
|
+
* advise `botdocs sync`). All OTHER failures stay silent so install/sync
|
|
46
|
+
* flows never break on a telemetry hiccup. */
|
|
11
47
|
export async function syncLibrary() {
|
|
12
48
|
const auth = loadAuth();
|
|
13
49
|
if (!auth?.syncLibrary)
|
|
@@ -15,16 +51,39 @@ export async function syncLibrary() {
|
|
|
15
51
|
const lf = loadLockfile();
|
|
16
52
|
const sanitized = {
|
|
17
53
|
version: 1,
|
|
54
|
+
// Generation counter the server last acked. Separate axis from the
|
|
55
|
+
// lockfile-FORMAT `version: 1` above — this is "which revision of the
|
|
56
|
+
// shelf did I base this write on". Omitted when we've never synced.
|
|
57
|
+
baseVersion: auth.libraryVersion,
|
|
18
58
|
installs: lf.installs.map((i) => ({ ref: i.ref, type: i.type, version: i.version })),
|
|
19
59
|
};
|
|
20
60
|
try {
|
|
21
|
-
await apiFetch('/api/library/lockfile-sync', {
|
|
61
|
+
const result = await apiFetch('/api/library/lockfile-sync', {
|
|
22
62
|
method: 'POST',
|
|
23
63
|
auth: true,
|
|
24
64
|
body: sanitized,
|
|
25
65
|
});
|
|
66
|
+
// Persist the new generation counter so the next sync sends it as
|
|
67
|
+
// baseVersion. Re-load auth in case it changed under us; guard the write
|
|
68
|
+
// so we don't resurrect a logged-out config.
|
|
69
|
+
if (typeof result.version === 'number') {
|
|
70
|
+
const current = loadAuth();
|
|
71
|
+
if (current)
|
|
72
|
+
saveAuth({ ...current, libraryVersion: result.version });
|
|
73
|
+
}
|
|
26
74
|
}
|
|
27
|
-
catch {
|
|
28
|
-
//
|
|
75
|
+
catch (err) {
|
|
76
|
+
// 409 library_conflict is the ONE telemetry failure worth surfacing — it's
|
|
77
|
+
// user-actionable (another device moved the shelf). Everything else
|
|
78
|
+
// (network blips, 5xx, the launch-day waitlist 403) stays silent so the
|
|
79
|
+
// foreground command isn't disrupted by a background sync.
|
|
80
|
+
if (err instanceof ApiError && err.status === 409) {
|
|
81
|
+
const conflict = asLibraryConflict(err.body);
|
|
82
|
+
if (conflict) {
|
|
83
|
+
printConflictNotice(conflict.conflictingDevice);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Silent — never break user flows.
|
|
29
88
|
}
|
|
30
89
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botdocs/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"description": "CLI for BotDocs — author, publish, install, and sync agent skills across Claude, Claude Code, Cursor, Codex, ChatGPT, Windsurf, Copilot, Gemini, Antigravity, and OpenCode.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"botdocs",
|