ccdeck 3.29.11 → 3.31.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 +4 -3
- package/bin/agent-dag.js +67 -118
- package/bin/cli/help.js +67 -0
- package/bin/cli/login-item.js +182 -0
- package/bin/cli/one-shot.js +254 -0
- package/bin/cli/package.js +39 -0
- package/bin/cli/pulse.js +98 -0
- package/bin/cli/restart.js +179 -0
- package/bin/cli/screen.js +166 -0
- package/bin/cli/second-start.js +120 -0
- package/bin/cli/startup.js +459 -0
- package/bin/cli/uninstall.js +169 -0
- package/bin/deck.js +112 -1559
- package/dist/web/assets/BrowserWatchModal-n63GjLhv.js +1 -0
- package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
- package/dist/web/assets/index-CZJf1Cpp.js +299 -0
- package/dist/web/assets/index-D4q1ElXU.css +1 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +40 -0
- package/src/server/account-projects.mjs +25 -11
- package/src/server/account-routes.mjs +254 -0
- package/src/server/activity.mjs +3 -3
- package/src/server/append-budget.mjs +185 -0
- package/src/server/append-failures.mjs +147 -0
- package/src/server/args.mjs +37 -5
- package/src/server/atomic-write.mjs +300 -0
- package/src/server/block-notify.mjs +0 -5
- package/src/server/boot-lock.mjs +1 -1
- package/src/server/brand.mjs +7 -6
- package/src/server/browser-react.mjs +10 -2
- package/src/server/browser-watch-decks.mjs +146 -0
- package/src/server/browser-watch-feed.mjs +69 -0
- package/src/server/browser-watch-log.mjs +173 -0
- package/src/server/browser-watch-mtime.mjs +18 -0
- package/src/server/browser-watch-relay.mjs +112 -0
- package/src/server/browser-watch-routes.mjs +172 -0
- package/src/server/browser-watch-seen.mjs +127 -0
- package/src/server/browser-watch-store.mjs +14 -143
- package/src/server/browser-watch.mjs +278 -626
- package/src/server/canonical-path.mjs +150 -0
- package/src/server/ccusage-failure.mjs +64 -0
- package/src/server/ccusage-install.mjs +459 -0
- package/src/server/ccusage-runner.mjs +271 -0
- package/src/server/ccusage.mjs +21 -756
- package/src/server/claude-accounts.mjs +220 -464
- package/src/server/claude-collector.mjs +189 -0
- package/src/server/claude-dir.mjs +7 -3
- package/src/server/claude-identity.mjs +5 -4
- package/src/server/claude-reset-credits.mjs +130 -0
- package/src/server/claude-verdicts.mjs +174 -0
- package/src/server/clear-route.mjs +191 -0
- package/src/server/codex-auth.mjs +4 -4
- package/src/server/codex-dir.mjs +52 -8
- package/src/server/codex-enrichment.mjs +310 -0
- package/src/server/codex-quota.mjs +5 -3
- package/src/server/codex-translate.mjs +407 -0
- package/src/server/codex-usage.mjs +8 -5
- package/src/server/codex-watch.mjs +496 -0
- package/src/server/cswap-auto-loop.mjs +342 -0
- package/src/server/cswap-auto-readers.mjs +303 -0
- package/src/server/cswap-auto.mjs +51 -609
- package/src/server/cswap-install.mjs +69 -265
- package/src/server/cswap-layout.mjs +242 -0
- package/src/server/deck-home.mjs +2 -2
- package/src/server/deck-prefs.mjs +58 -184
- package/src/server/deck-scope.mjs +52 -0
- package/src/server/desktop-update-routes.mjs +75 -0
- package/src/server/detach.mjs +78 -0
- package/src/server/discovery.mjs +298 -0
- package/src/server/event-log.mjs +328 -0
- package/src/server/event-pipeline.mjs +235 -0
- package/src/server/event-ring.mjs +214 -0
- package/src/server/event-routes.mjs +352 -0
- package/src/server/event-sink.mjs +26 -0
- package/src/server/exec-children.mjs +128 -0
- package/src/server/exec-not-found.mjs +213 -0
- package/src/server/exec-output.mjs +23 -0
- package/src/server/exec-spec.mjs +341 -0
- package/src/server/exec.mjs +74 -661
- package/src/server/health-route.mjs +42 -0
- package/src/server/hook-challenge.mjs +37 -0
- package/src/server/http-io.mjs +104 -0
- package/src/server/index.mjs +174 -7620
- package/src/server/install-layout.mjs +506 -0
- package/src/server/installer.mjs +79 -608
- package/src/server/jsonl-chunks.mjs +139 -0
- package/src/server/lan-accounts.mjs +27 -0
- package/src/server/lan-beacon.mjs +457 -0
- package/src/server/lan-call.mjs +239 -0
- package/src/server/lan-copies.mjs +270 -0
- package/src/server/lan-deck.mjs +583 -0
- package/src/server/lan-dials.mjs +161 -0
- package/src/server/lan-engine.mjs +475 -1024
- package/src/server/lan-hearing.mjs +49 -0
- package/src/server/lan-inbound.mjs +100 -0
- package/src/server/lan-invite-offer.mjs +77 -0
- package/src/server/lan-invite.mjs +207 -0
- package/src/server/lan-lines.mjs +73 -0
- package/src/server/lan-manifest.mjs +74 -0
- package/src/server/lan-reach.mjs +82 -69
- package/src/server/lan-requests.mjs +249 -0
- package/src/server/lan-round-record.mjs +45 -0
- package/src/server/lan-round-timer.mjs +62 -0
- package/src/server/lan-routes.mjs +143 -0
- package/src/server/lan-serve.mjs +112 -0
- package/src/server/lan-socket.mjs +243 -925
- package/src/server/lan-sync.mjs +58 -1026
- package/src/server/lan-tailnet-poll.mjs +57 -0
- package/src/server/lan-turns.mjs +80 -0
- package/src/server/lan-wire.mjs +620 -0
- package/src/server/lifecycle.mjs +421 -0
- package/src/server/listen.mjs +110 -0
- package/src/server/live-decks.mjs +138 -0
- package/src/server/load-average.mjs +32 -0
- package/src/server/log-election.mjs +235 -0
- package/src/server/log-replay.mjs +374 -0
- package/src/server/log-writer.mjs +72 -557
- package/src/server/login-service.mjs +3 -2
- package/src/server/macmon.mjs +1 -1
- package/src/server/memory-files.mjs +163 -0
- package/src/server/memory-metrics.mjs +178 -0
- package/src/server/metrics-history.mjs +94 -0
- package/src/server/metrics-run.mjs +100 -0
- package/src/server/music-routes.mjs +134 -0
- package/src/server/network-metrics.mjs +5 -3
- package/src/server/network-sampler.mjs +256 -0
- package/src/server/npm-latest.mjs +385 -0
- package/src/server/npm-upgrade.mjs +352 -0
- package/src/server/npx.mjs +41 -7
- package/src/server/output-watch.mjs +5 -4
- package/src/server/pinned-build.mjs +110 -0
- package/src/server/prefs-give-back.mjs +206 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +86 -0
- package/src/server/prefs-routes.mjs +99 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +70 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -0
- package/src/server/provider-status.mjs +307 -0
- package/src/server/pulse-line.mjs +132 -0
- package/src/server/purge-key.mjs +7 -7
- package/src/server/quota-cli.mjs +174 -0
- package/src/server/quota-oauth.mjs +190 -0
- package/src/server/quota-shape.mjs +229 -0
- package/src/server/quota-store-resets.mjs +206 -0
- package/src/server/quota.mjs +123 -509
- package/src/server/relay-guard.mjs +3 -3
- package/src/server/request-gates.mjs +441 -0
- package/src/server/restart-note.mjs +180 -0
- package/src/server/retire-sound-hook.mjs +3 -3
- package/src/server/ring-bounds.mjs +215 -0
- package/src/server/running-deck.mjs +1 -1
- package/src/server/running-version.mjs +24 -0
- package/src/server/self-update.mjs +39 -1371
- package/src/server/session-enrichment.mjs +622 -0
- package/src/server/session-read-gate.mjs +49 -0
- package/src/server/session-tracking.mjs +242 -0
- package/src/server/sse-clients.mjs +222 -0
- package/src/server/stale-discovery.mjs +30 -0
- package/src/server/static-serve.mjs +81 -0
- package/src/server/supervisor.mjs +73 -12
- package/src/server/system-metrics.mjs +169 -1935
- package/src/server/term.mjs +11 -249
- package/src/server/thermal-metrics.mjs +520 -0
- package/src/server/thermal-sampler.mjs +204 -0
- package/src/server/token-redact.mjs +123 -0
- package/src/server/transcript-gate.mjs +107 -0
- package/src/server/transcript-scan.mjs +563 -0
- package/src/server/usage-routes.mjs +108 -0
- package/src/server/uv-bootstrap.mjs +1 -1
- package/src/server/wordmark.mjs +127 -0
- package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
- package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
- package/dist/web/assets/index-Ck2RAla3.css +0 -1
- package/dist/web/assets/index-jmrcEsCj.js +0 -298
|
@@ -8,14 +8,20 @@
|
|
|
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
10
|
import { run } from "./exec.mjs";
|
|
11
|
+
import { failureDetail } from "./exec-output.mjs";
|
|
11
12
|
// The version comparator was written out here as well, identical apart from a
|
|
12
13
|
// type guard this copy lacked, and only the self-update one was under test
|
|
13
|
-
// (#374). No cycle: self-update.mjs
|
|
14
|
-
//
|
|
14
|
+
// (#374). No cycle: self-update.mjs and the four modules it reads from
|
|
15
|
+
// (install-layout, npm-latest, restart-note, npm-upgrade) import node:* and
|
|
16
|
+
// three modules that reach nothing but leaves — exec.mjs, app-host.mjs and
|
|
17
|
+
// deck-probe.mjs — and none of them imports this file.
|
|
15
18
|
import { isOlder } from "./self-update.mjs";
|
|
16
19
|
import { bootstrapUv, existingBootstrappedUv } from "./uv-bootstrap.mjs";
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
// Where installers leave claude-swap and which one owns this machine's copy —
|
|
21
|
+
// pure layout, with the platform a parameter, so it lives on its own.
|
|
22
|
+
import { PKG, cswapCandidates, cswapOwner } from "./cswap-layout.mjs";
|
|
23
|
+
import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
19
25
|
import { homedir } from "node:os";
|
|
20
26
|
|
|
21
27
|
const INSTALL_TIMEOUT_MS = 180_000; // uv resolves + builds a Python env
|
|
@@ -25,127 +31,14 @@ const INSTALL_TIMEOUT_MS = 180_000; // uv resolves + builds a Python env
|
|
|
25
31
|
const UPDATE_CHECK_MS = 24 * 3600_000;
|
|
26
32
|
const MARKER = join(homedir(), ".agents-deck", ".cswap-update-check");
|
|
27
33
|
|
|
28
|
-
/**
|
|
29
|
-
* The subdirectories of `dir`, newest-looking first, or [] when it cannot be
|
|
30
|
-
* read at all.
|
|
31
|
-
*
|
|
32
|
-
* Injected into cswapCandidates below rather than called from it, for the reason
|
|
33
|
-
* the platform is a parameter there: a Windows layout has to be describable from
|
|
34
|
-
* a Mac. A missing directory, a disconnected network drive and a profile the
|
|
35
|
-
* process cannot read are all the same answer — nothing here — never a throw,
|
|
36
|
-
* because this runs unprompted at startup for an optional panel.
|
|
37
|
-
*
|
|
38
|
-
* The sort is numeric so `Python313` sorts above `Python39` rather than below
|
|
39
|
-
* it: when two interpreters both have a cswap, the newer one is the one the user
|
|
40
|
-
* most likely installed it with, and the order this returns is the order the
|
|
41
|
-
* caller probes in.
|
|
42
|
-
*
|
|
43
|
-
* Exported for its test rather than for a caller. Every part of it that can be
|
|
44
|
-
* wrong — which names count as an interpreter, what order they come back in,
|
|
45
|
-
* what a directory that cannot be read answers — is invisible from
|
|
46
|
-
* cswapCandidates, which injects a substitute precisely so its own Windows
|
|
47
|
-
* layout can be checked from a Mac. Driven against real directories in
|
|
48
|
-
* cswap-admin.test.ts, on whichever OS is running the suite.
|
|
49
|
-
*/
|
|
50
|
-
export function pythonVersionDirs(dir) {
|
|
51
|
-
try {
|
|
52
|
-
return readdirSync(dir, { withFileTypes: true })
|
|
53
|
-
.filter(e => e.isDirectory() || e.isSymbolicLink())
|
|
54
|
-
.map(e => e.name)
|
|
55
|
-
// `Python312`, and the tagged builds the installer also writes:
|
|
56
|
-
// `Python312-32`, `Python313-arm64`.
|
|
57
|
-
.filter(n => /^Python\d[\w.-]*$/i.test(n))
|
|
58
|
-
.sort((a, b) => b.localeCompare(a, "en", { numeric: true }));
|
|
59
|
-
} catch {
|
|
60
|
-
return [];
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* How to invoke cswap: the bare name when PATH resolves it, otherwise an
|
|
66
|
-
* absolute path to where its installers actually put it.
|
|
67
|
-
*
|
|
68
|
-
* ~/.local/bin is where both `uv tool install` and `pipx install` place
|
|
69
|
-
* executables, on every platform, and it is famously not on PATH — that is the
|
|
70
|
-
* whole reason `pipx ensurepath` exists. Installing claude-swap successfully
|
|
71
|
-
* and then reporting it as missing because the shell cannot see it is a bad
|
|
72
|
-
* enough outcome on its own; it is worse now that the deck may have done the
|
|
73
|
-
* installing. So PATH is a convenience here, not the source of truth.
|
|
74
|
-
*
|
|
75
|
-
* Re-resolved when a lookup fails so an install during this process is picked
|
|
76
|
-
* up without a restart.
|
|
77
|
-
*/
|
|
78
|
-
/**
|
|
79
|
-
* Every place an installer is known to leave cswap. The platform is a parameter
|
|
80
|
-
* and the directory listing is injected, so the Windows list can be checked from
|
|
81
|
-
* a Mac — which is the only way this list stays right, since it exists entirely
|
|
82
|
-
* for machines the author is not sitting at.
|
|
83
|
-
*/
|
|
84
|
-
export function cswapCandidates(platform = process.platform, env = process.env, home = homedir(), {
|
|
85
|
-
versionDirs = pythonVersionDirs,
|
|
86
|
-
} = {}) {
|
|
87
|
-
// The path flavour follows the PLATFORM ARGUMENT, not the host: node's `join`
|
|
88
|
-
// would emit forward slashes when this is exercised from a Mac, which is both
|
|
89
|
-
// wrong for the caller and invisible in a test.
|
|
90
|
-
const { join } = platform === "win32" ? winPath : posixPath;
|
|
91
|
-
const exe = platform === "win32" ? "cswap.exe" : "cswap";
|
|
92
|
-
const dirs = [];
|
|
93
|
-
// Explicit configuration first: someone who set these means them.
|
|
94
|
-
if (env.UV_TOOL_BIN_DIR) dirs.push(env.UV_TOOL_BIN_DIR);
|
|
95
|
-
if (env.XDG_BIN_HOME) dirs.push(env.XDG_BIN_HOME);
|
|
96
|
-
// Where `uv tool install` and `pipx install` put executables, everywhere.
|
|
97
|
-
dirs.push(join(home, ".local", "bin"));
|
|
98
|
-
if (platform === "win32") {
|
|
99
|
-
// pipx before 1.5, and any `pip install --user`. APPDATA is respected when
|
|
100
|
-
// set because a roaming profile moves it off the home directory.
|
|
101
|
-
//
|
|
102
|
-
// THE VERSION SEGMENT IS NOT OPTIONAL (#552). CPython on Windows always
|
|
103
|
-
// puts the interpreter between the root and `Scripts`:
|
|
104
|
-
//
|
|
105
|
-
// %APPDATA%\Python\Python312\Scripts pip install --user
|
|
106
|
-
// %LOCALAPPDATA%\Programs\Python\Python312\Scripts per-user installer
|
|
107
|
-
//
|
|
108
|
-
// — `{userbase}\Python{version_nodot}\Scripts` is sysconfig's `nt_user`
|
|
109
|
-
// scheme, not a convention. The two paths this used to build omitted it, so
|
|
110
|
-
// NEITHER could exist on a real machine: every candidate missed,
|
|
111
|
-
// `cswapVersion` answered null, `ensureCswap` reported `not_on_path`, and
|
|
112
|
-
// the deck re-ran a whole install attempt on every launch for a user who
|
|
113
|
-
// already had cswap.exe sitting there. The POSIX side never had the bug —
|
|
114
|
-
// `~/.local/bin` carries no version — which is why this stayed a Windows
|
|
115
|
-
// false negative in the one function whose whole purpose is to not depend
|
|
116
|
-
// on PATH.
|
|
117
|
-
//
|
|
118
|
-
// Which versions exist is a fact about the machine, so it is read rather
|
|
119
|
-
// than guessed: enumerating Python38…Python315 would be eight wrong paths
|
|
120
|
-
// and a ninth wrong one next year.
|
|
121
|
-
const appData = env.APPDATA || join(home, "AppData", "Roaming");
|
|
122
|
-
const localAppData = env.LOCALAPPDATA || join(home, "AppData", "Local");
|
|
123
|
-
for (const root of [join(appData, "Python"), join(localAppData, "Programs", "Python")]) {
|
|
124
|
-
for (const version of versionDirs(root)) dirs.push(join(root, version, "Scripts"));
|
|
125
|
-
}
|
|
126
|
-
dirs.push(join(home, "scoop", "shims"));
|
|
127
|
-
} else {
|
|
128
|
-
dirs.push(join(home, ".pyenv", "shims"));
|
|
129
|
-
// uv keeps the tool's own venv here and only symlinks into ~/.local/bin; if
|
|
130
|
-
// that link was never made, this is still a working executable.
|
|
131
|
-
dirs.push(join(home, ".local", "share", "uv", "tools", "claude-swap", "bin"));
|
|
132
|
-
dirs.push("/opt/homebrew/bin", "/usr/local/bin");
|
|
133
|
-
}
|
|
134
|
-
return dirs.map(d => join(d, exe));
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// The distribution name, which is what both installers key their directories on
|
|
138
|
-
// — `cswap` is only the console script.
|
|
139
|
-
const PKG = "claude-swap";
|
|
140
|
-
|
|
141
34
|
// ── which versions of it this deck is willing to install ─────────────────────
|
|
142
35
|
//
|
|
143
36
|
// The install used to be the bare name — `uv tool install claude-swap` — which
|
|
144
37
|
// means "whatever that project publishes next, forever", resolved on a machine
|
|
145
38
|
// the author will never see, and then handed `cswap export -`, `add` and
|
|
146
39
|
// `import`: every command in the accounts panel that carries a Claude refresh
|
|
147
|
-
// token.
|
|
148
|
-
// checked against the one Astral publishes beside it (uv-bootstrap.mjs). The
|
|
40
|
+
// token. On the same install path, uv itself is fetched only after its SHA-256
|
|
41
|
+
// is checked against the one Astral publishes beside it (uv-bootstrap.mjs). The
|
|
149
42
|
// package that holds the credentials had no bound of any kind.
|
|
150
43
|
//
|
|
151
44
|
// FLOOR: the newest release at the time this bound was written, which is the
|
|
@@ -197,136 +90,6 @@ export function isAcceptableVersion(v) {
|
|
|
197
90
|
/** Two version strings naming the same release — `0.26` and `0.26.0` do. */
|
|
198
91
|
function sameVersion(a, b) { return !isOlder(a, b) && !isOlder(b, a); }
|
|
199
92
|
|
|
200
|
-
/** True when `child` is `dir` or lives under it, in `platform`'s path flavour. */
|
|
201
|
-
function underDir(child, dir, platform) {
|
|
202
|
-
const { sep, normalize } = platform === "win32" ? winPath : posixPath;
|
|
203
|
-
const norm = p => {
|
|
204
|
-
// Windows paths compare case-insensitively, and `C:\x\` and `C:\x` are one
|
|
205
|
-
// directory.
|
|
206
|
-
let s = normalize(String(p));
|
|
207
|
-
if (platform === "win32") s = s.toLowerCase();
|
|
208
|
-
return s.length > 1 && s.endsWith(sep) ? s.slice(0, -sep.length) : s;
|
|
209
|
-
};
|
|
210
|
-
const c = norm(child), d = norm(dir);
|
|
211
|
-
return c === d || c.startsWith(d + sep);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Where `uv tool install claude-swap` puts the tool's own venv.
|
|
216
|
-
*
|
|
217
|
-
* UV_TOOL_DIR wins outright; otherwise uv's persistent data directory, which is
|
|
218
|
-
* `$XDG_DATA_HOME/uv` or `~/.local/share/uv` on Unix — macOS included, uv does
|
|
219
|
-
* not use `~/Library` — and `%APPDATA%\uv\data` on Windows. The `data` segment
|
|
220
|
-
* is Windows-only and is not optional there.
|
|
221
|
-
*/
|
|
222
|
-
function uvToolVenvs(platform, env, home) {
|
|
223
|
-
const { join } = platform === "win32" ? winPath : posixPath;
|
|
224
|
-
if (env.UV_TOOL_DIR) return [join(env.UV_TOOL_DIR, PKG)];
|
|
225
|
-
if (platform === "win32") {
|
|
226
|
-
const appData = env.APPDATA || join(home, "AppData", "Roaming");
|
|
227
|
-
return [join(appData, "uv", "data", "tools", PKG)];
|
|
228
|
-
}
|
|
229
|
-
return [join(env.XDG_DATA_HOME || join(home, ".local", "share"), "uv", "tools", PKG)];
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* Where `pipx install claude-swap` puts the package's venv.
|
|
234
|
-
*
|
|
235
|
-
* Read off pipx's own `paths.py`: the venvs are always `<home>/venvs`, and the
|
|
236
|
-
* home is PIPX_HOME when set, else the first EXISTING legacy fallback
|
|
237
|
-
* (`~/.local/pipx`, plus `~/pipx` on Windows), else platformdirs'
|
|
238
|
-
* `user_data_path("pipx")`. Since what gets asked here is whether one specific
|
|
239
|
-
* venv is on disk, every candidate home can simply be tried rather than
|
|
240
|
-
* replaying pipx's precedence.
|
|
241
|
-
*
|
|
242
|
-
* platformdirs on Windows appends the app name twice when no author is given,
|
|
243
|
-
* which pipx does not give — `%LOCALAPPDATA%\pipx\pipx`, not `%LOCALAPPDATA%\
|
|
244
|
-
* pipx`. That doubled segment is real and is the whole path on a modern
|
|
245
|
-
* Windows pipx.
|
|
246
|
-
*/
|
|
247
|
-
function pipxVenvs(platform, env, home) {
|
|
248
|
-
const { join } = platform === "win32" ? winPath : posixPath;
|
|
249
|
-
if (env.PIPX_HOME) return [join(env.PIPX_HOME, "venvs", PKG)];
|
|
250
|
-
const homes = [join(home, ".local", "pipx")];
|
|
251
|
-
if (platform === "win32") {
|
|
252
|
-
homes.push(join(home, "pipx"));
|
|
253
|
-
homes.push(join(env.LOCALAPPDATA || join(home, "AppData", "Local"), "pipx", "pipx"));
|
|
254
|
-
} else if (platform === "darwin") {
|
|
255
|
-
homes.push(join(home, "Library", "Application Support", "pipx"));
|
|
256
|
-
} else {
|
|
257
|
-
homes.push(join(env.XDG_DATA_HOME || join(home, ".local", "share"), "pipx"));
|
|
258
|
-
}
|
|
259
|
-
return homes.map(h => join(h, "venvs", PKG));
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
function realpathOrSelf(p) {
|
|
263
|
-
try { return realpathSync(p); } catch { return p; }
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* Which installer OWNS the claude-swap this machine runs — "uv", "pipx", or
|
|
268
|
-
* null when nothing offered here does, or when the evidence is ambiguous.
|
|
269
|
-
*
|
|
270
|
-
* The daily upgrade used to be handed to `findInstaller()`, which returns the
|
|
271
|
-
* first tool that answers `--version`. That is the right question when choosing
|
|
272
|
-
* something to install WITH and the wrong one when upgrading something already
|
|
273
|
-
* installed: on a machine with uv present and claude-swap installed some other
|
|
274
|
-
* way — a `pip install --user` copy, which #574 taught cswapBin to find, or a
|
|
275
|
-
* pipx one — the upgrade went to uv, which answers
|
|
276
|
-
*
|
|
277
|
-
* error: Failed to upgrade claude-swap
|
|
278
|
-
* Caused by: `claude-swap` is not installed; run `uv tool install …`
|
|
279
|
-
*
|
|
280
|
-
* and pipx, given someone else's package, answers "Package is not installed.
|
|
281
|
-
* Expected to find <PIPX_HOME>/venvs/claude-swap, but it does not exist." Both
|
|
282
|
-
* went through runDetached, which read no output and waited for no exit, so the
|
|
283
|
-
* refusal reached nobody while ensureCswap still reported "upgrading" and the
|
|
284
|
-
* marker was already burned for the day. The version never moved and the deck
|
|
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.
|
|
289
|
-
*
|
|
290
|
-
* Two signals, strongest first. The executable the deck actually runs, with its
|
|
291
|
-
* symlinks followed, sitting inside one installer's directory is decisive —
|
|
292
|
-
* that is the POSIX case, where both installers link `~/.local/bin/cswap` at
|
|
293
|
-
* their own venv. Windows copies the launcher instead, so there the layout
|
|
294
|
-
* question is asked directly: exactly one of the two venv directories exists.
|
|
295
|
-
* Zero means nothing offered here owns it — a `pip install --user` copy is the
|
|
296
|
-
* common shape, and `installers()` deliberately refuses to offer bare pip — and
|
|
297
|
-
* two means the machine has both and the resolved path did not say which is on
|
|
298
|
-
* PATH. Both answer null, because a silent boot is better than a daily sentence
|
|
299
|
-
* that is not true.
|
|
300
|
-
*
|
|
301
|
-
* Pure, and platform/env/home/filesystem all arrive as arguments, for the reason
|
|
302
|
-
* cswapCandidates gives: a Windows layout has to be checkable from a Mac.
|
|
303
|
-
*/
|
|
304
|
-
export function cswapOwner(bin, platform = process.platform, env = process.env, home = homedir(), {
|
|
305
|
-
exists = existsSync,
|
|
306
|
-
realpath = realpathOrSelf,
|
|
307
|
-
} = {}) {
|
|
308
|
-
const roots = [
|
|
309
|
-
...uvToolVenvs(platform, env, home).map(dir => ({ owner: "uv", dir })),
|
|
310
|
-
...pipxVenvs(platform, env, home).map(dir => ({ owner: "pipx", dir })),
|
|
311
|
-
];
|
|
312
|
-
|
|
313
|
-
// cswapBin answers the bare word whenever PATH resolved it, and a bare word
|
|
314
|
-
// points at no layout at all — only a path can be followed.
|
|
315
|
-
if (typeof bin === "string" && /[\\/]/.test(bin)) {
|
|
316
|
-
// BOTH sides get resolved, or the comparison is between two spellings of one
|
|
317
|
-
// directory rather than between two directories. A symlinked home is the
|
|
318
|
-
// ordinary way that happens — /var → /private/var on macOS, a network or
|
|
319
|
-
// container-mounted profile on Linux — and it would silently turn the
|
|
320
|
-
// strongest signal here into no signal at all.
|
|
321
|
-
const real = realpath(bin);
|
|
322
|
-
const hit = roots.find(r => underDir(real, realpath(r.dir), platform));
|
|
323
|
-
if (hit) return hit.owner;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
const owners = new Set(roots.filter(r => exists(r.dir)).map(r => r.owner));
|
|
327
|
-
return owners.size === 1 ? [...owners][0] : null;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
93
|
let _bin = null;
|
|
331
94
|
|
|
332
95
|
/**
|
|
@@ -354,6 +117,20 @@ function versionIn(r) {
|
|
|
354
117
|
return m ? m[1] : "installed";
|
|
355
118
|
}
|
|
356
119
|
|
|
120
|
+
/**
|
|
121
|
+
* How to invoke cswap: the bare name when PATH resolves it, otherwise an
|
|
122
|
+
* absolute path to where its installers actually put it.
|
|
123
|
+
*
|
|
124
|
+
* ~/.local/bin is where both `uv tool install` and `pipx install` place
|
|
125
|
+
* executables, on every platform, and it is famously not on PATH — that is the
|
|
126
|
+
* whole reason `pipx ensurepath` exists. Installing claude-swap successfully
|
|
127
|
+
* and then reporting it as missing because the shell cannot see it is a bad
|
|
128
|
+
* enough outcome on its own; it is worse now that the deck may have done the
|
|
129
|
+
* installing. So PATH is a convenience here, not the source of truth.
|
|
130
|
+
*
|
|
131
|
+
* Re-resolved when a lookup fails so an install during this process is picked
|
|
132
|
+
* up without a restart.
|
|
133
|
+
*/
|
|
357
134
|
export async function cswapBin() {
|
|
358
135
|
// An explicit path wins over everything and is never cached away — someone
|
|
359
136
|
// debugging a bad resolution needs it to take effect immediately.
|
|
@@ -410,15 +187,6 @@ export async function cswapVersion() {
|
|
|
410
187
|
return versionIn(r);
|
|
411
188
|
}
|
|
412
189
|
|
|
413
|
-
/**
|
|
414
|
-
* Install claude-swap with whichever Python tool installer is present.
|
|
415
|
-
*
|
|
416
|
-
* `uv` first because it is what claude-swap documents and it is dramatically
|
|
417
|
-
* faster; `pipx` as the established alternative. Deliberately NOT falling back
|
|
418
|
-
* to bare `pip install --user`: that drops the package into the user's default
|
|
419
|
-
* Python environment where it can collide with their own dependencies, which
|
|
420
|
-
* is not a thing to do to someone without asking.
|
|
421
|
-
*/
|
|
422
190
|
/**
|
|
423
191
|
* Python interpreters that are safe to execute on this machine.
|
|
424
192
|
*
|
|
@@ -537,6 +305,15 @@ async function installers(spec = RANGE_SPEC) {
|
|
|
537
305
|
return out;
|
|
538
306
|
}
|
|
539
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Install claude-swap with whichever Python tool installer is present.
|
|
310
|
+
*
|
|
311
|
+
* `uv` first because it is what claude-swap documents and it is dramatically
|
|
312
|
+
* faster; `pipx` as the established alternative. Deliberately NOT falling back
|
|
313
|
+
* to bare `pip install --user`: that drops the package into the user's default
|
|
314
|
+
* Python environment where it can collide with their own dependencies, which
|
|
315
|
+
* is not a thing to do to someone without asking.
|
|
316
|
+
*/
|
|
540
317
|
async function installCswap() {
|
|
541
318
|
// What to ask for is decided HERE, before anything is installed, so that what
|
|
542
319
|
// arrives can be checked against it afterwards — see ensureCswap. When the
|
|
@@ -552,7 +329,7 @@ async function installCswap() {
|
|
|
552
329
|
if (!(await run(cmd, probe, { timeout: 8_000 })).ok) continue;
|
|
553
330
|
const r = await run(cmd, args, { timeout: INSTALL_TIMEOUT_MS });
|
|
554
331
|
if (r.ok) return { ok: true, via, want, spec };
|
|
555
|
-
return { ok: false, reason: "install_failed", via, spec, detail: (r
|
|
332
|
+
return { ok: false, reason: "install_failed", via, spec, detail: failureDetail(r, 300) };
|
|
556
333
|
}
|
|
557
334
|
|
|
558
335
|
// Nothing on the machine can install a Python application. Rather than hand
|
|
@@ -563,7 +340,7 @@ async function installCswap() {
|
|
|
563
340
|
|
|
564
341
|
const r = await run(boot.bin, ["tool", "install", spec], { timeout: INSTALL_TIMEOUT_MS });
|
|
565
342
|
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
|
|
343
|
+
return { ok: false, reason: "install_failed", via: "uv (fetched)", spec, detail: failureDetail(r, 300) };
|
|
567
344
|
}
|
|
568
345
|
|
|
569
346
|
/**
|
|
@@ -693,6 +470,24 @@ let _upgrade = null;
|
|
|
693
470
|
*/
|
|
694
471
|
export function upgradeSettled() { return _upgrade; }
|
|
695
472
|
|
|
473
|
+
/**
|
|
474
|
+
* Whether ensureCswap is installing claude-swap right now.
|
|
475
|
+
*
|
|
476
|
+
* The install runs behind a deck that is already serving — reportStartup stops
|
|
477
|
+
* waiting the moment `onInstalling` fires — so for the minutes a first run takes
|
|
478
|
+
* the accounts panel is asking about a tool that is on its way. Absence alone
|
|
479
|
+
* read as `no_cswap`, and the panel answered it the only way that reason
|
|
480
|
+
* allows: "claude-swap isn't installed", and a command to install it by hand.
|
|
481
|
+
* Somebody who ran it then had two installers racing for one directory, over a
|
|
482
|
+
* tool that would have arrived by itself a minute later.
|
|
483
|
+
*
|
|
484
|
+
* True from the moment ensureCswap commits to an install until it has checked
|
|
485
|
+
* what arrived, whichever way that went. A failed install is `no_cswap` again,
|
|
486
|
+
* and then the hand-typed command is the right answer.
|
|
487
|
+
*/
|
|
488
|
+
let _installing = false;
|
|
489
|
+
export function cswapInstalling() { return _installing; }
|
|
490
|
+
|
|
696
491
|
/**
|
|
697
492
|
* Upgrade claude-swap in the background when a newer release exists, and find
|
|
698
493
|
* out what happened.
|
|
@@ -752,7 +547,7 @@ function upgradeInBackground({ cmd, upgrade, via }, { from, want }) {
|
|
|
752
547
|
: (typeof to === "string" && to !== "installed" && !isAcceptableVersion(to))
|
|
753
548
|
? "unexpected_version"
|
|
754
549
|
: null,
|
|
755
|
-
detail: r.ok ? null : (r
|
|
550
|
+
detail: r.ok ? null : failureDetail(r, 300),
|
|
756
551
|
};
|
|
757
552
|
} catch (e) {
|
|
758
553
|
entry = { from, want: want ?? null, to: null, at: new Date().toISOString(), via: via ?? null,
|
|
@@ -817,7 +612,7 @@ export async function ensureCswap({ onInstalling = null } = {}) {
|
|
|
817
612
|
// boot with no network yet — a laptop opened on a train, the ten seconds
|
|
818
613
|
// before Wi-Fi associates — burned the whole shared 24-hour window on a
|
|
819
614
|
// check that never reached PyPI, and the next real chance was the day
|
|
820
|
-
// after.
|
|
615
|
+
// after. npm-latest.mjs states this rule for itself in as many words.
|
|
821
616
|
const latest = await newestAcceptableOnPypi();
|
|
822
617
|
touchMarker();
|
|
823
618
|
if (latest && existing !== "installed" && isOlder(existing, latest)) {
|
|
@@ -835,10 +630,19 @@ export async function ensureCswap({ onInstalling = null } = {}) {
|
|
|
835
630
|
return { state: "present", version: existing };
|
|
836
631
|
}
|
|
837
632
|
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
633
|
+
_installing = true;
|
|
634
|
+
try {
|
|
635
|
+
// Said before the install starts rather than after it, because after it is
|
|
636
|
+
// three minutes later and the whole point is not to be waited for.
|
|
637
|
+
try { onInstalling?.(); } catch { /* a caller's notification is not our problem */ }
|
|
638
|
+
return await installAndConfirm();
|
|
639
|
+
} finally {
|
|
640
|
+
_installing = false;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
841
643
|
|
|
644
|
+
/** The install half of ensureCswap: run it, then check what arrived. */
|
|
645
|
+
async function installAndConfirm() {
|
|
842
646
|
const result = await installCswap();
|
|
843
647
|
if (!result.ok) return { state: "unavailable", ...result };
|
|
844
648
|
// Something was just installed, so any path resolved before it is a guess made
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// Where claude-swap lives on disk: every place its installers are known to
|
|
2
|
+
// leave the `cswap` executable, and which installer owns the copy this machine
|
|
3
|
+
// actually runs.
|
|
4
|
+
//
|
|
5
|
+
// Nothing here spawns anything. What the two answers depend on — the platform,
|
|
6
|
+
// the environment, the home directory, a directory listing, whether a path
|
|
7
|
+
// exists — arrives as a parameter with the real one as its default, because a
|
|
8
|
+
// Windows layout has to be checkable from a Mac: these lists exist entirely for
|
|
9
|
+
// machines the author is not sitting at. cswap-install.mjs resolves the binary
|
|
10
|
+
// against cswapCandidates and aims its daily upgrade with cswapOwner.
|
|
11
|
+
import { existsSync, readdirSync, realpathSync } from "node:fs";
|
|
12
|
+
import { posix as posixPath, win32 as winPath } from "node:path";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
|
|
15
|
+
// The distribution name, which is what both installers key their directories on
|
|
16
|
+
// — `cswap` is only the console script.
|
|
17
|
+
export const PKG = "claude-swap";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The subdirectories of `dir`, newest-looking first, or [] when it cannot be
|
|
21
|
+
* read at all.
|
|
22
|
+
*
|
|
23
|
+
* Injected into cswapCandidates below rather than called from it, for the reason
|
|
24
|
+
* the platform is a parameter there: a Windows layout has to be describable from
|
|
25
|
+
* a Mac. A missing directory, a disconnected network drive and a profile the
|
|
26
|
+
* process cannot read are all the same answer — nothing here — never a throw,
|
|
27
|
+
* because this runs unprompted at startup for an optional panel.
|
|
28
|
+
*
|
|
29
|
+
* The sort is numeric so `Python313` sorts above `Python39` rather than below
|
|
30
|
+
* it: when two interpreters both have a cswap, the newer one is the one the user
|
|
31
|
+
* most likely installed it with, and the order this returns is the order the
|
|
32
|
+
* caller probes in.
|
|
33
|
+
*
|
|
34
|
+
* Exported for its test rather than for a caller. Every part of it that can be
|
|
35
|
+
* wrong — which names count as an interpreter, what order they come back in,
|
|
36
|
+
* what a directory that cannot be read answers — is invisible from
|
|
37
|
+
* cswapCandidates, which injects a substitute precisely so its own Windows
|
|
38
|
+
* layout can be checked from a Mac. Driven against real directories in
|
|
39
|
+
* cswap-admin.test.ts, on whichever OS is running the suite.
|
|
40
|
+
*/
|
|
41
|
+
export function pythonVersionDirs(dir) {
|
|
42
|
+
try {
|
|
43
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
44
|
+
.filter(e => e.isDirectory() || e.isSymbolicLink())
|
|
45
|
+
.map(e => e.name)
|
|
46
|
+
// `Python312`, and the tagged builds the installer also writes:
|
|
47
|
+
// `Python312-32`, `Python313-arm64`.
|
|
48
|
+
.filter(n => /^Python\d[\w.-]*$/i.test(n))
|
|
49
|
+
.sort((a, b) => b.localeCompare(a, "en", { numeric: true }));
|
|
50
|
+
} catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Every place an installer is known to leave cswap. The platform is a parameter
|
|
57
|
+
* and the directory listing is injected, so the Windows list can be checked from
|
|
58
|
+
* a Mac — which is the only way this list stays right, since it exists entirely
|
|
59
|
+
* for machines the author is not sitting at.
|
|
60
|
+
*/
|
|
61
|
+
export function cswapCandidates(platform = process.platform, env = process.env, home = homedir(), {
|
|
62
|
+
versionDirs = pythonVersionDirs,
|
|
63
|
+
} = {}) {
|
|
64
|
+
// The path flavour follows the PLATFORM ARGUMENT, not the host: node's `join`
|
|
65
|
+
// would emit forward slashes when this is exercised from a Mac, which is both
|
|
66
|
+
// wrong for the caller and invisible in a test.
|
|
67
|
+
const { join } = platform === "win32" ? winPath : posixPath;
|
|
68
|
+
const exe = platform === "win32" ? "cswap.exe" : "cswap";
|
|
69
|
+
const dirs = [];
|
|
70
|
+
// Explicit configuration first: someone who set these means them.
|
|
71
|
+
if (env.UV_TOOL_BIN_DIR) dirs.push(env.UV_TOOL_BIN_DIR);
|
|
72
|
+
if (env.XDG_BIN_HOME) dirs.push(env.XDG_BIN_HOME);
|
|
73
|
+
// Where `uv tool install` and `pipx install` put executables, everywhere.
|
|
74
|
+
dirs.push(join(home, ".local", "bin"));
|
|
75
|
+
if (platform === "win32") {
|
|
76
|
+
// pipx before 1.5, and any `pip install --user`. APPDATA is respected when
|
|
77
|
+
// set because a roaming profile moves it off the home directory.
|
|
78
|
+
//
|
|
79
|
+
// THE VERSION SEGMENT IS NOT OPTIONAL (#552). CPython on Windows always
|
|
80
|
+
// puts the interpreter between the root and `Scripts`:
|
|
81
|
+
//
|
|
82
|
+
// %APPDATA%\Python\Python312\Scripts pip install --user
|
|
83
|
+
// %LOCALAPPDATA%\Programs\Python\Python312\Scripts per-user installer
|
|
84
|
+
//
|
|
85
|
+
// — `{userbase}\Python{version_nodot}\Scripts` is sysconfig's `nt_user`
|
|
86
|
+
// scheme, not a convention. The two paths this used to build omitted it, so
|
|
87
|
+
// NEITHER could exist on a real machine: every candidate missed,
|
|
88
|
+
// `cswapVersion` answered null, `ensureCswap` reported `not_on_path`, and
|
|
89
|
+
// the deck re-ran a whole install attempt on every launch for a user who
|
|
90
|
+
// already had cswap.exe sitting there. The POSIX side never had the bug —
|
|
91
|
+
// `~/.local/bin` carries no version — which is why this stayed a Windows
|
|
92
|
+
// false negative in the one function whose whole purpose is to not depend
|
|
93
|
+
// on PATH.
|
|
94
|
+
//
|
|
95
|
+
// Which versions exist is a fact about the machine, so it is read rather
|
|
96
|
+
// than guessed: enumerating Python38…Python315 would be eight wrong paths
|
|
97
|
+
// and a ninth wrong one next year.
|
|
98
|
+
const appData = env.APPDATA || join(home, "AppData", "Roaming");
|
|
99
|
+
const localAppData = env.LOCALAPPDATA || join(home, "AppData", "Local");
|
|
100
|
+
for (const root of [join(appData, "Python"), join(localAppData, "Programs", "Python")]) {
|
|
101
|
+
for (const version of versionDirs(root)) dirs.push(join(root, version, "Scripts"));
|
|
102
|
+
}
|
|
103
|
+
dirs.push(join(home, "scoop", "shims"));
|
|
104
|
+
} else {
|
|
105
|
+
dirs.push(join(home, ".pyenv", "shims"));
|
|
106
|
+
// uv keeps the tool's own venv here and only symlinks into ~/.local/bin; if
|
|
107
|
+
// that link was never made, this is still a working executable.
|
|
108
|
+
dirs.push(join(home, ".local", "share", "uv", "tools", "claude-swap", "bin"));
|
|
109
|
+
dirs.push("/opt/homebrew/bin", "/usr/local/bin");
|
|
110
|
+
}
|
|
111
|
+
return dirs.map(d => join(d, exe));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** True when `child` is `dir` or lives under it, in `platform`'s path flavour. */
|
|
115
|
+
function underDir(child, dir, platform) {
|
|
116
|
+
const { sep, normalize } = platform === "win32" ? winPath : posixPath;
|
|
117
|
+
const norm = p => {
|
|
118
|
+
// Windows paths compare case-insensitively, and `C:\x\` and `C:\x` are one
|
|
119
|
+
// directory.
|
|
120
|
+
let s = normalize(String(p));
|
|
121
|
+
if (platform === "win32") s = s.toLowerCase();
|
|
122
|
+
return s.length > 1 && s.endsWith(sep) ? s.slice(0, -sep.length) : s;
|
|
123
|
+
};
|
|
124
|
+
const c = norm(child), d = norm(dir);
|
|
125
|
+
return c === d || c.startsWith(d + sep);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Where `uv tool install claude-swap` puts the tool's own venv.
|
|
130
|
+
*
|
|
131
|
+
* UV_TOOL_DIR wins outright; otherwise uv's persistent data directory, which is
|
|
132
|
+
* `$XDG_DATA_HOME/uv` or `~/.local/share/uv` on Unix — macOS included, uv does
|
|
133
|
+
* not use `~/Library` — and `%APPDATA%\uv\data` on Windows. The `data` segment
|
|
134
|
+
* is Windows-only and is not optional there.
|
|
135
|
+
*/
|
|
136
|
+
function uvToolVenvs(platform, env, home) {
|
|
137
|
+
const { join } = platform === "win32" ? winPath : posixPath;
|
|
138
|
+
if (env.UV_TOOL_DIR) return [join(env.UV_TOOL_DIR, PKG)];
|
|
139
|
+
if (platform === "win32") {
|
|
140
|
+
const appData = env.APPDATA || join(home, "AppData", "Roaming");
|
|
141
|
+
return [join(appData, "uv", "data", "tools", PKG)];
|
|
142
|
+
}
|
|
143
|
+
return [join(env.XDG_DATA_HOME || join(home, ".local", "share"), "uv", "tools", PKG)];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Where `pipx install claude-swap` puts the package's venv.
|
|
148
|
+
*
|
|
149
|
+
* Read off pipx's own `paths.py`: the venvs are always `<home>/venvs`, and the
|
|
150
|
+
* home is PIPX_HOME when set, else the first EXISTING legacy fallback
|
|
151
|
+
* (`~/.local/pipx`, plus `~/pipx` on Windows), else platformdirs'
|
|
152
|
+
* `user_data_path("pipx")`. Since what gets asked here is whether one specific
|
|
153
|
+
* venv is on disk, every candidate home can simply be tried rather than
|
|
154
|
+
* replaying pipx's precedence.
|
|
155
|
+
*
|
|
156
|
+
* platformdirs on Windows appends the app name twice when no author is given,
|
|
157
|
+
* which pipx does not give — `%LOCALAPPDATA%\pipx\pipx`, not `%LOCALAPPDATA%\
|
|
158
|
+
* pipx`. That doubled segment is real and is the whole path on a modern
|
|
159
|
+
* Windows pipx.
|
|
160
|
+
*/
|
|
161
|
+
function pipxVenvs(platform, env, home) {
|
|
162
|
+
const { join } = platform === "win32" ? winPath : posixPath;
|
|
163
|
+
if (env.PIPX_HOME) return [join(env.PIPX_HOME, "venvs", PKG)];
|
|
164
|
+
const homes = [join(home, ".local", "pipx")];
|
|
165
|
+
if (platform === "win32") {
|
|
166
|
+
homes.push(join(home, "pipx"));
|
|
167
|
+
homes.push(join(env.LOCALAPPDATA || join(home, "AppData", "Local"), "pipx", "pipx"));
|
|
168
|
+
} else if (platform === "darwin") {
|
|
169
|
+
homes.push(join(home, "Library", "Application Support", "pipx"));
|
|
170
|
+
} else {
|
|
171
|
+
homes.push(join(env.XDG_DATA_HOME || join(home, ".local", "share"), "pipx"));
|
|
172
|
+
}
|
|
173
|
+
return homes.map(h => join(h, "venvs", PKG));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function realpathOrSelf(p) {
|
|
177
|
+
try { return realpathSync(p); } catch { return p; }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Which installer OWNS the claude-swap this machine runs — "uv", "pipx", or
|
|
182
|
+
* null when nothing offered here does, or when the evidence is ambiguous.
|
|
183
|
+
*
|
|
184
|
+
* The daily upgrade used to be handed to `findInstaller()`, which returns the
|
|
185
|
+
* first tool that answers `--version`. That is the right question when choosing
|
|
186
|
+
* something to install WITH and the wrong one when upgrading something already
|
|
187
|
+
* installed: on a machine with uv present and claude-swap installed some other
|
|
188
|
+
* way — a `pip install --user` copy, which #574 taught cswapBin to find, or a
|
|
189
|
+
* pipx one — the upgrade went to uv, which answers
|
|
190
|
+
*
|
|
191
|
+
* error: Failed to upgrade claude-swap
|
|
192
|
+
* Caused by: `claude-swap` is not installed; run `uv tool install …`
|
|
193
|
+
*
|
|
194
|
+
* and pipx, given someone else's package, answers "Package is not installed.
|
|
195
|
+
* Expected to find <PIPX_HOME>/venvs/claude-swap, but it does not exist." Both
|
|
196
|
+
* went through runDetached, which read no output and waited for no exit, so the
|
|
197
|
+
* refusal reached nobody while ensureCswap still reported "upgrading" and the
|
|
198
|
+
* marker was already burned for the day. The version never moved and the deck
|
|
199
|
+
* said it was moving, every launch, forever. The upgrade is captured now (#1000)
|
|
200
|
+
* and a refusal is at least written down — but aiming it correctly is still
|
|
201
|
+
* this function's job, and a recorded failure is a worse outcome than one that
|
|
202
|
+
* never had to happen.
|
|
203
|
+
*
|
|
204
|
+
* Two signals, strongest first. The executable the deck actually runs, with its
|
|
205
|
+
* symlinks followed, sitting inside one installer's directory is decisive —
|
|
206
|
+
* that is the POSIX case, where both installers link `~/.local/bin/cswap` at
|
|
207
|
+
* their own venv. Windows copies the launcher instead, so there the layout
|
|
208
|
+
* question is asked directly: exactly one of the two venv directories exists.
|
|
209
|
+
* Zero means nothing offered here owns it — a `pip install --user` copy is the
|
|
210
|
+
* common shape, and cswap-install.mjs's `installers()` deliberately refuses to
|
|
211
|
+
* offer bare pip — and two means the machine has both and the resolved path did
|
|
212
|
+
* not say which is on PATH. Both answer null, because a silent boot is better
|
|
213
|
+
* than a daily sentence that is not true.
|
|
214
|
+
*
|
|
215
|
+
* Pure, and platform/env/home/filesystem all arrive as arguments, for the reason
|
|
216
|
+
* cswapCandidates gives: a Windows layout has to be checkable from a Mac.
|
|
217
|
+
*/
|
|
218
|
+
export function cswapOwner(bin, platform = process.platform, env = process.env, home = homedir(), {
|
|
219
|
+
exists = existsSync,
|
|
220
|
+
realpath = realpathOrSelf,
|
|
221
|
+
} = {}) {
|
|
222
|
+
const roots = [
|
|
223
|
+
...uvToolVenvs(platform, env, home).map(dir => ({ owner: "uv", dir })),
|
|
224
|
+
...pipxVenvs(platform, env, home).map(dir => ({ owner: "pipx", dir })),
|
|
225
|
+
];
|
|
226
|
+
|
|
227
|
+
// cswapBin answers the bare word whenever PATH resolved it, and a bare word
|
|
228
|
+
// points at no layout at all — only a path can be followed.
|
|
229
|
+
if (typeof bin === "string" && /[\\/]/.test(bin)) {
|
|
230
|
+
// BOTH sides get resolved, or the comparison is between two spellings of one
|
|
231
|
+
// directory rather than between two directories. A symlinked home is the
|
|
232
|
+
// ordinary way that happens — /var → /private/var on macOS, a network or
|
|
233
|
+
// container-mounted profile on Linux — and it would silently turn the
|
|
234
|
+
// strongest signal here into no signal at all.
|
|
235
|
+
const real = realpath(bin);
|
|
236
|
+
const hit = roots.find(r => underDir(real, realpath(r.dir), platform));
|
|
237
|
+
if (hit) return hit.owner;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const owners = new Set(roots.filter(r => exists(r.dir)).map(r => r.owner));
|
|
241
|
+
return owners.size === 1 ? [...owners][0] : null;
|
|
242
|
+
}
|