@vibe-cafe/vibe-usage 0.10.2 → 0.10.3

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 (3) hide show
  1. package/package.json +1 -1
  2. package/src/api.js +54 -10
  3. package/src/sync.js +29 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/api.js CHANGED
@@ -10,6 +10,17 @@ const INITIAL_DELAY = 1000;
10
10
  // guaranteeing no uncompressed request ever leaves the client.
11
11
  const GZIP_MIN_BYTES = 0;
12
12
 
13
+ export function retryDelayMs(attempt, random = Math.random) {
14
+ const ceiling = INITIAL_DELAY * 2 ** attempt;
15
+ // Equal jitter keeps a real backoff floor while preventing every desktop
16
+ // client from retrying a shared outage on the same 1s / 2s boundaries.
17
+ return Math.round(ceiling / 2 + random() * ceiling / 2);
18
+ }
19
+
20
+ function sleep(ms) {
21
+ return new Promise(resolve => setTimeout(resolve, ms));
22
+ }
23
+
13
24
  export async function ingest(apiUrl, apiKey, buckets, opts, sessions) {
14
25
  let lastError;
15
26
  for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
@@ -22,8 +33,7 @@ export async function ingest(apiUrl, apiKey, buckets, opts, sessions) {
22
33
  throw err;
23
34
  }
24
35
  if (attempt < MAX_RETRIES - 1) {
25
- const delay = INITIAL_DELAY * 2 ** attempt;
26
- await new Promise(r => setTimeout(r, delay));
36
+ await sleep(retryDelayMs(attempt));
27
37
  }
28
38
  }
29
39
  }
@@ -222,13 +232,36 @@ function _jsonRequest(apiUrl, path, method, body, timeoutMs) {
222
232
 
223
233
  /**
224
234
  * GET user settings from the vibecafe API.
225
- * Returns null on any failure (network, auth, timeout) caller should fail-safe.
235
+ * Returns null after transient failures are exhausted. A 401 remains distinct
236
+ * so callers can surface invalid credentials instead of calling it an outage.
226
237
  * @param {string} apiUrl
227
238
  * @param {string} apiKey
228
239
  * @returns {Promise<{uploadProject: boolean} | null>}
229
240
  */
230
- export function fetchSettings(apiUrl, apiKey) {
231
- return new Promise((resolve) => {
241
+ export async function fetchSettings(apiUrl, apiKey, retry = {}) {
242
+ const wait = retry.sleep ?? sleep;
243
+ const random = retry.random ?? Math.random;
244
+
245
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
246
+ try {
247
+ return await fetchSettingsOnce(apiUrl, apiKey);
248
+ } catch (err) {
249
+ if (err.message === 'UNAUTHORIZED') throw err;
250
+ // Retrying a permanent client response cannot make it valid. 429 is the
251
+ // exception: it is transient load shedding and benefits from backoff.
252
+ if (err.statusCode >= 400 && err.statusCode < 500 && err.statusCode !== 429) {
253
+ return null;
254
+ }
255
+ if (attempt < MAX_RETRIES - 1) {
256
+ await wait(retryDelayMs(attempt, random));
257
+ }
258
+ }
259
+ }
260
+ return null;
261
+ }
262
+
263
+ function fetchSettingsOnce(apiUrl, apiKey) {
264
+ return new Promise((resolve, reject) => {
232
265
  const url = new URL('/api/usage/settings', apiUrl);
233
266
  const mod = url.protocol === 'https:' ? https : http;
234
267
 
@@ -242,20 +275,31 @@ export function fetchSettings(apiUrl, apiKey) {
242
275
  let data = '';
243
276
  res.on('data', (chunk) => { data += chunk; });
244
277
  res.on('end', () => {
278
+ if (res.statusCode === 401) {
279
+ reject(new Error('UNAUTHORIZED'));
280
+ return;
281
+ }
245
282
  if (res.statusCode < 200 || res.statusCode >= 300) {
246
- resolve(null);
283
+ const err = new Error(`HTTP ${res.statusCode}: ${data}`);
284
+ err.statusCode = res.statusCode;
285
+ reject(err);
247
286
  return;
248
287
  }
249
288
  try {
250
- resolve(JSON.parse(data));
289
+ const settings = JSON.parse(data);
290
+ if (typeof settings?.uploadProject !== 'boolean') {
291
+ reject(new Error('Invalid settings response'));
292
+ return;
293
+ }
294
+ resolve(settings);
251
295
  } catch {
252
- resolve(null);
296
+ reject(new Error('Invalid settings response'));
253
297
  }
254
298
  });
255
299
  });
256
300
 
257
- req.on('error', () => resolve(null));
258
- req.on('timeout', () => { req.destroy(); resolve(null); });
301
+ req.on('error', reject);
302
+ req.on('timeout', () => { req.destroy(new Error('Settings request timed out')); });
259
303
  req.end();
260
304
  });
261
305
  }
package/src/sync.js CHANGED
@@ -18,6 +18,15 @@ function formatBytes(bytes) {
18
18
  return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
19
19
  }
20
20
 
21
+ export function resolveUploadProjectSetting(settings) {
22
+ if (typeof settings?.uploadProject !== 'boolean') {
23
+ const error = new Error('SETTINGS_UNAVAILABLE');
24
+ error.code = 'SETTINGS_UNAVAILABLE';
25
+ throw error;
26
+ }
27
+ return settings.uploadProject;
28
+ }
29
+
21
30
  export async function runSync({ throws = false, quiet = false, surface = 'cli' } = {}) {
22
31
  const config = loadConfig();
23
32
  if (!config?.apiKey) {
@@ -32,6 +41,26 @@ export async function runSync({ throws = false, quiet = false, surface = 'cli' }
32
41
  saveConfig(config);
33
42
  }
34
43
 
44
+ // Privacy is a required input, not an optional hint. If the settings API is
45
+ // unavailable, treating it as `false` changes every project-bearing item's
46
+ // incremental identity to `unknown` and can trigger a full-history upload.
47
+ // Resolve it before parsing or loading upload state so failure is a true
48
+ // no-op: no data upload and no state mutation.
49
+ const apiUrl = config.apiUrl || 'https://vibecafe.ai';
50
+ let uploadProject;
51
+ try {
52
+ const settings = await fetchSettings(apiUrl, config.apiKey);
53
+ uploadProject = resolveUploadProjectSetting(settings);
54
+ } catch (err) {
55
+ if (err.message === 'UNAUTHORIZED') {
56
+ console.error(failure('API Key 无效,请运行 `npx @vibe-cafe/vibe-usage init` 重新配置。'));
57
+ } else {
58
+ console.error(failure('暂时无法读取上传设置,本次同步已安全取消(未上传数据)。请稍后重试。'));
59
+ }
60
+ if (throws) throw err;
61
+ process.exit(1);
62
+ }
63
+
35
64
  const allBuckets = [];
36
65
  const allSessions = [];
37
66
  const parserResults = [];
@@ -116,11 +145,6 @@ export async function runSync({ throws = false, quiet = false, surface = 'cli' }
116
145
  for (const b of allBuckets) if (!b.hostname) b.hostname = host;
117
146
  for (const s of allSessions) if (!s.hostname) s.hostname = host;
118
147
 
119
- // Privacy: check if user allows project name upload
120
- const apiUrl = config.apiUrl || 'https://vibecafe.ai';
121
- const settings = await fetchSettings(apiUrl, config.apiKey);
122
- const uploadProject = settings?.uploadProject === true;
123
-
124
148
  if (!quiet) {
125
149
  if (uploadProject) {
126
150
  console.log(dim(' 项目名: 上传(可在 Web 设置中关闭)'));