agent-dag 3.22.1 → 3.22.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/README.md +6 -477
- package/package.json +14 -48
- package/shim.js +107 -0
- package/LICENSE +0 -661
- package/LICENSING.md +0 -82
- package/THIRD_PARTY_NOTICES.md +0 -395
- package/bin/agent-dag.js +0 -626
- package/bin/deck.js +0 -1805
- package/dist/web/assets/index-CJYsv0lr.css +0 -1
- package/dist/web/assets/index-Ifm23DDC.js +0 -270
- package/dist/web/index.html +0 -49
- package/hook/hook.js +0 -542
- package/release-notes.json +0 -398
- package/src/server/activity.mjs +0 -52
- package/src/server/agent-activity.mjs +0 -522
- package/src/server/args.mjs +0 -183
- package/src/server/auto-update.mjs +0 -79
- package/src/server/block-notify.mjs +0 -173
- package/src/server/boot-deadline.mjs +0 -127
- package/src/server/brand.mjs +0 -16
- package/src/server/browser-history.mjs +0 -497
- package/src/server/browser-presence.mjs +0 -211
- package/src/server/browser-profiles.mjs +0 -279
- package/src/server/browser-react.mjs +0 -284
- package/src/server/browser-watch-store.mjs +0 -350
- package/src/server/browser-watch.mjs +0 -905
- package/src/server/ccusage.mjs +0 -1168
- package/src/server/claude-accounts.mjs +0 -951
- package/src/server/claude-dir.mjs +0 -213
- package/src/server/codex-auth.mjs +0 -388
- package/src/server/codex-dir.mjs +0 -171
- package/src/server/codex-quota.mjs +0 -449
- package/src/server/codex-usage.mjs +0 -512
- package/src/server/cswap-admin.mjs +0 -1562
- package/src/server/cswap-auto.mjs +0 -658
- package/src/server/cswap-install.mjs +0 -641
- package/src/server/deck-home.mjs +0 -243
- package/src/server/deck-prefs.mjs +0 -301
- package/src/server/deck-probe.mjs +0 -111
- package/src/server/detach.mjs +0 -244
- package/src/server/exec.mjs +0 -996
- package/src/server/global-install.mjs +0 -67
- package/src/server/hwmonitor.mjs +0 -56
- package/src/server/index.mjs +0 -6043
- package/src/server/installer.mjs +0 -912
- package/src/server/invoked-as.mjs +0 -144
- package/src/server/lan-about.mjs +0 -119
- package/src/server/lan-engine.mjs +0 -952
- package/src/server/lan-reach.mjs +0 -256
- package/src/server/lan-socket.mjs +0 -682
- package/src/server/lan-sync.mjs +0 -941
- package/src/server/lhm-parse.mjs +0 -91
- package/src/server/log-tail.mjs +0 -139
- package/src/server/log-writer.mjs +0 -322
- package/src/server/login-service.mjs +0 -473
- package/src/server/macmon.mjs +0 -310
- package/src/server/npx.mjs +0 -264
- package/src/server/open-url.mjs +0 -242
- package/src/server/presence.mjs +0 -40
- package/src/server/quota.mjs +0 -792
- package/src/server/relay-guard.mjs +0 -507
- package/src/server/reset-label.mjs +0 -78
- package/src/server/retire-sound-hook.mjs +0 -349
- package/src/server/running-deck.mjs +0 -234
- package/src/server/self-update.mjs +0 -1380
- package/src/server/stop-deck.mjs +0 -171
- package/src/server/supervisor.mjs +0 -392
- package/src/server/system-metrics.mjs +0 -1825
- package/src/server/term.mjs +0 -686
- package/src/server/uv-bootstrap.mjs +0 -337
|
@@ -1,1562 +0,0 @@
|
|
|
1
|
-
// Everything the accounts panel does that CHANGES the claude-swap store.
|
|
2
|
-
//
|
|
3
|
-
// Reading it lives in claude-accounts.mjs. This is the other half: signing a
|
|
4
|
-
// new account in, sharing one to another machine, and the small edits —
|
|
5
|
-
// rename, reorder, remove.
|
|
6
|
-
//
|
|
7
|
-
// Two constraints from claude-swap's own source shape all of it.
|
|
8
|
-
//
|
|
9
|
-
// `cswap add` does not sign anyone in. It captures whatever is already live —
|
|
10
|
-
// identity from ~/.claude.json, credentials from the macOS Keychain item
|
|
11
|
-
// "Claude Code-credentials". So a browser login has to land in Claude Code's
|
|
12
|
-
// own store first, which is exactly what `claude auth login` does, and the
|
|
13
|
-
// panel's job is to drive that conversation rather than to invent one.
|
|
14
|
-
//
|
|
15
|
-
// And `cswap add` takes no lock while assigning the next slot as max+1. Two
|
|
16
|
-
// concurrent adds pick the same number and the second write silently drops the
|
|
17
|
-
// first account's record. Nothing upstream prevents it, so every mutation here
|
|
18
|
-
// goes through one mutex.
|
|
19
|
-
import { AsyncLocalStorage } from "node:async_hooks";
|
|
20
|
-
import { existsSync } from "node:fs";
|
|
21
|
-
import { brotliCompressSync, brotliDecompressSync, constants as zlibConstants } from "node:zlib";
|
|
22
|
-
import { readFile } from "node:fs/promises";
|
|
23
|
-
import { homedir } from "node:os";
|
|
24
|
-
import { join } from "node:path";
|
|
25
|
-
import { looksMissing, pathLookup, run, runDetached, runInteractive } from "./exec.mjs";
|
|
26
|
-
import { backupRoot, invalidateClaudeAccountsCache } from "./claude-accounts.mjs";
|
|
27
|
-
import { claudeCliCandidates } from "./claude-dir.mjs";
|
|
28
|
-
import { cswapBin } from "./cswap-install.mjs";
|
|
29
|
-
import { PRODUCT } from "./brand.mjs";
|
|
30
|
-
|
|
31
|
-
// An OAuth code is short-lived at the source; there is no point holding a child
|
|
32
|
-
// open longer than a user would plausibly take to fetch one.
|
|
33
|
-
const LOGIN_TIMEOUT_MS = 5 * 60_000;
|
|
34
|
-
const CSWAP_TIMEOUT_MS = 60_000;
|
|
35
|
-
// How long to wait for the CLI's verdict on a pasted code before saying so.
|
|
36
|
-
// Exchanging a code is one HTTPS round trip; a minute is generous.
|
|
37
|
-
const CODE_VERDICT_MS = 60_000;
|
|
38
|
-
// How long a shared account stays importable. Long enough to walk to the other
|
|
39
|
-
// machine, short enough that a copy left in clipboard history goes stale.
|
|
40
|
-
export const SHARE_TTL_MS = 10 * 60_000;
|
|
41
|
-
// How many accounts one bundle may carry. See shareAccounts.
|
|
42
|
-
const MAX_SHARE_ACCOUNTS = 50;
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* The prefix a share is written with, and the one it used to be written with.
|
|
46
|
-
*
|
|
47
|
-
* `ccdeck1:` is base64 of the envelope JSON. `ccdeck2:` is base64 of the same
|
|
48
|
-
* JSON compressed, and the difference is not cosmetic — it was reported from
|
|
49
|
-
* the panel as "the text is very large", and on a real store it is:
|
|
50
|
-
*
|
|
51
|
-
* 1 account 2200 characters -> 1024
|
|
52
|
-
* 3 accounts 6168 characters -> 1816
|
|
53
|
-
*
|
|
54
|
-
* Because what a bundle mostly contains is not credentials. An account's two
|
|
55
|
-
* OAuth tokens are 216 characters between them; the envelope around them is
|
|
56
|
-
* two thousand, and every one of its key names — `refreshTokenExpiresAt`,
|
|
57
|
-
* `organizationRateLimitTier`, `claudeCodeTrialDurationDays` — repeats
|
|
58
|
-
* verbatim for every account added. That is exactly what a compressor is for,
|
|
59
|
-
* which is why the saving grows with the number of accounts rather than
|
|
60
|
-
* shrinking.
|
|
61
|
-
*
|
|
62
|
-
* Brotli rather than gzip: 10% smaller here, in node:zlib since v11, no
|
|
63
|
-
* dependency either way.
|
|
64
|
-
*
|
|
65
|
-
* BOTH prefixes are read, so a blob copied before this change still imports.
|
|
66
|
-
* Only `ccdeck2:` is written, which does mean a deck older than this cannot
|
|
67
|
-
* read a new share — it will say the text does not look like a shared account.
|
|
68
|
-
* That cost is paid once and it is smallest now: the feature shipped in
|
|
69
|
-
* 1.48.0 and the format has had no time to spread.
|
|
70
|
-
*/
|
|
71
|
-
const SHARE_PREFIX = "ccdeck2:";
|
|
72
|
-
const SHARE_PREFIX_V1 = "ccdeck1:";
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* The most an imported blob may decompress to.
|
|
76
|
-
*
|
|
77
|
-
* A few hundred bytes of brotli can name gigabytes of output, and this input
|
|
78
|
-
* arrives by paste from wherever the user found it. 50 accounts at ~1.6 KB of
|
|
79
|
-
* envelope each is under 100 KB, so 2 MB is twenty times the largest bundle
|
|
80
|
-
* this deck will ever produce and still nothing to allocate by accident.
|
|
81
|
-
*/
|
|
82
|
-
const SHARE_MAX_BYTES = 2 << 20;
|
|
83
|
-
|
|
84
|
-
// ── serialization ────────────────────────────────────────────────────────────
|
|
85
|
-
|
|
86
|
-
let _chain = Promise.resolve();
|
|
87
|
-
|
|
88
|
-
// Whether the code running right now is itself the mutation holding the lock.
|
|
89
|
-
// Async context rather than a plain boolean, which could not tell that apart
|
|
90
|
-
// from another request that merely arrived while the lock was held — and would
|
|
91
|
-
// wave that one through, which is the opposite of a mutex.
|
|
92
|
-
const _holder = new AsyncLocalStorage();
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* One store mutation at a time.
|
|
96
|
-
*
|
|
97
|
-
* Not defence against another process — that would need claude-swap's own file
|
|
98
|
-
* lock, which `add` does not take either. This is defence against ourselves:
|
|
99
|
-
* two browser tabs, or a double-click, are enough to race a slot assignment.
|
|
100
|
-
*
|
|
101
|
-
* Re-entrant, because a mutation that reaches for the lock from inside one
|
|
102
|
-
* would otherwise wait for itself forever: the chain cannot advance past the
|
|
103
|
-
* outer link until it settles, and the outer link is blocked on this call. It
|
|
104
|
-
* already has exclusive access, so it simply runs.
|
|
105
|
-
*/
|
|
106
|
-
export function withStoreLock(fn) {
|
|
107
|
-
if (_holder.getStore()) return Promise.resolve().then(fn);
|
|
108
|
-
const held = () => _holder.run(true, fn);
|
|
109
|
-
const next = _chain.then(held, held);
|
|
110
|
-
// Keep the chain alive even when a link rejects, or every later mutation
|
|
111
|
-
// inherits the failure.
|
|
112
|
-
_chain = next.then(() => {}, () => {});
|
|
113
|
-
return next;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// ── shared helpers ───────────────────────────────────────────────────────────
|
|
117
|
-
|
|
118
|
-
// cswapBin comes from cswap-install.mjs, which searches the places uv and pipx
|
|
119
|
-
// actually install to. This module used to answer the bare name "cswap"
|
|
120
|
-
// instead, which is fine on a Mac where ~/.local/bin is usually on PATH and
|
|
121
|
-
// wrong on Windows where it is usually not: every mutation here — share,
|
|
122
|
-
// import, remove, rename, reorder — failed with cmd.exe's "is not recognized",
|
|
123
|
-
// while the read-only half of the panel worked, because it was already using
|
|
124
|
-
// the resolver. Reported from Windows on 2026-08-14.
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Which `claude` the account surface runs: the configured one, else the first
|
|
128
|
-
* candidate this machine actually has, else the bare name.
|
|
129
|
-
*
|
|
130
|
-
* WHY THIS IS NOT `AGENTS_DECK_CLAUDE ?? "claude"` ANY MORE (#570). That was
|
|
131
|
-
* the whole of this module's resolution, and it feeds every child the accounts
|
|
132
|
-
* panel starts — `claude auth status --json` for `currentIdentity`, and the
|
|
133
|
-
* `claude auth login` whose output the sign-in dialog reads a link out of. On a
|
|
134
|
-
* machine whose `claude` is at `~/.local/bin/claude` but whose deck was started
|
|
135
|
-
* from something that never sourced a shell rc — a LaunchAgent, a systemd user
|
|
136
|
-
* unit, pm2, a desktop shortcut — the bare name is an ENOENT, so the login
|
|
137
|
-
* child is dead within milliseconds, the flow reports `no_url`, and the dialog
|
|
138
|
-
* shows "the claude CLI could not be run: not on PATH. Set AGENTS_DECK_CLAUDE
|
|
139
|
-
* to its full path." That sentence is a real remedy and it is why this was a
|
|
140
|
-
* smaller bug than #553; it is still a request to spell out a path the deck had
|
|
141
|
-
* already found for itself, because `hasClaudeInstalled()` stat'ed that exact
|
|
142
|
-
* file at boot to decide this was a Claude machine, and since #553 the quota
|
|
143
|
-
* panel beside this one runs the same binary without being told anything.
|
|
144
|
-
*
|
|
145
|
-
* SO IT READS THE SAME LIST, ON THE SAME TERMS #553 SETTLED ON. The list is
|
|
146
|
-
* `claudeCliCandidates` in claude-dir.mjs, whose other two readers are
|
|
147
|
-
* `hasClaudeInstalled()` — the boot question this module's whole surface hangs
|
|
148
|
-
* off — and `quotaClaudeBin` in quota.mjs. This is the same question at a third
|
|
149
|
-
* site, so nothing here is decided again:
|
|
150
|
-
*
|
|
151
|
-
* - AGENTS_DECK_CLAUDE first, and it is the one thing that skips the list
|
|
152
|
-
* entirely. It is documented in the README as "full path to the `claude`
|
|
153
|
-
* CLI", it is what the failure message above tells people to set, and
|
|
154
|
-
* someone who set it has already been through this once — second-guessing
|
|
155
|
-
* them with a stat would be answering a question they have closed. An empty
|
|
156
|
-
* value reads as unset, the way `AGENTS_DECK_CSWAP` does in cswapBin.
|
|
157
|
-
* - Then the candidate list's own order, unchanged: PATH first on POSIX, the
|
|
158
|
-
* two known install directories first on Windows. Preferring a different
|
|
159
|
-
* copy would silently change which binary signs somebody in on every
|
|
160
|
-
* machine that has two, and a `claude auth login` that suddenly runs a
|
|
161
|
-
* different binary is a credential path, not a detail.
|
|
162
|
-
* - The bare name is only answered with when PATH actually holds it, and
|
|
163
|
-
* `pathLookup` is a yes/no gate rather than the path it found, so spawn's
|
|
164
|
-
* own resolution — and, on Windows, exec.mjs's PATHEXT walk, since `claude`
|
|
165
|
-
* there is `claude.exe` or `claude.cmd` and never the bare word — stays in
|
|
166
|
-
* charge of the PATH case exactly as before.
|
|
167
|
-
* - The absolute candidates are stat'ed only once PATH has come up empty, so
|
|
168
|
-
* the common case costs one stat rather than a directory walk. Against what
|
|
169
|
-
* follows it — a whole Claude Code process, and a browser sign-in a human
|
|
170
|
-
* is walking through — that is not a cost worth naming.
|
|
171
|
-
*
|
|
172
|
-
* Pure, with the platform, environment, home directory and existence check all
|
|
173
|
-
* parameters, so the Windows branch is checkable from the platforms this repo
|
|
174
|
-
* is actually developed on. Exported for that test rather than for a caller
|
|
175
|
-
* (#383): `claudeBin` below is the only one, and it hands back the real
|
|
176
|
-
* machine's answer.
|
|
177
|
-
*/
|
|
178
|
-
export function adminClaudeBin(platform = process.platform, env = process.env,
|
|
179
|
-
home = homedir(), exists = existsSync) {
|
|
180
|
-
if (env.AGENTS_DECK_CLAUDE) return env.AGENTS_DECK_CLAUDE;
|
|
181
|
-
const sep = platform === "win32" ? "\\" : "/";
|
|
182
|
-
// process.env is case-insensitive on Windows; an injected plain object in a
|
|
183
|
-
// test is not, and %Path% is how the variable is actually spelled there.
|
|
184
|
-
const pathEnv = env.PATH ?? env.Path ?? env.path ?? "";
|
|
185
|
-
for (const c of claudeCliCandidates(platform, env, home)) {
|
|
186
|
-
if (c.includes(sep)) { if (exists(c)) return c; }
|
|
187
|
-
else if (pathLookup(c, platform, { pathEnv, exists })) return c;
|
|
188
|
-
}
|
|
189
|
-
// Nothing on PATH and nothing at any known install directory. The bare name
|
|
190
|
-
// is still the right last resort — POSIX `execvp` and cmd.exe's own search
|
|
191
|
-
// both deserve their turn at a layout no list here knows — and the ENOENT it
|
|
192
|
-
// produces is what failureText turns into the AGENTS_DECK_CLAUDE sentence.
|
|
193
|
-
return "claude";
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
async function claudeBin() {
|
|
197
|
-
return adminClaudeBin();
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/** Slot → email for everything currently in the store, plus the active slot. */
|
|
201
|
-
export async function readStore() {
|
|
202
|
-
try {
|
|
203
|
-
const seq = JSON.parse(await readFile(join(backupRoot(), "sequence.json"), "utf8"));
|
|
204
|
-
const accounts = seq?.accounts ?? {};
|
|
205
|
-
return {
|
|
206
|
-
slots: Object.keys(accounts),
|
|
207
|
-
emails: Object.fromEntries(Object.entries(accounts).map(([k, v]) => [k, v?.email ?? ""])),
|
|
208
|
-
// The other half of the identity claude-swap keys an account by. One
|
|
209
|
-
// address under two organizations is two accounts on purpose, and a
|
|
210
|
-
// bundle carrying both must not report them as one - see identityKey.
|
|
211
|
-
orgs: Object.fromEntries(Object.entries(accounts).map(([k, v]) => [k, v?.organizationUuid ?? ""])),
|
|
212
|
-
activeNum: seq?.activeAccountNumber ?? null,
|
|
213
|
-
};
|
|
214
|
-
} catch {
|
|
215
|
-
return { slots: [], emails: {}, orgs: {}, activeNum: null };
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* Which slot appeared between two store reads.
|
|
221
|
-
*
|
|
222
|
-
* `cswap add --json` is rejected by argparse (exit 2), so its human output is
|
|
223
|
-
* the only thing it offers and parsing that would break on any wording change.
|
|
224
|
-
* The store is the fact. `null` means nothing new — which is not a failure: an
|
|
225
|
-
* account already present is refreshed in place, on its existing slot.
|
|
226
|
-
*/
|
|
227
|
-
export function newSlot(before, after) {
|
|
228
|
-
const had = new Set(before.slots);
|
|
229
|
-
const fresh = after.slots.filter(s => !had.has(s));
|
|
230
|
-
return fresh.length === 1 ? fresh[0] : null;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* Anthropic's own view of who is signed in. Null when it cannot be read.
|
|
235
|
-
*
|
|
236
|
-
* Exported for its test rather than for a caller (#383). Both callers are inside
|
|
237
|
-
* the login flow and neither can show what was parsed: `spawnLogin` keeps only
|
|
238
|
-
* `identity?.email` as the address to restore to, and `submitLoginCode` turns
|
|
239
|
-
* the whole thing into a pass/fail — a null there is the difference between a
|
|
240
|
-
* sign-in the deck accepts and one it reports as "signed in, but the claude CLI
|
|
241
|
-
* still reports nobody logged in". The success path, where the email read here
|
|
242
|
-
* is what matches the new credential to a cswap slot, is reachable only with a
|
|
243
|
-
* real signed-in CLI on the machine running the suite. See
|
|
244
|
-
* cswap-identity.test.ts.
|
|
245
|
-
*/
|
|
246
|
-
export async function currentIdentity() {
|
|
247
|
-
const r = await run(await claudeBin(), ["auth", "status", "--json"], { timeout: 20_000 });
|
|
248
|
-
if (!r.ok) return null;
|
|
249
|
-
try {
|
|
250
|
-
const j = JSON.parse(r.stdout);
|
|
251
|
-
return j?.loggedIn ? { email: j.email ?? "", orgId: j.orgId ?? "" } : null;
|
|
252
|
-
} catch {
|
|
253
|
-
return null;
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// ── login ────────────────────────────────────────────────────────────────────
|
|
258
|
-
|
|
259
|
-
// `claude auth login` prints the link as an OSC-8 hyperlink: ESC ] 8 ; ; <url>
|
|
260
|
-
// BEL, then the visible text — which is the same url again — then an empty
|
|
261
|
-
// closer. Verified byte for byte against real output: two `\x1b]8;;`, both
|
|
262
|
-
// terminated by BEL, never the ESC-backslash form.
|
|
263
|
-
//
|
|
264
|
-
// So the url appears TWICE, back to back, and a naive /https:\/\/\S+/ captures
|
|
265
|
-
// them joined into one unusable string. Stripping the escape sequences takes
|
|
266
|
-
// the link target away with them and leaves the visible copy, once.
|
|
267
|
-
const OSC8 = /\x1b\]8;;[^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
268
|
-
const ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
269
|
-
|
|
270
|
-
export function stripTerminalEscapes(text) {
|
|
271
|
-
return String(text ?? "").replace(OSC8, "").replace(ANSI, "");
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/** The sign-in URL out of `claude auth login`'s output, or null. */
|
|
275
|
-
export function extractLoginUrl(text) {
|
|
276
|
-
const clean = stripTerminalEscapes(text);
|
|
277
|
-
const m = clean.match(/https:\/\/[^\s'"]*\/oauth\/authorize\?[^\s'"]+/);
|
|
278
|
-
return m ? m[0] : null;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// The prompt the CLI blocks on. Matched rather than assumed: writing a code
|
|
282
|
-
// into a child that is not asking for one would send it somewhere unknown.
|
|
283
|
-
const CODE_PROMPT = /paste code here/gi;
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* How many times one line of output asks for the code.
|
|
287
|
-
*
|
|
288
|
-
* Per LINE, not per delivery. The prompt ends without a newline, so exec.mjs
|
|
289
|
-
* re-offers the same unterminated line on every chunk as it grows — and both
|
|
290
|
-
* streams share that buffer, so a progress dot, a `\r` spinner frame on stderr,
|
|
291
|
-
* or simply the prompt split across two writes all arrive as the ask again,
|
|
292
|
-
* a few characters longer. Counting an ask whenever the text merely CHANGED, as
|
|
293
|
-
* this used to, turned any of those into a phantom second prompt, and a second
|
|
294
|
-
* prompt is exactly how submitLoginCode hears "that code was rejected": a
|
|
295
|
-
* login the CLI was busy completing came back as `code_rejected`, with the
|
|
296
|
-
* account unregistered and the live credentials left switched.
|
|
297
|
-
*
|
|
298
|
-
* A real re-ask writes the prompt again — on a fresh line, or after a `\r`
|
|
299
|
-
* redraw of this one — so it shows up as another match, not as a longer tail.
|
|
300
|
-
*/
|
|
301
|
-
export function countCodePrompts(line) {
|
|
302
|
-
return (stripTerminalEscapes(line).match(CODE_PROMPT) ?? []).length;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* The whole login, as one object, because the browser talks to it twice: once
|
|
307
|
-
* to start and get a URL, once to hand back the code.
|
|
308
|
-
*
|
|
309
|
-
* `previousActive` is captured before anything happens. `cswap add` sets
|
|
310
|
-
* activeAccountNumber to whatever it just added, so without this the machine
|
|
311
|
-
* silently changes account underneath every running session.
|
|
312
|
-
*/
|
|
313
|
-
let _login = null;
|
|
314
|
-
|
|
315
|
-
// A start that has not published its flow yet.
|
|
316
|
-
//
|
|
317
|
-
// `_login` is only assigned once the child exists, and everything before that
|
|
318
|
-
// awaits — a store read, then `claude auth status`, which shells out and takes
|
|
319
|
-
// hundreds of milliseconds. Two requests overlapping inside that window (two
|
|
320
|
-
// tabs, or a double-submitted POST) therefore both read `_login` as null, both
|
|
321
|
-
// walked past the guards below, and both spawned `claude auth login`. Only the
|
|
322
|
-
// later assignment stayed reachable, so the other child — holding an open OAuth
|
|
323
|
-
// flow and an open stdin — was beyond cancelLogin and beyond the dialog's
|
|
324
|
-
// Escape, and ran on invisibly until its five-minute timeout. Concurrent starts
|
|
325
|
-
// share the one spawn instead, the promise guard ccusage.mjs uses to share an
|
|
326
|
-
// install.
|
|
327
|
-
let _starting = null;
|
|
328
|
-
|
|
329
|
-
export function loginState() {
|
|
330
|
-
if (!_login) return { state: "idle" };
|
|
331
|
-
const { state, url, error, account, expiresAt } = _login;
|
|
332
|
-
return { state, url: url ?? null, error: error ?? null, account: account ?? null, expiresAt: expiresAt ?? null };
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/**
|
|
336
|
-
* What an address may be made of before it becomes an argv element.
|
|
337
|
-
*
|
|
338
|
-
* NOT AN RFC 5322 PARSER, and it should not be read as one. RFC 5322 permits
|
|
339
|
-
* quoted local parts, spaces inside them, comments in parentheses and bracketed
|
|
340
|
-
* address literals; a regex that accepted all of that would accept precisely the
|
|
341
|
-
* shapes this exists to keep out. The job here is narrower and worth stating
|
|
342
|
-
* plainly: keep a FLAG-SHAPED or WHITESPACE-BEARING string out of a spawn's
|
|
343
|
-
* argument vector. Anything it wrongly refuses is an address nobody has ever
|
|
344
|
-
* typed into this dialog; anything it wrongly accepts is inert as an argument,
|
|
345
|
-
* which is the only property being defended. Whether the address exists is
|
|
346
|
-
* Anthropic's question, asked a moment later by the CLI itself.
|
|
347
|
-
*
|
|
348
|
-
* `--email` was the field the alias allowlist below missed. `email.includes("@")`
|
|
349
|
-
* was the whole of its validation and the value came straight off the request
|
|
350
|
-
* body, so the two residuals exec.mjs documents were both reachable through it
|
|
351
|
-
* on Windows, where `claude` is a `.cmd` shim and the vector goes through
|
|
352
|
-
* `cmd.exe /d /s /c`: an interior newline is a command separator inside that one
|
|
353
|
-
* quoted line, and `%USERPROFILE%` expands inside quotes with no escape
|
|
354
|
-
* available. `"a@b\ncalc.exe"` and `"%USERPROFILE%@x"` both satisfy
|
|
355
|
-
* `includes("@")`, and both are payloads alias-charset.test.ts already pins as
|
|
356
|
-
* refused for the other field.
|
|
357
|
-
*
|
|
358
|
-
* The leading-character rule is the same argv-position rule ALIAS_OK now carries.
|
|
359
|
-
* `-x@y.z` starts with a dash, so a child parser reads it as an option rather
|
|
360
|
-
* than as the value of `--email`, and what happens next depends entirely on
|
|
361
|
-
* which options that CLI happens to define.
|
|
362
|
-
*/
|
|
363
|
-
const EMAIL_OK =
|
|
364
|
-
/^[A-Za-z0-9][A-Za-z0-9._%+-]{0,63}@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$/;
|
|
365
|
-
|
|
366
|
-
// The SMTP forward-path limit. The pattern above bounds each PIECE — 64 for the
|
|
367
|
-
// local part, 63 per label — and a domain may carry any number of labels, so
|
|
368
|
-
// without this the whole is unbounded. A bound belongs here for the reason
|
|
369
|
-
// ALIAS_OK has one: on Windows the value ends up inside a single cmd.exe command
|
|
370
|
-
// line, which has a hard length limit of its own.
|
|
371
|
-
const EMAIL_MAX_LENGTH = 254;
|
|
372
|
-
|
|
373
|
-
/**
|
|
374
|
-
* The `--email` value `claude auth login` should be given, or a refusal.
|
|
375
|
-
*
|
|
376
|
-
* Three answers rather than two, and the third is the one that matters: `null`
|
|
377
|
-
* means NO ADDRESS WAS OFFERED, which is the only shape the deck's own dialog
|
|
378
|
-
* sends (`AddAccountDialog` posts a bare `{action:"login"}`) and which must stay
|
|
379
|
-
* an ordinary sign-in with no flag appended. A value that is present and
|
|
380
|
-
* unusable is refused outright instead of being quietly dropped: dropping it
|
|
381
|
-
* would run a DIFFERENT sign-in from the one that was asked for and call it a
|
|
382
|
-
* success, and this route is reachable by anything holding the deck token.
|
|
383
|
-
*/
|
|
384
|
-
function loginEmailArg(email) {
|
|
385
|
-
if (email == null) return { ok: true, email: null };
|
|
386
|
-
if (typeof email !== "string") return { ok: false };
|
|
387
|
-
const clean = email.trim();
|
|
388
|
-
if (!clean) return { ok: true, email: null };
|
|
389
|
-
if (clean.length > EMAIL_MAX_LENGTH || !EMAIL_OK.test(clean)) return { ok: false };
|
|
390
|
-
return { ok: true, email: clean };
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
export async function startLogin({ email } = {}) {
|
|
394
|
-
// Argv position is settled first, before any state moves. A refusal here must
|
|
395
|
-
// not cancel a sign-in that is already running — the caller asked for
|
|
396
|
-
// something the deck will not do, and the flow already in flight is not part
|
|
397
|
-
// of that bargain.
|
|
398
|
-
const wanted = loginEmailArg(email);
|
|
399
|
-
if (!wanted.ok) return { ok: false, reason: "bad_email", ...loginState() };
|
|
400
|
-
// Registering is the half that writes to the store; interrupting it would
|
|
401
|
-
// leave an account half-recorded, so that one is refused. A flow merely
|
|
402
|
-
// waiting for a code is not precious — it is most often the one abandoned by
|
|
403
|
-
// the page reload that just happened — and it yields to the new request
|
|
404
|
-
// rather than blocking it for the rest of its five minutes.
|
|
405
|
-
if (_login?.state === "registering") {
|
|
406
|
-
return { ok: false, reason: "already_running", ...loginState() };
|
|
407
|
-
}
|
|
408
|
-
if (_login?.state === "awaiting_url" || _login?.state === "awaiting_code") {
|
|
409
|
-
await cancelLogin();
|
|
410
|
-
}
|
|
411
|
-
if (!_starting) {
|
|
412
|
-
_starting = spawnLogin(wanted.email).finally(() => { _starting = null; });
|
|
413
|
-
}
|
|
414
|
-
const flow = await _starting;
|
|
415
|
-
|
|
416
|
-
// The URL arrives on the child's first write, typically within a second — and
|
|
417
|
-
// the child's death ends the wait just as conclusively, which is why the state
|
|
418
|
-
// is polled beside it. A `claude` that cannot be run at all is gone in
|
|
419
|
-
// milliseconds and the done handler below has already written down why, but
|
|
420
|
-
// waiting only for a url meant this POST sat open for the whole fifteen
|
|
421
|
-
// seconds afterwards: a spinner in front of a user whose answer was ready
|
|
422
|
-
// almost immediately.
|
|
423
|
-
await waitFor(() => flow.url || flow.state === "failed" || flow.state === "done", 15_000);
|
|
424
|
-
// A sign-in that got somewhere without a link this could read is still a
|
|
425
|
-
// sign-in, and since #708 the done handler can carry one all the way to
|
|
426
|
-
// `done` on its own. Saying "no url" over that would throw a completed login
|
|
427
|
-
// away at the last step, which is the whole of the bug being fixed.
|
|
428
|
-
if (!flow.url && (flow.state === "registering" || flow.state === "done")) {
|
|
429
|
-
return { ok: true, ...loginState() };
|
|
430
|
-
}
|
|
431
|
-
if (!flow.url) {
|
|
432
|
-
flow.child.kill();
|
|
433
|
-
// Same identity check as the done handler below, and for a sharper reason:
|
|
434
|
-
// a second startLogin cancels this one's child (the yield path above), so
|
|
435
|
-
// this url can never arrive and this wait always runs its full fifteen
|
|
436
|
-
// seconds. Overwriting _login then would erase a live flow — the dialog
|
|
437
|
-
// would flip to "failed" while the newer child is still waiting for a code
|
|
438
|
-
// that can no longer be delivered, and with its handle gone not even
|
|
439
|
-
// cancelLogin could reach it.
|
|
440
|
-
//
|
|
441
|
-
// A flow the child's own death already explained keeps that explanation:
|
|
442
|
-
// failureText turns an unrunnable CLI into "not on PATH. Set
|
|
443
|
-
// AGENTS_DECK_CLAUDE to its full path.", the only sentence in the whole flow
|
|
444
|
-
// that names a fix, and publishing a guess about the missing link over it
|
|
445
|
-
// destroyed it on the server.
|
|
446
|
-
const died = flow === _login && flow.state === "failed" ? flow.error : null;
|
|
447
|
-
if (flow === _login && !died) {
|
|
448
|
-
_login = { state: "failed", error: "the claude CLI did not print a sign-in link" };
|
|
449
|
-
}
|
|
450
|
-
// Carried as `detail` because that is the only field that outranks the
|
|
451
|
-
// reason in the dialog's say(): REASONS["no_url"] asks "is it installed?",
|
|
452
|
-
// which is exactly the question the sentence above has already answered, and
|
|
453
|
-
// it would win over `error`.
|
|
454
|
-
return { ok: false, reason: "no_url", ...(died ? { detail: died } : {}), ...loginState() };
|
|
455
|
-
}
|
|
456
|
-
return { ok: true, ...loginState() };
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
/**
|
|
460
|
-
* Start the child and publish it, as one indivisible step.
|
|
461
|
-
*
|
|
462
|
-
* Separate from startLogin so `_starting` can dedupe it: everything here runs
|
|
463
|
-
* before `_login` exists, and a caller that reaches it a second time
|
|
464
|
-
* concurrently would spawn a sign-in nothing can cancel.
|
|
465
|
-
*/
|
|
466
|
-
async function spawnLogin(email) {
|
|
467
|
-
const before = await readStore();
|
|
468
|
-
const identity = await currentIdentity();
|
|
469
|
-
|
|
470
|
-
const args = ["auth", "login"];
|
|
471
|
-
// Already through loginEmailArg, which is the only caller's boundary: this is
|
|
472
|
-
// either an address that cannot be read as a flag or null, and null is the
|
|
473
|
-
// ordinary case.
|
|
474
|
-
if (email) args.push("--email", email);
|
|
475
|
-
|
|
476
|
-
const child = runInteractive(await claudeBin(), args, { timeout: LOGIN_TIMEOUT_MS });
|
|
477
|
-
// A sign-in outlives the request that started it, so it can also outlive the
|
|
478
|
-
// deck. Nothing else would ever reap it: it is waiting on a stdin that no
|
|
479
|
-
// longer has a writer, and it holds the user's next attempt hostage for five
|
|
480
|
-
// minutes. Killed on the way out, and unregistered as soon as it settles so
|
|
481
|
-
// the handler list cannot grow.
|
|
482
|
-
const onExit = () => { try { child.kill(); } catch { /* already gone */ } };
|
|
483
|
-
process.on("exit", onExit);
|
|
484
|
-
child.done.then(() => process.off("exit", onExit), () => process.off("exit", onExit));
|
|
485
|
-
|
|
486
|
-
_login = {
|
|
487
|
-
state: "awaiting_url",
|
|
488
|
-
child,
|
|
489
|
-
previousActive: before.activeNum,
|
|
490
|
-
previousEmail: identity?.email ?? null,
|
|
491
|
-
before,
|
|
492
|
-
url: null,
|
|
493
|
-
error: null,
|
|
494
|
-
account: null,
|
|
495
|
-
expiresAt: Date.now() + LOGIN_TIMEOUT_MS,
|
|
496
|
-
// How many times the CLI has asked for a code. A second ask after we
|
|
497
|
-
// answered is how a rejected code announces itself — the process does not
|
|
498
|
-
// exit, it just asks again, so waiting for exit would hang for the whole
|
|
499
|
-
// five-minute window on a typo.
|
|
500
|
-
prompts: 0,
|
|
501
|
-
};
|
|
502
|
-
const flow = _login;
|
|
503
|
-
|
|
504
|
-
// How many asks the line currently being written has already contributed.
|
|
505
|
-
// The prompt is an unterminated line, re-delivered as it grows, so the same
|
|
506
|
-
// ask arrives over and over — and once more, whole, when something else
|
|
507
|
-
// finally ends the line.
|
|
508
|
-
let countedOnLine = 0;
|
|
509
|
-
|
|
510
|
-
child.onLine((line, partial) => {
|
|
511
|
-
if (!flow.url) {
|
|
512
|
-
const url = extractLoginUrl(line);
|
|
513
|
-
if (url) { flow.url = url; flow.state = "awaiting_code"; }
|
|
514
|
-
}
|
|
515
|
-
const asks = countCodePrompts(line);
|
|
516
|
-
if (asks > countedOnLine) {
|
|
517
|
-
flow.prompts += asks - countedOnLine;
|
|
518
|
-
countedOnLine = asks;
|
|
519
|
-
}
|
|
520
|
-
// A newline ended that line; whatever comes next is a new one.
|
|
521
|
-
if (!partial) countedOnLine = 0;
|
|
522
|
-
});
|
|
523
|
-
// The child ending before a code was pasted USED to be read as a failure, and
|
|
524
|
-
// on this CLI that is the ordinary end of a sign-in that worked (#708).
|
|
525
|
-
//
|
|
526
|
-
// `claude auth login` 2.1.246 does two things at once: it prints
|
|
527
|
-
// "Paste code here if prompted > " and blocks on stdin, AND it listens on a
|
|
528
|
-
// loopback port for the OAuth callback. Which half finishes the exchange is
|
|
529
|
-
// not decided by the CLI's version — it is decided by whether the browser
|
|
530
|
-
// that opened can reach this machine's loopback. On the deck's own machine it
|
|
531
|
-
// can, so the CLI takes the code itself, prints "Login successful." and exits
|
|
532
|
-
// 0 while the deck is still sitting in `awaiting_code`; the page that
|
|
533
|
-
// authorised says "You're all set up" and never shows a code to paste. On a
|
|
534
|
-
// deck reached from another machine it cannot, the page shows the code, and
|
|
535
|
-
// the paste path below is the one that runs.
|
|
536
|
-
//
|
|
537
|
-
// So the exit alone is not the verdict. The verdict is the identity, and
|
|
538
|
-
// `claude auth status --json` is the oracle this module already trusts for
|
|
539
|
-
// it — asked here against the identity recorded before the flow started.
|
|
540
|
-
child.done.then(async (r) => {
|
|
541
|
-
if (flow !== _login) return;
|
|
542
|
-
if (flow.state !== "awaiting_url" && flow.state !== "awaiting_code") return;
|
|
543
|
-
// Answered before any await, so that a `claude` which cannot be run at all
|
|
544
|
-
// is still reported within milliseconds: startLogin waits on this state to
|
|
545
|
-
// decide whether to keep holding its POST open, and the sentence naming
|
|
546
|
-
// AGENTS_DECK_CLAUDE is the only one in the flow that names a fix. Nothing
|
|
547
|
-
// was signed in either way, so there is nothing to ask about.
|
|
548
|
-
if (r.timedOut || cannotRun(r)) {
|
|
549
|
-
flow.state = "failed";
|
|
550
|
-
flow.error = loginFailureText(r);
|
|
551
|
-
return;
|
|
552
|
-
}
|
|
553
|
-
// Claimed before asking, for the same reason submitLoginCode claims it: a
|
|
554
|
-
// code posted while the question is out would otherwise register the same
|
|
555
|
-
// login a second time, with a second `cswap add` racing this one.
|
|
556
|
-
flow.state = "registering";
|
|
557
|
-
const identity = await currentIdentity();
|
|
558
|
-
if (flow !== _login) return;
|
|
559
|
-
// A clean exit with somebody logged in is a completed sign-in, including
|
|
560
|
-
// the re-sign-in of an account the deck already holds — an identity that
|
|
561
|
-
// did not CHANGE is not an identity that did not arrive. A dirty exit
|
|
562
|
-
// counts only when the identity moved, which is a login that landed in
|
|
563
|
-
// spite of whatever the CLI complained about on its way out.
|
|
564
|
-
if (identity && (r.ok || identity.email !== flow.previousEmail)) {
|
|
565
|
-
await registerSignedIn(flow, identity);
|
|
566
|
-
return;
|
|
567
|
-
}
|
|
568
|
-
flow.state = "failed";
|
|
569
|
-
flow.error = loginFailureText(r);
|
|
570
|
-
}).catch((err) => {
|
|
571
|
-
// This handler answers nobody's request — its promise is dropped — so a
|
|
572
|
-
// throw anywhere in it would be an unhandled rejection AND a dialog left
|
|
573
|
-
// spinning on `registering` until the poll gave up. It was three lines of
|
|
574
|
-
// synchronous assignment before; it now shells out twice.
|
|
575
|
-
console.error(`${PRODUCT} sign-in: the sign-in could not be finished:`, err?.message ?? err);
|
|
576
|
-
if (flow !== _login) return;
|
|
577
|
-
flow.state = "failed";
|
|
578
|
-
flow.error = "the sign-in could not be finished — see the deck's log";
|
|
579
|
-
});
|
|
580
|
-
return flow;
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
/** A run that never reached the CLI at all — nothing can have been signed in. */
|
|
584
|
-
function cannotRun(r) {
|
|
585
|
-
return r?.code === "ENOENT" || looksMissing(`${r?.stderr ?? ""}\n${r?.stdout ?? ""}`, "", r?.code);
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
/**
|
|
589
|
-
* Why a sign-in failed, in words meant for the person who pressed the button.
|
|
590
|
-
*
|
|
591
|
-
* The child's own output is offered only when it is a diagnosis — see
|
|
592
|
-
* failureText, which since #708 refuses a line that announces success or is
|
|
593
|
-
* merely the prompt the CLI was still sitting on. What it printed is not thrown
|
|
594
|
-
* away; it goes to the deck's log, where an operator can read it, rather than
|
|
595
|
-
* onto a dialog as the reason.
|
|
596
|
-
*/
|
|
597
|
-
function loginFailureText(r) {
|
|
598
|
-
// One line, the way ccusage's `note` says everything it says: an operator
|
|
599
|
-
// watching the terminal is reading it beside the deck's own repainted status
|
|
600
|
-
// rows, and the escapes in it are a login link's OSC-8 wrapper.
|
|
601
|
-
//
|
|
602
|
-
// stderr goes LAST because the line is cut from the front. What should be
|
|
603
|
-
// lost to the bound is the CLI's chatter — the greeting and a sign-in link
|
|
604
|
-
// that is 400 characters by itself — never its complaint.
|
|
605
|
-
const tail = stripTerminalEscapes(`${r?.stdout ?? ""}\n${r?.stderr ?? ""}`).replace(/\s+/g, " ").trim();
|
|
606
|
-
if (tail) console.error(`${PRODUCT} sign-in: claude auth login did not complete:`, tail.slice(-300));
|
|
607
|
-
if (r?.timedOut) return "the sign-in window expired";
|
|
608
|
-
return failureText(r, "claude auth login", "the sign-in did not complete — nothing new was signed in");
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
/**
|
|
612
|
-
* Everything after the sign-in itself: confirm who we are now, record it with
|
|
613
|
-
* claude-swap, and put the previously-active account back in front.
|
|
614
|
-
*
|
|
615
|
-
* Shared by the two ways a sign-in can end (#708) — a code pasted into the
|
|
616
|
-
* prompt, and the CLI finishing the exchange through its own loopback callback
|
|
617
|
-
* — because the steps after it are identical and have to stay identical. An
|
|
618
|
-
* account the deck skipped `cswap add` for is signed in at the CLI level and
|
|
619
|
-
* invisible to the panel, with the account the user was on left switched away
|
|
620
|
-
* from.
|
|
621
|
-
*
|
|
622
|
-
* Returns the same `{ok, ...loginState()}` both callers answer their request
|
|
623
|
-
* with; the done handler simply drops it.
|
|
624
|
-
*/
|
|
625
|
-
async function registerSignedIn(flow, identity) {
|
|
626
|
-
return withStoreLock(async () => {
|
|
627
|
-
const add = await run(await cswapBin(), ["add"], { timeout: CSWAP_TIMEOUT_MS });
|
|
628
|
-
if (!add.ok) {
|
|
629
|
-
flow.state = "failed";
|
|
630
|
-
flow.error = addFailureText(add);
|
|
631
|
-
await restoreActive(flow.previousActive);
|
|
632
|
-
return { ok: false, reason: "add_failed", ...loginState() };
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
const after = await readStore();
|
|
636
|
-
const slot = newSlot(flow.before, after);
|
|
637
|
-
// No new slot means the account was already managed and cswap refreshed its
|
|
638
|
-
// credentials in place. That is a success with a different sentence.
|
|
639
|
-
const num = slot ?? Object.keys(after.emails).find(k => after.emails[k] === identity.email) ?? null;
|
|
640
|
-
|
|
641
|
-
await restoreActive(flow.previousActive);
|
|
642
|
-
invalidateClaudeAccountsCache();
|
|
643
|
-
// Collect straight away, so the new row shows numbers instead of "never
|
|
644
|
-
// collected" until the next poll — the same nudge seedFirstAccount uses.
|
|
645
|
-
runDetached(await cswapBin(), ["list"]);
|
|
646
|
-
|
|
647
|
-
flow.state = "done";
|
|
648
|
-
flow.account = { num, email: identity.email, added: slot != null };
|
|
649
|
-
return { ok: true, ...loginState() };
|
|
650
|
-
});
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
/**
|
|
654
|
-
* Hand the code back, then register whatever it signed us in as.
|
|
655
|
-
*
|
|
656
|
-
* Every step after the code is verification, not optimism: the CLI can exit 0
|
|
657
|
-
* having changed nothing, so the identity is re-read and compared before the
|
|
658
|
-
* store is touched at all.
|
|
659
|
-
*/
|
|
660
|
-
export async function submitLoginCode(code) {
|
|
661
|
-
const flow = _login;
|
|
662
|
-
if (!flow || flow.state !== "awaiting_code") return { ok: false, reason: "not_waiting", ...loginState() };
|
|
663
|
-
if (typeof code !== "string" || !code.trim()) return { ok: false, reason: "empty_code", ...loginState() };
|
|
664
|
-
if (flow.prompts === 0) return { ok: false, reason: "not_prompted", ...loginState() };
|
|
665
|
-
|
|
666
|
-
const askedBefore = flow.prompts;
|
|
667
|
-
flow.state = "registering";
|
|
668
|
-
flow.child.write(code.trim() + "\n");
|
|
669
|
-
|
|
670
|
-
// Whichever comes first: the CLI finishing, or it asking again. A wrong code
|
|
671
|
-
// produces the second, and the flow stays usable so the user can retype
|
|
672
|
-
// rather than starting the whole sign-in over.
|
|
673
|
-
const r = await Promise.race([
|
|
674
|
-
flow.child.done,
|
|
675
|
-
waitFor(() => flow.prompts > askedBefore, CODE_VERDICT_MS, 200).then(again => (again ? "rejected" : "slow")),
|
|
676
|
-
]);
|
|
677
|
-
if (r === "rejected") {
|
|
678
|
-
flow.state = "awaiting_code";
|
|
679
|
-
flow.error = "that code was not accepted — copy it again from the browser";
|
|
680
|
-
return { ok: false, reason: "code_rejected", ...loginState() };
|
|
681
|
-
}
|
|
682
|
-
if (r === "slow") {
|
|
683
|
-
flow.state = "awaiting_code";
|
|
684
|
-
flow.error = "the claude CLI has not answered — try the code again";
|
|
685
|
-
return { ok: false, reason: "no_verdict", ...loginState() };
|
|
686
|
-
}
|
|
687
|
-
if (!r.ok) {
|
|
688
|
-
flow.state = "failed";
|
|
689
|
-
// The `|| "the code was not accepted"` this used to end with could never
|
|
690
|
-
// run: failureText always answered with something, if only an exit status.
|
|
691
|
-
// The sentence is where it can be reached now — as the fallback failureText
|
|
692
|
-
// reaches for when the CLI printed nothing worth repeating.
|
|
693
|
-
flow.error = r.timedOut ? "the sign-in window expired" : failureText(r, "claude auth login", "the code was not accepted");
|
|
694
|
-
return { ok: false, reason: "login_failed", ...loginState() };
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
const identity = await currentIdentity();
|
|
698
|
-
if (!identity) {
|
|
699
|
-
flow.state = "failed";
|
|
700
|
-
flow.error = "signed in, but the claude CLI still reports nobody logged in";
|
|
701
|
-
return { ok: false, reason: "no_identity", ...loginState() };
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
return registerSignedIn(flow, identity);
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
export async function cancelLogin() {
|
|
708
|
-
const flow = _login;
|
|
709
|
-
if (!flow) return { ok: true, ...loginState() };
|
|
710
|
-
// Stopping the child touches no file, so it happens straight away: a cancel
|
|
711
|
-
// that waited its turn first would leave `claude auth login` sitting on the
|
|
712
|
-
// user's next attempt for the rest of its five minutes.
|
|
713
|
-
try { flow.child?.kill(); } catch { /* already gone */ }
|
|
714
|
-
// Switching back does touch the store, so it is queued like every other
|
|
715
|
-
// mutation. Cancel is reachable while submitLoginCode holds the lock — the
|
|
716
|
-
// dialog fires it on Escape, mid-registration — and running `cswap switch`
|
|
717
|
-
// beside an in-flight `cswap add` is precisely the unlocked read-modify-write
|
|
718
|
-
// of sequence.json the mutex exists to prevent: claude-swap takes no file lock
|
|
719
|
-
// around `add`, so whichever write lands second drops the other's record.
|
|
720
|
-
return withStoreLock(async () => {
|
|
721
|
-
// The login may have completed before the cancel arrived, in which case the
|
|
722
|
-
// live credentials already moved and putting them back is the point.
|
|
723
|
-
await restoreActive(flow.previousActive);
|
|
724
|
-
invalidateClaudeAccountsCache();
|
|
725
|
-
// A newer sign-in can have started while this waited its turn; clearing the
|
|
726
|
-
// slot then would drop a live flow's handle instead of this dead one's.
|
|
727
|
-
if (flow === _login) _login = null;
|
|
728
|
-
return { ok: true, ...loginState() };
|
|
729
|
-
});
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
/** Put the account that was active before the login back in front. */
|
|
733
|
-
async function restoreActive(num) {
|
|
734
|
-
if (num == null) return;
|
|
735
|
-
const after = await readStore();
|
|
736
|
-
if (String(after.activeNum) === String(num)) return;
|
|
737
|
-
await run(await cswapBin(), ["switch", String(num)], { timeout: 30_000 }).catch(() => {});
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
// ── share / import ───────────────────────────────────────────────────────────
|
|
741
|
-
|
|
742
|
-
/**
|
|
743
|
-
* One account, packaged for another deck.
|
|
744
|
-
*
|
|
745
|
-
* claude-swap's envelope carries the account's OAuth token in the clear — its
|
|
746
|
-
* own module header says so ("No encryption is built in"). The wrapper adds an
|
|
747
|
-
* expiry so a copy left behind in clipboard history stops working, and nothing
|
|
748
|
-
* more: it is not encryption and is not presented as any.
|
|
749
|
-
*
|
|
750
|
-
* Say the limit of that out loud, because the UI used to imply the opposite.
|
|
751
|
-
* `exp` is a plain number inside plain base64'd JSON with NO key, MAC or
|
|
752
|
-
* signature over it, so anyone holding the text can decode it, write a later
|
|
753
|
-
* `exp`, re-encode, and import it. unwrapShare's check is therefore a check
|
|
754
|
-
* against staleness, not against an adversary — and it cannot be made into one
|
|
755
|
-
* here. A MAC needs a secret both decks hold, and two decks that already shared
|
|
756
|
-
* a secret would not need this function; and even a perfect signature would
|
|
757
|
-
* only stop THIS import path, since the payload it wraps is the credential
|
|
758
|
-
* itself and `cswap import` accepts it unwrapped. The honest fix for a share
|
|
759
|
-
* that got away is to sign the account out and back in. See share-expiry-
|
|
760
|
-
* forgeable.test.ts, which pins the forgery rather than leaving it implied.
|
|
761
|
-
*
|
|
762
|
-
* The default export shape is used deliberately, never --full, which would
|
|
763
|
-
* embed the entire ~/.claude.json including every project and MCP server.
|
|
764
|
-
*/
|
|
765
|
-
export function wrapShare(payload, now = Date.now(), ttlMs = SHARE_TTL_MS) {
|
|
766
|
-
const body = JSON.stringify({ v: 1, exp: now + ttlMs, payload });
|
|
767
|
-
const packed = brotliCompressSync(Buffer.from(body, "utf8"), {
|
|
768
|
-
params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 11 },
|
|
769
|
-
});
|
|
770
|
-
return SHARE_PREFIX + packed.toString("base64");
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
/**
|
|
774
|
-
* The inverse, for either prefix. Returns `{ok:true, payload}` or
|
|
775
|
-
* `{ok:false, reason}`.
|
|
776
|
-
*
|
|
777
|
-
* `v` inside the envelope is unchanged at 1 and deliberately so: it versions
|
|
778
|
-
* the SHAPE of the envelope, and that shape did not change. The prefix versions
|
|
779
|
-
* the encoding. Folding the two would have made an old blob unreadable for no
|
|
780
|
-
* reason, since its contents are exactly what this still expects.
|
|
781
|
-
*/
|
|
782
|
-
export function unwrapShare(blob, now = Date.now()) {
|
|
783
|
-
const text = String(blob ?? "").trim();
|
|
784
|
-
const v2 = text.startsWith(SHARE_PREFIX);
|
|
785
|
-
if (!v2 && !text.startsWith(SHARE_PREFIX_V1)) return { ok: false, reason: "not_a_share" };
|
|
786
|
-
let env;
|
|
787
|
-
try {
|
|
788
|
-
// Sliced by the prefix that actually matched. The two are the same length
|
|
789
|
-
// today and writing it this way is what keeps that from being load-bearing.
|
|
790
|
-
const bytes = Buffer.from(text.slice((v2 ? SHARE_PREFIX : SHARE_PREFIX_V1).length), "base64");
|
|
791
|
-
// maxOutputLength is the whole reason a bounded decompress is safe to point
|
|
792
|
-
// at pasted text; without it a short blob can name an allocation that ends
|
|
793
|
-
// the process.
|
|
794
|
-
const body = v2
|
|
795
|
-
? brotliDecompressSync(bytes, { maxOutputLength: SHARE_MAX_BYTES })
|
|
796
|
-
: bytes;
|
|
797
|
-
env = JSON.parse(body.toString("utf8"));
|
|
798
|
-
} catch {
|
|
799
|
-
return { ok: false, reason: "corrupt" };
|
|
800
|
-
}
|
|
801
|
-
if (env?.v !== 1) return { ok: false, reason: "wrong_version" };
|
|
802
|
-
// Checked before the payload is looked at, let alone handed to cswap.
|
|
803
|
-
if (typeof env.exp !== "number" || env.exp < now) return { ok: false, reason: "expired" };
|
|
804
|
-
if (typeof env.payload !== "string" || !env.payload) return { ok: false, reason: "corrupt" };
|
|
805
|
-
return { ok: true, payload: env.payload };
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
/**
|
|
809
|
-
* claude-swap's own identity for an account, as one comparable string.
|
|
810
|
-
*
|
|
811
|
-
* `(email, organizationUuid)`, which is the composite `transfer.py` keys its
|
|
812
|
-
* duplicate check and its already-here check on. Matching on the address alone
|
|
813
|
-
* would fold one address's two organizations into a single row, and the whole
|
|
814
|
-
* reason cswap carries the org is that they are two accounts.
|
|
815
|
-
*
|
|
816
|
-
* The address is lower-cased because both sides of every comparison here come
|
|
817
|
-
* from the same store or the same bundle, so folding case can only join what a
|
|
818
|
-
* human would call the same account, never split one.
|
|
819
|
-
*/
|
|
820
|
-
export function identityKey(email, org) {
|
|
821
|
-
return `${String(email ?? "").trim().toLowerCase()} ${String(org ?? "")}`;
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
/**
|
|
825
|
-
* N single-account envelopes, folded into the one bundle cswap will take back.
|
|
826
|
-
*
|
|
827
|
-
* `cswap export --account` names ONE account, so a chosen subset cannot come
|
|
828
|
-
* out of a single call and the deck has to do the folding. It is deliberately
|
|
829
|
-
* not a new format: the head envelope is spread whole and only `accounts` and
|
|
830
|
-
* `activeAccountNumber` are replaced, so `version`, `exportedFrom`,
|
|
831
|
-
* `swapVersion` and any field a later claude-swap adds arrive on the far side
|
|
832
|
-
* exactly as that claude-swap wrote them. Nothing here hard-codes its
|
|
833
|
-
* FORMAT_VERSION, because a constant copied out of another project's source is
|
|
834
|
-
* a constant that drifts.
|
|
835
|
-
*
|
|
836
|
-
* `activeAccountNumber` is re-guarded rather than carried: cswap only records
|
|
837
|
-
* it when that slot is in the payload, and a subset can drop the slot the head
|
|
838
|
-
* envelope pointed at. An import that referenced a missing account would be
|
|
839
|
-
* seeding an active slot that never arrived.
|
|
840
|
-
*
|
|
841
|
-
* A duplicate identity is DROPPED rather than passed on. `import_accounts`
|
|
842
|
-
* refuses a whole envelope over one repeated `(email, org)` pair, so carrying
|
|
843
|
-
* it would trade five shared accounts for a bundle that imports none.
|
|
844
|
-
*/
|
|
845
|
-
export function mergeExports(texts) {
|
|
846
|
-
const envelopes = [];
|
|
847
|
-
for (const text of texts) {
|
|
848
|
-
let env;
|
|
849
|
-
try { env = JSON.parse(text); } catch { return { ok: false, reason: "unreadable_export" }; }
|
|
850
|
-
if (!env || typeof env !== "object" || !Array.isArray(env.accounts)) {
|
|
851
|
-
return { ok: false, reason: "unreadable_export" };
|
|
852
|
-
}
|
|
853
|
-
envelopes.push(env);
|
|
854
|
-
}
|
|
855
|
-
if (!envelopes.length) return { ok: false, reason: "nothing_to_share" };
|
|
856
|
-
|
|
857
|
-
const head = envelopes[0];
|
|
858
|
-
// Every part came out of one binary in one pass, so a disagreement here is
|
|
859
|
-
// not a version to reconcile - it is a sign the parts are not what we think.
|
|
860
|
-
if (envelopes.some(e => e.version !== head.version)) return { ok: false, reason: "mixed_versions" };
|
|
861
|
-
|
|
862
|
-
const accounts = [];
|
|
863
|
-
const dropped = [];
|
|
864
|
-
const seen = new Set();
|
|
865
|
-
for (const env of envelopes) {
|
|
866
|
-
for (const a of env.accounts) {
|
|
867
|
-
const key = identityKey(a?.email, a?.organizationUuid);
|
|
868
|
-
if (seen.has(key)) { dropped.push({ num: String(a?.number ?? ""), email: a?.email ?? "" }); continue; }
|
|
869
|
-
seen.add(key);
|
|
870
|
-
accounts.push(a);
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
|
-
if (!accounts.length) return { ok: false, reason: "nothing_to_share" };
|
|
874
|
-
|
|
875
|
-
const nums = new Set(accounts.map(a => String(a?.number ?? "")));
|
|
876
|
-
const active = envelopes
|
|
877
|
-
.map(e => e.activeAccountNumber)
|
|
878
|
-
.find(n => n != null && nums.has(String(n)));
|
|
879
|
-
return {
|
|
880
|
-
ok: true,
|
|
881
|
-
dropped,
|
|
882
|
-
envelope: { ...head, activeAccountNumber: active ?? null, accounts },
|
|
883
|
-
};
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
/**
|
|
887
|
-
* One account, or several, packaged for another deck.
|
|
888
|
-
*
|
|
889
|
-
* claude-swap's envelope carries each account's OAuth token in the clear - its
|
|
890
|
-
* own module header says so ("No encryption is built in"). The wrapper adds an
|
|
891
|
-
* expiry so a copy left behind in clipboard history stops working, and nothing
|
|
892
|
-
* more: it is not encryption and is not presented as any. A bundle makes that
|
|
893
|
-
* larger, not different - five accounts is five tokens on the clipboard -
|
|
894
|
-
* which is why the dialog states the count before the copy rather than after.
|
|
895
|
-
*
|
|
896
|
-
* Say the limit of the expiry out loud, because the UI used to imply the
|
|
897
|
-
* opposite. `exp` is a plain number inside plain base64'd JSON with NO key, MAC
|
|
898
|
-
* or signature over it, so anyone holding the text can decode it, write a later
|
|
899
|
-
* `exp`, re-encode, and import it. unwrapShare's check is therefore a check
|
|
900
|
-
* against staleness, not against an adversary - and it cannot be made into one
|
|
901
|
-
* here. A MAC needs a secret both decks hold, and two decks that already shared
|
|
902
|
-
* a secret would not need this function; and even a perfect signature would
|
|
903
|
-
* only stop THIS import path, since the payload it wraps is the credential
|
|
904
|
-
* itself and `cswap import` accepts it unwrapped. The honest fix for a share
|
|
905
|
-
* that got away is to sign the account out and back in. See share-expiry-
|
|
906
|
-
* forgeable.test.ts, which pins the forgery rather than leaving it implied.
|
|
907
|
-
*
|
|
908
|
-
* Only the accounts asked for are exported. Reading the whole store and then
|
|
909
|
-
* dropping the unwanted rows would be one spawn instead of several, and it
|
|
910
|
-
* would pull a refresh token this deck was never asked to move into this
|
|
911
|
-
* process; it would also lose the failure, because a whole-store export skips a
|
|
912
|
-
* slot with no backup credentials in silence while `--account` on that slot is
|
|
913
|
-
* a hard error naming it. A count that is quietly short is the one outcome a
|
|
914
|
-
* share must never have.
|
|
915
|
-
*
|
|
916
|
-
* The default export shape is used deliberately, never --full, which would
|
|
917
|
-
* embed the entire ~/.claude.json including every project and MCP server.
|
|
918
|
-
*/
|
|
919
|
-
export async function shareAccounts(nums) {
|
|
920
|
-
const asked = Array.isArray(nums) ? nums : [nums];
|
|
921
|
-
// One spawn per account, so the length of this list is a length of time the
|
|
922
|
-
// request holds. A store never has fifty accounts; a caller that sends nine
|
|
923
|
-
// hundred numbers is not a person picking from a panel, and the ceiling
|
|
924
|
-
// costs nothing to the one who is.
|
|
925
|
-
if (asked.length > MAX_SHARE_ACCOUNTS) return { ok: false, reason: "too_many" };
|
|
926
|
-
const wanted = [];
|
|
927
|
-
for (const raw of asked) {
|
|
928
|
-
const n = Number(raw);
|
|
929
|
-
if (!Number.isInteger(n) || n < 1 || n > 999) return { ok: false, reason: "bad_account" };
|
|
930
|
-
if (!wanted.includes(n)) wanted.push(n);
|
|
931
|
-
}
|
|
932
|
-
if (!wanted.length) return { ok: false, reason: "bad_account" };
|
|
933
|
-
|
|
934
|
-
// Names for the accounts that fail, read from the store before anything is
|
|
935
|
-
// spawned - because the failure sentence must never be built from the
|
|
936
|
-
// export's own output.
|
|
937
|
-
const store = await readStore();
|
|
938
|
-
const texts = [];
|
|
939
|
-
const failed = [];
|
|
940
|
-
for (const n of wanted) {
|
|
941
|
-
const r = await run(await cswapBin(), ["export", "-", "--account", String(n)], { timeout: CSWAP_TIMEOUT_MS });
|
|
942
|
-
if (!r.ok || !r.stdout.trim()) {
|
|
943
|
-
// The failure sentence is built from stderr ALONE for this one command,
|
|
944
|
-
// because its stdout is the credential. `failureText` concatenates
|
|
945
|
-
// `${stderr}\n${stdout}` and `firstUseful` takes the LAST non-empty line -
|
|
946
|
-
// right for every other cswap command, and here it means any stdout at all
|
|
947
|
-
// outranks the real error. claude-swap writes its diagnostics to stderr
|
|
948
|
-
// specifically so stdout stays pure JSON in pipe mode, and it writes the
|
|
949
|
-
// envelope as its last act; a non-zero exit after a partial write would
|
|
950
|
-
// therefore put the tail of `json.dumps(envelope, indent=2)` in front of the
|
|
951
|
-
// user, and one of those lines is the refresh token on its own.
|
|
952
|
-
//
|
|
953
|
-
// Nothing is lost by dropping it: the ENOENT branch keys off `r.code`, which
|
|
954
|
-
// `run` sets, and cmd.exe's "is not recognized" is stderr's.
|
|
955
|
-
failed.push({
|
|
956
|
-
num: String(n),
|
|
957
|
-
email: store.emails?.[String(n)] || "",
|
|
958
|
-
detail: failureText({ ...r, stdout: "" }, "cswap export"),
|
|
959
|
-
});
|
|
960
|
-
continue;
|
|
961
|
-
}
|
|
962
|
-
texts.push(r.stdout);
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
// Nothing came out at all. There is no partial bundle to hand over, so this
|
|
966
|
-
// is the plain failure the single-account share has always reported.
|
|
967
|
-
if (!texts.length) {
|
|
968
|
-
return { ok: false, reason: "export_failed", detail: failed[0]?.detail ?? "", failed };
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
const merged = mergeExports(texts);
|
|
972
|
-
if (!merged.ok) return { ok: false, reason: merged.reason, failed };
|
|
973
|
-
for (const d of merged.dropped) {
|
|
974
|
-
failed.push({ ...d, detail: "another slot already holds this address in this organization" });
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
const shared = merged.envelope.accounts.map(a => ({ num: String(a?.number ?? ""), email: a?.email ?? "" }));
|
|
978
|
-
return {
|
|
979
|
-
ok: true,
|
|
980
|
-
blob: wrapShare(JSON.stringify(merged.envelope)),
|
|
981
|
-
expiresAt: Date.now() + SHARE_TTL_MS,
|
|
982
|
-
// What the bundle CARRIES, never what was asked for. The copy row counts
|
|
983
|
-
// this list, so a bundle that came up short says so.
|
|
984
|
-
shared,
|
|
985
|
-
failed,
|
|
986
|
-
};
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
/**
|
|
991
|
-
* Did anything actually arrive in the store?
|
|
992
|
-
*
|
|
993
|
-
* THREE STATES MEAN YES AND THEY ARE NOT INTERCHANGEABLE. `imported` is a slot
|
|
994
|
-
* that did not exist; `healed` and `updated` are one that did and was rewritten,
|
|
995
|
-
* which is what a forced import produces and what `added` — counting `imported`
|
|
996
|
-
* alone — reports as nothing having happened. A caller that asked for a replace
|
|
997
|
-
* and read `added` would conclude its own success had failed.
|
|
998
|
-
*
|
|
999
|
-
* `present` is the decline: claude-swap kept what it had. `failed` is the
|
|
1000
|
-
* refusal. Neither is an arrival.
|
|
1001
|
-
*/
|
|
1002
|
-
export function landed(results) {
|
|
1003
|
-
return (Array.isArray(results) ? results : [])
|
|
1004
|
-
.some(r => r?.state === "imported" || r?.state === "healed" || r?.state === "updated");
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
/**
|
|
1008
|
-
* The identities a bundle carries, or `[]` when it cannot be read.
|
|
1009
|
-
*
|
|
1010
|
-
* The payload is the credential, so this takes the two fields it needs and
|
|
1011
|
-
* nothing else: no caller ever receives the parsed envelope, and a bundle that
|
|
1012
|
-
* will not parse degrades to an unnamed import rather than to an error, since
|
|
1013
|
-
* cswap is the one entitled to refuse it.
|
|
1014
|
-
*/
|
|
1015
|
-
export function bundleAccounts(payload) {
|
|
1016
|
-
let env;
|
|
1017
|
-
try { env = JSON.parse(payload); } catch { return []; }
|
|
1018
|
-
if (!env || typeof env !== "object" || !Array.isArray(env.accounts)) return [];
|
|
1019
|
-
const out = [];
|
|
1020
|
-
for (const a of env.accounts) {
|
|
1021
|
-
if (!a || typeof a !== "object") continue;
|
|
1022
|
-
const email = typeof a.email === "string" ? a.email.trim() : "";
|
|
1023
|
-
if (!email) continue;
|
|
1024
|
-
out.push({ email, org: typeof a.organizationUuid === "string" ? a.organizationUuid : "" });
|
|
1025
|
-
}
|
|
1026
|
-
// All of them or none. `wanted` is what the result list counts against, so a
|
|
1027
|
-
// bundle read as three when it holds four reports "1 of 3 imported" about a
|
|
1028
|
-
// paste of four - the missing one arrives and is never named, and a non-empty
|
|
1029
|
-
// list keeps the store-diff fallback from running to catch it. claude-swap
|
|
1030
|
-
// itself refuses an envelope whose entry has no address, so this is a guard
|
|
1031
|
-
// against a shape neither project has today rather than a live case.
|
|
1032
|
-
return out.length === env.accounts.length ? out : [];
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
/**
|
|
1036
|
-
* The same bundle, cut down to one account.
|
|
1037
|
-
*
|
|
1038
|
-
* What "update anyway" sends. `--force` overwrites every account it matches, so
|
|
1039
|
-
* a forced import of the whole bundle would rewrite credentials the user never
|
|
1040
|
-
* pointed at; narrowing first is what keeps an overwrite a named act. Written
|
|
1041
|
-
* as a filter over the original envelope rather than a fresh one, for the same
|
|
1042
|
-
* reason mergeExports spreads its head: those fields belong to claude-swap.
|
|
1043
|
-
*/
|
|
1044
|
-
export function narrowBundle(payload, key) {
|
|
1045
|
-
let env;
|
|
1046
|
-
try { env = JSON.parse(payload); } catch { return { ok: false, reason: "corrupt" }; }
|
|
1047
|
-
if (!env || typeof env !== "object" || !Array.isArray(env.accounts)) return { ok: false, reason: "corrupt" };
|
|
1048
|
-
const accounts = env.accounts.filter(a => identityKey(a?.email, a?.organizationUuid) === key);
|
|
1049
|
-
if (!accounts.length) return { ok: false, reason: "not_in_bundle" };
|
|
1050
|
-
const nums = new Set(accounts.map(a => String(a?.number ?? "")));
|
|
1051
|
-
const active = env.activeAccountNumber != null && nums.has(String(env.activeAccountNumber))
|
|
1052
|
-
? env.activeAccountNumber
|
|
1053
|
-
: null;
|
|
1054
|
-
return { ok: true, payload: JSON.stringify({ ...env, activeAccountNumber: active, accounts }) };
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
/**
|
|
1058
|
-
* What happened to each account in the bundle, decided by the store.
|
|
1059
|
-
*
|
|
1060
|
-
* The store is the fact. `cswap import` narrates itself per account on stderr,
|
|
1061
|
-
* and parsing that as the primary answer would make a reworded release report
|
|
1062
|
-
* imports that did not happen - the failure mode `newSlot` already refuses for
|
|
1063
|
-
* the same reason. So the slot map before and after the run decides the two
|
|
1064
|
-
* outcomes that matter: an identity holding a slot it did not hold arrived, and
|
|
1065
|
-
* one absent from both never came.
|
|
1066
|
-
*
|
|
1067
|
-
* stderr is then read for one thing only, and one no store diff can show: an
|
|
1068
|
-
* account already present whose credentials were REWRITTEN in place, which
|
|
1069
|
-
* moves no slot. `Replaced` is claude-swap's dead-token auto-heal (its #136),
|
|
1070
|
-
* `Overwrote` is a `--force`. If either line is ever reworded the nuance is
|
|
1071
|
-
* lost and the row reads "already here", which is still true - the degradation
|
|
1072
|
-
* is a less specific report, never a wrong one.
|
|
1073
|
-
*
|
|
1074
|
-
* And it is applied ONLY where the address names exactly one row in the bundle.
|
|
1075
|
-
* cswap's line carries no organization, so with one address held under two of
|
|
1076
|
-
* them a single `Replaced me@x.com` would mark both rows healed and one of
|
|
1077
|
-
* those would be false - which is the thing the paragraph above promises this
|
|
1078
|
-
* never does.
|
|
1079
|
-
*/
|
|
1080
|
-
export function importOutcomes(before, after, wanted, stderr = "") {
|
|
1081
|
-
const slotsBy = (store) => new Map(
|
|
1082
|
-
(store?.slots ?? []).map(s => [identityKey(store?.emails?.[s], store?.orgs?.[s]), s]),
|
|
1083
|
-
);
|
|
1084
|
-
const had = slotsBy(before);
|
|
1085
|
-
const now = slotsBy(after);
|
|
1086
|
-
|
|
1087
|
-
// How many rows in this bundle share each address. An address held twice is
|
|
1088
|
-
// an address cswap's own narration cannot resolve.
|
|
1089
|
-
const byAddress = new Map();
|
|
1090
|
-
for (const w of wanted) {
|
|
1091
|
-
const a = String(w.email ?? "").trim().toLowerCase();
|
|
1092
|
-
byAddress.set(a, (byAddress.get(a) ?? 0) + 1);
|
|
1093
|
-
}
|
|
1094
|
-
const rewritten = new Map();
|
|
1095
|
-
for (const line of String(stderr ?? "").split(/\r?\n/)) {
|
|
1096
|
-
const m = /^\s*(Replaced|Overwrote)\s+(\S+)/.exec(line);
|
|
1097
|
-
if (!m) continue;
|
|
1098
|
-
const addr = String(m[2]).trim().toLowerCase();
|
|
1099
|
-
if ((byAddress.get(addr) ?? 0) !== 1) continue;
|
|
1100
|
-
rewritten.set(addr, m[1] === "Replaced" ? "healed" : "updated");
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
return wanted.map(w => {
|
|
1104
|
-
const key = identityKey(w.email, w.org);
|
|
1105
|
-
const wasHere = had.has(key);
|
|
1106
|
-
const slot = now.get(key) ?? null;
|
|
1107
|
-
if (!wasHere && slot != null) return { email: w.email, org: w.org, num: slot, state: "imported" };
|
|
1108
|
-
if (wasHere) {
|
|
1109
|
-
return {
|
|
1110
|
-
email: w.email,
|
|
1111
|
-
org: w.org,
|
|
1112
|
-
num: had.get(key),
|
|
1113
|
-
state: rewritten.get(String(w.email ?? "").trim().toLowerCase()) ?? "present",
|
|
1114
|
-
};
|
|
1115
|
-
}
|
|
1116
|
-
return { email: w.email, org: w.org, num: null, state: "failed" };
|
|
1117
|
-
});
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
/**
|
|
1121
|
-
* A share, or a bundle of them, taken into this deck's store.
|
|
1122
|
-
*
|
|
1123
|
-
* Non-destructive by default and deliberately so: without `--force` claude-swap
|
|
1124
|
-
* adds what is missing, leaves a healthy account exactly as it is, and replaces
|
|
1125
|
-
* only a slot its own usage row has quarantined as refresh-token-dead. That is
|
|
1126
|
-
* already the rule a person would ask for - leave what works, fix what does
|
|
1127
|
-
* not - so the default run never passes the flag.
|
|
1128
|
-
*
|
|
1129
|
-
* `force` is honoured ONLY together with `only`, which names a single account.
|
|
1130
|
-
* A forced import of a whole bundle would rewrite every matching credential on
|
|
1131
|
-
* this machine, and a fresh token replaced by a stale one is not recoverable
|
|
1132
|
-
* from here - the fix is a re-login. Requiring the pair is what makes the
|
|
1133
|
-
* clobber something a person chose while looking at the address.
|
|
1134
|
-
*/
|
|
1135
|
-
export async function importAccount(blob, { force = false, only = null } = {}) {
|
|
1136
|
-
const un = unwrapShare(blob);
|
|
1137
|
-
if (!un.ok) return { ok: false, reason: un.reason };
|
|
1138
|
-
|
|
1139
|
-
let payload = un.payload;
|
|
1140
|
-
const narrowing = only != null;
|
|
1141
|
-
if (narrowing) {
|
|
1142
|
-
const cut = narrowBundle(payload, identityKey(only?.email, only?.org));
|
|
1143
|
-
if (!cut.ok) return { ok: false, reason: cut.reason };
|
|
1144
|
-
payload = cut.payload;
|
|
1145
|
-
}
|
|
1146
|
-
const overwrite = force === true && narrowing;
|
|
1147
|
-
const wanted = bundleAccounts(payload);
|
|
1148
|
-
|
|
1149
|
-
return withStoreLock(async () => {
|
|
1150
|
-
const before = await readStore();
|
|
1151
|
-
const args = overwrite ? ["import", "-", "--force"] : ["import", "-"];
|
|
1152
|
-
const child = runInteractive(await cswapBin(), args, { timeout: CSWAP_TIMEOUT_MS });
|
|
1153
|
-
child.write(payload);
|
|
1154
|
-
// cswap reads stdin to EOF, so the pipe has to close for it to proceed.
|
|
1155
|
-
//
|
|
1156
|
-
// This used to write a raw EOT byte and then call `endStdin(child)` - a
|
|
1157
|
-
// helper that was never written. EOT only means end-of-file on a TTY, so
|
|
1158
|
-
// the byte did nothing to a pipe, and the call threw ReferenceError before
|
|
1159
|
-
// cswap ever saw the payload: the route answered 500 and the dialog fell
|
|
1160
|
-
// back to "the import failed", which is exactly what a genuinely refused
|
|
1161
|
-
// import says. Reported 2026-08-14.
|
|
1162
|
-
child.end();
|
|
1163
|
-
|
|
1164
|
-
const r = await child.done;
|
|
1165
|
-
if (!r.ok) return { ok: false, reason: "import_failed", detail: failureText(r, "cswap import") };
|
|
1166
|
-
|
|
1167
|
-
const after = await readStore();
|
|
1168
|
-
invalidateClaudeAccountsCache();
|
|
1169
|
-
|
|
1170
|
-
// An unreadable envelope was still imported, or still refused, by cswap -
|
|
1171
|
-
// only the naming is lost. Fall back to the store's own new slots so the
|
|
1172
|
-
// dialog can say what arrived even then.
|
|
1173
|
-
const results = wanted.length
|
|
1174
|
-
? importOutcomes(before, after, wanted, r.stderr)
|
|
1175
|
-
: (() => {
|
|
1176
|
-
const had = new Set(before.slots ?? []);
|
|
1177
|
-
return (after.slots ?? []).filter(s => !had.has(s))
|
|
1178
|
-
.map(s => ({ email: after.emails?.[s] || "", org: after.orgs?.[s] || "", num: s, state: "imported" }));
|
|
1179
|
-
})();
|
|
1180
|
-
|
|
1181
|
-
const arrived = results.filter(x => x.state === "imported");
|
|
1182
|
-
if (arrived.length) runDetached(await cswapBin(), ["list"]);
|
|
1183
|
-
return {
|
|
1184
|
-
ok: true,
|
|
1185
|
-
results,
|
|
1186
|
-
// Nothing new is not an error: without --force, cswap skips an account it
|
|
1187
|
-
// already holds. Saying which happened is the difference between "it
|
|
1188
|
-
// worked" and "why is nothing different".
|
|
1189
|
-
added: arrived.length > 0,
|
|
1190
|
-
num: arrived.length === 1 ? arrived[0].num : null,
|
|
1191
|
-
email: arrived.length === 1 ? arrived[0].email : null,
|
|
1192
|
-
output: firstUseful(r.stdout),
|
|
1193
|
-
};
|
|
1194
|
-
});
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
// ── the small edits ──────────────────────────────────────────────────────────
|
|
1198
|
-
|
|
1199
|
-
// The exact question `cswap remove` asks. There is no --yes flag: assume_yes is
|
|
1200
|
-
// a Python parameter its TUI passes in-process, so the only way through from a
|
|
1201
|
-
// CLI is to answer. Matched, never assumed — an unrecognised prompt gets the
|
|
1202
|
-
// child killed instead of a blind "y".
|
|
1203
|
-
const REMOVE_PROMPT = /are you sure you want to permanently remove account-(\d+)/i;
|
|
1204
|
-
|
|
1205
|
-
export function removePromptMatches(line, num) {
|
|
1206
|
-
const m = REMOVE_PROMPT.exec(stripTerminalEscapes(line));
|
|
1207
|
-
return Boolean(m) && m[1] === String(num);
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
|
-
/**
|
|
1211
|
-
* Re-capture the active slot's credentials, which is what unfreezes a row whose
|
|
1212
|
-
* stored copy died.
|
|
1213
|
-
*
|
|
1214
|
-
* WHY THIS EXISTS AND WHY IT IS NOT A LOGIN. claude-swap keeps its own copy of
|
|
1215
|
-
* each account's credentials, taken when the slot was added. When that copy's
|
|
1216
|
-
* refresh token dies it quarantines the row: no further collection is
|
|
1217
|
-
* attempted, so the numbers freeze and every Refresh re-reads a store that
|
|
1218
|
-
* cannot change. #721 fixed the sentence the panel said about that state; this
|
|
1219
|
-
* is what ends it — started by the deck itself (autoRecapture, below) the
|
|
1220
|
-
* moment a read finds the state, and still reachable from the admin route.
|
|
1221
|
-
*
|
|
1222
|
-
* `cswap add` on an account already in the store is an idempotent credential
|
|
1223
|
-
* refresh — registerSignedIn above says so in its own words: "No new slot means
|
|
1224
|
-
* the account was already managed and cswap refreshed its credentials in
|
|
1225
|
-
* place." It captures whatever is signed in RIGHT NOW, with the working
|
|
1226
|
-
* credentials the user already has. Nobody is signed in or out, no browser
|
|
1227
|
-
* opens, and the active account does not change.
|
|
1228
|
-
*
|
|
1229
|
-
* WHICH IS WHY IT ASKS WHO THAT IS FIRST. "Whatever is signed in" is the active
|
|
1230
|
-
* slot's account only while the two agree. Somebody who signed in as another
|
|
1231
|
-
* account in a terminal has made it a different one, and `cswap add` would then
|
|
1232
|
-
* refresh — or add — that account instead. Behind a press that was a narrow
|
|
1233
|
-
* race; with the deck starting it on its own it is the one thing that must be
|
|
1234
|
-
* ruled out. So the capture runs only when the CLI's account is the active
|
|
1235
|
-
* slot's, and, when the caller names an account, that one too.
|
|
1236
|
-
*/
|
|
1237
|
-
export async function recaptureActive({ expect = null } = {}) {
|
|
1238
|
-
return withStoreLock(async () => {
|
|
1239
|
-
// Who is signed in right now: `cswap add` captures THAT, whoever it is.
|
|
1240
|
-
const before = await currentIdentity();
|
|
1241
|
-
if (!before?.email) return { ok: false, reason: "not_signed_in" };
|
|
1242
|
-
const who = before.email.toLowerCase();
|
|
1243
|
-
const store = await readStore();
|
|
1244
|
-
const activeNum = String(store.activeNum);
|
|
1245
|
-
const activeEmail = String(store.emails[activeNum] ?? "").toLowerCase();
|
|
1246
|
-
// One address under two organizations is two accounts to claude-swap, so a
|
|
1247
|
-
// matching email in another org is still somebody else's slot.
|
|
1248
|
-
const sameOrg = !before.orgId || !store.orgs[activeNum] || before.orgId === store.orgs[activeNum];
|
|
1249
|
-
if (who !== activeEmail || !sameOrg || (expect != null && who !== String(expect).toLowerCase())) {
|
|
1250
|
-
return { ok: false, reason: "not_active_account", email: before.email };
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
|
-
const add = await run(await cswapBin(), ["add"], { timeout: CSWAP_TIMEOUT_MS });
|
|
1254
|
-
if (!add.ok) return { ok: false, reason: "add_failed", error: addFailureText(add) };
|
|
1255
|
-
|
|
1256
|
-
invalidateClaudeAccountsCache();
|
|
1257
|
-
// AWAITED, NOT DETACHED, AND THAT IS THE WHOLE DIFFERENCE THE PRESS MAKES.
|
|
1258
|
-
// `cswap add` clears the strike instantly, so a detached collection left a
|
|
1259
|
-
// window where the badge was gone but the numbers were still twenty hours
|
|
1260
|
-
// old and the row still said "due" in amber — a press that looked like it
|
|
1261
|
-
// had done nothing, which is the complaint this button exists to answer.
|
|
1262
|
-
// Waiting costs a few seconds and returns a row that has actually moved.
|
|
1263
|
-
//
|
|
1264
|
-
// Its failure is not the press's failure: the credentials are captured
|
|
1265
|
-
// either way, and claude-swap's own schedule will collect within minutes.
|
|
1266
|
-
// So a timeout here still reports success, with `collected: false` for a
|
|
1267
|
-
// caller that wants to say so.
|
|
1268
|
-
const collect = await run(await cswapBin(), ["list"], { timeout: CSWAP_TIMEOUT_MS })
|
|
1269
|
-
.catch(() => null);
|
|
1270
|
-
invalidateClaudeAccountsCache();
|
|
1271
|
-
return { ok: true, email: before.email, collected: collect?.ok === true };
|
|
1272
|
-
});
|
|
1273
|
-
}
|
|
1274
|
-
|
|
1275
|
-
// ── the same repair, without the press ──────────────────────────────────────
|
|
1276
|
-
|
|
1277
|
-
/**
|
|
1278
|
-
* How long one account is left alone after the deck re-captured it on its own.
|
|
1279
|
-
*
|
|
1280
|
-
* Ten minutes: past claude-swap's longest ordinary poll interval, so a capture
|
|
1281
|
-
* that took has been collected before this could fire again, and one that did
|
|
1282
|
-
* not — a collector that still cannot read the login — costs one `claude auth
|
|
1283
|
-
* status` and one `cswap add` per ten minutes rather than one per panel poll.
|
|
1284
|
-
*/
|
|
1285
|
-
const AUTO_RECAPTURE_RETRY_MS = 10 * 60_000;
|
|
1286
|
-
|
|
1287
|
-
/** Emails whose automatic attempt is under way. Per account rather than one
|
|
1288
|
-
* flag: after an auto-switch a second account can pause while the first's
|
|
1289
|
-
* attempt still waits on the store lock, and it must not borrow that attempt's
|
|
1290
|
-
* `running`. The lock serialises the attempts themselves. */
|
|
1291
|
-
const _autoRunning = new Set();
|
|
1292
|
-
/** Lower-cased email → the last automatic attempt: when, and why it failed. */
|
|
1293
|
-
const _autoTried = new Map();
|
|
1294
|
-
|
|
1295
|
-
/**
|
|
1296
|
-
* What the deck's own re-capture of a paused account should do now, given its
|
|
1297
|
-
* last attempt. Pure; exported for tests.
|
|
1298
|
-
*
|
|
1299
|
-
* running an attempt is under way
|
|
1300
|
-
* start none has been made, or the last is old enough to try again
|
|
1301
|
-
* failed one was made inside the window and the row is still paused;
|
|
1302
|
-
* `reason` is why it failed, or null when the capture itself held
|
|
1303
|
-
*/
|
|
1304
|
-
export function autoRecaptureState(last, now, { inFlight = false, retryMs = AUTO_RECAPTURE_RETRY_MS } = {}) {
|
|
1305
|
-
if (inFlight) return { state: "running" };
|
|
1306
|
-
if (!last || now - last.at >= retryMs) return { state: "start" };
|
|
1307
|
-
return { state: "failed", reason: last.reason ?? null, retryAt: last.at + retryMs };
|
|
1308
|
-
}
|
|
1309
|
-
|
|
1310
|
-
/**
|
|
1311
|
-
* Re-capture a paused account because a read found it paused — the repair the
|
|
1312
|
-
* `resume` button used to wait for.
|
|
1313
|
-
*
|
|
1314
|
-
* Synchronous for its caller, which is building a roster row: it starts the
|
|
1315
|
-
* attempt and returns the state to draw. At most one attempt per account under
|
|
1316
|
-
* way and one per AUTO_RECAPTURE_RETRY_MS, always for the account the row names,
|
|
1317
|
-
* which recaptureActive checks against who is signed in inside the store lock.
|
|
1318
|
-
*/
|
|
1319
|
-
export function autoRecapture({ email, now = Date.now() } = {}) {
|
|
1320
|
-
const key = String(email ?? "").trim().toLowerCase();
|
|
1321
|
-
if (!key) return null;
|
|
1322
|
-
const next = autoRecaptureState(_autoTried.get(key), now, { inFlight: _autoRunning.has(key) });
|
|
1323
|
-
if (next.state !== "start") return next;
|
|
1324
|
-
_autoRunning.add(key);
|
|
1325
|
-
_autoTried.set(key, { at: now, reason: null });
|
|
1326
|
-
recaptureActive({ expect: key })
|
|
1327
|
-
.then(
|
|
1328
|
-
r => { if (!r?.ok) _autoTried.set(key, { at: now, reason: r?.reason ?? "failed" }); },
|
|
1329
|
-
() => { _autoTried.set(key, { at: now, reason: "failed" }); },
|
|
1330
|
-
)
|
|
1331
|
-
// And the roster forgets the row it drew as `running`, so the next read —
|
|
1332
|
-
// forced or not — says how the attempt went, instead of the cache or a
|
|
1333
|
-
// forced read's one-minute floor holding `resuming…` over a failure.
|
|
1334
|
-
.finally(() => { _autoRunning.delete(key); invalidateClaudeAccountsCache(); });
|
|
1335
|
-
return { state: "running" };
|
|
1336
|
-
}
|
|
1337
|
-
|
|
1338
|
-
export async function removeAccount(num) {
|
|
1339
|
-
const n = Number(num);
|
|
1340
|
-
if (!Number.isInteger(n) || n < 1 || n > 999) return { ok: false, reason: "bad_account" };
|
|
1341
|
-
|
|
1342
|
-
return withStoreLock(async () => {
|
|
1343
|
-
const child = runInteractive(await cswapBin(), ["remove", String(n)], { timeout: CSWAP_TIMEOUT_MS });
|
|
1344
|
-
let answered = false;
|
|
1345
|
-
child.onLine((line) => {
|
|
1346
|
-
if (answered) return;
|
|
1347
|
-
if (removePromptMatches(line, n)) { answered = true; child.write("y\n"); }
|
|
1348
|
-
});
|
|
1349
|
-
const r = await child.done;
|
|
1350
|
-
invalidateClaudeAccountsCache();
|
|
1351
|
-
if (!r.ok) return { ok: false, reason: "remove_failed", detail: failureText(r, "cswap remove") };
|
|
1352
|
-
// Exit 0 without the prompt means cswap declined for its own reason — a
|
|
1353
|
-
// live session on that account, most often — and printed why.
|
|
1354
|
-
if (!answered) return { ok: false, reason: "not_confirmed", detail: firstUseful(r.stdout || r.stderr) };
|
|
1355
|
-
return { ok: true, output: firstUseful(r.stdout) };
|
|
1356
|
-
});
|
|
1357
|
-
}
|
|
1358
|
-
|
|
1359
|
-
/**
|
|
1360
|
-
* What an alias may be made of.
|
|
1361
|
-
*
|
|
1362
|
-
* Every other argument this module sends to cswap is an integer bounded to
|
|
1363
|
-
* 1..999; the alias was the one free-text field, and `.trim()` was the whole of
|
|
1364
|
-
* its validation. That is fine on POSIX, where `run` spawns the argument vector
|
|
1365
|
-
* untouched, and not fine on Windows: cswap is a `.cmd` shim there, so the
|
|
1366
|
-
* vector goes through `cmd.exe /d /s /c` (see viaCmd). Quote-doubling handles
|
|
1367
|
-
* `"` and every other metacharacter, which leaves exactly the residual exec.mjs
|
|
1368
|
-
* documents — `%VAR%` expands inside quotes and a command line has no escape for
|
|
1369
|
-
* it — so `%USERPROFILE%` in an alias stored the user's home path, and an alias
|
|
1370
|
-
* carrying an unbalanced quote plus an `&` could end the quoted region early.
|
|
1371
|
-
* An interior newline survived `.trim()` untouched as well.
|
|
1372
|
-
*
|
|
1373
|
-
* The same allowlist discipline cswap-auto.mjs already applies to its model
|
|
1374
|
-
* list, and it closes the unbounded-length half too: an alias is a short name
|
|
1375
|
-
* shown instead of an email, so 64 characters is not a constraint anyone meets
|
|
1376
|
-
* by accident.
|
|
1377
|
-
*
|
|
1378
|
-
* The leading `(?!-)` is the half that allowlist missed, and it is not about
|
|
1379
|
-
* quoting at all — it is about ARGV POSITION, which no amount of quoting fixes
|
|
1380
|
-
* because the value arrives intact and is then read as syntax by the CHILD.
|
|
1381
|
-
* `-` is in the character class, so `--unset` matched, and
|
|
1382
|
-
* `setAlias(3, "--unset")` built ["alias", "3", "--unset"] — character for
|
|
1383
|
-
* character claude-swap's own command for CLEARING an alias. Its `_alias_command`
|
|
1384
|
-
* hands that vector to argparse, which sets `unset=True` and leaves `alias_name`
|
|
1385
|
-
* as None; the store dropped the name, cswap printed "Removed alias for
|
|
1386
|
-
* Account 3", exited 0, and the deck reported the rename as a success. Any other
|
|
1387
|
-
* `-x` spelling is consumed the same way — `-h` prints help and exits 0, which
|
|
1388
|
-
* also arrives here as a rename that worked.
|
|
1389
|
-
*
|
|
1390
|
-
* argparse does honour `--` as an end-of-options separator, so
|
|
1391
|
-
* ["alias", "3", "--", "--unset"] would reach `set_alias` as data. It is
|
|
1392
|
-
* deliberately not used: the separator only helps for the one child whose parser
|
|
1393
|
-
* we can read, `claude auth login` is the other spawn on this route and its
|
|
1394
|
-
* parser is not ours to verify, and a value the deck refuses outright cannot be
|
|
1395
|
-
* mangled by a CLI that changes its mind later. The validator is the guard.
|
|
1396
|
-
*
|
|
1397
|
-
* Refusing a leading dash rather than requiring a leading alphanumeric is the
|
|
1398
|
-
* narrower rule, and it is the one the hazard actually describes: `.env` and
|
|
1399
|
-
* `_work` are ordinary positional arguments to every parser involved, while
|
|
1400
|
-
* `acme-corp` — the one dash-bearing name in alias-charset.test.ts's list of
|
|
1401
|
-
* names people use — keeps working because only the FIRST character is
|
|
1402
|
-
* constrained.
|
|
1403
|
-
*/
|
|
1404
|
-
const ALIAS_OK = /^(?!-)[A-Za-z0-9 ._-]{1,64}$/;
|
|
1405
|
-
|
|
1406
|
-
export async function setAlias(num, alias) {
|
|
1407
|
-
const n = Number(num);
|
|
1408
|
-
if (!Number.isInteger(n) || n < 1 || n > 999) return { ok: false, reason: "bad_account" };
|
|
1409
|
-
const clean = typeof alias === "string" ? alias.trim() : "";
|
|
1410
|
-
if (clean && !ALIAS_OK.test(clean)) return { ok: false, reason: "bad_value" };
|
|
1411
|
-
const args = clean ? ["alias", String(n), clean] : ["alias", String(n), "--unset"];
|
|
1412
|
-
return withStoreLock(async () => {
|
|
1413
|
-
const r = await run(await cswapBin(), args, { timeout: CSWAP_TIMEOUT_MS });
|
|
1414
|
-
invalidateClaudeAccountsCache();
|
|
1415
|
-
return r.ok
|
|
1416
|
-
? { ok: true, output: firstUseful(r.stdout) }
|
|
1417
|
-
: { ok: false, reason: "alias_failed", detail: failureText(r, "cswap alias") };
|
|
1418
|
-
});
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
|
-
/**
|
|
1422
|
-
* What a `cswap move` actually did, read out of the store rather than its words.
|
|
1423
|
-
*
|
|
1424
|
-
* The panel needs two facts to keep its manage block pointed at the account
|
|
1425
|
-
* the user opened it on: which slot that account ended up in, and whether a
|
|
1426
|
-
* second account was displaced to make room. `cswap move` does carry both, but
|
|
1427
|
-
* not in a line this code can take: a swap prints "Swapped Account 2 and
|
|
1428
|
-
* Account 3:" and then one indented roster line per account, and `firstUseful`
|
|
1429
|
-
* takes the LAST line of a command's output — right for every other cswap
|
|
1430
|
-
* command, and here it hands back "3: someone@example.com" instead of the
|
|
1431
|
-
* verdict. The store is the fact, for the same reason `newSlot` reads it after
|
|
1432
|
-
* an add rather than parsing what add printed.
|
|
1433
|
-
*
|
|
1434
|
-
* Claude-swap has three cases — the account is already there, the target slot
|
|
1435
|
-
* is free and it relocates, the target is occupied and the two trade places —
|
|
1436
|
-
* and all three are legible in what settled: the mover holds the target, and
|
|
1437
|
-
* on a swap the occupant holds the slot the mover left. `to` is null when the
|
|
1438
|
-
* account is not where it was sent, which is not a case that should happen and
|
|
1439
|
-
* is exactly why it is reported rather than assumed.
|
|
1440
|
-
*/
|
|
1441
|
-
export function moveOutcome(before, after, num, slot) {
|
|
1442
|
-
const from = String(num), to = String(slot);
|
|
1443
|
-
const mover = before.emails[from] || "";
|
|
1444
|
-
const occupant = before.slots.includes(to) ? (before.emails[to] || "") : null;
|
|
1445
|
-
// Identity by email wherever the store has one, occupancy alone where it
|
|
1446
|
-
// does not: a blank email cannot tell two accounts apart, and refusing to
|
|
1447
|
-
// answer on that account would be worse than answering from the slot.
|
|
1448
|
-
const landed = after.slots.includes(to) && (!mover || after.emails[to] === mover);
|
|
1449
|
-
const swapped = landed && from !== to && occupant !== null
|
|
1450
|
-
&& after.slots.includes(from) && (!occupant || after.emails[from] === occupant);
|
|
1451
|
-
return { from: Number(num), to: landed ? Number(slot) : null, swapped };
|
|
1452
|
-
}
|
|
1453
|
-
|
|
1454
|
-
export async function moveAccount(num, slot) {
|
|
1455
|
-
const n = Number(num), s = Number(slot);
|
|
1456
|
-
if (!Number.isInteger(n) || n < 1 || n > 999) return { ok: false, reason: "bad_account" };
|
|
1457
|
-
if (!Number.isInteger(s) || s < 1 || s > 999) return { ok: false, reason: "bad_slot" };
|
|
1458
|
-
return withStoreLock(async () => {
|
|
1459
|
-
// Read first: which slots were taken before the move is the only way to
|
|
1460
|
-
// know afterwards whether anyone was standing in the destination.
|
|
1461
|
-
const before = await readStore();
|
|
1462
|
-
const r = await run(await cswapBin(), ["move", String(n), String(s)], { timeout: CSWAP_TIMEOUT_MS });
|
|
1463
|
-
invalidateClaudeAccountsCache();
|
|
1464
|
-
if (!r.ok) return { ok: false, reason: "move_failed", detail: failureText(r, "cswap move") };
|
|
1465
|
-
return { ok: true, output: firstUseful(r.stdout), ...moveOutcome(before, await readStore(), n, s) };
|
|
1466
|
-
});
|
|
1467
|
-
}
|
|
1468
|
-
|
|
1469
|
-
// ── text ─────────────────────────────────────────────────────────────────────
|
|
1470
|
-
|
|
1471
|
-
/**
|
|
1472
|
-
* What went wrong, in a sentence the user can act on.
|
|
1473
|
-
*
|
|
1474
|
-
* The missing-tool case is singled out because its own output is useless: on
|
|
1475
|
-
* Windows it is cmd.exe's two-line "is not recognized …/operable program or
|
|
1476
|
-
* batch file.", and `firstUseful` — which takes the LAST line, correctly for
|
|
1477
|
-
* every other CLI — leaves the second half on screen by itself.
|
|
1478
|
-
*
|
|
1479
|
-
* The exit status goes to looksMissing beside the text (#552). This is the one
|
|
1480
|
-
* caller with no candidate spelling to compare against, so the shape rules alone
|
|
1481
|
-
* are all the TEXT can offer it — and on a non-English Windows the text says
|
|
1482
|
-
* nothing this recognises. The status does: 9009 is cmd.exe's "no such command"
|
|
1483
|
-
* in every language. Without it, a German user pressing "share…" got the last
|
|
1484
|
-
* line of a translated sentence instead of the sentence about PATH.
|
|
1485
|
-
*/
|
|
1486
|
-
export function failureText(r, what = "cswap", fallback = "") {
|
|
1487
|
-
const out = `${r?.stderr ?? ""}\n${r?.stdout ?? ""}`;
|
|
1488
|
-
const tool = String(what).split(" ")[0];
|
|
1489
|
-
if (r?.code === "ENOENT" || looksMissing(out, "", r?.code)) {
|
|
1490
|
-
return tool === "claude"
|
|
1491
|
-
? "the claude CLI could not be run: not on PATH. Set AGENTS_DECK_CLAUDE to its full path."
|
|
1492
|
-
: "cswap could not be run: not on PATH, and not in the places uv and pipx install to. Set AGENTS_DECK_CSWAP to its full path.";
|
|
1493
|
-
}
|
|
1494
|
-
// A run its deadline stopped has no exit status worth quoting and no last
|
|
1495
|
-
// line worth quoting either — whatever it had printed, it had not finished.
|
|
1496
|
-
// Asked for one anyway, this said "cswap export exited 0", a success code for
|
|
1497
|
-
// a command that never completed.
|
|
1498
|
-
if (r?.timedOut || r?.code === "ETIMEDOUT") return `${what} took too long and was stopped`;
|
|
1499
|
-
return diagnosis(r?.stderr) || diagnosis(r?.stdout) || fallback || `${what} exited ${r?.code}`;
|
|
1500
|
-
}
|
|
1501
|
-
|
|
1502
|
-
/**
|
|
1503
|
-
* A line that is NOT a diagnosis, however it reached us.
|
|
1504
|
-
*
|
|
1505
|
-
* Two shapes were being shown to users as the reason something failed (#708),
|
|
1506
|
-
* both out of `claude auth login`: the CLI's own "Login successful." — a
|
|
1507
|
-
* failure reason containing the word "successful" is not a diagnosis, it is a
|
|
1508
|
-
* dump — and "Paste code here if prompted >", the unterminated prompt it was
|
|
1509
|
-
* still sitting on, which says that something was ASKED and nothing about
|
|
1510
|
-
* anything going wrong.
|
|
1511
|
-
*/
|
|
1512
|
-
const NOT_A_DIAGNOSIS = /\bsuccess(?:ful|fully)?\b|[>?]\s*$/i;
|
|
1513
|
-
|
|
1514
|
-
/**
|
|
1515
|
-
* One stream's last useful line, if it is worth showing a person.
|
|
1516
|
-
*
|
|
1517
|
-
* Split per stream because the two are not equal: a CLI's diagnosis goes to
|
|
1518
|
-
* stderr and its ordinary progress chatter goes to stdout. Reading the LAST
|
|
1519
|
-
* line of the two concatenated — which is what this module did — handed stdout
|
|
1520
|
-
* the answer whenever it had written anything at all, so `claude auth login`
|
|
1521
|
-
* explained itself with the prompt it had printed rather than with the "Login
|
|
1522
|
-
* failed: …" it had put on stderr.
|
|
1523
|
-
*/
|
|
1524
|
-
function diagnosis(text) {
|
|
1525
|
-
const line = firstUseful(text);
|
|
1526
|
-
return line && !NOT_A_DIAGNOSIS.test(line) ? line : "";
|
|
1527
|
-
}
|
|
1528
|
-
|
|
1529
|
-
/** The line worth showing a user out of a CLI's output. */
|
|
1530
|
-
export function firstUseful(text) {
|
|
1531
|
-
const lines = stripTerminalEscapes(text)
|
|
1532
|
-
.split(/\r?\n/)
|
|
1533
|
-
.map(l => l.replace(/^Error:\s*/i, "").trim())
|
|
1534
|
-
.filter(l => l && !/^-+$/.test(l));
|
|
1535
|
-
return lines.length ? lines[lines.length - 1].slice(0, 300) : "";
|
|
1536
|
-
}
|
|
1537
|
-
|
|
1538
|
-
/**
|
|
1539
|
-
* `cswap add`'s failure, in the words most likely to be actionable.
|
|
1540
|
-
*
|
|
1541
|
-
* The Keychain case is singled out because it is the one a server hits and a
|
|
1542
|
-
* terminal does not: a process without a GUI session cannot read the login
|
|
1543
|
-
* keychain, so the credential read times out and the message alone
|
|
1544
|
-
* ("unreadable right now") does not say what to do.
|
|
1545
|
-
*/
|
|
1546
|
-
export function addFailureText(r) {
|
|
1547
|
-
const text = firstUseful(r.stderr || r.stdout);
|
|
1548
|
-
if (/keychain/i.test(text)) {
|
|
1549
|
-
return `${text} — start ${PRODUCT} from a Terminal window rather than a background service.`;
|
|
1550
|
-
}
|
|
1551
|
-
return failureText(r, "cswap add");
|
|
1552
|
-
}
|
|
1553
|
-
|
|
1554
|
-
async function waitFor(get, timeoutMs, stepMs = 100) {
|
|
1555
|
-
const until = Date.now() + timeoutMs;
|
|
1556
|
-
for (;;) {
|
|
1557
|
-
const v = get();
|
|
1558
|
-
if (v) return v;
|
|
1559
|
-
if (Date.now() >= until) return null;
|
|
1560
|
-
await new Promise(r => setTimeout(r, stepMs));
|
|
1561
|
-
}
|
|
1562
|
-
}
|