@indigoai-us/hq-cli 5.50.2 → 5.51.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.
Files changed (39) hide show
  1. package/dist/bin/hq-auth-refresh.d.ts +1 -1
  2. package/dist/bin/hq-auth-refresh.js +5 -2
  3. package/dist/commands/members.d.ts +17 -0
  4. package/dist/commands/members.js +65 -28
  5. package/dist/commands/people.d.ts +26 -1
  6. package/dist/commands/people.js +70 -7
  7. package/dist/commands/secrets-scope.d.ts +20 -0
  8. package/dist/commands/secrets-scope.js +19 -0
  9. package/dist/commands/secrets.js +21 -6
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.js +44 -14
  12. package/dist/node-preflight.d.ts +39 -0
  13. package/dist/node-preflight.js +55 -0
  14. package/dist/sentry.d.ts +12 -0
  15. package/dist/sentry.js +19 -3
  16. package/dist/utils/epipe.d.ts +8 -0
  17. package/dist/utils/epipe.js +30 -0
  18. package/dist/utils/intercepted-process-exit.d.ts +7 -0
  19. package/dist/utils/intercepted-process-exit.js +38 -0
  20. package/e2e/cli.test.ts +35 -0
  21. package/package.json +1 -1
  22. package/src/bin/hq-auth-refresh.ts +3 -0
  23. package/src/commands/members.test.ts +176 -0
  24. package/src/commands/members.ts +113 -28
  25. package/src/commands/people.test.ts +212 -5
  26. package/src/commands/people.ts +141 -5
  27. package/src/commands/secrets-scope.test.ts +56 -0
  28. package/src/commands/secrets-scope.ts +32 -0
  29. package/src/commands/secrets.ts +24 -10
  30. package/src/index.ts +40 -12
  31. package/src/node-preflight.test.ts +60 -0
  32. package/src/node-preflight.ts +67 -0
  33. package/src/sentry-epipe.test.ts +37 -0
  34. package/src/sentry-release.test.ts +54 -0
  35. package/src/sentry.ts +21 -1
  36. package/src/utils/epipe.test.ts +28 -0
  37. package/src/utils/epipe.ts +29 -0
  38. package/src/utils/intercepted-process-exit.test.ts +37 -0
  39. package/src/utils/intercepted-process-exit.ts +36 -0
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Unit tests for the runtime Node version guard.
3
+ *
4
+ * The guard fails the CLI fast on Node < 20 (where prebuilt native modules hit
5
+ * an ABI mismatch and `util.styleText` is missing) with an actionable upgrade
6
+ * message, and is a no-op on Node 20+.
7
+ */
8
+
9
+ import { describe, expect, it } from "vitest";
10
+
11
+ import { MIN_NODE_MAJOR, checkNodeVersion } from "./node-preflight.js";
12
+
13
+ describe("checkNodeVersion", () => {
14
+ it("rejects Node 18 with an actionable upgrade message", () => {
15
+ const result = checkNodeVersion("18.19.0");
16
+
17
+ expect(result.ok).toBe(false);
18
+ expect(result.major).toBe(18);
19
+ expect(result.message).toContain(`Node.js ${MIN_NODE_MAJOR} or newer`);
20
+ expect(result.message).toContain("18.19.0");
21
+ expect(result.message).toMatch(/upgrade/i);
22
+ });
23
+
24
+ it("rejects every major below the minimum", () => {
25
+ for (const version of ["14.21.3", "16.20.2", "19.9.0"]) {
26
+ const result = checkNodeVersion(version);
27
+ expect(result.ok).toBe(false);
28
+ expect(result.message).toBeDefined();
29
+ }
30
+ });
31
+
32
+ it("accepts Node 20 (the minimum) without a message", () => {
33
+ const result = checkNodeVersion("20.11.1");
34
+
35
+ expect(result.ok).toBe(true);
36
+ expect(result.major).toBe(20);
37
+ expect(result.message).toBeUndefined();
38
+ });
39
+
40
+ it("accepts newer majors (22, 24)", () => {
41
+ for (const version of ["22.3.0", "24.0.0"]) {
42
+ const result = checkNodeVersion(version);
43
+ expect(result.ok).toBe(true);
44
+ expect(result.message).toBeUndefined();
45
+ }
46
+ });
47
+
48
+ it("treats an unparseable version as supported (never blocks on a bad string)", () => {
49
+ const result = checkNodeVersion("not-a-version");
50
+
51
+ expect(result.ok).toBe(true);
52
+ expect(result.message).toBeUndefined();
53
+ });
54
+
55
+ it("defaults to the running runtime, which is supported in CI", () => {
56
+ // The test runner itself must be on a supported Node, so the default-arg
57
+ // path returns ok — also proving importing this module did not exit.
58
+ expect(checkNodeVersion().ok).toBe(true);
59
+ });
60
+ });
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Runtime Node.js version guard for the hq CLI.
3
+ *
4
+ * HQ tooling requires Node.js 20 or newer. On older runtimes (notably Node 18)
5
+ * the CLI dies with cryptic failures long before reaching any of its own code:
6
+ * a native-module ABI mismatch from a prebuilt dependency, and a missing
7
+ * `util.styleText` (added in Node 20). Those errors give the user no hint that
8
+ * the real problem is just an old Node.
9
+ *
10
+ * This module exists to fail fast with an actionable message instead. It is
11
+ * imported FIRST by every CLI entry point (`index.ts`, `bin/hq-auth-refresh.ts`)
12
+ * so the check runs before commander, Sentry, or any dependency that needs a
13
+ * Node 20+ API or a newer native ABI is evaluated. ES modules evaluate their
14
+ * imports in source order, so as long as this is the first import in the entry
15
+ * module, the guard short-circuits an unsupported runtime cleanly.
16
+ *
17
+ * Keep this file dependency-free — it must not import anything that could itself
18
+ * fail to load on the very runtime it is trying to detect.
19
+ */
20
+
21
+ export const MIN_NODE_MAJOR = 20;
22
+
23
+ export interface NodeVersionCheck {
24
+ ok: boolean;
25
+ major: number;
26
+ message?: string;
27
+ }
28
+
29
+ /**
30
+ * Pure check: is the given Node version string (e.g. "18.19.0") supported?
31
+ * Defaults to the running runtime's version. An unparseable version is treated
32
+ * as supported so we never block a user on a version string we can't read.
33
+ */
34
+ export function checkNodeVersion(
35
+ versionString: string = process.versions.node,
36
+ ): NodeVersionCheck {
37
+ const major = Number.parseInt(String(versionString).split(".")[0] ?? "", 10);
38
+
39
+ if (!Number.isFinite(major) || major >= MIN_NODE_MAJOR) {
40
+ return { ok: true, major };
41
+ }
42
+
43
+ const message =
44
+ `hq requires Node.js ${MIN_NODE_MAJOR} or newer — you are running Node ${versionString}.\n` +
45
+ `Older versions fail with native-module ABI mismatches and missing APIs.\n` +
46
+ `Please upgrade to Node ${MIN_NODE_MAJOR}+ (https://nodejs.org/) and run hq again.`;
47
+
48
+ return { ok: false, major, message };
49
+ }
50
+
51
+ /**
52
+ * Side-effecting guard run on import: prints the upgrade message to stderr and
53
+ * exits 1 on an unsupported runtime. A no-op on Node 20+. Set
54
+ * `HQ_SKIP_NODE_PREFLIGHT=1` to bypass (used by the test runner, which already
55
+ * runs on a supported Node).
56
+ */
57
+ export function enforceNodeVersion(): void {
58
+ if (process.env.HQ_SKIP_NODE_PREFLIGHT) return;
59
+
60
+ const result = checkNodeVersion();
61
+ if (!result.ok && result.message) {
62
+ process.stderr.write(`${result.message}\n`);
63
+ process.exit(1);
64
+ }
65
+ }
66
+
67
+ enforceNodeVersion();
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { ErrorEvent, EventHint } from "@sentry/node";
3
+
4
+ // sentry.ts reads BUNDLED_DSN at import time; stub it so the module loads
5
+ // without a real DSN (mirrors sentry.test.ts).
6
+ vi.mock("./sentry-dsn.generated.js", () => ({ BUNDLED_DSN: "" }));
7
+
8
+ import { epipeAwareBeforeSend } from "./sentry.js";
9
+
10
+ describe("epipeAwareBeforeSend (HQ-6B)", () => {
11
+ it("drops an EPIPE crash before it can ship a fatal", () => {
12
+ const epipe = Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
13
+ const event = {
14
+ exception: { values: [{ type: "Error", value: "write EPIPE" }] },
15
+ } as ErrorEvent;
16
+ const out = epipeAwareBeforeSend(event, {
17
+ originalException: epipe,
18
+ } as EventHint);
19
+ expect(out).toBeNull();
20
+ });
21
+
22
+ it("forwards a genuine error to the scrubber (still reported)", () => {
23
+ const err = new Error("boom");
24
+ const event = { message: "boom" } as ErrorEvent;
25
+ const out = epipeAwareBeforeSend(event, {
26
+ originalException: err,
27
+ } as EventHint);
28
+ expect(out).not.toBeNull();
29
+ expect(out?.message).toBe("boom");
30
+ });
31
+
32
+ it("forwards when there is no originalException hint", () => {
33
+ const event = { message: "no hint" } as ErrorEvent;
34
+ const out = epipeAwareBeforeSend(event, {} as EventHint);
35
+ expect(out).not.toBeNull();
36
+ });
37
+ });
@@ -0,0 +1,54 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import path from "node:path";
5
+
6
+ // `@sentry/node`'s `init` is a non-configurable export, so it can't be spied
7
+ // in place — replace it via importOriginal spread (every OTHER Sentry export
8
+ // stays real, so initSentry's setUser/startSession remain harmless no-ops with
9
+ // no real SDK init). We only need to inspect the options handed to `init`.
10
+ const { initMock } = vi.hoisted(() => ({ initMock: vi.fn() }));
11
+ vi.mock("@sentry/node", async (importOriginal) => {
12
+ const actual = await importOriginal<typeof import("@sentry/node")>();
13
+ return { ...actual, init: initMock };
14
+ });
15
+ // A DSN must be present for initSentry to reach Sentry.init.
16
+ vi.mock("./sentry-dsn.generated.js", () => ({
17
+ BUNDLED_DSN: "https://examplePublicKey@o0.ingest.sentry.io/0",
18
+ }));
19
+
20
+ import { initSentry } from "./sentry.js";
21
+ import { CLI_VERSION } from "./cli-version.js";
22
+
23
+ const PKG_VERSION = (
24
+ JSON.parse(
25
+ readFileSync(
26
+ path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json"),
27
+ "utf-8",
28
+ ),
29
+ ) as { version: string }
30
+ ).version;
31
+
32
+ describe("initSentry — release tag carries the real CLI version (resolve-by-release)", () => {
33
+ afterEach(() => {
34
+ vi.clearAllMocks();
35
+ });
36
+
37
+ // Locks the resolve-by-release path for hq-cli: every event must be stamped
38
+ // with the REAL package version so Sentry's "resolved in next release" works
39
+ // and old-version stragglers (the legacy `hq-cli@0.0.0` events from pre-#8
40
+ // installs) sort as the oldest release and stay suppressed. Regression guard
41
+ // against ever reverting to the `npm_package_version` 0.0.0 pitfall.
42
+ it("stamps release = hq-cli@<package.json version>, never the 0.0.0 placeholder", () => {
43
+ initSentry();
44
+
45
+ expect(initMock).toHaveBeenCalledTimes(1);
46
+ const opts = initMock.mock.calls[0][0] as { release?: string };
47
+
48
+ // CLI_VERSION resolves from package.json at runtime (not npm_package_version).
49
+ expect(CLI_VERSION).toBe(PKG_VERSION);
50
+ expect(CLI_VERSION).not.toBe("0.0.0");
51
+ expect(opts.release).toBe(`hq-cli@${PKG_VERSION}`);
52
+ expect(opts.release).not.toBe("hq-cli@0.0.0");
53
+ });
54
+ });
package/src/sentry.ts CHANGED
@@ -1,9 +1,29 @@
1
1
  import * as Sentry from "@sentry/node";
2
+ import type { ErrorEvent, EventHint } from "@sentry/node";
2
3
  import { BUNDLED_DSN } from "./sentry-dsn.generated.js";
3
4
  import { beforeSend } from "./sentry-before-send.js";
4
5
  import { beforeBreadcrumb } from "./utils/breadcrumb-buffer.js";
5
6
  import { CLI_VERSION } from "./cli-version.js";
6
7
  import { getCachedSentryUser } from "./utils/sentry-identity.js";
8
+ import { isEpipe } from "./utils/epipe.js";
9
+
10
+ /**
11
+ * Drop broken-pipe (EPIPE) crashes before scrubbing/send. A closed downstream
12
+ * reader (`hq … | head`, `source <(hq …)`, a parent that exited) is normal
13
+ * Unix behavior with no user-facing degradation — never a defect to report.
14
+ * This is the path-independent catch-all for HQ-6B: whichever way an EPIPE
15
+ * surfaces (a synchronous throw into the command catch, an async stream
16
+ * 'error', or a write outside the top-level try), it never ships a fatal. The
17
+ * clean-exit handlers in index.ts still keep exit code 0 for the common paths.
18
+ * Consistent with the swallow-EPIPE posture established in #138.
19
+ */
20
+ export function epipeAwareBeforeSend(
21
+ event: ErrorEvent,
22
+ hint: EventHint,
23
+ ): ErrorEvent | null {
24
+ if (isEpipe(hint?.originalException)) return null;
25
+ return beforeSend(event, hint);
26
+ }
7
27
 
8
28
  export function initSentry(): void {
9
29
  const dsn = BUNDLED_DSN || process.env.SENTRY_DSN;
@@ -18,7 +38,7 @@ export function initSentry(): void {
18
38
  initialScope: {
19
39
  tags: { repo: "hq-cli" },
20
40
  },
21
- beforeSend,
41
+ beforeSend: epipeAwareBeforeSend,
22
42
  beforeBreadcrumb,
23
43
  });
24
44
  // Attribute events to the logged-in HQ identity (best-effort; null when not
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isEpipe } from "./epipe.js";
3
+
4
+ describe("isEpipe", () => {
5
+ it("matches a real Node EPIPE error", () => {
6
+ const err = Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
7
+ expect(isEpipe(err)).toBe(true);
8
+ });
9
+
10
+ it("matches a plain object carrying code EPIPE", () => {
11
+ expect(isEpipe({ code: "EPIPE" })).toBe(true);
12
+ });
13
+
14
+ it("does NOT match other errno codes (so real faults still report)", () => {
15
+ expect(isEpipe(Object.assign(new Error("disk full"), { code: "ENOSPC" }))).toBe(
16
+ false,
17
+ );
18
+ expect(isEpipe(Object.assign(new Error("nope"), { code: "EACCES" }))).toBe(false);
19
+ expect(isEpipe(new Error("plain error with no code"))).toBe(false);
20
+ });
21
+
22
+ it("does NOT match non-error values", () => {
23
+ expect(isEpipe(null)).toBe(false);
24
+ expect(isEpipe(undefined)).toBe(false);
25
+ expect(isEpipe("EPIPE")).toBe(false);
26
+ expect(isEpipe(42)).toBe(false);
27
+ });
28
+ });
@@ -0,0 +1,29 @@
1
+ // src/utils/epipe.ts
2
+ //
3
+ // Classify the "broken pipe" (EPIPE) error: a downstream reader closed the
4
+ // pipe before the CLI finished writing to stdout/stderr. This is normal,
5
+ // expected Unix behavior — `hq … | head`, `source <(hq …)`, or a parent
6
+ // process that exits while `hq` is still printing — NOT an HQ code defect.
7
+ //
8
+ // HQ-6B: a synchronous `write EPIPE` thrown from `console.log` (the Sentry
9
+ // console-instrumentation wraps it) propagated out of an awaited command into
10
+ // the CLI's top-level `catch`, which captured it via `Sentry.captureException`
11
+ // and shipped a fatal — even though the only thing that happened is the
12
+ // consumer of `hq`'s output went away. The async path (an emitted stream
13
+ // 'error' event) was already handled by the stdout/stderr listeners in
14
+ // index.ts; this predicate closes the synchronous-throw path too, mirroring
15
+ // the environmental-error carve-out (HQ-CLI-2).
16
+
17
+ /**
18
+ * True when `err` is a Node EPIPE error — the pipe `hq` was writing to was
19
+ * closed by its reader. Callers should treat this as a clean, expected exit
20
+ * (code 0) and SKIP Sentry capture: there is no defect and no user-facing
21
+ * degradation to fix.
22
+ */
23
+ export function isEpipe(err: unknown): boolean {
24
+ return (
25
+ typeof err === "object" &&
26
+ err !== null &&
27
+ (err as NodeJS.ErrnoException).code === "EPIPE"
28
+ );
29
+ }
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isInterceptedProcessExit } from "./intercepted-process-exit.js";
3
+
4
+ describe("isInterceptedProcessExit", () => {
5
+ it("matches the fuzz harness's intercepted-process.exit marker error", () => {
6
+ expect(isInterceptedProcessExit(new Error("fuzz-intercepted-process-exit"))).toBe(true);
7
+ });
8
+
9
+ it("matches when the marker is wrapped in a longer message", () => {
10
+ expect(
11
+ isInterceptedProcessExit(new Error("audit: fuzz-intercepted-process-exit code=1")),
12
+ ).toBe(true);
13
+ });
14
+
15
+ it("matches a plain object carrying the marker message", () => {
16
+ expect(isInterceptedProcessExit({ message: "fuzz-intercepted-process-exit" })).toBe(true);
17
+ });
18
+
19
+ it("matches a bare string carrying the marker", () => {
20
+ expect(isInterceptedProcessExit("fuzz-intercepted-process-exit")).toBe(true);
21
+ });
22
+
23
+ it("does NOT match genuine errors (so real faults still report)", () => {
24
+ expect(isInterceptedProcessExit(new Error("boom"))).toBe(false);
25
+ expect(isInterceptedProcessExit(new Error("ENOENT: no such file or directory"))).toBe(false);
26
+ expect(isInterceptedProcessExit(Object.assign(new Error("nope"), { code: "EACCES" }))).toBe(
27
+ false,
28
+ );
29
+ });
30
+
31
+ it("does NOT match non-error values", () => {
32
+ expect(isInterceptedProcessExit(null)).toBe(false);
33
+ expect(isInterceptedProcessExit(undefined)).toBe(false);
34
+ expect(isInterceptedProcessExit(42)).toBe(false);
35
+ expect(isInterceptedProcessExit({})).toBe(false);
36
+ });
37
+ });
@@ -0,0 +1,36 @@
1
+ // src/utils/intercepted-process-exit.ts
2
+ //
3
+ // Classify the "intercepted process.exit" synthetic error (HQ-CLI-3).
4
+ //
5
+ // Security/audit FUZZ harnesses (e.g. `/opt/audit/fuzz-exports.js`) monkey-patch
6
+ // `process.exit` to THROW a marker instead of actually terminating, so the
7
+ // fuzzer can keep exercising a binary's entrypoints. When such a harness drives
8
+ // `hq` (e.g. `hq <unknown-command>`), commander legitimately calls
9
+ // `process.exit(1)` — and the patched exit turns that normal CLI control-flow
10
+ // into a thrown `Error: fuzz-intercepted-process-exit` that propagates out of
11
+ // `program.parseAsync()` into the CLI's top-level catch, which then shipped it to
12
+ // Sentry as a fatal.
13
+ //
14
+ // This is a test-harness artifact, NOT an hq-cli defect: a real user's
15
+ // `process.exit` exits the process and nothing is ever thrown or captured.
16
+ // There is no user-facing degradation to fix, so the top-level catch skips
17
+ // Sentry capture for it — the genuine "expected, non-actionable" carve-out,
18
+ // mirroring the EPIPE (HQ-6B) and environmental-FS (HQ-CLI-2) carve-outs.
19
+
20
+ /** The marker a fuzz/audit harness throws in place of a real `process.exit`. */
21
+ const INTERCEPTED_PROCESS_EXIT_MARKER = "fuzz-intercepted-process-exit";
22
+
23
+ /**
24
+ * True when `err` is a fuzz/audit harness's intercepted-`process.exit` marker
25
+ * rather than a genuine fault. Callers should SKIP Sentry capture (no defect, no
26
+ * user-facing degradation) while preserving the intended exit code.
27
+ */
28
+ export function isInterceptedProcessExit(err: unknown): boolean {
29
+ if (typeof err === "object" && err !== null) {
30
+ const message = (err as { message?: unknown }).message;
31
+ if (typeof message === "string" && message.includes(INTERCEPTED_PROCESS_EXIT_MARKER)) {
32
+ return true;
33
+ }
34
+ }
35
+ return typeof err === "string" && err.includes(INTERCEPTED_PROCESS_EXIT_MARKER);
36
+ }