@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.
- package/dist/adapters/raw-evidence-git-diff.js +50 -0
- package/dist/adapters/raw-evidence-keys.js +4 -1
- package/dist/adapters/raw-evidence-pack-store.js +13 -4
- package/dist/adapters/raw-evidence.js +43 -1
- package/dist/autostart-node-path.js +141 -0
- package/dist/autostart-self-heal.js +115 -11
- package/dist/autostart.js +213 -41
- package/dist/commands/autostart-heal.js +162 -0
- package/dist/commands/collection-roots.js +4 -4
- package/dist/commands/doctor.js +47 -1
- package/dist/commands/heartbeat.js +193 -0
- package/dist/commands/install-receipts.js +45 -19
- package/dist/commands/install-update.js +3 -3
- package/dist/commands/local-args.js +7 -1
- package/dist/commands/local.js +116 -10
- package/dist/commands/ops-render.js +29 -0
- package/dist/commands/ops.js +6 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/session-sync.js +146 -25
- package/dist/dev-build.js +186 -0
- package/dist/evidence-upload-client.js +112 -4
- package/dist/evidence-upload-rekey.js +40 -0
- package/dist/log-rotation.js +144 -0
- package/dist/onboarding-roots.js +23 -6
- package/dist/raw-evidence-gc.js +9 -23
- package/dist/scheduled-self-update.js +1 -0
- package/dist/second-install.js +160 -0
- package/dist/sync-health-class.js +242 -0
- package/dist/upload.js +2 -0
- package/package.json +3 -3
|
@@ -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
|
+
}
|
package/dist/onboarding-roots.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
package/dist/raw-evidence-gc.js
CHANGED
|
@@ -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
|
+
}
|