vibeusage 0.2.16 → 0.2.18
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 +136 -20
- package/README.zh-CN.md +135 -19
- package/package.json +1 -1
- package/src/cli.js +3 -2
- package/src/commands/status.js +52 -1
- package/src/commands/sync.js +64 -1
- package/src/lib/codex-config.js +132 -10
- package/src/lib/diagnostics.js +3 -0
- package/src/lib/rollout.js +155 -1
- package/src/lib/subscriptions.js +317 -0
- package/src/lib/uploader.js +7 -1
- package/src/lib/vibeusage-api.js +7 -2
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
const os = require('node:os');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const cp = require('node:child_process');
|
|
5
|
+
|
|
6
|
+
const { readJson } = require('./fs');
|
|
7
|
+
|
|
8
|
+
const OPENAI_AUTH_CLAIM = 'https://api.openai.com/auth';
|
|
9
|
+
const MACOS_SECURITY_BIN = '/usr/bin/security';
|
|
10
|
+
const CLAUDE_CODE_KEYCHAIN_SERVICES = ['Claude Code-credentials'];
|
|
11
|
+
|
|
12
|
+
function normalizeString(value) {
|
|
13
|
+
if (typeof value !== 'string') return null;
|
|
14
|
+
const trimmed = value.trim();
|
|
15
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function normalizeScalarToString(value) {
|
|
19
|
+
if (value === null || value === undefined) return null;
|
|
20
|
+
if (typeof value === 'string') return normalizeString(value);
|
|
21
|
+
if (typeof value === 'number') {
|
|
22
|
+
if (!Number.isFinite(value)) return null;
|
|
23
|
+
return normalizeString(String(value));
|
|
24
|
+
}
|
|
25
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function base64UrlDecodeToString(value) {
|
|
30
|
+
if (typeof value !== 'string' || value.length === 0) return null;
|
|
31
|
+
const padLen = (4 - (value.length % 4)) % 4;
|
|
32
|
+
const padded = value + '='.repeat(padLen);
|
|
33
|
+
const base64 = padded.replace(/-/g, '+').replace(/_/g, '/');
|
|
34
|
+
try {
|
|
35
|
+
return Buffer.from(base64, 'base64').toString('utf8');
|
|
36
|
+
} catch (_e) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function decodeJwtPayload(token) {
|
|
42
|
+
const jwt = normalizeString(token);
|
|
43
|
+
if (!jwt) return null;
|
|
44
|
+
const parts = jwt.split('.');
|
|
45
|
+
if (parts.length < 2) return null;
|
|
46
|
+
const decoded = base64UrlDecodeToString(parts[1]);
|
|
47
|
+
if (!decoded) return null;
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(decoded);
|
|
50
|
+
} catch (_e) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function extractOpenAiAuthNamespace(payload) {
|
|
56
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
57
|
+
const ns = payload[OPENAI_AUTH_CLAIM];
|
|
58
|
+
if (!ns || typeof ns !== 'object' || Array.isArray(ns)) return null;
|
|
59
|
+
return ns;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function extractChatgptSubscriptionFromPayload(payload) {
|
|
63
|
+
const ns = extractOpenAiAuthNamespace(payload);
|
|
64
|
+
if (!ns) return null;
|
|
65
|
+
|
|
66
|
+
const planType = normalizeString(ns.chatgpt_plan_type);
|
|
67
|
+
const activeStart = normalizeString(ns.chatgpt_subscription_active_start);
|
|
68
|
+
const activeUntil = normalizeString(ns.chatgpt_subscription_active_until);
|
|
69
|
+
const lastChecked = normalizeString(ns.chatgpt_subscription_last_checked);
|
|
70
|
+
|
|
71
|
+
if (!planType && !activeStart && !activeUntil && !lastChecked) return null;
|
|
72
|
+
return { planType, activeStart, activeUntil, lastChecked };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function mergeSubscription(primary, secondary) {
|
|
76
|
+
if (!primary && !secondary) return null;
|
|
77
|
+
const a = primary || {};
|
|
78
|
+
const b = secondary || {};
|
|
79
|
+
return {
|
|
80
|
+
planType: a.planType || b.planType || null,
|
|
81
|
+
activeStart: a.activeStart || b.activeStart || null,
|
|
82
|
+
activeUntil: a.activeUntil || b.activeUntil || null,
|
|
83
|
+
lastChecked: a.lastChecked || b.lastChecked || null
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isDisplayablePlanType(planType) {
|
|
88
|
+
const normalized = normalizeString(planType);
|
|
89
|
+
if (!normalized) return false;
|
|
90
|
+
const v = normalized.toLowerCase();
|
|
91
|
+
if (v === 'free' || v === 'none' || v === 'unknown') return false;
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function resolveCodexHome({ home, env }) {
|
|
96
|
+
const explicit = normalizeString(env?.CODEX_HOME);
|
|
97
|
+
return explicit ? path.resolve(explicit) : path.join(home, '.codex');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function resolveOpencodeDataDir({ home, env }) {
|
|
101
|
+
const explicit = normalizeString(env?.XDG_DATA_HOME);
|
|
102
|
+
const base = explicit ? path.resolve(explicit) : path.join(home, '.local', 'share');
|
|
103
|
+
return path.join(base, 'opencode');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function detectCodexChatgptSubscription({ home, env }) {
|
|
107
|
+
const codexHome = resolveCodexHome({ home, env });
|
|
108
|
+
const authPath = path.join(codexHome, 'auth.json');
|
|
109
|
+
const auth = await readJson(authPath);
|
|
110
|
+
if (!auth || typeof auth !== 'object') return null;
|
|
111
|
+
|
|
112
|
+
const accessPayload = decodeJwtPayload(auth?.tokens?.access_token);
|
|
113
|
+
const idPayload = decodeJwtPayload(auth?.tokens?.id_token);
|
|
114
|
+
|
|
115
|
+
const accessInfo = extractChatgptSubscriptionFromPayload(accessPayload);
|
|
116
|
+
const idInfo = extractChatgptSubscriptionFromPayload(idPayload);
|
|
117
|
+
const merged = mergeSubscription(accessInfo, idInfo);
|
|
118
|
+
if (!merged || !isDisplayablePlanType(merged.planType)) return null;
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
tool: 'codex',
|
|
122
|
+
provider: 'openai',
|
|
123
|
+
product: 'chatgpt',
|
|
124
|
+
planType: merged.planType,
|
|
125
|
+
activeStart: merged.activeStart,
|
|
126
|
+
activeUntil: merged.activeUntil,
|
|
127
|
+
lastChecked: merged.lastChecked
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function detectOpencodeChatgptSubscription({ home, env }) {
|
|
132
|
+
const dataDir = resolveOpencodeDataDir({ home, env });
|
|
133
|
+
const authPath = path.join(dataDir, 'auth.json');
|
|
134
|
+
const auth = await readJson(authPath);
|
|
135
|
+
if (!auth || typeof auth !== 'object') return null;
|
|
136
|
+
|
|
137
|
+
const accessPayload = decodeJwtPayload(auth?.openai?.access);
|
|
138
|
+
const info = extractChatgptSubscriptionFromPayload(accessPayload);
|
|
139
|
+
if (!info || !isDisplayablePlanType(info.planType)) return null;
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
tool: 'opencode',
|
|
143
|
+
provider: 'openai',
|
|
144
|
+
product: 'chatgpt',
|
|
145
|
+
planType: info.planType,
|
|
146
|
+
activeStart: info.activeStart,
|
|
147
|
+
activeUntil: info.activeUntil,
|
|
148
|
+
lastChecked: info.lastChecked
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function probeMacosKeychainGenericPassword({
|
|
153
|
+
service,
|
|
154
|
+
securityRunner,
|
|
155
|
+
timeoutMs
|
|
156
|
+
} = {}) {
|
|
157
|
+
const svc = normalizeString(service);
|
|
158
|
+
if (!svc) return false;
|
|
159
|
+
|
|
160
|
+
const runner = typeof securityRunner === 'function' ? securityRunner : cp.spawnSync;
|
|
161
|
+
if (runner === cp.spawnSync && !fs.existsSync(MACOS_SECURITY_BIN)) return false;
|
|
162
|
+
|
|
163
|
+
const result = runner(
|
|
164
|
+
MACOS_SECURITY_BIN,
|
|
165
|
+
['find-generic-password', '-s', svc],
|
|
166
|
+
{
|
|
167
|
+
stdio: 'ignore',
|
|
168
|
+
timeout: Number.isFinite(timeoutMs) ? timeoutMs : 2000
|
|
169
|
+
}
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
if (!result || result.error) return false;
|
|
173
|
+
return result.status === 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function readMacosKeychainPassword({
|
|
177
|
+
service,
|
|
178
|
+
securityRunner,
|
|
179
|
+
timeoutMs
|
|
180
|
+
} = {}) {
|
|
181
|
+
const svc = normalizeString(service);
|
|
182
|
+
if (!svc) return null;
|
|
183
|
+
|
|
184
|
+
const runner = typeof securityRunner === 'function' ? securityRunner : cp.spawnSync;
|
|
185
|
+
if (runner === cp.spawnSync && !fs.existsSync(MACOS_SECURITY_BIN)) return null;
|
|
186
|
+
|
|
187
|
+
const result = runner(
|
|
188
|
+
MACOS_SECURITY_BIN,
|
|
189
|
+
['find-generic-password', '-s', svc, '-w'],
|
|
190
|
+
{
|
|
191
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
192
|
+
timeout: Number.isFinite(timeoutMs) ? timeoutMs : 2000,
|
|
193
|
+
encoding: 'utf8'
|
|
194
|
+
}
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
if (!result || result.error) return null;
|
|
198
|
+
if (result.status !== 0) return null;
|
|
199
|
+
|
|
200
|
+
const stdout =
|
|
201
|
+
typeof result.stdout === 'string'
|
|
202
|
+
? result.stdout
|
|
203
|
+
: Buffer.isBuffer(result.stdout)
|
|
204
|
+
? result.stdout.toString('utf8')
|
|
205
|
+
: '';
|
|
206
|
+
const trimmed = stdout.trim();
|
|
207
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function detectClaudeCodeCredentialsPresence({
|
|
211
|
+
platform,
|
|
212
|
+
securityRunner
|
|
213
|
+
} = {}) {
|
|
214
|
+
if (platform !== 'darwin') return null;
|
|
215
|
+
|
|
216
|
+
for (const service of CLAUDE_CODE_KEYCHAIN_SERVICES) {
|
|
217
|
+
const present = probeMacosKeychainGenericPassword({
|
|
218
|
+
service,
|
|
219
|
+
securityRunner
|
|
220
|
+
});
|
|
221
|
+
if (!present) continue;
|
|
222
|
+
|
|
223
|
+
// Existence-only probe: do not read secrets or infer paid tier.
|
|
224
|
+
return {
|
|
225
|
+
tool: 'claude',
|
|
226
|
+
provider: 'anthropic',
|
|
227
|
+
product: 'credentials',
|
|
228
|
+
planType: 'present'
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function extractClaudeKeychainSubscription(payload) {
|
|
236
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null;
|
|
237
|
+
|
|
238
|
+
const oauth = payload.claudeAiOauth;
|
|
239
|
+
if (!oauth || typeof oauth !== 'object' || Array.isArray(oauth)) return null;
|
|
240
|
+
|
|
241
|
+
const subscriptionType = normalizeScalarToString(oauth.subscriptionType);
|
|
242
|
+
const rateLimitTier = normalizeScalarToString(oauth.rateLimitTier);
|
|
243
|
+
|
|
244
|
+
if (!subscriptionType) return null;
|
|
245
|
+
return { subscriptionType, rateLimitTier };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function detectClaudeCodeSubscriptionDetails({
|
|
249
|
+
platform,
|
|
250
|
+
securityRunner
|
|
251
|
+
} = {}) {
|
|
252
|
+
if (platform !== 'darwin') return null;
|
|
253
|
+
|
|
254
|
+
for (const service of CLAUDE_CODE_KEYCHAIN_SERVICES) {
|
|
255
|
+
const raw = readMacosKeychainPassword({
|
|
256
|
+
service,
|
|
257
|
+
securityRunner
|
|
258
|
+
});
|
|
259
|
+
if (!raw) continue;
|
|
260
|
+
|
|
261
|
+
let payload;
|
|
262
|
+
try {
|
|
263
|
+
payload = JSON.parse(raw);
|
|
264
|
+
} catch (_e) {
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const info = extractClaudeKeychainSubscription(payload);
|
|
269
|
+
if (!info) continue;
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
tool: 'claude',
|
|
273
|
+
provider: 'anthropic',
|
|
274
|
+
product: 'subscription',
|
|
275
|
+
planType: info.subscriptionType,
|
|
276
|
+
rateLimitTier: info.rateLimitTier
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function collectLocalSubscriptions({
|
|
284
|
+
home = os.homedir(),
|
|
285
|
+
env = process.env,
|
|
286
|
+
platform = process.platform,
|
|
287
|
+
securityRunner,
|
|
288
|
+
probeKeychain = false,
|
|
289
|
+
probeKeychainDetails = false
|
|
290
|
+
} = {}) {
|
|
291
|
+
const out = [];
|
|
292
|
+
|
|
293
|
+
const codex = await detectCodexChatgptSubscription({ home, env });
|
|
294
|
+
if (codex) out.push(codex);
|
|
295
|
+
|
|
296
|
+
const opencode = await detectOpencodeChatgptSubscription({ home, env });
|
|
297
|
+
if (opencode) out.push(opencode);
|
|
298
|
+
|
|
299
|
+
if (probeKeychainDetails) {
|
|
300
|
+
const claude = detectClaudeCodeSubscriptionDetails({ platform, securityRunner });
|
|
301
|
+
if (claude) out.push(claude);
|
|
302
|
+
else if (probeKeychain) {
|
|
303
|
+
const present = detectClaudeCodeCredentialsPresence({ platform, securityRunner });
|
|
304
|
+
if (present) out.push(present);
|
|
305
|
+
}
|
|
306
|
+
} else if (probeKeychain) {
|
|
307
|
+
const claude = detectClaudeCodeCredentialsPresence({ platform, securityRunner });
|
|
308
|
+
if (claude) out.push(claude);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Gemini: no stable local subscription/tier signal found yet.
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
module.exports = {
|
|
316
|
+
collectLocalSubscriptions
|
|
317
|
+
};
|
package/src/lib/uploader.js
CHANGED
|
@@ -13,6 +13,7 @@ const MAX_INGEST_BUCKETS = 500;
|
|
|
13
13
|
async function drainQueueToCloud({
|
|
14
14
|
baseUrl,
|
|
15
15
|
deviceToken,
|
|
16
|
+
deviceSubscriptions,
|
|
16
17
|
queuePath,
|
|
17
18
|
queueStatePath,
|
|
18
19
|
projectQueuePath,
|
|
@@ -45,6 +46,10 @@ async function drainQueueToCloud({
|
|
|
45
46
|
const maxBuckets = Math.max(1, Math.floor(Number(batchSize || 200)));
|
|
46
47
|
const totalLimit = Math.min(maxBuckets, MAX_INGEST_BUCKETS);
|
|
47
48
|
|
|
49
|
+
const normalizedSubscriptions = Array.isArray(deviceSubscriptions)
|
|
50
|
+
? deviceSubscriptions.filter((entry) => entry && typeof entry === 'object')
|
|
51
|
+
: [];
|
|
52
|
+
|
|
48
53
|
for (let batch = 0; batch < maxBatches; batch++) {
|
|
49
54
|
const hasHourly = queueSize > offset;
|
|
50
55
|
const hasProject = projectQueueEnabled && projectQueueSize > projectOffset;
|
|
@@ -73,7 +78,8 @@ async function drainQueueToCloud({
|
|
|
73
78
|
baseUrl,
|
|
74
79
|
deviceToken,
|
|
75
80
|
hourly: res.buckets,
|
|
76
|
-
project_hourly: projectRes.buckets
|
|
81
|
+
project_hourly: projectRes.buckets,
|
|
82
|
+
device_subscriptions: batch === 0 ? normalizedSubscriptions : undefined
|
|
77
83
|
});
|
|
78
84
|
inserted += ingest.inserted || 0;
|
|
79
85
|
skipped += ingest.skipped || 0;
|
package/src/lib/vibeusage-api.js
CHANGED
|
@@ -62,13 +62,18 @@ async function exchangeLinkCode({ baseUrl, linkCode, requestId, deviceName, plat
|
|
|
62
62
|
return { token, deviceId, userId: data?.user_id || null };
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
async function ingestHourly({ baseUrl, deviceToken, hourly, project_hourly }) {
|
|
65
|
+
async function ingestHourly({ baseUrl, deviceToken, hourly, project_hourly, device_subscriptions }) {
|
|
66
|
+
const body = { hourly, project_hourly };
|
|
67
|
+
if (Array.isArray(device_subscriptions) && device_subscriptions.length > 0) {
|
|
68
|
+
body.device_subscriptions = device_subscriptions;
|
|
69
|
+
}
|
|
70
|
+
|
|
66
71
|
const data = await invokeFunctionWithRetry({
|
|
67
72
|
baseUrl,
|
|
68
73
|
accessToken: deviceToken,
|
|
69
74
|
slug: 'vibeusage-ingest',
|
|
70
75
|
method: 'POST',
|
|
71
|
-
body
|
|
76
|
+
body,
|
|
72
77
|
errorPrefix: 'Ingest failed',
|
|
73
78
|
retry: { maxRetries: 3, baseDelayMs: 500, maxDelayMs: 5000 }
|
|
74
79
|
});
|