ccdeck 3.22.15 → 3.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -10
- package/THIRD_PARTY_NOTICES.md +123 -15
- package/bin/deck.js +264 -21
- package/dist/web/assets/BrowserWatchModal-B00oF8_I.js +1 -0
- package/dist/web/assets/UsageHistoryModal-IkFiv0Vg.js +1 -0
- package/dist/web/assets/index-DgrvXP9K.css +1 -0
- package/dist/web/assets/index-Q2E4ePHr.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 +244 -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 -184
- package/src/server/installer.mjs +136 -8
- package/src/server/lan-about.mjs +8 -3
- package/src/server/lan-engine.mjs +335 -47
- package/src/server/lan-socket.mjs +384 -57
- package/src/server/lan-sync.mjs +472 -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-BYqkKWPC.js +0 -274
- package/dist/web/assets/index-s_dF8YYA.css +0 -1
|
@@ -13,6 +13,9 @@ import { run } from "./exec.mjs";
|
|
|
13
13
|
import { cswapBin } from "./cswap-install.mjs";
|
|
14
14
|
import { invalidateClaudeAccountsCache } from "./claude-accounts.mjs";
|
|
15
15
|
import { invalidateQuotaCache } from "./quota.mjs";
|
|
16
|
+
// The one store mutex. A tick is the only thing in the deck that moves the live
|
|
17
|
+
// account with nobody watching, which is why it of all writers must queue.
|
|
18
|
+
import { withStoreLock } from "./store-lock.mjs";
|
|
16
19
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
17
20
|
import { join } from "node:path";
|
|
18
21
|
import { homedir } from "node:os";
|
|
@@ -307,9 +310,28 @@ function summarise(stdout) {
|
|
|
307
310
|
};
|
|
308
311
|
}
|
|
309
312
|
|
|
310
|
-
/**
|
|
313
|
+
/**
|
|
314
|
+
* Evaluate a tick for real. May switch the active account.
|
|
315
|
+
*
|
|
316
|
+
* UNDER THE STORE MUTEX (#1039). `cswap auto --once` is not a question: it
|
|
317
|
+
* reads sequence.json, decides, and writes it back, which is the unlocked
|
|
318
|
+
* read-modify-write cswap-admin.mjs's header opens by explaining and the reason
|
|
319
|
+
* every mutation there goes through one lock. This one was outside it, and it
|
|
320
|
+
* is the writer least likely to be noticed — nobody presses it. The interval
|
|
321
|
+
* floor is MIN_INTERVAL_S = 15 s (claude-swap's own) and one `cswap add` is
|
|
322
|
+
* allowed sixty, so a tick landing inside a sign-in takes no coincidence at
|
|
323
|
+
* all: whichever write lands second drops the other's record, and the deck
|
|
324
|
+
* reports a rotation that was silently undone, or the account the user has just
|
|
325
|
+
* added is gone.
|
|
326
|
+
*
|
|
327
|
+
* A tick skipped because the lock was busy is not a tick lost — `tick()` already
|
|
328
|
+
* says as much about its own guard: the next interval is at most fifteen
|
|
329
|
+
* seconds away and the work is idempotent by design. Here it is not even
|
|
330
|
+
* skipped, only queued.
|
|
331
|
+
*/
|
|
311
332
|
async function runAutoTick() {
|
|
312
|
-
const r = await
|
|
333
|
+
const r = await withStoreLock(async () =>
|
|
334
|
+
run(await cswapBin(), ["auto", "--once", "--json"], { timeout: TICK_TIMEOUT_MS }));
|
|
313
335
|
// A KILLED RUN IS NOT A QUIET ONE. `run`'s timeout path deliberately keeps an
|
|
314
336
|
// 8 KB tail of whatever the child managed to print, so `!r.ok && !r.stdout`
|
|
315
337
|
// is false for a tick that emitted its `{"event":"poll"}` line and then
|
|
@@ -457,6 +479,11 @@ let _enabled = false;
|
|
|
457
479
|
let _starting = false;
|
|
458
480
|
// The tick in flight, so the interval can skip rather than stack. See tick.
|
|
459
481
|
let _ticking = null;
|
|
482
|
+
// What every tick waits for before it runs anything: the launcher's word that
|
|
483
|
+
// the claude-swap it drives is not being installed or upgraded underneath it.
|
|
484
|
+
// Settled by default, so a caller with nothing to wait for — a respawn, a test,
|
|
485
|
+
// the dev server — ticks exactly as before. See initCswapAuto (#1043).
|
|
486
|
+
let _toolQuiet = Promise.resolve();
|
|
460
487
|
|
|
461
488
|
async function loadState() {
|
|
462
489
|
try { return JSON.parse(await readFile(STATE_PATH, "utf8")); } catch { return {}; }
|
|
@@ -499,6 +526,16 @@ function forgetAccountScopedCaches() {
|
|
|
499
526
|
}
|
|
500
527
|
|
|
501
528
|
async function runTick() {
|
|
529
|
+
// NOT WHILE THE TOOL IS BEING SET UP (#1043). A tick is `cswap auto --once`,
|
|
530
|
+
// which moves the user's live Claude credentials, and the boot arms this loop
|
|
531
|
+
// the moment the port binds — while bin/deck.js may still be installing
|
|
532
|
+
// claude-swap, or has just fired an upgrade of it that nothing awaits. It sits
|
|
533
|
+
// here rather than in startLoop so that it holds every tick that could land in
|
|
534
|
+
// that window: the boot's eager one, an interval that comes round during a
|
|
535
|
+
// three-minute install, and one the user starts from the panel meanwhile.
|
|
536
|
+
// Everything below is asked after it, so a switch turned off while this was
|
|
537
|
+
// waiting is seen as off.
|
|
538
|
+
await _toolQuiet;
|
|
502
539
|
// Re-check each time: the user can start their own loop at any point, and
|
|
503
540
|
// the deck should fall silent rather than compete with it.
|
|
504
541
|
if (await externalAutoRunning()) {
|
|
@@ -630,8 +667,19 @@ export async function setAutoEnabled(enabled) {
|
|
|
630
667
|
return { ok: true, enabled: _enabled };
|
|
631
668
|
}
|
|
632
669
|
|
|
633
|
-
/**
|
|
634
|
-
|
|
670
|
+
/**
|
|
671
|
+
* Restore the persisted setting at server boot.
|
|
672
|
+
*
|
|
673
|
+
* `after` is the launcher's promise that claude-swap is quiet: its startup job
|
|
674
|
+
* has settled, and so has any upgrade that job started without waiting for.
|
|
675
|
+
* index.mjs passes on what bin/deck.js hands startServer. With nothing to wait
|
|
676
|
+
* for it is null, and the loop ticks as it always did. A rejection counts as
|
|
677
|
+
* settled, because a job that failed has nothing left running either.
|
|
678
|
+
*
|
|
679
|
+
* The loop still starts here and reads its interval here. Only the ticks wait.
|
|
680
|
+
*/
|
|
681
|
+
export async function initCswapAuto({ after = null } = {}) {
|
|
682
|
+
_toolQuiet = Promise.resolve(after).then(() => {}, () => {});
|
|
635
683
|
const state = await loadState();
|
|
636
684
|
if (state.enabled) { _enabled = true; await startLoop(); }
|
|
637
685
|
}
|
|
@@ -649,10 +697,41 @@ export async function autoStatus() {
|
|
|
649
697
|
|
|
650
698
|
// ── per-account rotation flag ──────────────────────────────────────────────
|
|
651
699
|
|
|
652
|
-
/** Hold an account out of auto-rotation, or return it.
|
|
700
|
+
/** Hold an account out of auto-rotation, or return it.
|
|
701
|
+
*
|
|
702
|
+
* UNDER THE MUTEX AND FOLLOWED BY AN INVALIDATION, like the nine other
|
|
703
|
+
* mutations of claude-swap's store, and this was the one that was neither.
|
|
704
|
+
*
|
|
705
|
+
* `cswap enable/disable` writes `accounts[N].disabled` in sequence.json, which
|
|
706
|
+
* is the field the roster reads at claude-accounts.mjs:734 — so it is the
|
|
707
|
+
* read-modify-write cswap-admin.mjs opens by explaining is why every mutation
|
|
708
|
+
* goes through one mutex.
|
|
709
|
+
*
|
|
710
|
+
* And the missing invalidation is what made the press look like a no-op. The
|
|
711
|
+
* panel polls every 15s and each poll stamps `_lastReadAt`, so that stamp is
|
|
712
|
+
* never more than 15s old. The press then reloads with ?refresh=1, and a
|
|
713
|
+
* forced read needs `now - _lastReadAt >= FORCE_POLL_MS` (60s) — a quantity
|
|
714
|
+
* that can never be reached while the panel is open. The read was refused
|
|
715
|
+
* every time and `heldReading` handed back the pre-press roster: the chip did
|
|
716
|
+
* not move, the button still said "hold out", and nothing had failed.
|
|
717
|
+
* invalidateClaudeAccountsCache sets `_lastReadAt = 0`, which is what makes
|
|
718
|
+
* the following forced read real work — the invalidator's own docblock says
|
|
719
|
+
* so, and calls a floor that answers ?refresh=1 with the stale roster "the
|
|
720
|
+
* guard being the bug". */
|
|
653
721
|
export async function setAccountEnabled(accountNum, enabled) {
|
|
654
722
|
const num = Number(accountNum);
|
|
655
723
|
if (!Number.isInteger(num) || num < 1 || num > 999) return { ok: false, reason: "bad_account" };
|
|
656
|
-
|
|
657
|
-
|
|
724
|
+
// Statically, from store-lock.mjs, like the tick above. #950 reached for it
|
|
725
|
+
// through a dynamic `import("./cswap-admin.mjs")` to avoid adding a static
|
|
726
|
+
// edge between two modules that already imported each other, which was the
|
|
727
|
+
// right call while the lock lived inside one of them. It no longer does
|
|
728
|
+
// (#1039), and store-lock.mjs imports nothing at all — so there is no edge to
|
|
729
|
+
// avoid, and the same function arrives without loading the admin surface to
|
|
730
|
+
// get it.
|
|
731
|
+
return withStoreLock(async () => {
|
|
732
|
+
const r = await run(await cswapBin(), [enabled ? "enable" : "disable", String(num)]);
|
|
733
|
+
if (!r.ok) return { ok: false, reason: "command_failed", detail: (r.stderr || r.stdout).trim().slice(0, 300) };
|
|
734
|
+
invalidateClaudeAccountsCache();
|
|
735
|
+
return { ok: true };
|
|
736
|
+
});
|
|
658
737
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// caller prints what this returns, and AGENTS_DECK_NO_INSTALL=1 turns it off
|
|
8
8
|
// entirely. It is always best-effort — the deck's core function does not
|
|
9
9
|
// depend on it, so a failure is reported and then ignored.
|
|
10
|
-
import { run
|
|
10
|
+
import { run } from "./exec.mjs";
|
|
11
11
|
// The version comparator was written out here as well, identical apart from a
|
|
12
12
|
// type guard this copy lacked, and only the self-update one was under test
|
|
13
13
|
// (#374). No cycle: self-update.mjs imports node:* and ./exec.mjs, which this
|
|
@@ -138,6 +138,65 @@ export function cswapCandidates(platform = process.platform, env = process.env,
|
|
|
138
138
|
// — `cswap` is only the console script.
|
|
139
139
|
const PKG = "claude-swap";
|
|
140
140
|
|
|
141
|
+
// ── which versions of it this deck is willing to install ─────────────────────
|
|
142
|
+
//
|
|
143
|
+
// The install used to be the bare name — `uv tool install claude-swap` — which
|
|
144
|
+
// means "whatever that project publishes next, forever", resolved on a machine
|
|
145
|
+
// the author will never see, and then handed `cswap export -`, `add` and
|
|
146
|
+
// `import`: every command in the accounts panel that carries a Claude refresh
|
|
147
|
+
// token. Forty lines away, uv itself is fetched only after its SHA-256 is
|
|
148
|
+
// checked against the one Astral publishes beside it (uv-bootstrap.mjs). The
|
|
149
|
+
// package that holds the credentials had no bound of any kind.
|
|
150
|
+
//
|
|
151
|
+
// FLOOR: the newest release at the time this bound was written, which is the
|
|
152
|
+
// version the accounts panel is developed and exercised against. It is NOT a
|
|
153
|
+
// claim that 0.25 would fail — nothing here has tested that, and a copy the
|
|
154
|
+
// user installed themselves is left alone whatever its number. What it says is
|
|
155
|
+
// that the deck has an opinion at all, so that a resolver answering an install
|
|
156
|
+
// with something much older — a stale mirror, a private index on
|
|
157
|
+
// PIP_INDEX_URL, a yank of everything newer — is answering a question the deck
|
|
158
|
+
// did not ask, and can be told so. Raise it deliberately, with claude-swap's
|
|
159
|
+
// release notes open.
|
|
160
|
+
//
|
|
161
|
+
// CEILING: the next major. claude-swap is 0.x and ships a minor every couple of
|
|
162
|
+
// weeks, so a ceiling tight enough to stop a hostile 0.27 would stop every real
|
|
163
|
+
// release too and rot within the month. What this ceiling does stop is the
|
|
164
|
+
// shape a takeover actually takes — a version number picked to win every
|
|
165
|
+
// resolution, `9.9.9` — and a 1.0 arriving unattended while the deck's caller
|
|
166
|
+
// list still speaks 0.x.
|
|
167
|
+
//
|
|
168
|
+
// Spelled `~=0.26` and not `>=0.26,<1`, which is the same set. PEP 440's
|
|
169
|
+
// compatible-release operator expands to `>= 0.26, == 0.*`, and the spelling
|
|
170
|
+
// with no `<` or `>` in it cannot be read as redirection by anything that ever
|
|
171
|
+
// sees this argument as text: `run` spawns with shell:false, but the Windows
|
|
172
|
+
// leg routes a `.cmd`/`.bat` shim through cmd.exe (exec.mjs viaCmd), where `<`
|
|
173
|
+
// and `>` are syntax while outside quotes. That quoting is correct and tested;
|
|
174
|
+
// not needing it is better.
|
|
175
|
+
// Dated for the reason uv-bootstrap.mjs dates its FALLBACK_VERSION: a floor
|
|
176
|
+
// rots silently. Nothing fails when it falls behind — it simply stops being the
|
|
177
|
+
// version anyone is developing against — so no test and no user will report it.
|
|
178
|
+
const MIN_VERSION = "0.26"; // latest on PyPI as of 2026-09-15
|
|
179
|
+
const MAX_VERSION = "1"; // the first one this deck will not run
|
|
180
|
+
const RANGE_SPEC = `${PKG}~=${MIN_VERSION}`; // >= 0.26, == 0.*
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* True when `v` is a version this deck is willing to put in front of the user's
|
|
184
|
+
* credentials.
|
|
185
|
+
*
|
|
186
|
+
* Deliberately false for anything without a leading number, which is what
|
|
187
|
+
* `versionIn` answers ("installed") for a copy that printed no version at all.
|
|
188
|
+
* That is an absence of evidence rather than evidence of a bad version, so the
|
|
189
|
+
* two callers that can act on it check for it themselves rather than reading a
|
|
190
|
+
* `false` here as a verdict.
|
|
191
|
+
*/
|
|
192
|
+
export function isAcceptableVersion(v) {
|
|
193
|
+
if (typeof v !== "string" || !/^\d/.test(v)) return false;
|
|
194
|
+
return !isOlder(v, MIN_VERSION) && isOlder(v, MAX_VERSION);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Two version strings naming the same release — `0.26` and `0.26.0` do. */
|
|
198
|
+
function sameVersion(a, b) { return !isOlder(a, b) && !isOlder(b, a); }
|
|
199
|
+
|
|
141
200
|
/** True when `child` is `dir` or lives under it, in `platform`'s path flavour. */
|
|
142
201
|
function underDir(child, dir, platform) {
|
|
143
202
|
const { sep, normalize } = platform === "win32" ? winPath : posixPath;
|
|
@@ -220,10 +279,13 @@ function realpathOrSelf(p) {
|
|
|
220
279
|
*
|
|
221
280
|
* and pipx, given someone else's package, answers "Package is not installed.
|
|
222
281
|
* Expected to find <PIPX_HOME>/venvs/claude-swap, but it does not exist." Both
|
|
223
|
-
*
|
|
282
|
+
* went through runDetached, which read no output and waited for no exit, so the
|
|
224
283
|
* refusal reached nobody while ensureCswap still reported "upgrading" and the
|
|
225
284
|
* marker was already burned for the day. The version never moved and the deck
|
|
226
|
-
* said it was moving, every launch, forever.
|
|
285
|
+
* said it was moving, every launch, forever. The upgrade is captured now (#1000)
|
|
286
|
+
* and a refusal is at least written down — but aiming it correctly is still
|
|
287
|
+
* this function's job, and a recorded failure is a worse outcome than one that
|
|
288
|
+
* never had to happen.
|
|
227
289
|
*
|
|
228
290
|
* Two signals, strongest first. The executable the deck actually runs, with its
|
|
229
291
|
* symlinks followed, sitting inside one installer's directory is decisive —
|
|
@@ -435,11 +497,20 @@ async function safePythons() {
|
|
|
435
497
|
* default. Several entries can share an owner — the bundled uv upgrades what
|
|
436
498
|
* the system uv installed and vice versa, since both read UV_TOOL_DIR — so the
|
|
437
499
|
* probe still decides WHICH of an owner's spellings runs.
|
|
500
|
+
*
|
|
501
|
+
* The INSTALL line carries a version specifier and the UPGRADE line carries the
|
|
502
|
+
* bare name, which is not an inconsistency: both installers record what they
|
|
503
|
+
* were asked for and re-resolve an upgrade against that recorded requirement —
|
|
504
|
+
* uv in the tool's `uv-receipt.toml`, pipx in `pipx_metadata.json`'s
|
|
505
|
+
* `package_or_url` — and neither `uv tool upgrade` nor `pipx upgrade` accepts a
|
|
506
|
+
* specifier in the first place. So the bound set here at install time is the
|
|
507
|
+
* bound every later upgrade of that install is resolved inside, and the deck
|
|
508
|
+
* states it once, where it is actually read.
|
|
438
509
|
*/
|
|
439
|
-
async function installers() {
|
|
510
|
+
async function installers(spec = RANGE_SPEC) {
|
|
440
511
|
const out = [
|
|
441
|
-
{ cmd: "uv", probe: ["--version"], args: ["tool", "install",
|
|
442
|
-
{ cmd: "pipx", probe: ["--version"], args: ["install",
|
|
512
|
+
{ cmd: "uv", probe: ["--version"], args: ["tool", "install", spec], upgrade: ["tool", "upgrade", PKG], via: "uv", owner: "uv" },
|
|
513
|
+
{ cmd: "pipx", probe: ["--version"], args: ["install", spec], upgrade: ["upgrade", PKG], via: "pipx", owner: "pipx" },
|
|
443
514
|
];
|
|
444
515
|
// A uv fetched on an earlier run counts as installed tooling from here on.
|
|
445
516
|
const own = existingBootstrappedUv();
|
|
@@ -447,8 +518,8 @@ async function installers() {
|
|
|
447
518
|
out.push({
|
|
448
519
|
cmd: own,
|
|
449
520
|
probe: ["--version"],
|
|
450
|
-
args: ["tool", "install",
|
|
451
|
-
upgrade: ["tool", "upgrade",
|
|
521
|
+
args: ["tool", "install", spec],
|
|
522
|
+
upgrade: ["tool", "upgrade", PKG],
|
|
452
523
|
via: "uv (bundled)",
|
|
453
524
|
owner: "uv",
|
|
454
525
|
});
|
|
@@ -457,8 +528,8 @@ async function installers() {
|
|
|
457
528
|
out.push({
|
|
458
529
|
cmd: py,
|
|
459
530
|
probe: ["-m", "pipx", "--version"],
|
|
460
|
-
args: ["-m", "pipx", "install",
|
|
461
|
-
upgrade: ["-m", "pipx", "upgrade",
|
|
531
|
+
args: ["-m", "pipx", "install", spec],
|
|
532
|
+
upgrade: ["-m", "pipx", "upgrade", PKG],
|
|
462
533
|
via: `${py} -m pipx`,
|
|
463
534
|
owner: "pipx",
|
|
464
535
|
});
|
|
@@ -467,11 +538,21 @@ async function installers() {
|
|
|
467
538
|
}
|
|
468
539
|
|
|
469
540
|
async function installCswap() {
|
|
470
|
-
for
|
|
541
|
+
// What to ask for is decided HERE, before anything is installed, so that what
|
|
542
|
+
// arrives can be checked against it afterwards — see ensureCswap. When the
|
|
543
|
+
// registry answers, the deck names one exact version and can then confirm
|
|
544
|
+
// that exact version came back; when it does not, the range still holds and
|
|
545
|
+
// the check falls back to the range. The lookup is the same one request the
|
|
546
|
+
// daily check already makes, bounded at six seconds in front of an install
|
|
547
|
+
// that takes minutes.
|
|
548
|
+
const want = await newestAcceptableOnPypi();
|
|
549
|
+
const spec = want ? `${PKG}==${want}` : RANGE_SPEC;
|
|
550
|
+
|
|
551
|
+
for (const { cmd, probe, args, via } of await installers(spec)) {
|
|
471
552
|
if (!(await run(cmd, probe, { timeout: 8_000 })).ok) continue;
|
|
472
553
|
const r = await run(cmd, args, { timeout: INSTALL_TIMEOUT_MS });
|
|
473
|
-
if (r.ok) return { ok: true, via };
|
|
474
|
-
return { ok: false, reason: "install_failed", via, detail: (r.stderr || r.stdout).trim().slice(0, 300) };
|
|
554
|
+
if (r.ok) return { ok: true, via, want, spec };
|
|
555
|
+
return { ok: false, reason: "install_failed", via, spec, detail: (r.stderr || r.stdout).trim().slice(0, 300) };
|
|
475
556
|
}
|
|
476
557
|
|
|
477
558
|
// Nothing on the machine can install a Python application. Rather than hand
|
|
@@ -480,9 +561,9 @@ async function installCswap() {
|
|
|
480
561
|
const boot = await bootstrapUv();
|
|
481
562
|
if (!boot.ok) return { ok: false, reason: "no_installer", bootstrap: boot.reason, hint: await installHint() };
|
|
482
563
|
|
|
483
|
-
const r = await run(boot.bin, ["tool", "install",
|
|
484
|
-
if (r.ok) return { ok: true, via: `uv ${boot.version} (fetched)
|
|
485
|
-
return { ok: false, reason: "install_failed", via: "uv (fetched)", detail: (r.stderr || r.stdout).trim().slice(0, 300) };
|
|
564
|
+
const r = await run(boot.bin, ["tool", "install", spec], { timeout: INSTALL_TIMEOUT_MS });
|
|
565
|
+
if (r.ok) return { ok: true, via: `uv ${boot.version} (fetched)`, want, spec };
|
|
566
|
+
return { ok: false, reason: "install_failed", via: "uv (fetched)", spec, detail: (r.stderr || r.stdout).trim().slice(0, 300) };
|
|
486
567
|
}
|
|
487
568
|
|
|
488
569
|
/**
|
|
@@ -493,19 +574,38 @@ async function installCswap() {
|
|
|
493
574
|
* uv is a single self-contained binary and is what claude-swap documents, so
|
|
494
575
|
* that is what gets recommended; if the machine already has Python, pipx via
|
|
495
576
|
* pip is offered instead because it uses something already installed.
|
|
577
|
+
*
|
|
578
|
+
* WHAT IS NOT HERE, AND WHY. This used to end in
|
|
579
|
+
* `curl -LsSf https://astral.sh/uv/install.sh | sh` — and its PowerShell twin
|
|
580
|
+
* `irm … | iex` — which is the exact command uv-bootstrap.mjs opens by naming
|
|
581
|
+
* and declining, for the exact reason given there: it executes whatever that
|
|
582
|
+
* URL happens to serve, with the user's privileges. This string is not
|
|
583
|
+
* scrollback. It reaches the browser as `hint` on the `no_cswap` roster reply
|
|
584
|
+
* and renders in the accounts panel as the command to run, and the machines
|
|
585
|
+
* that see it are disproportionately the ones that set
|
|
586
|
+
* AGENTS_DECK_NO_DOWNLOAD=1 — the reason the deck could not fetch a verified uv
|
|
587
|
+
* for them is often that they asked it not to fetch binaries. Handing that user
|
|
588
|
+
* a shell pipe is the same thing at one remove. A package manager's name is
|
|
589
|
+
* offered instead: slower to type, and the artifact is one somebody else has
|
|
590
|
+
* already checked.
|
|
496
591
|
*/
|
|
497
592
|
export async function installHint() {
|
|
498
|
-
const
|
|
499
|
-
|
|
500
|
-
: "
|
|
593
|
+
const getUv = {
|
|
594
|
+
darwin: "brew install uv",
|
|
595
|
+
win32: "winget install --id astral-sh.uv -e",
|
|
596
|
+
}[process.platform]
|
|
597
|
+
// No pipe, and no command invented for a distribution that may not carry
|
|
598
|
+
// one: on Linux uv is packaged by some distributions and not others, so the
|
|
599
|
+
// honest answer is where to look.
|
|
600
|
+
|| "install uv from your package manager — https://docs.astral.sh/uv/getting-started/installation/";
|
|
501
601
|
for (const py of await safePythons()) {
|
|
502
602
|
if ((await run(py, ["-c", "import sys"], { timeout: 5_000 })).ok) {
|
|
503
603
|
// Quoted: a resolved Windows path routinely contains spaces.
|
|
504
604
|
const q = /\s/.test(py) ? `"${py}"` : py;
|
|
505
|
-
return `${q} -m pip install --user pipx && ${q} -m pipx install
|
|
605
|
+
return `${q} -m pip install --user pipx && ${q} -m pipx install "${RANGE_SPEC}"`;
|
|
506
606
|
}
|
|
507
607
|
}
|
|
508
|
-
return uv
|
|
608
|
+
return `${getUv} (then: uv tool install "${RANGE_SPEC}")`;
|
|
509
609
|
}
|
|
510
610
|
|
|
511
611
|
function updateCheckDue() {
|
|
@@ -519,37 +619,148 @@ function touchMarker() {
|
|
|
519
619
|
} catch { /* ignore */ }
|
|
520
620
|
}
|
|
521
621
|
|
|
522
|
-
/**
|
|
523
|
-
|
|
622
|
+
/**
|
|
623
|
+
* The newest claude-swap on PyPI that this deck would actually install, or null
|
|
624
|
+
* when the registry cannot be asked.
|
|
625
|
+
*
|
|
626
|
+
* "Newest acceptable" and not `info.version`, which is PyPI's own idea of
|
|
627
|
+
* latest and is bounded by nothing here. Reading that one and then installing
|
|
628
|
+
* inside a bound is two different questions answered by two different parties,
|
|
629
|
+
* and the disagreement between them is a daily lie of the kind this file has
|
|
630
|
+
* already been bitten by twice (#579): the day claude-swap ships 1.0.0, every
|
|
631
|
+
* deck would see a newer version, fire an upgrade its own specifier forbids,
|
|
632
|
+
* and print "upgrading to v1.0.0 in background" at every launch, forever, about
|
|
633
|
+
* a version that can never arrive. So the bound decides what "newest" means,
|
|
634
|
+
* once, and the upgrade decision and the install target are the same answer.
|
|
635
|
+
*
|
|
636
|
+
* Pre-releases are skipped — claude-swap publishes `0.21.0b1` and friends, and
|
|
637
|
+
* an unattended upgrade is not the place to chase them — as are releases whose
|
|
638
|
+
* every file has been yanked, which is the author withdrawing them.
|
|
639
|
+
*/
|
|
640
|
+
async function newestAcceptableOnPypi() {
|
|
524
641
|
try {
|
|
525
642
|
const res = await fetch("https://pypi.org/pypi/claude-swap/json", {
|
|
526
643
|
signal: AbortSignal.timeout(6_000),
|
|
527
644
|
});
|
|
528
645
|
if (!res.ok) return null;
|
|
529
|
-
const
|
|
530
|
-
|
|
646
|
+
const body = await res.json();
|
|
647
|
+
const releases = body?.releases;
|
|
648
|
+
const names = releases && typeof releases === "object"
|
|
649
|
+
? Object.keys(releases)
|
|
650
|
+
// A body with no `releases` map still names one version, and it is judged
|
|
651
|
+
// by exactly the same rule as any other.
|
|
652
|
+
: (typeof body?.info?.version === "string" ? [body.info.version] : []);
|
|
653
|
+
|
|
654
|
+
let best = null;
|
|
655
|
+
for (const v of names) {
|
|
656
|
+
if (!/^\d+(\.\d+)*$/.test(v)) continue; // final releases only
|
|
657
|
+
if (!isAcceptableVersion(v)) continue;
|
|
658
|
+
const files = releases?.[v];
|
|
659
|
+
if (Array.isArray(files) && files.length > 0 && files.every(f => f?.yanked)) continue;
|
|
660
|
+
if (best === null || isOlder(best, v)) best = v;
|
|
661
|
+
}
|
|
662
|
+
return best;
|
|
531
663
|
} catch {
|
|
532
664
|
return null;
|
|
533
665
|
}
|
|
534
666
|
}
|
|
535
667
|
|
|
536
668
|
/**
|
|
537
|
-
*
|
|
669
|
+
* What the last background upgrade did, written where a person can read it.
|
|
538
670
|
*
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
|
|
671
|
+
* One file, overwritten, holding the last outcome only: this is a record of
|
|
672
|
+
* what landed on the machine, not a history of the project's releases.
|
|
673
|
+
*/
|
|
674
|
+
const UPGRADE_RECORD = join(homedir(), ".agents-deck", "cswap-upgrade.json");
|
|
675
|
+
|
|
676
|
+
function recordUpgrade(entry) {
|
|
677
|
+
try {
|
|
678
|
+
mkdirSync(join(homedir(), ".agents-deck"), { recursive: true });
|
|
679
|
+
writeFileSync(UPGRADE_RECORD, JSON.stringify(entry, null, 2) + "\n");
|
|
680
|
+
} catch { /* a note about an upgrade is not worth failing the upgrade over */ }
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
let _upgrade = null;
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* The background upgrade started by the most recent `ensureCswap`, as a promise
|
|
687
|
+
* that resolves to what was recorded — or null when this run started none.
|
|
688
|
+
*
|
|
689
|
+
* Exported for its test rather than for a caller, the way resetCswapBin and
|
|
690
|
+
* cswapCandidates are: ensureCswap deliberately does not await this, so without
|
|
691
|
+
* a handle on it the only observable part of an upgrade would once again be the
|
|
692
|
+
* sentence printed before it happens.
|
|
693
|
+
*/
|
|
694
|
+
export function upgradeSettled() { return _upgrade; }
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Upgrade claude-swap in the background when a newer release exists, and find
|
|
698
|
+
* out what happened.
|
|
699
|
+
*
|
|
700
|
+
* STILL UNAWAITED, and that has not changed: an upgrade resolves a Python
|
|
701
|
+
* environment and can take tens of seconds, which is not a thing to put in
|
|
702
|
+
* front of the server starting. The running copy keeps working; the new one is
|
|
703
|
+
* there next launch.
|
|
704
|
+
*
|
|
705
|
+
* What changed is that it is no longer spawned BLIND. This was `runDetached`,
|
|
706
|
+
* which is `stdio: "ignore"` and no exit listener — so the deck fired a command
|
|
707
|
+
* that replaces the binary holding Claude refresh tokens and then had no way,
|
|
708
|
+
* ever, to say whether it ran, what it exited with, or which version came back.
|
|
709
|
+
* The whole announcement was one boot row naming the version being LEFT. A
|
|
710
|
+
* captured `run` costs nothing the detached one saved — nobody waits for either
|
|
711
|
+
* — and it buys the three facts worth having: it ran, it exited zero, and the
|
|
712
|
+
* version now answering is one this deck is willing to drive.
|
|
713
|
+
*
|
|
714
|
+
* `INSTALL_TIMEOUT_MS`, not `run`'s 20-second default: this is the same
|
|
715
|
+
* environment build the install path allows three minutes for, and a 20-second
|
|
716
|
+
* cap would report a healthy slow upgrade as a killed one.
|
|
542
717
|
*
|
|
543
718
|
* The command line comes from the installer entry rather than from its label,
|
|
544
719
|
* and the ENTRY comes from cswapOwner rather than from whichever tool answers a
|
|
545
|
-
* probe first:
|
|
546
|
-
*
|
|
720
|
+
* probe first: an upgrade aimed at the wrong tool is refused, and used to be
|
|
721
|
+
* refused where nobody could see it while the caller still reported
|
|
547
722
|
* "upgrading". Those are the two halves of "aimed at the wrong tool" — a right
|
|
548
723
|
* argv sent to a tool that does not own the package is just as invisible as a
|
|
549
|
-
* wrong argv, and was the longer-lived of the two.
|
|
724
|
+
* wrong argv, and was the longer-lived of the two. Both are now written down.
|
|
550
725
|
*/
|
|
551
|
-
function upgradeInBackground({ cmd, upgrade }) {
|
|
552
|
-
|
|
726
|
+
function upgradeInBackground({ cmd, upgrade, via }, { from, want }) {
|
|
727
|
+
_upgrade = (async () => {
|
|
728
|
+
let entry;
|
|
729
|
+
try {
|
|
730
|
+
const r = await run(cmd, upgrade, { timeout: INSTALL_TIMEOUT_MS });
|
|
731
|
+
// The version read below must be taken AFTER the upgrade, and cswapBin
|
|
732
|
+
// memoizes the last `--version` it saw for PROBE_FRESH_MS. An upgrade
|
|
733
|
+
// that finds nothing to do returns in well under those five seconds, so
|
|
734
|
+
// without this the recorded "to" could be the reading from before the
|
|
735
|
+
// command ran — the one number this whole function exists to replace.
|
|
736
|
+
resetCswapBin();
|
|
737
|
+
const to = await cswapVersion();
|
|
738
|
+
entry = {
|
|
739
|
+
from,
|
|
740
|
+
want: want ?? null,
|
|
741
|
+
to,
|
|
742
|
+
at: new Date().toISOString(),
|
|
743
|
+
via: via ?? null,
|
|
744
|
+
ok: r.ok && typeof to === "string" && (to === "installed" || isAcceptableVersion(to)),
|
|
745
|
+
// Why not, when not. An upgrade that exits non-zero and one that lands
|
|
746
|
+
// a version outside the bound are very different events, and the second
|
|
747
|
+
// is the one worth a person's attention: the specifier this deck
|
|
748
|
+
// installs with does not permit it, so something other than that
|
|
749
|
+
// specifier decided what to install.
|
|
750
|
+
reason: !r.ok
|
|
751
|
+
? "command_failed"
|
|
752
|
+
: (typeof to === "string" && to !== "installed" && !isAcceptableVersion(to))
|
|
753
|
+
? "unexpected_version"
|
|
754
|
+
: null,
|
|
755
|
+
detail: r.ok ? null : (r.stderr || r.stdout).trim().slice(0, 300),
|
|
756
|
+
};
|
|
757
|
+
} catch (e) {
|
|
758
|
+
entry = { from, want: want ?? null, to: null, at: new Date().toISOString(), via: via ?? null,
|
|
759
|
+
ok: false, reason: "threw", detail: String(e?.message ?? e).slice(0, 300) };
|
|
760
|
+
}
|
|
761
|
+
recordUpgrade(entry);
|
|
762
|
+
return entry;
|
|
763
|
+
})();
|
|
553
764
|
}
|
|
554
765
|
|
|
555
766
|
/**
|
|
@@ -587,6 +798,11 @@ async function findUpgrader(owner) {
|
|
|
587
798
|
* three minutes to arrive.
|
|
588
799
|
*/
|
|
589
800
|
export async function ensureCswap({ onInstalling = null } = {}) {
|
|
801
|
+
// Whatever a previous call left behind is a previous call's outcome, and
|
|
802
|
+
// upgradeSettled() answering with it would be the same class of stale reading
|
|
803
|
+
// resetCswapBin exists to prevent.
|
|
804
|
+
_upgrade = null;
|
|
805
|
+
|
|
590
806
|
if (process.env.AGENTS_DECK_NO_INSTALL === "1") {
|
|
591
807
|
const version = await cswapVersion();
|
|
592
808
|
return version ? { state: "present", version } : { state: "skipped" };
|
|
@@ -602,17 +818,17 @@ export async function ensureCswap({ onInstalling = null } = {}) {
|
|
|
602
818
|
// before Wi-Fi associates — burned the whole shared 24-hour window on a
|
|
603
819
|
// check that never reached PyPI, and the next real chance was the day
|
|
604
820
|
// after. self-update.mjs states this rule for itself in as many words.
|
|
605
|
-
const latest = await
|
|
821
|
+
const latest = await newestAcceptableOnPypi();
|
|
606
822
|
touchMarker();
|
|
607
823
|
if (latest && existing !== "installed" && isOlder(existing, latest)) {
|
|
608
824
|
// Who owns it, not what is installed on the machine: an upgrade aimed at
|
|
609
|
-
// a tool that never installed this package is refused
|
|
610
|
-
//
|
|
611
|
-
//
|
|
612
|
-
//
|
|
825
|
+
// a tool that never installed this package is refused, and "upgrading"
|
|
826
|
+
// would then be a sentence printed daily about nothing. When nobody
|
|
827
|
+
// offered here owns it, "present" is the whole truth and is what gets
|
|
828
|
+
// said.
|
|
613
829
|
const found = await findUpgrader(cswapOwner(await cswapBin()));
|
|
614
830
|
if (found) {
|
|
615
|
-
upgradeInBackground(found);
|
|
831
|
+
upgradeInBackground(found, { from: existing, want: latest });
|
|
616
832
|
return { state: "upgrading", version: existing, latest, via: found.via };
|
|
617
833
|
}
|
|
618
834
|
}
|
|
@@ -635,7 +851,28 @@ export async function ensureCswap({ onInstalling = null } = {}) {
|
|
|
635
851
|
// of the shell that launched us — cswapBin looks there directly, so this
|
|
636
852
|
// confirms the install rather than confirming the user's PATH.
|
|
637
853
|
const version = await cswapVersion();
|
|
638
|
-
return
|
|
639
|
-
|
|
640
|
-
|
|
854
|
+
if (!version) return { state: "unavailable", reason: "not_on_path", via: result.via };
|
|
855
|
+
|
|
856
|
+
// WHAT WAS ASKED FOR IS NOT WHAT ARRIVED. The deck names an exact version
|
|
857
|
+
// (or, with no registry answer, a range) on the install command line, so a
|
|
858
|
+
// different one coming back means something other than that specifier chose
|
|
859
|
+
// it — a local index, a shadowed `cswap` earlier on PATH, a resolver that did
|
|
860
|
+
// not honour the bound. The next thing this binary is handed is
|
|
861
|
+
// `cswap export -`, which prints a Claude refresh token to stdout. Refusing
|
|
862
|
+
// is the cheap half of that trade: the accounts panel stays dark and the boot
|
|
863
|
+
// row says why, against a copy of the tool the deck cannot account for.
|
|
864
|
+
//
|
|
865
|
+
// A version that does not parse at all — `versionIn` answers "installed" —
|
|
866
|
+
// is deliberately NOT refused. That is an absence of evidence, it is an
|
|
867
|
+
// outcome this file already treats as ordinary (see the `existing !==
|
|
868
|
+
// "installed"` guard above), and turning it into a dark panel would trade a
|
|
869
|
+
// supply-chain risk for a certain outage the day `cswap --version` changes
|
|
870
|
+
// how it prints.
|
|
871
|
+
if (version !== "installed") {
|
|
872
|
+
const wrong = result.want ? !sameVersion(version, result.want) : !isAcceptableVersion(version);
|
|
873
|
+
if (wrong) {
|
|
874
|
+
return { state: "unavailable", reason: "unexpected_version", version, want: result.want ?? RANGE_SPEC, via: result.via };
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
return { state: "installed", via: result.via, version };
|
|
641
878
|
}
|
package/src/server/deck-home.mjs
CHANGED
|
@@ -55,7 +55,7 @@ const APP = "ccdeck";
|
|
|
55
55
|
/** Point the deck's whole home somewhere else in one variable. The escape
|
|
56
56
|
* hatch for a portable install, a test, or anybody who simply wants it
|
|
57
57
|
* elsewhere; it wins over every rule below. */
|
|
58
|
-
|
|
58
|
+
const HOME_ENV = "CCDECK_HOME";
|
|
59
59
|
|
|
60
60
|
/** Where the deck kept everything before this, and where two of its files still
|
|
61
61
|
* live. Also the place a migration reads from. */
|