@indigoai-us/hq-cli 5.10.0 → 5.11.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.
@@ -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
+ }
@@ -10,9 +10,40 @@ import {
10
10
  clearAllCache,
11
11
  } from "../utils/secrets-cache.js";
12
12
  import { SECRET_NAME_PATTERN, GROUP_ID_PATTERN } from "./_patterns.js";
13
- import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
13
+ import {
14
+ vaultApiFetch,
15
+ getCompanyUid,
16
+ getEntityUid,
17
+ } from "../utils/vault-api.js";
14
18
  export type { VaultApiOptions } from "../utils/vault-api.js";
15
- export { vaultApiFetch, getCompanyUid };
19
+ export { vaultApiFetch, getCompanyUid, getEntityUid };
20
+
21
+ interface SecretsScopeOpts {
22
+ company?: string;
23
+ personal?: boolean;
24
+ }
25
+
26
+ function scopeOpts(opts: SecretsScopeOpts): {
27
+ personal: boolean;
28
+ companySlug: string | undefined;
29
+ } {
30
+ if (opts.personal && opts.company) {
31
+ console.error(
32
+ chalk.red("Error: --personal cannot be combined with --company."),
33
+ );
34
+ process.exit(1);
35
+ }
36
+ return { personal: !!opts.personal, companySlug: opts.company };
37
+ }
38
+
39
+ function rejectIfPersonal(opts: SecretsScopeOpts, action: string): void {
40
+ if (opts.personal) {
41
+ console.error(
42
+ chalk.red(`Error: ${action} is not supported with --personal.`),
43
+ );
44
+ process.exit(1);
45
+ }
46
+ }
16
47
 
17
48
  function shellSingleQuote(value: string): string {
18
49
  return "'" + value.replace(/'/g, "'\\''") + "'";
@@ -125,7 +156,11 @@ export function registerSecretsCommand(program: Command): void {
125
156
  const secrets = program
126
157
  .command("secrets")
127
158
  .description("Manage secrets in HQ vault (SSM Parameter Store)")
128
- .option("--company <slug>", "Company slug (resolves to companyUid)");
159
+ .option("--company <slug>", "Company slug (resolves to companyUid)")
160
+ .option(
161
+ "--personal",
162
+ "Operate on the caller's personal vault (no sharing)",
163
+ );
129
164
 
130
165
  secrets
131
166
  .command("set <name>")
@@ -164,8 +199,10 @@ export function registerSecretsCommand(program: Command): void {
164
199
  }
165
200
 
166
201
  const token = await ensureCognitoToken();
167
- const companySlug = secrets.opts().company as string | undefined;
168
- const companyUid = await getCompanyUid(token, companySlug);
202
+ const companyUid = await getEntityUid(
203
+ token,
204
+ scopeOpts(secrets.opts()),
205
+ );
169
206
 
170
207
  const res = await vaultApiFetch({
171
208
  token,
@@ -200,8 +237,10 @@ export function registerSecretsCommand(program: Command): void {
200
237
  .action(async (name: string, opts: { reveal?: boolean }) => {
201
238
  try {
202
239
  const token = await ensureCognitoToken();
203
- const companySlug = secrets.opts().company as string | undefined;
204
- const companyUid = await getCompanyUid(token, companySlug);
240
+ const companyUid = await getEntityUid(
241
+ token,
242
+ scopeOpts(secrets.opts()),
243
+ );
205
244
 
206
245
  const query: Record<string, string> = {};
207
246
  if (opts.reveal) {
@@ -283,8 +322,10 @@ export function registerSecretsCommand(program: Command): void {
283
322
  }
284
323
 
285
324
  const token = await ensureCognitoToken();
286
- const companySlug = secrets.opts().company as string | undefined;
287
- const companyUid = await getCompanyUid(token, companySlug);
325
+ const companyUid = await getEntityUid(
326
+ token,
327
+ scopeOpts(secrets.opts()),
328
+ );
288
329
 
289
330
  const query: Record<string, string> = {};
290
331
  if (normalizedPrefix) {
@@ -364,8 +405,10 @@ export function registerSecretsCommand(program: Command): void {
364
405
  }
365
406
 
366
407
  const token = await ensureCognitoToken();
367
- const companySlug = secrets.opts().company as string | undefined;
368
- const companyUid = await getCompanyUid(token, companySlug);
408
+ const companyUid = await getEntityUid(
409
+ token,
410
+ scopeOpts(secrets.opts()),
411
+ );
369
412
 
370
413
  const res = await vaultApiFetch({
371
414
  token,
@@ -427,8 +470,10 @@ export function registerSecretsCommand(program: Command): void {
427
470
  }
428
471
 
429
472
  const token = await ensureCognitoToken();
430
- const companySlug = secrets.opts().company as string | undefined;
431
- const companyUid = await getCompanyUid(token, companySlug);
473
+ const companyUid = await getEntityUid(
474
+ token,
475
+ scopeOpts(secrets.opts()),
476
+ );
432
477
 
433
478
  const revealed = await Promise.all(
434
479
  keys.map(async (key) => {
@@ -518,8 +563,10 @@ export function registerSecretsCommand(program: Command): void {
518
563
  }
519
564
 
520
565
  const token = await ensureCognitoToken();
521
- const companySlug = secrets.opts().company as string | undefined;
522
- const companyUid = await getCompanyUid(token, companySlug);
566
+ const companyUid = await getEntityUid(
567
+ token,
568
+ scopeOpts(secrets.opts()),
569
+ );
523
570
 
524
571
  const revealed = await Promise.all(
525
572
  keys.map(async (key) => {
@@ -568,6 +615,8 @@ export function registerSecretsCommand(program: Command): void {
568
615
  .option("--expires <duration>", "Token expiry duration (e.g. 24h, 2d, 30m)", "24h")
569
616
  .action(async (name: string, opts: { expires: string }) => {
570
617
  try {
618
+ rejectIfPersonal(secrets.opts(), "generate-link");
619
+
571
620
  if (!SECRET_NAME_PATTERN.test(name)) {
572
621
  console.error(chalk.red(`Invalid secret name '${name}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_API_KEY or DEV/MY_KEY)`));
573
622
  process.exit(1);
@@ -586,8 +635,10 @@ export function registerSecretsCommand(program: Command): void {
586
635
  }
587
636
 
588
637
  const token = await ensureCognitoToken();
589
- const companySlug = secrets.opts().company as string | undefined;
590
- const companyUid = await getCompanyUid(token, companySlug);
638
+ const companyUid = await getEntityUid(
639
+ token,
640
+ scopeOpts(secrets.opts()),
641
+ );
591
642
 
592
643
  const res = await vaultApiFetch({
593
644
  token,
@@ -632,6 +683,8 @@ export function registerSecretsCommand(program: Command): void {
632
683
  .requiredOption("--permission <level>", "Permission level: read | write | admin")
633
684
  .action(async (path: string, opts: { with: string; permission: string }) => {
634
685
  try {
686
+ rejectIfPersonal(secrets.opts(), "share");
687
+
635
688
  if (!SECRET_NAME_PATTERN.test(path)) {
636
689
  console.error(chalk.red(`Invalid secret path '${path}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_KEY or PROD/DB_PASSWORD)`));
637
690
  process.exit(1);
@@ -651,8 +704,10 @@ export function registerSecretsCommand(program: Command): void {
651
704
  const granteeId = opts.with;
652
705
 
653
706
  const token = await ensureCognitoToken();
654
- const companySlug = secrets.opts().company as string | undefined;
655
- const companyUid = await getCompanyUid(token, companySlug);
707
+ const companyUid = await getEntityUid(
708
+ token,
709
+ scopeOpts(secrets.opts()),
710
+ );
656
711
 
657
712
  const res = await vaultApiFetch({
658
713
  token,
@@ -695,6 +750,8 @@ export function registerSecretsCommand(program: Command): void {
695
750
  .requiredOption("--from <principal>", "Email address or group id to remove")
696
751
  .action(async (path: string, opts: { from: string }) => {
697
752
  try {
753
+ rejectIfPersonal(secrets.opts(), "unshare");
754
+
698
755
  if (!SECRET_NAME_PATTERN.test(path)) {
699
756
  console.error(chalk.red(`Invalid secret path '${path}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_KEY or PROD/DB_PASSWORD)`));
700
757
  process.exit(1);
@@ -709,8 +766,10 @@ export function registerSecretsCommand(program: Command): void {
709
766
  const granteeId = opts.from;
710
767
 
711
768
  const token = await ensureCognitoToken();
712
- const companySlug = secrets.opts().company as string | undefined;
713
- const companyUid = await getCompanyUid(token, companySlug);
769
+ const companyUid = await getEntityUid(
770
+ token,
771
+ scopeOpts(secrets.opts()),
772
+ );
714
773
 
715
774
  const res = await vaultApiFetch({
716
775
  token,
@@ -751,14 +810,18 @@ export function registerSecretsCommand(program: Command): void {
751
810
  .description("Show the ACL (access control list) for a secret path")
752
811
  .action(async (path: string) => {
753
812
  try {
813
+ rejectIfPersonal(secrets.opts(), "acl");
814
+
754
815
  if (!SECRET_NAME_PATTERN.test(path)) {
755
816
  console.error(chalk.red(`Invalid secret path '${path}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_KEY or PROD/DB_PASSWORD)`));
756
817
  process.exit(1);
757
818
  }
758
819
 
759
820
  const token = await ensureCognitoToken();
760
- const companySlug = secrets.opts().company as string | undefined;
761
- const companyUid = await getCompanyUid(token, companySlug);
821
+ const companyUid = await getEntityUid(
822
+ token,
823
+ scopeOpts(secrets.opts()),
824
+ );
762
825
 
763
826
  const secretPath = path;
764
827
  const res = await vaultApiFetch({
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