@indigoai-us/hq-cli 5.5.2 → 5.5.3

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 (121) hide show
  1. package/dist/bin/hq-auth-refresh.js +26 -13
  2. package/dist/commands/_patterns.d.ts +3 -0
  3. package/dist/commands/_patterns.js +6 -0
  4. package/dist/commands/add.js +4 -1
  5. package/dist/commands/auth.js +4 -1
  6. package/dist/commands/cloud.js +4 -1
  7. package/dist/commands/groups.d.ts +3 -0
  8. package/dist/commands/groups.js +344 -0
  9. package/dist/commands/list.js +4 -1
  10. package/dist/commands/login.js +4 -1
  11. package/dist/commands/logout.js +4 -1
  12. package/dist/commands/onboard.js +4 -1
  13. package/dist/commands/pack-install.js +4 -1
  14. package/dist/commands/pkg-install.js +4 -1
  15. package/dist/commands/pkg-list.js +4 -1
  16. package/dist/commands/pkg-remove.js +4 -1
  17. package/dist/commands/pkg-update.js +4 -1
  18. package/dist/commands/secrets.d.ts +9 -0
  19. package/dist/commands/secrets.js +263 -25
  20. package/dist/commands/sync.js +4 -1
  21. package/dist/commands/team-sync.js +4 -1
  22. package/dist/commands/update.js +4 -1
  23. package/dist/commands/whoami.js +4 -1
  24. package/dist/index.js +21 -2
  25. package/dist/sentry-before-send.d.ts +3 -0
  26. package/dist/sentry-before-send.js +219 -0
  27. package/dist/sentry-dsn.generated.d.ts +2 -0
  28. package/dist/sentry-dsn.generated.js +5 -0
  29. package/dist/sentry.d.ts +4 -0
  30. package/dist/sentry.js +22 -0
  31. package/dist/strategies/link.js +4 -1
  32. package/dist/strategies/merge.js +4 -1
  33. package/dist/types.js +4 -1
  34. package/dist/utils/cognito-session.js +4 -1
  35. package/dist/utils/git.js +4 -1
  36. package/dist/utils/hq-root.js +4 -1
  37. package/dist/utils/integrity.js +4 -1
  38. package/dist/utils/manifest.js +4 -1
  39. package/dist/utils/registry-client.js +4 -1
  40. package/dist/utils/registry.js +4 -1
  41. package/dist/utils/secrets-cache.js +6 -2
  42. package/package.json +4 -3
  43. package/scripts/generate-dsn.mjs +26 -0
  44. package/src/bin/hq-auth-refresh.ts +23 -16
  45. package/src/commands/_patterns.ts +2 -0
  46. package/src/commands/groups.ts +357 -0
  47. package/src/commands/secrets.ts +306 -26
  48. package/src/index.ts +17 -1
  49. package/src/sentry-before-send.test.ts +359 -0
  50. package/src/sentry-before-send.ts +221 -0
  51. package/src/sentry.test.ts +24 -0
  52. package/src/sentry.ts +19 -0
  53. package/src/utils/secrets-cache.ts +2 -1
  54. package/dist/bin/hq-auth-refresh.d.ts.map +0 -1
  55. package/dist/bin/hq-auth-refresh.js.map +0 -1
  56. package/dist/commands/add.d.ts.map +0 -1
  57. package/dist/commands/add.js.map +0 -1
  58. package/dist/commands/auth.d.ts.map +0 -1
  59. package/dist/commands/auth.js.map +0 -1
  60. package/dist/commands/cloud.d.ts.map +0 -1
  61. package/dist/commands/cloud.js.map +0 -1
  62. package/dist/commands/list.d.ts.map +0 -1
  63. package/dist/commands/list.js.map +0 -1
  64. package/dist/commands/login.d.ts.map +0 -1
  65. package/dist/commands/login.js.map +0 -1
  66. package/dist/commands/logout.d.ts.map +0 -1
  67. package/dist/commands/logout.js.map +0 -1
  68. package/dist/commands/onboard.d.ts.map +0 -1
  69. package/dist/commands/onboard.js.map +0 -1
  70. package/dist/commands/pack-install.d.ts.map +0 -1
  71. package/dist/commands/pack-install.js.map +0 -1
  72. package/dist/commands/pkg-install.d.ts.map +0 -1
  73. package/dist/commands/pkg-install.js.map +0 -1
  74. package/dist/commands/pkg-list.d.ts.map +0 -1
  75. package/dist/commands/pkg-list.js.map +0 -1
  76. package/dist/commands/pkg-remove.d.ts.map +0 -1
  77. package/dist/commands/pkg-remove.js.map +0 -1
  78. package/dist/commands/pkg-update.d.ts.map +0 -1
  79. package/dist/commands/pkg-update.js.map +0 -1
  80. package/dist/commands/secrets.d.ts.map +0 -1
  81. package/dist/commands/secrets.js.map +0 -1
  82. package/dist/commands/sync.d.ts.map +0 -1
  83. package/dist/commands/sync.js.map +0 -1
  84. package/dist/commands/team-sync.d.ts.map +0 -1
  85. package/dist/commands/team-sync.js.map +0 -1
  86. package/dist/commands/update.d.ts.map +0 -1
  87. package/dist/commands/update.js.map +0 -1
  88. package/dist/commands/whoami.d.ts.map +0 -1
  89. package/dist/commands/whoami.js.map +0 -1
  90. package/dist/index.d.ts.map +0 -1
  91. package/dist/index.js.map +0 -1
  92. package/dist/strategies/link.d.ts.map +0 -1
  93. package/dist/strategies/link.js.map +0 -1
  94. package/dist/strategies/merge.d.ts.map +0 -1
  95. package/dist/strategies/merge.js.map +0 -1
  96. package/dist/types.d.ts.map +0 -1
  97. package/dist/types.js.map +0 -1
  98. package/dist/utils/auth.d.ts +0 -27
  99. package/dist/utils/auth.d.ts.map +0 -1
  100. package/dist/utils/auth.js +0 -155
  101. package/dist/utils/auth.js.map +0 -1
  102. package/dist/utils/cognito-session.d.ts.map +0 -1
  103. package/dist/utils/cognito-session.js.map +0 -1
  104. package/dist/utils/git.d.ts.map +0 -1
  105. package/dist/utils/git.js.map +0 -1
  106. package/dist/utils/hq-root.d.ts.map +0 -1
  107. package/dist/utils/hq-root.js.map +0 -1
  108. package/dist/utils/integrity.d.ts.map +0 -1
  109. package/dist/utils/integrity.js.map +0 -1
  110. package/dist/utils/manifest.d.ts.map +0 -1
  111. package/dist/utils/manifest.js.map +0 -1
  112. package/dist/utils/registry-client.d.ts.map +0 -1
  113. package/dist/utils/registry-client.js.map +0 -1
  114. package/dist/utils/registry.d.ts.map +0 -1
  115. package/dist/utils/registry.js.map +0 -1
  116. package/dist/utils/secrets-cache.d.ts.map +0 -1
  117. package/dist/utils/secrets-cache.js.map +0 -1
  118. package/dist/utils/token-store.d.ts +0 -28
  119. package/dist/utils/token-store.d.ts.map +0 -1
  120. package/dist/utils/token-store.js +0 -68
  121. package/dist/utils/token-store.js.map +0 -1
@@ -12,8 +12,9 @@ import {
12
12
  removeCacheEntry,
13
13
  clearAllCache,
14
14
  } from "../utils/secrets-cache.js";
15
+ import { SECRET_NAME_PATTERN, GROUP_ID_PATTERN } from "./_patterns.js";
15
16
 
16
- interface VaultApiOptions {
17
+ export interface VaultApiOptions {
17
18
  token: string;
18
19
  path: string;
19
20
  method?: string;
@@ -25,7 +26,18 @@ function shellSingleQuote(value: string): string {
25
26
  return "'" + value.replace(/'/g, "'\\''") + "'";
26
27
  }
27
28
 
28
- async function vaultApiFetch(opts: VaultApiOptions): Promise<Response> {
29
+ // API Gateway v2 URL-decodes `%2F` before route matching, so a full-name
30
+ // `encodeURIComponent` would collapse structural `/` separators into a single
31
+ // segment the `/name/{proxy+}` route can't match.
32
+ function buildSecretNamePath(companyUid: string, name: string): string {
33
+ const encodedName = name
34
+ .split("/")
35
+ .map(encodeURIComponent)
36
+ .join("/");
37
+ return `/secrets/${encodeURIComponent(companyUid)}/name/${encodedName}`;
38
+ }
39
+
40
+ export async function vaultApiFetch(opts: VaultApiOptions): Promise<Response> {
29
41
  const url = new URL(opts.path, DEFAULT_VAULT_API_URL);
30
42
  if (opts.query) {
31
43
  for (const [k, v] of Object.entries(opts.query)) {
@@ -91,7 +103,7 @@ async function resolveCompanyFromMemberships(
91
103
  );
92
104
  }
93
105
 
94
- async function getCompanyUid(
106
+ export async function getCompanyUid(
95
107
  token: string,
96
108
  companySlug: string | undefined,
97
109
  ): Promise<string> {
@@ -205,8 +217,8 @@ export function registerSecretsCommand(program: Command): void {
205
217
  .option("--from-stdin", "Read secret value from piped stdin")
206
218
  .action(async (name: string, opts: { fromStdin?: boolean }) => {
207
219
  try {
208
- if (!/^[A-Z][A-Z0-9_]*$/.test(name)) {
209
- console.error(chalk.red(`Invalid secret name '${name}': must match ^[A-Z][A-Z0-9_]*$ (e.g. MY_API_KEY)`));
220
+ if (!SECRET_NAME_PATTERN.test(name)) {
221
+ 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)`));
210
222
  process.exit(1);
211
223
  }
212
224
 
@@ -282,7 +294,7 @@ export function registerSecretsCommand(program: Command): void {
282
294
 
283
295
  const res = await vaultApiFetch({
284
296
  token,
285
- path: `/secrets/${encodeURIComponent(companyUid)}/${encodeURIComponent(name)}`,
297
+ path: buildSecretNamePath(companyUid, name),
286
298
  query: Object.keys(query).length > 0 ? query : undefined,
287
299
  });
288
300
 
@@ -329,16 +341,44 @@ export function registerSecretsCommand(program: Command): void {
329
341
 
330
342
  secrets
331
343
  .command("list")
332
- .description("List all secrets for the company")
333
- .action(async () => {
344
+ .description("List all secrets for the company (including nested path-based names)")
345
+ .option(
346
+ "--prefix <path>",
347
+ "Filter by path prefix (e.g. DEV or DEV/SUB); omit or pass empty string for all secrets",
348
+ )
349
+ .action(async (opts: { prefix?: string }) => {
334
350
  try {
351
+ // MUST match SECRET_PREFIX_PATTERN in hq-pro/src/vault-service/handlers/secrets.ts
352
+ let normalizedPrefix: string | undefined;
353
+ if (opts.prefix) {
354
+ const normalized = opts.prefix.replace(/^\/+|\/+$/g, "");
355
+ if (
356
+ normalized.length === 0 ||
357
+ !SECRET_NAME_PATTERN.test(normalized)
358
+ ) {
359
+ console.error(
360
+ chalk.red(
361
+ `Invalid prefix '${opts.prefix}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. DEV or DEV/SUB)`,
362
+ ),
363
+ );
364
+ process.exit(1);
365
+ }
366
+ normalizedPrefix = normalized;
367
+ }
368
+
335
369
  const token = await ensureCognitoToken();
336
370
  const companySlug = secrets.opts().company as string | undefined;
337
371
  const companyUid = await getCompanyUid(token, companySlug);
338
372
 
373
+ const query: Record<string, string> = {};
374
+ if (normalizedPrefix) {
375
+ query.prefix = normalizedPrefix;
376
+ }
377
+
339
378
  const res = await vaultApiFetch({
340
379
  token,
341
380
  path: `/secrets/${encodeURIComponent(companyUid)}`,
381
+ query: Object.keys(query).length > 0 ? query : undefined,
342
382
  });
343
383
 
344
384
  if (!res.ok) {
@@ -350,7 +390,7 @@ export function registerSecretsCommand(program: Command): void {
350
390
  }
351
391
 
352
392
  const data = (await res.json()) as {
353
- secrets: { name: string; lastModifiedDate?: string; version?: number }[];
393
+ secrets: { name: string; lastModifiedDate?: string; version?: number; permission?: "admin" | "write" | "read" }[];
354
394
  };
355
395
 
356
396
  if (data.secrets.length === 0) {
@@ -359,11 +399,23 @@ export function registerSecretsCommand(program: Command): void {
359
399
  }
360
400
 
361
401
  const nameWidth = Math.max(4, ...data.secrets.map((s) => s.name.length));
362
- const header = `${"NAME".padEnd(nameWidth)} LAST MODIFIED`;
363
- console.log(chalk.bold(header));
364
- for (const s of data.secrets) {
365
- const modified = s.lastModifiedDate ?? "-";
366
- console.log(`${s.name.padEnd(nameWidth)} ${modified}`);
402
+ const hasPermission = data.secrets.some((s) => s.permission !== undefined);
403
+ if (hasPermission) {
404
+ const accessWidth = Math.max(6, ...data.secrets.map((s) => (s.permission ?? "-").length));
405
+ const header = `${"NAME".padEnd(nameWidth)} ${"ACCESS".padEnd(accessWidth)} LAST MODIFIED`;
406
+ console.log(chalk.bold(header));
407
+ for (const s of data.secrets) {
408
+ const access = s.permission ?? "-";
409
+ const modified = s.lastModifiedDate ?? "-";
410
+ console.log(`${s.name.padEnd(nameWidth)} ${access.padEnd(accessWidth)} ${modified}`);
411
+ }
412
+ } else {
413
+ const header = `${"NAME".padEnd(nameWidth)} LAST MODIFIED`;
414
+ console.log(chalk.bold(header));
415
+ for (const s of data.secrets) {
416
+ const modified = s.lastModifiedDate ?? "-";
417
+ console.log(`${s.name.padEnd(nameWidth)} ${modified}`);
418
+ }
367
419
  }
368
420
  } catch (err) {
369
421
  console.error(
@@ -380,8 +432,8 @@ export function registerSecretsCommand(program: Command): void {
380
432
  .option("--force", "Skip confirmation prompt")
381
433
  .action(async (name: string, opts: { force?: boolean }) => {
382
434
  try {
383
- if (!/^[A-Z][A-Z0-9_]*$/.test(name)) {
384
- console.error(chalk.red(`Invalid secret name '${name}': must match ^[A-Z][A-Z0-9_]*$ (e.g. MY_API_KEY)`));
435
+ if (!SECRET_NAME_PATTERN.test(name)) {
436
+ 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)`));
385
437
  process.exit(1);
386
438
  }
387
439
 
@@ -401,7 +453,7 @@ export function registerSecretsCommand(program: Command): void {
401
453
 
402
454
  const res = await vaultApiFetch({
403
455
  token,
404
- path: `/secrets/${encodeURIComponent(companyUid)}/${encodeURIComponent(name)}`,
456
+ path: buildSecretNamePath(companyUid, name),
405
457
  method: "DELETE",
406
458
  });
407
459
 
@@ -452,8 +504,8 @@ export function registerSecretsCommand(program: Command): void {
452
504
  }
453
505
 
454
506
  for (const key of keys) {
455
- if (!/^[A-Z][A-Z0-9_]*$/.test(key)) {
456
- console.error(chalk.red(`Invalid secret name '${key}': must match ^[A-Z][A-Z0-9_]*$`));
507
+ if (!SECRET_NAME_PATTERN.test(key)) {
508
+ console.error(chalk.red(`Invalid secret name '${key}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$`));
457
509
  process.exit(1);
458
510
  }
459
511
  }
@@ -470,7 +522,7 @@ export function registerSecretsCommand(program: Command): void {
470
522
  }
471
523
  const res = await vaultApiFetch({
472
524
  token,
473
- path: `/secrets/${encodeURIComponent(companyUid)}/${encodeURIComponent(key)}`,
525
+ path: buildSecretNamePath(companyUid, key),
474
526
  query: { reveal: "true" },
475
527
  });
476
528
  if (!res.ok) {
@@ -543,8 +595,8 @@ export function registerSecretsCommand(program: Command): void {
543
595
  }
544
596
 
545
597
  for (const key of keys) {
546
- if (!/^[A-Z][A-Z0-9_]*$/.test(key)) {
547
- console.error(chalk.red(`Invalid secret name '${key}': must match ^[A-Z][A-Z0-9_]*$`));
598
+ if (!SECRET_NAME_PATTERN.test(key)) {
599
+ console.error(chalk.red(`Invalid secret name '${key}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$`));
548
600
  process.exit(1);
549
601
  }
550
602
  }
@@ -561,7 +613,7 @@ export function registerSecretsCommand(program: Command): void {
561
613
  }
562
614
  const res = await vaultApiFetch({
563
615
  token,
564
- path: `/secrets/${encodeURIComponent(companyUid)}/${encodeURIComponent(key)}`,
616
+ path: buildSecretNamePath(companyUid, key),
565
617
  query: { reveal: "true" },
566
618
  });
567
619
  if (!res.ok) {
@@ -600,8 +652,8 @@ export function registerSecretsCommand(program: Command): void {
600
652
  .option("--expires <duration>", "Token expiry duration (e.g. 24h, 2d, 30m)", "24h")
601
653
  .action(async (name: string, opts: { expires: string }) => {
602
654
  try {
603
- if (!/^[A-Z][A-Z0-9_]*$/.test(name)) {
604
- console.error(chalk.red(`Invalid secret name '${name}': must match ^[A-Z][A-Z0-9_]*$ (e.g. MY_API_KEY)`));
655
+ if (!SECRET_NAME_PATTERN.test(name)) {
656
+ 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)`));
605
657
  process.exit(1);
606
658
  }
607
659
 
@@ -623,9 +675,10 @@ export function registerSecretsCommand(program: Command): void {
623
675
 
624
676
  const res = await vaultApiFetch({
625
677
  token,
626
- path: `/secrets/${encodeURIComponent(companyUid)}/${encodeURIComponent(name)}/generate-token`,
678
+ path: buildSecretNamePath(companyUid, name),
627
679
  method: "POST",
628
680
  body: { expiresInMs },
681
+ query: { action: "generate-token" },
629
682
  });
630
683
 
631
684
  if (!res.ok) {
@@ -656,6 +709,233 @@ export function registerSecretsCommand(program: Command): void {
656
709
  }
657
710
  });
658
711
 
712
+ secrets
713
+ .command("share <path>")
714
+ .description("Share a secret with a person or group")
715
+ .requiredOption("--with <principal>", "Email address or group id to share with")
716
+ .requiredOption("--permission <level>", "Permission level: read | write | admin")
717
+ .action(async (path: string, opts: { with: string; permission: string }) => {
718
+ try {
719
+ if (!SECRET_NAME_PATTERN.test(path)) {
720
+ 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)`));
721
+ process.exit(1);
722
+ }
723
+
724
+ if (!["read", "write", "admin"].includes(opts.permission)) {
725
+ console.error(chalk.red(`Invalid permission '${opts.permission}': must be one of read, write, admin`));
726
+ process.exit(1);
727
+ }
728
+
729
+ const isEmail = /^[^\s]+@[^\s]+$/.test(opts.with);
730
+ if (!isEmail && !GROUP_ID_PATTERN.test(opts.with)) {
731
+ console.error(chalk.red(`Invalid principal '${opts.with}': must be an email address or a group id matching grp_<alphanumeric>`));
732
+ process.exit(1);
733
+ }
734
+ const granteeType = isEmail ? "email" : "group";
735
+ const granteeId = opts.with;
736
+
737
+ const token = await ensureCognitoToken();
738
+ const companySlug = secrets.opts().company as string | undefined;
739
+ const companyUid = await getCompanyUid(token, companySlug);
740
+
741
+ const res = await vaultApiFetch({
742
+ token,
743
+ path: `/secrets/${encodeURIComponent(companyUid)}/acl/grant`,
744
+ method: "POST",
745
+ body: { path, granteeType, granteeId, permission: opts.permission },
746
+ });
747
+
748
+ if (!res.ok) {
749
+ const body = await res.json().catch(() => ({})) as Record<string, string>;
750
+ if (res.status === 401) {
751
+ console.error(chalk.red("Not authenticated — please run `hq login`"));
752
+ } else if (res.status === 403) {
753
+ console.error(chalk.red("Not authorized to share this secret"));
754
+ } else if (res.status === 404) {
755
+ console.error(chalk.red("ACL record not found for this path — has the secret been created?"));
756
+ } else if (res.status === 409) {
757
+ console.error(chalk.red("Concurrent modification — please retry"));
758
+ } else if (res.status >= 500) {
759
+ console.error(chalk.red(`Server error: ${body.error ?? res.statusText}`));
760
+ } else {
761
+ console.error(chalk.red(body.message ?? body.error ?? "Invalid request"));
762
+ }
763
+ process.exit(1);
764
+ }
765
+
766
+ console.log(chalk.green(`Shared '${path}' with ${opts.with} (${opts.permission})`));
767
+ } catch (err) {
768
+ console.error(
769
+ chalk.red("Error:"),
770
+ err instanceof Error ? err.message : String(err),
771
+ );
772
+ process.exit(1);
773
+ }
774
+ });
775
+
776
+ secrets
777
+ .command("unshare <path>")
778
+ .description("Remove a grant from a secret")
779
+ .requiredOption("--from <principal>", "Email address or group id to remove")
780
+ .action(async (path: string, opts: { from: string }) => {
781
+ try {
782
+ if (!SECRET_NAME_PATTERN.test(path)) {
783
+ 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)`));
784
+ process.exit(1);
785
+ }
786
+
787
+ const isEmailFrom = /^[^\s]+@[^\s]+$/.test(opts.from);
788
+ if (!isEmailFrom && !GROUP_ID_PATTERN.test(opts.from)) {
789
+ console.error(chalk.red(`Invalid principal '${opts.from}': must be an email address or a group id matching grp_<alphanumeric>`));
790
+ process.exit(1);
791
+ }
792
+ const granteeType = isEmailFrom ? "email" : "group";
793
+ const granteeId = opts.from;
794
+
795
+ const token = await ensureCognitoToken();
796
+ const companySlug = secrets.opts().company as string | undefined;
797
+ const companyUid = await getCompanyUid(token, companySlug);
798
+
799
+ const res = await vaultApiFetch({
800
+ token,
801
+ path: `/secrets/${encodeURIComponent(companyUid)}/acl/revoke`,
802
+ method: "POST",
803
+ body: { path, granteeType, granteeId },
804
+ });
805
+
806
+ if (!res.ok) {
807
+ const body = await res.json().catch(() => ({})) as Record<string, string>;
808
+ if (res.status === 401) {
809
+ console.error(chalk.red("Not authenticated — please run `hq login`"));
810
+ } else if (res.status === 403) {
811
+ console.error(chalk.red("Not authorized to modify this secret's ACL"));
812
+ } else if (res.status === 404) {
813
+ console.log(chalk.green(`Grant already absent for '${path}' / ${opts.from}`));
814
+ return;
815
+ } else if (res.status >= 500) {
816
+ console.error(chalk.red(`Server error: ${body.error ?? res.statusText}`));
817
+ } else {
818
+ console.error(chalk.red(body.message ?? body.error ?? "Invalid request"));
819
+ }
820
+ process.exit(1);
821
+ }
822
+
823
+ console.log(chalk.green(`Removed grant for ${opts.from} on '${path}'`));
824
+ } catch (err) {
825
+ console.error(
826
+ chalk.red("Error:"),
827
+ err instanceof Error ? err.message : String(err),
828
+ );
829
+ process.exit(1);
830
+ }
831
+ });
832
+
833
+ secrets
834
+ .command("acl <path>")
835
+ .description("Show the ACL (access control list) for a secret path")
836
+ .action(async (path: string) => {
837
+ try {
838
+ if (!SECRET_NAME_PATTERN.test(path)) {
839
+ 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)`));
840
+ process.exit(1);
841
+ }
842
+
843
+ const token = await ensureCognitoToken();
844
+ const companySlug = secrets.opts().company as string | undefined;
845
+ const companyUid = await getCompanyUid(token, companySlug);
846
+
847
+ const secretPath = path;
848
+ const res = await vaultApiFetch({
849
+ token,
850
+ path: `/secrets/${encodeURIComponent(companyUid)}/acl`,
851
+ query: { path: secretPath },
852
+ });
853
+
854
+ if (!res.ok) {
855
+ const body = await res.json().catch(() => ({})) as Record<string, string>;
856
+ if (res.status === 401) {
857
+ console.error(chalk.red("Not authenticated — please run `hq login`"));
858
+ } else if (res.status === 403) {
859
+ console.error(chalk.red("Not authorized to view this secret's ACL"));
860
+ } else if (res.status === 404) {
861
+ console.error(chalk.red(`No ACL record exists for '${path}'`));
862
+ } else if (res.status >= 500) {
863
+ console.error(chalk.red(`Server error: ${body.error ?? res.statusText}`));
864
+ } else {
865
+ console.error(chalk.red(body.message ?? body.error ?? "Invalid request"));
866
+ }
867
+ process.exit(1);
868
+ }
869
+
870
+ const data = (await res.json()) as {
871
+ acl: {
872
+ itemType: "ACL";
873
+ companyUid: string;
874
+ path: string;
875
+ creatorUid: string;
876
+ open?: boolean;
877
+ entries: Array<{
878
+ granteeType: string;
879
+ granteeId: string;
880
+ permission: string;
881
+ grantedBy: string;
882
+ grantedAt: string;
883
+ }>;
884
+ createdAt: string;
885
+ updatedAt: string;
886
+ schemaVersion: number;
887
+ };
888
+ };
889
+
890
+ const acl = data.acl;
891
+ const aclStatus = acl.open ? "open" : "restricted";
892
+
893
+ console.log(chalk.green(`ACL for ${acl.path} (${aclStatus})`));
894
+ console.log(`Creator: ${acl.creatorUid}`);
895
+
896
+ if (acl.entries.length === 0) {
897
+ if (acl.open) {
898
+ console.log(chalk.gray("Open ACL — all active members have read access."));
899
+ } else {
900
+ console.log(chalk.gray("No explicit grants — only creator has access."));
901
+ }
902
+ return;
903
+ }
904
+
905
+ console.log("Entries:");
906
+ const TYPE_W = Math.max(4, ...acl.entries.map((e) => e.granteeType.length));
907
+ const GRANTEE_W = Math.max(7, ...acl.entries.map((e) => e.granteeId.length));
908
+ const PERM_W = Math.max(10, ...acl.entries.map((e) => e.permission.length));
909
+ const BY_W = Math.max(10, ...acl.entries.map((e) => e.grantedBy.length));
910
+ const tableHeader = [
911
+ "TYPE".padEnd(TYPE_W),
912
+ "GRANTEE".padEnd(GRANTEE_W),
913
+ "PERMISSION".padEnd(PERM_W),
914
+ "GRANTED_BY".padEnd(BY_W),
915
+ "GRANTED_AT",
916
+ ].join(" ");
917
+ console.log(chalk.bold(tableHeader));
918
+ for (const e of acl.entries) {
919
+ const grantedAt = e.grantedAt.slice(0, 10);
920
+ console.log(
921
+ [
922
+ e.granteeType.padEnd(TYPE_W),
923
+ e.granteeId.padEnd(GRANTEE_W),
924
+ e.permission.padEnd(PERM_W),
925
+ e.grantedBy.padEnd(BY_W),
926
+ grantedAt,
927
+ ].join(" "),
928
+ );
929
+ }
930
+ } catch (err) {
931
+ console.error(
932
+ chalk.red("Error:"),
933
+ err instanceof Error ? err.message : String(err),
934
+ );
935
+ process.exit(1);
936
+ }
937
+ });
938
+
659
939
  const cache = secrets
660
940
  .command("cache")
661
941
  .description("Manage the local secrets cache");
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { Command } from "commander";
8
+ import { initSentry, Sentry } from "./sentry.js";
8
9
  import { registerAddCommand } from "./commands/add.js";
9
10
  import { registerSyncCommand } from "./commands/sync.js";
10
11
  import { registerListCommand } from "./commands/list.js";
@@ -21,6 +22,9 @@ import { registerPackageListCommand } from "./commands/pkg-list.js";
21
22
  import { registerTeamSyncCommand } from "./commands/team-sync.js";
22
23
  import { registerAuthCommands } from "./commands/auth.js";
23
24
  import { registerSecretsCommand } from "./commands/secrets.js";
25
+ import { registerGroupsCommand } from "./commands/groups.js";
26
+
27
+ initSentry();
24
28
 
25
29
  const program = new Command();
26
30
 
@@ -74,7 +78,19 @@ registerAuthCommands(program);
74
78
  // Secrets management (subcommand group — hq secrets set|get|list|delete|exec|generate-link|cache)
75
79
  registerSecretsCommand(program);
76
80
 
81
+ // Groups management (subcommand group — hq groups create|delete|add|remove|list|members)
82
+ registerGroupsCommand(program);
83
+
77
84
  // Onboarding (top-level — Cognito + vault-service provisioning)
78
85
  registerOnboardCommand(program);
79
86
 
80
- program.parse();
87
+ (async () => {
88
+ try {
89
+ await program.parseAsync();
90
+ } catch (err) {
91
+ Sentry.captureException(err);
92
+ process.exitCode = 1;
93
+ } finally {
94
+ await Sentry.flush(2000);
95
+ }
96
+ })();