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