@bli-cockpit/cli 0.2.46 → 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.
@@ -0,0 +1,144 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ /**
4
+ * BLI-3553. Measured on the reference Mac, 2026-09-04, not estimated:
5
+ * `sync.log` 219,866,695 bytes and `sync.err.log` 59,635,270 after two months
6
+ * of a 15-minute tick. The repo's logging contract says log the success branch
7
+ * too, which is correct and is exactly why the files grow: the cost of that
8
+ * rule is bounded here, not by logging less.
9
+ *
10
+ * A cap DID exist for sync.log — buried in `raw-evidence-gc.ts`, 50 MB,
11
+ * truncate-to-zero with no archive. It could not work: the GC is throttled to
12
+ * once a day and skipped outright by `COCKPIT_DISABLE_GC`, while the log grows
13
+ * about 8 MB an hour on an active machine, so the file spent almost all of its
14
+ * life several times over the cap (last GC 19 hours before the measurement
15
+ * above). sync.err.log had no cap at all. That copy is gone; this is the one
16
+ * owner, it runs every tick, and it keeps an archive instead of destroying the
17
+ * only record of what happened.
18
+ *
19
+ * WHY TRUNCATE-AFTER-COPY, NOT RENAME-AND-REOPEN
20
+ *
21
+ * launchd opens StandardOutPath/StandardErrorPath once and holds that fd for
22
+ * the life of the job (the paths are right there in the `launchctl print`
23
+ * capture pinned in autostart.test.ts). Renaming the file does not move the fd:
24
+ * the job would keep writing into the renamed — eventually unlinked — inode, so
25
+ * the disk would never be reclaimed and `sync.err.log` would stop receiving new
26
+ * lines entirely until the agent was reloaded. Only the tick can reopen it, and
27
+ * the tick is not the writer; launchd is.
28
+ *
29
+ * A dated StandardErrorPath plus a pruner was the other candidate and is worse:
30
+ * it changes the plist on a schedule, every change has to be re-registered
31
+ * (which on macOS means the BLI-2583 bootout/bootstrap hazard), and it makes
32
+ * the rendered-plist comparison in autostartStatus a moving target — the exact
33
+ * shape that made every Windows machine read "needs repair" in BLI-2541.
34
+ *
35
+ * So: copy the last `maxBytes` of the file aside, then `truncate(path, 0)`.
36
+ * Same inode, so launchd's fd stays valid and its next write lands at offset 0
37
+ * of a file operators can read. The tail is capped rather than copied whole, so
38
+ * the archives cannot themselves become the disk problem — worst case on disk
39
+ * is (keep + 1) x maxBytes per stream.
40
+ */
41
+ export const DEFAULT_LOG_ROTATION_MAX_BYTES = 20 * 1024 * 1024;
42
+ export const DEFAULT_LOG_ROTATION_KEEP = 3;
43
+ /** The two files launchd writes for the scheduled tick. */
44
+ export const ROTATED_LOG_NAMES = ["sync.log", "sync.err.log"];
45
+ /**
46
+ * Caps the scheduled tick's own logs. Never throws: a rotation failure is data
47
+ * for the caller to log, never a reason a sync does not run.
48
+ */
49
+ export async function rotateCollectorLogs(paths, options = {}) {
50
+ const maxBytes = options.maxBytes ?? DEFAULT_LOG_ROTATION_MAX_BYTES;
51
+ const keep = options.keep ?? DEFAULT_LOG_ROTATION_KEEP;
52
+ const names = options.names ?? ROTATED_LOG_NAMES;
53
+ const result = { rotated: [], failures: [] };
54
+ for (const name of names) {
55
+ const filePath = path.join(paths.state_dir, name);
56
+ const size = await fs
57
+ .stat(filePath)
58
+ .then((info) => (info.isFile() ? info.size : null))
59
+ .catch(() => null);
60
+ if (size === null || size <= maxBytes)
61
+ continue;
62
+ try {
63
+ const kept = await rotateOne(filePath, maxBytes, keep);
64
+ result.rotated.push({
65
+ name,
66
+ bytes_before: size,
67
+ bytes_kept: kept,
68
+ archives: keep,
69
+ });
70
+ }
71
+ catch (error) {
72
+ result.failures.push({
73
+ name,
74
+ reason: error instanceof Error && error.message
75
+ ? `rotation_failed:${error.code ?? "unknown"}`
76
+ : "rotation_failed:unknown",
77
+ });
78
+ }
79
+ }
80
+ return result;
81
+ }
82
+ async function rotateOne(filePath, maxBytes, keep) {
83
+ // Shift the archives down first: .2 -> .3, .1 -> .2, and drop whatever fell
84
+ // off the end. Done before the copy so a crash mid-rotation loses an old
85
+ // archive, never the live log.
86
+ await fs.rm(`${filePath}.${keep}`, { force: true });
87
+ for (let index = keep - 1; index >= 1; index -= 1) {
88
+ await fs
89
+ .rename(`${filePath}.${index}`, `${filePath}.${index + 1}`)
90
+ .catch(() => undefined);
91
+ }
92
+ const handle = await fs.open(filePath, "r+");
93
+ try {
94
+ const info = await handle.stat();
95
+ const start = Math.max(0, info.size - maxBytes);
96
+ const buffer = Buffer.allocUnsafe(info.size - start);
97
+ await handle.read(buffer, 0, buffer.length, start);
98
+ // Drop the partial first line so the archive never opens mid-JSON.
99
+ const newline = start > 0 ? buffer.indexOf(0x0a) : -1;
100
+ const tail = newline >= 0 ? buffer.subarray(newline + 1) : buffer;
101
+ await fs.writeFile(`${filePath}.1`, tail);
102
+ // The load-bearing line: same inode, so launchd's held fd keeps working.
103
+ await handle.truncate(0);
104
+ return tail.length;
105
+ }
106
+ finally {
107
+ await handle.close();
108
+ }
109
+ }
110
+ /**
111
+ * Rotates and says what happened, on both branches. Safe to call every tick:
112
+ * the size check is one stat per file and a rotation is rare.
113
+ */
114
+ export async function rotateCollectorLogsBestEffort(paths, options = {}) {
115
+ let result = { rotated: [], failures: [] };
116
+ try {
117
+ result = await rotateCollectorLogs(paths, options);
118
+ }
119
+ catch (error) {
120
+ result = {
121
+ rotated: [],
122
+ failures: [
123
+ {
124
+ name: "*",
125
+ reason: `rotation_threw:${error?.code ?? "unknown"}`,
126
+ },
127
+ ],
128
+ };
129
+ }
130
+ // Metadata only: file names (fixed strings) and byte counts, never a path
131
+ // and never a line of content.
132
+ for (const rotated of result.rotated) {
133
+ console.error("[log-rotation] capped a scheduled-tick log", JSON.stringify({
134
+ file: rotated.name,
135
+ bytes_before: rotated.bytes_before,
136
+ bytes_kept: rotated.bytes_kept,
137
+ archives_kept: rotated.archives,
138
+ }));
139
+ }
140
+ for (const failure of result.failures) {
141
+ console.error("[log-rotation] could not cap a scheduled-tick log", JSON.stringify({ file: failure.name, reason: failure.reason }));
142
+ }
143
+ return result;
144
+ }
@@ -4,6 +4,23 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { isSamePath, normalizeCollectionRoots, } from "./root-normalization.js";
6
6
  export const COLLECTION_ROOT_REQUIRED = "collection_root_required";
7
+ /**
8
+ * No approved collection root, so there is nothing this machine may look at.
9
+ *
10
+ * A type rather than a message prefix (BLI-3551): the health-receipt classifier
11
+ * used to recognise this by searching the error text, which is the same habit
12
+ * that filed `session_report_unposted` as an auth failure. The message is
13
+ * unchanged — `collection_root_required: <what to do about it>` — so every
14
+ * operator-facing string and every test that reads one still matches; what
15
+ * changed is that the classifier reads the type.
16
+ */
17
+ export class CollectionRootRequiredError extends Error {
18
+ reason = COLLECTION_ROOT_REQUIRED;
19
+ constructor(detail) {
20
+ super(`${COLLECTION_ROOT_REQUIRED}: ${detail}`);
21
+ this.name = "CollectionRootRequiredError";
22
+ }
23
+ }
7
24
  export function homeRootConsentPrompt(homeDirInput) {
8
25
  const homeDir = path.resolve(homeDirInput ?? os.homedir());
9
26
  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]: `;
@@ -22,7 +39,7 @@ export async function resolveOnboardingRoots(options) {
22
39
  }
23
40
  if (hasRootInput(explicitInput) && explicit.rejected.length > 0) {
24
41
  if (!options.interactive) {
25
- throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${rootRejectionExplanation(explicit.rejected[0], options)}`);
42
+ throw new CollectionRootRequiredError(rootRejectionExplanation(explicit.rejected[0], options));
26
43
  }
27
44
  explainRejectedRoots(options, withoutHomeRejections(explicit.rejected));
28
45
  const homeOptIn = await promptForHomeRootOptIn(options, explicit.rejected);
@@ -54,7 +71,7 @@ export async function resolveOnboardingRoots(options) {
54
71
  return resolvedRoots(options, [homeDir], "cwd_likely_root", true);
55
72
  }
56
73
  if (!options.interactive) {
57
- throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${homeRootTutorial(homeDir)}`);
74
+ throw new CollectionRootRequiredError(homeRootTutorial(homeDir));
58
75
  }
59
76
  const homeOptIn = await promptForHomeRootOptIn(options, [
60
77
  { input: homeDir, reason: "home_dir" },
@@ -63,7 +80,7 @@ export async function resolveOnboardingRoots(options) {
63
80
  return homeOptIn;
64
81
  }
65
82
  if (!options.interactive) {
66
- throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${missingCollectionRootMessage(options)}`);
83
+ throw new CollectionRootRequiredError(missingCollectionRootMessage(options));
67
84
  }
68
85
  const cwdRoot = likelyBliRootFromCwd(cwd);
69
86
  if (cwdRoot) {
@@ -160,7 +177,7 @@ async function promptForRoots(options, message) {
160
177
  prompt.message?.(missingCollectionRootMessage(options));
161
178
  }
162
179
  }
163
- throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${missingCollectionRootMessage(options)}`);
180
+ throw new CollectionRootRequiredError(missingCollectionRootMessage(options));
164
181
  }
165
182
  function savedRootsPrompt(roots) {
166
183
  if (roots.length === 1) {
@@ -174,7 +191,7 @@ function savedRootsPrompt(roots) {
174
191
  }
175
192
  function requirePrompt(options) {
176
193
  if (!options.prompt) {
177
- throw new Error(`${COLLECTION_ROOT_REQUIRED}: interactive prompt unavailable.`);
194
+ throw new CollectionRootRequiredError(`interactive prompt unavailable.`);
178
195
  }
179
196
  return options.prompt;
180
197
  }
@@ -269,7 +286,7 @@ async function promptForDeclinedHomeRoot(options, homeDir) {
269
286
  break;
270
287
  }
271
288
  prompt.message?.(HOME_ROOT_NO_FOLDER_CHOSEN_MESSAGE);
272
- throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${HOME_ROOT_NO_FOLDER_CHOSEN_MESSAGE}`);
289
+ throw new CollectionRootRequiredError(HOME_ROOT_NO_FOLDER_CHOSEN_MESSAGE);
273
290
  }
274
291
  function resolveRootInputPath(input, homeDir, cwd) {
275
292
  const expanded = input === "~" || /^~[\\/]/u.test(input)
@@ -4,7 +4,6 @@ import path from "node:path";
4
4
  import { readRawEvidenceCursor } from "./cursors/raw-evidence-cursor.js";
5
5
  import { readRawEvidenceStagingState } from "./raw-evidence-staging.js";
6
6
  const RAW_EVIDENCE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
7
- const SYNC_LOG_MAX_BYTES = 50 * 1024 * 1024;
8
7
  /**
9
8
  * A staging directory belongs to one in-flight collection pass. Anything this
10
9
  * old is the remains of a crash or a kill, never live work — the longest sync
@@ -17,22 +16,12 @@ const STAGING_ORPHAN_MS = 6 * 60 * 60 * 1000;
17
16
  const GC_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
18
17
  export async function runRawEvidenceLocalGc(paths, env = process.env, now = new Date()) {
19
18
  if (env["COCKPIT_DISABLE_GC"] === "1") {
20
- return {
21
- skipped: true,
22
- removed_dirs: 0,
23
- freed_bytes: 0,
24
- rotated_sync_log: false,
25
- };
19
+ return { skipped: true, removed_dirs: 0, freed_bytes: 0 };
26
20
  }
27
21
  const throttleMarker = path.join(paths.state_dir, ".last-raw-evidence-gc");
28
22
  const lastRun = await fs.stat(throttleMarker).catch(() => null);
29
23
  if (lastRun && now.getTime() - lastRun.mtimeMs < GC_MIN_INTERVAL_MS) {
30
- return {
31
- skipped: true,
32
- removed_dirs: 0,
33
- freed_bytes: 0,
34
- rotated_sync_log: false,
35
- };
24
+ return { skipped: true, removed_dirs: 0, freed_bytes: 0 };
36
25
  }
37
26
  await fs.mkdir(paths.state_dir, { recursive: true }).catch(() => undefined);
38
27
  await fs.writeFile(throttleMarker, now.toISOString()).catch(() => undefined);
@@ -60,11 +49,17 @@ export async function runRawEvidenceLocalGc(paths, env = process.env, now = new
60
49
  removedDirs += 1;
61
50
  freedBytes += inspection.byteSize;
62
51
  }
52
+ // Log capping used to live here, and that is why it did not work: this GC
53
+ // is throttled to once a day and skipped entirely by COCKPIT_DISABLE_GC,
54
+ // while sync.log grows ~8 MB an hour on an active machine (measured on the
55
+ // reference Mac, 2026-09-04: 219,866,695 bytes with the last GC 19 hours
56
+ // earlier). A daily truncate-to-zero of a file that reaches ~190 MB a day is
57
+ // not a cap, and sync.err.log had no cap at all. One owner now, running in
58
+ // the tick preamble: log-rotation.ts (BLI-3553).
63
59
  return {
64
60
  skipped: false,
65
61
  removed_dirs: removedDirs,
66
62
  freed_bytes: freedBytes,
67
- rotated_sync_log: await rotateSyncLog(paths),
68
63
  };
69
64
  }
70
65
  export function rawEvidenceGcSummary(result) {
@@ -288,15 +283,6 @@ async function listFiles(root) {
288
283
  }
289
284
  return files;
290
285
  }
291
- async function rotateSyncLog(paths) {
292
- const syncLog = path.join(paths.state_dir, "sync.log");
293
- const info = await fs.stat(syncLog).catch(() => null);
294
- if (!info?.isFile() || info.size <= SYNC_LOG_MAX_BYTES)
295
- return false;
296
- await fs.truncate(syncLog, 0).catch(() => undefined);
297
- const after = await fs.stat(syncLog).catch(() => null);
298
- return after?.isFile() === true && after.size === 0;
299
- }
300
286
  async function exists(target) {
301
287
  return fs.stat(target).then(() => true, () => false);
302
288
  }
@@ -127,6 +127,7 @@ export async function runScheduledSelfUpdate(paths, deps, options = {}) {
127
127
  reason: "updated",
128
128
  target_version: targetVersion ?? installedVersion,
129
129
  installed_version: installedVersion,
130
+ previous_version: deps.currentVersion,
130
131
  ...forcedFields,
131
132
  };
132
133
  }
@@ -0,0 +1,160 @@
1
+ import path from "node:path";
2
+ import { lstat, realpath } from "node:fs/promises";
3
+ import { DARWIN_LOGIN_SHELL } from "./autostart-node-path.js";
4
+ const NOT_CHECKED = {
5
+ detected: false,
6
+ reason: "probe_unavailable",
7
+ other_bin: null,
8
+ install_count: 0,
9
+ paths: [],
10
+ };
11
+ export async function detectSecondCockpitInstall(options) {
12
+ const platform = options.platform ?? process.platform;
13
+ const lookup = platform === "win32"
14
+ ? { cmd: "where.exe", args: ["cockpit"] }
15
+ : platform === "darwin"
16
+ ? { cmd: DARWIN_LOGIN_SHELL, args: ["-lc", "which -a cockpit"] }
17
+ : null;
18
+ if (!lookup)
19
+ return NOT_CHECKED;
20
+ const result = await options
21
+ .exec(lookup.cmd, lookup.args)
22
+ .catch(() => null);
23
+ if (!result) {
24
+ return { ...NOT_CHECKED, reason: "probe_failed" };
25
+ }
26
+ const candidates = parseCandidatePaths(result.stdout, platform);
27
+ if (candidates.length === 0) {
28
+ // `which`/`where` found nothing: this process was started by an absolute
29
+ // path (the scheduler always does) and the operator's PATH has no cockpit
30
+ // at all. Nothing to compare against, and nothing to complain about.
31
+ return {
32
+ detected: false,
33
+ reason: result.code === 0 ? "probe_failed" : "single_install",
34
+ other_bin: null,
35
+ install_count: 0,
36
+ paths: [],
37
+ };
38
+ }
39
+ const byDirectory = new Map();
40
+ for (const candidate of candidates) {
41
+ const key = directoryKey(candidate, platform);
42
+ if (!byDirectory.has(key))
43
+ byDirectory.set(key, candidate);
44
+ }
45
+ const installs = [...byDirectory.values()];
46
+ if (installs.length > 1) {
47
+ const other = await pickOther(installs, options, platform);
48
+ return {
49
+ detected: true,
50
+ reason: "second_install_detected",
51
+ other_bin: platformBasename(other ?? installs[installs.length - 1] ?? "", platform),
52
+ install_count: installs.length,
53
+ paths: installs,
54
+ };
55
+ }
56
+ // Exactly one on PATH. It can still be a DIFFERENT install from the one
57
+ // running now — the scheduler holds an absolute path to whichever prefix it
58
+ // was registered with. Only decidable where the bin is a symlink into the
59
+ // package (npm's POSIX layout); a Windows `.cmd` shim is a generated script,
60
+ // not a link, so there is nothing to resolve and the check stays quiet
61
+ // rather than guessing.
62
+ const only = installs[0];
63
+ if (!only || platform === "win32") {
64
+ return single(installs);
65
+ }
66
+ const ownEntryPoint = options.cliEntryPoint ?? process.argv[1] ?? "";
67
+ if (!ownEntryPoint)
68
+ return single(installs);
69
+ const isLink = await (options.isSymbolicLink ?? defaultIsSymbolicLink)(only);
70
+ if (!isLink)
71
+ return single(installs);
72
+ const resolve = options.realpath ?? ((candidate) => realpath(candidate));
73
+ const [linked, own] = await Promise.all([
74
+ resolve(only).catch(() => null),
75
+ resolve(ownEntryPoint).catch(() => ownEntryPoint),
76
+ ]);
77
+ if (!linked || linked === own)
78
+ return single(installs);
79
+ return {
80
+ detected: true,
81
+ reason: "second_install_detected",
82
+ other_bin: platformBasename(only, platform),
83
+ install_count: 2,
84
+ paths: [only, ownEntryPoint],
85
+ };
86
+ }
87
+ function single(installs) {
88
+ return {
89
+ detected: false,
90
+ reason: "single_install",
91
+ other_bin: null,
92
+ install_count: installs.length,
93
+ paths: installs,
94
+ };
95
+ }
96
+ /**
97
+ * Which of several installs is NOT the one this process came from.
98
+ *
99
+ * npm lays a global install out as `<prefix>/bin/cockpit` beside
100
+ * `<prefix>/lib/node_modules/...`, and on Windows as `<prefix>\cockpit.cmd`
101
+ * beside `<prefix>\node_modules\...`. Either way the bin's GRANDPARENT is the
102
+ * prefix and our own entry point lives under it, so anything under a different
103
+ * prefix is the other install. If nothing matches — an unusual layout — name
104
+ * the last candidate rather than nothing: an operator can act on a named bin
105
+ * and cannot act on silence.
106
+ */
107
+ async function pickOther(installs, options, platform) {
108
+ const entryPoint = options.cliEntryPoint ?? process.argv[1] ?? "";
109
+ const other = installs.find((candidate) => !isUnderPrefixOf(candidate, entryPoint, platform));
110
+ return other ?? installs[installs.length - 1] ?? null;
111
+ }
112
+ function isUnderPrefixOf(candidateBin, entryPoint, platform) {
113
+ if (!entryPoint)
114
+ return false;
115
+ const platformPath = platform === "win32" ? path.win32 : path.posix;
116
+ const prefix = platformPath.dirname(platformPath.dirname(candidateBin));
117
+ if (!prefix || prefix === "." || prefix === platformPath.sep)
118
+ return false;
119
+ const normalize = (value) => platform === "win32" ? value.toLowerCase() : value;
120
+ const bounded = prefix.endsWith(platformPath.sep)
121
+ ? prefix
122
+ : prefix + platformPath.sep;
123
+ return normalize(entryPoint).startsWith(normalize(bounded));
124
+ }
125
+ function platformBasename(candidate, platform) {
126
+ return platform === "win32"
127
+ ? path.win32.basename(candidate)
128
+ : path.posix.basename(candidate);
129
+ }
130
+ function parseCandidatePaths(stdout, platform) {
131
+ return stdout
132
+ .split(/\r?\n/u)
133
+ .map((line) => line.trim())
134
+ .filter((line) => {
135
+ if (!line)
136
+ return false;
137
+ // zsh's `which -a` also prints shell-function bodies and
138
+ // "cockpit not found"; only absolute paths are installs.
139
+ return platform === "win32"
140
+ ? /^[A-Za-z]:\\/u.test(line)
141
+ : line.startsWith("/");
142
+ });
143
+ }
144
+ function directoryKey(candidate, platform) {
145
+ if (!candidate)
146
+ return "";
147
+ const directory = platform === "win32"
148
+ ? path.win32.dirname(candidate).toLowerCase()
149
+ : path.posix.dirname(candidate);
150
+ return directory;
151
+ }
152
+ async function defaultIsSymbolicLink(candidate) {
153
+ return lstat(candidate).then((info) => info.isSymbolicLink(), () => false);
154
+ }
155
+ /** One line for a receipt or a log: named, path-free. */
156
+ export function secondInstallReason(finding) {
157
+ return finding.detected
158
+ ? `second_install_detected ${finding.other_bin ?? "cockpit"}`
159
+ : finding.reason;
160
+ }