just-usage 0.0.1 → 0.0.4
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/CHANGELOG.md +25 -0
- package/dist/cli.js +1532 -339
- package/package.json +3 -2
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { createRequire } from "node:module";
|
|
3
|
-
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
4
2
|
|
|
5
3
|
// src/cli.ts
|
|
6
4
|
import { parseArgs } from "node:util";
|
|
@@ -17,7 +15,7 @@ import { mkdirSync } from "node:fs";
|
|
|
17
15
|
// package.json
|
|
18
16
|
var package_default = {
|
|
19
17
|
name: "just-usage",
|
|
20
|
-
version: "0.0.
|
|
18
|
+
version: "0.0.4",
|
|
21
19
|
description: "One local page for your coding-CLI subscription quotas: Claude, Codex, Cursor, OpenCode Go.",
|
|
22
20
|
type: "module",
|
|
23
21
|
license: "MIT",
|
|
@@ -44,13 +42,14 @@ var package_default = {
|
|
|
44
42
|
files: [
|
|
45
43
|
"dist",
|
|
46
44
|
"README.md",
|
|
45
|
+
"CHANGELOG.md",
|
|
47
46
|
"LICENSE"
|
|
48
47
|
],
|
|
49
48
|
engines: {
|
|
50
49
|
node: ">=20"
|
|
51
50
|
},
|
|
52
51
|
scripts: {
|
|
53
|
-
dev: "bun
|
|
52
|
+
dev: "bun --watch src/cli.ts",
|
|
54
53
|
build: "bun run scripts/build.ts",
|
|
55
54
|
test: "bun test",
|
|
56
55
|
typecheck: "tsc --noEmit",
|
|
@@ -135,20 +134,41 @@ function clampPercent(v) {
|
|
|
135
134
|
v = Number(v);
|
|
136
135
|
if (typeof v !== "number" || !Number.isFinite(v))
|
|
137
136
|
return null;
|
|
138
|
-
|
|
137
|
+
if (v <= 0)
|
|
138
|
+
return 0;
|
|
139
|
+
if (v >= 100)
|
|
140
|
+
return 100;
|
|
141
|
+
const tenth = Math.round(v * 10) / 10;
|
|
142
|
+
if (tenth === 0)
|
|
143
|
+
return 0.1;
|
|
144
|
+
if (tenth === 100)
|
|
145
|
+
return 99.9;
|
|
146
|
+
return tenth;
|
|
147
|
+
}
|
|
148
|
+
function formatPercent(used) {
|
|
149
|
+
if (used === null)
|
|
150
|
+
return "—";
|
|
151
|
+
const n = clampPercent(used) ?? 0;
|
|
152
|
+
const tenth = Math.round(n * 10) / 10;
|
|
153
|
+
return Number.isInteger(tenth) ? `${tenth}%` : `${tenth.toFixed(1)}%`;
|
|
154
|
+
}
|
|
155
|
+
function formatPlan(plan) {
|
|
156
|
+
if (!plan || !plan.trim())
|
|
157
|
+
return null;
|
|
158
|
+
return plan.replace(/\S+/g, (w) => w.charAt(0).toUpperCase() + w.slice(1));
|
|
139
159
|
}
|
|
140
160
|
function windowLabel(minutes) {
|
|
141
161
|
if (minutes === null)
|
|
142
|
-
return "Window";
|
|
162
|
+
return "Window Usage";
|
|
143
163
|
if (minutes === 300)
|
|
144
|
-
return "5h";
|
|
164
|
+
return "5h Usage";
|
|
145
165
|
if (minutes === 10080)
|
|
146
|
-
return "Weekly";
|
|
166
|
+
return "Weekly Usage";
|
|
147
167
|
if (minutes % 1440 === 0)
|
|
148
|
-
return `${minutes / 1440}d`;
|
|
168
|
+
return `${minutes / 1440}d Usage`;
|
|
149
169
|
if (minutes % 60 === 0)
|
|
150
|
-
return `${minutes / 60}h`;
|
|
151
|
-
return `${minutes}m`;
|
|
170
|
+
return `${minutes / 60}h Usage`;
|
|
171
|
+
return `${minutes}m Usage`;
|
|
152
172
|
}
|
|
153
173
|
function formatDuration(ms) {
|
|
154
174
|
if (ms <= 0)
|
|
@@ -183,9 +203,13 @@ function severity(usedPercent) {
|
|
|
183
203
|
return "warn";
|
|
184
204
|
return "ok";
|
|
185
205
|
}
|
|
206
|
+
function formatHostname(name) {
|
|
207
|
+
return name.replace(/\.local$/, "").replace(/[-_]+/g, " ").replace(/\s+/g, " ").trim();
|
|
208
|
+
}
|
|
186
209
|
function slugify(s) {
|
|
187
210
|
return s.toLowerCase().replace(/@.*$/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "account";
|
|
188
211
|
}
|
|
212
|
+
var GENERIC_LABELS = new Set(["", "default", "token", "go", "pending", "profile", "account", "codex"]);
|
|
189
213
|
|
|
190
214
|
// src/adapters/common.ts
|
|
191
215
|
function snapshot(account, status, patch = {}) {
|
|
@@ -196,7 +220,7 @@ function snapshot(account, status, patch = {}) {
|
|
|
196
220
|
provider: account.provider,
|
|
197
221
|
label: account.label,
|
|
198
222
|
email: email ?? account.email ?? null,
|
|
199
|
-
plan: plan ?? null,
|
|
223
|
+
plan: formatPlan(plan) ?? null,
|
|
200
224
|
kind: account.kind
|
|
201
225
|
},
|
|
202
226
|
status,
|
|
@@ -398,34 +422,56 @@ async function fetchCodex(account) {
|
|
|
398
422
|
client?.close();
|
|
399
423
|
}
|
|
400
424
|
}
|
|
401
|
-
async function
|
|
425
|
+
async function startCodexLogin(codexHome, timeoutMs = 5 * 60000) {
|
|
402
426
|
const client = await AppServerClient.start(codexHome);
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
427
|
+
let closed = false;
|
|
428
|
+
const close = () => {
|
|
429
|
+
if (closed)
|
|
430
|
+
return;
|
|
431
|
+
closed = true;
|
|
432
|
+
client.close();
|
|
433
|
+
};
|
|
434
|
+
const completed = new Promise((resolve, reject) => {
|
|
435
|
+
const timer = setTimeout(() => reject(new Error("login timed out")), timeoutMs);
|
|
436
|
+
client.onNotification(async (n) => {
|
|
437
|
+
if (n.method !== "account/login/completed")
|
|
438
|
+
return;
|
|
439
|
+
clearTimeout(timer);
|
|
440
|
+
const p = isObject(n.params) ? n.params : {};
|
|
441
|
+
if (!p.success) {
|
|
442
|
+
reject(new Error(typeof p.error === "string" ? p.error : "login failed"));
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
try {
|
|
446
|
+
const acct = await client.request("account/read", {});
|
|
447
|
+
const info = isObject(acct.account) ? acct.account : {};
|
|
448
|
+
resolve({
|
|
449
|
+
email: typeof info.email === "string" ? info.email : null,
|
|
450
|
+
plan: typeof info.planType === "string" ? info.planType : null
|
|
451
|
+
});
|
|
452
|
+
} catch (e) {
|
|
453
|
+
reject(e instanceof Error ? e : new Error(String(e)));
|
|
454
|
+
}
|
|
416
455
|
});
|
|
456
|
+
}).finally(close);
|
|
457
|
+
try {
|
|
417
458
|
const start = await client.request("account/login/start", { type: "chatgpt" });
|
|
418
|
-
if (typeof start.authUrl
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
459
|
+
if (typeof start.authUrl !== "string" || !start.authUrl)
|
|
460
|
+
throw new Error("Codex did not return an auth URL.");
|
|
461
|
+
return { authUrl: start.authUrl, completed, close };
|
|
462
|
+
} catch (e) {
|
|
463
|
+
close();
|
|
464
|
+
throw e;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
async function codexLogin(codexHome, onAuthUrl, timeoutMs = 5 * 60000) {
|
|
468
|
+
const handle = await startCodexLogin(codexHome, timeoutMs);
|
|
469
|
+
onAuthUrl(handle.authUrl);
|
|
470
|
+
try {
|
|
471
|
+
return await handle.completed;
|
|
472
|
+
} catch (e) {
|
|
473
|
+
handle.close();
|
|
474
|
+
throw e;
|
|
429
475
|
}
|
|
430
476
|
}
|
|
431
477
|
|
|
@@ -517,9 +563,6 @@ function openInBrowser(url) {
|
|
|
517
563
|
child.unref();
|
|
518
564
|
} catch {}
|
|
519
565
|
}
|
|
520
|
-
function stripAnsi(s) {
|
|
521
|
-
return s.replace(/\u001b\[[0-9;]*[A-Za-z]/g, "");
|
|
522
|
-
}
|
|
523
566
|
function withTimeout(p, ms, label) {
|
|
524
567
|
return new Promise((resolve, reject) => {
|
|
525
568
|
const t = setTimeout(() => reject(new Error(`${label} timed out after ${Math.round(ms / 1000)}s`)), ms);
|
|
@@ -603,10 +646,10 @@ function secretStore() {
|
|
|
603
646
|
var USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
604
647
|
var PROFILE_URL = "https://api.anthropic.com/api/oauth/profile";
|
|
605
648
|
var FALLBACK_CLI_VERSION = "2.1.259";
|
|
606
|
-
function keychainService(
|
|
607
|
-
if (!
|
|
649
|
+
function keychainService(configDir) {
|
|
650
|
+
if (!configDir)
|
|
608
651
|
return "Claude Code-credentials";
|
|
609
|
-
const hash = createHash("sha256").update(
|
|
652
|
+
const hash = createHash("sha256").update(configDir).digest("hex").slice(0, 8);
|
|
610
653
|
return `Claude Code-credentials-${hash}`;
|
|
611
654
|
}
|
|
612
655
|
function parseCreds(text) {
|
|
@@ -624,24 +667,24 @@ function parseCreds(text) {
|
|
|
624
667
|
return null;
|
|
625
668
|
}
|
|
626
669
|
}
|
|
627
|
-
async function readClaudeCredentials(
|
|
670
|
+
async function readClaudeCredentials(configDir) {
|
|
628
671
|
if (process.platform === "darwin") {
|
|
629
|
-
const res = await run("security", ["find-generic-password", "-s", keychainService(
|
|
672
|
+
const res = await run("security", ["find-generic-password", "-s", keychainService(configDir), "-w"], { timeoutMs: 20000 });
|
|
630
673
|
if (res.code === 0) {
|
|
631
674
|
const creds = parseCreds(res.stdout.trim());
|
|
632
675
|
if (creds)
|
|
633
676
|
return creds;
|
|
634
677
|
}
|
|
635
678
|
}
|
|
636
|
-
const file = join2(
|
|
679
|
+
const file = join2(configDir ?? join2(homedir2(), ".claude"), ".credentials.json");
|
|
637
680
|
if (existsSync2(file))
|
|
638
681
|
return parseCreds(readFileSync2(file, "utf8"));
|
|
639
682
|
return null;
|
|
640
683
|
}
|
|
641
|
-
async function claudeAuthStatus(
|
|
684
|
+
async function claudeAuthStatus(configDir) {
|
|
642
685
|
const env = {};
|
|
643
|
-
if (
|
|
644
|
-
env.CLAUDE_CONFIG_DIR =
|
|
686
|
+
if (configDir)
|
|
687
|
+
env.CLAUDE_CONFIG_DIR = configDir;
|
|
645
688
|
const res = await run("claude", ["auth", "status", "--json"], { env, timeoutMs: 15000 });
|
|
646
689
|
if (res.code === null)
|
|
647
690
|
return null;
|
|
@@ -681,17 +724,18 @@ function normalizeClaudeUsage(body) {
|
|
|
681
724
|
return [];
|
|
682
725
|
const out = [];
|
|
683
726
|
const push = (w) => w && out.push(w);
|
|
684
|
-
push(bucket(body.five_hour, "five_hour", "5h", 300));
|
|
685
|
-
push(bucket(body.seven_day, "seven_day", "Weekly", 10080));
|
|
686
|
-
push(bucket(body.seven_day_opus, "seven_day_opus", "Weekly · Opus", 10080));
|
|
687
|
-
push(bucket(body.seven_day_sonnet, "seven_day_sonnet", "Weekly · Sonnet", 10080));
|
|
688
|
-
push(bucket(body.seven_day_oauth_apps, "seven_day_oauth_apps", "Weekly · OAuth apps", 10080));
|
|
727
|
+
push(bucket(body.five_hour, "five_hour", "5h Usage", 300));
|
|
728
|
+
push(bucket(body.seven_day, "seven_day", "Weekly Usage", 10080));
|
|
729
|
+
push(bucket(body.seven_day_opus, "seven_day_opus", "Weekly · Opus Usage", 10080));
|
|
730
|
+
push(bucket(body.seven_day_sonnet, "seven_day_sonnet", "Weekly · Sonnet Usage", 10080));
|
|
731
|
+
push(bucket(body.seven_day_oauth_apps, "seven_day_oauth_apps", "Weekly · OAuth apps Usage", 10080));
|
|
689
732
|
if (out.length === 0 && Array.isArray(body.limits)) {
|
|
690
733
|
for (const [i, item] of body.limits.entries()) {
|
|
691
734
|
if (!isObject(item))
|
|
692
735
|
continue;
|
|
693
736
|
const name = [item.name, item.type, item.id].find((x) => typeof x === "string" && x.length > 0) ?? `limit ${i + 1}`;
|
|
694
|
-
|
|
737
|
+
const label = name.replace(/_/g, " ");
|
|
738
|
+
push(bucket(item, `limits:${name}`, /usage$/i.test(label) ? label : `${label} Usage`, null));
|
|
695
739
|
}
|
|
696
740
|
}
|
|
697
741
|
const extra = body.extra_usage;
|
|
@@ -720,14 +764,10 @@ async function resolveToken(account) {
|
|
|
720
764
|
return { fail: snapshot(account, "error", { message: "Stored token missing. Run `just-usage remove` and add it again." }) };
|
|
721
765
|
return { token, plan: null };
|
|
722
766
|
}
|
|
723
|
-
const status = await claudeAuthStatus(account.path);
|
|
724
|
-
if (status && !status.loggedIn) {
|
|
725
|
-
const hint = account.kind === "default" ? "Run `claude` and `/login`." : `Run \`just-usage login ${account.id}\`.`;
|
|
726
|
-
return { fail: snapshot(account, "signed_out", { message: `Not signed in. ${hint}` }) };
|
|
727
|
-
}
|
|
728
767
|
const creds = await readClaudeCredentials(account.path);
|
|
729
768
|
if (!creds) {
|
|
730
|
-
|
|
769
|
+
const hint = account.kind === "default" ? "Run `claude` and `/login`." : `Run \`just-usage login ${account.id}\`.`;
|
|
770
|
+
return { fail: snapshot(account, "signed_out", { message: `Not signed in. ${hint}` }) };
|
|
731
771
|
}
|
|
732
772
|
if (creds.expiresAt && creds.expiresAt < Date.now()) {
|
|
733
773
|
const hint = account.kind === "default" ? "Open `claude` once to refresh it." : `Run \`CLAUDE_CONFIG_DIR=${account.path} claude\` once to refresh it.`;
|
|
@@ -785,6 +825,9 @@ async function verifyClaudeToken(token) {
|
|
|
785
825
|
return { ok: true, email, message: "ok" };
|
|
786
826
|
}
|
|
787
827
|
|
|
828
|
+
// src/accounts.ts
|
|
829
|
+
import { renameSync, rmSync as rmSync2 } from "node:fs";
|
|
830
|
+
|
|
788
831
|
// src/adapters/opencode.ts
|
|
789
832
|
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
|
|
790
833
|
import { homedir as homedir3 } from "node:os";
|
|
@@ -812,9 +855,9 @@ function readOpenCodeGoKey() {
|
|
|
812
855
|
}
|
|
813
856
|
}
|
|
814
857
|
var WINDOWS = [
|
|
815
|
-
{ key: "rolling", id: "rolling", label: "5h", minutes: 300, kind: "rolling" },
|
|
816
|
-
{ key: "weekly", id: "weekly", label: "Weekly", minutes: 10080, kind: "rolling" },
|
|
817
|
-
{ key: "monthly", id: "monthly", label: "Monthly", minutes: null, kind: "cycle" }
|
|
858
|
+
{ key: "rolling", id: "rolling", label: "5h Usage", minutes: 300, kind: "rolling" },
|
|
859
|
+
{ key: "weekly", id: "weekly", label: "Weekly Usage", minutes: 10080, kind: "rolling" },
|
|
860
|
+
{ key: "monthly", id: "monthly", label: "Monthly Usage", minutes: null, kind: "cycle" }
|
|
818
861
|
];
|
|
819
862
|
function normalizeOpenCodeUsage(body) {
|
|
820
863
|
if (!isObject(body))
|
|
@@ -848,7 +891,7 @@ async function fetchOpenCode(account) {
|
|
|
848
891
|
if (status === 401)
|
|
849
892
|
return snapshot(account, "error", { message: "Key rejected (401)." });
|
|
850
893
|
if (status === 403)
|
|
851
|
-
return snapshot(account, "unsupported", { message: "
|
|
894
|
+
return snapshot(account, "unsupported", { message: "No active OpenCode Go subscription." });
|
|
852
895
|
if (status === 429)
|
|
853
896
|
return snapshot(account, "error", { message: "Rate limited (429). Try again shortly." });
|
|
854
897
|
if (status !== 200)
|
|
@@ -861,28 +904,348 @@ async function fetchOpenCode(account) {
|
|
|
861
904
|
}
|
|
862
905
|
}
|
|
863
906
|
|
|
907
|
+
// src/registry.ts
|
|
908
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync2, rmSync } from "node:fs";
|
|
909
|
+
import { dirname as dirname2, join as join4 } from "node:path";
|
|
910
|
+
function readRegistry() {
|
|
911
|
+
const file = paths.registry();
|
|
912
|
+
if (!existsSync4(file))
|
|
913
|
+
return { version: 1, accounts: [] };
|
|
914
|
+
try {
|
|
915
|
+
const parsed = JSON.parse(readFileSync4(file, "utf8"));
|
|
916
|
+
return { version: 1, accounts: Array.isArray(parsed.accounts) ? parsed.accounts : [] };
|
|
917
|
+
} catch {
|
|
918
|
+
return { version: 1, accounts: [] };
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
function writeRegistry(reg) {
|
|
922
|
+
const file = paths.registry();
|
|
923
|
+
ensureDir(dirname2(file));
|
|
924
|
+
writeFileSync2(file, JSON.stringify(reg, null, 2) + `
|
|
925
|
+
`, { mode: 384 });
|
|
926
|
+
}
|
|
927
|
+
function listAccounts(provider) {
|
|
928
|
+
const all = readRegistry().accounts;
|
|
929
|
+
return provider ? all.filter((a) => a.provider === provider) : all;
|
|
930
|
+
}
|
|
931
|
+
function getAccount(id) {
|
|
932
|
+
return readRegistry().accounts.find((a) => a.id === id) ?? null;
|
|
933
|
+
}
|
|
934
|
+
function newAccountId(provider, hint) {
|
|
935
|
+
const existing = new Set(readRegistry().accounts.map((a) => a.id));
|
|
936
|
+
const base = `${provider}:${slugify(hint)}`;
|
|
937
|
+
if (base.endsWith(":default"))
|
|
938
|
+
return newAccountId(provider, `${hint}-2`);
|
|
939
|
+
if (!existing.has(base))
|
|
940
|
+
return base;
|
|
941
|
+
for (let i = 2;; i++) {
|
|
942
|
+
const candidate = `${base}-${i}`;
|
|
943
|
+
if (!existing.has(candidate))
|
|
944
|
+
return candidate;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
function profileDirFor(provider, id) {
|
|
948
|
+
return join4(paths.profiles(provider), id.split(":")[1] ?? "account");
|
|
949
|
+
}
|
|
950
|
+
function saveAccount(record) {
|
|
951
|
+
const reg = readRegistry();
|
|
952
|
+
reg.accounts = reg.accounts.filter((a) => a.id !== record.id);
|
|
953
|
+
reg.accounts.push(record);
|
|
954
|
+
writeRegistry(reg);
|
|
955
|
+
}
|
|
956
|
+
function updateAccount(id, patch) {
|
|
957
|
+
const reg = readRegistry();
|
|
958
|
+
const idx = reg.accounts.findIndex((a) => a.id === id);
|
|
959
|
+
if (idx === -1)
|
|
960
|
+
return;
|
|
961
|
+
reg.accounts[idx] = { ...reg.accounts[idx], ...patch, id };
|
|
962
|
+
writeRegistry(reg);
|
|
963
|
+
}
|
|
964
|
+
function deleteAccount(id) {
|
|
965
|
+
const reg = readRegistry();
|
|
966
|
+
const record = reg.accounts.find((a) => a.id === id) ?? null;
|
|
967
|
+
if (!record)
|
|
968
|
+
return null;
|
|
969
|
+
reg.accounts = reg.accounts.filter((a) => a.id !== id);
|
|
970
|
+
writeRegistry(reg);
|
|
971
|
+
if (record.kind === "profile" && record.path && record.path.startsWith(paths.profiles(record.provider))) {
|
|
972
|
+
rmSync(record.path, { recursive: true, force: true });
|
|
973
|
+
}
|
|
974
|
+
return record;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// src/accounts.ts
|
|
978
|
+
class AccountError extends Error {
|
|
979
|
+
status;
|
|
980
|
+
constructor(message, status = 400) {
|
|
981
|
+
super(message);
|
|
982
|
+
this.status = status;
|
|
983
|
+
this.name = "AccountError";
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
async function addClaudeToken(token, label) {
|
|
987
|
+
const secret = token.trim();
|
|
988
|
+
if (!secret)
|
|
989
|
+
throw new AccountError("No token given.");
|
|
990
|
+
const check = await verifyClaudeToken(secret);
|
|
991
|
+
if (!check.ok)
|
|
992
|
+
throw new AccountError(`Token check failed: ${check.message}`);
|
|
993
|
+
const named = label?.trim() || "";
|
|
994
|
+
const id = newAccountId("claude", named || check.email || "account");
|
|
995
|
+
await secretStore().set(id, secret);
|
|
996
|
+
const account = {
|
|
997
|
+
id,
|
|
998
|
+
provider: "claude",
|
|
999
|
+
label: named,
|
|
1000
|
+
kind: "token",
|
|
1001
|
+
email: check.email,
|
|
1002
|
+
createdAt: new Date().toISOString()
|
|
1003
|
+
};
|
|
1004
|
+
saveAccount(account);
|
|
1005
|
+
return { account };
|
|
1006
|
+
}
|
|
1007
|
+
async function addOpenCodeKey(key, label) {
|
|
1008
|
+
const secret = key.trim();
|
|
1009
|
+
if (!secret)
|
|
1010
|
+
throw new AccountError("No key given.");
|
|
1011
|
+
const { status } = await fetchOpenCodeUsage(secret);
|
|
1012
|
+
if (status === 401)
|
|
1013
|
+
throw new AccountError("Key rejected (401).");
|
|
1014
|
+
if (status !== 200 && status !== 403)
|
|
1015
|
+
throw new AccountError(`Usage endpoint returned HTTP ${status}.`);
|
|
1016
|
+
const named = label?.trim() || "";
|
|
1017
|
+
const id = newAccountId("opencode", named || "account");
|
|
1018
|
+
await secretStore().set(id, secret);
|
|
1019
|
+
const account = {
|
|
1020
|
+
id,
|
|
1021
|
+
provider: "opencode",
|
|
1022
|
+
label: named,
|
|
1023
|
+
kind: "token",
|
|
1024
|
+
createdAt: new Date().toISOString()
|
|
1025
|
+
};
|
|
1026
|
+
saveAccount(account);
|
|
1027
|
+
return {
|
|
1028
|
+
account,
|
|
1029
|
+
warning: status === 403 ? "Key is valid but has no active OpenCode Go subscription." : undefined
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
function renameExtraAccount(id, label) {
|
|
1033
|
+
if (id.endsWith(":default"))
|
|
1034
|
+
throw new AccountError("Default accounts are renamed on the page only.");
|
|
1035
|
+
if (!getAccount(id))
|
|
1036
|
+
throw new AccountError(`Unknown account: ${id}`, 404);
|
|
1037
|
+
updateAccount(id, { label: label.trim() });
|
|
1038
|
+
return getAccount(id);
|
|
1039
|
+
}
|
|
1040
|
+
async function removeExtraAccount(id) {
|
|
1041
|
+
if (id.endsWith(":default"))
|
|
1042
|
+
throw new AccountError("Default accounts belong to the CLI itself; sign out there instead.");
|
|
1043
|
+
const rec = deleteAccount(id);
|
|
1044
|
+
if (!rec)
|
|
1045
|
+
throw new AccountError(`Unknown account: ${id}`, 404);
|
|
1046
|
+
if (rec.kind === "token")
|
|
1047
|
+
await secretStore().delete(id);
|
|
1048
|
+
return rec;
|
|
1049
|
+
}
|
|
1050
|
+
function saveCodexProfile(opts) {
|
|
1051
|
+
const named = opts.label?.trim() || "";
|
|
1052
|
+
const finalLabel = named;
|
|
1053
|
+
const id = named ? opts.tmpId : newAccountId("codex", opts.email || opts.tmpId.split(":")[1] || "account");
|
|
1054
|
+
const path = id === opts.tmpId ? opts.dir : ensureDir(profileDirFor("codex", id));
|
|
1055
|
+
if (path !== opts.dir) {
|
|
1056
|
+
rmSync2(path, { recursive: true, force: true });
|
|
1057
|
+
renameSync(opts.dir, path);
|
|
1058
|
+
}
|
|
1059
|
+
const account = {
|
|
1060
|
+
id,
|
|
1061
|
+
provider: "codex",
|
|
1062
|
+
label: finalLabel,
|
|
1063
|
+
kind: "profile",
|
|
1064
|
+
path,
|
|
1065
|
+
email: opts.email,
|
|
1066
|
+
createdAt: new Date().toISOString()
|
|
1067
|
+
};
|
|
1068
|
+
saveAccount(account);
|
|
1069
|
+
return account;
|
|
1070
|
+
}
|
|
1071
|
+
function isLocalCallbackUrl(raw) {
|
|
1072
|
+
let u;
|
|
1073
|
+
try {
|
|
1074
|
+
u = new URL(raw.trim());
|
|
1075
|
+
} catch {
|
|
1076
|
+
return false;
|
|
1077
|
+
}
|
|
1078
|
+
if (u.protocol !== "http:" && u.protocol !== "https:")
|
|
1079
|
+
return false;
|
|
1080
|
+
const host = u.hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
1081
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
1082
|
+
}
|
|
1083
|
+
async function submitLocalCallback(raw) {
|
|
1084
|
+
if (!isLocalCallbackUrl(raw))
|
|
1085
|
+
throw new AccountError("Paste the localhost callback URL from the sign-in redirect.");
|
|
1086
|
+
try {
|
|
1087
|
+
await fetch(raw.trim(), { redirect: "manual" });
|
|
1088
|
+
} catch (e) {
|
|
1089
|
+
throw new AccountError(`Could not reach the local callback: ${e instanceof Error ? e.message : String(e)}`);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
var SESSION_TTL_MS = 10 * 60000;
|
|
1093
|
+
var codexSessions = new Map;
|
|
1094
|
+
function dropCodexSession(id, removeDir) {
|
|
1095
|
+
const rec = codexSessions.get(id);
|
|
1096
|
+
if (!rec)
|
|
1097
|
+
return;
|
|
1098
|
+
clearTimeout(rec.timer);
|
|
1099
|
+
rec.handle.close();
|
|
1100
|
+
codexSessions.delete(id);
|
|
1101
|
+
if (removeDir && rec.status !== "done") {
|
|
1102
|
+
try {
|
|
1103
|
+
rmSync2(rec.dir, { recursive: true, force: true });
|
|
1104
|
+
} catch {}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
async function beginCodexAdd(label) {
|
|
1108
|
+
if (!await which("codex"))
|
|
1109
|
+
throw new AccountError("codex is not installed (npm i -g @openai/codex).");
|
|
1110
|
+
const tmpId = newAccountId("codex", label?.trim() || "pending");
|
|
1111
|
+
const dir = ensureDir(profileDirFor("codex", tmpId));
|
|
1112
|
+
const handle = await startCodexLogin(dir);
|
|
1113
|
+
const id = crypto.randomUUID();
|
|
1114
|
+
const rec = {
|
|
1115
|
+
id,
|
|
1116
|
+
label: label?.trim() || undefined,
|
|
1117
|
+
tmpId,
|
|
1118
|
+
dir,
|
|
1119
|
+
handle,
|
|
1120
|
+
status: "waiting",
|
|
1121
|
+
timer: setTimeout(() => dropCodexSession(id, true), SESSION_TTL_MS)
|
|
1122
|
+
};
|
|
1123
|
+
rec.timer.unref?.();
|
|
1124
|
+
handle.completed.then((info) => {
|
|
1125
|
+
rec.account = saveCodexProfile({ label: rec.label, tmpId, dir, email: info.email });
|
|
1126
|
+
rec.status = "done";
|
|
1127
|
+
}).catch((e) => {
|
|
1128
|
+
rec.status = "error";
|
|
1129
|
+
rec.error = e instanceof Error ? e.message : String(e);
|
|
1130
|
+
});
|
|
1131
|
+
codexSessions.set(id, rec);
|
|
1132
|
+
return { sessionId: id, authUrl: handle.authUrl };
|
|
1133
|
+
}
|
|
1134
|
+
async function beginCodexRelogin(accountId) {
|
|
1135
|
+
const existing = getAccount(accountId);
|
|
1136
|
+
if (!existing)
|
|
1137
|
+
throw new AccountError(`Unknown account: ${accountId}`, 404);
|
|
1138
|
+
if (existing.provider !== "codex" || existing.kind !== "profile" || !existing.path) {
|
|
1139
|
+
throw new AccountError(`${accountId} cannot be re-authenticated this way.`);
|
|
1140
|
+
}
|
|
1141
|
+
const handle = await startCodexLogin(existing.path);
|
|
1142
|
+
const id = crypto.randomUUID();
|
|
1143
|
+
const rec = {
|
|
1144
|
+
id,
|
|
1145
|
+
tmpId: existing.id,
|
|
1146
|
+
dir: existing.path,
|
|
1147
|
+
handle,
|
|
1148
|
+
status: "waiting",
|
|
1149
|
+
timer: setTimeout(() => dropCodexSession(id, false), SESSION_TTL_MS)
|
|
1150
|
+
};
|
|
1151
|
+
rec.timer.unref?.();
|
|
1152
|
+
handle.completed.then((info) => {
|
|
1153
|
+
updateAccount(existing.id, { email: info.email });
|
|
1154
|
+
rec.account = { ...existing, email: info.email };
|
|
1155
|
+
rec.status = "done";
|
|
1156
|
+
}).catch((e) => {
|
|
1157
|
+
rec.status = "error";
|
|
1158
|
+
rec.error = e instanceof Error ? e.message : String(e);
|
|
1159
|
+
});
|
|
1160
|
+
codexSessions.set(id, rec);
|
|
1161
|
+
return { sessionId: id, authUrl: handle.authUrl };
|
|
1162
|
+
}
|
|
1163
|
+
function codexSessionStatus(sessionId) {
|
|
1164
|
+
const rec = codexSessions.get(sessionId);
|
|
1165
|
+
if (!rec)
|
|
1166
|
+
return { status: "error", error: "Login session expired." };
|
|
1167
|
+
return { status: rec.status, authUrl: rec.handle.authUrl, error: rec.error, account: rec.account };
|
|
1168
|
+
}
|
|
1169
|
+
async function submitCodexCallback(sessionId, url) {
|
|
1170
|
+
if (!isLocalCallbackUrl(url))
|
|
1171
|
+
throw new AccountError("Paste the localhost callback URL from the sign-in redirect.");
|
|
1172
|
+
const rec = codexSessions.get(sessionId);
|
|
1173
|
+
if (!rec)
|
|
1174
|
+
throw new AccountError("Login session expired.", 404);
|
|
1175
|
+
if (rec.status !== "waiting")
|
|
1176
|
+
return;
|
|
1177
|
+
await submitLocalCallback(url);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
864
1180
|
// src/collect.ts
|
|
865
1181
|
import { hostname } from "node:os";
|
|
866
1182
|
|
|
867
1183
|
// src/adapters/cursor.ts
|
|
868
|
-
import { existsSync as
|
|
1184
|
+
import { existsSync as existsSync5, readFileSync as readFileSync5 } from "node:fs";
|
|
869
1185
|
import { homedir as homedir4 } from "node:os";
|
|
870
|
-
import { join as
|
|
1186
|
+
import { join as join5 } from "node:path";
|
|
871
1187
|
var USAGE_URL3 = "https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage";
|
|
872
|
-
|
|
873
|
-
|
|
1188
|
+
var GROK_BOT_URL = "https://api2.cursor.sh/aiserver.v1.DashboardService/GetSandUsageStatus";
|
|
1189
|
+
var PLAN_URL = "https://api2.cursor.sh/aiserver.v1.DashboardService/GetPlanInfo";
|
|
1190
|
+
var KEYCHAIN_ACCOUNT = "cursor-user";
|
|
1191
|
+
var KEYCHAIN_SERVICE = "cursor-access-token";
|
|
1192
|
+
function tokenFromAuthJson(parsed) {
|
|
1193
|
+
if (!isObject(parsed))
|
|
1194
|
+
return null;
|
|
1195
|
+
return typeof parsed.accessToken === "string" && parsed.accessToken ? parsed.accessToken : null;
|
|
874
1196
|
}
|
|
875
|
-
function
|
|
876
|
-
|
|
877
|
-
if (!existsSync4(file))
|
|
1197
|
+
function tokenFromAuthFile(file) {
|
|
1198
|
+
if (!existsSync5(file))
|
|
878
1199
|
return null;
|
|
879
1200
|
try {
|
|
880
|
-
|
|
881
|
-
return typeof parsed.accessToken === "string" && parsed.accessToken ? parsed.accessToken : null;
|
|
1201
|
+
return tokenFromAuthJson(JSON.parse(readFileSync5(file, "utf8")));
|
|
882
1202
|
} catch {
|
|
883
1203
|
return null;
|
|
884
1204
|
}
|
|
885
1205
|
}
|
|
1206
|
+
function authFiles() {
|
|
1207
|
+
const home = homedir4();
|
|
1208
|
+
const out = [];
|
|
1209
|
+
if (process.platform === "win32") {
|
|
1210
|
+
const roaming = process.env.APPDATA || join5(home, "AppData", "Roaming");
|
|
1211
|
+
out.push(join5(roaming, "Cursor", "auth.json"));
|
|
1212
|
+
} else if (process.platform !== "darwin") {
|
|
1213
|
+
const xdg = process.env.XDG_CONFIG_HOME || join5(home, ".config");
|
|
1214
|
+
out.push(join5(xdg, "cursor", "auth.json"));
|
|
1215
|
+
}
|
|
1216
|
+
out.push(join5(home, ".cursor", "auth.json"));
|
|
1217
|
+
return out;
|
|
1218
|
+
}
|
|
1219
|
+
async function tokenFromKeychain() {
|
|
1220
|
+
if (process.platform !== "darwin")
|
|
1221
|
+
return null;
|
|
1222
|
+
const res = await run("security", ["find-generic-password", "-a", KEYCHAIN_ACCOUNT, "-s", KEYCHAIN_SERVICE, "-w"], { timeoutMs: 20000 });
|
|
1223
|
+
if (res.code !== 0)
|
|
1224
|
+
return null;
|
|
1225
|
+
const v = res.stdout.replace(/\r?\n$/, "");
|
|
1226
|
+
return v || null;
|
|
1227
|
+
}
|
|
1228
|
+
async function readCursorAccessToken() {
|
|
1229
|
+
const env = process.env.CURSOR_AUTH_TOKEN?.trim();
|
|
1230
|
+
if (env)
|
|
1231
|
+
return env;
|
|
1232
|
+
if (process.env.CURSOR_AUTH_FILE) {
|
|
1233
|
+
const fromFile = tokenFromAuthFile(process.env.CURSOR_AUTH_FILE);
|
|
1234
|
+
if (fromFile)
|
|
1235
|
+
return fromFile;
|
|
1236
|
+
}
|
|
1237
|
+
const fromKeychain = await tokenFromKeychain();
|
|
1238
|
+
if (fromKeychain)
|
|
1239
|
+
return fromKeychain;
|
|
1240
|
+
if (process.env.CURSOR_AUTH_FILE)
|
|
1241
|
+
return null;
|
|
1242
|
+
for (const file of authFiles()) {
|
|
1243
|
+
const fromFile = tokenFromAuthFile(file);
|
|
1244
|
+
if (fromFile)
|
|
1245
|
+
return fromFile;
|
|
1246
|
+
}
|
|
1247
|
+
return null;
|
|
1248
|
+
}
|
|
886
1249
|
function jwtExpiry(token) {
|
|
887
1250
|
const part = token.split(".")[1];
|
|
888
1251
|
if (!part)
|
|
@@ -895,13 +1258,6 @@ function jwtExpiry(token) {
|
|
|
895
1258
|
return null;
|
|
896
1259
|
}
|
|
897
1260
|
}
|
|
898
|
-
async function cursorEmail() {
|
|
899
|
-
const res = await run("cursor-agent", ["status"], { timeoutMs: 15000 });
|
|
900
|
-
const text = stripAnsi(res.stdout + `
|
|
901
|
-
` + res.stderr);
|
|
902
|
-
const m = text.match(/Logged in as\s+(\S+)/i);
|
|
903
|
-
return m?.[1] ?? null;
|
|
904
|
-
}
|
|
905
1261
|
function cents(v) {
|
|
906
1262
|
if (typeof v === "string" && v.trim())
|
|
907
1263
|
v = Number(v);
|
|
@@ -917,28 +1273,12 @@ function normalizeCursorUsage(body) {
|
|
|
917
1273
|
const out = [];
|
|
918
1274
|
const plan = isObject(body.planUsage) ? body.planUsage : null;
|
|
919
1275
|
if (plan) {
|
|
920
|
-
const limit = cents(plan.limit);
|
|
921
|
-
const included = cents(plan.includedSpend);
|
|
922
|
-
const bonus = cents(plan.bonusSpend);
|
|
923
|
-
let used = limit && included !== null ? clampPercent(included / limit * 100) : null;
|
|
924
|
-
if (used === null)
|
|
925
|
-
used = clampPercent(plan.totalPercentUsed);
|
|
926
|
-
if (used !== null) {
|
|
927
|
-
const parts = [];
|
|
928
|
-
if (limit !== null && included !== null)
|
|
929
|
-
parts.push(`${money(included)} of ${money(limit)} included`);
|
|
930
|
-
if (bonus)
|
|
931
|
-
parts.push(`${money(bonus)} bonus usage`);
|
|
932
|
-
if (typeof body.displayMessage === "string" && body.displayMessage.trim())
|
|
933
|
-
parts.push(body.displayMessage.trim());
|
|
934
|
-
out.push({ id: "included", label: "Included · billing cycle", usedPercent: used, resetsAt: cycleEnd, windowMinutes: null, kind: "cycle", note: parts.join(" · ") || undefined });
|
|
935
|
-
}
|
|
936
|
-
const api = clampPercent(plan.apiPercentUsed);
|
|
937
|
-
if (api !== null)
|
|
938
|
-
out.push({ id: "api", label: "Named models", usedPercent: api, resetsAt: cycleEnd, windowMinutes: null, kind: "cycle" });
|
|
939
1276
|
const auto = clampPercent(plan.autoPercentUsed);
|
|
940
1277
|
if (auto !== null)
|
|
941
|
-
out.push({ id: "auto", label: "
|
|
1278
|
+
out.push({ id: "auto", label: "Cursor Models", usedPercent: auto, resetsAt: cycleEnd, windowMinutes: null, kind: "cycle" });
|
|
1279
|
+
const api = clampPercent(plan.apiPercentUsed);
|
|
1280
|
+
if (api !== null)
|
|
1281
|
+
out.push({ id: "api", label: "Other Models", usedPercent: api, resetsAt: cycleEnd, windowMinutes: null, kind: "cycle" });
|
|
942
1282
|
}
|
|
943
1283
|
const spend = isObject(body.spendLimitUsage) ? body.spendLimitUsage : null;
|
|
944
1284
|
if (spend) {
|
|
@@ -971,37 +1311,82 @@ function normalizeCursorUsage(body) {
|
|
|
971
1311
|
}
|
|
972
1312
|
return out.length ? out : null;
|
|
973
1313
|
}
|
|
1314
|
+
function normalizeGrokBotUsage(body) {
|
|
1315
|
+
if (!isObject(body))
|
|
1316
|
+
return null;
|
|
1317
|
+
const src = isObject(body.usage) ? body.usage : body;
|
|
1318
|
+
if (src.usesPooledEnterpriseAllowance === true)
|
|
1319
|
+
return null;
|
|
1320
|
+
if (src.hasNonZeroIncludedLimit === false)
|
|
1321
|
+
return null;
|
|
1322
|
+
if (src.includedLimitZero === true)
|
|
1323
|
+
return null;
|
|
1324
|
+
const used = clampPercent(src.usagePercent);
|
|
1325
|
+
if (used === null)
|
|
1326
|
+
return null;
|
|
1327
|
+
return {
|
|
1328
|
+
id: "grok_bot",
|
|
1329
|
+
label: "Weekly Usage",
|
|
1330
|
+
group: "Grok Bot",
|
|
1331
|
+
usedPercent: used,
|
|
1332
|
+
resetsAt: isoOrNull(src.nextResetTimestampUtc) ?? epochToIso(src.nextResetTimestampUtc),
|
|
1333
|
+
windowMinutes: 10080,
|
|
1334
|
+
kind: "rolling"
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1337
|
+
function insertGrokBot(windows, grok) {
|
|
1338
|
+
const extra = windows.findIndex((w) => w.id === "on_demand" || w.id === "pooled");
|
|
1339
|
+
if (extra === -1)
|
|
1340
|
+
return [...windows, grok];
|
|
1341
|
+
return [...windows.slice(0, extra), grok, ...windows.slice(extra)];
|
|
1342
|
+
}
|
|
1343
|
+
function planFromCursorPlanInfo(body) {
|
|
1344
|
+
if (!isObject(body))
|
|
1345
|
+
return null;
|
|
1346
|
+
const info = isObject(body.planInfo) ? body.planInfo : body;
|
|
1347
|
+
return typeof info.planName === "string" && info.planName.trim() ? info.planName.trim() : null;
|
|
1348
|
+
}
|
|
974
1349
|
async function fetchCursor(account) {
|
|
975
1350
|
try {
|
|
976
|
-
const token =
|
|
1351
|
+
const token = await readCursorAccessToken();
|
|
977
1352
|
if (!token)
|
|
978
1353
|
return snapshot(account, "signed_out", { message: "Not signed in. Run `cursor-agent login`." });
|
|
979
1354
|
const exp = jwtExpiry(token);
|
|
980
|
-
const emailP = cursorEmail().catch(() => null);
|
|
981
1355
|
if (exp && exp < Date.now()) {
|
|
982
|
-
return snapshot(account, "error", { email:
|
|
1356
|
+
return snapshot(account, "error", { email: account.email ?? null, message: "Cursor session expired. Run `cursor-agent login`." });
|
|
983
1357
|
}
|
|
984
|
-
const
|
|
1358
|
+
const headers = {
|
|
1359
|
+
Authorization: `Bearer ${token}`,
|
|
1360
|
+
"Content-Type": "application/json",
|
|
1361
|
+
"Connect-Protocol-Version": "1"
|
|
1362
|
+
};
|
|
1363
|
+
const extra = {
|
|
985
1364
|
method: "POST",
|
|
986
|
-
headers
|
|
987
|
-
Authorization: `Bearer ${token}`,
|
|
988
|
-
"Content-Type": "application/json",
|
|
989
|
-
"Connect-Protocol-Version": "1"
|
|
990
|
-
},
|
|
1365
|
+
headers,
|
|
991
1366
|
body: "{}"
|
|
992
|
-
}
|
|
993
|
-
const
|
|
1367
|
+
};
|
|
1368
|
+
const grokReq = fetchJson(GROK_BOT_URL, extra).catch(() => null);
|
|
1369
|
+
const planReq = fetchJson(PLAN_URL, extra).catch(() => null);
|
|
1370
|
+
const res = await fetchJson(USAGE_URL3, extra);
|
|
1371
|
+
const email = account.email ?? null;
|
|
994
1372
|
if (res.status === 401 || res.status === 403) {
|
|
995
1373
|
return snapshot(account, "error", { email, message: `Cursor rejected the session (${res.status}). Run \`cursor-agent login\`.` });
|
|
996
1374
|
}
|
|
997
1375
|
if (res.status !== 200) {
|
|
998
1376
|
return snapshot(account, "error", { email, message: `Cursor usage endpoint returned HTTP ${res.status}.` });
|
|
999
1377
|
}
|
|
1000
|
-
|
|
1378
|
+
let windows = normalizeCursorUsage(res.body);
|
|
1001
1379
|
if (!windows) {
|
|
1002
1380
|
return snapshot(account, "unsupported", { email, message: "Cursor returned no plan usage for this account (team/enterprise plans are not supported yet)." });
|
|
1003
1381
|
}
|
|
1004
|
-
|
|
1382
|
+
const [grokRes, planRes] = await Promise.all([grokReq, planReq]);
|
|
1383
|
+
if (grokRes?.status === 200) {
|
|
1384
|
+
const grok = normalizeGrokBotUsage(grokRes.body);
|
|
1385
|
+
if (grok)
|
|
1386
|
+
windows = insertGrokBot(windows, grok);
|
|
1387
|
+
}
|
|
1388
|
+
const plan = planRes?.status === 200 ? planFromCursorPlanInfo(planRes.body) : null;
|
|
1389
|
+
return snapshot(account, "ok", { email, plan, windows });
|
|
1005
1390
|
} catch (e) {
|
|
1006
1391
|
return snapshot(account, "error", { message: errorMessage(e) });
|
|
1007
1392
|
}
|
|
@@ -1021,76 +1406,6 @@ function fetchSnapshot(account) {
|
|
|
1021
1406
|
}
|
|
1022
1407
|
}
|
|
1023
1408
|
|
|
1024
|
-
// src/registry.ts
|
|
1025
|
-
import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync2, rmSync } from "node:fs";
|
|
1026
|
-
import { dirname as dirname2, join as join5 } from "node:path";
|
|
1027
|
-
function readRegistry() {
|
|
1028
|
-
const file = paths.registry();
|
|
1029
|
-
if (!existsSync5(file))
|
|
1030
|
-
return { version: 1, accounts: [] };
|
|
1031
|
-
try {
|
|
1032
|
-
const parsed = JSON.parse(readFileSync5(file, "utf8"));
|
|
1033
|
-
return { version: 1, accounts: Array.isArray(parsed.accounts) ? parsed.accounts : [] };
|
|
1034
|
-
} catch {
|
|
1035
|
-
return { version: 1, accounts: [] };
|
|
1036
|
-
}
|
|
1037
|
-
}
|
|
1038
|
-
function writeRegistry(reg) {
|
|
1039
|
-
const file = paths.registry();
|
|
1040
|
-
ensureDir(dirname2(file));
|
|
1041
|
-
writeFileSync2(file, JSON.stringify(reg, null, 2) + `
|
|
1042
|
-
`, { mode: 384 });
|
|
1043
|
-
}
|
|
1044
|
-
function listAccounts(provider) {
|
|
1045
|
-
const all = readRegistry().accounts;
|
|
1046
|
-
return provider ? all.filter((a) => a.provider === provider) : all;
|
|
1047
|
-
}
|
|
1048
|
-
function getAccount(id) {
|
|
1049
|
-
return readRegistry().accounts.find((a) => a.id === id) ?? null;
|
|
1050
|
-
}
|
|
1051
|
-
function newAccountId(provider, hint) {
|
|
1052
|
-
const existing = new Set(readRegistry().accounts.map((a) => a.id));
|
|
1053
|
-
const base = `${provider}:${slugify(hint)}`;
|
|
1054
|
-
if (base.endsWith(":default"))
|
|
1055
|
-
return newAccountId(provider, `${hint}-2`);
|
|
1056
|
-
if (!existing.has(base))
|
|
1057
|
-
return base;
|
|
1058
|
-
for (let i = 2;; i++) {
|
|
1059
|
-
const candidate = `${base}-${i}`;
|
|
1060
|
-
if (!existing.has(candidate))
|
|
1061
|
-
return candidate;
|
|
1062
|
-
}
|
|
1063
|
-
}
|
|
1064
|
-
function profileDirFor(provider, id) {
|
|
1065
|
-
return join5(paths.profiles(provider), id.split(":")[1] ?? "account");
|
|
1066
|
-
}
|
|
1067
|
-
function saveAccount(record) {
|
|
1068
|
-
const reg = readRegistry();
|
|
1069
|
-
reg.accounts = reg.accounts.filter((a) => a.id !== record.id);
|
|
1070
|
-
reg.accounts.push(record);
|
|
1071
|
-
writeRegistry(reg);
|
|
1072
|
-
}
|
|
1073
|
-
function updateAccount(id, patch) {
|
|
1074
|
-
const reg = readRegistry();
|
|
1075
|
-
const idx = reg.accounts.findIndex((a) => a.id === id);
|
|
1076
|
-
if (idx === -1)
|
|
1077
|
-
return;
|
|
1078
|
-
reg.accounts[idx] = { ...reg.accounts[idx], ...patch, id };
|
|
1079
|
-
writeRegistry(reg);
|
|
1080
|
-
}
|
|
1081
|
-
function deleteAccount(id) {
|
|
1082
|
-
const reg = readRegistry();
|
|
1083
|
-
const record = reg.accounts.find((a) => a.id === id) ?? null;
|
|
1084
|
-
if (!record)
|
|
1085
|
-
return null;
|
|
1086
|
-
reg.accounts = reg.accounts.filter((a) => a.id !== id);
|
|
1087
|
-
writeRegistry(reg);
|
|
1088
|
-
if (record.kind === "profile" && record.path && record.path.startsWith(paths.profiles(record.provider))) {
|
|
1089
|
-
rmSync(record.path, { recursive: true, force: true });
|
|
1090
|
-
}
|
|
1091
|
-
return record;
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
1409
|
// src/types.ts
|
|
1095
1410
|
var PROVIDERS = [
|
|
1096
1411
|
{ id: "claude", name: "Claude", bin: "claude" },
|
|
@@ -1106,8 +1421,7 @@ function providerName(id) {
|
|
|
1106
1421
|
async function detectProviders() {
|
|
1107
1422
|
return Promise.all(PROVIDERS.map(async (p) => {
|
|
1108
1423
|
const path = await which(p.bin);
|
|
1109
|
-
|
|
1110
|
-
return { id: p.id, installed: path !== null, version };
|
|
1424
|
+
return { id: p.id, installed: path !== null, version: null };
|
|
1111
1425
|
}));
|
|
1112
1426
|
}
|
|
1113
1427
|
function resolveAccounts(provider, installed) {
|
|
@@ -1132,12 +1446,15 @@ async function collectReport(update, only) {
|
|
|
1132
1446
|
const providers = await Promise.all(PROVIDERS.filter((p) => !only || only.includes(p.id)).map(async (p) => {
|
|
1133
1447
|
const pres = presence.find((x) => x.id === p.id);
|
|
1134
1448
|
const accounts = resolveAccounts(p.id, pres.installed);
|
|
1135
|
-
const snapshots = await Promise.all(
|
|
1136
|
-
|
|
1449
|
+
const [snapshots, version] = await Promise.all([
|
|
1450
|
+
Promise.all(accounts.map(fetchAccount)),
|
|
1451
|
+
pres.installed ? binVersion(p.bin) : Promise.resolve(null)
|
|
1452
|
+
]);
|
|
1453
|
+
return { id: p.id, name: p.name, installed: pres.installed, version, accounts: snapshots };
|
|
1137
1454
|
}));
|
|
1138
1455
|
return {
|
|
1139
1456
|
version: VERSION,
|
|
1140
|
-
hostname: hostname()
|
|
1457
|
+
hostname: formatHostname(hostname()),
|
|
1141
1458
|
fetchedAt: new Date().toISOString(),
|
|
1142
1459
|
update,
|
|
1143
1460
|
providers
|
|
@@ -1154,22 +1471,28 @@ class ReportCache {
|
|
|
1154
1471
|
this.getUpdate = getUpdate;
|
|
1155
1472
|
}
|
|
1156
1473
|
get(force = false) {
|
|
1474
|
+
const cached = this.report ? { ...this.report, update: this.getUpdate() } : null;
|
|
1475
|
+
const fresh = cached && Date.now() - Date.parse(cached.fetchedAt) < this.ttlMs;
|
|
1476
|
+
if (!force && fresh)
|
|
1477
|
+
return Promise.resolve(cached);
|
|
1157
1478
|
if (this.inflight)
|
|
1158
|
-
return this.inflight;
|
|
1159
|
-
if (!force && this.report && Date.now() - Date.parse(this.report.fetchedAt) < this.ttlMs) {
|
|
1160
|
-
return Promise.resolve({ ...this.report, update: this.getUpdate() });
|
|
1161
|
-
}
|
|
1479
|
+
return cached && !force ? Promise.resolve(cached) : this.inflight;
|
|
1162
1480
|
this.inflight = collectReport(this.getUpdate()).then((r) => {
|
|
1163
1481
|
this.report = r;
|
|
1164
1482
|
return r;
|
|
1165
1483
|
}).finally(() => {
|
|
1166
1484
|
this.inflight = null;
|
|
1167
1485
|
});
|
|
1486
|
+
if (!force && cached)
|
|
1487
|
+
return Promise.resolve(cached);
|
|
1168
1488
|
return this.inflight;
|
|
1169
1489
|
}
|
|
1170
1490
|
peek() {
|
|
1171
1491
|
return this.report ? { ...this.report, update: this.getUpdate() } : null;
|
|
1172
1492
|
}
|
|
1493
|
+
invalidate() {
|
|
1494
|
+
this.report = null;
|
|
1495
|
+
}
|
|
1173
1496
|
}
|
|
1174
1497
|
|
|
1175
1498
|
// src/server.ts
|
|
@@ -1209,39 +1532,48 @@ var ui_default = `<!doctype html>
|
|
|
1209
1532
|
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-variant-numeric: tabular-nums; }
|
|
1210
1533
|
.wrap { max-width: 820px; margin: 0 auto; padding: 44px 20px 64px; }
|
|
1211
1534
|
|
|
1212
|
-
header { display: flex; align-items:
|
|
1535
|
+
header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 22px; gap: 16px; }
|
|
1213
1536
|
h1 { font-size: 15px; font-weight: 500; letter-spacing: .01em; margin: 0; }
|
|
1214
1537
|
h1 span { color: var(--dim); font-weight: 400; margin-left: 8px; }
|
|
1215
1538
|
.meta { color: var(--muted); font-size: 12px; display: flex; gap: 14px; align-items: center; }
|
|
1216
|
-
button.
|
|
1539
|
+
button.opts-btn {
|
|
1540
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
1217
1541
|
background: transparent; color: var(--fg); border: 1px solid var(--line-2); border-radius: 6px;
|
|
1218
1542
|
padding: 5px 11px; font: inherit; font-size: 12px; cursor: pointer;
|
|
1219
1543
|
}
|
|
1220
|
-
button.
|
|
1221
|
-
button.
|
|
1544
|
+
button.opts-btn:hover { border-color: #3a3a3a; }
|
|
1545
|
+
button.opts-btn svg { width: 13px; height: 13px; display: block; flex: none; }
|
|
1546
|
+
button.opts-btn .icon-back { display: none; }
|
|
1547
|
+
button.opts-btn.back .icon-gear { display: none; }
|
|
1548
|
+
button.opts-btn.back .icon-back { display: block; }
|
|
1222
1549
|
|
|
1223
|
-
|
|
1224
|
-
|
|
1550
|
+
.tabs {
|
|
1551
|
+
display: flex; gap: 4px; flex-wrap: wrap;
|
|
1552
|
+
border-bottom: 1px solid var(--line);
|
|
1553
|
+
margin: 0 0 16px; padding: 0;
|
|
1554
|
+
}
|
|
1225
1555
|
.tab {
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
font: inherit; font-size: 13px; display: flex; gap:
|
|
1229
|
-
|
|
1230
|
-
}
|
|
1231
|
-
.tab
|
|
1232
|
-
.tab.
|
|
1233
|
-
.tab.active
|
|
1234
|
-
content: ""; position: absolute; bottom: 0; width: 10px; height: 10px; background: var(--surface);
|
|
1235
|
-
}
|
|
1236
|
-
.tab.active::before { left: -10px; -webkit-mask: radial-gradient(circle at 0 0, transparent 10px, #000 10.5px); mask: radial-gradient(circle at 0 0, transparent 10px, #000 10.5px); }
|
|
1237
|
-
.tab.active::after { right: -10px; -webkit-mask: radial-gradient(circle at 100% 0, transparent 10px, #000 10.5px); mask: radial-gradient(circle at 100% 0, transparent 10px, #000 10.5px); }
|
|
1238
|
-
.dot { width: 6px; height: 6px; border-radius: 50%; background: var(--dim); flex: none; }
|
|
1239
|
-
.dot.ok { background: var(--ok); } .dot.warn { background: var(--warn); } .dot.crit { background: var(--crit); }
|
|
1556
|
+
background: none; border: none; border-bottom: 2px solid transparent;
|
|
1557
|
+
color: var(--muted); padding: 9px 12px 10px; margin-bottom: -1px; cursor: pointer;
|
|
1558
|
+
font: inherit; font-size: 13px; display: flex; gap: 8px; align-items: center; user-select: none;
|
|
1559
|
+
}
|
|
1560
|
+
.tab:hover { color: var(--fg); }
|
|
1561
|
+
.tab.active { color: var(--fg); border-bottom-color: var(--fg); }
|
|
1562
|
+
.tab img.logo { width: 14px; height: 14px; display: block; flex: none; opacity: .55; }
|
|
1563
|
+
.tab.active img.logo, .tab:hover img.logo { opacity: 1; }
|
|
1240
1564
|
.tab .n { color: var(--dim); font-size: 11px; }
|
|
1241
1565
|
|
|
1242
1566
|
.panel { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; min-height: 160px; }
|
|
1567
|
+
.panel.cards {
|
|
1568
|
+
display: grid; grid-template-columns: 1fr 1fr; gap: 12px; align-items: stretch;
|
|
1569
|
+
background: transparent; border: none; min-height: 0;
|
|
1570
|
+
}
|
|
1243
1571
|
.card { padding: 18px 18px 20px; border-bottom: 1px solid var(--line); }
|
|
1244
1572
|
.card:last-child { border-bottom: none; }
|
|
1573
|
+
.panel.cards .card {
|
|
1574
|
+
background: var(--surface); border: 1px solid var(--line); border-radius: 10px;
|
|
1575
|
+
border-bottom: 1px solid var(--line); min-width: 0; height: 100%;
|
|
1576
|
+
}
|
|
1245
1577
|
.card-head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
|
|
1246
1578
|
.label { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1247
1579
|
.plan { color: var(--muted); font-size: 12px; text-transform: capitalize; margin-left: 8px; font-weight: 400; }
|
|
@@ -1250,28 +1582,147 @@ var ui_default = `<!doctype html>
|
|
|
1250
1582
|
.badge.warn { color: var(--warn); border-color: rgba(245,197,66,.35); }
|
|
1251
1583
|
|
|
1252
1584
|
.win { margin-top: 14px; }
|
|
1253
|
-
.win-
|
|
1585
|
+
.win-group {
|
|
1586
|
+
margin: 28px 0 0; font-size: 16px; font-weight: 500; color: var(--fg);
|
|
1587
|
+
}
|
|
1588
|
+
.win-group + .win { margin-top: 14px; }
|
|
1589
|
+
.win-row { display: flex; justify-content: space-between; align-items: baseline; font-size: 12px; color: var(--muted); margin-bottom: 8px; gap: 12px; }
|
|
1254
1590
|
.win-row b { color: var(--fg); font-weight: 500; }
|
|
1255
1591
|
.win-row .pct { color: var(--fg); }
|
|
1256
1592
|
.win-row .pct.warn { color: var(--warn); } .win-row .pct.crit { color: var(--crit); }
|
|
1257
|
-
.track { height:
|
|
1258
|
-
.fill { height: 100%; border-radius:
|
|
1593
|
+
.track { height: 6px; background: var(--line); border-radius: 3px; overflow: hidden; }
|
|
1594
|
+
.fill { height: 100%; border-radius: 3px; background: var(--ok); width: 0; transition: width .5s cubic-bezier(.2,.7,.2,1); }
|
|
1259
1595
|
.fill.warn { background: var(--warn); } .fill.crit { background: var(--crit); }
|
|
1596
|
+
.fill.sliver { min-width: 8px; }
|
|
1260
1597
|
.note { color: var(--dim); font-size: 11px; margin-top: 5px; }
|
|
1261
1598
|
|
|
1262
1599
|
.msg { color: var(--muted); font-size: 13px; margin-top: 6px; }
|
|
1263
1600
|
.empty { padding: 46px 18px; color: var(--muted); text-align: center; font-size: 13px; }
|
|
1264
1601
|
.hint { color: var(--dim); font-size: 12px; margin-top: 8px; }
|
|
1265
1602
|
code { font-family: ui-monospace, Menlo, Consolas, monospace; background: #121212; padding: 1px 6px; border-radius: 4px; font-size: 12px; color: var(--fg); }
|
|
1266
|
-
.credits {
|
|
1603
|
+
.credits {
|
|
1604
|
+
display: flex; justify-content: space-between; align-items: baseline; gap: 12px;
|
|
1605
|
+
margin-top: 16px; padding-top: 14px; border-top: 1px dashed var(--line);
|
|
1606
|
+
font-size: 12px;
|
|
1607
|
+
}
|
|
1608
|
+
.credits b { color: var(--fg); font-weight: 500; }
|
|
1609
|
+
.credits-avail {
|
|
1610
|
+
position: relative; color: var(--muted); cursor: help;
|
|
1611
|
+
border-bottom: 1px dotted var(--dim);
|
|
1612
|
+
}
|
|
1613
|
+
.credits-avail:hover, .credits-avail:focus-visible { color: var(--fg); }
|
|
1614
|
+
.credits-tip {
|
|
1615
|
+
display: none; position: absolute; right: 0; bottom: calc(100% + 8px);
|
|
1616
|
+
min-width: 240px; padding: 8px 10px;
|
|
1617
|
+
background: var(--surface); border: 1px solid var(--line-2); border-radius: 8px;
|
|
1618
|
+
color: var(--muted); font-size: 11px; line-height: 1.45; white-space: nowrap; z-index: 5;
|
|
1619
|
+
}
|
|
1620
|
+
.credits-avail:hover .credits-tip, .credits-avail:focus-visible .credits-tip { display: block; }
|
|
1621
|
+
.credits-tip-row { display: flex; align-items: baseline; gap: 12px; }
|
|
1622
|
+
.credits-tip-row + .credits-tip-row { margin-top: 6px; }
|
|
1623
|
+
.credits-tip-n { color: var(--fg); font-variant-numeric: tabular-nums; min-width: 1em; }
|
|
1624
|
+
.credits-tip-date { color: var(--fg); flex: 1; }
|
|
1625
|
+
.credits-tip-left { color: var(--dim); font-variant-numeric: tabular-nums; margin-left: auto; }
|
|
1626
|
+
|
|
1627
|
+
#dash[hidden], #settings[hidden] { display: none; }
|
|
1628
|
+
.settings { display: flex; flex-direction: column; gap: 22px; }
|
|
1629
|
+
.opts-label { font-size: 11px; color: var(--dim); letter-spacing: .04em; text-transform: uppercase; margin-bottom: 8px; }
|
|
1630
|
+
.opts-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 6px 0; user-select: none; }
|
|
1631
|
+
.opts-row.dragging {
|
|
1632
|
+
background: var(--surface); border: 1px solid var(--line-2); border-radius: 8px; padding: 6px 8px;
|
|
1633
|
+
}
|
|
1634
|
+
.opts-prov { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
1635
|
+
.opts-row img.logo { width: 14px; height: 14px; }
|
|
1636
|
+
.grip {
|
|
1637
|
+
width: 10px; height: 14px; flex: none; cursor: grab; touch-action: none;
|
|
1638
|
+
background-image: radial-gradient(circle, var(--dim) 1px, transparent 1.2px);
|
|
1639
|
+
background-size: 5px 5px; background-position: 0 1px;
|
|
1640
|
+
}
|
|
1641
|
+
.grip:active { cursor: grabbing; }
|
|
1642
|
+
.chk {
|
|
1643
|
+
appearance: none; -webkit-appearance: none;
|
|
1644
|
+
width: 15px; height: 15px; margin: 0; padding: 0;
|
|
1645
|
+
border: 1px solid var(--line-2); border-radius: 4px;
|
|
1646
|
+
background: transparent; cursor: pointer; flex: none;
|
|
1647
|
+
display: grid; place-items: center;
|
|
1648
|
+
}
|
|
1649
|
+
.chk:hover { border-color: var(--muted); }
|
|
1650
|
+
.chk:checked { background: var(--fg); border-color: var(--fg); }
|
|
1651
|
+
.chk:checked::after {
|
|
1652
|
+
content: ""; width: 7px; height: 4px;
|
|
1653
|
+
border-left: 1.5px solid var(--bg);
|
|
1654
|
+
border-bottom: 1.5px solid var(--bg);
|
|
1655
|
+
transform: translateY(-1px) rotate(-45deg);
|
|
1656
|
+
}
|
|
1657
|
+
.opts-inline { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
|
1658
|
+
.opts-inline .opts-label { margin: 0; }
|
|
1659
|
+
.opts-inline.tight { margin-bottom: 8px; }
|
|
1660
|
+
.seg { display: inline-flex; border: 1px solid var(--line-2); border-radius: 6px; overflow: hidden; width: auto; }
|
|
1661
|
+
.seg label { text-align: center; font-size: 11px; padding: 3px 8px; color: var(--muted); cursor: pointer; }
|
|
1662
|
+
.seg input { appearance: none; position: absolute; }
|
|
1663
|
+
.seg label:has(input:checked) { background: #121212; color: var(--fg); }
|
|
1664
|
+
.opts-actions { display: flex; justify-content: flex-end; align-items: center; gap: 12px; padding-top: 2px; }
|
|
1665
|
+
.opts-actions:has(#opts-signed-in:not([hidden])) { justify-content: space-between; }
|
|
1666
|
+
.opts-link { background: none; border: none; color: var(--muted); font: inherit; font-size: 12px; cursor: pointer; padding: 0; }
|
|
1667
|
+
.opts-link:hover { color: var(--fg); }
|
|
1668
|
+
.opts-link[hidden] { display: none; }
|
|
1669
|
+
.acct-group { padding-bottom: 12px; }
|
|
1670
|
+
.acct-group + .acct-group { border-top: 1px solid var(--line); padding-top: 12px; }
|
|
1671
|
+
.acct-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; font-size: 13px; }
|
|
1672
|
+
.acct-head img.logo { width: 14px; height: 14px; }
|
|
1673
|
+
.acct-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 5px 0; font-size: 13px; }
|
|
1674
|
+
.acct-actions { display: flex; align-items: center; gap: 10px; flex: none; }
|
|
1675
|
+
.acct-row .acct-actions { opacity: 0; }
|
|
1676
|
+
.acct-row:hover .acct-actions, .acct-row:focus-within .acct-actions { opacity: 1; }
|
|
1677
|
+
.dlg-body .acct-actions { justify-content: space-between; width: 100%; }
|
|
1678
|
+
.acct-meta { color: var(--dim); font-size: 12px; margin-left: 8px; }
|
|
1679
|
+
.acct-rename { display: flex; gap: 8px; flex: 1; min-width: 0; }
|
|
1680
|
+
dialog.dlg {
|
|
1681
|
+
width: min(420px, calc(100vw - 32px));
|
|
1682
|
+
background: var(--surface); color: var(--fg);
|
|
1683
|
+
border: 1px solid var(--line); border-radius: 12px;
|
|
1684
|
+
padding: 0;
|
|
1685
|
+
}
|
|
1686
|
+
dialog.dlg::backdrop { background: rgba(0, 0, 0, .9); }
|
|
1687
|
+
.dlg-head {
|
|
1688
|
+
display: flex; justify-content: space-between; align-items: center;
|
|
1689
|
+
padding: 14px 16px; border-bottom: 1px solid var(--line);
|
|
1690
|
+
}
|
|
1691
|
+
.dlg-head strong { font-weight: 500; font-size: 13px; }
|
|
1692
|
+
.dlg-body { padding: 14px 16px 16px; display: flex; flex-direction: column; gap: 12px; }
|
|
1693
|
+
.pick { display: flex; flex-direction: column; gap: 6px; }
|
|
1694
|
+
.pick-btn {
|
|
1695
|
+
display: flex; align-items: center; gap: 10px;
|
|
1696
|
+
background: transparent; color: var(--fg); border: 1px solid var(--line-2); border-radius: 8px;
|
|
1697
|
+
padding: 8px 10px; font: inherit; font-size: 13px; cursor: pointer; text-align: left;
|
|
1698
|
+
}
|
|
1699
|
+
.pick-btn:hover { border-color: #3a3a3a; }
|
|
1700
|
+
.pick-btn img.logo { width: 14px; height: 14px; }
|
|
1701
|
+
.field {
|
|
1702
|
+
background: #121212; color: var(--fg); border: 1px solid var(--line-2); border-radius: 6px;
|
|
1703
|
+
padding: 6px 10px; font: inherit; font-size: 12px; min-width: 0; flex: 1;
|
|
1704
|
+
}
|
|
1705
|
+
.field:focus { outline: none; border-color: var(--muted); }
|
|
1706
|
+
.acct-note { color: var(--dim); font-size: 12px; }
|
|
1707
|
+
.acct-msg { color: var(--muted); font-size: 12px; }
|
|
1708
|
+
.acct-msg.err { color: var(--crit); }
|
|
1709
|
+
.acct-oauth { display: flex; flex-direction: column; gap: 8px; }
|
|
1710
|
+
.acct-url { font-size: 12px; color: var(--fg); word-break: break-all; }
|
|
1711
|
+
.acct-url a { color: var(--fg); }
|
|
1712
|
+
button.refresh {
|
|
1713
|
+
background: transparent; color: var(--fg); border: 1px solid var(--line-2); border-radius: 6px;
|
|
1714
|
+
padding: 5px 11px; font: inherit; font-size: 12px; cursor: pointer;
|
|
1715
|
+
}
|
|
1716
|
+
button.refresh:hover { border-color: #3a3a3a; }
|
|
1717
|
+
button.refresh[disabled] { opacity: .45; cursor: default; }
|
|
1267
1718
|
|
|
1268
1719
|
footer { margin-top: 26px; color: var(--dim); font-size: 12px; display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
|
1269
1720
|
footer .update { color: var(--warn); }
|
|
1270
|
-
footer kbd { font-family: inherit; color: var(--dim); }
|
|
1271
1721
|
@media (max-width: 520px) {
|
|
1272
1722
|
.wrap { padding: 28px 12px 48px; }
|
|
1273
|
-
.tab { padding: 8px
|
|
1723
|
+
.tab { padding: 8px 10px; font-size: 12px; }
|
|
1274
1724
|
.tab .n { display: none; }
|
|
1725
|
+
.panel.cards { grid-template-columns: 1fr; }
|
|
1275
1726
|
}
|
|
1276
1727
|
</style>
|
|
1277
1728
|
</head>
|
|
@@ -1280,18 +1731,56 @@ var ui_default = `<!doctype html>
|
|
|
1280
1731
|
<header>
|
|
1281
1732
|
<h1>just-usage<span id="host"></span></h1>
|
|
1282
1733
|
<div class="meta">
|
|
1283
|
-
<span id="updated"
|
|
1284
|
-
<button class="
|
|
1734
|
+
<span id="updated"></span>
|
|
1735
|
+
<button class="opts-btn" id="opts-btn" type="button"><svg class="icon-gear" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg><svg class="icon-back" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 18 9 12l6-6"/><path d="M9 12h12"/></svg><span id="opts-label">Options</span></button>
|
|
1285
1736
|
</div>
|
|
1286
1737
|
</header>
|
|
1738
|
+
<div id="dash">
|
|
1287
1739
|
<nav class="tabs" id="tabs" role="tablist"></nav>
|
|
1288
1740
|
<section class="panel" id="panel" role="tabpanel"><div class="empty">Loading…</div></section>
|
|
1741
|
+
</div>
|
|
1742
|
+
<div id="settings" class="settings" hidden>
|
|
1743
|
+
<div>
|
|
1744
|
+
<div class="opts-label">Providers</div>
|
|
1745
|
+
<div id="opts-providers"></div>
|
|
1746
|
+
</div>
|
|
1747
|
+
<div class="opts-inline">
|
|
1748
|
+
<div class="opts-label">Meters</div>
|
|
1749
|
+
<div class="seg" id="opts-meter">
|
|
1750
|
+
<label><input type="radio" name="meter" value="left" checked> Left</label>
|
|
1751
|
+
<label><input type="radio" name="meter" value="used"> Used</label>
|
|
1752
|
+
</div>
|
|
1753
|
+
</div>
|
|
1754
|
+
<label class="opts-row" for="opts-email">
|
|
1755
|
+
<span>Show email on accounts</span>
|
|
1756
|
+
<input class="chk" id="opts-email" type="checkbox">
|
|
1757
|
+
</label>
|
|
1758
|
+
<div>
|
|
1759
|
+
<div class="opts-inline tight">
|
|
1760
|
+
<div class="opts-label">Accounts</div>
|
|
1761
|
+
<button class="refresh" id="acct-add" type="button">Add account</button>
|
|
1762
|
+
</div>
|
|
1763
|
+
<div id="opts-accounts"></div>
|
|
1764
|
+
</div>
|
|
1765
|
+
<div class="opts-actions">
|
|
1766
|
+
<button class="opts-link" id="opts-signed-in" type="button">Signed-in only</button>
|
|
1767
|
+
<button class="refresh" id="refresh" type="button">Refresh data</button>
|
|
1768
|
+
</div>
|
|
1769
|
+
</div>
|
|
1289
1770
|
<footer>
|
|
1290
1771
|
<span id="version"></span>
|
|
1291
1772
|
<span id="update"></span>
|
|
1292
1773
|
</footer>
|
|
1293
1774
|
</div>
|
|
1294
1775
|
|
|
1776
|
+
<dialog class="dlg" id="add-acct">
|
|
1777
|
+
<div class="dlg-head">
|
|
1778
|
+
<strong id="add-acct-title">Add account</strong>
|
|
1779
|
+
<button class="opts-link" id="add-acct-close" type="button">Close</button>
|
|
1780
|
+
</div>
|
|
1781
|
+
<div class="dlg-body" id="add-acct-body"></div>
|
|
1782
|
+
</dialog>
|
|
1783
|
+
|
|
1295
1784
|
<script>
|
|
1296
1785
|
(() => {
|
|
1297
1786
|
const PROVIDERS = [
|
|
@@ -1308,15 +1797,69 @@ var ui_default = `<!doctype html>
|
|
|
1308
1797
|
};
|
|
1309
1798
|
const $ = (id) => document.getElementById(id);
|
|
1310
1799
|
let report = null;
|
|
1311
|
-
|
|
1800
|
+
try {
|
|
1801
|
+
const cached = JSON.parse(localStorage.getItem("ju.report"));
|
|
1802
|
+
if (cached && Array.isArray(cached.providers)) report = cached;
|
|
1803
|
+
} catch {}
|
|
1804
|
+
const params = new URLSearchParams(location.search);
|
|
1805
|
+
const wanted = params.get("tab");
|
|
1312
1806
|
let active = PROVIDERS.some(([id]) => id === wanted) ? wanted : localStorage.getItem("ju.tab") || "claude";
|
|
1807
|
+
let settingsOpen = params.get("view") === "settings";
|
|
1313
1808
|
let loading = false;
|
|
1809
|
+
let busy = false;
|
|
1810
|
+
let showEmail = localStorage.getItem("ju.showEmail") === "1";
|
|
1811
|
+
let aliases = {};
|
|
1812
|
+
try {
|
|
1813
|
+
const raw = JSON.parse(localStorage.getItem("ju.aliases"));
|
|
1814
|
+
if (raw && typeof raw === "object") aliases = raw;
|
|
1815
|
+
} catch {}
|
|
1816
|
+
let renaming = null;
|
|
1817
|
+
let wizard = { step: "provider", provider: "", label: "", secret: "", sessionId: "", authUrl: "", callback: "", msg: "", err: false };
|
|
1818
|
+
let codexPoll = 0;
|
|
1819
|
+
let enabled = null;
|
|
1820
|
+
try {
|
|
1821
|
+
const raw = JSON.parse(localStorage.getItem("ju.providers"));
|
|
1822
|
+
if (raw && typeof raw === "object") enabled = raw;
|
|
1823
|
+
} catch {}
|
|
1824
|
+
let dragging = false;
|
|
1825
|
+
let showLeft = localStorage.getItem("ju.showLeft") !== "0";
|
|
1826
|
+
let order = PROVIDERS.map(([id]) => id);
|
|
1827
|
+
try {
|
|
1828
|
+
const raw = JSON.parse(localStorage.getItem("ju.order"));
|
|
1829
|
+
if (Array.isArray(raw)) {
|
|
1830
|
+
const known = new Set(order);
|
|
1831
|
+
const next = raw.filter((id) => known.has(id));
|
|
1832
|
+
for (const id of order) if (!next.includes(id)) next.push(id);
|
|
1833
|
+
order = next;
|
|
1834
|
+
}
|
|
1835
|
+
} catch {}
|
|
1314
1836
|
|
|
1315
1837
|
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
1316
|
-
// Escape, then turn \`backticked\` spans into <code>.
|
|
1317
1838
|
const rich = (s) => esc(s).replace(/\`([^\`]+)\`/g, "<code>$1</code>");
|
|
1318
1839
|
const sev = (p) => (p == null ? null : p >= 85 ? "crit" : p >= 60 ? "warn" : "ok");
|
|
1319
1840
|
const rank = { ok: 1, warn: 2, crit: 3 };
|
|
1841
|
+
const logo = (id) => \`<img class="logo" src="/logos/\${id}.svg?v=2" alt="" width="14" height="14">\`;
|
|
1842
|
+
const formatPlan = (p) => String(p).replace(/\\S+/g, (w) => w.charAt(0).toUpperCase() + w.slice(1));
|
|
1843
|
+
const GENERIC_LABELS = new Set(["", "default", "token", "go", "pending", "profile", "account", "codex"]);
|
|
1844
|
+
function isGenericLabel(label, email) {
|
|
1845
|
+
const s = String(label ?? "").trim();
|
|
1846
|
+
if (!s) return true;
|
|
1847
|
+
if (email && s === email) return true;
|
|
1848
|
+
return GENERIC_LABELS.has(s.toLowerCase());
|
|
1849
|
+
}
|
|
1850
|
+
function accountTitle(a, index) {
|
|
1851
|
+
if (showEmail && a.account.email) return a.account.email;
|
|
1852
|
+
const alias = String(aliases[a.account.id] || "").trim();
|
|
1853
|
+
if (alias) return alias;
|
|
1854
|
+
if (a.account.kind === "default" || /^default$/i.test(String(a.account.label || "").trim())) return "Default";
|
|
1855
|
+
if (!isGenericLabel(a.account.label, a.account.email)) return String(a.account.label).trim();
|
|
1856
|
+
return \`Account \${index + 1}\`;
|
|
1857
|
+
}
|
|
1858
|
+
function formatPercent(used) {
|
|
1859
|
+
if (used == null) return "—";
|
|
1860
|
+
const tenth = Math.round(Math.min(100, Math.max(0, used)) * 10) / 10;
|
|
1861
|
+
return Number.isInteger(tenth) ? \`\${tenth}%\` : \`\${tenth.toFixed(1)}%\`;
|
|
1862
|
+
}
|
|
1320
1863
|
|
|
1321
1864
|
function fmtDuration(ms) {
|
|
1322
1865
|
const min = Math.round(ms / 60000);
|
|
@@ -1336,31 +1879,80 @@ var ui_default = `<!doctype html>
|
|
|
1336
1879
|
}
|
|
1337
1880
|
return diff <= 0 ? "resetting" : \`resets in \${fmtDuration(diff)}\`;
|
|
1338
1881
|
}
|
|
1339
|
-
function
|
|
1882
|
+
function fmtAgo(iso) {
|
|
1340
1883
|
const t = Date.parse(iso);
|
|
1341
|
-
|
|
1884
|
+
if (!isFinite(t)) return "";
|
|
1885
|
+
const sec = Math.max(0, Math.round((Date.now() - t) / 1000));
|
|
1886
|
+
if (sec < 20) return "just now";
|
|
1887
|
+
if (sec < 60) return \`\${sec} sec ago\`;
|
|
1888
|
+
const min = Math.round(sec / 60);
|
|
1889
|
+
if (min < 60) return min === 1 ? "1 min ago" : \`\${min} min ago\`;
|
|
1890
|
+
const hr = Math.round(min / 60);
|
|
1891
|
+
if (hr < 48) return hr === 1 ? "1 hr ago" : \`\${hr} hr ago\`;
|
|
1892
|
+
const d = Math.round(hr / 24);
|
|
1893
|
+
return d === 1 ? "1 day ago" : \`\${d} days ago\`;
|
|
1342
1894
|
}
|
|
1343
1895
|
|
|
1344
|
-
function
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1896
|
+
function isSignedIn(p) {
|
|
1897
|
+
return !!p?.accounts.some((a) => a.status !== "signed_out");
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
function visibleIds() {
|
|
1901
|
+
if (enabled) return order.filter((id) => enabled[id]);
|
|
1902
|
+
if (!report) return [];
|
|
1903
|
+
return order.filter((id) => isSignedIn(report.providers.find((x) => x.id === id)));
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
function ensureActive() {
|
|
1907
|
+
const vis = visibleIds();
|
|
1908
|
+
if (!vis.length || vis.includes(active)) return;
|
|
1909
|
+
active = vis[0];
|
|
1910
|
+
localStorage.setItem("ju.tab", active);
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
function persistEnabled(next) {
|
|
1914
|
+
enabled = next;
|
|
1915
|
+
if (next) localStorage.setItem("ju.providers", JSON.stringify(next));
|
|
1916
|
+
else localStorage.removeItem("ju.providers");
|
|
1917
|
+
ensureActive();
|
|
1918
|
+
if (settingsOpen) {
|
|
1919
|
+
renderTabs();
|
|
1920
|
+
$("opts-signed-in").hidden = !enabled;
|
|
1921
|
+
} else render();
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
function persistOrder(next) {
|
|
1925
|
+
order = next;
|
|
1926
|
+
localStorage.setItem("ju.order", JSON.stringify(next));
|
|
1927
|
+
ensureActive();
|
|
1928
|
+
if (settingsOpen) renderTabs();
|
|
1929
|
+
else render();
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
function setView(open) {
|
|
1933
|
+
settingsOpen = open;
|
|
1934
|
+
$("dash").hidden = open;
|
|
1935
|
+
$("settings").hidden = !open;
|
|
1936
|
+
const btn = $("opts-btn");
|
|
1937
|
+
$("opts-label").textContent = open ? "Back" : "Options";
|
|
1938
|
+
const q = new URLSearchParams();
|
|
1939
|
+
if (open) q.set("view", "settings");
|
|
1940
|
+
else q.set("tab", active);
|
|
1941
|
+
history.replaceState(null, "", q.toString() ? \`?\${q}\` : location.pathname);
|
|
1942
|
+
if (open) {
|
|
1943
|
+
renderSettings();
|
|
1944
|
+
renderChrome();
|
|
1945
|
+
} else render();
|
|
1355
1946
|
}
|
|
1356
1947
|
|
|
1357
1948
|
function renderTabs() {
|
|
1358
|
-
|
|
1949
|
+
const vis = visibleIds();
|
|
1950
|
+
$("tabs").innerHTML = vis.map((id) => {
|
|
1951
|
+
const name = PROVIDERS.find(([x]) => x === id)?.[1] || id;
|
|
1359
1952
|
const p = report?.providers.find((x) => x.id === id);
|
|
1360
|
-
const s = providerSeverity(p);
|
|
1361
1953
|
const n = p ? p.accounts.filter((a) => a.status === "ok").length : 0;
|
|
1362
1954
|
return \`<button class="tab\${id === active ? " active" : ""}" role="tab" data-id="\${id}" aria-selected="\${id === active}">
|
|
1363
|
-
|
|
1955
|
+
\${logo(id)}\${esc(name)}\${n > 1 ? \`<span class="n">\${n}</span>\` : ""}</button>\`;
|
|
1364
1956
|
}).join("");
|
|
1365
1957
|
for (const el of $("tabs").querySelectorAll(".tab")) {
|
|
1366
1958
|
el.onclick = () => { active = el.dataset.id; localStorage.setItem("ju.tab", active); render(); };
|
|
@@ -1368,21 +1960,38 @@ var ui_default = `<!doctype html>
|
|
|
1368
1960
|
}
|
|
1369
1961
|
|
|
1370
1962
|
function renderWindow(w) {
|
|
1371
|
-
const
|
|
1372
|
-
const
|
|
1963
|
+
const used = w.usedPercent;
|
|
1964
|
+
const shown = showLeft && used != null ? Math.max(0, 100 - used) : used;
|
|
1965
|
+
const s = sev(used);
|
|
1966
|
+
const pct = formatPercent(shown);
|
|
1967
|
+
const word = showLeft ? "left" : "used";
|
|
1373
1968
|
const reset = fmtReset(w);
|
|
1969
|
+
const bar = shown == null ? 0 : shown;
|
|
1970
|
+
const sliver = shown > 0 && shown < 100;
|
|
1374
1971
|
return \`<div class="win">
|
|
1375
1972
|
<div class="win-row"><b>\${esc(w.label)}</b>
|
|
1376
|
-
<span class="mono"><span class="pct\${s ? " " + s : ""}">\${pct}
|
|
1377
|
-
<div class="track"><div class="fill\${s ? " " + s : ""}" style="width:\${
|
|
1973
|
+
<span class="mono"><span class="pct\${s ? " " + s : ""}">\${pct} \${word}</span>\${reset ? \` · \${esc(reset)}\` : ""}</span></div>
|
|
1974
|
+
<div class="track"><div class="fill\${s ? " " + s : ""}\${sliver ? " sliver" : ""}" style="width:\${bar}%"></div></div>
|
|
1378
1975
|
\${w.note ? \`<div class="note">\${esc(w.note)}</div>\` : ""}
|
|
1379
1976
|
</div>\`;
|
|
1380
1977
|
}
|
|
1381
1978
|
|
|
1382
|
-
function
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
const
|
|
1979
|
+
function renderWindows(windows) {
|
|
1980
|
+
let html = "";
|
|
1981
|
+
let lastGroup = null;
|
|
1982
|
+
for (const w of windows) {
|
|
1983
|
+
if (w.group && w.group !== lastGroup) {
|
|
1984
|
+
html += \`<div class="win-group">\${esc(w.group)}</div>\`;
|
|
1985
|
+
lastGroup = w.group;
|
|
1986
|
+
}
|
|
1987
|
+
html += renderWindow(w);
|
|
1988
|
+
}
|
|
1989
|
+
return html;
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
function renderCard(a, provider, index) {
|
|
1993
|
+
const title = accountTitle(a, index);
|
|
1994
|
+
const plan = a.account.plan ? \`<span class="plan">\${esc(formatPlan(a.account.plan))}</span>\` : "";
|
|
1386
1995
|
let badge = "";
|
|
1387
1996
|
if (a.status === "ok") {
|
|
1388
1997
|
let worst = null;
|
|
@@ -1390,78 +1999,516 @@ var ui_default = `<!doctype html>
|
|
|
1390
1999
|
if (worst === "crit") badge = \`<span class="badge crit">critical</span>\`;
|
|
1391
2000
|
else if (worst === "warn") badge = \`<span class="badge warn">running low</span>\`;
|
|
1392
2001
|
} else {
|
|
1393
|
-
const text = { signed_out: "signed out", unsupported: "
|
|
2002
|
+
const text = { signed_out: "signed out", unsupported: "Unsupported", error: "error" }[a.status] || a.status;
|
|
1394
2003
|
badge = \`<span class="badge">\${esc(text)}</span>\`;
|
|
1395
2004
|
}
|
|
1396
2005
|
let body = "";
|
|
1397
2006
|
if (a.status === "ok") {
|
|
1398
|
-
body = a.windows
|
|
2007
|
+
body = renderWindows(a.windows);
|
|
1399
2008
|
if (a.resetCredits && a.resetCredits.availableCount > 0) {
|
|
1400
|
-
const c = a.resetCredits.credits.find((x) => x.status === "available") || a.resetCredits.credits[0];
|
|
1401
|
-
const exp = c && c.expiresAt ? \` · expires \${new Date(c.expiresAt).toLocaleDateString(undefined, { month: "short", day: "numeric" })}\` : "";
|
|
1402
2009
|
const n = a.resetCredits.availableCount;
|
|
1403
|
-
|
|
2010
|
+
const rows = (a.resetCredits.credits || [])
|
|
2011
|
+
.filter((c) => !c.status || c.status === "available")
|
|
2012
|
+
.map((c, i) => {
|
|
2013
|
+
const t = c.expiresAt ? Date.parse(c.expiresAt) : NaN;
|
|
2014
|
+
const day = Number.isFinite(t)
|
|
2015
|
+
? \`\${new Date(t).getDate()} \${new Date(t).toLocaleDateString(undefined, { month: "short" })}\`
|
|
2016
|
+
: "—";
|
|
2017
|
+
const left = Number.isFinite(t) && t > Date.now() ? fmtDuration(t - Date.now()) : "now";
|
|
2018
|
+
return \`<div class="credits-tip-row"><span class="credits-tip-n">\${i + 1}</span><span class="credits-tip-date">\${esc(day)}</span><span class="credits-tip-left">\${esc(left)}</span></div>\`;
|
|
2019
|
+
});
|
|
2020
|
+
const tip = rows.length ? \`<span class="credits-tip">\${rows.join("")}</span>\` : "";
|
|
2021
|
+
body += \`<div class="credits"><b>Rate Limit Reset</b>
|
|
2022
|
+
<span class="credits-avail" tabindex="0">\${n} available\${tip}</span></div>\`;
|
|
1404
2023
|
}
|
|
1405
2024
|
} else {
|
|
1406
2025
|
body = a.message ? \`<div class="msg">\${rich(a.message)}</div>\` : \`<div class="hint">\${SIGNIN_HINT[provider] || ""}</div>\`;
|
|
1407
2026
|
}
|
|
1408
2027
|
return \`<article class="card">
|
|
1409
|
-
<div class="card-head"><div class="label">\${esc(title)}\${
|
|
2028
|
+
<div class="card-head"><div class="label">\${esc(title)}\${plan}</div>\${badge}</div>
|
|
1410
2029
|
\${body}
|
|
1411
2030
|
</article>\`;
|
|
1412
2031
|
}
|
|
1413
2032
|
|
|
2033
|
+
function formatHost(name) {
|
|
2034
|
+
return String(name ?? "").replace(/\\.local$/, "").replace(/[-_]+/g, " ").replace(/\\s+/g, " ").trim();
|
|
2035
|
+
}
|
|
2036
|
+
|
|
1414
2037
|
function renderPanel() {
|
|
2038
|
+
$("panel").classList.remove("cards");
|
|
2039
|
+
if (!report) {
|
|
2040
|
+
$("panel").innerHTML = \`<div class="empty">Loading…</div>\`;
|
|
2041
|
+
return;
|
|
2042
|
+
}
|
|
2043
|
+
const vis = visibleIds();
|
|
2044
|
+
if (vis.length === 0) {
|
|
2045
|
+
$("panel").innerHTML = \`<div class="empty">No providers enabled.<div class="hint">Open Options to show one, or add an account there.</div></div>\`;
|
|
2046
|
+
return;
|
|
2047
|
+
}
|
|
1415
2048
|
const p = report?.providers.find((x) => x.id === active);
|
|
1416
2049
|
const name = PROVIDERS.find(([id]) => id === active)?.[1] || active;
|
|
1417
2050
|
if (!p) { $("panel").innerHTML = \`<div class="empty">Loading…</div>\`; return; }
|
|
1418
2051
|
if (!p.installed && p.accounts.length === 0) {
|
|
1419
|
-
$("panel").innerHTML = \`<div class="empty">\${esc(name)} isn't installed on \${esc(report.hostname)}.</div>\`;
|
|
2052
|
+
$("panel").innerHTML = \`<div class="empty">\${esc(name)} isn't installed on \${esc(formatHost(report.hostname))}.</div>\`;
|
|
1420
2053
|
return;
|
|
1421
2054
|
}
|
|
1422
2055
|
if (p.accounts.length === 0) {
|
|
1423
2056
|
$("panel").innerHTML = \`<div class="empty">No \${esc(name)} accounts.<div class="hint">\${SIGNIN_HINT[active] || ""}</div></div>\`;
|
|
1424
2057
|
return;
|
|
1425
2058
|
}
|
|
1426
|
-
$("panel").
|
|
2059
|
+
$("panel").classList.toggle("cards", p.accounts.length > 1);
|
|
2060
|
+
$("panel").innerHTML = p.accounts.map((a, i) => renderCard(a, active, i)).join("");
|
|
1427
2061
|
}
|
|
1428
2062
|
|
|
1429
|
-
function
|
|
1430
|
-
|
|
1431
|
-
|
|
2063
|
+
function renderOptions() {
|
|
2064
|
+
const vis = new Set(visibleIds());
|
|
2065
|
+
$("opts-providers").innerHTML = order.map((id) => {
|
|
2066
|
+
const name = PROVIDERS.find(([x]) => x === id)?.[1] || id;
|
|
2067
|
+
return \`<div class="opts-row" data-id="\${id}">
|
|
2068
|
+
<span class="opts-prov"><span class="grip" title="Drag to reorder"></span>\${logo(id)}\${esc(name)}</span>
|
|
2069
|
+
<input class="chk" type="checkbox" data-id="\${id}" \${vis.has(id) ? "checked" : ""}>
|
|
2070
|
+
</div>\`;
|
|
2071
|
+
}).join("");
|
|
2072
|
+
for (const input of $("opts-providers").querySelectorAll("input")) {
|
|
2073
|
+
input.onchange = () => {
|
|
2074
|
+
const next = Object.fromEntries(order.map((id) => [id, vis.has(id)]));
|
|
2075
|
+
next[input.dataset.id] = input.checked;
|
|
2076
|
+
persistEnabled(next);
|
|
2077
|
+
};
|
|
2078
|
+
}
|
|
2079
|
+
const list = $("opts-providers");
|
|
2080
|
+
for (const grip of list.querySelectorAll(".grip")) {
|
|
2081
|
+
grip.onpointerdown = (e) => {
|
|
2082
|
+
if (e.button !== 0) return;
|
|
2083
|
+
e.preventDefault();
|
|
2084
|
+
const row = grip.closest(".opts-row");
|
|
2085
|
+
dragging = true;
|
|
2086
|
+
row.classList.add("dragging");
|
|
2087
|
+
const place = (clientY) => {
|
|
2088
|
+
const others = [...list.querySelectorAll(".opts-row")].filter((r) => r !== row);
|
|
2089
|
+
const before = others.find((r) => {
|
|
2090
|
+
const b = r.getBoundingClientRect();
|
|
2091
|
+
return clientY < b.top + b.height / 2;
|
|
2092
|
+
});
|
|
2093
|
+
if (before) list.insertBefore(row, before);
|
|
2094
|
+
else list.appendChild(row);
|
|
2095
|
+
};
|
|
2096
|
+
const move = (ev) => place(ev.clientY);
|
|
2097
|
+
const stop = () => {
|
|
2098
|
+
document.removeEventListener("pointermove", move);
|
|
2099
|
+
document.removeEventListener("pointerup", stop);
|
|
2100
|
+
document.removeEventListener("pointercancel", stop);
|
|
2101
|
+
dragging = false;
|
|
2102
|
+
row.classList.remove("dragging");
|
|
2103
|
+
const next = [...list.querySelectorAll(".opts-row")].map((r) => r.dataset.id);
|
|
2104
|
+
if (next.join() !== order.join()) persistOrder(next);
|
|
2105
|
+
};
|
|
2106
|
+
document.addEventListener("pointermove", move);
|
|
2107
|
+
document.addEventListener("pointerup", stop);
|
|
2108
|
+
document.addEventListener("pointercancel", stop);
|
|
2109
|
+
};
|
|
2110
|
+
}
|
|
2111
|
+
$("opts-signed-in").hidden = !enabled;
|
|
2112
|
+
for (const input of $("opts-meter").querySelectorAll("input")) {
|
|
2113
|
+
input.checked = (input.value === "left") === showLeft;
|
|
2114
|
+
}
|
|
2115
|
+
$("opts-email").checked = showEmail;
|
|
2116
|
+
renderAccounts();
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
function wizardMsg(text, err) {
|
|
2120
|
+
wizard.msg = text || "";
|
|
2121
|
+
wizard.err = !!err;
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
async function api(path, opts) {
|
|
2125
|
+
const res = await fetch(path, { cache: "no-store", ...opts });
|
|
2126
|
+
const body = await res.json().catch(() => ({}));
|
|
2127
|
+
if (!res.ok) throw new Error(body.error || \`HTTP \${res.status}\`);
|
|
2128
|
+
return body;
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
function renderAccounts() {
|
|
2132
|
+
const host = $("opts-accounts");
|
|
2133
|
+
if (!host) return;
|
|
2134
|
+
host.innerHTML = order.map((id) => {
|
|
2135
|
+
const name = PROVIDERS.find(([x]) => x === id)?.[1] || id;
|
|
2136
|
+
const p = report?.providers.find((x) => x.id === id);
|
|
2137
|
+
const rows = (p?.accounts || []).map((a, i) => {
|
|
2138
|
+
const title = accountTitle(a, i);
|
|
2139
|
+
const extra = a.account.kind === "default" ? "from CLI" : a.account.kind;
|
|
2140
|
+
if (renaming === a.account.id) {
|
|
2141
|
+
return \`<div class="acct-row">
|
|
2142
|
+
<span class="acct-rename"><input class="field acct-rename-input" data-id="\${esc(a.account.id)}" value="\${esc(title)}">
|
|
2143
|
+
<button class="refresh acct-rename-save" type="button" data-id="\${esc(a.account.id)}">Save</button></span>
|
|
2144
|
+
<button class="opts-link acct-rename-cancel" type="button">Cancel</button>
|
|
2145
|
+
</div>\`;
|
|
2146
|
+
}
|
|
2147
|
+
const rm = a.account.kind !== "default"
|
|
2148
|
+
? \`<button class="opts-link acct-remove" type="button" data-id="\${esc(a.account.id)}">Remove</button>\`
|
|
2149
|
+
: "";
|
|
2150
|
+
return \`<div class="acct-row"><span>\${esc(title)}<span class="acct-meta">\${esc(extra)}</span></span>
|
|
2151
|
+
<span class="acct-actions"><button class="opts-link acct-rename-btn" type="button" data-id="\${esc(a.account.id)}">Rename</button>\${rm}</span></div>\`;
|
|
2152
|
+
}).join("") || \`<div class="acct-note">No accounts yet.</div>\`;
|
|
2153
|
+
return \`<div class="acct-group"><div class="acct-head">\${logo(id)}\${esc(name)}</div>\${rows}</div>\`;
|
|
2154
|
+
}).join("");
|
|
2155
|
+
for (const btn of host.querySelectorAll(".acct-rename-btn")) {
|
|
2156
|
+
btn.onclick = () => { renaming = btn.dataset.id; renderAccounts(); };
|
|
2157
|
+
}
|
|
2158
|
+
const cancel = host.querySelector(".acct-rename-cancel");
|
|
2159
|
+
if (cancel) cancel.onclick = () => { renaming = null; renderAccounts(); };
|
|
2160
|
+
const save = host.querySelector(".acct-rename-save");
|
|
2161
|
+
const input = host.querySelector(".acct-rename-input");
|
|
2162
|
+
if (save && input) save.onclick = () => renameAccount(save.dataset.id, input.value);
|
|
2163
|
+
for (const btn of host.querySelectorAll(".acct-remove")) {
|
|
2164
|
+
btn.onclick = () => removeAccount(btn.dataset.id);
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
function persistAliases() {
|
|
2169
|
+
localStorage.setItem("ju.aliases", JSON.stringify(aliases));
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
async function renameAccount(id, label) {
|
|
2173
|
+
const next = String(label ?? "").trim();
|
|
2174
|
+
renaming = null;
|
|
2175
|
+
if (id.endsWith(":default")) {
|
|
2176
|
+
if (next && next !== "Default") aliases[id] = next;
|
|
2177
|
+
else delete aliases[id];
|
|
2178
|
+
persistAliases();
|
|
2179
|
+
render();
|
|
2180
|
+
return;
|
|
2181
|
+
}
|
|
2182
|
+
try {
|
|
2183
|
+
const body = await api(\`/api/accounts/\${encodeURIComponent(id)}\`, {
|
|
2184
|
+
method: "PATCH",
|
|
2185
|
+
headers: { "Content-Type": "application/json" },
|
|
2186
|
+
body: JSON.stringify({ label: next }),
|
|
2187
|
+
});
|
|
2188
|
+
if (body.account && report) {
|
|
2189
|
+
for (const p of report.providers) {
|
|
2190
|
+
const a = p.accounts.find((x) => x.account.id === id);
|
|
2191
|
+
if (a) a.account.label = body.account.label;
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
delete aliases[id];
|
|
2195
|
+
persistAliases();
|
|
2196
|
+
} catch (e) {
|
|
2197
|
+
wizardMsg(e.message, true);
|
|
2198
|
+
}
|
|
2199
|
+
render();
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
function resetWizard() {
|
|
2203
|
+
clearInterval(codexPoll);
|
|
2204
|
+
wizard = { step: "provider", provider: "", label: "", secret: "", sessionId: "", authUrl: "", callback: "", msg: "", err: false };
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
function openWizard() {
|
|
2208
|
+
resetWizard();
|
|
2209
|
+
$("add-acct").showModal();
|
|
2210
|
+
renderWizard();
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
function closeWizard() {
|
|
2214
|
+
$("add-acct").close();
|
|
2215
|
+
resetWizard();
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
function renderWizard() {
|
|
2219
|
+
const dlg = $("add-acct");
|
|
2220
|
+
if (!dlg.open) return;
|
|
2221
|
+
const name = PROVIDERS.find(([id]) => id === wizard.provider)?.[1] || "account";
|
|
2222
|
+
$("add-acct-title").textContent = wizard.step === "provider" ? "Add account" : \`Add \${name}\`;
|
|
2223
|
+
let body = "";
|
|
2224
|
+
if (wizard.step === "provider") {
|
|
2225
|
+
body = \`<div class="pick">
|
|
2226
|
+
<button class="pick-btn" type="button" data-id="claude">\${logo("claude")} Claude</button>
|
|
2227
|
+
<button class="pick-btn" type="button" data-id="codex">\${logo("codex")} Codex</button>
|
|
2228
|
+
<button class="pick-btn" type="button" data-id="opencode">\${logo("opencode")} OpenCode Go</button>
|
|
2229
|
+
</div>
|
|
2230
|
+
<div class="acct-note">One Cursor account for now — whatever <code>cursor-agent</code> is signed in as.</div>\`;
|
|
2231
|
+
} else if (wizard.step === "label") {
|
|
2232
|
+
body = \`<div class="acct-note">Optional. Leave blank to show as Account 2, Account 3, …</div>
|
|
2233
|
+
<input class="field" id="wiz-label" placeholder="Label (optional)" value="\${esc(wizard.label)}">
|
|
2234
|
+
<div class="acct-actions"><button class="opts-link" id="wiz-back" type="button">Back</button>
|
|
2235
|
+
<button class="refresh" id="wiz-next" type="button">Continue</button></div>\`;
|
|
2236
|
+
} else if (wizard.provider === "codex") {
|
|
2237
|
+
body = \`<div class="acct-note">Sign in with ChatGPT. If you are not on this machine, paste the localhost callback after the redirect.</div>
|
|
2238
|
+
\${wizard.authUrl ? \`<div class="acct-url">Open <a href="\${esc(wizard.authUrl)}" target="_blank" rel="noreferrer">this sign-in URL</a></div>
|
|
2239
|
+
<input class="field" id="wiz-callback" placeholder="http://localhost:… callback" value="\${esc(wizard.callback)}">
|
|
2240
|
+
<button class="refresh" id="wiz-callback-go" type="button">Submit callback</button>\`
|
|
2241
|
+
: \`<button class="refresh" id="wiz-codex" type="button">Sign in with ChatGPT</button>\`}
|
|
2242
|
+
<button class="opts-link" id="wiz-back" type="button">Back</button>\`;
|
|
2243
|
+
} else {
|
|
2244
|
+
const ph = wizard.provider === "claude" ? "Setup token from <code>claude setup-token</code>" : "OpenCode Go API key";
|
|
2245
|
+
body = \`<div class="acct-note">\${ph}.</div>
|
|
2246
|
+
<input class="field" id="wiz-secret" type="password" placeholder="\${wizard.provider === "claude" ? "Setup token" : "API key"}" value="\${esc(wizard.secret)}" autocomplete="off">
|
|
2247
|
+
<div class="acct-actions"><button class="opts-link" id="wiz-back" type="button">Back</button>
|
|
2248
|
+
<button class="refresh" id="wiz-save" type="button">Add</button></div>\`;
|
|
2249
|
+
}
|
|
2250
|
+
if (wizard.msg) body += \`<div class="acct-msg\${wizard.err ? " err" : ""}">\${esc(wizard.msg)}</div>\`;
|
|
2251
|
+
$("add-acct-body").innerHTML = body;
|
|
2252
|
+
for (const btn of dlg.querySelectorAll(".pick-btn")) {
|
|
2253
|
+
btn.onclick = () => { wizard.provider = btn.dataset.id; wizard.step = "label"; renderWizard(); };
|
|
2254
|
+
}
|
|
2255
|
+
const label = $("wiz-label");
|
|
2256
|
+
if (label) label.oninput = () => { wizard.label = label.value; };
|
|
2257
|
+
const secret = $("wiz-secret");
|
|
2258
|
+
if (secret) secret.oninput = () => { wizard.secret = secret.value; };
|
|
2259
|
+
const cb = $("wiz-callback");
|
|
2260
|
+
if (cb) cb.oninput = () => { wizard.callback = cb.value; };
|
|
2261
|
+
const back = $("wiz-back");
|
|
2262
|
+
if (back) back.onclick = () => {
|
|
2263
|
+
wizard.msg = "";
|
|
2264
|
+
wizard.step = wizard.step === "auth" ? "label" : "provider";
|
|
2265
|
+
if (wizard.step === "provider") wizard.provider = "";
|
|
2266
|
+
renderWizard();
|
|
2267
|
+
};
|
|
2268
|
+
const next = $("wiz-next");
|
|
2269
|
+
if (next) next.onclick = () => { wizard.step = "auth"; wizard.msg = ""; renderWizard(); };
|
|
2270
|
+
const save = $("wiz-save");
|
|
2271
|
+
if (save) save.onclick = addToken;
|
|
2272
|
+
const start = $("wiz-codex");
|
|
2273
|
+
if (start) start.onclick = startCodex;
|
|
2274
|
+
const submit = $("wiz-callback-go");
|
|
2275
|
+
if (submit) submit.onclick = submitCodexCallback;
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
async function addToken() {
|
|
2279
|
+
if (busy) return;
|
|
2280
|
+
if (!wizard.secret.trim()) { wizardMsg("Paste a token or key first.", true); renderWizard(); return; }
|
|
2281
|
+
busy = true;
|
|
2282
|
+
wizardMsg("Adding…", false);
|
|
2283
|
+
renderWizard();
|
|
2284
|
+
try {
|
|
2285
|
+
const body = await api("/api/accounts", {
|
|
2286
|
+
method: "POST",
|
|
2287
|
+
headers: { "Content-Type": "application/json" },
|
|
2288
|
+
body: JSON.stringify({ provider: wizard.provider, label: wizard.label, secret: wizard.secret }),
|
|
2289
|
+
});
|
|
2290
|
+
closeWizard();
|
|
2291
|
+
if (body.warning) wizardMsg(body.warning, false);
|
|
2292
|
+
await load(true);
|
|
2293
|
+
} catch (e) {
|
|
2294
|
+
wizardMsg(e.message, true);
|
|
2295
|
+
} finally {
|
|
2296
|
+
busy = false;
|
|
2297
|
+
if ($("add-acct").open) renderWizard();
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
async function startCodex() {
|
|
2302
|
+
if (busy) return;
|
|
2303
|
+
const popup = window.open("about:blank", "_blank");
|
|
2304
|
+
busy = true;
|
|
2305
|
+
wizardMsg("Starting sign-in…", false);
|
|
2306
|
+
renderWizard();
|
|
2307
|
+
try {
|
|
2308
|
+
const body = await api("/api/accounts/codex/start", {
|
|
2309
|
+
method: "POST",
|
|
2310
|
+
headers: { "Content-Type": "application/json" },
|
|
2311
|
+
body: JSON.stringify({ label: wizard.label }),
|
|
2312
|
+
});
|
|
2313
|
+
wizard.sessionId = body.sessionId;
|
|
2314
|
+
wizard.authUrl = body.authUrl;
|
|
2315
|
+
if (body.authUrl) {
|
|
2316
|
+
if (popup) popup.location.replace(body.authUrl);
|
|
2317
|
+
else window.location.assign(body.authUrl);
|
|
2318
|
+
} else {
|
|
2319
|
+
popup?.close();
|
|
2320
|
+
}
|
|
2321
|
+
wizardMsg("Waiting for sign-in…", false);
|
|
2322
|
+
pollCodex();
|
|
2323
|
+
} catch (e) {
|
|
2324
|
+
popup?.close();
|
|
2325
|
+
wizardMsg(e.message, true);
|
|
2326
|
+
} finally {
|
|
2327
|
+
busy = false;
|
|
2328
|
+
renderWizard();
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
async function submitCodexCallback() {
|
|
2333
|
+
if (!wizard.sessionId) return;
|
|
2334
|
+
try {
|
|
2335
|
+
await api("/api/accounts/codex/callback", {
|
|
2336
|
+
method: "POST",
|
|
2337
|
+
headers: { "Content-Type": "application/json" },
|
|
2338
|
+
body: JSON.stringify({ sessionId: wizard.sessionId, url: wizard.callback }),
|
|
2339
|
+
});
|
|
2340
|
+
wizardMsg("Callback submitted. Waiting for Codex…", false);
|
|
2341
|
+
renderWizard();
|
|
2342
|
+
pollCodex();
|
|
2343
|
+
} catch (e) {
|
|
2344
|
+
wizardMsg(e.message, true);
|
|
2345
|
+
renderWizard();
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
function pollCodex() {
|
|
2350
|
+
clearInterval(codexPoll);
|
|
2351
|
+
if (!wizard.sessionId) return;
|
|
2352
|
+
const tick = async () => {
|
|
2353
|
+
try {
|
|
2354
|
+
const body = await api(\`/api/accounts/codex/session/\${encodeURIComponent(wizard.sessionId)}\`);
|
|
2355
|
+
if (body.status === "done") {
|
|
2356
|
+
clearInterval(codexPoll);
|
|
2357
|
+
closeWizard();
|
|
2358
|
+
await load(true);
|
|
2359
|
+
} else if (body.status === "error") {
|
|
2360
|
+
clearInterval(codexPoll);
|
|
2361
|
+
wizardMsg(body.error || "Sign-in failed.", true);
|
|
2362
|
+
wizard.sessionId = "";
|
|
2363
|
+
renderWizard();
|
|
2364
|
+
}
|
|
2365
|
+
} catch {}
|
|
2366
|
+
};
|
|
2367
|
+
tick();
|
|
2368
|
+
codexPoll = setInterval(tick, 2000);
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
async function removeAccount(id) {
|
|
2372
|
+
if (busy) return;
|
|
2373
|
+
busy = true;
|
|
2374
|
+
try {
|
|
2375
|
+
await api(\`/api/accounts/\${encodeURIComponent(id)}\`, { method: "DELETE" });
|
|
2376
|
+
await load(true);
|
|
2377
|
+
} catch (e) {
|
|
2378
|
+
wizardMsg(e.message, true);
|
|
2379
|
+
} finally {
|
|
2380
|
+
busy = false;
|
|
2381
|
+
renderAccounts();
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
function renderSettings() {
|
|
2386
|
+
if (!dragging) renderOptions();
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
function renderChrome() {
|
|
1432
2390
|
if (report) {
|
|
1433
|
-
$("host").textContent = report.hostname;
|
|
1434
|
-
$("updated").textContent =
|
|
2391
|
+
$("host").textContent = formatHost(report.hostname);
|
|
2392
|
+
$("updated").textContent = \`updated \${fmtAgo(report.fetchedAt)}\`;
|
|
1435
2393
|
$("version").textContent = \`v\${report.version}\`;
|
|
1436
2394
|
const u = report.update;
|
|
1437
2395
|
$("update").innerHTML = u && u.available
|
|
1438
2396
|
? \`<span class="update">v\${esc(u.latest)} available — run <code>just-usage upgrade</code></span>\`
|
|
1439
|
-
:
|
|
2397
|
+
: "";
|
|
2398
|
+
}
|
|
2399
|
+
const locked = refreshLocked();
|
|
2400
|
+
$("refresh").disabled = loading || locked;
|
|
2401
|
+
$("refresh").textContent = loading ? "Refreshing…" : locked ? refreshLabel() : "Refresh data";
|
|
2402
|
+
$("opts-label").textContent = settingsOpen ? "Back" : "Options";
|
|
2403
|
+
$("opts-btn").classList.toggle("back", settingsOpen);
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
function render() {
|
|
2407
|
+
ensureActive();
|
|
2408
|
+
$("dash").hidden = settingsOpen;
|
|
2409
|
+
$("settings").hidden = !settingsOpen;
|
|
2410
|
+
if (settingsOpen) renderSettings();
|
|
2411
|
+
else {
|
|
2412
|
+
renderTabs();
|
|
2413
|
+
renderPanel();
|
|
2414
|
+
}
|
|
2415
|
+
renderChrome();
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
function adoptReport(next) {
|
|
2419
|
+
if (report) {
|
|
2420
|
+
for (const p of next.providers || []) {
|
|
2421
|
+
const prev = report.providers.find((x) => x.id === p.id);
|
|
2422
|
+
for (const a of p.accounts || []) {
|
|
2423
|
+
const old = prev?.accounts.find((x) => x.account.id === a.account.id);
|
|
2424
|
+
if (!a.account.email && old?.account.email) a.account.email = old.account.email;
|
|
2425
|
+
if (!a.account.plan && old?.account.plan) a.account.plan = old.account.plan;
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
1440
2428
|
}
|
|
1441
|
-
|
|
2429
|
+
report = next;
|
|
2430
|
+
try { localStorage.setItem("ju.report", JSON.stringify(next)); } catch {}
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
const COOLDOWN_MS = 5 * 60 * 1000;
|
|
2434
|
+
function refreshUntil() {
|
|
2435
|
+
return Number(localStorage.getItem("ju.refreshAt") || 0) + COOLDOWN_MS;
|
|
2436
|
+
}
|
|
2437
|
+
function refreshLocked() {
|
|
2438
|
+
return Date.now() < refreshUntil();
|
|
2439
|
+
}
|
|
2440
|
+
function refreshLabel() {
|
|
2441
|
+
const left = refreshUntil() - Date.now();
|
|
2442
|
+
if (left <= 0) return "Refresh data";
|
|
2443
|
+
const min = Math.max(1, Math.ceil(left / 60000));
|
|
2444
|
+
return \`Refresh in \${min}m\`;
|
|
2445
|
+
}
|
|
2446
|
+
function markRefreshed() {
|
|
2447
|
+
localStorage.setItem("ju.refreshAt", String(Date.now()));
|
|
1442
2448
|
}
|
|
1443
2449
|
|
|
1444
|
-
async function load(force) {
|
|
2450
|
+
async function load(force, manual) {
|
|
2451
|
+
if (manual && refreshLocked()) return;
|
|
2452
|
+
if (manual) markRefreshed();
|
|
1445
2453
|
loading = true;
|
|
1446
2454
|
render();
|
|
1447
2455
|
try {
|
|
1448
2456
|
const res = await fetch(\`/api/quotas\${force ? "?refresh=1" : ""}\`, { cache: "no-store" });
|
|
1449
|
-
|
|
2457
|
+
adoptReport(await res.json());
|
|
1450
2458
|
} catch (e) {
|
|
1451
|
-
$("panel").innerHTML = \`<div class="empty">Couldn't reach just-usage. Is the server still running?</div>\`;
|
|
2459
|
+
if (!report) $("panel").innerHTML = \`<div class="empty">Couldn't reach just-usage. Is the server still running?</div>\`;
|
|
1452
2460
|
} finally {
|
|
1453
2461
|
loading = false;
|
|
1454
2462
|
render();
|
|
1455
2463
|
}
|
|
1456
2464
|
}
|
|
1457
2465
|
|
|
1458
|
-
$("
|
|
2466
|
+
$("opts-btn").onclick = () => setView(!settingsOpen);
|
|
2467
|
+
$("refresh").onclick = () => load(true, true);
|
|
2468
|
+
$("opts-signed-in").onclick = () => persistEnabled(null);
|
|
2469
|
+
$("opts-email").onchange = () => {
|
|
2470
|
+
showEmail = $("opts-email").checked;
|
|
2471
|
+
localStorage.setItem("ju.showEmail", showEmail ? "1" : "0");
|
|
2472
|
+
if (settingsOpen) renderAccounts();
|
|
2473
|
+
else render();
|
|
2474
|
+
};
|
|
2475
|
+
$("acct-add").onclick = openWizard;
|
|
2476
|
+
$("add-acct-close").onclick = closeWizard;
|
|
2477
|
+
$("add-acct").addEventListener("close", resetWizard);
|
|
2478
|
+
$("add-acct").addEventListener("click", (e) => { if (e.target === $("add-acct")) closeWizard(); });
|
|
2479
|
+
for (const input of $("opts-meter").querySelectorAll("input")) {
|
|
2480
|
+
input.onchange = () => {
|
|
2481
|
+
showLeft = input.value === "left";
|
|
2482
|
+
localStorage.setItem("ju.showLeft", showLeft ? "1" : "0");
|
|
2483
|
+
if (!settingsOpen) render();
|
|
2484
|
+
};
|
|
2485
|
+
}
|
|
1459
2486
|
document.addEventListener("keydown", (e) => {
|
|
1460
2487
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
1461
|
-
if (
|
|
1462
|
-
|
|
2488
|
+
if (settingsOpen) return;
|
|
2489
|
+
const vis = visibleIds();
|
|
2490
|
+
if (e.key >= "1" && e.key <= String(vis.length)) {
|
|
2491
|
+
active = vis[Number(e.key) - 1];
|
|
2492
|
+
localStorage.setItem("ju.tab", active);
|
|
2493
|
+
render();
|
|
2494
|
+
} else if (e.key === "r") load(true, true);
|
|
1463
2495
|
});
|
|
1464
|
-
|
|
2496
|
+
const STALE_MS = 5 * 60 * 1000;
|
|
2497
|
+
function maybeAutoRefresh() {
|
|
2498
|
+
if (!report || loading || settingsOpen) return;
|
|
2499
|
+
const t = Date.parse(report.fetchedAt);
|
|
2500
|
+
if (Number.isFinite(t) && Date.now() - t >= STALE_MS) load(true);
|
|
2501
|
+
}
|
|
2502
|
+
setInterval(() => {
|
|
2503
|
+
maybeAutoRefresh();
|
|
2504
|
+
if (!report || loading) return;
|
|
2505
|
+
if (settingsOpen) renderChrome();
|
|
2506
|
+
else render();
|
|
2507
|
+
}, 15000);
|
|
2508
|
+
document.addEventListener("visibilitychange", () => {
|
|
2509
|
+
if (document.visibilityState === "visible") maybeAutoRefresh();
|
|
2510
|
+
});
|
|
2511
|
+
if (report) render();
|
|
1465
2512
|
load(false);
|
|
1466
2513
|
})();
|
|
1467
2514
|
</script>
|
|
@@ -1469,7 +2516,41 @@ var ui_default = `<!doctype html>
|
|
|
1469
2516
|
</html>
|
|
1470
2517
|
`;
|
|
1471
2518
|
|
|
2519
|
+
// src/ui/logos/claude.svg
|
|
2520
|
+
var claude_default = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ececec" role="img">
|
|
2521
|
+
<title>Claude</title>
|
|
2522
|
+
<path d="m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z"/>
|
|
2523
|
+
</svg>
|
|
2524
|
+
`;
|
|
2525
|
+
|
|
2526
|
+
// src/ui/logos/codex.svg
|
|
2527
|
+
var codex_default = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ececec" role="img">
|
|
2528
|
+
<title>Codex</title>
|
|
2529
|
+
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z"/>
|
|
2530
|
+
</svg>
|
|
2531
|
+
`;
|
|
2532
|
+
|
|
2533
|
+
// src/ui/logos/cursor.svg
|
|
2534
|
+
var cursor_default = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ececec" role="img">
|
|
2535
|
+
<title>Cursor</title>
|
|
2536
|
+
<path d="M11.503.131 1.891 5.678a.84.84 0 0 0-.42.726v11.188c0 .3.162.575.42.724l9.609 5.55a1 1 0 0 0 .998 0l9.61-5.55a.84.84 0 0 0 .42-.724V6.404a.84.84 0 0 0-.42-.726L12.497.131a1.01 1.01 0 0 0-.996 0M2.657 6.338h18.55c.263 0 .43.287.297.515L12.23 22.918c-.062.107-.229.064-.229-.06V12.335a.59.59 0 0 0-.295-.51l-9.11-5.257c-.109-.063-.064-.23.061-.23"/>
|
|
2537
|
+
</svg>
|
|
2538
|
+
`;
|
|
2539
|
+
|
|
2540
|
+
// src/ui/logos/opencode.svg
|
|
2541
|
+
var opencode_default = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ececec" fill-rule="evenodd" role="img">
|
|
2542
|
+
<title>OpenCode</title>
|
|
2543
|
+
<path d="M22 24H2V0h20zM17 4.8H7v14.4h10z"/>
|
|
2544
|
+
</svg>
|
|
2545
|
+
`;
|
|
2546
|
+
|
|
1472
2547
|
// src/server.ts
|
|
2548
|
+
var LOGOS = {
|
|
2549
|
+
claude: claude_default,
|
|
2550
|
+
codex: codex_default,
|
|
2551
|
+
cursor: cursor_default,
|
|
2552
|
+
opencode: opencode_default
|
|
2553
|
+
};
|
|
1473
2554
|
function json(res, status, body) {
|
|
1474
2555
|
const text = JSON.stringify(body);
|
|
1475
2556
|
res.writeHead(status, {
|
|
@@ -1479,33 +2560,134 @@ function json(res, status, body) {
|
|
|
1479
2560
|
});
|
|
1480
2561
|
res.end(text);
|
|
1481
2562
|
}
|
|
1482
|
-
function
|
|
2563
|
+
function readBody(req, limit = 64000) {
|
|
2564
|
+
return new Promise((resolve, reject) => {
|
|
2565
|
+
const chunks = [];
|
|
2566
|
+
let n = 0;
|
|
2567
|
+
req.on("data", (c) => {
|
|
2568
|
+
n += c.length;
|
|
2569
|
+
if (n > limit) {
|
|
2570
|
+
req.destroy();
|
|
2571
|
+
reject(new AccountError("Request body too large."));
|
|
2572
|
+
return;
|
|
2573
|
+
}
|
|
2574
|
+
chunks.push(c);
|
|
2575
|
+
});
|
|
2576
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
2577
|
+
req.on("error", reject);
|
|
2578
|
+
});
|
|
2579
|
+
}
|
|
2580
|
+
async function readJson(req) {
|
|
2581
|
+
const text = await readBody(req);
|
|
2582
|
+
if (!text.trim())
|
|
2583
|
+
return {};
|
|
2584
|
+
const parsed = JSON.parse(text);
|
|
2585
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
2586
|
+
throw new AccountError("Expected a JSON object.");
|
|
2587
|
+
return parsed;
|
|
2588
|
+
}
|
|
2589
|
+
function str(v) {
|
|
2590
|
+
return typeof v === "string" ? v : "";
|
|
2591
|
+
}
|
|
2592
|
+
async function startServer(opts) {
|
|
1483
2593
|
const cache = new ReportCache(CACHE_TTL_MS, opts.getUpdate);
|
|
1484
2594
|
cache.get(true).catch(() => {});
|
|
2595
|
+
const tailscaleIp = await detectTailscaleIp();
|
|
2596
|
+
const mutated = async (work) => {
|
|
2597
|
+
const out = await work();
|
|
2598
|
+
cache.invalidate();
|
|
2599
|
+
return out;
|
|
2600
|
+
};
|
|
1485
2601
|
const handler = async (req, res) => {
|
|
1486
2602
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
1487
2603
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
1488
2604
|
try {
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
return;
|
|
1492
|
-
}
|
|
1493
|
-
if (url.pathname === "/") {
|
|
2605
|
+
const method = req.method ?? "GET";
|
|
2606
|
+
if (url.pathname === "/" && (method === "GET" || method === "HEAD")) {
|
|
1494
2607
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
|
1495
2608
|
res.end(ui_default);
|
|
1496
2609
|
return;
|
|
1497
2610
|
}
|
|
1498
|
-
|
|
2611
|
+
const logo = url.pathname.match(/^\/logos\/([a-z]+)\.svg$/);
|
|
2612
|
+
if (logo && (method === "GET" || method === "HEAD")) {
|
|
2613
|
+
const svg = LOGOS[logo[1]];
|
|
2614
|
+
if (!svg) {
|
|
2615
|
+
json(res, 404, { error: "not found" });
|
|
2616
|
+
return;
|
|
2617
|
+
}
|
|
2618
|
+
res.writeHead(200, { "Content-Type": "image/svg+xml; charset=utf-8", "Cache-Control": "public, max-age=86400" });
|
|
2619
|
+
res.end(svg);
|
|
2620
|
+
return;
|
|
2621
|
+
}
|
|
2622
|
+
if (url.pathname === "/api/quotas" && (method === "GET" || method === "HEAD")) {
|
|
1499
2623
|
const force = url.searchParams.get("refresh") === "1";
|
|
1500
2624
|
json(res, 200, await cache.get(force));
|
|
1501
2625
|
return;
|
|
1502
2626
|
}
|
|
1503
|
-
if (url.pathname === "/api/health") {
|
|
2627
|
+
if (url.pathname === "/api/health" && (method === "GET" || method === "HEAD")) {
|
|
1504
2628
|
json(res, 200, { ok: true, version: VERSION });
|
|
1505
2629
|
return;
|
|
1506
2630
|
}
|
|
2631
|
+
if (url.pathname === "/api/accounts" && method === "POST") {
|
|
2632
|
+
const body = await readJson(req);
|
|
2633
|
+
const provider = str(body.provider);
|
|
2634
|
+
const label = str(body.label) || undefined;
|
|
2635
|
+
const secret = str(body.secret);
|
|
2636
|
+
if (provider === "claude") {
|
|
2637
|
+
json(res, 200, await mutated(() => addClaudeToken(secret, label)));
|
|
2638
|
+
return;
|
|
2639
|
+
}
|
|
2640
|
+
if (provider === "opencode") {
|
|
2641
|
+
json(res, 200, await mutated(() => addOpenCodeKey(secret, label)));
|
|
2642
|
+
return;
|
|
2643
|
+
}
|
|
2644
|
+
json(res, 400, { error: "Add Claude with a setup-token, OpenCode with an API key, or start a Codex sign-in." });
|
|
2645
|
+
return;
|
|
2646
|
+
}
|
|
2647
|
+
if (url.pathname === "/api/accounts/codex/start" && method === "POST") {
|
|
2648
|
+
const body = await readJson(req);
|
|
2649
|
+
const accountId = str(body.accountId);
|
|
2650
|
+
json(res, 200, accountId ? await beginCodexRelogin(accountId) : await beginCodexAdd(str(body.label) || undefined));
|
|
2651
|
+
return;
|
|
2652
|
+
}
|
|
2653
|
+
if (url.pathname === "/api/accounts/codex/callback" && method === "POST") {
|
|
2654
|
+
const body = await readJson(req);
|
|
2655
|
+
await submitCodexCallback(str(body.sessionId), str(body.url));
|
|
2656
|
+
json(res, 200, { ok: true });
|
|
2657
|
+
return;
|
|
2658
|
+
}
|
|
2659
|
+
const session = url.pathname.match(/^\/api\/accounts\/codex\/session\/([^/]+)$/);
|
|
2660
|
+
if (session && (method === "GET" || method === "HEAD")) {
|
|
2661
|
+
const state = codexSessionStatus(decodeURIComponent(session[1]));
|
|
2662
|
+
if (state.status === "done")
|
|
2663
|
+
cache.invalidate();
|
|
2664
|
+
json(res, 200, state);
|
|
2665
|
+
return;
|
|
2666
|
+
}
|
|
2667
|
+
const remove = url.pathname.match(/^\/api\/accounts\/([^/]+)$/);
|
|
2668
|
+
if (remove && method === "PATCH") {
|
|
2669
|
+
const body = await readJson(req);
|
|
2670
|
+
json(res, 200, { account: await mutated(async () => renameExtraAccount(decodeURIComponent(remove[1]), str(body.label))) });
|
|
2671
|
+
return;
|
|
2672
|
+
}
|
|
2673
|
+
if (remove && method === "DELETE") {
|
|
2674
|
+
json(res, 200, { account: await mutated(() => removeExtraAccount(decodeURIComponent(remove[1]))) });
|
|
2675
|
+
return;
|
|
2676
|
+
}
|
|
2677
|
+
if (method !== "GET" && method !== "HEAD" && method !== "POST" && method !== "DELETE" && method !== "PATCH") {
|
|
2678
|
+
json(res, 405, { error: "method not allowed" });
|
|
2679
|
+
return;
|
|
2680
|
+
}
|
|
1507
2681
|
json(res, 404, { error: "not found" });
|
|
1508
2682
|
} catch (e) {
|
|
2683
|
+
if (e instanceof AccountError) {
|
|
2684
|
+
json(res, e.status, { error: e.message });
|
|
2685
|
+
return;
|
|
2686
|
+
}
|
|
2687
|
+
if (e instanceof SyntaxError) {
|
|
2688
|
+
json(res, 400, { error: "Invalid JSON." });
|
|
2689
|
+
return;
|
|
2690
|
+
}
|
|
1509
2691
|
json(res, 500, { error: e instanceof Error ? e.message : String(e) });
|
|
1510
2692
|
}
|
|
1511
2693
|
};
|
|
@@ -1513,7 +2695,7 @@ function startServer(opts) {
|
|
|
1513
2695
|
return new Promise((resolve, reject) => {
|
|
1514
2696
|
server.once("error", reject);
|
|
1515
2697
|
server.listen(opts.port, opts.host, () => {
|
|
1516
|
-
resolve({ close: () => server.close(), urls: reachableUrls(opts.host, opts.port) });
|
|
2698
|
+
resolve({ close: () => server.close(), urls: reachableUrls(opts.host, opts.port, tailscaleIp) });
|
|
1517
2699
|
});
|
|
1518
2700
|
});
|
|
1519
2701
|
}
|
|
@@ -1521,22 +2703,49 @@ function isTailscale(ip) {
|
|
|
1521
2703
|
const [a, b] = ip.split(".").map(Number);
|
|
1522
2704
|
return a === 100 && b !== undefined && b >= 64 && b <= 127;
|
|
1523
2705
|
}
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
2706
|
+
var TAILSCALE_NOTE = "Tailscale only. Not same-Wi-Fi. Other devices need Tailscale too.";
|
|
2707
|
+
async function detectTailscaleIp() {
|
|
2708
|
+
const res = await run("tailscale", ["status", "--json"], { timeoutMs: 4000 });
|
|
2709
|
+
if (res.code !== 0)
|
|
2710
|
+
return null;
|
|
2711
|
+
try {
|
|
2712
|
+
const body = JSON.parse(res.stdout);
|
|
2713
|
+
if (body.BackendState !== "Running")
|
|
2714
|
+
return null;
|
|
2715
|
+
const ips = body.Self?.TailscaleIPs ?? body.TailscaleIPs;
|
|
2716
|
+
if (!Array.isArray(ips))
|
|
2717
|
+
return null;
|
|
2718
|
+
for (const ip of ips) {
|
|
2719
|
+
if (typeof ip === "string" && isTailscale(ip))
|
|
2720
|
+
return ip;
|
|
2721
|
+
}
|
|
2722
|
+
return null;
|
|
2723
|
+
} catch {
|
|
2724
|
+
return null;
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
function reachableUrls(host, port, tailscaleIp = null) {
|
|
2728
|
+
if (host !== "0.0.0.0" && host !== "::") {
|
|
2729
|
+
return [{ kind: "local", url: `http://${host === "::1" ? "localhost" : host}:${port}` }];
|
|
2730
|
+
}
|
|
2731
|
+
const urls = [{ kind: "local", url: `http://127.0.0.1:${port}` }];
|
|
1528
2732
|
const shortHost = hostname2().replace(/\.local$/, "").toLowerCase();
|
|
1529
2733
|
if (shortHost)
|
|
1530
|
-
urls.push(`http://${shortHost}:${port}`);
|
|
1531
|
-
const seen = new Set;
|
|
2734
|
+
urls.push({ kind: "network", url: `http://${shortHost}:${port}` });
|
|
2735
|
+
const seen = new Set(tailscaleIp ? [tailscaleIp] : []);
|
|
1532
2736
|
for (const list of Object.values(networkInterfaces())) {
|
|
1533
2737
|
for (const iface of list ?? []) {
|
|
1534
2738
|
if (iface.family !== "IPv4" || iface.internal || seen.has(iface.address))
|
|
1535
2739
|
continue;
|
|
2740
|
+
if (isTailscale(iface.address))
|
|
2741
|
+
continue;
|
|
1536
2742
|
seen.add(iface.address);
|
|
1537
|
-
urls.push(`http://${iface.address}:${port}
|
|
2743
|
+
urls.push({ kind: "network", url: `http://${iface.address}:${port}` });
|
|
1538
2744
|
}
|
|
1539
2745
|
}
|
|
2746
|
+
if (tailscaleIp) {
|
|
2747
|
+
urls.push({ kind: "tailscale", url: `http://${tailscaleIp}:${port}`, note: TAILSCALE_NOTE });
|
|
2748
|
+
}
|
|
1540
2749
|
return urls;
|
|
1541
2750
|
}
|
|
1542
2751
|
|
|
@@ -1559,7 +2768,7 @@ function bar(used, width = 20) {
|
|
|
1559
2768
|
function pct(used) {
|
|
1560
2769
|
if (used === null)
|
|
1561
2770
|
return " — ";
|
|
1562
|
-
const s =
|
|
2771
|
+
const s = formatPercent(used).padStart(5);
|
|
1563
2772
|
const sev = severity(used);
|
|
1564
2773
|
return sev === "crit" ? c.red(s) : sev === "warn" ? c.yellow(s) : s;
|
|
1565
2774
|
}
|
|
@@ -1614,10 +2823,10 @@ function readCache() {
|
|
|
1614
2823
|
} catch {}
|
|
1615
2824
|
return null;
|
|
1616
2825
|
}
|
|
1617
|
-
function writeCache(
|
|
2826
|
+
function writeCache(c) {
|
|
1618
2827
|
try {
|
|
1619
2828
|
ensureDir(dirname3(paths.updateCache()));
|
|
1620
|
-
writeFileSync3(paths.updateCache(), JSON.stringify(
|
|
2829
|
+
writeFileSync3(paths.updateCache(), JSON.stringify(c) + `
|
|
1621
2830
|
`);
|
|
1622
2831
|
} catch {}
|
|
1623
2832
|
}
|
|
@@ -1668,8 +2877,8 @@ async function checkForUpdate(force = false) {
|
|
|
1668
2877
|
writeCache(entry);
|
|
1669
2878
|
return toInfo(entry);
|
|
1670
2879
|
}
|
|
1671
|
-
function toInfo(
|
|
1672
|
-
return { current: VERSION, latest:
|
|
2880
|
+
function toInfo(c) {
|
|
2881
|
+
return { current: VERSION, latest: c.latest, available: semverGt(c.latest, VERSION), checkedAt: c.checkedAt };
|
|
1673
2882
|
}
|
|
1674
2883
|
function detectPackageManager(argv1 = process.argv[1] ?? "", execPath = process.execPath) {
|
|
1675
2884
|
const p = argv1.replace(/\\/g, "/");
|
|
@@ -1790,12 +2999,14 @@ Update available: v${u.current} → v${u.latest}. Run: just-usage upgrade
|
|
|
1790
2999
|
throw e;
|
|
1791
3000
|
}
|
|
1792
3001
|
console.log(`${PACKAGE_NAME} v${VERSION}`);
|
|
1793
|
-
for (const
|
|
1794
|
-
|
|
3002
|
+
for (const u of server.urls) {
|
|
3003
|
+
const tag = u.kind === "local" ? "local " : u.kind === "tailscale" ? "tailscale" : "network ";
|
|
3004
|
+
console.log(` ${tag} ${u.url}${u.note ? ` — ${u.note}` : ""}`);
|
|
3005
|
+
}
|
|
1795
3006
|
console.log(`
|
|
1796
3007
|
Press Ctrl+C to stop.`);
|
|
1797
3008
|
if (shouldOpen)
|
|
1798
|
-
openInBrowser(server.urls[0]);
|
|
3009
|
+
openInBrowser(server.urls[0].url);
|
|
1799
3010
|
const shutdown = () => {
|
|
1800
3011
|
server.close();
|
|
1801
3012
|
process.exit(0);
|
|
@@ -1841,30 +3052,18 @@ ${url}
|
|
|
1841
3052
|
`);
|
|
1842
3053
|
openInBrowser(url);
|
|
1843
3054
|
});
|
|
1844
|
-
const
|
|
1845
|
-
|
|
1846
|
-
const path = id === tmpId ? dir : ensureDir(profileDirFor("codex", id));
|
|
1847
|
-
if (path !== dir) {
|
|
1848
|
-
const { renameSync, rmSync: rmSync2 } = await import("node:fs");
|
|
1849
|
-
rmSync2(path, { recursive: true, force: true });
|
|
1850
|
-
renameSync(dir, path);
|
|
1851
|
-
}
|
|
1852
|
-
saveAccount({ id, provider: "codex", label: finalLabel, kind: "profile", path, email: info.email, createdAt: new Date().toISOString() });
|
|
1853
|
-
console.log(`Added ${id}${info.email ? ` (${info.email}${info.plan ? `, ${info.plan}` : ""})` : ""}.`);
|
|
3055
|
+
const rec = saveCodexProfile({ label, tmpId, dir, email: info.email });
|
|
3056
|
+
console.log(`Added ${rec.id}${info.email ? ` (${info.email}${info.plan ? `, ${info.plan}` : ""})` : ""}.`);
|
|
1854
3057
|
}
|
|
1855
|
-
async function
|
|
3058
|
+
async function addClaudeTokenCli(label) {
|
|
1856
3059
|
console.log("Run `claude setup-token` in the account you want to add, then paste the token here.");
|
|
1857
3060
|
const token = await prompt("Token: ", { secret: true });
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
fail(
|
|
1863
|
-
|
|
1864
|
-
const id = newAccountId("claude", finalLabel);
|
|
1865
|
-
await secretStore().set(id, token);
|
|
1866
|
-
saveAccount({ id, provider: "claude", label: finalLabel, kind: "token", email: check.email, createdAt: new Date().toISOString() });
|
|
1867
|
-
console.log(`Added ${id}${check.email ? ` (${check.email})` : ""}. Token stored in ${secretStore().kind}.`);
|
|
3061
|
+
try {
|
|
3062
|
+
const { account } = await addClaudeToken(token, label);
|
|
3063
|
+
console.log(`Added ${account.id}${account.email ? ` (${account.email})` : ""}.`);
|
|
3064
|
+
} catch (e) {
|
|
3065
|
+
fail(e instanceof Error ? e.message : String(e));
|
|
3066
|
+
}
|
|
1868
3067
|
}
|
|
1869
3068
|
async function addClaudeProfile(label) {
|
|
1870
3069
|
if (!await which("claude"))
|
|
@@ -1876,31 +3075,25 @@ async function addClaudeProfile(label) {
|
|
|
1876
3075
|
await runInteractive("claude", ["auth", "login"], { CLAUDE_CONFIG_DIR: dir });
|
|
1877
3076
|
const status = await claudeAuthStatus(dir);
|
|
1878
3077
|
if (!status?.loggedIn) {
|
|
1879
|
-
const { rmSync
|
|
1880
|
-
|
|
3078
|
+
const { rmSync } = await import("node:fs");
|
|
3079
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1881
3080
|
fail("Login did not complete; nothing was saved.");
|
|
1882
3081
|
}
|
|
1883
3082
|
saveAccount({ id, provider: "claude", label: label ?? id.split(":")[1], kind: "profile", path: dir, email: null, createdAt: new Date().toISOString() });
|
|
1884
3083
|
console.log(`
|
|
1885
3084
|
Added ${id}. Note: on macOS the first read may trigger a Keychain prompt — choose "Always Allow".`);
|
|
1886
3085
|
}
|
|
1887
|
-
async function
|
|
3086
|
+
async function addOpenCodeCli(label) {
|
|
1888
3087
|
console.log("Paste an OpenCode Go API key (from https://opencode.ai/ → Go → API keys).");
|
|
1889
3088
|
const key = await prompt("Key: ", { secret: true });
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
fail(`Usage endpoint returned HTTP ${status}.`);
|
|
1899
|
-
const finalLabel = label ?? "go";
|
|
1900
|
-
const id = newAccountId("opencode", finalLabel);
|
|
1901
|
-
await secretStore().set(id, key);
|
|
1902
|
-
saveAccount({ id, provider: "opencode", label: finalLabel, kind: "token", createdAt: new Date().toISOString() });
|
|
1903
|
-
console.log(`Added ${id}${windows.length ? ` (${windows.map((w) => `${w.label} ${w.usedPercent}%`).join(", ")})` : ""}. Key stored in ${secretStore().kind}.`);
|
|
3089
|
+
try {
|
|
3090
|
+
const { account, warning } = await addOpenCodeKey(key, label);
|
|
3091
|
+
if (warning)
|
|
3092
|
+
console.log(`Warning: ${warning} Saving anyway.`);
|
|
3093
|
+
console.log(`Added ${account.id}.`);
|
|
3094
|
+
} catch (e) {
|
|
3095
|
+
fail(e instanceof Error ? e.message : String(e));
|
|
3096
|
+
}
|
|
1904
3097
|
}
|
|
1905
3098
|
async function cmdAdd(argv) {
|
|
1906
3099
|
const { values, positionals } = parseArgs({
|
|
@@ -1915,9 +3108,9 @@ async function cmdAdd(argv) {
|
|
|
1915
3108
|
case "codex":
|
|
1916
3109
|
return addCodex(values.label);
|
|
1917
3110
|
case "claude":
|
|
1918
|
-
return values.token ?
|
|
3111
|
+
return values.token ? addClaudeTokenCli(values.label) : addClaudeProfile(values.label);
|
|
1919
3112
|
case "opencode":
|
|
1920
|
-
return
|
|
3113
|
+
return addOpenCodeCli(values.label);
|
|
1921
3114
|
case "cursor":
|
|
1922
3115
|
fail("Cursor is single-account: just-usage shows whatever `cursor-agent` is logged in as.");
|
|
1923
3116
|
}
|
|
@@ -1955,12 +3148,12 @@ async function cmdRemove(argv) {
|
|
|
1955
3148
|
fail("Usage: just-usage remove <account-id>");
|
|
1956
3149
|
if (id.endsWith(":default"))
|
|
1957
3150
|
fail("Default accounts belong to the CLI itself; sign out there instead.");
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
3151
|
+
try {
|
|
3152
|
+
const rec = await removeExtraAccount(id);
|
|
3153
|
+
console.log(`Removed ${rec.id}.`);
|
|
3154
|
+
} catch (e) {
|
|
3155
|
+
fail(e instanceof Error ? e.message : String(e));
|
|
3156
|
+
}
|
|
1964
3157
|
}
|
|
1965
3158
|
async function cmdUpgrade(argv) {
|
|
1966
3159
|
const { values } = parseArgs({ args: argv, options: { check: { type: "boolean" }, yes: { type: "boolean", short: "y" } }, allowPositionals: true, strict: false });
|