@indigoai-us/hq-cli 5.50.2 → 5.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/bin/hq-auth-refresh.d.ts +1 -1
  2. package/dist/bin/hq-auth-refresh.js +5 -2
  3. package/dist/commands/members.d.ts +17 -0
  4. package/dist/commands/members.js +65 -28
  5. package/dist/commands/people.d.ts +26 -1
  6. package/dist/commands/people.js +70 -7
  7. package/dist/commands/secrets-scope.d.ts +20 -0
  8. package/dist/commands/secrets-scope.js +19 -0
  9. package/dist/commands/secrets.js +21 -6
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.js +44 -14
  12. package/dist/node-preflight.d.ts +39 -0
  13. package/dist/node-preflight.js +55 -0
  14. package/dist/sentry.d.ts +12 -0
  15. package/dist/sentry.js +19 -3
  16. package/dist/utils/epipe.d.ts +8 -0
  17. package/dist/utils/epipe.js +30 -0
  18. package/dist/utils/intercepted-process-exit.d.ts +7 -0
  19. package/dist/utils/intercepted-process-exit.js +38 -0
  20. package/e2e/cli.test.ts +35 -0
  21. package/package.json +1 -1
  22. package/src/bin/hq-auth-refresh.ts +3 -0
  23. package/src/commands/members.test.ts +176 -0
  24. package/src/commands/members.ts +113 -28
  25. package/src/commands/people.test.ts +212 -5
  26. package/src/commands/people.ts +141 -5
  27. package/src/commands/secrets-scope.test.ts +56 -0
  28. package/src/commands/secrets-scope.ts +32 -0
  29. package/src/commands/secrets.ts +24 -10
  30. package/src/index.ts +40 -12
  31. package/src/node-preflight.test.ts +60 -0
  32. package/src/node-preflight.ts +67 -0
  33. package/src/sentry-epipe.test.ts +37 -0
  34. package/src/sentry-release.test.ts +54 -0
  35. package/src/sentry.ts +21 -1
  36. package/src/utils/epipe.test.ts +28 -0
  37. package/src/utils/epipe.ts +29 -0
  38. package/src/utils/intercepted-process-exit.test.ts +37 -0
  39. package/src/utils/intercepted-process-exit.ts +36 -0
@@ -0,0 +1,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.51.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
 
@@ -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
  // ---------------------------------------------------------------------------
@@ -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("List pending invites for the company")
750
- .action(async () => {
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
- const invites = await listPendingInvites(token, companyUid);
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
- if (invites.length === 0) {
759
- console.log(chalk.gray("No pending invites for this company."));
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
- const targetW = Math.max(
764
- 6,
765
- ...invites.map((i) => (i.inviteeEmail ?? i.personUid ?? "").length),
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, ...invites.map((i) => i.role.length));
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 inv of invites) {
782
- const target = inv.inviteeEmail ?? inv.personUid ?? "";
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
- ? "Not authorized — only admins and owners can list invites"
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);