@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.
- package/CHANGELOG.md +23 -0
- package/dist/bin/hq-auth-refresh.d.ts +1 -1
- package/dist/bin/hq-auth-refresh.js +5 -2
- package/dist/commands/files.js +33 -3
- package/dist/commands/members.d.ts +27 -0
- package/dist/commands/members.js +95 -37
- package/dist/commands/people.d.ts +26 -1
- package/dist/commands/people.js +70 -7
- package/dist/commands/secrets-scope.d.ts +20 -0
- package/dist/commands/secrets-scope.js +19 -0
- package/dist/commands/secrets.js +21 -6
- package/dist/index.d.ts +1 -1
- package/dist/index.js +44 -14
- package/dist/node-preflight.d.ts +39 -0
- package/dist/node-preflight.js +55 -0
- package/dist/sentry.d.ts +12 -0
- package/dist/sentry.js +19 -3
- package/dist/utils/epipe.d.ts +8 -0
- package/dist/utils/epipe.js +30 -0
- package/dist/utils/intercepted-process-exit.d.ts +7 -0
- package/dist/utils/intercepted-process-exit.js +38 -0
- package/e2e/cli.test.ts +35 -0
- package/package.json +1 -1
- package/src/bin/hq-auth-refresh.ts +3 -0
- package/src/commands/files.test.ts +130 -0
- package/src/commands/files.ts +55 -3
- package/src/commands/members.test.ts +292 -0
- package/src/commands/members.ts +153 -43
- package/src/commands/people.test.ts +212 -5
- package/src/commands/people.ts +141 -5
- package/src/commands/secrets-scope.test.ts +56 -0
- package/src/commands/secrets-scope.ts +32 -0
- package/src/commands/secrets.ts +24 -10
- package/src/index.ts +40 -12
- package/src/node-preflight.test.ts +60 -0
- package/src/node-preflight.ts +67 -0
- package/src/sentry-epipe.test.ts +37 -0
- package/src/sentry-release.test.ts +54 -0
- package/src/sentry.ts +21 -1
- package/src/utils/epipe.test.ts +28 -0
- package/src/utils/epipe.ts +29 -0
- package/src/utils/intercepted-process-exit.test.ts +37 -0
- package/src/utils/intercepted-process-exit.ts +36 -0
package/dist/index.d.ts
CHANGED
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]="
|
|
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
|
|
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
|
|
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
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
if (
|
|
192
|
-
process.
|
|
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
|
-
|
|
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=
|
|
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]="
|
|
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=
|
|
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
|
@@ -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
|
// ---------------------------------------------------------------------------
|
package/src/commands/files.ts
CHANGED
|
@@ -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
|
-
|
|
576
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|