agent-dag 3.22.1 → 3.22.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -477
- package/package.json +14 -48
- package/shim.js +107 -0
- package/LICENSE +0 -661
- package/LICENSING.md +0 -82
- package/THIRD_PARTY_NOTICES.md +0 -395
- package/bin/agent-dag.js +0 -626
- package/bin/deck.js +0 -1805
- package/dist/web/assets/index-CJYsv0lr.css +0 -1
- package/dist/web/assets/index-Ifm23DDC.js +0 -270
- package/dist/web/index.html +0 -49
- package/hook/hook.js +0 -542
- package/release-notes.json +0 -398
- package/src/server/activity.mjs +0 -52
- package/src/server/agent-activity.mjs +0 -522
- package/src/server/args.mjs +0 -183
- package/src/server/auto-update.mjs +0 -79
- package/src/server/block-notify.mjs +0 -173
- package/src/server/boot-deadline.mjs +0 -127
- package/src/server/brand.mjs +0 -16
- package/src/server/browser-history.mjs +0 -497
- package/src/server/browser-presence.mjs +0 -211
- package/src/server/browser-profiles.mjs +0 -279
- package/src/server/browser-react.mjs +0 -284
- package/src/server/browser-watch-store.mjs +0 -350
- package/src/server/browser-watch.mjs +0 -905
- package/src/server/ccusage.mjs +0 -1168
- package/src/server/claude-accounts.mjs +0 -951
- package/src/server/claude-dir.mjs +0 -213
- package/src/server/codex-auth.mjs +0 -388
- package/src/server/codex-dir.mjs +0 -171
- package/src/server/codex-quota.mjs +0 -449
- package/src/server/codex-usage.mjs +0 -512
- package/src/server/cswap-admin.mjs +0 -1562
- package/src/server/cswap-auto.mjs +0 -658
- package/src/server/cswap-install.mjs +0 -641
- package/src/server/deck-home.mjs +0 -243
- package/src/server/deck-prefs.mjs +0 -301
- package/src/server/deck-probe.mjs +0 -111
- package/src/server/detach.mjs +0 -244
- package/src/server/exec.mjs +0 -996
- package/src/server/global-install.mjs +0 -67
- package/src/server/hwmonitor.mjs +0 -56
- package/src/server/index.mjs +0 -6043
- package/src/server/installer.mjs +0 -912
- package/src/server/invoked-as.mjs +0 -144
- package/src/server/lan-about.mjs +0 -119
- package/src/server/lan-engine.mjs +0 -952
- package/src/server/lan-reach.mjs +0 -256
- package/src/server/lan-socket.mjs +0 -682
- package/src/server/lan-sync.mjs +0 -941
- package/src/server/lhm-parse.mjs +0 -91
- package/src/server/log-tail.mjs +0 -139
- package/src/server/log-writer.mjs +0 -322
- package/src/server/login-service.mjs +0 -473
- package/src/server/macmon.mjs +0 -310
- package/src/server/npx.mjs +0 -264
- package/src/server/open-url.mjs +0 -242
- package/src/server/presence.mjs +0 -40
- package/src/server/quota.mjs +0 -792
- package/src/server/relay-guard.mjs +0 -507
- package/src/server/reset-label.mjs +0 -78
- package/src/server/retire-sound-hook.mjs +0 -349
- package/src/server/running-deck.mjs +0 -234
- package/src/server/self-update.mjs +0 -1380
- package/src/server/stop-deck.mjs +0 -171
- package/src/server/supervisor.mjs +0 -392
- package/src/server/system-metrics.mjs +0 -1825
- package/src/server/term.mjs +0 -686
- package/src/server/uv-bootstrap.mjs +0 -337
|
@@ -1,1380 +0,0 @@
|
|
|
1
|
-
// Tells the deck when the code it is executing is no longer the code on disk.
|
|
2
|
-
//
|
|
3
|
-
// Node caches every module at import. A deck that was already running when
|
|
4
|
-
// `npm i -g agents-deck` replaced its files keeps executing the OLD code until
|
|
5
|
-
// the process restarts — and nothing says so. The terminal banner still prints
|
|
6
|
-
// the version it booted with, and the browser's __APP_VERSION__ is baked into
|
|
7
|
-
// whichever bundle it happened to load, so the UI can even show the NEW number
|
|
8
|
-
// while the server runs the old one.
|
|
9
|
-
//
|
|
10
|
-
// That is not theoretical. On 2026-08-12 a deck left running from before
|
|
11
|
-
// v1.30.4 kept spawning `claude --print /usage` once a minute, burning the
|
|
12
|
-
// whole hourly usage-endpoint budget and 429-ing claude-swap, while the fix sat
|
|
13
|
-
// unused on disk. The user had no way to see it.
|
|
14
|
-
//
|
|
15
|
-
// So we report three distinct versions:
|
|
16
|
-
// running — captured at boot by the caller, before an upgrade can land
|
|
17
|
-
// installed — re-read from disk per call; what a restart would run
|
|
18
|
-
// latest — npm's dist-tag, fetched at most once an hour (CHECK_MS below,
|
|
19
|
-
// which also explains why it is not the daily cadence it was)
|
|
20
|
-
//
|
|
21
|
-
// Installing is opt-in and narrow. `npm i -g` runs only when the user asks for
|
|
22
|
-
// it by name and only where it can actually work: a global install, on a
|
|
23
|
-
// directory we can write, outside a git checkout and outside an npx cache.
|
|
24
|
-
// Everywhere else this stays what it has always been — a printed command.
|
|
25
|
-
import { accessSync, constants as FS, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
26
|
-
import { spawn } from "node:child_process";
|
|
27
|
-
import { homedir } from "node:os";
|
|
28
|
-
import { dirname, join, resolve } from "node:path";
|
|
29
|
-
import { killTree, shimPath, spawnSpec } from "./exec.mjs";
|
|
30
|
-
|
|
31
|
-
// Once an hour, not once a day.
|
|
32
|
-
//
|
|
33
|
-
// The daily cadence was copied from the ccusage and cswap checks, where it is
|
|
34
|
-
// right: those answer "is a different tool out of date", and nobody is waiting
|
|
35
|
-
// on it. This one answers "is the thing you are looking at out of date", and a
|
|
36
|
-
// day is long enough that a deck started shortly before a release shows nothing
|
|
37
|
-
// at all until tomorrow — reported from a machine running `npx ccdeck`, which
|
|
38
|
-
// is the case where it bites hardest, since npx runs are short-lived and each
|
|
39
|
-
// one inherits the same stale marker. The request is ~20 bytes.
|
|
40
|
-
const CHECK_MS = 3600_000;
|
|
41
|
-
// A lookup that failed is not an answer, so it must not spend the hour an
|
|
42
|
-
// answer buys. It still has to spend something: the reason to rate-limit is
|
|
43
|
-
// gone, but a registry that is down would otherwise be asked again on every
|
|
44
|
-
// single poll. Five minutes is the compromise — short enough that a network
|
|
45
|
-
// coming back is noticed while the user is still looking at the deck, long
|
|
46
|
-
// enough that a full npm outage costs a dozen ~20-byte requests an hour.
|
|
47
|
-
const RETRY_MS = 300_000;
|
|
48
|
-
const FETCH_TIMEOUT_MS = 6_000;
|
|
49
|
-
// A third marker family: ccusage owns ~/.agents-deck/ccusage/.last-update-check
|
|
50
|
-
// and cswap owns ~/.agents-deck/.cswap-update-check. Sharing one would make the
|
|
51
|
-
// three features fight over a single daily slot.
|
|
52
|
-
const MARKER_DIR = join(homedir(), ".agents-deck");
|
|
53
|
-
// The name this deck was published under is part of the marker's identity.
|
|
54
|
-
//
|
|
55
|
-
// A single ~/.agents-deck/.self-update-check was shared by every deck on the
|
|
56
|
-
// machine, so whichever one asked npm first pinned the answer for all of them
|
|
57
|
-
// until the window expired — including a freshly started `npx ccdeck` that had
|
|
58
|
-
// never written it, and including decks running a DIFFERENT package where the
|
|
59
|
-
// cached version means nothing. Reported with four decks alive at once: three
|
|
60
|
-
// served the same `checkedAt` to the millisecond and none of them showed the
|
|
61
|
-
// seven releases that had shipped meanwhile. One file per package name means
|
|
62
|
-
// `agents-deck`, `ccdeck` and `agent-dag` decks stop silencing each other; the
|
|
63
|
-
// old shared path stays here only to be inherited from once.
|
|
64
|
-
const LEGACY_MARKER = join(MARKER_DIR, ".self-update-check");
|
|
65
|
-
|
|
66
|
-
// Dedupe concurrent /api/version calls into one fetch, per package name — two
|
|
67
|
-
// names are two different questions and must not be answered with one answer.
|
|
68
|
-
const _inflight = new Map();
|
|
69
|
-
|
|
70
|
-
// ── what a forced check may cost ─────────────────────────────────────────────
|
|
71
|
-
//
|
|
72
|
-
// `_inflight` deduplicates callers that OVERLAP and nothing else. `checkDue`
|
|
73
|
-
// answers `true` on `force` before it asks anything else, so a caller that
|
|
74
|
-
// waited for one check to settle and then asked again got a fresh
|
|
75
|
-
// `https://registry.npmjs.org/…/dist-tags` every time. Reads on this server are
|
|
76
|
-
// deliberately open — `isTrustedRead` does not apply the `Sec-Fetch-Site` test
|
|
77
|
-
// that `isTrustedMutation` does, because a cross-site read of
|
|
78
|
-
// `http://127.0.0.1:4317` is an ordinary top-level navigation — so
|
|
79
|
-
//
|
|
80
|
-
// (async function spin() {
|
|
81
|
-
// for (;;) await fetch("http://127.0.0.1:4317/api/version?refresh=1",
|
|
82
|
-
// { mode: "no-cors" });
|
|
83
|
-
// })();
|
|
84
|
-
//
|
|
85
|
-
// from any page the user had open was one registry request per turn, as fast as
|
|
86
|
-
// the round trip allows, and every so often two of them: `runCheck` confirms a
|
|
87
|
-
// tag it has not seen before against the version document. The requests are
|
|
88
|
-
// small — the whole reason the dist-tags endpoint is used here rather than the
|
|
89
|
-
// packument — but they leave the user's address, carrying the user-agent this
|
|
90
|
-
// deck sets, and they are aimed at a third party rather than at the machine the
|
|
91
|
-
// loop is running on. That makes it #580's shape with the cost pointed
|
|
92
|
-
// outwards, which if anything is the worse direction.
|
|
93
|
-
//
|
|
94
|
-
// Nothing above this line was going to stop it. The hour is written to a marker
|
|
95
|
-
// FILE, and `force` walks past it; `first` walks past it too; and on a machine
|
|
96
|
-
// where `~/.agents-deck` cannot be written `writeMarker` swallows the failure,
|
|
97
|
-
// so `checkDue` sees "never checked" on every single call and the hour is not
|
|
98
|
-
// really there at all.
|
|
99
|
-
//
|
|
100
|
-
// So the floor sits under all of it, in this process's own memory, between the
|
|
101
|
-
// last rule that admitted a check and the request it admitted. It is quota.mjs's
|
|
102
|
-
// number and codex-quota.mjs's and codex-usage.mjs's — the five routes in this
|
|
103
|
-
// deck's router that accept `?refresh=1` and can pay for it have no business
|
|
104
|
-
// disagreeing about what it costs.
|
|
105
|
-
const FORCE_POLL_MS = 60_000;
|
|
106
|
-
|
|
107
|
-
// Stamped when a check STARTS rather than when npm answers, because what the
|
|
108
|
-
// floor rations is the request. Per package name, like `_inflight` and like the
|
|
109
|
-
// markers: two names are two different questions, and one of them being asked
|
|
110
|
-
// is not a reason to refuse the other. Deliberately in memory rather than on
|
|
111
|
-
// disk — the marker is shared by every deck running that package and this is
|
|
112
|
-
// about what THIS process is sending.
|
|
113
|
-
const _lastAskAt = new Map();
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Whether we may spend a registry request right now.
|
|
117
|
-
*
|
|
118
|
-
* Exported for tests, for the same reason quota.mjs exports `maySelfPoll`,
|
|
119
|
-
* codex-quota.mjs `mayFetchQuota` and codex-usage.mjs `mayScanUsage`: this is
|
|
120
|
-
* the rule, it is pure, and it is worth pinning down away from the request it
|
|
121
|
-
* guards.
|
|
122
|
-
*/
|
|
123
|
-
export function mayAskNpm({ now, lastAskAt }) {
|
|
124
|
-
// A stamp from the future is a clock that moved, not a check that just ran —
|
|
125
|
-
// the same case `checkDue` answers twice below with `> now`, and the same
|
|
126
|
-
// answer. Without it, a machine whose clock corrects backwards by an hour
|
|
127
|
-
// would go an hour without a version check.
|
|
128
|
-
if (lastAskAt > now) return true;
|
|
129
|
-
return now - lastAskAt >= FORCE_POLL_MS;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// ── version comparison ───────────────────────────────────────────────────────
|
|
133
|
-
|
|
134
|
-
/** True when `a` sorts before `b`. Numeric-segment compare — non-numeric
|
|
135
|
-
* segments count as 0, missing segments pad with 0, so "1.30" < "1.30.1" and
|
|
136
|
-
* "1.9.0" < "1.10.0".
|
|
137
|
-
*
|
|
138
|
-
* cswap-install.mjs had this written out a second time, without the type guard
|
|
139
|
-
* below, and imports it from here now (#374). The two bodies were identical:
|
|
140
|
-
* swept over 271,441 version-string pairs they disagreed on none, and the
|
|
141
|
-
* guard was the whole difference — `isOlder(null, "1.0.0")` answers false here
|
|
142
|
-
* and threw a TypeError there. Nothing could reach that call with a non-string
|
|
143
|
-
* (both arguments are behind `typeof v === "string"` checks at the one call
|
|
144
|
-
* site), so this is the copy with a test behind it absorbing the one without,
|
|
145
|
-
* not a bug fix. */
|
|
146
|
-
export function isOlder(a, b) {
|
|
147
|
-
if (typeof a !== "string" || typeof b !== "string") return false;
|
|
148
|
-
const seg = (v) => v.split(/[.\-+]/).map(n => parseInt(n, 10)).map(n => Number.isNaN(n) ? 0 : n);
|
|
149
|
-
const x = seg(a), y = seg(b);
|
|
150
|
-
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
|
151
|
-
const d = (x[i] ?? 0) - (y[i] ?? 0);
|
|
152
|
-
if (d !== 0) return d < 0;
|
|
153
|
-
}
|
|
154
|
-
return false;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// ── what is on disk ──────────────────────────────────────────────────────────
|
|
158
|
-
|
|
159
|
-
/** A package directory's own manifest, or null when there is not one worth
|
|
160
|
-
* reading there.
|
|
161
|
-
*
|
|
162
|
-
* Shared by everything below that asks a directory who it is, so the three
|
|
163
|
-
* callers cannot drift on what a missing, truncated or non-object package.json
|
|
164
|
-
* means. A JSON document is not necessarily an object — `null`, `"ccdeck"` and
|
|
165
|
-
* `[]` all parse — and a manifest that is not an object has no fields to read,
|
|
166
|
-
* so it is refused here once rather than guarded against three times. */
|
|
167
|
-
function readManifest(dir) {
|
|
168
|
-
try {
|
|
169
|
-
const meta = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
170
|
-
return meta && typeof meta === "object" ? meta : null;
|
|
171
|
-
} catch {
|
|
172
|
-
return null;
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/** Version currently written in the package's own package.json. Deliberately
|
|
177
|
-
* read fresh on every call: that is the whole point — it changes under a
|
|
178
|
-
* running process when npm replaces the install.
|
|
179
|
-
*
|
|
180
|
-
* And sometimes the directory changes with it. A `npm i -g ccdeck` performed
|
|
181
|
-
* before #340 left the deck nested inside a launcher package; upgrading such an
|
|
182
|
-
* install now writes the flat tarball over that launcher, and npm's reify takes
|
|
183
|
-
* the nested copy — the one this process is running out of — with it. Reading
|
|
184
|
-
* our own manifest then answers null, which pickNotice reads as "nothing is
|
|
185
|
-
* installed" and turns into the same upgrade offered forever, with the restart
|
|
186
|
-
* notice this module exists for never firing at all.
|
|
187
|
-
*
|
|
188
|
-
* The version is not lost, it moved one directory up. successorRoot is where
|
|
189
|
-
* to, and it answers null in every layout where nothing moved — so the normal
|
|
190
|
-
* case still reads exactly one manifest. */
|
|
191
|
-
export function installedVersion(pkgRoot) {
|
|
192
|
-
const own = readManifest(pkgRoot);
|
|
193
|
-
if (typeof own?.version === "string") return own.version;
|
|
194
|
-
const moved = successorRoot(pkgRoot);
|
|
195
|
-
const v = moved ? readManifest(moved)?.version : null;
|
|
196
|
-
return typeof v === "string" ? v : null;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/** npx keeps each package in its own content-addressed cache directory, so
|
|
200
|
-
* `npm i -g` is the wrong advice there — nothing global exists to upgrade. */
|
|
201
|
-
export function isNpxInstall(pkgRoot) {
|
|
202
|
-
// Split on both separators, not `path.sep`: Windows paths reach here with
|
|
203
|
-
// backslashes but a POSIX-style path is still a valid input there, and a
|
|
204
|
-
// separator-agnostic test is what keeps this identical on all three
|
|
205
|
-
// platforms. Segment-wise, so a directory merely named "my_npx-tools" is not
|
|
206
|
-
// mistaken for the npx cache.
|
|
207
|
-
return typeof pkgRoot === "string" && pkgRoot.split(/[\\/]/).includes("_npx");
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/** A git checkout is the maintainer's own tree. Its version routinely sits
|
|
211
|
-
* ahead of npm, and telling someone to `npm i -g` over their working copy is
|
|
212
|
-
* actively wrong, so the registry side of the check is skipped there.
|
|
213
|
-
*
|
|
214
|
-
* Exported for one caller outside this file: the login item, which must not be
|
|
215
|
-
* installed from a checkout for the same reason it must not be installed from
|
|
216
|
-
* an npx run — the path is not one anybody promised to keep. Everything else it
|
|
217
|
-
* gates stays internal, and each of those is already
|
|
218
|
-
* asserted against a directory with a real `.git` in it — `upgradeCommand`
|
|
219
|
-
* returning "git pull && npm run build", `upgradeName` refusing to move a
|
|
220
|
-
* checkout onto a published alias, and `startUpgrade` refusing with
|
|
221
|
-
* `git_checkout`. A test importing the predicate would restate what those three
|
|
222
|
-
* already prove, one level further from the behaviour a user can see.
|
|
223
|
-
*
|
|
224
|
-
* `existsSync` and not a directory test, deliberately (#587). Git writes `.git`
|
|
225
|
-
* as a directory only for an ordinary clone; a linked worktree and a submodule
|
|
226
|
-
* each get a FILE whose whole content is one `gitdir:` line, and all three are
|
|
227
|
-
* checkouts nobody may install over. Nothing here reads that line, which is
|
|
228
|
-
* also what keeps the rule identical on Windows, where the path inside it
|
|
229
|
-
* carries a drive letter and backslashes. Both shapes are covered in
|
|
230
|
-
* worktree-git-file-587.test.ts and beside every checkout fixture in the
|
|
231
|
-
* suite — narrowing this to `.isDirectory()` fails them. */
|
|
232
|
-
export function isGitCheckout(pkgRoot) {
|
|
233
|
-
try { return existsSync(join(pkgRoot, ".git")); } catch { return false; }
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// ── npx ──────────────────────────────────────────────────────────────────────
|
|
237
|
-
//
|
|
238
|
-
// An npx run lives in ~/.npm/_npx/<hash>/node_modules/<pkg>. The hash is over
|
|
239
|
-
// the SPEC the user typed, so upgrading means fetching a different directory —
|
|
240
|
-
// there is nothing to install over. What there IS, is the spec itself: npm
|
|
241
|
-
// writes it into <hash>/package.json as `_npx.packages`, which is the only
|
|
242
|
-
// record of whether the user typed `ccdeck`, `agent-dag` or `agents-deck`.
|
|
243
|
-
// Re-running the wrong one would work but would leave them on a package they
|
|
244
|
-
// never asked for, so it is worth reading rather than guessing.
|
|
245
|
-
|
|
246
|
-
/** The `_npx/<hash>` directory this package was unpacked into, or null. Pure —
|
|
247
|
-
* path arithmetic only, so both platforms' separators can be tested. */
|
|
248
|
-
export function npxRoot(pkgRoot) {
|
|
249
|
-
if (typeof pkgRoot !== "string") return null;
|
|
250
|
-
const parts = pkgRoot.split(/[\\/]/);
|
|
251
|
-
const i = parts.lastIndexOf("_npx");
|
|
252
|
-
if (i === -1 || i + 1 >= parts.length) return null;
|
|
253
|
-
// Keep the separator the input used: a Windows path must come back as one.
|
|
254
|
-
const sep = pkgRoot.includes("\\") && !pkgRoot.includes("/") ? "\\" : "/";
|
|
255
|
-
return parts.slice(0, i + 2).join(sep);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/** Package name out of an npm spec, scope intact: `ccdeck@1.2.3` → `ccdeck`,
|
|
259
|
-
* `@scope/pkg` → `@scope/pkg`. Null for anything that is not a plain name —
|
|
260
|
-
* a tarball URL or a git spec is not something to re-run with `@latest`. */
|
|
261
|
-
export function bareSpecName(spec) {
|
|
262
|
-
if (typeof spec !== "string") return null;
|
|
263
|
-
const s = spec.trim();
|
|
264
|
-
if (!s) return null;
|
|
265
|
-
const at = s.lastIndexOf("@");
|
|
266
|
-
const name = at > 0 ? s.slice(0, at) : s;
|
|
267
|
-
return /^@?[a-z0-9][a-z0-9._-]*(\/[a-z0-9][a-z0-9._-]*)?$/i.test(name) ? name : null;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/** What to hand `npx -y`, read from the cache directory's own metadata.
|
|
271
|
-
*
|
|
272
|
-
* `fallback` is what to answer when that metadata names nothing usable — the
|
|
273
|
-
* package name, for the caller whose job is to re-run SOMETHING. Pass null and
|
|
274
|
-
* the answer is null instead, which is what invoked-as.mjs needs: it asks which
|
|
275
|
-
* name the user typed, and there the package name is not a lesser answer, it is
|
|
276
|
-
* a wrong one. */
|
|
277
|
-
export function npxSpecFromMeta(meta, fallback = "agents-deck") {
|
|
278
|
-
const list = meta && meta._npx && Array.isArray(meta._npx.packages) ? meta._npx.packages : [];
|
|
279
|
-
for (const entry of list) {
|
|
280
|
-
const name = bareSpecName(entry);
|
|
281
|
-
if (name) return `${name}@latest`;
|
|
282
|
-
}
|
|
283
|
-
return fallback ? `${fallback}@latest` : null;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/** The same, answered against the filesystem. Null when this is not an npx run,
|
|
287
|
-
* and — for a caller that passed no fallback — when the metadata cannot be
|
|
288
|
-
* read. */
|
|
289
|
-
export function npxRestartSpec(pkgRoot, name = "agents-deck") {
|
|
290
|
-
const root = npxRoot(pkgRoot);
|
|
291
|
-
if (!root) return null;
|
|
292
|
-
let meta = null;
|
|
293
|
-
try { meta = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); } catch { /* fall back to the name */ }
|
|
294
|
-
return npxSpecFromMeta(meta, name);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// ── the package that installed us ────────────────────────────────────────────
|
|
298
|
-
//
|
|
299
|
-
// Two of the three names on npm are this tarball republished; the third is not
|
|
300
|
-
// this tarball at all. `ccdeck` is a stub that ships nothing but bin/ and
|
|
301
|
-
// depends on `agents-deck`, so `npm i -g ccdeck` produces a layout no other
|
|
302
|
-
// install does: npm stopped hoisting a global package's dependencies in v7, so
|
|
303
|
-
// the deck lands at `<prefix>/lib/node_modules/ccdeck/node_modules/agents-deck`
|
|
304
|
-
// and the process runs out of a directory owned by a package it is not named
|
|
305
|
-
// after.
|
|
306
|
-
//
|
|
307
|
-
// #351 taught the stub to FIND the deck there. Nothing downstream learned about
|
|
308
|
-
// it, so every self-update surface went on naming `agents-deck` — which in that
|
|
309
|
-
// layout resolves to `<prefix>/lib/node_modules/agents-deck`, a directory this
|
|
310
|
-
// process never reads and a `npm i -g ccdeck` user usually does not have at
|
|
311
|
-
// all. "Update now" therefore installed a second, unrelated tree: the running
|
|
312
|
-
// install never moved, so installedVersion stayed put, the restart notice never
|
|
313
|
-
// came, and the banner offered the identical update forever while the upgrade
|
|
314
|
-
// reported "done".
|
|
315
|
-
//
|
|
316
|
-
// So the name to install is not the name this build was PUBLISHED under, it is
|
|
317
|
-
// the name the install was REACHED under — and under the stub layout that is
|
|
318
|
-
// written on disk one directory up. Read from there rather than from argv[1]
|
|
319
|
-
// (invoked-as.mjs answers the neighbouring question of which command the user
|
|
320
|
-
// TYPED, and cannot answer this one): the stub spawns the deck by absolute
|
|
321
|
-
// path, so argv[1] reads `agent-dag.js` under all three names, and on Windows
|
|
322
|
-
// npm's .cmd/.ps1 shims never pass the typed name on at all. The directory npm
|
|
323
|
-
// built is the one carrier that survives every platform.
|
|
324
|
-
//
|
|
325
|
-
// The other two names need no layout at all, and that is why the first fix
|
|
326
|
-
// missed one of them. `agents-deck` and `agent-dag` are the same tarball
|
|
327
|
-
// published twice with the manifest renamed between the two, so the deck IS the
|
|
328
|
-
// whole package under both and sits directly under the global node_modules with
|
|
329
|
-
// nothing above it — hostPackage correctly finds no host, and the answer falls
|
|
330
|
-
// through to a default that is right for one of the two and wrong for the
|
|
331
|
-
// other. What both of them do carry is their own package.json, which npm's
|
|
332
|
-
// rename made authoritative: see installedName.
|
|
333
|
-
|
|
334
|
-
/** Every name this deck is published under.
|
|
335
|
-
*
|
|
336
|
-
* `agents-deck` and `agent-dag` are one tarball published twice — see
|
|
337
|
-
* .github/workflows/publish.yml, which renames it between the two — and since
|
|
338
|
-
* #340 `ccdeck` is a third rename of the same tarball rather than a launcher
|
|
339
|
-
* package in front of it. The same three strings as
|
|
340
|
-
* invoked-as.mjs's COMMANDS, and deliberately not that list: this is the set
|
|
341
|
-
* of npm PACKAGES a `npm i -g` may name, that is the set of bin commands a
|
|
342
|
-
* user may type. They are equal only because the rename made them so, and a
|
|
343
|
-
* test pins them against each other rather than either side assuming it. */
|
|
344
|
-
export const ALIAS_PACKAGES = ["agents-deck", "agent-dag", "ccdeck"];
|
|
345
|
-
|
|
346
|
-
/** Which of those three THIS build was published as, out of its own manifest.
|
|
347
|
-
*
|
|
348
|
-
* The other half of the same bug, and the easier half. `agent-dag` is not a
|
|
349
|
-
* stub and is not nested inside anything — `npm i -g agent-dag` puts the deck
|
|
350
|
-
* straight into `<prefix>/lib/node_modules/agent-dag` — so hostPackage finds no
|
|
351
|
-
* host above it and there is no layout to read the answer out of. There does
|
|
352
|
-
* not need to be: CI publishes that name by renaming the manifest in the
|
|
353
|
-
* tarball (`npm pkg set name=agent-dag` in .github/workflows/publish.yml), so
|
|
354
|
-
* the running copy's own package.json says `agent-dag` and has all along.
|
|
355
|
-
*
|
|
356
|
-
* It was never read. `name` is a parameter with a default in every exported
|
|
357
|
-
* function here, and not one caller in the deck passes it — index.mjs and
|
|
358
|
-
* bin/deck.js both call versionReport and startUpgrade with a pkgRoot and
|
|
359
|
-
* nothing else — so the default WAS the answer, and the default is a guess
|
|
360
|
-
* about which of three names this build carries. For `npm i -g agent-dag` it
|
|
361
|
-
* guessed wrong: the user was told to run `npm i -g agents-deck@latest`, which
|
|
362
|
-
* installs a second, unrelated global package while their `agent-dag` binary
|
|
363
|
-
* stays exactly where it was, and the version check went on caching under a
|
|
364
|
-
* package this install is not.
|
|
365
|
-
*
|
|
366
|
-
* Confined to the three names, for the reason hostNameFromMeta is: the answer
|
|
367
|
-
* becomes an argument in the `npm i -g` this process spawns, and startUpgrade
|
|
368
|
-
* promises that vector can only ever name this deck. A fork that republishes
|
|
369
|
-
* under a fourth name adds it to the list above, which is one line and a
|
|
370
|
-
* deliberate one — rather than having the deck hand npm a package name it has
|
|
371
|
-
* never heard of because a directory on disk said so.
|
|
372
|
-
*
|
|
373
|
-
* `fallback` is what to answer when the manifest is missing, unreadable, or
|
|
374
|
-
* names something that is not one of ours — the caller's own `name`, so every
|
|
375
|
-
* shape that cannot prove which alias it is keeps the behaviour it had. */
|
|
376
|
-
export function installedName(pkgRoot, fallback = "agents-deck") {
|
|
377
|
-
const self = readManifest(pkgRoot)?.name;
|
|
378
|
-
return typeof self === "string" && ALIAS_PACKAGES.includes(self) ? self : fallback;
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
/** The directory of the package this one is installed INSIDE, or null when it
|
|
382
|
-
* is not inside one. Pure — path arithmetic only, so a Windows layout can be
|
|
383
|
-
* tested on a POSIX box and both separators are split on, exactly as npxRoot
|
|
384
|
-
* does it and for the same reason. */
|
|
385
|
-
export function hostRoot(pkgRoot) {
|
|
386
|
-
if (typeof pkgRoot !== "string") return null;
|
|
387
|
-
const parts = pkgRoot.split(/[\\/]/);
|
|
388
|
-
// `<host>/node_modules/<us>` and nothing else: the segment directly above us
|
|
389
|
-
// has to be the node_modules npm nested us into, and the one above that is
|
|
390
|
-
// the host. A scoped package would sit one level deeper, under `@scope`, and
|
|
391
|
-
// is refused here rather than guessed at — none of the three names is scoped.
|
|
392
|
-
if (parts.length < 3 || parts[parts.length - 2] !== "node_modules") return null;
|
|
393
|
-
// Keep the separator the input used: a Windows path must come back as one.
|
|
394
|
-
const sep = pkgRoot.includes("\\") && !pkgRoot.includes("/") ? "\\" : "/";
|
|
395
|
-
return parts.slice(0, -2).join(sep) || null;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
/** The host package's name, out of its own manifest — but only when it is one
|
|
399
|
-
* of this deck's aliases AND it declares a dependency on us.
|
|
400
|
-
*
|
|
401
|
-
* The alias half is the load-bearing one. Any project that lists `agents-deck`
|
|
402
|
-
* in its dependencies and runs it out of its own node_modules — a workspace, a
|
|
403
|
-
* CI job, a tool that embeds the deck — is in exactly the same shape on disk
|
|
404
|
-
* as the stub, and answering with THAT name would have the deck offering to
|
|
405
|
-
* `npm i -g their-app@latest`: a package it has no business installing, and on
|
|
406
|
-
* a private name one that does not exist. Confining the answer to the three
|
|
407
|
-
* published names leaves every such install with the fallback it has today. */
|
|
408
|
-
export function hostNameFromMeta(meta, name = "agents-deck") {
|
|
409
|
-
const host = typeof meta?.name === "string" ? meta.name : null;
|
|
410
|
-
if (!host || !ALIAS_PACKAGES.includes(host)) return null;
|
|
411
|
-
return typeof meta?.dependencies?.[name] === "string" ? host : null;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
/** The same, answered against the filesystem: `{ root, name }` for the alias
|
|
415
|
-
* package this copy was installed as a dependency of, or null. */
|
|
416
|
-
export function hostPackage(pkgRoot, name = "agents-deck") {
|
|
417
|
-
const root = hostRoot(pkgRoot);
|
|
418
|
-
if (!root) return null;
|
|
419
|
-
const host = hostNameFromMeta(readManifest(root), name);
|
|
420
|
-
return host ? { root, name: host } : null;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
/**
|
|
424
|
-
* The directory that holds this install's code AFTER an upgrade replaced it,
|
|
425
|
-
* or null when nothing has been replaced.
|
|
426
|
-
*
|
|
427
|
-
* One layout produces this and it is a transitional one. Before #340, `npm i -g
|
|
428
|
-
* ccdeck` installed a launcher package with the deck nested inside it:
|
|
429
|
-
*
|
|
430
|
-
* <prefix>/lib/node_modules/ccdeck/ the launcher
|
|
431
|
-
* <prefix>/lib/node_modules/ccdeck/node_modules/agents-deck/ pkgRoot
|
|
432
|
-
*
|
|
433
|
-
* upgradeName reads the host's declared dependency and correctly answers
|
|
434
|
-
* `ccdeck`, so the upgrade runs `npm i -g ccdeck@latest` — which since #340
|
|
435
|
-
* installs the deck itself over the host directory and removes everything that
|
|
436
|
-
* was under it, including pkgRoot. The process keeps running (POSIX keeps an
|
|
437
|
-
* open inode alive, and the modules are already loaded) out of a directory that
|
|
438
|
-
* no longer exists.
|
|
439
|
-
*
|
|
440
|
-
* Deliberately NOT hostPackage. That function recognises a host by the
|
|
441
|
-
* dependency it declares on us, and the whole point here is that the host has
|
|
442
|
-
* just stopped declaring one — it is no longer a launcher, it is the deck. What
|
|
443
|
-
* identifies it instead is its name, confined to the three we publish, for the
|
|
444
|
-
* same reason installedName confines its answer: this decides what a version
|
|
445
|
-
* report says about the user's machine, and a directory that merely happens to
|
|
446
|
-
* sit above us is not evidence.
|
|
447
|
-
*
|
|
448
|
-
* Guarded on our own manifest being unreadable, so nothing changes for an
|
|
449
|
-
* install that is intact — including the ordinary nested layout before it is
|
|
450
|
-
* upgraded, where pkgRoot answers for itself and this is never consulted.
|
|
451
|
-
*/
|
|
452
|
-
export function successorRoot(pkgRoot) {
|
|
453
|
-
if (readManifest(pkgRoot)) return null;
|
|
454
|
-
const root = hostRoot(pkgRoot);
|
|
455
|
-
if (!root) return null;
|
|
456
|
-
const name = readManifest(root)?.name;
|
|
457
|
-
return typeof name === "string" && ALIAS_PACKAGES.includes(name) ? root : null;
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
/** The package an upgrade would actually install here — the only package worth
|
|
461
|
-
* asking npm about.
|
|
462
|
-
*
|
|
463
|
-
* The check used to ask about `agents-deck` no matter what the upgrade
|
|
464
|
-
* command installed, so a deck started with `npx ccdeck` compared its version
|
|
465
|
-
* against `agents-deck`'s dist-tag and then handed back `npx -y ccdeck@latest`.
|
|
466
|
-
* Nothing tied the two together. CI publishes the three names one after
|
|
467
|
-
* another, so between the first and the last publish they genuinely disagree,
|
|
468
|
-
* and inside that window the deck offered a version the command could not
|
|
469
|
-
* install — the ETARGET below, with a window measured in publishes rather than
|
|
470
|
-
* in seconds of propagation.
|
|
471
|
-
*
|
|
472
|
-
* Deriving the name from what would actually be installed keeps the two halves
|
|
473
|
-
* consistent by construction: whatever the command will install is what gets
|
|
474
|
-
* asked about, and the per-name marker follows the same name. Each install
|
|
475
|
-
* shape carries that answer somewhere different — npx in the spec it recorded,
|
|
476
|
-
* a stub install in the layout npm built, a plain global install in the
|
|
477
|
-
* manifest of the package it is. All three are read; none is assumed. */
|
|
478
|
-
export function upgradeName(pkgRoot, name = "agents-deck") {
|
|
479
|
-
// The published name this build actually carries, which outranks `name` in
|
|
480
|
-
// every branch below because `name` is a default at every call site in the
|
|
481
|
-
// deck and the manifest on disk is not a guess. It replaces the parameter
|
|
482
|
-
// rather than sitting beside it: this one function is where all four of the
|
|
483
|
-
// registry-shaped answers meet — the dist-tag that is fetched, the marker it
|
|
484
|
-
// is cached in, the command the user is shown, and the argv npm is spawned
|
|
485
|
-
// with — so resolving the name once here is what keeps those four naming one
|
|
486
|
-
// package, which is the property the whole function exists to hold.
|
|
487
|
-
const self = installedName(pkgRoot, name);
|
|
488
|
-
// A checkout installs nothing at all, so the published name is the only
|
|
489
|
-
// sensible subject for the version question — and the only one whose
|
|
490
|
-
// dist-tag says anything about the branch the maintainer is sitting on.
|
|
491
|
-
if (isGitCheckout(pkgRoot)) return self;
|
|
492
|
-
// npx recorded the spec the user typed, which is a better answer than the
|
|
493
|
-
// manifest: `npx ccdeck` unpacks a deck whose manifest says `agents-deck`,
|
|
494
|
-
// and re-running the name they did not type would move them off the stub.
|
|
495
|
-
// The manifest is what is left when that record cannot be read.
|
|
496
|
-
if (isNpxInstall(pkgRoot)) return bareSpecName(npxRestartSpec(pkgRoot, self)) ?? self;
|
|
497
|
-
// Left: a global install, where the answer is whichever package owns the
|
|
498
|
-
// directory npm would rewrite. That is the stub for `npm i -g ccdeck`, where
|
|
499
|
-
// the deck is nested one level down inside a package it is not named after,
|
|
500
|
-
// and this build's own name for `npm i -g agents-deck` and `npm i -g
|
|
501
|
-
// agent-dag`, where the deck IS the whole package and nothing is above it.
|
|
502
|
-
//
|
|
503
|
-
// successorRoot is the third case, and it is the second half of the same
|
|
504
|
-
// question. Once that stub install HAS been upgraded, the host has stopped
|
|
505
|
-
// declaring a dependency on us — which is the only thing hostPackage
|
|
506
|
-
// recognises a host by — so this fell through to `self`, and `self` is the
|
|
507
|
-
// fallback `agents-deck` because our own manifest went with the directory.
|
|
508
|
-
// The user was then shown `npm i -g agents-deck@latest`: a different package,
|
|
509
|
-
// a second global tree, and their `ccdeck` binary left exactly where it was.
|
|
510
|
-
// That is #358 verbatim, arriving through the upgrade that was supposed to be
|
|
511
|
-
// the end of it. The successor's own manifest names it, and that name is what
|
|
512
|
-
// the next upgrade has to install.
|
|
513
|
-
const host = hostPackage(pkgRoot, self)?.name;
|
|
514
|
-
if (host) return host;
|
|
515
|
-
const moved = successorRoot(pkgRoot);
|
|
516
|
-
return moved ? installedName(moved, self) : self;
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
/** The exact line the user can paste, for the way THIS copy was installed. */
|
|
520
|
-
export function upgradeCommand(pkgRoot, name = "agents-deck") {
|
|
521
|
-
// A checkout is updated by pulling, and the bundle is built, not shipped —
|
|
522
|
-
// so `npm run build` is part of the answer rather than an afterthought.
|
|
523
|
-
if (isGitCheckout(pkgRoot)) return "git pull && npm run build";
|
|
524
|
-
if (isNpxInstall(pkgRoot)) return `npx -y ${upgradeName(pkgRoot, name)}@latest`;
|
|
525
|
-
// Through upgradeName for the same reason the npx line above it is: the
|
|
526
|
-
// printed command is the user's escape hatch when the button fails or is not
|
|
527
|
-
// offered, and one that names a package this install cannot be replaced by is
|
|
528
|
-
// worse than none — it looks like it worked.
|
|
529
|
-
return `npm i -g ${upgradeName(pkgRoot, name)}@latest`;
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
// ── what npm has ─────────────────────────────────────────────────────────────
|
|
533
|
-
|
|
534
|
-
// The dist-tags endpoint answers with ~20 bytes ({"latest":"1.30.7"}); the full
|
|
535
|
-
// packument is >2 KB and needs parsing we have no use for.
|
|
536
|
-
//
|
|
537
|
-
// Answers `{ ok, version }` rather than a bare string, because "npm says the
|
|
538
|
-
// latest is X" and "npm did not answer" used to arrive here as the same null.
|
|
539
|
-
// `ok` is true only when the registry handed back a usable version — a
|
|
540
|
-
// timeout, a non-200 and a 200 with no `latest` in it are all failures, and
|
|
541
|
-
// the caller has to be able to tell them from an up-to-date deck.
|
|
542
|
-
async function fetchLatest(name) {
|
|
543
|
-
try {
|
|
544
|
-
const res = await fetch(`https://registry.npmjs.org/-/package/${name}/dist-tags`, {
|
|
545
|
-
headers: { accept: "application/json", "user-agent": "agents-deck" },
|
|
546
|
-
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
547
|
-
});
|
|
548
|
-
if (!res.ok) return { ok: false, version: null };
|
|
549
|
-
const v = (await res.json())?.latest;
|
|
550
|
-
return typeof v === "string" ? { ok: true, version: v } : { ok: false, version: null };
|
|
551
|
-
} catch {
|
|
552
|
-
return { ok: false, version: null };
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
// A dist-tag is a pointer, and being pointed at is not the same as being
|
|
557
|
-
// installable.
|
|
558
|
-
//
|
|
559
|
-
// npm makes the moved tag visible before the version document has propagated to
|
|
560
|
-
// the replica the installer reads, so for a window `{"latest":"1.33.28"}` and
|
|
561
|
-
// `No matching version found for ccdeck@1.33.28` are both true at the same
|
|
562
|
-
// moment. Reported live: the banner offered v1.33.28, the restart ran
|
|
563
|
-
// `npx -y ccdeck@latest`, npm answered ETARGET, and the deck came back on the
|
|
564
|
-
// version it started with after tearing itself down — under npx a restart is
|
|
565
|
-
// not free, since the worker exits and hands the port over before anything is
|
|
566
|
-
// fetched.
|
|
567
|
-
//
|
|
568
|
-
// So the tag is checked against the version document, which is the same
|
|
569
|
-
// question `npm view <name>@<version> version` asks and the same document the
|
|
570
|
-
// installer resolves against. 404 is the answer this exists for: published
|
|
571
|
-
// tag, unpublished version, try again in five minutes. `ok` is false only when
|
|
572
|
-
// the registry gave no usable answer at all — that is not a licence to
|
|
573
|
-
// announce either, but it is not evidence of an unpublished version.
|
|
574
|
-
async function isPublished(name, version) {
|
|
575
|
-
try {
|
|
576
|
-
const res = await fetch(`https://registry.npmjs.org/${name}/${version}`, {
|
|
577
|
-
headers: { accept: "application/json", "user-agent": "agents-deck" },
|
|
578
|
-
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
579
|
-
});
|
|
580
|
-
if (res.status === 404) return { ok: true, published: false };
|
|
581
|
-
if (!res.ok) return { ok: false, published: false };
|
|
582
|
-
// The document has to be the one asked for: a registry that answers 200
|
|
583
|
-
// with something else has not shown that this version is resolvable.
|
|
584
|
-
const body = await res.json().catch(() => null);
|
|
585
|
-
return { ok: true, published: body?.version === version };
|
|
586
|
-
} catch {
|
|
587
|
-
return { ok: false, published: false };
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
/** Marker file name for a package: `ccdeck` → `.self-update-check-ccdeck`.
|
|
592
|
-
*
|
|
593
|
-
* Pure, and deliberately strict about what reaches the filesystem — a package
|
|
594
|
-
* name may be scoped (`@scope/pkg`), and `/`, `\` and `:` are either a path
|
|
595
|
-
* separator or outright illegal in a Windows file name. Everything outside
|
|
596
|
-
* `[a-z0-9._-]` collapses to `-`, the scope's leading `@` is dropped so the
|
|
597
|
-
* common case reads plainly, and the tail is trimmed so a pathological name
|
|
598
|
-
* cannot produce a path the OS refuses. An unusable name falls back to the
|
|
599
|
-
* default rather than to the shared file this fix exists to get rid of. */
|
|
600
|
-
export function markerFileName(name = "agents-deck") {
|
|
601
|
-
return `.self-update-check-${safeNamePart(name)}`;
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
/** The sanitised half, shared with the restart-failure note below so the two
|
|
605
|
-
* files agree on what a package name becomes on disk. */
|
|
606
|
-
function safeNamePart(name) {
|
|
607
|
-
const raw = typeof name === "string" ? name.trim().toLowerCase() : "";
|
|
608
|
-
const safe = raw
|
|
609
|
-
.replace(/^@/, "")
|
|
610
|
-
.replace(/[^a-z0-9._-]+/g, "-")
|
|
611
|
-
.slice(0, 64)
|
|
612
|
-
// Trimmed after the slice, and at both ends: Windows silently drops a
|
|
613
|
-
// trailing dot from a file name, so a name that ends in one would write to
|
|
614
|
-
// a path that is not the path we would later read.
|
|
615
|
-
.replace(/^[-.]+|[-.]+$/g, "");
|
|
616
|
-
return safe || "agents-deck";
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
function markerPath(name) {
|
|
620
|
-
return join(MARKER_DIR, markerFileName(name));
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
// The marker carries the answer, not just the timestamp. The existing markers
|
|
624
|
-
// store only an mtime, which means a restart inside the window forgets what npm
|
|
625
|
-
// said and shows nothing until the window expires.
|
|
626
|
-
//
|
|
627
|
-
// It carries the outcome too. `at` is when npm last ANSWERED and `version` is
|
|
628
|
-
// what it said; `failedAt` is when the last attempt failed, and is null the
|
|
629
|
-
// moment one succeeds. Keeping them apart is what lets the deck say "checked
|
|
630
|
-
// 3m ago" and "could not reach npm" as the different things they are, instead
|
|
631
|
-
// of reporting a timeout as a fresh, up-to-date check.
|
|
632
|
-
function readMarkerFile(path) {
|
|
633
|
-
try {
|
|
634
|
-
const m = JSON.parse(readFileSync(path, "utf8"));
|
|
635
|
-
// Either half is enough to be worth keeping: a marker written by a first
|
|
636
|
-
// attempt that failed has no `at` yet, and markers written before
|
|
637
|
-
// `failedAt` existed have no `failedAt` at all.
|
|
638
|
-
return (typeof m?.at === "number" || typeof m?.failedAt === "number") ? m : null;
|
|
639
|
-
} catch {
|
|
640
|
-
return null;
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
// The unsuffixed marker every earlier deck wrote, read at most once per name
|
|
645
|
-
// per process: upgrading to this version should not throw away an answer npm
|
|
646
|
-
// already gave. It is consulted only while the per-name file is still missing,
|
|
647
|
-
// so the shared file never goes back to being in charge.
|
|
648
|
-
const _legacy = new Map();
|
|
649
|
-
function legacyMarker(name) {
|
|
650
|
-
const key = markerFileName(name);
|
|
651
|
-
if (!_legacy.has(key)) _legacy.set(key, readMarkerFile(LEGACY_MARKER));
|
|
652
|
-
return _legacy.get(key);
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
function readMarker(name) {
|
|
656
|
-
return readMarkerFile(markerPath(name)) ?? legacyMarker(name);
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
/** The version npm last named as `latest` for this package, straight off the
|
|
660
|
-
* marker and with no lookup of its own.
|
|
661
|
-
*
|
|
662
|
-
* This is what the supervisor calls a failed upgrade's TARGET. It has to be
|
|
663
|
-
* answerable without the network — the supervisor asks it in the moment
|
|
664
|
-
* between a click and a fetch, and the reason the fetch is about to fail may
|
|
665
|
-
* well be that there is no network — and it has to be the same number the
|
|
666
|
-
* banner offered, which is precisely what the marker holds. */
|
|
667
|
-
export function lastKnownLatest(name = "agents-deck") {
|
|
668
|
-
const v = readMarker(name)?.version;
|
|
669
|
-
return typeof v === "string" && v ? v : null;
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
function writeMarker(name, marker) {
|
|
673
|
-
const path = markerPath(name);
|
|
674
|
-
try {
|
|
675
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
676
|
-
writeFileSync(path, JSON.stringify(marker));
|
|
677
|
-
} catch { /* a read-only home must not break the deck */ }
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
/** What the marker should hold after an attempt. Pure, because "a failed
|
|
681
|
-
* lookup must not be recorded as a successful one" is precisely the rule this
|
|
682
|
-
* file used to get wrong, and a rule worth a bug is worth a test.
|
|
683
|
-
*
|
|
684
|
-
* A success stamps the hour and clears the failure. A failure records only
|
|
685
|
-
* itself, leaving the last real answer and the time it arrived untouched —
|
|
686
|
-
* the deck keeps showing what it knew, and stops claiming it just confirmed
|
|
687
|
-
* it.
|
|
688
|
-
*
|
|
689
|
-
* `installable` is the third outcome: npm answered, and what it named is not
|
|
690
|
-
* yet a version anything can install. That is a real answer — `at` moves, the
|
|
691
|
-
* registry was reached — but the version is held in `pending` instead of
|
|
692
|
-
* `version`, so `latest` stays a number the upgrade command can resolve, and
|
|
693
|
-
* `pendingAt` puts the next look on the short window rather than the hour. */
|
|
694
|
-
export function nextMarker({ prev, now, ok, version, installable = true }) {
|
|
695
|
-
if (!ok) {
|
|
696
|
-
return {
|
|
697
|
-
at: prev?.at ?? null,
|
|
698
|
-
version: prev?.version ?? null,
|
|
699
|
-
failedAt: now,
|
|
700
|
-
pending: prev?.pending ?? null,
|
|
701
|
-
pendingAt: prev?.pendingAt ?? null,
|
|
702
|
-
};
|
|
703
|
-
}
|
|
704
|
-
if (!installable) {
|
|
705
|
-
return { at: now, version: prev?.version ?? null, failedAt: null, pending: version ?? null, pendingAt: now };
|
|
706
|
-
}
|
|
707
|
-
return { at: now, version: version ?? null, failedAt: null, pending: null, pendingAt: null };
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
// Even a per-package marker is shared by every deck running that package, so
|
|
711
|
-
// one deck's check still answers for the others inside the window. Asking once
|
|
712
|
-
// per PROCESS lands the check exactly where the user expects the truth: a
|
|
713
|
-
// freshly started deck — which for `npx ccdeck` is every single run. Keyed by
|
|
714
|
-
// name, so a process that asked about one package has not asked about another.
|
|
715
|
-
const _askedThisProcess = new Set();
|
|
716
|
-
|
|
717
|
-
/**
|
|
718
|
-
* Whether to ask npm, or reuse the answer on disk. Pure, because "why did no
|
|
719
|
-
* banner appear" is the question this feature gets asked, and the rule behind
|
|
720
|
-
* it should be readable in one place.
|
|
721
|
-
*/
|
|
722
|
-
export function checkDue({ at, failedAt, pendingAt, now, first = false, force = false, ttlMs = CHECK_MS, retryMs = RETRY_MS }) {
|
|
723
|
-
if (force || first) return true; // explicit ask, or this process's first
|
|
724
|
-
// Two ways of not having an answer yet, and neither may spend the hour a real
|
|
725
|
-
// answer buys: the last attempt failed, or npm named a version that cannot be
|
|
726
|
-
// installed yet. Both take the short window instead — an unreachable registry
|
|
727
|
-
// must not turn every poll into another request, and a release mid-publish is
|
|
728
|
-
// resolvable minutes later, not an hour later. Answered before `at`, which
|
|
729
|
-
// here is the older, settled check and would otherwise ask again immediately
|
|
730
|
-
// (or, for a pending version, not for another hour).
|
|
731
|
-
const unsettled = [failedAt, pendingAt].filter(t => typeof t === "number");
|
|
732
|
-
if (unsettled.length) {
|
|
733
|
-
const last = Math.max(...unsettled);
|
|
734
|
-
if (last > now) return true; // clock moved; do not wait it out
|
|
735
|
-
return now - last >= retryMs;
|
|
736
|
-
}
|
|
737
|
-
if (typeof at !== "number") return true; // never checked
|
|
738
|
-
if (at > now) return true; // marker from the future: a moved clock
|
|
739
|
-
return now - at >= ttlMs;
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
/** Last known npm `latest`, refreshed at most once per CHECK_MS. Returns the
|
|
743
|
-
* cached answer immediately when the window has not elapsed.
|
|
744
|
-
*
|
|
745
|
-
* `force` skips the window: the first call in this process, and an explicit
|
|
746
|
-
* "check now" from the UI. What it does not skip is FORCE_POLL_MS — see
|
|
747
|
-
* mayAskNpm, and the note above it for what a forced call used to cost. */
|
|
748
|
-
async function latestOnNpm(name, now, force = false) {
|
|
749
|
-
const m = readMarker(name);
|
|
750
|
-
const key = markerFileName(name);
|
|
751
|
-
const first = !_askedThisProcess.has(key);
|
|
752
|
-
_askedThisProcess.add(key);
|
|
753
|
-
if (!checkDue({ at: m?.at, failedAt: m?.failedAt, pendingAt: m?.pendingAt, now, first, force })) {
|
|
754
|
-
return m?.version ?? null;
|
|
755
|
-
}
|
|
756
|
-
// Offered before the floor: a check that has not answered yet is a lookup
|
|
757
|
-
// newer than the marker, which is what refresh asked for, and joining it costs
|
|
758
|
-
// nothing.
|
|
759
|
-
const inflight = _inflight.get(key);
|
|
760
|
-
if (inflight) return inflight;
|
|
761
|
-
// The floor, under every rule above it. A refused check is answered with the
|
|
762
|
-
// version we already hold — the same string an ordinary cached call returns,
|
|
763
|
-
// carrying the marker's own `checkedAt` rather than the moment of the read
|
|
764
|
-
// that was refused, so /api/version reports exactly what it reported a moment
|
|
765
|
-
// ago and no surface learns a new failure mode from being asked twice.
|
|
766
|
-
if (!mayAskNpm({ now, lastAskAt: _lastAskAt.get(key) ?? 0 })) return m?.version ?? null;
|
|
767
|
-
_lastAskAt.set(key, now);
|
|
768
|
-
const run = runCheck(name, m, now)
|
|
769
|
-
// Record the outcome, not just the moment, and record it against THIS
|
|
770
|
-
// package: only an answer stamps `at`; a failure takes the short retry
|
|
771
|
-
// window instead of the hour, keeps the version we already knew rather
|
|
772
|
-
// than erasing it, and lands in this name's marker rather than spending
|
|
773
|
-
// another package's window on a lookup that was never about it.
|
|
774
|
-
.then((marker) => {
|
|
775
|
-
writeMarker(name, marker);
|
|
776
|
-
return marker.version ?? null;
|
|
777
|
-
})
|
|
778
|
-
.catch(() => m?.version ?? null)
|
|
779
|
-
.finally(() => { _inflight.delete(key); });
|
|
780
|
-
_inflight.set(key, run);
|
|
781
|
-
return run;
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
/** One check, as a marker: what npm's dist-tag says, and — only when that is a
|
|
785
|
-
* version this deck has not already confirmed — whether it can be installed.
|
|
786
|
-
*
|
|
787
|
-
* The second request is what keeps the banner honest, and it is skipped in the
|
|
788
|
-
* case that runs all day: a tag that has not moved was confirmed the first
|
|
789
|
-
* time it was seen, so a deck sitting on the current release still costs one
|
|
790
|
-
* ~20-byte GET per check. Confirming costs one more, once per release. */
|
|
791
|
-
async function runCheck(name, prev, now) {
|
|
792
|
-
const { ok, version } = await fetchLatest(name);
|
|
793
|
-
if (!ok || version === prev?.version) return nextMarker({ prev, now, ok, version });
|
|
794
|
-
const probe = await isPublished(name, version);
|
|
795
|
-
return nextMarker({ prev, now, ok, version, installable: probe.ok && probe.published });
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
// ── the notice ───────────────────────────────────────────────────────────────
|
|
799
|
-
|
|
800
|
-
/** Pure: turns three version strings into at most one thing worth saying.
|
|
801
|
-
*
|
|
802
|
-
* "restart" outranks "upgrade" because it is the free fix — the newer code is
|
|
803
|
-
* already on the machine and a restart is all that stands between the user and
|
|
804
|
-
* it. Once restarted, the next check surfaces the upgrade if one is still due. */
|
|
805
|
-
export function pickNotice({ running, installed, latest }) {
|
|
806
|
-
if (running && installed && isOlder(running, installed)) {
|
|
807
|
-
return { kind: "restart", from: running, to: installed };
|
|
808
|
-
}
|
|
809
|
-
const have = installed ?? running;
|
|
810
|
-
if (have && latest && isOlder(have, latest)) {
|
|
811
|
-
return { kind: "upgrade", from: have, to: latest };
|
|
812
|
-
}
|
|
813
|
-
return null;
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
// ── installing ───────────────────────────────────────────────────────────────
|
|
817
|
-
|
|
818
|
-
const INSTALL_TIMEOUT_MS = 300_000; // a cold global install on a slow line
|
|
819
|
-
|
|
820
|
-
/**
|
|
821
|
-
* What `spawn` gets for `npm install -g <target>@latest`.
|
|
822
|
-
*
|
|
823
|
-
* This was the last caller still spelling it the way #362 and #456 were written
|
|
824
|
-
* to remove: `spawn("npm.cmd", args, { shell: true })`, with a comment claiming
|
|
825
|
-
* it was "the same pair the ccusage installer uses". ccusage stopped using that
|
|
826
|
-
* pair when #456 fixed it, and this one was never revisited — it does not go
|
|
827
|
-
* through `run`/`runInteractive`/`runDetached`, so #457's sweep of their callers
|
|
828
|
-
* could not see it and exec-shim-callers.test.ts never listed it.
|
|
829
|
-
*
|
|
830
|
-
* Both halves of the old spelling were wrong on Windows and only one of them
|
|
831
|
-
* bites today.
|
|
832
|
-
*
|
|
833
|
-
* The one that bites: a `.cmd` shim locates its payload relative to `%~dp0`,
|
|
834
|
-
* the drive and path of the command token cmd.exe was handed, and a BARE
|
|
835
|
-
* `npm.cmd` carries no directory — so `%~dp0` came out as the deck's working
|
|
836
|
-
* directory and npm's shim went looking for `node_modules\npm\bin\npm-cli.js`
|
|
837
|
-
* underneath it. A user with a global install, deck started from
|
|
838
|
-
* `C:\Users\vceban`, clicks Update now and npm dies with `Cannot find module
|
|
839
|
-
* 'C:\Users\vceban\node_modules\npm\bin\npm-cli.js'` — on a machine whose npm
|
|
840
|
-
* is perfectly healthy, and where typing the same command at the same prompt
|
|
841
|
-
* works. shimPath is the answer, and `?? "npm.cmd"` keeps a layout it cannot see
|
|
842
|
-
* exactly as well off as it was.
|
|
843
|
-
*
|
|
844
|
-
* The one that does not, yet: `shell: true` makes Node join file and args with
|
|
845
|
-
* single spaces and no quoting. These arguments contain no spaces, so it has
|
|
846
|
-
* never mattered here — but it is the #362 defect sitting one argument away, and
|
|
847
|
-
* spawnSpec removes it by quoting every token into the cmd.exe line.
|
|
848
|
-
*
|
|
849
|
-
* POSIX is untouched: `npm` there is a real executable, isBatch is false, and
|
|
850
|
-
* the vector goes to spawn exactly as it always has.
|
|
851
|
-
*
|
|
852
|
-
* Exported for tests: the platform is a parameter so the Windows command line
|
|
853
|
-
* can be checked from any OS, and `deps` stands in for the Windows filesystem
|
|
854
|
-
* the shim lookup asks about. The same shape ccusage.mjs's installSpec has.
|
|
855
|
-
*/
|
|
856
|
-
export function upgradeSpec(target, platform = process.platform, deps) {
|
|
857
|
-
const args = ["install", "-g", `${target}@latest`, "--no-audit", "--no-fund", "--loglevel", "error"];
|
|
858
|
-
const file = platform === "win32" ? (shimPath("npm.cmd", deps) ?? "npm.cmd") : "npm";
|
|
859
|
-
return { ...spawnSpec(file, args, platform), plain: args };
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
/**
|
|
863
|
-
* Why an in-app upgrade would be wrong here, or null when it is fine.
|
|
864
|
-
*
|
|
865
|
-
* Pure so the policy can be read and tested on its own — it is the part that
|
|
866
|
-
* decides whether we are allowed to write to the user's machine.
|
|
867
|
-
*/
|
|
868
|
-
export function upgradeBlockedReason({ git, npx, writable, optedOut }) {
|
|
869
|
-
if (optedOut) return "opted_out";
|
|
870
|
-
// The maintainer's own tree. Its version leads npm's, and installing over it
|
|
871
|
-
// would replace a working copy with a published tarball.
|
|
872
|
-
if (git) return "git_checkout";
|
|
873
|
-
// npx runs from a content-addressed cache directory that is never upgraded in
|
|
874
|
-
// place — `npx agents-deck@latest` fetches a DIFFERENT directory, which this
|
|
875
|
-
// process could not switch to even after restarting.
|
|
876
|
-
if (npx) return "npx";
|
|
877
|
-
// Almost always a root-owned global prefix. Failing inside npm with EACCES
|
|
878
|
-
// tells the user less than declining up front does.
|
|
879
|
-
if (!writable) return "not_writable";
|
|
880
|
-
return null;
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
/**
|
|
884
|
-
* How this copy can update itself, if it can at all.
|
|
885
|
-
*
|
|
886
|
-
* "install" — `npm i -g` here and restart into the new files.
|
|
887
|
-
* "npx" — nothing to install: the supervisor re-runs `npx -y <spec>`,
|
|
888
|
-
* which fetches a NEW cache directory and hands the port to it.
|
|
889
|
-
* null — a checkout, an unwritable prefix, or an explicit opt-out; the
|
|
890
|
-
* user gets the command and does it themselves.
|
|
891
|
-
*
|
|
892
|
-
* Pure, so the policy is one readable expression rather than three conditions
|
|
893
|
-
* spread across the server and the UI.
|
|
894
|
-
*/
|
|
895
|
-
export function upgradeMode(blockedReason) {
|
|
896
|
-
if (blockedReason === null || blockedReason === undefined) return "install";
|
|
897
|
-
return blockedReason === "npx" ? "npx" : null;
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
/**
|
|
901
|
-
* Can this user write into `p`?
|
|
902
|
-
*
|
|
903
|
-
* ON WINDOWS THE QUESTION HAS TO BE ASKED BY WRITING (#795). libuv's
|
|
904
|
-
* `fs__access` short-circuits to success for anything carrying
|
|
905
|
-
* FILE_ATTRIBUTE_DIRECTORY — "Directories cannot be read-only on Windows" — and
|
|
906
|
-
* never consults the ACL; Node documents this. Both arguments this is called
|
|
907
|
-
* with are always directories, so `writable` was unconditionally true there,
|
|
908
|
-
* `upgradeBlockedReason` could never answer `not_writable`, and the banner
|
|
909
|
-
* offered "Update & restart" on the common nvm-windows layout where the global
|
|
910
|
-
* prefix is C:\Program Files\nodejs and a non-elevated shell cannot write it.
|
|
911
|
-
* npm then died with EPERM and the user got a truncated npm log tail in a
|
|
912
|
-
* 46-character box — where a POSIX user in the same position gets "the install
|
|
913
|
-
* directory is not writable by this user" and the command to paste. The comment
|
|
914
|
-
* on upgradeBlockedReason states the intent the platform defeated: "Failing
|
|
915
|
-
* inside npm with EACCES tells the user less than declining up front does."
|
|
916
|
-
*
|
|
917
|
-
* The probe is `createTemp`'s "wx" pattern, synchronously: a name nothing else
|
|
918
|
-
* can be using, created exclusively and removed at once. POSIX keeps
|
|
919
|
-
* `accessSync`, where it is correct and cheaper.
|
|
920
|
-
*/
|
|
921
|
-
function dirWritable(p) {
|
|
922
|
-
if (process.platform !== "win32") {
|
|
923
|
-
try { accessSync(p, FS.W_OK); return true; } catch { return false; }
|
|
924
|
-
}
|
|
925
|
-
// `wx` fails if the name exists, so a collision reads as "not writable"
|
|
926
|
-
// rather than clobbering somebody's file — hence pid and a random suffix.
|
|
927
|
-
const probe = join(p, `.ccdeck-w-${process.pid}-${Math.random().toString(36).slice(2, 8)}`);
|
|
928
|
-
try {
|
|
929
|
-
writeFileSync(probe, "", { flag: "wx" });
|
|
930
|
-
return true;
|
|
931
|
-
} catch {
|
|
932
|
-
return false;
|
|
933
|
-
} finally {
|
|
934
|
-
try { unlinkSync(probe); } catch { /* never created, or already gone */ }
|
|
935
|
-
}
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
/** The same question, answered against the real filesystem and environment. */
|
|
939
|
-
export function upgradeBlock(pkgRoot) {
|
|
940
|
-
// Whatever `npm i -g` would rewrite is what has to be writable, and under the
|
|
941
|
-
// stub layout that is not this directory: `npm i -g ccdeck` replaces
|
|
942
|
-
// <prefix>/lib/node_modules/ccdeck, and the copy running out of its nested
|
|
943
|
-
// node_modules goes with it. In practice both pairs answer the same, since
|
|
944
|
-
// one npm install created them with one owner — but the question is about the
|
|
945
|
-
// tree the command touches, and that tree is the host's.
|
|
946
|
-
//
|
|
947
|
-
// Asked with this build's own name rather than with the default, for the same
|
|
948
|
-
// reason upgradeName is: the host is recognised by the dependency it declares
|
|
949
|
-
// on us, and "us" is whatever the manifest here says — `agents-deck` under the
|
|
950
|
-
// stub npm publishes today, but nothing in this rule should assume that.
|
|
951
|
-
//
|
|
952
|
-
// successorRoot covers the case after such an upgrade has run: pkgRoot is
|
|
953
|
-
// gone, so hostPackage cannot recognise a host that no longer declares us,
|
|
954
|
-
// and asking accessSync about a deleted directory answers ENOENT — which this
|
|
955
|
-
// function reported as `not_writable`, telling the user their npm prefix was
|
|
956
|
-
// read-only when it was not.
|
|
957
|
-
const target = hostPackage(pkgRoot, installedName(pkgRoot))?.root
|
|
958
|
-
?? successorRoot(pkgRoot)
|
|
959
|
-
?? pkgRoot;
|
|
960
|
-
return upgradeBlockedReason({
|
|
961
|
-
git: isGitCheckout(pkgRoot),
|
|
962
|
-
npx: isNpxInstall(pkgRoot),
|
|
963
|
-
// npm -g rewrites the package directory and its parent (the global
|
|
964
|
-
// node_modules), so both have to be ours to write.
|
|
965
|
-
writable: dirWritable(target) && dirWritable(resolve(target, "..")),
|
|
966
|
-
optedOut: process.env.AGENTS_DECK_NO_INSTALL === "1",
|
|
967
|
-
});
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
// ── the note a failed npx relaunch leaves behind ─────────────────────────────
|
|
971
|
-
//
|
|
972
|
-
// The npx upgrade is the one path whose failure the server cannot see. It runs
|
|
973
|
-
// in the SUPERVISOR, after this process has already exited: the worker asks to
|
|
974
|
-
// come back through `npx -y <spec>@latest`, npx fails, and the supervisor
|
|
975
|
-
// relaunches the copy on disk. The new worker boots knowing nothing, so
|
|
976
|
-
// /api/version kept answering `upgrade: {state:"idle"}` — the banner still
|
|
977
|
-
// offered "Update & restart", the tab said nothing at all, and a user who
|
|
978
|
-
// clicked the button without watching the terminal saw the deck blink and come
|
|
979
|
-
// back unchanged. Every click then repeated the whole cycle identically.
|
|
980
|
-
//
|
|
981
|
-
// A file is the only channel between the two processes: the supervisor writes
|
|
982
|
-
// one when the relaunch fails, and the worker it starts instead reads it here.
|
|
983
|
-
// Same directory as the update markers, and named after the package for the
|
|
984
|
-
// same reason they are.
|
|
985
|
-
//
|
|
986
|
-
// The package name alone was not enough, and one release was enough to show it.
|
|
987
|
-
// Two `npx ccdeck` decks resolve into the same content-addressed _npx directory,
|
|
988
|
-
// so they run the same package at the same version out of the same home: when
|
|
989
|
-
// one user's upgrade failed, the other deck — which had never asked for
|
|
990
|
-
// anything — read that note as its own, reported `upgrade: {state:"failed"}`
|
|
991
|
-
// and labelled its first ever click "Retry update". The version-staleness rule
|
|
992
|
-
// below cannot catch it, because both decks are the same version.
|
|
993
|
-
//
|
|
994
|
-
// So the name carries WHOSE failure it is as well as which package's: the pid
|
|
995
|
-
// of the supervisor that wrote it. That is unique among the decks alive on the
|
|
996
|
-
// machine, and it crosses the process boundary on its own — the worker that
|
|
997
|
-
// reads the note is a child the supervisor spawns after the failure, and
|
|
998
|
-
// inherits the pid through AGENTS_DECK_SUPERVISOR_PID. A worker with no
|
|
999
|
-
// supervisor to answer for reads nothing rather than falling back to a shared
|
|
1000
|
-
// file, which is the bug this whole naming exists to avoid.
|
|
1001
|
-
const NOTE_PREFIX = ".restart-failed-";
|
|
1002
|
-
|
|
1003
|
-
/** Which supervisor a note belongs to, as the file name may spell it. Digits
|
|
1004
|
-
* and nothing else: legal on every filesystem, and readable back as the pid
|
|
1005
|
-
* the sweep below asks about. Anything else is refused rather than scrubbed
|
|
1006
|
-
* into digits, since two keys must never collapse into one file name. */
|
|
1007
|
-
function safeOwner(key) {
|
|
1008
|
-
const raw = String(key ?? "").trim();
|
|
1009
|
-
return /^\d{1,12}$/.test(raw) && Number(raw) > 0 ? raw : null;
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
/** This process's own key, set by the supervisor on itself and inherited by
|
|
1013
|
-
* every worker it launches. */
|
|
1014
|
-
export function restartFailureKey(env = process.env) {
|
|
1015
|
-
return safeOwner(env?.AGENTS_DECK_SUPERVISOR_PID);
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
/** Called once by the supervisor, on its own environment, which every worker it
|
|
1019
|
-
* spawns then inherits. Assigned rather than defaulted: a deck launched by
|
|
1020
|
-
* another deck's npx relaunch inherits that supervisor's key and must answer
|
|
1021
|
-
* for itself, not for the parent whose upgrade it is the result of. */
|
|
1022
|
-
export function claimRestartFailureKey(env = process.env, pid = process.pid) {
|
|
1023
|
-
env.AGENTS_DECK_SUPERVISOR_PID = String(pid);
|
|
1024
|
-
return safeOwner(pid);
|
|
1025
|
-
}
|
|
1026
|
-
|
|
1027
|
-
/** `ccdeck` under supervisor 4821 → `.restart-failed-ccdeck-4821`, or null when
|
|
1028
|
-
* there is no supervisor, which is not a deck any note can be about. */
|
|
1029
|
-
export function restartFailureFileName(name = "agents-deck", key = restartFailureKey()) {
|
|
1030
|
-
const owner = safeOwner(key);
|
|
1031
|
-
return owner ? `${NOTE_PREFIX}${safeNamePart(name)}-${owner}` : null;
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
function restartFailurePath(name, key) {
|
|
1035
|
-
const file = restartFailureFileName(name, key);
|
|
1036
|
-
return file ? join(MARKER_DIR, file) : null;
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
/** Called by the supervisor when an upgrade does not happen — because the fetch
|
|
1040
|
-
* failed, because the fetched copy never served, or because this target has
|
|
1041
|
-
* already failed here and is not being tried again. Best-effort: a read-only
|
|
1042
|
-
* home costs the report, not the deck. */
|
|
1043
|
-
export function recordRestartFailure({
|
|
1044
|
-
name = "agents-deck", command = null, error = null, version = null,
|
|
1045
|
-
target = null, attempts = 1, at = Date.now(), failedAt = at, key = restartFailureKey(),
|
|
1046
|
-
} = {}) {
|
|
1047
|
-
const file = restartFailureFileName(name, key);
|
|
1048
|
-
if (!file) return;
|
|
1049
|
-
const path = join(MARKER_DIR, file);
|
|
1050
|
-
try {
|
|
1051
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
1052
|
-
writeFileSync(path, JSON.stringify({
|
|
1053
|
-
command,
|
|
1054
|
-
error: error ? String(error).slice(0, 300) : null,
|
|
1055
|
-
// The version that failed to leave — see restartFailureNotice.
|
|
1056
|
-
version,
|
|
1057
|
-
// The version that failed to ARRIVE, and how many attempts it has cost.
|
|
1058
|
-
// Together they are the whole of what stops an unattended deck retrying
|
|
1059
|
-
// the identical fetch forever; see upgradeAttempt in supervisor.mjs.
|
|
1060
|
-
target,
|
|
1061
|
-
attempts,
|
|
1062
|
-
// When the fetch itself failed, which a refusal re-stating that failure
|
|
1063
|
-
// carries forward unchanged. `at` moves on every write because the
|
|
1064
|
-
// browser ends its attempt on a note it has not seen before — the
|
|
1065
|
-
// cooldown must not be pushed out by the act of asking about it.
|
|
1066
|
-
failedAt,
|
|
1067
|
-
at,
|
|
1068
|
-
}));
|
|
1069
|
-
sweepOrphanedNotes(file);
|
|
1070
|
-
} catch { /* ignore */ }
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
/** Called before each attempt, so a retry is answered by its own outcome rather
|
|
1074
|
-
* than by the last one's. */
|
|
1075
|
-
export function clearRestartFailure(name = "agents-deck", key = restartFailureKey()) {
|
|
1076
|
-
const path = restartFailurePath(name, key);
|
|
1077
|
-
if (!path) return;
|
|
1078
|
-
try { rmSync(path, { force: true }); } catch { /* ignore */ }
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
export function readRestartFailure(name = "agents-deck", key = restartFailureKey()) {
|
|
1082
|
-
const path = restartFailurePath(name, key);
|
|
1083
|
-
if (!path) return null;
|
|
1084
|
-
try {
|
|
1085
|
-
const m = JSON.parse(readFileSync(path, "utf8"));
|
|
1086
|
-
return m && typeof m === "object" ? m : null;
|
|
1087
|
-
} catch {
|
|
1088
|
-
return null;
|
|
1089
|
-
}
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
// A note is named after a process, so it outlives its deck whenever that
|
|
1093
|
-
// supervisor is killed before anyone reads the tab, and nothing would ever
|
|
1094
|
-
// delete it: the retry that clears one is exactly the thing that never happened.
|
|
1095
|
-
// Swept from the only path that creates notes, and only where the owner is
|
|
1096
|
-
// provably gone — another deck's note is another deck's to clear. Names without
|
|
1097
|
-
// a pid are the single shared file of v1.33.82, which nothing reads any more.
|
|
1098
|
-
function sweepOrphanedNotes(keep) {
|
|
1099
|
-
let files;
|
|
1100
|
-
try { files = readdirSync(MARKER_DIR); } catch { return; }
|
|
1101
|
-
for (const f of files) {
|
|
1102
|
-
if (f === keep || !f.startsWith(NOTE_PREFIX)) continue;
|
|
1103
|
-
const owner = Number(f.slice(f.lastIndexOf("-") + 1));
|
|
1104
|
-
if (Number.isInteger(owner) && owner > 0 && processAlive(owner)) continue;
|
|
1105
|
-
try { rmSync(join(MARKER_DIR, f), { force: true }); } catch { /* ignore */ }
|
|
1106
|
-
}
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
// Signal 0 delivers nothing; it asks whether the pid could be signalled.
|
|
1110
|
-
//
|
|
1111
|
-
// BOTH ERRNOS, and the second one is the Windows spelling. POSIX `kill(2)`
|
|
1112
|
-
// answers EPERM for a process this account may not signal. On Windows
|
|
1113
|
-
// `uv_kill` calls `OpenProcess`, a denial is ERROR_ACCESS_DENIED, and libuv
|
|
1114
|
-
// maps that to EACCES — so a deck started from an elevated terminal, or under
|
|
1115
|
-
// another account, read as DEAD to every probe in this repo. What followed was
|
|
1116
|
-
// silent: the live deck's discovery file was unlinked on the next hook fire,
|
|
1117
|
-
// rewritten five seconds later by keepDiscovery, and its banner went on
|
|
1118
|
-
// claiming it was receiving events it had stopped receiving.
|
|
1119
|
-
function processAlive(pid) {
|
|
1120
|
-
try { process.kill(pid, 0); return true; }
|
|
1121
|
-
catch (e) { return e?.code === "EPERM" || e?.code === "EACCES"; }
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
/**
|
|
1125
|
-
* The note as the version report should carry it, or null when it no longer
|
|
1126
|
-
* describes this deck. Pure, because the staleness rule is the whole subtlety.
|
|
1127
|
-
* WHOSE failure it is was settled by the file name; this decides only whether
|
|
1128
|
-
* it is still current.
|
|
1129
|
-
*
|
|
1130
|
-
* The note names the version that was running when the upgrade failed. While
|
|
1131
|
-
* that is still the version on disk, the failure is current: the deck really is
|
|
1132
|
-
* stuck where it was. Once the files are a different version the upgrade
|
|
1133
|
-
* happened some other way — a `npm i -g`, a fixed npm prefix, a manual npx —
|
|
1134
|
-
* and a note about a deck that no longer exists must not keep claiming the
|
|
1135
|
-
* update is broken.
|
|
1136
|
-
*/
|
|
1137
|
-
export function restartFailureNotice(record, installed = null) {
|
|
1138
|
-
if (!record || typeof record.error !== "string" || !record.error) return null;
|
|
1139
|
-
if (record.version && installed && record.version !== installed) return null;
|
|
1140
|
-
return {
|
|
1141
|
-
state: "failed",
|
|
1142
|
-
command: typeof record.command === "string" ? record.command : null,
|
|
1143
|
-
error: record.error,
|
|
1144
|
-
at: typeof record.at === "number" ? record.at : 0,
|
|
1145
|
-
};
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
// One install at a time, per process. State is deliberately coarse: the UI only
|
|
1149
|
-
// needs to know whether to show a spinner, a version, or an error.
|
|
1150
|
-
let _upgrade = { state: "idle", command: null, error: null, at: 0 };
|
|
1151
|
-
|
|
1152
|
-
export function upgradeStatus() {
|
|
1153
|
-
return { ..._upgrade };
|
|
1154
|
-
}
|
|
1155
|
-
|
|
1156
|
-
/**
|
|
1157
|
-
* Start `npm i -g <name>@latest` in the background.
|
|
1158
|
-
*
|
|
1159
|
-
* Returns immediately with the accepted command, or a refusal. Never installs
|
|
1160
|
-
* anything except this deck — under one of its own three published names, and
|
|
1161
|
-
* never at a version the caller chose. The argument vector is fixed here, not
|
|
1162
|
-
* assembled from request input: the only thing that varies is which alias, and
|
|
1163
|
-
* that is read off the install on disk and confined to ALIAS_PACKAGES.
|
|
1164
|
-
*/
|
|
1165
|
-
export function startUpgrade({ pkgRoot, name = "agents-deck" }) {
|
|
1166
|
-
if (_upgrade.state === "running") return { ok: true, already: true, command: _upgrade.command };
|
|
1167
|
-
const blocked = upgradeBlock(pkgRoot);
|
|
1168
|
-
if (blocked) return { ok: false, reason: blocked, command: upgradeCommand(pkgRoot, name) };
|
|
1169
|
-
|
|
1170
|
-
// The package this install can actually be replaced by — the stub for a
|
|
1171
|
-
// `npm i -g ccdeck`, the published name everywhere else. Installing `name`
|
|
1172
|
-
// there wrote a tree this process never reads, so the version on disk never
|
|
1173
|
-
// moved and the same update was offered forever.
|
|
1174
|
-
const target = upgradeName(pkgRoot, name);
|
|
1175
|
-
const spec = upgradeSpec(target);
|
|
1176
|
-
// The LOGICAL vector, not the cmd.exe line: this string is shown to the user
|
|
1177
|
-
// and is the one they can paste. `cmd /d /s /c "…"` is an implementation
|
|
1178
|
-
// detail of how this platform reaches npm, and pasting it would be advice
|
|
1179
|
-
// about the deck rather than about their install.
|
|
1180
|
-
const command = `npm ${spec.plain.join(" ")}`;
|
|
1181
|
-
_upgrade = { state: "running", command, error: null, at: Date.now() };
|
|
1182
|
-
|
|
1183
|
-
let child;
|
|
1184
|
-
try {
|
|
1185
|
-
child = spawn(spec.file, spec.args, { ...spec.opts, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
1186
|
-
} catch (err) {
|
|
1187
|
-
_upgrade = { state: "failed", command, error: err?.message ?? String(err), at: Date.now() };
|
|
1188
|
-
return { ok: false, reason: "spawn_failed", command };
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
// Only the tail is kept: npm's failures put the useful line near the end, and
|
|
1192
|
-
// a full buffer of an install log is not something the browser should hold.
|
|
1193
|
-
let err = "";
|
|
1194
|
-
const keepTail = (s) => { err = (err + s).slice(-4000); };
|
|
1195
|
-
child.stdout.on("data", d => keepTail(String(d)));
|
|
1196
|
-
child.stderr.on("data", d => keepTail(String(d)));
|
|
1197
|
-
|
|
1198
|
-
// The deadline states the outcome itself, and only then kills.
|
|
1199
|
-
//
|
|
1200
|
-
// npm is a .cmd shim on Windows and is therefore spawned through cmd.exe, so
|
|
1201
|
-
// `child` is cmd.exe and npm itself is a grandchild — a plain kill would
|
|
1202
|
-
// report the install as timed out while it carried on writing to
|
|
1203
|
-
// node_modules. killTree is what reaches it (taskkill /T there, the same
|
|
1204
|
-
// plain signal everywhere else).
|
|
1205
|
-
//
|
|
1206
|
-
// Leaving the verdict to 'close' was the other half of the same bug. 'close'
|
|
1207
|
-
// waits for the stdio pipes, which the grandchild inherited, so on Windows it
|
|
1208
|
-
// could arrive minutes after the deadline — or never, if the tree kill could
|
|
1209
|
-
// not run at all — and until it did, /api/version kept reporting
|
|
1210
|
-
// `state: "running"` with the UI spinning on "installing…" and the guard at
|
|
1211
|
-
// the top of this function refusing every retry. When it finally arrived it
|
|
1212
|
-
// carried the killed wrapper's status, so a five-minute timeout was announced
|
|
1213
|
-
// to the user as "npm exited null".
|
|
1214
|
-
let timedOut = false;
|
|
1215
|
-
const timer = setTimeout(() => {
|
|
1216
|
-
timedOut = true;
|
|
1217
|
-
_upgrade = {
|
|
1218
|
-
state: "failed",
|
|
1219
|
-
command,
|
|
1220
|
-
error: `timed out after ${Math.round(INSTALL_TIMEOUT_MS / 60_000)} minutes`,
|
|
1221
|
-
at: Date.now(),
|
|
1222
|
-
};
|
|
1223
|
-
killTree(child);
|
|
1224
|
-
}, INSTALL_TIMEOUT_MS);
|
|
1225
|
-
timer.unref?.();
|
|
1226
|
-
|
|
1227
|
-
child.on("error", (e) => {
|
|
1228
|
-
clearTimeout(timer);
|
|
1229
|
-
// A kill can make the child emit one of these; whatever it says, the reason
|
|
1230
|
-
// this install failed is the deadline that has already been reported.
|
|
1231
|
-
if (timedOut) return;
|
|
1232
|
-
_upgrade = { state: "failed", command, error: e?.message ?? String(e), at: Date.now() };
|
|
1233
|
-
});
|
|
1234
|
-
child.on("close", (code) => {
|
|
1235
|
-
clearTimeout(timer);
|
|
1236
|
-
if (code === 0) {
|
|
1237
|
-
// A clean exit is a real install even if it lands after the deadline —
|
|
1238
|
-
// npm finishing in the same breath as the timer is the one case where the
|
|
1239
|
-
// files on disk disagree with the verdict above, and the files win.
|
|
1240
|
-
//
|
|
1241
|
-
// Deliberately does not restart anything. The new files on disk make
|
|
1242
|
-
// installedVersion() disagree with the running one, and the ordinary
|
|
1243
|
-
// drift path takes it from there — including its wait for an idle moment.
|
|
1244
|
-
_upgrade = { state: "done", command, error: null, at: Date.now() };
|
|
1245
|
-
} else if (!timedOut && _upgrade?.state !== "failed") {
|
|
1246
|
-
// Not over a failure the 'error' handler already explained. A missing npm
|
|
1247
|
-
// emits 'error' with ENOENT and THEN 'close' with a null code, and this
|
|
1248
|
-
// branch used to replace "spawn npm ENOENT" with "npm exited -2" — the
|
|
1249
|
-
// one message that says what is wrong, overwritten by the one that does
|
|
1250
|
-
// not.
|
|
1251
|
-
_upgrade = { state: "failed", command, error: lastMeaningfulLine(err) || `npm exited ${code}`, at: Date.now() };
|
|
1252
|
-
}
|
|
1253
|
-
});
|
|
1254
|
-
|
|
1255
|
-
return { ok: true, command };
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1258
|
-
// The furniture around a Node crash, none of which is the reason anything
|
|
1259
|
-
// failed. npm's own log needs none of this — it is `npm ERR!` lines and a rule —
|
|
1260
|
-
// but the two get mixed the moment the thing npm's shim tried to load is
|
|
1261
|
-
// missing, which is what #535's bare `npm.cmd` produced on every attempt.
|
|
1262
|
-
const CRASH_NOISE = [
|
|
1263
|
-
/^\s*at\s/, // stack frames
|
|
1264
|
-
/^\s*\^+\s*$/, // the caret under the throw
|
|
1265
|
-
/^node:internal\//, // the frame node leads with
|
|
1266
|
-
/^\s*throw\s/,
|
|
1267
|
-
/^Node\.js v/, // the last line, and the one that was quoted
|
|
1268
|
-
/^\s*[{}]\s*$/, // the error object's braces
|
|
1269
|
-
/^\s*(code|errno|syscall|path|requireStack|stack):/,
|
|
1270
|
-
/^Require stack:/,
|
|
1271
|
-
/^-+$/,
|
|
1272
|
-
/^A complete log/,
|
|
1273
|
-
];
|
|
1274
|
-
|
|
1275
|
-
/** npm's real complaint, for the banner: the last line that is not furniture.
|
|
1276
|
-
*
|
|
1277
|
-
* Last rather than first, and that is the whole reason this is not npx.mjs's
|
|
1278
|
-
* summariser under another name. The two read different documents. npm's log
|
|
1279
|
-
* opens with its codes — `code EACCES`, `syscall mkdir` — and ends with the
|
|
1280
|
-
* sentence a person can act on, so the last line wins. An npx failure is a Node
|
|
1281
|
-
* crash dump, which opens with the sentence and ends with a stack, a brace and
|
|
1282
|
-
* a version banner, so the first signal line wins there. Sharing one function
|
|
1283
|
-
* would mean picking one of those and being wrong about the other half the
|
|
1284
|
-
* time; sharing the NOISE list would be the copy this comment exists instead
|
|
1285
|
-
* of.
|
|
1286
|
-
*
|
|
1287
|
-
* What was wrong was not the rule but its list. `lastMeaningfulLine` dropped
|
|
1288
|
-
* `npm ERR!` prefixes, rules and "A complete log", and nothing else — so when
|
|
1289
|
-
* #535's spawn failed with a MODULE_NOT_FOUND dump, the last surviving line was
|
|
1290
|
-
* `Node.js v22.11.0`, and that string was the entire explanation the UI gave
|
|
1291
|
-
* for a failed upgrade. */
|
|
1292
|
-
export function lastMeaningfulLine(text) {
|
|
1293
|
-
const lines = String(text ?? "").split(/\r?\n/)
|
|
1294
|
-
.map(l => l.replace(/^npm (ERR!|WARN)\s*/, "").trim())
|
|
1295
|
-
.filter(l => l && !CRASH_NOISE.some(re => re.test(l)));
|
|
1296
|
-
if (!lines.length) return "";
|
|
1297
|
-
// A line that names an error outranks a later line that does not, because
|
|
1298
|
-
// what survives the filter after one is usually its context rather than its
|
|
1299
|
-
// successor: `Require stack:` is furniture, but the paths listed under it are
|
|
1300
|
-
// not shaped like furniture and would otherwise be the last thing standing.
|
|
1301
|
-
// Still the LAST such line, not the first — npm's own log builds up to its
|
|
1302
|
-
// sentence, and picking the first would answer `code EACCES` where the next
|
|
1303
|
-
// line says which directory and why.
|
|
1304
|
-
const named = lines.filter(l => /(^|\s)[A-Za-z]*Error:/.test(l));
|
|
1305
|
-
const pick = named.length ? named[named.length - 1] : lines[lines.length - 1];
|
|
1306
|
-
return pick.slice(0, 300);
|
|
1307
|
-
}
|
|
1308
|
-
|
|
1309
|
-
/** Full answer for GET /api/version. Never throws, and answers the local half
|
|
1310
|
-
* even when the registry is unreachable.
|
|
1311
|
-
*
|
|
1312
|
-
* Network worst case is 2 x FETCH_TIMEOUT_MS, not one: the dist-tag lookup,
|
|
1313
|
-
* plus — only when the tag has moved to a version this deck has not confirmed
|
|
1314
|
-
* yet — the installability probe (see runCheck), each carrying its own
|
|
1315
|
-
* AbortSignal timeout. That second request costs its timeout at most once per
|
|
1316
|
-
* release; a deck sitting on the current release stays inside one. */
|
|
1317
|
-
export async function versionReport({ running, pkgRoot, name = "agents-deck", now = Date.now(), force = false }) {
|
|
1318
|
-
const installed = installedVersion(pkgRoot);
|
|
1319
|
-
// Asked about the package the command installs, not about the one this build
|
|
1320
|
-
// happens to be named after — see upgradeName. Everything registry-shaped in
|
|
1321
|
-
// this report is about `target`: the version, the marker it is cached in, and
|
|
1322
|
-
// the name the report gives for it.
|
|
1323
|
-
const target = upgradeName(pkgRoot, name);
|
|
1324
|
-
// Only an explicit opt-out silences the registry.
|
|
1325
|
-
//
|
|
1326
|
-
// A checkout used to be excluded here too, on the reasoning that its version
|
|
1327
|
-
// leads npm's. That reasoning holds for the COMMAND — telling someone to
|
|
1328
|
-
// `npm i -g` over their working copy is wrong — but not for the question.
|
|
1329
|
-
// Knowing a release shipped is useful however you would install it, and
|
|
1330
|
-
// suppressing the lookup meant `latest` was always null, so the upgrade
|
|
1331
|
-
// notice could never appear and the "this is a checkout" explanation had
|
|
1332
|
-
// nowhere to render. A checkout that is ahead of npm still says nothing:
|
|
1333
|
-
// isOlder decides that, not this.
|
|
1334
|
-
const skipRegistry =
|
|
1335
|
-
process.env.AGENTS_DECK_NO_UPDATE_CHECK === "1" ||
|
|
1336
|
-
process.env.AGENTS_DECK_NO_INSTALL === "1";
|
|
1337
|
-
const latest = skipRegistry ? null : await latestOnNpm(target, now, force);
|
|
1338
|
-
const marker = skipRegistry ? null : readMarker(target);
|
|
1339
|
-
const blocked = upgradeBlock(pkgRoot);
|
|
1340
|
-
// An install started in THIS process outranks the note on disk: it is newer
|
|
1341
|
-
// by construction, and a running one must not be reported as a past failure.
|
|
1342
|
-
// The note is read whatever the registry is doing — it is a local event, not
|
|
1343
|
-
// a lookup — so an offline deck still explains why its update did nothing.
|
|
1344
|
-
// Only the note addressed to this deck's own supervisor is read: the decks
|
|
1345
|
-
// sharing this home directory are usually the same package at the same
|
|
1346
|
-
// version, and none of them may answer for another's failed upgrade.
|
|
1347
|
-
const live = upgradeStatus();
|
|
1348
|
-
const upgrade = live.state === "idle"
|
|
1349
|
-
? (restartFailureNotice(readRestartFailure(target), installed) ?? live)
|
|
1350
|
-
: live;
|
|
1351
|
-
return {
|
|
1352
|
-
name: target,
|
|
1353
|
-
running: running ?? null,
|
|
1354
|
-
installed,
|
|
1355
|
-
latest,
|
|
1356
|
-
// When npm last ANSWERED, so the UI can say it rather than leaving the
|
|
1357
|
-
// user to wonder whether the check runs at all. A lookup that failed does
|
|
1358
|
-
// not move this: "checked 2 minutes ago" over an hour-old answer is the
|
|
1359
|
-
// one thing this field must never say.
|
|
1360
|
-
checkedAt: marker?.at ?? null,
|
|
1361
|
-
// …and when it last failed, null once one succeeds. Without it, the single
|
|
1362
|
-
// most common reason for a missing update button — a proxy, a flaky line,
|
|
1363
|
-
// an offline machine — is indistinguishable from being up to date.
|
|
1364
|
-
checkFailedAt: marker?.failedAt ?? null,
|
|
1365
|
-
// A version npm's dist-tag names that the registry cannot serve yet — the
|
|
1366
|
-
// one thing `latest` deliberately will not say, since saying it is what
|
|
1367
|
-
// sent a deck into a restart that ended in ETARGET. Reported so the state
|
|
1368
|
-
// is visible rather than looking like nothing was published at all.
|
|
1369
|
-
latestPending: marker?.pending ?? null,
|
|
1370
|
-
checkDisabled: skipRegistry,
|
|
1371
|
-
notice: pickNotice({ running, installed, latest }),
|
|
1372
|
-
command: upgradeCommand(pkgRoot, name),
|
|
1373
|
-
// Why an in-app `npm i -g` is refused, when it is — so the UI can say so
|
|
1374
|
-
// instead of leaving a gap the user has to guess about. "npx" is a refusal
|
|
1375
|
-
// of the install, not of the update: upgradeMode says so.
|
|
1376
|
-
upgradeBlocked: blocked,
|
|
1377
|
-
upgradeMode: upgradeMode(blocked),
|
|
1378
|
-
upgrade,
|
|
1379
|
-
};
|
|
1380
|
-
}
|