@indigoai-us/hq-cli 5.106.2 → 5.107.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 +24 -0
- package/dist/commands/cloud.d.ts +30 -0
- package/dist/commands/cloud.js +85 -16
- package/dist/commands/reindex.js +15 -5
- package/dist/commands/run.js +25 -2
- package/dist/index.js +12 -1
- package/dist/lib/doctor/checks/sync-health.d.ts +65 -0
- package/dist/lib/doctor/checks/sync-health.js +279 -0
- package/dist/lib/doctor/registry.js +6 -0
- package/dist/main.js +19 -1
- package/dist/run/env-graph-guard.d.ts +83 -0
- package/dist/run/env-graph-guard.js +155 -0
- package/dist/unhandled-rejection-boundary.d.ts +30 -0
- package/dist/unhandled-rejection-boundary.js +106 -0
- package/dist/utils/client-health-contract.d.ts +151 -0
- package/dist/utils/client-health-contract.js +306 -0
- package/dist/utils/client-health.d.ts +134 -0
- package/dist/utils/client-health.js +376 -0
- package/dist/utils/hook-trust.d.ts +62 -2
- package/dist/utils/hook-trust.js +242 -3
- package/dist/utils/vault-api.js +6 -0
- package/package.json +2 -2
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Versions & sync health — the doctor family the registry docblock always
|
|
3
|
+
* anticipated (client-sync-health-control-plane US-015).
|
|
4
|
+
*
|
|
5
|
+
* Reports, per install:
|
|
6
|
+
* - the CLI / Core / desktop (hq-sync menubar) component versions, from the
|
|
7
|
+
* same collectors the feedback and client-health paths use (US-003), so
|
|
8
|
+
* `hq doctor --json` shows exactly what a heartbeat would report;
|
|
9
|
+
* - whether a newer hq-core release is known to be available — read OFFLINE
|
|
10
|
+
* from the cache stamped by the `check-hq-update` SessionStart hook
|
|
11
|
+
* (`workspace/.hq-update-check/last-check.json`), never from the network,
|
|
12
|
+
* preserving the doctor's offline contract (no cache → UNTESTED, not PASS);
|
|
13
|
+
* - per-company sync journal staleness via the engine's `listJournals()` —
|
|
14
|
+
* the ONLY correct enumeration of per-scope journal shards
|
|
15
|
+
* (single-path reconstruction regressed before: feedback_9fbf1f82 /
|
|
16
|
+
* feedback_46288b7b).
|
|
17
|
+
*
|
|
18
|
+
* Caution paid for in blood (bridge-health false positives): a machine with no
|
|
19
|
+
* journals at all is NA, not WARN — CLI-only installs never sync locally and
|
|
20
|
+
* must not read as degraded. Staleness warns only on a corroborated signal: a
|
|
21
|
+
* journal that EXISTS and carries a parseable, old `lastSync`.
|
|
22
|
+
*
|
|
23
|
+
* Every dependency is injectable so the family is unit-testable without an HQ
|
|
24
|
+
* tree, a state dir, or the wall clock.
|
|
25
|
+
*/
|
|
26
|
+
import * as fs from "node:fs";
|
|
27
|
+
import * as path from "node:path";
|
|
28
|
+
import { listJournals } from "@indigoai-us/hq-cloud";
|
|
29
|
+
import { CLI_VERSION } from "../../../cli-version.js";
|
|
30
|
+
import { readSyncVersion } from "../../../utils/feedback-versions.js";
|
|
31
|
+
import { readHqVersion } from "../../../utils/pack-contributions.js";
|
|
32
|
+
/** The id of the versions/sync family. */
|
|
33
|
+
export const SYNC_FAMILY_ID = "sync";
|
|
34
|
+
/** Human title for grouped output. */
|
|
35
|
+
export const SYNC_FAMILY_TITLE = "Versions & sync";
|
|
36
|
+
/**
|
|
37
|
+
* A journal shard older than this is reported stale. Seven days: long enough
|
|
38
|
+
* that a laptop shut over a weekend never warns, short enough that a silently
|
|
39
|
+
* dead sync runner surfaces well before data divergence becomes painful.
|
|
40
|
+
*/
|
|
41
|
+
export const STALE_JOURNAL_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
|
|
42
|
+
/** The offline update cache written by the check-hq-update SessionStart hook. */
|
|
43
|
+
export const UPDATE_CACHE_RELPATH = path.join("workspace", ".hq-update-check", "last-check.json");
|
|
44
|
+
function defaultVersions(hqRoot) {
|
|
45
|
+
return {
|
|
46
|
+
cli: CLI_VERSION,
|
|
47
|
+
core: safeReadCore(hqRoot),
|
|
48
|
+
desktop: safeReadDesktop(),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function safeReadCore(hqRoot) {
|
|
52
|
+
try {
|
|
53
|
+
return readHqVersion(hqRoot);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function safeReadDesktop() {
|
|
60
|
+
try {
|
|
61
|
+
return readSyncVersion();
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const DEFAULT_DEPS = {
|
|
68
|
+
versions: defaultVersions,
|
|
69
|
+
// Deliberately NOT wrapped in a try/catch: an enumeration failure must
|
|
70
|
+
// surface as UNKNOWN in `journalResults`, never be collapsed into the
|
|
71
|
+
// empty-list (NA, "cloud sync not in use") case. False healthy is worse
|
|
72
|
+
// than no check.
|
|
73
|
+
journals: () => listJournals(),
|
|
74
|
+
now: () => new Date(),
|
|
75
|
+
};
|
|
76
|
+
/** The versions/sync check family. Registered in `createDefaultRegistry`. */
|
|
77
|
+
export const syncHealthFamily = {
|
|
78
|
+
id: SYNC_FAMILY_ID,
|
|
79
|
+
title: SYNC_FAMILY_TITLE,
|
|
80
|
+
run: (context) => Promise.resolve(checkSyncHealth(context)),
|
|
81
|
+
};
|
|
82
|
+
/** Run every versions/sync check. A thrown check degrades to UNKNOWN. */
|
|
83
|
+
export function checkSyncHealth(context, deps = DEFAULT_DEPS) {
|
|
84
|
+
try {
|
|
85
|
+
return [
|
|
86
|
+
...versionResults(context, deps),
|
|
87
|
+
...updateAvailabilityResult(context, deps),
|
|
88
|
+
...journalResults(deps),
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
return [
|
|
93
|
+
{
|
|
94
|
+
status: "UNKNOWN",
|
|
95
|
+
checkId: "sync.error",
|
|
96
|
+
message: `versions/sync checks could not run: ${error.message}`,
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// ─── Component versions ──────────────────────────────────────────────────────
|
|
102
|
+
function versionResults(context, deps) {
|
|
103
|
+
const versions = deps.versions(context.hqRoot);
|
|
104
|
+
const results = [
|
|
105
|
+
{
|
|
106
|
+
status: "PASS",
|
|
107
|
+
checkId: "sync.versions.cli",
|
|
108
|
+
message: `hq-cli ${versions.cli}.`,
|
|
109
|
+
},
|
|
110
|
+
];
|
|
111
|
+
if (versions.core) {
|
|
112
|
+
results.push({
|
|
113
|
+
status: "PASS",
|
|
114
|
+
checkId: "sync.versions.core",
|
|
115
|
+
message: `hq-core ${versions.core} (core/core.yaml hqVersion).`,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
results.push({
|
|
120
|
+
status: "WARN",
|
|
121
|
+
checkId: "sync.versions.core",
|
|
122
|
+
target: path.join(context.hqRoot, "core", "core.yaml"),
|
|
123
|
+
message: "hq-core version could not be read from core/core.yaml — the scaffold may be missing or pre-v12.",
|
|
124
|
+
remediation: "Run /update-hq to (re)install the hq-core scaffold.",
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
// Desktop absence is NA, never WARN: hq-sync is optional (CLI-only and CI
|
|
128
|
+
// machines legitimately run without it), so "not installed" is not degraded.
|
|
129
|
+
results.push(versions.desktop
|
|
130
|
+
? {
|
|
131
|
+
status: "PASS",
|
|
132
|
+
checkId: "sync.versions.desktop",
|
|
133
|
+
message: `hq-sync desktop ${versions.desktop} (~/.hq/sync-version.json).`,
|
|
134
|
+
}
|
|
135
|
+
: {
|
|
136
|
+
status: "NA",
|
|
137
|
+
checkId: "sync.versions.desktop",
|
|
138
|
+
message: "hq-sync desktop app not detected (~/.hq/sync-version.json absent) — not required on CLI-only installs.",
|
|
139
|
+
});
|
|
140
|
+
return results;
|
|
141
|
+
}
|
|
142
|
+
// ─── Update availability (offline, from the SessionStart hook's cache) ───────
|
|
143
|
+
function updateAvailabilityResult(context, deps) {
|
|
144
|
+
const cachePath = path.join(context.hqRoot, UPDATE_CACHE_RELPATH);
|
|
145
|
+
let latest = null;
|
|
146
|
+
try {
|
|
147
|
+
const parsed = JSON.parse(fs.readFileSync(cachePath, "utf-8"));
|
|
148
|
+
const value = parsed?.latest;
|
|
149
|
+
if (typeof value === "string" && /^\d+\.\d+\.\d+$/.test(value)) {
|
|
150
|
+
latest = value;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// Missing or malformed cache: fall through to UNTESTED.
|
|
155
|
+
}
|
|
156
|
+
if (!latest) {
|
|
157
|
+
return [
|
|
158
|
+
{
|
|
159
|
+
status: "UNTESTED",
|
|
160
|
+
checkId: "sync.update.core",
|
|
161
|
+
target: cachePath,
|
|
162
|
+
message: "Latest hq-core release unknown — no update-check cache yet (written by the check-hq-update SessionStart hook; the doctor never goes to the network for it).",
|
|
163
|
+
},
|
|
164
|
+
];
|
|
165
|
+
}
|
|
166
|
+
const core = deps.versions(context.hqRoot).core;
|
|
167
|
+
if (!core) {
|
|
168
|
+
return [
|
|
169
|
+
{
|
|
170
|
+
status: "UNKNOWN",
|
|
171
|
+
checkId: "sync.update.core",
|
|
172
|
+
message: `Latest hq-core release is v${latest}, but the local core version could not be read to compare.`,
|
|
173
|
+
},
|
|
174
|
+
];
|
|
175
|
+
}
|
|
176
|
+
if (semverGt(latest, core)) {
|
|
177
|
+
return [
|
|
178
|
+
{
|
|
179
|
+
status: "WARN",
|
|
180
|
+
checkId: "sync.update.core",
|
|
181
|
+
message: `hq-core update available: local v${core}, latest v${latest}.`,
|
|
182
|
+
remediation: "Run /update-hq in a fresh session to upgrade.",
|
|
183
|
+
},
|
|
184
|
+
];
|
|
185
|
+
}
|
|
186
|
+
return [
|
|
187
|
+
{
|
|
188
|
+
status: "PASS",
|
|
189
|
+
checkId: "sync.update.core",
|
|
190
|
+
message: `hq-core is up to date (local v${core}, latest known v${latest}).`,
|
|
191
|
+
},
|
|
192
|
+
];
|
|
193
|
+
}
|
|
194
|
+
/** True when `a` > `b` for plain X.Y.Z versions. Non-numeric parts compare 0. */
|
|
195
|
+
export function semverGt(a, b) {
|
|
196
|
+
const pa = a.split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
197
|
+
const pb = b.split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
198
|
+
for (let i = 0; i < 3; i++) {
|
|
199
|
+
const da = pa[i] ?? 0;
|
|
200
|
+
const db = pb[i] ?? 0;
|
|
201
|
+
if (da !== db)
|
|
202
|
+
return da > db;
|
|
203
|
+
}
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
// ─── Per-journal staleness ───────────────────────────────────────────────────
|
|
207
|
+
function journalResults(deps) {
|
|
208
|
+
// Enumeration failure (throw / IO error) is UNKNOWN — which FAILS per the
|
|
209
|
+
// doctor's exit-code contract — never NA: a broken or unreadable journal
|
|
210
|
+
// store must not read as "cloud sync not in use" (a false healthy). Only a
|
|
211
|
+
// SUCCESSFUL enumeration that finds nothing is the benign CLI-only case.
|
|
212
|
+
let journals;
|
|
213
|
+
try {
|
|
214
|
+
journals = deps.journals();
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
return [
|
|
218
|
+
{
|
|
219
|
+
status: "UNKNOWN",
|
|
220
|
+
checkId: "sync.journals",
|
|
221
|
+
message: `Sync journal enumeration failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
222
|
+
remediation: "Check that the HQ state directory is readable, then re-run `hq doctor`.",
|
|
223
|
+
},
|
|
224
|
+
];
|
|
225
|
+
}
|
|
226
|
+
// No journals is NA, never WARN: a CLI-only install has nothing to sync
|
|
227
|
+
// locally and must not read as degraded (bridge-health false-positive
|
|
228
|
+
// lesson — warn only on corroborated signals).
|
|
229
|
+
if (journals.length === 0) {
|
|
230
|
+
return [
|
|
231
|
+
{
|
|
232
|
+
status: "NA",
|
|
233
|
+
checkId: "sync.journals",
|
|
234
|
+
message: "No local sync journals found — HQ cloud sync is not in use on this machine.",
|
|
235
|
+
},
|
|
236
|
+
];
|
|
237
|
+
}
|
|
238
|
+
const nowMs = deps.now().getTime();
|
|
239
|
+
return journals.map((entry) => {
|
|
240
|
+
const lastSync = entry.journal?.lastSync;
|
|
241
|
+
const checkId = `sync.journal.${entry.slug}`;
|
|
242
|
+
if (typeof lastSync !== "string" || lastSync.length === 0) {
|
|
243
|
+
return {
|
|
244
|
+
status: "WARN",
|
|
245
|
+
checkId,
|
|
246
|
+
target: entry.path,
|
|
247
|
+
message: `Sync journal '${entry.slug}' exists but has never recorded a successful sync.`,
|
|
248
|
+
remediation: "Run /hq-sync (or `hq sync`) to complete a first sync.",
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
const parsed = Date.parse(lastSync);
|
|
252
|
+
if (!Number.isFinite(parsed)) {
|
|
253
|
+
return {
|
|
254
|
+
status: "UNKNOWN",
|
|
255
|
+
checkId,
|
|
256
|
+
target: entry.path,
|
|
257
|
+
message: `Sync journal '${entry.slug}' has an unparseable lastSync (${lastSync}).`,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
const ageMs = nowMs - parsed;
|
|
261
|
+
if (ageMs > STALE_JOURNAL_THRESHOLD_MS) {
|
|
262
|
+
const days = Math.floor(ageMs / 86_400_000);
|
|
263
|
+
return {
|
|
264
|
+
status: "WARN",
|
|
265
|
+
checkId,
|
|
266
|
+
target: entry.path,
|
|
267
|
+
message: `Sync journal '${entry.slug}' is stale: last successful sync ${days} day${days === 1 ? "" : "s"} ago (${lastSync}).`,
|
|
268
|
+
remediation: "Run /hq-sync (or `hq sync`) and check the sync runner.",
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
status: "PASS",
|
|
273
|
+
checkId,
|
|
274
|
+
target: entry.path,
|
|
275
|
+
message: `Sync journal '${entry.slug}' is fresh (last sync ${lastSync}).`,
|
|
276
|
+
};
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
//# sourceMappingURL=sync-health.js.map
|
|
@@ -20,6 +20,7 @@ import { checkGrokWiring } from "./checks/grok-wiring.js";
|
|
|
20
20
|
import { checkRuntimeProbe } from "./checks/runtime-probe.js";
|
|
21
21
|
import { runtimeHealthFamily } from "./checks/runtime-health.js";
|
|
22
22
|
import { integrationsFamily } from "./checks/integrations.js";
|
|
23
|
+
import { syncHealthFamily } from "./checks/sync-health.js";
|
|
23
24
|
import { fixtureCoverageFamily } from "./fixtures/discover.js";
|
|
24
25
|
import { checkClaudeWiring } from "./checks/claude-wiring.js";
|
|
25
26
|
/**
|
|
@@ -181,6 +182,11 @@ export function createDefaultRegistry() {
|
|
|
181
182
|
// The engine remains family-agnostic: integrations is one bounded,
|
|
182
183
|
// read-only inventory family, registered alongside all other checks.
|
|
183
184
|
registry.register(integrationsFamily);
|
|
185
|
+
// Versions & sync (US-015): CLI/Core/desktop component versions, offline
|
|
186
|
+
// update availability, and per-company sync journal staleness — the family
|
|
187
|
+
// this registry's docblock always anticipated. Local-only reads, so the
|
|
188
|
+
// doctor's offline contract is preserved.
|
|
189
|
+
registry.register(syncHealthFamily);
|
|
184
190
|
return registry;
|
|
185
191
|
}
|
|
186
192
|
//# sourceMappingURL=registry.js.map
|
package/dist/main.js
CHANGED
|
@@ -75,6 +75,7 @@ import { qmdStoreMissingMessage } from "./utils/qmd-store-missing-error.js";
|
|
|
75
75
|
import { qmdStoreUnopenableMessage } from "./utils/qmd-store-unopenable-error.js";
|
|
76
76
|
import { hqStateWriteErrorMessage } from "./utils/hq-state-write-error.js";
|
|
77
77
|
import { isExpectedUserError } from "./utils/expected-cli-error.js";
|
|
78
|
+
import { isVarlockEnvError } from "./run/env-graph-guard.js";
|
|
78
79
|
import { isEpipe } from "./utils/epipe.js";
|
|
79
80
|
import { isInterceptedProcessExit } from "./utils/intercepted-process-exit.js";
|
|
80
81
|
import { isAuthError } from "./utils/auth-error.js";
|
|
@@ -89,6 +90,7 @@ import { enforceVersionGate, shouldSkipGate, } from "./utils/version-gate.js";
|
|
|
89
90
|
import { autoUpdateAndReexec } from "./utils/self-update.js";
|
|
90
91
|
import { CLI_VERSION } from "./cli-version.js";
|
|
91
92
|
import { emitCliSessionStarted } from "./utils/cli-telemetry.js";
|
|
93
|
+
import { reportCliClientHealthInvocation } from "./utils/client-health.js";
|
|
92
94
|
import { settleWithin } from "./utils/settle-with-timeout.js";
|
|
93
95
|
import { emitPlanLimitNag } from "./lib/plan-limit-nag.js";
|
|
94
96
|
import { isPackageRootResolutionError, packageRootCaptureContext, } from "./utils/package-root-diagnostics.js";
|
|
@@ -288,7 +290,12 @@ registerDoctorCommand(program);
|
|
|
288
290
|
// from `hq doctor` (hook guardrails). Does not start MQTT listen.
|
|
289
291
|
registerMeshCommand(program);
|
|
290
292
|
program.hook("preAction", async () => {
|
|
291
|
-
|
|
293
|
+
// Both are best-effort, bounded (1.2s), and fully swallowed: neither can
|
|
294
|
+
// delay past its bound or change the command's result or exit code.
|
|
295
|
+
await Promise.all([
|
|
296
|
+
emitCliSessionStarted(),
|
|
297
|
+
reportCliClientHealthInvocation(),
|
|
298
|
+
]);
|
|
292
299
|
});
|
|
293
300
|
export async function runCli() {
|
|
294
301
|
// Set when a self-update re-exec'd this command on a newer CLI: the child
|
|
@@ -463,6 +470,17 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
463
470
|
deps.stderr.write(`hq: ${err.message}\n`);
|
|
464
471
|
deps.setExitCode(1);
|
|
465
472
|
}
|
|
473
|
+
else if (isVarlockEnvError(err)) {
|
|
474
|
+
// HQ-CLI-W: `hq run` could not load or resolve a .env.schema/.env.local
|
|
475
|
+
// because the file itself is malformed. The typed carrier
|
|
476
|
+
// (EnvGraphLoadError / EnvResolutionError) already names the offending
|
|
477
|
+
// file and the pinned varlock version, so print that one actionable line
|
|
478
|
+
// and skip Sentry capture — a user's un-parseable env file is their file,
|
|
479
|
+
// not an hq-cli defect. A genuinely unclassified floated rejection is not
|
|
480
|
+
// one of these carriers, so it keeps falling through to the capture arm.
|
|
481
|
+
deps.stderr.write(`hq: ${err.message}\n`);
|
|
482
|
+
deps.setExitCode(1);
|
|
483
|
+
}
|
|
466
484
|
else if (isPackageRootResolutionError(err)) {
|
|
467
485
|
deps.stderr.write(`hq: ${err.message}\n`);
|
|
468
486
|
deps.sentry.captureException(err, {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** The varlock version hq-cli is pinned to (package.json `dependencies`). */
|
|
2
|
+
export declare const VARLOCK_PINNED_VERSION = "1.0.0";
|
|
3
|
+
/** A schema/plugin source varlock could not finish loading. */
|
|
4
|
+
interface GuardDataSource {
|
|
5
|
+
readonly isValid: boolean;
|
|
6
|
+
readonly loadingError?: {
|
|
7
|
+
readonly message?: string;
|
|
8
|
+
} | null;
|
|
9
|
+
readonly schemaErrors?: ReadonlyArray<{
|
|
10
|
+
readonly message?: string;
|
|
11
|
+
readonly isWarning?: boolean;
|
|
12
|
+
}>;
|
|
13
|
+
readonly resolutionErrors?: ReadonlyArray<{
|
|
14
|
+
readonly message?: string;
|
|
15
|
+
}>;
|
|
16
|
+
readonly label?: string;
|
|
17
|
+
/** Present on file-based sources (.env.schema / .env.local). */
|
|
18
|
+
readonly fullPath?: string;
|
|
19
|
+
}
|
|
20
|
+
interface GuardPlugin {
|
|
21
|
+
readonly loadingError?: {
|
|
22
|
+
readonly message?: string;
|
|
23
|
+
} | null;
|
|
24
|
+
readonly localPath?: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The structural subset of varlock's EnvGraph this module reads. varlock's
|
|
28
|
+
* concrete EnvGraph is assignable to it, so `hq run` passes its graph with no
|
|
29
|
+
* cast. Everything the guard reads is optional so a varlock shape change
|
|
30
|
+
* degrades to "no problems found" rather than a crash — the resolve-time layer
|
|
31
|
+
* and the varlock-shape smoke test (src/run/varlock-shape.test.ts) are what
|
|
32
|
+
* make such a degradation fail loudly.
|
|
33
|
+
*/
|
|
34
|
+
export interface LoadedEnvGraph {
|
|
35
|
+
readonly sortedDataSources?: ReadonlyArray<GuardDataSource>;
|
|
36
|
+
readonly plugins?: ReadonlyArray<GuardPlugin>;
|
|
37
|
+
resolveEnvValues(keys?: Array<string>): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A schema/entry file could not be loaded, so the env graph never finished
|
|
41
|
+
* processing. Carries an actionable, file-naming message; classified by
|
|
42
|
+
* handleTopLevelError as a user-file error (printed, not reported to Sentry).
|
|
43
|
+
*/
|
|
44
|
+
export declare class EnvGraphLoadError extends Error {
|
|
45
|
+
constructor(message: string);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Resolving the env graph floated a rejection (varlock's un-awaited resolver).
|
|
49
|
+
* Converted from the process-level unhandled rejection by resolveEnvValuesOrThrow
|
|
50
|
+
* so the promise `hq run` awaits can never hang. Classified as a user-file error.
|
|
51
|
+
*/
|
|
52
|
+
export declare class EnvResolutionError extends Error {
|
|
53
|
+
readonly reason: unknown;
|
|
54
|
+
constructor(reason: unknown);
|
|
55
|
+
}
|
|
56
|
+
/** True for either typed carrier this module raises. */
|
|
57
|
+
export declare function isVarlockEnvError(err: unknown): boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Throw a typed, actionable EnvGraphLoadError when varlock did not finish
|
|
60
|
+
* loading the graph — i.e. when it would have bailed out of finishLoad() before
|
|
61
|
+
* processing items, leaving them unresolvable. Mirrors finishLoad's two
|
|
62
|
+
* early-return conditions exactly: any data source with `isValid === false`, or
|
|
63
|
+
* any plugin with a `loadingError`. A fully-valid graph passes silently.
|
|
64
|
+
*/
|
|
65
|
+
export declare function assertEnvGraphLoaded(graph: LoadedEnvGraph): void;
|
|
66
|
+
/**
|
|
67
|
+
* Await `graph.resolveEnvValues()`, but convert varlock's floated resolution
|
|
68
|
+
* rejection into a thrown EnvResolutionError instead of hanging on the promise
|
|
69
|
+
* that never settles.
|
|
70
|
+
*
|
|
71
|
+
* varlock@1.0.0's resolveEnvValues dispatches its per-item resolver un-awaited
|
|
72
|
+
* and with a dead `_reject`, so a throw inside it becomes a process-level
|
|
73
|
+
* unhandled rejection AND leaves the returned promise permanently pending. We
|
|
74
|
+
* race the resolve against a scoped `unhandledRejection` listener: whichever
|
|
75
|
+
* settles first wins. The listener is prepended (so the prior listeners still
|
|
76
|
+
* run — nothing is swallowed) and removed in `finally` (restoring the exact
|
|
77
|
+
* prior listener set, including the no-prior-listener case). If resolve settles
|
|
78
|
+
* normally the listener never fires; a stray unrelated rejection during the
|
|
79
|
+
* window is still delivered to the pre-existing listeners.
|
|
80
|
+
*/
|
|
81
|
+
export declare function resolveEnvValuesOrThrow(graph: LoadedEnvGraph): Promise<void>;
|
|
82
|
+
export {};
|
|
83
|
+
//# sourceMappingURL=env-graph-guard.d.ts.map
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// src/run/env-graph-guard.ts
|
|
2
|
+
//
|
|
3
|
+
// Guards for `hq run`'s use of varlock's env graph. Two independent layers
|
|
4
|
+
// close the same class of failure that produced Sentry HQ-CLI-W
|
|
5
|
+
// ("expected dataType to be set", varlock ConfigItem.resolve):
|
|
6
|
+
//
|
|
7
|
+
// 1. assertEnvGraphLoaded(graph) — refuse to resolve a graph varlock never
|
|
8
|
+
// finished loading. When ANY discovered entry file (.env.schema /
|
|
9
|
+
// .env.local) fails to parse, varlock's EnvGraph.finishLoad() returns
|
|
10
|
+
// early — BEFORE it processes items — so every ConfigItem is left with
|
|
11
|
+
// `dataType === undefined` and NO error attached. Resolving such a graph
|
|
12
|
+
// reaches varlock's internal invariant `expected dataType to be set` for
|
|
13
|
+
// any item whose key also exists in process.env (varlock treats the whole
|
|
14
|
+
// process env as overrides), and — because varlock fires that resolution
|
|
15
|
+
// as an un-awaited, un-caught promise — the throw becomes a process-level
|
|
16
|
+
// unhandled rejection while the promise `hq run` awaits NEVER settles.
|
|
17
|
+
// finishLoad's two early-return conditions are: any data source with
|
|
18
|
+
// `isValid === false`, or any plugin with a `loadingError`. This guard
|
|
19
|
+
// mirrors exactly those conditions and throws a typed, actionable error
|
|
20
|
+
// first, so resolution is never reached with an unprocessed graph.
|
|
21
|
+
//
|
|
22
|
+
// 2. resolveEnvValuesOrThrow(graph) — defence in depth for the case the
|
|
23
|
+
// shape-dependent guard above cannot see (a future varlock could change
|
|
24
|
+
// the members it reads). It wraps `graph.resolveEnvValues()` in a
|
|
25
|
+
// narrowly-scoped `unhandledRejection` window: if varlock floats the
|
|
26
|
+
// resolution rejection, this converts it into a thrown EnvResolutionError
|
|
27
|
+
// instead of hanging forever on the never-settling promise. The window is
|
|
28
|
+
// restored to the exact prior listener set afterwards, and every rejection
|
|
29
|
+
// it observes is still forwarded to the pre-existing listeners, so it never
|
|
30
|
+
// swallows an unrelated rejection or permanently alters process semantics.
|
|
31
|
+
//
|
|
32
|
+
// Both failures are a user's malformed schema file, not an hq-cli defect, so
|
|
33
|
+
// main.ts's handleTopLevelError classifies these typed carriers as an
|
|
34
|
+
// actionable `hq: …` line WITHOUT a Sentry capture (see isVarlockEnvError).
|
|
35
|
+
import { claimRejection, releaseRejection, } from "../unhandled-rejection-boundary.js";
|
|
36
|
+
/** The varlock version hq-cli is pinned to (package.json `dependencies`). */
|
|
37
|
+
export const VARLOCK_PINNED_VERSION = "1.0.0";
|
|
38
|
+
/**
|
|
39
|
+
* A schema/entry file could not be loaded, so the env graph never finished
|
|
40
|
+
* processing. Carries an actionable, file-naming message; classified by
|
|
41
|
+
* handleTopLevelError as a user-file error (printed, not reported to Sentry).
|
|
42
|
+
*/
|
|
43
|
+
export class EnvGraphLoadError extends Error {
|
|
44
|
+
constructor(message) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.name = "EnvGraphLoadError";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Resolving the env graph floated a rejection (varlock's un-awaited resolver).
|
|
51
|
+
* Converted from the process-level unhandled rejection by resolveEnvValuesOrThrow
|
|
52
|
+
* so the promise `hq run` awaits can never hang. Classified as a user-file error.
|
|
53
|
+
*/
|
|
54
|
+
export class EnvResolutionError extends Error {
|
|
55
|
+
reason;
|
|
56
|
+
constructor(reason) {
|
|
57
|
+
const underlying = reason instanceof Error ? reason.message : String(reason);
|
|
58
|
+
super(`could not resolve your env schema (varlock ${VARLOCK_PINNED_VERSION}): ${underlying}. ` +
|
|
59
|
+
`This usually means a .env.schema or .env.local file could not be parsed — ` +
|
|
60
|
+
`check them for syntax errors and try again.`);
|
|
61
|
+
this.name = "EnvResolutionError";
|
|
62
|
+
this.reason = reason;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** True for either typed carrier this module raises. */
|
|
66
|
+
export function isVarlockEnvError(err) {
|
|
67
|
+
return err instanceof EnvGraphLoadError || err instanceof EnvResolutionError;
|
|
68
|
+
}
|
|
69
|
+
/** Collect the human-readable reason(s) a source is invalid, mirroring varlock. */
|
|
70
|
+
function describeSourceFailure(source) {
|
|
71
|
+
const parts = [];
|
|
72
|
+
const loadingMessage = source.loadingError?.message;
|
|
73
|
+
if (loadingMessage)
|
|
74
|
+
parts.push(loadingMessage);
|
|
75
|
+
for (const err of source.schemaErrors ?? []) {
|
|
76
|
+
if (!err.isWarning && err.message)
|
|
77
|
+
parts.push(err.message);
|
|
78
|
+
}
|
|
79
|
+
for (const err of source.resolutionErrors ?? []) {
|
|
80
|
+
if (err.message)
|
|
81
|
+
parts.push(err.message);
|
|
82
|
+
}
|
|
83
|
+
return parts.length > 0 ? parts.join("; ") : "failed to load";
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Throw a typed, actionable EnvGraphLoadError when varlock did not finish
|
|
87
|
+
* loading the graph — i.e. when it would have bailed out of finishLoad() before
|
|
88
|
+
* processing items, leaving them unresolvable. Mirrors finishLoad's two
|
|
89
|
+
* early-return conditions exactly: any data source with `isValid === false`, or
|
|
90
|
+
* any plugin with a `loadingError`. A fully-valid graph passes silently.
|
|
91
|
+
*/
|
|
92
|
+
export function assertEnvGraphLoaded(graph) {
|
|
93
|
+
const problems = [];
|
|
94
|
+
for (const source of graph.sortedDataSources ?? []) {
|
|
95
|
+
if (source.isValid === false) {
|
|
96
|
+
const where = source.fullPath ?? source.label ?? "unknown source";
|
|
97
|
+
problems.push(`${where}: ${describeSourceFailure(source)}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
for (const plugin of graph.plugins ?? []) {
|
|
101
|
+
if (plugin.loadingError) {
|
|
102
|
+
const where = plugin.localPath ?? "plugin";
|
|
103
|
+
problems.push(`${where}: ${plugin.loadingError.message ?? String(plugin.loadingError)}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (problems.length === 0)
|
|
107
|
+
return;
|
|
108
|
+
throw new EnvGraphLoadError(`hq run could not load your env schema (varlock ${VARLOCK_PINNED_VERSION}). ` +
|
|
109
|
+
`Fix the following and re-run:\n` +
|
|
110
|
+
problems.map((p) => ` - ${p}`).join("\n"));
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Await `graph.resolveEnvValues()`, but convert varlock's floated resolution
|
|
114
|
+
* rejection into a thrown EnvResolutionError instead of hanging on the promise
|
|
115
|
+
* that never settles.
|
|
116
|
+
*
|
|
117
|
+
* varlock@1.0.0's resolveEnvValues dispatches its per-item resolver un-awaited
|
|
118
|
+
* and with a dead `_reject`, so a throw inside it becomes a process-level
|
|
119
|
+
* unhandled rejection AND leaves the returned promise permanently pending. We
|
|
120
|
+
* race the resolve against a scoped `unhandledRejection` listener: whichever
|
|
121
|
+
* settles first wins. The listener is prepended (so the prior listeners still
|
|
122
|
+
* run — nothing is swallowed) and removed in `finally` (restoring the exact
|
|
123
|
+
* prior listener set, including the no-prior-listener case). If resolve settles
|
|
124
|
+
* normally the listener never fires; a stray unrelated rejection during the
|
|
125
|
+
* window is still delivered to the pre-existing listeners.
|
|
126
|
+
*/
|
|
127
|
+
export async function resolveEnvValuesOrThrow(graph) {
|
|
128
|
+
let claimedReason;
|
|
129
|
+
let didClaim = false;
|
|
130
|
+
const onRejection = (reason) => {
|
|
131
|
+
// Claim this rejection so the process-wide boundary (index.ts) defers to us
|
|
132
|
+
// and does not ALSO report it — one failure, one diagnostic. The
|
|
133
|
+
// pre-existing listeners still fire (this is a prependOnceListener), so the
|
|
134
|
+
// rejection is never swallowed from the process's normal handling.
|
|
135
|
+
claimedReason = reason;
|
|
136
|
+
didClaim = true;
|
|
137
|
+
claimRejection(reason);
|
|
138
|
+
rejectRace(new EnvResolutionError(reason));
|
|
139
|
+
};
|
|
140
|
+
let rejectRace = () => { };
|
|
141
|
+
const rejection = new Promise((_resolve, reject) => {
|
|
142
|
+
rejectRace = reject;
|
|
143
|
+
});
|
|
144
|
+
process.prependOnceListener("unhandledRejection", onRejection);
|
|
145
|
+
try {
|
|
146
|
+
await Promise.race([graph.resolveEnvValues(), rejection]);
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
// No-op if the once-listener already fired; restores the prior set exactly.
|
|
150
|
+
process.removeListener("unhandledRejection", onRejection);
|
|
151
|
+
if (didClaim)
|
|
152
|
+
releaseRejection(claimedReason);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
//# sourceMappingURL=env-graph-guard.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export declare function hadFatalRejection(): boolean;
|
|
2
|
+
export declare function claimRejection(reason: unknown): void;
|
|
3
|
+
export declare function releaseRejection(reason: unknown): void;
|
|
4
|
+
/** Reset the process-level rejection state. Test-only. */
|
|
5
|
+
export declare function __resetRejectionStateForTests(): void;
|
|
6
|
+
export interface RejectionBoundaryDependencies {
|
|
7
|
+
/** Reads the current process exit code (undefined when still unset). */
|
|
8
|
+
readonly getExitCode: () => number | undefined;
|
|
9
|
+
readonly setExitCode: (code: number) => void;
|
|
10
|
+
/** Best-effort actionable report; may be async and may not finish before exit. */
|
|
11
|
+
readonly report: (reason: unknown) => void;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Handle one floated rejection. Synchronously fixes the exit code — a broken
|
|
15
|
+
* pipe (EPIPE) is a clean close (0), anything else is a failure (1) — WITHOUT
|
|
16
|
+
* downgrading a non-zero code a command path already set, then kicks off a
|
|
17
|
+
* best-effort actionable report. Never calls process.exit, so the command's
|
|
18
|
+
* finally block (release-health session end + bounded Sentry flush) still runs.
|
|
19
|
+
*/
|
|
20
|
+
export declare function handleFatalRejection(reason: unknown, deps: RejectionBoundaryDependencies): void;
|
|
21
|
+
/**
|
|
22
|
+
* Register the boundary on `process`, once, before index.ts dispatches into the
|
|
23
|
+
* command graph. Idempotent so repeated imports/installs add a single listener.
|
|
24
|
+
* The actionable report routes through main.ts's existing handleTopLevelError
|
|
25
|
+
* with a NO-OP capture: @sentry/node's integration has already captured the
|
|
26
|
+
* rejection, so this only adds the classified `hq:` line (and its EPIPE / typed
|
|
27
|
+
* carve-outs) without a second Sentry event.
|
|
28
|
+
*/
|
|
29
|
+
export declare function installProcessRejectionBoundary(): void;
|
|
30
|
+
//# sourceMappingURL=unhandled-rejection-boundary.d.ts.map
|