@bli-cockpit/cli 0.2.40 → 0.2.41

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.
@@ -2,6 +2,7 @@ import { collectCarState } from "./car-state.js";
2
2
  import fs from "node:fs/promises";
3
3
  import { makeSourceScan, makeUnavailableScan, } from "./common.js";
4
4
  import { collectGitState } from "./git-state.js";
5
+ import { describeError } from "../health-detail.js";
5
6
  import { collectRawEvidencePack, } from "./raw-evidence.js";
6
7
  import { generateDumbRiskFlags } from "./risk-flags.js";
7
8
  import { resolveTicketBinding, } from "./ticket-binding.js";
@@ -50,9 +51,11 @@ export async function runLocalSourceCollectors(options) {
50
51
  makeUnavailableScan(context, "claude_hooks", "claude-hooks", "claude_hooks_not_present"),
51
52
  ];
52
53
  // claude_jsonl availability (B.4): "ok" when ~/.claude/projects exists, else
53
- // skipped with claude_projects_dir_not_found so an operator on a non-Claude
54
- // machine sees why the claude funnel is empty. Collection itself is gated
55
- // separately by collect_claude_jsonl in the sync orchestrator.
54
+ // skipped with the reason it could not be read — absent, permission-denied,
55
+ // not a directory, or unreadable (BLI-3483) so an operator sees whether
56
+ // the empty claude funnel is normal or a wall they can take down. Collection
57
+ // itself is gated separately by collect_claude_jsonl in the sync
58
+ // orchestrator.
56
59
  const claudeJsonlScan = await makeClaudeJsonlScan(context, options.claudeProjectsDir);
57
60
  const candidates = collectBindingCandidates([
58
61
  git.scan,
@@ -93,9 +96,52 @@ async function makeClaudeJsonlScan(context, projectsDir) {
93
96
  if (!projectsDir) {
94
97
  return makeUnavailableScan(context, "claude_jsonl", "claude-jsonl", "claude_projects_dir_not_configured");
95
98
  }
96
- const present = await fs.stat(projectsDir).then((stat) => stat.isDirectory(), () => false);
97
- return makeSourceScan(context, "claude_jsonl", "claude-jsonl", present ? "ok" : "skipped", present ? "claude_projects_dir_present" : "claude_projects_dir_not_found");
99
+ // Until BLI-3483 every failure of this stat became `claude_projects_dir_
100
+ // not_found`, so a machine that HAS the folder and cannot open it — macOS
101
+ // TCC withholding Full Disk Access, a Windows ACL, a corporate MDM profile —
102
+ // reported the same word as a machine that has never run Claude Code. One
103
+ // repair is "nothing to do"; the other is "grant this app access, then every
104
+ // Claude session on this laptop starts collecting". Opposite meanings, one
105
+ // label, and the empty funnel looked normal either way.
106
+ const inspected = await fs.stat(projectsDir).then((stat) => ({ ok: true, isDirectory: stat.isDirectory() }), (error) => ({ ok: false, error }));
107
+ if (inspected.ok && inspected.isDirectory) {
108
+ return makeSourceScan(context, "claude_jsonl", "claude-jsonl", "ok", "claude_projects_dir_present");
109
+ }
110
+ const reason = inspected.ok
111
+ ? "claude_projects_dir_not_a_directory"
112
+ : claudeProjectsDirFailureReason(inspected.error);
113
+ // Never the path: it is under the operator's home directory.
114
+ console.error("[local-sources] claude projects directory unavailable", JSON.stringify({
115
+ reason,
116
+ fix_hint: CLAUDE_PROJECTS_FIX_HINTS[reason],
117
+ ...(inspected.ok ? {} : describeError(inspected.error)),
118
+ }));
119
+ return makeSourceScan(context, "claude_jsonl", "claude-jsonl", "skipped", reason);
120
+ }
121
+ /**
122
+ * `ENOENT`/`ENOTDIR` is the ordinary "this machine does not run Claude Code".
123
+ * `EACCES`/`EPERM` is a permission wall a person can take down. Anything else
124
+ * (`ELOOP`, `ENAMETOOLONG`, an I/O error) is named as unreadable rather than
125
+ * folded into either, because guessing between them is what created this bug.
126
+ */
127
+ function claudeProjectsDirFailureReason(error) {
128
+ const code = error && typeof error === "object"
129
+ ? error.code
130
+ : undefined;
131
+ if (code === "ENOENT" || code === "ENOTDIR") {
132
+ return "claude_projects_dir_not_found";
133
+ }
134
+ if (code === "EACCES" || code === "EPERM") {
135
+ return "claude_projects_dir_permission_denied";
136
+ }
137
+ return "claude_projects_dir_unreadable";
98
138
  }
139
+ const CLAUDE_PROJECTS_FIX_HINTS = {
140
+ claude_projects_dir_not_found: "nothing to repair unless this machine runs Claude Code",
141
+ claude_projects_dir_permission_denied: "macOS: grant the terminal/CLI Full Disk Access in System Settings > Privacy & Security; Windows: check the folder ACL on the user profile",
142
+ claude_projects_dir_not_a_directory: "a file sits where the projects directory should be; move or remove it",
143
+ claude_projects_dir_unreadable: "read the error_code beside this line; the folder exists but the stat failed",
144
+ };
99
145
  function isGitStateFacts(value) {
100
146
  return Boolean(value && typeof value === "object" && "repo_root" in value);
101
147
  }
package/dist/autostart.js CHANGED
@@ -3,6 +3,7 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths } from "./local-state.js";
5
5
  import { savedDiscoveryLimitArgs } from "./discovery-limits.js";
6
+ import { redactedHealthDetail } from "./health-detail.js";
6
7
  /** launchd LaunchAgent label; matches docs/runbooks/cockpit-launchd-sync.md. */
7
8
  export const AUTOSTART_LABEL = "com.bli.cockpit.sync";
8
9
  export const WINDOWS_AUTOSTART_TASK_NAME = "BLI Cockpit Sync";
@@ -100,6 +101,28 @@ export async function installAutostartAgent(options) {
100
101
  await options.exec("launchctl", ["unload", plistPath]).catch(() => undefined);
101
102
  const load = await options.exec("launchctl", ["load", plistPath]);
102
103
  const loaded = load.code === 0;
104
+ // Both branches log, for the same reason the Windows path does: a line that
105
+ // only fires on failure cannot answer "did background collection get
106
+ // installed at all today?". Until BLI-3483 the macOS failure existed only in
107
+ // the returned `message`, which nothing on the sync path reads — a Mac could
108
+ // finish `cockpit onboard` with no scheduler and nothing in sync.err.log.
109
+ // Metadata only: launchctl's stderr can carry the state directory, so the
110
+ // exit code and the redacted first line travel, never the raw text.
111
+ if (loaded) {
112
+ console.error("[autostart] launchd agent loaded", JSON.stringify({
113
+ label: AUTOSTART_LABEL,
114
+ interval_seconds: intervalSeconds,
115
+ watch_path_count: watchPaths.length,
116
+ }));
117
+ }
118
+ else {
119
+ console.error("[autostart] launchctl load failed; this Mac has no background collection", JSON.stringify({
120
+ reason: "autostart_load_failed",
121
+ label: AUTOSTART_LABEL,
122
+ exit_code: load.code,
123
+ detail: launchctlDetail(load),
124
+ }));
125
+ }
103
126
  return {
104
127
  status: "installed",
105
128
  label: AUTOSTART_LABEL,
@@ -112,10 +135,27 @@ export async function installAutostartAgent(options) {
112
135
  ...(loaded
113
136
  ? {}
114
137
  : {
115
- message: `launchctl load exited ${load.code}: ${load.stderr.trim() || "unknown error"}`,
138
+ message: `launchctl load exited ${load.code}: ${launchctlDetail(load)}`,
116
139
  }),
117
140
  };
118
141
  }
142
+ /**
143
+ * What `launchctl` actually said, or an honest account of it saying nothing.
144
+ *
145
+ * "unknown error" was the old fallback, and it is the least useful string in
146
+ * the file: `launchctl load` exits nonzero with EMPTY stderr for the ordinary
147
+ * cases — the agent is already loaded, or the plist was rejected — so the
148
+ * message an operator saw named neither the exit code's meaning nor the
149
+ * silence itself (BLI-3483). Exit 1 with no output is a fact; say so, and say
150
+ * what to do next. stdout is consulted too because launchctl is inconsistent
151
+ * about which stream it uses across macOS versions.
152
+ */
153
+ function launchctlDetail(result) {
154
+ const spoken = result.stderr.trim() || result.stdout.trim();
155
+ if (spoken)
156
+ return redactedHealthDetail(spoken);
157
+ return `no output on either stream (exit ${result.code}); the agent may already be loaded, or launchd rejected the plist — check \`launchctl print gui/$(id -u)/${AUTOSTART_LABEL}\``;
158
+ }
119
159
  /**
120
160
  * Removes the LaunchAgent. Reports `absent` when there was nothing to remove so
121
161
  * the command is safe to run repeatedly.
@@ -379,8 +419,9 @@ async function windowsTaskStatus(options) {
379
419
  syncLogPath: windowsSyncLogPath(options.homeDir),
380
420
  })}`;
381
421
  const currentScript = await readFile(scriptPath, "utf8").catch(() => null);
382
- if (currentScript !== expectedScript) {
383
- registrationProblems.push("sync script does not match the current roots or Tower runtime");
422
+ const scriptDifference = renderedFileDifference(currentScript, expectedScript);
423
+ if (scriptDifference) {
424
+ registrationProblems.push(`sync script does not match the current roots or Tower runtime (${describeDifference(scriptDifference)})`);
384
425
  }
385
426
  }
386
427
  // Same shape as the sync-script checks: existence always, content only when
@@ -398,8 +439,9 @@ async function windowsTaskStatus(options) {
398
439
  scriptPath,
399
440
  });
400
441
  const currentLauncher = await readFile(launcherPath, "utf8").catch(() => null);
401
- if (currentLauncher !== expectedLauncher) {
402
- registrationProblems.push("sync launcher does not match the current Tower runtime");
442
+ const launcherDifference = renderedFileDifference(currentLauncher, expectedLauncher);
443
+ if (launcherDifference) {
444
+ registrationProblems.push(`sync launcher does not match the current Tower runtime (${describeDifference(launcherDifference)})`);
403
445
  }
404
446
  }
405
447
  // Both branches log. A line that only fires on failure cannot answer "did
@@ -431,6 +473,64 @@ async function windowsTaskStatus(options) {
431
473
  : {}),
432
474
  };
433
475
  }
476
+ function renderedFileDifference(current, expected) {
477
+ const expectedLines = normalizedScriptLines(expected);
478
+ if (current === null) {
479
+ return {
480
+ reason: "file_unreadable",
481
+ first_differing_line: null,
482
+ current_line_count: null,
483
+ expected_line_count: expectedLines.length,
484
+ };
485
+ }
486
+ if (hasByteOrderMark(current) !== hasByteOrderMark(expected)) {
487
+ return {
488
+ reason: "byte_order_mark",
489
+ first_differing_line: 0,
490
+ current_line_count: normalizedScriptLines(current).length,
491
+ expected_line_count: expectedLines.length,
492
+ };
493
+ }
494
+ const currentLines = normalizedScriptLines(current);
495
+ const shared = Math.min(currentLines.length, expectedLines.length);
496
+ for (let index = 0; index < shared; index += 1) {
497
+ if (currentLines[index] !== expectedLines[index]) {
498
+ return {
499
+ reason: "line_content",
500
+ first_differing_line: index,
501
+ current_line_count: currentLines.length,
502
+ expected_line_count: expectedLines.length,
503
+ };
504
+ }
505
+ }
506
+ if (currentLines.length !== expectedLines.length) {
507
+ return {
508
+ reason: "line_count",
509
+ first_differing_line: shared,
510
+ current_line_count: currentLines.length,
511
+ expected_line_count: expectedLines.length,
512
+ };
513
+ }
514
+ return null;
515
+ }
516
+ /** Safe to print: a reason label, a line index, two counts. No content. */
517
+ function describeDifference(difference) {
518
+ if (difference.reason === "file_unreadable")
519
+ return "file could not be read";
520
+ if (difference.reason === "byte_order_mark")
521
+ return "byte-order mark differs";
522
+ return `${difference.reason} at line ${difference.first_differing_line}, ${difference.current_line_count} lines on disk vs ${difference.expected_line_count} expected`;
523
+ }
524
+ function normalizedScriptLines(value) {
525
+ const withoutBom = hasByteOrderMark(value) ? value.slice(UTF8_BOM.length) : value;
526
+ return withoutBom
527
+ .replace(/\r\n/gu, "\n")
528
+ .replace(/\s+$/u, "")
529
+ .split("\n");
530
+ }
531
+ function hasByteOrderMark(value) {
532
+ return value.startsWith(UTF8_BOM);
533
+ }
434
534
  function windowsAutostartScriptPath(homeDir) {
435
535
  return path.join(getCollectorRuntimePaths(homeDir ?? os.homedir()).state_dir, WINDOWS_AUTOSTART_SCRIPT_NAME);
436
536
  }
@@ -12,10 +12,8 @@
12
12
  * Identity is the existing paired device session. `--for` changes WHOSE page is
13
13
  * asked for, never who is asking — the same rule `cockpit jarvis --as` follows.
14
14
  */
15
- import { writeLine } from "./cli-io.js";
15
+ import { colorEnabled, dim, writeLine } from "./cli-io.js";
16
16
  import { loadPairedSession, towerJsonRequest } from "../tower-client.js";
17
- const DIM = "\x1b[2m";
18
- const RESET = "\x1b[0m";
19
17
  const REQUEST_DEADLINE_MS = 30_000;
20
18
  export async function runBrief(command, io) {
21
19
  const session = await loadPairedSession("brief", command.homeDir);
@@ -81,19 +79,22 @@ function queryFor(command) {
81
79
  return query ? `?${query}` : "";
82
80
  }
83
81
  function writeHuman(io, command, body) {
82
+ // Decided once per page: colour only for a real terminal that has not said
83
+ // NO_COLOR, so `cockpit brief > page.txt` is text and nothing else (BLI-3482).
84
+ const styled = colorEnabled(io);
84
85
  const page = body.page ?? {};
85
86
  // Whose page, and — when it is not the newest — that it is a record of a
86
87
  // moment rather than something that failed to update.
87
88
  const whose = page.displayName ? `${page.displayName}'s page` : "This page";
88
89
  const pinned = page.olderVersionLabel ? ` · version from ${page.olderVersionLabel}` : "";
89
- writeLine(io.stdout, `${DIM}${whose}${pinned}${RESET}`);
90
+ writeLine(io.stdout, dim(`${whose}${pinned}`, styled));
90
91
  writeLine(io.stdout, "");
91
92
  writeLine(io.stdout, body.text ?? "");
92
93
  if (body.claims && body.claims.length > 0) {
93
94
  writeLine(io.stdout, "");
94
- writeLine(io.stdout, `${DIM}Claim ids — pass one to \`cockpit correct --claim\`:${RESET}`);
95
+ writeLine(io.stdout, dim("Claim ids — pass one to `cockpit correct --claim`:", styled));
95
96
  for (const claim of body.claims) {
96
- writeLine(io.stdout, `${DIM} [${claim.claimId}]${RESET} ${claim.text ?? ""}`);
97
+ writeLine(io.stdout, `${dim(` [${claim.claimId}]`, styled)} ${claim.text ?? ""}`);
97
98
  }
98
99
  }
99
100
  if (body.versions) {
@@ -101,13 +102,13 @@ function writeHuman(io, command, body) {
101
102
  if (body.versions.length === 0) {
102
103
  // Never "there is one version". An empty list with a reason is an
103
104
  // answer; an empty list without one is a wrong answer.
104
- writeLine(io.stdout, `${DIM}Earlier versions could not be listed (${body.versionsReason ?? "no reason given"}).${RESET}`);
105
+ writeLine(io.stdout, dim(`Earlier versions could not be listed (${body.versionsReason ?? "no reason given"}).`, styled));
105
106
  }
106
107
  else {
107
- writeLine(io.stdout, `${DIM}Versions — pass one to \`cockpit brief --version\`:${RESET}`);
108
+ writeLine(io.stdout, dim("Versions — pass one to `cockpit brief --version`:", styled));
108
109
  for (const version of body.versions) {
109
110
  const headline = version.headline ? ` — ${version.headline}` : "";
110
- writeLine(io.stdout, `${DIM} ${version.version}/${version.of} ${version.pageId} ${version.asOf}${headline}${RESET}`);
111
+ writeLine(io.stdout, dim(` ${version.version}/${version.of} ${version.pageId} ${version.asOf}${headline}`, styled));
111
112
  }
112
113
  }
113
114
  }
@@ -118,7 +119,7 @@ function writeHuman(io, command, body) {
118
119
  .filter(Boolean);
119
120
  if (others.length > 0) {
120
121
  writeLine(io.stdout, "");
121
- writeLine(io.stdout, `${DIM}You can also open: ${others.join(", ")} (\`--for <name>\`)${RESET}`);
122
+ writeLine(io.stdout, dim(`You can also open: ${others.join(", ")} (\`--for <name>\`)`, styled));
122
123
  }
123
124
  }
124
125
  }
@@ -13,6 +13,35 @@ export function writeRaw(stream, text) {
13
13
  export function errorMessage(error) {
14
14
  return error instanceof Error ? error.message : String(error);
15
15
  }
16
+ // ------------------------------------------------------------------- styling
17
+ const SGR_DIM = "\x1b[2m";
18
+ const SGR_RESET = "\x1b[0m";
19
+ /**
20
+ * Whether this run may put colour escapes on stdout (BLI-3482). Five commands
21
+ * had their own copy of the same two escape constants and none of them asked
22
+ * this question, so `cockpit brief > page.txt` wrote `ESC[2m` into the file and
23
+ * `cockpit scout | grep` matched against bytes nobody typed.
24
+ *
25
+ * Two conditions, both from `io` rather than the process so a test can state
26
+ * them: stdout is a real terminal, and NO_COLOR is absent or empty
27
+ * (no-color.org — any non-empty value, whatever it says, means no colour).
28
+ * `defaultIo()` passes `process.env`, so in production this IS `process.env`.
29
+ */
30
+ export function colorEnabled(io) {
31
+ if ((io.env["NO_COLOR"] ?? "") !== "")
32
+ return false;
33
+ return Boolean(io.stdout.isTTY);
34
+ }
35
+ /**
36
+ * The one dim-text helper. `enabled` is an explicit argument, never read from
37
+ * the process here, so the pure render modules stay deterministic and a caller
38
+ * cannot forget the gate without the compiler saying so.
39
+ */
40
+ export function dim(text, enabled) {
41
+ if (!enabled || text === "")
42
+ return text;
43
+ return `${SGR_DIM}${text}${SGR_RESET}`;
44
+ }
16
45
  export function writeExecOutput(io, result, options) {
17
46
  if (options.stdout)
18
47
  writeRaw(io.stdout, result.stdout);
@@ -23,10 +23,8 @@
23
23
  * is printed on stdout with an exit code of 0 — the correction WAS filed, with
24
24
  * its outcome recorded. Only a failure to file at all is a non-zero exit.
25
25
  */
26
- import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
26
+ import { colorEnabled, dim, isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
27
27
  import { loadPairedSession, towerJsonRequest } from "../tower-client.js";
28
- const DIM = "\x1b[2m";
29
- const RESET = "\x1b[0m";
30
28
  const REQUEST_DEADLINE_MS = 60_000;
31
29
  const MAX_CORRECTION_LENGTH = 4000;
32
30
  export async function runCorrect(command, io) {
@@ -101,11 +99,12 @@ export async function runCorrect(command, io) {
101
99
  writeLine(io.stdout, JSON.stringify({ ok: true, ...filed }));
102
100
  }
103
101
  else {
102
+ const styled = colorEnabled(io);
104
103
  writeLine(io.stdout, filed.reply ?? "Filed.");
105
104
  if (filed.finding)
106
- writeLine(io.stdout, `${DIM}The record says: ${filed.finding}${RESET}`);
105
+ writeLine(io.stdout, dim(`The record says: ${filed.finding}`, styled));
107
106
  if (filed.link)
108
- writeLine(io.stdout, `${DIM}${filed.link}${RESET}`);
107
+ writeLine(io.stdout, dim(filed.link, styled));
109
108
  }
110
109
  writeLine(io.stderr, `[correct cli] filed ${JSON.stringify({
111
110
  tier: filed.tier ?? null,
@@ -5,12 +5,10 @@
5
5
  * existing paired device session, and every turn is executed by the dashboard
6
6
  * through the same JARVIS runtime used by web chat and Slack.
7
7
  */
8
- import { isInteractiveStdin, readLine, readPipedText, writeLine } from "./cli-io.js";
8
+ import { colorEnabled, dim, isInteractiveStdin, readLine, readPipedText, writeLine, } from "./cli-io.js";
9
9
  import { attachedFileRefusalSentence, readAttachedImage, } from "./jarvis-attachment.js";
10
10
  import { loadPairedSession, towerFailureDetail, towerJsonRequest, towerRequest, } from "../tower-client.js";
11
11
  import { readTowerTurn, streamFailureDetail, } from "../tower-stream.js";
12
- const TRACE_DIM = "\x1b[2m";
13
- const TRACE_RESET = "\x1b[0m";
14
12
  /**
15
13
  * The dashboard route caps a turn at 120s (`maxDuration = 120`). The client
16
14
  * waits slightly longer so the server's own named failure wins the race
@@ -108,14 +106,15 @@ function writeThreadList(io, threads) {
108
106
  writeLine(io.stdout, "No terminal conversations yet. Ask something with `cockpit jarvis`.");
109
107
  return;
110
108
  }
109
+ const styled = colorEnabled(io);
111
110
  for (const thread of threads) {
112
111
  const turns = thread.turnCount === 1 ? "1 turn" : `${thread.turnCount ?? 0} turns`;
113
- writeLine(io.stdout, `${thread.name} ${TRACE_DIM}${turns} · ${thread.lastAt ?? "unknown"}${TRACE_RESET}`);
112
+ writeLine(io.stdout, `${thread.name} ${dim(`${turns} · ${thread.lastAt ?? "unknown"}`, styled)}`);
114
113
  if (thread.preview)
115
- writeLine(io.stdout, `${TRACE_DIM} ${thread.preview}${TRACE_RESET}`);
114
+ writeLine(io.stdout, dim(` ${thread.preview}`, styled));
116
115
  }
117
116
  writeLine(io.stdout, "");
118
- writeLine(io.stdout, `${TRACE_DIM}Replay one with \`cockpit jarvis --thread <name> --history\`.${TRACE_RESET}`);
117
+ writeLine(io.stdout, dim("Replay one with `cockpit jarvis --thread <name> --history`.", styled));
119
118
  }
120
119
  function writeThreadHistory(io, body) {
121
120
  const messages = body.messages ?? [];
@@ -124,7 +123,7 @@ function writeThreadHistory(io, body) {
124
123
  return;
125
124
  }
126
125
  if (body.truncated) {
127
- writeLine(io.stdout, `${TRACE_DIM}Showing the most recent ${messages.length}; there is more before this (\`--limit\`).${TRACE_RESET}`);
126
+ writeLine(io.stdout, dim(`Showing the most recent ${messages.length}; there is more before this (\`--limit\`).`, colorEnabled(io)));
128
127
  }
129
128
  for (const message of messages) {
130
129
  const speaker = message.role === "you" ? "you" : "jarvis";
@@ -294,7 +293,7 @@ function writeTraceBlock(io, trace) {
294
293
  function writeTraceLine(io, step) {
295
294
  const elapsed = typeof step.elapsedMs === "number" ? ` (${step.elapsedMs}ms)` : "";
296
295
  const failure = step.status === "failed" ? ` — failed: ${step.detail ?? "no reason given"}` : "";
297
- writeLine(io.stdout, `${TRACE_DIM} ⏺ ${step.label}${elapsed}${failure}${TRACE_RESET}`);
296
+ writeLine(io.stdout, dim(` ⏺ ${step.label}${elapsed}${failure}`, colorEnabled(io)));
298
297
  }
299
298
  /**
300
299
  * Draws one streamed tool call, and says whether it drew anything.
@@ -38,6 +38,26 @@ export async function discoverCommandWorktrees(repoRoot, discovery = {}, io) {
38
38
  throw new Error(message);
39
39
  }
40
40
  if (worktrees.length === 0 && !discovery.allowEmpty) {
41
+ // "No git repos found" is only true when the scan could actually SEE
42
+ // everywhere it looked. With unreadable folders in hand, the sentence sent
43
+ // the operator to the wrong repair — "run from a git repo", when the repos
44
+ // may be sitting right there behind a permission wall (BLI-3483). The
45
+ // warning above prints the folders; this refusal has to carry the fact,
46
+ // because it is the line that ends the command.
47
+ if (result.unreadable_dirs.length > 0) {
48
+ console.error("[local-discovery] scan found no repos while folders were unopenable", JSON.stringify({
49
+ reason: "empty_scan_with_unreadable_dirs",
50
+ unreadable_dir_count: result.unreadable_dirs.length,
51
+ codes: [...new Set(result.unreadable_dirs.map((dir) => dir.code))],
52
+ }));
53
+ throw new Error([
54
+ `No git repos found, and ${result.unreadable_dirs.length} folder(s) could not be opened — so this is not proof there are none.`,
55
+ ...(io
56
+ ? ["The folders are listed in the warning above."]
57
+ : ["Run `cockpit doctor` to see which folders."]),
58
+ "Fix the permissions on those folders and run the command again, or point --workspace at the project folders you can read.",
59
+ ].join("\n"));
60
+ }
41
61
  throw new Error("No git repos found. Run from a git repo, or from a parent folder containing git repos.");
42
62
  }
43
63
  return worktrees;
@@ -155,6 +155,17 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
155
155
  }
156
156
  }
157
157
  catch (error) {
158
+ // The outermost net under every subcommand. It printed `error.message` and
159
+ // dropped the name, the code, the syscall and the cause — and never said
160
+ // WHICH subcommand died, so a scheduled `cockpit sync` and a hand-typed
161
+ // `cockpit doctor` failing the same way were indistinguishable in
162
+ // sync.err.log (BLI-3483). The human line is unchanged; the structured one
163
+ // beside it carries names and codes only, scrubbed by `describeError`.
164
+ console.error("[cockpit-cli] command failed", JSON.stringify({
165
+ reason: "command_failed",
166
+ command: command.kind,
167
+ ...describeError(error),
168
+ }));
158
169
  writeLine(io.stderr, errorMessage(error));
159
170
  return 1;
160
171
  }
@@ -1115,8 +1126,26 @@ async function runAutostartSelfHealAfterSync(command, io, dashboardUrl) {
1115
1126
  let result;
1116
1127
  try {
1117
1128
  const rawExec = io.exec;
1118
- if (!rawExec)
1129
+ if (!rawExec) {
1130
+ // BLI-3483: this was a bare `return`. On Windows the self-heal is the
1131
+ // only thing that puts a broken scheduler back, so abandoning it here
1132
+ // meant a machine could stop collecting forever and leave no receipt
1133
+ // anywhere — the exact shape the fleet contract forbids. The packed CLI
1134
+ // always supplies a runner (`commands/cli-io.ts`), so this fires only for
1135
+ // an embedder that built its own `io`; it costs one line either way.
1136
+ console.error("[autostart-self-heal] no process runner on this io; the repair could not be attempted", JSON.stringify({
1137
+ reason: "runner_unavailable",
1138
+ platform: process.platform,
1139
+ next_action: "reinstall the CLI (npm i -g @bli-cockpit/cli) and run `cockpit autostart install`",
1140
+ }));
1141
+ result = {
1142
+ status: "skipped",
1143
+ reason: "runner_unavailable",
1144
+ detail: "No process runner available to this CLI invocation; run `cockpit autostart install` by hand.",
1145
+ };
1146
+ await reportAutostartSelfHealOutcome(command, io, dashboardUrl, result);
1119
1147
  return;
1148
+ }
1120
1149
  const spawnEnv = envWithNodeRuntimeOnPath(io.env ?? process.env);
1121
1150
  const exec = (cmd, args, options) => rawExec(cmd, args, { ...options, env: options?.env ?? spawnEnv });
1122
1151
  result = await runAutostartSelfHeal(getCollectorRuntimePaths(command.homeDir), {
@@ -1137,6 +1166,10 @@ async function runAutostartSelfHealAfterSync(command, io, dashboardUrl) {
1137
1166
  // throttle are silent; an actual repair attempt reports either way.
1138
1167
  if (!result || result.reason === "repair_throttled_recent_attempt")
1139
1168
  return;
1169
+ await reportAutostartSelfHealOutcome(command, io, dashboardUrl, result);
1170
+ }
1171
+ /** One receipt for the repair, whichever branch above produced the outcome. */
1172
+ async function reportAutostartSelfHealOutcome(command, io, dashboardUrl, result) {
1140
1173
  await reportInstallEventsBestEffort({
1141
1174
  homeDir: command.homeDir,
1142
1175
  dashboardUrl,
@@ -293,8 +293,14 @@ async function pasteNote(command, door) {
293
293
  form.set("name", command.name);
294
294
  if (command.exclude)
295
295
  form.set("exclusions", command.exclude);
296
- if (text.length >= NOTE_SLOW_UPLOAD_BYTES) {
297
- writeLine(door.io.stderr, `Reading ${Math.round(text.length / 1024)} KB of pasted text. This can take a couple of minutes.`);
296
+ // Bytes, not `.length` (BLI-3482). `NOTE_SLOW_UPLOAD_BYTES` is a BYTE
297
+ // threshold, and the file path above already compares `bytes.byteLength`
298
+ // against it; `text.length` counts UTF-16 units, so a note in any non-Latin
299
+ // script was measured at a third to a half of the size actually being sent
300
+ // and the "this will take a while" line stayed silent through the wait.
301
+ const pastedBytes = Buffer.byteLength(text, "utf8");
302
+ if (pastedBytes >= NOTE_SLOW_UPLOAD_BYTES) {
303
+ writeLine(door.io.stderr, `Reading ${Math.round(pastedBytes / 1024)} KB of pasted text. This can take a couple of minutes.`);
298
304
  }
299
305
  const answer = await ask(door, {
300
306
  path: "/api/notes/upload",
@@ -311,6 +317,7 @@ async function pasteNote(command, door) {
311
317
  note_id: body.noteId ?? null,
312
318
  scope: body.scope ?? null,
313
319
  chars: text.length,
320
+ byte_size: pastedBytes,
314
321
  named_by_caller: Boolean(command.name),
315
322
  })}`);
316
323
  if (door.json)
@@ -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.40");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.41");
19
19
  return 0;
20
20
  }
21
21
 
@@ -15,12 +15,15 @@
15
15
  * **A bounded read says so.** The server returns at most 8 cards and 12
16
16
  * signals; when there are more, `truncationLines` prints how many. A quiet
17
17
  * board and a truncated board must never look alike.
18
+ *
19
+ * **Styling arrives as an argument, never as a global.** `styled` says whether
20
+ * this run's stdout is a terminal that asked for colour (BLI-3482); `scout.ts`
21
+ * decides it once with `colorEnabled(io)` and the layout stays deterministic.
18
22
  */
19
- const DIM = "\x1b[2m";
20
- const RESET = "\x1b[0m";
23
+ import { dim } from "./cli-io.js";
21
24
  // ------------------------------------------------------------------ rendering
22
25
  /** The whole board as terminal lines, in the page's three sections and order. */
23
- export function renderScoutBoard(payload) {
26
+ export function renderScoutBoard(payload, styled) {
24
27
  const board = payload.board ?? {};
25
28
  const lines = payload.lines ?? {};
26
29
  const out = [];
@@ -35,7 +38,7 @@ export function renderScoutBoard(payload) {
35
38
  }
36
39
  else {
37
40
  for (const card of experiments)
38
- out.push(...renderExperiment(card));
41
+ out.push(...renderExperiment(card, styled));
39
42
  }
40
43
  const settled = board.settled ?? [];
41
44
  if (settled.length > 0) {
@@ -51,14 +54,14 @@ export function renderScoutBoard(payload) {
51
54
  }
52
55
  else {
53
56
  for (const signal of signals)
54
- out.push(...renderSignal(signal));
57
+ out.push(...renderSignal(signal, styled));
55
58
  }
56
59
  const truncation = truncationLines(board);
57
60
  if (truncation.length > 0)
58
61
  out.push("", ...truncation);
59
62
  return out;
60
63
  }
61
- function renderExperiment(card) {
64
+ function renderExperiment(card, styled) {
62
65
  const sourceCount = card.sourceCount ?? 0;
63
66
  const head = [
64
67
  shortId(card.id ?? ""),
@@ -74,23 +77,25 @@ function renderExperiment(card) {
74
77
  if (card.claimSummary)
75
78
  out.push(` ${card.claimSummary}`);
76
79
  if (card.title)
77
- out.push(dim(` ${card.title}`));
80
+ out.push(dim(` ${card.title}`, styled));
78
81
  for (const row of card.crossref ?? []) {
79
- if (row.person)
80
- out.push(dim(` On your team · ${row.person} · ${row.observedPattern ?? ""}`));
82
+ if (row.person) {
83
+ out.push(dim(` On your team · ${row.person} · ${row.observedPattern ?? ""}`, styled));
84
+ }
81
85
  }
82
86
  if (card.expectedPayoff)
83
- out.push(dim(` Worth trying · ${card.expectedPayoff}`));
87
+ out.push(dim(` Worth trying · ${card.expectedPayoff}`, styled));
84
88
  for (const source of card.sources ?? []) {
85
- if (source.title)
86
- out.push(dim(` ${source.title} · ${source.source ?? ""} · ${source.url ?? ""}`));
89
+ if (source.title) {
90
+ out.push(dim(` ${source.title} · ${source.source ?? ""} · ${source.url ?? ""}`, styled));
91
+ }
87
92
  }
88
93
  return out;
89
94
  }
90
- function renderSignal(signal) {
95
+ function renderSignal(signal, styled) {
91
96
  const out = [` ⏺ ${signal.title ?? "untitled"} · ${dayStamp(signal.gatheredAt)}`];
92
97
  if (signal.synopsis)
93
- out.push(dim(` ${signal.synopsis}`));
98
+ out.push(dim(` ${signal.synopsis}`, styled));
94
99
  const foot = [
95
100
  signal.source,
96
101
  signal.evidenceStrength ?? undefined,
@@ -99,7 +104,7 @@ function renderSignal(signal) {
99
104
  signal.url,
100
105
  ].filter((part) => Boolean(part));
101
106
  if (foot.length > 0)
102
- out.push(dim(` ${foot.join(" · ")}`));
107
+ out.push(dim(` ${foot.join(" · ")}`, styled));
103
108
  return out;
104
109
  }
105
110
  /**
@@ -164,9 +169,6 @@ export function shortId(id) {
164
169
  function dayStamp(iso) {
165
170
  return iso ? iso.slice(0, 10) : "";
166
171
  }
167
- function dim(text) {
168
- return `${DIM}${text}${RESET}`;
169
- }
170
172
  function indent(text) {
171
173
  return ` ${text}`;
172
174
  }