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.
@@ -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
+ };
@@ -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;
@@ -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: { hourly, project_hourly },
76
+ body,
72
77
  errorPrefix: 'Ingest failed',
73
78
  retry: { maxRetries: 3, baseDelayMs: 500, maxDelayMs: 5000 }
74
79
  });