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.
@@ -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
+ };
@@ -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
- async function drainQueueToCloud({ baseUrl, deviceToken, queuePath, queueStatePath, maxBatches, batchSize, onProgress }) {
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 res = await readBatch(queuePath, offset, maxBuckets);
27
- if (res.buckets.length === 0) break;
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
- attempted += res.buckets.length;
30
- const ingest = await ingestHourly({ baseUrl, deviceToken, hourly: res.buckets });
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();
@@ -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: { hourly },
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
+ };