@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.
- package/dist/bin/hq-auth-refresh.d.ts +1 -1
- package/dist/bin/hq-auth-refresh.js +5 -2
- package/dist/commands/members.d.ts +17 -0
- package/dist/commands/members.js +65 -28
- 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/members.test.ts +176 -0
- package/src/commands/members.ts +113 -28
- 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
|
@@ -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
|
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
formatInviteHttpError,
|
|
39
39
|
getCallerPersonUid,
|
|
40
40
|
inviteMember,
|
|
41
|
+
listActiveMembers,
|
|
41
42
|
listPendingInvites,
|
|
42
43
|
registerMembersCommand,
|
|
43
44
|
resendInvite,
|
|
@@ -743,6 +744,181 @@ describe("listPendingInvites", () => {
|
|
|
743
744
|
});
|
|
744
745
|
});
|
|
745
746
|
|
|
747
|
+
// ---------------------------------------------------------------------------
|
|
748
|
+
// listActiveMembers
|
|
749
|
+
// ---------------------------------------------------------------------------
|
|
750
|
+
|
|
751
|
+
describe("listActiveMembers", () => {
|
|
752
|
+
it("GETs /membership/company/{uid} and returns the members array", async () => {
|
|
753
|
+
fetchSpy.mockResolvedValueOnce(
|
|
754
|
+
jsonResponse(200, {
|
|
755
|
+
members: [
|
|
756
|
+
{
|
|
757
|
+
membershipKey: "k1",
|
|
758
|
+
personUid: "prs_alice",
|
|
759
|
+
companyUid: "cmp_acme",
|
|
760
|
+
role: "owner",
|
|
761
|
+
status: "active",
|
|
762
|
+
personEmail: "alice@example.com",
|
|
763
|
+
personName: "Alice",
|
|
764
|
+
},
|
|
765
|
+
],
|
|
766
|
+
}),
|
|
767
|
+
);
|
|
768
|
+
|
|
769
|
+
const members = await listActiveMembers("test-token", "cmp_acme");
|
|
770
|
+
|
|
771
|
+
const call = fetchSpy.mock.calls[0];
|
|
772
|
+
expect(String(call[0])).toMatch(/\/membership\/company\/cmp_acme$/);
|
|
773
|
+
expect(members).toHaveLength(1);
|
|
774
|
+
expect(members[0].personEmail).toBe("alice@example.com");
|
|
775
|
+
expect(members[0].role).toBe("owner");
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
it("returns [] when the members key is missing", async () => {
|
|
779
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
|
|
780
|
+
await expect(
|
|
781
|
+
listActiveMembers("test-token", "cmp_acme"),
|
|
782
|
+
).resolves.toEqual([]);
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
it("returns [] when members is null", async () => {
|
|
786
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { members: null }));
|
|
787
|
+
await expect(
|
|
788
|
+
listActiveMembers("test-token", "cmp_acme"),
|
|
789
|
+
).resolves.toEqual([]);
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
it("throws InviteHttpError on non-ok", async () => {
|
|
793
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(403, { error: "forbidden" }));
|
|
794
|
+
await expect(
|
|
795
|
+
listActiveMembers("test-token", "cmp_acme"),
|
|
796
|
+
).rejects.toBeInstanceOf(InviteHttpError);
|
|
797
|
+
});
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
// ---------------------------------------------------------------------------
|
|
801
|
+
// registerMembersCommand list
|
|
802
|
+
// ---------------------------------------------------------------------------
|
|
803
|
+
|
|
804
|
+
describe("registerMembersCommand list", () => {
|
|
805
|
+
it("defaults to ACTIVE members: renders EMAIL/ROLE/NAME + the share hint", async () => {
|
|
806
|
+
fetchSpy.mockResolvedValueOnce(
|
|
807
|
+
jsonResponse(200, {
|
|
808
|
+
members: [
|
|
809
|
+
{
|
|
810
|
+
membershipKey: "k1",
|
|
811
|
+
personUid: "prs_alice",
|
|
812
|
+
companyUid: "cmp_acme",
|
|
813
|
+
role: "owner",
|
|
814
|
+
status: "active",
|
|
815
|
+
personEmail: "alice@example.com",
|
|
816
|
+
personName: "Alice",
|
|
817
|
+
},
|
|
818
|
+
{
|
|
819
|
+
membershipKey: "k2",
|
|
820
|
+
personUid: "prs_bob",
|
|
821
|
+
companyUid: "cmp_acme",
|
|
822
|
+
role: "member",
|
|
823
|
+
status: "active",
|
|
824
|
+
personSlug: "bob",
|
|
825
|
+
},
|
|
826
|
+
],
|
|
827
|
+
}),
|
|
828
|
+
);
|
|
829
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
830
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
831
|
+
|
|
832
|
+
await buildMembersProgram().parseAsync(
|
|
833
|
+
["members", "--company", "acme", "list"],
|
|
834
|
+
{ from: "user" },
|
|
835
|
+
);
|
|
836
|
+
|
|
837
|
+
const call = fetchSpy.mock.calls[0];
|
|
838
|
+
expect(String(call[0])).toMatch(/\/membership\/company\/cmp_acme$/);
|
|
839
|
+
const output = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
840
|
+
expect(output).toContain("EMAIL");
|
|
841
|
+
expect(output).toContain("ROLE");
|
|
842
|
+
expect(output).toContain("NAME");
|
|
843
|
+
expect(output).toContain("alice@example.com");
|
|
844
|
+
expect(output).toContain("owner");
|
|
845
|
+
expect(output).toContain("Alice");
|
|
846
|
+
// unresolved email falls back to personUid; name falls back to slug
|
|
847
|
+
expect(output).toContain("prs_bob");
|
|
848
|
+
expect(output).toContain("bob");
|
|
849
|
+
expect(output).toContain(
|
|
850
|
+
"hq secrets share <path> --with <email>",
|
|
851
|
+
);
|
|
852
|
+
expect(errSpy).not.toHaveBeenCalled();
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
it("default active: empty roster prints the no-active-members message", async () => {
|
|
856
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { members: [] }));
|
|
857
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
858
|
+
|
|
859
|
+
await buildMembersProgram().parseAsync(
|
|
860
|
+
["members", "--company", "acme", "list"],
|
|
861
|
+
{ from: "user" },
|
|
862
|
+
);
|
|
863
|
+
|
|
864
|
+
expect(logSpy).toHaveBeenCalledWith(
|
|
865
|
+
expect.stringContaining("No active members found for this company."),
|
|
866
|
+
);
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
it("--pending preserves the OLD pending-invites table verbatim", async () => {
|
|
870
|
+
fetchSpy.mockResolvedValueOnce(
|
|
871
|
+
jsonResponse(200, {
|
|
872
|
+
pending: [
|
|
873
|
+
{
|
|
874
|
+
membershipKey: "email:alice@example.com#cmp_acme",
|
|
875
|
+
inviteeEmail: "alice@example.com",
|
|
876
|
+
companyUid: "cmp_acme",
|
|
877
|
+
role: "member",
|
|
878
|
+
status: "pending",
|
|
879
|
+
invitedBy: "prs_admin",
|
|
880
|
+
invitedAt: "2026-05-21T12:00:00Z",
|
|
881
|
+
},
|
|
882
|
+
],
|
|
883
|
+
}),
|
|
884
|
+
);
|
|
885
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
886
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
887
|
+
|
|
888
|
+
await buildMembersProgram().parseAsync(
|
|
889
|
+
["members", "--company", "acme", "list", "--pending"],
|
|
890
|
+
{ from: "user" },
|
|
891
|
+
);
|
|
892
|
+
|
|
893
|
+
const call = fetchSpy.mock.calls[0];
|
|
894
|
+
expect(String(call[0])).toMatch(
|
|
895
|
+
/\/membership\/company\/cmp_acme\/pending$/,
|
|
896
|
+
);
|
|
897
|
+
const output = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
898
|
+
expect(output).toContain("TARGET");
|
|
899
|
+
expect(output).toContain("INVITED_BY");
|
|
900
|
+
expect(output).toContain("MEMBERSHIP_KEY");
|
|
901
|
+
expect(output).toContain("alice@example.com");
|
|
902
|
+
// the active-only share hint must NOT appear in the pending view
|
|
903
|
+
expect(output).not.toContain("hq secrets share");
|
|
904
|
+
expect(errSpy).not.toHaveBeenCalled();
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
it("--pending: empty invites prints the no-pending-invites message", async () => {
|
|
908
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { pending: [] }));
|
|
909
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
910
|
+
|
|
911
|
+
await buildMembersProgram().parseAsync(
|
|
912
|
+
["members", "--company", "acme", "list", "--pending"],
|
|
913
|
+
{ from: "user" },
|
|
914
|
+
);
|
|
915
|
+
|
|
916
|
+
expect(logSpy).toHaveBeenCalledWith(
|
|
917
|
+
expect.stringContaining("No pending invites for this company."),
|
|
918
|
+
);
|
|
919
|
+
});
|
|
920
|
+
});
|
|
921
|
+
|
|
746
922
|
// ---------------------------------------------------------------------------
|
|
747
923
|
// revokeInvite
|
|
748
924
|
// ---------------------------------------------------------------------------
|
package/src/commands/members.ts
CHANGED
|
@@ -31,6 +31,23 @@ interface MyMembership {
|
|
|
31
31
|
status: string;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* An ACTIVE member of a company, as returned by
|
|
36
|
+
* `GET /membership/company/{companyUid}`. The server filters to
|
|
37
|
+
* `status: "active"` and enriches each row with resolved person metadata
|
|
38
|
+
* (`personEmail` / `personName` / `personSlug`) when available.
|
|
39
|
+
*/
|
|
40
|
+
export interface ActiveMember {
|
|
41
|
+
membershipKey: string;
|
|
42
|
+
personUid: string;
|
|
43
|
+
companyUid: string;
|
|
44
|
+
role: string;
|
|
45
|
+
status: string;
|
|
46
|
+
personEmail?: string;
|
|
47
|
+
personName?: string;
|
|
48
|
+
personSlug?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
34
51
|
export interface InviteOptions {
|
|
35
52
|
target: string;
|
|
36
53
|
role: string;
|
|
@@ -374,6 +391,30 @@ export async function listPendingInvites(
|
|
|
374
391
|
return data?.pending ?? data?.invites ?? [];
|
|
375
392
|
}
|
|
376
393
|
|
|
394
|
+
export async function listActiveMembers(
|
|
395
|
+
token: string,
|
|
396
|
+
companyUid: string,
|
|
397
|
+
): Promise<ActiveMember[]> {
|
|
398
|
+
const res = await vaultApiFetch({
|
|
399
|
+
token,
|
|
400
|
+
path: `/membership/company/${encodeURIComponent(companyUid)}`,
|
|
401
|
+
});
|
|
402
|
+
if (!res.ok) {
|
|
403
|
+
const err = (await res.json().catch(() => ({}))) as Record<string, string>;
|
|
404
|
+
throw new InviteHttpError(
|
|
405
|
+
res.status,
|
|
406
|
+
err.message ?? err.error ?? res.statusText,
|
|
407
|
+
err.code,
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
// Server schema: `{ members: [...] }` — active members only, enriched with
|
|
411
|
+
// resolved person metadata (personEmail / personName / personSlug).
|
|
412
|
+
const data = (await res.json()) as {
|
|
413
|
+
members?: ActiveMember[] | null;
|
|
414
|
+
};
|
|
415
|
+
return data?.members ?? [];
|
|
416
|
+
}
|
|
417
|
+
|
|
377
418
|
/**
|
|
378
419
|
* Resolve a `revoke` CLI argument into the canonical `membershipKey` shape
|
|
379
420
|
* the server requires. Accepts three input forms:
|
|
@@ -746,55 +787,99 @@ export function registerMembersCommand(program: Command): void {
|
|
|
746
787
|
|
|
747
788
|
members
|
|
748
789
|
.command("list")
|
|
749
|
-
.description(
|
|
750
|
-
|
|
790
|
+
.description(
|
|
791
|
+
"List the company's active members (use --pending for pending invites)",
|
|
792
|
+
)
|
|
793
|
+
.option("--pending", "List pending invites instead of active members")
|
|
794
|
+
.action(async (opts: { pending?: boolean }) => {
|
|
751
795
|
try {
|
|
752
796
|
const token = await ensureCognitoToken();
|
|
753
797
|
const companySlug = members.opts().company as string | undefined;
|
|
754
798
|
const companyUid = await getCompanyUid(token, companySlug);
|
|
755
799
|
|
|
756
|
-
|
|
800
|
+
if (opts.pending) {
|
|
801
|
+
// --pending: preserve the original pending-invites view verbatim.
|
|
802
|
+
const invites = await listPendingInvites(token, companyUid);
|
|
803
|
+
|
|
804
|
+
if (invites.length === 0) {
|
|
805
|
+
console.log(chalk.gray("No pending invites for this company."));
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
757
808
|
|
|
758
|
-
|
|
759
|
-
|
|
809
|
+
const targetW = Math.max(
|
|
810
|
+
6,
|
|
811
|
+
...invites.map((i) => (i.inviteeEmail ?? i.personUid ?? "").length),
|
|
812
|
+
);
|
|
813
|
+
const roleW = Math.max(4, ...invites.map((i) => i.role.length));
|
|
814
|
+
const byW = Math.max(10, ...invites.map((i) => i.invitedBy.length));
|
|
815
|
+
const keyW = Math.max(
|
|
816
|
+
14,
|
|
817
|
+
...invites.map((i) => i.membershipKey.length),
|
|
818
|
+
);
|
|
819
|
+
console.log(
|
|
820
|
+
chalk.bold(
|
|
821
|
+
[
|
|
822
|
+
"TARGET".padEnd(targetW),
|
|
823
|
+
"ROLE".padEnd(roleW),
|
|
824
|
+
"INVITED_BY".padEnd(byW),
|
|
825
|
+
"INVITED_AT",
|
|
826
|
+
"MEMBERSHIP_KEY".padEnd(keyW),
|
|
827
|
+
].join(" "),
|
|
828
|
+
),
|
|
829
|
+
);
|
|
830
|
+
for (const inv of invites) {
|
|
831
|
+
const target = inv.inviteeEmail ?? inv.personUid ?? "";
|
|
832
|
+
console.log(
|
|
833
|
+
[
|
|
834
|
+
target.padEnd(targetW),
|
|
835
|
+
inv.role.padEnd(roleW),
|
|
836
|
+
inv.invitedBy.padEnd(byW),
|
|
837
|
+
shortDate(inv.invitedAt),
|
|
838
|
+
inv.membershipKey.padEnd(keyW),
|
|
839
|
+
].join(" "),
|
|
840
|
+
);
|
|
841
|
+
}
|
|
760
842
|
return;
|
|
761
843
|
}
|
|
762
844
|
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
845
|
+
// Default: list ACTIVE members so their emails drop straight into
|
|
846
|
+
// `hq secrets share <path> --with <email>`.
|
|
847
|
+
const activeMembers = await listActiveMembers(token, companyUid);
|
|
848
|
+
|
|
849
|
+
if (activeMembers.length === 0) {
|
|
850
|
+
console.log(chalk.gray("No active members found for this company."));
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
const emailW = Math.max(
|
|
855
|
+
5,
|
|
856
|
+
...activeMembers.map((m) => (m.personEmail ?? m.personUid).length),
|
|
766
857
|
);
|
|
767
|
-
const roleW = Math.max(4, ...
|
|
768
|
-
const byW = Math.max(10, ...invites.map((i) => i.invitedBy.length));
|
|
769
|
-
const keyW = Math.max(14, ...invites.map((i) => i.membershipKey.length));
|
|
858
|
+
const roleW = Math.max(4, ...activeMembers.map((m) => m.role.length));
|
|
770
859
|
console.log(
|
|
771
860
|
chalk.bold(
|
|
772
|
-
[
|
|
773
|
-
"TARGET".padEnd(targetW),
|
|
774
|
-
"ROLE".padEnd(roleW),
|
|
775
|
-
"INVITED_BY".padEnd(byW),
|
|
776
|
-
"INVITED_AT",
|
|
777
|
-
"MEMBERSHIP_KEY".padEnd(keyW),
|
|
778
|
-
].join(" "),
|
|
861
|
+
["EMAIL".padEnd(emailW), "ROLE".padEnd(roleW), "NAME"].join(" "),
|
|
779
862
|
),
|
|
780
863
|
);
|
|
781
|
-
for (const
|
|
782
|
-
const
|
|
864
|
+
for (const m of activeMembers) {
|
|
865
|
+
const email = m.personEmail ?? m.personUid;
|
|
866
|
+
const name = m.personName ?? m.personSlug ?? "";
|
|
783
867
|
console.log(
|
|
784
|
-
[
|
|
785
|
-
target.padEnd(targetW),
|
|
786
|
-
inv.role.padEnd(roleW),
|
|
787
|
-
inv.invitedBy.padEnd(byW),
|
|
788
|
-
shortDate(inv.invitedAt),
|
|
789
|
-
inv.membershipKey.padEnd(keyW),
|
|
790
|
-
].join(" "),
|
|
868
|
+
[email.padEnd(emailW), m.role.padEnd(roleW), name].join(" "),
|
|
791
869
|
);
|
|
792
870
|
}
|
|
871
|
+
console.log(
|
|
872
|
+
chalk.gray(
|
|
873
|
+
"Share a secret with a member: hq secrets share <path> --with <email>",
|
|
874
|
+
),
|
|
875
|
+
);
|
|
793
876
|
} catch (err) {
|
|
794
877
|
if (err instanceof InviteHttpError) {
|
|
795
878
|
const msg =
|
|
796
879
|
err.status === 403
|
|
797
|
-
?
|
|
880
|
+
? opts.pending
|
|
881
|
+
? "Not authorized — only admins and owners can list invites"
|
|
882
|
+
: "Not authorized — only company members can list members"
|
|
798
883
|
: formatInviteHttpError(err.status, err.message);
|
|
799
884
|
console.error(chalk.red(msg));
|
|
800
885
|
process.exit(1);
|