ccdeck 3.36.8 → 3.37.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 +15 -7
- package/bin/agent-dag.js +19 -6
- package/bin/cli/help.js +1 -0
- package/bin/cli/leaving.js +54 -0
- package/bin/cli/login-item.js +44 -20
- package/bin/cli/one-shot.js +15 -5
- package/bin/cli/screen.js +10 -5
- package/bin/cli/startup.js +6 -2
- package/bin/cli/uninstall.js +45 -0
- package/bin/deck.js +50 -8
- package/dist/web/assets/BrowserWatchModal-DCHV09J8.js +1 -0
- package/dist/web/assets/UsageHistoryModal-oDjpHw4z.js +1 -0
- package/dist/web/assets/index-B0SOa-XB.css +1 -0
- package/dist/web/assets/index-wCbqcRJd.js +306 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +80 -9
- package/package.json +1 -1
- package/release-notes.json +3 -1
- package/src/server/account-origins.mjs +8 -1
- package/src/server/account-projects.mjs +34 -9
- package/src/server/activation.mjs +39 -10
- package/src/server/app-host.mjs +7 -2
- package/src/server/args.mjs +3 -0
- package/src/server/browser-watch-routes.mjs +23 -16
- package/src/server/browser-watch.mjs +9 -3
- package/src/server/claude-accounts.mjs +27 -7
- package/src/server/claude-verdicts.mjs +12 -1
- package/src/server/cli-versions.mjs +6 -4
- package/src/server/codex-auth.mjs +29 -0
- package/src/server/codex-quota.mjs +57 -7
- package/src/server/deck-home.mjs +23 -1
- package/src/server/deck-prefs.mjs +70 -7
- package/src/server/depth-facts.mjs +178 -0
- package/src/server/detach.mjs +61 -33
- package/src/server/event-log.mjs +15 -0
- package/src/server/event-pipeline.mjs +5 -2
- package/src/server/event-routes.mjs +22 -0
- package/src/server/feedback-form.mjs +36 -13
- package/src/server/index.mjs +6 -2
- package/src/server/install-layout.mjs +35 -0
- package/src/server/install-ref.mjs +39 -0
- package/src/server/installer.mjs +20 -2
- package/src/server/lan-beacon.mjs +18 -3
- package/src/server/lan-deck.mjs +7 -1
- package/src/server/lan-engine.mjs +10 -1
- package/src/server/lan-manifest.mjs +9 -6
- package/src/server/lan-requests.mjs +18 -2
- package/src/server/lan-serve.mjs +4 -2
- package/src/server/lan-sync.mjs +97 -6
- package/src/server/lifecycle.mjs +18 -2
- package/src/server/log-replay.mjs +9 -3
- package/src/server/login-service.mjs +95 -12
- package/src/server/npm-upgrade.mjs +12 -4
- package/src/server/pinned-build.mjs +3 -0
- package/src/server/prefs-routes.mjs +5 -8
- package/src/server/prefs-state.mjs +9 -1
- package/src/server/quota-oauth.mjs +88 -13
- package/src/server/quota-shape.mjs +28 -0
- package/src/server/quota.mjs +68 -17
- package/src/server/rating.mjs +87 -0
- package/src/server/reports-routes.mjs +21 -2
- package/src/server/reports.mjs +704 -63
- package/src/server/ring-bounds.mjs +11 -0
- package/src/server/running-deck.mjs +48 -0
- package/src/server/self-update.mjs +1 -1
- package/src/server/session-enrichment.mjs +14 -6
- package/src/server/stable-node.mjs +68 -0
- package/src/server/static-serve.mjs +5 -0
- package/src/server/supervisor.mjs +21 -15
- package/src/server/tailscale.mjs +8 -4
- package/src/server/transcript-scan.mjs +7 -4
- package/src/server/usage-day.mjs +76 -11
- package/dist/web/assets/BrowserWatchModal-CeVGLslQ.js +0 -1
- package/dist/web/assets/UsageHistoryModal-BcUzGS2-.js +0 -1
- package/dist/web/assets/index-4ecbC_Cy.js +0 -306
- package/dist/web/assets/index-BkXTN4dZ.css +0 -1
package/src/server/reports.mjs
CHANGED
|
@@ -7,8 +7,12 @@
|
|
|
7
7
|
// WHAT GOES OUT, to api.ccdeck.dev, while `prefs.reports` is true and the
|
|
8
8
|
// machine has not vetoed it (reportsVetoed):
|
|
9
9
|
//
|
|
10
|
-
// - an "install" event the first time,
|
|
10
|
+
// - an "install" event the first time, with the ccdeck.dev page its command
|
|
11
|
+
// was copied from when the command said (`--ref`, install-ref.mjs) — a
|
|
12
|
+
// page name, never anything about the person,
|
|
11
13
|
// - an "update" event, with the version it came from, when the version moves,
|
|
14
|
+
// and how the new one arrived: the deck's own update, the desktop app's
|
|
15
|
+
// updater, npx, npm, or a source checkout,
|
|
12
16
|
// - an "active" event at most once a UTC day, which is how many people use
|
|
13
17
|
// ccdeck gets counted without counting anything else about them, and which
|
|
14
18
|
// alone also carries coarse counts of how much: how many sessions,
|
|
@@ -16,15 +20,30 @@
|
|
|
16
20
|
// and that day's date — numbers, never their names (see usage-day.mjs) —
|
|
17
21
|
// and which of the deck's features were used that day, as fixed names
|
|
18
22
|
// ("usage-history", "account-switch") and nothing about what was done
|
|
19
|
-
// with them (feature-use.mjs),
|
|
23
|
+
// with them (feature-use.mjs), and how the deck held up and who runs it:
|
|
24
|
+
// how long this run took to start, that day's peak memory and how many
|
|
25
|
+
// events it took in, each as a bucket ("2s", "512m", "1k"), the Claude and
|
|
26
|
+
// Codex plan as its category ("max-20x", "plus") and nothing else from the
|
|
27
|
+
// credentials it is read off, and how many Claude accounts and paired
|
|
28
|
+
// machines the deck holds, as capped counts (depth-facts.mjs),
|
|
29
|
+
// - an "uninstall" event when somebody runs `ccdeck --uninstall`, with the
|
|
30
|
+
// reason they picked from a short list if they picked one (bin/cli/leaving.js),
|
|
20
31
|
// - an "activated" event, once, when a new install's first session arrives:
|
|
21
32
|
// how long after the install it came, as a bucket ("5m", "1h", "1d", "7d",
|
|
22
33
|
// "later"), and whether it was a Claude or a Codex session (activation.mjs),
|
|
34
|
+
// - a "rated" event, once, if somebody answers the one question the deck asks
|
|
35
|
+
// about itself after about a week of use — "How useful is ccdeck to you?" —
|
|
36
|
+
// with the number picked, 0 to 10, and how many days the deck had been used
|
|
37
|
+
// as a range ("7-13", "30-89"); nothing for a question put off or left
|
|
38
|
+
// unanswered (rating.mjs),
|
|
23
39
|
// - errors: a request handler that threw on this server, or an error the page
|
|
24
|
-
// caught, with
|
|
25
|
-
//
|
|
40
|
+
// caught, with every path outside the deck's own package, project folder
|
|
41
|
+
// names, the host of every web address, every IP address and the machine
|
|
42
|
+
// name a network error carries, email addresses and key-shaped strings
|
|
43
|
+
// scrubbed out before they leave (see scrub),
|
|
26
44
|
// - a "ping" heartbeat, every ten minutes or so while the deck runs, which
|
|
27
|
-
// moves the install's "last seen" so the admin can show who is online now
|
|
45
|
+
// moves the install's "last seen" so the admin can show who is online now,
|
|
46
|
+
// and is counted per day, which is how long the deck stays open.
|
|
28
47
|
// It writes no event and carries the install id alone — no facts, no
|
|
29
48
|
// fingerprint.
|
|
30
49
|
//
|
|
@@ -61,24 +80,38 @@
|
|
|
61
80
|
//
|
|
62
81
|
// NOTHING HERE THROWS OR WAITS FOR ANYBODY. A report that cannot be sent is
|
|
63
82
|
// dropped: it is a nicety for the people who make ccdeck, and no part of the
|
|
64
|
-
// deck depends on it.
|
|
83
|
+
// deck depends on it. An event a check-in owes is tried again on the heartbeat,
|
|
84
|
+
// less often the longer the API stays out of reach; one the API itself refuses
|
|
85
|
+
// (its 400, 413 or 422 problem+json) is not sent again, since the same body would
|
|
86
|
+
// be refused the same way. A 4xx the edge in front of it answers with is tried again.
|
|
65
87
|
|
|
66
88
|
import { createHash, randomUUID } from "node:crypto";
|
|
89
|
+
import { chmod, mkdir, readFile, unlink } from "node:fs/promises";
|
|
67
90
|
import { arch as osArch, cpus as osCpus, homedir, hostname, platform as osPlatform, totalmem } from "node:os";
|
|
68
|
-
import { dirname, resolve } from "node:path";
|
|
69
|
-
import { fileURLToPath } from "node:url";
|
|
91
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
92
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
70
93
|
import { inApp } from "./app-host.mjs";
|
|
94
|
+
import { stripBom, writeFileAtomic } from "./atomic-write.mjs";
|
|
95
|
+
import { deckDataDir } from "./deck-home.mjs";
|
|
71
96
|
import { reportsVetoed } from "./deck-prefs.mjs";
|
|
72
|
-
import {
|
|
73
|
-
import {
|
|
97
|
+
import { prefsDir } from "./prefs-path.mjs";
|
|
98
|
+
import { isGitCheckout, isNpxInstall } from "./install-layout.mjs";
|
|
99
|
+
import { bootFoundNoPrefs, heldPrefs, prefsRead } from "./prefs-state.mjs";
|
|
74
100
|
import { RUNNING_VERSION } from "./running-version.mjs";
|
|
75
101
|
import { FEATURES, usageDay, utcDay } from "./usage-day.mjs";
|
|
76
102
|
import { setupFacts, sinceInstallBucket, whenSetupKnown } from "./activation.mjs";
|
|
103
|
+
import { notedRef, refSlug } from "./install-ref.mjs";
|
|
104
|
+
import {
|
|
105
|
+
CLAUDE_PLANS, CODEX_PLANS, cappedCount, deckDepth, eventsBucket, launchBucket, memoryBucket, updateVia,
|
|
106
|
+
} from "./depth-facts.mjs";
|
|
107
|
+
import { daysUsedBucket, normaliseRating, ratingDue, ratingStage, scoreOf } from "./rating.mjs";
|
|
77
108
|
|
|
78
109
|
export const REPORTS_API = "https://api.ccdeck.dev";
|
|
79
110
|
const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
80
111
|
const TIMEOUT_MS = 6000;
|
|
81
|
-
/** How often a long-running deck checks
|
|
112
|
+
/** How often a long-running deck checks in whatever the heartbeat found. The beat
|
|
113
|
+
* already sends what is due — a new day's "active", a check-in that failed —
|
|
114
|
+
* so this is the floor under it, not the schedule. */
|
|
82
115
|
const CHECK_IN_EVERY_MS = 6 * 60 * 60 * 1000;
|
|
83
116
|
/** The heartbeat's beat: a lighter timer than the check-in that only moves the
|
|
84
117
|
* install's "last seen", so the admin can count who is online now. Ten minutes,
|
|
@@ -86,15 +119,26 @@ const CHECK_IN_EVERY_MS = 6 * 60 * 60 * 1000;
|
|
|
86
119
|
* not all beat on the same tick. */
|
|
87
120
|
const PING_EVERY_MS = 10 * 60 * 1000;
|
|
88
121
|
const PING_JITTER_MS = 2 * 60 * 1000;
|
|
122
|
+
/** The longest a 429's or a 503's Retry-After is waited for: a header that asks
|
|
123
|
+
* for a week does not silence a deck for one. */
|
|
124
|
+
const RETRY_AFTER_MAX_MS = 24 * 60 * 60 * 1000;
|
|
125
|
+
/** How long a report waits for the CLI versions it is about to carry: a couple
|
|
126
|
+
* of `--version` answers (cli-versions.mjs). One not known by then is left out. */
|
|
127
|
+
const CLI_PROBE_WAIT_MS = 8_000;
|
|
89
128
|
/** Errors per hour this deck may send, and how long one error is not repeated. */
|
|
90
129
|
const ERRORS_PER_HOUR = 20;
|
|
91
130
|
const SAME_ERROR_QUIET_MS = 60 * 60 * 1000;
|
|
92
131
|
const MESSAGE_MAX = 500;
|
|
93
132
|
const STACK_MAX = 4000;
|
|
94
133
|
|
|
134
|
+
/** Why somebody uninstalled, when they said: the answers `ccdeck --uninstall`
|
|
135
|
+
* offers (bin/cli/leaving.js), and nothing else ever leaves as a reason. */
|
|
136
|
+
export const UNINSTALL_REASONS = Object.freeze(["not-useful", "too-noisy", "broken", "other-tool", "privacy", "other"]);
|
|
137
|
+
|
|
95
138
|
const EMPTY_REPORT = Object.freeze({
|
|
96
139
|
installId: "", lastVersion: "", lastActiveDay: "", forget: "", usage: null,
|
|
97
|
-
installedAt: "", firstSessionAt: "", firstProvider: "", activationSent: false,
|
|
140
|
+
installedAt: "", firstSessionAt: "", firstProvider: "", activationSent: false, ref: "", selfUpdate: null,
|
|
141
|
+
rating: normaliseRating(null),
|
|
98
142
|
});
|
|
99
143
|
|
|
100
144
|
/** Is this deck sending reports right now? The switch, which is on unless
|
|
@@ -210,16 +254,38 @@ function intlLocale() {
|
|
|
210
254
|
}
|
|
211
255
|
|
|
212
256
|
/** The shell as a bare name — "zsh", "bash", "fish". `SHELL` names it on POSIX;
|
|
213
|
-
* on Windows there is no `SHELL` (unless a Git-Bash-style one set it), so
|
|
214
|
-
*
|
|
257
|
+
* on Windows there is no `SHELL` (unless a Git-Bash-style one set it), so it is
|
|
258
|
+
* read off what each shell leaves for what it starts (windowsShell). */
|
|
215
259
|
export function shellToken(env = process.env, platform = process.platform) {
|
|
216
260
|
const sh = env?.SHELL;
|
|
217
261
|
if (sh) return token(baseName(sh), TOKEN_CAPS.shell);
|
|
218
|
-
if (platform === "win32")
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
262
|
+
if (platform === "win32") return windowsShell(env);
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The Windows shell that started the deck — "pwsh", "powershell", "cmd" — or
|
|
268
|
+
* nothing when it cannot be told.
|
|
269
|
+
*
|
|
270
|
+
* Not `PSModulePath` being set, nor `ComSpec`: Windows sets both machine-wide,
|
|
271
|
+
* so every process has them — cmd.exe, Windows PowerShell, and the desktop app
|
|
272
|
+
* started from the Start menu — and every Windows deck used to say "pwsh".
|
|
273
|
+
* What PowerShell adds for what it starts is its user module folder, under the
|
|
274
|
+
* user's profile: Documents\PowerShell\Modules for PowerShell 7,
|
|
275
|
+
* Documents\WindowsPowerShell\Modules for Windows PowerShell. cmd.exe defines
|
|
276
|
+
* `PROMPT`. PowerShell is asked first, because npm's .cmd shim runs through
|
|
277
|
+
* cmd.exe even from a PowerShell prompt.
|
|
278
|
+
*/
|
|
279
|
+
function windowsShell(env) {
|
|
280
|
+
const profile = String(env?.USERPROFILE ?? "").replace(/[\\/]+$/, "").toLowerCase();
|
|
281
|
+
const userModules = profile
|
|
282
|
+
? String(env?.PSModulePath ?? "").split(";")
|
|
283
|
+
.map(p => p.trim().replace(/[\\/]+$/, "").toLowerCase())
|
|
284
|
+
.filter(p => p.startsWith(`${profile}\\`) || p.startsWith(`${profile}/`))
|
|
285
|
+
: [];
|
|
286
|
+
if (userModules.some(p => /[\\/]powershell[\\/]modules$/.test(p))) return "pwsh";
|
|
287
|
+
if (userModules.some(p => /[\\/]windowspowershell[\\/]modules$/.test(p))) return "powershell";
|
|
288
|
+
if (env?.PROMPT) return token(baseName(env?.ComSpec ?? env?.COMSPEC ?? "cmd"), TOKEN_CAPS.shell) ?? "cmd";
|
|
223
289
|
return undefined;
|
|
224
290
|
}
|
|
225
291
|
|
|
@@ -284,22 +350,132 @@ export function installFacts({
|
|
|
284
350
|
return facts;
|
|
285
351
|
}
|
|
286
352
|
|
|
353
|
+
// One folder or file name of a path. One in the middle may hold spaces ("Bob
|
|
354
|
+
// Smith", "Application Support"), because the separator after it says where it
|
|
355
|
+
// ends; the last stops at the first space, so the words after a path are kept.
|
|
356
|
+
const NAME = String.raw`[^\s\\/:'"\`<>|?*;,]`;
|
|
357
|
+
const MIDDLE = String.raw`${NAME}(?:[^\\/\r\n:'"\`<>|?*;,]*${NAME})?[\\/]`;
|
|
358
|
+
const LAST = String.raw`[^\s\\/:'"\`<>|?*;,()]+`;
|
|
359
|
+
const TAIL = String.raw`(?:${MIDDLE})*(?:${LAST})?`;
|
|
360
|
+
/** A path in any shape an error carries one: a file:// URL, a ~ path, a drive
|
|
361
|
+
* path, a UNC path, an absolute POSIX path. Not an address — `https://host/x`
|
|
362
|
+
* starts after a colon and its path after the host, or after the `<deck>` or
|
|
363
|
+
* `<host>` that host is sent as (hideOrigin) — and not `node:internal/…`.
|
|
364
|
+
* A `:42:7` after it is left where it is. */
|
|
365
|
+
export const PATH_PATTERN = String.raw`(?<![\w.~/\\\]-]|<(?:deck|host)>)(?:file://(?:/[A-Za-z]:)?[^\s:'"\`<>()]*|~[\\/]${TAIL}|[A-Za-z]:[\\/]${TAIL}|\\\\(?:[?.]\\)?(?:[A-Za-z]:\\)?${TAIL}|/(?=${NAME})${TAIL}|(?<!:)//(?=${NAME})${TAIL})`;
|
|
366
|
+
/** A folder name in Claude Code's encoding of a project's path, every character
|
|
367
|
+
* but letters and digits made a dash: `-home-alice-acme`, `C--Users-Bob-acme`. */
|
|
368
|
+
export const PROJECT_PATTERN = String.raw`(?<![\w-])(?:[A-Za-z]--|-[A-Za-z0-9][\w.]*-)[\w.-]*`;
|
|
369
|
+
/** The folders under home whose name is kept in front of a scrubbed path: the
|
|
370
|
+
* two CLIs the deck reads. They say which side an error came from, and name nobody. */
|
|
371
|
+
const KEPT_HOME_FOLDERS = new Set([".claude", ".codex"]);
|
|
372
|
+
|
|
373
|
+
/** What a path outside the deck is sent as: `<path>`, or `~/<path>` and
|
|
374
|
+
* `~/.claude/<path>` for one under the home folder. */
|
|
375
|
+
function hidePath(path) {
|
|
376
|
+
if (!path.startsWith("~")) return "<path>";
|
|
377
|
+
const sep = path[1];
|
|
378
|
+
const [top, ...rest] = path.slice(2).split(/[\\/]/);
|
|
379
|
+
if (!top) return path;
|
|
380
|
+
if (!KEPT_HOME_FOLDERS.has(top)) return `~${sep}<path>`;
|
|
381
|
+
return rest.join("") ? `~${sep}${top}${sep}<path>` : path;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** The scheme and host of an address (with its port, and a user in front of the
|
|
385
|
+
* host): a deck opened from another machine is opened by that machine's name,
|
|
386
|
+
* and every frame of the page carries it. The second group is the bundle's
|
|
387
|
+
* `/assets/` after it, which only the deck's own page loads scripts from. */
|
|
388
|
+
export const ORIGIN_PATTERN = String.raw`\b(https?|wss?)://[^\s/?#\\'"\x60<>(),;]+(?=(/assets/)?)`;
|
|
389
|
+
|
|
390
|
+
/** What an address is sent as: `<deck>/assets/…` for the page's own scripts, so
|
|
391
|
+
* a frame still says where in the bundle; `https://<host>/…` for any other. */
|
|
392
|
+
function hideOrigin(_origin, scheme, assets) {
|
|
393
|
+
return assets ? "<deck>" : `${scheme}://<host>`;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const OCTET = String.raw`(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)`;
|
|
397
|
+
const IPV4 = String.raw`${OCTET}(?:\.${OCTET}){3}`;
|
|
398
|
+
const HEX = String.raw`[0-9A-Fa-f]{1,4}`;
|
|
399
|
+
/** An IPv6 address: compressed with a `::` or all eight groups there, with a
|
|
400
|
+
* digit in it (`Face::Add` is code), an IPv4 address allowed at its end
|
|
401
|
+
* (`::ffff:192.168.1.5`), and a port Node glues on with no brackets
|
|
402
|
+
* (`fd7a:115c::5:4317`) taken along, since nothing tells it from the last group. */
|
|
403
|
+
const IPV6 = String.raw`(?=[0-9A-Fa-f:]*\d)(?=[0-9A-Fa-f:]*::|(?:${HEX}:){7})(?:(?:${HEX})?:)+(?:${IPV4}|${HEX})?`;
|
|
404
|
+
const ZONE = String.raw`%[\w-]+(?:\.[\w-]+)*`;
|
|
405
|
+
/** The loopback and the any-address, plain or mapped: they name nobody. */
|
|
406
|
+
const KEPT_IPV6 = String.raw`(?:::1?|::ffff:(?:127(?:\.\d{1,3}){3}|0\.0\.0\.0))`;
|
|
407
|
+
|
|
408
|
+
/** Every IPv4 and IPv6 address, bracketed or bare, but the loopback and the
|
|
409
|
+
* any-address, so `connect ECONNREFUSED 127.0.0.1:4317` stays readable. Not a
|
|
410
|
+
* version: an IPv4 address is four parts, never one after a slash
|
|
411
|
+
* (`Chrome/130.0.0.0`) or a dash (`node-22.18.0`). */
|
|
412
|
+
export const ADDRESS_PATTERN = String.raw`(?<![\w:.%-])(?:\[(?!${KEPT_IPV6}\])${IPV6}(?:${ZONE})?\]|(?!${KEPT_IPV6}(?::\d{1,5})?(?![\w:%]|\.\d))${IPV6}(?:${ZONE})?(?![\w%]|\.\d))|(?<![\w./-])(?!127\.|0\.0\.0\.0\b)${IPV4}(?![\w-]|\.\d)`;
|
|
413
|
+
|
|
414
|
+
/** A host name, with a letter in it somewhere: one of all digits is an IPv4 address. */
|
|
415
|
+
const HOST = String.raw`(?=[\w.-]*[A-Za-z])[A-Za-z0-9][\w-]*(?:\.[\w-]+)*`;
|
|
416
|
+
/** A host name where a network error puts one: after a DNS or connect failure
|
|
417
|
+
* (`getaddrinfo ENOTFOUND bobs-pc`), glued to a port (`bobs-pc:4317`), or
|
|
418
|
+
* ending in a local-network suffix (.local, .lan, .home, .internal, .ts.net).
|
|
419
|
+
* Not localhost, and not a file:line:col frame: a port has no second number
|
|
420
|
+
* after it. */
|
|
421
|
+
export const HOST_PATTERN = String.raw`(?<![\w.@/\\%-])(?!localhost(?![\w-]|\.\w))(?:(?<=\b(?:(?:getaddrinfo|connect) E[A-Z_]+|ENOTFOUND|EAI_AGAIN) )${HOST}|${HOST}(?=:\d{1,5}(?![\w:-]|\.\w))|${HOST}\.(?:local|lan|home|internal|ts\.net)(?![\w-]|\.\w))`;
|
|
422
|
+
|
|
423
|
+
const escapeRegExp = s => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
424
|
+
|
|
425
|
+
/** Every spelling of `dir` a stack or a message can use — the path, and the
|
|
426
|
+
* file:// URL an ES module's frames carry — as a pattern for `dir` and the
|
|
427
|
+
* separator after it, so `/x/ccdeck-fork` is not taken for `/x/ccdeck`. */
|
|
428
|
+
function spellings(dir) {
|
|
429
|
+
const windows = /^(?:[A-Za-z]:[\\/]|\\\\)/.test(dir);
|
|
430
|
+
const url = pathToFileURL(dir, { windows }).href.replace(/\/$/, "");
|
|
431
|
+
return new RegExp(`(?:${escapeRegExp(url)}|${escapeRegExp(dir)})(?=[\\\\/])`, windows ? "gi" : "g");
|
|
432
|
+
}
|
|
433
|
+
|
|
287
434
|
/**
|
|
288
435
|
* Take out of an error what could say who someone is.
|
|
289
436
|
*
|
|
437
|
+
* No path outside the deck's own package leaves. A path inside it keeps its
|
|
438
|
+
* place in the package — `ccdeck/src/server/tail.mjs:42:7`, however the deck was
|
|
439
|
+
* installed — so a stack still says where the bug is and reads the same on every
|
|
440
|
+
* machine; so does a dependency npx put beside it (`node_modules/ws/…`). Every
|
|
441
|
+
* other path becomes `<path>` (`~/.claude/<path>` under the home folder, see
|
|
442
|
+
* hidePath), and a project folder in Claude Code's encoding `<project>`: either
|
|
443
|
+
* would name what somebody works on.
|
|
444
|
+
*
|
|
445
|
+
* No host leaves either. The page's frames name the machine the deck was opened
|
|
446
|
+
* by (`http://alices-macbook.local:4317/assets/…`); they become `<deck>/assets/…`,
|
|
447
|
+
* which still says where in the bundle and reads the same on every machine, and
|
|
448
|
+
* any other address keeps its scheme and path and loses its host (hideOrigin).
|
|
449
|
+
* An address with no scheme in front goes the same way: every IPv4 and IPv6
|
|
450
|
+
* address (ADDRESS_PATTERN), and a host name where a network error puts one
|
|
451
|
+
* (HOST_PATTERN: `getaddrinfo ENOTFOUND bobs-pc.local`, `bobs-pc:4317`), becomes
|
|
452
|
+
* `<host>`, a port kept apart from it left in place. The loopback and the
|
|
453
|
+
* any-address stay — `connect ECONNREFUSED 127.0.0.1:4317` names nobody, and
|
|
454
|
+
* says which port.
|
|
455
|
+
*
|
|
290
456
|
* The catch-all for long opaque strings deliberately stops at a slash: with one
|
|
291
457
|
* in it, the pattern swallowed every long file path, and a stack whose frames
|
|
292
458
|
* read `at save (~/.<secret>.mjs:40:3)` tells nobody where the bug is.
|
|
293
459
|
* The API scrubs again;
|
|
294
|
-
* this is the pass that means it never has to:
|
|
295
|
-
*
|
|
460
|
+
* this is the pass that means it never has to: paths, project folders, hosts
|
|
461
|
+
* and addresses, email addresses, and strings shaped like keys and tokens.
|
|
296
462
|
*/
|
|
297
|
-
export function scrub(text, home = homedir()) {
|
|
463
|
+
export function scrub(text, home = homedir(), root = PKG_ROOT) {
|
|
298
464
|
let out = String(text ?? "");
|
|
299
|
-
if (
|
|
465
|
+
if (root && root.length > 1) {
|
|
466
|
+
out = out.replace(spellings(root), "ccdeck");
|
|
467
|
+
if (basename(dirname(root)) === "node_modules") out = out.replace(spellings(dirname(root)), "node_modules");
|
|
468
|
+
}
|
|
469
|
+
// Never half a name: a home of /home/al is not the start of /home/alice.
|
|
470
|
+
if (home && home.length > 1) out = out.replace(new RegExp(`${escapeRegExp(home)}(?![\\w.-])`, "g"), "~");
|
|
300
471
|
return out
|
|
301
472
|
.replace(/\/(?:home|Users)\/[^/\s:'"]+/g, "~")
|
|
302
473
|
.replace(/[A-Za-z]:\\(?:Users|Documents and Settings)\\[^\\\r\n:'"]+/gi, "~")
|
|
474
|
+
.replace(new RegExp(PATH_PATTERN, "g"), hidePath)
|
|
475
|
+
.replace(new RegExp(PROJECT_PATTERN, "g"), "<project>")
|
|
476
|
+
.replace(new RegExp(ORIGIN_PATTERN, "g"), hideOrigin)
|
|
477
|
+
.replace(new RegExp(ADDRESS_PATTERN, "g"), "<host>")
|
|
478
|
+
.replace(new RegExp(HOST_PATTERN, "g"), "<host>")
|
|
303
479
|
.replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, "<email>")
|
|
304
480
|
.replace(
|
|
305
481
|
/\b(?:sk-ant-[A-Za-z0-9_-]{8,}|sk-[A-Za-z0-9_-]{20,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abpr]-[A-Za-z0-9-]{10,}|eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}|[A-Fa-f0-9]{40,}|[A-Za-z0-9+_=-]{48,})/g,
|
|
@@ -318,10 +494,16 @@ export function scrub(text, home = homedir()) {
|
|
|
318
494
|
* @param {NodeJS.ProcessEnv} [deps.env]
|
|
319
495
|
* @param {ReturnType<typeof installFacts>} [deps.facts]
|
|
320
496
|
* @param {string} [deps.home]
|
|
497
|
+
* @param {string} [deps.root] the deck's own package, whose paths an error keeps (see scrub)
|
|
321
498
|
* @param {Promise<unknown>} [deps.ready] what start() waits for: the prefs read at import
|
|
322
499
|
* @param {() => { claudeVersion?: string, codexVersion?: string }} [deps.versions] the CLI
|
|
323
500
|
* versions to fold into the facts, read fresh on every send so a background
|
|
324
501
|
* probe's answer rides along the moment it arrives. Default: none.
|
|
502
|
+
* @param {() => Promise<unknown>} [deps.probe] asks the CLIs their versions again,
|
|
503
|
+
* for `versions` to answer with: started by start() once the prefs say reports
|
|
504
|
+
* are on, again on each new UTC day, and waited for — never past
|
|
505
|
+
* CLI_PROBE_WAIT_MS — before an install, update or "active" goes out.
|
|
506
|
+
* Default: none.
|
|
325
507
|
* @param {ReturnType<typeof import("./usage-day.mjs").createUsageDay>} [deps.usage]
|
|
326
508
|
* the day's tally: the "active" report carries the last finished day's totals,
|
|
327
509
|
* read only when an "active" is about to be sent, and the tally is saved into
|
|
@@ -330,6 +512,27 @@ export function scrub(text, home = homedir()) {
|
|
|
330
512
|
* boot set up, folded into install, update and active (activation.mjs).
|
|
331
513
|
* @param {() => Promise<unknown>} [deps.setupKnown] what start() waits for besides the
|
|
332
514
|
* prefs, so the first install event carries the setup. Bounded.
|
|
515
|
+
* @param {() => string | undefined} [deps.reference] the ref this run's command
|
|
516
|
+
* carried (install-ref.mjs), for the install event alone.
|
|
517
|
+
* @param {(ctx: { setup: object, prefs: object }) => Promise<object>} [deps.depth] the
|
|
518
|
+
* plans, accounts and paired machines the "active" carries (depth-facts.mjs),
|
|
519
|
+
* read only when one is about to go. Default: none — the deck's own reporter
|
|
520
|
+
* passes deckDepth, so no test reads a real credentials file.
|
|
521
|
+
* @param {() => number} [deps.launchedIn] ms from this process starting to the
|
|
522
|
+
* deck listening, read once by start(), which runs as the deck starts listening.
|
|
523
|
+
* @param {() => number} [deps.memory] the process's resident memory in bytes,
|
|
524
|
+
* sampled on the heartbeat's beat for the usage day's peak.
|
|
525
|
+
* @param {boolean} [deps.npx] this copy runs out of an npx cache, which is how an
|
|
526
|
+
* npm install that changed version under it updated.
|
|
527
|
+
* @param {() => number} [deps.uptime] how long this deck has been up, in ms: the
|
|
528
|
+
* question about the deck is never asked in the minutes after a launch.
|
|
529
|
+
* @param {() => boolean} [deps.firstRun] this run's boot found no settings file:
|
|
530
|
+
* the deck had never run here, so an install id made now is a real install and
|
|
531
|
+
* its first session is timed. Default: the boot read's own answer.
|
|
532
|
+
* @param {{ read: () => Promise<unknown>, write: (kept: object | null) => Promise<void> }} [deps.ratingKept]
|
|
533
|
+
* where the question's outcome is kept a second time, out of reach of an
|
|
534
|
+
* older deck's write of prefs.json (ratingFile). Default: none — the deck's
|
|
535
|
+
* own reporter passes ratingFile(), so no test writes beside a real prefs.json.
|
|
333
536
|
*/
|
|
334
537
|
export function createReporter({
|
|
335
538
|
fetchImpl = globalThis.fetch,
|
|
@@ -338,16 +541,28 @@ export function createReporter({
|
|
|
338
541
|
env = process.env,
|
|
339
542
|
facts = installFacts({ env }),
|
|
340
543
|
home = homedir(),
|
|
544
|
+
root = PKG_ROOT,
|
|
341
545
|
ready = prefsRead,
|
|
342
546
|
versions = () => ({}),
|
|
547
|
+
probe = async () => {},
|
|
343
548
|
usage = usageDay,
|
|
344
549
|
setup = setupFacts,
|
|
345
550
|
setupKnown = () => whenSetupKnown(),
|
|
551
|
+
reference = notedRef,
|
|
552
|
+
depth = async () => ({}),
|
|
553
|
+
launchedIn = () => process.uptime() * 1000,
|
|
554
|
+
memory = () => process.memoryUsage.rss(),
|
|
555
|
+
npx = false,
|
|
556
|
+
uptime = () => process.uptime() * 1000,
|
|
557
|
+
firstRun = bootFoundNoPrefs,
|
|
558
|
+
ratingKept = null,
|
|
346
559
|
} = {}) {
|
|
347
560
|
const errorsSent = [];
|
|
348
561
|
const lastSentAt = new Map();
|
|
349
562
|
let timer = null;
|
|
350
563
|
let pingTimer = null;
|
|
564
|
+
/** How long this run took to start, read when start() runs; null before. */
|
|
565
|
+
let launchMs = null;
|
|
351
566
|
|
|
352
567
|
/** The CLI versions to add to the facts right now, tokenised and omitted when
|
|
353
568
|
* not yet known — read on every send so the background probe's result appears
|
|
@@ -364,6 +579,41 @@ export function createReporter({
|
|
|
364
579
|
}
|
|
365
580
|
}
|
|
366
581
|
|
|
582
|
+
/** The UTC day the CLIs were last asked their versions, and the asking under way. */
|
|
583
|
+
let probedDay = "";
|
|
584
|
+
let probing = null;
|
|
585
|
+
|
|
586
|
+
/** Ask the CLIs their versions, at most once a UTC day — so a deck left running
|
|
587
|
+
* for a week says the Claude Code it has now, not the one it booted with — and
|
|
588
|
+
* resolve once the answer is in. Never rejects. */
|
|
589
|
+
function probeVersions() {
|
|
590
|
+
try {
|
|
591
|
+
const today = utcDay(now());
|
|
592
|
+
if (!probing && probedDay !== today) {
|
|
593
|
+
probedDay = today;
|
|
594
|
+
probing = Promise.resolve()
|
|
595
|
+
.then(() => probe())
|
|
596
|
+
.catch(() => { /* a probe that could not run leaves the versions as they were */ })
|
|
597
|
+
.finally(() => { probing = null; });
|
|
598
|
+
}
|
|
599
|
+
} catch {
|
|
600
|
+
// A clock that cannot say the day; the report goes with what is known.
|
|
601
|
+
}
|
|
602
|
+
return probing ?? Promise.resolve();
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** The versions fresh for a report about to carry them: the day's probe, waited
|
|
606
|
+
* for, but never past CLI_PROBE_WAIT_MS — a version not known by then is left
|
|
607
|
+
* out, and rides on a later report. */
|
|
608
|
+
async function versionsReady() {
|
|
609
|
+
let timer;
|
|
610
|
+
await Promise.race([
|
|
611
|
+
probeVersions(),
|
|
612
|
+
new Promise(r => { timer = setTimeout(r, CLI_PROBE_WAIT_MS); timer.unref?.(); }),
|
|
613
|
+
]);
|
|
614
|
+
clearTimeout(timer);
|
|
615
|
+
}
|
|
616
|
+
|
|
367
617
|
/** The full facts an event carries: the fixed install facts, plus whatever CLI
|
|
368
618
|
* versions are known this moment. */
|
|
369
619
|
function factsNow() {
|
|
@@ -384,6 +634,25 @@ export function createReporter({
|
|
|
384
634
|
}
|
|
385
635
|
}
|
|
386
636
|
|
|
637
|
+
/** Whether this is the deck's first run here. Never throws: a run it cannot
|
|
638
|
+
* tell is not one. */
|
|
639
|
+
function isFirstRun() {
|
|
640
|
+
try {
|
|
641
|
+
return firstRun() === true;
|
|
642
|
+
} catch {
|
|
643
|
+
return false;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** This run's ref, if it is one. Never throws. */
|
|
648
|
+
function refNow() {
|
|
649
|
+
try {
|
|
650
|
+
return refSlug(reference());
|
|
651
|
+
} catch {
|
|
652
|
+
return undefined;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
387
656
|
/** The coarse usage the "active" report carries: the last finished day before
|
|
388
657
|
* `today`, its date as `usageDay` and its counts beside it. Nothing at all
|
|
389
658
|
* when there is no such day yet — a first launch has no yesterday — and only
|
|
@@ -400,12 +669,45 @@ export function createReporter({
|
|
|
400
669
|
// The features used that day, as names off the fixed list — an empty list
|
|
401
670
|
// is a real answer (the deck ran, nothing was opened), not a missing one.
|
|
402
671
|
out.features = Array.isArray(raw.features) ? raw.features.filter(f => FEATURES.includes(f)) : [];
|
|
672
|
+
// How hard the deck worked that day, as buckets (depth-facts.mjs). A peak
|
|
673
|
+
// of zero is a day no beat sampled, and null events a day this deck did
|
|
674
|
+
// not count in full (usage-day.mjs): neither says anything.
|
|
675
|
+
addIf(out, "events", eventsBucket(raw.events));
|
|
676
|
+
addIf(out, "deckMemory", raw.peakMb > 0 ? memoryBucket(raw.peakMb) : undefined);
|
|
403
677
|
return out;
|
|
404
678
|
} catch {
|
|
405
679
|
return {};
|
|
406
680
|
}
|
|
407
681
|
}
|
|
408
682
|
|
|
683
|
+
/** What the "active" says about the deck beyond its day: how long this run
|
|
684
|
+
* took to start, as a bucket, and the plans, accounts and paired machines
|
|
685
|
+
* the depth provider reads — each only as a word off its list or a capped
|
|
686
|
+
* count (depth-facts.mjs). A reading that throws costs the report nothing. */
|
|
687
|
+
async function depthNow() {
|
|
688
|
+
const out = {};
|
|
689
|
+
addIf(out, "launch", launchMs == null ? undefined : launchBucket(launchMs));
|
|
690
|
+
try {
|
|
691
|
+
const d = (await depth({ setup: setupNow(), prefs: prefs.current() })) ?? {};
|
|
692
|
+
addIf(out, "claudePlan", CLAUDE_PLANS.includes(d.claudePlan) ? d.claudePlan : undefined);
|
|
693
|
+
addIf(out, "codexPlan", CODEX_PLANS.includes(d.codexPlan) ? d.codexPlan : undefined);
|
|
694
|
+
addIf(out, "accounts", cappedCount(d.accounts));
|
|
695
|
+
addIf(out, "machines", cappedCount(d.machines));
|
|
696
|
+
} catch {
|
|
697
|
+
// A plan or a count that could not be read is left out.
|
|
698
|
+
}
|
|
699
|
+
return out;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** The deck's memory into the day's tally, which keeps the day's highest. */
|
|
703
|
+
function samplePeak() {
|
|
704
|
+
try {
|
|
705
|
+
usage.notePeak?.(memory() / (1024 * 1024));
|
|
706
|
+
} catch {
|
|
707
|
+
// A reading that failed this beat; the next one tries again.
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
409
711
|
/** The tally the last save wrote, so a save with nothing new writes nothing. */
|
|
410
712
|
let usageSaved = -1;
|
|
411
713
|
|
|
@@ -435,6 +737,9 @@ export function createReporter({
|
|
|
435
737
|
}
|
|
436
738
|
}
|
|
437
739
|
|
|
740
|
+
/** What became of a send: "sent"; "refused" — the API saying no to this body,
|
|
741
|
+
* which it will say again to the same body, so it is final; or "failed" —
|
|
742
|
+
* anything else, worth another try. */
|
|
438
743
|
async function call(method, path, body) {
|
|
439
744
|
try {
|
|
440
745
|
const res = await fetchImpl(REPORTS_API + path, {
|
|
@@ -443,12 +748,94 @@ export function createReporter({
|
|
|
443
748
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
444
749
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
445
750
|
});
|
|
446
|
-
|
|
751
|
+
if (res.ok) return "sent";
|
|
752
|
+
if (res.status === 429 || res.status === 503) waitAsAsked(res);
|
|
753
|
+
return refusedByApi(res) ? "refused" : "failed";
|
|
754
|
+
} catch {
|
|
755
|
+
return "failed";
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** Whether an answer is the API's own verdict on the body: a 400, 413 or 422
|
|
760
|
+
* it wrote as application/problem+json, the only way it turns a body down.
|
|
761
|
+
* Any other 4xx — a 403 or 404 Cloudflare answers with itself (a challenge,
|
|
762
|
+
* a rule, a tunnel being moved), a proxy's 401 — says nothing about the body,
|
|
763
|
+
* and a later try can get through, so it is tried again like a 5xx. */
|
|
764
|
+
function refusedByApi(res) {
|
|
765
|
+
try {
|
|
766
|
+
if (res.status !== 400 && res.status !== 413 && res.status !== 422) return false;
|
|
767
|
+
const type = String(res.headers?.get?.("content-type") ?? "").toLowerCase();
|
|
768
|
+
return type.split(";")[0].trim() === "application/problem+json";
|
|
447
769
|
} catch {
|
|
448
770
|
return false;
|
|
449
771
|
}
|
|
450
772
|
}
|
|
451
773
|
|
|
774
|
+
/** Delivered, or refused for good: either way nothing more to send. */
|
|
775
|
+
const settled = outcome => outcome !== "failed";
|
|
776
|
+
|
|
777
|
+
/** Check-ins in a row that ended still owing something, and the earliest the
|
|
778
|
+
* timers may check in again, by `now` — so a laptop that slept through the
|
|
779
|
+
* wait tries on its first beat awake. */
|
|
780
|
+
let failedInARow = 0;
|
|
781
|
+
let retryAt = 0;
|
|
782
|
+
/** The earliest the timers may send anything at all — a check-in or the
|
|
783
|
+
* heartbeat — because the API asked for the wait (a Retry-After), whichever
|
|
784
|
+
* request it answered. Kept apart from the backoff, which is the check-ins'
|
|
785
|
+
* own: a check-in that owes nothing more clears that, and not this. */
|
|
786
|
+
let askedWaitUntil = 0;
|
|
787
|
+
|
|
788
|
+
/** A Retry-After, in seconds or as a date, moves the next try out to it. */
|
|
789
|
+
function waitAsAsked(res) {
|
|
790
|
+
try {
|
|
791
|
+
const raw = String(res.headers?.get?.("retry-after") ?? "").trim();
|
|
792
|
+
if (!raw) return;
|
|
793
|
+
const at = now().getTime();
|
|
794
|
+
const ms = /^\d+$/.test(raw) ? Number(raw) * 1000 : Date.parse(raw) - at;
|
|
795
|
+
if (Number.isFinite(ms) && ms > 0) askedWaitUntil = Math.max(askedWaitUntil, at + Math.min(ms, RETRY_AFTER_MAX_MS));
|
|
796
|
+
} catch {
|
|
797
|
+
// A header that will not read: the backoff alone decides.
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** Whether the API's own Retry-After has passed. Never throws. */
|
|
802
|
+
function askedWaitOver() {
|
|
803
|
+
try {
|
|
804
|
+
return now().getTime() >= askedWaitUntil;
|
|
805
|
+
} catch {
|
|
806
|
+
return true;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/** After a check-in: one that still owes something waits half a beat before
|
|
811
|
+
* the next try — so the first is the next beat, however long this one took
|
|
812
|
+
* to fail — then twice that each time, up to the six-hour check-in; one that
|
|
813
|
+
* owes nothing clears the wait. Never throws. */
|
|
814
|
+
function afterCheckIn() {
|
|
815
|
+
try {
|
|
816
|
+
if (!checkInDue()) {
|
|
817
|
+
failedInARow = 0;
|
|
818
|
+
retryAt = 0;
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
failedInARow++;
|
|
822
|
+
const wait = Math.min((PING_EVERY_MS / 2) * 2 ** (failedInARow - 1), CHECK_IN_EVERY_MS);
|
|
823
|
+
retryAt = Math.max(retryAt, now().getTime() + wait);
|
|
824
|
+
} catch {
|
|
825
|
+
// A clock that cannot say: the next beat tries again.
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/** Whether the timers may check in now: the backoff, and any wait the API
|
|
830
|
+
* asked for, both over. Never throws. */
|
|
831
|
+
function retryDue() {
|
|
832
|
+
try {
|
|
833
|
+
return now().getTime() >= retryAt && askedWaitOver();
|
|
834
|
+
} catch {
|
|
835
|
+
return true;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
452
839
|
/** Change the reporter's state, but only while it still belongs to `installId`:
|
|
453
840
|
* a switch-off that landed in between has the last word. */
|
|
454
841
|
function remember(installId, change) {
|
|
@@ -457,14 +844,40 @@ export function createReporter({
|
|
|
457
844
|
);
|
|
458
845
|
}
|
|
459
846
|
|
|
847
|
+
/** Check-ins under way, so a beat that lands during a slow one does not start
|
|
848
|
+
* a second beside it and send the same install twice. */
|
|
849
|
+
let checkingIn = 0;
|
|
850
|
+
|
|
460
851
|
/** Send whatever is due: a pending deletion, then install or update, then
|
|
461
852
|
* today's "active". Never throws: a floating rejection here would be an
|
|
462
853
|
* unhandled one, and Node's answer to those is to end the process. */
|
|
463
854
|
async function checkIn() {
|
|
855
|
+
checkingIn++;
|
|
464
856
|
try {
|
|
465
857
|
await sendWhatIsDue();
|
|
466
858
|
} catch {
|
|
467
859
|
// A settings file that cannot be written this minute; the next check-in tries again.
|
|
860
|
+
} finally {
|
|
861
|
+
checkingIn--;
|
|
862
|
+
afterCheckIn();
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/** Whether a check-in has something to send: the conditions sendWhatIsDue
|
|
867
|
+
* acts on, read off the prefs and the clock alone — no network — so the
|
|
868
|
+
* heartbeat can ask on every beat. Never throws. */
|
|
869
|
+
function checkInDue() {
|
|
870
|
+
try {
|
|
871
|
+
if (reportsVetoed(env)) return false;
|
|
872
|
+
const p = prefs.current();
|
|
873
|
+
const r = p.report;
|
|
874
|
+
if (r.forget) return true;
|
|
875
|
+
if (!reportsOn(p, env)) return false;
|
|
876
|
+
if (!r.installId || r.lastVersion !== facts.version || r.lastActiveDay !== utcDay(now())) return true;
|
|
877
|
+
if (r.installedAt && !r.activationSent && (r.firstSessionAt || usage.firstUse?.())) return true;
|
|
878
|
+
return r.rating.score !== null && !r.rating.sent;
|
|
879
|
+
} catch {
|
|
880
|
+
return false;
|
|
468
881
|
}
|
|
469
882
|
}
|
|
470
883
|
|
|
@@ -474,45 +887,72 @@ export function createReporter({
|
|
|
474
887
|
if (reportsVetoed(env)) return;
|
|
475
888
|
const p = prefs.current();
|
|
476
889
|
const { forget } = p.report ?? EMPTY_REPORT;
|
|
477
|
-
|
|
890
|
+
// Only a deletion that landed is done with: one refused stays owed, and is
|
|
891
|
+
// asked again as a failed one is, rather than dropped.
|
|
892
|
+
if (forget && (await call("DELETE", `/v1/app/installs/${encodeURIComponent(forget)}`)) === "sent") {
|
|
478
893
|
await prefs.update(prev => (prev.report.forget === forget ? { report: { ...prev.report, forget: "" } } : undefined));
|
|
479
894
|
}
|
|
480
895
|
if (!reportsOn(prefs.current(), env)) return;
|
|
481
896
|
|
|
482
897
|
// The id is made here, the first time there is something to send under it.
|
|
483
898
|
if (!prefs.current().report.installId) {
|
|
899
|
+
const fresh = isFirstRun();
|
|
484
900
|
await prefs.update(prev =>
|
|
485
901
|
prev.reports === false || prev.report.installId
|
|
486
902
|
? undefined
|
|
487
|
-
//
|
|
488
|
-
// session is measured against. Kept here, never sent as
|
|
489
|
-
|
|
903
|
+
// On a first run, when the id is made is when this install began: the
|
|
904
|
+
// clock the first session is measured against. Kept here, never sent as
|
|
905
|
+
// a time. A deck that ran here before — one upgraded from a version
|
|
906
|
+
// with no reports, which had no id to keep — gets none, and is never
|
|
907
|
+
// timed: its first session was long ago, and "now" would be a lie.
|
|
908
|
+
: { report: { ...prev.report, installId: randomUUID(), installedAt: fresh ? now().toISOString() : "" } },
|
|
490
909
|
);
|
|
491
910
|
}
|
|
492
911
|
const { installId, lastVersion, lastActiveDay } = prefs.current().report;
|
|
493
912
|
if (!installId) return;
|
|
913
|
+
const today = utcDay(now());
|
|
914
|
+
// The CLI versions ride on install, update and "active", and those go out at a
|
|
915
|
+
// boot more often than not: so they are asked for first, and on a new day
|
|
916
|
+
// asked again.
|
|
917
|
+
if (lastVersion !== facts.version || lastActiveDay !== today) await versionsReady();
|
|
494
918
|
if (!lastVersion) {
|
|
495
|
-
|
|
496
|
-
|
|
919
|
+
// The site page the command came from: one an earlier try of this install
|
|
920
|
+
// could not send, else this run's. Kept until the install gets out, then
|
|
921
|
+
// never again — only a new install has a page to come from.
|
|
922
|
+
const ref = refSlug(prefs.current().report.ref) ?? refNow();
|
|
923
|
+
const body = { installId, kind: "install", ...factsNow(), ...setupNow() };
|
|
924
|
+
addIf(body, "ref", ref);
|
|
925
|
+
// Refused by the API is as done as sent: the same body would be refused again.
|
|
926
|
+
if (settled(await call("POST", "/v1/app/events", body))) {
|
|
927
|
+
await remember(installId, { lastVersion: facts.version, ref: "" });
|
|
928
|
+
} else if (ref) {
|
|
929
|
+
await remember(installId, { ref });
|
|
497
930
|
}
|
|
498
931
|
} else if (lastVersion !== facts.version) {
|
|
499
|
-
|
|
500
|
-
|
|
932
|
+
const body = { installId, kind: "update", fromVersion: lastVersion, ...factsNow(), ...setupNow() };
|
|
933
|
+
// How it arrived: the deck's own update if it wrote that it started one
|
|
934
|
+
// from this version, else what the channel says (depth-facts.mjs).
|
|
935
|
+
addIf(body, "via", updateVia({
|
|
936
|
+
marker: prefs.current().report.selfUpdate, lastVersion, channel: facts.channel, npx, now: now(),
|
|
937
|
+
}));
|
|
938
|
+
if (settled(await call("POST", "/v1/app/events", body))) {
|
|
939
|
+
await remember(installId, { lastVersion: facts.version, selfUpdate: null });
|
|
501
940
|
}
|
|
502
941
|
}
|
|
503
942
|
|
|
504
|
-
const today = utcDay(now());
|
|
505
943
|
if (lastActiveDay !== today) {
|
|
506
944
|
// Usage rides on the "active" event alone, and is read only here — the one
|
|
507
945
|
// report it belongs to — so a checkIn that sends nothing new never asks the
|
|
508
946
|
// deck for its counts.
|
|
509
947
|
const used = coarseUsage(today);
|
|
510
|
-
|
|
948
|
+
const held = await depthNow();
|
|
949
|
+
if (settled(await call("POST", "/v1/app/events", { installId, kind: "active", ...factsNow(), ...setupNow(), ...used, ...held }))) {
|
|
511
950
|
if (used.usageDay) usage.markSent(used.usageDay);
|
|
512
951
|
await remember(installId, { lastActiveDay: today });
|
|
513
952
|
}
|
|
514
953
|
}
|
|
515
954
|
await sendActivation();
|
|
955
|
+
await sendRating();
|
|
516
956
|
await saveUsage();
|
|
517
957
|
}
|
|
518
958
|
|
|
@@ -521,7 +961,8 @@ export function createReporter({
|
|
|
521
961
|
* after the install it came, in a bucket, and which CLI it came from.
|
|
522
962
|
*
|
|
523
963
|
* Only for an install whose start this deck saw — `installedAt` is made with
|
|
524
|
-
* the id, so an install from before this version
|
|
964
|
+
* the id, on a first run only, so an install from before this version, or one
|
|
965
|
+
* whose id was made over an older deck's settings, has none and is never
|
|
525
966
|
* measured: its first session was long ago, and "now" would be a lie. The time
|
|
526
967
|
* is taken from the first use this run heard, so a session that arrived before
|
|
527
968
|
* the first check-in had an id is still dated when it happened. A send that
|
|
@@ -540,7 +981,140 @@ export function createReporter({
|
|
|
540
981
|
const body = { installId: r.installId, kind: "activated", ...factsNow(), ...setupNow() };
|
|
541
982
|
addIf(body, "firstSession", sinceInstallBucket(r.installedAt, r.firstSessionAt));
|
|
542
983
|
addIf(body, "firstProvider", r.firstProvider === "codex" || r.firstProvider === "claude" ? r.firstProvider : undefined);
|
|
543
|
-
if (await call("POST", "/v1/app/events", body)) await remember(r.installId, { activationSent: true });
|
|
984
|
+
if (settled(await call("POST", "/v1/app/events", body))) await remember(r.installId, { activationSent: true });
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/** The deck starting an update of its own — the Upgrade press, the npx
|
|
988
|
+
* relaunch onto the latest, or the update it runs while nobody is looking
|
|
989
|
+
* (lifecycle.mjs) — written down so the "update" the next version sends can
|
|
990
|
+
* say so. Only the version it started from and when; never throws. */
|
|
991
|
+
async function noteSelfUpdate() {
|
|
992
|
+
try {
|
|
993
|
+
const p = prefs.current();
|
|
994
|
+
if (!reportsOn(p, env) || !p.report.installId) return;
|
|
995
|
+
await remember(p.report.installId, { selfUpdate: { from: facts.version, at: now().toISOString() } });
|
|
996
|
+
} catch {
|
|
997
|
+
// Unsaid, the update is credited to its channel instead.
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/** The answer to the deck's one question, said once — tried again at the next
|
|
1002
|
+
* check-in when it cannot get through. The score and how long the deck had
|
|
1003
|
+
* been used, as a range; nothing else about the question ever leaves. */
|
|
1004
|
+
async function sendRating() {
|
|
1005
|
+
const p = prefs.current();
|
|
1006
|
+
const r = p.report;
|
|
1007
|
+
if (!reportsOn(p, env) || !r.installId || r.rating.score === null || r.rating.sent) return;
|
|
1008
|
+
const days = usage.daysUsed?.() ?? 0;
|
|
1009
|
+
const body = { installId: r.installId, kind: "rated", ...factsNow(), score: r.rating.score, daysUsed: daysUsedBucket(days) };
|
|
1010
|
+
if (settled(await call("POST", "/v1/app/events", body))) {
|
|
1011
|
+
await remember(r.installId, { rating: { ...prefs.current().report.rating, sent: true } });
|
|
1012
|
+
await keepRating();
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/** Should the page ask "How useful is ccdeck to you?" now? Only while reports
|
|
1017
|
+
* are on and there is an install to send the answer under (rating.mjs says
|
|
1018
|
+
* the rest). Never throws. */
|
|
1019
|
+
function ratingAsk() {
|
|
1020
|
+
try {
|
|
1021
|
+
const p = prefs.current();
|
|
1022
|
+
if (!reportsOn(p, env) || !p.report.installId) return false;
|
|
1023
|
+
return ratingDue(p.report.rating, usage.daysUsed?.() ?? 0, now(), uptime());
|
|
1024
|
+
} catch {
|
|
1025
|
+
return false;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/** The question answered: kept, then sent. A second answer, from a second tab,
|
|
1030
|
+
* changes nothing. Resolves to whether it was kept; never throws. */
|
|
1031
|
+
async function rate(score) {
|
|
1032
|
+
try {
|
|
1033
|
+
const picked = scoreOf(score);
|
|
1034
|
+
const p = prefs.current();
|
|
1035
|
+
if (picked === null || !reportsOn(p, env) || !p.report.installId || p.report.rating.score !== null) return false;
|
|
1036
|
+
await remember(p.report.installId, { rating: { ...p.report.rating, score: picked, sent: false } });
|
|
1037
|
+
await keepRating();
|
|
1038
|
+
await sendRating();
|
|
1039
|
+
return prefs.current().report.rating.score === picked;
|
|
1040
|
+
} catch {
|
|
1041
|
+
return false;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/** "Not now": the question waits a month, and after the second time it stops.
|
|
1046
|
+
* Resolves to whether it was kept; never throws. */
|
|
1047
|
+
async function rateLater() {
|
|
1048
|
+
try {
|
|
1049
|
+
const p = prefs.current();
|
|
1050
|
+
const r = p.report.rating;
|
|
1051
|
+
if (!reportsOn(p, env) || !p.report.installId || r.score !== null) return false;
|
|
1052
|
+
await remember(p.report.installId, { rating: { ...r, later: r.later + 1, laterAt: now().toISOString() } });
|
|
1053
|
+
await keepRating();
|
|
1054
|
+
return true;
|
|
1055
|
+
} catch {
|
|
1056
|
+
return false;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/** The question's outcome, kept a second time beside prefs.json (ratingFile)
|
|
1061
|
+
* under the install it belongs to — or taken off the disk once there is no
|
|
1062
|
+
* install to keep it for, so switching reports off forgets the id there too.
|
|
1063
|
+
* Never throws. */
|
|
1064
|
+
async function keepRating() {
|
|
1065
|
+
try {
|
|
1066
|
+
if (!ratingKept) return;
|
|
1067
|
+
const p = prefs.current();
|
|
1068
|
+
const { installId, rating } = p.report;
|
|
1069
|
+
await ratingKept.write(reportsOn(p, env) && installId ? { installId, rating } : null);
|
|
1070
|
+
} catch {
|
|
1071
|
+
// Unkept, the outcome still lives in prefs.json, as it always did.
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/** The kept outcome, back into the prefs when it is further along than what
|
|
1076
|
+
* prefs.json says: an older deck wrote that file in between, and its write
|
|
1077
|
+
* drops every key it does not know, the rating among them — so the question
|
|
1078
|
+
* would be asked again, and a second answer sent. Only under the install it
|
|
1079
|
+
* was kept for. Never throws. */
|
|
1080
|
+
async function restoreRating() {
|
|
1081
|
+
try {
|
|
1082
|
+
if (!ratingKept) return;
|
|
1083
|
+
const kept = await ratingKept.read();
|
|
1084
|
+
const p = prefs.current();
|
|
1085
|
+
if (!reportsOn(p, env) || !p.report.installId || kept?.installId !== p.report.installId) return;
|
|
1086
|
+
const rating = normaliseRating(kept.rating);
|
|
1087
|
+
if (ratingStage(rating) > ratingStage(p.report.rating)) await remember(p.report.installId, { rating });
|
|
1088
|
+
} catch {
|
|
1089
|
+
// A kept file that will not read: what prefs.json says stands.
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/** Whether a report would go out right now: reports on, the machine not
|
|
1094
|
+
* vetoing them, and an install id to send under. `ccdeck --uninstall` asks
|
|
1095
|
+
* before it asks the person anything. Never throws. */
|
|
1096
|
+
async function willReport() {
|
|
1097
|
+
try {
|
|
1098
|
+
await Promise.resolve(ready).catch(() => {});
|
|
1099
|
+
const p = prefs.current();
|
|
1100
|
+
return reportsOn(p, env) && Boolean(p.report.installId);
|
|
1101
|
+
} catch {
|
|
1102
|
+
return false;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/** The install leaving: `ccdeck --uninstall`, with the reason picked off
|
|
1107
|
+
* UNINSTALL_REASONS if one was. The id stays: a deck run again later is the
|
|
1108
|
+
* same install coming back. Resolves to whether it got through; never throws. */
|
|
1109
|
+
async function reportUninstall(reason) {
|
|
1110
|
+
try {
|
|
1111
|
+
if (!(await willReport())) return false;
|
|
1112
|
+
const body = { installId: prefs.current().report.installId, kind: "uninstall", ...factsNow() };
|
|
1113
|
+
addIf(body, "reason", UNINSTALL_REASONS.includes(reason) ? reason : undefined);
|
|
1114
|
+
return (await call("POST", "/v1/app/events", body)) === "sent";
|
|
1115
|
+
} catch {
|
|
1116
|
+
return false;
|
|
1117
|
+
}
|
|
544
1118
|
}
|
|
545
1119
|
|
|
546
1120
|
/** The first session this run heard, sent the moment it lands rather than at
|
|
@@ -559,11 +1133,13 @@ export function createReporter({
|
|
|
559
1133
|
* dropped if it cannot get through, and it never throws. No id yet means the
|
|
560
1134
|
* first check-in has not run, so there is nothing to be online under and
|
|
561
1135
|
* nothing is sent; the check-in that makes the id is what puts this deck
|
|
562
|
-
* online, and the ping only keeps that fresh.
|
|
1136
|
+
* online, and the ping only keeps that fresh. Not while a Retry-After the
|
|
1137
|
+
* API sent — on a ping or a check-in — is still running: it asked to be left
|
|
1138
|
+
* alone that long, and the heartbeat is the request sent most often. */
|
|
563
1139
|
async function ping() {
|
|
564
1140
|
try {
|
|
565
1141
|
const p = prefs.current();
|
|
566
|
-
if (!reportsOn(p, env) || !p.report.installId) return;
|
|
1142
|
+
if (!reportsOn(p, env) || !p.report.installId || !askedWaitOver()) return;
|
|
567
1143
|
await call("POST", "/v1/app/ping", { installId: p.report.installId });
|
|
568
1144
|
} catch {
|
|
569
1145
|
// The lightest of the niceties: a heartbeat that could not be sent is gone.
|
|
@@ -581,6 +1157,7 @@ export function createReporter({
|
|
|
581
1157
|
reports: false,
|
|
582
1158
|
report: { ...EMPTY_REPORT, forget: prev.report.installId || prev.report.forget },
|
|
583
1159
|
}));
|
|
1160
|
+
await keepRating();
|
|
584
1161
|
await checkIn();
|
|
585
1162
|
}
|
|
586
1163
|
|
|
@@ -592,7 +1169,7 @@ export function createReporter({
|
|
|
592
1169
|
async function reportError(where, error) {
|
|
593
1170
|
const p = prefs.current();
|
|
594
1171
|
if (!reportsOn(p, env) || !p.report.installId) return false;
|
|
595
|
-
const message = scrub(error?.message ?? error, home).slice(0, MESSAGE_MAX).trim();
|
|
1172
|
+
const message = scrub(error?.message ?? error, home, root).slice(0, MESSAGE_MAX).trim();
|
|
596
1173
|
if (!message) return false;
|
|
597
1174
|
const at = now().getTime();
|
|
598
1175
|
while (errorsSent.length && at - errorsSent[0] > SAME_ERROR_QUIET_MS) errorsSent.shift();
|
|
@@ -600,11 +1177,23 @@ export function createReporter({
|
|
|
600
1177
|
if (errorsSent.length >= ERRORS_PER_HOUR || at - (lastSentAt.get(key) ?? -Infinity) < SAME_ERROR_QUIET_MS) return false;
|
|
601
1178
|
errorsSent.push(at);
|
|
602
1179
|
lastSentAt.set(key, at);
|
|
603
|
-
const stack = typeof error?.stack === "string" ? scrub(error.stack, home).slice(0, STACK_MAX) : undefined;
|
|
1180
|
+
const stack = typeof error?.stack === "string" ? scrub(error.stack, home, root).slice(0, STACK_MAX) : undefined;
|
|
604
1181
|
// Less the runtime, and less the deviceId — an error is not one of the events
|
|
605
1182
|
// the fingerprint rides on, so the personal field never leaves on one.
|
|
606
1183
|
const { runtime: _runtime, deviceId: _deviceId, ...fields } = factsNow();
|
|
607
|
-
return call("POST", "/v1/app/errors", { installId: p.report.installId, ...fields, where, message, stack });
|
|
1184
|
+
return (await call("POST", "/v1/app/errors", { installId: p.report.installId, ...fields, where, message, stack })) === "sent";
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/** One beat. Whatever a check-in still owes goes first: a boot check-in that
|
|
1188
|
+
* could not get through (a login item started before the Wi-Fi), or a UTC day
|
|
1189
|
+
* with no "active" yet. Left to the six-hour timer, that was hours of awake
|
|
1190
|
+
* time — Node's timers stand still while the machine sleeps, so on a laptop it
|
|
1191
|
+
* could be days, and a day the deck was used could end unsaid. Not before a
|
|
1192
|
+
* check-in that could not get through has waited its turn (afterCheckIn), so
|
|
1193
|
+
* an API that is down is not asked again every ten minutes. Then the ping. */
|
|
1194
|
+
async function beat() {
|
|
1195
|
+
if (!checkingIn && checkInDue() && retryDue()) await checkIn();
|
|
1196
|
+
await ping();
|
|
608
1197
|
}
|
|
609
1198
|
|
|
610
1199
|
/** The heartbeat timer, rescheduled from itself so each beat carries fresh
|
|
@@ -613,7 +1202,8 @@ export function createReporter({
|
|
|
613
1202
|
* tally if it moved, so a deck that stops loses ten minutes of it at most. */
|
|
614
1203
|
function scheduleNextPing() {
|
|
615
1204
|
pingTimer = setTimeout(() => {
|
|
616
|
-
|
|
1205
|
+
samplePeak();
|
|
1206
|
+
void beat();
|
|
617
1207
|
void saveUsage();
|
|
618
1208
|
scheduleNextPing();
|
|
619
1209
|
}, PING_EVERY_MS + Math.floor(Math.random() * PING_JITTER_MS));
|
|
@@ -621,16 +1211,34 @@ export function createReporter({
|
|
|
621
1211
|
}
|
|
622
1212
|
|
|
623
1213
|
/** Check in once the prefs are read, and every few hours after; and beat a
|
|
624
|
-
* heartbeat on its own, lighter timer
|
|
625
|
-
*
|
|
626
|
-
*
|
|
1214
|
+
* heartbeat on its own, lighter timer, which also sends whatever a check-in
|
|
1215
|
+
* still owes (beat). Both timers never hold the process open. The first
|
|
1216
|
+
* check-in already puts a freshly launched deck online, so the first ping is
|
|
1217
|
+
* a full interval out — nothing pings at boot. */
|
|
627
1218
|
function start() {
|
|
628
|
-
|
|
1219
|
+
// start() runs as the deck starts listening (index.mjs), so this is the
|
|
1220
|
+
// launch: process start to ready. A second start() keeps the first answer.
|
|
1221
|
+
if (launchMs == null) {
|
|
1222
|
+
try {
|
|
1223
|
+
const ms = launchedIn();
|
|
1224
|
+
launchMs = Number.isFinite(ms) && ms >= 0 ? ms : null;
|
|
1225
|
+
} catch {
|
|
1226
|
+
launchMs = null;
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
samplePeak();
|
|
1230
|
+
const prefsIn = Promise.resolve(ready).catch(() => {});
|
|
1231
|
+
// The CLIs are asked their versions as soon as the prefs say reports are on —
|
|
1232
|
+
// past boot, beside the wait for the setup — so the first check-in, which
|
|
1233
|
+
// waits for the answer, is held up by little or nothing.
|
|
1234
|
+
prefsIn.then(() => { if (reportsOn(prefs.current(), env)) void probeVersions(); }).catch(() => {});
|
|
1235
|
+
Promise.all([prefsIn, Promise.resolve(setupKnown()).catch(() => {})])
|
|
629
1236
|
.then(restoreUsage)
|
|
1237
|
+
.then(restoreRating)
|
|
630
1238
|
.then(checkIn)
|
|
631
1239
|
.then(() => usage.onFirstUse?.(() => void onFirstSession()));
|
|
632
1240
|
if (!timer) {
|
|
633
|
-
timer = setInterval(() => void checkIn(), CHECK_IN_EVERY_MS);
|
|
1241
|
+
timer = setInterval(() => { if (retryDue()) void checkIn(); }, CHECK_IN_EVERY_MS);
|
|
634
1242
|
timer.unref?.();
|
|
635
1243
|
}
|
|
636
1244
|
if (!pingTimer) scheduleNextPing();
|
|
@@ -643,7 +1251,10 @@ export function createReporter({
|
|
|
643
1251
|
pingTimer = null;
|
|
644
1252
|
}
|
|
645
1253
|
|
|
646
|
-
return {
|
|
1254
|
+
return {
|
|
1255
|
+
checkIn, ping, setReports, reportError, restoreUsage, restoreRating, saveUsage, sendActivation, willReport,
|
|
1256
|
+
reportUninstall, noteSelfUpdate, ratingAsk, rate, rateLater, sendRating, start, stop,
|
|
1257
|
+
};
|
|
647
1258
|
}
|
|
648
1259
|
|
|
649
1260
|
// ── the deck's own providers ─────────────────────────────────────────────────
|
|
@@ -654,22 +1265,52 @@ export function createReporter({
|
|
|
654
1265
|
// `--version` that imports it for nothing. The day's usage tally is imported
|
|
655
1266
|
// directly: usage-day.mjs imports nothing.
|
|
656
1267
|
|
|
657
|
-
// The CLI versions, detected
|
|
658
|
-
//
|
|
659
|
-
//
|
|
660
|
-
//
|
|
1268
|
+
// The CLI versions, detected in the background, off the boot path: the reporter
|
|
1269
|
+
// runs this once the prefs say reports are on (never under the machine's veto),
|
|
1270
|
+
// and again on each new UTC day (probeVersions). The last answer is kept until
|
|
1271
|
+
// the next one lands, and a probe that comes back with less — a wedged shim, a
|
|
1272
|
+
// timeout — does not take a version it knew away.
|
|
661
1273
|
let cliVersionCache = {};
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
1274
|
+
async function probeCliVersions() {
|
|
1275
|
+
const { detectCliVersions } = await import("./cli-versions.mjs");
|
|
1276
|
+
cliVersionCache = { ...cliVersionCache, ...(await detectCliVersions()) };
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/**
|
|
1280
|
+
* Where the question's outcome is kept a second time: rating.json, beside
|
|
1281
|
+
* prefs.json. A 3.36.x deck shares that folder — the desktop app before it
|
|
1282
|
+
* updates, an `npx ccdeck@3.36.9` — and its normalise drops every key it does
|
|
1283
|
+
* not know the first time it writes prefs.json, report.rating among them. It
|
|
1284
|
+
* never writes this file. `dir` is the deck's data dir, read when it is used.
|
|
1285
|
+
* Only the install id and the question's state go in it; `write(null)` takes
|
|
1286
|
+
* it off the disk.
|
|
1287
|
+
*/
|
|
1288
|
+
export function ratingFile(dir) {
|
|
1289
|
+
const path = () => join(prefsDir(dir ?? deckDataDir()), "rating.json");
|
|
1290
|
+
return {
|
|
1291
|
+
async read() {
|
|
1292
|
+
try {
|
|
1293
|
+
return JSON.parse(stripBom(await readFile(path(), "utf8")));
|
|
1294
|
+
} catch {
|
|
1295
|
+
return null;
|
|
1296
|
+
}
|
|
1297
|
+
},
|
|
1298
|
+
async write(kept) {
|
|
1299
|
+
const at = path();
|
|
1300
|
+
if (!kept) {
|
|
1301
|
+
await unlink(at).catch(err => { if (err?.code !== "ENOENT") throw err; });
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
await mkdir(dirname(at), { recursive: true, mode: 0o700 });
|
|
1305
|
+
await writeFileAtomic(at, JSON.stringify(kept) + "\n");
|
|
1306
|
+
// The install id is kept as close as prefs.json keeps it.
|
|
1307
|
+
await chmod(at, 0o600).catch(() => {});
|
|
1308
|
+
},
|
|
1309
|
+
};
|
|
672
1310
|
}
|
|
673
1311
|
|
|
674
1312
|
/** The deck's own reporter. */
|
|
675
|
-
export const reporter = createReporter({
|
|
1313
|
+
export const reporter = createReporter({
|
|
1314
|
+
versions: () => cliVersionCache, probe: probeCliVersions, depth: deckDepth, npx: isNpxInstall(PKG_ROOT),
|
|
1315
|
+
ratingKept: ratingFile(),
|
|
1316
|
+
});
|