@botdocs/cli 0.12.2 → 0.14.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/commands/edit.js +71 -7
- package/dist/commands/ingest.js +32 -1
- package/dist/commands/login.js +24 -3
- package/dist/commands/proposals.d.ts +69 -0
- package/dist/commands/proposals.js +556 -0
- package/dist/commands/propose.d.ts +18 -0
- package/dist/commands/propose.js +202 -0
- package/dist/commands/publish.d.ts +15 -13
- package/dist/commands/publish.js +29 -25
- package/dist/commands/unpublish.d.ts +7 -7
- package/dist/commands/unpublish.js +11 -11
- package/dist/index.js +18 -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/dist/lib/proposals.d.ts +37 -0
- package/dist/lib/proposals.js +72 -0
- package/package.json +1 -1
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
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** A file for hashing — ONLY filename + content participate. */
|
|
2
|
+
export interface HashableFile {
|
|
3
|
+
filename: string;
|
|
4
|
+
content: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Canonical file-set hash. Mirrors the server's `fileSetHash` exactly so the
|
|
8
|
+
* `basedFileHash` the CLI sends matches what the server would compute over the
|
|
9
|
+
* same live file set. Sort is by filename ascending using the same comparison
|
|
10
|
+
* the server uses (`<` / `>` on the raw filename string).
|
|
11
|
+
*/
|
|
12
|
+
export declare function fileSetHash(files: ReadonlyArray<HashableFile>): string;
|
|
13
|
+
/**
|
|
14
|
+
* Canonical SYNTHESIS idempotency hash. DISTINCT from {@link fileSetHash}
|
|
15
|
+
* (which is the drift anchor / `basedFileHash`). The server keys the
|
|
16
|
+
* partial-unique index `botdoc_proposals_synthesis_hash_uq` on this value so
|
|
17
|
+
* the same OPEN synthesis (same sources + same merged content) can't be
|
|
18
|
+
* created twice — the CLI computes it before POSTing only so the value is
|
|
19
|
+
* stable client-side; the server recomputes + enforces it.
|
|
20
|
+
*
|
|
21
|
+
* MUST match `apps/web/src/lib/proposals.ts#sourceHash` byte-for-byte:
|
|
22
|
+
*
|
|
23
|
+
* sha256-hex( JSON.stringify({
|
|
24
|
+
* sources: [...new Set(synthesizedFrom)].sort(),
|
|
25
|
+
* files: [...mergedFiles]
|
|
26
|
+
* .sort by filename ascending (same `<`/`>` compare as fileSetHash)
|
|
27
|
+
* .map(f => [f.filename, sha256hex(f.content)]),
|
|
28
|
+
* }) )
|
|
29
|
+
*
|
|
30
|
+
* Notes (identical exclusions to fileSetHash):
|
|
31
|
+
* - `sources` is deduplicated + lexicographically sorted, so source-id
|
|
32
|
+
* ordering / duplicates don't change the hash.
|
|
33
|
+
* - `files` pairs each filename with the sha256 of its content (not the raw
|
|
34
|
+
* body) to keep the JSON small.
|
|
35
|
+
* - `mode`/`sortOrder` are NOT part of the hash.
|
|
36
|
+
*/
|
|
37
|
+
export declare function sourceHash(synthesizedFrom: ReadonlyArray<string>, mergedFiles: ReadonlyArray<HashableFile>): string;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* proposals.ts — shared helpers for the CLI proposal lane.
|
|
3
|
+
*
|
|
4
|
+
* The load-bearing piece here is {@link fileSetHash}: the canonical file-set
|
|
5
|
+
* hash that the server computes in `apps/web/src/lib/proposals.ts#fileSetHash`.
|
|
6
|
+
* The two MUST agree byte-for-byte — the server uses it as the drift anchor
|
|
7
|
+
* (`basedFileHash`) the CLI sends on every `propose`. Any divergence (sort
|
|
8
|
+
* order, framing byte, included fields) would make the server reject or
|
|
9
|
+
* mis-detect drift for every proposal.
|
|
10
|
+
*
|
|
11
|
+
* Canonical spec (identical on both sides):
|
|
12
|
+
* - sha256, hex digest
|
|
13
|
+
* - over each file sorted by filename ascending (JS default string compare)
|
|
14
|
+
* - of: filename + "\u0000" + content + "\u0000"
|
|
15
|
+
* - `mode` and `sortOrder` are NOT part of the hash.
|
|
16
|
+
*/
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
18
|
+
/** NUL framing byte between/after each (filename, content) pair. Defined once
|
|
19
|
+
* so the intent is obvious and it can never be mistaken for a space. */
|
|
20
|
+
const NUL = '\u0000';
|
|
21
|
+
/**
|
|
22
|
+
* Canonical file-set hash. Mirrors the server's `fileSetHash` exactly so the
|
|
23
|
+
* `basedFileHash` the CLI sends matches what the server would compute over the
|
|
24
|
+
* same live file set. Sort is by filename ascending using the same comparison
|
|
25
|
+
* the server uses (`<` / `>` on the raw filename string).
|
|
26
|
+
*/
|
|
27
|
+
export function fileSetHash(files) {
|
|
28
|
+
const sorted = [...files].sort((a, b) => a.filename < b.filename ? -1 : a.filename > b.filename ? 1 : 0);
|
|
29
|
+
const hash = createHash('sha256');
|
|
30
|
+
for (const f of sorted) {
|
|
31
|
+
hash.update(f.filename);
|
|
32
|
+
hash.update(NUL);
|
|
33
|
+
hash.update(f.content);
|
|
34
|
+
hash.update(NUL);
|
|
35
|
+
}
|
|
36
|
+
return hash.digest('hex');
|
|
37
|
+
}
|
|
38
|
+
/** sha256-hex of a single string (a file's content). Helper for {@link sourceHash}. */
|
|
39
|
+
function sha256Hex(value) {
|
|
40
|
+
return createHash('sha256').update(value).digest('hex');
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Canonical SYNTHESIS idempotency hash. DISTINCT from {@link fileSetHash}
|
|
44
|
+
* (which is the drift anchor / `basedFileHash`). The server keys the
|
|
45
|
+
* partial-unique index `botdoc_proposals_synthesis_hash_uq` on this value so
|
|
46
|
+
* the same OPEN synthesis (same sources + same merged content) can't be
|
|
47
|
+
* created twice — the CLI computes it before POSTing only so the value is
|
|
48
|
+
* stable client-side; the server recomputes + enforces it.
|
|
49
|
+
*
|
|
50
|
+
* MUST match `apps/web/src/lib/proposals.ts#sourceHash` byte-for-byte:
|
|
51
|
+
*
|
|
52
|
+
* sha256-hex( JSON.stringify({
|
|
53
|
+
* sources: [...new Set(synthesizedFrom)].sort(),
|
|
54
|
+
* files: [...mergedFiles]
|
|
55
|
+
* .sort by filename ascending (same `<`/`>` compare as fileSetHash)
|
|
56
|
+
* .map(f => [f.filename, sha256hex(f.content)]),
|
|
57
|
+
* }) )
|
|
58
|
+
*
|
|
59
|
+
* Notes (identical exclusions to fileSetHash):
|
|
60
|
+
* - `sources` is deduplicated + lexicographically sorted, so source-id
|
|
61
|
+
* ordering / duplicates don't change the hash.
|
|
62
|
+
* - `files` pairs each filename with the sha256 of its content (not the raw
|
|
63
|
+
* body) to keep the JSON small.
|
|
64
|
+
* - `mode`/`sortOrder` are NOT part of the hash.
|
|
65
|
+
*/
|
|
66
|
+
export function sourceHash(synthesizedFrom, mergedFiles) {
|
|
67
|
+
const sources = [...new Set(synthesizedFrom)].sort();
|
|
68
|
+
const files = [...mergedFiles]
|
|
69
|
+
.sort((a, b) => (a.filename < b.filename ? -1 : a.filename > b.filename ? 1 : 0))
|
|
70
|
+
.map((f) => [f.filename, sha256Hex(f.content)]);
|
|
71
|
+
return sha256Hex(JSON.stringify({ sources, files }));
|
|
72
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botdocs/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
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",
|