vibeusage 0.2.15 → 0.2.17
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 +138 -20
- package/README.zh-CN.md +137 -19
- package/package.json +7 -3
- package/src/cli.js +3 -2
- package/src/commands/init.js +25 -0
- package/src/commands/status.js +52 -1
- package/src/commands/sync.js +114 -15
- package/src/lib/codex-config.js +132 -10
- package/src/lib/diagnostics.js +3 -0
- package/src/lib/project-usage-purge.js +100 -0
- package/src/lib/rollout.js +743 -20
- package/src/lib/subscriptions.js +317 -0
- package/src/lib/uploader.js +112 -8
- package/src/lib/vibeusage-api.js +7 -2
- package/src/lib/vibeusage-public-repo.js +88 -0
|
@@ -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
|
@@ -8,26 +8,79 @@ const { ingestHourly } = require('./vibeusage-api');
|
|
|
8
8
|
const DEFAULT_SOURCE = 'codex';
|
|
9
9
|
const DEFAULT_MODEL = 'unknown';
|
|
10
10
|
const BUCKET_SEPARATOR = '|';
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
const MAX_INGEST_BUCKETS = 500;
|
|
12
|
+
|
|
13
|
+
async function drainQueueToCloud({
|
|
14
|
+
baseUrl,
|
|
15
|
+
deviceToken,
|
|
16
|
+
deviceSubscriptions,
|
|
17
|
+
queuePath,
|
|
18
|
+
queueStatePath,
|
|
19
|
+
projectQueuePath,
|
|
20
|
+
projectQueueStatePath,
|
|
21
|
+
maxBatches,
|
|
22
|
+
batchSize,
|
|
23
|
+
onProgress
|
|
24
|
+
}) {
|
|
13
25
|
await ensureDir(require('node:path').dirname(queueStatePath));
|
|
26
|
+
const projectQueueEnabled = typeof projectQueuePath === 'string' && projectQueuePath.length > 0;
|
|
27
|
+
if (projectQueueEnabled && (!projectQueueStatePath || projectQueueStatePath.length === 0)) {
|
|
28
|
+
throw new Error('projectQueueStatePath is required when projectQueuePath is set');
|
|
29
|
+
}
|
|
30
|
+
if (projectQueueEnabled) {
|
|
31
|
+
await ensureDir(require('node:path').dirname(projectQueueStatePath));
|
|
32
|
+
}
|
|
14
33
|
|
|
15
34
|
const state = (await readJson(queueStatePath)) || { offset: 0 };
|
|
16
35
|
let offset = Number(state.offset || 0);
|
|
36
|
+
const projectStatePath = projectQueueEnabled ? projectQueueStatePath : null;
|
|
37
|
+
const projectState = projectQueueEnabled ? (await readJson(projectStatePath)) || { offset: 0 } : { offset: 0 };
|
|
38
|
+
let projectOffset = Number(projectState.offset || 0);
|
|
17
39
|
let inserted = 0;
|
|
18
40
|
let skipped = 0;
|
|
19
41
|
let attempted = 0;
|
|
20
42
|
|
|
21
43
|
const cb = typeof onProgress === 'function' ? onProgress : null;
|
|
22
44
|
const queueSize = await safeFileSize(queuePath);
|
|
45
|
+
const projectQueueSize = projectQueueEnabled ? await safeFileSize(projectQueuePath) : 0;
|
|
23
46
|
const maxBuckets = Math.max(1, Math.floor(Number(batchSize || 200)));
|
|
47
|
+
const totalLimit = Math.min(maxBuckets, MAX_INGEST_BUCKETS);
|
|
48
|
+
|
|
49
|
+
const normalizedSubscriptions = Array.isArray(deviceSubscriptions)
|
|
50
|
+
? deviceSubscriptions.filter((entry) => entry && typeof entry === 'object')
|
|
51
|
+
: [];
|
|
24
52
|
|
|
25
53
|
for (let batch = 0; batch < maxBatches; batch++) {
|
|
26
|
-
const
|
|
27
|
-
|
|
54
|
+
const hasHourly = queueSize > offset;
|
|
55
|
+
const hasProject = projectQueueEnabled && projectQueueSize > projectOffset;
|
|
56
|
+
let hourlyLimit = 0;
|
|
57
|
+
let projectLimit = 0;
|
|
58
|
+
|
|
59
|
+
if (hasHourly && hasProject) {
|
|
60
|
+
hourlyLimit = Math.ceil(totalLimit / 2);
|
|
61
|
+
projectLimit = totalLimit - hourlyLimit;
|
|
62
|
+
} else if (hasHourly) {
|
|
63
|
+
hourlyLimit = totalLimit;
|
|
64
|
+
} else if (hasProject) {
|
|
65
|
+
projectLimit = totalLimit;
|
|
66
|
+
}
|
|
28
67
|
|
|
29
|
-
|
|
30
|
-
|
|
68
|
+
const res =
|
|
69
|
+
hourlyLimit > 0 ? await readBatch(queuePath, offset, hourlyLimit) : { buckets: [], nextOffset: offset };
|
|
70
|
+
const projectRes =
|
|
71
|
+
projectLimit > 0
|
|
72
|
+
? await readProjectBatch(projectQueuePath, projectOffset, projectLimit)
|
|
73
|
+
: { buckets: [], nextOffset: projectOffset };
|
|
74
|
+
if (res.buckets.length === 0 && projectRes.buckets.length === 0) break;
|
|
75
|
+
|
|
76
|
+
attempted += res.buckets.length + projectRes.buckets.length;
|
|
77
|
+
const ingest = await ingestHourly({
|
|
78
|
+
baseUrl,
|
|
79
|
+
deviceToken,
|
|
80
|
+
hourly: res.buckets,
|
|
81
|
+
project_hourly: projectRes.buckets,
|
|
82
|
+
device_subscriptions: batch === 0 ? normalizedSubscriptions : undefined
|
|
83
|
+
});
|
|
31
84
|
inserted += ingest.inserted || 0;
|
|
32
85
|
skipped += ingest.skipped || 0;
|
|
33
86
|
|
|
@@ -35,13 +88,20 @@ async function drainQueueToCloud({ baseUrl, deviceToken, queuePath, queueStatePa
|
|
|
35
88
|
state.offset = offset;
|
|
36
89
|
state.updatedAt = new Date().toISOString();
|
|
37
90
|
await writeJson(queueStatePath, state);
|
|
91
|
+
if (projectQueuePath) {
|
|
92
|
+
projectOffset = projectRes.nextOffset;
|
|
93
|
+
projectState.offset = projectOffset;
|
|
94
|
+
projectState.updatedAt = new Date().toISOString();
|
|
95
|
+
await writeJson(projectStatePath, projectState);
|
|
96
|
+
}
|
|
38
97
|
|
|
39
98
|
if (cb) {
|
|
99
|
+
const combinedOffset = offset + projectOffset;
|
|
40
100
|
cb({
|
|
41
101
|
batch: batch + 1,
|
|
42
102
|
maxBatches,
|
|
43
|
-
offset,
|
|
44
|
-
queueSize,
|
|
103
|
+
offset: combinedOffset,
|
|
104
|
+
queueSize: queueSize + projectQueueSize,
|
|
45
105
|
inserted,
|
|
46
106
|
skipped
|
|
47
107
|
});
|
|
@@ -88,6 +148,46 @@ async function readBatch(queuePath, startOffset, maxBuckets) {
|
|
|
88
148
|
return { buckets: Array.from(bucketMap.values()), nextOffset: offset };
|
|
89
149
|
}
|
|
90
150
|
|
|
151
|
+
async function readProjectBatch(queuePath, startOffset, maxBuckets) {
|
|
152
|
+
if (!queuePath) return { buckets: [], nextOffset: startOffset };
|
|
153
|
+
const st = await fs.stat(queuePath).catch(() => null);
|
|
154
|
+
if (!st || !st.isFile()) return { buckets: [], nextOffset: startOffset };
|
|
155
|
+
if (startOffset >= st.size) return { buckets: [], nextOffset: startOffset };
|
|
156
|
+
|
|
157
|
+
const stream = fssync.createReadStream(queuePath, { encoding: 'utf8', start: startOffset });
|
|
158
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
159
|
+
|
|
160
|
+
const bucketMap = new Map();
|
|
161
|
+
let offset = startOffset;
|
|
162
|
+
let linesRead = 0;
|
|
163
|
+
for await (const line of rl) {
|
|
164
|
+
const bytes = Buffer.byteLength(line, 'utf8') + 1;
|
|
165
|
+
offset += bytes;
|
|
166
|
+
if (!line.trim()) continue;
|
|
167
|
+
let bucket;
|
|
168
|
+
try {
|
|
169
|
+
bucket = JSON.parse(line);
|
|
170
|
+
} catch (_e) {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const hourStart = typeof bucket?.hour_start === 'string' ? bucket.hour_start : null;
|
|
174
|
+
const projectKey = typeof bucket?.project_key === 'string' ? bucket.project_key : null;
|
|
175
|
+
const projectRef = typeof bucket?.project_ref === 'string' ? bucket.project_ref : null;
|
|
176
|
+
if (!hourStart || !projectKey || !projectRef) continue;
|
|
177
|
+
const source = normalizeSource(bucket?.source) || DEFAULT_SOURCE;
|
|
178
|
+
bucket.source = source;
|
|
179
|
+
bucket.project_key = projectKey;
|
|
180
|
+
bucket.project_ref = projectRef;
|
|
181
|
+
bucketMap.set(projectBucketKey(projectKey, source, hourStart), bucket);
|
|
182
|
+
linesRead += 1;
|
|
183
|
+
if (linesRead >= maxBuckets) break;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
rl.close();
|
|
187
|
+
stream.close?.();
|
|
188
|
+
return { buckets: Array.from(bucketMap.values()), nextOffset: offset };
|
|
189
|
+
}
|
|
190
|
+
|
|
91
191
|
async function safeFileSize(p) {
|
|
92
192
|
try {
|
|
93
193
|
const st = await fs.stat(p);
|
|
@@ -101,6 +201,10 @@ function bucketKey(source, model, hourStart) {
|
|
|
101
201
|
return `${source}${BUCKET_SEPARATOR}${model}${BUCKET_SEPARATOR}${hourStart}`;
|
|
102
202
|
}
|
|
103
203
|
|
|
204
|
+
function projectBucketKey(projectKey, source, hourStart) {
|
|
205
|
+
return `${projectKey}${BUCKET_SEPARATOR}${source}${BUCKET_SEPARATOR}${hourStart}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
104
208
|
function normalizeSource(value) {
|
|
105
209
|
if (typeof value !== 'string') return null;
|
|
106
210
|
const trimmed = value.trim().toLowerCase();
|
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 }) {
|
|
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
|
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const crypto = require('node:crypto');
|
|
2
|
+
|
|
3
|
+
const GITHUB_HOST = 'github.com';
|
|
4
|
+
const GITHUB_API_BASE = 'https://api.github.com';
|
|
5
|
+
|
|
6
|
+
function parseGitHubRepoId(projectRef) {
|
|
7
|
+
if (typeof projectRef !== 'string') return null;
|
|
8
|
+
let parsed;
|
|
9
|
+
try {
|
|
10
|
+
parsed = new URL(projectRef);
|
|
11
|
+
} catch (_err) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
if (!parsed.hostname || parsed.hostname.toLowerCase() !== GITHUB_HOST) return null;
|
|
15
|
+
const segments = parsed.pathname.split('/').filter(Boolean);
|
|
16
|
+
if (segments.length < 2) return null;
|
|
17
|
+
const owner = segments[0].trim().toLowerCase();
|
|
18
|
+
const repo = segments[1].trim().replace(/\.git$/i, '').toLowerCase();
|
|
19
|
+
if (!owner || !repo) return null;
|
|
20
|
+
return { owner, repo, repoId: `${owner}/${repo}` };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function hashRepoRoot(repoRoot) {
|
|
24
|
+
return crypto.createHash('sha256').update(String(repoRoot)).digest('hex');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isRateLimited(res, body) {
|
|
28
|
+
if (!res) return false;
|
|
29
|
+
const remaining = res.headers?.get?.('x-ratelimit-remaining');
|
|
30
|
+
if (remaining === '0') return true;
|
|
31
|
+
if (res.status === 429) return true;
|
|
32
|
+
if (body && typeof body.message === 'string' && body.message.toLowerCase().includes('rate limit')) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function resolveGitHubPublicStatus(projectRef, fetchImpl) {
|
|
39
|
+
const parsed = parseGitHubRepoId(projectRef);
|
|
40
|
+
if (!parsed) {
|
|
41
|
+
return { status: 'blocked', projectKey: null, projectRef, reason: 'non_github' };
|
|
42
|
+
}
|
|
43
|
+
const fetchFn = fetchImpl || fetch;
|
|
44
|
+
const apiUrl = `${GITHUB_API_BASE}/repos/${parsed.repoId}`;
|
|
45
|
+
|
|
46
|
+
let res;
|
|
47
|
+
let body = null;
|
|
48
|
+
try {
|
|
49
|
+
res = await fetchFn(apiUrl, {
|
|
50
|
+
headers: {
|
|
51
|
+
Accept: 'application/vnd.github+json',
|
|
52
|
+
'User-Agent': 'vibeusage-cli'
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
} catch (_err) {
|
|
56
|
+
return { status: 'pending_public', projectKey: parsed.repoId, projectRef, reason: 'fetch_failed' };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (res.status === 200) {
|
|
60
|
+
body = await res.json().catch(() => ({}));
|
|
61
|
+
const isPrivate = body && typeof body.private === 'boolean' ? body.private : null;
|
|
62
|
+
const visibility = typeof body?.visibility === 'string' ? body.visibility : null;
|
|
63
|
+
const isPublic = isPrivate === false || visibility === 'public';
|
|
64
|
+
return {
|
|
65
|
+
status: isPublic ? 'public_verified' : 'blocked',
|
|
66
|
+
projectKey: parsed.repoId,
|
|
67
|
+
projectRef,
|
|
68
|
+
reason: isPublic ? 'public' : 'private'
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (res.status === 404) {
|
|
73
|
+
return { status: 'blocked', projectKey: parsed.repoId, projectRef, reason: 'not_found' };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
body = await res.json().catch(() => ({}));
|
|
77
|
+
if (isRateLimited(res, body) || res.status === 401 || res.status === 403 || res.status >= 500) {
|
|
78
|
+
return { status: 'pending_public', projectKey: parsed.repoId, projectRef, reason: 'rate_limited' };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { status: 'pending_public', projectKey: parsed.repoId, projectRef, reason: 'unknown' };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = {
|
|
85
|
+
parseGitHubRepoId,
|
|
86
|
+
hashRepoRoot,
|
|
87
|
+
resolveGitHubPublicStatus
|
|
88
|
+
};
|