vibeusage 0.2.15 → 0.2.16
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 +2 -0
- package/README.zh-CN.md +2 -0
- package/package.json +7 -3
- package/src/commands/init.js +25 -0
- package/src/commands/sync.js +38 -4
- package/src/lib/project-usage-purge.js +100 -0
- package/src/lib/rollout.js +588 -19
- package/src/lib/uploader.js +106 -8
- package/src/lib/vibeusage-api.js +2 -2
- package/src/lib/vibeusage-public-repo.js +88 -0
package/src/lib/uploader.js
CHANGED
|
@@ -8,26 +8,73 @@ 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
|
+
queuePath,
|
|
17
|
+
queueStatePath,
|
|
18
|
+
projectQueuePath,
|
|
19
|
+
projectQueueStatePath,
|
|
20
|
+
maxBatches,
|
|
21
|
+
batchSize,
|
|
22
|
+
onProgress
|
|
23
|
+
}) {
|
|
13
24
|
await ensureDir(require('node:path').dirname(queueStatePath));
|
|
25
|
+
const projectQueueEnabled = typeof projectQueuePath === 'string' && projectQueuePath.length > 0;
|
|
26
|
+
if (projectQueueEnabled && (!projectQueueStatePath || projectQueueStatePath.length === 0)) {
|
|
27
|
+
throw new Error('projectQueueStatePath is required when projectQueuePath is set');
|
|
28
|
+
}
|
|
29
|
+
if (projectQueueEnabled) {
|
|
30
|
+
await ensureDir(require('node:path').dirname(projectQueueStatePath));
|
|
31
|
+
}
|
|
14
32
|
|
|
15
33
|
const state = (await readJson(queueStatePath)) || { offset: 0 };
|
|
16
34
|
let offset = Number(state.offset || 0);
|
|
35
|
+
const projectStatePath = projectQueueEnabled ? projectQueueStatePath : null;
|
|
36
|
+
const projectState = projectQueueEnabled ? (await readJson(projectStatePath)) || { offset: 0 } : { offset: 0 };
|
|
37
|
+
let projectOffset = Number(projectState.offset || 0);
|
|
17
38
|
let inserted = 0;
|
|
18
39
|
let skipped = 0;
|
|
19
40
|
let attempted = 0;
|
|
20
41
|
|
|
21
42
|
const cb = typeof onProgress === 'function' ? onProgress : null;
|
|
22
43
|
const queueSize = await safeFileSize(queuePath);
|
|
44
|
+
const projectQueueSize = projectQueueEnabled ? await safeFileSize(projectQueuePath) : 0;
|
|
23
45
|
const maxBuckets = Math.max(1, Math.floor(Number(batchSize || 200)));
|
|
46
|
+
const totalLimit = Math.min(maxBuckets, MAX_INGEST_BUCKETS);
|
|
24
47
|
|
|
25
48
|
for (let batch = 0; batch < maxBatches; batch++) {
|
|
26
|
-
const
|
|
27
|
-
|
|
49
|
+
const hasHourly = queueSize > offset;
|
|
50
|
+
const hasProject = projectQueueEnabled && projectQueueSize > projectOffset;
|
|
51
|
+
let hourlyLimit = 0;
|
|
52
|
+
let projectLimit = 0;
|
|
53
|
+
|
|
54
|
+
if (hasHourly && hasProject) {
|
|
55
|
+
hourlyLimit = Math.ceil(totalLimit / 2);
|
|
56
|
+
projectLimit = totalLimit - hourlyLimit;
|
|
57
|
+
} else if (hasHourly) {
|
|
58
|
+
hourlyLimit = totalLimit;
|
|
59
|
+
} else if (hasProject) {
|
|
60
|
+
projectLimit = totalLimit;
|
|
61
|
+
}
|
|
28
62
|
|
|
29
|
-
|
|
30
|
-
|
|
63
|
+
const res =
|
|
64
|
+
hourlyLimit > 0 ? await readBatch(queuePath, offset, hourlyLimit) : { buckets: [], nextOffset: offset };
|
|
65
|
+
const projectRes =
|
|
66
|
+
projectLimit > 0
|
|
67
|
+
? await readProjectBatch(projectQueuePath, projectOffset, projectLimit)
|
|
68
|
+
: { buckets: [], nextOffset: projectOffset };
|
|
69
|
+
if (res.buckets.length === 0 && projectRes.buckets.length === 0) break;
|
|
70
|
+
|
|
71
|
+
attempted += res.buckets.length + projectRes.buckets.length;
|
|
72
|
+
const ingest = await ingestHourly({
|
|
73
|
+
baseUrl,
|
|
74
|
+
deviceToken,
|
|
75
|
+
hourly: res.buckets,
|
|
76
|
+
project_hourly: projectRes.buckets
|
|
77
|
+
});
|
|
31
78
|
inserted += ingest.inserted || 0;
|
|
32
79
|
skipped += ingest.skipped || 0;
|
|
33
80
|
|
|
@@ -35,13 +82,20 @@ async function drainQueueToCloud({ baseUrl, deviceToken, queuePath, queueStatePa
|
|
|
35
82
|
state.offset = offset;
|
|
36
83
|
state.updatedAt = new Date().toISOString();
|
|
37
84
|
await writeJson(queueStatePath, state);
|
|
85
|
+
if (projectQueuePath) {
|
|
86
|
+
projectOffset = projectRes.nextOffset;
|
|
87
|
+
projectState.offset = projectOffset;
|
|
88
|
+
projectState.updatedAt = new Date().toISOString();
|
|
89
|
+
await writeJson(projectStatePath, projectState);
|
|
90
|
+
}
|
|
38
91
|
|
|
39
92
|
if (cb) {
|
|
93
|
+
const combinedOffset = offset + projectOffset;
|
|
40
94
|
cb({
|
|
41
95
|
batch: batch + 1,
|
|
42
96
|
maxBatches,
|
|
43
|
-
offset,
|
|
44
|
-
queueSize,
|
|
97
|
+
offset: combinedOffset,
|
|
98
|
+
queueSize: queueSize + projectQueueSize,
|
|
45
99
|
inserted,
|
|
46
100
|
skipped
|
|
47
101
|
});
|
|
@@ -88,6 +142,46 @@ async function readBatch(queuePath, startOffset, maxBuckets) {
|
|
|
88
142
|
return { buckets: Array.from(bucketMap.values()), nextOffset: offset };
|
|
89
143
|
}
|
|
90
144
|
|
|
145
|
+
async function readProjectBatch(queuePath, startOffset, maxBuckets) {
|
|
146
|
+
if (!queuePath) return { buckets: [], nextOffset: startOffset };
|
|
147
|
+
const st = await fs.stat(queuePath).catch(() => null);
|
|
148
|
+
if (!st || !st.isFile()) return { buckets: [], nextOffset: startOffset };
|
|
149
|
+
if (startOffset >= st.size) return { buckets: [], nextOffset: startOffset };
|
|
150
|
+
|
|
151
|
+
const stream = fssync.createReadStream(queuePath, { encoding: 'utf8', start: startOffset });
|
|
152
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
153
|
+
|
|
154
|
+
const bucketMap = new Map();
|
|
155
|
+
let offset = startOffset;
|
|
156
|
+
let linesRead = 0;
|
|
157
|
+
for await (const line of rl) {
|
|
158
|
+
const bytes = Buffer.byteLength(line, 'utf8') + 1;
|
|
159
|
+
offset += bytes;
|
|
160
|
+
if (!line.trim()) continue;
|
|
161
|
+
let bucket;
|
|
162
|
+
try {
|
|
163
|
+
bucket = JSON.parse(line);
|
|
164
|
+
} catch (_e) {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const hourStart = typeof bucket?.hour_start === 'string' ? bucket.hour_start : null;
|
|
168
|
+
const projectKey = typeof bucket?.project_key === 'string' ? bucket.project_key : null;
|
|
169
|
+
const projectRef = typeof bucket?.project_ref === 'string' ? bucket.project_ref : null;
|
|
170
|
+
if (!hourStart || !projectKey || !projectRef) continue;
|
|
171
|
+
const source = normalizeSource(bucket?.source) || DEFAULT_SOURCE;
|
|
172
|
+
bucket.source = source;
|
|
173
|
+
bucket.project_key = projectKey;
|
|
174
|
+
bucket.project_ref = projectRef;
|
|
175
|
+
bucketMap.set(projectBucketKey(projectKey, source, hourStart), bucket);
|
|
176
|
+
linesRead += 1;
|
|
177
|
+
if (linesRead >= maxBuckets) break;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
rl.close();
|
|
181
|
+
stream.close?.();
|
|
182
|
+
return { buckets: Array.from(bucketMap.values()), nextOffset: offset };
|
|
183
|
+
}
|
|
184
|
+
|
|
91
185
|
async function safeFileSize(p) {
|
|
92
186
|
try {
|
|
93
187
|
const st = await fs.stat(p);
|
|
@@ -101,6 +195,10 @@ function bucketKey(source, model, hourStart) {
|
|
|
101
195
|
return `${source}${BUCKET_SEPARATOR}${model}${BUCKET_SEPARATOR}${hourStart}`;
|
|
102
196
|
}
|
|
103
197
|
|
|
198
|
+
function projectBucketKey(projectKey, source, hourStart) {
|
|
199
|
+
return `${projectKey}${BUCKET_SEPARATOR}${source}${BUCKET_SEPARATOR}${hourStart}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
104
202
|
function normalizeSource(value) {
|
|
105
203
|
if (typeof value !== 'string') return null;
|
|
106
204
|
const trimmed = value.trim().toLowerCase();
|
package/src/lib/vibeusage-api.js
CHANGED
|
@@ -62,13 +62,13 @@ 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 }) {
|
|
66
66
|
const data = await invokeFunctionWithRetry({
|
|
67
67
|
baseUrl,
|
|
68
68
|
accessToken: deviceToken,
|
|
69
69
|
slug: 'vibeusage-ingest',
|
|
70
70
|
method: 'POST',
|
|
71
|
-
body: { hourly },
|
|
71
|
+
body: { hourly, project_hourly },
|
|
72
72
|
errorPrefix: 'Ingest failed',
|
|
73
73
|
retry: { maxRetries: 3, baseDelayMs: 500, maxDelayMs: 5000 }
|
|
74
74
|
});
|
|
@@ -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
|
+
};
|