@botdocs/cli 0.10.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- return {
16
- kind: 'global',
17
- dest: path.join(ctx.homeDir, '.claude', 'skills', skillPath, finalName),
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
- return {
33
- kind: 'project',
34
- dest: path.join(ctx.projectDir, '.claude', 'agents', ctx.slug, finalName),
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
- return {
39
- kind: 'project',
40
- dest: path.join(ctx.projectDir, '.claude', 'commands', path.basename(src)),
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
- return {
45
- kind: 'project',
46
- dest: path.join(ctx.projectDir, '.cursor', 'rules', path.basename(src)),
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
- return { kind: 'global', dest: path.join(codexBase, slug, 'SKILL.md') };
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
- return { kind: 'global', dest: path.join(codexBase, ctx.slug, finalName) };
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
- return {
73
- kind: 'project',
74
- dest: path.join(ctx.projectDir, '.github', 'instructions', path.basename(src)),
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
- return {
82
- kind: 'project',
83
- dest: path.join(ctx.projectDir, '.windsurf', 'rules', path.basename(src)),
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
- return { kind: 'project', dest: path.join(agentBase, slug, 'SKILL.md') };
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
- return { kind: 'project', dest: path.join(agentBase, ctx.slug, finalName) };
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
- return { kind: 'project', dest: path.join(opencodeBase, slug, 'SKILL.md') };
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
- return { kind: 'project', dest: path.join(opencodeBase, ctx.slug, finalName) };
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 };
@@ -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 {};
@@ -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
- fs.writeFileSync(getAuthFile(), JSON.stringify(config, null, 2), 'utf-8');
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
+ }
@@ -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;
@@ -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;