@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,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only Firefox history lookup.
|
|
3
|
+
*
|
|
4
|
+
* Needed because opencode.ai serves the workspace app only at
|
|
5
|
+
* `/workspace/<id>/...`: there is no listing page at `/workspace` (it answers
|
|
6
|
+
* 404 even when authenticated), so the id has to be observed. Firefox records
|
|
7
|
+
* the URL the OAuth flow lands on, which makes its history the reliable source.
|
|
8
|
+
*
|
|
9
|
+
* Read-only: `places.sqlite` (with `-wal`/`-shm`) is copied to a private temp
|
|
10
|
+
* directory and opened there. Only URLs are read, and query strings are never
|
|
11
|
+
* returned to callers.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { DatabaseSync } from "node:sqlite";
|
|
15
|
+
import { copyFileSync, existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
import { firefoxRoots, parseFirefoxProfiles } from "./cookies.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {{ home?: string, usersRoot?: string, platform?: string }} [options]
|
|
23
|
+
* @returns {Array<{ profile: string, path: string }>}
|
|
24
|
+
*/
|
|
25
|
+
export function discoverHistoryStores(options = {}) {
|
|
26
|
+
/** @type {Array<{ profile: string, path: string }>} */
|
|
27
|
+
const stores = [];
|
|
28
|
+
for (const root of firefoxRoots(options)) {
|
|
29
|
+
for (const profile of parseFirefoxProfiles(root)) {
|
|
30
|
+
const path = join(profile.dir, "places.sqlite");
|
|
31
|
+
if (!existsSync(path)) continue;
|
|
32
|
+
const platformPrefix = root.includes("Application Support")
|
|
33
|
+
? "mac"
|
|
34
|
+
: root.endsWith("Firefox")
|
|
35
|
+
? "windows"
|
|
36
|
+
: "linux";
|
|
37
|
+
stores.push({ profile: `${platformPrefix}:${profile.label}`, path });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return stores;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Strip the query string so no token can leak through a URL.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} url
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
export function pathOnly(url) {
|
|
50
|
+
try {
|
|
51
|
+
const parsed = new URL(url);
|
|
52
|
+
return parsed.pathname;
|
|
53
|
+
} catch {
|
|
54
|
+
return String(url).split("?")[0];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Extract workspace ids from a list of URLs, most recent first.
|
|
60
|
+
*
|
|
61
|
+
* @param {string[]} urls
|
|
62
|
+
* @returns {string[]}
|
|
63
|
+
*/
|
|
64
|
+
export function extractWorkspaceIds(urls) {
|
|
65
|
+
/** @type {string[]} */
|
|
66
|
+
const ids = [];
|
|
67
|
+
const ignored = new Set(["settings", "usage", "go", "keys", "members", "billing", "new", "login", "auth"]);
|
|
68
|
+
for (const url of urls) {
|
|
69
|
+
const match = pathOnly(url).match(/\/(?:workspace|console)\/([A-Za-z0-9_-]{4,})/);
|
|
70
|
+
if (!match) continue;
|
|
71
|
+
if (ignored.has(match[1])) continue;
|
|
72
|
+
if (!ids.includes(match[1])) ids.push(match[1]);
|
|
73
|
+
}
|
|
74
|
+
return ids;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Query one history database.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} dbPath
|
|
81
|
+
* @param {{ urlLike: string, limit?: number }} query
|
|
82
|
+
* @returns {string[]}
|
|
83
|
+
*/
|
|
84
|
+
const activeHistoryCleanups = new Set();
|
|
85
|
+
if (typeof process !== "undefined" && typeof process.on === "function") {
|
|
86
|
+
const runCleanups = () => {
|
|
87
|
+
for (const fn of activeHistoryCleanups) {
|
|
88
|
+
try { fn(); } catch {}
|
|
89
|
+
}
|
|
90
|
+
activeHistoryCleanups.clear();
|
|
91
|
+
};
|
|
92
|
+
process.once("exit", runCleanups);
|
|
93
|
+
process.once("SIGINT", () => {
|
|
94
|
+
runCleanups();
|
|
95
|
+
process.exit(130);
|
|
96
|
+
});
|
|
97
|
+
process.once("SIGTERM", () => {
|
|
98
|
+
runCleanups();
|
|
99
|
+
process.exit(143);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function readVisitedUrls(dbPath, query) {
|
|
104
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-quota-history-"));
|
|
105
|
+
const target = join(dir, "places.sqlite");
|
|
106
|
+
const cleanupFn = () => {
|
|
107
|
+
activeHistoryCleanups.delete(cleanupFn);
|
|
108
|
+
try {
|
|
109
|
+
rmSync(dir, { recursive: true, force: true });
|
|
110
|
+
} catch {
|
|
111
|
+
// Best effort.
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
activeHistoryCleanups.add(cleanupFn);
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
118
|
+
const source = `${dbPath}${suffix}`;
|
|
119
|
+
if (existsSync(source)) {
|
|
120
|
+
try {
|
|
121
|
+
copyFileSync(source, `${target}${suffix}`);
|
|
122
|
+
} catch {
|
|
123
|
+
// A locked sidecar is not fatal.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
let database;
|
|
128
|
+
try {
|
|
129
|
+
database = new DatabaseSync(target, { readOnly: true });
|
|
130
|
+
} catch {
|
|
131
|
+
database = new DatabaseSync(target);
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
const rows = database
|
|
135
|
+
.prepare("select url from moz_places where url like ? order by last_visit_date desc limit ?")
|
|
136
|
+
.all(query.urlLike, query.limit ?? 50);
|
|
137
|
+
return /** @type {Array<{ url: string }>} */ (rows).map((row) => row.url).filter(Boolean);
|
|
138
|
+
} finally {
|
|
139
|
+
try {
|
|
140
|
+
database.close();
|
|
141
|
+
} catch {
|
|
142
|
+
// Ignore.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
return [];
|
|
147
|
+
} finally {
|
|
148
|
+
cleanupFn();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Find recently visited workspace ids, newest first.
|
|
154
|
+
*
|
|
155
|
+
* @param {{
|
|
156
|
+
* stores?: Array<{ profile: string, path: string }>,
|
|
157
|
+
* home?: string,
|
|
158
|
+
* usersRoot?: string,
|
|
159
|
+
* platform?: string,
|
|
160
|
+
* }} [options]
|
|
161
|
+
* @returns {{ ids: string[], profile: string | null, scanned: number }}
|
|
162
|
+
*/
|
|
163
|
+
export function findRecentWorkspaceIds(options = {}) {
|
|
164
|
+
const stores = options.stores ?? discoverHistoryStores(options);
|
|
165
|
+
let scanned = 0;
|
|
166
|
+
for (const store of stores) {
|
|
167
|
+
scanned += 1;
|
|
168
|
+
const urls = readVisitedUrls(store.path, { urlLike: "%opencode.ai/%", limit: 100 });
|
|
169
|
+
const ids = extractWorkspaceIds(urls);
|
|
170
|
+
if (ids.length > 0) return { ids, profile: store.profile, scanned };
|
|
171
|
+
}
|
|
172
|
+
return { ids: [], profile: null, scanned };
|
|
173
|
+
}
|
package/src/cache.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local response cache.
|
|
3
|
+
*
|
|
4
|
+
* Holds only the already-normalized report (percentages, windows, labels): no
|
|
5
|
+
* access token, refresh token, API key, cookie or account e-mail beyond the
|
|
6
|
+
* display identity the renderers already print.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_TTL_MS = 60_000;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Merge a partial report into an existing cached report so partial family queries
|
|
17
|
+
* do not clobber other cached providers.
|
|
18
|
+
*
|
|
19
|
+
* @param {import("./engine.js").PiQuotaReport | null | undefined} existingReport
|
|
20
|
+
* @param {import("./engine.js").PiQuotaReport} newReport
|
|
21
|
+
* @returns {import("./engine.js").PiQuotaReport}
|
|
22
|
+
*/
|
|
23
|
+
export function mergeReportIntoCache(existingReport, newReport) {
|
|
24
|
+
if (!existingReport || !existingReport.byFamily) return newReport;
|
|
25
|
+
const mergedByFamily = { ...existingReport.byFamily, ...newReport.byFamily };
|
|
26
|
+
const existingFamilies = Object.keys(existingReport.byFamily);
|
|
27
|
+
const newFamilies = Object.keys(newReport.byFamily || {});
|
|
28
|
+
const allFamilies = Array.from(new Set([...existingFamilies, ...newFamilies]));
|
|
29
|
+
const providers = allFamilies.flatMap((family) => mergedByFamily[family] || []);
|
|
30
|
+
|
|
31
|
+
const keptExistingWarnings = (existingReport.warnings || []).filter((w) => {
|
|
32
|
+
const family = String(w).split(":")[0];
|
|
33
|
+
return !newFamilies.includes(family);
|
|
34
|
+
});
|
|
35
|
+
const seenPatterns = new Set();
|
|
36
|
+
const warnings = [];
|
|
37
|
+
for (const w of [...(newReport.warnings || []), ...keptExistingWarnings]) {
|
|
38
|
+
const pattern = w.replace(/\d+m ago/, "Xm ago").replace(/\d+s old/, "Xs old").replace(/\d+s\b/, "Xs");
|
|
39
|
+
if (!seenPatterns.has(pattern)) {
|
|
40
|
+
seenPatterns.add(pattern);
|
|
41
|
+
warnings.push(w);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
...existingReport,
|
|
47
|
+
...newReport,
|
|
48
|
+
providers,
|
|
49
|
+
byFamily: mergedByFamily,
|
|
50
|
+
warnings,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {{ env?: Record<string, string | undefined>, home?: string, path?: string }} [options]
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
export function resolveCachePath(options = {}) {
|
|
59
|
+
if (options.path) return options.path;
|
|
60
|
+
const env = options.env ?? process.env;
|
|
61
|
+
const cacheHome = options.home ? join(options.home, ".cache") : (env.XDG_CACHE_HOME || join(homedir(), ".cache"));
|
|
62
|
+
return join(cacheHome, "pi-quota", "usage.json");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {{
|
|
67
|
+
* path?: string,
|
|
68
|
+
* ttlMs?: number,
|
|
69
|
+
* now?: number,
|
|
70
|
+
* env?: Record<string, string | undefined>,
|
|
71
|
+
* home?: string,
|
|
72
|
+
* }} [options]
|
|
73
|
+
* @returns {{ report: import("./engine.js").PiQuotaReport, ageMs: number, path: string } | null}
|
|
74
|
+
*/
|
|
75
|
+
export function readCache(options = {}) {
|
|
76
|
+
const path = options.path ?? resolveCachePath(options);
|
|
77
|
+
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
78
|
+
const now = options.now ?? Date.now();
|
|
79
|
+
|
|
80
|
+
let raw;
|
|
81
|
+
try {
|
|
82
|
+
raw = readFileSync(path, "utf-8");
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let parsed;
|
|
88
|
+
try {
|
|
89
|
+
parsed = JSON.parse(raw);
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
94
|
+
const report = /** @type {{ report?: import("./engine.js").PiQuotaReport }} */ (parsed).report;
|
|
95
|
+
if (!report || !Array.isArray(report.providers)) return null;
|
|
96
|
+
|
|
97
|
+
const savedAt = typeof /** @type {{ savedAt?: unknown }} */ (parsed).savedAt === "number"
|
|
98
|
+
? /** @type {number} */ (/** @type {{ savedAt?: number }} */ (parsed).savedAt)
|
|
99
|
+
: null;
|
|
100
|
+
if (savedAt === null) return null;
|
|
101
|
+
const ageMs = now - savedAt;
|
|
102
|
+
if (ageMs > ttlMs) return null;
|
|
103
|
+
|
|
104
|
+
for (const provider of report.providers) {
|
|
105
|
+
for (const win of provider.windows ?? []) {
|
|
106
|
+
if (win.resetsAt) {
|
|
107
|
+
const parsedMs = Date.parse(win.resetsAt);
|
|
108
|
+
if (!Number.isNaN(parsedMs)) {
|
|
109
|
+
win.resetsInSec = Math.max(0, Math.round((parsedMs - now) / 1000));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { report, ageMs, path };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {import("./engine.js").PiQuotaReport} report
|
|
120
|
+
* @param {{
|
|
121
|
+
* path?: string,
|
|
122
|
+
* now?: number,
|
|
123
|
+
* env?: Record<string, string | undefined>,
|
|
124
|
+
* home?: string,
|
|
125
|
+
* }} [options]
|
|
126
|
+
* @returns {{ ok: boolean, path: string, error?: string }}
|
|
127
|
+
*/
|
|
128
|
+
export function writeCache(report, options = {}) {
|
|
129
|
+
const path = options.path ?? resolveCachePath(options);
|
|
130
|
+
const now = options.now ?? Date.now();
|
|
131
|
+
let finalReport = report;
|
|
132
|
+
if (options.merge) {
|
|
133
|
+
const existing = readCache({ ...options, path, ttlMs: Number.POSITIVE_INFINITY });
|
|
134
|
+
if (existing?.report) {
|
|
135
|
+
finalReport = mergeReportIntoCache(existing.report, report);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const payload = JSON.stringify({ savedAt: now, report: finalReport }, null, 2);
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
142
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
143
|
+
writeFileSync(temporary, payload, { encoding: "utf-8", mode: 0o600 });
|
|
144
|
+
try {
|
|
145
|
+
chmodSync(temporary, 0o600);
|
|
146
|
+
} catch {
|
|
147
|
+
// Best-effort in environments where chmod is not supported.
|
|
148
|
+
}
|
|
149
|
+
renameSync(temporary, path);
|
|
150
|
+
return { ok: true, path };
|
|
151
|
+
} catch (error) {
|
|
152
|
+
return { ok: false, path, error: /** @type {{ message?: string }} */ (error)?.message ?? String(error) };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* @param {{ path?: string, env?: Record<string, string | undefined>, home?: string }} [options]
|
|
158
|
+
* @returns {{ removed: boolean, path: string }}
|
|
159
|
+
*/
|
|
160
|
+
export function clearCache(options = {}) {
|
|
161
|
+
const path = options.path ?? resolveCachePath(options);
|
|
162
|
+
try {
|
|
163
|
+
rmSync(path, { force: true });
|
|
164
|
+
return { removed: true, path };
|
|
165
|
+
} catch {
|
|
166
|
+
return { removed: false, path };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* @param {{ path?: string, env?: Record<string, string | undefined>, home?: string }} [options]
|
|
172
|
+
* @returns {{ exists: boolean, path: string, sizeBytes: number | null }}
|
|
173
|
+
*/
|
|
174
|
+
export function describeCache(options = {}) {
|
|
175
|
+
const path = options.path ?? resolveCachePath(options);
|
|
176
|
+
try {
|
|
177
|
+
const stats = statSync(path);
|
|
178
|
+
return { exists: true, path, sizeBytes: stats.size };
|
|
179
|
+
} catch {
|
|
180
|
+
return { exists: false, path, sizeBytes: null };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Read-through helper: return a fresh cached report or refresh it.
|
|
186
|
+
*
|
|
187
|
+
* @param {{
|
|
188
|
+
* ttlMs?: number,
|
|
189
|
+
* now?: number,
|
|
190
|
+
* force?: boolean,
|
|
191
|
+
* path?: string,
|
|
192
|
+
* families?: string[],
|
|
193
|
+
* merge?: boolean,
|
|
194
|
+
* env?: Record<string, string | undefined>,
|
|
195
|
+
* home?: string,
|
|
196
|
+
* }} options
|
|
197
|
+
* @param {() => Promise<import("./engine.js").PiQuotaReport>} loader
|
|
198
|
+
* @returns {Promise<{ report: import("./engine.js").PiQuotaReport, cached: boolean, ageMs: number }>}
|
|
199
|
+
*/
|
|
200
|
+
export async function withCache(options, loader) {
|
|
201
|
+
const requested = options.families;
|
|
202
|
+
if (!options.force) {
|
|
203
|
+
const hit = readCache(options);
|
|
204
|
+
if (hit) {
|
|
205
|
+
const allPresent = !requested || requested.every((fam) => fam in hit.report.byFamily);
|
|
206
|
+
if (allPresent) {
|
|
207
|
+
const filteredReport = requested
|
|
208
|
+
? {
|
|
209
|
+
...hit.report,
|
|
210
|
+
providers: hit.report.providers.filter((p) => requested.includes(p.family)),
|
|
211
|
+
byFamily: Object.fromEntries(
|
|
212
|
+
Object.entries(hit.report.byFamily).filter(([fam]) => requested.includes(fam)),
|
|
213
|
+
),
|
|
214
|
+
}
|
|
215
|
+
: hit.report;
|
|
216
|
+
return { report: filteredReport, cached: true, ageMs: hit.ageMs };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const report = await loader();
|
|
221
|
+
const isPartial = Array.isArray(requested) && requested.length > 0;
|
|
222
|
+
writeCache(report, { ...options, merge: isPartial || options.merge });
|
|
223
|
+
return { report, cached: false, ageMs: 0 };
|
|
224
|
+
}
|
package/src/cli/args.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Argument parsing shared by both CLIs.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately small: this parser only owns the flags piquota itself
|
|
5
|
+
* implements, and reports anything else as unknown.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {Object} ParsedArgs
|
|
10
|
+
* @property {string[]} positionals
|
|
11
|
+
* @property {boolean} json
|
|
12
|
+
* @property {boolean} compact
|
|
13
|
+
* @property {boolean} color
|
|
14
|
+
* @property {boolean} noCache
|
|
15
|
+
* @property {boolean} force
|
|
16
|
+
* @property {boolean} explain
|
|
17
|
+
* @property {boolean} help
|
|
18
|
+
* @property {boolean} version
|
|
19
|
+
* @property {boolean} status
|
|
20
|
+
* @property {boolean} clearCache
|
|
21
|
+
* @property {number} ttlMs
|
|
22
|
+
* @property {number} timeoutMs
|
|
23
|
+
* @property {string[]} unknown Flags this parser does not own.
|
|
24
|
+
* @property {string[]} raw Original argv.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const VALUE_FLAGS = new Set(["--ttl", "--timeout"]);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Flags a subcommand consumes from the raw argv, collected here only so the parser
|
|
31
|
+
* can tell a real typo apart from a flag that belongs to subcommands like `auth`.
|
|
32
|
+
*/
|
|
33
|
+
const SUBCOMMAND_VALUE_FLAGS = new Set(["--wait"]);
|
|
34
|
+
|
|
35
|
+
/** Boolean flags owned by a subcommand. */
|
|
36
|
+
const SUBCOMMAND_FLAGS = new Set(["--paste", "--no-browser", "--no-refresh"]);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* What to name in an "unknown flag" report.
|
|
40
|
+
*
|
|
41
|
+
* A bare flag is reported alone when nothing followed it or when what followed was
|
|
42
|
+
* another flag: `--interval --fetch-ttl` must not claim that `--fetch-ttl` was the
|
|
43
|
+
* value of `--interval`.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} flag
|
|
46
|
+
* @param {string | undefined} value
|
|
47
|
+
* @returns {string[]}
|
|
48
|
+
*/
|
|
49
|
+
function flagWithValue(flag, value) {
|
|
50
|
+
return value !== undefined && value !== "" && !value.startsWith("-") ? [flag, value] : [flag];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {string[]} argv
|
|
55
|
+
* @param {{ defaultTtlMs?: number, defaultTimeoutMs?: number }} [defaults]
|
|
56
|
+
* @returns {ParsedArgs}
|
|
57
|
+
*/
|
|
58
|
+
export function parseArgs(argv, defaults = {}) {
|
|
59
|
+
/** @type {ParsedArgs} */
|
|
60
|
+
const parsed = {
|
|
61
|
+
positionals: [],
|
|
62
|
+
json: false,
|
|
63
|
+
compact: false,
|
|
64
|
+
color: true,
|
|
65
|
+
noCache: false,
|
|
66
|
+
force: false,
|
|
67
|
+
explain: false,
|
|
68
|
+
help: false,
|
|
69
|
+
version: false,
|
|
70
|
+
status: false,
|
|
71
|
+
box: false,
|
|
72
|
+
clearCache: false,
|
|
73
|
+
ttlMs: defaults.defaultTtlMs ?? 60_000,
|
|
74
|
+
timeoutMs: defaults.defaultTimeoutMs ?? 15_000,
|
|
75
|
+
unknown: [],
|
|
76
|
+
raw: [...argv],
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
80
|
+
const arg = argv[index];
|
|
81
|
+
|
|
82
|
+
if (VALUE_FLAGS.has(arg)) {
|
|
83
|
+
const value = argv[index + 1];
|
|
84
|
+
index += 1;
|
|
85
|
+
const number = Number(value);
|
|
86
|
+
if (!Number.isFinite(number) || number <= 0) {
|
|
87
|
+
parsed.unknown.push(...flagWithValue(arg, value));
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (arg === "--ttl") parsed.ttlMs = number * 1000;
|
|
91
|
+
if (arg === "--timeout") parsed.timeoutMs = number;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (SUBCOMMAND_VALUE_FLAGS.has(arg)) {
|
|
96
|
+
// The value is consumed here only so it cannot be mistaken for a
|
|
97
|
+
// positional; the subcommand reads it from `raw` itself.
|
|
98
|
+
const value = argv[index + 1];
|
|
99
|
+
if (value === undefined || value.startsWith("-") || Number.isNaN(Number(value))) {
|
|
100
|
+
parsed.unknown.push(...flagWithValue(arg, value));
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
index += 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
switch (arg) {
|
|
108
|
+
case "--json":
|
|
109
|
+
parsed.json = true;
|
|
110
|
+
break;
|
|
111
|
+
case "--compact":
|
|
112
|
+
case "-c":
|
|
113
|
+
parsed.compact = true;
|
|
114
|
+
break;
|
|
115
|
+
case "--no-color":
|
|
116
|
+
parsed.color = false;
|
|
117
|
+
break;
|
|
118
|
+
case "--no-cache":
|
|
119
|
+
parsed.noCache = true;
|
|
120
|
+
break;
|
|
121
|
+
case "--force":
|
|
122
|
+
case "--refresh":
|
|
123
|
+
parsed.force = true;
|
|
124
|
+
break;
|
|
125
|
+
case "--explain":
|
|
126
|
+
parsed.explain = true;
|
|
127
|
+
break;
|
|
128
|
+
case "--help":
|
|
129
|
+
case "-h":
|
|
130
|
+
parsed.help = true;
|
|
131
|
+
break;
|
|
132
|
+
case "--version":
|
|
133
|
+
case "-v":
|
|
134
|
+
parsed.version = true;
|
|
135
|
+
break;
|
|
136
|
+
case "--status":
|
|
137
|
+
parsed.status = true;
|
|
138
|
+
break;
|
|
139
|
+
case "--box":
|
|
140
|
+
parsed.box = true;
|
|
141
|
+
break;
|
|
142
|
+
case "--clear-cache":
|
|
143
|
+
parsed.clearCache = true;
|
|
144
|
+
break;
|
|
145
|
+
default:
|
|
146
|
+
// Owned by a subcommand, which reads it from `raw` itself.
|
|
147
|
+
if (SUBCOMMAND_FLAGS.has(arg)) break;
|
|
148
|
+
if (arg.startsWith("-")) parsed.unknown.push(arg);
|
|
149
|
+
else parsed.positionals.push(arg);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return parsed;
|
|
154
|
+
}
|