@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
|
@@ -11,8 +11,18 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Either cap yields a *truncated* result rather than a failure: the bytes that
|
|
13
13
|
* were captured are real evidence, and the truncation reason travels with them.
|
|
14
|
+
*
|
|
15
|
+
* One thing that is NOT a broken git (BLI-3551): a repo root that is no longer
|
|
16
|
+
* on disk. `spawn` reports a missing `cwd` as `ENOENT` with syscall `spawn git`
|
|
17
|
+
* — indistinguishable, by the error alone, from git not being installed — so a
|
|
18
|
+
* pruned worktree was logged as `git diff failed` twice per session per tick.
|
|
19
|
+
* One operator's `sync.err.log` held 30,842 such lines against 7,380 real
|
|
20
|
+
* ingests. The root is now checked before the spawn and the outcome names
|
|
21
|
+
* itself: `repo_root_missing`, a fact about the folder rather than a fault in
|
|
22
|
+
* git.
|
|
14
23
|
*/
|
|
15
24
|
import { spawn } from "node:child_process";
|
|
25
|
+
import fs from "node:fs/promises";
|
|
16
26
|
export const MAX_GIT_DIFF_BYTES = 2 * 1024 * 1024;
|
|
17
27
|
export const GIT_DIFF_TIMEOUT_MS = 3_000;
|
|
18
28
|
/** Paths Cockpit never reads, excluded at the git level rather than after. */
|
|
@@ -28,7 +38,47 @@ const SECRET_EXCLUDING_PATHSPEC = [
|
|
|
28
38
|
":(exclude)**/*.pem",
|
|
29
39
|
":(exclude)**/*.key",
|
|
30
40
|
];
|
|
41
|
+
/**
|
|
42
|
+
* The repo root a diff was asked for is not on disk any more.
|
|
43
|
+
*
|
|
44
|
+
* A named, typed outcome rather than a bare `ENOENT`: the caller reports it as
|
|
45
|
+
* its own skip reason and logs it once per root, and nothing downstream has to
|
|
46
|
+
* read an errno to tell a deleted worktree from a broken git install.
|
|
47
|
+
*/
|
|
48
|
+
export const REPO_ROOT_MISSING_REASON = "repo_root_missing";
|
|
49
|
+
export class RepoRootMissingError extends Error {
|
|
50
|
+
reason = REPO_ROOT_MISSING_REASON;
|
|
51
|
+
constructor() {
|
|
52
|
+
// No path in the message: this string reaches receipts and logs, and a
|
|
53
|
+
// local path is exactly what must not travel (see health-detail.ts).
|
|
54
|
+
super("git diff skipped: the repository root is no longer on disk");
|
|
55
|
+
this.name = "RepoRootMissingError";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Is this repo root still a directory on disk?
|
|
60
|
+
*
|
|
61
|
+
* Answered with one `stat` rather than by attempting the spawn and reading the
|
|
62
|
+
* errno, so the same answer holds on both host families: Windows reports a
|
|
63
|
+
* missing `cwd` through the same `ENOENT`/`spawn git` shape macOS does, and a
|
|
64
|
+
* path that exists but is a file is equally not a repo root.
|
|
65
|
+
*/
|
|
66
|
+
export async function repoRootExists(repoRoot) {
|
|
67
|
+
try {
|
|
68
|
+
const stats = await fs.stat(repoRoot);
|
|
69
|
+
return stats.isDirectory();
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
31
75
|
export async function runGitDiff(args, repoRoot) {
|
|
76
|
+
// Checked before the spawn, not after: `spawn` collapses "no such cwd" and
|
|
77
|
+
// "no such program" into one `ENOENT`, and only one of those is worth an
|
|
78
|
+
// operator's attention.
|
|
79
|
+
if (!(await repoRootExists(repoRoot))) {
|
|
80
|
+
throw new RepoRootMissingError();
|
|
81
|
+
}
|
|
32
82
|
return new Promise((resolve, reject) => {
|
|
33
83
|
const child = spawn("git", [...args, ...SECRET_EXCLUDING_PATHSPEC], {
|
|
34
84
|
cwd: repoRoot,
|
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
* already staged, so they must never depend on a clock or a path.
|
|
9
9
|
* 2. **Remote object keys** — the readable `operators/…/repos/…/sessions/…`
|
|
10
10
|
* namespace an operator reads during an incident, followed by an immutable
|
|
11
|
-
* content address
|
|
11
|
+
* content address. Every raw-evidence object carries its own content hash in
|
|
12
|
+
* its key, the manifest included since BLI-3552; the pack-relative fallback
|
|
13
|
+
* below survives only for a caller that has no content address to give, and
|
|
14
|
+
* a key that does not name its own bytes can collide with different bytes.
|
|
12
15
|
*
|
|
13
16
|
* Everything here is pure: same input, same name, on every machine and every
|
|
14
17
|
* platform. Nothing in this file reads a file, spawns a process or logs.
|
|
@@ -97,10 +97,19 @@ async function countPriorPacks(rawEvidenceRoot, workContextId, packId) {
|
|
|
97
97
|
/**
|
|
98
98
|
* Write the manifest, or keep the one already in a reused pack.
|
|
99
99
|
*
|
|
100
|
-
* Byte-stability matters here: the manifest
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
100
|
+
* Byte-stability matters here: rewriting the manifest with a fresh `created_at`
|
|
101
|
+
* on every sync means a new remote object per sync for a pack whose files have
|
|
102
|
+
* not changed. A reused pack whose manifest already describes exactly these
|
|
103
|
+
* files keeps it.
|
|
104
|
+
*
|
|
105
|
+
* It used to matter for a second and much sharper reason. The manifest's object
|
|
106
|
+
* key embedded the PACK id — a function of the other files' hashes, not of the
|
|
107
|
+
* manifest's own bytes — so a rewrite pushed different bytes at the identical
|
|
108
|
+
* key, and once one of them was durable `begin` answered
|
|
109
|
+
* `hash_mismatch_committed_object` forever. Reuse was the only thing standing
|
|
110
|
+
* in the way, and it does not hold when the pack directory is refilled, pruned
|
|
111
|
+
* or absent. BLI-3552 moved the manifest onto its own content address, so this
|
|
112
|
+
* is now a cost saving rather than the load-bearing invariant.
|
|
104
113
|
*/
|
|
105
114
|
export async function stageManifest(options) {
|
|
106
115
|
if (options.reusePack) {
|
|
@@ -14,7 +14,7 @@ import { sanitizeTextEvidenceForUpload } from "./raw-evidence-sanitize.js";
|
|
|
14
14
|
import { countEvidenceEntries, makeEvidenceCompleteness, markBudgetCapApplied, markCapApplied, recordScanned, recordSkipCount, recordTruncationCount, } from "./raw-evidence-completeness.js";
|
|
15
15
|
import { evidenceEntry, pointerFromEntry, RAW_EVIDENCE_BUCKET, } from "./raw-evidence-manifest.js";
|
|
16
16
|
import { chmodPrivate, ensurePrivateDir, persistStagingState, promoteStagedPack, stageManifest, } from "./raw-evidence-pack-store.js";
|
|
17
|
-
import { GIT_DIFF_TIMEOUT_MS, MAX_GIT_DIFF_BYTES, runGitDiff, } from "./raw-evidence-git-diff.js";
|
|
17
|
+
import { GIT_DIFF_TIMEOUT_MS, MAX_GIT_DIFF_BYTES, REPO_ROOT_MISSING_REASON, RepoRootMissingError, repoRootExists, runGitDiff, } from "./raw-evidence-git-diff.js";
|
|
18
18
|
// Re-exported so every consumer keeps importing from `adapters/raw-evidence`.
|
|
19
19
|
export { RAW_EVIDENCE_BUCKET, RAW_EVIDENCE_RETENTION_MODE, } from "./raw-evidence-manifest.js";
|
|
20
20
|
export { sanitizeTextEvidenceForUpload } from "./raw-evidence-sanitize.js";
|
|
@@ -247,6 +247,19 @@ async function finishWithPromotedPack(collection, options, run) {
|
|
|
247
247
|
mediaType: "application/json",
|
|
248
248
|
redactedSummary: "Local raw evidence pack manifest.",
|
|
249
249
|
bytes: manifestBytes,
|
|
250
|
+
// The manifest carries its own content hash in its key, exactly like
|
|
251
|
+
// every other object in the pack (BLI-3552). Without it the key was
|
|
252
|
+
// `…/<packId>/manifest.json`, and a pack id is a function of the OTHER
|
|
253
|
+
// files' hashes — not of the manifest's bytes, which also carry
|
|
254
|
+
// `created_at`, the ordering-dependent `files[].relative_path`, `branch`,
|
|
255
|
+
// and the skipped/redacted/reused ledgers. So the same key named
|
|
256
|
+
// different bytes whenever the pack directory was not adopted verbatim
|
|
257
|
+
// (a refill, a pruned or wiped state dir, a machine that had never seen
|
|
258
|
+
// the pack). `begin` then answered `hash_mismatch_committed_object` on
|
|
259
|
+
// every sync forever, because the new manifest is stable and the old one
|
|
260
|
+
// is durable. `stageManifest`'s byte-stability trick still stands; it is
|
|
261
|
+
// now a nice-to-have rather than the only thing between us and a loop.
|
|
262
|
+
contentAddress: `manifest/${sha256(manifestBytes).slice(0, 16)}.json`,
|
|
250
263
|
}));
|
|
251
264
|
await persistStagingState(options.stateDir, collection.staging, context.now.toISOString());
|
|
252
265
|
console.error("[raw-evidence] pack staged", JSON.stringify({
|
|
@@ -1011,6 +1024,25 @@ const GIT_DIFF_TARGETS = [
|
|
|
1011
1024
|
{ label: "staged", args: ["diff", "--cached", "--no-ext-diff", "--"] },
|
|
1012
1025
|
];
|
|
1013
1026
|
async function collectGitDiffFiles(collection, repoRoot) {
|
|
1027
|
+
// BLI-3551: asked once per root, before either target, so a pruned worktree
|
|
1028
|
+
// costs ONE line instead of one per diff target per session per tick — and
|
|
1029
|
+
// the line says the folder is gone rather than accusing git of failing.
|
|
1030
|
+
if (!(await repoRootExists(repoRoot))) {
|
|
1031
|
+
console.error("[raw-evidence] git diff skipped, repository root is no longer on disk", JSON.stringify({
|
|
1032
|
+
reason: REPO_ROOT_MISSING_REASON,
|
|
1033
|
+
diff_targets_skipped: GIT_DIFF_TARGETS.length,
|
|
1034
|
+
next_action: "nothing to do; the diff returns when the worktree is restored or the session ages out",
|
|
1035
|
+
}));
|
|
1036
|
+
for (const target of GIT_DIFF_TARGETS) {
|
|
1037
|
+
recordScanned(collection, "git_diff");
|
|
1038
|
+
collection.skipped.push({
|
|
1039
|
+
kind: "git_diff",
|
|
1040
|
+
label: target.label,
|
|
1041
|
+
reason: REPO_ROOT_MISSING_REASON,
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1014
1046
|
for (const target of GIT_DIFF_TARGETS) {
|
|
1015
1047
|
recordScanned(collection, "git_diff");
|
|
1016
1048
|
let diff;
|
|
@@ -1018,6 +1050,16 @@ async function collectGitDiffFiles(collection, repoRoot) {
|
|
|
1018
1050
|
diff = await runGitDiff(target.args, repoRoot);
|
|
1019
1051
|
}
|
|
1020
1052
|
catch (error) {
|
|
1053
|
+
// The root was there a moment ago and is not now (or a second collector
|
|
1054
|
+
// pruned it mid-tick). Same named outcome, still not a git failure.
|
|
1055
|
+
if (error instanceof RepoRootMissingError) {
|
|
1056
|
+
collection.skipped.push({
|
|
1057
|
+
kind: "git_diff",
|
|
1058
|
+
label: target.label,
|
|
1059
|
+
reason: REPO_ROOT_MISSING_REASON,
|
|
1060
|
+
});
|
|
1061
|
+
continue;
|
|
1062
|
+
}
|
|
1021
1063
|
// `git_diff_failed` is the skip label and stays. It covers git not being
|
|
1022
1064
|
// installed, the folder not being a repo, a locked index and a diff that
|
|
1023
1065
|
// exceeded the child-process buffer — and the diff is half the evidence
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { realpath, stat } from "node:fs/promises";
|
|
3
|
+
/**
|
|
4
|
+
* BLI-3553. The scheduler's action string names an absolute node binary — it
|
|
5
|
+
* has to, because launchd starts the tick with a bare
|
|
6
|
+
* `PATH=/usr/bin:/bin:/usr/sbin:/sbin` (see scheduled-self-update.ts) and Task
|
|
7
|
+
* Scheduler is no friendlier. The hazard is that `process.execPath`, which is
|
|
8
|
+
* what we render, points INTO a version-pinned directory on both of the
|
|
9
|
+
* layouts this fleet actually runs:
|
|
10
|
+
*
|
|
11
|
+
* - Homebrew: `/opt/homebrew/Cellar/node/26.0.0/bin/node`. `brew upgrade node`
|
|
12
|
+
* deletes that whole directory, so every tick afterwards exits 127 forever.
|
|
13
|
+
* Observed verbatim in `launchctl print gui/501/com.bli.cockpit.sync` on the
|
|
14
|
+
* reference Mac, 2026-09-04.
|
|
15
|
+
* - nvm-windows: `%APPDATA%\nvm\v22.11.0\node.exe`. `nvm use <other>` leaves
|
|
16
|
+
* the old directory in place but the operator's node moves; `nvm uninstall`
|
|
17
|
+
* deletes it outright.
|
|
18
|
+
*
|
|
19
|
+
* Both layouts publish a STABLE alias beside the versioned one — Homebrew's
|
|
20
|
+
* `<prefix>/opt/<formula>/bin/node` symlink, nvm-windows' `NVM_SYMLINK`
|
|
21
|
+
* junction (`C:\Program Files\nodejs` by default) — and that alias is exactly
|
|
22
|
+
* what survives the upgrade. So: when the running node is version-pinned, and
|
|
23
|
+
* the stable alias exists AND resolves to the same major version, render the
|
|
24
|
+
* alias. Otherwise render the exact path we were given and let the caller's
|
|
25
|
+
* runtime fallback (renderDarwinSyncCommand) find a node another way.
|
|
26
|
+
*
|
|
27
|
+
* "Verify against what the platform returns, not what you passed it"
|
|
28
|
+
* (BLI-2541): the check reads the symlink back off the filesystem and compares
|
|
29
|
+
* the version segment it actually lands on. A guessed alias that happens not to
|
|
30
|
+
* exist, or that a `brew link` left pointing at node@20 while the collector
|
|
31
|
+
* runs on 26, is rejected by name rather than written into the plist.
|
|
32
|
+
*/
|
|
33
|
+
/** The zsh the launchd plist runs; also the login shell the fallback probes. */
|
|
34
|
+
export const DARWIN_LOGIN_SHELL = "/bin/zsh";
|
|
35
|
+
const defaultProbe = {
|
|
36
|
+
realpath: (candidate) => realpath(candidate),
|
|
37
|
+
exists: (candidate) => stat(candidate).then(() => true, () => false),
|
|
38
|
+
};
|
|
39
|
+
/** `/opt/homebrew/Cellar/node@22/22.11.0/bin/node` → prefix, formula, version. */
|
|
40
|
+
const HOMEBREW_CELLAR_NODE = /^(?<prefix>.*)\/Cellar\/(?<formula>node(?:@[0-9]+)?)\/(?<version>[^/]+)\/bin\/node$/u;
|
|
41
|
+
/** `C:\Users\x\AppData\Roaming\nvm\v22.11.0\node.exe` → version. */
|
|
42
|
+
const NVM_WINDOWS_NODE = /[\\/]nvm[\\/]v(?<version>[0-9]+(?:\.[0-9]+)*)[\\/]node\.exe$/iu;
|
|
43
|
+
const NVM_WINDOWS_DEFAULT_SYMLINK = "C:\\Program Files\\nodejs";
|
|
44
|
+
/**
|
|
45
|
+
* The node path the scheduler should name, given the one this process is
|
|
46
|
+
* running under. Never throws: every failure is a reason label and the
|
|
47
|
+
* original path.
|
|
48
|
+
*/
|
|
49
|
+
export async function resolveStableNodeExecutable(nodeExecutable, options = {}) {
|
|
50
|
+
const platform = options.platform ?? process.platform;
|
|
51
|
+
const probe = {
|
|
52
|
+
...defaultProbe,
|
|
53
|
+
env: options.probe?.env ?? process.env,
|
|
54
|
+
...options.probe,
|
|
55
|
+
};
|
|
56
|
+
if (platform === "darwin") {
|
|
57
|
+
return resolveHomebrewStableNode(nodeExecutable, probe);
|
|
58
|
+
}
|
|
59
|
+
if (platform === "win32") {
|
|
60
|
+
return resolveNvmWindowsStableNode(nodeExecutable, probe);
|
|
61
|
+
}
|
|
62
|
+
return { path: nodeExecutable, reason: "not_version_pinned", changed: false };
|
|
63
|
+
}
|
|
64
|
+
async function resolveHomebrewStableNode(nodeExecutable, probe) {
|
|
65
|
+
const match = HOMEBREW_CELLAR_NODE.exec(nodeExecutable);
|
|
66
|
+
const prefix = match?.groups?.["prefix"];
|
|
67
|
+
const formula = match?.groups?.["formula"];
|
|
68
|
+
const pinnedVersion = match?.groups?.["version"];
|
|
69
|
+
if (!match || !prefix || !formula || !pinnedVersion) {
|
|
70
|
+
return unchanged(nodeExecutable, "not_version_pinned");
|
|
71
|
+
}
|
|
72
|
+
// Homebrew's opt-prefix: the one path that does not move across upgrades.
|
|
73
|
+
const candidate = path.posix.join(prefix, "opt", formula, "bin", "node");
|
|
74
|
+
return verifyStableAlias({
|
|
75
|
+
original: nodeExecutable,
|
|
76
|
+
candidate,
|
|
77
|
+
pinnedVersion,
|
|
78
|
+
readVersion: (resolved) => HOMEBREW_CELLAR_NODE.exec(resolved)?.groups?.["version"] ?? null,
|
|
79
|
+
probe,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
async function resolveNvmWindowsStableNode(nodeExecutable, probe) {
|
|
83
|
+
const pinnedVersion = NVM_WINDOWS_NODE.exec(nodeExecutable)?.groups?.["version"];
|
|
84
|
+
if (!pinnedVersion)
|
|
85
|
+
return unchanged(nodeExecutable, "not_version_pinned");
|
|
86
|
+
// nvm-windows points NVM_SYMLINK at whichever version is active; the
|
|
87
|
+
// installer's default is C:\Program Files\nodejs and that is what every
|
|
88
|
+
// machine-wide PATH entry names.
|
|
89
|
+
const symlinkDir = (probe.env?.["NVM_SYMLINK"] ?? "").trim() || NVM_WINDOWS_DEFAULT_SYMLINK;
|
|
90
|
+
const candidate = path.win32.join(symlinkDir, "node.exe");
|
|
91
|
+
if (path.win32.resolve(candidate).toLowerCase() ===
|
|
92
|
+
path.win32.resolve(nodeExecutable).toLowerCase()) {
|
|
93
|
+
return unchanged(nodeExecutable, "not_version_pinned");
|
|
94
|
+
}
|
|
95
|
+
return verifyStableAlias({
|
|
96
|
+
original: nodeExecutable,
|
|
97
|
+
candidate,
|
|
98
|
+
pinnedVersion,
|
|
99
|
+
readVersion: (resolved) => NVM_WINDOWS_NODE.exec(resolved)?.groups?.["version"] ?? null,
|
|
100
|
+
probe,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The BLI-2541 half: read the alias back off the filesystem and judge what it
|
|
105
|
+
* actually lands on, rather than trusting the string we composed.
|
|
106
|
+
*/
|
|
107
|
+
async function verifyStableAlias(options) {
|
|
108
|
+
const { original, candidate, pinnedVersion, readVersion, probe } = options;
|
|
109
|
+
if (!(await probe.exists(candidate))) {
|
|
110
|
+
return unchanged(original, "stable_alias_missing");
|
|
111
|
+
}
|
|
112
|
+
const resolved = await probe.realpath(candidate).catch(() => null);
|
|
113
|
+
if (!resolved)
|
|
114
|
+
return unchanged(original, "stable_alias_unresolvable");
|
|
115
|
+
// Exactly the binary we are running: nothing left to check.
|
|
116
|
+
const originalResolved = await probe.realpath(original).catch(() => original);
|
|
117
|
+
if (samePath(resolved, originalResolved)) {
|
|
118
|
+
return { path: candidate, reason: "pinned_to_stable_alias", changed: true };
|
|
119
|
+
}
|
|
120
|
+
const resolvedVersion = readVersion(resolved);
|
|
121
|
+
if (!resolvedVersion)
|
|
122
|
+
return unchanged(original, "stable_alias_unresolvable");
|
|
123
|
+
const resolvedMajor = majorOf(resolvedVersion);
|
|
124
|
+
const pinnedMajor = majorOf(pinnedVersion);
|
|
125
|
+
// An unparseable version on either side is a mismatch, not a pass: two
|
|
126
|
+
// nulls comparing equal would silently accept an alias we cannot judge.
|
|
127
|
+
if (!resolvedMajor || !pinnedMajor || resolvedMajor !== pinnedMajor) {
|
|
128
|
+
return unchanged(original, "stable_alias_major_mismatch");
|
|
129
|
+
}
|
|
130
|
+
return { path: candidate, reason: "pinned_to_stable_alias", changed: true };
|
|
131
|
+
}
|
|
132
|
+
function unchanged(nodeExecutable, reason) {
|
|
133
|
+
return { path: nodeExecutable, reason, changed: false };
|
|
134
|
+
}
|
|
135
|
+
function samePath(left, right) {
|
|
136
|
+
return left.replace(/[\\/]+$/u, "").toLowerCase() ===
|
|
137
|
+
right.replace(/[\\/]+$/u, "").toLowerCase();
|
|
138
|
+
}
|
|
139
|
+
function majorOf(version) {
|
|
140
|
+
return /^([0-9]+)/u.exec(version)?.[1] ?? null;
|
|
141
|
+
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { autostartStatus, installAutostartAgent, registeredRuntimePathProblems, } from "./autostart.js";
|
|
5
|
+
import { detectSecondCockpitInstall, secondInstallReason, } from "./second-install.js";
|
|
4
6
|
export const AUTOSTART_REPAIR_THROTTLE_MARKER = ".last-autostart-repair";
|
|
5
7
|
const AUTOSTART_REPAIR_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
6
8
|
const DETAIL_MAX_CHARS = 300;
|
|
7
9
|
export async function runAutostartSelfHeal(paths, options) {
|
|
8
10
|
const platform = options.platform ?? process.platform;
|
|
9
|
-
if (platform !== "win32")
|
|
11
|
+
if (platform !== "win32" && platform !== "darwin")
|
|
10
12
|
return null;
|
|
11
13
|
if (options.repoRoots.length === 0)
|
|
12
14
|
return null;
|
|
@@ -18,14 +20,49 @@ export async function runAutostartSelfHeal(paths, options) {
|
|
|
18
20
|
exec: options.exec,
|
|
19
21
|
platform,
|
|
20
22
|
});
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
if (status.status !== "not_loaded")
|
|
23
|
+
// Absent means the operator (or onboarding) owns the decision, not this tick.
|
|
24
|
+
if (status.status === "absent")
|
|
24
25
|
return null;
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
26
|
+
// A registration can read as "loaded" and still name a binary that no longer
|
|
27
|
+
// exists — that is precisely what `brew upgrade node` leaves behind. Ask the
|
|
28
|
+
// filesystem about the paths the SCHEDULER holds, not the ones this process
|
|
29
|
+
// is running under (BLI-3553).
|
|
30
|
+
const runtimeProblems = status.status === "loaded"
|
|
31
|
+
? await registeredRuntimePathProblems({
|
|
32
|
+
homeDir: options.homeDir,
|
|
33
|
+
exec: options.exec,
|
|
34
|
+
platform,
|
|
35
|
+
})
|
|
36
|
+
: [];
|
|
37
|
+
if (status.status !== "not_loaded" && runtimeProblems.length === 0) {
|
|
38
|
+
// Healthy is the steady state and stays silent.
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const problem = [
|
|
42
|
+
...(status.message ? [status.message] : []),
|
|
43
|
+
...runtimeProblems,
|
|
44
|
+
]
|
|
45
|
+
.join("; ")
|
|
46
|
+
.slice(0, DETAIL_MAX_CHARS) || "registration not loaded";
|
|
47
|
+
// Two installs fighting over one registration re-register each other every
|
|
48
|
+
// tick. Naming that is the repair; rewriting the plist would BE the flapping.
|
|
49
|
+
const secondInstall = await detectSecondCockpitInstall({
|
|
50
|
+
exec: options.exec,
|
|
51
|
+
platform,
|
|
52
|
+
cliEntryPoint: options.cliEntryPoint,
|
|
53
|
+
});
|
|
54
|
+
if (secondInstall.detected) {
|
|
55
|
+
return {
|
|
56
|
+
status: "skipped",
|
|
57
|
+
reason: secondInstallReason(secondInstall),
|
|
58
|
+
detail: `${problem}; refusing to re-register while ${secondInstall.install_count} Tower installs are on PATH`.slice(0, DETAIL_MAX_CHARS),
|
|
59
|
+
step: platform === "darwin" ? "autostart_heal" : "autostart_repair",
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
// Attempts are throttled (marker mtime, written for the attempt not the
|
|
63
|
+
// outcome) so a persistently failing repair cannot run every 15 minutes. The
|
|
64
|
+
// status probe above still runs every tick — it is one launchctl/schtasks
|
|
65
|
+
// query.
|
|
29
66
|
const now = options.now ?? new Date();
|
|
30
67
|
const marker = path.join(paths.state_dir, AUTOSTART_REPAIR_THROTTLE_MARKER);
|
|
31
68
|
const lastAttempt = await fs.stat(marker).catch(() => null);
|
|
@@ -35,7 +72,8 @@ export async function runAutostartSelfHeal(paths, options) {
|
|
|
35
72
|
}
|
|
36
73
|
await fs.mkdir(paths.state_dir, { recursive: true }).catch(() => undefined);
|
|
37
74
|
await fs.writeFile(marker, now.toISOString()).catch(() => undefined);
|
|
38
|
-
|
|
75
|
+
if (platform === "darwin")
|
|
76
|
+
return scheduleDarwinHeal(options, problem);
|
|
39
77
|
const repaired = await installAutostartAgent({
|
|
40
78
|
homeDir: options.homeDir,
|
|
41
79
|
repoRoot: options.repoRoots[0],
|
|
@@ -45,11 +83,77 @@ export async function runAutostartSelfHeal(paths, options) {
|
|
|
45
83
|
platform,
|
|
46
84
|
});
|
|
47
85
|
if (repaired.loaded) {
|
|
48
|
-
return {
|
|
86
|
+
return {
|
|
87
|
+
status: "ok",
|
|
88
|
+
reason: "autostart_repaired",
|
|
89
|
+
detail: problem,
|
|
90
|
+
step: "autostart_repair",
|
|
91
|
+
};
|
|
49
92
|
}
|
|
50
93
|
return {
|
|
51
94
|
status: "fail",
|
|
52
95
|
reason: "autostart_repair_failed",
|
|
53
96
|
detail: (repaired.message ?? problem).slice(0, DETAIL_MAX_CHARS),
|
|
97
|
+
step: "autostart_repair",
|
|
54
98
|
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Hands the repair to a process launchd is not about to kill.
|
|
102
|
+
*
|
|
103
|
+
* `detached: true` puts the child in its own session, so it survives the
|
|
104
|
+
* launchd job's process group going away; `stdio: "ignore"` means it holds no
|
|
105
|
+
* descriptor on the job's log files (which the tick may be rotating); `unref()`
|
|
106
|
+
* lets this process exit immediately. The child's first act is to WAIT for
|
|
107
|
+
* this pid to disappear — see commands/autostart-heal.ts.
|
|
108
|
+
*/
|
|
109
|
+
async function scheduleDarwinHeal(options, problem) {
|
|
110
|
+
const nodeExecutable = options.nodeExecutable ?? process.execPath;
|
|
111
|
+
const cliEntryPoint = options.cliEntryPoint ?? process.argv[1] ?? "";
|
|
112
|
+
if (!cliEntryPoint) {
|
|
113
|
+
return {
|
|
114
|
+
status: "fail",
|
|
115
|
+
reason: "heal_entry_point_unknown",
|
|
116
|
+
detail: problem,
|
|
117
|
+
step: "autostart_heal",
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
const args = [
|
|
121
|
+
cliEntryPoint,
|
|
122
|
+
"autostart",
|
|
123
|
+
"heal-detached",
|
|
124
|
+
"--parent-pid",
|
|
125
|
+
String(process.pid),
|
|
126
|
+
...(options.homeDir ? ["--home", options.homeDir] : []),
|
|
127
|
+
...(options.dashboardUrl ? ["--dashboard-url", options.dashboardUrl] : []),
|
|
128
|
+
];
|
|
129
|
+
const spawner = options.spawnDetached ?? defaultSpawnDetached;
|
|
130
|
+
let child = null;
|
|
131
|
+
try {
|
|
132
|
+
child = spawner(nodeExecutable, args);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
child = null;
|
|
136
|
+
}
|
|
137
|
+
if (!child || child.pid === null) {
|
|
138
|
+
return {
|
|
139
|
+
status: "fail",
|
|
140
|
+
reason: "heal_spawn_failed",
|
|
141
|
+
detail: problem,
|
|
142
|
+
step: "autostart_heal",
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
status: "skipped",
|
|
147
|
+
reason: "heal_scheduled",
|
|
148
|
+
detail: problem,
|
|
149
|
+
step: "autostart_heal",
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
function defaultSpawnDetached(command, args) {
|
|
153
|
+
const child = spawn(command, args, {
|
|
154
|
+
detached: true,
|
|
155
|
+
stdio: "ignore",
|
|
156
|
+
});
|
|
157
|
+
child.unref();
|
|
158
|
+
return { pid: child.pid ?? null };
|
|
55
159
|
}
|