@indigoai-us/hq-cli 5.94.1 → 5.94.3
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 +36 -0
- package/dist/commands/company-transfer.d.ts +185 -0
- package/dist/commands/company-transfer.js +664 -0
- package/dist/commands/company.js +3 -0
- package/dist/commands/core-checkpoint.js +157 -36
- package/dist/commands/integrations.js +79 -6
- package/dist/commands/search.js +23 -11
- package/dist/lib/search-index/index.d.ts +1 -0
- package/dist/lib/search-index/index.js +41 -5
- package/dist/main.js +23 -4
- package/dist/utils/network-transport-error.d.ts +16 -0
- package/dist/utils/network-transport-error.js +184 -0
- package/dist/utils/redact-error-text.d.ts +10 -0
- package/dist/utils/redact-error-text.js +33 -0
- package/dist/utils/unexpected-cli-error.d.ts +16 -0
- package/dist/utils/unexpected-cli-error.js +25 -12
- package/dist/utils/vault-api.js +29 -9
- package/package.json +1 -1
|
@@ -13,12 +13,23 @@ import { homedir } from "node:os";
|
|
|
13
13
|
import { resolveLiveRoot } from "../utils/hq-roots.js";
|
|
14
14
|
import { peekIdToken } from "../utils/id-token.js";
|
|
15
15
|
const DEFAULT_TRIGGER = "stop-gate";
|
|
16
|
-
const BACKENDS = new Set(["auto", "claude", "codex", "none"]);
|
|
16
|
+
const BACKENDS = new Set(["auto", "claude", "codex", "grok", "none"]);
|
|
17
17
|
// Pinned by operator directive 2026-07-31; change defaults here deliberately.
|
|
18
18
|
const CODEX_SIBLING_MODEL = "gpt-5.6-terra";
|
|
19
19
|
const CODEX_SIBLING_REASONING_EFFORT = "high";
|
|
20
20
|
const CLAUDE_SIBLING_MODEL = "claude-opus-5";
|
|
21
21
|
const CLAUDE_SIBLING_EFFORT = "medium";
|
|
22
|
+
const GROK_SIBLING_MODEL = "grok-4.5";
|
|
23
|
+
const GROK_SIBLING_EFFORT = "high";
|
|
24
|
+
/**
|
|
25
|
+
* `grok -p` is single-turn by default: without a turn budget it answers with a
|
|
26
|
+
* plan and exits, having done nothing. The maintenance flow needs many turns.
|
|
27
|
+
*/
|
|
28
|
+
const GROK_SIBLING_MAX_TURNS = "100";
|
|
29
|
+
/** Preference order for `--backend auto`, first healthy candidate wins. */
|
|
30
|
+
const AUTO_BACKEND_ORDER = ["codex", "grok", "claude"];
|
|
31
|
+
/** A backend that cannot answer `--version` this fast is treated as broken. */
|
|
32
|
+
const BACKEND_PROBE_TIMEOUT_MS = 10_000;
|
|
22
33
|
/** A sibling still holding the lock after this long is treated as abandoned. */
|
|
23
34
|
const SIBLING_LOCK_TTL_MS = 60 * 60 * 1000;
|
|
24
35
|
/** Newest-wins cap so a wedged sibling cannot grow an unbounded queue. */
|
|
@@ -402,21 +413,150 @@ function backendOnPath(name) {
|
|
|
402
413
|
}
|
|
403
414
|
});
|
|
404
415
|
}
|
|
405
|
-
|
|
416
|
+
/**
|
|
417
|
+
* An installed binary is not necessarily a working one: a partial install, a
|
|
418
|
+
* broken wrapper or a missing runtime all leave an executable on PATH that
|
|
419
|
+
* fails the moment it is asked to do anything. Ask it for its version — cheap,
|
|
420
|
+
* bounded, and enough to reject a backend before handing it a checkpoint.
|
|
421
|
+
*/
|
|
422
|
+
function backendResponds(name) {
|
|
423
|
+
try {
|
|
424
|
+
execFileSync(name, ["--version"], {
|
|
425
|
+
timeout: BACKEND_PROBE_TIMEOUT_MS,
|
|
426
|
+
stdio: "ignore",
|
|
427
|
+
});
|
|
428
|
+
return true;
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function processAlive(pid) {
|
|
435
|
+
try {
|
|
436
|
+
process.kill(pid, 0);
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Backends whose most recent run died without producing a report.
|
|
445
|
+
*
|
|
446
|
+
* A version probe cannot catch a backend that starts cleanly and then fails at
|
|
447
|
+
* runtime — an expired login, a sandbox it cannot write in, a model it is not
|
|
448
|
+
* entitled to. The evidence for that is a finished run directory with no
|
|
449
|
+
* report.md in it, so prefer a different backend for the next checkpoint. Only
|
|
450
|
+
* the newest run per backend counts: one that has since succeeded is healthy.
|
|
451
|
+
*/
|
|
452
|
+
function crashedBackends(siblingRoot) {
|
|
453
|
+
const crashed = new Set();
|
|
454
|
+
let runDirs;
|
|
455
|
+
try {
|
|
456
|
+
runDirs = fs
|
|
457
|
+
.readdirSync(siblingRoot)
|
|
458
|
+
.filter((entry) => entry !== "pending.jsonl")
|
|
459
|
+
.sort()
|
|
460
|
+
.reverse();
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
return crashed;
|
|
464
|
+
}
|
|
465
|
+
const seen = new Set();
|
|
466
|
+
for (const runDir of runDirs) {
|
|
467
|
+
const absolute = path.join(siblingRoot, runDir);
|
|
468
|
+
let backend;
|
|
469
|
+
try {
|
|
470
|
+
const payload = JSON.parse(fs.readFileSync(path.join(absolute, "payload.json"), "utf8"));
|
|
471
|
+
backend = payload.backend;
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (typeof backend !== "string" || !backend || seen.has(backend))
|
|
477
|
+
continue;
|
|
478
|
+
seen.add(backend);
|
|
479
|
+
if (fs.existsSync(path.join(absolute, "report.md")))
|
|
480
|
+
continue;
|
|
481
|
+
// A missing report only means a crash once the process itself is gone.
|
|
482
|
+
let pid = Number.NaN;
|
|
483
|
+
try {
|
|
484
|
+
pid = Number.parseInt(fs.readFileSync(path.join(absolute, "sibling.pid"), "utf8").trim(), 10);
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
// A run that never recorded a PID cannot still be running.
|
|
488
|
+
}
|
|
489
|
+
if (Number.isSafeInteger(pid) && pid > 0 && processAlive(pid))
|
|
490
|
+
continue;
|
|
491
|
+
crashed.add(backend);
|
|
492
|
+
}
|
|
493
|
+
return crashed;
|
|
494
|
+
}
|
|
495
|
+
function resolveBackend(requested, liveRoot) {
|
|
406
496
|
const value = requested ?? "auto";
|
|
407
497
|
if (!BACKENDS.has(value))
|
|
408
498
|
usage(`checkpoint: unknown backend: ${value}`);
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
499
|
+
// An explicitly named backend is honoured as given; only `auto` shops around.
|
|
500
|
+
if (value !== "auto")
|
|
501
|
+
return value;
|
|
502
|
+
const available = AUTO_BACKEND_ORDER.filter((name) => backendOnPath(name) && backendResponds(name));
|
|
503
|
+
if (available.length === 0)
|
|
414
504
|
return "none";
|
|
505
|
+
const crashed = crashedBackends(path.join(liveRoot, "workspace", "checkpoints", "sibling"));
|
|
506
|
+
// If every candidate failed last time, still try the preferred one: a stale
|
|
507
|
+
// failure is a weaker signal than running no maintenance sibling at all.
|
|
508
|
+
return available.find((name) => !crashed.has(name)) ?? available[0];
|
|
509
|
+
}
|
|
510
|
+
function siblingArgs(backend, prompt) {
|
|
511
|
+
switch (backend) {
|
|
512
|
+
case "claude":
|
|
513
|
+
return [
|
|
514
|
+
"-p",
|
|
515
|
+
prompt,
|
|
516
|
+
"--model",
|
|
517
|
+
CLAUDE_SIBLING_MODEL,
|
|
518
|
+
"--effort",
|
|
519
|
+
CLAUDE_SIBLING_EFFORT,
|
|
520
|
+
"--permission-mode",
|
|
521
|
+
"acceptEdits",
|
|
522
|
+
];
|
|
523
|
+
case "grok":
|
|
524
|
+
return [
|
|
525
|
+
"-p",
|
|
526
|
+
prompt,
|
|
527
|
+
"-m",
|
|
528
|
+
GROK_SIBLING_MODEL,
|
|
529
|
+
"--reasoning-effort",
|
|
530
|
+
GROK_SIBLING_EFFORT,
|
|
531
|
+
// `acceptEdits` auto-approves edits only. A terminal command — the git
|
|
532
|
+
// scan, the journal helper — then hits an approval gate with no human
|
|
533
|
+
// attached, and grok cancels the whole run: `stopReason: cancelled,
|
|
534
|
+
// cancellationCategory: PermissionCancelled`. Unattended approval plus
|
|
535
|
+
// a sandbox is the same posture codex gets from `-s workspace-write`.
|
|
536
|
+
"--always-approve",
|
|
537
|
+
"--sandbox",
|
|
538
|
+
"workspace",
|
|
539
|
+
"--max-turns",
|
|
540
|
+
GROK_SIBLING_MAX_TURNS,
|
|
541
|
+
];
|
|
542
|
+
case "codex":
|
|
543
|
+
return [
|
|
544
|
+
"exec",
|
|
545
|
+
"--skip-git-repo-check",
|
|
546
|
+
"-s",
|
|
547
|
+
"workspace-write",
|
|
548
|
+
"--dangerously-bypass-hook-trust",
|
|
549
|
+
"-m",
|
|
550
|
+
CODEX_SIBLING_MODEL,
|
|
551
|
+
"-c",
|
|
552
|
+
`model_reasoning_effort=${CODEX_SIBLING_REASONING_EFFORT}`,
|
|
553
|
+
prompt,
|
|
554
|
+
];
|
|
415
555
|
}
|
|
416
|
-
return value;
|
|
417
556
|
}
|
|
418
|
-
function siblingPayload(input, threadPath) {
|
|
557
|
+
function siblingPayload(input, threadPath, backend) {
|
|
419
558
|
return {
|
|
559
|
+
backend,
|
|
420
560
|
summary: input.summary ?? "",
|
|
421
561
|
files: input.files,
|
|
422
562
|
learnings: input.learnings,
|
|
@@ -525,7 +665,7 @@ function startSibling(liveRoot, input, threadPath, backend) {
|
|
|
525
665
|
const siblingRoot = path.join(liveRoot, "workspace", "checkpoints", "sibling");
|
|
526
666
|
const pendingPath = path.join(siblingRoot, "pending.jsonl");
|
|
527
667
|
const lockPath = path.join(stateDir, "checkpoint-sibling.lock");
|
|
528
|
-
const payload = siblingPayload(input, threadPath);
|
|
668
|
+
const payload = siblingPayload(input, threadPath, backend);
|
|
529
669
|
const activePid = existingSiblingPid(lockPath, Date.now());
|
|
530
670
|
fs.mkdirSync(siblingRoot, { recursive: true });
|
|
531
671
|
if (activePid !== null) {
|
|
@@ -542,29 +682,7 @@ function startSibling(liveRoot, input, threadPath, backend) {
|
|
|
542
682
|
fs.writeFileSync(path.join(runDir, "prompt.md"), prompt);
|
|
543
683
|
const logPath = path.join(runDir, "run.log");
|
|
544
684
|
const logFd = fs.openSync(logPath, "a");
|
|
545
|
-
const args = backend
|
|
546
|
-
? [
|
|
547
|
-
"-p",
|
|
548
|
-
prompt,
|
|
549
|
-
"--model",
|
|
550
|
-
CLAUDE_SIBLING_MODEL,
|
|
551
|
-
"--effort",
|
|
552
|
-
CLAUDE_SIBLING_EFFORT,
|
|
553
|
-
"--permission-mode",
|
|
554
|
-
"acceptEdits",
|
|
555
|
-
]
|
|
556
|
-
: [
|
|
557
|
-
"exec",
|
|
558
|
-
"--skip-git-repo-check",
|
|
559
|
-
"-s",
|
|
560
|
-
"workspace-write",
|
|
561
|
-
"--dangerously-bypass-hook-trust",
|
|
562
|
-
"-m",
|
|
563
|
-
CODEX_SIBLING_MODEL,
|
|
564
|
-
"-c",
|
|
565
|
-
`model_reasoning_effort=${CODEX_SIBLING_REASONING_EFFORT}`,
|
|
566
|
-
prompt,
|
|
567
|
-
];
|
|
685
|
+
const args = siblingArgs(backend, prompt);
|
|
568
686
|
let child;
|
|
569
687
|
try {
|
|
570
688
|
child = spawn(backend, args, {
|
|
@@ -587,7 +705,10 @@ function startSibling(liveRoot, input, threadPath, backend) {
|
|
|
587
705
|
if (!child.pid)
|
|
588
706
|
throw new Error("checkpoint: sibling did not return a PID");
|
|
589
707
|
fs.writeFileSync(lockPath, String(child.pid));
|
|
590
|
-
|
|
708
|
+
// Recorded per run so a later checkpoint can tell "died without a report"
|
|
709
|
+
// apart from "still working" without consulting the shared lock.
|
|
710
|
+
fs.writeFileSync(path.join(runDir, "sibling.pid"), String(child.pid));
|
|
711
|
+
printResult(`checkpoint: sibling started (${backend}, pid ${child.pid})`);
|
|
591
712
|
}
|
|
592
713
|
function hasGateProbeConflict(options, command) {
|
|
593
714
|
return [
|
|
@@ -641,7 +762,7 @@ function runCheckpoint(options, command, group) {
|
|
|
641
762
|
if (!input.summary?.trim()) {
|
|
642
763
|
usage("checkpoint: --summary is required unless --idle or --gate-probe is used");
|
|
643
764
|
}
|
|
644
|
-
const backend = resolveBackend(options.backend);
|
|
765
|
+
const backend = resolveBackend(options.backend, liveRoot);
|
|
645
766
|
const now = new Date();
|
|
646
767
|
const threadId = `T-${formatTimestamp(now)}-auto-${summarySlug(input.summary)}`;
|
|
647
768
|
const threadPath = path.join(liveRoot, "workspace", "threads", `${threadId}.json`);
|
|
@@ -730,7 +851,7 @@ export function registerCoreCheckpointCommand(core) {
|
|
|
730
851
|
.option("--payload <file|->", "JSON payload file, or - for stdin")
|
|
731
852
|
.option("--idle", "touch stamps without a checkpoint")
|
|
732
853
|
.option("--no-agent", "do not spawn the maintenance sibling")
|
|
733
|
-
.option("--backend <auto|claude|codex|none>", "sibling backend", "auto")
|
|
854
|
+
.option("--backend <auto|claude|codex|grok|none>", "sibling backend", "auto")
|
|
734
855
|
.option("--gate-probe", "write the local Stop-hook eligibility verdict")
|
|
735
856
|
.option("--hq-root <path>", "HQ installation to operate on")
|
|
736
857
|
.option("--dry-run", "print the planned checkpoint without writing")
|
|
@@ -30,6 +30,7 @@ import chalk from "chalk";
|
|
|
30
30
|
import { ensureCognitoIdToken } from "../utils/cognito-session.js";
|
|
31
31
|
import { getCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
|
|
32
32
|
import { AuthError } from "../utils/auth-error.js";
|
|
33
|
+
import { redactErrorText } from "../utils/redact-error-text.js";
|
|
33
34
|
export class IntegrationsCliError extends Error {
|
|
34
35
|
/**
|
|
35
36
|
* True when the error is the caller's request/state/permission (a client 4xx
|
|
@@ -53,6 +54,43 @@ export class IntegrationsCliError extends Error {
|
|
|
53
54
|
function isClientError(status) {
|
|
54
55
|
return status >= 400 && status < 500;
|
|
55
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Statuses that mean HQ's integration gateway (or the third-party provider
|
|
59
|
+
* behind it) could not serve this request right now — an UPSTREAM AVAILABILITY
|
|
60
|
+
* event, not an hq-cli defect and not something the caller did wrong.
|
|
61
|
+
*
|
|
62
|
+
* HQ-CLI-F: the 500/503s in Sentry correlate request-for-request with the
|
|
63
|
+
* hq-pro `IntegrationMcpFunction` Lambda hitting its 30s timeout while waiting
|
|
64
|
+
* on a provider (CloudWatch `integration_mcp_audit event=provider_error`, and
|
|
65
|
+
* the same spikes counted in the AWS/Lambda `Errors` metric). The event is
|
|
66
|
+
* already recorded first-party, in the project that owns the fix; mirroring it
|
|
67
|
+
* into hq-cli's tracker is duplicate, unactionable noise. 429 is included
|
|
68
|
+
* because a rate-limited call is the same "retry in a moment" outcome (it was
|
|
69
|
+
* already `expected` via `isClientError`; only its wording changes here).
|
|
70
|
+
*/
|
|
71
|
+
function isUpstreamUnavailable(status) {
|
|
72
|
+
return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
|
|
73
|
+
}
|
|
74
|
+
/** Actionable wording for an upstream-availability status. */
|
|
75
|
+
function upstreamUnavailableMessage(status) {
|
|
76
|
+
return status === 429
|
|
77
|
+
? `HQ's integration gateway is rate-limiting this request (HTTP 429). Wait a moment and retry.`
|
|
78
|
+
: `HQ's integration gateway is temporarily unavailable (HTTP ${status}). ` +
|
|
79
|
+
`This is a service-side hiccup, not a problem with your command — retry in a moment.`;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Shared non-2xx guard for every integration-gateway call site. Raises the
|
|
83
|
+
* expected, actionable upstream-availability error when the status says the
|
|
84
|
+
* service is down or throttling; returns otherwise so the caller keeps its own
|
|
85
|
+
* status-specific message and `expected` classification unchanged.
|
|
86
|
+
*/
|
|
87
|
+
function raiseIfUpstreamUnavailable(res) {
|
|
88
|
+
if (isUpstreamUnavailable(res.status)) {
|
|
89
|
+
throw new IntegrationsCliError(upstreamUnavailableMessage(res.status), {
|
|
90
|
+
expected: true,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
56
94
|
// The integration gateway answers `POST /v1/integrations/mcp` JSON-RPC-style: a
|
|
57
95
|
// transport failure is a non-2xx HTTP status, but a GOVERNED refusal arrives as
|
|
58
96
|
// HTTP 200 carrying a JSON-RPC `error` object (mirrors hq-pro's
|
|
@@ -67,12 +105,35 @@ function isClientError(status) {
|
|
|
67
105
|
// read-only share rejecting a write.
|
|
68
106
|
// -32602 INVALID_PARAMS — an unknown tool or unsupported provider for the
|
|
69
107
|
// connection (a bad request the caller can correct).
|
|
108
|
+
// -32050 PROVIDER_ERROR — a THIRD-PARTY provider fault, surfaced verbatim.
|
|
109
|
+
// hq-pro mints this code for EVERY provider fault
|
|
110
|
+
// and for nothing else: `integration-mcp/server.ts`
|
|
111
|
+
// maps an `IntegrationMcpError` with status
|
|
112
|
+
// 'provider_error' to -32050, raised by
|
|
113
|
+
// `integration-mcp/dispatch.ts` for ProviderTimeout,
|
|
114
|
+
// ProviderRateLimited, ProviderParseError,
|
|
115
|
+
// ProviderWriteUnknown and TokenRefreshFailed.
|
|
116
|
+
// NOTE the remote server's OWN JSON-RPC code is
|
|
117
|
+
// embedded in the message TEXT ("Remote MCP request
|
|
118
|
+
// failed (-32602): …"); the wire code hq-cli sees is
|
|
119
|
+
// always -32050, so the codes above never match it.
|
|
120
|
+
// Every occurrence is already recorded first-party
|
|
121
|
+
// in hq-pro — `integration_mcp_audit
|
|
122
|
+
// event=provider_error` with reason/provider/tool,
|
|
123
|
+
// an `integration_mcp_health_signal` metric, and
|
|
124
|
+
// hq-pro's own Sentry project — so hq-cli reporting
|
|
125
|
+
// it again is duplicate noise in the wrong tracker,
|
|
126
|
+
// filed against a codebase that cannot fix it
|
|
127
|
+
// (HQ-CLI-F). Should hq-pro ever reuse -32050 for
|
|
128
|
+
// an hq-pro-side fault, the mapping site named above
|
|
129
|
+
// is where that change is traceable; -32603 below
|
|
130
|
+
// remains the code for hq-pro's own faults.
|
|
70
131
|
// Everything else stays unexpected so a genuine fault still reaches Sentry:
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
const EXPECTED_GATEWAY_ERROR_CODES = new Set([-32003, -32602]);
|
|
132
|
+
// INTERNAL_ERROR (-32603), CONFLICT (-32009, which the gateway also raises for
|
|
133
|
+
// a confirm queue being unavailable or an owner notification failing — real
|
|
134
|
+
// backend faults worth a report), METHOD_NOT_FOUND / PARSE_ERROR, and any
|
|
135
|
+
// absent or unrecognized code.
|
|
136
|
+
const EXPECTED_GATEWAY_ERROR_CODES = new Set([-32003, -32602, -32050]);
|
|
76
137
|
function isExpectedGatewayError(code) {
|
|
77
138
|
return code != null && EXPECTED_GATEWAY_ERROR_CODES.has(code);
|
|
78
139
|
}
|
|
@@ -104,6 +165,7 @@ export async function fetchConnections(token, companyUid) {
|
|
|
104
165
|
});
|
|
105
166
|
if (!res.ok) {
|
|
106
167
|
raiseIfUnauthorized(res);
|
|
168
|
+
raiseIfUpstreamUnavailable(res);
|
|
107
169
|
const body = (await res.json().catch(() => ({})));
|
|
108
170
|
throw new IntegrationsCliError(body.error ?? `Failed to list integrations (HTTP ${res.status})`, { expected: isClientError(res.status) });
|
|
109
171
|
}
|
|
@@ -162,10 +224,20 @@ export async function callGateway(token, params) {
|
|
|
162
224
|
const message = (await res.json().catch(() => null));
|
|
163
225
|
if (!res.ok || !message) {
|
|
164
226
|
raiseIfUnauthorized(res);
|
|
227
|
+
raiseIfUpstreamUnavailable(res);
|
|
165
228
|
throw new IntegrationsCliError(`Integration gateway request failed (HTTP ${res.status}).`, { expected: isClientError(res.status) });
|
|
166
229
|
}
|
|
167
230
|
if (message.error) {
|
|
168
|
-
|
|
231
|
+
// The gateway's error text is minted UPSTREAM (hq-pro, the integration
|
|
232
|
+
// factory, and beyond it the third-party provider), so it is untrusted:
|
|
233
|
+
// scrub credentials and bound the length here, at the throw site, because
|
|
234
|
+
// an `expected` error is printed straight from `err.message` by the
|
|
235
|
+
// top-level handler and never passes through `unexpectedCliErrorMessage`.
|
|
236
|
+
// The chain is idempotent, so the unexpected path scrubbing again is a
|
|
237
|
+
// no-op. This preserves PR #298's user-visible diagnostic; it only makes
|
|
238
|
+
// it safe on the newly-expected path.
|
|
239
|
+
throw new IntegrationsCliError(redactErrorText(message.error.message ?? "") ||
|
|
240
|
+
"Integration gateway returned an error.", { expected: isExpectedGatewayError(message.error.code) });
|
|
169
241
|
}
|
|
170
242
|
return message;
|
|
171
243
|
}
|
|
@@ -344,6 +416,7 @@ export function registerIntegrationsCommand(program) {
|
|
|
344
416
|
const body = (await res.json().catch(() => ({})));
|
|
345
417
|
if (!res.ok) {
|
|
346
418
|
raiseIfUnauthorized(res);
|
|
419
|
+
raiseIfUpstreamUnavailable(res);
|
|
347
420
|
throw new IntegrationsCliError(body.error ?? `${decision} failed (HTTP ${res.status})`, { expected: isClientError(res.status) });
|
|
348
421
|
}
|
|
349
422
|
if (opts.json) {
|
package/dist/commands/search.js
CHANGED
|
@@ -23,26 +23,38 @@ function relay(result) {
|
|
|
23
23
|
process.stderr.write(result.stderr);
|
|
24
24
|
}
|
|
25
25
|
export function registerSearchCommand(program) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
search
|
|
35
|
-
.command('<query>')
|
|
26
|
+
// The query is an ARGUMENT on `search` itself, not a subcommand. It was
|
|
27
|
+
// previously registered as `.command('<query>')`, which makes commander look
|
|
28
|
+
// for a subcommand literally named `<query>` — so every real query failed
|
|
29
|
+
// with "unknown command '<the query>'" and plain `hq search` never worked.
|
|
30
|
+
// Variadic so `hq search foo bar` needs no quoting; `search get` stays a
|
|
31
|
+
// subcommand and still wins when it is the first token.
|
|
32
|
+
const search = program
|
|
33
|
+
.command('search')
|
|
36
34
|
.description('Search the local HQ qmd index')
|
|
35
|
+
.argument('[query...]', 'Query terms (joined with spaces)')
|
|
37
36
|
.option('--mode <mode>', 'Search mode: keyword, semantic, or hybrid', 'keyword')
|
|
38
37
|
.option('-c, --collection <collection>', 'Restrict search to a collection')
|
|
39
38
|
.option('-n, --count <count>', 'Maximum result count', (value) => Number(value))
|
|
40
39
|
.option('--json', 'Request machine-readable qmd output')
|
|
41
|
-
.action((
|
|
40
|
+
.action((queryParts = [], options) => {
|
|
41
|
+
const query = queryParts.join(' ').trim();
|
|
42
|
+
if (!query) {
|
|
43
|
+
search.outputHelp();
|
|
44
|
+
process.exitCode = 1;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
42
47
|
if (!['keyword', 'semantic', 'hybrid'].includes(options.mode ?? 'keyword')) {
|
|
43
48
|
throw new Error(`Unknown search mode '${options.mode}'. Expected keyword, semantic, or hybrid.`);
|
|
44
49
|
}
|
|
45
50
|
relay(runQmd(buildSearchArgs(query, options)));
|
|
46
51
|
});
|
|
52
|
+
search
|
|
53
|
+
.command('get <document>')
|
|
54
|
+
.description('Retrieve a qmd document by path or document id')
|
|
55
|
+
.option('-c, --collection <collection>', 'Restrict retrieval to a collection')
|
|
56
|
+
.action((document, options) => {
|
|
57
|
+
relay(runQmd(buildGetArgs(document, options)));
|
|
58
|
+
});
|
|
47
59
|
}
|
|
48
60
|
//# sourceMappingURL=search.js.map
|
|
@@ -27,6 +27,7 @@ export type ResolveQmdBinOptions = {
|
|
|
27
27
|
isExecutable?: (candidate: string) => boolean;
|
|
28
28
|
packageBin?: () => string | undefined;
|
|
29
29
|
pathBin?: () => string | undefined;
|
|
30
|
+
isUsable?: (bin: string) => boolean;
|
|
30
31
|
};
|
|
31
32
|
/**
|
|
32
33
|
* Return the pinned package version when qmd is supplied by this CLI.
|
|
@@ -108,6 +108,27 @@ function pathBin() {
|
|
|
108
108
|
}
|
|
109
109
|
return undefined;
|
|
110
110
|
}
|
|
111
|
+
const usableQmdCache = new Map();
|
|
112
|
+
/**
|
|
113
|
+
* Does this qmd binary actually run? `--version` is the cheapest subcommand
|
|
114
|
+
* that still loads the native module, which is exactly what fails when the
|
|
115
|
+
* bindings are missing. Cached per path so resolution stays a single spawn.
|
|
116
|
+
*/
|
|
117
|
+
function isUsableQmd(bin) {
|
|
118
|
+
const cached = usableQmdCache.get(bin);
|
|
119
|
+
if (cached !== undefined)
|
|
120
|
+
return cached;
|
|
121
|
+
let usable = false;
|
|
122
|
+
try {
|
|
123
|
+
const probe = spawnSync(bin, ['--version'], { encoding: 'utf8', timeout: 10_000 });
|
|
124
|
+
usable = !probe.error && probe.status === 0;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
usable = false;
|
|
128
|
+
}
|
|
129
|
+
usableQmdCache.set(bin, usable);
|
|
130
|
+
return usable;
|
|
131
|
+
}
|
|
111
132
|
/** Resolve qmd without relying on a globally installed copy. */
|
|
112
133
|
export function resolveQmdBin(options = {}) {
|
|
113
134
|
const env = options.env ?? process.env;
|
|
@@ -123,12 +144,27 @@ export function resolveQmdBin(options = {}) {
|
|
|
123
144
|
probes.push('HQ_QMD_BIN (not set)');
|
|
124
145
|
}
|
|
125
146
|
const installed = (options.packageBin ?? packageLocalBin)();
|
|
126
|
-
if (installed && executable(installed))
|
|
127
|
-
return installed;
|
|
128
|
-
probes.push(`package-local @tobilu/qmd (${installed ?? 'not found'})`);
|
|
129
147
|
const onPath = (options.pathBin ?? pathBin)();
|
|
130
|
-
|
|
131
|
-
|
|
148
|
+
const fallback = onPath && executable(onPath) ? onPath : undefined;
|
|
149
|
+
if (installed && executable(installed)) {
|
|
150
|
+
// Executable is not the same as usable: a global install can ship a qmd
|
|
151
|
+
// whose native better-sqlite3 bindings were never compiled, so every
|
|
152
|
+
// invocation crashes. Preferring it blindly turned a dormant packaging
|
|
153
|
+
// problem into broken search on hosts that had a working qmd on PATH.
|
|
154
|
+
//
|
|
155
|
+
// The runnability probe costs a ~0.3s spawn, so only pay it when there is
|
|
156
|
+
// something to fall back TO. With no alternative the answer is the bundled
|
|
157
|
+
// binary either way, and a failure there now surfaces loudly rather than
|
|
158
|
+
// silently, so probing would buy nothing but latency on the hot path.
|
|
159
|
+
if (!fallback || (options.isUsable ?? isUsableQmd)(installed))
|
|
160
|
+
return installed;
|
|
161
|
+
probes.push(`package-local @tobilu/qmd (${installed}, present but not runnable)`);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
probes.push(`package-local @tobilu/qmd (${installed ?? 'not found'})`);
|
|
165
|
+
}
|
|
166
|
+
if (fallback)
|
|
167
|
+
return fallback;
|
|
132
168
|
probes.push(`qmd on PATH (${onPath ?? 'not found'})`);
|
|
133
169
|
throw new QmdBinaryMissingError(`Unable to resolve qmd. Probed ${probes.join('; ')}. Install @tobilu/qmd or set HQ_QMD_BIN to an executable qmd binary.`);
|
|
134
170
|
}
|
package/dist/main.js
CHANGED
|
@@ -61,6 +61,7 @@ import { registerSearchCommand } from "./commands/search.js";
|
|
|
61
61
|
import { registerIndexCommand } from "./commands/index-cmd.js";
|
|
62
62
|
import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
|
|
63
63
|
import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
|
|
64
|
+
import { networkTransportErrorMessage } from "./utils/network-transport-error.js";
|
|
64
65
|
import { isExpectedUserError } from "./utils/expected-cli-error.js";
|
|
65
66
|
import { isEpipe } from "./utils/epipe.js";
|
|
66
67
|
import { isInterceptedProcessExit } from "./utils/intercepted-process-exit.js";
|
|
@@ -73,7 +74,7 @@ import { emitCliSessionStarted } from "./utils/cli-telemetry.js";
|
|
|
73
74
|
import { settleWithin } from "./utils/settle-with-timeout.js";
|
|
74
75
|
import { emitPlanLimitNag } from "./lib/plan-limit-nag.js";
|
|
75
76
|
import { isPackageRootResolutionError, packageRootCaptureContext, } from "./utils/package-root-diagnostics.js";
|
|
76
|
-
import { unexpectedCliErrorMessage } from "./utils/unexpected-cli-error.js";
|
|
77
|
+
import { fallbackOperatorMessage, unexpectedCliErrorMessage } from "./utils/unexpected-cli-error.js";
|
|
77
78
|
/** Hard upper bound for non-user-visible release-health finalization. */
|
|
78
79
|
const RELEASE_HEALTH_SETTLE_TIMEOUT_MS = 3_000;
|
|
79
80
|
// Swallow EPIPE when a downstream reader (e.g. `source <(…)`, `| head`) closes
|
|
@@ -343,14 +344,32 @@ export function handleTopLevelError(err, deps = defaultTopLevelErrorDependencies
|
|
|
343
344
|
// identical, unfixable crash reports (HQ-CLI-2). Genuine errors still go
|
|
344
345
|
// to Sentry and still exit 1.
|
|
345
346
|
const envMsg = environmentalFsErrorMessage(err);
|
|
347
|
+
// A raw network transport failure (undici's `TypeError: fetch failed`
|
|
348
|
+
// with a ConnectTimeoutError / ECONNREFUSED / ENOTFOUND cause) is the
|
|
349
|
+
// caller's connectivity, not an hq-cli defect. Before this branch it fell
|
|
350
|
+
// through to the capture below: it filed a crash report with the useless
|
|
351
|
+
// culprit `?(undici)`, and printed only the equally useless line
|
|
352
|
+
// `hq: TypeError: fetch failed` (before #337 landed the always-print
|
|
353
|
+
// fallback, it printed nothing at all) — HQ-CLI-G. Print an actionable
|
|
354
|
+
// message that names the unreachable host, exit 1, and skip Sentry.
|
|
355
|
+
// Ordered after the environmental check so a full disk keeps its exact
|
|
356
|
+
// existing message.
|
|
357
|
+
const transportMsg = envMsg ? null : networkTransportErrorMessage(err);
|
|
346
358
|
if (envMsg) {
|
|
347
359
|
deps.stderr.write(`hq: ${envMsg}\n`);
|
|
348
360
|
}
|
|
361
|
+
else if (transportMsg) {
|
|
362
|
+
deps.stderr.write(`hq: ${transportMsg}\n`);
|
|
363
|
+
}
|
|
349
364
|
else {
|
|
350
365
|
deps.sentry.captureException(err);
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
366
|
+
// Always emit something. Printing only when unexpectedCliErrorMessage()
|
|
367
|
+
// returned a value meant every error class except IntegrationsCliError
|
|
368
|
+
// exited 1 with zero bytes on stdout AND stderr — a silent failure the
|
|
369
|
+
// caller cannot distinguish from success. The fallback runs through the
|
|
370
|
+
// same redaction chain, so this widens visibility, not disclosure.
|
|
371
|
+
const userMessage = unexpectedCliErrorMessage(err) ?? fallbackOperatorMessage(err);
|
|
372
|
+
deps.stderr.write(`hq: ${userMessage}\n`);
|
|
354
373
|
}
|
|
355
374
|
deps.setExitCode(1);
|
|
356
375
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The recognized transport code for `err`, or null when `err` is not a
|
|
3
|
+
* transport failure. Used for breadcrumb annotation at the fetch call site —
|
|
4
|
+
* a bounded, non-secret label, never the error text itself.
|
|
5
|
+
*/
|
|
6
|
+
export declare function networkTransportErrorCode(err: unknown): string | null;
|
|
7
|
+
/**
|
|
8
|
+
* If `err` is a raw network transport failure, return a short, actionable
|
|
9
|
+
* user-facing message; otherwise return `null`.
|
|
10
|
+
*
|
|
11
|
+
* A non-null result means the caller should PRINT the message, exit non-zero,
|
|
12
|
+
* and SKIP Sentry capture — the condition is the caller's network, not a bug
|
|
13
|
+
* HQ can fix. A null result means "handle this as usual (capture to Sentry)".
|
|
14
|
+
*/
|
|
15
|
+
export declare function networkTransportErrorMessage(err: unknown): string | null;
|
|
16
|
+
//# sourceMappingURL=network-transport-error.d.ts.map
|