@botdocs/cli 0.3.2 → 0.5.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.
Files changed (91) hide show
  1. package/README.md +145 -36
  2. package/dist/commands/backups.d.ts +4 -0
  3. package/dist/commands/backups.js +291 -0
  4. package/dist/commands/edit.js +16 -8
  5. package/dist/commands/ingest.d.ts +2 -0
  6. package/dist/commands/ingest.js +162 -28
  7. package/dist/commands/install.d.ts +4 -0
  8. package/dist/commands/install.js +40 -3
  9. package/dist/commands/login.d.ts +7 -0
  10. package/dist/commands/login.js +240 -75
  11. package/dist/commands/sync.d.ts +16 -0
  12. package/dist/commands/sync.js +337 -25
  13. package/dist/commands/team.d.ts +2 -0
  14. package/dist/commands/team.js +251 -0
  15. package/dist/commands/undo.d.ts +19 -0
  16. package/dist/commands/undo.js +88 -0
  17. package/dist/commands/views/conflict-prompt.d.ts +24 -0
  18. package/dist/commands/views/conflict-prompt.js +19 -0
  19. package/dist/commands/views/login-app.d.ts +30 -0
  20. package/dist/commands/views/login-app.js +57 -0
  21. package/dist/commands/views/sync-app.d.ts +27 -0
  22. package/dist/commands/views/sync-app.js +147 -0
  23. package/dist/commands/views/sync-state.d.ts +84 -0
  24. package/dist/commands/views/sync-state.js +93 -0
  25. package/dist/commands/views/theme.d.ts +16 -0
  26. package/dist/commands/views/theme.js +16 -0
  27. package/dist/commands/whoami.js +13 -13
  28. package/dist/index.js +46 -39
  29. package/dist/lib/api.d.ts +2 -3
  30. package/dist/lib/api.js +14 -7
  31. package/dist/lib/auto-detect.js +46 -0
  32. package/dist/lib/backup.d.ts +121 -0
  33. package/dist/lib/backup.js +387 -0
  34. package/dist/lib/canonical.d.ts +1 -1
  35. package/dist/lib/canonical.js +43 -1
  36. package/dist/lib/config.d.ts +8 -1
  37. package/dist/lib/config.js +18 -9
  38. package/dist/lib/lockfile.d.ts +9 -0
  39. package/dist/lib/prompts.d.ts +10 -0
  40. package/dist/lib/prompts.js +36 -12
  41. package/package.json +27 -7
  42. package/templates/agents.md +60 -47
  43. package/templates/ecosystem-prompts/compile-antigravity.md +14 -0
  44. package/templates/ecosystem-prompts/compile-copilot.md +14 -0
  45. package/templates/ecosystem-prompts/compile-gemini.md +14 -0
  46. package/templates/ecosystem-prompts/compile-opencode.md +13 -0
  47. package/templates/ecosystem-prompts/compile-windsurf.md +13 -0
  48. package/dist/commands/check-updates.test.d.ts +0 -1
  49. package/dist/commands/check-updates.test.js +0 -128
  50. package/dist/commands/clone.d.ts +0 -3
  51. package/dist/commands/clone.js +0 -70
  52. package/dist/commands/compile.test.d.ts +0 -1
  53. package/dist/commands/compile.test.js +0 -110
  54. package/dist/commands/diff.d.ts +0 -3
  55. package/dist/commands/diff.js +0 -65
  56. package/dist/commands/edit.test.d.ts +0 -1
  57. package/dist/commands/edit.test.js +0 -102
  58. package/dist/commands/endorse.d.ts +0 -7
  59. package/dist/commands/endorse.js +0 -70
  60. package/dist/commands/ingest.test.d.ts +0 -1
  61. package/dist/commands/ingest.test.js +0 -109
  62. package/dist/commands/install.test.d.ts +0 -1
  63. package/dist/commands/install.test.js +0 -253
  64. package/dist/commands/list.test.d.ts +0 -1
  65. package/dist/commands/list.test.js +0 -51
  66. package/dist/commands/publish.test.d.ts +0 -1
  67. package/dist/commands/publish.test.js +0 -138
  68. package/dist/commands/pull.d.ts +0 -3
  69. package/dist/commands/pull.js +0 -78
  70. package/dist/commands/sync.test.d.ts +0 -1
  71. package/dist/commands/sync.test.js +0 -263
  72. package/dist/commands/uninstall.test.d.ts +0 -1
  73. package/dist/commands/uninstall.test.js +0 -67
  74. package/dist/lib/auto-detect.test.d.ts +0 -1
  75. package/dist/lib/auto-detect.test.js +0 -58
  76. package/dist/lib/canonical.test.d.ts +0 -1
  77. package/dist/lib/canonical.test.js +0 -48
  78. package/dist/lib/diff.test.d.ts +0 -1
  79. package/dist/lib/diff.test.js +0 -28
  80. package/dist/lib/library-sync.test.d.ts +0 -1
  81. package/dist/lib/library-sync.test.js +0 -63
  82. package/dist/lib/llm.test.d.ts +0 -1
  83. package/dist/lib/llm.test.js +0 -72
  84. package/dist/lib/lockfile.test.d.ts +0 -1
  85. package/dist/lib/lockfile.test.js +0 -99
  86. package/dist/lib/manifest.test.d.ts +0 -1
  87. package/dist/lib/manifest.test.js +0 -72
  88. package/dist/lib/shell-hook.test.d.ts +0 -1
  89. package/dist/lib/shell-hook.test.js +0 -68
  90. package/dist/test-utils.d.ts +0 -43
  91. package/dist/test-utils.js +0 -101
@@ -1,87 +1,252 @@
1
+ import React from 'react';
2
+ import { randomBytes } from 'node:crypto';
3
+ import open from 'open';
4
+ import { render } from 'ink';
5
+ import * as p from '@clack/prompts';
1
6
  import { saveAuth } from '../lib/config.js';
2
- const DEFAULT_GITHUB_CLIENT_ID = 'Ov23lizUYDKJOhumsyee';
3
- const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || process.env.GITHUB_ID || DEFAULT_GITHUB_CLIENT_ID;
7
+ import { getApiUrl } from '../lib/api.js';
8
+ import { LoginApp } from './views/login-app.js';
9
+ /** Total wall-clock budget for the polling loop. After this we tell the user
10
+ * the request expired and exit 1. Mirrors the server-side state TTL. */
11
+ const POLL_TIMEOUT_MS = 10 * 60 * 1000;
12
+ const POLL_INITIAL_DELAY_MS = 1500;
13
+ const POLL_MAX_DELAY_MS = 5000;
14
+ const POLL_BACKOFF_FACTOR = 1.3;
4
15
  export async function login(options) {
5
- // Step 1: Request device code
6
- const deviceResponse = await fetch('https://github.com/login/device/code', {
7
- method: 'POST',
8
- headers: {
9
- Accept: 'application/json',
10
- 'Content-Type': 'application/json',
11
- },
12
- body: JSON.stringify({
13
- client_id: GITHUB_CLIENT_ID,
14
- scope: 'read:user',
15
- }),
16
- });
17
- if (!deviceResponse.ok) {
18
- console.error('Failed to initiate device code flow.');
16
+ if (options?.token) {
17
+ await loginWithToken(options.token, options.syncLibrary === true);
18
+ return;
19
+ }
20
+ // Pick a render path: Ink for a real TTY (unless --no-ink), plain otherwise.
21
+ // The plain path covers piped output, CI, screen readers, and explicit opt-out.
22
+ const useInk = !options?.noInk && Boolean(process.stdout.isTTY);
23
+ if (useInk) {
24
+ await loginInkInteractive(options?.syncLibrary === true);
25
+ return;
26
+ }
27
+ await loginPlainInteractive(options?.syncLibrary === true);
28
+ }
29
+ /** --token flag flow: validate the supplied token by hitting the API and, on
30
+ * success, persist it to ~/.botdocs/auth.json so subsequent commands can reuse
31
+ * the bearer. Stays plain — this is paste-then-save with no perceivable
32
+ * latency, so a TUI adds friction with no upside. */
33
+ async function loginWithToken(token, syncLibrary) {
34
+ if (!token.startsWith('bd_')) {
35
+ console.error('Invalid token. Tokens start with `bd_` and are minted at /settings/tokens.');
19
36
  process.exit(1);
20
37
  }
21
- const deviceData = (await deviceResponse.json());
22
- // Step 2: Display user code
23
- console.log('\nTo authenticate, visit:');
24
- console.log(`\n ${deviceData.verification_uri}\n`);
25
- console.log(`Enter code: ${deviceData.user_code}\n`);
26
- console.log('Waiting for authorization...');
27
- // Step 3: Poll for token
28
- const interval = (deviceData.interval || 5) * 1000;
29
- const expiresAt = Date.now() + deviceData.expires_in * 1000;
30
- while (Date.now() < expiresAt) {
31
- await new Promise((resolve) => setTimeout(resolve, interval));
32
- const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
33
- method: 'POST',
34
- headers: {
35
- Accept: 'application/json',
36
- 'Content-Type': 'application/json',
37
- },
38
- body: JSON.stringify({
39
- client_id: GITHUB_CLIENT_ID,
40
- device_code: deviceData.device_code,
41
- grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
42
- }),
38
+ const baseUrl = getApiUrl();
39
+ let res;
40
+ try {
41
+ res = await fetch(`${baseUrl}/api/cli/whoami`, {
42
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
43
43
  });
44
- const tokenData = (await tokenResponse.json());
45
- if (tokenData.access_token) {
46
- // Step 4: Get user info
47
- const userResponse = await fetch('https://api.github.com/user', {
48
- headers: {
49
- Authorization: `Bearer ${tokenData.access_token}`,
50
- Accept: 'application/vnd.github+json',
51
- },
52
- });
53
- if (!userResponse.ok) {
54
- console.error('Failed to get user info from GitHub.');
55
- process.exit(1);
56
- }
57
- const user = (await userResponse.json());
58
- // Step 5: Save credentials
59
- saveAuth({
60
- githubToken: tokenData.access_token,
61
- username: user.login,
62
- displayName: user.name || user.login,
63
- syncLibrary: options?.syncLibrary === true,
64
- });
65
- console.log(`\nAuthenticated as ${user.login}`);
66
- if (options?.syncLibrary) {
67
- console.log(' Library sync enabled. Your installed-refs list will appear at https://botdocs.ai/library after install/sync/uninstall.');
68
- }
69
- else {
70
- console.log(' Library sync is OFF. Re-run `botdocs login --sync-library` to enable the personalized Library page.');
71
- }
72
- return;
44
+ }
45
+ catch (err) {
46
+ console.error(`Failed to contact ${baseUrl}: ${err instanceof Error ? err.message : String(err)}`);
47
+ process.exit(1);
48
+ }
49
+ if (res.status === 401) {
50
+ console.error('Authentication failed. The token is invalid or expired.');
51
+ process.exit(1);
52
+ }
53
+ if (!res.ok) {
54
+ console.error(`Token validation failed (${res.status}). Try again later.`);
55
+ process.exit(1);
56
+ }
57
+ const user = (await res.json());
58
+ saveAuth({
59
+ token,
60
+ username: user.username,
61
+ displayName: user.displayName,
62
+ syncLibrary,
63
+ });
64
+ printSignedIn(user.username, syncLibrary);
65
+ }
66
+ /** 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
+ async function initLoginState(baseUrl) {
69
+ const state = randomBytes(32).toString('hex');
70
+ const initRes = await fetch(`${baseUrl}/api/cli/auth/init`, {
71
+ method: 'POST',
72
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
73
+ body: JSON.stringify({ state }),
74
+ });
75
+ if (!initRes.ok) {
76
+ throw new Error(`Failed to start login (HTTP ${initRes.status}).`);
77
+ }
78
+ return {
79
+ state,
80
+ authUrl: `${baseUrl}/cli-auth?state=${state}`,
81
+ tail: state.slice(-6),
82
+ };
83
+ }
84
+ /** Browser-mediated flow rendered via Ink. The component is a pure function
85
+ * of `status`; we drive that state via React updates from the actual auth
86
+ * work (init → open → poll → save) happening here. On any terminal state
87
+ * (success/expired/error) we re-render once with the final values and let
88
+ * the component's effect call `useApp().exit()` to unmount. */
89
+ async function loginInkInteractive(syncLibrary) {
90
+ const baseUrl = getApiUrl();
91
+ let status = 'initializing';
92
+ let authUrl;
93
+ let stateTail;
94
+ let resolvedUsername;
95
+ let errorMessage;
96
+ const instance = render(React.createElement(LoginApp, {
97
+ status,
98
+ syncLibrary,
99
+ }));
100
+ // Re-render the LoginApp with current state. Wrapped in a helper because
101
+ // we update the screen at multiple points across the async flow.
102
+ const rerender = () => {
103
+ instance.rerender(React.createElement(LoginApp, {
104
+ status,
105
+ authUrl,
106
+ stateTail,
107
+ username: resolvedUsername,
108
+ errorMessage,
109
+ syncLibrary,
110
+ }));
111
+ };
112
+ // Allow Ctrl-C to cleanly cancel mid-poll. We let Ink unmount the screen
113
+ // before exiting so the terminal isn't left in raw mode.
114
+ const onSigInt = () => {
115
+ instance.unmount();
116
+ console.log('\nLogin cancelled. Re-run `botdocs login` to try again.');
117
+ process.exit(130);
118
+ };
119
+ process.once('SIGINT', onSigInt);
120
+ try {
121
+ // Phase 1: register state with the server.
122
+ let init;
123
+ try {
124
+ init = await initLoginState(baseUrl);
73
125
  }
74
- if (tokenData.error === 'expired_token') {
75
- console.error('\nDevice code expired. Please try again.');
126
+ catch (err) {
127
+ status = 'error';
128
+ errorMessage = err instanceof Error ? err.message : String(err);
129
+ rerender();
130
+ await instance.waitUntilExit();
76
131
  process.exit(1);
77
132
  }
78
- if (tokenData.error &&
79
- tokenData.error !== 'authorization_pending' &&
80
- tokenData.error !== 'slow_down') {
81
- console.error(`\nAuthentication error: ${tokenData.error_description || tokenData.error}`);
133
+ authUrl = init.authUrl;
134
+ stateTail = init.tail;
135
+ status = 'browser-opening';
136
+ rerender();
137
+ // Phase 2: best-effort browser open. We don't await long here — the
138
+ // printed URL in the polling state acts as the fallback if the OS hook
139
+ // is unreliable (CI, restricted shells, etc.).
140
+ try {
141
+ await open(init.authUrl);
142
+ }
143
+ catch {
144
+ // Swallowed — URL is still visible on screen.
145
+ }
146
+ // Phase 3: poll until the server hands us a token.
147
+ status = 'polling';
148
+ rerender();
149
+ const granted = await pollUntilGranted(baseUrl, init.state);
150
+ if (!granted) {
151
+ status = 'expired';
152
+ rerender();
153
+ await instance.waitUntilExit();
82
154
  process.exit(1);
83
155
  }
156
+ saveAuth({
157
+ token: granted.token,
158
+ username: granted.username,
159
+ displayName: granted.displayName,
160
+ syncLibrary,
161
+ });
162
+ resolvedUsername = granted.username;
163
+ status = 'success';
164
+ rerender();
165
+ await instance.waitUntilExit();
166
+ }
167
+ finally {
168
+ process.off('SIGINT', onSigInt);
169
+ }
170
+ }
171
+ /** Plain-text fallback for non-TTY environments (CI, piped output, screen
172
+ * readers) and `--no-ink`. Uses a clack spinner for a tasteful single-line
173
+ * polling indicator; the spinner degrades gracefully when stdout isn't a TTY. */
174
+ async function loginPlainInteractive(syncLibrary) {
175
+ const baseUrl = getApiUrl();
176
+ let init;
177
+ try {
178
+ init = await initLoginState(baseUrl);
179
+ }
180
+ catch (err) {
181
+ console.error(err instanceof Error ? err.message : String(err));
182
+ process.exit(1);
183
+ }
184
+ console.log('\nOpening your browser to authorize this terminal session.');
185
+ console.log(`If it doesn't open, visit:\n\n ${init.authUrl}\n`);
186
+ console.log(`Confirm the suffix matches: …${init.tail}\n`);
187
+ try {
188
+ await open(init.authUrl);
189
+ }
190
+ catch {
191
+ // Suppressed — the printed URL is the fallback.
192
+ }
193
+ const sp = p.spinner();
194
+ sp.start('Waiting for authorization (up to 10 minutes)…');
195
+ const granted = await pollUntilGranted(baseUrl, init.state);
196
+ if (!granted) {
197
+ sp.stop('Login expired.');
198
+ // Tests assert on stderr — keep the human-readable failure there so
199
+ // automation can pipe stderr separately without losing the signal.
200
+ console.error('\nLogin expired — re-run `botdocs login`.');
201
+ process.exit(1);
202
+ }
203
+ sp.stop(`Signed in as @${granted.username}`);
204
+ saveAuth({
205
+ token: granted.token,
206
+ username: granted.username,
207
+ displayName: granted.displayName,
208
+ syncLibrary,
209
+ });
210
+ printSyncLibraryHint(syncLibrary);
211
+ }
212
+ /** Polls the server with exponential backoff. Returns the granted payload on
213
+ * success or null if the loop ran out of budget (matching the server's TTL). */
214
+ async function pollUntilGranted(baseUrl, state) {
215
+ const deadline = Date.now() + POLL_TIMEOUT_MS;
216
+ let delay = POLL_INITIAL_DELAY_MS;
217
+ while (Date.now() < deadline) {
218
+ await sleep(delay);
219
+ delay = Math.min(delay * POLL_BACKOFF_FACTOR, POLL_MAX_DELAY_MS);
220
+ const res = await fetch(`${baseUrl}/api/cli/auth/poll?state=${encodeURIComponent(state)}`, { headers: { Accept: 'application/json' } });
221
+ if (res.status === 410) {
222
+ return null;
223
+ }
224
+ if (!res.ok) {
225
+ // Transient server errors — keep polling instead of exiting.
226
+ continue;
227
+ }
228
+ const body = (await res.json());
229
+ if ('pending' in body && body.pending) {
230
+ continue;
231
+ }
232
+ if ('token' in body) {
233
+ return body;
234
+ }
84
235
  }
85
- console.error('\nAuthentication timed out. Please try again.');
86
- process.exit(1);
236
+ return null;
237
+ }
238
+ function printSignedIn(username, syncLibrary) {
239
+ console.log(`\n✓ Signed in as @${username}`);
240
+ printSyncLibraryHint(syncLibrary);
241
+ }
242
+ function printSyncLibraryHint(syncLibrary) {
243
+ if (syncLibrary) {
244
+ console.log(' Library sync enabled. Your installed-refs list will appear at https://botdocs.ai/library after install/sync/uninstall.');
245
+ }
246
+ else {
247
+ console.log(' Library sync is OFF. Re-run `botdocs login --sync-library` to enable the personalized Library page.');
248
+ }
249
+ }
250
+ function sleep(ms) {
251
+ return new Promise((resolve) => setTimeout(resolve, ms));
87
252
  }
@@ -1,7 +1,23 @@
1
+ import type { SyncAction } from './views/sync-state.js';
1
2
  interface SyncOptions {
2
3
  yes?: boolean;
3
4
  dryRun?: boolean;
4
5
  json?: boolean;
6
+ /** When true, skip backups before overwriting. Default behavior backs up
7
+ * any locally-edited or untracked file at a sync destination before the
8
+ * overwrite. --dry-run prints "would back up" lines without writing. */
9
+ noBackup?: boolean;
10
+ /** Force the plain-text rendering path even on a real TTY. Mirrors the
11
+ * same flag on `login` — useful for screen readers and anyone who finds
12
+ * the live redraw distracting. */
13
+ noInk?: boolean;
5
14
  }
15
+ /** The three choices a user can make on a per-skill conflict. `keep` and
16
+ * `skip` are semantically identical today (both leave the local file alone)
17
+ * but kept separate so we can distinguish "I explicitly want my version" from
18
+ * "let me decide later" in future UX. */
19
+ export type ConflictChoice = 'keep' | 'overwrite' | 'skip';
20
+ export type AwaitConflictChoice = (ref: string, file: string) => Promise<ConflictChoice>;
21
+ export type SyncDispatch = (action: SyncAction) => void;
6
22
  export declare function sync(rawRef: string | undefined, options: SyncOptions): Promise<void>;
7
23
  export {};