@cad0p/pi-usage 0.0.0-stage → 0.11.2
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/LICENSE +21 -0
- package/README.md +509 -2
- package/bin/piquota.js +199 -0
- package/extensions/quota-panel.ts +963 -0
- package/package.json +59 -4
- package/src/auth/claude-code-auth.js +490 -0
- package/src/auth/jwt.js +84 -0
- package/src/auth/pi-auth.js +424 -0
- package/src/browser/cookies.js +557 -0
- package/src/browser/history.js +173 -0
- package/src/cache.js +224 -0
- package/src/cli/args.js +154 -0
- package/src/cli/commands/auth.js +216 -0
- package/src/cli/commands/gentle-pi.js +44 -0
- package/src/cli/commands/omarchy.js +75 -0
- package/src/cli/explain.js +99 -0
- package/src/cli/output.js +34 -0
- package/src/engine.js +404 -0
- package/src/exec.js +31 -0
- package/src/gentle-pi/rail-patch.js +335 -0
- package/src/http.js +182 -0
- package/src/model.js +332 -0
- package/src/omarchy/assets/antigravity.svg +3 -0
- package/src/omarchy/assets/claude.svg +1 -0
- package/src/omarchy/assets/codex.svg +1 -0
- package/src/omarchy/assets/github-actions.svg +3 -0
- package/src/omarchy/assets/opencode-go.svg +3 -0
- package/src/omarchy/publish.js +152 -0
- package/src/omarchy/record.js +462 -0
- package/src/opencode/dashboard.js +337 -0
- package/src/opencode/session.js +485 -0
- package/src/providers/antigravity-oauth.js +173 -0
- package/src/providers/antigravity.js +225 -0
- package/src/providers/backoff.js +146 -0
- package/src/providers/claude.js +194 -0
- package/src/providers/codex.js +151 -0
- package/src/providers/error-kind.js +128 -0
- package/src/providers/github-actions.js +379 -0
- package/src/providers/opencode-go.js +274 -0
- package/src/refresh.js +292 -0
- package/src/render/panel.js +411 -0
- package/src/render/theme.js +102 -0
- package/src/sticky.js +319 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only reader for Pi's provider credential store (`auth.json`).
|
|
3
|
+
*
|
|
4
|
+
* Hard rules enforced by this module:
|
|
5
|
+
* - open the file with read-only flags and never write, move or chmod it;
|
|
6
|
+
* - never import, refresh or "sync" tokens back into it;
|
|
7
|
+
* - never emit token material through logs, errors or JSON output.
|
|
8
|
+
*
|
|
9
|
+
* On WSL the same Pi installation may also exist on the Windows drive. We read
|
|
10
|
+
* both when present and de-duplicate by provider identity so a shared account is
|
|
11
|
+
* never shown twice.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
import { extractChatGptAccountId, extractChatGptPlanType, extractEmail, extractExpiresAtMs } from "./jwt.js";
|
|
20
|
+
import { redact } from "../http.js";
|
|
21
|
+
|
|
22
|
+
/** Pi provider key -> normalized quota family. */
|
|
23
|
+
export const FAMILY_BY_PROVIDER = {
|
|
24
|
+
"openai-codex": "codex",
|
|
25
|
+
anthropic: "claude",
|
|
26
|
+
antigravity: "antigravity",
|
|
27
|
+
"opencode-go": "opencode-go",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Display label used by every surface (terminal, Pi TUI, Moshi cards). */
|
|
31
|
+
export const LABEL_BY_FAMILY = {
|
|
32
|
+
codex: "Codex (Pi)",
|
|
33
|
+
claude: "Claude (Pi)",
|
|
34
|
+
antigravity: "Antigravity (Pi)",
|
|
35
|
+
"opencode-go": "OpenCode Go (Pi)",
|
|
36
|
+
"github-actions": "GitHub Actions",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const WINDOWS_USERS_ROOT = "/mnt/c/Users";
|
|
40
|
+
const MAX_WINDOWS_PROFILES = 40;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @typedef {Object} PiCredential
|
|
44
|
+
* @property {string} provider Raw Pi provider key, e.g. "openai-codex".
|
|
45
|
+
* @property {string} family Normalized family, e.g. "codex".
|
|
46
|
+
* @property {string | null} slot Numbered sibling account, e.g. "2" for
|
|
47
|
+
* "opencode-go-2"; null for the base entry.
|
|
48
|
+
* @property {string} label Display label, e.g. "Codex (Pi)".
|
|
49
|
+
* @property {string} source Absolute path the credential came from.
|
|
50
|
+
* @property {"pi" | "claude-code"} [sourceKind] Which kind of store produced it.
|
|
51
|
+
* @property {string} identity Stable per-provider identity (never a token).
|
|
52
|
+
* @property {string} [access] OAuth access token (memory only).
|
|
53
|
+
* @property {string} [refresh] OAuth refresh token (memory only).
|
|
54
|
+
* @property {string} [key] API key (memory only).
|
|
55
|
+
* @property {number | null} expiresAtMs Epoch milliseconds when known.
|
|
56
|
+
* @property {string | null} accountId
|
|
57
|
+
* @property {string | null} projectId
|
|
58
|
+
* @property {string | null} email
|
|
59
|
+
* @property {string | null} planType
|
|
60
|
+
* @property {"oauth" | "api_key"} kind
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @typedef {Object} PiAuthLoadResult
|
|
65
|
+
* @property {PiCredential[]} credentials
|
|
66
|
+
* @property {string[]} paths Store paths that were read.
|
|
67
|
+
* @property {string[]} warnings Non-fatal issues (already redacted).
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {Record<string, string | undefined>} env
|
|
72
|
+
* @returns {string[]}
|
|
73
|
+
*/
|
|
74
|
+
function explicitAuthPaths(env) {
|
|
75
|
+
const paths = [];
|
|
76
|
+
for (const key of ["PI_QUOTA_AUTH_PATH", "PI_AUTH_PATH"]) {
|
|
77
|
+
const value = env[key];
|
|
78
|
+
if (value) paths.push(value);
|
|
79
|
+
}
|
|
80
|
+
return paths;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Windows-side Pi stores reachable from WSL, discovered without spawning cmd.exe.
|
|
85
|
+
*
|
|
86
|
+
* @param {{ platform?: string, usersRoot?: string }} [options]
|
|
87
|
+
* @returns {string[]}
|
|
88
|
+
*/
|
|
89
|
+
export function discoverWindowsAuthPaths(options = {}) {
|
|
90
|
+
const platform = options.platform ?? process.platform;
|
|
91
|
+
const usersRoot = options.usersRoot ?? WINDOWS_USERS_ROOT;
|
|
92
|
+
if (platform === "win32") return [];
|
|
93
|
+
if (!existsSync(usersRoot)) return [];
|
|
94
|
+
|
|
95
|
+
/** @type {string[]} */
|
|
96
|
+
const found = [];
|
|
97
|
+
let entries = [];
|
|
98
|
+
try {
|
|
99
|
+
entries = readdirSync(usersRoot, { withFileTypes: true });
|
|
100
|
+
} catch {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let scanned = 0;
|
|
105
|
+
for (const entry of entries) {
|
|
106
|
+
if (scanned >= MAX_WINDOWS_PROFILES) break;
|
|
107
|
+
if (!entry.isDirectory()) continue;
|
|
108
|
+
const name = entry.name;
|
|
109
|
+
if (name === "Default" || name === "Default User" || name === "All Users" || name === "Public") continue;
|
|
110
|
+
scanned += 1;
|
|
111
|
+
const candidate = join(usersRoot, name, ".pi", "agent", "auth.json");
|
|
112
|
+
if (existsSync(candidate)) found.push(candidate);
|
|
113
|
+
}
|
|
114
|
+
return found;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Candidate store paths, in priority order, without duplicates.
|
|
119
|
+
*
|
|
120
|
+
* @param {{
|
|
121
|
+
* env?: Record<string, string | undefined>,
|
|
122
|
+
* home?: string,
|
|
123
|
+
* platform?: string,
|
|
124
|
+
* usersRoot?: string,
|
|
125
|
+
* }} [options]
|
|
126
|
+
* @returns {string[]}
|
|
127
|
+
*/
|
|
128
|
+
export function resolveAuthPaths(options = {}) {
|
|
129
|
+
const env = options.env ?? process.env;
|
|
130
|
+
const home = options.home ?? homedir();
|
|
131
|
+
const explicit = explicitAuthPaths(env);
|
|
132
|
+
if (explicit.length > 0) return dedupePaths(explicit);
|
|
133
|
+
|
|
134
|
+
const candidates = [
|
|
135
|
+
join(home, ".pi", "agent", "auth.json"),
|
|
136
|
+
...discoverWindowsAuthPaths({ platform: options.platform, usersRoot: options.usersRoot }),
|
|
137
|
+
];
|
|
138
|
+
return dedupePaths(candidates.filter((path) => existsSync(path)));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @param {string[]} paths
|
|
143
|
+
* @returns {string[]}
|
|
144
|
+
*/
|
|
145
|
+
function dedupePaths(paths) {
|
|
146
|
+
const seen = new Set();
|
|
147
|
+
const out = [];
|
|
148
|
+
for (const path of paths) {
|
|
149
|
+
let key = path;
|
|
150
|
+
try {
|
|
151
|
+
key = realpathSync(path);
|
|
152
|
+
} catch {
|
|
153
|
+
// Keep the unresolved path when realpath fails; existsSync already ran.
|
|
154
|
+
}
|
|
155
|
+
if (seen.has(key)) continue;
|
|
156
|
+
seen.add(key);
|
|
157
|
+
out.push(path);
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* @param {unknown} value
|
|
164
|
+
* @returns {string | null}
|
|
165
|
+
*/
|
|
166
|
+
function text(value) {
|
|
167
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Milliseconds since epoch below which we treat a stored timestamp as SECONDS.
|
|
172
|
+
* Current second-epochs are ~1.8e9 and millisecond-epochs ~1.8e12, so 1e11
|
|
173
|
+
* separates them with a wide margin (1e11 ms is 1973, 1e11 s is year 5138).
|
|
174
|
+
*/
|
|
175
|
+
const SECONDS_EPOCH_CEILING = 1e11;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* @param {unknown} value
|
|
179
|
+
* @returns {number | null} Epoch milliseconds.
|
|
180
|
+
*/
|
|
181
|
+
function epochMs(value) {
|
|
182
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
|
183
|
+
return value > SECONDS_EPOCH_CEILING ? value : value * 1000;
|
|
184
|
+
}
|
|
185
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
186
|
+
const asNumber = Number(value);
|
|
187
|
+
if (Number.isFinite(asNumber) && asNumber > 0) return epochMs(asNumber);
|
|
188
|
+
const parsed = Date.parse(value);
|
|
189
|
+
if (!Number.isNaN(parsed)) return parsed;
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Non-reversible identity fingerprint. Used only for de-duplication.
|
|
196
|
+
*
|
|
197
|
+
* @param {string[]} parts
|
|
198
|
+
* @returns {string}
|
|
199
|
+
*/
|
|
200
|
+
function fingerprint(parts) {
|
|
201
|
+
return createHash("sha256").update(parts.join("\u0000")).digest("hex").slice(0, 16);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Resolve a raw auth.json key onto a family and, when it is a numbered sibling,
|
|
206
|
+
* its slot.
|
|
207
|
+
*
|
|
208
|
+
* Pi's own identifiers are the only base providers. Everything else follows the
|
|
209
|
+
* convention used by multi-account providers: `<base>-<account>`, where the
|
|
210
|
+
* account starts at the FIRST `-<digits>` boundary. That boundary is what makes
|
|
211
|
+
* "opencode-go-2" a slot 2 of "opencode-go" and "opencode-2-work" a slot
|
|
212
|
+
* "2-work" of "opencode" (an unknown base, therefore ignored) rather than a
|
|
213
|
+
* slot "work" of "opencode-2".
|
|
214
|
+
*
|
|
215
|
+
* @param {string} provider
|
|
216
|
+
* @returns {{ base: string, family: string, slot: string | null } | null}
|
|
217
|
+
*/
|
|
218
|
+
export function resolveProviderSlot(provider) {
|
|
219
|
+
if (typeof provider !== "string" || provider === "") return null;
|
|
220
|
+
const family = FAMILY_BY_PROVIDER[provider];
|
|
221
|
+
if (family) return { base: provider, family, slot: null };
|
|
222
|
+
|
|
223
|
+
const match = /^(.*?)-(\d.*)$/.exec(provider);
|
|
224
|
+
if (!match) return null;
|
|
225
|
+
const [, base, account] = match;
|
|
226
|
+
const siblingFamily = FAMILY_BY_PROVIDER[base];
|
|
227
|
+
if (!siblingFamily || account === "") return null;
|
|
228
|
+
return { base, family: siblingFamily, slot: account };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Normalize one raw auth.json entry into a credential.
|
|
233
|
+
*
|
|
234
|
+
* @param {string} provider
|
|
235
|
+
* @param {Record<string, unknown>} raw
|
|
236
|
+
* @param {string} source
|
|
237
|
+
* @returns {PiCredential | null}
|
|
238
|
+
*/
|
|
239
|
+
export function normalizeCredential(provider, raw, source) {
|
|
240
|
+
const resolved = resolveProviderSlot(provider);
|
|
241
|
+
if (!resolved || !raw || typeof raw !== "object") return null;
|
|
242
|
+
const { base, family, slot } = resolved;
|
|
243
|
+
|
|
244
|
+
const kind = raw.type === "api_key" ? "api_key" : "oauth";
|
|
245
|
+
const access = text(raw.access);
|
|
246
|
+
const refresh = text(raw.refresh);
|
|
247
|
+
const key = text(raw.key);
|
|
248
|
+
if (!access && !refresh && !key) return null;
|
|
249
|
+
|
|
250
|
+
const accountId = text(raw.accountId) ?? (access ? extractChatGptAccountId(access) : null);
|
|
251
|
+
const email = text(raw.email) ?? (access ? extractEmail(access) : null);
|
|
252
|
+
const projectId = text(raw.projectId) ?? text(raw.project_id);
|
|
253
|
+
const planType = text(raw.planType) ?? (access ? extractChatGptPlanType(access) : null);
|
|
254
|
+
const expiresAtMs = epochMs(raw.expires) ?? (access ? extractExpiresAtMs(access) : null);
|
|
255
|
+
|
|
256
|
+
// Fingerprint on the BASE provider, not the raw slot id: the same secret in
|
|
257
|
+
// "opencode-go" and "opencode-go-2" must still collapse to one credential,
|
|
258
|
+
// while different secrets in two slots stay distinct.
|
|
259
|
+
const secretMaterial = access ?? key ?? refresh ?? "";
|
|
260
|
+
const identity = accountId ?? email ?? projectId ?? fingerprint([base, secretMaterial]);
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
provider,
|
|
264
|
+
family,
|
|
265
|
+
slot,
|
|
266
|
+
label: LABEL_BY_FAMILY[family],
|
|
267
|
+
source,
|
|
268
|
+
sourceKind: "pi",
|
|
269
|
+
identity,
|
|
270
|
+
access: access ?? undefined,
|
|
271
|
+
refresh: refresh ?? undefined,
|
|
272
|
+
key: key ?? undefined,
|
|
273
|
+
expiresAtMs,
|
|
274
|
+
accountId,
|
|
275
|
+
projectId,
|
|
276
|
+
email,
|
|
277
|
+
planType,
|
|
278
|
+
kind,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Parse one store file. Never throws.
|
|
284
|
+
*
|
|
285
|
+
* @param {string} path
|
|
286
|
+
* @returns {{ credentials: PiCredential[], warnings: string[] }}
|
|
287
|
+
*/
|
|
288
|
+
export function readAuthStore(path) {
|
|
289
|
+
/** @type {PiCredential[]} */
|
|
290
|
+
const credentials = [];
|
|
291
|
+
/** @type {string[]} */
|
|
292
|
+
const warnings = [];
|
|
293
|
+
|
|
294
|
+
let raw;
|
|
295
|
+
try {
|
|
296
|
+
raw = readFileSync(path, { encoding: "utf-8", flag: "r" });
|
|
297
|
+
} catch (error) {
|
|
298
|
+
warnings.push(`cannot read ${path}: ${redact(/** @type {{ message?: string }} */ (error)?.message)}`);
|
|
299
|
+
return { credentials, warnings };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
let parsed;
|
|
303
|
+
try {
|
|
304
|
+
parsed = JSON.parse(raw);
|
|
305
|
+
} catch {
|
|
306
|
+
warnings.push(`cannot parse ${path}: invalid JSON`);
|
|
307
|
+
return { credentials, warnings };
|
|
308
|
+
}
|
|
309
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
310
|
+
warnings.push(`cannot use ${path}: expected a JSON object`);
|
|
311
|
+
return { credentials, warnings };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
for (const [provider, entry] of Object.entries(parsed)) {
|
|
315
|
+
if (!resolveProviderSlot(provider)) continue;
|
|
316
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
317
|
+
warnings.push(`${path}: ${provider} entry is not an object`);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const credential = normalizeCredential(provider, /** @type {Record<string, unknown>} */ (entry), path);
|
|
321
|
+
if (credential) credentials.push(credential);
|
|
322
|
+
}
|
|
323
|
+
return { credentials, warnings, rawProviders: Object.keys(parsed) };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Load every Pi credential from every resolvable store, de-duplicated.
|
|
328
|
+
*
|
|
329
|
+
* @param {{
|
|
330
|
+
* env?: Record<string, string | undefined>,
|
|
331
|
+
* home?: string,
|
|
332
|
+
* platform?: string,
|
|
333
|
+
* usersRoot?: string,
|
|
334
|
+
* paths?: string[],
|
|
335
|
+
* }} [options]
|
|
336
|
+
* @returns {PiAuthLoadResult}
|
|
337
|
+
*/
|
|
338
|
+
export function loadPiCredentials(options = {}) {
|
|
339
|
+
const paths = options.paths ?? resolveAuthPaths(options);
|
|
340
|
+
/** @type {PiCredential[]} */
|
|
341
|
+
const credentials = [];
|
|
342
|
+
/** @type {string[]} */
|
|
343
|
+
const warnings = [];
|
|
344
|
+
/** @type {string[]} */
|
|
345
|
+
const usedPaths = [];
|
|
346
|
+
/** @type {Set<string>} */
|
|
347
|
+
const seen = new Set();
|
|
348
|
+
/** @type {Set<string>} */
|
|
349
|
+
const rawProviders = new Set();
|
|
350
|
+
|
|
351
|
+
for (const path of paths) {
|
|
352
|
+
const result = readAuthStore(path);
|
|
353
|
+
warnings.push(...result.warnings);
|
|
354
|
+
if (result.credentials.length > 0) usedPaths.push(path);
|
|
355
|
+
for (const p of result.rawProviders ?? []) rawProviders.add(p);
|
|
356
|
+
for (const credential of result.credentials) {
|
|
357
|
+
const key = `${credential.family}:${credential.identity}`;
|
|
358
|
+
if (seen.has(key)) continue;
|
|
359
|
+
seen.add(key);
|
|
360
|
+
credentials.push(credential);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
credentials.sort(compareCredentials);
|
|
365
|
+
return { credentials, paths: usedPaths, warnings, rawProviders: Array.from(rawProviders) };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Slot order inside a family: the base entry first, then siblings by their
|
|
370
|
+
* leading number. Plain string comparison would put "#10" before "#2", which
|
|
371
|
+
* reads as a shuffled account list in every renderer.
|
|
372
|
+
*
|
|
373
|
+
* @param {string | null} slot
|
|
374
|
+
* @returns {number}
|
|
375
|
+
*/
|
|
376
|
+
function slotOrder(slot) {
|
|
377
|
+
if (slot === null) return -1;
|
|
378
|
+
const numeric = Number.parseInt(slot, 10);
|
|
379
|
+
return Number.isFinite(numeric) ? numeric : Number.MAX_SAFE_INTEGER;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* @param {PiCredential} a
|
|
384
|
+
* @param {PiCredential} b
|
|
385
|
+
* @returns {number}
|
|
386
|
+
*/
|
|
387
|
+
function compareCredentials(a, b) {
|
|
388
|
+
const byFamily = a.family.localeCompare(b.family);
|
|
389
|
+
if (byFamily !== 0) return byFamily;
|
|
390
|
+
const bySlot = slotOrder(a.slot) - slotOrder(b.slot);
|
|
391
|
+
if (bySlot !== 0) return bySlot;
|
|
392
|
+
return String(a.slot ?? "").localeCompare(String(b.slot ?? ""));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Freshness check driven by the stored expiry. Read-only: this module never
|
|
397
|
+
* refreshes, because a rotated refresh token would invalidate Pi's stored copy.
|
|
398
|
+
*
|
|
399
|
+
* @param {PiCredential} credential
|
|
400
|
+
* @param {number} [now]
|
|
401
|
+
* @returns {{ fresh: boolean, expiresAtMs: number | null, expiresInMin: number | null }}
|
|
402
|
+
*/
|
|
403
|
+
export function checkFreshness(credential, now = Date.now()) {
|
|
404
|
+
const expiresAtMs = credential.expiresAtMs ?? null;
|
|
405
|
+
if (expiresAtMs === null) return { fresh: true, expiresAtMs: null, expiresInMin: null };
|
|
406
|
+
const expiresInMin = Math.round((expiresAtMs - now) / 60000);
|
|
407
|
+
return { fresh: expiresAtMs > now, expiresAtMs, expiresInMin };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Sanity check used by diagnostics: confirm the store exists and is a plain file.
|
|
412
|
+
*
|
|
413
|
+
* @param {string} path
|
|
414
|
+
* @returns {{ exists: boolean, isFile: boolean, sizeBytes: number | null }}
|
|
415
|
+
*/
|
|
416
|
+
export function describeStore(path) {
|
|
417
|
+
if (!existsSync(path)) return { exists: false, isFile: false, sizeBytes: null };
|
|
418
|
+
try {
|
|
419
|
+
const stats = statSync(path);
|
|
420
|
+
return { exists: true, isFile: stats.isFile(), sizeBytes: stats.size };
|
|
421
|
+
} catch {
|
|
422
|
+
return { exists: true, isFile: false, sizeBytes: null };
|
|
423
|
+
}
|
|
424
|
+
}
|