@indigoai-us/hq-cli 5.111.2 → 5.112.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.
@@ -1,68 +1,67 @@
1
+ import { Command } from "commander";
2
+ import { COMMAND_CATALOG } from "./command-catalog.generated.js";
3
+ const commandCatalog = COMMAND_CATALOG;
1
4
  /**
2
- * Hot-command manifest for the entrypoint's lazy registration.
3
- *
4
- * main.ts used to import the entire ~60-module command graph at module scope,
5
- * so every invocation paid for every command. That is fine for a human typing
6
- * one command and ruinous for the calls automation makes in a loop: the
7
- * hq-sentry agent fleet on an outpost runs `hq secrets` about 39 times a minute
8
- * (each worker resolves credentials before every external command it executes),
9
- * and each of those processes spent ~0.75 CPU-seconds importing commands it
10
- * never ran — around half a core, continuously. Measured on Outpost 2
11
- * (i-09424eff61920a4ac) 2026-09-05; see register-all.ts for the numbers.
12
- *
13
- * A command listed here registers ITSELF and nothing else. Anything not listed
14
- * — `--help`, a bare `hq`, an unknown command, every other command — falls back
15
- * to `registerAllCommands`, the complete unchanged graph. That fallback is what
16
- * makes this safe to extend one command at a time: an entry is a performance
17
- * opt-in, never a behaviour change, and a command that is absent here is simply
18
- * as fast as it was before.
19
- *
20
- * This mirrors commands/scaffold-fast.ts, which does the same thing for the
21
- * relocated `hq core …` scripts, and carries the same anti-drift discipline: a
22
- * parity test registers each entry BOTH ways and asserts the resulting command
23
- * shape is identical, so a manifest entry cannot silently diverge from the real
24
- * registration.
25
- *
26
- * TO ADD A COMMAND: it must register exactly one top-level command, be
27
- * registered onto `program` (not onto a subcommand group), and have no other
28
- * module contributing subcommands to it. The parity test enforces the shape;
29
- * these three conditions are what make the entry correct in the first place.
5
+ * One entry for every root, including groups that are assembled by several
6
+ * registrar modules. The routing metadata itself is generated from the eager
7
+ * command graph and checked in tests and the build.
30
8
  */
9
+ export const LAZY_COMMANDS = commandCatalog.map((command) => ({
10
+ name: command.name,
11
+ register: async (program) => {
12
+ const registered = await registerCommandRoot(program, command.name);
13
+ if (!registered)
14
+ throw new Error(`No registration plan for '${command.name}'`);
15
+ },
16
+ }));
31
17
  /**
32
- * The hot paths, in descending order of how often automation calls them.
33
- *
34
- * `secrets` and `run` are the two commands HQ's own tooling puts on the inner
35
- * loop: every fleet worker shells through one of them before each external
36
- * command, which is exactly the shape that makes eager import expensive.
18
+ * Keep the registration seam at register-all.js. Besides making one place own
19
+ * the graph, this lets the torn-install recovery wrap failures while an update
20
+ * replaces the installed tree between startup and command registration.
37
21
  */
38
- export const LAZY_COMMANDS = [
39
- {
40
- name: "secrets",
41
- register: async (program) => {
42
- const { registerSecretsCommand } = await import("./commands/secrets.js");
43
- registerSecretsCommand(program);
44
- },
45
- },
46
- {
47
- name: "run",
48
- register: async (program) => {
49
- const { registerRunCommand } = await import("./commands/run.js");
50
- registerRunCommand(program);
51
- },
52
- },
53
- ];
22
+ export async function registerCommandRoot(program, root) {
23
+ const registration = await import("./register-all.js");
24
+ return registration.registerCommandRoot(program, root);
25
+ }
26
+ function commandDefinition(command) {
27
+ const stub = new Command(command.name)
28
+ .description(command.description);
29
+ for (const alias of command.aliases)
30
+ stub.alias(alias);
31
+ for (const argument of command.arguments) {
32
+ const name = `${argument.name}${argument.variadic ? "..." : ""}`;
33
+ stub.argument(argument.required ? `<${name}>` : `[${name}]`);
34
+ }
35
+ for (const option of command.options)
36
+ stub.option(option.flags, option.description);
37
+ if (command.hidden)
38
+ stub._hidden = true;
39
+ return stub;
40
+ }
54
41
  /**
55
- * Resolve `process.argv` to a manifest entry, or null to use the full graph.
56
- *
57
- * argv is `[node, hq, <name>, ...rest]`. `hq` declares no program-level options
58
- * before the command name, so argv[2] is the command token when there is one;
59
- * `--help`, `--version`, and a bare `hq` all fail the lookup and take the
60
- * fallback, which is the intended behaviour — help must list every command.
42
+ * Register only generated root metadata. This is enough for root help and
43
+ * Commander suggestions, without evaluating an implementation module.
44
+ */
45
+ export function registerCommandCatalog(program) {
46
+ for (const command of commandCatalog)
47
+ program.addCommand(commandDefinition(command));
48
+ }
49
+ function commandForToken(token) {
50
+ return commandCatalog.find((candidate) => candidate.name === token || candidate.aliases.includes(token));
51
+ }
52
+ /**
53
+ * Resolve the root requested by Commander argv. Root help, a bare invocation,
54
+ * and unknown tokens deliberately return null so the lightweight catalog can
55
+ * preserve the complete help and unknown-command recovery surfaces.
61
56
  */
62
57
  export function findLazyCommand(argv) {
63
- const name = argv[2];
64
- if (typeof name !== "string")
58
+ const first = argv[2];
59
+ const token = first === "help" ? argv[3] : first;
60
+ if (typeof token !== "string" || token.startsWith("-"))
61
+ return null;
62
+ const command = commandForToken(token);
63
+ if (!command)
65
64
  return null;
66
- return LAZY_COMMANDS.find((candidate) => candidate.name === name) ?? null;
65
+ return LAZY_COMMANDS.find((candidate) => candidate.name === command.name) ?? null;
67
66
  }
68
67
  //# sourceMappingURL=lazy-commands.js.map
@@ -0,0 +1,76 @@
1
+ /**
2
+ * `hq core sentry report` — constrained diagnostic transport for HQ hooks.
3
+ *
4
+ * The stdin event schema is deliberately closed. Hook payloads can contain
5
+ * commands, prompts, source content, transcript locations, and credentials, so
6
+ * this module accepts only a small set of scalar diagnostics and never reads a
7
+ * hook payload or the process environment. The owner-approved `hook_path`
8
+ * diagnostic is the narrow exception: it names the slow hook script and may
9
+ * include its company directory, but never carries the hook payload itself.
10
+ *
11
+ * Invalid top-level input and structurally rich metadata are rejected rather
12
+ * than truncated. Unknown scalar metadata is dropped instead: the allowlist,
13
+ * not a caller version match, determines what may leave the machine.
14
+ */
15
+ import type { CaptureContext, SeverityLevel } from "@sentry/node";
16
+ export declare const DEFAULT_SENTRY_REPORT_TIMEOUT_MS = 750;
17
+ export declare const MAX_SENTRY_REPORT_TIMEOUT_MS = 5000;
18
+ export declare const MAX_SENTRY_REPORT_PAYLOAD_BYTES = 2048;
19
+ export declare const MAX_SENTRY_REPORT_METADATA_VALUE_BYTES = 256;
20
+ /**
21
+ * The only hook diagnostics that can be attached as metadata. The list omits
22
+ * command text, prompts, payloads, company names, and repositories. `hook_path`
23
+ * is owner-approved telemetry for the exact hook script and may include a
24
+ * company directory; it is never a substitute for hook input or a transcript.
25
+ */
26
+ export declare const ALLOWED_SENTRY_REPORT_METADATA_KEYS: readonly ["hook_name", "hook_path", "hook_event", "tool_name", "session_id", "declared_timeout_ms", "elapsed_ms", "remaining_ms", "watchdog_timeout_ms", "hook_timeout_ms", "hq_version", "platform", "load_average"];
27
+ export declare const MAX_SENTRY_REPORT_METADATA_KEYS: number;
28
+ export type SentryReportMetadata = Record<string, string | number | boolean>;
29
+ export type SentryReportEvent = {
30
+ type: string;
31
+ message: string;
32
+ fingerprint: string;
33
+ level: SeverityLevel;
34
+ metadata: SentryReportMetadata;
35
+ };
36
+ export type SentryReportOptions = {
37
+ dryRun?: boolean;
38
+ /** Total time reserved for the report flush after validation. */
39
+ timeoutMs?: number;
40
+ };
41
+ export type SentryReportDependencies = {
42
+ sentry: {
43
+ captureMessage: (message: string, context?: CaptureContext | SeverityLevel) => string;
44
+ flush: (timeout?: number) => PromiseLike<boolean>;
45
+ };
46
+ stdout: Pick<NodeJS.WriteStream, "write">;
47
+ stderr: Pick<NodeJS.WriteStream, "write">;
48
+ };
49
+ export type SentryReportResult = {
50
+ code: 0 | 1;
51
+ event?: SentryReportEvent;
52
+ droppedMetadataKeys?: number;
53
+ };
54
+ /**
55
+ * Read no more than the accepted event size. Returning early keeps a malformed
56
+ * pipe from making a watchdog retain an unbounded hook payload in memory.
57
+ */
58
+ export declare function readSentryReportStdin(stdin: AsyncIterable<Buffer | string>): Promise<string | null>;
59
+ /** Unknown metadata is ignored before this cap is evaluated. */
60
+ export declare function exceedsSentryReportMetadataKeyCap(keys: Iterable<string>): boolean;
61
+ /**
62
+ * Parse and validate the closed report schema without touching Sentry. The
63
+ * function intentionally returns an ordinary result instead of throwing so a
64
+ * hook can ignore the CLI exit code without creating an unhandled rejection.
65
+ */
66
+ export declare function parseSentryReportEvent(stdin: string): SentryReportResult;
67
+ /**
68
+ * Capture one validated Sentry message and wait only for the supplied bounded
69
+ * flush budget. A zero result means the event entered the local transport and
70
+ * its queue drained in time, not that ingest accepted it. Normal reports are
71
+ * silent on stdout; dry-runs print the same scrubbed event projection used by
72
+ * the live path. All failures use a generic stderr line so malformed input and
73
+ * transport errors cannot echo event data back to a hook.
74
+ */
75
+ export declare function reportSentryEvent(stdin: string, options?: SentryReportOptions, dependencies?: SentryReportDependencies): Promise<SentryReportResult>;
76
+ //# sourceMappingURL=sentry-report.d.ts.map
@@ -0,0 +1,255 @@
1
+ /**
2
+ * `hq core sentry report` — constrained diagnostic transport for HQ hooks.
3
+ *
4
+ * The stdin event schema is deliberately closed. Hook payloads can contain
5
+ * commands, prompts, source content, transcript locations, and credentials, so
6
+ * this module accepts only a small set of scalar diagnostics and never reads a
7
+ * hook payload or the process environment. The owner-approved `hook_path`
8
+ * diagnostic is the narrow exception: it names the slow hook script and may
9
+ * include its company directory, but never carries the hook payload itself.
10
+ *
11
+ * Invalid top-level input and structurally rich metadata are rejected rather
12
+ * than truncated. Unknown scalar metadata is dropped instead: the allowlist,
13
+ * not a caller version match, determines what may leave the machine.
14
+ */
15
+ import * as Sentry from "@sentry/node";
16
+ import { beforeSend } from "../../sentry-before-send.js";
17
+ export const DEFAULT_SENTRY_REPORT_TIMEOUT_MS = 750;
18
+ export const MAX_SENTRY_REPORT_TIMEOUT_MS = 5_000;
19
+ export const MAX_SENTRY_REPORT_PAYLOAD_BYTES = 2_048;
20
+ export const MAX_SENTRY_REPORT_METADATA_VALUE_BYTES = 256;
21
+ const MAX_SENTRY_REPORT_TYPE_BYTES = 64;
22
+ const MAX_SENTRY_REPORT_MESSAGE_BYTES = 1_024;
23
+ const MAX_SENTRY_REPORT_FINGERPRINT_BYTES = 128;
24
+ const SENTRY_REPORT_LEVELS = new Set([
25
+ "fatal",
26
+ "error",
27
+ "warning",
28
+ "log",
29
+ "info",
30
+ "debug",
31
+ ]);
32
+ /**
33
+ * The only hook diagnostics that can be attached as metadata. The list omits
34
+ * command text, prompts, payloads, company names, and repositories. `hook_path`
35
+ * is owner-approved telemetry for the exact hook script and may include a
36
+ * company directory; it is never a substitute for hook input or a transcript.
37
+ */
38
+ export const ALLOWED_SENTRY_REPORT_METADATA_KEYS = [
39
+ "hook_name",
40
+ "hook_path",
41
+ "hook_event",
42
+ "tool_name",
43
+ "session_id",
44
+ "declared_timeout_ms",
45
+ "elapsed_ms",
46
+ "remaining_ms",
47
+ "watchdog_timeout_ms",
48
+ "hook_timeout_ms",
49
+ "hq_version",
50
+ "platform",
51
+ "load_average",
52
+ ];
53
+ const ALLOWED_METADATA_KEYS = new Set(ALLOWED_SENTRY_REPORT_METADATA_KEYS);
54
+ const SENTRY_REPORT_METADATA_KEY_HEADROOM = 2;
55
+ // This tracks the schema rather than a separately-maintained literal. A caller
56
+ // can send every recognised key with room for two later recognised fields,
57
+ // while unknown keys are deliberately dropped.
58
+ export const MAX_SENTRY_REPORT_METADATA_KEYS = ALLOWED_SENTRY_REPORT_METADATA_KEYS.length + SENTRY_REPORT_METADATA_KEY_HEADROOM;
59
+ const defaultDependencies = {
60
+ sentry: Sentry,
61
+ stdout: process.stdout,
62
+ stderr: process.stderr,
63
+ };
64
+ /**
65
+ * Read no more than the accepted event size. Returning early keeps a malformed
66
+ * pipe from making a watchdog retain an unbounded hook payload in memory.
67
+ */
68
+ export async function readSentryReportStdin(stdin) {
69
+ const chunks = [];
70
+ let totalBytes = 0;
71
+ for await (const chunk of stdin) {
72
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, "utf8");
73
+ totalBytes += bytes.length;
74
+ if (totalBytes > MAX_SENTRY_REPORT_PAYLOAD_BYTES)
75
+ return null;
76
+ chunks.push(bytes);
77
+ }
78
+ return Buffer.concat(chunks).toString("utf8");
79
+ }
80
+ function isRecord(value) {
81
+ return value !== null && typeof value === "object" && !Array.isArray(value);
82
+ }
83
+ function byteLength(value) {
84
+ return Buffer.byteLength(value, "utf8");
85
+ }
86
+ function isBoundedString(value, maxBytes) {
87
+ return typeof value === "string" && value.length > 0 && byteLength(value) <= maxBytes;
88
+ }
89
+ function isScalarMetadataValue(value) {
90
+ if (typeof value === "string")
91
+ return byteLength(value) <= MAX_SENTRY_REPORT_METADATA_VALUE_BYTES;
92
+ if (typeof value === "boolean")
93
+ return true;
94
+ return typeof value === "number" && Number.isFinite(value);
95
+ }
96
+ function isAllowedMetadataValue(key, value) {
97
+ if (!isScalarMetadataValue(value))
98
+ return false;
99
+ if (key === "tool_name" || key === "session_id" || key === "hook_path") {
100
+ return typeof value === "string";
101
+ }
102
+ return true;
103
+ }
104
+ /** Unknown metadata is ignored before this cap is evaluated. */
105
+ export function exceedsSentryReportMetadataKeyCap(keys) {
106
+ let recognisedKeys = 0;
107
+ for (const key of keys) {
108
+ if (!ALLOWED_METADATA_KEYS.has(key))
109
+ continue;
110
+ recognisedKeys++;
111
+ if (recognisedKeys > MAX_SENTRY_REPORT_METADATA_KEYS)
112
+ return true;
113
+ }
114
+ return false;
115
+ }
116
+ function invalidInput() {
117
+ return { code: 1 };
118
+ }
119
+ /**
120
+ * Parse and validate the closed report schema without touching Sentry. The
121
+ * function intentionally returns an ordinary result instead of throwing so a
122
+ * hook can ignore the CLI exit code without creating an unhandled rejection.
123
+ */
124
+ export function parseSentryReportEvent(stdin) {
125
+ if (byteLength(stdin) > MAX_SENTRY_REPORT_PAYLOAD_BYTES)
126
+ return invalidInput();
127
+ let parsed;
128
+ try {
129
+ parsed = JSON.parse(stdin);
130
+ }
131
+ catch {
132
+ return invalidInput();
133
+ }
134
+ if (!isRecord(parsed))
135
+ return invalidInput();
136
+ const allowedTopLevel = new Set(["type", "message", "fingerprint", "level", "metadata"]);
137
+ if (Object.keys(parsed).some((key) => !allowedTopLevel.has(key)))
138
+ return invalidInput();
139
+ if (!isBoundedString(parsed.type, MAX_SENTRY_REPORT_TYPE_BYTES))
140
+ return invalidInput();
141
+ if (!isBoundedString(parsed.message, MAX_SENTRY_REPORT_MESSAGE_BYTES))
142
+ return invalidInput();
143
+ if (!isBoundedString(parsed.fingerprint, MAX_SENTRY_REPORT_FINGERPRINT_BYTES))
144
+ return invalidInput();
145
+ if (typeof parsed.level !== "string" || !SENTRY_REPORT_LEVELS.has(parsed.level)) {
146
+ return invalidInput();
147
+ }
148
+ const rawMetadata = "metadata" in parsed ? parsed.metadata : {};
149
+ if (!isRecord(rawMetadata))
150
+ return invalidInput();
151
+ const metadataKeys = Object.keys(rawMetadata);
152
+ if (exceedsSentryReportMetadataKeyCap(metadataKeys))
153
+ return invalidInput();
154
+ const metadata = {};
155
+ let droppedMetadataKeys = 0;
156
+ for (const key of metadataKeys) {
157
+ if (!isScalarMetadataValue(rawMetadata[key]))
158
+ return invalidInput();
159
+ if (!ALLOWED_METADATA_KEYS.has(key)) {
160
+ droppedMetadataKeys++;
161
+ continue;
162
+ }
163
+ if (!isAllowedMetadataValue(key, rawMetadata[key]))
164
+ return invalidInput();
165
+ metadata[key] = rawMetadata[key];
166
+ }
167
+ return {
168
+ code: 0,
169
+ event: {
170
+ type: parsed.type,
171
+ message: parsed.message,
172
+ fingerprint: parsed.fingerprint,
173
+ level: parsed.level,
174
+ metadata,
175
+ },
176
+ droppedMetadataKeys,
177
+ };
178
+ }
179
+ function resolveTimeout(timeoutMs) {
180
+ const timeout = timeoutMs ?? DEFAULT_SENTRY_REPORT_TIMEOUT_MS;
181
+ if (!Number.isInteger(timeout) || timeout < 1 || timeout > MAX_SENTRY_REPORT_TIMEOUT_MS)
182
+ return null;
183
+ return timeout;
184
+ }
185
+ function sentryEnvelope(event) {
186
+ return {
187
+ level: event.level,
188
+ fingerprint: [event.fingerprint],
189
+ tags: { sentry_event_type: event.type },
190
+ extra: event.metadata,
191
+ };
192
+ }
193
+ /** Build the same scrubbed event projection that the live SDK path uses. */
194
+ function scrubbedSentryEnvelope(event) {
195
+ return beforeSend({
196
+ message: event.message,
197
+ ...sentryEnvelope(event),
198
+ }, {});
199
+ }
200
+ /**
201
+ * Resolve true only when the local transport queue drains within the budget.
202
+ * This does not confirm that the ingest service accepted the event.
203
+ */
204
+ async function flushWithin(sentry, timeoutMs) {
205
+ return new Promise((resolve) => {
206
+ const timer = setTimeout(() => resolve(false), timeoutMs);
207
+ void Promise.resolve()
208
+ .then(() => sentry.flush(timeoutMs))
209
+ .then((flushed) => {
210
+ clearTimeout(timer);
211
+ resolve(flushed === true);
212
+ }, () => {
213
+ clearTimeout(timer);
214
+ resolve(false);
215
+ });
216
+ });
217
+ }
218
+ /**
219
+ * Capture one validated Sentry message and wait only for the supplied bounded
220
+ * flush budget. A zero result means the event entered the local transport and
221
+ * its queue drained in time, not that ingest accepted it. Normal reports are
222
+ * silent on stdout; dry-runs print the same scrubbed event projection used by
223
+ * the live path. All failures use a generic stderr line so malformed input and
224
+ * transport errors cannot echo event data back to a hook.
225
+ */
226
+ export async function reportSentryEvent(stdin, options = {}, dependencies = defaultDependencies) {
227
+ const timeoutMs = resolveTimeout(options.timeoutMs);
228
+ const result = parseSentryReportEvent(stdin);
229
+ if (timeoutMs === null || result.code !== 0 || !result.event) {
230
+ dependencies.stderr.write("Invalid Sentry report input.\n");
231
+ return invalidInput();
232
+ }
233
+ if (result.droppedMetadataKeys) {
234
+ const noun = result.droppedMetadataKeys === 1 ? "key" : "keys";
235
+ dependencies.stderr.write(`Dropped ${result.droppedMetadataKeys} unsupported Sentry report metadata ${noun}.\n`);
236
+ }
237
+ const envelope = sentryEnvelope(result.event);
238
+ if (options.dryRun) {
239
+ const scrubbed = scrubbedSentryEnvelope(result.event);
240
+ if (scrubbed)
241
+ dependencies.stdout.write(`${JSON.stringify(scrubbed)}\n`);
242
+ return result;
243
+ }
244
+ try {
245
+ dependencies.sentry.captureMessage(result.event.message, envelope);
246
+ if (await flushWithin(dependencies.sentry, timeoutMs))
247
+ return result;
248
+ }
249
+ catch {
250
+ // Report failure below without exposing event fields or transport details.
251
+ }
252
+ dependencies.stderr.write("Sentry report failed.\n");
253
+ return invalidInput();
254
+ }
255
+ //# sourceMappingURL=sentry-report.js.map
package/dist/main.d.ts CHANGED
@@ -5,6 +5,12 @@
5
5
  import "./node-preflight.js";
6
6
  import "./node-network-compat.js";
7
7
  import { Sentry } from "./sentry.js";
8
+ /**
9
+ * Total time one CLI invocation may await foreground network work. This spans
10
+ * the hard version decision, pre-action observability, and release-health
11
+ * finalization; command execution itself does not consume this allowance.
12
+ */
13
+ export declare const FOREGROUND_NETWORK_BUDGET_MS = 1500;
8
14
  export type StreamErrorDependencies = {
9
15
  stderr: Pick<typeof process.stderr, "write">;
10
16
  exit: (code: number) => void;
package/dist/main.js CHANGED
@@ -9,7 +9,7 @@ import "./node-network-compat.js";
9
9
  import path from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
11
  import { Command } from "commander";
12
- import { initSentry, Sentry } from "./sentry.js";
12
+ import { finishSentrySession, initSentry, Sentry } from "./sentry.js";
13
13
  import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
14
14
  import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
15
15
  import { syncStateLockMessage } from "./utils/sync-state-lock-error.js";
@@ -41,7 +41,7 @@ import { refreshVersionCache, staleAgainstCachedLatest, } from "./utils/version-
41
41
  import { enforceVersionGate, shouldSkipGate, } from "./utils/version-gate.js";
42
42
  import { autoUpdateAndReexec } from "./utils/self-update.js";
43
43
  import { CLI_VERSION } from "./cli-version.js";
44
- import { findLazyCommand } from "./lazy-commands.js";
44
+ import { findLazyCommand, registerCommandCatalog } from "./lazy-commands.js";
45
45
  import { emitCliSessionStarted } from "./utils/cli-telemetry.js";
46
46
  import { reportCliClientHealthInvocation } from "./utils/client-health.js";
47
47
  import { settleWithin } from "./utils/settle-with-timeout.js";
@@ -52,8 +52,57 @@ import { registerCommandsWithRecovery } from "./startup-registration.js";
52
52
  import { installTreeTornCaptureContext, installTreeTornStderrLine, isInstallTreeTornError, } from "./utils/install-tree-torn.js";
53
53
  import { isVaultAccessDeniedError, vaultAccessDeniedMessage, } from "./utils/vault-access-denied-error.js";
54
54
  import { fallbackOperatorMessage, unexpectedCliErrorMessage } from "./utils/unexpected-cli-error.js";
55
- /** Hard upper bound for non-user-visible release-health finalization. */
56
- const RELEASE_HEALTH_SETTLE_TIMEOUT_MS = 3_000;
55
+ /**
56
+ * Total time one CLI invocation may await foreground network work. This spans
57
+ * the hard version decision, pre-action observability, and release-health
58
+ * finalization; command execution itself does not consume this allowance.
59
+ */
60
+ export const FOREGROUND_NETWORK_BUDGET_MS = 1_500;
61
+ /**
62
+ * Release health must always have a chance to flush an already-captured error
63
+ * and refresh the cache after a command. Reserve this before earlier work.
64
+ */
65
+ const FINALIZATION_FOREGROUND_WAIT_MS = 500;
66
+ /**
67
+ * A stalled version endpoint must leave the invocation heartbeat a chance to
68
+ * send. A timely blocked response is still enforced exactly as before.
69
+ */
70
+ const VERSION_GATE_FOREGROUND_WAIT_MS = 750;
71
+ /** Existing telemetry/health cap, now also bounded by the shared deadline. */
72
+ const OBSERVABILITY_FOREGROUND_WAIT_MS = 1_200;
73
+ /**
74
+ * Shared accounting for awaited foreground network work. Each lifecycle phase
75
+ * debits only the bounded await it performs; ordinary command runtime leaves
76
+ * the allowance untouched. Earlier phases reserve the finalization floor, so
77
+ * error flushing cannot disappear after a long command.
78
+ */
79
+ class ForegroundNetworkBudget {
80
+ budgetMs;
81
+ now;
82
+ consumedMs = 0;
83
+ constructor(budgetMs = FOREGROUND_NETWORK_BUDGET_MS, now = () => performance.now()) {
84
+ this.budgetMs = budgetMs;
85
+ this.now = now;
86
+ }
87
+ timeoutFor(phaseMaximumMs, reservedMs = 0) {
88
+ return Math.max(0, Math.min(phaseMaximumMs, Math.floor(this.budgetMs - reservedMs - this.consumedMs)));
89
+ }
90
+ async waitFor(phaseMaximumMs, reservedMs, work) {
91
+ const timeoutMs = this.timeoutFor(phaseMaximumMs, reservedMs);
92
+ if (timeoutMs === 0)
93
+ return undefined;
94
+ const startedAt = this.now();
95
+ try {
96
+ return await work(timeoutMs);
97
+ }
98
+ finally {
99
+ // A timer cannot interrupt synchronous work. Do not let a blocked event
100
+ // loop consume the finalization reserve as though it were network wait.
101
+ this.consumedMs += Math.min(timeoutMs, Math.max(0, this.now() - startedAt));
102
+ }
103
+ }
104
+ }
105
+ let activeForegroundNetworkBudget;
57
106
  /**
58
107
  * The RUNNING install's own entrypoint (`<pkg>/dist/index.js`), used as the
59
108
  * torn-install recovery re-exec target. It must be this resolved path — never
@@ -110,17 +159,23 @@ program
110
159
  .version(CLI_VERSION);
111
160
  program.hook("preAction", async () => {
112
161
  // Both are best-effort and fully swallowed: neither can change the command's
113
- // result or exit code. The 1.2s bound they carry is a TIMER, so it only
114
- // preempts asynchronous work synchronous work inside them runs to
115
- // completion regardless, because the timer cannot be serviced while the
116
- // event loop is blocked. Keep anything added here asynchronous, or
117
- // separately cheap: this hook is on the path of EVERY hq command.
118
- await Promise.all([
119
- emitCliSessionStarted(),
120
- reportCliClientHealthInvocation(),
121
- ]);
162
+ // result or exit code. They receive only the shared pre-finalization
163
+ // allowance (up to 1.2s), and that bound is a TIMER, so it only preempts
164
+ // asynchronous work synchronous work inside them runs to completion
165
+ // regardless, because the timer cannot be serviced while the event loop is
166
+ // blocked. Keep anything added here asynchronous, or separately cheap: this
167
+ // hook is on the path of EVERY hq command.
168
+ const budget = activeForegroundNetworkBudget;
169
+ if (!budget)
170
+ return;
171
+ await budget.waitFor(OBSERVABILITY_FOREGROUND_WAIT_MS, FINALIZATION_FOREGROUND_WAIT_MS, (timeoutMs) => Promise.all([
172
+ emitCliSessionStarted(timeoutMs),
173
+ reportCliClientHealthInvocation({ timeoutMs }),
174
+ ]));
122
175
  });
123
176
  export async function runCli() {
177
+ const foregroundNetworkBudget = new ForegroundNetworkBudget();
178
+ activeForegroundNetworkBudget = foregroundNetworkBudget;
124
179
  // Begin one registry refresh without awaiting it. Every command gate reads
125
180
  // only the client's held snapshot and falls back locally, so an offline or
126
181
  // slow registry cannot delay command parsing or alter a command failure.
@@ -149,12 +204,12 @@ export async function runCli() {
149
204
  // `version-check.ts`); whichever fires first re-execs, and the child
150
205
  // carries a guard env so it can never update again.
151
206
  if (!shouldSkipGate(process.argv)) {
152
- const gate = await enforceVersionGate(async (decision) => {
207
+ const gate = (await foregroundNetworkBudget.waitFor(VERSION_GATE_FOREGROUND_WAIT_MS, FINALIZATION_FOREGROUND_WAIT_MS, (timeoutMs) => enforceVersionGate(async (decision) => {
153
208
  const outcome = await autoUpdateAndReexec(process.argv, decision.latestVersion);
154
209
  if (outcome.action === "reexec")
155
210
  reexecStatus = outcome.reexecStatus ?? 0;
156
211
  return outcome.action === "reexec";
157
- });
212
+ }, { timeoutMs }))) ?? "continue";
158
213
  if (gate === "reexec")
159
214
  return;
160
215
  const cachedLatest = staleAgainstCachedLatest();
@@ -166,15 +221,11 @@ export async function runCli() {
166
221
  }
167
222
  }
168
223
  }
169
- // Register only what this invocation needs. A hot command named in the
170
- // lazy manifest imports its own module and nothing else; everything else —
171
- // `--help`, a bare `hq`, an unknown command, any command not on the
172
- // manifest falls back to the complete graph, so its behaviour is
173
- // unchanged. See register-all.ts for the measurements that motivated this.
174
- // The same lazy/full registration as before, wrapped so it recovers ONCE if
175
- // a global reinstall is tearing the install tree out from under these
176
- // deferred imports (Sentry HQ-CLI-1G/1H/1J/1K). Which modules are imported,
177
- // and in what order, is unchanged — only the failure handling is added.
224
+ // Register every contributor for the selected root only. Root help and
225
+ // unknown-command recovery use generated metadata, so they keep the full
226
+ // Commander surface without evaluating every command implementation.
227
+ // Registration remains inside the torn-install recovery boundary: deferred
228
+ // import failures still receive the same one-shot settled-tree re-exec.
178
229
  const registration = await registerCommandsWithRecovery({
179
230
  register: async () => {
180
231
  const lazy = findLazyCommand(process.argv);
@@ -182,8 +233,7 @@ export async function runCli() {
182
233
  await lazy.register(program);
183
234
  }
184
235
  else {
185
- const { registerAllCommands } = await import("./register-all.js");
186
- registerAllCommands(program);
236
+ registerCommandCatalog(program);
187
237
  }
188
238
  },
189
239
  argv: process.argv,
@@ -207,16 +257,24 @@ export async function runCli() {
207
257
  // process.exitCode — safe to run after exit codes have been set.
208
258
  emitPlanLimitNag();
209
259
  // Release health: finalize the per-run session before the flush.
210
- Sentry.endSession();
260
+ finishSentrySession();
211
261
  // Neither task may turn a successful command into Node's
212
262
  // `unsettled top-level await` exit. They are observability-only after the
213
263
  // command has completed, so a bounded best-effort wait is the terminal
214
- // lifecycle boundary for this invocation.
215
- await settleWithin([refreshVersionCache(), Sentry.flush(2000)], RELEASE_HEALTH_SETTLE_TIMEOUT_MS);
264
+ // lifecycle boundary for this invocation. The budget reserves this 500 ms
265
+ // slice even when the command itself was long-running.
266
+ const releaseHealthTimeoutMs = foregroundNetworkBudget.timeoutFor(FINALIZATION_FOREGROUND_WAIT_MS);
267
+ await settleWithin([
268
+ refreshVersionCache({ timeoutMs: releaseHealthTimeoutMs }),
269
+ Sentry.flush(releaseHealthTimeoutMs),
270
+ ], releaseHealthTimeoutMs);
216
271
  // Last, so it wins over anything the (skipped) command path would have
217
272
  // set: the re-exec'd child's status IS this invocation's result.
218
273
  if (reexecStatus !== null)
219
274
  process.exitCode = reexecStatus;
275
+ if (activeForegroundNetworkBudget === foregroundNetworkBudget) {
276
+ activeForegroundNetworkBudget = undefined;
277
+ }
220
278
  }
221
279
  }
222
280
  const defaultTopLevelErrorDependencies = {