@indigoai-us/hq-cli 5.102.0 → 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 +13 -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/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
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.103.0] — 2026-08-18
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `hq doctor` now checks the AI runtimes themselves, not just their hook
|
|
10
|
+
wiring. A new "AI runtime health" family verifies the `claude`, `codex`, and
|
|
11
|
+
`grok` CLIs are on PATH (a pure filesystem scan — the default run stays
|
|
12
|
+
offline; a missing CLI is WARN, never FAIL). A new `--live-runtimes` flag
|
|
13
|
+
additionally reads each installed CLI's version and sends it a one-line
|
|
14
|
+
prompt from a temp directory, proving the full binary → login → subscription
|
|
15
|
+
→ response path; without the flag those checks report UNTESTED, so a
|
|
16
|
+
logged-out or broken runtime can no longer hide behind green wiring checks.
|
|
17
|
+
|
|
5
18
|
## [5.102.0] — 2026-08-17
|
|
6
19
|
|
|
7
20
|
### Added
|
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
* hq agents start|stop <uid> — EC2 start/stop
|
|
16
16
|
* hq agents retry <uid> — resume setup from first non-done step
|
|
17
17
|
* hq agents rm <uid> --yes — deprovision (destructive; flag-guarded)
|
|
18
|
+
* hq agents jobs list <uid> — off-box job roster (schedule + rate)
|
|
19
|
+
* hq agents jobs pause <uid> <jobId> — flip schedule State=DISABLED
|
|
20
|
+
* hq agents jobs cancel <uid> <jobId> — delete schedule + drop the job
|
|
18
21
|
*
|
|
19
22
|
* Agents are company-scoped. `--company <slug>` may sit on the group
|
|
20
23
|
* (`hq agents --company acme list`) or on a subcommand
|
|
@@ -204,6 +207,46 @@ export declare function deprovisionAgent(token: string, agentUid: string): Promi
|
|
|
204
207
|
setupState?: string;
|
|
205
208
|
terminal?: boolean;
|
|
206
209
|
}>;
|
|
210
|
+
/** EventBridge Scheduler State as the operator list/pause surface reports it. */
|
|
211
|
+
export type JobScheduleState = "ENABLED" | "DISABLED";
|
|
212
|
+
/** One operator list row from `GET /v1/agents/{uid}/jobs`. */
|
|
213
|
+
export interface AgentJobView {
|
|
214
|
+
jobId: string;
|
|
215
|
+
scheduleState: JobScheduleState | string;
|
|
216
|
+
rate: string;
|
|
217
|
+
lastRunOutcome: string | null;
|
|
218
|
+
status?: string;
|
|
219
|
+
nextRunAt?: string | null;
|
|
220
|
+
lastRunAt?: string | null;
|
|
221
|
+
prompt?: string;
|
|
222
|
+
}
|
|
223
|
+
export interface PauseJobResult {
|
|
224
|
+
ok: true;
|
|
225
|
+
jobId: string;
|
|
226
|
+
scheduleState: "DISABLED" | string;
|
|
227
|
+
changed: boolean;
|
|
228
|
+
previousState?: string;
|
|
229
|
+
}
|
|
230
|
+
export interface CancelJobResult {
|
|
231
|
+
ok: true;
|
|
232
|
+
jobId: string;
|
|
233
|
+
}
|
|
234
|
+
export declare function listAgentJobs(token: string, agentUid: string): Promise<AgentJobView[]>;
|
|
235
|
+
export declare function pauseAgentJob(token: string, agentUid: string, jobId: string): Promise<PauseJobResult>;
|
|
236
|
+
export declare function cancelAgentJob(token: string, agentUid: string, jobId: string): Promise<CancelJobResult>;
|
|
237
|
+
/**
|
|
238
|
+
* Map a jobs-control HTTP error to a single operator-facing line. Pure so the
|
|
239
|
+
* status/code → copy mapping is unit-tested without a process exit.
|
|
240
|
+
*
|
|
241
|
+
* 404 without `JOB_NOT_FOUND` is also the deploy-order-safe path: hq-cli
|
|
242
|
+
* shipped before the hq-pro endpoints exist (or the agents flag is OFF, or
|
|
243
|
+
* the agent is cross-company) must print a clear message and exit non-zero,
|
|
244
|
+
* never throw a stack trace.
|
|
245
|
+
*/
|
|
246
|
+
export declare function formatJobsHttpError(err: AgentsHttpError, jobId?: string): string;
|
|
247
|
+
/** Stable padEnd table: jobId, scheduleState, rate, lastRunOutcome, status. */
|
|
248
|
+
export declare function formatJobsTable(jobs: AgentJobView[]): string;
|
|
249
|
+
export declare function formatPauseResult(result: PauseJobResult): string;
|
|
207
250
|
/** Human-readable "hot-applied to the running box" vs "saved for next launch". */
|
|
208
251
|
export declare function appliedHint(applied: boolean): string;
|
|
209
252
|
/** One message in a DM thread, as returned by `GET /v1/notify/thread`. */
|
package/dist/commands/agents.js
CHANGED
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
* hq agents start|stop <uid> — EC2 start/stop
|
|
16
16
|
* hq agents retry <uid> — resume setup from first non-done step
|
|
17
17
|
* hq agents rm <uid> --yes — deprovision (destructive; flag-guarded)
|
|
18
|
+
* hq agents jobs list <uid> — off-box job roster (schedule + rate)
|
|
19
|
+
* hq agents jobs pause <uid> <jobId> — flip schedule State=DISABLED
|
|
20
|
+
* hq agents jobs cancel <uid> <jobId> — delete schedule + drop the job
|
|
18
21
|
*
|
|
19
22
|
* Agents are company-scoped. `--company <slug>` may sit on the group
|
|
20
23
|
* (`hq agents --company acme list`) or on a subcommand
|
|
@@ -309,6 +312,79 @@ export async function deprovisionAgent(token, agentUid) {
|
|
|
309
312
|
method: "DELETE",
|
|
310
313
|
});
|
|
311
314
|
}
|
|
315
|
+
export async function listAgentJobs(token, agentUid) {
|
|
316
|
+
const data = await agentsRequest({
|
|
317
|
+
token,
|
|
318
|
+
path: `/v1/agents/${encodeURIComponent(agentUid)}/jobs`,
|
|
319
|
+
});
|
|
320
|
+
return data.jobs ?? [];
|
|
321
|
+
}
|
|
322
|
+
export async function pauseAgentJob(token, agentUid, jobId) {
|
|
323
|
+
return agentsRequest({
|
|
324
|
+
token,
|
|
325
|
+
path: `/v1/agents/${encodeURIComponent(agentUid)}/jobs/${encodeURIComponent(jobId)}/pause`,
|
|
326
|
+
method: "POST",
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
export async function cancelAgentJob(token, agentUid, jobId) {
|
|
330
|
+
return agentsRequest({
|
|
331
|
+
token,
|
|
332
|
+
path: `/v1/agents/${encodeURIComponent(agentUid)}/jobs/${encodeURIComponent(jobId)}/cancel`,
|
|
333
|
+
method: "POST",
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Map a jobs-control HTTP error to a single operator-facing line. Pure so the
|
|
338
|
+
* status/code → copy mapping is unit-tested without a process exit.
|
|
339
|
+
*
|
|
340
|
+
* 404 without `JOB_NOT_FOUND` is also the deploy-order-safe path: hq-cli
|
|
341
|
+
* shipped before the hq-pro endpoints exist (or the agents flag is OFF, or
|
|
342
|
+
* the agent is cross-company) must print a clear message and exit non-zero,
|
|
343
|
+
* never throw a stack trace.
|
|
344
|
+
*/
|
|
345
|
+
export function formatJobsHttpError(err, jobId) {
|
|
346
|
+
if (err.status === 401)
|
|
347
|
+
return "Not authenticated — run `hq login`.";
|
|
348
|
+
if (err.status === 403)
|
|
349
|
+
return "You need owner/admin on this company.";
|
|
350
|
+
if (err.status === 404 && err.code === "JOB_NOT_FOUND") {
|
|
351
|
+
const id = jobId ?? extractQuotedJobId(err.message) ?? "unknown";
|
|
352
|
+
return `Job '${id}' not found.`;
|
|
353
|
+
}
|
|
354
|
+
if (err.status === 404)
|
|
355
|
+
return "Agent not found or not accessible.";
|
|
356
|
+
if (err.code === "JOBS_SCHEDULER_UNAVAILABLE" ||
|
|
357
|
+
err.code === "SCHEDULE_DELETE_FAILED") {
|
|
358
|
+
return `${err.code}: ${err.message}`;
|
|
359
|
+
}
|
|
360
|
+
return err.message;
|
|
361
|
+
}
|
|
362
|
+
function extractQuotedJobId(message) {
|
|
363
|
+
const m = message.match(/Job '([^']+)'/i);
|
|
364
|
+
return m?.[1];
|
|
365
|
+
}
|
|
366
|
+
/** Stable padEnd table: jobId, scheduleState, rate, lastRunOutcome, status. */
|
|
367
|
+
export function formatJobsTable(jobs) {
|
|
368
|
+
if (jobs.length === 0)
|
|
369
|
+
return "No jobs.";
|
|
370
|
+
const cols = ["JOB_ID", "STATE", "RATE", "LAST_RUN", "STATUS"];
|
|
371
|
+
const rows = jobs.map((j) => [
|
|
372
|
+
j.jobId,
|
|
373
|
+
String(j.scheduleState ?? ""),
|
|
374
|
+
j.rate ?? "",
|
|
375
|
+
j.lastRunOutcome ?? "-",
|
|
376
|
+
j.status ?? "",
|
|
377
|
+
]);
|
|
378
|
+
const widths = cols.map((c, i) => Math.max(c.length, ...rows.map((r) => r[i].length)));
|
|
379
|
+
const render = (cells) => cells.map((cell, i) => cell.padEnd(widths[i])).join(" ");
|
|
380
|
+
return [chalk.bold(render(cols)), ...rows.map(render)].join("\n");
|
|
381
|
+
}
|
|
382
|
+
export function formatPauseResult(result) {
|
|
383
|
+
if (!result.changed)
|
|
384
|
+
return "Already paused";
|
|
385
|
+
const from = result.previousState ?? "ENABLED";
|
|
386
|
+
return `Paused ${result.jobId} (${from} → ${result.scheduleState})`;
|
|
387
|
+
}
|
|
312
388
|
/** Human-readable "hot-applied to the running box" vs "saved for next launch". */
|
|
313
389
|
export function appliedHint(applied) {
|
|
314
390
|
return applied
|
|
@@ -394,6 +470,13 @@ function fail(err) {
|
|
|
394
470
|
}
|
|
395
471
|
process.exit(1);
|
|
396
472
|
}
|
|
473
|
+
function failJobs(err, jobId) {
|
|
474
|
+
if (err instanceof AgentsHttpError) {
|
|
475
|
+
console.error(chalk.red(formatJobsHttpError(err, jobId)));
|
|
476
|
+
process.exit(1);
|
|
477
|
+
}
|
|
478
|
+
fail(err);
|
|
479
|
+
}
|
|
397
480
|
export function registerAgentsCommand(program) {
|
|
398
481
|
const agents = program
|
|
399
482
|
.command("agents")
|
|
@@ -842,5 +925,59 @@ export function registerAgentsCommand(program) {
|
|
|
842
925
|
fail(err);
|
|
843
926
|
}
|
|
844
927
|
});
|
|
928
|
+
// Off-box job control (US-003). Nested group so `hq agents jobs --help`
|
|
929
|
+
// lists list|pause|cancel. Same vaultApiFetch + person JWT as the rest of
|
|
930
|
+
// this file; keyed HQ_API_KEY rewrites /v1/agents → /v1/keys/agents.
|
|
931
|
+
const jobs = agents
|
|
932
|
+
.command("jobs")
|
|
933
|
+
.description("List, pause, or cancel an agent's scheduled jobs");
|
|
934
|
+
jobs
|
|
935
|
+
.command("list <agentUid>")
|
|
936
|
+
.description("List an agent's scheduled jobs")
|
|
937
|
+
.option("--json", "Emit raw JSON")
|
|
938
|
+
.action(async (agentUid, opts) => {
|
|
939
|
+
try {
|
|
940
|
+
const token = (await resolveVaultCredential()).token;
|
|
941
|
+
const roster = await listAgentJobs(token, agentUid);
|
|
942
|
+
if (opts.json) {
|
|
943
|
+
process.stdout.write(JSON.stringify(roster, null, 2) + "\n");
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
if (roster.length === 0) {
|
|
947
|
+
console.log(chalk.gray("No jobs."));
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
console.log(formatJobsTable(roster));
|
|
951
|
+
}
|
|
952
|
+
catch (err) {
|
|
953
|
+
failJobs(err);
|
|
954
|
+
}
|
|
955
|
+
});
|
|
956
|
+
jobs
|
|
957
|
+
.command("pause <agentUid> <jobId>")
|
|
958
|
+
.description("Pause a job's schedule (reversible)")
|
|
959
|
+
.action(async (agentUid, jobId) => {
|
|
960
|
+
try {
|
|
961
|
+
const token = (await resolveVaultCredential()).token;
|
|
962
|
+
const result = await pauseAgentJob(token, agentUid, jobId);
|
|
963
|
+
console.log(chalk.green(formatPauseResult(result)));
|
|
964
|
+
}
|
|
965
|
+
catch (err) {
|
|
966
|
+
failJobs(err, jobId);
|
|
967
|
+
}
|
|
968
|
+
});
|
|
969
|
+
jobs
|
|
970
|
+
.command("cancel <agentUid> <jobId>")
|
|
971
|
+
.description("Cancel a job and delete its schedule")
|
|
972
|
+
.action(async (agentUid, jobId) => {
|
|
973
|
+
try {
|
|
974
|
+
const token = (await resolveVaultCredential()).token;
|
|
975
|
+
const result = await cancelAgentJob(token, agentUid, jobId);
|
|
976
|
+
console.log(chalk.green(`Cancelled ${result.jobId}.`));
|
|
977
|
+
}
|
|
978
|
+
catch (err) {
|
|
979
|
+
failJobs(err, jobId);
|
|
980
|
+
}
|
|
981
|
+
});
|
|
845
982
|
}
|
|
846
983
|
//# sourceMappingURL=agents.js.map
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* - Running outside any HQ tree exits non-zero with a message naming exactly
|
|
9
9
|
* what it looked for — never a throw, never a false PASS.
|
|
10
10
|
* - The command performs no network calls and needs no authentication; it is
|
|
11
|
-
* purely a function of the on-disk shape of the tree.
|
|
11
|
+
* purely a function of the on-disk shape of the tree. `--live-runtimes` is
|
|
12
|
+
* the one opt-in exception: it probes the installed AI CLIs (claude, codex,
|
|
13
|
+
* grok) with a version read and a one-line prompt.
|
|
12
14
|
*
|
|
13
15
|
* US-015 adds reporting, `--json`, and the exit-code contract: the exit code is
|
|
14
16
|
* 0 unless some result is FAIL or UNKNOWN (WARN/UNTESTED/NA/KNOWN-DEFECT never
|
|
@@ -77,6 +79,13 @@ export interface RunDoctorOptions {
|
|
|
77
79
|
* does — so a plain `hq doctor` stays purely a function of the on-disk shape.
|
|
78
80
|
*/
|
|
79
81
|
deepTest?: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* Additionally probe each installed AI CLI (claude, codex, grok) with a
|
|
84
|
+
* version read and a one-line prompt (`--live-runtimes`). Off by default:
|
|
85
|
+
* this is the doctor's only networked tier, and without it the runtime-health
|
|
86
|
+
* family reports UNTESTED rather than spawning anything.
|
|
87
|
+
*/
|
|
88
|
+
liveRuntimes?: boolean;
|
|
80
89
|
}
|
|
81
90
|
/** The outcome of a doctor run, returned rather than thrown so it is testable. */
|
|
82
91
|
export interface RunDoctorResult {
|
package/dist/commands/doctor.js
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* - Running outside any HQ tree exits non-zero with a message naming exactly
|
|
9
9
|
* what it looked for — never a throw, never a false PASS.
|
|
10
10
|
* - The command performs no network calls and needs no authentication; it is
|
|
11
|
-
* purely a function of the on-disk shape of the tree.
|
|
11
|
+
* purely a function of the on-disk shape of the tree. `--live-runtimes` is
|
|
12
|
+
* the one opt-in exception: it probes the installed AI CLIs (claude, codex,
|
|
13
|
+
* grok) with a version read and a one-line prompt.
|
|
12
14
|
*
|
|
13
15
|
* US-015 adds reporting, `--json`, and the exit-code contract: the exit code is
|
|
14
16
|
* 0 unless some result is FAIL or UNKNOWN (WARN/UNTESTED/NA/KNOWN-DEFECT never
|
|
@@ -99,6 +101,7 @@ export async function runDoctor(options = {}) {
|
|
|
99
101
|
hqRoot,
|
|
100
102
|
platform: { id: platform.id, evidence: platform.evidence },
|
|
101
103
|
sessionId: options.sessionId,
|
|
104
|
+
liveRuntimes: options.liveRuntimes === true,
|
|
102
105
|
};
|
|
103
106
|
const families = await registry.run(context);
|
|
104
107
|
// `--deep-test` (US-008): after the read-only tiers, actually fire pure-guard
|
|
@@ -147,12 +150,13 @@ export async function runDoctor(options = {}) {
|
|
|
147
150
|
export function registerDoctorCommand(program) {
|
|
148
151
|
program
|
|
149
152
|
.command("doctor")
|
|
150
|
-
.description("Verify HQ hook guardrails are wired and firing (read-only, offline).")
|
|
153
|
+
.description("Verify HQ hook guardrails are wired and firing (read-only, offline; --live-runtimes adds networked AI CLI probes).")
|
|
151
154
|
.option("--json", "Emit the machine-readable JSON document (no colour).")
|
|
152
155
|
.option("--verbose", "Also print every PASS result in text output.")
|
|
153
156
|
.option("--no-color", "Disable ANSI colour even on a TTY.")
|
|
154
157
|
.option("--session-id <id>", "Scope the runtime probe's ledger check to this exact session.")
|
|
155
158
|
.option("--deep-test", "Also fire pure-guard hooks through the real gate under all three profiles (sandboxed).")
|
|
159
|
+
.option("--live-runtimes", "Also probe each installed AI CLI (claude, codex, grok) with a one-line prompt to verify login and subscription (networked; uses your subscriptions).")
|
|
156
160
|
.option("--fix", "Apply the allowlisted safe repairs (backs up first; read-only without this flag).")
|
|
157
161
|
.option("--yes", "Skip the interactive --fix confirmation (non-interactive use).")
|
|
158
162
|
.option("--force", "Let --fix run despite uncommitted changes under .claude/, .codex/, or .grok/.")
|
|
@@ -199,6 +203,7 @@ export function registerDoctorCommand(program) {
|
|
|
199
203
|
platform,
|
|
200
204
|
sessionId: opts.sessionId,
|
|
201
205
|
deepTest: opts.deepTest === true,
|
|
206
|
+
liveRuntimes: opts.liveRuntimes === true,
|
|
202
207
|
});
|
|
203
208
|
// Set the exit code rather than calling process.exit, so the CLI's
|
|
204
209
|
// normal shutdown (telemetry flush) still runs. Non-zero means either an
|
|
@@ -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;
|
|
@@ -167,6 +167,51 @@ export function resolveRunningManager() {
|
|
|
167
167
|
export function resolveRunningPrefix() {
|
|
168
168
|
return resolveRunningInstall().prefix;
|
|
169
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* Derive `PNPM_HOME` from a pnpm-managed install's own path. pnpm resolves its
|
|
172
|
+
* global bin directory from `PNPM_HOME` (or an explicit `global-bin-dir`), and
|
|
173
|
+
* `PNPM_HOME` is the path segment immediately preceding pnpm's global root, e.g.
|
|
174
|
+
*
|
|
175
|
+
* /home/u/.local/share/pnpm/global/5/… -> PNPM_HOME=/home/u/.local/share/pnpm
|
|
176
|
+
*
|
|
177
|
+
* Returns null for non-pnpm installs or a `packageRoot` with no `/global/`
|
|
178
|
+
* segment (nothing to derive from). Reading it from the running install is more
|
|
179
|
+
* accurate than trusting the inherited environment: it names the exact copy
|
|
180
|
+
* that is actually on PATH.
|
|
181
|
+
*/
|
|
182
|
+
export function derivePnpmHome(install) {
|
|
183
|
+
if (install.manager !== "pnpm" || !install.packageRoot)
|
|
184
|
+
return null;
|
|
185
|
+
const normalized = install.packageRoot.replace(/\\/g, "/");
|
|
186
|
+
const marker = normalized.indexOf("/global/");
|
|
187
|
+
if (marker === -1)
|
|
188
|
+
return null;
|
|
189
|
+
const home = normalized.slice(0, marker);
|
|
190
|
+
return home || null;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Environment for the pnpm self-update spawn. `pnpm add -g` aborts with
|
|
194
|
+
* `ERR_PNPM_NO_GLOBAL_BIN_DIR` (exit 1) when neither `PNPM_HOME` nor a
|
|
195
|
+
* `global-bin-dir` setting is present — the exact failure seen when `hq`
|
|
196
|
+
* self-updates from a minimal-environment parent (a systemd `--user` service,
|
|
197
|
+
* cron, or any non-login shell that never sourced the profile exporting
|
|
198
|
+
* `PNPM_HOME`). When the running install is pnpm-managed and the parent lacks
|
|
199
|
+
* `PNPM_HOME`, inject one derived from the install's own path so the update can
|
|
200
|
+
* find the global bin dir it is about to rewrite.
|
|
201
|
+
*
|
|
202
|
+
* A no-op for npm/bun installs, and for any environment that already sets
|
|
203
|
+
* `PNPM_HOME` (never overridden — the caller's value wins). Returns `undefined`
|
|
204
|
+
* when nothing needs injecting, so callers can spawn with the inherited
|
|
205
|
+
* environment unchanged (and pass no `env` at all).
|
|
206
|
+
*/
|
|
207
|
+
export function pnpmUpdateEnv(install, base = process.env) {
|
|
208
|
+
if (install.manager !== "pnpm" || base.PNPM_HOME)
|
|
209
|
+
return undefined;
|
|
210
|
+
const home = derivePnpmHome(install);
|
|
211
|
+
if (!home)
|
|
212
|
+
return undefined;
|
|
213
|
+
return { ...base, PNPM_HOME: home };
|
|
214
|
+
}
|
|
170
215
|
export function buildPrefixedInstallArgv(prefix) {
|
|
171
216
|
return ["install", "-g", "--prefix", prefix, LATEST_PACKAGE_SPEC];
|
|
172
217
|
}
|
|
@@ -307,12 +352,13 @@ async function fetchVersionDecision() {
|
|
|
307
352
|
// public surface (and the `__test__` block below) stable for existing callers
|
|
308
353
|
// and tests.
|
|
309
354
|
export { buildSpawnPlan, quoteForWindowsShell };
|
|
310
|
-
export function runUpdateCommand(cmd, args) {
|
|
355
|
+
export function runUpdateCommand(cmd, args, env) {
|
|
311
356
|
try {
|
|
312
357
|
const plan = buildSpawnPlan(cmd, args);
|
|
313
358
|
const result = spawnSync(plan.cmd, plan.args, {
|
|
314
359
|
stdio: "inherit",
|
|
315
360
|
shell: plan.shell,
|
|
361
|
+
...(env ? { env } : {}),
|
|
316
362
|
});
|
|
317
363
|
// spawnSync reports a missing executable via `error`, not a throw.
|
|
318
364
|
if (result.error) {
|
|
@@ -335,8 +381,11 @@ export function runUpdateCommand(cmd, args) {
|
|
|
335
381
|
};
|
|
336
382
|
}
|
|
337
383
|
}
|
|
338
|
-
function performUpdateCommand(cmd, args, runner = runUpdateCommand) {
|
|
339
|
-
|
|
384
|
+
function performUpdateCommand(cmd, args, runner = runUpdateCommand, env) {
|
|
385
|
+
// Forward env only when there is one to forward, so the common path keeps the
|
|
386
|
+
// two-argument runner call it has always made (and existing runner spies see
|
|
387
|
+
// no phantom `undefined` third argument).
|
|
388
|
+
return env === undefined ? runner(cmd, args) : runner(cmd, args, env);
|
|
340
389
|
}
|
|
341
390
|
function performUpdate(command, runner = runUpdateCommand) {
|
|
342
391
|
const parts = command.split(/\s+/).filter(Boolean);
|
|
@@ -442,11 +491,17 @@ function enforceUpdateRequired(decision, deps = {}) {
|
|
|
442
491
|
primaryCmd = parts[0] ?? "";
|
|
443
492
|
primaryArgs = parts.slice(1);
|
|
444
493
|
}
|
|
494
|
+
// A pnpm global install needs PNPM_HOME to locate its global bin dir; a
|
|
495
|
+
// minimal-environment parent (systemd, cron, non-login shell) lacks it and
|
|
496
|
+
// `pnpm add -g` would abort with ERR_PNPM_NO_GLOBAL_BIN_DIR. Derive one from
|
|
497
|
+
// the running install so the update is not environment-dependent. No-op for
|
|
498
|
+
// npm/bun and when PNPM_HOME is already set.
|
|
499
|
+
const updateEnv = pnpmUpdateEnv(install);
|
|
445
500
|
let result;
|
|
446
501
|
if (isManagedOutsideNpm) {
|
|
447
502
|
console.error(chalk.dim(` Detected a ${install.manager}-managed global install; updating with ${install.manager}`));
|
|
448
503
|
console.error(chalk.dim(` Running: ${primaryCmd} ${primaryArgs.join(" ")}`));
|
|
449
|
-
result = performUpdateCommand(primaryCmd, primaryArgs, runner);
|
|
504
|
+
result = performUpdateCommand(primaryCmd, primaryArgs, runner, updateEnv);
|
|
450
505
|
}
|
|
451
506
|
else if (prefix) {
|
|
452
507
|
console.error(chalk.dim(` Installing into npm prefix: ${prefix}`));
|
|
@@ -558,8 +613,10 @@ export const __test__ = {
|
|
|
558
613
|
buildPrefixedInstallArgv,
|
|
559
614
|
buildSpawnPlan,
|
|
560
615
|
cleanStalePartialInstall,
|
|
616
|
+
derivePnpmHome,
|
|
561
617
|
enforceUpdateRequired,
|
|
562
618
|
isBunManagedPackageDir,
|
|
619
|
+
pnpmUpdateEnv,
|
|
563
620
|
isPnpmManagedPackageDir,
|
|
564
621
|
npmPrefixFromPackageDir,
|
|
565
622
|
nudgeUpdateRecommended,
|