@indigoai-us/hq-cli 5.111.1 → 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.
- package/CHANGELOG.md +24 -0
- package/dist/bin/hq-auth-refresh.d.ts +2 -0
- package/dist/bin/hq-auth-refresh.js +12 -6
- package/dist/command-catalog.generated.d.ts +6399 -0
- package/dist/command-catalog.generated.js +8275 -0
- package/dist/command-registration-plan.d.ts +394 -0
- package/dist/command-registration-plan.js +103 -0
- package/dist/commands/core.js +18 -0
- package/dist/commands/whoami.d.ts +17 -0
- package/dist/commands/whoami.js +26 -3
- package/dist/lazy-commands.d.ts +20 -44
- package/dist/lazy-commands.js +57 -58
- package/dist/lib/core-utils/sentry-report.d.ts +76 -0
- package/dist/lib/core-utils/sentry-report.js +255 -0
- package/dist/lib/plan-limit-nag.d.ts +1 -1
- package/dist/lib/plan-limit-nag.js +33 -12
- package/dist/main.d.ts +6 -0
- package/dist/main.js +87 -29
- package/dist/register-all.d.ts +4 -29
- package/dist/register-all.js +4 -235
- package/dist/sentry.d.ts +8 -2
- package/dist/sentry.js +17 -1
- package/dist/utils/cli-telemetry.d.ts +2 -1
- package/dist/utils/cli-telemetry.js +5 -5
- package/dist/utils/contribution-table.d.ts +1 -1
- package/dist/utils/version-check.d.ts +4 -1
- package/dist/utils/version-check.js +2 -2
- package/dist/utils/version-gate.d.ts +5 -1
- package/dist/utils/version-gate.js +4 -4
- package/package.json +3 -2
package/dist/lazy-commands.d.ts
CHANGED
|
@@ -1,55 +1,31 @@
|
|
|
1
|
-
|
|
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.
|
|
30
|
-
*/
|
|
31
|
-
import type { Command } from "commander";
|
|
1
|
+
import { Command } from "commander";
|
|
32
2
|
export type LazyCommand = {
|
|
33
|
-
/**
|
|
3
|
+
/** Canonical top-level name; aliases resolve to this name. */
|
|
34
4
|
name: string;
|
|
35
|
-
/** Registers
|
|
5
|
+
/** Registers every contributor for this root and no unrelated root. */
|
|
36
6
|
register: (program: Command) => Promise<void>;
|
|
37
7
|
};
|
|
38
8
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* loop: every fleet worker shells through one of them before each external
|
|
43
|
-
* command, which is exactly the shape that makes eager import expensive.
|
|
9
|
+
* One entry for every root, including groups that are assembled by several
|
|
10
|
+
* registrar modules. The routing metadata itself is generated from the eager
|
|
11
|
+
* command graph and checked in tests and the build.
|
|
44
12
|
*/
|
|
45
13
|
export declare const LAZY_COMMANDS: readonly LazyCommand[];
|
|
46
14
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
15
|
+
* Keep the registration seam at register-all.js. Besides making one place own
|
|
16
|
+
* the graph, this lets the torn-install recovery wrap failures while an update
|
|
17
|
+
* replaces the installed tree between startup and command registration.
|
|
18
|
+
*/
|
|
19
|
+
export declare function registerCommandRoot(program: Command, root: string): Promise<boolean>;
|
|
20
|
+
/**
|
|
21
|
+
* Register only generated root metadata. This is enough for root help and
|
|
22
|
+
* Commander suggestions, without evaluating an implementation module.
|
|
23
|
+
*/
|
|
24
|
+
export declare function registerCommandCatalog(program: Command): void;
|
|
25
|
+
/**
|
|
26
|
+
* Resolve the root requested by Commander argv. Root help, a bare invocation,
|
|
27
|
+
* and unknown tokens deliberately return null so the lightweight catalog can
|
|
28
|
+
* preserve the complete help and unknown-command recovery surfaces.
|
|
53
29
|
*/
|
|
54
30
|
export declare function findLazyCommand(argv: readonly string[]): LazyCommand | null;
|
|
55
31
|
//# sourceMappingURL=lazy-commands.d.ts.map
|
package/dist/lazy-commands.js
CHANGED
|
@@ -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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
*
|
|
56
|
-
*
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
64
|
-
|
|
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
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* Additive only — never throws, never touches `process.exitCode`, never
|
|
16
16
|
* writes to stdout. Env off-switch: `HQ_NO_PLAN_LIMIT_NAG=1`.
|
|
17
17
|
*/
|
|
18
|
-
export declare const PLAN_LIMIT_UPGRADE_URL = "https://
|
|
18
|
+
export declare const PLAN_LIMIT_UPGRADE_URL = "https://hq.computer/billing/upgrade";
|
|
19
19
|
export interface PlanLimitEntry {
|
|
20
20
|
used: number;
|
|
21
21
|
limit: number;
|
|
@@ -19,7 +19,7 @@ import chalk from "chalk";
|
|
|
19
19
|
import * as fs from "node:fs";
|
|
20
20
|
import * as os from "node:os";
|
|
21
21
|
import * as path from "node:path";
|
|
22
|
-
export const PLAN_LIMIT_UPGRADE_URL = "https://
|
|
22
|
+
export const PLAN_LIMIT_UPGRADE_URL = "https://hq.computer/billing/upgrade";
|
|
23
23
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
24
24
|
/** Module-level last-seen cell — overwritten by each successful parse. */
|
|
25
25
|
let lastSeen = null;
|
|
@@ -58,9 +58,24 @@ function parseEntry(value) {
|
|
|
58
58
|
}
|
|
59
59
|
return { used: rec.used, limit: rec.limit, over: rec.over };
|
|
60
60
|
}
|
|
61
|
+
/** Validate the server-provided optional upgrade URL without throwing. */
|
|
62
|
+
function parseUpgradeUrl(value) {
|
|
63
|
+
if (typeof value !== "string")
|
|
64
|
+
return null;
|
|
65
|
+
try {
|
|
66
|
+
const parsed = new URL(value);
|
|
67
|
+
// hq-pro permits an environment-configured console base URL, so stages
|
|
68
|
+
// cannot use a production-host allowlist. HTTPS is the trust boundary.
|
|
69
|
+
return parsed.protocol === "https:" ? parsed.toString() : null;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
61
75
|
/**
|
|
62
76
|
* Defensively parse a decoded JSON body for a well-formed top-level
|
|
63
|
-
* `planLimits` object
|
|
77
|
+
* `planLimits` object and its optional `upgradeUrl`. Malformed or absent →
|
|
78
|
+
* null. Never throws.
|
|
64
79
|
*/
|
|
65
80
|
function parsePlanLimits(body) {
|
|
66
81
|
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
@@ -81,7 +96,12 @@ function parsePlanLimits(body) {
|
|
|
81
96
|
out[key] = entry;
|
|
82
97
|
anyValid = true;
|
|
83
98
|
}
|
|
84
|
-
return anyValid
|
|
99
|
+
return anyValid
|
|
100
|
+
? {
|
|
101
|
+
limits: out,
|
|
102
|
+
upgradeUrl: parseUpgradeUrl(planLimits.upgradeUrl),
|
|
103
|
+
}
|
|
104
|
+
: null;
|
|
85
105
|
}
|
|
86
106
|
/**
|
|
87
107
|
* Record plan-limit status from a decoded JSON response body.
|
|
@@ -90,11 +110,11 @@ function parsePlanLimits(body) {
|
|
|
90
110
|
*/
|
|
91
111
|
export function recordPlanLimitStatus(body) {
|
|
92
112
|
try {
|
|
93
|
-
const
|
|
94
|
-
if (
|
|
113
|
+
const status = parsePlanLimits(body);
|
|
114
|
+
if (status === null)
|
|
95
115
|
return;
|
|
96
|
-
const anyOver = Object.values(limits).some((e) => e.over);
|
|
97
|
-
lastSeen = {
|
|
116
|
+
const anyOver = Object.values(status.limits).some((e) => e.over);
|
|
117
|
+
lastSeen = { ...status, anyOver };
|
|
98
118
|
}
|
|
99
119
|
catch {
|
|
100
120
|
// Never throw from record path.
|
|
@@ -150,9 +170,9 @@ function writeShownAt(statePath, shownAt) {
|
|
|
150
170
|
function withinDayWindow(shownAt, nowMs) {
|
|
151
171
|
return nowMs - shownAt < DAY_MS;
|
|
152
172
|
}
|
|
153
|
-
function buildOverBox(overEntries) {
|
|
173
|
+
function buildOverBox(overEntries, upgradeUrl) {
|
|
154
174
|
const title = "⚠ HQ plan limit exceeded";
|
|
155
|
-
const upgrade = `Upgrade: ${
|
|
175
|
+
const upgrade = `Upgrade: ${upgradeUrl}`;
|
|
156
176
|
const resourceLines = overEntries.map(([key, entry]) => ` ${key}: ${entry.used}/${entry.limit}`);
|
|
157
177
|
const contentLines = [title, "", ...resourceLines, "", upgrade];
|
|
158
178
|
const innerWidth = Math.max(...contentLines.map((l) => l.length), 40);
|
|
@@ -178,7 +198,8 @@ export function emitPlanLimitNag(opts = {}) {
|
|
|
178
198
|
const write = opts.write ?? ((s) => process.stderr.write(s));
|
|
179
199
|
const now = opts.now ?? (() => new Date());
|
|
180
200
|
const statePath = opts.statePath ?? defaultStatePath();
|
|
181
|
-
const { limits, anyOver } = lastSeen;
|
|
201
|
+
const { limits, anyOver, upgradeUrl } = lastSeen;
|
|
202
|
+
const resolvedUpgradeUrl = upgradeUrl ?? PLAN_LIMIT_UPGRADE_URL;
|
|
182
203
|
const entries = Object.entries(limits);
|
|
183
204
|
if (entries.length === 0)
|
|
184
205
|
return;
|
|
@@ -191,7 +212,7 @@ export function emitPlanLimitNag(opts = {}) {
|
|
|
191
212
|
return;
|
|
192
213
|
overShownThisSession = true;
|
|
193
214
|
const overEntries = entries.filter(([, e]) => e.over);
|
|
194
|
-
const box = buildOverBox(overEntries);
|
|
215
|
+
const box = buildOverBox(overEntries, resolvedUpgradeUrl);
|
|
195
216
|
write(chalk.yellow(box) + "\n");
|
|
196
217
|
writeShownAt(statePath, nowMs);
|
|
197
218
|
return;
|
|
@@ -203,7 +224,7 @@ export function emitPlanLimitNag(opts = {}) {
|
|
|
203
224
|
if (worst === null)
|
|
204
225
|
return;
|
|
205
226
|
warningShownThisSession = true;
|
|
206
|
-
const line = `⚠ HQ free plan: ${formatEntryLine(worst.key, worst.entry)}. Upgrade: ${
|
|
227
|
+
const line = `⚠ HQ free plan: ${formatEntryLine(worst.key, worst.entry)}. Upgrade: ${resolvedUpgradeUrl}`;
|
|
207
228
|
write(chalk.yellow(line) + "\n");
|
|
208
229
|
}
|
|
209
230
|
catch {
|
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;
|