ccdeck 3.22.14 → 3.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -10
- package/THIRD_PARTY_NOTICES.md +123 -15
- package/bin/deck.js +264 -21
- package/dist/web/assets/BrowserWatchModal-DD6srgsk.js +1 -0
- package/dist/web/assets/UsageHistoryModal-zExZpRWU.js +1 -0
- package/dist/web/assets/index-Bf-qcZ6x.css +1 -0
- package/dist/web/assets/index-Dsca2TwS.js +296 -0
- package/dist/web/index.html +9 -5
- package/hook/hook.js +631 -99
- package/package.json +1 -1
- package/release-notes.json +62 -1
- package/src/server/activity.mjs +42 -4
- package/src/server/args.mjs +30 -4
- package/src/server/browser-history.mjs +164 -38
- package/src/server/browser-presence.mjs +60 -0
- package/src/server/browser-react.mjs +160 -7
- package/src/server/browser-watch-store.mjs +319 -17
- package/src/server/browser-watch.mjs +304 -60
- package/src/server/ccusage.mjs +20 -4
- package/src/server/claude-accounts.mjs +79 -34
- package/src/server/claude-identity.mjs +121 -0
- package/src/server/cswap-admin.mjs +160 -133
- package/src/server/cswap-auto.mjs +86 -7
- package/src/server/cswap-install.mjs +280 -43
- package/src/server/deck-home.mjs +1 -1
- package/src/server/deck-prefs.mjs +252 -36
- package/src/server/deck-probe.mjs +1 -1
- package/src/server/exec.mjs +84 -3
- package/src/server/global-install.mjs +13 -0
- package/src/server/index.mjs +1454 -182
- package/src/server/installer.mjs +136 -8
- package/src/server/lan-about.mjs +8 -3
- package/src/server/lan-engine.mjs +385 -51
- package/src/server/lan-socket.mjs +384 -57
- package/src/server/lan-sync.mjs +495 -22
- package/src/server/log-writer.mjs +575 -2
- package/src/server/login-service.mjs +31 -4
- package/src/server/open-url.mjs +22 -2
- package/src/server/output-watch.mjs +59 -8
- package/src/server/purge-key.mjs +135 -0
- package/src/server/quota.mjs +112 -7
- package/src/server/running-deck.mjs +15 -1
- package/src/server/self-update.mjs +98 -4
- package/src/server/session-recap.mjs +89 -0
- package/src/server/static-cache.mjs +67 -0
- package/src/server/stop-deck.mjs +2 -2
- package/src/server/store-lock.mjs +51 -0
- package/src/server/supervisor.mjs +1 -1
- package/src/server/system-metrics.mjs +143 -17
- package/dist/web/assets/index-BdYsAtN8.js +0 -274
- package/dist/web/assets/index-CgwmcBHv.css +0 -1
|
@@ -19,7 +19,23 @@ import { run, runDetached } from "./exec.mjs";
|
|
|
19
19
|
// The CLI identity oracle, already written and already trusted by the account
|
|
20
20
|
// admin routes. #721 needs the same answer, so it reuses the same function
|
|
21
21
|
// rather than shelling out a second way to ask one question.
|
|
22
|
-
|
|
22
|
+
//
|
|
23
|
+
// FROM claude-identity.mjs, NOT FROM cswap-admin.mjs. This one import was the
|
|
24
|
+
// single edge that closed the only static import cycle in src/server —
|
|
25
|
+
// cswap-admin.mjs imports this file back for `backupRoot`,
|
|
26
|
+
// `invalidateClaudeAccountsCache` and `verdictNow` — and a cycle is not merely
|
|
27
|
+
// untidy: two dynamic imports entering one concurrently are each handed the
|
|
28
|
+
// other module's half-built namespace, which has no exports on it at all. The
|
|
29
|
+
// boot wires `repairStaleCopyWith` across exactly that pair, and on CI it was
|
|
30
|
+
// handed two empty namespaces and wired nothing, silently. The oracle lives in
|
|
31
|
+
// a module that imports only leaves now, so both sides can reach it and neither
|
|
32
|
+
// closes a loop.
|
|
33
|
+
import { currentIdentity } from "./claude-identity.mjs";
|
|
34
|
+
// The one mutex, from the module that exists so that both halves of the
|
|
35
|
+
// accounts surface can reach it. Not from cswap-admin.mjs, which imports THIS
|
|
36
|
+
// file: the dependency has to go the other way, and a lock imported over a
|
|
37
|
+
// cycle is a lock that may not be there yet when a mutation wants it.
|
|
38
|
+
import { withStoreLock } from "./store-lock.mjs";
|
|
23
39
|
import { dirname, join } from "node:path";
|
|
24
40
|
import { existsSync } from "node:fs";
|
|
25
41
|
import { homedir, platform } from "node:os";
|
|
@@ -849,6 +865,20 @@ export function invalidateClaudeAccountsCache() {
|
|
|
849
865
|
* Claude Code's own lock files, in its order, with its staleness values, or it
|
|
850
866
|
* can interleave with Claude Code's token refresh and clobber it. cswap does
|
|
851
867
|
* that; a second implementation racing it would be worse than useless.
|
|
868
|
+
*
|
|
869
|
+
* AND IT TAKES THE DECK'S OWN MUTEX (#1039). Holding Claude Code's lock files
|
|
870
|
+
* says nothing about claude-swap's sequence.json, which `cswap switch` reads
|
|
871
|
+
* and writes with no file lock of its own — the same unlocked read-modify-write
|
|
872
|
+
* cswap-admin.mjs's header opens by explaining. `cancelLogin` already queues
|
|
873
|
+
* ITS `cswap switch` behind this lock and spells out why: running one beside an
|
|
874
|
+
* in-flight `cswap add` means whichever write lands second drops the other's
|
|
875
|
+
* record, so the account just signed in is registered and immediately lost, or
|
|
876
|
+
* the rotation is silently undone. This route is the same hazard with a click
|
|
877
|
+
* instead of a cancel, and it ran outside the lock entirely.
|
|
878
|
+
*
|
|
879
|
+
* The validation stays OUTSIDE the lock. A slot number that is not one is not a
|
|
880
|
+
* mutation and must not wait behind somebody else's sixty-second `cswap add`
|
|
881
|
+
* to be told so.
|
|
852
882
|
*/
|
|
853
883
|
export function switchClaudeAccount(accountNum) {
|
|
854
884
|
// Straight into an exec argument, so nothing but a slot number gets through.
|
|
@@ -859,13 +889,13 @@ export function switchClaudeAccount(accountNum) {
|
|
|
859
889
|
|
|
860
890
|
// Argument vector, never a shell — and resolved through exec.mjs so the
|
|
861
891
|
// Windows `.exe`/`.cmd` shim is found too.
|
|
862
|
-
return cswapBin()
|
|
892
|
+
return withStoreLock(() => cswapBin()
|
|
863
893
|
.then(bin => run(bin, ["switch", String(num)], { timeout: 30_000 }))
|
|
864
894
|
.then(r => {
|
|
865
895
|
if (r.ok) return { ok: true, output: r.stdout.trim() };
|
|
866
896
|
const reason = r.code === "ENOENT" ? "no_cswap" : r.killed ? "timeout" : "switch_failed";
|
|
867
897
|
return { ok: false, reason, output: (r.stderr || r.stdout).trim().slice(0, 500) };
|
|
868
|
-
});
|
|
898
|
+
}));
|
|
869
899
|
}
|
|
870
900
|
|
|
871
901
|
|
|
@@ -916,36 +946,51 @@ export async function seedFirstAccount() {
|
|
|
916
946
|
if (process.env.AGENTS_DECK_NO_INSTALL === "1") return { state: "skipped" };
|
|
917
947
|
if (existsSync(SEED_MARKER)) return { state: "already-tried" };
|
|
918
948
|
|
|
919
|
-
//
|
|
920
|
-
// a
|
|
921
|
-
//
|
|
922
|
-
//
|
|
923
|
-
//
|
|
924
|
-
//
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
//
|
|
931
|
-
//
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
949
|
+
// THE TEST AND THE WRITE ARE ONE CRITICAL SECTION (#1039). The whole guard
|
|
950
|
+
// below is a read of sequence.json — "only when the store holds no accounts
|
|
951
|
+
// at all, so nothing can be overwritten or reordered" — followed by a `cswap
|
|
952
|
+
// add` that assigns a slot as max+1 with no file lock of its own. Outside the
|
|
953
|
+
// mutex that pair is exactly the read-modify-write cswap-admin.mjs's header
|
|
954
|
+
// says every mutation must go through, and #796 is what it costs when the
|
|
955
|
+
// read and the write disagree about what is in the store: an add against a
|
|
956
|
+
// populated store, re-pointing activeAccountNumber with nothing here to put
|
|
957
|
+
// it back. This runs unawaited at boot, so there is no user to notice.
|
|
958
|
+
//
|
|
959
|
+
// The two refusals above stay outside: neither touches the store, and a deck
|
|
960
|
+
// that has already seeded must not wait behind a stranger's mutation to say
|
|
961
|
+
// so on every start.
|
|
962
|
+
return withStoreLock(async () => {
|
|
963
|
+
// Empty store only. A store with accounts in it is the user's, not ours, and
|
|
964
|
+
// a store we cannot parse is treated the same way.
|
|
965
|
+
// No file at all is the fresh install this exists for; a file that will not
|
|
966
|
+
// parse is a store in an unknown state. readJson answers null to both, so
|
|
967
|
+
// they are told apart before deciding, because they call for opposite
|
|
968
|
+
// decisions — seed, and keep well away.
|
|
969
|
+
const seqPath = join(backupRoot(), "sequence.json");
|
|
970
|
+
const before = existsSync(seqPath) ? accountCount(await readJson(seqPath)) : 0;
|
|
971
|
+
if (before > 0) return { state: "has-accounts", count: before };
|
|
972
|
+
if (before < 0) return { state: "unreadable-store" };
|
|
973
|
+
|
|
974
|
+
// Mark before running, not after: if `cswap add` half-succeeds or the process
|
|
975
|
+
// dies mid-way, the retry-forever loop is the worse outcome.
|
|
976
|
+
try {
|
|
977
|
+
await mkdir(dirname(SEED_MARKER), { recursive: true });
|
|
978
|
+
await writeFile(SEED_MARKER, new Date().toISOString());
|
|
979
|
+
} catch { /* best-effort — worst case it is attempted again */ }
|
|
980
|
+
|
|
981
|
+
const r = await run(await cswapBin(), ["add"], { timeout: 60_000 });
|
|
982
|
+
if (!r.ok) {
|
|
983
|
+
return { state: "failed", detail: (r.stderr || r.stdout).trim().slice(0, 200) };
|
|
984
|
+
}
|
|
941
985
|
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
986
|
+
invalidateClaudeAccountsCache();
|
|
987
|
+
const count = existsSync(seqPath) ? accountCount(await readJson(seqPath)) : 0;
|
|
988
|
+
if (count > 0) {
|
|
989
|
+
// Collect straight away. Otherwise the first thing the user sees is their
|
|
990
|
+
// account listed with "never fetched" beside it, waiting on a poll cycle
|
|
991
|
+
// for numbers that are the reason the panel exists.
|
|
992
|
+
runDetached(await cswapBin(), ["list"]);
|
|
993
|
+
}
|
|
994
|
+
return count > 0 ? { state: "added", count } : { state: "nothing-to-add" };
|
|
995
|
+
});
|
|
951
996
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Who the `claude` CLI says is signed in, and which `claude` binary answers.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS IS ITS OWN MODULE. It was in cswap-admin.mjs, and claude-accounts.mjs
|
|
4
|
+
// imported `currentIdentity` from there — the single edge that closed the only
|
|
5
|
+
// static import cycle in src/server, because cswap-admin.mjs imports
|
|
6
|
+
// claude-accounts.mjs back for `backupRoot`, `invalidateClaudeAccountsCache`
|
|
7
|
+
// and `verdictNow`.
|
|
8
|
+
//
|
|
9
|
+
// A cycle is not merely untidy here. Two dynamic imports that enter one
|
|
10
|
+
// concurrently are each handed the other module's HALF-BUILT namespace rather
|
|
11
|
+
// than waiting for it, and a half-built namespace has no exports on it at all.
|
|
12
|
+
// `startServer` wires cswap-admin's `autoRecapture` into claude-accounts'
|
|
13
|
+
// `repairStaleCopyWith` at boot, and on CI both namespaces arrived with zero
|
|
14
|
+
// keys: the call was a TypeError in a promise nothing awaited, so the repair
|
|
15
|
+
// was silently never wired while every test still passed. See
|
|
16
|
+
// boot-module-graph.test.ts, which now asserts that src/server has no cycles at
|
|
17
|
+
// all rather than trying to police the call sites of one.
|
|
18
|
+
//
|
|
19
|
+
// This file imports only leaves — exec.mjs and claude-dir.mjs have no relative
|
|
20
|
+
// imports of their own — so it can be reached from either side without closing
|
|
21
|
+
// anything. Both functions are re-exported from cswap-admin.mjs, which is where
|
|
22
|
+
// their callers and their tests already look for them.
|
|
23
|
+
import { pathLookup, run } from "./exec.mjs";
|
|
24
|
+
import { claudeCliCandidates } from "./claude-dir.mjs";
|
|
25
|
+
import { existsSync } from "node:fs";
|
|
26
|
+
import { homedir } from "node:os";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Which `claude` the account surface runs: the configured one, else the first
|
|
30
|
+
* candidate this machine actually has, else the bare name.
|
|
31
|
+
*
|
|
32
|
+
* WHY THIS IS NOT `AGENTS_DECK_CLAUDE ?? "claude"` ANY MORE (#570). That was
|
|
33
|
+
* the whole of this module's resolution, and it feeds every child the accounts
|
|
34
|
+
* panel starts — `claude auth status --json` for `currentIdentity`, and the
|
|
35
|
+
* `claude auth login` whose output the sign-in dialog reads a link out of. On a
|
|
36
|
+
* machine whose `claude` is at `~/.local/bin/claude` but whose deck was started
|
|
37
|
+
* from something that never sourced a shell rc — a LaunchAgent, a systemd user
|
|
38
|
+
* unit, pm2, a desktop shortcut — the bare name is an ENOENT, so the login
|
|
39
|
+
* child is dead within milliseconds, the flow reports `no_url`, and the dialog
|
|
40
|
+
* shows "the claude CLI could not be run: not on PATH. Set AGENTS_DECK_CLAUDE
|
|
41
|
+
* to its full path." That sentence is a real remedy and it is why this was a
|
|
42
|
+
* smaller bug than #553; it is still a request to spell out a path the deck had
|
|
43
|
+
* already found for itself, because `hasClaudeInstalled()` stat'ed that exact
|
|
44
|
+
* file at boot to decide this was a Claude machine, and since #553 the quota
|
|
45
|
+
* panel beside this one runs the same binary without being told anything.
|
|
46
|
+
*
|
|
47
|
+
* SO IT READS THE SAME LIST, ON THE SAME TERMS #553 SETTLED ON. The list is
|
|
48
|
+
* `claudeCliCandidates` in claude-dir.mjs, whose other two readers are
|
|
49
|
+
* `hasClaudeInstalled()` — the boot question this module's whole surface hangs
|
|
50
|
+
* off — and `quotaClaudeBin` in quota.mjs. This is the same question at a third
|
|
51
|
+
* site, so nothing here is decided again:
|
|
52
|
+
*
|
|
53
|
+
* - AGENTS_DECK_CLAUDE first, and it is the one thing that skips the list
|
|
54
|
+
* entirely. It is documented in the README as "full path to the `claude`
|
|
55
|
+
* CLI", it is what the failure message above tells people to set, and
|
|
56
|
+
* someone who set it has already been through this once — second-guessing
|
|
57
|
+
* them with a stat would be answering a question they have closed. An empty
|
|
58
|
+
* value reads as unset, the way `AGENTS_DECK_CSWAP` does in cswapBin.
|
|
59
|
+
* - Then the candidate list's own order, unchanged: PATH first on POSIX, the
|
|
60
|
+
* two known install directories first on Windows. Preferring a different
|
|
61
|
+
* copy would silently change which binary signs somebody in on every
|
|
62
|
+
* machine that has two, and a `claude auth login` that suddenly runs a
|
|
63
|
+
* different binary is a credential path, not a detail.
|
|
64
|
+
* - The bare name is only answered with when PATH actually holds it, and
|
|
65
|
+
* `pathLookup` is a yes/no gate rather than the path it found, so spawn's
|
|
66
|
+
* own resolution — and, on Windows, exec.mjs's PATHEXT walk, since `claude`
|
|
67
|
+
* there is `claude.exe` or `claude.cmd` and never the bare word — stays in
|
|
68
|
+
* charge of the PATH case exactly as before.
|
|
69
|
+
* - The absolute candidates are stat'ed only once PATH has come up empty, so
|
|
70
|
+
* the common case costs one stat rather than a directory walk. Against what
|
|
71
|
+
* follows it — a whole Claude Code process, and a browser sign-in a human
|
|
72
|
+
* is walking through — that is not a cost worth naming.
|
|
73
|
+
*
|
|
74
|
+
* Pure, with the platform, environment, home directory and existence check all
|
|
75
|
+
* parameters, so the Windows branch is checkable from the platforms this repo
|
|
76
|
+
* is actually developed on. Exported for that test rather than for a caller
|
|
77
|
+
* (#383): the real callers are `currentIdentity` below and cswap-admin.mjs's
|
|
78
|
+
* own `claudeBin`, and both take it with no arguments, which is the real
|
|
79
|
+
* machine's answer.
|
|
80
|
+
*/
|
|
81
|
+
export function adminClaudeBin(platform = process.platform, env = process.env,
|
|
82
|
+
home = homedir(), exists = existsSync) {
|
|
83
|
+
if (env.AGENTS_DECK_CLAUDE) return env.AGENTS_DECK_CLAUDE;
|
|
84
|
+
const sep = platform === "win32" ? "\\" : "/";
|
|
85
|
+
// process.env is case-insensitive on Windows; an injected plain object in a
|
|
86
|
+
// test is not, and %Path% is how the variable is actually spelled there.
|
|
87
|
+
const pathEnv = env.PATH ?? env.Path ?? env.path ?? "";
|
|
88
|
+
for (const c of claudeCliCandidates(platform, env, home)) {
|
|
89
|
+
if (c.includes(sep)) { if (exists(c)) return c; }
|
|
90
|
+
else if (pathLookup(c, platform, { pathEnv, exists })) return c;
|
|
91
|
+
}
|
|
92
|
+
// Nothing on PATH and nothing at any known install directory. The bare name
|
|
93
|
+
// is still the right last resort — POSIX `execvp` and cmd.exe's own search
|
|
94
|
+
// both deserve their turn at a layout no list here knows — and the ENOENT it
|
|
95
|
+
// produces is what failureText turns into the AGENTS_DECK_CLAUDE sentence.
|
|
96
|
+
return "claude";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Anthropic's own view of who is signed in. Null when it cannot be read.
|
|
101
|
+
*
|
|
102
|
+
* Exported for its test rather than for a caller (#383). Both callers are inside
|
|
103
|
+
* the login flow and neither can show what was parsed: `spawnLogin` keeps only
|
|
104
|
+
* `identity?.email` as the address to restore to, and `submitLoginCode` turns
|
|
105
|
+
* the whole thing into a pass/fail — a null there is the difference between a
|
|
106
|
+
* sign-in the deck accepts and one it reports as "signed in, but the claude CLI
|
|
107
|
+
* still reports nobody logged in". The success path, where the email read here
|
|
108
|
+
* is what matches the new credential to a cswap slot, is reachable only with a
|
|
109
|
+
* real signed-in CLI on the machine running the suite. See
|
|
110
|
+
* cswap-identity.test.ts.
|
|
111
|
+
*/
|
|
112
|
+
export async function currentIdentity() {
|
|
113
|
+
const r = await run(adminClaudeBin(), ["auth", "status", "--json"], { timeout: 20_000 });
|
|
114
|
+
if (!r.ok) return null;
|
|
115
|
+
try {
|
|
116
|
+
const j = JSON.parse(r.stdout);
|
|
117
|
+
return j?.loggedIn ? { email: j.email ?? "", orgId: j.orgId ?? "" } : null;
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -14,16 +14,19 @@
|
|
|
14
14
|
//
|
|
15
15
|
// And `cswap add` takes no lock while assigning the next slot as max+1. Two
|
|
16
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
|
|
18
|
-
// goes through one mutex.
|
|
19
|
-
|
|
17
|
+
// first account's record. Nothing upstream prevents it, so every mutation that
|
|
18
|
+
// touches the store goes through one mutex — the one in store-lock.mjs, which
|
|
19
|
+
// lives outside this file precisely so that the writers in claude-accounts.mjs
|
|
20
|
+
// and cswap-auto.mjs queue on the same lock rather than on one of their own.
|
|
20
21
|
import { existsSync } from "node:fs";
|
|
21
22
|
import { brotliCompressSync, brotliDecompressSync, constants as zlibConstants } from "node:zlib";
|
|
22
23
|
import { readFile } from "node:fs/promises";
|
|
23
24
|
import { homedir } from "node:os";
|
|
24
25
|
import { join } from "node:path";
|
|
25
26
|
import { looksMissing, pathLookup, run, runDetached, runInteractive } from "./exec.mjs";
|
|
26
|
-
import { backupRoot, invalidateClaudeAccountsCache } from "./claude-accounts.mjs";
|
|
27
|
+
import { backupRoot, invalidateClaudeAccountsCache, verdictNow } from "./claude-accounts.mjs";
|
|
28
|
+
import { withStoreLock } from "./store-lock.mjs";
|
|
29
|
+
import { adminClaudeBin, currentIdentity } from "./claude-identity.mjs";
|
|
27
30
|
import { claudeCliCandidates } from "./claude-dir.mjs";
|
|
28
31
|
import { cswapBin } from "./cswap-install.mjs";
|
|
29
32
|
import { PRODUCT } from "./brand.mjs";
|
|
@@ -83,35 +86,12 @@ const SHARE_MAX_BYTES = 2 << 20;
|
|
|
83
86
|
|
|
84
87
|
// ── serialization ────────────────────────────────────────────────────────────
|
|
85
88
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
|
|
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
|
-
}
|
|
89
|
+
// Re-exported rather than defined here, and rather than left here for this
|
|
90
|
+
// module's own callers to import from wherever. It IS the same function — one
|
|
91
|
+
// module instance, one chain — and the tests that reach for it through this
|
|
92
|
+
// module keep working, but the definition sits in store-lock.mjs so that the
|
|
93
|
+
// writers outside this file can queue on it too. See #1039.
|
|
94
|
+
export { withStoreLock };
|
|
115
95
|
|
|
116
96
|
// ── shared helpers ───────────────────────────────────────────────────────────
|
|
117
97
|
|
|
@@ -124,74 +104,19 @@ export function withStoreLock(fn) {
|
|
|
124
104
|
// the resolver. Reported from Windows on 2026-08-14.
|
|
125
105
|
|
|
126
106
|
/**
|
|
127
|
-
* Which `claude` the account surface runs
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
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.
|
|
107
|
+
* Which `claude` the account surface runs, and who it says is signed in.
|
|
108
|
+
*
|
|
109
|
+
* Both moved to claude-identity.mjs and are re-exported here, which is where
|
|
110
|
+
* every caller and every test already reaches for them. The move is not
|
|
111
|
+
* cosmetic: `currentIdentity` was the one thing claude-accounts.mjs imported
|
|
112
|
+
* from this file, and that import was the single edge closing the only static
|
|
113
|
+
* import cycle in src/server. Two concurrent dynamic imports entering that
|
|
114
|
+
* cycle were each handed the other module's half-built namespace — no exports
|
|
115
|
+
* on it at all — which is how the boot's stale-copy repair came to be wired to
|
|
116
|
+
* `undefined` on CI while every test passed. claude-identity.mjs imports only
|
|
117
|
+
* leaves, so it can be reached from either side without closing anything.
|
|
177
118
|
*/
|
|
178
|
-
export
|
|
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
|
-
}
|
|
119
|
+
export { adminClaudeBin, currentIdentity };
|
|
195
120
|
|
|
196
121
|
async function claudeBin() {
|
|
197
122
|
return adminClaudeBin();
|
|
@@ -230,29 +155,6 @@ export function newSlot(before, after) {
|
|
|
230
155
|
return fresh.length === 1 ? fresh[0] : null;
|
|
231
156
|
}
|
|
232
157
|
|
|
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
158
|
|
|
257
159
|
// ── login ────────────────────────────────────────────────────────────────────
|
|
258
160
|
|
|
@@ -328,8 +230,27 @@ let _starting = null;
|
|
|
328
230
|
|
|
329
231
|
export function loginState() {
|
|
330
232
|
if (!_login) return { state: "idle" };
|
|
331
|
-
const { state, url, error, account, expiresAt } = _login;
|
|
332
|
-
return {
|
|
233
|
+
const { state, url, error, account, expiresAt, restore } = _login;
|
|
234
|
+
return {
|
|
235
|
+
state, url: url ?? null, error: error ?? null, account: account ?? null, expiresAt: expiresAt ?? null,
|
|
236
|
+
// The sign-in's OTHER outcome, and the one it used to drop (#951). `true`
|
|
237
|
+
// until a restore has actually been attempted, because every state before
|
|
238
|
+
// that — awaiting_url, awaiting_code, registering — has moved nothing that
|
|
239
|
+
// needs putting back, and a dialog polling this must not flash a warning
|
|
240
|
+
// through the middle of an ordinary sign-in.
|
|
241
|
+
...restoreFields(restore),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The restore verdict, in the two fields a caller reads. Written once here so
|
|
246
|
+
* the login route, the cancel result and the dialog's type cannot drift. */
|
|
247
|
+
function restoreFields(restore) {
|
|
248
|
+
return {
|
|
249
|
+
restored: restore ? restore.restored : true,
|
|
250
|
+
activeAccount: restore && !restore.restored
|
|
251
|
+
? { num: restore.activeNum, email: restore.activeEmail }
|
|
252
|
+
: null,
|
|
253
|
+
};
|
|
333
254
|
}
|
|
334
255
|
|
|
335
256
|
/**
|
|
@@ -628,7 +549,11 @@ async function registerSignedIn(flow, identity) {
|
|
|
628
549
|
if (!add.ok) {
|
|
629
550
|
flow.state = "failed";
|
|
630
551
|
flow.error = addFailureText(add);
|
|
631
|
-
|
|
552
|
+
// Kept even on the branch that already failed: `cswap add` can fail
|
|
553
|
+
// having written a slot and moved the machine onto it, so this is the
|
|
554
|
+
// branch where the live login is MOST likely to have ended up somewhere
|
|
555
|
+
// the user did not choose.
|
|
556
|
+
flow.restore = await restoreActive(flow.previousActive);
|
|
632
557
|
return { ok: false, reason: "add_failed", ...loginState() };
|
|
633
558
|
}
|
|
634
559
|
|
|
@@ -638,7 +563,11 @@ async function registerSignedIn(flow, identity) {
|
|
|
638
563
|
// credentials in place. That is a success with a different sentence.
|
|
639
564
|
const num = slot ?? Object.keys(after.emails).find(k => after.emails[k] === identity.email) ?? null;
|
|
640
565
|
|
|
641
|
-
|
|
566
|
+
// Assigned, not discarded. `done` is still the right state — the account
|
|
567
|
+
// WAS added — but it is `done` with a qualification whenever this says the
|
|
568
|
+
// machine did not go back, and the dialog renders that qualification
|
|
569
|
+
// instead of "The account you were using is still active".
|
|
570
|
+
flow.restore = await restoreActive(flow.previousActive);
|
|
642
571
|
invalidateClaudeAccountsCache();
|
|
643
572
|
// Collect straight away, so the new row shows numbers instead of "never
|
|
644
573
|
// collected" until the next poll — the same nudge seedFirstAccount uses.
|
|
@@ -720,21 +649,66 @@ export async function cancelLogin() {
|
|
|
720
649
|
return withStoreLock(async () => {
|
|
721
650
|
// The login may have completed before the cancel arrived, in which case the
|
|
722
651
|
// live credentials already moved and putting them back is the point.
|
|
723
|
-
await restoreActive(flow.previousActive);
|
|
652
|
+
const restore = await restoreActive(flow.previousActive);
|
|
724
653
|
invalidateClaudeAccountsCache();
|
|
725
654
|
// A newer sign-in can have started while this waited its turn; clearing the
|
|
726
655
|
// slot then would drop a live flow's handle instead of this dead one's.
|
|
727
656
|
if (flow === _login) _login = null;
|
|
728
|
-
|
|
657
|
+
// The verdict goes on the RESULT, not through loginState(): the line above
|
|
658
|
+
// has just cleared `_login`, so loginState() is `{state:"idle"}` here and
|
|
659
|
+
// would carry nothing. Escape is a real way to end up with the credentials
|
|
660
|
+
// moved — the sign-in may have completed before the cancel arrived, which
|
|
661
|
+
// is why this calls restoreActive at all — so the answer has to survive.
|
|
662
|
+
// …and LAST, so that on the rare path where a newer sign-in has already
|
|
663
|
+
// taken `_login`, this cancel still answers about the account IT moved
|
|
664
|
+
// rather than about the fresh flow's untouched one.
|
|
665
|
+
return { ok: true, ...loginState(), ...restoreFields(restore) };
|
|
729
666
|
});
|
|
730
667
|
}
|
|
731
668
|
|
|
732
|
-
/**
|
|
669
|
+
/** Nothing to put back, or it is already back: the shape every caller reads. */
|
|
670
|
+
const RESTORED = Object.freeze({ restored: true, activeNum: null, activeEmail: null, detail: null });
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Put the account that was active before the login back in front — and ANSWER
|
|
674
|
+
* whether it went back (#951).
|
|
675
|
+
*
|
|
676
|
+
* This used to return `undefined` on every path, under a `.catch(() => {})`
|
|
677
|
+
* that could not fire. `run` is resolve-only (exec.mjs), so a non-zero exit, a
|
|
678
|
+
* timeout and a binary that is not there all arrive as `{ok:false, …}` rather
|
|
679
|
+
* than as a rejection; the catch was guarding against an exception that does
|
|
680
|
+
* not exist, and the result it was attached to was not assigned to anything.
|
|
681
|
+
* So a `cswap switch` that failed — a dead refresh token for the old slot is
|
|
682
|
+
* the ordinary way, and `authTrouble` exists to name that state — left every
|
|
683
|
+
* caller walking on to `flow.state = "done"` with no way to tell.
|
|
684
|
+
*
|
|
685
|
+
* What that costs is not a missing log line. `cswap add` moves
|
|
686
|
+
* activeAccountNumber AND ~/.claude/.credentials.json onto the account it just
|
|
687
|
+
* created, so a restore that silently fails leaves every Claude Code session on
|
|
688
|
+
* the machine running as the account that was just added — the precise outcome
|
|
689
|
+
* `previousActive` is captured to prevent, reported to the user as an
|
|
690
|
+
* unqualified success.
|
|
691
|
+
*
|
|
692
|
+
* THE VERDICT COMES OFF THE STORE, NOT OFF THE EXIT STATUS. `newSlot` settles
|
|
693
|
+
* the neighbouring question the same way and says why: the store is the fact.
|
|
694
|
+
* A `cswap switch` that moved the account and then exited non-zero is not
|
|
695
|
+
* something to warn about, and exit 0 with nothing moved is — and only a
|
|
696
|
+
* re-read can tell those apart.
|
|
697
|
+
*/
|
|
733
698
|
async function restoreActive(num) {
|
|
734
|
-
if (num == null) return;
|
|
699
|
+
if (num == null) return RESTORED;
|
|
735
700
|
const after = await readStore();
|
|
736
|
-
if (String(after.activeNum) === String(num)) return;
|
|
737
|
-
await run(await cswapBin(), ["switch", String(num)], { timeout: 30_000 })
|
|
701
|
+
if (String(after.activeNum) === String(num)) return RESTORED;
|
|
702
|
+
const r = await run(await cswapBin(), ["switch", String(num)], { timeout: 30_000 });
|
|
703
|
+
const now = await readStore();
|
|
704
|
+
if (String(now.activeNum) === String(num)) return RESTORED;
|
|
705
|
+
const active = now.activeNum == null ? null : String(now.activeNum);
|
|
706
|
+
return {
|
|
707
|
+
restored: false,
|
|
708
|
+
activeNum: active,
|
|
709
|
+
activeEmail: active == null ? null : (now.emails[active] ?? ""),
|
|
710
|
+
detail: failureText(r, "cswap switch", "the account could not be switched back"),
|
|
711
|
+
};
|
|
738
712
|
}
|
|
739
713
|
|
|
740
714
|
// ── share / import ───────────────────────────────────────────────────────────
|
|
@@ -1194,6 +1168,59 @@ export async function importAccount(blob, { force = false, only = null } = {}) {
|
|
|
1194
1168
|
});
|
|
1195
1169
|
}
|
|
1196
1170
|
|
|
1171
|
+
/**
|
|
1172
|
+
* Put a peer's copy of one account into a slot that holds nothing — and decide
|
|
1173
|
+
* that the slot holds nothing INSIDE the lock that then writes to it.
|
|
1174
|
+
*
|
|
1175
|
+
* THE PROMISE THIS KEEPS, and why it needed moving (#1040). A plain `cswap
|
|
1176
|
+
* import` never touches a slot that is present: it adds what is missing and
|
|
1177
|
+
* replaces only a row claude-swap has quarantined as refresh-token-dead, and is
|
|
1178
|
+
* "never triggered by the live store's `no credentials` state". So the one case
|
|
1179
|
+
* pairing exists for — an account this machine has no login for at all — was
|
|
1180
|
+
* the one case a plain import would not repair, and repairing it means
|
|
1181
|
+
* `--force`, which overwrites whatever is there.
|
|
1182
|
+
*
|
|
1183
|
+
* What makes that safe is the verdict: claude-swap, asked about THIS machine's
|
|
1184
|
+
* store, saying the slot holds no credentials. Nothing a peer sends can make a
|
|
1185
|
+
* slot say that. The verdict was already being asked for fresh rather than read
|
|
1186
|
+
* from the ten-minute cache, under a comment that names the exact reason —
|
|
1187
|
+
* "somebody who signed in two minutes ago still reads as `no_credentials`
|
|
1188
|
+
* there, and acting on that would replace the login they had just created".
|
|
1189
|
+
*
|
|
1190
|
+
* But the freshness fix only shortened the window; it did not close it, because
|
|
1191
|
+
* the read was outside the mutex and the write takes it. The queue in between
|
|
1192
|
+
* is long: `registerSignedIn` holds the lock for `cswap add` (60 s), `cswap
|
|
1193
|
+
* list` (60 s) and `restoreActive`'s `cswap switch` (30 s), so a forced import
|
|
1194
|
+
* could sit for up to two and a half minutes holding a verdict taken before any
|
|
1195
|
+
* of it — and then run, replacing the login that had just been created with a
|
|
1196
|
+
* peer's older blob. Which is the sentence that comment wrote out.
|
|
1197
|
+
*
|
|
1198
|
+
* Re-reading inside the lock makes the check and the write one critical
|
|
1199
|
+
* section, so the promise holds by construction rather than by timing.
|
|
1200
|
+
* `importAccount` takes the same lock again and the lock is re-entrant, so the
|
|
1201
|
+
* whole decision is one uninterrupted hold.
|
|
1202
|
+
*
|
|
1203
|
+
* Narrowed to the one account with `only`: a forced import of a whole bundle
|
|
1204
|
+
* would rewrite every matching credential on this machine, and a fresh token
|
|
1205
|
+
* replaced by a stale one is not recoverable from here.
|
|
1206
|
+
*/
|
|
1207
|
+
export async function fillEmptySlot(blob, { email, org } = {}) {
|
|
1208
|
+
return withStoreLock(async () => {
|
|
1209
|
+
// FIRST STATEMENT INSIDE THE LOCK. Anything awaited before this re-opens
|
|
1210
|
+
// the window it exists to close.
|
|
1211
|
+
const now = await verdictNow(email, org ?? "");
|
|
1212
|
+
if (now !== "no_credentials") return { ok: false, why: "claude-swap kept the slot it already has" };
|
|
1213
|
+
|
|
1214
|
+
const forced = await importAccount(blob, { force: true, only: { email, org: org ?? "" } });
|
|
1215
|
+
if (!forced?.ok) return { ok: false, why: forced?.reason ?? "import refused" };
|
|
1216
|
+
// `added` counts `imported` alone, and a forced replace is `healed` — see
|
|
1217
|
+
// landed, which is the difference between a repair and a repair reported as
|
|
1218
|
+
// a failure.
|
|
1219
|
+
if (!landed(forced.results)) return { ok: false, why: "claude-swap kept the slot it already has" };
|
|
1220
|
+
return { ok: true, filled: true };
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1197
1224
|
// ── the small edits ──────────────────────────────────────────────────────────
|
|
1198
1225
|
|
|
1199
1226
|
// The exact question `cswap remove` asks. There is no --yes flag: assume_yes is
|