@bli-cockpit/cli 0.2.46 → 0.2.47

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.
@@ -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 (or a pack-relative manifest path).
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's object key is the only key that
101
- * embeds the pack id, so rewriting it with a fresh `created_at` every sync
102
- * would push different bytes at the same content-addressed key forever. A
103
- * reused pack whose manifest already describes exactly these files keeps it.
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 { autostartStatus, installAutostartAgent, } from "./autostart.js";
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
- // Healthy is the steady state and stays silent; absent means the operator
22
- // (or onboarding) owns the decision, not this tick.
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
- // Attempts are throttled like the self-update's (marker mtime, written for
26
- // the attempt not the outcome) so a persistently failing repair cannot spawn
27
- // a registration every 15 minutes. The status probe above still runs every
28
- // tick — it is one schtasks query.
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
- const problem = (status.message ?? "task not loaded").slice(0, DETAIL_MAX_CHARS);
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 { status: "ok", reason: "autostart_repaired", detail: problem };
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
  }