ccdeck 3.28.1 → 3.29.1

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.
@@ -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
- return { ok: false, reason: "export_failed", detail: failed[0]?.detail ?? "", failed };
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
- if (arrived.length) runDetached(await cswapBin(), ["list"]);
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
- if (now !== "no_credentials") return { ok: false, why: "claude-swap kept the slot it already has" };
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
+ }