@indigoai-us/hq-cli 5.171.0 → 5.173.0

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.
@@ -24,6 +24,7 @@ import { retireLegacyListen } from "./legacy-listen.js";
24
24
  import { readDaemonState } from "./state.js";
25
25
  import { activateLaunchd, launchdPlistProgramArguments, launchdProgramArgumentsDiffer, parseLaunchctlPrintArguments, } from "../../../service-manager/launchd.js";
26
26
  import { activateSystemd } from "../../../service-manager/systemd.js";
27
+ import { nodeBinaryProblem, preferStableNodeBinary } from "../../../node-binary.js";
27
28
  /**
28
29
  * `schtasks /End` terminates the task's root process only. The daemon is
29
30
  * launched through `conhost.exe --headless`, so ending the task kills conhost
@@ -113,7 +114,7 @@ function migrateLegacyDaemonUnit(opts) {
113
114
  env: opts.env,
114
115
  hqRoot: verified,
115
116
  hqBinary: "/usr/bin/hq",
116
- nodeBinary: process.execPath || "/usr/bin/node",
117
+ nodeBinary: process.execPath ? resolveNodeBinary(process.execPath, opts.env ?? process.env) : "/usr/bin/node",
117
118
  });
118
119
  try {
119
120
  paths.hqBinary = resolveHqBinary(process.argv, opts.env ?? process.env);
@@ -264,18 +265,25 @@ export function resolveRunningCliEntry(argv = process.argv) {
264
265
  throw new Error(`Cannot resolve hq binary: ${entry} is not a real path`);
265
266
  }
266
267
  }
267
- /** Absolute node binary (realpath of process.execPath). No PATH lookup. */
268
+ /**
269
+ * Absolute node binary for a service unit: the realpath of process.execPath,
270
+ * then its upgrade-stable alias (/opt/homebrew/bin/node rather than
271
+ * /opt/homebrew/Cellar/node/<version>/bin/node) when one resolves to the same
272
+ * binary. No PATH lookup of `node` by name.
273
+ */
268
274
  export function resolveAbsoluteNodeBinary(execPath = process.execPath) {
269
275
  if (!execPath)
270
276
  throw new Error("Cannot resolve node binary: execPath is empty");
277
+ let real;
271
278
  try {
272
- return fs.realpathSync(execPath);
279
+ real = fs.realpathSync(execPath);
273
280
  }
274
281
  catch {
275
282
  if (path.isAbsolute(execPath))
276
283
  return execPath;
277
284
  throw new Error(`Cannot resolve node binary: ${execPath} is not a real path`);
278
285
  }
286
+ return preferStableNodeBinary(real);
279
287
  }
280
288
  /** Resolve the hq binary the current shell uses (argv[1] or PATH). */
281
289
  export function resolveHqBinary(argv = process.argv, env = process.env) {
@@ -312,7 +320,14 @@ export function isNewerCliVersion(current, recorded) {
312
320
  */
313
321
  export function installDaemonIfStale(opts) {
314
322
  const recorded = readDaemonState(opts.paths.daemonDir)?.cliVersion;
315
- if (!isNewerCliVersion(opts.currentVersion, recorded)) {
323
+ const platformForUnit = opts.platform ?? detectPlatform();
324
+ const installedNode = installedDaemonNodeBinary(opts.paths, platformForUnit, opts.readUnit);
325
+ // Only a rewrite that changes the node path helps: a unit whose node is gone
326
+ // or versioned, while this install resolves a different (stable) one.
327
+ const nodeProblem = installedNode && installedNode !== opts.paths.nodeBinary
328
+ ? (opts.nodeProblem ?? nodeBinaryProblem)(installedNode)
329
+ : null;
330
+ if (!isNewerCliVersion(opts.currentVersion, recorded) && !nodeProblem) {
316
331
  return {
317
332
  reinstalled: false,
318
333
  message: `daemon already records hq ${recorded}; not reinstalling`,
@@ -350,9 +365,42 @@ export function installDaemonIfStale(opts) {
350
365
  message: `${installed.message} Restarted with ${opts.paths.nodeBinary} ${opts.paths.hqBinary}.`,
351
366
  };
352
367
  }
368
+ /**
369
+ * The node binary baked into the installed daemon unit (darwin plist or linux
370
+ * user unit), or null when there is no unit or it cannot be read. Used to
371
+ * reinstall a unit whose node was a versioned path an upgrade removed.
372
+ */
373
+ export function installedDaemonNodeBinary(paths, platform, readUnit = (file) => {
374
+ try {
375
+ return fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null;
376
+ }
377
+ catch {
378
+ return null;
379
+ }
380
+ }) {
381
+ if (platform === "darwin") {
382
+ const text = readUnit(paths.plistPath);
383
+ if (!text)
384
+ return null;
385
+ const block = text.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/);
386
+ const first = block?.[1]?.match(/<string>([^<]*)<\/string>/);
387
+ return first?.[1] ?? null;
388
+ }
389
+ if (platform === "linux") {
390
+ const text = readUnit(paths.systemdUnitPath);
391
+ if (!text)
392
+ return null;
393
+ const m = text.match(/^ExecStart=('(?:[^']|'\\'')*'|\S+)/m);
394
+ if (!m)
395
+ return null;
396
+ const raw = m[1];
397
+ return raw.startsWith("'") ? raw.slice(1, -1).replace(/'\\''/g, "'") : raw;
398
+ }
399
+ return null;
400
+ }
353
401
  export function resolveNodeBinary(execPath = process.execPath, env = process.env) {
354
402
  if (execPath && fs.existsSync(execPath))
355
- return execPath;
403
+ return preferStableNodeBinary(execPath, { env });
356
404
  const pathEnv = env.PATH ?? "";
357
405
  for (const dir of pathEnv.split(path.delimiter)) {
358
406
  const candidate = path.join(dir, "node");
@@ -0,0 +1,50 @@
1
+ /**
2
+ * A node path that survives a node upgrade, for service units (LaunchAgents,
3
+ * systemd user units) that name node by absolute path.
4
+ *
5
+ * `process.execPath` is the resolved binary, e.g.
6
+ * /opt/homebrew/Cellar/node/26.0.0/bin/node. `brew upgrade node` deletes that
7
+ * directory, and every unit that baked it in stops launching (launchd cannot
8
+ * exec a missing program, so KeepAlive never brings the job back).
9
+ *
10
+ * The fix is to write the stable name the package manager keeps pointing at
11
+ * the current version (/opt/homebrew/bin/node, /opt/homebrew/opt/node/bin/node)
12
+ * instead of the versioned directory it points at today. A candidate is only
13
+ * used when it resolves to the SAME binary this process runs, so the unit
14
+ * keeps running the exact node it was installed with; a machine where no such
15
+ * alias exists (nvm without a `current` link, a hand-built node) keeps the
16
+ * execPath, as before.
17
+ */
18
+ export interface NodeBinaryIo {
19
+ existsSync(p: string): boolean;
20
+ realpathSync(p: string): string;
21
+ }
22
+ /** True when `p` is a node binary inside a version-specific directory. */
23
+ export declare function isVersionedNodePath(p: string): boolean;
24
+ /**
25
+ * Stable names that may point at `execPath`, most specific first:
26
+ * the Homebrew prefix's bin/ and opt/<formula>/ links, a `current` link under
27
+ * nvm, then the usual global bins and every PATH entry.
28
+ */
29
+ export declare function stableNodeCandidates(execPath: string, env?: NodeJS.ProcessEnv, home?: string): string[];
30
+ /**
31
+ * The stable name for `execPath` when it sits in a versioned directory and an
32
+ * alias resolving to the same binary exists; otherwise `execPath` unchanged.
33
+ */
34
+ export declare function preferStableNodeBinary(execPath: string, opts?: {
35
+ env?: NodeJS.ProcessEnv;
36
+ home?: string;
37
+ io?: NodeBinaryIo;
38
+ }): string;
39
+ /**
40
+ * Why a unit's node path needs rewriting, or null when it is fine:
41
+ * "missing" — the binary is gone (launchd can no longer start the job);
42
+ * "versioned" — it still exists but the next upgrade will remove it, and a
43
+ * stable alias for the same binary exists.
44
+ */
45
+ export declare function nodeBinaryProblem(nodePath: string, opts?: {
46
+ env?: NodeJS.ProcessEnv;
47
+ home?: string;
48
+ io?: NodeBinaryIo;
49
+ }): "missing" | "versioned" | null;
50
+ //# sourceMappingURL=node-binary.d.ts.map
@@ -0,0 +1,133 @@
1
+ /**
2
+ * A node path that survives a node upgrade, for service units (LaunchAgents,
3
+ * systemd user units) that name node by absolute path.
4
+ *
5
+ * `process.execPath` is the resolved binary, e.g.
6
+ * /opt/homebrew/Cellar/node/26.0.0/bin/node. `brew upgrade node` deletes that
7
+ * directory, and every unit that baked it in stops launching (launchd cannot
8
+ * exec a missing program, so KeepAlive never brings the job back).
9
+ *
10
+ * The fix is to write the stable name the package manager keeps pointing at
11
+ * the current version (/opt/homebrew/bin/node, /opt/homebrew/opt/node/bin/node)
12
+ * instead of the versioned directory it points at today. A candidate is only
13
+ * used when it resolves to the SAME binary this process runs, so the unit
14
+ * keeps running the exact node it was installed with; a machine where no such
15
+ * alias exists (nvm without a `current` link, a hand-built node) keeps the
16
+ * execPath, as before.
17
+ */
18
+ import * as fs from "node:fs";
19
+ import * as os from "node:os";
20
+ import * as path from "node:path";
21
+ const defaultIo = {
22
+ existsSync: (p) => fs.existsSync(p),
23
+ realpathSync: (p) => fs.realpathSync(p),
24
+ };
25
+ /**
26
+ * Install layouts whose node lives in a per-version directory that an upgrade
27
+ * or version switch removes or abandons.
28
+ */
29
+ const VERSIONED_NODE_PATTERNS = [
30
+ // Homebrew: <prefix>/Cellar/node/26.0.0/bin/node, <prefix>/Cellar/node@22/22.9.0/bin/node
31
+ /\/Cellar\/node(?:@[^/]+)?\/[^/]+\/bin\/node$/,
32
+ // nvm
33
+ /\/\.nvm\/versions\/node\/[^/]+\/bin\/node$/,
34
+ // Volta
35
+ /\/\.volta\/tools\/image\/node\/[^/]+\/bin\/node$/,
36
+ // fnm (installed versions and the per-shell multishell links)
37
+ /\/fnm\/node-versions\/[^/]+\/installation\/bin\/node$/,
38
+ /\/fnm_multishells\/[^/]+\/bin\/node$/,
39
+ // asdf / mise
40
+ /\/\.asdf\/installs\/nodejs\/[^/]+\/bin\/node$/,
41
+ /\/mise\/installs\/node\/[^/]+\/bin\/node$/,
42
+ // n
43
+ /\/n\/versions\/node\/[^/]+\/bin\/node$/,
44
+ ];
45
+ /** True when `p` is a node binary inside a version-specific directory. */
46
+ export function isVersionedNodePath(p) {
47
+ const normalized = p.split(path.sep).join("/");
48
+ return VERSIONED_NODE_PATTERNS.some((re) => re.test(normalized));
49
+ }
50
+ /**
51
+ * Stable names that may point at `execPath`, most specific first:
52
+ * the Homebrew prefix's bin/ and opt/<formula>/ links, a `current` link under
53
+ * nvm, then the usual global bins and every PATH entry.
54
+ */
55
+ export function stableNodeCandidates(execPath, env = process.env, home = os.homedir()) {
56
+ const normalized = execPath.split(path.sep).join("/");
57
+ const out = [];
58
+ const brew = /^(.*)\/Cellar\/(node(?:@[^/]+)?)\/[^/]+\/bin\/node$/.exec(normalized);
59
+ if (brew) {
60
+ const prefix = brew[1];
61
+ out.push(`${prefix}/bin/node`, `${prefix}/opt/${brew[2]}/bin/node`);
62
+ }
63
+ const nvmDir = env.NVM_DIR?.trim() || path.join(home, ".nvm");
64
+ if (/\/\.nvm\/versions\/node\//.test(normalized))
65
+ out.push(path.join(nvmDir, "current", "bin", "node"));
66
+ out.push("/opt/homebrew/bin/node", "/usr/local/bin/node", "/usr/bin/node");
67
+ for (const dir of (env.PATH ?? "").split(path.delimiter)) {
68
+ if (dir && path.isAbsolute(dir))
69
+ out.push(path.join(dir, "node"));
70
+ }
71
+ return [...new Set(out)];
72
+ }
73
+ /**
74
+ * The stable name for `execPath` when it sits in a versioned directory and an
75
+ * alias resolving to the same binary exists; otherwise `execPath` unchanged.
76
+ */
77
+ export function preferStableNodeBinary(execPath, opts = {}) {
78
+ if (!execPath || !isVersionedNodePath(execPath))
79
+ return execPath;
80
+ const io = opts.io ?? defaultIo;
81
+ let target;
82
+ try {
83
+ target = io.realpathSync(execPath);
84
+ }
85
+ catch {
86
+ // A versioned node that is already gone (a stale unit being repaired):
87
+ // there is no binary to match, so any working stable node is the answer.
88
+ return firstWorkingStableNode(execPath, opts, io) ?? execPath;
89
+ }
90
+ for (const candidate of stableNodeCandidates(execPath, opts.env ?? process.env, opts.home ?? os.homedir())) {
91
+ if (isVersionedNodePath(candidate))
92
+ continue;
93
+ try {
94
+ if (!io.existsSync(candidate))
95
+ continue;
96
+ if (io.realpathSync(candidate) === target)
97
+ return candidate;
98
+ }
99
+ catch {
100
+ /* dangling link — not a candidate */
101
+ }
102
+ }
103
+ return execPath;
104
+ }
105
+ function firstWorkingStableNode(execPath, opts, io) {
106
+ for (const candidate of stableNodeCandidates(execPath, opts.env ?? process.env, opts.home ?? os.homedir())) {
107
+ if (isVersionedNodePath(candidate))
108
+ continue;
109
+ try {
110
+ if (io.existsSync(candidate) && io.existsSync(io.realpathSync(candidate)))
111
+ return candidate;
112
+ }
113
+ catch {
114
+ /* dangling link */
115
+ }
116
+ }
117
+ return null;
118
+ }
119
+ /**
120
+ * Why a unit's node path needs rewriting, or null when it is fine:
121
+ * "missing" — the binary is gone (launchd can no longer start the job);
122
+ * "versioned" — it still exists but the next upgrade will remove it, and a
123
+ * stable alias for the same binary exists.
124
+ */
125
+ export function nodeBinaryProblem(nodePath, opts = {}) {
126
+ const io = opts.io ?? defaultIo;
127
+ if (!nodePath)
128
+ return null;
129
+ if (!io.existsSync(nodePath))
130
+ return "missing";
131
+ return preferStableNodeBinary(nodePath, opts) !== nodePath ? "versioned" : null;
132
+ }
133
+ //# sourceMappingURL=node-binary.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.171.0",
3
+ "version": "5.173.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {