@indigoai-us/hq-cli 5.50.2 → 5.52.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 (43) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/bin/hq-auth-refresh.d.ts +1 -1
  3. package/dist/bin/hq-auth-refresh.js +5 -2
  4. package/dist/commands/files.js +33 -3
  5. package/dist/commands/members.d.ts +27 -0
  6. package/dist/commands/members.js +95 -37
  7. package/dist/commands/people.d.ts +26 -1
  8. package/dist/commands/people.js +70 -7
  9. package/dist/commands/secrets-scope.d.ts +20 -0
  10. package/dist/commands/secrets-scope.js +19 -0
  11. package/dist/commands/secrets.js +21 -6
  12. package/dist/index.d.ts +1 -1
  13. package/dist/index.js +44 -14
  14. package/dist/node-preflight.d.ts +39 -0
  15. package/dist/node-preflight.js +55 -0
  16. package/dist/sentry.d.ts +12 -0
  17. package/dist/sentry.js +19 -3
  18. package/dist/utils/epipe.d.ts +8 -0
  19. package/dist/utils/epipe.js +30 -0
  20. package/dist/utils/intercepted-process-exit.d.ts +7 -0
  21. package/dist/utils/intercepted-process-exit.js +38 -0
  22. package/e2e/cli.test.ts +35 -0
  23. package/package.json +1 -1
  24. package/src/bin/hq-auth-refresh.ts +3 -0
  25. package/src/commands/files.test.ts +130 -0
  26. package/src/commands/files.ts +55 -3
  27. package/src/commands/members.test.ts +292 -0
  28. package/src/commands/members.ts +153 -43
  29. package/src/commands/people.test.ts +212 -5
  30. package/src/commands/people.ts +141 -5
  31. package/src/commands/secrets-scope.test.ts +56 -0
  32. package/src/commands/secrets-scope.ts +32 -0
  33. package/src/commands/secrets.ts +24 -10
  34. package/src/index.ts +40 -12
  35. package/src/node-preflight.test.ts +60 -0
  36. package/src/node-preflight.ts +67 -0
  37. package/src/sentry-epipe.test.ts +37 -0
  38. package/src/sentry-release.test.ts +54 -0
  39. package/src/sentry.ts +21 -1
  40. package/src/utils/epipe.test.ts +28 -0
  41. package/src/utils/epipe.ts +29 -0
  42. package/src/utils/intercepted-process-exit.test.ts +37 -0
  43. package/src/utils/intercepted-process-exit.ts +36 -0
package/dist/index.d.ts CHANGED
@@ -2,5 +2,5 @@
2
2
  /**
3
3
  * HQ CLI - Module management, package management, and cloud sync for HQ
4
4
  */
5
- export {};
5
+ import "./node-preflight.js";
6
6
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -2,8 +2,11 @@
2
2
  /**
3
3
  * HQ CLI - Module management, package management, and cloud sync for HQ
4
4
  */
5
+ // MUST be first: guard the Node version before any dependency that needs a
6
+ // Node 20+ API (e.g. util.styleText) or a newer native ABI is evaluated.
5
7
 
6
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="34e9e978-4c0a-5113-82af-9a33e30686c6")}catch(e){}}();
8
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="82c44c14-a953-5644-9bf2-dd2c6d487d63")}catch(e){}}();
9
+ import "./node-preflight.js";
7
10
  import { Command } from "commander";
8
11
  import { initSentry, Sentry } from "./sentry.js";
9
12
  import { registerAddCommand } from "./commands/add.js";
@@ -47,12 +50,18 @@ import { registerRescueCommand } from "./commands/rescue.js";
47
50
  import { registerMcpCommand } from "./commands/mcp-status.js";
48
51
  import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
49
52
  import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
53
+ import { isEpipe } from "./utils/epipe.js";
54
+ import { isInterceptedProcessExit } from "./utils/intercepted-process-exit.js";
50
55
  import { maybeWarnNewVersion, refreshVersionCache, } from "./utils/version-check.js";
51
56
  import { enforceVersionGate, shouldSkipGate, } from "./utils/version-gate.js";
52
57
  import { CLI_VERSION } from "./cli-version.js";
53
- // Swallow EPIPE when a downstream reader (e.g. `source <(…)`, `| head`) closes the pipe early.
58
+ // Swallow EPIPE when a downstream reader (e.g. `source <(…)`, `| head`) closes
59
+ // the pipe early. This covers the ASYNC path — an 'error' event emitted on the
60
+ // stream. The SYNCHRONOUS path (a `write EPIPE` thrown straight out of
61
+ // console.log inside a command) is handled in the top-level catch below; both
62
+ // share `isEpipe` (HQ-6B).
54
63
  const onPipeError = (err) => {
55
- if (err.code === "EPIPE") {
64
+ if (isEpipe(err)) {
56
65
  process.exit(0);
57
66
  }
58
67
  throw err;
@@ -182,19 +191,40 @@ registerMcpCommand(program);
182
191
  await program.parseAsync();
183
192
  }
184
193
  catch (err) {
185
- // A full disk / exhausted quota / read-only filesystem is the user's
186
- // machine, not an HQ code defect. Surface a clear, actionable message and
187
- // skip Sentry capture so one full disk doesn't flood the tracker with
188
- // identical, unfixable crash reports (HQ-CLI-2). Genuine errors still go
189
- // to Sentry and still exit 1.
190
- const envMsg = environmentalFsErrorMessage(err);
191
- if (envMsg) {
192
- process.stderr.write(`hq: ${envMsg}\n`);
194
+ // A broken pipe (EPIPE) means the reader of `hq`'s output closed it early
195
+ // (`hq | head`, `source <(hq …)`, a parent that exited). That is normal
196
+ // Unix behavior with no user-facing degradation exit cleanly (0) and
197
+ // skip Sentry capture instead of shipping a fatal (HQ-6B). A synchronous
198
+ // `write EPIPE` thrown out of console.log lands here rather than on the
199
+ // stream 'error' listener above.
200
+ if (isEpipe(err)) {
201
+ process.exitCode = 0;
202
+ }
203
+ else if (isInterceptedProcessExit(err)) {
204
+ // A security/audit FUZZ harness replaced `process.exit` with a throw so it
205
+ // can keep exercising the binary. Commander calling `process.exit` for
206
+ // normal CLI control flow (e.g. an unknown command → exit 1) then surfaces
207
+ // here as that synthetic marker. It is a test-harness artifact, NOT an
208
+ // hq-cli defect — a real user's `process.exit` just exits, so nothing is
209
+ // thrown or captured. Skip Sentry capture (no signal, no user-facing
210
+ // degradation) and preserve the intended non-zero exit (HQ-CLI-3).
211
+ process.exitCode = 1;
193
212
  }
194
213
  else {
195
- Sentry.captureException(err);
214
+ // A full disk / exhausted quota / read-only filesystem is the user's
215
+ // machine, not an HQ code defect. Surface a clear, actionable message and
216
+ // skip Sentry capture so one full disk doesn't flood the tracker with
217
+ // identical, unfixable crash reports (HQ-CLI-2). Genuine errors still go
218
+ // to Sentry and still exit 1.
219
+ const envMsg = environmentalFsErrorMessage(err);
220
+ if (envMsg) {
221
+ process.stderr.write(`hq: ${envMsg}\n`);
222
+ }
223
+ else {
224
+ Sentry.captureException(err);
225
+ }
226
+ process.exitCode = 1;
196
227
  }
197
- process.exitCode = 1;
198
228
  }
199
229
  finally {
200
230
  // Release health: finalize the per-run session before the flush.
@@ -203,4 +233,4 @@ registerMcpCommand(program);
203
233
  }
204
234
  })();
205
235
  //# sourceMappingURL=index.js.map
206
- //# debugId=34e9e978-4c0a-5113-82af-9a33e30686c6
236
+ //# debugId=82c44c14-a953-5644-9bf2-dd2c6d487d63
@@ -0,0 +1,39 @@
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
+ export declare const MIN_NODE_MAJOR = 20;
21
+ export interface NodeVersionCheck {
22
+ ok: boolean;
23
+ major: number;
24
+ message?: string;
25
+ }
26
+ /**
27
+ * Pure check: is the given Node version string (e.g. "18.19.0") supported?
28
+ * Defaults to the running runtime's version. An unparseable version is treated
29
+ * as supported so we never block a user on a version string we can't read.
30
+ */
31
+ export declare function checkNodeVersion(versionString?: string): NodeVersionCheck;
32
+ /**
33
+ * Side-effecting guard run on import: prints the upgrade message to stderr and
34
+ * exits 1 on an unsupported runtime. A no-op on Node 20+. Set
35
+ * `HQ_SKIP_NODE_PREFLIGHT=1` to bypass (used by the test runner, which already
36
+ * runs on a supported Node).
37
+ */
38
+ export declare function enforceNodeVersion(): void;
39
+ //# sourceMappingURL=node-preflight.d.ts.map
@@ -0,0 +1,55 @@
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
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="7d63229d-ff96-5511-9601-53c556487763")}catch(e){}}();
22
+ export const MIN_NODE_MAJOR = 20;
23
+ /**
24
+ * Pure check: is the given Node version string (e.g. "18.19.0") supported?
25
+ * Defaults to the running runtime's version. An unparseable version is treated
26
+ * as supported so we never block a user on a version string we can't read.
27
+ */
28
+ export function checkNodeVersion(versionString = process.versions.node) {
29
+ const major = Number.parseInt(String(versionString).split(".")[0] ?? "", 10);
30
+ if (!Number.isFinite(major) || major >= MIN_NODE_MAJOR) {
31
+ return { ok: true, major };
32
+ }
33
+ const message = `hq requires Node.js ${MIN_NODE_MAJOR} or newer — you are running Node ${versionString}.\n` +
34
+ `Older versions fail with native-module ABI mismatches and missing APIs.\n` +
35
+ `Please upgrade to Node ${MIN_NODE_MAJOR}+ (https://nodejs.org/) and run hq again.`;
36
+ return { ok: false, major, message };
37
+ }
38
+ /**
39
+ * Side-effecting guard run on import: prints the upgrade message to stderr and
40
+ * exits 1 on an unsupported runtime. A no-op on Node 20+. Set
41
+ * `HQ_SKIP_NODE_PREFLIGHT=1` to bypass (used by the test runner, which already
42
+ * runs on a supported Node).
43
+ */
44
+ export function enforceNodeVersion() {
45
+ if (process.env.HQ_SKIP_NODE_PREFLIGHT)
46
+ return;
47
+ const result = checkNodeVersion();
48
+ if (!result.ok && result.message) {
49
+ process.stderr.write(`${result.message}\n`);
50
+ process.exit(1);
51
+ }
52
+ }
53
+ enforceNodeVersion();
54
+ //# sourceMappingURL=node-preflight.js.map
55
+ //# debugId=7d63229d-ff96-5511-9601-53c556487763
package/dist/sentry.d.ts CHANGED
@@ -1,4 +1,16 @@
1
1
  import * as Sentry from "@sentry/node";
2
+ import type { ErrorEvent, EventHint } from "@sentry/node";
3
+ /**
4
+ * Drop broken-pipe (EPIPE) crashes before scrubbing/send. A closed downstream
5
+ * reader (`hq … | head`, `source <(hq …)`, a parent that exited) is normal
6
+ * Unix behavior with no user-facing degradation — never a defect to report.
7
+ * This is the path-independent catch-all for HQ-6B: whichever way an EPIPE
8
+ * surfaces (a synchronous throw into the command catch, an async stream
9
+ * 'error', or a write outside the top-level try), it never ships a fatal. The
10
+ * clean-exit handlers in index.ts still keep exit code 0 for the common paths.
11
+ * Consistent with the swallow-EPIPE posture established in #138.
12
+ */
13
+ export declare function epipeAwareBeforeSend(event: ErrorEvent, hint: EventHint): ErrorEvent | null;
2
14
  export declare function initSentry(): void;
3
15
  export { Sentry };
4
16
  //# sourceMappingURL=sentry.d.ts.map
package/dist/sentry.js CHANGED
@@ -1,11 +1,27 @@
1
1
 
2
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="a260ea42-f6d8-52d0-90c6-6e586de5efd6")}catch(e){}}();
2
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="a2ba6d5a-af8a-5512-9ce8-ced925ad5505")}catch(e){}}();
3
3
  import * as Sentry from "@sentry/node";
4
4
  import { BUNDLED_DSN } from "./sentry-dsn.generated.js";
5
5
  import { beforeSend } from "./sentry-before-send.js";
6
6
  import { beforeBreadcrumb } from "./utils/breadcrumb-buffer.js";
7
7
  import { CLI_VERSION } from "./cli-version.js";
8
8
  import { getCachedSentryUser } from "./utils/sentry-identity.js";
9
+ import { isEpipe } from "./utils/epipe.js";
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(event, hint) {
21
+ if (isEpipe(hint?.originalException))
22
+ return null;
23
+ return beforeSend(event, hint);
24
+ }
9
25
  export function initSentry() {
10
26
  const dsn = BUNDLED_DSN || process.env.SENTRY_DSN;
11
27
  if (!dsn)
@@ -20,7 +36,7 @@ export function initSentry() {
20
36
  initialScope: {
21
37
  tags: { repo: "hq-cli" },
22
38
  },
23
- beforeSend,
39
+ beforeSend: epipeAwareBeforeSend,
24
40
  beforeBreadcrumb,
25
41
  });
26
42
  // Attribute events to the logged-in HQ identity (best-effort; null when not
@@ -35,4 +51,4 @@ export function initSentry() {
35
51
  }
36
52
  export { Sentry };
37
53
  //# sourceMappingURL=sentry.js.map
38
- //# debugId=a260ea42-f6d8-52d0-90c6-6e586de5efd6
54
+ //# debugId=a2ba6d5a-af8a-5512-9ce8-ced925ad5505
@@ -0,0 +1,8 @@
1
+ /**
2
+ * True when `err` is a Node EPIPE error — the pipe `hq` was writing to was
3
+ * closed by its reader. Callers should treat this as a clean, expected exit
4
+ * (code 0) and SKIP Sentry capture: there is no defect and no user-facing
5
+ * degradation to fix.
6
+ */
7
+ export declare function isEpipe(err: unknown): boolean;
8
+ //# sourceMappingURL=epipe.d.ts.map
@@ -0,0 +1,30 @@
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
+ * True when `err` is a Node EPIPE error — the pipe `hq` was writing to was
18
+ * closed by its reader. Callers should treat this as a clean, expected exit
19
+ * (code 0) and SKIP Sentry capture: there is no defect and no user-facing
20
+ * degradation to fix.
21
+ */
22
+
23
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="3305e402-0237-55ac-a6d4-11143bc69b83")}catch(e){}}();
24
+ export function isEpipe(err) {
25
+ return (typeof err === "object" &&
26
+ err !== null &&
27
+ err.code === "EPIPE");
28
+ }
29
+ //# sourceMappingURL=epipe.js.map
30
+ //# debugId=3305e402-0237-55ac-a6d4-11143bc69b83
@@ -0,0 +1,7 @@
1
+ /**
2
+ * True when `err` is a fuzz/audit harness's intercepted-`process.exit` marker
3
+ * rather than a genuine fault. Callers should SKIP Sentry capture (no defect, no
4
+ * user-facing degradation) while preserving the intended exit code.
5
+ */
6
+ export declare function isInterceptedProcessExit(err: unknown): boolean;
7
+ //# sourceMappingURL=intercepted-process-exit.d.ts.map
@@ -0,0 +1,38 @@
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
+ /** The marker a fuzz/audit harness throws in place of a real `process.exit`. */
20
+
21
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="55c7e3b5-a8bc-50fd-835b-127a19172aa1")}catch(e){}}();
22
+ const INTERCEPTED_PROCESS_EXIT_MARKER = "fuzz-intercepted-process-exit";
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) {
29
+ if (typeof err === "object" && err !== null) {
30
+ const message = err.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
+ }
37
+ //# sourceMappingURL=intercepted-process-exit.js.map
38
+ //# debugId=55c7e3b5-a8bc-50fd-835b-127a19172aa1
package/e2e/cli.test.ts CHANGED
@@ -41,6 +41,31 @@ function runHq(args: string[], options: { cwd?: string; env?: NodeJS.ProcessEnv
41
41
  });
42
42
  }
43
43
 
44
+ // Spawn the built CLI with its stdout read-end closed immediately, so the
45
+ // child's first write to stdout hits EPIPE — the `hq … | head` / `source <(hq
46
+ // …)` scenario behind HQ-6B. Returns the child's own exit code + stderr.
47
+ function runHqWithClosedStdout(args: string[]) {
48
+ return new Promise<{ code: number | null; stderr: string }>(
49
+ (resolve, reject) => {
50
+ const child = spawn(process.execPath, [cliEntry, ...args], {
51
+ env: { ...process.env, HQ_NO_UPDATE_CHECK: "1" },
52
+ stdio: ["ignore", "pipe", "pipe"],
53
+ });
54
+ let stderr = "";
55
+ child.stderr.setEncoding("utf8");
56
+ child.stderr.on("data", (chunk) => {
57
+ stderr += chunk;
58
+ });
59
+ // Close the read end up front (and again on any byte that slips through)
60
+ // so subsequent writes by the child fail with EPIPE.
61
+ child.stdout.on("data", () => child.stdout.destroy());
62
+ child.stdout.destroy();
63
+ child.on("error", reject);
64
+ child.on("close", (code) => resolve({ code, stderr }));
65
+ },
66
+ );
67
+ }
68
+
44
69
  async function makeTempDir(prefix: string) {
45
70
  const dir = await mkdtemp(path.join(tmpdir(), prefix));
46
71
  tempDirs.push(dir);
@@ -62,6 +87,16 @@ describe("built hq CLI", () => {
62
87
  expect(result.stdout).toContain("whoami");
63
88
  });
64
89
 
90
+ it("exits cleanly when the downstream reader closes the pipe (HQ-6B)", async () => {
91
+ // Regression for HQ-6B: a closed stdout reader must NOT crash the CLI with
92
+ // a fatal `write EPIPE`. The process should exit 0 with no EPIPE traceback.
93
+ const { code, stderr } = await runHqWithClosedStdout(["--help"]);
94
+
95
+ expect(code).toBe(0);
96
+ expect(stderr).not.toMatch(/EPIPE/);
97
+ expect(stderr).not.toMatch(/Error:/);
98
+ });
99
+
65
100
  it("prints the package version from the built entrypoint", async () => {
66
101
  const result = await runHq(["--version"]);
67
102
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.50.2",
3
+ "version": "5.52.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -12,6 +12,9 @@
12
12
  * non-interactively.
13
13
  */
14
14
 
15
+ // MUST be first: guard the Node version before any dependency that needs a
16
+ // Node 20+ API (e.g. util.styleText) or a newer native ABI is evaluated.
17
+ import "../node-preflight.js";
15
18
  import { initSentry, Sentry } from "../sentry.js";
16
19
  import { refreshCachedSession } from "../utils/cognito-session.js";
17
20
 
@@ -501,6 +501,136 @@ describe("hq files share — direct-grant fork (with --with)", () => {
501
501
  expect(errs).toMatch(/--permission is required/);
502
502
  expect(fetchSpy).not.toHaveBeenCalled();
503
503
  });
504
+
505
+ it("--full --with email grants the '*' wildcard at the default write permission (no glob to quote)", async () => {
506
+ // 1) /membership/me for company resolution
507
+ fetchSpy.mockResolvedValueOnce(
508
+ jsonResponse(200, {
509
+ memberships: [
510
+ {
511
+ membershipKey: "k1",
512
+ companyUid: "cmp_acme",
513
+ role: "member",
514
+ status: "active",
515
+ },
516
+ ],
517
+ }),
518
+ );
519
+ // 2) POST /files/cmp_acme/acl/grant
520
+ fetchSpy.mockResolvedValueOnce(
521
+ jsonResponse(200, { acl: { path: "*" } }),
522
+ );
523
+
524
+ const program = buildProgram();
525
+ // No positional path, no --permission — --full supplies prefix '*' and
526
+ // defaults permission to write.
527
+ await program.parseAsync(
528
+ ["files", "share", "--full", "--with", "user@example.com"],
529
+ { from: "user" },
530
+ );
531
+
532
+ const mintCalls = fetchSpy.mock.calls.filter((c) =>
533
+ String(c[0]).includes("/share-session"),
534
+ );
535
+ expect(mintCalls).toHaveLength(0);
536
+
537
+ const grantCall = fetchSpy.mock.calls.find((c) =>
538
+ String(c[0]).includes("/acl/grant"),
539
+ );
540
+ expect(grantCall).toBeDefined();
541
+ const body = JSON.parse((grantCall![1]?.body as string) ?? "{}");
542
+ expect(body).toEqual({
543
+ prefix: "*",
544
+ granteeType: "email",
545
+ granteeId: "user@example.com",
546
+ permission: "write",
547
+ });
548
+
549
+ const printed = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
550
+ expect(printed).toMatch(/ENTIRE vault/);
551
+ expect(open).not.toHaveBeenCalled();
552
+ });
553
+
554
+ it("--full honors an explicit --permission read (read-only full vault)", async () => {
555
+ fetchSpy.mockResolvedValueOnce(
556
+ jsonResponse(200, {
557
+ memberships: [
558
+ { membershipKey: "k1", companyUid: "cmp_acme", role: "member", status: "active" },
559
+ ],
560
+ }),
561
+ );
562
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, { acl: { path: "*" } }));
563
+
564
+ const program = buildProgram();
565
+ await program.parseAsync(
566
+ ["files", "share", "--full", "--with", "user@example.com", "--permission", "read"],
567
+ { from: "user" },
568
+ );
569
+
570
+ const grantCall = fetchSpy.mock.calls.find((c) =>
571
+ String(c[0]).includes("/acl/grant"),
572
+ );
573
+ const body = JSON.parse((grantCall![1]?.body as string) ?? "{}");
574
+ expect(body).toEqual({
575
+ prefix: "*",
576
+ granteeType: "email",
577
+ granteeId: "user@example.com",
578
+ permission: "read",
579
+ });
580
+ });
581
+
582
+ it("--full composes with @all (whole vault for the whole company → company-wide '*')", async () => {
583
+ fetchSpy.mockResolvedValueOnce(
584
+ jsonResponse(200, {
585
+ memberships: [
586
+ { membershipKey: "k1", companyUid: "cmp_acme", role: "member", status: "active" },
587
+ ],
588
+ }),
589
+ );
590
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, { acl: { path: "*" } }));
591
+
592
+ const program = buildProgram();
593
+ await program.parseAsync(
594
+ ["files", "share", "--full", "--with", "@all"],
595
+ { from: "user" },
596
+ );
597
+
598
+ const grantCall = fetchSpy.mock.calls.find((c) =>
599
+ String(c[0]).includes("/acl/grant"),
600
+ );
601
+ const body = JSON.parse((grantCall![1]?.body as string) ?? "{}");
602
+ expect(body).toEqual({
603
+ prefix: "*",
604
+ granteeType: "company-wide",
605
+ granteeId: "",
606
+ permission: "write",
607
+ });
608
+ });
609
+
610
+ it("rejects --full without --with (and never calls the network)", async () => {
611
+ const program = buildProgram();
612
+ await expect(
613
+ program.parseAsync(["files", "share", "--full"], { from: "user" }),
614
+ ).rejects.toThrow(/__EXIT__:1/);
615
+
616
+ const errs = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
617
+ expect(errs).toMatch(/--full.*requires --with/);
618
+ expect(fetchSpy).not.toHaveBeenCalled();
619
+ });
620
+
621
+ it("rejects --full when file paths are also passed", async () => {
622
+ const program = buildProgram();
623
+ await expect(
624
+ program.parseAsync(
625
+ ["files", "share", "somePath", "--full", "--with", "user@example.com"],
626
+ { from: "user" },
627
+ ),
628
+ ).rejects.toThrow(/__EXIT__:1/);
629
+
630
+ const errs = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
631
+ expect(errs).toMatch(/entire vault; do not also pass file paths/);
632
+ expect(fetchSpy).not.toHaveBeenCalled();
633
+ });
504
634
  });
505
635
 
506
636
  // ---------------------------------------------------------------------------
@@ -135,6 +135,10 @@ export function registerFilesCommand(program: Command): Command {
135
135
  "Email address, group id, or '@all' to share with every active company member",
136
136
  )
137
137
  .option("--permission <level>", "Permission level (only with --with): read | write")
138
+ .option(
139
+ "--full",
140
+ "Grant access to the ENTIRE vault (the '*' wildcard prefix) — no need to quote a glob. Requires --with; defaults to write permission.",
141
+ )
138
142
  .option(
139
143
  "--expires <duration>",
140
144
  "Token expiry duration for share-session URL (e.g. 15m, 1h, 24h). Default 15m. Max 24h.",
@@ -146,11 +150,45 @@ export function registerFilesCommand(program: Command): Command {
146
150
  opts: {
147
151
  with?: string;
148
152
  permission?: string;
153
+ full?: boolean;
149
154
  expires?: string;
150
155
  open: boolean;
151
156
  },
152
157
  ) => {
153
158
  try {
159
+ // Full-vault grant: a glob-safe affordance for "give this person the
160
+ // whole vault" so admins never have to quote a `*` (an unquoted glob
161
+ // expands to local filenames and instantly fails the one-prefix
162
+ // check). Maps to the single `*` wildcard grant, which the server
163
+ // coalesces to one policy entry — sidestepping the per-prefix STS
164
+ // session-policy budget. Defaults to write permission.
165
+ if (opts.full) {
166
+ if (opts.with === undefined) {
167
+ console.error(
168
+ chalk.red(
169
+ "--full grants whole-vault access to a principal and requires --with <principal>.",
170
+ ),
171
+ );
172
+ process.exit(1);
173
+ }
174
+ if (paths && paths.length > 0) {
175
+ console.error(
176
+ chalk.red(
177
+ "--full grants the entire vault; do not also pass file paths.",
178
+ ),
179
+ );
180
+ process.exit(1);
181
+ }
182
+ await runDirectGrant({
183
+ prefix: "*",
184
+ principal: opts.with,
185
+ permission: opts.permission ?? "write",
186
+ companySlug: files.opts().company as string | undefined,
187
+ fullVault: true,
188
+ });
189
+ return;
190
+ }
191
+
154
192
  if (!paths || paths.length === 0) {
155
193
  console.error(
156
194
  chalk.red("usage: hq files share <paths...> [--with <principal>]"),
@@ -472,6 +510,12 @@ interface DirectGrantParams {
472
510
  principal: string;
473
511
  permission: string | undefined;
474
512
  companySlug: string | undefined;
513
+ /**
514
+ * Set by the `--full` affordance: the grant targets the whole vault (the
515
+ * `*` wildcard prefix). Only affects the success message wording — the
516
+ * request shape is identical to any other prefix grant.
517
+ */
518
+ fullVault?: boolean;
475
519
  }
476
520
 
477
521
  async function runDirectGrant(params: DirectGrantParams): Promise<void> {
@@ -572,9 +616,17 @@ async function runDirectGrant(params: DirectGrantParams): Promise<void> {
572
616
  };
573
617
  const printedPrefix = data.acl?.path ?? data.acl?.prefix ?? canonicalPrefix;
574
618
  const verb = autoCreated ? "Created ACL and granted" : "Granted";
575
- console.log(
576
- chalk.green(`${verb} ${params.permission} on ${printedPrefix} to ${principalLabel}`),
577
- );
619
+ if (params.fullVault) {
620
+ console.log(
621
+ chalk.green(
622
+ `${verb} ${params.permission} on the ENTIRE vault to ${principalLabel}`,
623
+ ),
624
+ );
625
+ } else {
626
+ console.log(
627
+ chalk.green(`${verb} ${params.permission} on ${printedPrefix} to ${principalLabel}`),
628
+ );
629
+ }
578
630
  }
579
631
 
580
632
  // ---------------------------------------------------------------------------