@indigoai-us/hq-cli 5.101.7 → 5.103.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.
@@ -0,0 +1,100 @@
1
+ /**
2
+ * AI runtime health: are the CLIs HQ orchestrates actually able to answer?
3
+ *
4
+ * Every hooks-family check verifies HQ's own wiring. This family verifies the
5
+ * other half of the contract: the Claude, Codex, and Grok CLIs themselves. A
6
+ * tree can pass every wiring check while `codex` is logged out, `grok` is not
7
+ * installed, or `claude` is broken by a bad update — and until this family
8
+ * existed `hq doctor` would still report all green.
9
+ *
10
+ * ## Two tiers, mirroring the doctor's offline contract
11
+ *
12
+ * 1. **Presence (always).** Resolving each binary against PATH is a pure
13
+ * filesystem scan — no process is spawned — so a plain `hq doctor` stays a
14
+ * function of the on-disk shape. A missing binary is WARN, not FAIL: HQ
15
+ * wires hooks for all three runtimes, but not every machine runs all three.
16
+ *
17
+ * 2. **Live probes (`--live-runtimes` only).** Reads the CLI's version and
18
+ * sends it a one-line prompt, verifying the full path: binary → auth →
19
+ * subscription → model → response. This is the doctor's ONLY networked
20
+ * tier and it never runs without the flag; without it the live checks
21
+ * report UNTESTED, never PASS — installed is not the same as working.
22
+ *
23
+ * Probes run from the OS temp directory, never the HQ tree, so a probe cannot
24
+ * trigger HQ's own hook stack or leave session state behind, and each one is
25
+ * bounded by a timeout. A timeout or spawn error is UNKNOWN (could not be
26
+ * determined), while a clean non-zero exit — the logged-out case — is FAIL.
27
+ */
28
+ import type { CheckContext, CheckFamily, CheckResult } from "../types.js";
29
+ /** The id of the AI-runtime-health family. */
30
+ export declare const RUNTIMES_FAMILY_ID = "runtimes";
31
+ /** Common id prefix for every result this family emits. */
32
+ export declare const RUNTIMES_PREFIX = "runtimes";
33
+ /**
34
+ * The deterministic one-line probe prompt. Health is proven by a round-trip
35
+ * (exit 0 plus non-empty output), not by exact-matching the reply, so a model
36
+ * that answers with anything at all still passes.
37
+ */
38
+ export declare const PROBE_PROMPT = "Reply with exactly: OK";
39
+ /** How long each live probe may run before it is killed and marked UNKNOWN. */
40
+ export declare const DEFAULT_PROBE_TIMEOUT_MS = 120000;
41
+ /** One AI runtime the doctor knows how to find and probe. */
42
+ export interface RuntimeSpec {
43
+ /** Stable key used in check ids, e.g. `runtimes.codex.binary`. */
44
+ key: string;
45
+ /** Human display name for messages. */
46
+ displayName: string;
47
+ /** The executable name resolved against PATH. */
48
+ binary: string;
49
+ /** Arguments that print the version and exit (offline, auth-free). */
50
+ versionArgs: string[];
51
+ /** Arguments for the non-interactive one-line prompt probe. */
52
+ probeArgs: string[];
53
+ /** Remediation hint when the binary is not on PATH. */
54
+ installHint: string;
55
+ }
56
+ /**
57
+ * The three runtimes HQ wires hooks for. Codex runs its probe under its own
58
+ * read-only sandbox and outside-a-repo mode so the probe can never write; the
59
+ * Claude and Grok print modes are non-interactive and make no edits.
60
+ */
61
+ export declare const AI_RUNTIMES: readonly RuntimeSpec[];
62
+ /** The outcome of one spawned probe, normalised so callers never throw. */
63
+ export interface ProbeOutcome {
64
+ /** True iff the process spawned and exited 0. */
65
+ ok: boolean;
66
+ /** Exit code, or null when the process never exited normally. */
67
+ code: number | null;
68
+ stdout: string;
69
+ stderr: string;
70
+ /** True when the probe was killed by the timeout. */
71
+ timedOut: boolean;
72
+ /** Present when the spawn itself failed (ENOENT, EACCES, …). */
73
+ spawnError?: string;
74
+ }
75
+ /** Injectable dependencies so the family is unit-testable without spawning. */
76
+ export interface RuntimeHealthDeps {
77
+ /** Resolve an executable against PATH; null when not found. */
78
+ resolveBinary?: (binary: string) => string | null;
79
+ /** Execute one probe. The default spawns from the OS temp dir. */
80
+ execProbe?: (file: string, args: string[], timeoutMs: number) => Promise<ProbeOutcome>;
81
+ /** Per-probe timeout. Default {@link DEFAULT_PROBE_TIMEOUT_MS}. */
82
+ probeTimeoutMs?: number;
83
+ }
84
+ /**
85
+ * The AI-runtime-health check family entry. Presence checks always run; the
86
+ * version and prompt probes run only when the context carries `liveRuntimes`
87
+ * (the `--live-runtimes` flag). Never rejects — an unexpected throw degrades
88
+ * to a single UNKNOWN result, mirroring the hooks family's safeTier.
89
+ */
90
+ export declare function checkRuntimeHealth(context: CheckContext, deps?: RuntimeHealthDeps): Promise<CheckResult[]>;
91
+ /** The registered family object. */
92
+ export declare const runtimeHealthFamily: CheckFamily;
93
+ /**
94
+ * Resolve an executable name against PATH with a pure filesystem scan — no
95
+ * process is spawned, keeping the doctor's default run a function of on-disk
96
+ * shape. First PATH entry containing an executable regular file wins, which is
97
+ * exactly the copy a shell would run.
98
+ */
99
+ export declare function resolveOnPath(binary: string): string | null;
100
+ //# sourceMappingURL=runtime-health.d.ts.map
@@ -0,0 +1,336 @@
1
+ /**
2
+ * AI runtime health: are the CLIs HQ orchestrates actually able to answer?
3
+ *
4
+ * Every hooks-family check verifies HQ's own wiring. This family verifies the
5
+ * other half of the contract: the Claude, Codex, and Grok CLIs themselves. A
6
+ * tree can pass every wiring check while `codex` is logged out, `grok` is not
7
+ * installed, or `claude` is broken by a bad update — and until this family
8
+ * existed `hq doctor` would still report all green.
9
+ *
10
+ * ## Two tiers, mirroring the doctor's offline contract
11
+ *
12
+ * 1. **Presence (always).** Resolving each binary against PATH is a pure
13
+ * filesystem scan — no process is spawned — so a plain `hq doctor` stays a
14
+ * function of the on-disk shape. A missing binary is WARN, not FAIL: HQ
15
+ * wires hooks for all three runtimes, but not every machine runs all three.
16
+ *
17
+ * 2. **Live probes (`--live-runtimes` only).** Reads the CLI's version and
18
+ * sends it a one-line prompt, verifying the full path: binary → auth →
19
+ * subscription → model → response. This is the doctor's ONLY networked
20
+ * tier and it never runs without the flag; without it the live checks
21
+ * report UNTESTED, never PASS — installed is not the same as working.
22
+ *
23
+ * Probes run from the OS temp directory, never the HQ tree, so a probe cannot
24
+ * trigger HQ's own hook stack or leave session state behind, and each one is
25
+ * bounded by a timeout. A timeout or spawn error is UNKNOWN (could not be
26
+ * determined), while a clean non-zero exit — the logged-out case — is FAIL.
27
+ */
28
+ import { spawn } from "node:child_process";
29
+ import * as fs from "node:fs";
30
+ import * as os from "node:os";
31
+ import * as path from "node:path";
32
+ /** The id of the AI-runtime-health family. */
33
+ export const RUNTIMES_FAMILY_ID = "runtimes";
34
+ /** Common id prefix for every result this family emits. */
35
+ export const RUNTIMES_PREFIX = "runtimes";
36
+ /**
37
+ * The deterministic one-line probe prompt. Health is proven by a round-trip
38
+ * (exit 0 plus non-empty output), not by exact-matching the reply, so a model
39
+ * that answers with anything at all still passes.
40
+ */
41
+ export const PROBE_PROMPT = "Reply with exactly: OK";
42
+ /** How long each live probe may run before it is killed and marked UNKNOWN. */
43
+ export const DEFAULT_PROBE_TIMEOUT_MS = 120_000;
44
+ /**
45
+ * The three runtimes HQ wires hooks for. Codex runs its probe under its own
46
+ * read-only sandbox and outside-a-repo mode so the probe can never write; the
47
+ * Claude and Grok print modes are non-interactive and make no edits.
48
+ */
49
+ export const AI_RUNTIMES = [
50
+ {
51
+ key: "claude",
52
+ displayName: "Claude Code",
53
+ binary: "claude",
54
+ versionArgs: ["--version"],
55
+ probeArgs: ["-p", PROBE_PROMPT],
56
+ installHint: "npm install -g @anthropic-ai/claude-code",
57
+ },
58
+ {
59
+ key: "codex",
60
+ displayName: "Codex CLI",
61
+ binary: "codex",
62
+ versionArgs: ["--version"],
63
+ probeArgs: [
64
+ "exec",
65
+ "--skip-git-repo-check",
66
+ "--sandbox",
67
+ "read-only",
68
+ PROBE_PROMPT,
69
+ ],
70
+ installHint: "npm install -g @openai/codex",
71
+ },
72
+ {
73
+ key: "grok",
74
+ displayName: "Grok CLI",
75
+ binary: "grok",
76
+ versionArgs: ["--version"],
77
+ probeArgs: ["-p", PROBE_PROMPT],
78
+ installHint: "install the Grok CLI and ensure `grok` is on PATH",
79
+ },
80
+ ];
81
+ /**
82
+ * The AI-runtime-health check family entry. Presence checks always run; the
83
+ * version and prompt probes run only when the context carries `liveRuntimes`
84
+ * (the `--live-runtimes` flag). Never rejects — an unexpected throw degrades
85
+ * to a single UNKNOWN result, mirroring the hooks family's safeTier.
86
+ */
87
+ export async function checkRuntimeHealth(context, deps = {}) {
88
+ try {
89
+ return await runRuntimeChecks(context, deps);
90
+ }
91
+ catch (error) {
92
+ return [
93
+ {
94
+ status: "UNKNOWN",
95
+ checkId: `${RUNTIMES_PREFIX}.error`,
96
+ message: `AI runtime checks could not run: ${error.message}`,
97
+ },
98
+ ];
99
+ }
100
+ }
101
+ async function runRuntimeChecks(context, deps) {
102
+ const resolved = {
103
+ resolveBinary: deps.resolveBinary ?? resolveOnPath,
104
+ execProbe: deps.execProbe ?? defaultExecProbe,
105
+ timeoutMs: deps.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS,
106
+ live: context.liveRuntimes === true,
107
+ };
108
+ // Each runtime's checks are independent, so probe them concurrently: total
109
+ // wall time is the slowest single runtime, not the sum of all three.
110
+ const perRuntime = await Promise.all(AI_RUNTIMES.map((spec) => checkOneRuntime(spec, resolved)));
111
+ return perRuntime.flat();
112
+ }
113
+ async function checkOneRuntime(spec, deps) {
114
+ const idBase = `${RUNTIMES_PREFIX}.${spec.key}`;
115
+ const resolvedPath = deps.resolveBinary(spec.binary);
116
+ if (!resolvedPath) {
117
+ return [
118
+ {
119
+ status: "WARN",
120
+ checkId: `${idBase}.binary`,
121
+ target: spec.binary,
122
+ message: `${spec.displayName} (\`${spec.binary}\`) was not found on PATH; HQ's ${spec.displayName} hook wiring can never fire on this machine.`,
123
+ remediation: `If you use ${spec.displayName} here: ${spec.installHint}.`,
124
+ },
125
+ {
126
+ status: "NA",
127
+ checkId: `${idBase}.responds`,
128
+ target: spec.binary,
129
+ message: `${spec.displayName} cannot be probed — the binary is not installed.`,
130
+ },
131
+ ];
132
+ }
133
+ const results = [
134
+ {
135
+ status: "PASS",
136
+ checkId: `${idBase}.binary`,
137
+ target: resolvedPath,
138
+ message: `${spec.displayName} (\`${spec.binary}\`) is on PATH at ${resolvedPath}.`,
139
+ },
140
+ ];
141
+ if (!deps.live) {
142
+ results.push({
143
+ status: "UNTESTED",
144
+ checkId: `${idBase}.responds`,
145
+ target: spec.binary,
146
+ message: `${spec.displayName} is installed but has not been exercised — installed is not the same as logged in and answering.`,
147
+ remediation: "Run `hq doctor --live-runtimes` to send each installed AI CLI a one-line prompt (networked; uses your subscriptions).",
148
+ });
149
+ return results;
150
+ }
151
+ // Live tier: version first (offline, auth-free), then the real prompt. The
152
+ // two run concurrently — they are independent evidence, and a broken CLI
153
+ // fails both cheaply.
154
+ const [version, probe] = await Promise.all([
155
+ deps.execProbe(resolvedPath, spec.versionArgs, deps.timeoutMs),
156
+ deps.execProbe(resolvedPath, spec.probeArgs, deps.timeoutMs),
157
+ ]);
158
+ results.push(versionResult(spec, idBase, version));
159
+ results.push(probeResult(spec, idBase, probe, deps.timeoutMs));
160
+ return results;
161
+ }
162
+ function versionResult(spec, idBase, outcome) {
163
+ const checkId = `${idBase}.version`;
164
+ if (outcome.ok) {
165
+ return {
166
+ status: "PASS",
167
+ checkId,
168
+ target: spec.binary,
169
+ message: `${spec.displayName} reports version: ${excerpt(outcome.stdout) || "(no output)"}.`,
170
+ };
171
+ }
172
+ if (outcome.timedOut || outcome.spawnError) {
173
+ return {
174
+ status: "UNKNOWN",
175
+ checkId,
176
+ target: spec.binary,
177
+ message: `${spec.displayName} version could not be read: ${outcome.timedOut ? "the command timed out" : outcome.spawnError}.`,
178
+ };
179
+ }
180
+ return {
181
+ status: "FAIL",
182
+ checkId,
183
+ target: spec.binary,
184
+ message: `\`${spec.binary} ${spec.versionArgs.join(" ")}\` exited ${outcome.code}: ${excerpt(outcome.stderr) || "(no stderr)"}.`,
185
+ };
186
+ }
187
+ function probeResult(spec, idBase, outcome, timeoutMs) {
188
+ const checkId = `${idBase}.responds`;
189
+ if (outcome.ok && outcome.stdout.trim().length > 0) {
190
+ return {
191
+ status: "PASS",
192
+ checkId,
193
+ target: spec.binary,
194
+ message: `${spec.displayName} answered a live prompt: "${excerpt(outcome.stdout)}".`,
195
+ };
196
+ }
197
+ if (outcome.timedOut) {
198
+ return {
199
+ status: "UNKNOWN",
200
+ checkId,
201
+ target: spec.binary,
202
+ message: `${spec.displayName} did not answer within ${Math.round(timeoutMs / 1000)}s — it may be hung, waiting on interactive input, or on a very slow network.`,
203
+ remediation: `Run \`${spec.binary}\` interactively to see what it is waiting on.`,
204
+ };
205
+ }
206
+ if (outcome.spawnError) {
207
+ return {
208
+ status: "UNKNOWN",
209
+ checkId,
210
+ target: spec.binary,
211
+ message: `${spec.displayName} could not be executed: ${outcome.spawnError}.`,
212
+ };
213
+ }
214
+ if (outcome.ok) {
215
+ return {
216
+ status: "FAIL",
217
+ checkId,
218
+ target: spec.binary,
219
+ message: `${spec.displayName} exited 0 but produced no output for the probe prompt — the runtime is not returning responses.`,
220
+ remediation: `Run \`${spec.binary}\` interactively to check login and subscription state.`,
221
+ };
222
+ }
223
+ return {
224
+ status: "FAIL",
225
+ checkId,
226
+ target: spec.binary,
227
+ message: `${spec.displayName} could not answer a live prompt (exit ${outcome.code}): ${excerpt(outcome.stderr) || excerpt(outcome.stdout) || "(no output)"}.`,
228
+ remediation: `Run \`${spec.binary}\` interactively to check login and subscription state.`,
229
+ };
230
+ }
231
+ /** The registered family object. */
232
+ export const runtimeHealthFamily = {
233
+ id: RUNTIMES_FAMILY_ID,
234
+ title: "AI runtime health",
235
+ run: (context) => checkRuntimeHealth(context),
236
+ };
237
+ // --- default dependencies ----------------------------------------------------
238
+ /**
239
+ * Resolve an executable name against PATH with a pure filesystem scan — no
240
+ * process is spawned, keeping the doctor's default run a function of on-disk
241
+ * shape. First PATH entry containing an executable regular file wins, which is
242
+ * exactly the copy a shell would run.
243
+ */
244
+ export function resolveOnPath(binary) {
245
+ const pathVar = process.env.PATH ?? "";
246
+ for (const dir of pathVar.split(path.delimiter)) {
247
+ if (!dir)
248
+ continue;
249
+ const candidate = path.join(dir, binary);
250
+ try {
251
+ if (!fs.statSync(candidate).isFile())
252
+ continue;
253
+ fs.accessSync(candidate, fs.constants.X_OK);
254
+ return candidate;
255
+ }
256
+ catch {
257
+ continue;
258
+ }
259
+ }
260
+ return null;
261
+ }
262
+ /**
263
+ * Spawn one probe from the OS temp directory (never the HQ tree, so a probe
264
+ * cannot trigger HQ's hook stack or leave session state), bounded by the
265
+ * timeout. Never rejects — every failure mode is folded into the outcome.
266
+ *
267
+ * stdin is `ignore` (closed at /dev/null), not a pipe: `codex exec` — and any
268
+ * CLI that reads piped stdin in non-TTY mode — blocks forever on a pipe that
269
+ * never reaches EOF, which turns every probe into a timeout.
270
+ */
271
+ function defaultExecProbe(file, args, timeoutMs) {
272
+ return new Promise((resolve) => {
273
+ const child = spawn(file, args, {
274
+ cwd: os.tmpdir(),
275
+ stdio: ["ignore", "pipe", "pipe"],
276
+ });
277
+ const cap = 1024 * 1024;
278
+ let stdout = "";
279
+ let stderr = "";
280
+ let timedOut = false;
281
+ let settled = false;
282
+ child.stdout?.setEncoding("utf8");
283
+ child.stderr?.setEncoding("utf8");
284
+ child.stdout?.on("data", (chunk) => {
285
+ if (stdout.length < cap)
286
+ stdout += chunk;
287
+ });
288
+ child.stderr?.on("data", (chunk) => {
289
+ if (stderr.length < cap)
290
+ stderr += chunk;
291
+ });
292
+ // SIGTERM at the deadline, escalating to SIGKILL for a child that ignores
293
+ // it — a hung probe must never hang the doctor itself.
294
+ const killTimer = setTimeout(() => {
295
+ timedOut = true;
296
+ child.kill("SIGTERM");
297
+ setTimeout(() => {
298
+ if (!settled)
299
+ child.kill("SIGKILL");
300
+ }, 10_000).unref();
301
+ }, timeoutMs);
302
+ const settle = (outcome) => {
303
+ if (settled)
304
+ return;
305
+ settled = true;
306
+ clearTimeout(killTimer);
307
+ resolve(outcome);
308
+ };
309
+ child.on("error", (error) => settle({
310
+ ok: false,
311
+ code: null,
312
+ stdout,
313
+ stderr,
314
+ timedOut: false,
315
+ spawnError: error.message,
316
+ }));
317
+ child.on("close", (code) => settle({
318
+ ok: code === 0 && !timedOut,
319
+ code,
320
+ stdout,
321
+ stderr,
322
+ timedOut,
323
+ }));
324
+ });
325
+ }
326
+ /** Last non-empty line of output, whitespace-collapsed and capped for display. */
327
+ function excerpt(output) {
328
+ const line = output
329
+ .split("\n")
330
+ .map((l) => l.trim())
331
+ .filter((l) => l.length > 0)
332
+ .at(-1) ?? "";
333
+ const collapsed = line.replace(/\s+/g, " ");
334
+ return collapsed.length > 120 ? `${collapsed.slice(0, 117)}...` : collapsed;
335
+ }
336
+ //# sourceMappingURL=runtime-health.js.map
@@ -18,6 +18,7 @@ import * as path from "node:path";
18
18
  import { checkCodexWiring } from "./checks/codex-wiring.js";
19
19
  import { checkGrokWiring } from "./checks/grok-wiring.js";
20
20
  import { checkRuntimeProbe } from "./checks/runtime-probe.js";
21
+ import { runtimeHealthFamily } from "./checks/runtime-health.js";
21
22
  import { fixtureCoverageFamily } from "./fixtures/discover.js";
22
23
  import { checkClaudeWiring } from "./checks/claude-wiring.js";
23
24
  /**
@@ -171,6 +172,11 @@ export function createDefaultRegistry() {
171
172
  // it needs no change to the hooks tier, per the registry's extensibility
172
173
  // contract.
173
174
  registry.register(fixtureCoverageFamily);
175
+ // AI runtime health: are the Claude / Codex / Grok CLIs installed and (with
176
+ // --live-runtimes) actually answering? Hooks wiring can be perfect while a
177
+ // runtime is logged out or broken; this family closes that blind spot. Its
178
+ // default tier is a pure PATH scan, preserving the offline contract.
179
+ registry.register(runtimeHealthFamily);
174
180
  return registry;
175
181
  }
176
182
  //# sourceMappingURL=registry.js.map
@@ -59,6 +59,13 @@ export interface CheckContext {
59
59
  * Absent means "any session's ledger counts".
60
60
  */
61
61
  sessionId?: string;
62
+ /**
63
+ * When true, the AI-runtime-health family may execute live, networked probes
64
+ * of the installed AI CLIs (the `--live-runtimes` flag). Absent or false
65
+ * keeps the run offline: the live checks report UNTESTED instead of
66
+ * executing anything.
67
+ */
68
+ liveRuntimes?: boolean;
62
69
  }
63
70
  /**
64
71
  * A check family: an id, a human title, and an async `run` returning per-item
@@ -71,7 +71,7 @@ export declare function buildSelfUpdatePlan(install: RunningInstall): {
71
71
  * to stdout. Progress is summarised on stderr by the caller instead; captured
72
72
  * stderr is kept only to explain a failure.
73
73
  */
74
- export declare function runUpdateQuiet(cmd: string, args: string[]): UpdateResult;
74
+ export declare function runUpdateQuiet(cmd: string, args: string[], env?: NodeJS.ProcessEnv): UpdateResult;
75
75
  /**
76
76
  * Serialize self-updates across concurrent `hq` processes. Without this, a
77
77
  * machine running several HQ agents can fire many `npm install -g` at the same
@@ -90,7 +90,7 @@ export interface SelfUpdateDeps {
90
90
  currentVersion?: string;
91
91
  fetchLatest?: () => Promise<string | null>;
92
92
  resolveInstall?: () => RunningInstall;
93
- runner?: (cmd: string, args: string[]) => UpdateResult;
93
+ runner?: (cmd: string, args: string[], env?: NodeJS.ProcessEnv) => UpdateResult;
94
94
  reexec?: (argv: string[], env: NodeJS.ProcessEnv) => number | null;
95
95
  acquireLock?: () => (() => void) | null;
96
96
  }
@@ -38,7 +38,7 @@ import * as path from "node:path";
38
38
  import semver from "semver";
39
39
  import chalk from "chalk";
40
40
  import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
41
- import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, resolveRunningInstall, runUpdateCommand, } from "./version-gate.js";
41
+ import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, } from "./version-gate.js";
42
42
  /**
43
43
  * Set on the re-exec'd child so it can never self-update (and re-exec) again.
44
44
  * One update + one re-exec per user invocation, ever.
@@ -88,13 +88,14 @@ export function buildSelfUpdatePlan(install) {
88
88
  * to stdout. Progress is summarised on stderr by the caller instead; captured
89
89
  * stderr is kept only to explain a failure.
90
90
  */
91
- export function runUpdateQuiet(cmd, args) {
91
+ export function runUpdateQuiet(cmd, args, env) {
92
92
  try {
93
93
  const plan = buildSpawnPlan(cmd, args);
94
94
  const result = spawnSync(plan.cmd, plan.args, {
95
95
  stdio: ["ignore", "pipe", "pipe"],
96
96
  shell: plan.shell,
97
97
  encoding: "utf-8",
98
+ ...(env ? { env } : {}),
98
99
  });
99
100
  if (result.error) {
100
101
  const code = result.error.code;
@@ -200,10 +201,25 @@ async function updateAndReexec(argv, flavor, known, deps) {
200
201
  let result;
201
202
  const install = (deps.resolveInstall ?? resolveRunningInstall)();
202
203
  const plan = buildSelfUpdatePlan(install);
204
+ // A pnpm global install needs PNPM_HOME to find its global bin dir. A
205
+ // minimal-environment parent (systemd, cron, non-login shell) lacks it and
206
+ // `pnpm add -g` aborts with ERR_PNPM_NO_GLOBAL_BIN_DIR (exit 1), so the
207
+ // self-update never lands until the box is next touched from an interactive
208
+ // shell. Derive PNPM_HOME from the running install so the update works
209
+ // regardless of the parent environment. No-op for npm/bun and when the
210
+ // parent already sets PNPM_HOME.
211
+ const updateEnv = pnpmUpdateEnv(install, env);
203
212
  try {
204
213
  console.error(chalk.dim(`Updating hq-cli ${current} → ${latest}…`));
205
214
  const defaultRunner = flavor.verbose ? runUpdateCommand : runUpdateQuiet;
206
- result = (deps.runner ?? defaultRunner)(plan.cmd, plan.args);
215
+ const runner = deps.runner ?? defaultRunner;
216
+ // Forward env only when PNPM_HOME had to be injected, so the common path
217
+ // spawns with the inherited environment and keeps the two-argument runner
218
+ // call it has always made.
219
+ result =
220
+ updateEnv === undefined
221
+ ? runner(plan.cmd, plan.args)
222
+ : runner(plan.cmd, plan.args, updateEnv);
207
223
  }
208
224
  finally {
209
225
  releaseLock();
@@ -96,6 +96,35 @@ export declare function resolveRunningInstall(): RunningInstall;
96
96
  export declare function resolveRunningManager(): InstallManager;
97
97
  /** Convenience view of {@link resolveRunningInstall} for callers needing one field. */
98
98
  export declare function resolveRunningPrefix(): string | null;
99
+ /**
100
+ * Derive `PNPM_HOME` from a pnpm-managed install's own path. pnpm resolves its
101
+ * global bin directory from `PNPM_HOME` (or an explicit `global-bin-dir`), and
102
+ * `PNPM_HOME` is the path segment immediately preceding pnpm's global root, e.g.
103
+ *
104
+ * /home/u/.local/share/pnpm/global/5/… -> PNPM_HOME=/home/u/.local/share/pnpm
105
+ *
106
+ * Returns null for non-pnpm installs or a `packageRoot` with no `/global/`
107
+ * segment (nothing to derive from). Reading it from the running install is more
108
+ * accurate than trusting the inherited environment: it names the exact copy
109
+ * that is actually on PATH.
110
+ */
111
+ export declare function derivePnpmHome(install: RunningInstall): string | null;
112
+ /**
113
+ * Environment for the pnpm self-update spawn. `pnpm add -g` aborts with
114
+ * `ERR_PNPM_NO_GLOBAL_BIN_DIR` (exit 1) when neither `PNPM_HOME` nor a
115
+ * `global-bin-dir` setting is present — the exact failure seen when `hq`
116
+ * self-updates from a minimal-environment parent (a systemd `--user` service,
117
+ * cron, or any non-login shell that never sourced the profile exporting
118
+ * `PNPM_HOME`). When the running install is pnpm-managed and the parent lacks
119
+ * `PNPM_HOME`, inject one derived from the install's own path so the update can
120
+ * find the global bin dir it is about to rewrite.
121
+ *
122
+ * A no-op for npm/bun installs, and for any environment that already sets
123
+ * `PNPM_HOME` (never overridden — the caller's value wins). Returns `undefined`
124
+ * when nothing needs injecting, so callers can spawn with the inherited
125
+ * environment unchanged (and pass no `env` at all).
126
+ */
127
+ export declare function pnpmUpdateEnv(install: RunningInstall, base?: NodeJS.ProcessEnv): NodeJS.ProcessEnv | undefined;
99
128
  export declare function buildPrefixedInstallArgv(prefix: string): string[];
100
129
  /**
101
130
  * Argv for updating a pnpm-managed global install. `pnpm add -g` rewrites the
@@ -150,10 +179,10 @@ export type UpdateResult = {
150
179
  detail?: string;
151
180
  code?: string;
152
181
  };
153
- type UpdateRunner = (cmd: string, args: string[]) => UpdateResult;
182
+ type UpdateRunner = (cmd: string, args: string[], env?: NodeJS.ProcessEnv) => UpdateResult;
154
183
  export { buildSpawnPlan, quoteForWindowsShell };
155
- export declare function runUpdateCommand(cmd: string, args: string[]): UpdateResult;
156
- declare function performUpdateCommand(cmd: string, args: string[], runner?: UpdateRunner): UpdateResult;
184
+ export declare function runUpdateCommand(cmd: string, args: string[], env?: NodeJS.ProcessEnv): UpdateResult;
185
+ declare function performUpdateCommand(cmd: string, args: string[], runner?: UpdateRunner, env?: NodeJS.ProcessEnv): UpdateResult;
157
186
  declare function performUpdate(command: string, runner?: UpdateRunner): UpdateResult;
158
187
  /**
159
188
  * Soft notify when the server says we're below `latestVersion` but still ≥
@@ -218,8 +247,10 @@ export declare const __test__: {
218
247
  buildPrefixedInstallArgv: typeof buildPrefixedInstallArgv;
219
248
  buildSpawnPlan: typeof buildSpawnPlan;
220
249
  cleanStalePartialInstall: typeof cleanStalePartialInstall;
250
+ derivePnpmHome: typeof derivePnpmHome;
221
251
  enforceUpdateRequired: typeof enforceUpdateRequired;
222
252
  isBunManagedPackageDir: typeof isBunManagedPackageDir;
253
+ pnpmUpdateEnv: typeof pnpmUpdateEnv;
223
254
  isPnpmManagedPackageDir: typeof isPnpmManagedPackageDir;
224
255
  npmPrefixFromPackageDir: typeof npmPrefixFromPackageDir;
225
256
  nudgeUpdateRecommended: typeof nudgeUpdateRecommended;