oc-go-usage-display 1.0.2 → 1.1.0

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/src/index.ts CHANGED
@@ -32,8 +32,23 @@
32
32
  import type { Plugin } from "@opencode-ai/plugin";
33
33
  import { tool } from "@opencode-ai/plugin";
34
34
  import * as fs from "node:fs";
35
- import * as os from "node:os";
36
35
  import * as path from "node:path";
36
+ import {
37
+ CONFIG_DIR,
38
+ extractSnapshotFromApiPayload,
39
+ extractWindow,
40
+ formatResetDuration,
41
+ isRecord,
42
+ mockSnapshot,
43
+ readAuthJsonApiKey,
44
+ toFiniteNumber,
45
+ toNonEmptyString,
46
+ unavailableSnapshot,
47
+ } from "./shared.js";
48
+ import type { UsageSnapshot, UsageWindow } from "./shared.js";
49
+
50
+ // Re-exported so `dist/index.js` keeps the helper surface used by tests.
51
+ export { formatResetDuration };
37
52
 
38
53
  // ---------------------------------------------------------------------------
39
54
  // Constants
@@ -43,82 +58,25 @@ const API_USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
43
58
  const CACHE_TTL_MS = 60_000;
44
59
  const FETCH_TIMEOUT_MS = 10_000;
45
60
 
46
- const CONFIG_DIR = path.join(os.homedir(), ".config", "opencode");
47
61
  const FILE_CONFIG_PATH = path.join(CONFIG_DIR, "oc-go-usage-display.json");
48
62
  const DISK_CACHE_PATH = path.join(CONFIG_DIR, "oc-go-usage-display-cache.json");
49
63
 
50
- function dataShareAuthPath(): string {
51
- const xdgDataHome = toNonEmptyString(process.env.XDG_DATA_HOME);
52
- if (xdgDataHome) return path.join(xdgDataHome, "opencode", "auth.json");
53
- return path.join(os.homedir(), ".local", "share", "opencode", "auth.json");
54
- }
55
-
56
- function authJsonPaths(): string[] {
57
- return [dataShareAuthPath(), path.join(CONFIG_DIR, "auth.json")];
58
- }
59
-
60
64
  // ---------------------------------------------------------------------------
61
65
  // Trusted types (parsed at the boundary, trusted internally)
62
66
  // ---------------------------------------------------------------------------
63
67
 
64
- type UsageWindow = {
65
- percent: number;
66
- resetInSec: number | null;
67
- status: string | null;
68
- resetText: string | null;
69
- };
70
-
71
- type UsageSnapshot = {
72
- rolling: UsageWindow | null;
73
- weekly: UsageWindow | null;
74
- monthly: UsageWindow | null;
75
- source: "api" | "scrape" | "mock" | "unavailable";
76
- fetchedAt: number;
77
- apiUnavailable?: boolean;
78
- apiError?: string;
79
- };
80
-
81
68
  type Credentials =
82
69
  | { kind: "apiKey"; apiKey: string }
83
70
  | { kind: "cookie"; workspaceId: string; authCookie: string }
84
71
  | { kind: "mock" }
85
72
  | { kind: "none" };
86
73
 
87
- // ---------------------------------------------------------------------------
88
- // Small pure helpers
89
- // ---------------------------------------------------------------------------
90
-
91
- function isRecord(value: unknown): value is Record<string, unknown> {
92
- return typeof value === "object" && value !== null && !Array.isArray(value);
93
- }
94
-
95
- function toFiniteNumber(value: unknown): number | null {
96
- if (typeof value !== "number" || !Number.isFinite(value)) return null;
97
- return value;
98
- }
99
-
100
- function toNonEmptyString(value: unknown): string | null {
101
- if (typeof value !== "string") return null;
102
- const trimmed = value.trim();
103
- return trimmed.length > 0 ? trimmed : null;
104
- }
105
-
106
- function formatResetDuration(totalSec: number | null): string | null {
107
- if (totalSec === null || !Number.isFinite(totalSec) || totalSec < 0) return null;
108
- const sec = Math.floor(totalSec);
109
- const hours = Math.floor(sec / 3600);
110
- const minutes = Math.floor((sec % 3600) / 60);
111
- if (hours > 0) return `${hours}h${minutes}m`;
112
- if (minutes > 0) return `${minutes}m`;
113
- return `${sec}s`;
114
- }
115
-
116
74
  function formatWindow(window: UsageWindow | null): string {
117
75
  if (!window) return "n/a";
118
76
  return `${window.percent}%`;
119
77
  }
120
78
 
121
- function formatCompactLine(snapshot: UsageSnapshot): string {
79
+ export function formatCompactLine(snapshot: UsageSnapshot): string {
122
80
  if (snapshot.apiUnavailable || (!snapshot.rolling && !snapshot.weekly && !snapshot.monthly)) {
123
81
  const reason = snapshot.apiError ?? "unknown error";
124
82
  return `Go n/a (${reason})`;
@@ -158,31 +116,6 @@ function readFileConfig(): { workspaceId: string | null; authCookie: string | nu
158
116
  };
159
117
  }
160
118
 
161
- function readAuthJsonApiKey(): string | null {
162
- for (const authPath of authJsonPaths()) {
163
- let raw: string;
164
- try {
165
- raw = fs.readFileSync(authPath, "utf8");
166
- } catch {
167
- continue;
168
- }
169
- let parsed: unknown;
170
- try {
171
- parsed = JSON.parse(raw);
172
- } catch {
173
- continue;
174
- }
175
- if (!isRecord(parsed)) continue;
176
- const goEntry = parsed["opencode-go"];
177
- const fallbackEntry = parsed["opencode"];
178
- const goKey = isRecord(goEntry) ? toNonEmptyString(goEntry.key) : null;
179
- if (goKey) return goKey;
180
- const fallbackKey = isRecord(fallbackEntry) ? toNonEmptyString(fallbackEntry.key) : null;
181
- if (fallbackKey) return fallbackKey;
182
- }
183
- return null;
184
- }
185
-
186
119
  function resolveCredentials(): Credentials {
187
120
  if (process.env.OPENCODE_GO_MOCK === "1") return { kind: "mock" };
188
121
 
@@ -229,6 +162,13 @@ function readDiskCache(now: number): UsageSnapshot | null {
229
162
  if (!isRecord(parsed.snapshot)) return null;
230
163
  const snapshot = parsed.snapshot;
231
164
  if (!("rolling" in snapshot && "weekly" in snapshot && "monthly" in snapshot)) return null;
165
+ // Validate cached windows instead of blindly trusting the shape; a corrupt
166
+ // entry is dropped so the next fetch repopulates the cache.
167
+ for (const key of ["rolling", "weekly", "monthly"] as const) {
168
+ const cachedWindow = snapshot[key];
169
+ if (cachedWindow === null) continue;
170
+ if (extractWindow(cachedWindow) === null) return null;
171
+ }
232
172
  return snapshot as UsageSnapshot;
233
173
  }
234
174
 
@@ -249,18 +189,6 @@ function writeDiskCache(snapshot: UsageSnapshot): void {
249
189
  // Fetch helpers
250
190
  // ---------------------------------------------------------------------------
251
191
 
252
- function unavailableSnapshot(error: string, source: UsageSnapshot["source"] = "unavailable"): UsageSnapshot {
253
- return {
254
- rolling: null,
255
- weekly: null,
256
- monthly: null,
257
- source,
258
- fetchedAt: Date.now(),
259
- apiUnavailable: true,
260
- apiError: error,
261
- };
262
- }
263
-
264
192
  async function fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
265
193
  const controller = new AbortController();
266
194
  const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
@@ -271,47 +199,8 @@ async function fetchWithTimeout(url: string, init: RequestInit): Promise<Respons
271
199
  }
272
200
  }
273
201
 
274
- // --- API-key path: tolerant JSON parsing (shape may evolve) ---
275
-
276
- function extractWindow(candidate: unknown): UsageWindow | null {
277
- if (!isRecord(candidate)) return null;
278
- const percent = toFiniteNumber(
279
- candidate.percent ??
280
- candidate.usagePercent ??
281
- candidate.usedPercent ??
282
- candidate.value ??
283
- candidate.usage,
284
- );
285
- if (percent === null) return null;
286
- const resetInSec = toFiniteNumber(candidate.resetInSec ?? candidate.resetInSeconds ?? null);
287
- const status = toNonEmptyString(candidate.status);
288
- const resetText = toNonEmptyString(candidate.resetText ?? candidate.reset ?? null);
289
- return { percent: Math.round(percent), resetInSec, status, resetText };
290
- }
291
-
292
- function extractSnapshotFromApiPayload(payload: unknown): UsageSnapshot | null {
293
- if (!isRecord(payload)) return null;
294
- const containers: unknown[] = [payload];
295
- for (const key of ["usage", "data", "go"]) {
296
- if (isRecord(payload[key])) containers.push(payload[key]);
297
- }
298
- for (const container of containers) {
299
- if (!isRecord(container)) continue;
300
- const rolling = extractWindow(container.rolling ?? container.rollingUsage ?? container["5h"]);
301
- const weekly = extractWindow(container.weekly ?? container.weeklyUsage ?? container["7d"]);
302
- const monthly = extractWindow(container.monthly ?? container.monthlyUsage ?? container["30d"]);
303
- if (rolling || weekly || monthly) {
304
- return {
305
- rolling,
306
- weekly,
307
- monthly,
308
- source: "api",
309
- fetchedAt: Date.now(),
310
- };
311
- }
312
- }
313
- return null;
314
- }
202
+ // --- API-key path: tolerant JSON parsing lives in `./shared.js` (shape may
203
+ // evolve); scrape helpers below stay server-local. ---
315
204
 
316
205
  async function fetchViaApiKey(apiKey: string): Promise<UsageSnapshot> {
317
206
  let response: Response;
@@ -464,7 +353,7 @@ async function fetchViaCookie(workspaceId: string, authCookie: string): Promise<
464
353
  redirect: "follow",
465
354
  headers: {
466
355
  Cookie: `auth=${authCookie}`,
467
- "User-Agent": "oc-go-usage-display-plugin/1.0",
356
+ "User-Agent": "oc-go-usage-display-plugin",
468
357
  Accept: "text/html,application/xhtml+xml",
469
358
  },
470
359
  });
@@ -497,22 +386,19 @@ async function fetchViaCookie(workspaceId: string, authCookie: string): Promise<
497
386
  };
498
387
  }
499
388
 
500
- function mockSnapshot(): UsageSnapshot {
501
- return {
502
- rolling: { percent: 42, resetInSec: 7543, status: "active", resetText: null },
503
- weekly: { percent: 15, resetInSec: null, status: "active", resetText: null },
504
- monthly: { percent: 61, resetInSec: null, status: "active", resetText: null },
505
- source: "mock",
506
- fetchedAt: Date.now(),
507
- };
508
- }
509
-
510
389
  // ---------------------------------------------------------------------------
511
390
  // Snapshot entry point (cached, never throws, never logs secrets)
512
391
  // ---------------------------------------------------------------------------
513
392
 
514
393
  async function getUsageSnapshot(): Promise<UsageSnapshot> {
515
394
  const now = Date.now();
395
+ // Mock bypasses cache for determinism: a stale disk/memory entry must
396
+ // never shadow the deterministic mock snapshot during tests.
397
+ if (process.env.OPENCODE_GO_MOCK === "1") {
398
+ const snapshot = mockSnapshot();
399
+ memoryCache = { at: now, snapshot };
400
+ return snapshot;
401
+ }
516
402
  if (memoryCache && isFresh(memoryCache.at, now)) return memoryCache.snapshot;
517
403
  const diskCached = readDiskCache(now);
518
404
  if (diskCached) {
package/src/shared.ts ADDED
@@ -0,0 +1,179 @@
1
+ // Shared pure helpers for the server (`src/index.ts`) and TUI (`src/tui.tsx`)
2
+ // targets: auth.json resolution, tolerant API-payload parsing, snapshot
3
+ // builders, and small parsing/formatting primitives.
4
+ //
5
+ // Single source of truth for the duplicated logic so both surfaces parse the
6
+ // usage API identically. Secrets are never logged here. Behavior must stay
7
+ // identical for both consumers (import via `./shared.js` under NodeNext).
8
+
9
+ import * as fs from "node:fs";
10
+ import * as os from "node:os";
11
+ import * as path from "node:path";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Constants
15
+ // ---------------------------------------------------------------------------
16
+
17
+ export const CONFIG_DIR = path.join(os.homedir(), ".config", "opencode");
18
+
19
+ export function dataShareAuthPath(): string {
20
+ const xdgDataHome = toNonEmptyString(process.env.XDG_DATA_HOME);
21
+ if (xdgDataHome) return path.join(xdgDataHome, "opencode", "auth.json");
22
+ return path.join(os.homedir(), ".local", "share", "opencode", "auth.json");
23
+ }
24
+
25
+ export function authJsonPaths(): string[] {
26
+ return [dataShareAuthPath(), path.join(CONFIG_DIR, "auth.json")];
27
+ }
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Trusted types (parsed at the boundary, trusted internally)
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export type UsageWindow = {
34
+ percent: number;
35
+ resetInSec: number | null;
36
+ status: string | null;
37
+ resetText: string | null;
38
+ };
39
+
40
+ export type UsageSnapshot = {
41
+ rolling: UsageWindow | null;
42
+ weekly: UsageWindow | null;
43
+ monthly: UsageWindow | null;
44
+ source: "api" | "scrape" | "mock" | "unavailable";
45
+ fetchedAt: number;
46
+ apiUnavailable?: boolean;
47
+ apiError?: string;
48
+ };
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Small pure helpers
52
+ // ---------------------------------------------------------------------------
53
+
54
+ export function isRecord(value: unknown): value is Record<string, unknown> {
55
+ return typeof value === "object" && value !== null && !Array.isArray(value);
56
+ }
57
+
58
+ export function toFiniteNumber(value: unknown): number | null {
59
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
60
+ return value;
61
+ }
62
+
63
+ export function toNonEmptyString(value: unknown): string | null {
64
+ if (typeof value !== "string") return null;
65
+ const trimmed = value.trim();
66
+ return trimmed.length > 0 ? trimmed : null;
67
+ }
68
+
69
+ export function formatResetDuration(totalSec: number | null): string | null {
70
+ if (totalSec === null || !Number.isFinite(totalSec) || totalSec < 0) return null;
71
+ const sec = Math.floor(totalSec);
72
+ const hours = Math.floor(sec / 3600);
73
+ const minutes = Math.floor((sec % 3600) / 60);
74
+ if (hours > 0) return `${hours}h${minutes}m`;
75
+ if (minutes > 0) return `${minutes}m`;
76
+ return `${sec}s`;
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Credentials boundary (auth.json; secrets never logged)
81
+ // ---------------------------------------------------------------------------
82
+
83
+ export function readAuthJsonApiKey(): string | null {
84
+ for (const authPath of authJsonPaths()) {
85
+ let raw: string;
86
+ try {
87
+ raw = fs.readFileSync(authPath, "utf8");
88
+ } catch {
89
+ continue;
90
+ }
91
+ let parsed: unknown;
92
+ try {
93
+ parsed = JSON.parse(raw);
94
+ } catch {
95
+ continue;
96
+ }
97
+ if (!isRecord(parsed)) continue;
98
+ const goEntry = parsed["opencode-go"];
99
+ const fallbackEntry = parsed["opencode"];
100
+ const goKey = isRecord(goEntry) ? toNonEmptyString(goEntry.key) : null;
101
+ if (goKey) return goKey;
102
+ const fallbackKey = isRecord(fallbackEntry) ? toNonEmptyString(fallbackEntry.key) : null;
103
+ if (fallbackKey) return fallbackKey;
104
+ }
105
+ return null;
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // API payload boundary (tolerant JSON parsing; shape may evolve)
110
+ // ---------------------------------------------------------------------------
111
+
112
+ export function extractWindow(candidate: unknown): UsageWindow | null {
113
+ if (!isRecord(candidate)) return null;
114
+ const percent = toFiniteNumber(
115
+ candidate.percent ??
116
+ candidate.usagePercent ??
117
+ candidate.usedPercent ??
118
+ candidate.value ??
119
+ candidate.usage,
120
+ );
121
+ if (percent === null) return null;
122
+ const resetInSec = toFiniteNumber(candidate.resetInSec ?? candidate.resetInSeconds ?? null);
123
+ const status = toNonEmptyString(candidate.status);
124
+ const resetText = toNonEmptyString(candidate.resetText ?? candidate.reset ?? null);
125
+ return { percent: Math.round(percent), resetInSec, status, resetText };
126
+ }
127
+
128
+ export function extractSnapshotFromApiPayload(payload: unknown): UsageSnapshot | null {
129
+ if (!isRecord(payload)) return null;
130
+ const containers: unknown[] = [payload];
131
+ for (const key of ["usage", "data", "go"]) {
132
+ if (isRecord(payload[key])) containers.push(payload[key]);
133
+ }
134
+ for (const container of containers) {
135
+ if (!isRecord(container)) continue;
136
+ const rolling = extractWindow(container.rolling ?? container.rollingUsage ?? container["5h"]);
137
+ const weekly = extractWindow(container.weekly ?? container.weeklyUsage ?? container["7d"]);
138
+ const monthly = extractWindow(container.monthly ?? container.monthlyUsage ?? container["30d"]);
139
+ if (rolling !== null || weekly !== null || monthly !== null) {
140
+ return {
141
+ rolling,
142
+ weekly,
143
+ monthly,
144
+ source: "api",
145
+ fetchedAt: Date.now(),
146
+ };
147
+ }
148
+ }
149
+ return null;
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Snapshot builders
154
+ // ---------------------------------------------------------------------------
155
+
156
+ export function mockSnapshot(): UsageSnapshot {
157
+ return {
158
+ rolling: { percent: 42, resetInSec: 7543, status: "active", resetText: null },
159
+ weekly: { percent: 15, resetInSec: null, status: "active", resetText: null },
160
+ monthly: { percent: 61, resetInSec: null, status: "active", resetText: null },
161
+ source: "mock",
162
+ fetchedAt: Date.now(),
163
+ };
164
+ }
165
+
166
+ export function unavailableSnapshot(
167
+ error: string,
168
+ source: UsageSnapshot["source"] = "unavailable",
169
+ ): UsageSnapshot {
170
+ return {
171
+ rolling: null,
172
+ weekly: null,
173
+ monthly: null,
174
+ source,
175
+ fetchedAt: Date.now(),
176
+ apiUnavailable: true,
177
+ apiError: error,
178
+ };
179
+ }