ccdeck 3.43.4 → 3.45.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 +79 -18
- package/THIRD_PARTY_NOTICES.md +70 -7
- package/bin/cli/commands.js +613 -0
- package/bin/cli/help.js +26 -3
- package/bin/cli/one-shot.js +2 -2
- package/bin/cli/picker.js +86 -0
- package/bin/cli/startup.js +13 -0
- package/bin/deck.js +16 -1
- package/dist/web/assets/BrowserWatchModal-Cbzf3TWa.js +1 -0
- package/dist/web/assets/IntegrationModal-BnnBr0a3.js +5 -0
- package/dist/web/assets/RadioPanel-BIqrMLQN.js +1 -0
- package/dist/web/assets/TrafficRadar-D1gZCIwX.js +8 -0
- package/dist/web/assets/UsageHistoryModal-DAhplVju.js +1 -0
- package/dist/web/assets/app-BW3NYUYB.js +318 -0
- package/dist/web/assets/app-C5RY_wZ4.css +1 -0
- package/dist/web/assets/{index-Ch4ks76J.js → index-r8Ymr47Q.js} +1 -1
- package/dist/web/index.html +3 -3
- package/package.json +2 -1
- package/release-notes.json +52 -0
- package/src/server/account-notify.mjs +268 -9
- package/src/server/account-pick.mjs +160 -0
- package/src/server/account-projects.mjs +55 -15
- package/src/server/account-routes.mjs +47 -4
- package/src/server/account-watch.mjs +107 -4
- package/src/server/activity.mjs +3 -2
- package/src/server/args.mjs +112 -2
- package/src/server/autoswitch-failover.mjs +138 -0
- package/src/server/claude-accounts.mjs +127 -16
- package/src/server/claude-dir.mjs +142 -5
- package/src/server/claude-identity.mjs +23 -0
- package/src/server/cli-lines.mjs +224 -0
- package/src/server/codex-cost-usage.mjs +67 -0
- package/src/server/codex-enrichment.mjs +53 -9
- package/src/server/codex-review-policy.d.mts +4 -0
- package/src/server/codex-review-policy.mjs +11 -0
- package/src/server/codex-skills.mjs +146 -0
- package/src/server/codex-watch.mjs +5 -2
- package/src/server/cswap-auto-choice.mjs +53 -0
- package/src/server/cswap-auto-loop.mjs +100 -9
- package/src/server/deck-client.mjs +112 -0
- package/src/server/deck-prefs.mjs +26 -5
- package/src/server/event-pipeline.mjs +19 -3
- package/src/server/event-routes.mjs +15 -0
- package/src/server/exec.mjs +6 -2
- package/src/server/feature-use.mjs +11 -1
- package/src/server/forge-http.mjs +514 -0
- package/src/server/forge-public-fetch.mjs +228 -0
- package/src/server/forge-routes.mjs +154 -0
- package/src/server/github-api.mjs +1369 -0
- package/src/server/gitlab-api.mjs +1271 -0
- package/src/server/index.mjs +23 -3
- package/src/server/installer.mjs +149 -8
- package/src/server/integrations/cache.mjs +42 -0
- package/src/server/integrations/errors.mjs +167 -0
- package/src/server/integrations/forge.mjs +76 -0
- package/src/server/integrations/jira.mjs +1026 -0
- package/src/server/integrations/providers.mjs +11 -0
- package/src/server/integrations/proxy.mjs +314 -0
- package/src/server/integrations/registry.mjs +142 -0
- package/src/server/integrations/routes.mjs +498 -0
- package/src/server/integrations/store.mjs +226 -0
- package/src/server/lan-deck.mjs +7 -0
- package/src/server/lan-elsewhere.d.mts +30 -0
- package/src/server/lan-elsewhere.mjs +137 -0
- package/src/server/lan-engine.mjs +2 -2
- package/src/server/lan-sync.mjs +3 -3
- package/src/server/lan-wire.mjs +3 -3
- package/src/server/login-guard.mjs +162 -0
- package/src/server/login-service.mjs +3 -0
- package/src/server/org-telemetry.mjs +173 -0
- package/src/server/pinned-build.mjs +8 -0
- package/src/server/prefs-routes.mjs +4 -0
- package/src/server/request-gates.mjs +26 -0
- package/src/server/session-read-gate.mjs +5 -3
- package/src/server/session-summary.mjs +281 -0
- package/src/server/skill-hook.mjs +162 -0
- package/src/server/stop-continue-gate.d.mts +8 -0
- package/src/server/stop-continue-gate.mjs +77 -0
- package/src/server/stop-continue.mjs +544 -0
- package/src/server/stop-failure.d.mts +10 -0
- package/src/server/stop-failure.mjs +97 -0
- package/src/server/swap-log.mjs +19 -10
- package/src/server/tool-audit-report.mjs +5 -3
- package/src/server/tool-audit-routes.mjs +5 -3
- package/src/server/tool-audit-skills.mjs +82 -0
- package/src/server/tool-audit.mjs +53 -11
- package/src/server/traffic-radar-capture.mjs +10 -1
- package/src/server/traffic-radar-monitor.mjs +4 -2
- package/src/server/traffic-radar-routes.mjs +13 -0
- package/src/server/traffic-radar-service.mjs +133 -0
- package/src/server/usage-day.mjs +3 -1
- package/dist/web/assets/BrowserWatchModal-nOxhGOO4.js +0 -1
- package/dist/web/assets/RadioPanel-BLfpQ1Iw.js +0 -1
- package/dist/web/assets/TrafficRadar-BFrObpjW.js +0 -1
- package/dist/web/assets/UsageHistoryModal-DCnFf1vi.js +0 -1
- package/dist/web/assets/app-BiJ8TfUl.css +0 -1
- package/dist/web/assets/app-DBsNB-2y.js +0 -312
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Auto-switch away from an active login that has died.
|
|
2
|
+
//
|
|
3
|
+
// WHY THE ENGINE CANNOT DO THIS UNDER THE DECK. claude-swap fails over only
|
|
4
|
+
// after the active account's usage has been unreadable for `unhealthyTicks`
|
|
5
|
+
// polls in a row (3 by default), and it counts them on the engine instance. The
|
|
6
|
+
// deck runs the engine as `cswap auto --once`, a new process per tick, so the
|
|
7
|
+
// count is 1 every time: each tick reports `active-usage-unknown 1/3` and the
|
|
8
|
+
// user stays on an account they can no longer use. Its idle hold has the same
|
|
9
|
+
// shape — `_idle_hold_since` starts again with every process, so its
|
|
10
|
+
// thirty-minute cap is never reached either. (claude-swap 0.26.0: cli.py builds
|
|
11
|
+
// a fresh AutoSwitchEngine for each --once run; autoswitch.py keeps both
|
|
12
|
+
// counters on it.)
|
|
13
|
+
//
|
|
14
|
+
// So the deck finishes that one decision itself, and nothing else: every other
|
|
15
|
+
// switch is still the engine's.
|
|
16
|
+
//
|
|
17
|
+
// WHAT COUNTS AS DEAD. Three answers from three places, all required:
|
|
18
|
+
// - the engine could not read the active account this tick;
|
|
19
|
+
// - claude-swap's stored copy of it is not alive (account-health.mjs);
|
|
20
|
+
// - the `claude` CLI itself says nobody is signed in. That is what Claude
|
|
21
|
+
// Code leaves behind once a refresh is refused: "Login expired · Please run
|
|
22
|
+
// /login" clears the saved credentials.
|
|
23
|
+
// Each alone has a known false alarm. An unreadable usage endpoint is a network
|
|
24
|
+
// blip; a dead stored copy beside a working session is #721's stale copy; and a
|
|
25
|
+
// signed-out CLI whose stored copy is alive is a /logout somebody meant, or one
|
|
26
|
+
// claude-swap can still repair. A CLI that could not be asked is no answer, so
|
|
27
|
+
// it never counts as signed out.
|
|
28
|
+
//
|
|
29
|
+
// WHERE IT GOES. An account claude-swap can still use, not disabled, under the
|
|
30
|
+
// threshold the tick ran with, and not one of the two the deck's own rules
|
|
31
|
+
// never land on either: set aside by claude-swap's engine (its refresh token is
|
|
32
|
+
// dead, the very thing being left) or in use by a local `cswap run` session.
|
|
33
|
+
// The freshest reading first, then the most headroom. A reading can be old —
|
|
34
|
+
// claude-swap collects idle accounts on its own schedule — and an old reading
|
|
35
|
+
// is still better than staying on a login that cannot answer at all; once the
|
|
36
|
+
// new account is active, the engine reads it and moves on by its own rules if
|
|
37
|
+
// it is full.
|
|
38
|
+
//
|
|
39
|
+
// AND THE DECK'S RULES. With a home or the weekly rule set, the loop answers a
|
|
40
|
+
// tick inside their cooldown without running the engine — except for an
|
|
41
|
+
// emergency, and an active account the engine could not read is one: it is
|
|
42
|
+
// the only way this failover hears of a dead login. The move it makes is
|
|
43
|
+
// recorded as the tick's (noteEngineTick), so the rules do not mistake it for
|
|
44
|
+
// a switch made by hand.
|
|
45
|
+
//
|
|
46
|
+
// HOW OFTEN. Once per FAILOVER_COOLDOWN_MS. A target whose login dies too is
|
|
47
|
+
// found the same way a tick later, and the wait keeps that from becoming a walk
|
|
48
|
+
// through every dead account in the store inside a minute.
|
|
49
|
+
import { fetchClaudeAccounts, switchClaudeAccount } from "./claude-accounts.mjs";
|
|
50
|
+
import { cliSignIn } from "./claude-identity.mjs";
|
|
51
|
+
|
|
52
|
+
/** The engine's two ways of saying it could not read the active account. */
|
|
53
|
+
export const LOST_ACTIVE_REASONS = new Set(["active-usage-unknown", "active-idle"]);
|
|
54
|
+
|
|
55
|
+
/** claude-swap's own cooldown after a switch, used here between failovers. */
|
|
56
|
+
export const FAILOVER_COOLDOWN_MS = 5 * 60_000;
|
|
57
|
+
|
|
58
|
+
/** What `failOver` names the switch, and the stuck tick, after. */
|
|
59
|
+
export const LOGIN_EXPIRED = "login-expired";
|
|
60
|
+
|
|
61
|
+
/** Whether a tick's answer could be a dead active login worth asking about. */
|
|
62
|
+
export function engineLostActive(tick) {
|
|
63
|
+
return tick?.ok === true && tick.event === "no-switch" && LOST_ACTIVE_REASONS.has(tick.reason);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The active roster row when its stored login is gone, else null. A stale
|
|
67
|
+
* copy is a working session the collector cannot see (#721), never this. */
|
|
68
|
+
export function deadActiveRow(accounts) {
|
|
69
|
+
const active = Array.isArray(accounts) ? accounts.find(a => a?.active) : null;
|
|
70
|
+
if (!active || active.staleCopy || active.alive !== false) return null;
|
|
71
|
+
return active;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The accounts a failover may land on, best first. `held` is the slots no
|
|
75
|
+
* move may land on whatever the rule (cswap-auto-choice.mjs slotsHeldBack):
|
|
76
|
+
* ones claude-swap's engine set aside, and ones a local `cswap run` session
|
|
77
|
+
* is using — the same two the deck's own rules skip. */
|
|
78
|
+
export function failoverTargets(accounts, threshold, held = new Set()) {
|
|
79
|
+
const limit = typeof threshold === "number" && Number.isFinite(threshold) ? threshold : 90;
|
|
80
|
+
return (Array.isArray(accounts) ? accounts : [])
|
|
81
|
+
.filter(a => a && !a.active && !a.disabled && a.alive === true && !a.staleCopy && !a.stopped
|
|
82
|
+
&& !held.has(a.num)
|
|
83
|
+
&& typeof a.headroom === "number" && 100 - a.headroom < limit)
|
|
84
|
+
.sort((a, b) => Number(a.stale === true) - Number(b.stale === true) || b.headroom - a.headroom);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const refOf = a => ({ number: a.num, email: a.email ?? "" });
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Finish a tick the engine could not finish: switch away from the active
|
|
91
|
+
* account when its login is dead, or say why not.
|
|
92
|
+
*
|
|
93
|
+
* Null when this tick is not about a dead login at all — the tick then stands
|
|
94
|
+
* as the engine reported it. Otherwise a tick-shaped answer to merge over it:
|
|
95
|
+
* a `switch` with `trigger: "login-expired"`, or a `no-switch` whose reason is
|
|
96
|
+
* `login-expired` and whose detail says what stood in the way.
|
|
97
|
+
*
|
|
98
|
+
* `before` runs inside the store lock, ahead of the switch, so the loop can
|
|
99
|
+
* stand down if it was turned off or a terminal engine started meanwhile.
|
|
100
|
+
* `heldBack(accounts)` names the slots no move may land on (failoverTargets);
|
|
101
|
+
* one it cannot read holds nothing back.
|
|
102
|
+
*/
|
|
103
|
+
export async function failOver(tick, {
|
|
104
|
+
lastAt = null,
|
|
105
|
+
now = Date.now(),
|
|
106
|
+
before,
|
|
107
|
+
roster = () => fetchClaudeAccounts({ force: true }),
|
|
108
|
+
signIn = cliSignIn,
|
|
109
|
+
switchTo = switchClaudeAccount,
|
|
110
|
+
heldBack = async () => new Set(),
|
|
111
|
+
} = {}) {
|
|
112
|
+
if (!engineLostActive(tick)) return null;
|
|
113
|
+
const read = await roster().catch(() => null);
|
|
114
|
+
const dead = deadActiveRow(read?.ok ? read.accounts : null);
|
|
115
|
+
if (!dead) return null;
|
|
116
|
+
const cli = await signIn().catch(() => null);
|
|
117
|
+
if (cli?.loggedIn !== false) return null;
|
|
118
|
+
|
|
119
|
+
const from = refOf(dead);
|
|
120
|
+
if (lastAt != null && now - lastAt < FAILOVER_COOLDOWN_MS) {
|
|
121
|
+
return { event: "no-switch", reason: LOGIN_EXPIRED, detail: "waiting after the last switch away from an expired login", from };
|
|
122
|
+
}
|
|
123
|
+
const held = await Promise.resolve().then(() => heldBack(read.accounts)).catch(() => null);
|
|
124
|
+
const [target] = failoverTargets(read.accounts, tick.threshold, held instanceof Set ? held : new Set());
|
|
125
|
+
if (!target) {
|
|
126
|
+
// `stuck`: the one answer the notifier says, and the panel shows.
|
|
127
|
+
return { event: "no-switch", reason: LOGIN_EXPIRED, detail: "no other account is ready", stuck: true, from };
|
|
128
|
+
}
|
|
129
|
+
// Forced: the current login is the dead one, and Claude Code has already
|
|
130
|
+
// cleared it. A plain switch would first back that empty login up, and
|
|
131
|
+
// claude-swap refuses to (see switchClaudeAccount).
|
|
132
|
+
const r = await switchTo(target.num, { before, force: true });
|
|
133
|
+
if (r?.skipped) return { event: "skipped", reason: r.skipped };
|
|
134
|
+
if (!r?.ok) {
|
|
135
|
+
return { ok: false, event: "no-switch", reason: "failover_failed", detail: r?.output || r?.reason || "switch failed", from };
|
|
136
|
+
}
|
|
137
|
+
return { event: "switch", switched: true, trigger: LOGIN_EXPIRED, from, to: refOf(target) };
|
|
138
|
+
}
|
|
@@ -39,10 +39,11 @@ import { currentIdentity } from "./claude-identity.mjs";
|
|
|
39
39
|
// cycle is a lock that may not be there yet when a mutation wants it.
|
|
40
40
|
import { withStoreLock } from "./store-lock.mjs";
|
|
41
41
|
// claude-swap's own per-slot verdicts, which have to be ASKED for rather than
|
|
42
|
-
// read off the store. The roster puts them on each row
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
42
|
+
// read off the store. The roster puts them on each row, and a switch asks for a
|
|
43
|
+
// collection through the same queue before it reads its target (#2184);
|
|
44
|
+
// verdictNow and verdictsNow are re-exported because cswap-admin.mjs and
|
|
45
|
+
// lan-deck.mjs reach them through this module.
|
|
46
|
+
import { verdictFor, verdictsAskedAt, verdictsNow } from "./claude-verdicts.mjs";
|
|
46
47
|
export { verdictNow, verdictsNow } from "./claude-verdicts.mjs";
|
|
47
48
|
// Asking claude-swap to collect, which every roster read does when something is
|
|
48
49
|
// due, and when each account will next be read.
|
|
@@ -51,6 +52,13 @@ import { nextReadAt, nudgeCollector } from "./claude-collector.mjs";
|
|
|
51
52
|
// (#1893). Pure, and keyed the way LAN sync keys accounts.
|
|
52
53
|
import { hasRecovered, reauthFor } from "./account-origins.mjs";
|
|
53
54
|
import { accountKey } from "./lan-copies.mjs";
|
|
55
|
+
// What each organization's Claude settings were last seen to turn on, so a row
|
|
56
|
+
// can say it before anyone switches to the account. See its header for when the
|
|
57
|
+
// shared remote-settings.json can be pinned on an organization at all.
|
|
58
|
+
import { observeOrgTelemetry } from "./org-telemetry.mjs";
|
|
59
|
+
// Whether a switch target's stored login is known dead, and whether a
|
|
60
|
+
// collection would read it first (#2184). Pure, and imports nothing.
|
|
61
|
+
import { collectionWouldRead, deadLoginOf } from "./login-guard.mjs";
|
|
54
62
|
import { dirname, join } from "node:path";
|
|
55
63
|
import { existsSync } from "node:fs";
|
|
56
64
|
import { homedir, platform } from "node:os";
|
|
@@ -463,6 +471,7 @@ async function readRoster(now, gen) {
|
|
|
463
471
|
}
|
|
464
472
|
|
|
465
473
|
const rows = await readUsageRows(root);
|
|
474
|
+
const orgTelemetry = await observeOrgTelemetry({ seq }).catch(() => ({}));
|
|
466
475
|
|
|
467
476
|
// Kick a collection for the NEXT poll if anything is due — either because
|
|
468
477
|
// claude-swap's schedule says so, or because an account has never been
|
|
@@ -496,7 +505,7 @@ async function readRoster(now, gen) {
|
|
|
496
505
|
const row = rows[num];
|
|
497
506
|
const key = accountKey(acct.email, acct.organizationUuid);
|
|
498
507
|
const origin = Object.hasOwn(origins, key) ? origins[key] : null;
|
|
499
|
-
const shown = rosterRow({ seq, num, acct, row, identity, now, key, origin });
|
|
508
|
+
const shown = rosterRow({ seq, num, acct, row, identity, now, key, origin, telemetry: orgTelemetry[acct.organizationUuid] ?? null });
|
|
500
509
|
accounts.push(shown);
|
|
501
510
|
// A put-off incident is over once a read succeeds after it, and the
|
|
502
511
|
// put-off goes with it (#1893) — said here, where the read is, rather than
|
|
@@ -523,7 +532,7 @@ function observedMs(seconds) {
|
|
|
523
532
|
return Number.isFinite(ms) && ms > 0 && Number.isFinite(new Date(ms).getTime()) ? ms : null;
|
|
524
533
|
}
|
|
525
534
|
|
|
526
|
-
function rosterRow({ seq, num, acct, row, identity, now, key, origin = null }) {
|
|
535
|
+
function rosterRow({ seq, num, acct, row, identity, now, key, origin = null, telemetry = null }) {
|
|
527
536
|
const matches = rowIsFor(row, acct);
|
|
528
537
|
const good = matches ? row.lastGood : null;
|
|
529
538
|
|
|
@@ -613,6 +622,11 @@ function rosterRow({ seq, num, acct, row, identity, now, key, origin = null }) {
|
|
|
613
622
|
// claude-swap has refused its login since: `{ key, since, dismissed }`.
|
|
614
623
|
// `key` and `since` together name it, for "Not now" to send back.
|
|
615
624
|
reauth: reauth ? { key, ...reauth } : null,
|
|
625
|
+
// What this account's organization last turned on for Claude Code's
|
|
626
|
+
// telemetry export, when the deck has seen its settings (org-telemetry.mjs):
|
|
627
|
+
// `{ orgName, level, content, destinations, seenAt }`. `level` "off" was
|
|
628
|
+
// seen off; null was never seen, which is unknown and not "off".
|
|
629
|
+
telemetry: telemetry ?? null,
|
|
616
630
|
};
|
|
617
631
|
}
|
|
618
632
|
|
|
@@ -628,8 +642,8 @@ function rosterRow({ seq, num, acct, row, identity, now, key, origin = null }) {
|
|
|
628
642
|
* It says nothing about whether the user is signed in. Measured on the machine
|
|
629
643
|
* that reported this, at the same instant:
|
|
630
644
|
*
|
|
631
|
-
* claude auth status --json -> loggedIn: true,
|
|
632
|
-
* cswap list --json ->
|
|
645
|
+
* claude auth status --json -> loggedIn: true, carol@example.net
|
|
646
|
+
* cswap list --json -> carol@example.net: relogin_required
|
|
633
647
|
* GET /api/quota -> source: cli, 5h 33%, 7d 37%
|
|
634
648
|
*
|
|
635
649
|
* The user had signed in again in a terminal, which refreshes the LIVE
|
|
@@ -796,6 +810,82 @@ export function slotNumber(accountNum) {
|
|
|
796
810
|
return Number.isInteger(num) && num >= 1 && num <= 999 ? num : null;
|
|
797
811
|
}
|
|
798
812
|
|
|
813
|
+
// ── before a switch: is the target's login known dead? (#2184) ──────────────
|
|
814
|
+
|
|
815
|
+
/** How long a switch waits for claude-swap to read its target first. A cold
|
|
816
|
+
* collection can take a minute; a switch that waited that long would read as
|
|
817
|
+
* a hang, and what it could not learn in time it does not hold against the
|
|
818
|
+
* target. */
|
|
819
|
+
const SWITCH_READ_WAIT_MS = 15_000;
|
|
820
|
+
|
|
821
|
+
/** Slot `num` as the store holds it — the account, its usage row and whether
|
|
822
|
+
* that row is the account's own — or null when there is no such slot. */
|
|
823
|
+
async function switchTarget(num) {
|
|
824
|
+
const root = backupRoot();
|
|
825
|
+
const seq = await readSequence(root);
|
|
826
|
+
const key = String(num);
|
|
827
|
+
const acct = seq?.accounts?.[key];
|
|
828
|
+
if (!acct) return null;
|
|
829
|
+
const row = (await readUsageRows(root))[key];
|
|
830
|
+
return { acct, row, matches: rowIsFor(row, acct), active: activeSlot(seq) === key };
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Ask claude-swap to read slot `num` before a switch onto it, when a
|
|
835
|
+
* collection would read it at all — see login-guard.mjs for why that is the
|
|
836
|
+
* only check 0.26.0 offers, and the incident it would have caught.
|
|
837
|
+
*
|
|
838
|
+
* Shares the verdict queue's `cswap list --json`, so it never starts a second
|
|
839
|
+
* collection beside one already running, and waits for it no longer than
|
|
840
|
+
* SWITCH_READ_WAIT_MS. Outside the store lock: a collection takes none of the
|
|
841
|
+
* deck's, and a mutation queued behind this one must not wait out somebody
|
|
842
|
+
* else's network. Never throws — a check that cannot be made is no answer.
|
|
843
|
+
*/
|
|
844
|
+
export async function readBeforeSwitch(num, { now = Date.now(), collect = () => verdictsNow(), waitMs = SWITCH_READ_WAIT_MS } = {}) {
|
|
845
|
+
let timer = null;
|
|
846
|
+
try {
|
|
847
|
+
const t = await switchTarget(num);
|
|
848
|
+
if (!t || t.active || deadLoginOf(t.row, { matches: t.matches })) return false;
|
|
849
|
+
if (!collectionWouldRead(t.row, { matches: t.matches, now })) return false;
|
|
850
|
+
const waited = new Promise(resolve => { timer = setTimeout(resolve, waitMs); timer.unref?.(); });
|
|
851
|
+
await Promise.race([Promise.resolve().then(collect).catch(() => null), waited]);
|
|
852
|
+
return true;
|
|
853
|
+
} catch {
|
|
854
|
+
return false;
|
|
855
|
+
} finally {
|
|
856
|
+
if (timer) clearTimeout(timer);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* The refusal for a switch onto slot `num`, when claude-swap's store says its
|
|
862
|
+
* stored login is dead — or null, which is every case it cannot say so,
|
|
863
|
+
* including a store it could not read.
|
|
864
|
+
*
|
|
865
|
+
* `{ ok: false, reason: "login_expired", account: { num, email, alias }, error,
|
|
866
|
+
* since }`, so the panel can name the account and offer the sign-in that fixes
|
|
867
|
+
* it. A switch to the slot that is already active is not this question: it
|
|
868
|
+
* writes nothing live.
|
|
869
|
+
*/
|
|
870
|
+
export async function expiredSwitchTarget(num) {
|
|
871
|
+
try {
|
|
872
|
+
const t = await switchTarget(num);
|
|
873
|
+
if (!t || t.active) return null;
|
|
874
|
+
const signedIn = originsNow()[accountKey(t.acct.email, t.acct.organizationUuid)]?.signedInAt;
|
|
875
|
+
const dead = deadLoginOf(t.row, { matches: t.matches, signedInAt: signedIn > 0 ? signedIn : null });
|
|
876
|
+
if (!dead) return null;
|
|
877
|
+
return {
|
|
878
|
+
ok: false,
|
|
879
|
+
reason: "login_expired",
|
|
880
|
+
account: { num, email: t.acct.email ?? null, alias: t.acct.alias ?? null },
|
|
881
|
+
error: dead.error,
|
|
882
|
+
since: dead.since,
|
|
883
|
+
};
|
|
884
|
+
} catch {
|
|
885
|
+
return null;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
799
889
|
/**
|
|
800
890
|
* Switch the active Claude account by delegating to `cswap`.
|
|
801
891
|
*
|
|
@@ -817,8 +907,25 @@ export function slotNumber(accountNum) {
|
|
|
817
907
|
* The validation stays OUTSIDE the lock. A slot number that is not one is not a
|
|
818
908
|
* mutation and must not wait behind somebody else's sixty-second `cswap add`
|
|
819
909
|
* to be told so.
|
|
910
|
+
*
|
|
911
|
+
* AND IT DOES NOT SWITCH ONTO A LOGIN KNOWN TO BE DEAD (#2184). Every session
|
|
912
|
+
* on the machine runs on the login this puts live, so a dead one stops all of
|
|
913
|
+
* them at once. The target is read first when a collection would read it
|
|
914
|
+
* (readBeforeSwitch), and judged inside the lock, on the store as it stands
|
|
915
|
+
* when `cswap switch` would run (expiredSwitchTarget). Anything that cannot be
|
|
916
|
+
* judged lets the switch go ahead.
|
|
917
|
+
*
|
|
918
|
+
* `before`, when given, is asked first thing inside the lock: a switch nobody
|
|
919
|
+
* pressed — the auto-switch failover — may have been turned off while it
|
|
920
|
+
* queued, and answers `{ skipped: <why> }` instead of moving the account.
|
|
921
|
+
*
|
|
922
|
+
* `force` is `cswap switch --force`: activate the target without backing up
|
|
923
|
+
* the current login first. Only for a current login already known to be dead.
|
|
924
|
+
* Claude Code clears a login whose refresh was refused, and a plain switch then
|
|
925
|
+
* refuses in turn ("Current account credential is empty …; refusing to
|
|
926
|
+
* overwrite its backup"), which would leave the user exactly where they were.
|
|
820
927
|
*/
|
|
821
|
-
export function switchClaudeAccount(accountNum) {
|
|
928
|
+
export function switchClaudeAccount(accountNum, { before, force = false } = {}) {
|
|
822
929
|
// Straight into an exec argument, so nothing but a slot number gets through.
|
|
823
930
|
const num = slotNumber(accountNum);
|
|
824
931
|
if (num == null) {
|
|
@@ -827,13 +934,17 @@ export function switchClaudeAccount(accountNum) {
|
|
|
827
934
|
|
|
828
935
|
// Argument vector, never a shell — and resolved through exec.mjs so the
|
|
829
936
|
// Windows `.exe`/`.cmd` shim is found too.
|
|
830
|
-
return
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
})
|
|
937
|
+
return readBeforeSwitch(num).then(() => withStoreLock(async () => {
|
|
938
|
+
const skipped = before ? await before() : null;
|
|
939
|
+
if (skipped) return { ok: false, skipped };
|
|
940
|
+
const expired = await expiredSwitchTarget(num);
|
|
941
|
+
if (expired) return expired;
|
|
942
|
+
const argv = force ? ["switch", String(num), "--force"] : ["switch", String(num)];
|
|
943
|
+
const r = await run(await cswapBin(), argv, { timeout: 30_000 });
|
|
944
|
+
if (r.ok) return { ok: true, output: r.stdout.trim() };
|
|
945
|
+
const reason = r.code === "ENOENT" ? "no_cswap" : r.killed ? "timeout" : "switch_failed";
|
|
946
|
+
return { ok: false, reason, output: failureDetail(r, 500) };
|
|
947
|
+
}));
|
|
837
948
|
}
|
|
838
949
|
|
|
839
950
|
/**
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// to "where does the `claude` command live", and the deck now asks that question
|
|
20
20
|
// at boot, before any quota exists, to decide whether this is a Claude machine
|
|
21
21
|
// at all. One list, one caller-visible name, no second spelling to drift.
|
|
22
|
-
import { existsSync } from "node:fs";
|
|
22
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
23
23
|
import { homedir } from "node:os";
|
|
24
24
|
import { join, resolve, posix as posixPath, win32 as winPath } from "node:path";
|
|
25
25
|
|
|
@@ -131,15 +131,26 @@ function claudeCliOnDisk({
|
|
|
131
131
|
home = homedir(),
|
|
132
132
|
exists = existsSync,
|
|
133
133
|
} = {}) {
|
|
134
|
+
for (const _ of claudeCliFound({ platform, env, home, exists })) return true;
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Every `claude` the walk above finds, in the order it finds them — the known
|
|
140
|
+
* install paths first, then each PATH entry. claudeCliOnDisk stops at the first;
|
|
141
|
+
* claudeCodeVersions below reads them all, because an old CLI anywhere on the
|
|
142
|
+
* machine still reads the settings file a new one does.
|
|
143
|
+
*/
|
|
144
|
+
function* claudeCliFound({ platform, env, home, exists }) {
|
|
134
145
|
const path = platform === "win32" ? winPath : posixPath;
|
|
135
146
|
const bare = [];
|
|
136
147
|
for (const candidate of claudeCliCandidates(platform, env, home)) {
|
|
137
148
|
// A full path is worth a single stat; a bare name means "ask PATH", which
|
|
138
149
|
// is the walk below. Same split quotaClaudeBin makes, for the same reason.
|
|
139
|
-
if (candidate.includes(path.sep)) { if (exists(candidate))
|
|
150
|
+
if (candidate.includes(path.sep)) { if (exists(candidate)) yield candidate; }
|
|
140
151
|
else bare.push(candidate);
|
|
141
152
|
}
|
|
142
|
-
if (bare.length === 0) return
|
|
153
|
+
if (bare.length === 0) return;
|
|
143
154
|
|
|
144
155
|
// process.env is case-insensitive on Windows, but an injected plain object in
|
|
145
156
|
// a test is not, and %Path% is how the variable is actually spelled there.
|
|
@@ -156,10 +167,136 @@ function claudeCliOnDisk({
|
|
|
156
167
|
const dir = entry.trim().replace(/^"+|"+$/g, "");
|
|
157
168
|
if (dir === "") continue;
|
|
158
169
|
for (const name of bare) {
|
|
159
|
-
for (const ext of exts)
|
|
170
|
+
for (const ext of exts) {
|
|
171
|
+
const found = path.join(dir, name + ext);
|
|
172
|
+
if (exists(found)) yield found;
|
|
173
|
+
}
|
|
160
174
|
}
|
|
161
175
|
}
|
|
162
|
-
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A Claude Code version as its installers spell it: three numbers, and a
|
|
179
|
+
* pre-release or build suffix that does not change which release it is. */
|
|
180
|
+
const CC_VERSION_RE = /^(\d+)\.(\d+)\.(\d+)(?:[-+][0-9A-Za-z.+-]*)?$/;
|
|
181
|
+
|
|
182
|
+
/** Whether `version` is `min` or newer, by the three numbers. */
|
|
183
|
+
export function claudeVersionAtLeast(version, min) {
|
|
184
|
+
const a = CC_VERSION_RE.exec(String(version ?? ""));
|
|
185
|
+
const b = CC_VERSION_RE.exec(String(min ?? ""));
|
|
186
|
+
if (!a || !b) return false;
|
|
187
|
+
for (let i = 1; i <= 3; i++) {
|
|
188
|
+
const d = Number(a[i]) - Number(b[i]);
|
|
189
|
+
if (d !== 0) return d > 0;
|
|
190
|
+
}
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The npm package every npm install of Claude Code is. */
|
|
195
|
+
const CC_PACKAGE = "@anthropic-ai/claude-code";
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The version of the one install `bin` belongs to, read off disk, or
|
|
199
|
+
* undefined when its layout does not say.
|
|
200
|
+
*
|
|
201
|
+
* npm the package's own package.json, found by walking up from the
|
|
202
|
+
* file the bin resolves to — or, for the `claude.cmd` shim npm
|
|
203
|
+
* writes on Windows, in the node_modules beside the shim
|
|
204
|
+
* native `~/.local/bin/claude` links to `…/claude/versions/<version>`;
|
|
205
|
+
* on Windows it is a copy, and the newest of those versions is the
|
|
206
|
+
* one the installer put there
|
|
207
|
+
* Homebrew the cask's binary lives at `…/Caskroom/claude-code/<version>/claude`
|
|
208
|
+
*
|
|
209
|
+
* The package.json comes first, and a version-shaped directory is believed only
|
|
210
|
+
* where those two layouts put one: a node manager keeps the NODE version in its
|
|
211
|
+
* paths (`…/installs/node/22.14.0/…`), and reading that as Claude Code's would
|
|
212
|
+
* let a machine with an old CLI through the gate.
|
|
213
|
+
*/
|
|
214
|
+
function installVersion(bin, { platform, home, exists, realpath, readText, list }) {
|
|
215
|
+
const path = platform === "win32" ? winPath : posixPath;
|
|
216
|
+
let real = bin;
|
|
217
|
+
try { real = realpath(bin); } catch { /* a dangling link reads as itself */ }
|
|
218
|
+
const fromPackage = file => {
|
|
219
|
+
try {
|
|
220
|
+
const pkg = JSON.parse(readText(file));
|
|
221
|
+
return pkg?.name === CC_PACKAGE && CC_VERSION_RE.test(pkg.version) ? pkg.version : undefined;
|
|
222
|
+
} catch { return undefined; }
|
|
223
|
+
};
|
|
224
|
+
const beside = path.join(path.dirname(bin), "node_modules", ...CC_PACKAGE.split("/"), "package.json");
|
|
225
|
+
if (exists(beside)) {
|
|
226
|
+
const v = fromPackage(beside);
|
|
227
|
+
if (v) return v;
|
|
228
|
+
}
|
|
229
|
+
let dir = path.dirname(real);
|
|
230
|
+
for (let i = 0; i < 5; i++) {
|
|
231
|
+
const file = path.join(dir, "package.json");
|
|
232
|
+
if (exists(file)) {
|
|
233
|
+
const v = fromPackage(file);
|
|
234
|
+
if (v) return v;
|
|
235
|
+
}
|
|
236
|
+
const up = path.dirname(dir);
|
|
237
|
+
if (up === dir) break;
|
|
238
|
+
dir = up;
|
|
239
|
+
}
|
|
240
|
+
const name = path.basename(real);
|
|
241
|
+
const parent = path.basename(path.dirname(real));
|
|
242
|
+
if (CC_VERSION_RE.test(name) && parent === "versions") return name;
|
|
243
|
+
if (CC_VERSION_RE.test(parent) && path.basename(path.dirname(path.dirname(real))) === "claude-code") return parent;
|
|
244
|
+
const nativeBin = path.join(home, ".local", "bin");
|
|
245
|
+
if (path.dirname(real) === nativeBin) {
|
|
246
|
+
try {
|
|
247
|
+
const versions = list(path.join(home, ".local", "share", "claude", "versions"))
|
|
248
|
+
.filter(v => CC_VERSION_RE.test(v));
|
|
249
|
+
let newest;
|
|
250
|
+
for (const v of versions) if (!newest || claudeVersionAtLeast(v, newest)) newest = v;
|
|
251
|
+
if (newest) return newest;
|
|
252
|
+
} catch { /* no native install beside it */ }
|
|
253
|
+
}
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The versions of every Claude Code the deck can find on this machine, read
|
|
259
|
+
* off disk without running any of them — the same places hasClaudeInstalled
|
|
260
|
+
* looks, each resolved to the install it belongs to. Distinct, and only the
|
|
261
|
+
* ones whose layout names a version: an install that does not say is left out
|
|
262
|
+
* rather than guessed at.
|
|
263
|
+
*
|
|
264
|
+
* WITHOUT A CHILD PROCESS, for the reason claudeCliOnDisk gives: the installer
|
|
265
|
+
* asks this at boot, and a `claude --version` is about three seconds there.
|
|
266
|
+
* Every dependency is injected so the suite can lay out an npm, a native and a
|
|
267
|
+
* Homebrew install for any platform without one on the machine.
|
|
268
|
+
*/
|
|
269
|
+
export function claudeCodeVersions(deps = {}) {
|
|
270
|
+
const out = new Set();
|
|
271
|
+
for (const { version } of claudeCodeInstalls(deps)) if (version) out.add(version);
|
|
272
|
+
return [...out];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Every `claude` the deck can find, each with the version its install layout
|
|
277
|
+
* names, or `version: undefined` where the layout does not say. The same walk
|
|
278
|
+
* and the same reading as claudeCodeVersions, which keeps only the versions;
|
|
279
|
+
* this keeps the installs that did not answer too, for a gate that has to
|
|
280
|
+
* treat "a Claude Code whose version is unknown" as a reason to refuse rather
|
|
281
|
+
* than as one fewer Claude Code (skill-hook.mjs).
|
|
282
|
+
*/
|
|
283
|
+
export function claudeCodeInstalls({
|
|
284
|
+
platform = process.platform,
|
|
285
|
+
env = process.env,
|
|
286
|
+
home = homedir(),
|
|
287
|
+
exists = existsSync,
|
|
288
|
+
realpath = p => realpathSync.native(p),
|
|
289
|
+
readText = p => readFileSync(p, "utf8"),
|
|
290
|
+
list = p => readdirSync(p),
|
|
291
|
+
} = {}) {
|
|
292
|
+
const out = [];
|
|
293
|
+
const seen = new Set();
|
|
294
|
+
for (const bin of claudeCliFound({ platform, env, home, exists })) {
|
|
295
|
+
if (seen.has(bin)) continue;
|
|
296
|
+
seen.add(bin);
|
|
297
|
+
out.push({ bin, version: installVersion(bin, { platform, home, exists, realpath, readText, list }) });
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
163
300
|
}
|
|
164
301
|
|
|
165
302
|
/**
|
|
@@ -120,3 +120,26 @@ export async function currentIdentity() {
|
|
|
120
120
|
return null;
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Whether the CLI says anybody is signed in — the answer currentIdentity folds
|
|
126
|
+
* away: `{ loggedIn: true, email }`, `{ loggedIn: false }`, or null when there
|
|
127
|
+
* is no answer to read.
|
|
128
|
+
*
|
|
129
|
+
* `claude auth status --json` exits 1 when nobody is signed in and still prints
|
|
130
|
+
* `{"loggedIn": false, …}` (Claude Code 2.1.302), so `r.ok` cannot tell
|
|
131
|
+
* "signed out" from "could not ask". currentIdentity returns null for both,
|
|
132
|
+
* which is right for its callers. The auto-switch failover may only act on a
|
|
133
|
+
* CLI that has said the login is gone, so it reads the printed answer whatever
|
|
134
|
+
* the exit code, and a run that timed out or printed nothing is no answer.
|
|
135
|
+
*/
|
|
136
|
+
export async function cliSignIn() {
|
|
137
|
+
const r = await run(adminClaudeBin(), ["auth", "status", "--json"], { timeout: 20_000 });
|
|
138
|
+
if (r.timedOut) return null;
|
|
139
|
+
try {
|
|
140
|
+
const j = JSON.parse(r.stdout);
|
|
141
|
+
if (j?.loggedIn === true) return { loggedIn: true, email: j.email ?? "" };
|
|
142
|
+
if (j?.loggedIn === false) return { loggedIn: false };
|
|
143
|
+
} catch { /* nothing printed that could be read */ }
|
|
144
|
+
return null;
|
|
145
|
+
}
|