@indigoai-us/hq-cli 5.10.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,53 @@
1
+ import { Command } from "commander";
2
+ export declare const VALID_ROLES: Set<string>;
3
+ export type Role = "owner" | "admin" | "member" | "guest";
4
+ export interface PendingInvite {
5
+ membershipKey: string;
6
+ personUid?: string;
7
+ inviteeEmail?: string;
8
+ companyUid: string;
9
+ role: string;
10
+ status: string;
11
+ inviteToken?: string;
12
+ invitedBy: string;
13
+ invitedAt: string;
14
+ }
15
+ export interface InviteOptions {
16
+ target: string;
17
+ role: string;
18
+ paths?: string;
19
+ companyUid: string;
20
+ callerUid: string;
21
+ token: string;
22
+ }
23
+ export interface InviteResult {
24
+ inviteToken: string;
25
+ magicLink: string;
26
+ membership: {
27
+ role: string;
28
+ status: string;
29
+ };
30
+ }
31
+ export interface DetectedTarget {
32
+ type: "email" | "person";
33
+ value: string;
34
+ }
35
+ export declare function detectTarget(target: string): DetectedTarget | null;
36
+ export declare function shortDate(iso: string): string;
37
+ /**
38
+ * Resolve the caller's personUid by reading their own membership list.
39
+ * The server infers the JWT identity, so this returns the canonical
40
+ * personUid attached to the caller's active memberships.
41
+ */
42
+ export declare function getCallerPersonUid(token: string): Promise<string>;
43
+ /** Send a `/membership/invite` request and return the magic link. */
44
+ export declare function inviteMember(options: InviteOptions): Promise<InviteResult>;
45
+ export declare class InviteHttpError extends Error {
46
+ status: number;
47
+ constructor(status: number, message: string);
48
+ }
49
+ export declare function formatInviteHttpError(status: number, fallback: string): string;
50
+ export declare function listPendingInvites(token: string, companyUid: string): Promise<PendingInvite[]>;
51
+ export declare function revokeInvite(token: string, tokenOrKey: string, companyUid: string): Promise<void>;
52
+ export declare function registerMembersCommand(program: Command): void;
53
+ //# sourceMappingURL=members.d.ts.map
@@ -0,0 +1,240 @@
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]="1b3646e3-32c8-5601-9dba-1d7c0f6cf972")}catch(e){}}();
3
+ import chalk from "chalk";
4
+ import { ensureCognitoToken } from "../utils/cognito-session.js";
5
+ import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
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
+ export function detectTarget(target) {
10
+ if (EMAIL_PATTERN.test(target)) {
11
+ return { type: "email", value: target.trim().toLowerCase() };
12
+ }
13
+ if (PERSON_UID_PATTERN.test(target)) {
14
+ return { type: "person", value: target };
15
+ }
16
+ return null;
17
+ }
18
+ export function shortDate(iso) {
19
+ return iso.slice(0, 10);
20
+ }
21
+ /**
22
+ * Resolve the caller's personUid by reading their own membership list.
23
+ * The server infers the JWT identity, so this returns the canonical
24
+ * personUid attached to the caller's active memberships.
25
+ */
26
+ export async function getCallerPersonUid(token) {
27
+ const res = await vaultApiFetch({ token, path: "/membership/me" });
28
+ if (!res.ok) {
29
+ throw new Error("Failed to resolve caller identity — run `hq login` and try again");
30
+ }
31
+ const data = (await res.json());
32
+ const personUid = data.memberships.find((m) => m.personUid)?.personUid;
33
+ if (!personUid) {
34
+ throw new Error("Your account has no person entity yet. Run `hq onboard create-company` or accept an invite first.");
35
+ }
36
+ return personUid;
37
+ }
38
+ /** Send a `/membership/invite` request and return the magic link. */
39
+ export async function inviteMember(options) {
40
+ if (!VALID_ROLES.has(options.role)) {
41
+ throw new Error(`Invalid role '${options.role}': must be one of owner, admin, member, guest`);
42
+ }
43
+ if (options.paths && options.role !== "guest") {
44
+ throw new Error("--paths is only valid with --role guest (allowedPrefixes are only meaningful for the guest role)");
45
+ }
46
+ const detected = detectTarget(options.target);
47
+ if (!detected) {
48
+ throw new Error(`Invalid target '${options.target}': must be an email address or a personUid matching prs_<alphanumeric>`);
49
+ }
50
+ const allowedPrefixes = options.paths
51
+ ? options.paths.split(",").map((p) => p.trim()).filter(Boolean)
52
+ : undefined;
53
+ const body = {
54
+ companyUid: options.companyUid,
55
+ role: options.role,
56
+ invitedBy: options.callerUid,
57
+ };
58
+ if (detected.type === "email")
59
+ body.inviteeEmail = detected.value;
60
+ else
61
+ body.personUid = detected.value;
62
+ if (allowedPrefixes)
63
+ body.allowedPrefixes = allowedPrefixes;
64
+ const res = await vaultApiFetch({
65
+ token: options.token,
66
+ path: "/membership/invite",
67
+ method: "POST",
68
+ body,
69
+ });
70
+ if (!res.ok) {
71
+ const err = (await res.json().catch(() => ({})));
72
+ throw new InviteHttpError(res.status, err.message ?? err.error ?? res.statusText);
73
+ }
74
+ const data = (await res.json());
75
+ return {
76
+ inviteToken: data.inviteToken,
77
+ magicLink: `hq://accept/${data.inviteToken}`,
78
+ membership: data.membership,
79
+ };
80
+ }
81
+ export class InviteHttpError extends Error {
82
+ status;
83
+ constructor(status, message) {
84
+ super(message);
85
+ this.status = status;
86
+ this.name = "InviteHttpError";
87
+ }
88
+ }
89
+ export function formatInviteHttpError(status, fallback) {
90
+ if (status === 401)
91
+ return "Not authenticated — please run `hq login`";
92
+ if (status === 403) {
93
+ return "Not authorized — only admins and owners can invite members";
94
+ }
95
+ if (status === 409) {
96
+ return "This person already has a membership or pending invite for this company";
97
+ }
98
+ if (status >= 500)
99
+ return `Server error: ${fallback}`;
100
+ return fallback;
101
+ }
102
+ export async function listPendingInvites(token, companyUid) {
103
+ const res = await vaultApiFetch({
104
+ token,
105
+ path: `/membership/company/${encodeURIComponent(companyUid)}/pending`,
106
+ });
107
+ if (!res.ok) {
108
+ const err = (await res.json().catch(() => ({})));
109
+ throw new InviteHttpError(res.status, err.message ?? err.error ?? res.statusText);
110
+ }
111
+ const data = (await res.json());
112
+ return data.invites;
113
+ }
114
+ export async function revokeInvite(token, tokenOrKey, companyUid) {
115
+ const res = await vaultApiFetch({
116
+ token,
117
+ path: "/membership/revoke",
118
+ method: "POST",
119
+ body: { membershipKey: tokenOrKey, companyUid },
120
+ });
121
+ if (!res.ok) {
122
+ const err = (await res.json().catch(() => ({})));
123
+ throw new InviteHttpError(res.status, err.message ?? err.error ?? res.statusText);
124
+ }
125
+ }
126
+ export function registerMembersCommand(program) {
127
+ const members = program
128
+ .command("members")
129
+ .description("Manage company memberships and invites")
130
+ .option("--company <slug>", "Company slug (resolves to companyUid)");
131
+ members
132
+ .command("invite <target>")
133
+ .description("Invite a person to the company by email or personUid (prints a magic link)")
134
+ .option("--role <role>", "Role for the invitee: owner, admin, member, or guest", "member")
135
+ .option("--paths <prefixes>", "Comma-separated allowed prefixes (only valid with --role guest)")
136
+ .action(async (target, opts) => {
137
+ try {
138
+ const token = await ensureCognitoToken();
139
+ const companySlug = members.opts().company;
140
+ const companyUid = await getCompanyUid(token, companySlug);
141
+ const callerUid = await getCallerPersonUid(token);
142
+ const result = await inviteMember({
143
+ target,
144
+ role: opts.role,
145
+ paths: opts.paths,
146
+ companyUid,
147
+ callerUid,
148
+ token,
149
+ });
150
+ console.log(chalk.green(`Invited ${target} as ${result.membership.role} (status: ${result.membership.status})`));
151
+ console.log();
152
+ console.log(chalk.bold("Magic link:"));
153
+ console.log(` ${result.magicLink}`);
154
+ console.log();
155
+ console.log(chalk.dim("Share this link with the invitee. They can run `hq onboard join --invite-token <token>` to accept."));
156
+ }
157
+ catch (err) {
158
+ if (err instanceof InviteHttpError) {
159
+ console.error(chalk.red(formatInviteHttpError(err.status, err.message)));
160
+ process.exit(1);
161
+ }
162
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
163
+ process.exit(1);
164
+ }
165
+ });
166
+ members
167
+ .command("list")
168
+ .description("List pending invites for the company")
169
+ .action(async () => {
170
+ try {
171
+ const token = await ensureCognitoToken();
172
+ const companySlug = members.opts().company;
173
+ const companyUid = await getCompanyUid(token, companySlug);
174
+ const invites = await listPendingInvites(token, companyUid);
175
+ if (invites.length === 0) {
176
+ console.log(chalk.gray("No pending invites for this company."));
177
+ return;
178
+ }
179
+ const targetW = Math.max(6, ...invites.map((i) => (i.inviteeEmail ?? i.personUid ?? "").length));
180
+ const roleW = Math.max(4, ...invites.map((i) => i.role.length));
181
+ const byW = Math.max(10, ...invites.map((i) => i.invitedBy.length));
182
+ const keyW = Math.max(14, ...invites.map((i) => i.membershipKey.length));
183
+ console.log(chalk.bold([
184
+ "TARGET".padEnd(targetW),
185
+ "ROLE".padEnd(roleW),
186
+ "INVITED_BY".padEnd(byW),
187
+ "INVITED_AT",
188
+ "MEMBERSHIP_KEY".padEnd(keyW),
189
+ ].join(" ")));
190
+ for (const inv of invites) {
191
+ const target = inv.inviteeEmail ?? inv.personUid ?? "";
192
+ console.log([
193
+ target.padEnd(targetW),
194
+ inv.role.padEnd(roleW),
195
+ inv.invitedBy.padEnd(byW),
196
+ shortDate(inv.invitedAt),
197
+ inv.membershipKey.padEnd(keyW),
198
+ ].join(" "));
199
+ }
200
+ }
201
+ catch (err) {
202
+ if (err instanceof InviteHttpError) {
203
+ const msg = err.status === 403
204
+ ? "Not authorized — only admins and owners can list invites"
205
+ : formatInviteHttpError(err.status, err.message);
206
+ console.error(chalk.red(msg));
207
+ process.exit(1);
208
+ }
209
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
210
+ process.exit(1);
211
+ }
212
+ });
213
+ members
214
+ .command("revoke <tokenOrKey>")
215
+ .description("Revoke a pending invite (accepts the inviteToken or membershipKey)")
216
+ .action(async (tokenOrKey) => {
217
+ try {
218
+ const token = await ensureCognitoToken();
219
+ const companySlug = members.opts().company;
220
+ const companyUid = await getCompanyUid(token, companySlug);
221
+ await revokeInvite(token, tokenOrKey, companyUid);
222
+ console.log(chalk.green(`Revoked invite '${tokenOrKey}'`));
223
+ }
224
+ catch (err) {
225
+ if (err instanceof InviteHttpError) {
226
+ const msg = err.status === 403
227
+ ? "Not authorized — only admins and owners can revoke invites"
228
+ : err.status === 404
229
+ ? "Invite not found — it may have already been accepted or revoked"
230
+ : formatInviteHttpError(err.status, err.message);
231
+ console.error(chalk.red(msg));
232
+ process.exit(1);
233
+ }
234
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
235
+ process.exit(1);
236
+ }
237
+ });
238
+ }
239
+ //# sourceMappingURL=members.js.map
240
+ //# debugId=1b3646e3-32c8-5601-9dba-1d7c0f6cf972
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * HQ CLI - Module management, package management, and cloud sync for HQ
4
4
  */
5
5
 
6
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="ebc3116c-f836-550a-b5bb-4a74bce24abc")}catch(e){}}();
6
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="e834c407-cd5c-5dc1-af3b-7f041af651ef")}catch(e){}}();
7
7
  import { Command } from "commander";
8
8
  import { initSentry, Sentry } from "./sentry.js";
9
9
  import { registerAddCommand } from "./commands/add.js";
@@ -27,6 +27,7 @@ import { registerSecretsCommand } from "./commands/secrets.js";
27
27
  import { registerRunCommand } from "./commands/run.js";
28
28
  import { registerGroupsCommand } from "./commands/groups.js";
29
29
  import { registerFilesCommand } from "./commands/files.js";
30
+ import { registerMembersCommand } from "./commands/members.js";
30
31
  initSentry();
31
32
  const program = new Command();
32
33
  program
@@ -81,6 +82,8 @@ registerRunCommand(program);
81
82
  registerGroupsCommand(program);
82
83
  // Files ACL management (subcommand group — hq files share|unshare|acl)
83
84
  registerFilesCommand(program);
85
+ // Membership management (subcommand group — hq members invite|list|revoke)
86
+ registerMembersCommand(program);
84
87
  // Onboarding (top-level — Cognito + vault-service provisioning)
85
88
  registerOnboardCommand(program);
86
89
  (async () => {
@@ -96,4 +99,4 @@ registerOnboardCommand(program);
96
99
  }
97
100
  })();
98
101
  //# sourceMappingURL=index.js.map
99
- //# debugId=ebc3116c-f836-550a-b5bb-4a74bce24abc
102
+ //# debugId=e834c407-cd5c-5dc1-af3b-7f041af651ef
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.10.0",
3
+ "version": "5.10.1",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -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
@@ -27,6 +27,7 @@ import { registerSecretsCommand } from "./commands/secrets.js";
27
27
  import { registerRunCommand } from "./commands/run.js";
28
28
  import { registerGroupsCommand } from "./commands/groups.js";
29
29
  import { registerFilesCommand } from "./commands/files.js";
30
+ import { registerMembersCommand } from "./commands/members.js";
30
31
 
31
32
  initSentry();
32
33
 
@@ -102,6 +103,9 @@ registerGroupsCommand(program);
102
103
  // Files ACL management (subcommand group — hq files share|unshare|acl)
103
104
  registerFilesCommand(program);
104
105
 
106
+ // Membership management (subcommand group — hq members invite|list|revoke)
107
+ registerMembersCommand(program);
108
+
105
109
  // Onboarding (top-level — Cognito + vault-service provisioning)
106
110
  registerOnboardCommand(program);
107
111