@eiei114/pi-sub-core 1.5.1

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/README.md +178 -0
  3. package/index.ts +540 -0
  4. package/package.json +35 -0
  5. package/src/cache.ts +546 -0
  6. package/src/config.ts +35 -0
  7. package/src/dependencies.ts +37 -0
  8. package/src/errors.ts +71 -0
  9. package/src/paths.ts +55 -0
  10. package/src/provider.ts +66 -0
  11. package/src/providers/detection.ts +51 -0
  12. package/src/providers/impl/anthropic.ts +174 -0
  13. package/src/providers/impl/antigravity.ts +226 -0
  14. package/src/providers/impl/codex.ts +186 -0
  15. package/src/providers/impl/copilot.ts +176 -0
  16. package/src/providers/impl/gemini.ts +130 -0
  17. package/src/providers/impl/kiro.ts +92 -0
  18. package/src/providers/impl/zai.ts +120 -0
  19. package/src/providers/index.ts +5 -0
  20. package/src/providers/metadata.ts +16 -0
  21. package/src/providers/registry.ts +54 -0
  22. package/src/providers/settings.ts +109 -0
  23. package/src/providers/status.ts +25 -0
  24. package/src/settings/behavior.ts +58 -0
  25. package/src/settings/menu.ts +83 -0
  26. package/src/settings/tools.ts +38 -0
  27. package/src/settings/ui.ts +450 -0
  28. package/src/settings-types.ts +95 -0
  29. package/src/settings-ui.ts +1 -0
  30. package/src/settings.ts +137 -0
  31. package/src/status.ts +245 -0
  32. package/src/storage/lock.ts +150 -0
  33. package/src/storage.ts +61 -0
  34. package/src/types.ts +33 -0
  35. package/src/ui/keybindings.ts +92 -0
  36. package/src/ui/settings-list.ts +290 -0
  37. package/src/usage/controller.ts +250 -0
  38. package/src/usage/fetch.ts +215 -0
  39. package/src/usage/types.ts +5 -0
  40. package/src/utils.ts +158 -0
  41. package/test/all.test.ts +9 -0
  42. package/test/cache.test.ts +157 -0
  43. package/test/controller.test.ts +101 -0
  44. package/test/detection.test.ts +24 -0
  45. package/test/extension.test.ts +233 -0
  46. package/test/helpers.ts +48 -0
  47. package/test/keybindings.test.ts +59 -0
  48. package/test/lock.test.ts +49 -0
  49. package/test/prioritize.test.ts +81 -0
  50. package/test/providers.test.ts +385 -0
  51. package/test/status.test.ts +70 -0
  52. package/tsconfig.json +5 -0
package/src/cache.ts ADDED
@@ -0,0 +1,546 @@
1
+ /**
2
+ * Cache management for sub-bar
3
+ * Shared cache across all pi instances to avoid redundant API calls
4
+ */
5
+
6
+ import * as path from "node:path";
7
+ import * as fs from "node:fs";
8
+ import type { ProviderName, ProviderStatus, UsageSnapshot } from "./types.js";
9
+ import { isExpectedMissingData } from "./errors.js";
10
+ import { getStorage } from "./storage.js";
11
+ import {
12
+ getCachePath,
13
+ getCacheLockPath,
14
+ getLegacyAgentCacheLockPath,
15
+ getLegacyAgentCachePath,
16
+ getLegacyCacheLockPath,
17
+ getLegacyCachePath,
18
+ } from "./paths.js";
19
+ import { tryAcquireFileLock, releaseFileLock, waitForLockRelease } from "./storage/lock.js";
20
+
21
+ /**
22
+ * Cache entry for a provider
23
+ */
24
+ export interface CacheEntry {
25
+ fetchedAt: number;
26
+ statusFetchedAt?: number;
27
+ usage?: UsageSnapshot;
28
+ status?: ProviderStatus;
29
+ }
30
+
31
+ /**
32
+ * Cache structure
33
+ */
34
+ export interface Cache {
35
+ [provider: string]: CacheEntry;
36
+ }
37
+
38
+ export type CacheUpdateListener = (provider: ProviderName, entry?: CacheEntry) => void;
39
+ export type CacheSnapshotListener = (cache: Cache) => void;
40
+
41
+ const cacheUpdateListeners = new Set<CacheUpdateListener>();
42
+ const cacheSnapshotListeners = new Set<CacheSnapshotListener>();
43
+
44
+ let lastCacheSnapshot: Cache | null = null;
45
+ let lastCacheContent = "";
46
+ let lastCacheMtimeMs = 0;
47
+ let legacyCacheMigrated = false;
48
+
49
+ function updateCacheSnapshot(cache: Cache, content: string, mtimeMs: number): void {
50
+ lastCacheSnapshot = cache;
51
+ lastCacheContent = content;
52
+ lastCacheMtimeMs = mtimeMs;
53
+ }
54
+
55
+ function resetCacheSnapshot(): void {
56
+ lastCacheSnapshot = {};
57
+ lastCacheContent = "";
58
+ lastCacheMtimeMs = 0;
59
+ }
60
+
61
+ function migrateLegacyCache(): void {
62
+ if (legacyCacheMigrated) return;
63
+ legacyCacheMigrated = true;
64
+ const storage = getStorage();
65
+ try {
66
+ const legacyCachePaths = [LEGACY_AGENT_CACHE_PATH, LEGACY_CACHE_PATH];
67
+ if (!storage.exists(CACHE_PATH)) {
68
+ const legacyPath = legacyCachePaths.find((path) => storage.exists(path));
69
+ if (legacyPath) {
70
+ const content = storage.readFile(legacyPath);
71
+ if (content) {
72
+ ensureCacheDir();
73
+ storage.writeFile(CACHE_PATH, content);
74
+ }
75
+ }
76
+ }
77
+ for (const legacyPath of legacyCachePaths) {
78
+ if (storage.exists(legacyPath)) {
79
+ storage.removeFile(legacyPath);
80
+ }
81
+ }
82
+ for (const legacyLockPath of [LEGACY_AGENT_LOCK_PATH, LEGACY_LOCK_PATH]) {
83
+ if (storage.exists(legacyLockPath)) {
84
+ storage.removeFile(legacyLockPath);
85
+ }
86
+ }
87
+ } catch (error) {
88
+ console.error("Failed to migrate cache:", error);
89
+ }
90
+ }
91
+
92
+ export function onCacheUpdate(listener: CacheUpdateListener): () => void {
93
+ cacheUpdateListeners.add(listener);
94
+ return () => {
95
+ cacheUpdateListeners.delete(listener);
96
+ };
97
+ }
98
+
99
+ export function onCacheSnapshot(listener: CacheSnapshotListener): () => void {
100
+ cacheSnapshotListeners.add(listener);
101
+ return () => {
102
+ cacheSnapshotListeners.delete(listener);
103
+ };
104
+ }
105
+
106
+ function emitCacheUpdate(provider: ProviderName, entry?: CacheEntry): void {
107
+ for (const listener of cacheUpdateListeners) {
108
+ try {
109
+ listener(provider, entry);
110
+ } catch (error) {
111
+ console.error("Failed to notify cache update:", error);
112
+ }
113
+ }
114
+ }
115
+
116
+ function emitCacheSnapshot(cache: Cache): void {
117
+ for (const listener of cacheSnapshotListeners) {
118
+ try {
119
+ listener(cache);
120
+ } catch (error) {
121
+ console.error("Failed to notify cache snapshot:", error);
122
+ }
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Cache file path
128
+ */
129
+ export const CACHE_PATH = getCachePath();
130
+ const LEGACY_CACHE_PATH = getLegacyCachePath();
131
+ const LEGACY_AGENT_CACHE_PATH = getLegacyAgentCachePath();
132
+
133
+ /**
134
+ * Lock file path
135
+ */
136
+ const LOCK_PATH = getCacheLockPath();
137
+ const LEGACY_LOCK_PATH = getLegacyCacheLockPath();
138
+ const LEGACY_AGENT_LOCK_PATH = getLegacyAgentCacheLockPath();
139
+
140
+ /**
141
+ * Lock timeout in milliseconds
142
+ */
143
+ const LOCK_TIMEOUT_MS = 5000;
144
+ const CACHE_WRITE_RETRY_ATTEMPTS = 8;
145
+ const CACHE_WRITE_RETRY_DELAY_MS = 25;
146
+ const RETRYABLE_RENAME_ERROR_CODES = new Set(["EPERM", "EACCES", "EBUSY", "ENOTEMPTY"]);
147
+ const SLEEP_ARRAY = new Int32Array(new SharedArrayBuffer(4));
148
+
149
+ /**
150
+ * Ensure cache directory exists
151
+ */
152
+ function ensureCacheDir(): void {
153
+ const storage = getStorage();
154
+ const dir = path.dirname(CACHE_PATH);
155
+ storage.ensureDir(dir);
156
+ }
157
+
158
+ /**
159
+ * Read cache from disk
160
+ */
161
+ export function readCache(): Cache {
162
+ migrateLegacyCache();
163
+ const storage = getStorage();
164
+ try {
165
+ const cacheExists = storage.exists(CACHE_PATH);
166
+ if (!cacheExists) {
167
+ if (lastCacheMtimeMs !== 0 || lastCacheContent) {
168
+ resetCacheSnapshot();
169
+ }
170
+ return lastCacheSnapshot ?? {};
171
+ }
172
+
173
+ const stat = fs.statSync(CACHE_PATH, { throwIfNoEntry: false });
174
+ if (stat && stat.mtimeMs === lastCacheMtimeMs && lastCacheSnapshot) {
175
+ return lastCacheSnapshot;
176
+ }
177
+
178
+ const content = storage.readFile(CACHE_PATH);
179
+ if (!content) {
180
+ updateCacheSnapshot({}, "", stat?.mtimeMs ?? 0);
181
+ return {};
182
+ }
183
+ if (!stat && content === lastCacheContent && lastCacheSnapshot) {
184
+ return lastCacheSnapshot;
185
+ }
186
+
187
+ try {
188
+ const parsed = JSON.parse(content) as Cache;
189
+ updateCacheSnapshot(parsed, content, stat?.mtimeMs ?? Date.now());
190
+ return parsed;
191
+ } catch (error) {
192
+ const lastBrace = content.lastIndexOf("}");
193
+ if (lastBrace > 0) {
194
+ const trimmed = content.slice(0, lastBrace + 1);
195
+ try {
196
+ const parsed = JSON.parse(trimmed) as Cache;
197
+ if (stat) {
198
+ writeCache(parsed);
199
+ } else {
200
+ updateCacheSnapshot(parsed, trimmed, Date.now());
201
+ }
202
+ return parsed;
203
+ } catch {
204
+ // fall through to log below
205
+ }
206
+ }
207
+ console.error("Failed to read cache:", error);
208
+ }
209
+ } catch (error) {
210
+ console.error("Failed to read cache:", error);
211
+ }
212
+ return {};
213
+ }
214
+
215
+ /**
216
+ * Write cache to disk
217
+ */
218
+ function writeCache(cache: Cache): void {
219
+ migrateLegacyCache();
220
+ const storage = getStorage();
221
+ try {
222
+ ensureCacheDir();
223
+ const content = JSON.stringify(cache, null, 2);
224
+ const cacheExists = storage.exists(CACHE_PATH);
225
+ if (cacheExists && content === lastCacheContent) {
226
+ const stat = fs.statSync(CACHE_PATH, { throwIfNoEntry: false });
227
+ updateCacheSnapshot(cache, content, stat?.mtimeMs ?? lastCacheMtimeMs);
228
+ return;
229
+ }
230
+ const tempPath = `${CACHE_PATH}.${process.pid}.${Date.now()}.tmp`;
231
+ fs.writeFileSync(tempPath, content, "utf-8");
232
+ try {
233
+ renameCacheFileWithRetry(tempPath, CACHE_PATH);
234
+ } finally {
235
+ removeTempCacheFile(tempPath);
236
+ }
237
+ const stat = fs.statSync(CACHE_PATH, { throwIfNoEntry: false });
238
+ updateCacheSnapshot(cache, content, stat?.mtimeMs ?? Date.now());
239
+ } catch (error) {
240
+ console.error("Failed to write cache:", error);
241
+ }
242
+ }
243
+
244
+ function renameCacheFileWithRetry(fromPath: string, toPath: string): void {
245
+ let lastError: unknown;
246
+ for (let attempt = 0; attempt < CACHE_WRITE_RETRY_ATTEMPTS; attempt++) {
247
+ try {
248
+ fs.renameSync(fromPath, toPath);
249
+ return;
250
+ } catch (error) {
251
+ if (!isRetryableRenameError(error)) {
252
+ throw error;
253
+ }
254
+ lastError = error;
255
+ if (attempt >= CACHE_WRITE_RETRY_ATTEMPTS - 1) {
256
+ break;
257
+ }
258
+ sleepSync(CACHE_WRITE_RETRY_DELAY_MS * (attempt + 1));
259
+ }
260
+ }
261
+ if (lastError) {
262
+ throw lastError;
263
+ }
264
+ throw new Error("Failed to rename cache file");
265
+ }
266
+
267
+ function removeTempCacheFile(tempPath: string): void {
268
+ try {
269
+ if (fs.existsSync(tempPath)) {
270
+ fs.unlinkSync(tempPath);
271
+ }
272
+ } catch {
273
+ // Ignore cleanup errors
274
+ }
275
+ }
276
+
277
+ function isRetryableRenameError(error: unknown): boolean {
278
+ if (!error || typeof error !== "object") return false;
279
+ const code = "code" in error ? (error as { code?: unknown }).code : undefined;
280
+ return typeof code === "string" && RETRYABLE_RENAME_ERROR_CODES.has(code);
281
+ }
282
+
283
+ function sleepSync(ms: number): void {
284
+ if (ms <= 0) return;
285
+ Atomics.wait(SLEEP_ARRAY, 0, 0, ms);
286
+ }
287
+
288
+ export interface CacheWatchOptions {
289
+ debounceMs?: number;
290
+ pollIntervalMs?: number;
291
+ lockRetryMs?: number;
292
+ }
293
+
294
+ export function watchCacheUpdates(options?: CacheWatchOptions): () => void {
295
+ migrateLegacyCache();
296
+ const debounceMs = options?.debounceMs ?? 250;
297
+ const pollIntervalMs = options?.pollIntervalMs ?? 5000;
298
+ const lockRetryMs = options?.lockRetryMs ?? 1000;
299
+ let debounceTimer: NodeJS.Timeout | undefined;
300
+ let pollTimer: NodeJS.Timeout | undefined;
301
+ let lockRetryPending = false;
302
+ let lastSnapshot = "";
303
+ let lastMtimeMs = 0;
304
+ let stopped = false;
305
+
306
+ const scheduleLockRetry = () => {
307
+ if (lockRetryPending || stopped) return;
308
+ lockRetryPending = true;
309
+ void waitForLockRelease(LOCK_PATH, lockRetryMs).then((released) => {
310
+ lockRetryPending = false;
311
+ if (released) {
312
+ emitFromCache();
313
+ }
314
+ });
315
+ };
316
+
317
+ const emitFromCache = () => {
318
+ try {
319
+ if (fs.existsSync(LOCK_PATH)) {
320
+ scheduleLockRetry();
321
+ return;
322
+ }
323
+ const stat = fs.statSync(CACHE_PATH, { throwIfNoEntry: false });
324
+ if (!stat || !stat.mtimeMs) return;
325
+ if (stat.mtimeMs === lastMtimeMs) return;
326
+ lastMtimeMs = stat.mtimeMs;
327
+ const content = fs.readFileSync(CACHE_PATH, "utf-8");
328
+ if (content === lastSnapshot) return;
329
+ lastSnapshot = content;
330
+ const cache = JSON.parse(content) as Cache;
331
+ updateCacheSnapshot(cache, content, stat.mtimeMs);
332
+ emitCacheSnapshot(cache);
333
+ for (const [provider, entry] of Object.entries(cache)) {
334
+ emitCacheUpdate(provider as ProviderName, entry);
335
+ }
336
+ } catch {
337
+ // Ignore parse or read errors (likely mid-write)
338
+ }
339
+ };
340
+
341
+ const scheduleEmit = () => {
342
+ if (stopped) return;
343
+ if (debounceTimer) clearTimeout(debounceTimer);
344
+ debounceTimer = setTimeout(() => emitFromCache(), debounceMs);
345
+ };
346
+
347
+ let watcher: fs.FSWatcher | undefined;
348
+ try {
349
+ watcher = fs.watch(CACHE_PATH, scheduleEmit);
350
+ watcher.unref?.();
351
+ } catch {
352
+ watcher = undefined;
353
+ }
354
+
355
+ pollTimer = setInterval(() => emitFromCache(), pollIntervalMs);
356
+ pollTimer.unref?.();
357
+
358
+ return () => {
359
+ stopped = true;
360
+ if (debounceTimer) clearTimeout(debounceTimer);
361
+ if (pollTimer) clearInterval(pollTimer);
362
+ watcher?.close();
363
+ };
364
+ }
365
+
366
+ /**
367
+ * Wait for lock to be released and re-check cache
368
+ * Returns the cache entry if it became fresh while waiting
369
+ */
370
+ async function waitForLockAndRecheck(
371
+ provider: ProviderName,
372
+ ttlMs: number,
373
+ maxWaitMs: number = 3000
374
+ ): Promise<CacheEntry | null> {
375
+ const released = await waitForLockRelease(LOCK_PATH, maxWaitMs);
376
+ if (!released) {
377
+ return null;
378
+ }
379
+
380
+ const cache = readCache();
381
+ const entry = cache[provider];
382
+ if (entry && entry.usage?.error && !isExpectedMissingData(entry.usage.error)) {
383
+ return null;
384
+ }
385
+ if (entry && Date.now() - entry.fetchedAt < ttlMs) {
386
+ return entry;
387
+ }
388
+ return null;
389
+ }
390
+
391
+ /**
392
+ * Get cached data for a provider if fresh, or null if stale/missing
393
+ */
394
+ export async function getCachedData(
395
+ provider: ProviderName,
396
+ ttlMs: number,
397
+ cacheSnapshot?: Cache
398
+ ): Promise<CacheEntry | null> {
399
+ const cache = cacheSnapshot ?? readCache();
400
+ const entry = cache[provider];
401
+
402
+ if (!entry) {
403
+ return null;
404
+ }
405
+
406
+ if (entry.usage?.error && !isExpectedMissingData(entry.usage.error)) {
407
+ return null;
408
+ }
409
+
410
+ const age = Date.now() - entry.fetchedAt;
411
+ if (age < ttlMs) {
412
+ return entry;
413
+ }
414
+
415
+ return null;
416
+ }
417
+
418
+ /**
419
+ * Fetch data with lock coordination
420
+ * Returns cached data if fresh, or executes fetchFn if cache is stale
421
+ */
422
+ export async function fetchWithCache<T extends { usage?: UsageSnapshot; status?: ProviderStatus; statusFetchedAt?: number }>(
423
+ provider: ProviderName,
424
+ ttlMs: number,
425
+ fetchFn: () => Promise<T>,
426
+ options?: { force?: boolean }
427
+ ): Promise<T> {
428
+ const forceRefresh = options?.force === true;
429
+
430
+ if (!forceRefresh) {
431
+ // Check cache first
432
+ const cached = await getCachedData(provider, ttlMs);
433
+ if (cached) {
434
+ return { usage: cached.usage, status: cached.status } as T;
435
+ }
436
+ }
437
+
438
+ // Cache is stale or forced refresh, try to acquire lock
439
+ const lockToken = tryAcquireFileLock(LOCK_PATH, LOCK_TIMEOUT_MS);
440
+
441
+ if (!lockToken) {
442
+ // Another process is fetching. Re-check once, then skip duplicate fetch work.
443
+ const freshEntry = await waitForLockAndRecheck(provider, ttlMs);
444
+ if (freshEntry) {
445
+ return { usage: freshEntry.usage, status: freshEntry.status } as T;
446
+ }
447
+ const cache = readCache();
448
+ const staleEntry = cache[provider];
449
+ if (staleEntry) {
450
+ return { usage: staleEntry.usage, status: staleEntry.status } as T;
451
+ }
452
+ return {} as T;
453
+ }
454
+
455
+ try {
456
+ // Fetch fresh data
457
+ const result = await fetchFn();
458
+
459
+ // Only cache if we got valid usage data (not just no-credentials/errors)
460
+ const hasCredentialError = result.usage?.error && isExpectedMissingData(result.usage.error);
461
+ const hasError = Boolean(result.usage?.error);
462
+ const shouldCache = result.usage && !hasCredentialError && !hasError;
463
+
464
+ const cache = readCache();
465
+
466
+ if (shouldCache) {
467
+ // Update cache with valid data
468
+ const fetchedAt = Date.now();
469
+ const previous = cache[provider];
470
+ const statusFetchedAt = result.statusFetchedAt ?? (result.status ? fetchedAt : previous?.statusFetchedAt);
471
+ cache[provider] = {
472
+ fetchedAt,
473
+ statusFetchedAt,
474
+ usage: result.usage,
475
+ status: result.status,
476
+ };
477
+ writeCache(cache);
478
+ emitCacheUpdate(provider, cache[provider]);
479
+ emitCacheSnapshot(cache);
480
+ } else if (hasCredentialError) {
481
+ // Remove from cache if no credentials
482
+ if (cache[provider]) {
483
+ delete cache[provider];
484
+ writeCache(cache);
485
+ emitCacheUpdate(provider, undefined);
486
+ emitCacheSnapshot(cache);
487
+ }
488
+ }
489
+
490
+ return result;
491
+ } finally {
492
+ if (lockToken) {
493
+ releaseFileLock(LOCK_PATH, lockToken);
494
+ }
495
+ }
496
+ }
497
+
498
+ export async function updateCacheStatus(
499
+ provider: ProviderName,
500
+ status: ProviderStatus,
501
+ options?: { statusFetchedAt?: number }
502
+ ): Promise<void> {
503
+ const lockToken = tryAcquireFileLock(LOCK_PATH, LOCK_TIMEOUT_MS);
504
+ if (!lockToken) {
505
+ return;
506
+ }
507
+ try {
508
+ const cache = readCache();
509
+ const entry = cache[provider];
510
+ const statusFetchedAt = options?.statusFetchedAt ?? Date.now();
511
+ cache[provider] = {
512
+ fetchedAt: entry?.fetchedAt ?? 0,
513
+ statusFetchedAt,
514
+ usage: entry?.usage,
515
+ status,
516
+ };
517
+ writeCache(cache);
518
+ emitCacheUpdate(provider, cache[provider]);
519
+ emitCacheSnapshot(cache);
520
+ } finally {
521
+ if (lockToken) {
522
+ releaseFileLock(LOCK_PATH, lockToken);
523
+ }
524
+ }
525
+ }
526
+
527
+ /**
528
+ * Clear cache for a specific provider or all providers
529
+ */
530
+ export function clearCache(provider?: ProviderName): void {
531
+ const storage = getStorage();
532
+ if (provider) {
533
+ const cache = readCache();
534
+ delete cache[provider];
535
+ writeCache(cache);
536
+ } else {
537
+ try {
538
+ if (storage.exists(CACHE_PATH)) {
539
+ storage.removeFile(CACHE_PATH);
540
+ }
541
+ resetCacheSnapshot();
542
+ } catch (error) {
543
+ console.error("Failed to clear cache:", error);
544
+ }
545
+ }
546
+ }
package/src/config.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Configuration constants for the sub-bar extension
3
+ */
4
+
5
+ /**
6
+ * Google Workspace status API endpoint
7
+ */
8
+ export const GOOGLE_STATUS_URL = "https://www.google.com/appsstatus/dashboard/incidents.json";
9
+
10
+ /**
11
+ * Google product ID for Gemini in the status API
12
+ */
13
+ export const GEMINI_PRODUCT_ID = "npdyhgECDJ6tB66MxXyo";
14
+
15
+ /**
16
+ * Model multipliers for Copilot request counting
17
+ * Maps model display names to their request multiplier
18
+ */
19
+ export { MODEL_MULTIPLIERS } from "@eiei114/pi-sub-shared";
20
+
21
+ /**
22
+ * Timeout for API requests in milliseconds
23
+ */
24
+ export const API_TIMEOUT_MS = 5000;
25
+
26
+ /**
27
+ * Timeout for CLI commands in milliseconds
28
+ */
29
+ export const CLI_TIMEOUT_MS = 10000;
30
+
31
+ /**
32
+ * Interval for automatic usage refresh in milliseconds
33
+ */
34
+ export const REFRESH_INTERVAL_MS = 60_000;
35
+
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Default dependencies using real implementations
3
+ */
4
+
5
+ import * as fs from "node:fs";
6
+ import * as os from "node:os";
7
+ import { execFileSync } from "node:child_process";
8
+ import type { ExecFileSyncOptionsWithStringEncoding } from "node:child_process";
9
+ import type { Dependencies } from "./types.js";
10
+
11
+ /**
12
+ * Create default dependencies using Node.js APIs
13
+ */
14
+ export function createDefaultDependencies(): Dependencies {
15
+ return {
16
+ fetch: globalThis.fetch,
17
+ readFile: (path: string) => {
18
+ try {
19
+ return fs.readFileSync(path, "utf-8");
20
+ } catch {
21
+ return undefined;
22
+ }
23
+ },
24
+ fileExists: (path: string) => {
25
+ try {
26
+ return fs.existsSync(path);
27
+ } catch {
28
+ return false;
29
+ }
30
+ },
31
+ execFileSync: (file: string, args: string[], options?: ExecFileSyncOptionsWithStringEncoding) => {
32
+ return execFileSync(file, args, options) as string;
33
+ },
34
+ homedir: () => os.homedir(),
35
+ env: process.env,
36
+ };
37
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Error utilities for the sub-bar extension
3
+ */
4
+
5
+ import type { UsageError, UsageErrorCode } from "./types.js";
6
+
7
+ export function createError(code: UsageErrorCode, message: string, httpStatus?: number): UsageError {
8
+ return { code, message, httpStatus };
9
+ }
10
+
11
+ export function noCredentials(): UsageError {
12
+ return createError("NO_CREDENTIALS", "No credentials found");
13
+ }
14
+
15
+ export function noCli(cliName: string): UsageError {
16
+ return createError("NO_CLI", `${cliName} CLI not found`);
17
+ }
18
+
19
+ export function notLoggedIn(): UsageError {
20
+ return createError("NOT_LOGGED_IN", "Not logged in");
21
+ }
22
+
23
+ export function fetchFailed(reason?: string): UsageError {
24
+ return createError("FETCH_FAILED", reason ?? "Fetch failed");
25
+ }
26
+
27
+ export function httpError(status: number): UsageError {
28
+ return createError("HTTP_ERROR", `HTTP ${status}`, status);
29
+ }
30
+
31
+ export function apiError(message: string): UsageError {
32
+ return createError("API_ERROR", message);
33
+ }
34
+
35
+ export function timeout(): UsageError {
36
+ return createError("TIMEOUT", "Request timed out");
37
+ }
38
+
39
+ /**
40
+ * Check if an error should be considered "no data available" vs actual error
41
+ * These are expected states when provider isn't configured
42
+ */
43
+ export function isExpectedMissingData(error: UsageError): boolean {
44
+ const ignoreCodes = new Set<UsageErrorCode>(["NO_CREDENTIALS", "NO_CLI", "NOT_LOGGED_IN"]);
45
+ return ignoreCodes.has(error.code);
46
+ }
47
+
48
+ /**
49
+ * Format error for display in the usage widget
50
+ */
51
+ export function formatErrorForDisplay(error: UsageError): string {
52
+ switch (error.code) {
53
+ case "NO_CREDENTIALS":
54
+ return "No creds";
55
+ case "NO_CLI":
56
+ return "No CLI";
57
+ case "NOT_LOGGED_IN":
58
+ return "Not logged in";
59
+ case "HTTP_ERROR":
60
+ if (error.httpStatus === 401) {
61
+ return "token no longer valid – please /login again";
62
+ }
63
+ return `${error.httpStatus}`;
64
+ case "FETCH_FAILED":
65
+ case "API_ERROR":
66
+ case "TIMEOUT":
67
+ case "UNKNOWN":
68
+ default:
69
+ return "Fetch failed";
70
+ }
71
+ }