ccdeck 3.29.10 → 3.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/agent-dag.js +67 -118
- package/bin/cli/help.js +67 -0
- package/bin/cli/login-item.js +182 -0
- package/bin/cli/one-shot.js +254 -0
- package/bin/cli/package.js +39 -0
- package/bin/cli/pulse.js +98 -0
- package/bin/cli/restart.js +179 -0
- package/bin/cli/screen.js +166 -0
- package/bin/cli/second-start.js +120 -0
- package/bin/cli/startup.js +459 -0
- package/bin/cli/uninstall.js +169 -0
- package/bin/deck.js +112 -1559
- package/dist/web/assets/BrowserWatchModal-DP3YMtEi.js +1 -0
- package/dist/web/assets/UsageHistoryModal-CcdPVXAw.js +1 -0
- package/dist/web/assets/index-3vS6A0vt.css +1 -0
- package/dist/web/assets/index-BVTWHDta.js +298 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +28 -0
- package/src/server/account-projects.mjs +25 -11
- package/src/server/account-routes.mjs +254 -0
- package/src/server/activity.mjs +3 -3
- package/src/server/append-budget.mjs +185 -0
- package/src/server/append-failures.mjs +147 -0
- package/src/server/args.mjs +37 -5
- package/src/server/atomic-write.mjs +300 -0
- package/src/server/block-notify.mjs +0 -5
- package/src/server/boot-lock.mjs +1 -1
- package/src/server/brand.mjs +7 -6
- package/src/server/browser-react.mjs +10 -2
- package/src/server/browser-watch-decks.mjs +146 -0
- package/src/server/browser-watch-feed.mjs +69 -0
- package/src/server/browser-watch-log.mjs +173 -0
- package/src/server/browser-watch-mtime.mjs +18 -0
- package/src/server/browser-watch-relay.mjs +112 -0
- package/src/server/browser-watch-routes.mjs +172 -0
- package/src/server/browser-watch-seen.mjs +127 -0
- package/src/server/browser-watch-store.mjs +14 -143
- package/src/server/browser-watch.mjs +278 -626
- package/src/server/canonical-path.mjs +150 -0
- package/src/server/ccusage-failure.mjs +64 -0
- package/src/server/ccusage-install.mjs +459 -0
- package/src/server/ccusage-runner.mjs +271 -0
- package/src/server/ccusage.mjs +21 -756
- package/src/server/claude-accounts.mjs +220 -464
- package/src/server/claude-collector.mjs +189 -0
- package/src/server/claude-dir.mjs +2 -2
- package/src/server/claude-identity.mjs +5 -4
- package/src/server/claude-reset-credits.mjs +130 -0
- package/src/server/claude-verdicts.mjs +174 -0
- package/src/server/clear-route.mjs +191 -0
- package/src/server/codex-auth.mjs +4 -4
- package/src/server/codex-dir.mjs +52 -8
- package/src/server/codex-enrichment.mjs +310 -0
- package/src/server/codex-quota.mjs +5 -3
- package/src/server/codex-translate.mjs +407 -0
- package/src/server/codex-usage.mjs +8 -5
- package/src/server/codex-watch.mjs +496 -0
- package/src/server/cswap-auto-loop.mjs +342 -0
- package/src/server/cswap-auto-readers.mjs +303 -0
- package/src/server/cswap-auto.mjs +51 -609
- package/src/server/cswap-install.mjs +69 -265
- package/src/server/cswap-layout.mjs +242 -0
- package/src/server/deck-home.mjs +1 -1
- package/src/server/deck-prefs.mjs +51 -184
- package/src/server/deck-scope.mjs +52 -0
- package/src/server/desktop-update-routes.mjs +75 -0
- package/src/server/detach.mjs +78 -0
- package/src/server/discovery.mjs +298 -0
- package/src/server/event-log.mjs +328 -0
- package/src/server/event-pipeline.mjs +235 -0
- package/src/server/event-ring.mjs +214 -0
- package/src/server/event-routes.mjs +352 -0
- package/src/server/event-sink.mjs +26 -0
- package/src/server/exec-children.mjs +128 -0
- package/src/server/exec-not-found.mjs +213 -0
- package/src/server/exec-output.mjs +23 -0
- package/src/server/exec-spec.mjs +341 -0
- package/src/server/exec.mjs +74 -661
- package/src/server/health-route.mjs +42 -0
- package/src/server/hook-challenge.mjs +37 -0
- package/src/server/http-io.mjs +104 -0
- package/src/server/index.mjs +172 -7620
- package/src/server/install-layout.mjs +506 -0
- package/src/server/installer.mjs +79 -608
- package/src/server/jsonl-chunks.mjs +139 -0
- package/src/server/lan-accounts.mjs +27 -0
- package/src/server/lan-beacon.mjs +457 -0
- package/src/server/lan-call.mjs +239 -0
- package/src/server/lan-copies.mjs +270 -0
- package/src/server/lan-deck.mjs +583 -0
- package/src/server/lan-dials.mjs +161 -0
- package/src/server/lan-engine.mjs +475 -1024
- package/src/server/lan-hearing.mjs +49 -0
- package/src/server/lan-inbound.mjs +100 -0
- package/src/server/lan-invite-offer.mjs +77 -0
- package/src/server/lan-invite.mjs +207 -0
- package/src/server/lan-lines.mjs +73 -0
- package/src/server/lan-manifest.mjs +74 -0
- package/src/server/lan-reach.mjs +82 -69
- package/src/server/lan-requests.mjs +249 -0
- package/src/server/lan-round-record.mjs +45 -0
- package/src/server/lan-round-timer.mjs +62 -0
- package/src/server/lan-routes.mjs +143 -0
- package/src/server/lan-serve.mjs +112 -0
- package/src/server/lan-socket.mjs +243 -925
- package/src/server/lan-sync.mjs +58 -1026
- package/src/server/lan-tailnet-poll.mjs +57 -0
- package/src/server/lan-turns.mjs +80 -0
- package/src/server/lan-wire.mjs +620 -0
- package/src/server/lifecycle.mjs +421 -0
- package/src/server/listen.mjs +110 -0
- package/src/server/live-decks.mjs +138 -0
- package/src/server/load-average.mjs +32 -0
- package/src/server/log-election.mjs +235 -0
- package/src/server/log-replay.mjs +374 -0
- package/src/server/log-writer.mjs +72 -557
- package/src/server/login-service.mjs +3 -2
- package/src/server/macmon.mjs +1 -1
- package/src/server/memory-files.mjs +163 -0
- package/src/server/memory-metrics.mjs +178 -0
- package/src/server/metrics-history.mjs +94 -0
- package/src/server/metrics-run.mjs +100 -0
- package/src/server/music-routes.mjs +134 -0
- package/src/server/network-metrics.mjs +5 -3
- package/src/server/network-sampler.mjs +256 -0
- package/src/server/npm-latest.mjs +385 -0
- package/src/server/npm-upgrade.mjs +352 -0
- package/src/server/npx.mjs +41 -7
- package/src/server/output-watch.mjs +5 -4
- package/src/server/pinned-build.mjs +108 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +77 -0
- package/src/server/prefs-routes.mjs +76 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +51 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -0
- package/src/server/pulse-line.mjs +132 -0
- package/src/server/purge-key.mjs +7 -7
- package/src/server/quota-cli.mjs +174 -0
- package/src/server/quota-oauth.mjs +190 -0
- package/src/server/quota-shape.mjs +229 -0
- package/src/server/quota-store-resets.mjs +206 -0
- package/src/server/quota.mjs +123 -509
- package/src/server/relay-guard.mjs +3 -3
- package/src/server/request-gates.mjs +441 -0
- package/src/server/restart-note.mjs +180 -0
- package/src/server/retire-sound-hook.mjs +3 -3
- package/src/server/ring-bounds.mjs +215 -0
- package/src/server/running-deck.mjs +1 -1
- package/src/server/running-version.mjs +24 -0
- package/src/server/self-update.mjs +39 -1371
- package/src/server/session-enrichment.mjs +622 -0
- package/src/server/session-read-gate.mjs +49 -0
- package/src/server/session-tracking.mjs +242 -0
- package/src/server/sse-clients.mjs +222 -0
- package/src/server/stale-discovery.mjs +30 -0
- package/src/server/static-serve.mjs +81 -0
- package/src/server/supervisor.mjs +73 -12
- package/src/server/system-metrics.mjs +169 -1935
- package/src/server/term.mjs +11 -249
- package/src/server/thermal-metrics.mjs +520 -0
- package/src/server/thermal-sampler.mjs +204 -0
- package/src/server/token-redact.mjs +123 -0
- package/src/server/transcript-gate.mjs +107 -0
- package/src/server/transcript-scan.mjs +563 -0
- package/src/server/usage-routes.mjs +93 -0
- package/src/server/uv-bootstrap.mjs +1 -1
- package/src/server/wordmark.mjs +127 -0
- package/dist/web/assets/BrowserWatchModal-CMn28FHb.js +0 -1
- package/dist/web/assets/UsageHistoryModal-C-sPhsKT.js +0 -1
- package/dist/web/assets/index-C1YFI9f4.js +0 -298
- package/dist/web/assets/index-Ck2RAla3.css +0 -1
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
// What npm says the newest version is, asked at most once an hour and never
|
|
2
|
+
// more often than the floor allows, with the answer kept on disk between runs.
|
|
3
|
+
//
|
|
4
|
+
// This lived in src/server/self-update.mjs, in two pieces: the check's windows
|
|
5
|
+
// and its floor at the top of the file, and the lookup, its marker file and the
|
|
6
|
+
// rules for when to ask again in the middle. They share the marker and the
|
|
7
|
+
// state that dedupes and rations the requests, and nothing else in the
|
|
8
|
+
// self-update code keeps any, so they moved here together. self-update.mjs
|
|
9
|
+
// reads the answer for its version report and re-exports the names it
|
|
10
|
+
// exported before; the restart-failure note shares the directory and the rule
|
|
11
|
+
// for turning a package name into a file name. The bodies are unchanged.
|
|
12
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { PUBLISHED_NAME } from "./install-layout.mjs";
|
|
16
|
+
|
|
17
|
+
// Once an hour, not once a day.
|
|
18
|
+
//
|
|
19
|
+
// The daily cadence was copied from the ccusage and cswap checks, where it is
|
|
20
|
+
// right: those answer "is a different tool out of date", and nobody is waiting
|
|
21
|
+
// on it. This one answers "is the thing you are looking at out of date", and a
|
|
22
|
+
// day is long enough that a deck started shortly before a release shows nothing
|
|
23
|
+
// at all until tomorrow — reported from a machine running `npx ccdeck`, which
|
|
24
|
+
// is the case where it bites hardest, since npx runs are short-lived and each
|
|
25
|
+
// one inherits the same stale marker. The request is ~20 bytes.
|
|
26
|
+
const CHECK_MS = 3600_000;
|
|
27
|
+
// A lookup that failed is not an answer, so it must not spend the hour an
|
|
28
|
+
// answer buys. It still has to spend something: the reason to rate-limit is
|
|
29
|
+
// gone, but a registry that is down would otherwise be asked again on every
|
|
30
|
+
// single poll. Five minutes is the compromise — short enough that a network
|
|
31
|
+
// coming back is noticed while the user is still looking at the deck, long
|
|
32
|
+
// enough that a full npm outage costs a dozen ~20-byte requests an hour.
|
|
33
|
+
const RETRY_MS = 300_000;
|
|
34
|
+
const FETCH_TIMEOUT_MS = 6_000;
|
|
35
|
+
// A third marker family: ccusage owns ~/.agents-deck/ccusage/.last-update-check
|
|
36
|
+
// and cswap owns ~/.agents-deck/.cswap-update-check. Sharing one would make the
|
|
37
|
+
// three features fight over a single daily slot.
|
|
38
|
+
export const MARKER_DIR = join(homedir(), ".agents-deck");
|
|
39
|
+
// The name this deck was published under is part of the marker's identity.
|
|
40
|
+
//
|
|
41
|
+
// A single ~/.agents-deck/.self-update-check was shared by every deck on the
|
|
42
|
+
// machine, so whichever one asked npm first pinned the answer for all of them
|
|
43
|
+
// until the window expired — including a freshly started `npx ccdeck` that had
|
|
44
|
+
// never written it, and including decks running a DIFFERENT package where the
|
|
45
|
+
// cached version means nothing. Reported with four decks alive at once: three
|
|
46
|
+
// served the same `checkedAt` to the millisecond and none of them showed the
|
|
47
|
+
// seven releases that had shipped meanwhile. One file per package name means
|
|
48
|
+
// `agents-deck`, `ccdeck` and `agent-dag` decks stop silencing each other; the
|
|
49
|
+
// old shared path stays here only to be inherited from once.
|
|
50
|
+
const LEGACY_MARKER = join(MARKER_DIR, ".self-update-check");
|
|
51
|
+
|
|
52
|
+
// Dedupe concurrent /api/version calls into one fetch, per package name — two
|
|
53
|
+
// names are two different questions and must not be answered with one answer.
|
|
54
|
+
const _inflight = new Map();
|
|
55
|
+
|
|
56
|
+
// ── what a forced check may cost ─────────────────────────────────────────────
|
|
57
|
+
//
|
|
58
|
+
// `_inflight` deduplicates callers that OVERLAP and nothing else. `checkDue`
|
|
59
|
+
// answers `true` on `force` before it asks anything else, so a caller that
|
|
60
|
+
// waited for one check to settle and then asked again got a fresh
|
|
61
|
+
// `https://registry.npmjs.org/…/dist-tags` every time. Reads on this server are
|
|
62
|
+
// deliberately open — `isTrustedRead` does not apply the `Sec-Fetch-Site` test
|
|
63
|
+
// that `isTrustedMutation` does, because a cross-site read of
|
|
64
|
+
// `http://127.0.0.1:4317` is an ordinary top-level navigation — so
|
|
65
|
+
//
|
|
66
|
+
// (async function spin() {
|
|
67
|
+
// for (;;) await fetch("http://127.0.0.1:4317/api/version?refresh=1",
|
|
68
|
+
// { mode: "no-cors" });
|
|
69
|
+
// })();
|
|
70
|
+
//
|
|
71
|
+
// from any page the user had open was one registry request per turn, as fast as
|
|
72
|
+
// the round trip allows, and every so often two of them: `runCheck` confirms a
|
|
73
|
+
// tag it has not seen before against the version document. The requests are
|
|
74
|
+
// small — the whole reason the dist-tags endpoint is used here rather than the
|
|
75
|
+
// packument — but they leave the user's address, carrying the user-agent this
|
|
76
|
+
// deck sets, and they are aimed at a third party rather than at the machine the
|
|
77
|
+
// loop is running on. That makes it #580's shape with the cost pointed
|
|
78
|
+
// outwards, which if anything is the worse direction.
|
|
79
|
+
//
|
|
80
|
+
// Nothing above this line was going to stop it. The hour is written to a marker
|
|
81
|
+
// FILE, and `force` walks past it; `first` walks past it too; and on a machine
|
|
82
|
+
// where `~/.agents-deck` cannot be written `writeMarker` swallows the failure,
|
|
83
|
+
// so `checkDue` sees "never checked" on every single call and the hour is not
|
|
84
|
+
// really there at all.
|
|
85
|
+
//
|
|
86
|
+
// So the floor sits under all of it, in this process's own memory, between the
|
|
87
|
+
// last rule that admitted a check and the request it admitted. It is quota.mjs's
|
|
88
|
+
// number and codex-quota.mjs's and codex-usage.mjs's — the five routes in this
|
|
89
|
+
// deck's router that accept `?refresh=1` and can pay for it have no business
|
|
90
|
+
// disagreeing about what it costs.
|
|
91
|
+
const FORCE_POLL_MS = 60_000;
|
|
92
|
+
|
|
93
|
+
// Stamped when a check STARTS rather than when npm answers, because what the
|
|
94
|
+
// floor rations is the request. Per package name, like `_inflight` and like the
|
|
95
|
+
// markers: two names are two different questions, and one of them being asked
|
|
96
|
+
// is not a reason to refuse the other. Deliberately in memory rather than on
|
|
97
|
+
// disk — the marker is shared by every deck running that package and this is
|
|
98
|
+
// about what THIS process is sending.
|
|
99
|
+
const _lastAskAt = new Map();
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Whether we may spend a registry request right now.
|
|
103
|
+
*
|
|
104
|
+
* Exported for tests, for the same reason quota.mjs exports `maySelfPoll`,
|
|
105
|
+
* codex-quota.mjs `mayFetchQuota` and codex-usage.mjs `mayScanUsage`: this is
|
|
106
|
+
* the rule, it is pure, and it is worth pinning down away from the request it
|
|
107
|
+
* guards.
|
|
108
|
+
*/
|
|
109
|
+
export function mayAskNpm({ now, lastAskAt }) {
|
|
110
|
+
// A stamp from the future is a clock that moved, not a check that just ran —
|
|
111
|
+
// the same case `checkDue` answers twice below with `> now`, and the same
|
|
112
|
+
// answer. Without it, a machine whose clock corrects backwards by an hour
|
|
113
|
+
// would go an hour without a version check.
|
|
114
|
+
if (lastAskAt > now) return true;
|
|
115
|
+
return now - lastAskAt >= FORCE_POLL_MS;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── what npm has ─────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
/** One GET to the registry, asked the way both lookups below ask it: for JSON,
|
|
121
|
+
* under this deck's user-agent, and given up on after FETCH_TIMEOUT_MS. */
|
|
122
|
+
function askRegistry(path) {
|
|
123
|
+
return fetch(`https://registry.npmjs.org/${path}`, {
|
|
124
|
+
headers: { accept: "application/json", "user-agent": "agents-deck" },
|
|
125
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The dist-tags endpoint answers with ~20 bytes ({"latest":"1.30.7"}); the full
|
|
130
|
+
// packument is >2 KB and needs parsing we have no use for.
|
|
131
|
+
//
|
|
132
|
+
// Answers `{ ok, version }` rather than a bare string, because "npm says the
|
|
133
|
+
// latest is X" and "npm did not answer" used to arrive here as the same null.
|
|
134
|
+
// `ok` is true only when the registry handed back a usable version — a
|
|
135
|
+
// timeout, a non-200 and a 200 with no `latest` in it are all failures, and
|
|
136
|
+
// the caller has to be able to tell them from an up-to-date deck.
|
|
137
|
+
async function fetchLatest(name) {
|
|
138
|
+
try {
|
|
139
|
+
const res = await askRegistry(`-/package/${name}/dist-tags`);
|
|
140
|
+
if (!res.ok) return { ok: false, version: null };
|
|
141
|
+
const v = (await res.json())?.latest;
|
|
142
|
+
return typeof v === "string" ? { ok: true, version: v } : { ok: false, version: null };
|
|
143
|
+
} catch {
|
|
144
|
+
return { ok: false, version: null };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// A dist-tag is a pointer, and being pointed at is not the same as being
|
|
149
|
+
// installable.
|
|
150
|
+
//
|
|
151
|
+
// npm makes the moved tag visible before the version document has propagated to
|
|
152
|
+
// the replica the installer reads, so for a window `{"latest":"1.33.28"}` and
|
|
153
|
+
// `No matching version found for ccdeck@1.33.28` are both true at the same
|
|
154
|
+
// moment. Reported live: the banner offered v1.33.28, the restart ran
|
|
155
|
+
// `npx -y ccdeck@latest`, npm answered ETARGET, and the deck came back on the
|
|
156
|
+
// version it started with after tearing itself down — under npx a restart is
|
|
157
|
+
// not free, since the worker exits and hands the port over before anything is
|
|
158
|
+
// fetched.
|
|
159
|
+
//
|
|
160
|
+
// So the tag is checked against the version document, which is the same
|
|
161
|
+
// question `npm view <name>@<version> version` asks and the same document the
|
|
162
|
+
// installer resolves against. 404 is the answer this exists for: published
|
|
163
|
+
// tag, unpublished version, try again in five minutes. `ok` is false only when
|
|
164
|
+
// the registry gave no usable answer at all — that is not a licence to
|
|
165
|
+
// announce either, but it is not evidence of an unpublished version.
|
|
166
|
+
async function isPublished(name, version) {
|
|
167
|
+
try {
|
|
168
|
+
const res = await askRegistry(`${name}/${version}`);
|
|
169
|
+
if (res.status === 404) return { ok: true, published: false };
|
|
170
|
+
if (!res.ok) return { ok: false, published: false };
|
|
171
|
+
// The document has to be the one asked for: a registry that answers 200
|
|
172
|
+
// with something else has not shown that this version is resolvable.
|
|
173
|
+
const body = await res.json().catch(() => null);
|
|
174
|
+
return { ok: true, published: body?.version === version };
|
|
175
|
+
} catch {
|
|
176
|
+
return { ok: false, published: false };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Marker file name for a package: `ccdeck` → `.self-update-check-ccdeck`.
|
|
181
|
+
*
|
|
182
|
+
* Pure, and deliberately strict about what reaches the filesystem — a package
|
|
183
|
+
* name may be scoped (`@scope/pkg`), and `/`, `\` and `:` are either a path
|
|
184
|
+
* separator or outright illegal in a Windows file name. Everything outside
|
|
185
|
+
* `[a-z0-9._-]` collapses to `-`, the scope's leading `@` is dropped so the
|
|
186
|
+
* common case reads plainly, and the tail is trimmed so a pathological name
|
|
187
|
+
* cannot produce a path the OS refuses. An unusable name falls back to the
|
|
188
|
+
* default rather than to the shared file this fix exists to get rid of. */
|
|
189
|
+
export function markerFileName(name = PUBLISHED_NAME) {
|
|
190
|
+
return `.self-update-check-${safeNamePart(name)}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The sanitised half, shared with the restart-failure note so the two files
|
|
194
|
+
* agree on what a package name becomes on disk. */
|
|
195
|
+
export function safeNamePart(name) {
|
|
196
|
+
const raw = typeof name === "string" ? name.trim().toLowerCase() : "";
|
|
197
|
+
const safe = raw
|
|
198
|
+
.replace(/^@/, "")
|
|
199
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
200
|
+
.slice(0, 64)
|
|
201
|
+
// Trimmed after the slice, and at both ends: Windows silently drops a
|
|
202
|
+
// trailing dot from a file name, so a name that ends in one would write to
|
|
203
|
+
// a path that is not the path we would later read.
|
|
204
|
+
.replace(/^[-.]+|[-.]+$/g, "");
|
|
205
|
+
return safe || PUBLISHED_NAME;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function markerPath(name) {
|
|
209
|
+
return join(MARKER_DIR, markerFileName(name));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// The marker carries the answer, not just the timestamp. The existing markers
|
|
213
|
+
// store only an mtime, which means a restart inside the window forgets what npm
|
|
214
|
+
// said and shows nothing until the window expires.
|
|
215
|
+
//
|
|
216
|
+
// It carries the outcome too. `at` is when npm last ANSWERED and `version` is
|
|
217
|
+
// what it said; `failedAt` is when the last attempt failed, and is null the
|
|
218
|
+
// moment one succeeds. Keeping them apart is what lets the deck say "checked
|
|
219
|
+
// 3m ago" and "could not reach npm" as the different things they are, instead
|
|
220
|
+
// of reporting a timeout as a fresh, up-to-date check.
|
|
221
|
+
function readMarkerFile(path) {
|
|
222
|
+
try {
|
|
223
|
+
const m = JSON.parse(readFileSync(path, "utf8"));
|
|
224
|
+
// Either half is enough to be worth keeping: a marker written by a first
|
|
225
|
+
// attempt that failed has no `at` yet, and markers written before
|
|
226
|
+
// `failedAt` existed have no `failedAt` at all.
|
|
227
|
+
return (typeof m?.at === "number" || typeof m?.failedAt === "number") ? m : null;
|
|
228
|
+
} catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// The unsuffixed marker every earlier deck wrote, read at most once per name
|
|
234
|
+
// per process: upgrading to this version should not throw away an answer npm
|
|
235
|
+
// already gave. It is consulted only while the per-name file is still missing,
|
|
236
|
+
// so the shared file never goes back to being in charge.
|
|
237
|
+
const _legacy = new Map();
|
|
238
|
+
function legacyMarker(name) {
|
|
239
|
+
const key = markerFileName(name);
|
|
240
|
+
if (!_legacy.has(key)) _legacy.set(key, readMarkerFile(LEGACY_MARKER));
|
|
241
|
+
return _legacy.get(key);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function readMarker(name) {
|
|
245
|
+
return readMarkerFile(markerPath(name)) ?? legacyMarker(name);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The version npm last named as `latest` for this package, straight off the
|
|
249
|
+
* marker and with no lookup of its own.
|
|
250
|
+
*
|
|
251
|
+
* This is what the supervisor calls a failed upgrade's TARGET. It has to be
|
|
252
|
+
* answerable without the network — the supervisor asks it in the moment
|
|
253
|
+
* between a click and a fetch, and the reason the fetch is about to fail may
|
|
254
|
+
* well be that there is no network — and it has to be the same number the
|
|
255
|
+
* banner offered, which is precisely what the marker holds. */
|
|
256
|
+
export function lastKnownLatest(name = PUBLISHED_NAME) {
|
|
257
|
+
const v = readMarker(name)?.version;
|
|
258
|
+
return typeof v === "string" && v ? v : null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function writeMarker(name, marker) {
|
|
262
|
+
const path = markerPath(name);
|
|
263
|
+
try {
|
|
264
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
265
|
+
writeFileSync(path, JSON.stringify(marker));
|
|
266
|
+
} catch { /* a read-only home must not break the deck */ }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** What the marker should hold after an attempt. Pure, because "a failed
|
|
270
|
+
* lookup must not be recorded as a successful one" is precisely the rule this
|
|
271
|
+
* file used to get wrong, and a rule worth a bug is worth a test.
|
|
272
|
+
*
|
|
273
|
+
* A success stamps the hour and clears the failure. A failure records only
|
|
274
|
+
* itself, leaving the last real answer and the time it arrived untouched —
|
|
275
|
+
* the deck keeps showing what it knew, and stops claiming it just confirmed
|
|
276
|
+
* it.
|
|
277
|
+
*
|
|
278
|
+
* `installable` is the third outcome: npm answered, and what it named is not
|
|
279
|
+
* yet a version anything can install. That is a real answer — `at` moves, the
|
|
280
|
+
* registry was reached — but the version is held in `pending` instead of
|
|
281
|
+
* `version`, so `latest` stays a number the upgrade command can resolve, and
|
|
282
|
+
* `pendingAt` puts the next look on the short window rather than the hour. */
|
|
283
|
+
export function nextMarker({ prev, now, ok, version, installable = true }) {
|
|
284
|
+
if (!ok) {
|
|
285
|
+
return {
|
|
286
|
+
at: prev?.at ?? null,
|
|
287
|
+
version: prev?.version ?? null,
|
|
288
|
+
failedAt: now,
|
|
289
|
+
pending: prev?.pending ?? null,
|
|
290
|
+
pendingAt: prev?.pendingAt ?? null,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
if (!installable) {
|
|
294
|
+
return { at: now, version: prev?.version ?? null, failedAt: null, pending: version ?? null, pendingAt: now };
|
|
295
|
+
}
|
|
296
|
+
return { at: now, version: version ?? null, failedAt: null, pending: null, pendingAt: null };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Even a per-package marker is shared by every deck running that package, so
|
|
300
|
+
// one deck's check still answers for the others inside the window. Asking once
|
|
301
|
+
// per PROCESS lands the check exactly where the user expects the truth: a
|
|
302
|
+
// freshly started deck — which for `npx ccdeck` is every single run. Keyed by
|
|
303
|
+
// name, so a process that asked about one package has not asked about another.
|
|
304
|
+
const _askedThisProcess = new Set();
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Whether to ask npm, or reuse the answer on disk. Pure, because "why did no
|
|
308
|
+
* banner appear" is the question this feature gets asked, and the rule behind
|
|
309
|
+
* it should be readable in one place.
|
|
310
|
+
*/
|
|
311
|
+
export function checkDue({ at, failedAt, pendingAt, now, first = false, force = false, ttlMs = CHECK_MS, retryMs = RETRY_MS }) {
|
|
312
|
+
if (force || first) return true; // explicit ask, or this process's first
|
|
313
|
+
// Two ways of not having an answer yet, and neither may spend the hour a real
|
|
314
|
+
// answer buys: the last attempt failed, or npm named a version that cannot be
|
|
315
|
+
// installed yet. Both take the short window instead — an unreachable registry
|
|
316
|
+
// must not turn every poll into another request, and a release mid-publish is
|
|
317
|
+
// resolvable minutes later, not an hour later. Answered before `at`, which
|
|
318
|
+
// here is the older, settled check and would otherwise ask again immediately
|
|
319
|
+
// (or, for a pending version, not for another hour).
|
|
320
|
+
const unsettled = [failedAt, pendingAt].filter(t => typeof t === "number");
|
|
321
|
+
if (unsettled.length) {
|
|
322
|
+
const last = Math.max(...unsettled);
|
|
323
|
+
if (last > now) return true; // clock moved; do not wait it out
|
|
324
|
+
return now - last >= retryMs;
|
|
325
|
+
}
|
|
326
|
+
if (typeof at !== "number") return true; // never checked
|
|
327
|
+
if (at > now) return true; // marker from the future: a moved clock
|
|
328
|
+
return now - at >= ttlMs;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Last known npm `latest`, refreshed at most once per CHECK_MS. Returns the
|
|
332
|
+
* cached answer immediately when the window has not elapsed.
|
|
333
|
+
*
|
|
334
|
+
* `force` skips the window: the first call in this process, and an explicit
|
|
335
|
+
* "check now" from the UI. What it does not skip is FORCE_POLL_MS — see
|
|
336
|
+
* mayAskNpm, and the note above it for what a forced call used to cost. */
|
|
337
|
+
export async function latestOnNpm(name, now, force = false) {
|
|
338
|
+
const m = readMarker(name);
|
|
339
|
+
const key = markerFileName(name);
|
|
340
|
+
const first = !_askedThisProcess.has(key);
|
|
341
|
+
_askedThisProcess.add(key);
|
|
342
|
+
if (!checkDue({ at: m?.at, failedAt: m?.failedAt, pendingAt: m?.pendingAt, now, first, force })) {
|
|
343
|
+
return m?.version ?? null;
|
|
344
|
+
}
|
|
345
|
+
// Offered before the floor: a check that has not answered yet is a lookup
|
|
346
|
+
// newer than the marker, which is what refresh asked for, and joining it costs
|
|
347
|
+
// nothing.
|
|
348
|
+
const inflight = _inflight.get(key);
|
|
349
|
+
if (inflight) return inflight;
|
|
350
|
+
// The floor, under every rule above it. A refused check is answered with the
|
|
351
|
+
// version we already hold — the same string an ordinary cached call returns,
|
|
352
|
+
// carrying the marker's own `checkedAt` rather than the moment of the read
|
|
353
|
+
// that was refused, so /api/version reports exactly what it reported a moment
|
|
354
|
+
// ago and no surface learns a new failure mode from being asked twice.
|
|
355
|
+
if (!mayAskNpm({ now, lastAskAt: _lastAskAt.get(key) ?? 0 })) return m?.version ?? null;
|
|
356
|
+
_lastAskAt.set(key, now);
|
|
357
|
+
const run = runCheck(name, m, now)
|
|
358
|
+
// Record the outcome, not just the moment, and record it against THIS
|
|
359
|
+
// package: only an answer stamps `at`; a failure takes the short retry
|
|
360
|
+
// window instead of the hour, keeps the version we already knew rather
|
|
361
|
+
// than erasing it, and lands in this name's marker rather than spending
|
|
362
|
+
// another package's window on a lookup that was never about it.
|
|
363
|
+
.then((marker) => {
|
|
364
|
+
writeMarker(name, marker);
|
|
365
|
+
return marker.version ?? null;
|
|
366
|
+
})
|
|
367
|
+
.catch(() => m?.version ?? null)
|
|
368
|
+
.finally(() => { _inflight.delete(key); });
|
|
369
|
+
_inflight.set(key, run);
|
|
370
|
+
return run;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** One check, as a marker: what npm's dist-tag says, and — only when that is a
|
|
374
|
+
* version this deck has not already confirmed — whether it can be installed.
|
|
375
|
+
*
|
|
376
|
+
* The second request is what keeps the banner honest, and it is skipped in the
|
|
377
|
+
* case that runs all day: a tag that has not moved was confirmed the first
|
|
378
|
+
* time it was seen, so a deck sitting on the current release still costs one
|
|
379
|
+
* ~20-byte GET per check. Confirming costs one more, once per release. */
|
|
380
|
+
async function runCheck(name, prev, now) {
|
|
381
|
+
const { ok, version } = await fetchLatest(name);
|
|
382
|
+
if (!ok || version === prev?.version) return nextMarker({ prev, now, ok, version });
|
|
383
|
+
const probe = await isPublished(name, version);
|
|
384
|
+
return nextMarker({ prev, now, ok, version, installable: probe.ok && probe.published });
|
|
385
|
+
}
|