@vibe-cafe/vibe-usage 0.11.0 → 0.11.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.
package/README.md CHANGED
@@ -249,6 +249,10 @@ For OpenCode, each root uses its SQLite database when present; only roots withou
249
249
 
250
250
  The first `npx @vibe-cafe/vibe-usage` run installs a user-level service (systemd on Linux, launchd on macOS, Task Scheduler on Windows — no admin rights needed) that syncs every 30 minutes and starts automatically on login. Nothing else to do.
251
251
 
252
+ Switching the CLI to a different account (running `init` again, or `config set apiKey`) rebinds the upload state, so the next sync re-uploads your full local history to the new account instead of treating it as already sent.
253
+
254
+ 换绑账号后(重新 `init` 或 `config set apiKey`),下一次同步会自动全量重传本地历史,不会因为旧账号的同步记录而漏传。
255
+
252
256
  <details>
253
257
  <summary>Managing the service, and how it is launched</summary>
254
258
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/state.js CHANGED
@@ -8,6 +8,8 @@ import { createHash, randomBytes } from 'node:crypto';
8
8
  // already uploaded successfully. Lets each sync skip re-sending unchanged
9
9
  // history: parsers stay stateless (still parse everything from disk every run),
10
10
  // but only new/changed items hit the network.
11
+ // The file also records the upload target it belongs to (`identity`), so state
12
+ // left by a previous account can never suppress that account's history.
11
13
  // VIBE_USAGE_STATE_DIR overrides the dir (test hook).
12
14
  const STATE_DIR = process.env.VIBE_USAGE_STATE_DIR?.trim() || join(homedir(), '.vibe-usage');
13
15
  const isDev = process.env.VIBE_USAGE_DEV === '1';
@@ -17,14 +19,57 @@ export function getStatePath() {
17
19
  return STATE_FILE;
18
20
  }
19
21
 
20
- export function loadState() {
22
+ // The upload target this state belongs to: which server, and which account on
23
+ // it. state.json only records what was already uploaded *to that target*, so
24
+ // after a re-bind (`init` again, `config set apiKey`, or a desktop app
25
+ // rewriting config.json) the old hashes must not make sync skip history the new
26
+ // account has never received.
27
+ //
28
+ // The key is stored only as a fingerprint. The raw apiKey must never appear in
29
+ // state.json — it is an ordinary-permission file next to the parser state, not
30
+ // a credential store; config.json (mode 0600) remains the only place it lives.
31
+ export function stateIdentity({ apiUrl, apiKey } = {}) {
32
+ return {
33
+ apiUrl: apiUrl || '',
34
+ keyFingerprint: apiKey
35
+ ? createHash('sha256').update(String(apiKey)).digest('hex').slice(0, 16)
36
+ : '',
37
+ };
38
+ }
39
+
40
+ function isIdentity(value) {
41
+ return !!value
42
+ && typeof value === 'object'
43
+ && typeof value.apiUrl === 'string'
44
+ && typeof value.keyFingerprint === 'string';
45
+ }
46
+
47
+ function sameIdentity(a, b) {
48
+ return a.apiUrl === b.apiUrl && a.keyFingerprint === b.keyFingerprint;
49
+ }
50
+
51
+ // `identity` is optional: callers that only read the recorded counts (e.g.
52
+ // `status`) pass nothing and keep the pre-0.11.1 behaviour of taking the file
53
+ // at face value.
54
+ export function loadState(identity) {
21
55
  if (!existsSync(STATE_FILE)) return { buckets: {}, sessions: {} };
22
56
  try {
23
57
  const parsed = JSON.parse(readFileSync(STATE_FILE, 'utf-8'));
24
- return {
25
- buckets: parsed.buckets ?? {},
26
- sessions: parsed.sessions ?? {},
27
- };
58
+ const buckets = parsed.buckets ?? {};
59
+ const sessions = parsed.sessions ?? {};
60
+ if (!isIdentity(identity) || !isIdentity(parsed.identity)) {
61
+ // No identity recorded — written by a CLI older than 0.11.1. Adopt the
62
+ // entries as-is rather than forcing a re-upload: on upgrade day that
63
+ // would make every installed client re-send its whole history at once.
64
+ // The next saveState() stamps the current identity, so any later re-bind
65
+ // is caught.
66
+ return { buckets, sessions };
67
+ }
68
+ if (sameIdentity(parsed.identity, identity)) return { buckets, sessions };
69
+ // Bound to a different account or server: nothing recorded here was ever
70
+ // uploaded to the current target, so start empty and re-send local history.
71
+ // `identityChanged` is a runtime signal for the caller, never persisted.
72
+ return { buckets: {}, sessions: {}, identityChanged: true };
28
73
  } catch {
29
74
  // Corrupt/unreadable state must not lose data — treat as empty, which
30
75
  // triggers a one-time full re-upload (same as a fresh install).
@@ -32,14 +77,28 @@ export function loadState() {
32
77
  }
33
78
  }
34
79
 
35
- export function saveState(state) {
80
+ export function saveState(state, identity) {
36
81
  mkdirSync(STATE_DIR, { recursive: true });
82
+ // Only the durable fields are written: `identityChanged` is loadState()'s
83
+ // one-run signal, not state. A CLI older than 0.11.1 reads just
84
+ // buckets/sessions, so the extra top-level `identity` key is ignored there —
85
+ // a file written by this version stays readable by older clients.
86
+ const payload = {
87
+ buckets: state.buckets ?? {},
88
+ sessions: state.sessions ?? {},
89
+ };
90
+ if (isIdentity(identity)) {
91
+ payload.identity = {
92
+ apiUrl: identity.apiUrl,
93
+ keyFingerprint: identity.keyFingerprint,
94
+ };
95
+ }
37
96
  // Atomic replace: write to a unique temp file then rename over the target.
38
97
  // A crash mid-write can no longer truncate state.json into an unreadable
39
98
  // file that loadState() would treat as empty (triggering a full re-upload).
40
99
  const tempPath = `${STATE_FILE}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
41
100
  try {
42
- writeFileSync(tempPath, JSON.stringify(state) + '\n', 'utf-8');
101
+ writeFileSync(tempPath, JSON.stringify(payload) + '\n', 'utf-8');
43
102
  renameSync(tempPath, STATE_FILE);
44
103
  } finally {
45
104
  // No-op after a successful rename (the temp file is already gone); cleans
package/src/sync.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { hostname as osHostname } from 'node:os';
2
2
  import { loadConfig, saveConfig } from './config.js';
3
3
  import {
4
- loadState, saveState, pruneState,
4
+ loadState, saveState, pruneState, stateIdentity,
5
5
  bucketKey, bucketHash, sessionKey, sessionHash,
6
6
  } from './state.js';
7
7
  import { ingest, fetchSettings } from './api.js';
@@ -119,6 +119,13 @@ export async function runSync({
119
119
  // Resolve it before parsing or loading upload state so failure is a true
120
120
  // no-op: no data upload and no state mutation.
121
121
  const apiUrl = config.apiUrl || 'https://vibecafe.ai';
122
+ // state.json records what was already uploaded to *this* account on *this*
123
+ // server. Passing the identity into loadState() makes state left by a
124
+ // previous account fall away, so a re-bind re-uploads the local history
125
+ // instead of diffing it against uploads the new account never received.
126
+ // Built from the same `apiUrl` the ingest calls below use, so the recorded
127
+ // target and the actual target can never drift apart.
128
+ const identity = stateIdentity({ apiUrl, apiKey: config.apiKey });
122
129
  let uploadProject;
123
130
  try {
124
131
  const settings = await fetchSettings(apiUrl, config.apiKey);
@@ -223,11 +230,14 @@ export async function runSync({
223
230
  // Successful parsers emitted no live items. Prune their old keys even on
224
231
  // this fast path; otherwise deleting the final local log would leave dead
225
232
  // state entries forever. Failed-parser sources remain protected.
226
- const state = loadState();
233
+ const state = loadState(identity);
234
+ if (state.identityChanged && !quiet) {
235
+ console.log(dim('检测到上传账号已更换,本次全量重传本地历史'));
236
+ }
227
237
  const before = Object.keys(state.buckets).length + Object.keys(state.sessions).length;
228
238
  pruneState(state, new Set(), new Set(), okSources);
229
239
  const pruned = before - (Object.keys(state.buckets).length + Object.keys(state.sessions).length);
230
- if (pruned > 0) saveState(state);
240
+ if (pruned > 0) saveState(state, identity);
231
241
  if (!quiet && parserProgress.length > 0) {
232
242
  for (const p of parserProgress) {
233
243
  console.log(dim(` ${p.source}: 正在建立本地索引 ${p.completed}/${p.total}(下次同步继续)`));
@@ -283,7 +293,10 @@ export async function runSync({
283
293
  // an active one sends just the current 30-min bucket.
284
294
  // Missing/corrupt state.json => empty maps => one-time full upload, then
285
295
  // incremental forever after.
286
- const state = loadState();
296
+ const state = loadState(identity);
297
+ if (state.identityChanged && !quiet) {
298
+ console.log(dim('检测到上传账号已更换,本次全量重传本地历史'));
299
+ }
287
300
  const changedBuckets = [];
288
301
  const changedSessions = [];
289
302
  const liveBucketKeys = new Set();
@@ -321,7 +334,7 @@ export async function runSync({
321
334
  const before = Object.keys(state.buckets).length + Object.keys(state.sessions).length;
322
335
  pruneState(state, liveBucketKeys, liveSessionKeys, okSources);
323
336
  const pruned = before - (Object.keys(state.buckets).length + Object.keys(state.sessions).length);
324
- if (pruned > 0) saveState(state);
337
+ if (pruned > 0) saveState(state, identity);
325
338
 
326
339
  if (changedBuckets.length === 0 && changedSessions.length === 0) {
327
340
  if (!quiet) console.log(dim('无新增数据。'));
@@ -422,7 +435,7 @@ export async function runSync({
422
435
  batchStateChanged = true;
423
436
  }
424
437
  }
425
- if (batchStateChanged) saveState(state);
438
+ if (batchStateChanged) saveState(state, identity);
426
439
  }
427
440
 
428
441
  if (totalBatches > 1 || allBucketsToSend.length > 0) {