@indigoai-us/hq-cli 5.9.0 → 5.10.1

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.
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Unit tests for `hq members invite|list|revoke` (members.ts).
3
+ *
4
+ * Coverage:
5
+ * - detectTarget — pure validation for email vs personUid vs invalid
6
+ * - getCallerPersonUid — happy path + missing-person-entity branch
7
+ * - inviteMember — email + personUid targets, --paths gating, HTTP errors
8
+ * - listPendingInvites — happy path + 403
9
+ * - revokeInvite — happy path + 404
10
+ */
11
+
12
+ import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest";
13
+
14
+ import {
15
+ InviteHttpError,
16
+ detectTarget,
17
+ formatInviteHttpError,
18
+ getCallerPersonUid,
19
+ inviteMember,
20
+ listPendingInvites,
21
+ revokeInvite,
22
+ } from "./members.js";
23
+
24
+ function jsonResponse(status: number, body: unknown): Response {
25
+ return new Response(JSON.stringify(body), {
26
+ status,
27
+ headers: { "Content-Type": "application/json" },
28
+ });
29
+ }
30
+
31
+ let fetchSpy: MockInstance<typeof fetch>;
32
+
33
+ beforeEach(() => {
34
+ fetchSpy = vi.spyOn(globalThis, "fetch");
35
+ });
36
+
37
+ afterEach(() => {
38
+ vi.restoreAllMocks();
39
+ });
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // detectTarget
43
+ // ---------------------------------------------------------------------------
44
+
45
+ describe("detectTarget", () => {
46
+ it("recognizes plain emails and lowercases them", () => {
47
+ expect(detectTarget("Alice@Example.com")).toEqual({
48
+ type: "email",
49
+ value: "alice@example.com",
50
+ });
51
+ });
52
+
53
+ it("recognizes person UIDs", () => {
54
+ expect(detectTarget("prs_bob123")).toEqual({
55
+ type: "person",
56
+ value: "prs_bob123",
57
+ });
58
+ });
59
+
60
+ it("returns null for invalid targets", () => {
61
+ expect(detectTarget("not-a-target")).toBeNull();
62
+ expect(detectTarget("cmp_company")).toBeNull();
63
+ expect(detectTarget("")).toBeNull();
64
+ });
65
+ });
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // getCallerPersonUid
69
+ // ---------------------------------------------------------------------------
70
+
71
+ describe("getCallerPersonUid", () => {
72
+ it("returns the personUid from the first membership", async () => {
73
+ fetchSpy.mockResolvedValueOnce(
74
+ jsonResponse(200, {
75
+ memberships: [
76
+ { membershipKey: "k1", personUid: "prs_admin", companyUid: "cmp_a", role: "owner", status: "active" },
77
+ ],
78
+ }),
79
+ );
80
+
81
+ const uid = await getCallerPersonUid("test-token");
82
+ expect(uid).toBe("prs_admin");
83
+ });
84
+
85
+ it("throws if the caller has no person entity yet", async () => {
86
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, { memberships: [] }));
87
+ await expect(getCallerPersonUid("test-token")).rejects.toThrow(/no person entity/);
88
+ });
89
+
90
+ it("throws on auth failure", async () => {
91
+ fetchSpy.mockResolvedValueOnce(jsonResponse(401, { error: "unauthorized" }));
92
+ await expect(getCallerPersonUid("test-token")).rejects.toThrow(/run `hq login`/);
93
+ });
94
+ });
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // inviteMember
98
+ // ---------------------------------------------------------------------------
99
+
100
+ describe("inviteMember", () => {
101
+ it("creates an invite for an email target and returns a magic link", async () => {
102
+ fetchSpy.mockResolvedValueOnce(
103
+ jsonResponse(200, {
104
+ membership: { role: "member", status: "pending" },
105
+ inviteToken: "tok_abc",
106
+ }),
107
+ );
108
+
109
+ const result = await inviteMember({
110
+ target: "alice@example.com",
111
+ role: "member",
112
+ companyUid: "cmp_acme",
113
+ callerUid: "prs_admin",
114
+ token: "test-token",
115
+ });
116
+
117
+ expect(result.magicLink).toBe("hq://accept/tok_abc");
118
+ expect(result.membership.role).toBe("member");
119
+
120
+ const call = fetchSpy.mock.calls[0];
121
+ const body = JSON.parse((call[1]?.body as string) ?? "{}");
122
+ expect(body).toEqual({
123
+ companyUid: "cmp_acme",
124
+ role: "member",
125
+ invitedBy: "prs_admin",
126
+ inviteeEmail: "alice@example.com",
127
+ });
128
+ });
129
+
130
+ it("creates an invite for a personUid target", async () => {
131
+ fetchSpy.mockResolvedValueOnce(
132
+ jsonResponse(200, {
133
+ membership: { role: "admin", status: "pending" },
134
+ inviteToken: "tok_456",
135
+ }),
136
+ );
137
+
138
+ await inviteMember({
139
+ target: "prs_bob",
140
+ role: "admin",
141
+ companyUid: "cmp_acme",
142
+ callerUid: "prs_admin",
143
+ token: "test-token",
144
+ });
145
+
146
+ const body = JSON.parse((fetchSpy.mock.calls[0][1]?.body as string) ?? "{}");
147
+ expect(body.personUid).toBe("prs_bob");
148
+ expect(body.inviteeEmail).toBeUndefined();
149
+ });
150
+
151
+ it("forwards allowedPrefixes when --paths is set with --role guest", async () => {
152
+ fetchSpy.mockResolvedValueOnce(
153
+ jsonResponse(200, {
154
+ membership: { role: "guest", status: "pending" },
155
+ inviteToken: "tok_guest",
156
+ }),
157
+ );
158
+
159
+ await inviteMember({
160
+ target: "alice@example.com",
161
+ role: "guest",
162
+ paths: "docs/, shared/",
163
+ companyUid: "cmp_acme",
164
+ callerUid: "prs_admin",
165
+ token: "test-token",
166
+ });
167
+
168
+ const body = JSON.parse((fetchSpy.mock.calls[0][1]?.body as string) ?? "{}");
169
+ expect(body.allowedPrefixes).toEqual(["docs/", "shared/"]);
170
+ });
171
+
172
+ it("rejects --paths with a non-guest role", async () => {
173
+ await expect(
174
+ inviteMember({
175
+ target: "alice@example.com",
176
+ role: "member",
177
+ paths: "docs/",
178
+ companyUid: "cmp_acme",
179
+ callerUid: "prs_admin",
180
+ token: "test-token",
181
+ }),
182
+ ).rejects.toThrow(/--paths is only valid with --role guest/);
183
+ expect(fetchSpy).not.toHaveBeenCalled();
184
+ });
185
+
186
+ it("rejects an invalid target", async () => {
187
+ await expect(
188
+ inviteMember({
189
+ target: "not-a-target",
190
+ role: "member",
191
+ companyUid: "cmp_acme",
192
+ callerUid: "prs_admin",
193
+ token: "test-token",
194
+ }),
195
+ ).rejects.toThrow(/Invalid target/);
196
+ expect(fetchSpy).not.toHaveBeenCalled();
197
+ });
198
+
199
+ it("rejects an unknown role", async () => {
200
+ await expect(
201
+ inviteMember({
202
+ target: "alice@example.com",
203
+ role: "superuser",
204
+ companyUid: "cmp_acme",
205
+ callerUid: "prs_admin",
206
+ token: "test-token",
207
+ }),
208
+ ).rejects.toThrow(/Invalid role/);
209
+ expect(fetchSpy).not.toHaveBeenCalled();
210
+ });
211
+
212
+ it("wraps non-2xx responses in InviteHttpError", async () => {
213
+ fetchSpy.mockResolvedValueOnce(jsonResponse(409, { error: "duplicate" }));
214
+
215
+ await expect(
216
+ inviteMember({
217
+ target: "alice@example.com",
218
+ role: "member",
219
+ companyUid: "cmp_acme",
220
+ callerUid: "prs_admin",
221
+ token: "test-token",
222
+ }),
223
+ ).rejects.toBeInstanceOf(InviteHttpError);
224
+ });
225
+ });
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // listPendingInvites
229
+ // ---------------------------------------------------------------------------
230
+
231
+ describe("listPendingInvites", () => {
232
+ it("returns the parsed invites array", async () => {
233
+ fetchSpy.mockResolvedValueOnce(
234
+ jsonResponse(200, {
235
+ invites: [
236
+ {
237
+ membershipKey: "k1",
238
+ inviteeEmail: "alice@example.com",
239
+ companyUid: "cmp_acme",
240
+ role: "member",
241
+ status: "pending",
242
+ invitedBy: "prs_admin",
243
+ invitedAt: "2026-05-04T12:00:00Z",
244
+ },
245
+ ],
246
+ }),
247
+ );
248
+
249
+ const invites = await listPendingInvites("test-token", "cmp_acme");
250
+ expect(invites).toHaveLength(1);
251
+ expect(invites[0].inviteeEmail).toBe("alice@example.com");
252
+ });
253
+
254
+ it("throws InviteHttpError on 403", async () => {
255
+ fetchSpy.mockResolvedValueOnce(jsonResponse(403, { error: "forbidden" }));
256
+ await expect(listPendingInvites("test-token", "cmp_acme")).rejects.toBeInstanceOf(
257
+ InviteHttpError,
258
+ );
259
+ });
260
+ });
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // revokeInvite
264
+ // ---------------------------------------------------------------------------
265
+
266
+ describe("revokeInvite", () => {
267
+ it("posts membershipKey + companyUid", async () => {
268
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
269
+
270
+ await revokeInvite("test-token", "k1", "cmp_acme");
271
+
272
+ const body = JSON.parse((fetchSpy.mock.calls[0][1]?.body as string) ?? "{}");
273
+ expect(body).toEqual({ membershipKey: "k1", companyUid: "cmp_acme" });
274
+ });
275
+
276
+ it("throws InviteHttpError on 404", async () => {
277
+ fetchSpy.mockResolvedValueOnce(jsonResponse(404, { error: "not found" }));
278
+ await expect(revokeInvite("test-token", "k1", "cmp_acme")).rejects.toBeInstanceOf(
279
+ InviteHttpError,
280
+ );
281
+ });
282
+ });
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // formatInviteHttpError
286
+ // ---------------------------------------------------------------------------
287
+
288
+ describe("formatInviteHttpError", () => {
289
+ it("maps 401 to a login hint", () => {
290
+ expect(formatInviteHttpError(401, "ignored")).toMatch(/run `hq login`/);
291
+ });
292
+ it("maps 403 to admin/owner hint", () => {
293
+ expect(formatInviteHttpError(403, "ignored")).toMatch(/admins and owners/);
294
+ });
295
+ it("maps 409 to duplicate-invite hint", () => {
296
+ expect(formatInviteHttpError(409, "ignored")).toMatch(/already has a membership/);
297
+ });
298
+ it("prefixes 5xx with 'Server error:'", () => {
299
+ expect(formatInviteHttpError(500, "boom")).toBe("Server error: boom");
300
+ });
301
+ it("falls through to the message for unmapped statuses", () => {
302
+ expect(formatInviteHttpError(400, "bad input")).toBe("bad input");
303
+ });
304
+ });
@@ -0,0 +1,367 @@
1
+ import { Command } from "commander";
2
+ import chalk from "chalk";
3
+ import { ensureCognitoToken } from "../utils/cognito-session.js";
4
+ import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
5
+
6
+ const EMAIL_PATTERN = /^[^\s]+@[^\s]+$/;
7
+ const PERSON_UID_PATTERN = /^prs_[A-Za-z0-9_-]+$/;
8
+ export const VALID_ROLES = new Set(["owner", "admin", "member", "guest"]);
9
+
10
+ export type Role = "owner" | "admin" | "member" | "guest";
11
+
12
+ export interface PendingInvite {
13
+ membershipKey: string;
14
+ personUid?: string;
15
+ inviteeEmail?: string;
16
+ companyUid: string;
17
+ role: string;
18
+ status: string;
19
+ inviteToken?: string;
20
+ invitedBy: string;
21
+ invitedAt: string;
22
+ }
23
+
24
+ interface MyMembership {
25
+ membershipKey: string;
26
+ personUid: string;
27
+ companyUid: string;
28
+ role: string;
29
+ status: string;
30
+ }
31
+
32
+ export interface InviteOptions {
33
+ target: string;
34
+ role: string;
35
+ paths?: string;
36
+ companyUid: string;
37
+ callerUid: string;
38
+ token: string;
39
+ }
40
+
41
+ export interface InviteResult {
42
+ inviteToken: string;
43
+ magicLink: string;
44
+ membership: { role: string; status: string };
45
+ }
46
+
47
+ export interface DetectedTarget {
48
+ type: "email" | "person";
49
+ value: string;
50
+ }
51
+
52
+ export function detectTarget(target: string): DetectedTarget | null {
53
+ if (EMAIL_PATTERN.test(target)) {
54
+ return { type: "email", value: target.trim().toLowerCase() };
55
+ }
56
+ if (PERSON_UID_PATTERN.test(target)) {
57
+ return { type: "person", value: target };
58
+ }
59
+ return null;
60
+ }
61
+
62
+ export function shortDate(iso: string): string {
63
+ return iso.slice(0, 10);
64
+ }
65
+
66
+ /**
67
+ * Resolve the caller's personUid by reading their own membership list.
68
+ * The server infers the JWT identity, so this returns the canonical
69
+ * personUid attached to the caller's active memberships.
70
+ */
71
+ export async function getCallerPersonUid(token: string): Promise<string> {
72
+ const res = await vaultApiFetch({ token, path: "/membership/me" });
73
+ if (!res.ok) {
74
+ throw new Error(
75
+ "Failed to resolve caller identity — run `hq login` and try again",
76
+ );
77
+ }
78
+ const data = (await res.json()) as { memberships: MyMembership[] };
79
+ const personUid = data.memberships.find((m) => m.personUid)?.personUid;
80
+ if (!personUid) {
81
+ throw new Error(
82
+ "Your account has no person entity yet. Run `hq onboard create-company` or accept an invite first.",
83
+ );
84
+ }
85
+ return personUid;
86
+ }
87
+
88
+ /** Send a `/membership/invite` request and return the magic link. */
89
+ export async function inviteMember(
90
+ options: InviteOptions,
91
+ ): Promise<InviteResult> {
92
+ if (!VALID_ROLES.has(options.role)) {
93
+ throw new Error(
94
+ `Invalid role '${options.role}': must be one of owner, admin, member, guest`,
95
+ );
96
+ }
97
+ if (options.paths && options.role !== "guest") {
98
+ throw new Error(
99
+ "--paths is only valid with --role guest (allowedPrefixes are only meaningful for the guest role)",
100
+ );
101
+ }
102
+
103
+ const detected = detectTarget(options.target);
104
+ if (!detected) {
105
+ throw new Error(
106
+ `Invalid target '${options.target}': must be an email address or a personUid matching prs_<alphanumeric>`,
107
+ );
108
+ }
109
+
110
+ const allowedPrefixes = options.paths
111
+ ? options.paths.split(",").map((p) => p.trim()).filter(Boolean)
112
+ : undefined;
113
+
114
+ const body: Record<string, unknown> = {
115
+ companyUid: options.companyUid,
116
+ role: options.role,
117
+ invitedBy: options.callerUid,
118
+ };
119
+ if (detected.type === "email") body.inviteeEmail = detected.value;
120
+ else body.personUid = detected.value;
121
+ if (allowedPrefixes) body.allowedPrefixes = allowedPrefixes;
122
+
123
+ const res = await vaultApiFetch({
124
+ token: options.token,
125
+ path: "/membership/invite",
126
+ method: "POST",
127
+ body,
128
+ });
129
+
130
+ if (!res.ok) {
131
+ const err = (await res.json().catch(() => ({}))) as Record<string, string>;
132
+ throw new InviteHttpError(
133
+ res.status,
134
+ err.message ?? err.error ?? res.statusText,
135
+ );
136
+ }
137
+
138
+ const data = (await res.json()) as {
139
+ membership: { role: string; status: string };
140
+ inviteToken: string;
141
+ };
142
+ return {
143
+ inviteToken: data.inviteToken,
144
+ magicLink: `hq://accept/${data.inviteToken}`,
145
+ membership: data.membership,
146
+ };
147
+ }
148
+
149
+ export class InviteHttpError extends Error {
150
+ constructor(public status: number, message: string) {
151
+ super(message);
152
+ this.name = "InviteHttpError";
153
+ }
154
+ }
155
+
156
+ export function formatInviteHttpError(status: number, fallback: string): string {
157
+ if (status === 401) return "Not authenticated — please run `hq login`";
158
+ if (status === 403) {
159
+ return "Not authorized — only admins and owners can invite members";
160
+ }
161
+ if (status === 409) {
162
+ return "This person already has a membership or pending invite for this company";
163
+ }
164
+ if (status >= 500) return `Server error: ${fallback}`;
165
+ return fallback;
166
+ }
167
+
168
+ export async function listPendingInvites(
169
+ token: string,
170
+ companyUid: string,
171
+ ): Promise<PendingInvite[]> {
172
+ const res = await vaultApiFetch({
173
+ token,
174
+ path: `/membership/company/${encodeURIComponent(companyUid)}/pending`,
175
+ });
176
+ if (!res.ok) {
177
+ const err = (await res.json().catch(() => ({}))) as Record<string, string>;
178
+ throw new InviteHttpError(
179
+ res.status,
180
+ err.message ?? err.error ?? res.statusText,
181
+ );
182
+ }
183
+ const data = (await res.json()) as { invites: PendingInvite[] };
184
+ return data.invites;
185
+ }
186
+
187
+ export async function revokeInvite(
188
+ token: string,
189
+ tokenOrKey: string,
190
+ companyUid: string,
191
+ ): Promise<void> {
192
+ const res = await vaultApiFetch({
193
+ token,
194
+ path: "/membership/revoke",
195
+ method: "POST",
196
+ body: { membershipKey: tokenOrKey, companyUid },
197
+ });
198
+ if (!res.ok) {
199
+ const err = (await res.json().catch(() => ({}))) as Record<string, string>;
200
+ throw new InviteHttpError(
201
+ res.status,
202
+ err.message ?? err.error ?? res.statusText,
203
+ );
204
+ }
205
+ }
206
+
207
+ export function registerMembersCommand(program: Command): void {
208
+ const members = program
209
+ .command("members")
210
+ .description("Manage company memberships and invites")
211
+ .option("--company <slug>", "Company slug (resolves to companyUid)");
212
+
213
+ members
214
+ .command("invite <target>")
215
+ .description(
216
+ "Invite a person to the company by email or personUid (prints a magic link)",
217
+ )
218
+ .option(
219
+ "--role <role>",
220
+ "Role for the invitee: owner, admin, member, or guest",
221
+ "member",
222
+ )
223
+ .option(
224
+ "--paths <prefixes>",
225
+ "Comma-separated allowed prefixes (only valid with --role guest)",
226
+ )
227
+ .action(
228
+ async (
229
+ target: string,
230
+ opts: { role: string; paths?: string },
231
+ ) => {
232
+ try {
233
+ const token = await ensureCognitoToken();
234
+ const companySlug = members.opts().company as string | undefined;
235
+ const companyUid = await getCompanyUid(token, companySlug);
236
+ const callerUid = await getCallerPersonUid(token);
237
+
238
+ const result = await inviteMember({
239
+ target,
240
+ role: opts.role,
241
+ paths: opts.paths,
242
+ companyUid,
243
+ callerUid,
244
+ token,
245
+ });
246
+
247
+ console.log(
248
+ chalk.green(
249
+ `Invited ${target} as ${result.membership.role} (status: ${result.membership.status})`,
250
+ ),
251
+ );
252
+ console.log();
253
+ console.log(chalk.bold("Magic link:"));
254
+ console.log(` ${result.magicLink}`);
255
+ console.log();
256
+ console.log(
257
+ chalk.dim(
258
+ "Share this link with the invitee. They can run `hq onboard join --invite-token <token>` to accept.",
259
+ ),
260
+ );
261
+ } catch (err) {
262
+ if (err instanceof InviteHttpError) {
263
+ console.error(chalk.red(formatInviteHttpError(err.status, err.message)));
264
+ process.exit(1);
265
+ }
266
+ console.error(
267
+ chalk.red("Error:"),
268
+ err instanceof Error ? err.message : String(err),
269
+ );
270
+ process.exit(1);
271
+ }
272
+ },
273
+ );
274
+
275
+ members
276
+ .command("list")
277
+ .description("List pending invites for the company")
278
+ .action(async () => {
279
+ try {
280
+ const token = await ensureCognitoToken();
281
+ const companySlug = members.opts().company as string | undefined;
282
+ const companyUid = await getCompanyUid(token, companySlug);
283
+
284
+ const invites = await listPendingInvites(token, companyUid);
285
+
286
+ if (invites.length === 0) {
287
+ console.log(chalk.gray("No pending invites for this company."));
288
+ return;
289
+ }
290
+
291
+ const targetW = Math.max(
292
+ 6,
293
+ ...invites.map((i) => (i.inviteeEmail ?? i.personUid ?? "").length),
294
+ );
295
+ const roleW = Math.max(4, ...invites.map((i) => i.role.length));
296
+ const byW = Math.max(10, ...invites.map((i) => i.invitedBy.length));
297
+ const keyW = Math.max(14, ...invites.map((i) => i.membershipKey.length));
298
+ console.log(
299
+ chalk.bold(
300
+ [
301
+ "TARGET".padEnd(targetW),
302
+ "ROLE".padEnd(roleW),
303
+ "INVITED_BY".padEnd(byW),
304
+ "INVITED_AT",
305
+ "MEMBERSHIP_KEY".padEnd(keyW),
306
+ ].join(" "),
307
+ ),
308
+ );
309
+ for (const inv of invites) {
310
+ const target = inv.inviteeEmail ?? inv.personUid ?? "";
311
+ console.log(
312
+ [
313
+ target.padEnd(targetW),
314
+ inv.role.padEnd(roleW),
315
+ inv.invitedBy.padEnd(byW),
316
+ shortDate(inv.invitedAt),
317
+ inv.membershipKey.padEnd(keyW),
318
+ ].join(" "),
319
+ );
320
+ }
321
+ } catch (err) {
322
+ if (err instanceof InviteHttpError) {
323
+ const msg =
324
+ err.status === 403
325
+ ? "Not authorized — only admins and owners can list invites"
326
+ : formatInviteHttpError(err.status, err.message);
327
+ console.error(chalk.red(msg));
328
+ process.exit(1);
329
+ }
330
+ console.error(
331
+ chalk.red("Error:"),
332
+ err instanceof Error ? err.message : String(err),
333
+ );
334
+ process.exit(1);
335
+ }
336
+ });
337
+
338
+ members
339
+ .command("revoke <tokenOrKey>")
340
+ .description("Revoke a pending invite (accepts the inviteToken or membershipKey)")
341
+ .action(async (tokenOrKey: string) => {
342
+ try {
343
+ const token = await ensureCognitoToken();
344
+ const companySlug = members.opts().company as string | undefined;
345
+ const companyUid = await getCompanyUid(token, companySlug);
346
+
347
+ await revokeInvite(token, tokenOrKey, companyUid);
348
+ console.log(chalk.green(`Revoked invite '${tokenOrKey}'`));
349
+ } catch (err) {
350
+ if (err instanceof InviteHttpError) {
351
+ const msg =
352
+ err.status === 403
353
+ ? "Not authorized — only admins and owners can revoke invites"
354
+ : err.status === 404
355
+ ? "Invite not found — it may have already been accepted or revoked"
356
+ : formatInviteHttpError(err.status, err.message);
357
+ console.error(chalk.red(msg));
358
+ process.exit(1);
359
+ }
360
+ console.error(
361
+ chalk.red("Error:"),
362
+ err instanceof Error ? err.message : String(err),
363
+ );
364
+ process.exit(1);
365
+ }
366
+ });
367
+ }
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ import { registerListCommand } from "./commands/list.js";
12
12
  import { registerUpdateCommand } from "./commands/update.js";
13
13
  import { registerCloudCommands } from "./commands/cloud.js";
14
14
  import { registerCloudProvisionCommands } from "./commands/cloud-provision.js";
15
+ import { registerCloudDemoteCommands } from "./commands/cloud-demote.js";
15
16
  import { registerLoginCommand } from "./commands/login.js";
16
17
  import { registerLogoutCommand } from "./commands/logout.js";
17
18
  import { registerWhoamiCommand } from "./commands/whoami.js";
@@ -26,6 +27,7 @@ import { registerSecretsCommand } from "./commands/secrets.js";
26
27
  import { registerRunCommand } from "./commands/run.js";
27
28
  import { registerGroupsCommand } from "./commands/groups.js";
28
29
  import { registerFilesCommand } from "./commands/files.js";
30
+ import { registerMembersCommand } from "./commands/members.js";
29
31
 
30
32
  initSentry();
31
33
 
@@ -78,6 +80,7 @@ const cloudCmd = program
78
80
  );
79
81
 
80
82
  registerCloudProvisionCommands(cloudCmd);
83
+ registerCloudDemoteCommands(cloudCmd);
81
84
 
82
85
  // Team commands (top-level)
83
86
  registerTeamSyncCommand(program);
@@ -100,6 +103,9 @@ registerGroupsCommand(program);
100
103
  // Files ACL management (subcommand group — hq files share|unshare|acl)
101
104
  registerFilesCommand(program);
102
105
 
106
+ // Membership management (subcommand group — hq members invite|list|revoke)
107
+ registerMembersCommand(program);
108
+
103
109
  // Onboarding (top-level — Cognito + vault-service provisioning)
104
110
  registerOnboardCommand(program);
105
111