ccdeck 3.28.1 → 3.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/THIRD_PARTY_NOTICES.md +148 -4
- package/dist/web/assets/{BrowserWatchModal-CIla-_hm.js → BrowserWatchModal-CxfWHmjb.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-CSgh5kjS.js → UsageHistoryModal-D-XRttte.js} +1 -1
- package/dist/web/assets/hls-BFpXDghs.js +42 -0
- package/dist/web/assets/index-DrbRdXDD.js +298 -0
- package/dist/web/assets/index-GTZ73XfY.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/release-notes.json +26 -0
- package/src/server/account-health.mjs +38 -0
- package/src/server/claude-accounts.mjs +84 -26
- package/src/server/cswap-admin.mjs +136 -8
- package/src/server/deck-prefs.mjs +2 -0
- package/src/server/fm-station.mjs +186 -0
- package/src/server/index.mjs +204 -20
- package/src/server/lan-engine.mjs +244 -38
- package/src/server/lan-socket.mjs +101 -15
- package/src/server/lan-sync.mjs +86 -9
- package/dist/web/assets/index-CsHTYwoD.js +0 -297
- package/dist/web/assets/index-DbwzUUcd.css +0 -1
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** A collector's verdict is about the stored copy, not the live CLI session.
|
|
2
|
+
* In particular, an inaccessible Keychain does not make the stored copy dead:
|
|
3
|
+
* treating it as dead starts a futile peer-healing loop on every LAN round. */
|
|
4
|
+
const LOST_COPY = new Set(["no_credentials", "relogin_required", "foreign_credential"]);
|
|
5
|
+
|
|
6
|
+
export function storedCopyAlive(alive, verdict) {
|
|
7
|
+
// An unreadable keychain or a deferred token refresh is a failure to CHECK,
|
|
8
|
+
// not evidence that the stored login has died. Do not repeatedly overwrite
|
|
9
|
+
// that slot with copies from a peer while the check is unavailable.
|
|
10
|
+
if (verdict === "keychain_unavailable" || verdict === "token_expired") return true;
|
|
11
|
+
return alive === true && !LOST_COPY.has(verdict);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** When a collector verdict is present, only `ok` is positive export evidence.
|
|
15
|
+
* Unknown and transient verdict strings are therefore fail-closed. */
|
|
16
|
+
export function exportVerdictOk(verdict) {
|
|
17
|
+
return verdict === "ok";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Cached preflight for the LAN hot path. A KNOWN bad or unknown verdict is
|
|
21
|
+
* refused before spawning an export. An inactive slot with no verdict may reach
|
|
22
|
+
* the bounded export itself: it exports the stored copy, which carries the
|
|
23
|
+
* slot's own identity. The ACTIVE slot may not, because its export is the live
|
|
24
|
+
* CLI login, which can belong to another account or be expired while the
|
|
25
|
+
* blob's labels still name the slot, so the identity check cannot catch it.
|
|
26
|
+
* Its verdict is refreshed in the background and the next round shares it. */
|
|
27
|
+
export function cachedExportReadable(verdict, { active = false } = {}) {
|
|
28
|
+
if (verdict == null) return !active;
|
|
29
|
+
return exportVerdictOk(verdict);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Is the live CLI login the account this slot names? The active slot exports
|
|
33
|
+
* the live login, and a verdict can predate a `/login` as somebody else. */
|
|
34
|
+
export function liveLoginIs(identity, email, org) {
|
|
35
|
+
const live = String(identity?.email ?? "").trim().toLowerCase();
|
|
36
|
+
if (!live || live !== String(email ?? "").trim().toLowerCase()) return false;
|
|
37
|
+
return !identity.orgId || !org || identity.orgId === org;
|
|
38
|
+
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
17
17
|
import { cswapBin, cswapVersion, installHint } from "./cswap-install.mjs";
|
|
18
18
|
import { run, runDetached } from "./exec.mjs";
|
|
19
|
+
import { storedCopyAlive } from "./account-health.mjs";
|
|
19
20
|
// The CLI identity oracle, already written and already trusted by the account
|
|
20
21
|
// admin routes. #721 needs the same answer, so it reuses the same function
|
|
21
22
|
// rather than shelling out a second way to ask one question.
|
|
@@ -347,14 +348,39 @@ export function nextReadAt(row, matches, fetchedAtMs, isActive, now) {
|
|
|
347
348
|
* for `--json` and keeps the verdicts. Still not awaited by anyone — the nudge
|
|
348
349
|
* stays synchronous for its callers — and still one child at a time.
|
|
349
350
|
*/
|
|
350
|
-
let _verdicts = { at: 0, byNum: {} };
|
|
351
|
+
let _verdicts = { at: 0, byNum: {}, identities: {} };
|
|
351
352
|
/** Stale after this, because a verdict that outlives its cause is worse than no
|
|
352
353
|
* verdict: "no credentials" under an account somebody has since signed into is
|
|
353
354
|
* a sentence that sends them to fix what is already fixed. */
|
|
354
355
|
const VERDICT_TTL_MS = 10 * 60_000;
|
|
355
|
-
/**
|
|
356
|
-
*
|
|
357
|
-
|
|
356
|
+
/** Run one `cswap list --json` at a time. A post-write question must start
|
|
357
|
+
* after any older collection has finished; routine readers can share the
|
|
358
|
+
* latest pending answer without starting another slow collection. */
|
|
359
|
+
export function createVerdictQueue(collect) {
|
|
360
|
+
let pending = null;
|
|
361
|
+
return {
|
|
362
|
+
ask({ fresh = false } = {}) {
|
|
363
|
+
if (pending && !fresh) return pending;
|
|
364
|
+
const previous = pending;
|
|
365
|
+
// Start the first poll immediately: requestCollection promises the UI that
|
|
366
|
+
// its refresh has started before it returns. Only subsequent fresh polls
|
|
367
|
+
// wait for the earlier snapshot to finish.
|
|
368
|
+
let next;
|
|
369
|
+
if (previous) next = previous.then(collect, collect);
|
|
370
|
+
else {
|
|
371
|
+
try { next = Promise.resolve(collect()); }
|
|
372
|
+
catch (error) { next = Promise.reject(error); }
|
|
373
|
+
}
|
|
374
|
+
pending = next;
|
|
375
|
+
void next.then(
|
|
376
|
+
() => { if (pending === next) pending = null; },
|
|
377
|
+
() => { if (pending === next) pending = null; },
|
|
378
|
+
);
|
|
379
|
+
return next;
|
|
380
|
+
},
|
|
381
|
+
busy() { return pending !== null; },
|
|
382
|
+
};
|
|
383
|
+
}
|
|
358
384
|
|
|
359
385
|
/**
|
|
360
386
|
* Ask claude-swap about one account RIGHT NOW, rather than reading the cache.
|
|
@@ -374,6 +400,20 @@ let _verdictInFlight = false;
|
|
|
374
400
|
export async function verdictNow(email, org, { runner = run, bin = cswapBin } = {}) {
|
|
375
401
|
const want = String(email ?? "").trim().toLowerCase();
|
|
376
402
|
if (!want) return null;
|
|
403
|
+
const all = await verdictsNow({ runner, bin, fresh: true });
|
|
404
|
+
return all?.find(a => a.email === want && a.org === (org ?? ""))?.status ?? null;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Every account's verdict from ONE `cswap list --json`, as `{ email, org,
|
|
409
|
+
* status, active }` rows (email lower-cased, status null when claude-swap gave none),
|
|
410
|
+
* or null when the question could not be asked at all.
|
|
411
|
+
*
|
|
412
|
+
* For a caller holding several identities at once — a round that imported
|
|
413
|
+
* three logins asks once, not three times, since each ask is a full usage
|
|
414
|
+
* collection that can take a minute on a cold network.
|
|
415
|
+
*/
|
|
416
|
+
async function collectVerdicts({ runner, bin }) {
|
|
377
417
|
try {
|
|
378
418
|
const out = await runner(await bin(), ["list", "--json"], { timeout: VERDICT_TIMEOUT_MS });
|
|
379
419
|
if (!out?.ok) return null;
|
|
@@ -381,20 +421,47 @@ export async function verdictNow(email, org, { runner = run, bin = cswapBin } =
|
|
|
381
421
|
// The same freshly-read answer feeds the cache the panel draws from, since
|
|
382
422
|
// it cost a subprocess either way.
|
|
383
423
|
const byNum = readVerdicts(out.stdout);
|
|
384
|
-
|
|
424
|
+
const identities = {};
|
|
385
425
|
for (const a of Array.isArray(d?.accounts) ? d.accounts : []) {
|
|
386
|
-
if (
|
|
387
|
-
|
|
388
|
-
|
|
426
|
+
if (Number.isInteger(a?.number) && typeof a?.email === "string" && a.email.trim()) {
|
|
427
|
+
identities[String(a.number)] = `${a.email.trim().toLowerCase()}@@${a.organizationUuid ?? ""}`;
|
|
428
|
+
}
|
|
389
429
|
}
|
|
390
|
-
|
|
430
|
+
_verdicts = { at: Date.now(), byNum, identities };
|
|
431
|
+
return (Array.isArray(d?.accounts) ? d.accounts : []).map(a => ({
|
|
432
|
+
number: a?.number,
|
|
433
|
+
email: String(a?.email ?? "").trim().toLowerCase(),
|
|
434
|
+
org: a?.organizationUuid ?? "",
|
|
435
|
+
status: typeof a?.usageStatus === "string" ? a.usageStatus : null,
|
|
436
|
+
// For the account Claude Code is signed in as, claude-swap reads the
|
|
437
|
+
// LIVE credential, not the stored copy — so its verdict there is about
|
|
438
|
+
// the live login. See checkImports.
|
|
439
|
+
active: a?.active === true,
|
|
440
|
+
}));
|
|
391
441
|
} catch { return null; }
|
|
392
442
|
}
|
|
393
443
|
|
|
444
|
+
const verdictQueue = createVerdictQueue(() => collectVerdicts({ runner: run, bin: cswapBin }));
|
|
445
|
+
|
|
446
|
+
/** Routine readers share a collection; post-write callers wait for any older
|
|
447
|
+
* collection and start a new one, so they never inspect the pre-write store. */
|
|
448
|
+
export function verdictsNow({ runner = run, bin = cswapBin, fresh = false } = {}) {
|
|
449
|
+
// Tests and callers supplying their own runner must receive their own answer.
|
|
450
|
+
if (runner !== run || bin !== cswapBin) return collectVerdicts({ runner, bin });
|
|
451
|
+
return verdictQueue.ask({ fresh });
|
|
452
|
+
}
|
|
453
|
+
|
|
394
454
|
/** claude-swap's verdict for a slot, or null when there is none fresh enough. */
|
|
395
|
-
function verdictFor(num, now) {
|
|
396
|
-
|
|
397
|
-
|
|
455
|
+
function verdictFor(num, now, email, org) {
|
|
456
|
+
return cachedVerdictFor(_verdicts, num, now, email, org);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Only attach a cached slot verdict to the identity it was collected for. */
|
|
460
|
+
export function cachedVerdictFor(cache, num, now, email, org) {
|
|
461
|
+
if (now - cache.at > VERDICT_TTL_MS) return null;
|
|
462
|
+
const identity = `${String(email ?? "").trim().toLowerCase()}@@${org ?? ""}`;
|
|
463
|
+
if (cache.identities[String(num)] !== identity) return null;
|
|
464
|
+
const v = cache.byNum[String(num)];
|
|
398
465
|
return typeof v === "string" && v !== "" ? v : null;
|
|
399
466
|
}
|
|
400
467
|
|
|
@@ -439,22 +506,12 @@ function nudgeCollector(rows, slots, now, activeNum) {
|
|
|
439
506
|
cswapBin().then(bin => runDetached(bin, ["auto", "--once", "--dry-run", "--json"])).catch(() => {});
|
|
440
507
|
return;
|
|
441
508
|
}
|
|
442
|
-
if (
|
|
443
|
-
_verdictInFlight = true;
|
|
509
|
+
if (verdictQueue.busy()) return;
|
|
444
510
|
// Fire-and-forget: this function is deliberately synchronous so callers never
|
|
445
511
|
// wait on it, and resolving the binary is the only async part. `run` rather
|
|
446
512
|
// than `runDetached` only so the output can be read; the caller is no more
|
|
447
513
|
// aware of it than before.
|
|
448
|
-
|
|
449
|
-
.then(bin => run(bin, ["list", "--json"], { timeout: VERDICT_TIMEOUT_MS }))
|
|
450
|
-
.then(out => {
|
|
451
|
-
const byNum = out?.ok ? readVerdicts(out.stdout) : {};
|
|
452
|
-
// Replaced whole rather than merged. A slot that has gone away must not
|
|
453
|
-
// keep the verdict it had when it was last seen.
|
|
454
|
-
if (Object.keys(byNum).length) _verdicts = { at: Date.now(), byNum };
|
|
455
|
-
})
|
|
456
|
-
.catch(() => {})
|
|
457
|
-
.finally(() => { _verdictInFlight = false; });
|
|
514
|
+
void verdictsNow();
|
|
458
515
|
}
|
|
459
516
|
|
|
460
517
|
/** Long enough for a cold collection over a slow network, short enough that a
|
|
@@ -637,6 +694,7 @@ async function readRoster(now, gen) {
|
|
|
637
694
|
.filter(Boolean),
|
|
638
695
|
].filter(Boolean);
|
|
639
696
|
|
|
697
|
+
const collector = verdictFor(num, now, acct.email, acct.organizationUuid);
|
|
640
698
|
accounts.push({
|
|
641
699
|
num: Number(num),
|
|
642
700
|
email: acct.email ?? null,
|
|
@@ -654,7 +712,7 @@ async function readRoster(now, gen) {
|
|
|
654
712
|
// `stale-copy` row means the live session is fine while the copy in the
|
|
655
713
|
// store is dead, and the copy is what a share would carry and what a
|
|
656
714
|
// peer's copy would heal. So both kinds of trouble read as not alive.
|
|
657
|
-
alive: trouble == null,
|
|
715
|
+
alive: storedCopyAlive(trouble == null, collector),
|
|
658
716
|
active: String(seq.activeAccountNumber) === num,
|
|
659
717
|
disabled: acct.disabled === true,
|
|
660
718
|
lanes,
|
|
@@ -694,7 +752,7 @@ async function readRoster(now, gen) {
|
|
|
694
752
|
// "no_credentials", "relogin_required", "keychain_unavailable", … It is
|
|
695
753
|
// what turns "not collecting" into a sentence with a next step in it, and
|
|
696
754
|
// it is null on every machine where the collector has not been asked yet.
|
|
697
|
-
collector
|
|
755
|
+
collector,
|
|
698
756
|
});
|
|
699
757
|
}
|
|
700
758
|
|
|
@@ -24,7 +24,8 @@ import { readFile } from "node:fs/promises";
|
|
|
24
24
|
import { homedir } from "node:os";
|
|
25
25
|
import { join } from "node:path";
|
|
26
26
|
import { looksMissing, pathLookup, run, runDetached, runInteractive } from "./exec.mjs";
|
|
27
|
-
import { backupRoot, invalidateClaudeAccountsCache, verdictNow } from "./claude-accounts.mjs";
|
|
27
|
+
import { backupRoot, invalidateClaudeAccountsCache, verdictNow, verdictsNow } from "./claude-accounts.mjs";
|
|
28
|
+
import { HERE } from "./lan-sync.mjs";
|
|
28
29
|
import { withStoreLock } from "./store-lock.mjs";
|
|
29
30
|
import { adminClaudeBin, currentIdentity } from "./claude-identity.mjs";
|
|
30
31
|
import { claudeCliCandidates } from "./claude-dir.mjs";
|
|
@@ -35,6 +36,109 @@ import { PRODUCT } from "./brand.mjs";
|
|
|
35
36
|
// open longer than a user would plausibly take to fetch one.
|
|
36
37
|
const LOGIN_TIMEOUT_MS = 5 * 60_000;
|
|
37
38
|
const CSWAP_TIMEOUT_MS = 60_000;
|
|
39
|
+
// ── whether this process can read what claude-swap holds ────────────────────
|
|
40
|
+
//
|
|
41
|
+
// ASKED OF CLAUDE-SWAP, NEVER READ OFF ITS WORDS. A Keychain it cannot open is
|
|
42
|
+
// logged to its own log file and nowhere else: `cswap export` then says only
|
|
43
|
+
// "no backup credentials found for account N", which is also what an account
|
|
44
|
+
// with no stored login says, and `cswap import` falls back to its .enc file and
|
|
45
|
+
// exits 0. Nothing on stderr tells a locked Keychain apart, so nothing here
|
|
46
|
+
// looks. `cswap list --json` does tell it apart — a per-account `usageStatus`
|
|
47
|
+
// of `keychain_unavailable` — and that is the one signal these read.
|
|
48
|
+
//
|
|
49
|
+
// MACOS ONLY. claude-swap reports `keychain_unavailable` on Linux and Windows
|
|
50
|
+
// as well, for an .enc file it cannot open, and every sentence these feed talks
|
|
51
|
+
// about a Mac's Keychain and a Terminal window.
|
|
52
|
+
|
|
53
|
+
/** Fresh verdict rows for several identities from one `cswap list --json`, in
|
|
54
|
+
* the order asked; null for any claude-swap would not answer for. */
|
|
55
|
+
async function rowsNow(ids, verdicts) {
|
|
56
|
+
const all = await verdicts({ fresh: true });
|
|
57
|
+
return ids.map(({ email, org }) => {
|
|
58
|
+
const want = String(email ?? "").trim().toLowerCase();
|
|
59
|
+
return all?.find(a => a.email === want && a.org === (org ?? "")) ?? null;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Why a share came out empty: `keychain_unavailable` when claude-swap says
|
|
65
|
+
* EVERY account that failed is one this Mac's Keychain will not open, and
|
|
66
|
+
* `export_failed` otherwise — the reason has to be true of the whole failure,
|
|
67
|
+
* because the detail printed beside it is.
|
|
68
|
+
*/
|
|
69
|
+
export async function exportFailure(failed, store, { platform = process.platform, verdicts = verdictsNow } = {}) {
|
|
70
|
+
if (platform !== "darwin" || !failed.length) return "export_failed";
|
|
71
|
+
const got = await rowsNow(
|
|
72
|
+
failed.map(f => ({ email: f.email, org: store?.orgs?.[f.num] ?? "" })),
|
|
73
|
+
verdicts,
|
|
74
|
+
);
|
|
75
|
+
// The panel's own copy of the verdicts was just replaced; its cached account
|
|
76
|
+
// list must not go on drawing the old ones.
|
|
77
|
+
invalidateClaudeAccountsCache();
|
|
78
|
+
return got.every(r => r?.status === "keychain_unavailable") ? "keychain_unavailable" : "export_failed";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** What claude-swap's verdict on a login that just landed means for it here.
|
|
82
|
+
* Everything else — `ok`, a transient `unavailable`, an api-key account — is a
|
|
83
|
+
* login that arrived and can be read, which is all this is asked. */
|
|
84
|
+
const AFTER_IMPORT = {
|
|
85
|
+
keychain_unavailable: HERE.unreadable,
|
|
86
|
+
no_credentials: HERE.noLogin,
|
|
87
|
+
relogin_required: HERE.expired,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** A Mac's locally unreadable login cannot be offered to a paired deck. */
|
|
91
|
+
export function markUnreadable(accounts, platform = process.platform) {
|
|
92
|
+
if (platform !== "darwin" || !Array.isArray(accounts)) return accounts;
|
|
93
|
+
return accounts.map(a => a.collector === "keychain_unavailable" ? { ...a, readable: false } : a);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The logins a round just imported, checked once from inside this process.
|
|
98
|
+
*
|
|
99
|
+
* `cswap import` exiting 0 is not proof: on a Mac it falls back quietly when
|
|
100
|
+
* the Keychain will not take the write, and a deck started from SSH or a
|
|
101
|
+
* LaunchAgent may then be unable to read what it holds. One HERE code per
|
|
102
|
+
* identity (see lan-sync.mjs), or null for one that is fine — and
|
|
103
|
+
* `HERE.unverified` when claude-swap could not be asked at all, because an
|
|
104
|
+
* unanswered question is not a yes.
|
|
105
|
+
*
|
|
106
|
+
* One `cswap list --json` for the whole round, after it, rather than one per
|
|
107
|
+
* login inside it: each is a usage collection that can take a minute, and the
|
|
108
|
+
* peer hangs up on a connection left idle for thirty seconds.
|
|
109
|
+
*
|
|
110
|
+
* THE SIGNED-IN ACCOUNT IS READ LIVE. For it, claude-swap's verdict is about the
|
|
111
|
+
* live login rather than the copy just written, so an expired or missing live
|
|
112
|
+
* login says nothing about the import and reads as unverified. A Keychain it
|
|
113
|
+
* cannot open still counts: it is the same Keychain, from the same session.
|
|
114
|
+
*/
|
|
115
|
+
export async function checkImports(ids, { platform = process.platform, verdicts = verdictsNow } = {}) {
|
|
116
|
+
if (platform !== "darwin" || !ids.length) return ids.map(() => null);
|
|
117
|
+
const got = await rowsNow(ids, verdicts);
|
|
118
|
+
return got.map(r => {
|
|
119
|
+
if (r?.status == null) return HERE.unverified;
|
|
120
|
+
if (!Object.hasOwn(AFTER_IMPORT, r.status)) return null;
|
|
121
|
+
return r.active && r.status !== "keychain_unavailable" ? HERE.unverified : AFTER_IMPORT[r.status];
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* An import's per-account results, each landed one carrying `check` — its
|
|
127
|
+
* checkImports code — when this Mac found something wrong with it. For the
|
|
128
|
+
* Add Account route, which says "imported" per account and was as blind to an
|
|
129
|
+
* unreadable login as the LAN round was.
|
|
130
|
+
*/
|
|
131
|
+
export async function checkImportResults(results, opts) {
|
|
132
|
+
const list = Array.isArray(results) ? results : [];
|
|
133
|
+
const came = list.filter(r => landed([r]));
|
|
134
|
+
if (!came.length) return list;
|
|
135
|
+
const codes = await checkImports(came.map(r => ({ email: r.email, org: r.org ?? "" })), opts);
|
|
136
|
+
return list.map(r => {
|
|
137
|
+
const code = codes[came.indexOf(r)];
|
|
138
|
+
return typeof code === "string" ? { ...r, check: code } : r;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
38
142
|
// How long to wait for the CLI's verdict on a pasted code before saying so.
|
|
39
143
|
// Exchanging a code is one HTTPS round trip; a minute is generous.
|
|
40
144
|
const CODE_VERDICT_MS = 60_000;
|
|
@@ -889,8 +993,13 @@ export function mergeExports(texts) {
|
|
|
889
993
|
*
|
|
890
994
|
* The default export shape is used deliberately, never --full, which would
|
|
891
995
|
* embed the entire ~/.claude.json including every project and MCP server.
|
|
996
|
+
*
|
|
997
|
+
* `explain: false` skips asking claude-swap why an empty export failed. The
|
|
998
|
+
* LAN answers a peer that gives up after ten seconds, and that question is a
|
|
999
|
+
* usage collection that can take a minute; it refreshes the verdict in the
|
|
1000
|
+
* background instead, so the next round's refusal can name the Keychain.
|
|
892
1001
|
*/
|
|
893
|
-
export async function shareAccounts(nums) {
|
|
1002
|
+
export async function shareAccounts(nums, { explain = true } = {}) {
|
|
894
1003
|
const asked = Array.isArray(nums) ? nums : [nums];
|
|
895
1004
|
// One spawn per account, so the length of this list is a length of time the
|
|
896
1005
|
// request holds. A store never has fifty accounts; a caller that sends nine
|
|
@@ -939,7 +1048,17 @@ export async function shareAccounts(nums) {
|
|
|
939
1048
|
// Nothing came out at all. There is no partial bundle to hand over, so this
|
|
940
1049
|
// is the plain failure the single-account share has always reported.
|
|
941
1050
|
if (!texts.length) {
|
|
942
|
-
|
|
1051
|
+
if (!explain) {
|
|
1052
|
+
if (process.platform === "darwin") void verdictsNow().then(() => invalidateClaudeAccountsCache(), () => {});
|
|
1053
|
+
return { ok: false, reason: "export_failed", detail: failed[0]?.detail ?? "", failed };
|
|
1054
|
+
}
|
|
1055
|
+
const reason = await exportFailure(failed, store);
|
|
1056
|
+
// failed[0]'s own line would read "no backup credentials found", which
|
|
1057
|
+
// sends somebody to sign in again over a login that is perfectly fine.
|
|
1058
|
+
const detail = reason === "keychain_unavailable"
|
|
1059
|
+
? `claude-swap could not open this Mac's Keychain — start ${PRODUCT} from a Terminal window on the Mac itself, not over SSH or as a background service`
|
|
1060
|
+
: failed[0]?.detail ?? "";
|
|
1061
|
+
return { ok: false, reason, detail, failed };
|
|
943
1062
|
}
|
|
944
1063
|
|
|
945
1064
|
const merged = mergeExports(texts);
|
|
@@ -1106,7 +1225,7 @@ export function importOutcomes(before, after, wanted, stderr = "") {
|
|
|
1106
1225
|
* from here - the fix is a re-login. Requiring the pair is what makes the
|
|
1107
1226
|
* clobber something a person chose while looking at the address.
|
|
1108
1227
|
*/
|
|
1109
|
-
export async function importAccount(blob, { force = false, only = null } = {}) {
|
|
1228
|
+
export async function importAccount(blob, { force = false, only = null, collect = true } = {}) {
|
|
1110
1229
|
const un = unwrapShare(blob);
|
|
1111
1230
|
if (!un.ok) return { ok: false, reason: un.reason };
|
|
1112
1231
|
|
|
@@ -1153,7 +1272,10 @@ export async function importAccount(blob, { force = false, only = null } = {}) {
|
|
|
1153
1272
|
})();
|
|
1154
1273
|
|
|
1155
1274
|
const arrived = results.filter(x => x.state === "imported");
|
|
1156
|
-
|
|
1275
|
+
// `collect: false` when a checkImports follows: its `cswap list --json` is
|
|
1276
|
+
// the same collection, and two at once race for one slot's claim, so the
|
|
1277
|
+
// loser reads a fresh slot as `unavailable`.
|
|
1278
|
+
if (arrived.length && collect) runDetached(await cswapBin(), ["list"]);
|
|
1157
1279
|
return {
|
|
1158
1280
|
ok: true,
|
|
1159
1281
|
results,
|
|
@@ -1204,14 +1326,20 @@ export async function importAccount(blob, { force = false, only = null } = {}) {
|
|
|
1204
1326
|
* would rewrite every matching credential on this machine, and a fresh token
|
|
1205
1327
|
* replaced by a stale one is not recoverable from here.
|
|
1206
1328
|
*/
|
|
1207
|
-
export async function fillEmptySlot(blob, { email, org } = {}) {
|
|
1329
|
+
export async function fillEmptySlot(blob, { email, org, platform = process.platform, collect = true } = {}) {
|
|
1208
1330
|
return withStoreLock(async () => {
|
|
1209
1331
|
// FIRST STATEMENT INSIDE THE LOCK. Anything awaited before this re-opens
|
|
1210
1332
|
// the window it exists to close.
|
|
1211
1333
|
const now = await verdictNow(email, org ?? "");
|
|
1212
|
-
|
|
1334
|
+
// THIS LINE IS THE PROTECTION: anything but `no_credentials` — an
|
|
1335
|
+
// unreadable Keychain included, which is an unknown and not an empty slot —
|
|
1336
|
+
// refuses the forced write. The ternary only names which refusal it was,
|
|
1337
|
+
// since "kept the slot it already has" sends nobody to unlock the Mac.
|
|
1338
|
+
if (now !== "no_credentials") {
|
|
1339
|
+
return { ok: false, why: platform === "darwin" && now === "keychain_unavailable" ? HERE.unreadable : "claude-swap kept the slot it already has" };
|
|
1340
|
+
}
|
|
1213
1341
|
|
|
1214
|
-
const forced = await importAccount(blob, { force: true, only: { email, org: org ?? "" } });
|
|
1342
|
+
const forced = await importAccount(blob, { force: true, only: { email, org: org ?? "" }, collect });
|
|
1215
1343
|
if (!forced?.ok) return { ok: false, why: forced?.reason ?? "import refused" };
|
|
1216
1344
|
// `added` counts `imported` alone, and a forced replace is `healed` — see
|
|
1217
1345
|
// landed, which is the difference between a repair and a repair reported as
|
|
@@ -109,6 +109,7 @@ export const DEFAULTS = Object.freeze({
|
|
|
109
109
|
// network it does not own turns this off in the dialog, in one press.
|
|
110
110
|
autoAsk: true,
|
|
111
111
|
autoAccept: true,
|
|
112
|
+
pairingMode: "automatic",
|
|
112
113
|
// WHICH ACCOUNT THIS DECK IS ON, told to the decks it is paired with — and
|
|
113
114
|
// only ever one it shares, so an unticked account is never named. On, so two
|
|
114
115
|
// of one person's machines show each other where they are working; off, and
|
|
@@ -232,6 +233,7 @@ function normaliseLan(raw) {
|
|
|
232
233
|
// overrides it, because a truthy string from a hand-edited file is not an
|
|
233
234
|
// answer.
|
|
234
235
|
autoAccept: typeof src.autoAccept === "boolean" ? src.autoAccept : DEFAULTS.lan.autoAccept,
|
|
236
|
+
pairingMode: src.pairingMode === "invite" ? "invite" : "automatic",
|
|
235
237
|
// Whether paired decks are told which shared account this one is on. Absent
|
|
236
238
|
// is on — the default above — and only a real boolean turns it off.
|
|
237
239
|
shareActive: typeof src.shareActive === "boolean" ? src.shareActive : DEFAULTS.lan.shareActive,
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Custom Claude FM stations that live on YouTube (#1208).
|
|
2
|
+
//
|
|
3
|
+
// A station somebody adds by link is played by the browser, never by this
|
|
4
|
+
// process: a direct stream goes into an <audio> element and a YouTube one into
|
|
5
|
+
// the same embed Claude FM uses. The only thing the deck itself does is the
|
|
6
|
+
// lookup below, which turns a `/@handle/live` or `watch?v=` link into the
|
|
7
|
+
// channel and the video the embed needs, because YouTube only says which video
|
|
8
|
+
// a handle is broadcasting in the page it serves.
|
|
9
|
+
//
|
|
10
|
+
// THAT MAKES THIS THE ONE ROUTE WHERE A CALLER NAMES WHAT THE DECK FETCHES, so
|
|
11
|
+
// the link is never fetched as given. It is parsed down to one of three shapes
|
|
12
|
+
// and the request is rebuilt from the parts: https, www.youtube.com, no port,
|
|
13
|
+
// no login, a path this file wrote and an id that matched a strict pattern.
|
|
14
|
+
// A link with a port or a login is refused rather than cleaned, because nobody
|
|
15
|
+
// copies one of those out of a YouTube tab and both change where the request
|
|
16
|
+
// would really go. index.mjs puts the route behind the guarded-read gate too,
|
|
17
|
+
// so a page on another site cannot use it to make the deck call anything.
|
|
18
|
+
const CHANNEL_ID = /^UC[A-Za-z0-9_-]{22}$/;
|
|
19
|
+
const VIDEO_ID = /^[A-Za-z0-9_-]{11}$/;
|
|
20
|
+
const HANDLE = /^[A-Za-z0-9._-]{1,64}$/;
|
|
21
|
+
const YOUTUBE_HOSTS = new Set(["youtube.com", "www.youtube.com", "m.youtube.com"]);
|
|
22
|
+
|
|
23
|
+
export const FM_STATION_READ_LIMIT = 2_000_000;
|
|
24
|
+
export const FM_STATION_TIMEOUT_MS = 6_000;
|
|
25
|
+
/** How long an answer stands. A station that resolved stays resolved for as
|
|
26
|
+
* long as claude-fm.mjs trusts its own live check; one that did not is asked
|
|
27
|
+
* again sooner, since "not live" is the answer most likely to change. */
|
|
28
|
+
export const FM_STATION_CACHE_MS = 10 * 60_000;
|
|
29
|
+
export const FM_STATION_MISS_CACHE_MS = 60_000;
|
|
30
|
+
/** Answers kept at once. A deck has a handful of stations; the cap only has to
|
|
31
|
+
* stop a caller walking the map up with invented handles. */
|
|
32
|
+
const CACHE_MAX = 32;
|
|
33
|
+
const MAX_REDIRECTS = 5;
|
|
34
|
+
|
|
35
|
+
const BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
|
|
36
|
+
const CANONICAL_VIDEO = /<link\s+rel="canonical"\s+href="https:\/\/www\.youtube\.com\/watch\?v=([A-Za-z0-9_-]{11})"/;
|
|
37
|
+
const EXTERNAL_CHANNEL = /"externalChannelId"\s*:\s*"(UC[A-Za-z0-9_-]{22})"/;
|
|
38
|
+
const CHANNEL_URL = /https:\/\/www\.youtube\.com\/channel\/(UC[A-Za-z0-9_-]{22})/;
|
|
39
|
+
|
|
40
|
+
export function parseYouTubeStationUrl(value) {
|
|
41
|
+
let url;
|
|
42
|
+
try { url = new URL(String(value ?? "").trim()); } catch { return null; }
|
|
43
|
+
if (url.protocol !== "https:") return null;
|
|
44
|
+
if (url.port || url.username || url.password) return null;
|
|
45
|
+
if (!YOUTUBE_HOSTS.has(url.hostname.toLowerCase())) return null;
|
|
46
|
+
|
|
47
|
+
const channel = /^\/channel\/([^/]+)(?:\/live)?\/?$/.exec(url.pathname)?.[1];
|
|
48
|
+
if (channel && CHANNEL_ID.test(channel)) {
|
|
49
|
+
return { kind: "channel", url: `https://www.youtube.com/channel/${channel}/live`, channel };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const handle = /^\/@([^/]+)\/live\/?$/.exec(url.pathname)?.[1];
|
|
53
|
+
if (handle && HANDLE.test(handle)) {
|
|
54
|
+
return { kind: "handle", url: `https://www.youtube.com/@${handle}/live`, handle };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (url.pathname === "/watch") {
|
|
58
|
+
const video = url.searchParams.get("v") ?? "";
|
|
59
|
+
if (VIDEO_ID.test(video)) return { kind: "video", url: `https://www.youtube.com/watch?v=${video}`, video };
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function readYouTubeStationPage(html) {
|
|
65
|
+
const page = typeof html === "string" ? html : "";
|
|
66
|
+
const detailsAt = page.indexOf('"videoDetails"');
|
|
67
|
+
const details = detailsAt < 0 ? "" : page.slice(detailsAt, detailsAt + 24_000);
|
|
68
|
+
const live = /"isLiveContent"\s*:\s*true/.test(details) || /"isLive"\s*:\s*true/.test(details);
|
|
69
|
+
const channel = /"channelId"\s*:\s*"(UC[A-Za-z0-9_-]{22})"/.exec(details)?.[1]
|
|
70
|
+
?? EXTERNAL_CHANNEL.exec(page)?.[1]
|
|
71
|
+
?? CHANNEL_URL.exec(page)?.[1]
|
|
72
|
+
?? null;
|
|
73
|
+
const video = CANONICAL_VIDEO.exec(page)?.[1] ?? null;
|
|
74
|
+
return { live, channel, video };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const _cache = new Map();
|
|
78
|
+
const _inflight = new Map();
|
|
79
|
+
|
|
80
|
+
/** For tests: every answer this process remembers, forgotten. */
|
|
81
|
+
export function forgetFmStations() {
|
|
82
|
+
_cache.clear();
|
|
83
|
+
_inflight.clear();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The channel and video a YouTube station link plays, or why it cannot.
|
|
88
|
+
*
|
|
89
|
+
* CACHED AND SHARED, the way fetchClaudeFm is. Every canvas that mounts with a
|
|
90
|
+
* custom station selected asks this, and four open tabs were four page loads
|
|
91
|
+
* of up to 2MB each from YouTube on every reload; one lookup per link now
|
|
92
|
+
* answers all of them, and a second caller that arrives while the first is
|
|
93
|
+
* still reading waits for that same read.
|
|
94
|
+
*/
|
|
95
|
+
export async function resolveYouTubeStation(value, { fetchImpl, now = Date.now } = {}) {
|
|
96
|
+
const parsed = parseYouTubeStationUrl(value);
|
|
97
|
+
if (!parsed) return { ok: false, error: "unsupported_url" };
|
|
98
|
+
if (parsed.kind === "channel") return { ok: true, channel: parsed.channel, video: null };
|
|
99
|
+
|
|
100
|
+
const key = parsed.url;
|
|
101
|
+
const hit = _cache.get(key);
|
|
102
|
+
if (hit && now() - hit.at < (hit.answer.ok ? FM_STATION_CACHE_MS : FM_STATION_MISS_CACHE_MS)) return hit.answer;
|
|
103
|
+
const pending = _inflight.get(key);
|
|
104
|
+
if (pending) return pending;
|
|
105
|
+
|
|
106
|
+
const lookup = lookUp(parsed, fetchImpl ?? fetch)
|
|
107
|
+
.then(answer => {
|
|
108
|
+
_cache.delete(key);
|
|
109
|
+
_cache.set(key, { at: now(), answer });
|
|
110
|
+
// A Map iterates in insertion order, so the first key is the oldest.
|
|
111
|
+
while (_cache.size > CACHE_MAX) _cache.delete(_cache.keys().next().value);
|
|
112
|
+
return answer;
|
|
113
|
+
})
|
|
114
|
+
.finally(() => { _inflight.delete(key); });
|
|
115
|
+
_inflight.set(key, lookup);
|
|
116
|
+
return lookup;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function lookUp(parsed, get) {
|
|
120
|
+
try {
|
|
121
|
+
// Validate every hop before fetching it. Automatic redirects may contact
|
|
122
|
+
// an arbitrary host before response.url can be inspected (SSRF).
|
|
123
|
+
const signal = AbortSignal.timeout(FM_STATION_TIMEOUT_MS);
|
|
124
|
+
let target = parsed.url;
|
|
125
|
+
let response;
|
|
126
|
+
for (let redirects = 0; ; redirects++) {
|
|
127
|
+
if (!isYouTubeHost(target)) return { ok: false, error: "unresolved" };
|
|
128
|
+
response = await get(target, {
|
|
129
|
+
headers: { "User-Agent": BROWSER_UA, "Accept-Language": "en-US,en;q=0.9" },
|
|
130
|
+
signal,
|
|
131
|
+
redirect: "manual",
|
|
132
|
+
});
|
|
133
|
+
// A custom fetch implementation must not silently follow redirects.
|
|
134
|
+
if (response.url && !isYouTubeHost(response.url)) {
|
|
135
|
+
try { await response.body?.cancel(); } catch { /* already closed */ }
|
|
136
|
+
return { ok: false, error: "unresolved" };
|
|
137
|
+
}
|
|
138
|
+
if (![301, 302, 303, 307, 308].includes(response.status)) break;
|
|
139
|
+
const location = response.headers?.get("location");
|
|
140
|
+
try { await response.body?.cancel(); } catch { /* already closed */ }
|
|
141
|
+
if (!location || redirects >= MAX_REDIRECTS) return { ok: false, error: "unresolved" };
|
|
142
|
+
try { target = new URL(location, target).href; }
|
|
143
|
+
catch { return { ok: false, error: "unresolved" }; }
|
|
144
|
+
}
|
|
145
|
+
if (!response.ok) return { ok: false, error: `youtube_${response.status}` };
|
|
146
|
+
const marks = readYouTubeStationPage(await readBounded(response));
|
|
147
|
+
if (parsed.kind === "video" && !marks.live) return { ok: false, error: "not_live" };
|
|
148
|
+
if (parsed.kind === "handle" && !marks.live && !marks.video) return { ok: false, error: "not_live" };
|
|
149
|
+
if (marks.channel) return { ok: true, channel: marks.channel, video: marks.video };
|
|
150
|
+
if (marks.video) return { ok: true, channel: null, video: marks.video };
|
|
151
|
+
if (parsed.kind === "video") return { ok: true, channel: null, video: parsed.video };
|
|
152
|
+
return { ok: false, error: "unresolved" };
|
|
153
|
+
} catch {
|
|
154
|
+
// Offline, DNS, a timeout: one answer, because the canvas does the same
|
|
155
|
+
// thing with all of them, and the error text is the operator's, not the
|
|
156
|
+
// page's — see the note above index.mjs's `guard`.
|
|
157
|
+
return { ok: false, error: "unreachable" };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function isYouTubeHost(href) {
|
|
162
|
+
try {
|
|
163
|
+
const url = new URL(href);
|
|
164
|
+
const host = url.hostname.toLowerCase();
|
|
165
|
+
return url.protocol === "https:" && !url.username && !url.password && !url.port
|
|
166
|
+
&& (host === "youtube.com" || host.endsWith(".youtube.com"));
|
|
167
|
+
} catch { return false; }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function readBounded(response, limit = FM_STATION_READ_LIMIT) {
|
|
171
|
+
if (!response.body?.getReader) return (await response.text()).slice(0, limit);
|
|
172
|
+
const reader = response.body.getReader();
|
|
173
|
+
const decoder = new TextDecoder();
|
|
174
|
+
let out = "";
|
|
175
|
+
try {
|
|
176
|
+
while (out.length < limit) {
|
|
177
|
+
const { done, value } = await reader.read();
|
|
178
|
+
if (done) break;
|
|
179
|
+
out += decoder.decode(value, { stream: true });
|
|
180
|
+
if (out.includes('"isLive":true') && out.includes('rel="canonical"') && out.includes('"channelId"')) break;
|
|
181
|
+
}
|
|
182
|
+
} finally {
|
|
183
|
+
try { await reader.cancel(); } catch { /* connection already closed */ }
|
|
184
|
+
}
|
|
185
|
+
return out.slice(0, limit);
|
|
186
|
+
}
|