@vibe-cafe/vibe-usage 0.10.14 → 0.10.15

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
@@ -8,10 +8,10 @@ Track your AI coding tool token usage and sync to [vibecafe.ai](https://vibecafe
8
8
  npx @vibe-cafe/vibe-usage
9
9
  ```
10
10
 
11
- That's it. The CLI opens [vibecafe.ai/usage/device](https://vibecafe.ai/usage/device) in your browser; sign in, confirm the verification code shown in your terminal, click 「确认链接」, and the CLI receives an API key automatically.
11
+ That's it. On first setup, the CLI asks whether project names and the device name may leave the machine (both default to **No**), then opens [vibecafe.ai/usage/device](https://vibecafe.ai/usage/device) in your browser. Sign in, confirm the verification code shown in your terminal, click 「确认链接」, and the CLI receives an API key automatically.
12
12
 
13
13
  After approval, it will:
14
- 1. Save your API key to `~/.vibe-usage/config.json`
14
+ 1. Save your API key and local privacy choices to `~/.vibe-usage/config.json`
15
15
  2. Detect installed AI coding tools
16
16
  3. Run an initial sync of your usage data
17
17
  4. Prompt you to enable the background daemon for continuous syncing (recommended)
@@ -32,6 +32,8 @@ npx @vibe-cafe/vibe-usage init # Re-run setup via browser login
32
32
  npx @vibe-cafe/vibe-usage init --manual-key <vbu_...> # Skip browser, use pre-issued key (CI/headless)
33
33
  npx @vibe-cafe/vibe-usage sync # Manual sync
34
34
  npx @vibe-cafe/vibe-usage sync --extra-codex-home /path/to/.codex # Add another Codex Home for this run only
35
+ npx @vibe-cafe/vibe-usage config set uploadProject false # Never upload project names
36
+ npx @vibe-cafe/vibe-usage config set uploadHostname false # Use an opaque per-install device id
35
37
  npx @vibe-cafe/vibe-usage summary # Print last 7 days as markdown (cost / tokens / by model / by project)
36
38
  npx @vibe-cafe/vibe-usage summary --days N # Same, over the last N days (1-90)
37
39
  npx @vibe-cafe/vibe-usage daemon # Continuous sync (every 30m, foreground)
@@ -155,10 +157,40 @@ Config stored at `~/.vibe-usage/config.json` (dev: `config.dev.json`).
155
157
  |-----|-------------|
156
158
  | `apiKey` | Your API key (starts with `vbu_`) |
157
159
  | `apiUrl` | Server URL (default: `https://vibecafe.ai`) |
158
- | `hostname` | Stable device name for usage tracking (set at init, reused across syncs) |
160
+ | `hostname` | Stable device name or user-chosen alias; stays local when `uploadHostname=false` |
161
+ | `uploadProject` | Local project-name control. `false` always wins over the Web setting |
162
+ | `uploadHostname` | Local device-name control. `false` replaces the name at the final network boundary |
163
+ | `deviceId` | Generated opaque per-install identity used when `uploadHostname=false` |
159
164
  | `codexExtraHome` | Optional additional Codex Home scanned together with `$CODEX_HOME` / `~/.codex` |
160
165
 
161
- The `hostname` is captured once during `init` and reused for all future syncs. This prevents macOS mDNS hostname changes (e.g., `MacBook-Pro` `MacBook-Pro-2`) from creating duplicate device entries. To change it manually:
166
+ New setups default both local upload controls to `false`. Existing configs without
167
+ these keys retain their previous behavior until you choose a value: the Web
168
+ project-name setting remains authoritative, and the configured device name is
169
+ uploaded.
170
+
171
+ ```bash
172
+ # Local false cannot be overridden by a later Web setting.
173
+ npx @vibe-cafe/vibe-usage config set uploadProject false
174
+
175
+ # Replaces the device name in buckets, sessions, and sync metadata with a
176
+ # persistent random id such as device-0011223344556677.
177
+ npx @vibe-cafe/vibe-usage config set uploadHostname false
178
+ ```
179
+
180
+ The sanitization happens after every parser and before hashing or HTTP
181
+ serialization. `cursor-cloud` remains a fixed, non-identifying sentinel so
182
+ Cursor account exports still deduplicate across computers.
183
+
184
+ These controls prevent future transmissions; they do not silently delete data
185
+ already stored in the cloud. To remove previously uploaded identifiers, run
186
+ `vibe-usage reset` after enabling both controls. A full reset deletes the
187
+ account's existing usage before re-uploading the logs available on this
188
+ computer, so coordinate first if the account syncs multiple computers.
189
+
190
+ When device-name upload is enabled, `hostname` is captured once during `init`
191
+ and reused for all future syncs. This prevents macOS mDNS hostname changes
192
+ (for example, `MacBook-Pro` → `MacBook-Pro-2`) from creating duplicate device
193
+ entries. It can also be set to a non-identifying alias:
162
194
 
163
195
  ```bash
164
196
  npx @vibe-cafe/vibe-usage config set hostname my-device-name
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.10.14",
3
+ "version": "0.10.15",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/index.js CHANGED
@@ -24,6 +24,8 @@ async function showStatus() {
24
24
  if (config.codexExtraHome) {
25
25
  console.log(` Extra Codex Home: ${config.codexExtraHome}`);
26
26
  }
27
+ console.log(` Project names: ${config.uploadProject === false ? 'hidden locally' : 'server setting'}`);
28
+ console.log(` Device name: ${config.uploadHostname === false ? 'anonymous device id' : 'uploaded'}`);
27
29
  }
28
30
 
29
31
  console.log('\n Detected tools:');
@@ -48,7 +50,15 @@ async function showStatus() {
48
50
  console.log();
49
51
  }
50
52
 
51
- const VALID_CONFIG_KEYS = ['apiKey', 'apiUrl', 'hostname', 'codexExtraHome'];
53
+ const BOOLEAN_CONFIG_KEYS = new Set(['uploadProject', 'uploadHostname']);
54
+ const VALID_CONFIG_KEYS = [
55
+ 'apiKey',
56
+ 'apiUrl',
57
+ 'hostname',
58
+ 'uploadProject',
59
+ 'uploadHostname',
60
+ 'codexExtraHome',
61
+ ];
52
62
 
53
63
  function handleConfig(args) {
54
64
  const sub = args[0];
@@ -81,6 +91,14 @@ function handleConfig(args) {
81
91
  console.error(`Valid keys: ${VALID_CONFIG_KEYS.join(', ')}`);
82
92
  process.exit(1);
83
93
  }
94
+ if (BOOLEAN_CONFIG_KEYS.has(key)) {
95
+ const normalized = value.toLowerCase();
96
+ if (normalized !== 'true' && normalized !== 'false') {
97
+ console.error(`${key} must be true or false.`);
98
+ process.exit(1);
99
+ }
100
+ value = normalized === 'true';
101
+ }
84
102
  if (key === 'codexExtraHome' && value !== '') {
85
103
  const validation = validateExtraCodexHome(value);
86
104
  if (!validation.ok) {
@@ -233,6 +251,8 @@ export async function run(rawArgs) {
233
251
  npx @vibe-cafe/vibe-usage config get <key> Get a config value
234
252
  npx @vibe-cafe/vibe-usage config set <key> <value> Set a config value
235
253
  npx @vibe-cafe/vibe-usage config set codexExtraHome <path> Persist another Codex Home
254
+ npx @vibe-cafe/vibe-usage config set uploadProject false Never upload project names
255
+ npx @vibe-cafe/vibe-usage config set uploadHostname false Replace the device name with an anonymous id
236
256
  npx @vibe-cafe/vibe-usage help Show this help
237
257
  `);
238
258
  break;
package/src/init.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { createInterface } from 'node:readline';
2
2
  import { execFile } from 'node:child_process';
3
- import { hostname as osHostname, platform } from 'node:os';
3
+ import { platform } from 'node:os';
4
4
  import { loadConfig, saveConfig } from './config.js';
5
5
  import { ingest, requestDeviceCode, pollDeviceCode } from './api.js';
6
- import { runSync } from './sync.js';
6
+ import { resolveOptionalBoolean, resolveSyncHostname, runSync } from './sync.js';
7
7
  import { detectInstalledTools } from './tools.js';
8
8
  import { bigHeader, success, failure, warn, arrow, link, dim, divider } from './output.js';
9
9
 
@@ -30,6 +30,14 @@ function isDaemonPlatform() {
30
30
  return process.platform === 'linux' || process.platform === 'darwin';
31
31
  }
32
32
 
33
+ async function resolvePrivacyChoice(existingValue, key, question) {
34
+ const configured = resolveOptionalBoolean(existingValue, key);
35
+ if (configured !== undefined) return configured;
36
+ if (!process.stdin.isTTY) return false;
37
+ const answer = (await prompt(question)).toLowerCase();
38
+ return answer === 'y' || answer === 'yes';
39
+ }
40
+
33
41
  export async function runInit(options = {}) {
34
42
  const { apiKey: providedKey, codexExtraHome } = options;
35
43
 
@@ -51,7 +59,31 @@ export async function runInit(options = {}) {
51
59
  }
52
60
 
53
61
  const apiUrl = process.env.VIBE_USAGE_API_URL || 'https://vibecafe.ai';
54
- const host = existing?.hostname || osHostname().replace(/\.local$/, '');
62
+ let uploadProject;
63
+ let uploadHostname;
64
+ let draftConfig;
65
+ let host;
66
+ try {
67
+ uploadProject = await resolvePrivacyChoice(
68
+ existing?.uploadProject,
69
+ 'uploadProject',
70
+ '上传项目名以查看按项目统计?项目名可能包含客户或内部代号。 [y/N] ',
71
+ );
72
+ uploadHostname = await resolvePrivacyChoice(
73
+ existing?.uploadHostname,
74
+ 'uploadHostname',
75
+ '上传设备名以区分电脑?选择否将使用匿名设备 ID。 [y/N] ',
76
+ );
77
+ draftConfig = {
78
+ ...(existing || {}),
79
+ uploadProject,
80
+ uploadHostname,
81
+ };
82
+ host = resolveSyncHostname(draftConfig).hostname;
83
+ } catch (err) {
84
+ console.error(failure(err.message));
85
+ process.exit(1);
86
+ }
55
87
 
56
88
  let apiKey;
57
89
  if (providedKey) {
@@ -77,10 +109,9 @@ export async function runInit(options = {}) {
77
109
  }
78
110
 
79
111
  const config = {
112
+ ...draftConfig,
80
113
  apiKey,
81
114
  apiUrl,
82
- hostname: host,
83
- ...(existing?.codexExtraHome ? { codexExtraHome: existing.codexExtraHome } : {}),
84
115
  };
85
116
  saveConfig(config);
86
117
 
package/src/reset.js CHANGED
@@ -1,8 +1,7 @@
1
1
  import { createInterface } from 'node:readline';
2
- import { hostname as getHostname } from 'node:os';
3
- import { loadConfig } from './config.js';
2
+ import { loadConfig, saveConfig } from './config.js';
4
3
  import { deleteAllData } from './api.js';
5
- import { runSync } from './sync.js';
4
+ import { resolveSyncHostname, runSync } from './sync.js';
6
5
  import { clearState } from './state.js';
7
6
  import { success, failure, arrow, link, dim } from './output.js';
8
7
 
@@ -34,10 +33,18 @@ export async function runReset(args = [], deps = {}) {
34
33
  process.exit(1);
35
34
  }
36
35
 
37
- // Target the hostname persisted at init — the same one sync.js uploads
38
- // under. A fresh os.hostname() can have drifted since (macOS mDNS adds -2
39
- // suffixes), which would delete zero rows, or another machine's rows.
40
- const currentHost = config.hostname || getHostname().replace(/\.local$/, '');
36
+ // Target the exact privacy-safe identity sync.js uploads under. This keeps
37
+ // `reset --local` aligned with both the stable configured hostname and the
38
+ // anonymous per-install device id used when hostname upload is disabled.
39
+ let hostIdentity;
40
+ try {
41
+ hostIdentity = resolveSyncHostname(config);
42
+ if (hostIdentity.changed) saveConfig(config);
43
+ } catch (err) {
44
+ console.error(failure(err.message));
45
+ process.exit(1);
46
+ }
47
+ const currentHost = hostIdentity.hostname;
41
48
  const apiUrl = config.apiUrl || 'https://vibecafe.ai';
42
49
 
43
50
  if (hostOnly) {
package/src/sync.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { hostname as osHostname } from 'node:os';
2
+ import { randomBytes } from 'node:crypto';
2
3
  import { loadConfig, saveConfig } from './config.js';
3
4
  import {
4
5
  loadState, saveState, pruneState,
@@ -14,13 +15,83 @@ import { success, failure, warn, arrow, link, dim } from './output.js';
14
15
  const BATCH_SIZE = 100;
15
16
  const SESSION_BATCH_SIZE = 500;
16
17
 
18
+ const ANONYMOUS_DEVICE_ID_PATTERN = /^device-[0-9a-f]{16}$/;
19
+ const SHARED_HOSTNAME_SENTINELS = new Set(['cursor-cloud']);
20
+
21
+ export function resolveOptionalBoolean(value, key) {
22
+ if (value === undefined) return undefined;
23
+ if (typeof value === 'boolean') return value;
24
+ const error = new Error(`配置 ${key} 必须是 true 或 false。`);
25
+ error.code = 'INVALID_CONFIG';
26
+ throw error;
27
+ }
28
+
29
+ export function resolveSyncHostname(config, {
30
+ systemHostname = () => osHostname().replace(/\.local$/, ''),
31
+ createDeviceId = () => `device-${randomBytes(8).toString('hex')}`,
32
+ } = {}) {
33
+ const uploadHostname = resolveOptionalBoolean(config.uploadHostname, 'uploadHostname') ?? true;
34
+ const previousHostname = typeof config.hostname === 'string' && config.hostname.trim()
35
+ ? config.hostname.trim()
36
+ : undefined;
37
+
38
+ if (!uploadHostname) {
39
+ const existingDeviceId = typeof config.deviceId === 'string'
40
+ ? config.deviceId.trim().toLowerCase()
41
+ : '';
42
+ const hostname = ANONYMOUS_DEVICE_ID_PATTERN.test(existingDeviceId)
43
+ ? existingDeviceId
44
+ : createDeviceId();
45
+ const changed = config.deviceId !== hostname;
46
+ if (changed) config.deviceId = hostname;
47
+ return { hostname, previousHostname, uploadHostname, changed };
48
+ }
49
+
50
+ const hostname = previousHostname || systemHostname();
51
+ const changed = config.hostname !== hostname;
52
+ if (changed) config.hostname = hostname;
53
+ return { hostname, previousHostname, uploadHostname, changed };
54
+ }
55
+
56
+ export function applyHostnamePrivacy(records, hostname, uploadHostname) {
57
+ for (const record of records) {
58
+ if (uploadHostname) {
59
+ if (!record.hostname) record.hostname = hostname;
60
+ } else if (!SHARED_HOSTNAME_SENTINELS.has(record.hostname)) {
61
+ record.hostname = hostname;
62
+ }
63
+ }
64
+ }
65
+
66
+ // A hostname privacy toggle changes the server bucket key. Carry unchanged
67
+ // local state across that key change so enabling privacy does not re-upload
68
+ // all historical buckets beside their older server rows. Changed buckets still
69
+ // upload under the anonymous id; `reset` remains the explicit way to remove
70
+ // identifiers that were uploaded before the local control was enabled.
71
+ export function migrateHiddenHostnameState(state, buckets, previousHostname, hostname) {
72
+ if (!previousHostname || previousHostname === hostname) return false;
73
+ let changed = false;
74
+ for (const bucket of buckets) {
75
+ if (bucket.hostname !== hostname) continue;
76
+ const oldKey = bucketKey({ ...bucket, hostname: previousHostname });
77
+ const newKey = bucketKey(bucket);
78
+ const currentHash = bucketHash(bucket);
79
+ if (state.buckets[oldKey] !== currentHash) continue;
80
+ if (!(newKey in state.buckets)) state.buckets[newKey] = currentHash;
81
+ delete state.buckets[oldKey];
82
+ changed = true;
83
+ }
84
+ return changed;
85
+ }
86
+
17
87
  function formatBytes(bytes) {
18
88
  if (bytes < 1024) return `${bytes}B`;
19
89
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
20
90
  return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
21
91
  }
22
92
 
23
- export function resolveUploadProjectSetting(settings) {
93
+ export function resolveUploadProjectSetting(settings, localSetting) {
94
+ if (localSetting === false) return false;
24
95
  if (typeof settings?.uploadProject !== 'boolean') {
25
96
  const error = new Error('SETTINGS_UNAVAILABLE');
26
97
  error.code = 'SETTINGS_UNAVAILABLE';
@@ -90,6 +161,18 @@ export async function runSync({
90
161
  saveConfig(config);
91
162
  }
92
163
 
164
+ let localUploadProject;
165
+ let hostIdentity;
166
+ try {
167
+ localUploadProject = resolveOptionalBoolean(config.uploadProject, 'uploadProject');
168
+ hostIdentity = resolveSyncHostname(config);
169
+ if (hostIdentity.changed) saveConfig(config);
170
+ } catch (err) {
171
+ console.error(failure(err.message));
172
+ if (throws) throw err;
173
+ process.exit(1);
174
+ }
175
+
93
176
  // Privacy is a required input, not an optional hint. If the settings API is
94
177
  // unavailable, treating it as `false` changes every project-bearing item's
95
178
  // incremental identity to `unknown` and can trigger a full-history upload.
@@ -97,36 +180,45 @@ export async function runSync({
97
180
  // no-op: no data upload and no state mutation.
98
181
  const apiUrl = config.apiUrl || 'https://vibecafe.ai';
99
182
  let uploadProject;
100
- try {
101
- const settings = await fetchSettings(apiUrl, config.apiKey);
102
- uploadProject = resolveUploadProjectSetting(settings);
103
- // Scope the cached privacy choice to the server that returned it. Reusing
104
- // the value after `apiUrl` changes could expose project names to a
105
- // different server during its first settings outage.
106
- if (
107
- config.lastUploadProject !== uploadProject
108
- || config.lastUploadProjectApiUrl !== apiUrl
109
- ) {
110
- config.lastUploadProject = uploadProject;
111
- config.lastUploadProjectApiUrl = apiUrl;
112
- saveConfig(config);
113
- }
114
- } catch (err) {
115
- if (err.message === 'UNAUTHORIZED') {
116
- console.error(failure('API Key 无效,请运行 `npx @vibe-cafe/vibe-usage init` 重新配置。'));
117
- if (throws) throw err;
118
- process.exit(1);
119
- }
120
- // Settings endpoint unreachable (not auth): degrade to the last confirmed
121
- // choice for this same server rather than hard-aborting every upload.
122
- const cachedUploadProject = resolveCachedUploadProjectSetting(config, apiUrl);
123
- if (typeof cachedUploadProject === 'boolean') {
124
- uploadProject = cachedUploadProject;
125
- if (!quiet) console.log(warn('设置接口不可用,沿用上次的项目名设置。'));
126
- } else {
127
- console.error(failure('暂时无法读取上传设置,本次同步已安全取消(未上传数据)。请稍后重试。'));
128
- if (throws) throw err;
129
- process.exit(1);
183
+ if (localUploadProject === false) {
184
+ // A local deny is authoritative and needs no server round trip. This is
185
+ // both fail-closed and usable when the settings endpoint is unavailable.
186
+ uploadProject = false;
187
+ } else {
188
+ try {
189
+ const settings = await fetchSettings(apiUrl, config.apiKey);
190
+ uploadProject = resolveUploadProjectSetting(settings, localUploadProject);
191
+ // Scope the cached privacy choice to the server that returned it. Reusing
192
+ // the value after `apiUrl` changes could expose project names to a
193
+ // different server during its first settings outage.
194
+ if (
195
+ config.lastUploadProject !== uploadProject
196
+ || config.lastUploadProjectApiUrl !== apiUrl
197
+ ) {
198
+ config.lastUploadProject = uploadProject;
199
+ config.lastUploadProjectApiUrl = apiUrl;
200
+ saveConfig(config);
201
+ }
202
+ } catch (err) {
203
+ if (err.message === 'UNAUTHORIZED') {
204
+ console.error(failure('API Key 无效,请运行 `npx @vibe-cafe/vibe-usage init` 重新配置。'));
205
+ if (throws) throw err;
206
+ process.exit(1);
207
+ }
208
+ // Settings endpoint unreachable (not auth): degrade to the last confirmed
209
+ // choice for this same server rather than hard-aborting every upload.
210
+ const cachedUploadProject = resolveCachedUploadProjectSetting(config, apiUrl);
211
+ if (typeof cachedUploadProject === 'boolean') {
212
+ uploadProject = resolveUploadProjectSetting(
213
+ { uploadProject: cachedUploadProject },
214
+ localUploadProject,
215
+ );
216
+ if (!quiet) console.log(warn('设置接口不可用,沿用上次的项目名设置。'));
217
+ } else {
218
+ console.error(failure('暂时无法读取上传设置,本次同步已安全取消(未上传数据)。请稍后重试。'));
219
+ if (throws) throw err;
220
+ process.exit(1);
221
+ }
130
222
  }
131
223
  }
132
224
 
@@ -221,23 +313,24 @@ export async function runSync({
221
313
  }
222
314
  }
223
315
 
224
- let host = config.hostname;
225
- if (!host) {
226
- host = osHostname().replace(/\.local$/, '');
227
- config.hostname = host;
228
- saveConfig(config);
229
- }
230
- // Cloud-sourced parsers (e.g. cursor) pre-set their own hostname sentinel so
231
- // the same account data isn't stored as separate rows per machine.
232
- for (const b of allBuckets) if (!b.hostname) b.hostname = host;
233
- for (const s of allSessions) if (!s.hostname) s.hostname = host;
316
+ const host = hostIdentity.hostname;
317
+ // Cloud-backed parsers use explicit non-identifying sentinels (currently
318
+ // `cursor-cloud`) so the same account data deduplicates across computers.
319
+ // Every other hostname is assigned here, at the final network boundary.
320
+ applyHostnamePrivacy(allBuckets, host, hostIdentity.uploadHostname);
321
+ applyHostnamePrivacy(allSessions, host, hostIdentity.uploadHostname);
234
322
 
235
323
  if (!quiet) {
236
324
  if (uploadProject) {
237
- console.log(dim(' 项目名: 上传(可在 Web 设置中关闭)'));
325
+ console.log(dim(' 项目名: 上传(本机或 Web 设置均可关闭)'));
238
326
  } else {
239
327
  console.log(dim(' 项目名: 已隐藏'));
240
328
  }
329
+ console.log(dim(
330
+ hostIdentity.uploadHostname
331
+ ? ' 设备名: 上传'
332
+ : ` 设备名: 已替换为匿名 ID (${host})`,
333
+ ));
241
334
  }
242
335
  if (!uploadProject) {
243
336
  for (const b of allBuckets) b.project = 'unknown';
@@ -253,6 +346,13 @@ export async function runSync({
253
346
  // Missing/corrupt state.json => empty maps => one-time full upload, then
254
347
  // incremental forever after.
255
348
  const state = loadState();
349
+ const migratedHostnameState = !hostIdentity.uploadHostname
350
+ && migrateHiddenHostnameState(
351
+ state,
352
+ allBuckets,
353
+ hostIdentity.previousHostname,
354
+ host,
355
+ );
256
356
  const changedBuckets = [];
257
357
  const changedSessions = [];
258
358
  const liveBucketKeys = new Set();
@@ -290,7 +390,7 @@ export async function runSync({
290
390
  const before = Object.keys(state.buckets).length + Object.keys(state.sessions).length;
291
391
  pruneState(state, liveBucketKeys, liveSessionKeys, okSources);
292
392
  const pruned = before - (Object.keys(state.buckets).length + Object.keys(state.sessions).length);
293
- if (pruned > 0) saveState(state);
393
+ if (pruned > 0 || migratedHostnameState) saveState(state);
294
394
 
295
395
  if (changedBuckets.length === 0 && changedSessions.length === 0) {
296
396
  if (!quiet) console.log(dim('无新增数据。'));