ccdeck 3.36.9 → 3.37.1
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/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 +41 -5
- package/bin/deck.js +50 -8
- package/dist/web/assets/BrowserWatchModal-njN0zg93.js +1 -0
- package/dist/web/assets/UsageHistoryModal-DYSZZZEz.js +1 -0
- package/dist/web/assets/index-B0SOa-XB.css +1 -0
- package/dist/web/assets/index-DDxnWaTa.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 +8 -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 +40 -21
- 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 +672 -64
- 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-BCrDvuug.js +0 -1
- package/dist/web/assets/UsageHistoryModal-BtRCuo33.js +0 -1
- package/dist/web/assets/index-BkXTN4dZ.css +0 -1
- package/dist/web/assets/index-tVBMXvDv.js +0 -306
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,17 +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),
|
|
20
29
|
// - an "uninstall" event when somebody runs `ccdeck --uninstall`, with the
|
|
21
30
|
// reason they picked from a short list if they picked one (bin/cli/leaving.js),
|
|
22
31
|
// - an "activated" event, once, when a new install's first session arrives:
|
|
23
32
|
// how long after the install it came, as a bucket ("5m", "1h", "1d", "7d",
|
|
24
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),
|
|
25
39
|
// - errors: a request handler that threw on this server, or an error the page
|
|
26
|
-
// caught, with
|
|
27
|
-
//
|
|
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),
|
|
28
44
|
// - a "ping" heartbeat, every ten minutes or so while the deck runs, which
|
|
29
|
-
// 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.
|
|
30
47
|
// It writes no event and carries the install id alone — no facts, no
|
|
31
48
|
// fingerprint.
|
|
32
49
|
//
|
|
@@ -63,24 +80,38 @@
|
|
|
63
80
|
//
|
|
64
81
|
// NOTHING HERE THROWS OR WAITS FOR ANYBODY. A report that cannot be sent is
|
|
65
82
|
// dropped: it is a nicety for the people who make ccdeck, and no part of the
|
|
66
|
-
// 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.
|
|
67
87
|
|
|
68
88
|
import { createHash, randomUUID } from "node:crypto";
|
|
89
|
+
import { chmod, mkdir, readFile, unlink } from "node:fs/promises";
|
|
69
90
|
import { arch as osArch, cpus as osCpus, homedir, hostname, platform as osPlatform, totalmem } from "node:os";
|
|
70
|
-
import { dirname, resolve } from "node:path";
|
|
71
|
-
import { fileURLToPath } from "node:url";
|
|
91
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
92
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
72
93
|
import { inApp } from "./app-host.mjs";
|
|
94
|
+
import { stripBom, writeFileAtomic } from "./atomic-write.mjs";
|
|
95
|
+
import { deckDataDir } from "./deck-home.mjs";
|
|
73
96
|
import { reportsVetoed } from "./deck-prefs.mjs";
|
|
74
|
-
import {
|
|
75
|
-
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";
|
|
76
100
|
import { RUNNING_VERSION } from "./running-version.mjs";
|
|
77
101
|
import { FEATURES, usageDay, utcDay } from "./usage-day.mjs";
|
|
78
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";
|
|
79
108
|
|
|
80
109
|
export const REPORTS_API = "https://api.ccdeck.dev";
|
|
81
110
|
const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
82
111
|
const TIMEOUT_MS = 6000;
|
|
83
|
-
/** 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. */
|
|
84
115
|
const CHECK_IN_EVERY_MS = 6 * 60 * 60 * 1000;
|
|
85
116
|
/** The heartbeat's beat: a lighter timer than the check-in that only moves the
|
|
86
117
|
* install's "last seen", so the admin can count who is online now. Ten minutes,
|
|
@@ -88,6 +119,12 @@ const CHECK_IN_EVERY_MS = 6 * 60 * 60 * 1000;
|
|
|
88
119
|
* not all beat on the same tick. */
|
|
89
120
|
const PING_EVERY_MS = 10 * 60 * 1000;
|
|
90
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;
|
|
91
128
|
/** Errors per hour this deck may send, and how long one error is not repeated. */
|
|
92
129
|
const ERRORS_PER_HOUR = 20;
|
|
93
130
|
const SAME_ERROR_QUIET_MS = 60 * 60 * 1000;
|
|
@@ -100,7 +137,8 @@ export const UNINSTALL_REASONS = Object.freeze(["not-useful", "too-noisy", "brok
|
|
|
100
137
|
|
|
101
138
|
const EMPTY_REPORT = Object.freeze({
|
|
102
139
|
installId: "", lastVersion: "", lastActiveDay: "", forget: "", usage: null,
|
|
103
|
-
installedAt: "", firstSessionAt: "", firstProvider: "", activationSent: false,
|
|
140
|
+
installedAt: "", firstSessionAt: "", firstProvider: "", activationSent: false, ref: "", selfUpdate: null,
|
|
141
|
+
rating: normaliseRating(null),
|
|
104
142
|
});
|
|
105
143
|
|
|
106
144
|
/** Is this deck sending reports right now? The switch, which is on unless
|
|
@@ -216,16 +254,38 @@ function intlLocale() {
|
|
|
216
254
|
}
|
|
217
255
|
|
|
218
256
|
/** The shell as a bare name — "zsh", "bash", "fish". `SHELL` names it on POSIX;
|
|
219
|
-
* on Windows there is no `SHELL` (unless a Git-Bash-style one set it), so
|
|
220
|
-
*
|
|
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). */
|
|
221
259
|
export function shellToken(env = process.env, platform = process.platform) {
|
|
222
260
|
const sh = env?.SHELL;
|
|
223
261
|
if (sh) return token(baseName(sh), TOKEN_CAPS.shell);
|
|
224
|
-
if (platform === "win32")
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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";
|
|
229
289
|
return undefined;
|
|
230
290
|
}
|
|
231
291
|
|
|
@@ -290,22 +350,132 @@ export function installFacts({
|
|
|
290
350
|
return facts;
|
|
291
351
|
}
|
|
292
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
|
+
|
|
293
434
|
/**
|
|
294
435
|
* Take out of an error what could say who someone is.
|
|
295
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
|
+
*
|
|
296
456
|
* The catch-all for long opaque strings deliberately stops at a slash: with one
|
|
297
457
|
* in it, the pattern swallowed every long file path, and a stack whose frames
|
|
298
458
|
* read `at save (~/.<secret>.mjs:40:3)` tells nobody where the bug is.
|
|
299
459
|
* The API scrubs again;
|
|
300
|
-
* this is the pass that means it never has to:
|
|
301
|
-
*
|
|
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.
|
|
302
462
|
*/
|
|
303
|
-
export function scrub(text, home = homedir()) {
|
|
463
|
+
export function scrub(text, home = homedir(), root = PKG_ROOT) {
|
|
304
464
|
let out = String(text ?? "");
|
|
305
|
-
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"), "~");
|
|
306
471
|
return out
|
|
307
472
|
.replace(/\/(?:home|Users)\/[^/\s:'"]+/g, "~")
|
|
308
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>")
|
|
309
479
|
.replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, "<email>")
|
|
310
480
|
.replace(
|
|
311
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,
|
|
@@ -324,10 +494,16 @@ export function scrub(text, home = homedir()) {
|
|
|
324
494
|
* @param {NodeJS.ProcessEnv} [deps.env]
|
|
325
495
|
* @param {ReturnType<typeof installFacts>} [deps.facts]
|
|
326
496
|
* @param {string} [deps.home]
|
|
497
|
+
* @param {string} [deps.root] the deck's own package, whose paths an error keeps (see scrub)
|
|
327
498
|
* @param {Promise<unknown>} [deps.ready] what start() waits for: the prefs read at import
|
|
328
499
|
* @param {() => { claudeVersion?: string, codexVersion?: string }} [deps.versions] the CLI
|
|
329
500
|
* versions to fold into the facts, read fresh on every send so a background
|
|
330
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.
|
|
331
507
|
* @param {ReturnType<typeof import("./usage-day.mjs").createUsageDay>} [deps.usage]
|
|
332
508
|
* the day's tally: the "active" report carries the last finished day's totals,
|
|
333
509
|
* read only when an "active" is about to be sent, and the tally is saved into
|
|
@@ -336,6 +512,27 @@ export function scrub(text, home = homedir()) {
|
|
|
336
512
|
* boot set up, folded into install, update and active (activation.mjs).
|
|
337
513
|
* @param {() => Promise<unknown>} [deps.setupKnown] what start() waits for besides the
|
|
338
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.
|
|
339
536
|
*/
|
|
340
537
|
export function createReporter({
|
|
341
538
|
fetchImpl = globalThis.fetch,
|
|
@@ -344,16 +541,28 @@ export function createReporter({
|
|
|
344
541
|
env = process.env,
|
|
345
542
|
facts = installFacts({ env }),
|
|
346
543
|
home = homedir(),
|
|
544
|
+
root = PKG_ROOT,
|
|
347
545
|
ready = prefsRead,
|
|
348
546
|
versions = () => ({}),
|
|
547
|
+
probe = async () => {},
|
|
349
548
|
usage = usageDay,
|
|
350
549
|
setup = setupFacts,
|
|
351
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,
|
|
352
559
|
} = {}) {
|
|
353
560
|
const errorsSent = [];
|
|
354
561
|
const lastSentAt = new Map();
|
|
355
562
|
let timer = null;
|
|
356
563
|
let pingTimer = null;
|
|
564
|
+
/** How long this run took to start, read when start() runs; null before. */
|
|
565
|
+
let launchMs = null;
|
|
357
566
|
|
|
358
567
|
/** The CLI versions to add to the facts right now, tokenised and omitted when
|
|
359
568
|
* not yet known — read on every send so the background probe's result appears
|
|
@@ -370,6 +579,41 @@ export function createReporter({
|
|
|
370
579
|
}
|
|
371
580
|
}
|
|
372
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
|
+
|
|
373
617
|
/** The full facts an event carries: the fixed install facts, plus whatever CLI
|
|
374
618
|
* versions are known this moment. */
|
|
375
619
|
function factsNow() {
|
|
@@ -390,6 +634,25 @@ export function createReporter({
|
|
|
390
634
|
}
|
|
391
635
|
}
|
|
392
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
|
+
|
|
393
656
|
/** The coarse usage the "active" report carries: the last finished day before
|
|
394
657
|
* `today`, its date as `usageDay` and its counts beside it. Nothing at all
|
|
395
658
|
* when there is no such day yet — a first launch has no yesterday — and only
|
|
@@ -406,12 +669,45 @@ export function createReporter({
|
|
|
406
669
|
// The features used that day, as names off the fixed list — an empty list
|
|
407
670
|
// is a real answer (the deck ran, nothing was opened), not a missing one.
|
|
408
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);
|
|
409
677
|
return out;
|
|
410
678
|
} catch {
|
|
411
679
|
return {};
|
|
412
680
|
}
|
|
413
681
|
}
|
|
414
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
|
+
|
|
415
711
|
/** The tally the last save wrote, so a save with nothing new writes nothing. */
|
|
416
712
|
let usageSaved = -1;
|
|
417
713
|
|
|
@@ -441,6 +737,9 @@ export function createReporter({
|
|
|
441
737
|
}
|
|
442
738
|
}
|
|
443
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. */
|
|
444
743
|
async function call(method, path, body) {
|
|
445
744
|
try {
|
|
446
745
|
const res = await fetchImpl(REPORTS_API + path, {
|
|
@@ -449,12 +748,94 @@ export function createReporter({
|
|
|
449
748
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
450
749
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
451
750
|
});
|
|
452
|
-
|
|
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";
|
|
453
769
|
} catch {
|
|
454
770
|
return false;
|
|
455
771
|
}
|
|
456
772
|
}
|
|
457
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
|
+
|
|
458
839
|
/** Change the reporter's state, but only while it still belongs to `installId`:
|
|
459
840
|
* a switch-off that landed in between has the last word. */
|
|
460
841
|
function remember(installId, change) {
|
|
@@ -463,14 +844,40 @@ export function createReporter({
|
|
|
463
844
|
);
|
|
464
845
|
}
|
|
465
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
|
+
|
|
466
851
|
/** Send whatever is due: a pending deletion, then install or update, then
|
|
467
852
|
* today's "active". Never throws: a floating rejection here would be an
|
|
468
853
|
* unhandled one, and Node's answer to those is to end the process. */
|
|
469
854
|
async function checkIn() {
|
|
855
|
+
checkingIn++;
|
|
470
856
|
try {
|
|
471
857
|
await sendWhatIsDue();
|
|
472
858
|
} catch {
|
|
473
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;
|
|
474
881
|
}
|
|
475
882
|
}
|
|
476
883
|
|
|
@@ -480,45 +887,72 @@ export function createReporter({
|
|
|
480
887
|
if (reportsVetoed(env)) return;
|
|
481
888
|
const p = prefs.current();
|
|
482
889
|
const { forget } = p.report ?? EMPTY_REPORT;
|
|
483
|
-
|
|
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") {
|
|
484
893
|
await prefs.update(prev => (prev.report.forget === forget ? { report: { ...prev.report, forget: "" } } : undefined));
|
|
485
894
|
}
|
|
486
895
|
if (!reportsOn(prefs.current(), env)) return;
|
|
487
896
|
|
|
488
897
|
// The id is made here, the first time there is something to send under it.
|
|
489
898
|
if (!prefs.current().report.installId) {
|
|
899
|
+
const fresh = isFirstRun();
|
|
490
900
|
await prefs.update(prev =>
|
|
491
901
|
prev.reports === false || prev.report.installId
|
|
492
902
|
? undefined
|
|
493
|
-
//
|
|
494
|
-
// session is measured against. Kept here, never sent as
|
|
495
|
-
|
|
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() : "" } },
|
|
496
909
|
);
|
|
497
910
|
}
|
|
498
911
|
const { installId, lastVersion, lastActiveDay } = prefs.current().report;
|
|
499
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();
|
|
500
918
|
if (!lastVersion) {
|
|
501
|
-
|
|
502
|
-
|
|
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 });
|
|
503
930
|
}
|
|
504
931
|
} else if (lastVersion !== facts.version) {
|
|
505
|
-
|
|
506
|
-
|
|
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 });
|
|
507
940
|
}
|
|
508
941
|
}
|
|
509
942
|
|
|
510
|
-
const today = utcDay(now());
|
|
511
943
|
if (lastActiveDay !== today) {
|
|
512
944
|
// Usage rides on the "active" event alone, and is read only here — the one
|
|
513
945
|
// report it belongs to — so a checkIn that sends nothing new never asks the
|
|
514
946
|
// deck for its counts.
|
|
515
947
|
const used = coarseUsage(today);
|
|
516
|
-
|
|
948
|
+
const held = await depthNow();
|
|
949
|
+
if (settled(await call("POST", "/v1/app/events", { installId, kind: "active", ...factsNow(), ...setupNow(), ...used, ...held }))) {
|
|
517
950
|
if (used.usageDay) usage.markSent(used.usageDay);
|
|
518
951
|
await remember(installId, { lastActiveDay: today });
|
|
519
952
|
}
|
|
520
953
|
}
|
|
521
954
|
await sendActivation();
|
|
955
|
+
await sendRating();
|
|
522
956
|
await saveUsage();
|
|
523
957
|
}
|
|
524
958
|
|
|
@@ -527,7 +961,8 @@ export function createReporter({
|
|
|
527
961
|
* after the install it came, in a bucket, and which CLI it came from.
|
|
528
962
|
*
|
|
529
963
|
* Only for an install whose start this deck saw — `installedAt` is made with
|
|
530
|
-
* 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
|
|
531
966
|
* measured: its first session was long ago, and "now" would be a lie. The time
|
|
532
967
|
* is taken from the first use this run heard, so a session that arrived before
|
|
533
968
|
* the first check-in had an id is still dated when it happened. A send that
|
|
@@ -546,7 +981,113 @@ export function createReporter({
|
|
|
546
981
|
const body = { installId: r.installId, kind: "activated", ...factsNow(), ...setupNow() };
|
|
547
982
|
addIf(body, "firstSession", sinceInstallBucket(r.installedAt, r.firstSessionAt));
|
|
548
983
|
addIf(body, "firstProvider", r.firstProvider === "codex" || r.firstProvider === "claude" ? r.firstProvider : undefined);
|
|
549
|
-
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
|
+
}
|
|
550
1091
|
}
|
|
551
1092
|
|
|
552
1093
|
/** Whether a report would go out right now: reports on, the machine not
|
|
@@ -570,7 +1111,7 @@ export function createReporter({
|
|
|
570
1111
|
if (!(await willReport())) return false;
|
|
571
1112
|
const body = { installId: prefs.current().report.installId, kind: "uninstall", ...factsNow() };
|
|
572
1113
|
addIf(body, "reason", UNINSTALL_REASONS.includes(reason) ? reason : undefined);
|
|
573
|
-
return await call("POST", "/v1/app/events", body);
|
|
1114
|
+
return (await call("POST", "/v1/app/events", body)) === "sent";
|
|
574
1115
|
} catch {
|
|
575
1116
|
return false;
|
|
576
1117
|
}
|
|
@@ -592,11 +1133,13 @@ export function createReporter({
|
|
|
592
1133
|
* dropped if it cannot get through, and it never throws. No id yet means the
|
|
593
1134
|
* first check-in has not run, so there is nothing to be online under and
|
|
594
1135
|
* nothing is sent; the check-in that makes the id is what puts this deck
|
|
595
|
-
* 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. */
|
|
596
1139
|
async function ping() {
|
|
597
1140
|
try {
|
|
598
1141
|
const p = prefs.current();
|
|
599
|
-
if (!reportsOn(p, env) || !p.report.installId) return;
|
|
1142
|
+
if (!reportsOn(p, env) || !p.report.installId || !askedWaitOver()) return;
|
|
600
1143
|
await call("POST", "/v1/app/ping", { installId: p.report.installId });
|
|
601
1144
|
} catch {
|
|
602
1145
|
// The lightest of the niceties: a heartbeat that could not be sent is gone.
|
|
@@ -614,6 +1157,7 @@ export function createReporter({
|
|
|
614
1157
|
reports: false,
|
|
615
1158
|
report: { ...EMPTY_REPORT, forget: prev.report.installId || prev.report.forget },
|
|
616
1159
|
}));
|
|
1160
|
+
await keepRating();
|
|
617
1161
|
await checkIn();
|
|
618
1162
|
}
|
|
619
1163
|
|
|
@@ -625,7 +1169,7 @@ export function createReporter({
|
|
|
625
1169
|
async function reportError(where, error) {
|
|
626
1170
|
const p = prefs.current();
|
|
627
1171
|
if (!reportsOn(p, env) || !p.report.installId) return false;
|
|
628
|
-
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();
|
|
629
1173
|
if (!message) return false;
|
|
630
1174
|
const at = now().getTime();
|
|
631
1175
|
while (errorsSent.length && at - errorsSent[0] > SAME_ERROR_QUIET_MS) errorsSent.shift();
|
|
@@ -633,11 +1177,23 @@ export function createReporter({
|
|
|
633
1177
|
if (errorsSent.length >= ERRORS_PER_HOUR || at - (lastSentAt.get(key) ?? -Infinity) < SAME_ERROR_QUIET_MS) return false;
|
|
634
1178
|
errorsSent.push(at);
|
|
635
1179
|
lastSentAt.set(key, at);
|
|
636
|
-
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;
|
|
637
1181
|
// Less the runtime, and less the deviceId — an error is not one of the events
|
|
638
1182
|
// the fingerprint rides on, so the personal field never leaves on one.
|
|
639
1183
|
const { runtime: _runtime, deviceId: _deviceId, ...fields } = factsNow();
|
|
640
|
-
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();
|
|
641
1197
|
}
|
|
642
1198
|
|
|
643
1199
|
/** The heartbeat timer, rescheduled from itself so each beat carries fresh
|
|
@@ -646,7 +1202,8 @@ export function createReporter({
|
|
|
646
1202
|
* tally if it moved, so a deck that stops loses ten minutes of it at most. */
|
|
647
1203
|
function scheduleNextPing() {
|
|
648
1204
|
pingTimer = setTimeout(() => {
|
|
649
|
-
|
|
1205
|
+
samplePeak();
|
|
1206
|
+
void beat();
|
|
650
1207
|
void saveUsage();
|
|
651
1208
|
scheduleNextPing();
|
|
652
1209
|
}, PING_EVERY_MS + Math.floor(Math.random() * PING_JITTER_MS));
|
|
@@ -654,16 +1211,34 @@ export function createReporter({
|
|
|
654
1211
|
}
|
|
655
1212
|
|
|
656
1213
|
/** Check in once the prefs are read, and every few hours after; and beat a
|
|
657
|
-
* heartbeat on its own, lighter timer
|
|
658
|
-
*
|
|
659
|
-
*
|
|
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. */
|
|
660
1218
|
function start() {
|
|
661
|
-
|
|
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(() => {})])
|
|
662
1236
|
.then(restoreUsage)
|
|
1237
|
+
.then(restoreRating)
|
|
663
1238
|
.then(checkIn)
|
|
664
1239
|
.then(() => usage.onFirstUse?.(() => void onFirstSession()));
|
|
665
1240
|
if (!timer) {
|
|
666
|
-
timer = setInterval(() => void checkIn(), CHECK_IN_EVERY_MS);
|
|
1241
|
+
timer = setInterval(() => { if (retryDue()) void checkIn(); }, CHECK_IN_EVERY_MS);
|
|
667
1242
|
timer.unref?.();
|
|
668
1243
|
}
|
|
669
1244
|
if (!pingTimer) scheduleNextPing();
|
|
@@ -676,7 +1251,10 @@ export function createReporter({
|
|
|
676
1251
|
pingTimer = null;
|
|
677
1252
|
}
|
|
678
1253
|
|
|
679
|
-
return {
|
|
1254
|
+
return {
|
|
1255
|
+
checkIn, ping, setReports, reportError, restoreUsage, restoreRating, saveUsage, sendActivation, willReport,
|
|
1256
|
+
reportUninstall, noteSelfUpdate, ratingAsk, rate, rateLater, sendRating, start, stop,
|
|
1257
|
+
};
|
|
680
1258
|
}
|
|
681
1259
|
|
|
682
1260
|
// ── the deck's own providers ─────────────────────────────────────────────────
|
|
@@ -687,22 +1265,52 @@ export function createReporter({
|
|
|
687
1265
|
// `--version` that imports it for nothing. The day's usage tally is imported
|
|
688
1266
|
// directly: usage-day.mjs imports nothing.
|
|
689
1267
|
|
|
690
|
-
// The CLI versions, detected
|
|
691
|
-
//
|
|
692
|
-
//
|
|
693
|
-
//
|
|
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.
|
|
694
1273
|
let cliVersionCache = {};
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
+
};
|
|
705
1310
|
}
|
|
706
1311
|
|
|
707
1312
|
/** The deck's own reporter. */
|
|
708
|
-
export const reporter = createReporter({
|
|
1313
|
+
export const reporter = createReporter({
|
|
1314
|
+
versions: () => cliVersionCache, probe: probeCliVersions, depth: deckDepth, npx: isNpxInstall(PKG_ROOT),
|
|
1315
|
+
ratingKept: ratingFile(),
|
|
1316
|
+
});
|