@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 +4 -0
- package/package.json +1 -1
- package/src/state.js +66 -7
- package/src/sync.js +19 -6
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
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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(
|
|
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) {
|