@bli-cockpit/cli 0.2.68 → 0.2.69

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.
@@ -81,8 +81,22 @@ export async function writeDarwinAutostartPlist(options) {
81
81
  */
82
82
  export async function darwinAgentProblems(options, settings) {
83
83
  const homeDir = options.homeDir ?? os.homedir();
84
- const nodeExecutable = (await schedulerNodeExecutable(options, "darwin")).path;
85
84
  const plist = await readFile(plistPathFor(homeDir), "utf8").catch(() => "");
85
+ if (!plist)
86
+ return ["plist could not be read"];
87
+ // A caller that named no roots gave us nothing to compare against:
88
+ // `resolveAutostartSettings` fills `work_dirs` from `process.cwd()`, so the
89
+ // comparison below would reject a perfectly good plist for naming a
90
+ // workspace nobody asked us to expect (BLI-3793 — that is how the setup
91
+ // receipt printed `autostart_not_loaded` beside a green doctor row). The
92
+ // Windows arm has refused this comparison since it was written
93
+ // (`syncScriptProblem`); macOS refuses it now too. Whether launchd HAS the
94
+ // agent is still answered — by `launchctl list`, in the sibling module.
95
+ if (!options.repoRoots || options.repoRoots.length === 0) {
96
+ console.error("[autostart] registration not compared against expectations", JSON.stringify({ reason: "no_roots_supplied", label: AUTOSTART_LABEL }));
97
+ return [];
98
+ }
99
+ const nodeExecutable = (await schedulerNodeExecutable(options, "darwin")).path;
86
100
  return darwinAgentRegistrationProblems(plist, {
87
101
  discoveryArgs: await savedDiscoveryLimitArgs(homeDir),
88
102
  workDirs: settings.work_dirs,
@@ -0,0 +1,173 @@
1
+ import { autostartStatus, registeredRuntimePathProblems, } from "../autostart.js";
2
+ import { redactedHealthDetail } from "../health-detail.js";
3
+ import { getCollectorRuntimePaths, readLocalCollectorConfig, } from "../local-state.js";
4
+ import { normalizeCollectionRoots } from "../root-normalization.js";
5
+ /**
6
+ * "Is background collection actually registered and running on this host?" —
7
+ * asked ONCE, here (BLI-3793).
8
+ *
9
+ * Two surfaces print that answer: doctor's `autostart-alive` row and the setup
10
+ * receipt's `collector autostart` word. Until this module they asked the same
11
+ * function DIFFERENTLY, and so printed opposite answers in one run:
12
+ *
13
+ * ✅ autostart-alive background sync is running
14
+ * collector autostart ✗ (autostart_not_loaded) — Run `cockpit autostart install`.
15
+ *
16
+ * The doctor row handed `autostartStatus` the machine's saved collection
17
+ * roots; the receipt handed it `{homeDir, exec}` and nothing else. With no
18
+ * roots, `resolveAutostartSettings` fills `work_dirs` from `process.cwd()`,
19
+ * the launchd read-back compares the plist against a registration nobody ever
20
+ * asked for, the comparison fails, and a perfectly loaded agent reads
21
+ * `not_loaded`. The receipt therefore said "✓" only when the operator happened
22
+ * to be standing in their own collection root. Proven on the reference Mac:
23
+ * the same plist read `loaded` with roots passed and `not_loaded` without,
24
+ * flipping back to `loaded` when the process chdir'd into the saved root.
25
+ *
26
+ * So: one reader, one state, ONE reason label. `autostartDoctorRow` and
27
+ * `autostartSetupPiece` below only choose words for a state this file already
28
+ * decided — they never re-ask the host. Both host families go through the same
29
+ * `autostartStatus` front door, so the Windows probe keeps its own
30
+ * implementation behind this same contract.
31
+ */
32
+ const TAG = "[autostart reading]";
33
+ /**
34
+ * The machine's approved collection roots as the SCHEDULER should have them:
35
+ * an explicit `--workspace` when one was given, otherwise what the config on
36
+ * this host saved. Honours `homeDir` so a `--home` run reads the same machine
37
+ * its receipt reads.
38
+ */
39
+ export async function autostartRegistrationRoots(homeDir, repoRoot) {
40
+ if (repoRoot)
41
+ return [repoRoot];
42
+ const config = await readLocalCollectorConfig(getCollectorRuntimePaths(homeDir)).catch(() => null);
43
+ return normalizeCollectionRoots(config?.default_repo_paths ?? []);
44
+ }
45
+ /** The ONE read of the host's scheduler. Never throws; an unaskable host is
46
+ * `unreadable` with a reason, never "absent" (BLI-2541: never report a state
47
+ * you did not observe). */
48
+ export async function readAutostartRegistration(options) {
49
+ const exec = options.exec;
50
+ if (!exec) {
51
+ return {
52
+ state: "unreadable",
53
+ reason: "autostart_runner_unavailable",
54
+ message: "the operating-system scheduler could not be asked: no process runner on this run",
55
+ roots: [],
56
+ };
57
+ }
58
+ const roots = await autostartRegistrationRoots(options.homeDir, options.repoRoot).catch(() => []);
59
+ const result = await autostartStatus({
60
+ exec,
61
+ repoRoots: roots,
62
+ ...(roots[0] ? { repoRoot: roots[0] } : {}),
63
+ ...(options.homeDir ? { homeDir: options.homeDir } : {}),
64
+ ...(options.dashboardUrl ? { dashboardUrl: options.dashboardUrl } : {}),
65
+ ...(options.platform ? { platform: options.platform } : {}),
66
+ }).catch((error) => {
67
+ console.error(`${TAG} scheduler probe threw`, JSON.stringify({
68
+ reason: "autostart_probe_failed",
69
+ error_name: error instanceof Error ? error.name : typeof error,
70
+ }));
71
+ return null;
72
+ });
73
+ const reading = await stateFor(result, roots, options);
74
+ console.error(`${TAG} read back`, JSON.stringify({
75
+ reason: reading.reason,
76
+ state: reading.state,
77
+ root_count: reading.roots.length,
78
+ platform: options.platform ?? process.platform,
79
+ }));
80
+ return reading;
81
+ }
82
+ async function stateFor(result, roots, options) {
83
+ if (!result) {
84
+ return {
85
+ state: "unreadable",
86
+ reason: "autostart_probe_failed",
87
+ message: "the operating-system scheduler could not be read this run",
88
+ roots,
89
+ };
90
+ }
91
+ const detail = result.message
92
+ ? { detail: redactedHealthDetail(result.message) }
93
+ : {};
94
+ if (result.status === "unsupported") {
95
+ return {
96
+ state: "unsupported",
97
+ reason: "platform_unsupported",
98
+ message: result.message ?? "autostart is not supported on this host",
99
+ roots,
100
+ };
101
+ }
102
+ if (result.status === "absent" || result.status === "uninstalled") {
103
+ return {
104
+ state: "absent",
105
+ reason: "autostart_absent",
106
+ message: "background sync is not installed on this machine",
107
+ roots,
108
+ ...detail,
109
+ };
110
+ }
111
+ if (result.status === "not_loaded") {
112
+ return {
113
+ state: "not_loaded",
114
+ reason: "autostart_not_loaded",
115
+ message: "background sync is not running",
116
+ roots,
117
+ ...detail,
118
+ };
119
+ }
120
+ // BLI-3553: "loaded" only means the scheduler accepted the registration. It
121
+ // says nothing about whether the binary that registration names still exists
122
+ // — and `brew upgrade node` deletes exactly that. Ask the filesystem about
123
+ // the paths the PLATFORM holds, not the ones this process runs under.
124
+ const missing = options.exec
125
+ ? await registeredRuntimePathProblems({
126
+ exec: options.exec,
127
+ ...(options.homeDir ? { homeDir: options.homeDir } : {}),
128
+ ...(options.platform ? { platform: options.platform } : {}),
129
+ }).catch(() => [])
130
+ : [];
131
+ if (missing.length > 0) {
132
+ return {
133
+ state: "runtime_path_missing",
134
+ reason: "autostart_runtime_path_missing",
135
+ message: `background sync is registered but cannot run: ${missing.join("; ")}`,
136
+ roots,
137
+ };
138
+ }
139
+ return {
140
+ state: "loaded",
141
+ reason: "autostart_loaded",
142
+ message: "background sync is running",
143
+ roots,
144
+ };
145
+ }
146
+ /**
147
+ * The setup receipt's `collector autostart` word (BLI-3731).
148
+ *
149
+ * `ok` only when the host says it is registered AND loaded AND the runtime it
150
+ * names still exists. A plist nobody loaded runs nothing, which is exactly the
151
+ * failure that looks fine from the outside.
152
+ */
153
+ export function autostartSetupPiece(reading) {
154
+ switch (reading.state) {
155
+ case "loaded":
156
+ return { status: "ok", reason: reading.reason };
157
+ case "unsupported":
158
+ return { status: "skipped", reason: reading.reason };
159
+ case "unreadable":
160
+ return { status: "unknown", reason: reading.reason };
161
+ default:
162
+ return { status: "missing", reason: reading.reason };
163
+ }
164
+ }
165
+ /** Doctor's `autostart-alive` row status for the same reading. The `code` a
166
+ * caller pairs with this is `reading.reason` — there is no second vocabulary. */
167
+ export function autostartDoctorStatus(reading) {
168
+ if (reading.state === "loaded")
169
+ return "ok";
170
+ if (reading.state === "unsupported")
171
+ return "skipped";
172
+ return "needs_fix";
173
+ }
@@ -1,6 +1,6 @@
1
- import { autostartStatus, installAutostartAgent, registeredRuntimePathProblems, } from "../autostart.js";
1
+ import { installAutostartAgent } from "../autostart.js";
2
2
  import { fail, needsFix, ok, skipped } from "./doctor-report.js";
3
- import { doctorRoots, savedRoots } from "./doctor-access.js";
3
+ import { autostartDoctorStatus, autostartRegistrationRoots, readAutostartRegistration, } from "./autostart-reading.js";
4
4
  import { installMemoryIntegration, inspectMemoryIntegration, } from "./memory-install.js";
5
5
  /**
6
6
  * The `autostart-alive` and `memory-registered` check family: registrations
@@ -8,44 +8,40 @@ import { installMemoryIntegration, inspectMemoryIntegration, } from "./memory-in
8
8
  * background sync scheduler and BLI Memory's MCP/hook wiring. Both fixes
9
9
  * write host configuration only; neither installs software.
10
10
  */
11
+ /**
12
+ * The row and the setup receipt's `collector autostart` word are the SAME
13
+ * reading now (BLI-3793) — `autostart-reading.ts` asks the host once and hands
14
+ * back one state and one reason label. This function only chooses the row
15
+ * status for it; it does not decide anything about the machine.
16
+ */
11
17
  export async function checkAutostartState(context) {
12
- const exec = context.io.exec;
13
- if (!exec) {
14
- return needsFix("autostart-alive", "runner_unavailable", "autostart runner unavailable; would refresh autostart");
15
- }
16
- const roots = await doctorRoots(context);
17
- const result = await autostartStatus({
18
- repoRoot: roots[0],
19
- repoRoots: roots,
18
+ const reading = await readAutostartRegistration({
19
+ exec: context.io.exec,
20
+ homeDir: context.command.homeDir,
20
21
  dashboardUrl: context.command.dashboardUrl,
21
- exec,
22
+ repoRoot: context.command.repoRoot,
22
23
  });
23
- if (result.status === "loaded") {
24
- // BLI-3553: "loaded" only means the scheduler accepted the registration.
25
- // It says nothing about whether the binary that registration names still
26
- // exists and `brew upgrade node` deletes exactly that. Ask the
27
- // filesystem about the paths the PLATFORM holds, not the ones this process
28
- // happens to be running under.
29
- const missing = await registeredRuntimePathProblems({ exec });
30
- if (missing.length > 0) {
31
- return needsFix("autostart-alive", "runtime_path_missing", `background sync is registered but cannot run: ${missing.join("; ")}`);
32
- }
33
- return ok("autostart-alive", "already_installed", "background sync is running");
34
- }
35
- if (result.status === "unsupported") {
36
- return skipped("autostart-alive", "unsupported", result.message ?? "unsupported");
24
+ const status = autostartDoctorStatus(reading);
25
+ if (status === "ok")
26
+ return ok("autostart-alive", reading.reason, reading.message);
27
+ if (status === "skipped") {
28
+ return skipped("autostart-alive", reading.reason, reading.message);
37
29
  }
38
- return needsFix("autostart-alive", result.status === "not_loaded" ? "not_loaded" : "absent", "background sync is not running");
30
+ return needsFix("autostart-alive", reading.reason, reading.message);
39
31
  }
40
32
  export async function fixAutostartState(context) {
41
33
  const exec = context.io.exec;
42
34
  if (!exec) {
43
35
  return fail("autostart-alive", "runner_unavailable", "autostart runner unavailable");
44
36
  }
45
- const roots = await savedRoots();
37
+ // The same roots the CHECK read against, resolved by the same function — a
38
+ // fix that registers a different boundary than the check inspected would
39
+ // leave the row red forever (BLI-3793).
40
+ const roots = await autostartRegistrationRoots(context.command.homeDir, context.command.repoRoot);
46
41
  const result = await installAutostartAgent({
47
- repoRoot: context.command.repoRoot ?? roots[0],
48
- repoRoots: context.command.repoRoot ? [context.command.repoRoot] : roots,
42
+ ...(roots[0] ? { repoRoot: roots[0] } : {}),
43
+ repoRoots: roots,
44
+ ...(context.command.homeDir ? { homeDir: context.command.homeDir } : {}),
49
45
  dashboardUrl: context.command.dashboardUrl,
50
46
  exec,
51
47
  });
@@ -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.68");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.69");
19
19
  return 0;
20
20
  }
21
21
 
@@ -15,8 +15,13 @@
15
15
  * in, so the session file is the evidence — there is no
16
16
  * second thing to ask.
17
17
  * device that same session, still unexpired.
18
- * collector.autostart `autostartStatus`, which asks launchd or Task
19
- * Scheduler what it actually has registered.
18
+ * collector.autostart `readAutostartRegistration`, which asks launchd or
19
+ * Task Scheduler what it actually has registered. That
20
+ * reader is SHARED with doctor's `autostart-alive` row
21
+ * (BLI-3793) — this module used to ask the scheduler
22
+ * itself, without the machine's saved roots, and so
23
+ * printed `autostart_not_loaded` under a doctor row
24
+ * that said the same agent was running.
20
25
  *
21
26
  * The five AGENT-HOST words in the printed block are NOT computed here. They
22
27
  * are BLI-3729's `MemoryInstallReceipt`, already read back off
@@ -36,7 +41,7 @@
36
41
  import os from "node:os";
37
42
  import { SETUP_RECEIPT_SCHEMA_VERSION, setupReceiptGaps, setupReceiptLine, } from "@bli-cockpit/telemetry-core";
38
43
  import { readHeartbeatMemoryReceipt } from "./heartbeat.js";
39
- import { autostartStatus } from "../autostart.js";
44
+ import { autostartSetupPiece, readAutostartRegistration, } from "./autostart-reading.js";
40
45
  import { readLocalSessionReference } from "../local-state.js";
41
46
  import { getCollectorRuntimePaths } from "../local-state-paths.js";
42
47
  import { readJsonFile, writeJsonFile } from "../local-state-files.js";
@@ -63,14 +68,19 @@ export async function buildSetupReceipt(io, options = {}) {
63
68
  });
64
69
  const autostart = await probes.autostart().catch((error) => {
65
70
  console.error(`${TAG} scheduler could not be read`, JSON.stringify({ reason: "autostart_probe_failed", error_name: errorName(error) }));
66
- return { status: "unreadable" };
71
+ return {
72
+ state: "unreadable",
73
+ reason: "autostart_probe_failed",
74
+ message: "the operating-system scheduler could not be read this run",
75
+ roots: [],
76
+ };
67
77
  });
68
78
  const receipt = {
69
79
  schema_version: SETUP_RECEIPT_SCHEMA_VERSION,
70
80
  checked_at: checkedAt,
71
81
  browser: browserPiece(session),
72
82
  device: devicePiece(session),
73
- collector: { autostart: autostartPiece(autostart) },
83
+ collector: { autostart: autostartSetupPiece(autostart) },
74
84
  };
75
85
  const gaps = setupReceiptGaps(receipt, memory);
76
86
  console.error(`${TAG} read back`, JSON.stringify({
@@ -97,19 +107,16 @@ function withDefaultProbes(io, homeDir, options) {
97
107
  };
98
108
  }),
99
109
  autostart: options.probes?.autostart ??
100
- (async () => {
101
- // The scheduler can only be READ through a process runner; a machine
102
- // whose io carries none is unknown, never "absent" (BLI-2541: never
103
- // report a state you did not observe).
104
- const exec = io.exec;
105
- if (!exec)
106
- return { status: "unreadable", message: "runner_unavailable" };
107
- const result = await autostartStatus({ homeDir, exec });
108
- return {
109
- status: result.status,
110
- ...(result.message ? { message: result.message } : {}),
111
- };
112
- }),
110
+ // The one shared reader. It handles the no-process-runner host itself
111
+ // (`unreadable`, never "absent" BLI-2541: never report a state you did
112
+ // not observe) and it hands the scheduler this machine's saved roots,
113
+ // which is what stopped this word from contradicting doctor's row
114
+ // (BLI-3793).
115
+ (async () => readAutostartRegistration({
116
+ exec: io.exec,
117
+ homeDir,
118
+ ...(options.dashboardUrl ? { dashboardUrl: options.dashboardUrl } : {}),
119
+ })),
113
120
  };
114
121
  }
115
122
  /**
@@ -139,32 +146,6 @@ function devicePiece(session) {
139
146
  }
140
147
  return { status: "missing", reason: `session_${session.state}` };
141
148
  }
142
- /**
143
- * The scheduler is `ok` only when the host says it is registered AND loaded.
144
- * `installed` on macOS means the plist is on disk; `loaded` means launchd has
145
- * it. A plist nobody loaded runs nothing, which is exactly the failure that
146
- * looks fine from the outside.
147
- */
148
- function autostartPiece(result) {
149
- if (result.status === "loaded" || result.status === "installed") {
150
- return { status: "ok", reason: `autostart_${result.status}` };
151
- }
152
- if (result.status === "not_loaded") {
153
- return { status: "missing", reason: "autostart_not_loaded" };
154
- }
155
- if (result.status === "absent" || result.status === "uninstalled") {
156
- return { status: "missing", reason: "autostart_absent" };
157
- }
158
- if (result.status === "unsupported") {
159
- return { status: "skipped", reason: "platform_unsupported" };
160
- }
161
- return { status: "unknown", reason: normalizeReason(result.status) };
162
- }
163
- /** Reason labels are `[a-z0-9_:.-]` by schema; anything else becomes one word. */
164
- function normalizeReason(value) {
165
- const cleaned = value.trim().toLowerCase().replace(/[^a-z0-9_:.-]+/gu, "_");
166
- return cleaned.slice(0, 120) || "unlabelled";
167
- }
168
149
  function errorName(error) {
169
150
  return error instanceof Error ? error.name : typeof error;
170
151
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.68",
3
+ "version": "0.2.69",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {