@bli-cockpit/cli 0.2.9 → 0.2.11

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.
@@ -9,7 +9,7 @@ import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET }
9
9
  import { acquireBackfillLock } from "../backfill-lock.js";
10
10
  import { BACKFILL_COMPLETION_RECHECK_MS, BACKFILL_COVERAGE_VERSION, emptyBackfillCursorState, prepareBackfillCursorForScope, readBackfillCursor, recordBackfillCursorObservations, recordBackfillScanCoverage, writeBackfillCompletionMarker, writeBackfillCursor, } from "../cursors/backfill-cursor.js";
11
11
  import { getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, readLocalWorkContextForRepo, startLocalWorkContext } from "../local-state.js";
12
- import { collectionRootPathAliases, discoverGitWorktreesInRootsWithStatus, } from "../repo-identity.js";
12
+ import { DEFAULT_DISCOVERY_MAX_DEPTH, DEFAULT_DISCOVERY_MAX_REPOS, collectionRootPathAliases, discoverGitWorktreesInRootsWithStatus, } from "../repo-identity.js";
13
13
  import { isRawEvidenceUploadableAttributionState } from "../raw-evidence-attribution-policy.js";
14
14
  import { normalizeCollectionRoots } from "../root-normalization.js";
15
15
  import { acquireSyncLock } from "../sync-lock.js";
@@ -17,21 +17,21 @@ import { LocalUploadBlockedError, postCodexSessionReport, syncLocalAmbientEnvelo
17
17
  const BACKFILL_UPLOAD_BATCH_SESSIONS = 25;
18
18
  const BACKFILL_MAX_CONSECUTIVE_FAILURES = 3;
19
19
  const ALL_BACKFILL_SINCE_MINUTES = 20 * 365 * 24 * 60;
20
- const DEFAULT_DISCOVERY_MAX_DEPTH = 3;
21
- const DEFAULT_DISCOVERY_MAX_REPOS = 50;
20
+ // Window a bare `cockpit backfill` uses. Wide enough to cover a new machine's
21
+ // recent history and an intern who went quiet for a few weeks, narrow enough
22
+ // that it is not the whole-history scan `--all` deliberately gates.
23
+ export const DEFAULT_BACKFILL_SINCE_DAYS = 30;
22
24
  export async function runBackfillCommand(command, io) {
25
+ // A bare `cockpit backfill` used to refuse and print three lines telling the
26
+ // operator to pick a window. That put a mandatory flag on the command that
27
+ // matters most for coverage, so the honest default is to run the common case
28
+ // and say plainly which window was chosen. `--all` stays explicit because it
29
+ // is the expensive, whole-history scan.
30
+ let effective = command;
23
31
  if (!command.all && command.sinceDays === undefined) {
24
- const message = bareBackfillMessage();
25
- const retryCommand = backfillRetryCommand(command);
26
- writeLine(io.stderr, message);
27
- if (command.json) {
28
- writeLine(io.stdout, JSON.stringify({
29
- status: "blocked",
30
- reason: "missing_window",
31
- retry_command: retryCommand,
32
- }, null, 2));
33
- }
34
- return 1;
32
+ effective = { ...command, sinceDays: DEFAULT_BACKFILL_SINCE_DAYS };
33
+ const notice = defaultBackfillWindowNotice();
34
+ writeLine(command.json ? io.stderr : io.stdout, notice);
35
35
  }
36
36
  if (command.all && !command.yes && !isInteractiveStdin(io)) {
37
37
  const message = "--all requires TTY confirmation; pass --yes for agent runs.";
@@ -46,8 +46,8 @@ export async function runBackfillCommand(command, io) {
46
46
  }
47
47
  return 1;
48
48
  }
49
- const result = await runBackfill(command, io);
50
- if (command.json) {
49
+ const result = await runBackfill(effective, io);
50
+ if (effective.json) {
51
51
  writeLine(io.stdout, JSON.stringify(result, null, 2));
52
52
  return result.status === "complete" ? 0 : 1;
53
53
  }
@@ -508,11 +508,11 @@ export async function runBackfill(command, io) {
508
508
  await lock.handle.release();
509
509
  }
510
510
  }
511
- function bareBackfillMessage() {
511
+ function defaultBackfillWindowNotice() {
512
512
  return [
513
- "cockpit backfill needs an explicit window.",
514
- "Use `cockpit backfill --since-days N` for post-pairing history; the effective start is capped at the collector paired_at timestamp.",
515
- "Use `cockpit backfill --all` only after reviewing a dry-run; on headless agent runs add `--yes`.",
513
+ `No window given — backfilling the last ${DEFAULT_BACKFILL_SINCE_DAYS} days.`,
514
+ "The effective start is capped at the collector paired_at timestamp.",
515
+ "Use `cockpit backfill --since-days N` for a different window, or `cockpit backfill --all` for the full local history (review a dry-run first; add `--yes` on headless agent runs).",
516
516
  ].join("\n");
517
517
  }
518
518
  /**
@@ -7,6 +7,18 @@ import { DEFAULT_DASHBOARD_URL } from "../local-state.js";
7
7
  import { DEFAULT_AUTOSTART_INTERVAL_SECONDS } from "../autostart.js";
8
8
  import { IntentSourceSchema, WorkIntentSchema, WorkPhaseSchema, } from "@bli-cockpit/telemetry-core";
9
9
  const WORK_ROOT_FLAGS = ["--repo", "--workspace"];
10
+ // The six human "set my machine up" doors. They are one thing wearing six
11
+ // hats, so they all run the convergence command — but they keep accepting the
12
+ // flags they always accepted, because DMs, runbooks and AGENTS.md rules across
13
+ // the fleet still spell them out.
14
+ export const DOCTOR_SETUP_ALIASES = [
15
+ "install",
16
+ "onboard",
17
+ "login",
18
+ "pair",
19
+ "update",
20
+ "upgrade",
21
+ ];
10
22
  export function parseLocalArgs(argv) {
11
23
  const command = argv[0];
12
24
  switch (command) {
@@ -15,6 +27,10 @@ export function parseLocalArgs(argv) {
15
27
  case "update":
16
28
  case "upgrade":
17
29
  return parseUpdateArgs(command, argv.slice(1));
30
+ // NOT yet aliased to the convergence command. `onboard --ticket <id>` binds
31
+ // work to a ticket, and routing it here would accept the flag and drop the
32
+ // binding with no error — the silent breakage BLI-2490 explicitly forbids.
33
+ // Aliasing lands once the convergence run honours --ticket. See BLI-2494.
18
34
  case "do-everything":
19
35
  case "fix":
20
36
  return parseDoctorArgs(command, argv.slice(1));
@@ -114,15 +130,46 @@ function parseUpdateArgs(alias, args) {
114
130
  };
115
131
  }
116
132
  function parseDoctorArgs(alias, args) {
133
+ // The allowed set is the UNION of what the six setup doors used to accept.
134
+ // An alias that silently rejected a flag its own docs told people to pass
135
+ // would be a worse dead end than the one we are removing.
117
136
  const values = parseNamedArgs(args, {
118
137
  allowedFlags: [
138
+ "--home",
139
+ "--repo",
119
140
  "--workspace",
120
141
  "--dashboard-url",
121
142
  "--update-tag",
122
143
  "--dry-run",
123
144
  "--json",
145
+ "--allow-home-root",
146
+ "--max-depth",
147
+ "--max-repos",
148
+ // Accepted and ignored: the convergence run works these out itself.
149
+ // Rejecting them would break existing DMs and runbooks for no gain.
150
+ "--email",
151
+ "--device-name",
152
+ "--ticket",
153
+ "--branch",
154
+ "--no-auth",
155
+ "--poll-interval-ms",
156
+ "--timeout-ms",
157
+ ],
158
+ valueFlags: [
159
+ "--home",
160
+ "--repo",
161
+ "--workspace",
162
+ "--dashboard-url",
163
+ "--update-tag",
164
+ "--max-depth",
165
+ "--max-repos",
166
+ "--email",
167
+ "--device-name",
168
+ "--ticket",
169
+ "--branch",
170
+ "--poll-interval-ms",
171
+ "--timeout-ms",
124
172
  ],
125
- valueFlags: ["--workspace", "--dashboard-url", "--update-tag"],
126
173
  });
127
174
  assertNoPositionals(values.positionals, alias);
128
175
  const updateTag = optionalNonEmpty(values.flags.get("--update-tag"));
@@ -132,11 +179,16 @@ function parseDoctorArgs(alias, args) {
132
179
  return {
133
180
  kind: "doctor",
134
181
  alias,
135
- repoRoot: optionalNonEmpty(values.flags.get("--workspace")),
182
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
183
+ repoRoot: optionalNonEmpty(workRootFlagValue(values)),
184
+ collectionRoots: optionalNonEmptyList(workRootFlagValues(values)),
136
185
  dashboardUrl: normalizeUrl(values.flags.get("--dashboard-url") ?? DEFAULT_DASHBOARD_URL),
137
186
  updateTag,
138
187
  dryRun: values.booleans.has("--dry-run"),
139
188
  json: values.booleans.has("--json"),
189
+ allowHomeRoot: values.booleans.has("--allow-home-root"),
190
+ maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
191
+ maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
140
192
  };
141
193
  }
142
194
  function parseInstallArgs(args) {
@@ -10,11 +10,12 @@ import { inspectBackfillLock } from "../backfill-lock.js";
10
10
  import { parseLocalArgs, normalizeUrl } from "./local-args.js";
11
11
  import { autostartStatus, installAutostartAgent, uninstallAutostartAgent, } from "../autostart.js";
12
12
  import { DEFAULT_DASHBOARD_URL, ensureLocalCollectorConfig, getCollectorRuntimePaths, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext, } from "../local-state.js";
13
+ import { redactSecretLikeContent, } from "@bli-cockpit/telemetry-core";
13
14
  import { CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT, CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES, defaultCodexSessionDirs, scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
14
15
  import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
15
16
  import { backfillCompletionCovers, emptyBackfillCursorState, prepareBackfillCursorForScope, readBackfillCompletionMarker, readBackfillCursor, } from "../cursors/backfill-cursor.js";
16
17
  import { acquireSyncLock } from "../sync-lock.js";
17
- import { collectionRootPathAliases, discoverGitWorktreesInRootsWithStatus, } from "../repo-identity.js";
18
+ import { DEFAULT_DISCOVERY_MAX_DEPTH, DEFAULT_DISCOVERY_MAX_REPOS, collectionRootPathAliases, discoverGitWorktreesInRootsWithStatus, } from "../repo-identity.js";
18
19
  import { runAttributedWorktreeSync, matchesLiveSyncWorktree, } from "./session-sync.js";
19
20
  import { COLLECTION_ROOT_REQUIRED, missingCollectionRootMessage, normalizeRootsDetailed, resolveOnboardingRoots, rootRejectionExplanation, } from "../onboarding-roots.js";
20
21
  import { rawEvidenceGcSummary, runRawEvidenceLocalGc, } from "../raw-evidence-gc.js";
@@ -367,6 +368,9 @@ async function runInstall(command, io) {
367
368
  addInstallEvent(installEvents, "home_root_optin", "ok");
368
369
  }
369
370
  const result = await installLocalCollector(resolved.command);
371
+ // Same invariant as the onboarding path: never report a successful install
372
+ // over a config that saved no usable collection root.
373
+ await assertCollectionRootPersisted(resolved.command.homeDir);
370
374
  addInstallEvent(installEvents, "install", "ok");
371
375
  if (command.json) {
372
376
  writeLine(io.stdout, JSON.stringify(result, null, 2));
@@ -707,6 +711,15 @@ export async function reportInstallEventsBestEffort(options) {
707
711
  ...(event.error_code
708
712
  ? { error_code: sanitizeInstallErrorCode(event.error_code) }
709
713
  : {}),
714
+ // Already redacted and capped at the point it was produced; bounded
715
+ // again here because this mapping is what the server contract sees.
716
+ ...(event.error_detail
717
+ ? {
718
+ error_detail: event.error_detail
719
+ .trim()
720
+ .slice(0, SYNC_ERROR_DETAIL_MAX_CHARS),
721
+ }
722
+ : {}),
710
723
  ...(event.at ? { at: event.at } : {}),
711
724
  })),
712
725
  });
@@ -1199,7 +1212,7 @@ async function resolveOnboardingRootsForCommand(command, io) {
1199
1212
  };
1200
1213
  }
1201
1214
  async function persistOnboardingRootConfig(command, resolution) {
1202
- return installLocalCollector({
1215
+ const result = await installLocalCollector({
1203
1216
  homeDir: command.homeDir,
1204
1217
  repoRoot: resolution.primaryRoot,
1205
1218
  repoRoots: resolution.collectionRoots,
@@ -1207,6 +1220,88 @@ async function persistOnboardingRootConfig(command, resolution) {
1207
1220
  dashboardUrl: command.dashboardUrl,
1208
1221
  deviceName: command.deviceName,
1209
1222
  });
1223
+ await assertCollectionRootPersisted(command.homeDir);
1224
+ return result;
1225
+ }
1226
+ /**
1227
+ * Setup does not get to claim success on its own say-so.
1228
+ *
1229
+ * Onboarding used to write the config and report success without ever reading
1230
+ * it back. Savina's onboard did exactly that, persisted nothing, and every
1231
+ * scheduled sync afterwards threw `collection_root_required` into a log nobody
1232
+ * reads — twelve consecutive failures, six days at 3 uploaded of 195, found
1233
+ * only by hand-querying the database. BLI-1986 fixed one path into that state;
1234
+ * this closes the state itself.
1235
+ *
1236
+ * So we read the config back through the SAME resolution the scheduled sync
1237
+ * will use, and fail here — in front of a human who can still fix it — rather
1238
+ * than silently handing back a machine that will never collect.
1239
+ */
1240
+ export async function assertCollectionRootPersisted(homeDir) {
1241
+ const config = await readLocalCollectorConfig(getCollectorRuntimePaths(homeDir)).catch(() => null);
1242
+ const saved = normalizeCollectionRoots(config?.default_repo_paths ?? []);
1243
+ if (saved.length === 0) {
1244
+ throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${collectionRootNotPersistedMessage(homeDir)}`);
1245
+ }
1246
+ // Present in the file is not the same as usable. A root that no longer
1247
+ // exists on disk resolves to nothing at sync time, which is the same silent
1248
+ // dead end arriving one step later.
1249
+ const usable = [];
1250
+ for (const root of saved) {
1251
+ if (await directoryExists(root))
1252
+ usable.push(root);
1253
+ }
1254
+ if (usable.length === 0) {
1255
+ throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${collectionRootMissingOnDiskMessage(saved, homeDir)}`);
1256
+ }
1257
+ return usable;
1258
+ }
1259
+ // Placeholders like <path-to-your-work-folder> make a person stop and think.
1260
+ // These print real, paste-able commands with this machine's actual paths in
1261
+ // them, so the fix is a copy away rather than a puzzle.
1262
+ function collectionRootNotPersistedMessage(homeDir) {
1263
+ const home = path.resolve(homeDir ?? os.homedir());
1264
+ return [
1265
+ "Setup finished without saving a collection root, so this machine would never collect anything.",
1266
+ "Nothing was saved, so nothing is broken — setup just did not finish.",
1267
+ "",
1268
+ "Fix it by running ONE of these:",
1269
+ "",
1270
+ " # Sync everything on this machine (what most people want on a work laptop)",
1271
+ " cockpit do-everything --allow-home-root",
1272
+ "",
1273
+ " # Or sync one folder — replace the path with where your projects live",
1274
+ ` cockpit do-everything --workspace ${path.join(home, "BLI")}`,
1275
+ "",
1276
+ " # Or answer the folder question interactively",
1277
+ " cockpit do-everything",
1278
+ "",
1279
+ "Then check it worked:",
1280
+ " cockpit status",
1281
+ ].join("\n");
1282
+ }
1283
+ function collectionRootMissingOnDiskMessage(saved, homeDir) {
1284
+ const home = path.resolve(homeDir ?? os.homedir());
1285
+ return [
1286
+ "Cockpit is set up to collect from a folder that is not on this machine:",
1287
+ ...saved.map((root) => ` ${root}`),
1288
+ "",
1289
+ "That usually means the folder was renamed, moved, or deleted since setup.",
1290
+ "",
1291
+ "Fix it by running ONE of these:",
1292
+ "",
1293
+ " # Point Cockpit at where your projects actually live now",
1294
+ ` cockpit do-everything --workspace ${path.join(home, "BLI")}`,
1295
+ "",
1296
+ " # Or sync everything on this machine and stop worrying about the path",
1297
+ " cockpit do-everything --allow-home-root",
1298
+ "",
1299
+ "Not sure where your projects are? This lists the folders Cockpit can see:",
1300
+ " cockpit status",
1301
+ ].join("\n");
1302
+ }
1303
+ async function directoryExists(dir) {
1304
+ return stat(dir).then((stats) => stats.isDirectory(), () => false);
1210
1305
  }
1211
1306
  async function runDoctorLogin(command, io) {
1212
1307
  return runLogin({
@@ -1218,9 +1313,15 @@ async function runDoctorLogin(command, io) {
1218
1313
  }
1219
1314
  async function resolveAndSaveDoctorRoots(command, io) {
1220
1315
  const rootCommand = {
1316
+ homeDir: command.homeDir,
1221
1317
  repoRoot: command.repoRoot,
1318
+ collectionRoots: command.collectionRoots,
1222
1319
  dashboardUrl: command.dashboardUrl,
1223
1320
  json: command.json,
1321
+ // Interactively this is not needed — the convergence run reaches the
1322
+ // home-folder consent prompt and Enter accepts it. The flag is the
1323
+ // headless equivalent for scripted and scheduled runs.
1324
+ allowHomeRoot: command.allowHomeRoot,
1224
1325
  };
1225
1326
  const resolution = await resolveOnboardingRootsForCommand(rootCommand, io);
1226
1327
  await persistOnboardingRootConfig(rootCommand, resolution);
@@ -1678,8 +1779,6 @@ function rawEvidenceSyncLine(sync) {
1678
1779
  function cursorStatusLine(sync) {
1679
1780
  return `Cursor: ${sync.cursor_tracked_object_count} durable object(s) tracked`;
1680
1781
  }
1681
- const DEFAULT_DISCOVERY_MAX_DEPTH = 3;
1682
- const DEFAULT_DISCOVERY_MAX_REPOS = 50;
1683
1782
  const ALL_SESSION_SCAN_WINDOW_MINUTES = 20 * 365 * 24 * 60;
1684
1783
  const SESSION_SCAN_OVERRIDE_LIMIT = 10_000;
1685
1784
  async function discoverCommandWorktrees(repoRoot, discovery = {}, io) {
@@ -1693,17 +1792,59 @@ async function discoverCommandWorktrees(repoRoot, discovery = {}, io) {
1693
1792
  });
1694
1793
  const worktrees = result.worktrees;
1695
1794
  if (!result.complete) {
1696
- const reasons = result.incomplete_reasons.join(",");
1697
- if (io) {
1698
- writeLine(io.stderr, `BLOCKED: repo discovery is incomplete (${reasons}); no collection cursor was advanced.`);
1699
- }
1700
- throw new Error(`repo discovery incomplete: ${reasons}. Raise --max-depth or --max-repos until every approved-root repo is included.`);
1795
+ // Sync fails closed here ON PURPOSE, and that is not the bug. Advancing a
1796
+ // cursor after a partial scan would mark the run as covering repos it
1797
+ // never saw, permanently skipping their sessions — backfill can tolerate
1798
+ // partial only because it keeps per-scope completion markers, and sync
1799
+ // does not. The bug (BLI-2362) was that the refusal named no roots and
1800
+ // gave no runnable command, so a big workspace just stayed red forever.
1801
+ const message = incompleteDiscoveryMessage({
1802
+ result,
1803
+ roots,
1804
+ maxDepth: discovery.maxDepth ?? DEFAULT_DISCOVERY_MAX_DEPTH,
1805
+ maxRepos: maxWorktrees,
1806
+ found: worktrees.length,
1807
+ });
1808
+ if (io)
1809
+ writeLine(io.stderr, message);
1810
+ throw new Error(message);
1701
1811
  }
1702
1812
  if (worktrees.length === 0 && !discovery.allowEmpty) {
1703
1813
  throw new Error("No git repos found. Run from a git repo, or from a parent folder containing git repos.");
1704
1814
  }
1705
1815
  return worktrees;
1706
1816
  }
1817
+ /**
1818
+ * Names the roots that could not be covered and hands back a command that
1819
+ * actually fixes it, with this machine's numbers already filled in.
1820
+ */
1821
+ function incompleteDiscoveryMessage(input) {
1822
+ const { result, roots, maxDepth, maxRepos, found } = input;
1823
+ const blocked = result.incomplete_roots.length > 0 ? result.incomplete_roots : roots;
1824
+ const hitRepoCap = result.incomplete_reasons.includes("max_worktrees_reached");
1825
+ const nextDepth = maxDepth + 3;
1826
+ const nextRepos = Math.max(maxRepos * 2, found + 50);
1827
+ const retry = [
1828
+ "cockpit do-everything",
1829
+ ...roots.map((root) => `--workspace ${root}`),
1830
+ `--max-depth ${hitRepoCap ? maxDepth : nextDepth}`,
1831
+ `--max-repos ${nextRepos}`,
1832
+ ].join(" ");
1833
+ return [
1834
+ `Cockpit could not finish scanning for repos, so it stopped instead of collecting a partial picture (${result.incomplete_reasons.join(", ")}).`,
1835
+ "It stops rather than continuing because a partial scan would mark these repos as already checked and skip them from now on.",
1836
+ "",
1837
+ "Could not fully scan:",
1838
+ ...blocked.map((root) => ` ${root}`),
1839
+ "",
1840
+ `Found ${found} repo(s) before stopping, with --max-depth ${maxDepth} and --max-repos ${maxRepos}.`,
1841
+ "",
1842
+ "Run this to raise the limits and try again:",
1843
+ ` ${retry}`,
1844
+ "",
1845
+ "If that still stops, the folder is deeper or larger than expected — raise the numbers again, or point --workspace at the specific project folders instead of a parent.",
1846
+ ].join("\n");
1847
+ }
1707
1848
  async function runMultiRepoOnboard(command, io, worktrees) {
1708
1849
  if (!command.json) {
1709
1850
  writeLine(io.stdout, `3/5 Parent folder mode: discovered ${worktrees.length} git worktree(s).`);
@@ -1974,6 +2115,7 @@ async function runSync(command, io) {
1974
2115
  step: "sync_complete",
1975
2116
  status: "fail",
1976
2117
  error_code: classifySyncHealthError(error),
2118
+ error_detail: redactedSyncErrorDetail(error),
1977
2119
  },
1978
2120
  ],
1979
2121
  json: command.json,
@@ -2048,7 +2190,7 @@ async function runSyncWithHealthReceipt(command, io) {
2048
2190
  await lock.handle.release();
2049
2191
  }
2050
2192
  }
2051
- function classifySyncHealthError(error) {
2193
+ export function classifySyncHealthError(error) {
2052
2194
  const message = errorMessage(error);
2053
2195
  if (/auth|token|session|unauthorized|forbidden|401|403/iu.test(message)) {
2054
2196
  return "auth_failed";
@@ -2056,11 +2198,33 @@ function classifySyncHealthError(error) {
2056
2198
  if (/fetch|network|enotfound|econnrefused|timeout/iu.test(message)) {
2057
2199
  return "network_failed";
2058
2200
  }
2059
- if (/collection.root|workspace|repo|worktree/iu.test(message)) {
2201
+ // Anchored on the code the collector actually throws rather than on loose
2202
+ // vocabulary. The old test matched /collection.root|workspace|repo|worktree/
2203
+ // against the message, so any failure that merely mentioned a repo was filed
2204
+ // as a collection-root failure and the real reason was lost (BLI-2492).
2205
+ if (message.includes(COLLECTION_ROOT_REQUIRED) ||
2206
+ /collection root/iu.test(message)) {
2060
2207
  return "collection_root_failed";
2061
2208
  }
2062
2209
  return "sync_failed";
2063
2210
  }
2211
+ // The bucket above is for aggregation. This is the reason — the actual message,
2212
+ // redacted on the machine that produced it, before it ever leaves.
2213
+ //
2214
+ // Error text can carry absolute paths and, on some auth failures, token-shaped
2215
+ // fragments. It goes through the same deterministic redaction the collector
2216
+ // already applies to evidence, and is capped so one pathological stack trace
2217
+ // cannot dominate a health receipt.
2218
+ export const SYNC_ERROR_DETAIL_MAX_CHARS = 600;
2219
+ export function redactedSyncErrorDetail(error) {
2220
+ const message = errorMessage(error).replace(/\s+/gu, " ").trim();
2221
+ const { text } = redactSecretLikeContent(message, {
2222
+ appliedBy: "local_collector",
2223
+ });
2224
+ return text.length > SYNC_ERROR_DETAIL_MAX_CHARS
2225
+ ? `${text.slice(0, SYNC_ERROR_DETAIL_MAX_CHARS - 1)}…`
2226
+ : text;
2227
+ }
2064
2228
  async function runSyncLocked(command, io) {
2065
2229
  const collectionRoots = await resolveSyncCollectionRoots(command);
2066
2230
  const worktrees = await discoverCommandWorktrees(collectionRoots, {
@@ -2,13 +2,20 @@ import { localCommandHelp, runLocalCockpitCli, rootCommandNames } from "./local.
2
2
 
3
3
  export async function runCockpitCli(argv, io) {
4
4
  const command = argv[0];
5
- if (!command || command === "--help" || command === "-h") {
5
+ // Bare `cockpit` runs the convergence command instead of printing a
6
+ // usage wall (BLI-2490). This file is GENERATED and does not import
7
+ // commands/root.ts, so the routing has to be mirrored here or the fix
8
+ // ships to nobody.
9
+ if (!command) {
10
+ return runLocalCockpitCli(["do-everything"], io);
11
+ }
12
+ if (command === "--help" || command === "-h") {
6
13
  writeLine(io?.stdout ?? process.stdout, cockpitHelp());
7
14
  return 0;
8
15
  }
9
16
 
10
17
  if (command === "--version" || command === "-V" || command === "version") {
11
- writeLine(io?.stdout ?? process.stdout, "0.2.9");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.11");
12
19
  return 0;
13
20
  }
14
21
 
@@ -24,6 +31,11 @@ export async function runCockpitCli(argv, io) {
24
31
 
25
32
  function cockpitHelp() {
26
33
  return [
34
+ "Just run `cockpit do-everything` — it takes this machine from nothing to collecting and asks you anything it needs.",
35
+ "(Running plain `cockpit` does the same thing.)",
36
+ "",
37
+ "Everything below is for scripting and agents.",
38
+ "",
27
39
  "Usage:",
28
40
  localCommandHelp(),
29
41
  "",
@@ -6,7 +6,7 @@ import { isSamePath, normalizeCollectionRoots, } from "./root-normalization.js";
6
6
  export const COLLECTION_ROOT_REQUIRED = "collection_root_required";
7
7
  export function homeRootConsentPrompt(homeDirInput) {
8
8
  const homeDir = path.resolve(homeDirInput ?? os.homedir());
9
- return `You're in your home folder (${homeDir}).\n Sync ALL projects on this machine? Every git repo under here gets captured now and in the future.\n This is a work machine — exclude personal projects yourself if needed. [y/N]: `;
9
+ return `You're in your home folder (${homeDir}).\n Sync ALL projects on this machine? Every git repo under here gets captured now and in the future.\n This is a work machine — exclude personal projects yourself if needed.\n Press Enter to sync everything, or answer n to name one folder instead. [Y/n]: `;
10
10
  }
11
11
  export const HOME_ROOT_DECLINE_PATH_PROMPT = "Okay — which folder should I sync? Enter the full path to your work directory: ";
12
12
  export const HOME_ROOT_NO_FOLDER_CHOSEN_MESSAGE = "No folder chosen. Re-run: cockpit onboard --workspace <path-to-your-work-folder>";
@@ -223,9 +223,20 @@ async function promptForHomeRootOptIn(options, rejections) {
223
223
  }
224
224
  return promptForDeclinedHomeRoot(options, homeDir);
225
225
  }
226
+ // Bare Enter is the answer most people give, so it has to mean the thing that
227
+ // collects. It used to mean decline, and the decline path then asks for a
228
+ // folder that is not home — which a person who keeps every project directly in
229
+ // their home folder cannot answer. They finished onboarding with zero roots and
230
+ // every session on the machine was legitimately out of scope forever after.
231
+ // Anything that isn't recognisably yes still routes to the decline prompt,
232
+ // where a typed path is recoverable rather than lost.
226
233
  function isHomeRootYes(raw) {
227
234
  const answer = raw.trim().split(/\s+/u)[0]?.toLowerCase() ?? "";
228
- return answer === "y" || answer === "yes";
235
+ if (answer === "")
236
+ return true;
237
+ if (answer.startsWith("n"))
238
+ return false;
239
+ return answer.startsWith("y");
229
240
  }
230
241
  async function promptForDeclinedHomeRoot(options, homeDir) {
231
242
  const prompt = requirePrompt(options);
@@ -16,6 +16,21 @@ const SKIPPED_DIR_NAMES = new Set([
16
16
  "node_modules",
17
17
  "out",
18
18
  ]);
19
+ /**
20
+ * How deep discovery walks, and how many repos it will hold, when the caller
21
+ * names no limit. Both are the ONLY defaults — commands import these rather
22
+ * than declaring their own, because a second copy silently drifts.
23
+ *
24
+ * These are generous on purpose. Discovery stops descending the moment it sees
25
+ * a git marker and skips dot-dirs, `node_modules`, `dist` and friends, so the
26
+ * walk is bounded by plain folders rather than by repo contents: on a real
27
+ * workspace of 38 repos, depth 20 visited the same 308 directories in the same
28
+ * ~110ms as depth 6, because the tree simply ran out first. A limit that is too
29
+ * low is not merely slow — discovery fails closed, so every extra level costs
30
+ * nothing while every missing level costs the whole machine's collection.
31
+ */
32
+ export const DEFAULT_DISCOVERY_MAX_DEPTH = 20;
33
+ export const DEFAULT_DISCOVERY_MAX_REPOS = 200;
19
34
  export async function resolveRepoWorktreeIdentity(repoRoot) {
20
35
  const requestedPath = path.resolve(repoRoot);
21
36
  const gitRoot = await runGit(["rev-parse", "--show-toplevel"], requestedPath);
@@ -62,14 +77,14 @@ export async function discoverGitWorktrees(root, options = {}) {
62
77
  export async function discoverGitWorktreesWithStatus(root, options = {}) {
63
78
  const resolvedRoot = path.resolve(root);
64
79
  const allowedRoots = [await stableWorktreeRoot(resolvedRoot)];
65
- const maxWorktrees = Math.max(1, options.maxWorktrees ?? 50);
80
+ const maxWorktrees = Math.max(1, options.maxWorktrees ?? DEFAULT_DISCOVERY_MAX_REPOS);
66
81
  const direct = await resolveRepoWorktreeIdentity(resolvedRoot).catch(() => null);
67
82
  if (direct)
68
83
  return expandLinkedWorktrees([direct], maxWorktrees, allowedRoots);
69
84
  if (await hasGitMarker(resolvedRoot)) {
70
85
  return expandLinkedWorktrees([await fallbackFilesystemIdentity(resolvedRoot)], maxWorktrees, allowedRoots);
71
86
  }
72
- const maxDepth = options.maxDepth ?? 2;
87
+ const maxDepth = options.maxDepth ?? DEFAULT_DISCOVERY_MAX_DEPTH;
73
88
  const discovered = new Map();
74
89
  const stack = [{ dir: resolvedRoot, depth: 0 }];
75
90
  const visited = new Set();
@@ -113,6 +128,8 @@ export async function discoverGitWorktreesWithStatus(root, options = {}) {
113
128
  worktrees: expanded.worktrees,
114
129
  complete: incompleteReasons.size === 0,
115
130
  incomplete_reasons: [...incompleteReasons].sort(),
131
+ // Single-root scanner: the only root in play is the one it was handed.
132
+ incomplete_roots: incompleteReasons.size === 0 ? [] : [path.resolve(root)],
116
133
  };
117
134
  }
118
135
  /**
@@ -126,9 +143,10 @@ export async function discoverGitWorktreesInRoots(roots, options = {}) {
126
143
  return (await discoverGitWorktreesInRootsWithStatus(roots, options)).worktrees;
127
144
  }
128
145
  export async function discoverGitWorktreesInRootsWithStatus(roots, options = {}) {
129
- const maxWorktrees = Math.max(1, options.maxWorktrees ?? 50);
146
+ const maxWorktrees = Math.max(1, options.maxWorktrees ?? DEFAULT_DISCOVERY_MAX_REPOS);
130
147
  const discovered = new Map();
131
148
  const incompleteReasons = new Set();
149
+ const incompleteRoots = new Set();
132
150
  for (const root of roots) {
133
151
  const result = await discoverGitWorktreesWithStatus(root, {
134
152
  ...options,
@@ -136,11 +154,15 @@ export async function discoverGitWorktreesInRootsWithStatus(roots, options = {})
136
154
  });
137
155
  for (const reason of result.incomplete_reasons) {
138
156
  incompleteReasons.add(reason);
157
+ incompleteRoots.add(root);
139
158
  }
140
159
  for (const worktree of result.worktrees) {
141
160
  if (discovered.size >= maxWorktrees) {
142
161
  if (!discovered.has(worktree.worktree_fingerprint)) {
143
162
  incompleteReasons.add("max_worktrees_reached");
163
+ // The cap is global, so the root being read when it filled up is the
164
+ // one whose repos got dropped.
165
+ incompleteRoots.add(root);
144
166
  }
145
167
  continue;
146
168
  }
@@ -151,6 +173,7 @@ export async function discoverGitWorktreesInRootsWithStatus(roots, options = {})
151
173
  worktrees: [...discovered.values()].sort(compareIdentity),
152
174
  complete: incompleteReasons.size === 0,
153
175
  incomplete_reasons: [...incompleteReasons].sort(),
176
+ incomplete_roots: [...incompleteRoots].sort(),
154
177
  };
155
178
  }
156
179
  /**
@@ -219,6 +242,9 @@ async function expandLinkedWorktrees(identities, maxWorktrees, allowedRoots) {
219
242
  worktrees,
220
243
  complete: incompleteReasons.length === 0,
221
244
  incomplete_reasons: incompleteReasons,
245
+ // Linked-worktree expansion is not scoped to one root; callers merge this
246
+ // into a result that already knows which roots were involved.
247
+ incomplete_roots: [],
222
248
  };
223
249
  }
224
250
  function isLinkedWorktreeWithinCollectionScope(linked, discoveredFromApprovedRoots, allowedRoots) {
@@ -23,6 +23,11 @@ export async function enqueueInstallEventEntry(paths, options) {
23
23
  step: event.step,
24
24
  status: event.status,
25
25
  ...(event.error_code ? { error_code: event.error_code } : {}),
26
+ // Spooling must not quietly downgrade a receipt. This mapping copies
27
+ // named fields, so a new one has to be added here too — otherwise a
28
+ // failure that could not be delivered immediately loses its reason on
29
+ // the way to disk and replays as a bare bucket (BLI-2492).
30
+ ...(event.error_detail ? { error_detail: event.error_detail } : {}),
26
31
  at: event.at ?? createdAt,
27
32
  })),
28
33
  };
@@ -157,6 +162,9 @@ function parseEvent(value) {
157
162
  ...(stringValue(record["error_code"])
158
163
  ? { error_code: stringValue(record["error_code"]) }
159
164
  : {}),
165
+ ...(stringValue(record["error_detail"])
166
+ ? { error_detail: stringValue(record["error_detail"]) }
167
+ : {}),
160
168
  at,
161
169
  };
162
170
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,7 +23,7 @@
23
23
  "pretypecheck": "npm run build",
24
24
  "typecheck": "node -e \"await import('./dist/commands/public-root.js')\"",
25
25
  "pretest": "npm run build",
26
- "test": "node dist/cli.js --help && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
26
+ "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
27
27
  },
28
28
  "dependencies": {
29
29
  "@bli-cockpit/telemetry-core": "0.1.15"