@indigoai-us/hq-cli 5.50.1 → 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 (61) 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/onboard-warning.d.ts +7 -0
  6. package/dist/commands/onboard-warning.js +14 -0
  7. package/dist/commands/onboard.js +5 -5
  8. package/dist/commands/pack-install.d.ts +12 -0
  9. package/dist/commands/pack-install.js +74 -3
  10. package/dist/commands/packs.js +17 -3
  11. package/dist/commands/people.d.ts +26 -1
  12. package/dist/commands/people.js +70 -7
  13. package/dist/commands/secrets-scope.d.ts +20 -0
  14. package/dist/commands/secrets-scope.js +19 -0
  15. package/dist/commands/secrets.js +21 -6
  16. package/dist/index.d.ts +1 -1
  17. package/dist/index.js +44 -14
  18. package/dist/node-preflight.d.ts +39 -0
  19. package/dist/node-preflight.js +55 -0
  20. package/dist/sentry.d.ts +12 -0
  21. package/dist/sentry.js +19 -3
  22. package/dist/types.d.ts +18 -0
  23. package/dist/utils/epipe.d.ts +8 -0
  24. package/dist/utils/epipe.js +30 -0
  25. package/dist/utils/intercepted-process-exit.d.ts +7 -0
  26. package/dist/utils/intercepted-process-exit.js +38 -0
  27. package/dist/utils/pack-contributions.d.ts +7 -0
  28. package/dist/utils/pack-contributions.js +12 -2
  29. package/dist/utils/version-gate.d.ts +40 -1
  30. package/dist/utils/version-gate.js +91 -20
  31. package/e2e/cli.test.ts +35 -0
  32. package/package.json +1 -1
  33. package/src/bin/hq-auth-refresh.ts +3 -0
  34. package/src/commands/members.test.ts +176 -0
  35. package/src/commands/members.ts +113 -28
  36. package/src/commands/onboard-warning.test.ts +26 -0
  37. package/src/commands/onboard-warning.ts +12 -0
  38. package/src/commands/onboard.ts +4 -7
  39. package/src/commands/pack-install.test.ts +144 -0
  40. package/src/commands/pack-install.ts +86 -1
  41. package/src/commands/packs.ts +19 -0
  42. package/src/commands/people.test.ts +212 -5
  43. package/src/commands/people.ts +141 -5
  44. package/src/commands/secrets-scope.test.ts +56 -0
  45. package/src/commands/secrets-scope.ts +32 -0
  46. package/src/commands/secrets.ts +24 -10
  47. package/src/index.ts +40 -12
  48. package/src/node-preflight.test.ts +60 -0
  49. package/src/node-preflight.ts +67 -0
  50. package/src/sentry-epipe.test.ts +37 -0
  51. package/src/sentry-release.test.ts +54 -0
  52. package/src/sentry.ts +21 -1
  53. package/src/types.ts +19 -1
  54. package/src/utils/epipe.test.ts +28 -0
  55. package/src/utils/epipe.ts +29 -0
  56. package/src/utils/intercepted-process-exit.test.ts +37 -0
  57. package/src/utils/intercepted-process-exit.ts +36 -0
  58. package/src/utils/pack-contributions.test.ts +53 -0
  59. package/src/utils/pack-contributions.ts +17 -0
  60. package/src/utils/version-gate.test.ts +122 -0
  61. package/src/utils/version-gate.ts +109 -13
@@ -28,17 +28,66 @@
28
28
  * to silence both check + gate).
29
29
  */
30
30
 
31
- !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]="d7d58261-f0e4-56bc-b6eb-4117731744f9")}catch(e){}}();
31
+ !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]="0f613895-ad4a-5211-bf75-a2e843782c47")}catch(e){}}();
32
32
  import { spawnSync } from "node:child_process";
33
+ import { readFileSync } from "node:fs";
34
+ import path from "node:path";
35
+ import { fileURLToPath } from "node:url";
33
36
  import chalk from "chalk";
34
- import { CLI_VERSION } from "../cli-version.js";
37
+ import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
35
38
  import { DEFAULT_VAULT_API_URL } from "./cognito-session.js";
36
39
  const CLIENT_ID = "hq-cli";
37
40
  const ENDPOINT_PATH = "/v1/client-version/check";
38
41
  const FETCH_TIMEOUT_MS = 3_000;
42
+ const LATEST_PACKAGE_SPEC = `${CLI_NAME}@latest`;
39
43
  function isOptedOut() {
40
44
  return process.env.HQ_NO_UPDATE_CHECK === "1";
41
45
  }
46
+ function findRunningPackageRoot() {
47
+ let dir = path.dirname(fileURLToPath(import.meta.url));
48
+ while (true) {
49
+ try {
50
+ const pkg = JSON.parse(readFileSync(path.join(dir, "package.json"), "utf-8"));
51
+ if (pkg.name === CLI_NAME)
52
+ return dir;
53
+ }
54
+ catch {
55
+ // Keep walking; compiled installs usually start under dist/.
56
+ }
57
+ const parent = path.dirname(dir);
58
+ if (parent === dir)
59
+ return null;
60
+ dir = parent;
61
+ }
62
+ }
63
+ export function npmPrefixFromPackageDir(pkgDir) {
64
+ const normalized = pkgDir.replace(/\\/g, "/").replace(/\/+$/, "");
65
+ const segments = normalized.split("/");
66
+ const nodeModulesIndex = segments.lastIndexOf("node_modules");
67
+ if (nodeModulesIndex === -1)
68
+ return null;
69
+ const prefixEnd = segments[nodeModulesIndex - 1] === "lib"
70
+ ? nodeModulesIndex - 1
71
+ : nodeModulesIndex;
72
+ const prefix = segments.slice(0, prefixEnd).join("/");
73
+ if (prefix === "" && normalized.startsWith("/"))
74
+ return "/";
75
+ return prefix || null;
76
+ }
77
+ export function resolveRunningPrefix() {
78
+ try {
79
+ const pkgRoot = findRunningPackageRoot();
80
+ if (!pkgRoot)
81
+ return null;
82
+ return npmPrefixFromPackageDir(pkgRoot);
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
88
+ export function buildPrefixedInstallArgv(prefix) {
89
+ return ["install", "-g", "--prefix", prefix, LATEST_PACKAGE_SPEC];
90
+ }
42
91
  /**
43
92
  * Hit POST /v1/client-version/check. Returns the parsed body on 200, or
44
93
  * `null` on any failure (caller treats as "no gate"). Tight 3s timeout —
@@ -74,18 +123,7 @@ async function fetchVersionDecision() {
74
123
  return null;
75
124
  }
76
125
  }
77
- /**
78
- * Run the upgrade command in a blocking subprocess. Inherits stdio so the
79
- * user sees the npm progress. We do NOT auto-rerun the CLI on completion —
80
- * forcing a re-invocation would run twice on the same process and feel
81
- * janky; instead we print a clear "rerun your command" message and exit.
82
- */
83
- function performUpdate(command) {
84
- const parts = command.split(/\s+/).filter(Boolean);
85
- if (parts.length === 0)
86
- return { ok: false, detail: "empty command" };
87
- const cmd = parts[0];
88
- const args = parts.slice(1);
126
+ function runUpdateCommand(cmd, args) {
89
127
  try {
90
128
  const result = spawnSync(cmd, args, { stdio: "inherit" });
91
129
  if (result.status !== 0) {
@@ -100,6 +138,17 @@ function performUpdate(command) {
100
138
  return { ok: false, detail: err instanceof Error ? err.message : String(err) };
101
139
  }
102
140
  }
141
+ function performUpdateCommand(cmd, args, runner = runUpdateCommand) {
142
+ return runner(cmd, args);
143
+ }
144
+ function performUpdate(command, runner = runUpdateCommand) {
145
+ const parts = command.split(/\s+/).filter(Boolean);
146
+ if (parts.length === 0)
147
+ return { ok: false, detail: "empty command" };
148
+ const cmd = parts[0];
149
+ const args = parts.slice(1);
150
+ return performUpdateCommand(cmd, args, runner);
151
+ }
103
152
  /**
104
153
  * Soft notify when the server says we're below `latestVersion` but still ≥
105
154
  * `minVersion`. Single chalk-yellow line on stderr; never blocks.
@@ -121,24 +170,40 @@ function nudgeUpdateRecommended(decision) {
121
170
  * 0 — update succeeded; user must rerun their command
122
171
  * 75 — update failed (EX_TEMPFAIL; common for sudo/EACCES on system npm)
123
172
  */
124
- function enforceUpdateRequired(decision) {
173
+ function enforceUpdateRequired(decision, deps = {}) {
125
174
  const banner = chalk.red.bold(`✗ hq-cli ${decision.currentVersion} is below the minimum required version (${decision.minVersion}).`);
126
175
  console.error(banner);
127
176
  if (decision.message)
128
177
  console.error(chalk.dim(` ${decision.message}`));
129
178
  const command = decision.updateCommand;
130
- if (!command) {
179
+ const prefix = (deps.resolvePrefix ?? resolveRunningPrefix)();
180
+ if (!command && !prefix) {
131
181
  console.error(chalk.red(" No updateCommand provided by hq-pro — see https://hq.indigo.ai/docs/cli-update for manual steps."));
132
182
  if (decision.downloadUrl) {
133
183
  console.error(chalk.dim(` Download: ${decision.downloadUrl}`));
134
184
  }
135
185
  process.exit(75);
136
186
  }
137
- console.error(chalk.dim(` Running: ${command}`));
138
- const result = performUpdate(command);
187
+ const runner = deps.runner ?? runUpdateCommand;
188
+ const result = prefix
189
+ ? (() => {
190
+ const args = buildPrefixedInstallArgv(prefix);
191
+ console.error(chalk.dim(` Installing into npm prefix: ${prefix}`));
192
+ console.error(chalk.dim(` Running: npm ${args.join(" ")}`));
193
+ return performUpdateCommand("npm", args, runner);
194
+ })()
195
+ : (() => {
196
+ console.error(chalk.dim(` Running: ${command}`));
197
+ return deps.performUpdateString
198
+ ? deps.performUpdateString(command)
199
+ : performUpdate(command, runner);
200
+ })();
139
201
  if (!result.ok) {
140
202
  console.error(chalk.red(`✗ Update failed${result.detail ? `: ${result.detail}` : ""}.`));
141
- console.error(chalk.dim(` Try manually: ${command}`));
203
+ const manual = prefix
204
+ ? `npm ${buildPrefixedInstallArgv(prefix).join(" ")}`
205
+ : command;
206
+ console.error(chalk.dim(` Try manually: ${manual}`));
142
207
  process.exit(75);
143
208
  }
144
209
  console.error(chalk.green(`✓ Updated to hq-cli ${decision.latestVersion}. Rerun your command.`));
@@ -179,7 +244,13 @@ export const __test__ = {
179
244
  CLIENT_ID,
180
245
  ENDPOINT_PATH,
181
246
  FETCH_TIMEOUT_MS,
247
+ buildPrefixedInstallArgv,
248
+ enforceUpdateRequired,
249
+ npmPrefixFromPackageDir,
182
250
  performUpdate,
251
+ performUpdateCommand,
252
+ runUpdateCommand,
253
+ resolveRunningPrefix,
183
254
  };
184
255
  //# sourceMappingURL=version-gate.js.map
185
- //# debugId=d7d58261-f0e4-56bc-b6eb-4117731744f9
256
+ //# debugId=0f613895-ad4a-5211-bf75-a2e843782c47
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.1",
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);
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { buildCreateCompanyWarning } from "./onboard-warning.js";
4
+
5
+ describe("buildCreateCompanyWarning", () => {
6
+ it("documents the warning shown on the create-company path", () => {
7
+ const warning = buildCreateCompanyWarning();
8
+ expect(warning).toContain("NEW company");
9
+ expect(warning).toMatch(/does NOT join an existing one/i);
10
+ });
11
+
12
+ it("advises existing-company users to ask an admin or owner for an invite", () => {
13
+ const warning = buildCreateCompanyWarning();
14
+ expect(warning).toMatch(/ask your admin or owner.*invite/i);
15
+ });
16
+
17
+ it("tells invitees to run hq sync after accepting", () => {
18
+ const warning = buildCreateCompanyWarning();
19
+ expect(warning).toContain("accept it and run `hq sync`");
20
+ });
21
+
22
+ it("warns that duplicate creation leaves the user alone in a separate company", () => {
23
+ const warning = buildCreateCompanyWarning();
24
+ expect(warning).toMatch(/separate one you'd be alone in/i);
25
+ });
26
+ });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Builds the static create-time warning surfaced by both `create-company` and
3
+ * `dry-run`, because users may accidentally create duplicate companies instead
4
+ * of joining the existing company they were invited to.
5
+ */
6
+ export function buildCreateCompanyWarning(): string {
7
+ return (
8
+ " This creates a NEW company that you own — it does NOT join an existing one.\n" +
9
+ " If your company already uses HQ, do NOT create it again: ask your admin or owner to send you an invite, then accept it and run `hq sync` to pull the existing company.\n" +
10
+ " Creating a same-named company makes a separate one you'd be alone in.\n"
11
+ );
12
+ }
@@ -32,6 +32,7 @@ import {
32
32
  import { createDefaultVaultClient } from "./cloud-provision.js";
33
33
  import { detectOnboardingIdentityMismatch } from "./onboard-identity-guard.js";
34
34
  import { planOnboardJoin } from "./onboard-join.js";
35
+ import { buildCreateCompanyWarning } from "./onboard-warning.js";
35
36
 
36
37
  // ---------------------------------------------------------------------------
37
38
  // Command registration
@@ -70,13 +71,7 @@ export function registerOnboardCommand(program: Command): void {
70
71
  console.log(` Company: ${options.name} (${options.slug})`);
71
72
  console.log(` Person: ${options.personName} <${options.email}>`);
72
73
  console.log(` HQ root: ${options.hqRoot}\n`);
73
- console.log(
74
- chalk.gray(
75
- " This creates a NEW company you own. Joining a teammate's existing\n" +
76
- " company? Stop — accept your invite, then run `hq sync` to pull it.\n" +
77
- " Creating a same-named company leaves you alone in a separate one.\n",
78
- ),
79
- );
74
+ console.log(chalk.gray(buildCreateCompanyWarning()));
80
75
 
81
76
  const accessToken = await ensureCognitoToken();
82
77
  const result = await runOnboardCli({
@@ -248,6 +243,8 @@ export function registerOnboardCommand(program: Command): void {
248
243
  )
249
244
  .action(async (options: { hqRoot: string }) => {
250
245
  try {
246
+ console.log(chalk.gray(buildCreateCompanyWarning()));
247
+
251
248
  // No auth needed for dry-run — runOnboardCli handles this branch
252
249
  // without touching the vault-service.
253
250
  const result = await runOnboardCli({