@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.
- package/CHANGELOG.md +57 -0
- package/dist/commands/agents.d.ts +43 -0
- package/dist/commands/agents.js +137 -0
- package/dist/commands/doctor.d.ts +10 -1
- package/dist/commands/doctor.js +7 -2
- package/dist/commands/integrations-api.d.ts +216 -0
- package/dist/commands/integrations-api.js +135 -0
- package/dist/commands/integrations-connect.d.ts +30 -0
- package/dist/commands/integrations-connect.js +583 -0
- package/dist/commands/integrations-core.d.ts +216 -0
- package/dist/commands/integrations-core.js +320 -0
- package/dist/commands/integrations-manage.d.ts +50 -0
- package/dist/commands/integrations-manage.js +556 -0
- package/dist/commands/integrations-oauth.d.ts +43 -0
- package/dist/commands/integrations-oauth.js +159 -0
- package/dist/commands/integrations.d.ts +32 -69
- package/dist/commands/integrations.js +42 -262
- package/dist/commands/reindex.js +1 -1
- package/dist/lib/doctor/checks/runtime-health.d.ts +100 -0
- package/dist/lib/doctor/checks/runtime-health.js +336 -0
- package/dist/lib/doctor/registry.js +6 -0
- package/dist/lib/doctor/types.d.ts +7 -0
- package/dist/utils/self-update.d.ts +2 -2
- package/dist/utils/self-update.js +19 -3
- package/dist/utils/version-gate.d.ts +34 -3
- package/dist/utils/version-gate.js +61 -4
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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;
|