@botdocs/cli 0.10.3 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/bin/botdocs.cjs +78 -0
- package/dist/commands/check-updates.d.ts +22 -0
- package/dist/commands/check-updates.js +73 -18
- package/dist/commands/edit.js +10 -2
- package/dist/commands/ingest.d.ts +25 -0
- package/dist/commands/ingest.js +303 -12
- package/dist/commands/init.d.ts +24 -0
- package/dist/commands/init.js +43 -6
- package/dist/commands/install.js +146 -5
- package/dist/commands/list.js +62 -0
- package/dist/commands/login.js +56 -2
- package/dist/commands/publish.d.ts +30 -3
- package/dist/commands/publish.js +353 -40
- package/dist/commands/sync.js +252 -40
- package/dist/commands/uninstall.js +12 -0
- package/dist/commands/validate.js +82 -8
- package/dist/commands/views/login-app.js +6 -5
- package/dist/commands/views/theme.d.ts +3 -4
- package/dist/commands/views/theme.js +3 -4
- package/dist/index.js +162 -30
- package/dist/lib/api.d.ts +55 -2
- package/dist/lib/api.js +168 -11
- package/dist/lib/auto-detect.js +70 -30
- package/dist/lib/config.d.ts +15 -0
- package/dist/lib/config.js +83 -2
- package/dist/lib/ingest-session-client.d.ts +93 -0
- package/dist/lib/ingest-session-client.js +217 -0
- package/dist/lib/lockfile.d.ts +13 -0
- package/dist/lib/manifest.d.ts +12 -0
- package/dist/lib/manifest.js +29 -2
- package/dist/lib/node-preflight.d.ts +20 -0
- package/dist/lib/node-preflight.js +11 -0
- package/dist/lib/skill-caps.d.ts +17 -0
- package/dist/lib/skill-caps.js +19 -0
- package/package.json +3 -3
package/dist/lib/auto-detect.js
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
const NORMALIZE = (p) => p.replace(/\\/g, '/');
|
|
3
|
+
/**
|
|
4
|
+
* Defense-in-depth path confinement.
|
|
5
|
+
*
|
|
6
|
+
* The server-side validator in `apps/web/src/lib/filename-validation.ts` is
|
|
7
|
+
* the authoritative gate against malicious manifest filenames — `..`
|
|
8
|
+
* segments, absolute paths, NUL bytes, etc. all get rejected before they
|
|
9
|
+
* ever reach the DB. This function adds a second layer on the CLI side so
|
|
10
|
+
* an already-running CLI installing from a (hypothetical) server that
|
|
11
|
+
* regressed the validator still can't write outside the install root.
|
|
12
|
+
*
|
|
13
|
+
* Asserts `path.resolve(dest)` is contained within `path.resolve(root)`
|
|
14
|
+
* before returning the dest. If the post-join resolution escapes the
|
|
15
|
+
* root, throws a clear error so the install fails loudly rather than
|
|
16
|
+
* silently writing to `~/.zshenv` or `~/.ssh/authorized_keys`.
|
|
17
|
+
*
|
|
18
|
+
* Even branches that already use `path.basename(src)` (which neutralizes
|
|
19
|
+
* `..` on its own) call this — defense in depth means we don't have to
|
|
20
|
+
* remember which branches are safe-by-construction.
|
|
21
|
+
*/
|
|
22
|
+
function assertWithinRoot(dest, root, srcRelative) {
|
|
23
|
+
const resolvedDest = path.resolve(dest);
|
|
24
|
+
const resolvedRoot = path.resolve(root);
|
|
25
|
+
// The dest must be either the root itself (unusual but technically fine)
|
|
26
|
+
// or a strict descendant. `resolvedRoot + path.sep` avoids the classic
|
|
27
|
+
// `/root` ⊂ `/rootBAD` prefix-match pitfall.
|
|
28
|
+
if (resolvedDest !== resolvedRoot &&
|
|
29
|
+
!resolvedDest.startsWith(resolvedRoot + path.sep)) {
|
|
30
|
+
throw new Error(`Manifest filename '${srcRelative}' resolved outside install root '${resolvedRoot}'`);
|
|
31
|
+
}
|
|
32
|
+
return dest;
|
|
33
|
+
}
|
|
3
34
|
export function detectDestination(srcRelative, ctx) {
|
|
4
35
|
const src = NORMALIZE(srcRelative);
|
|
5
36
|
if (src.startsWith('claude/')) {
|
|
@@ -12,10 +43,9 @@ export function detectDestination(srcRelative, ctx) {
|
|
|
12
43
|
? remainder.replace(/^[^/]+\//, '')
|
|
13
44
|
: remainder;
|
|
14
45
|
const skillPath = ctx.flatScope ? ctx.slug : path.join(ctx.scope, ctx.slug);
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
};
|
|
46
|
+
const root = path.join(ctx.homeDir, '.claude', 'skills', skillPath);
|
|
47
|
+
const dest = path.join(root, finalName);
|
|
48
|
+
return { kind: 'global', dest: assertWithinRoot(dest, root, srcRelative) };
|
|
19
49
|
}
|
|
20
50
|
if (src.startsWith('claude-code/agents/')) {
|
|
21
51
|
// Layout mirrors claude skills:
|
|
@@ -29,22 +59,19 @@ export function detectDestination(srcRelative, ctx) {
|
|
|
29
59
|
const finalName = remainder.includes('/')
|
|
30
60
|
? remainder.replace(/^[^/]+\//, '')
|
|
31
61
|
: remainder;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
};
|
|
62
|
+
const root = path.join(ctx.projectDir, '.claude', 'agents', ctx.slug);
|
|
63
|
+
const dest = path.join(root, finalName);
|
|
64
|
+
return { kind: 'project', dest: assertWithinRoot(dest, root, srcRelative) };
|
|
36
65
|
}
|
|
37
66
|
if (src.startsWith('claude-code/commands/')) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
};
|
|
67
|
+
const root = path.join(ctx.projectDir, '.claude', 'commands');
|
|
68
|
+
const dest = path.join(root, path.basename(src));
|
|
69
|
+
return { kind: 'project', dest: assertWithinRoot(dest, root, srcRelative) };
|
|
42
70
|
}
|
|
43
71
|
if (src.startsWith('cursor/rules/')) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
};
|
|
72
|
+
const root = path.join(ctx.projectDir, '.cursor', 'rules');
|
|
73
|
+
const dest = path.join(root, path.basename(src));
|
|
74
|
+
return { kind: 'project', dest: assertWithinRoot(dest, root, srcRelative) };
|
|
48
75
|
}
|
|
49
76
|
if (src.startsWith('codex/')) {
|
|
50
77
|
// Codex skills are nested SKILL.md directories at
|
|
@@ -61,27 +88,29 @@ export function detectDestination(srcRelative, ctx) {
|
|
|
61
88
|
if (!remainder.includes('/')) {
|
|
62
89
|
// Flat legacy form `codex/<slug>.md` → nested SKILL.md.
|
|
63
90
|
const slug = remainder.replace(/\.md$/, '');
|
|
64
|
-
|
|
91
|
+
const root = path.join(codexBase, slug);
|
|
92
|
+
const dest = path.join(root, 'SKILL.md');
|
|
93
|
+
return { kind: 'global', dest: assertWithinRoot(dest, root, srcRelative) };
|
|
65
94
|
}
|
|
66
95
|
const finalName = remainder.replace(/^[^/]+\//, '');
|
|
67
|
-
|
|
96
|
+
const root = path.join(codexBase, ctx.slug);
|
|
97
|
+
const dest = path.join(root, finalName);
|
|
98
|
+
return { kind: 'global', dest: assertWithinRoot(dest, root, srcRelative) };
|
|
68
99
|
}
|
|
69
100
|
if (src.startsWith('copilot/instructions/')) {
|
|
70
101
|
// GitHub Copilot custom instructions live in .github/instructions/
|
|
71
102
|
// (https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot).
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
};
|
|
103
|
+
const root = path.join(ctx.projectDir, '.github', 'instructions');
|
|
104
|
+
const dest = path.join(root, path.basename(src));
|
|
105
|
+
return { kind: 'project', dest: assertWithinRoot(dest, root, srcRelative) };
|
|
76
106
|
}
|
|
77
107
|
if (src.startsWith('windsurf/rules/')) {
|
|
78
108
|
// Windsurf reads project rules from <proj>/.windsurf/rules/<slug>.md
|
|
79
109
|
// (docs.windsurf.com). Flat .md rule, project-scoped. The canonical form
|
|
80
110
|
// is already flat, so basename() is the right leaf either way.
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
};
|
|
111
|
+
const root = path.join(ctx.projectDir, '.windsurf', 'rules');
|
|
112
|
+
const dest = path.join(root, path.basename(src));
|
|
113
|
+
return { kind: 'project', dest: assertWithinRoot(dest, root, srcRelative) };
|
|
85
114
|
}
|
|
86
115
|
if (src.startsWith('gemini/')) {
|
|
87
116
|
// Gemini CLI has NO per-skill file directory — it uses hierarchical
|
|
@@ -89,6 +118,9 @@ export function detectDestination(srcRelative, ctx) {
|
|
|
89
118
|
// project). There's no real path to write to, so route to `manual` (like
|
|
90
119
|
// chatgpt): install surfaces the content for the user to paste into their
|
|
91
120
|
// GEMINI.md or @import it, rather than fabricating ~/.gemini/instructions/.
|
|
121
|
+
//
|
|
122
|
+
// `manual` doesn't write to disk, so confinement doesn't apply — `dest`
|
|
123
|
+
// here is just an informational hint shown to the user.
|
|
92
124
|
return { kind: 'manual', dest: src };
|
|
93
125
|
}
|
|
94
126
|
if (src.startsWith('antigravity/skills/')) {
|
|
@@ -108,10 +140,14 @@ export function detectDestination(srcRelative, ctx) {
|
|
|
108
140
|
const agentBase = path.join(ctx.projectDir, '.agent', 'skills');
|
|
109
141
|
if (!remainder.includes('/')) {
|
|
110
142
|
const slug = remainder.replace(/\.md$/, '');
|
|
111
|
-
|
|
143
|
+
const root = path.join(agentBase, slug);
|
|
144
|
+
const dest = path.join(root, 'SKILL.md');
|
|
145
|
+
return { kind: 'project', dest: assertWithinRoot(dest, root, srcRelative) };
|
|
112
146
|
}
|
|
113
147
|
const finalName = remainder.replace(/^[^/]+\//, '');
|
|
114
|
-
|
|
148
|
+
const root = path.join(agentBase, ctx.slug);
|
|
149
|
+
const dest = path.join(root, finalName);
|
|
150
|
+
return { kind: 'project', dest: assertWithinRoot(dest, root, srcRelative) };
|
|
115
151
|
}
|
|
116
152
|
if (src.startsWith('opencode/')) {
|
|
117
153
|
// OpenCode skills are nested SKILL.md directories (opencode.ai/docs/skills).
|
|
@@ -128,13 +164,17 @@ export function detectDestination(srcRelative, ctx) {
|
|
|
128
164
|
const opencodeBase = path.join(ctx.projectDir, '.opencode', 'skills');
|
|
129
165
|
if (src.startsWith('opencode/instructions/')) {
|
|
130
166
|
const slug = path.basename(src).replace(/\.md$/, '');
|
|
131
|
-
|
|
167
|
+
const root = path.join(opencodeBase, slug);
|
|
168
|
+
const dest = path.join(root, 'SKILL.md');
|
|
169
|
+
return { kind: 'project', dest: assertWithinRoot(dest, root, srcRelative) };
|
|
132
170
|
}
|
|
133
171
|
const remainder = src.slice('opencode/'.length);
|
|
134
172
|
const finalName = remainder.includes('/')
|
|
135
173
|
? remainder.replace(/^[^/]+\//, '')
|
|
136
174
|
: remainder;
|
|
137
|
-
|
|
175
|
+
const root = path.join(opencodeBase, ctx.slug);
|
|
176
|
+
const dest = path.join(root, finalName);
|
|
177
|
+
return { kind: 'project', dest: assertWithinRoot(dest, root, srcRelative) };
|
|
138
178
|
}
|
|
139
179
|
if (src.startsWith('chatgpt/')) {
|
|
140
180
|
return { kind: 'manual', dest: src };
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -14,4 +14,19 @@ interface AuthConfig {
|
|
|
14
14
|
export declare function saveAuth(config: AuthConfig): void;
|
|
15
15
|
export declare function loadAuth(): AuthConfig | null;
|
|
16
16
|
export declare function clearAuth(): void;
|
|
17
|
+
/** One-time startup migration check. If auth.json exists and was created by
|
|
18
|
+
* a pre-P1-G CLI (mode 0644 / world-readable), tighten it in place AND
|
|
19
|
+
* warn the user — the token may have been read by another local user, so
|
|
20
|
+
* they should consider rotating it via /settings/tokens.
|
|
21
|
+
*
|
|
22
|
+
* Best-effort: never throws, never blocks CLI startup. Skipped on Windows
|
|
23
|
+
* where POSIX modes don't apply. Returns a structured result so callers
|
|
24
|
+
* (and tests) can observe the action taken without scraping stderr.
|
|
25
|
+
*/
|
|
26
|
+
export interface AuthPermsCheck {
|
|
27
|
+
changed: boolean;
|
|
28
|
+
previousMode?: number;
|
|
29
|
+
warning?: string;
|
|
30
|
+
}
|
|
31
|
+
export declare function checkAuthFilePerms(): AuthPermsCheck;
|
|
17
32
|
export {};
|
package/dist/lib/config.js
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
|
+
// POSIX modes used for the CLI's credential storage. The token lives in
|
|
5
|
+
// auth.json and is the bearer for every authenticated API call — a default
|
|
6
|
+
// umask of 022 leaves the file at 0644 (world-readable) on shared dev
|
|
7
|
+
// boxes, which is a real exfiltration surface. We lock the dir to 0700 and
|
|
8
|
+
// the file to 0600 so only the owning user can traverse/read.
|
|
9
|
+
const CONFIG_DIR_MODE = 0o700; // rwx for user only
|
|
10
|
+
const CONFIG_FILE_MODE = 0o600; // rw for user only
|
|
4
11
|
// Resolved lazily so tests can swap os.homedir between cases without having
|
|
5
12
|
// to monkey-patch a captured constant.
|
|
6
13
|
function getConfigDir() {
|
|
@@ -12,12 +19,46 @@ function getAuthFile() {
|
|
|
12
19
|
function ensureConfigDir() {
|
|
13
20
|
const dir = getConfigDir();
|
|
14
21
|
if (!fs.existsSync(dir)) {
|
|
15
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
22
|
+
fs.mkdirSync(dir, { recursive: true, mode: CONFIG_DIR_MODE });
|
|
23
|
+
return dir;
|
|
16
24
|
}
|
|
25
|
+
// Existing dir — if it's too permissive, lock it down. Don't try this
|
|
26
|
+
// on Windows where chmod is largely a no-op; the per-user profile ACL
|
|
27
|
+
// is the real defense.
|
|
28
|
+
if (process.platform !== 'win32') {
|
|
29
|
+
try {
|
|
30
|
+
const stat = fs.statSync(dir);
|
|
31
|
+
const currentMode = stat.mode & 0o777;
|
|
32
|
+
if (currentMode !== CONFIG_DIR_MODE) {
|
|
33
|
+
fs.chmodSync(dir, CONFIG_DIR_MODE);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Best-effort. A chmod failure shouldn't block login.
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return dir;
|
|
17
41
|
}
|
|
18
42
|
export function saveAuth(config) {
|
|
19
43
|
ensureConfigDir();
|
|
20
|
-
|
|
44
|
+
const file = getAuthFile();
|
|
45
|
+
// The `mode` option on writeFileSync only takes effect when the file
|
|
46
|
+
// doesn't exist yet (it's passed to open(O_CREAT)). For existing files
|
|
47
|
+
// the underlying inode mode is preserved, which means an auth.json
|
|
48
|
+
// written before this hardening landed would stay 0644 forever. So we
|
|
49
|
+
// chmod separately below.
|
|
50
|
+
fs.writeFileSync(file, JSON.stringify(config, null, 2), {
|
|
51
|
+
encoding: 'utf-8',
|
|
52
|
+
mode: CONFIG_FILE_MODE,
|
|
53
|
+
});
|
|
54
|
+
if (process.platform !== 'win32') {
|
|
55
|
+
try {
|
|
56
|
+
fs.chmodSync(file, CONFIG_FILE_MODE);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Best-effort — a chmod failure shouldn't fail login.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
21
62
|
}
|
|
22
63
|
export function loadAuth() {
|
|
23
64
|
const file = getAuthFile();
|
|
@@ -37,3 +78,43 @@ export function clearAuth() {
|
|
|
37
78
|
fs.unlinkSync(file);
|
|
38
79
|
}
|
|
39
80
|
}
|
|
81
|
+
export function checkAuthFilePerms() {
|
|
82
|
+
if (process.platform === 'win32')
|
|
83
|
+
return { changed: false };
|
|
84
|
+
const file = getAuthFile();
|
|
85
|
+
if (!fs.existsSync(file))
|
|
86
|
+
return { changed: false };
|
|
87
|
+
let previousMode;
|
|
88
|
+
try {
|
|
89
|
+
previousMode = fs.statSync(file).mode & 0o777;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return { changed: false };
|
|
93
|
+
}
|
|
94
|
+
if (previousMode === CONFIG_FILE_MODE)
|
|
95
|
+
return { changed: false };
|
|
96
|
+
// Mode is wrong — tighten in place and surface a warning. We intentionally
|
|
97
|
+
// do NOT clear the token; the user's session keeps working, but we tell
|
|
98
|
+
// them their bearer was historically readable so they can rotate.
|
|
99
|
+
try {
|
|
100
|
+
fs.chmodSync(file, CONFIG_FILE_MODE);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// chmod failed — still surface the warning so the user knows.
|
|
104
|
+
}
|
|
105
|
+
// Also tighten the directory in passing.
|
|
106
|
+
try {
|
|
107
|
+
const dir = getConfigDir();
|
|
108
|
+
const dirMode = fs.statSync(dir).mode & 0o777;
|
|
109
|
+
if (dirMode !== CONFIG_DIR_MODE) {
|
|
110
|
+
fs.chmodSync(dir, CONFIG_DIR_MODE);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// best-effort
|
|
115
|
+
}
|
|
116
|
+
const warning = `botdocs: ~/.botdocs/auth.json was mode ${previousMode.toString(8).padStart(4, '0')} ` +
|
|
117
|
+
`(world/group readable). Fixed to 0600. If this machine has other users, ` +
|
|
118
|
+
`consider rotating your token at https://botdocs.ai/settings/tokens.`;
|
|
119
|
+
return { changed: true, previousMode, warning };
|
|
120
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export interface ClaimResult {
|
|
2
|
+
sessionId: string;
|
|
3
|
+
expiresAt: string;
|
|
4
|
+
}
|
|
5
|
+
export declare class PairingClaimError extends Error {
|
|
6
|
+
readonly status: number | undefined;
|
|
7
|
+
constructor(message: string, status?: number, options?: {
|
|
8
|
+
cause?: unknown;
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
/** Event types and per-type payload shapes — must stay aligned with the
|
|
12
|
+
* web schema in apps/web/src/lib/ingest-session.ts. Drift here means the
|
|
13
|
+
* server 400s and the CLI swallows it silently. Bump both in lockstep. */
|
|
14
|
+
export type EventType = 'selection_committed' | 'upload_started' | 'upload_completed' | 'upload_failed' | 'cancelled';
|
|
15
|
+
export interface EventPayloads {
|
|
16
|
+
selection_committed: {
|
|
17
|
+
totalSelected: number;
|
|
18
|
+
candidatesScanned: number;
|
|
19
|
+
};
|
|
20
|
+
upload_started: {
|
|
21
|
+
filename: string;
|
|
22
|
+
type: 'agent' | 'prompt' | 'context' | 'workflow';
|
|
23
|
+
experimental: boolean;
|
|
24
|
+
};
|
|
25
|
+
upload_completed: {
|
|
26
|
+
filename: string;
|
|
27
|
+
type: 'agent' | 'prompt' | 'context' | 'workflow';
|
|
28
|
+
experimental: boolean;
|
|
29
|
+
draftId: string;
|
|
30
|
+
slug: string;
|
|
31
|
+
};
|
|
32
|
+
upload_failed: {
|
|
33
|
+
filename: string;
|
|
34
|
+
reason: string;
|
|
35
|
+
};
|
|
36
|
+
cancelled: {
|
|
37
|
+
reason?: string;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export interface IngestSessionClientOptions {
|
|
41
|
+
/** Window in ms within which queued events are batched before sending.
|
|
42
|
+
* Defaults to 80 ms — short enough to feel real-time, long enough to
|
|
43
|
+
* batch ~10 file events into one round-trip on a fast scan. */
|
|
44
|
+
batchWindowMs?: number;
|
|
45
|
+
/** Override `console.error` for tests. */
|
|
46
|
+
logger?: (msg: string) => void;
|
|
47
|
+
}
|
|
48
|
+
/** Encapsulates the paired-session lifecycle. Created in two phases:
|
|
49
|
+
* `new IngestSessionClient(...)` then `await client.claim(code)`. Once
|
|
50
|
+
* `sessionId` is set, `emit` / `finalize` / `cancel` are usable. */
|
|
51
|
+
export declare class IngestSessionClient {
|
|
52
|
+
private sessionId;
|
|
53
|
+
private readonly queue;
|
|
54
|
+
private flushTimer;
|
|
55
|
+
private flushing;
|
|
56
|
+
private closed;
|
|
57
|
+
private readonly batchWindowMs;
|
|
58
|
+
private readonly log;
|
|
59
|
+
constructor(options?: IngestSessionClientOptions);
|
|
60
|
+
/** True when a code has been claimed and events can flow. */
|
|
61
|
+
get isPaired(): boolean;
|
|
62
|
+
/** Exchange a BD-XXXXX code for a sessionId. Throws PairingClaimError on
|
|
63
|
+
* any non-2xx — the caller is expected to catch and offer the user a
|
|
64
|
+
* graceful "continue without pairing" path.
|
|
65
|
+
*
|
|
66
|
+
* Side effect: ON SUCCESS the server writes a `paired` event (seq 1)
|
|
67
|
+
* with the machineName + cwd. We don't queue that locally because the
|
|
68
|
+
* server already has it. */
|
|
69
|
+
claim(input: {
|
|
70
|
+
code: string;
|
|
71
|
+
machineName: string;
|
|
72
|
+
cwd: string;
|
|
73
|
+
}): Promise<ClaimResult>;
|
|
74
|
+
/** Queue an event for the next batched flush. No-op when not paired or
|
|
75
|
+
* after the session is closed. NEVER throws — the catch in `flush()`
|
|
76
|
+
* downgrades errors to a debug log. */
|
|
77
|
+
emit<T extends EventType>(type: T, payload: EventPayloads[T]): void;
|
|
78
|
+
/** Send the final `done` event + flip session state to `complete`.
|
|
79
|
+
* Flushes any pending events first so the consumer sees them before
|
|
80
|
+
* the end-of-stream marker. NEVER throws. */
|
|
81
|
+
finalize(totals: {
|
|
82
|
+
uploaded: number;
|
|
83
|
+
failed: number;
|
|
84
|
+
}): Promise<void>;
|
|
85
|
+
/** Cancel the session — used on Ctrl+C and on the unhappy-path early
|
|
86
|
+
* return. Writes a `cancelled` event server-side. NEVER throws. */
|
|
87
|
+
cancel(reason?: string): Promise<void>;
|
|
88
|
+
/** Manually flush any queued events synchronously. Public for tests +
|
|
89
|
+
* the finalize path; the normal flow goes through `scheduleFlush()`. */
|
|
90
|
+
flushNow(): Promise<void>;
|
|
91
|
+
private scheduleFlush;
|
|
92
|
+
private drainQueue;
|
|
93
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-side client for the live ingest pairing flow.
|
|
3
|
+
*
|
|
4
|
+
* Lifecycle:
|
|
5
|
+
*
|
|
6
|
+
* 1. User runs `botdocs ingest --pair`. The CLI prompts for the code
|
|
7
|
+
* that the web's onboarding step shows them (BD-XXXXX).
|
|
8
|
+
* 2. CLI calls `claim()`. On success, the server has flipped the
|
|
9
|
+
* session to `paired` and we hold a `sessionId` we use for events.
|
|
10
|
+
* 3. CLI calls `emit(type, payload)` as the ingest flow progresses.
|
|
11
|
+
* Events are buffered briefly (default 80ms) and flushed in
|
|
12
|
+
* batches to amortize round-trips during a fast scan.
|
|
13
|
+
* 4. On success, CLI calls `finalize({ uploaded, failed })`. On
|
|
14
|
+
* failure or Ctrl+C, CLI calls `cancel()`.
|
|
15
|
+
*
|
|
16
|
+
* Resilience: every network call is **fire-and-forget from the user's
|
|
17
|
+
* perspective**. Pairing is a nice-to-have side channel — if the API is
|
|
18
|
+
* down or the user has flaky wifi, the ingest itself must continue. So:
|
|
19
|
+
*
|
|
20
|
+
* - `claim()` rejects with `PairingClaimError` so the caller can decide
|
|
21
|
+
* whether to fall back to unpaired mode (we do).
|
|
22
|
+
* - All other calls (`emit`, `finalize`, `cancel`) NEVER throw. They
|
|
23
|
+
* log to stderr in debug mode and drop the event quietly otherwise.
|
|
24
|
+
*
|
|
25
|
+
* Batching: events queued up while a flush is in flight stack up in
|
|
26
|
+
* `pendingFlush` so we don't fan-out concurrent POSTs. A trailing flush
|
|
27
|
+
* is scheduled at most once per debounce window.
|
|
28
|
+
*/
|
|
29
|
+
import { ApiError, apiFetch } from './api.js';
|
|
30
|
+
const DEBUG = process.env.BOTDOCS_DEBUG === '1';
|
|
31
|
+
const DEFAULT_BATCH_WINDOW_MS = 80;
|
|
32
|
+
export class PairingClaimError extends Error {
|
|
33
|
+
status;
|
|
34
|
+
constructor(message, status, options) {
|
|
35
|
+
super(message, options);
|
|
36
|
+
this.name = 'PairingClaimError';
|
|
37
|
+
this.status = status;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Encapsulates the paired-session lifecycle. Created in two phases:
|
|
41
|
+
* `new IngestSessionClient(...)` then `await client.claim(code)`. Once
|
|
42
|
+
* `sessionId` is set, `emit` / `finalize` / `cancel` are usable. */
|
|
43
|
+
export class IngestSessionClient {
|
|
44
|
+
sessionId = null;
|
|
45
|
+
queue = [];
|
|
46
|
+
flushTimer = null;
|
|
47
|
+
flushing = false;
|
|
48
|
+
closed = false;
|
|
49
|
+
batchWindowMs;
|
|
50
|
+
log;
|
|
51
|
+
constructor(options = {}) {
|
|
52
|
+
this.batchWindowMs = options.batchWindowMs ?? DEFAULT_BATCH_WINDOW_MS;
|
|
53
|
+
this.log = options.logger ?? ((msg) => process.stderr.write(`${msg}\n`));
|
|
54
|
+
}
|
|
55
|
+
/** True when a code has been claimed and events can flow. */
|
|
56
|
+
get isPaired() {
|
|
57
|
+
return this.sessionId !== null && !this.closed;
|
|
58
|
+
}
|
|
59
|
+
/** Exchange a BD-XXXXX code for a sessionId. Throws PairingClaimError on
|
|
60
|
+
* any non-2xx — the caller is expected to catch and offer the user a
|
|
61
|
+
* graceful "continue without pairing" path.
|
|
62
|
+
*
|
|
63
|
+
* Side effect: ON SUCCESS the server writes a `paired` event (seq 1)
|
|
64
|
+
* with the machineName + cwd. We don't queue that locally because the
|
|
65
|
+
* server already has it. */
|
|
66
|
+
async claim(input) {
|
|
67
|
+
try {
|
|
68
|
+
const res = await apiFetch('/api/cli/ingest-sessions/claim', {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
auth: true,
|
|
71
|
+
body: {
|
|
72
|
+
code: input.code,
|
|
73
|
+
machineName: input.machineName,
|
|
74
|
+
cwd: input.cwd,
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
this.sessionId = res.sessionId;
|
|
78
|
+
return res;
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
if (err instanceof ApiError) {
|
|
82
|
+
// Translate the most common cases into actionable copy. Anything
|
|
83
|
+
// else falls through with the server's message.
|
|
84
|
+
if (err.status === 404) {
|
|
85
|
+
throw new PairingClaimError("Pairing code not recognized. Check the onboarding page for a fresh code.", 404, { cause: err });
|
|
86
|
+
}
|
|
87
|
+
if (err.status === 410) {
|
|
88
|
+
throw new PairingClaimError('Pairing code expired. Refresh the onboarding page for a fresh code.', 410, { cause: err });
|
|
89
|
+
}
|
|
90
|
+
if (err.status === 409) {
|
|
91
|
+
throw new PairingClaimError('Pairing code was already used. Refresh the onboarding page for a fresh code.', 409, { cause: err });
|
|
92
|
+
}
|
|
93
|
+
throw new PairingClaimError(err.message, err.status, { cause: err });
|
|
94
|
+
}
|
|
95
|
+
throw new PairingClaimError(err instanceof Error ? err.message : 'Failed to pair with botdocs.ai', undefined, { cause: err });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** Queue an event for the next batched flush. No-op when not paired or
|
|
99
|
+
* after the session is closed. NEVER throws — the catch in `flush()`
|
|
100
|
+
* downgrades errors to a debug log. */
|
|
101
|
+
emit(type, payload) {
|
|
102
|
+
if (!this.isPaired)
|
|
103
|
+
return;
|
|
104
|
+
this.queue.push({ type, payload });
|
|
105
|
+
this.scheduleFlush();
|
|
106
|
+
}
|
|
107
|
+
/** Send the final `done` event + flip session state to `complete`.
|
|
108
|
+
* Flushes any pending events first so the consumer sees them before
|
|
109
|
+
* the end-of-stream marker. NEVER throws. */
|
|
110
|
+
async finalize(totals) {
|
|
111
|
+
if (!this.sessionId || this.closed)
|
|
112
|
+
return;
|
|
113
|
+
await this.flushNow();
|
|
114
|
+
const sessionId = this.sessionId;
|
|
115
|
+
this.closed = true;
|
|
116
|
+
try {
|
|
117
|
+
await apiFetch(`/api/cli/ingest-sessions/${encodeURIComponent(sessionId)}/finalize`, {
|
|
118
|
+
method: 'POST',
|
|
119
|
+
auth: true,
|
|
120
|
+
body: totals,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
if (DEBUG)
|
|
125
|
+
this.log(`[pair] finalize failed: ${describe(err)}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Cancel the session — used on Ctrl+C and on the unhappy-path early
|
|
129
|
+
* return. Writes a `cancelled` event server-side. NEVER throws. */
|
|
130
|
+
async cancel(reason) {
|
|
131
|
+
if (!this.sessionId || this.closed)
|
|
132
|
+
return;
|
|
133
|
+
const sessionId = this.sessionId;
|
|
134
|
+
this.closed = true;
|
|
135
|
+
// Best-effort: do NOT flush the queue. Cancel is the "user hit Ctrl+C"
|
|
136
|
+
// path; getting the cancel signal in fast matters more than
|
|
137
|
+
// delivering trailing events.
|
|
138
|
+
try {
|
|
139
|
+
await apiFetch(`/api/ingest-sessions/${encodeURIComponent(sessionId)}`, {
|
|
140
|
+
method: 'DELETE',
|
|
141
|
+
auth: true,
|
|
142
|
+
body: reason ? { reason } : undefined,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
if (DEBUG)
|
|
147
|
+
this.log(`[pair] cancel failed: ${describe(err)}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** Manually flush any queued events synchronously. Public for tests +
|
|
151
|
+
* the finalize path; the normal flow goes through `scheduleFlush()`. */
|
|
152
|
+
async flushNow() {
|
|
153
|
+
if (this.flushTimer) {
|
|
154
|
+
clearTimeout(this.flushTimer);
|
|
155
|
+
this.flushTimer = null;
|
|
156
|
+
}
|
|
157
|
+
await this.drainQueue();
|
|
158
|
+
}
|
|
159
|
+
scheduleFlush() {
|
|
160
|
+
if (this.flushTimer || this.flushing)
|
|
161
|
+
return;
|
|
162
|
+
this.flushTimer = setTimeout(() => {
|
|
163
|
+
this.flushTimer = null;
|
|
164
|
+
void this.drainQueue();
|
|
165
|
+
}, this.batchWindowMs);
|
|
166
|
+
// Don't let a pending flush keep the Node process alive — the CLI
|
|
167
|
+
// exit path will call finalize() which awaits a final flush anyway.
|
|
168
|
+
if (typeof this.flushTimer.unref === 'function') {
|
|
169
|
+
this.flushTimer.unref();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async drainQueue() {
|
|
173
|
+
if (this.flushing)
|
|
174
|
+
return;
|
|
175
|
+
if (!this.sessionId || this.closed) {
|
|
176
|
+
this.queue.length = 0;
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (this.queue.length === 0)
|
|
180
|
+
return;
|
|
181
|
+
this.flushing = true;
|
|
182
|
+
// Snapshot the current batch and clear the queue under the lock.
|
|
183
|
+
// Anything queued while we're posting will get sent on the next tick.
|
|
184
|
+
const batch = this.queue.splice(0, this.queue.length);
|
|
185
|
+
try {
|
|
186
|
+
await apiFetch(`/api/cli/ingest-sessions/${encodeURIComponent(this.sessionId)}/events`, {
|
|
187
|
+
method: 'POST',
|
|
188
|
+
auth: true,
|
|
189
|
+
body: { events: batch },
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
if (DEBUG)
|
|
194
|
+
this.log(`[pair] events flush failed (dropped ${batch.length}): ${describe(err)}`);
|
|
195
|
+
// Don't requeue — a retry storm against a down endpoint helps no one.
|
|
196
|
+
// The web side will see a gap in the seq sequence and the design
|
|
197
|
+
// already handles partial event loss (it renders what it has).
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
this.flushing = false;
|
|
201
|
+
}
|
|
202
|
+
// If more events arrived during the in-flight POST, schedule another
|
|
203
|
+
// flush. Bounded recursion via the timer (not a tight loop).
|
|
204
|
+
if (this.queue.length > 0)
|
|
205
|
+
this.scheduleFlush();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function describe(err) {
|
|
209
|
+
if (err instanceof Error)
|
|
210
|
+
return err.message;
|
|
211
|
+
try {
|
|
212
|
+
return JSON.stringify(err);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return String(err);
|
|
216
|
+
}
|
|
217
|
+
}
|
package/dist/lib/lockfile.d.ts
CHANGED
|
@@ -20,6 +20,19 @@ export interface InstalledRef {
|
|
|
20
20
|
type: 'team';
|
|
21
21
|
slug: string;
|
|
22
22
|
};
|
|
23
|
+
/** True when the most recent sync bumped the lockfile to the current
|
|
24
|
+
* upstream version BUT couldn't apply every change — at least one file
|
|
25
|
+
* still reflects the user's local edits (preserved via a skip choice).
|
|
26
|
+
* Subsequent syncs use `skippedFiles` to know which files still need
|
|
27
|
+
* conflict resolution; the rest are treated as up-to-date.
|
|
28
|
+
*
|
|
29
|
+
* Absent/false on a clean install or a fully-applied sync. */
|
|
30
|
+
partial?: boolean;
|
|
31
|
+
/** The list of `src` paths the most recent sync skipped on this entry.
|
|
32
|
+
* Used in concert with `partial` so sync display can say "1 file
|
|
33
|
+
* skipped — conflict" and so the next sync can leave clean files alone
|
|
34
|
+
* while still surfacing the unresolved ones. */
|
|
35
|
+
skippedFiles?: string[];
|
|
23
36
|
}
|
|
24
37
|
export interface Lockfile {
|
|
25
38
|
version: 1;
|
package/dist/lib/manifest.d.ts
CHANGED
|
@@ -2,6 +2,18 @@ export type BotDocType = 'SPEC' | 'SKILL' | 'BUNDLE';
|
|
|
2
2
|
export declare class ManifestError extends Error {
|
|
3
3
|
constructor(message: string);
|
|
4
4
|
}
|
|
5
|
+
/**
|
|
6
|
+
* The placeholder description `botdocs init` writes into a fresh manifest.
|
|
7
|
+
* Duplicated here (kept in sync with the constant in `commands/init.ts`) so
|
|
8
|
+
* the validator can reject the exact string without commands/init.ts and
|
|
9
|
+
* lib/manifest.ts having a circular dependency (init imports validate
|
|
10
|
+
* indirectly through the publish flow; manifest is upstream of both).
|
|
11
|
+
*
|
|
12
|
+
* If you change this, change `INIT_PLACEHOLDER_DESCRIPTION` in
|
|
13
|
+
* `commands/init.ts` to match — a string-literal diff in either file
|
|
14
|
+
* silently breaks the "you left the placeholder" detection.
|
|
15
|
+
*/
|
|
16
|
+
export declare const INIT_PLACEHOLDER_DESCRIPTION = "TODO: describe your skill in one sentence.";
|
|
5
17
|
export interface SkillRef {
|
|
6
18
|
username: string;
|
|
7
19
|
slug: string;
|