@bli-cockpit/cli 0.2.47 → 0.2.48

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.
@@ -25,6 +25,7 @@ import os from "node:os";
25
25
  import path from "node:path";
26
26
  import { COLLECTOR_HEARTBEAT_SCHEMA_VERSION, } from "@bli-cockpit/telemetry-core";
27
27
  import { describeError } from "../health-detail.js";
28
+ import { shouldSuppressFleetReceipts, } from "../dev-build.js";
28
29
  import { getCollectorRuntimePaths, readLocalCollectorSessionFile, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
29
30
  const HEARTBEAT_TIMEOUT_MS = 5_000;
30
31
  /**
@@ -112,6 +113,23 @@ export function buildCollectorHeartbeat(options) {
112
113
  * `sync.err.log` alone when the dashboard says a device is quiet.
113
114
  */
114
115
  export async function sendCollectorHeartbeatBestEffort(options) {
116
+ // BLI-3554. A checkout's heartbeat would move `last_seen_at` on a real fleet
117
+ // device row, so an agent's worktree could keep a genuinely dead machine
118
+ // looking alive to the BLI-3550 reader.
119
+ const suppression = shouldSuppressFleetReceipts({
120
+ dashboardUrl: options.dashboardUrl,
121
+ ...(options.devBuildProbe ? { probe: options.devBuildProbe } : {}),
122
+ });
123
+ if (suppression.suppressed) {
124
+ console.error("[heartbeat] dev build; this tick is not checking in with the fleet", JSON.stringify({
125
+ reason: "receipt_suppressed:dev_build",
126
+ detected_by: suppression.reason,
127
+ root_count: options.roots.length,
128
+ sync_status: options.facts.status,
129
+ fix: "set COCKPIT_DEV=0 to heartbeat from a checkout deliberately",
130
+ }));
131
+ return false;
132
+ }
115
133
  const paths = getCollectorRuntimePaths(options.homeDir);
116
134
  const session = await readLocalCollectorSessionFile(paths).catch(() => null);
117
135
  if (!session ||
@@ -14,6 +14,7 @@ import { errorMessage, writeLine } from "./cli-io.js";
14
14
  import { describeError, maskLocalIdentifiers, redactedHealthDetail, } from "../health-detail.js";
15
15
  import { getCollectorRuntimePaths, readLocalCollectorSessionFile, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
16
16
  import { redactSecretLikeContent } from "@bli-cockpit/telemetry-core";
17
+ import { shouldSuppressFleetReceipts, } from "../dev-build.js";
17
18
  import { enqueueInstallEventEntry, readPendingInstallEventEntries, recordInstallEventAttemptFailure, removeInstallEventEntry, } from "../spool/install-event-outbox.js";
18
19
  export function addInstallEvent(events, step, status, errorCode,
19
20
  // BLI-2542: the bucket alone cannot be acted on. Callers that hold the reason
@@ -47,6 +48,25 @@ export function sanitizeInstallErrorCode(value) {
47
48
  export async function reportInstallEventsBestEffort(options) {
48
49
  if (options.events.length === 0)
49
50
  return null;
51
+ // BLI-3554. Withheld BEFORE the outbox, not before the POST: an entry queued
52
+ // by a checkout survives in `~/.cockpit` and the next real scheduled tick
53
+ // would deliver it under the workspace version, which is exactly how 47
54
+ // sandbox receipts reached the production fleet table.
55
+ const suppression = shouldSuppressFleetReceipts({
56
+ dashboardUrl: options.dashboardUrl,
57
+ ...(options.devBuildProbe ? { probe: options.devBuildProbe } : {}),
58
+ });
59
+ if (suppression.suppressed) {
60
+ console.error("[install-receipts] dev build; install receipts withheld from the fleet", JSON.stringify({
61
+ reason: "receipt_suppressed:dev_build",
62
+ detected_by: suppression.reason,
63
+ command: options.command,
64
+ event_count: options.events.length,
65
+ cli_version: LOCAL_COLLECTOR_VERSION,
66
+ fix: "set COCKPIT_DEV=0 to post receipts from a checkout deliberately",
67
+ }));
68
+ return null;
69
+ }
50
70
  const paths = getCollectorRuntimePaths(options.homeDir);
51
71
  try {
52
72
  await enqueueInstallEventEntry(paths, {
@@ -98,6 +118,7 @@ export async function reportInstallEventsBestEffort(options) {
98
118
  }
99
119
  const pending = (await readPendingInstallEventEntries(paths)).slice(0, 20);
100
120
  const failures = [];
121
+ let delivered = 0;
101
122
  let observedMinCliVersion = null;
102
123
  for (let offset = 0; offset < pending.length; offset += 5) {
103
124
  await Promise.all(pending.slice(offset, offset + 5).map(async (entry) => {
@@ -138,6 +159,7 @@ export async function reportInstallEventsBestEffort(options) {
138
159
  observedMinCliVersion = receipt.min_cli_version.trim();
139
160
  }
140
161
  await removeInstallEventEntry(paths, entry.outbox_id);
162
+ delivered += 1;
141
163
  }
142
164
  catch (error) {
143
165
  const failureReason = classifyInstallTelemetryError(error);
@@ -170,6 +192,18 @@ export async function reportInstallEventsBestEffort(options) {
170
192
  }
171
193
  }));
172
194
  }
195
+ if (delivered > 0) {
196
+ // The success branch says so too (BLI-3554 / the logging contract): a log
197
+ // that only fires on failure cannot answer "did any receipt land at all
198
+ // today?", which is the question a suppressed dev build now provokes.
199
+ console.error("[install-receipts] install events delivered", JSON.stringify({
200
+ reason: "install_events_delivered",
201
+ command: options.command,
202
+ delivered_entries: delivered,
203
+ kept_for_retry: pending.length - delivered,
204
+ cli_version: LOCAL_COLLECTOR_VERSION,
205
+ }));
206
+ }
173
207
  if (options.json && failures.length > 0) {
174
208
  writeLine(options.io.stderr, `Install event telemetry queued for retry: ${[...new Set(failures)].join(",")}`);
175
209
  }
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
15
15
  }
16
16
 
17
17
  if (command === "--version" || command === "-V" || command === "version") {
18
- writeLine(io?.stdout ?? process.stdout, "0.2.47");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.48");
19
19
  return 0;
20
20
  }
21
21
 
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Is this collector a DEV BUILD? One predicate, two callers.
3
+ *
4
+ * BLI-3554. Edward's device row carried 47 `npm_install_eacces` receipts
5
+ * stamped `cli_version` 0.1.50 between 2026-09-02T16:55Z and 2026-09-03T06:25Z.
6
+ * 0.1.50 was never a published install on that machine — it was the workspace
7
+ * version in `packages/cockpit-local-collector/package.json` at the time, so
8
+ * every one of those rows came from an agent or a test running the built
9
+ * collector inside a worktree. They land in the same production
10
+ * `cockpit_install_events` table the fleet reader (BLI-3550,
11
+ * `apps/dashboard/src/lib/ops/fleet-liveness.ts`) alarms on, so a developer's
12
+ * sandbox failure reads as a fleet machine in trouble.
13
+ *
14
+ * The predicate is deliberately NOT a version comparison. A version equal to
15
+ * the workspace version is a symptom: it changes on every release, it is equal
16
+ * for the one machine that legitimately runs the newest published build the
17
+ * day it ships, and it says nothing about where the code was loaded from.
18
+ * What we can actually observe is the FILE the running code was loaded from:
19
+ *
20
+ * - a published install lives under a `node_modules/` prefix
21
+ * (`/opt/homebrew/lib/node_modules/@bli-cockpit/cli/dist/…`,
22
+ * `C:\\Users\\x\\AppData\\Roaming\\npm\\node_modules\\@bli-cockpit\\cli\\dist\\…`),
23
+ * and that is the whole fleet;
24
+ * - a dev build lives inside a checkout of this repo — a
25
+ * `packages/cockpit-local-collector/` (or `packages/cockpit-cli/`) ancestor
26
+ * whose `package.json` names the workspace, with a `.git` marker or a
27
+ * `.claude/worktrees` segment above it.
28
+ *
29
+ * `COCKPIT_DEV=1` forces dev. `COCKPIT_DEV=0` forces the opposite — that is the
30
+ * escape hatch for a deliberate live test from a checkout, documented in
31
+ * `docs/runbooks/cockpit-collector-receipts.md`.
32
+ *
33
+ * Suppression only bites when the target is a PRODUCTION dashboard. Pointing a
34
+ * checkout at `http://localhost:3000` is how the receipt path itself is
35
+ * developed, and refusing to post there would make this predicate the reason
36
+ * the next receipt bug cannot be reproduced.
37
+ */
38
+ import fs from "node:fs";
39
+ import path from "node:path";
40
+ import { fileURLToPath } from "node:url";
41
+ /** The workspaces whose `dist/` a developer or agent runs from a checkout. */
42
+ const WORKSPACE_PACKAGE_NAMES = new Set([
43
+ "@bli-cockpit/local-collector",
44
+ "@bli-cockpit/cli",
45
+ ]);
46
+ const TRUE_VALUES = new Set(["1", "true", "yes", "on"]);
47
+ const FALSE_VALUES = new Set(["0", "false", "no", "off"]);
48
+ /**
49
+ * Walks up from the running module. Pure apart from the two injected readers,
50
+ * so a Windows layout can be asserted from a macOS test run.
51
+ */
52
+ export function detectDevBuild(probe = {}) {
53
+ const env = probe.env ?? process.env;
54
+ const rawFlag = (env.COCKPIT_DEV ?? "").trim().toLowerCase();
55
+ if (TRUE_VALUES.has(rawFlag)) {
56
+ return { devBuild: true, reason: "cockpit_dev_env" };
57
+ }
58
+ if (FALSE_VALUES.has(rawFlag)) {
59
+ // Deliberate live test from a checkout: the operator has said so out loud,
60
+ // and the path evidence below is not allowed to overrule them.
61
+ return { devBuild: false, reason: "cockpit_dev_env_disabled" };
62
+ }
63
+ const pathApi = probe.pathApi ?? path;
64
+ const modulePath = probe.modulePath ?? currentModulePath();
65
+ if (!modulePath)
66
+ return { devBuild: false, reason: "not_a_checkout" };
67
+ if (hasNodeModulesSegment(modulePath)) {
68
+ // Every published install is under a `node_modules/` prefix, on both host
69
+ // families. This branch is the whole fleet, and it is checked first so a
70
+ // machine that happens to have a repo checkout somewhere above its global
71
+ // npm prefix cannot be mislabelled.
72
+ return { devBuild: false, reason: "published_install" };
73
+ }
74
+ const readPackageName = probe.readPackageName ?? defaultReadPackageName;
75
+ const pathExists = probe.pathExists ?? defaultPathExists;
76
+ const packageRoot = findWorkspacePackageRoot(pathApi.dirname(modulePath), pathApi, readPackageName);
77
+ if (!packageRoot)
78
+ return { devBuild: false, reason: "not_a_checkout" };
79
+ if (!hasCheckoutMarkerAbove(packageRoot, pathApi, pathExists)) {
80
+ return { devBuild: false, reason: "not_a_checkout" };
81
+ }
82
+ return { devBuild: true, reason: "workspace_checkout" };
83
+ }
84
+ /**
85
+ * Should this process withhold fleet receipts (install events, heartbeat)?
86
+ *
87
+ * Two facts, in this order: is the code a dev build, and is the target the
88
+ * production fleet. A dev build talking to `localhost` still posts, because
89
+ * that is the only way to exercise the receipt path at all.
90
+ */
91
+ export function shouldSuppressFleetReceipts(options) {
92
+ const verdict = detectDevBuild(options.probe ?? {});
93
+ if (!verdict.devBuild)
94
+ return { suppressed: false, reason: verdict.reason };
95
+ if (isLocalDashboardUrl(options.dashboardUrl)) {
96
+ return { suppressed: false, reason: "local_dashboard" };
97
+ }
98
+ return { suppressed: true, reason: verdict.reason };
99
+ }
100
+ /** A dashboard on this machine — safe for a checkout to post at. */
101
+ export function isLocalDashboardUrl(dashboardUrl) {
102
+ let hostname;
103
+ try {
104
+ hostname = new URL(dashboardUrl).hostname.toLowerCase();
105
+ }
106
+ catch {
107
+ // An unparseable URL is not demonstrably local, and this predicate only
108
+ // ever widens suppression when it says false.
109
+ return false;
110
+ }
111
+ const bare = hostname.replace(/^\[|\]$/gu, "");
112
+ return (bare === "localhost" ||
113
+ bare === "127.0.0.1" ||
114
+ bare === "::1" ||
115
+ bare === "0.0.0.0" ||
116
+ bare.endsWith(".localhost"));
117
+ }
118
+ function currentModulePath() {
119
+ try {
120
+ return fileURLToPath(import.meta.url);
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ }
126
+ /**
127
+ * Case-insensitive on purpose: Windows spells the same folder
128
+ * `Node_Modules` without complaint, and both separators appear on that host
129
+ * because a POSIX-style path survives most Node APIs there.
130
+ */
131
+ function hasNodeModulesSegment(modulePath) {
132
+ return modulePath
133
+ .split(/[\\/]+/u)
134
+ .some((segment) => segment.toLowerCase() === "node_modules");
135
+ }
136
+ function findWorkspacePackageRoot(startDir, pathApi, readPackageName) {
137
+ let dir = startDir;
138
+ // Bounded: `path.dirname` reaches a fixed point at `/` or `C:\`, and the
139
+ // depth guard keeps a pathological symlink loop from spinning a sync walk.
140
+ for (let depth = 0; depth < 64; depth += 1) {
141
+ const name = readPackageName(pathApi.join(dir, "package.json"));
142
+ if (name && WORKSPACE_PACKAGE_NAMES.has(name))
143
+ return dir;
144
+ const parent = pathApi.dirname(dir);
145
+ if (parent === dir)
146
+ return null;
147
+ dir = parent;
148
+ }
149
+ return null;
150
+ }
151
+ /**
152
+ * A repo checkout marker at or above the workspace package: `.git` (a
153
+ * directory in a normal clone, a FILE in a `git worktree`), or the
154
+ * `.claude/worktrees` staging area agents run from.
155
+ */
156
+ function hasCheckoutMarkerAbove(packageRoot, pathApi, pathExists) {
157
+ let dir = packageRoot;
158
+ for (let depth = 0; depth < 64; depth += 1) {
159
+ if (pathExists(pathApi.join(dir, ".git")))
160
+ return true;
161
+ if (pathExists(pathApi.join(dir, ".claude", "worktrees")))
162
+ return true;
163
+ const parent = pathApi.dirname(dir);
164
+ if (parent === dir)
165
+ return false;
166
+ dir = parent;
167
+ }
168
+ return false;
169
+ }
170
+ function defaultReadPackageName(packageJsonPath) {
171
+ try {
172
+ const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
173
+ return typeof parsed.name === "string" ? parsed.name : null;
174
+ }
175
+ catch {
176
+ return null;
177
+ }
178
+ }
179
+ function defaultPathExists(candidate) {
180
+ try {
181
+ return fs.existsSync(candidate);
182
+ }
183
+ catch {
184
+ return false;
185
+ }
186
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.47",
3
+ "version": "0.2.48",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,7 @@
24
24
  "pretypecheck": "npm run build",
25
25
  "typecheck": "node -e \"await import('./dist/commands/public-root.js')\"",
26
26
  "pretest": "npm run build",
27
- "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
27
+ "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
30
  "@bli-cockpit/telemetry-core": "0.1.26"