@botdocs/cli 0.14.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import React from 'react';
2
+ import os from 'node:os';
2
3
  import { randomBytes } from 'node:crypto';
3
4
  import open from 'open';
4
5
  import { render } from 'ink';
@@ -36,11 +37,22 @@ async function loginWithToken(token, syncLibrary) {
36
37
  process.exit(1);
37
38
  }
38
39
  const baseUrl = getApiUrl();
40
+ // Stamp the device identity on the validating call so the token's device
41
+ // registers (and the free-tier device/session caps bind) the moment it's
42
+ // saved — not just on the next command. Best-effort: a failure to resolve
43
+ // the id never blocks login.
44
+ const { deviceId, machineName } = deviceIdentity();
45
+ const headers = {
46
+ Authorization: `Bearer ${token}`,
47
+ Accept: 'application/json',
48
+ };
49
+ if (deviceId)
50
+ headers['X-Device-Id'] = deviceId;
51
+ if (machineName)
52
+ headers['X-Machine-Name'] = machineName;
39
53
  let res;
40
54
  try {
41
- res = await fetch(`${baseUrl}/api/cli/whoami`, {
42
- headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
43
- });
55
+ res = await fetch(`${baseUrl}/api/cli/whoami`, { headers });
44
56
  }
45
57
  catch (err) {
46
58
  console.error(`Failed to contact ${baseUrl}: ${err instanceof Error ? err.message : String(err)}`);
@@ -70,21 +82,51 @@ async function loginWithToken(token, syncLibrary) {
70
82
  process.exit(1);
71
83
  }
72
84
  const user = (await res.json());
85
+ // Persist the SAME device id we sent on the whoami header so the device that
86
+ // just registered server-side matches every subsequent request.
73
87
  saveAuth({
74
88
  token,
75
89
  username: user.username,
76
90
  displayName: user.displayName,
77
91
  syncLibrary,
92
+ ...persistedDeviceField(deviceId),
78
93
  });
79
- // Generate + persist the stable device identity now, so the very next
80
- // authenticated request carries an X-Device-Id (no-op when BOTDOCS_DEVICE_ID
81
- // is set — that override always wins and isn't persisted).
82
- getOrCreateDeviceId();
83
94
  printSignedIn(user.username, syncLibrary);
84
95
  }
96
+ /** Best-effort stable device identity for this CLI install. Mirrors
97
+ * `attachDeviceHeaders` in lib/api.ts, but the login flows can't reuse it: they
98
+ * use raw `fetch` (there's no bearer to attach yet) and they ALSO need the id
99
+ * for the browser auth URL. Returns nulls if it can't be resolved (e.g. a
100
+ * read-only home dir) so login never hard-fails on device identity.
101
+ *
102
+ * Resolve this ONCE per login and thread the same id into both the request
103
+ * (header/URL) and `persistedDeviceField` — the server registers the device
104
+ * under the forwarded id, so the persisted id must match or the next command
105
+ * would present as a different device. */
106
+ function deviceIdentity() {
107
+ try {
108
+ return { deviceId: getOrCreateDeviceId(), machineName: os.hostname() };
109
+ }
110
+ catch {
111
+ return { deviceId: null, machineName: null };
112
+ }
113
+ }
114
+ /** The `auth.json` fragment carrying the device id to persist. `getOrCreateDeviceId`
115
+ * only persists when an auth file already exists, but at login time we're
116
+ * writing that file for the first time — so we fold the id straight into the
117
+ * `saveAuth` payload. A `BOTDOCS_DEVICE_ID` override always wins at read time
118
+ * and is intentionally NOT persisted (so auth.json stays portable across CI
119
+ * runners), so we omit it in that case. */
120
+ function persistedDeviceField(deviceId) {
121
+ if (process.env.BOTDOCS_DEVICE_ID?.trim())
122
+ return {};
123
+ return deviceId ? { deviceId } : {};
124
+ }
85
125
  /** Helper: register a new state with the server and return the public auth
86
- * URL plus the last-6-chars suffix the user can verify in their browser. */
87
- async function initLoginState(baseUrl) {
126
+ * URL plus the last-6-chars suffix the user can verify in their browser. The
127
+ * caller passes the resolved device identity so the SAME id is forwarded in
128
+ * the URL and persisted to auth.json. */
129
+ async function initLoginState(baseUrl, deviceId, machineName) {
88
130
  const state = randomBytes(32).toString('hex');
89
131
  const initRes = await fetch(`${baseUrl}/api/cli/auth/init`, {
90
132
  method: 'POST',
@@ -118,9 +160,19 @@ async function initLoginState(baseUrl) {
118
160
  // state carried in the URL hash fragment (#state=...), which browsers
119
161
  // never send to the server and never write to history. That refactor is
120
162
  // out of scope for the P3 bucket — tracked as a separate item.
163
+ //
164
+ // Forward this install's device identity in the URL. The browser that lands
165
+ // /cli-auth has no X-Device-Id header of its own, so the page relays these
166
+ // into the grant POST body — that's what registers the device + binds the
167
+ // free-tier caps at login time (rather than only on the next CLI command).
168
+ const params = new URLSearchParams({ state });
169
+ if (deviceId)
170
+ params.set('device', deviceId);
171
+ if (machineName)
172
+ params.set('machine', machineName);
121
173
  return {
122
174
  state,
123
- authUrl: `${baseUrl}/cli-auth?state=${state}`,
175
+ authUrl: `${baseUrl}/cli-auth?${params.toString()}`,
124
176
  tail: state.slice(-6),
125
177
  };
126
178
  }
@@ -131,6 +183,9 @@ async function initLoginState(baseUrl) {
131
183
  * the component's effect call `useApp().exit()` to unmount. */
132
184
  async function loginInkInteractive(syncLibrary) {
133
185
  const baseUrl = getApiUrl();
186
+ // Resolve the device identity ONCE — forwarded to the browser via the auth
187
+ // URL and persisted (below) under the same id.
188
+ const { deviceId, machineName } = deviceIdentity();
134
189
  let status = 'initializing';
135
190
  let authUrl;
136
191
  let stateTail;
@@ -164,7 +219,7 @@ async function loginInkInteractive(syncLibrary) {
164
219
  // Phase 1: register state with the server.
165
220
  let init;
166
221
  try {
167
- init = await initLoginState(baseUrl);
222
+ init = await initLoginState(baseUrl, deviceId, machineName);
168
223
  }
169
224
  catch (err) {
170
225
  status = 'error';
@@ -201,8 +256,8 @@ async function loginInkInteractive(syncLibrary) {
201
256
  username: granted.username,
202
257
  displayName: granted.displayName,
203
258
  syncLibrary,
259
+ ...persistedDeviceField(deviceId),
204
260
  });
205
- getOrCreateDeviceId();
206
261
  resolvedUsername = granted.username;
207
262
  status = 'success';
208
263
  rerender();
@@ -217,9 +272,12 @@ async function loginInkInteractive(syncLibrary) {
217
272
  * polling indicator; the spinner degrades gracefully when stdout isn't a TTY. */
218
273
  async function loginPlainInteractive(syncLibrary) {
219
274
  const baseUrl = getApiUrl();
275
+ // Resolve the device identity ONCE — forwarded to the browser via the auth
276
+ // URL and persisted (below) under the same id.
277
+ const { deviceId, machineName } = deviceIdentity();
220
278
  let init;
221
279
  try {
222
- init = await initLoginState(baseUrl);
280
+ init = await initLoginState(baseUrl, deviceId, machineName);
223
281
  }
224
282
  catch (err) {
225
283
  console.error(err instanceof Error ? err.message : String(err));
@@ -250,8 +308,8 @@ async function loginPlainInteractive(syncLibrary) {
250
308
  username: granted.username,
251
309
  displayName: granted.displayName,
252
310
  syncLibrary,
311
+ ...persistedDeviceField(deviceId),
253
312
  });
254
- getOrCreateDeviceId();
255
313
  printSyncLibraryHint(syncLibrary);
256
314
  }
257
315
  /** Polls the server with exponential backoff. Returns the granted payload on
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botdocs/cli",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "description": "CLI for BotDocs — author, publish, install, and sync agent skills across Claude, Claude Code, Cursor, Codex, ChatGPT, Windsurf, Copilot, Gemini, Antigravity, and OpenCode.",
5
5
  "keywords": [
6
6
  "botdocs",