@indigoai-us/hq-cli 5.10.1 → 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.
@@ -13,11 +13,11 @@
13
13
  * hq sync status — show local journal summary
14
14
  */
15
15
 
16
- !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]="5ddbce6a-fdde-506e-8cdd-2e5b0ad20fff")}catch(e){}}();
16
+ !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]="213f074e-13c2-5b95-9519-813adf9adfa9")}catch(e){}}();
17
17
  import chalk from "chalk";
18
18
  import * as fs from "fs";
19
19
  import * as path from "path";
20
- import { share, sync, readJournal, getJournalPath, } from "@indigoai-us/hq-cloud";
20
+ import { share, sync, readJournal, getJournalPath, loadCachedTokens, } from "@indigoai-us/hq-cloud";
21
21
  import { DEFAULT_HQ_ROOT, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
22
22
  export function registerCloudCommands(program) {
23
23
  program
@@ -85,6 +85,13 @@ export function registerCloudCommands(program) {
85
85
  const onEvent = jsonMode
86
86
  ? (event) => emitJson(event)
87
87
  : undefined;
88
+ // Stamp every uploaded object's S3 user metadata with the syncing
89
+ // user's Cognito identity (`Metadata['created-by']`). The hq-console
90
+ // vault UI's CREATED BY column reads this back via HEAD; without it,
91
+ // every row renders `—`. Resolved best-effort from the cached
92
+ // idToken — pre-vended `--creds-from-stdin` paths still get author
93
+ // attribution as long as the caller is logged in locally.
94
+ const author = resolveUploadAuthorFromCache();
88
95
  const result = await share({
89
96
  paths: targetPaths,
90
97
  company: options.company,
@@ -94,6 +101,7 @@ export function registerCloudCommands(program) {
94
101
  entityContext,
95
102
  hqRoot: options.hqRoot,
96
103
  onEvent,
104
+ ...(author ? { author } : {}),
97
105
  });
98
106
  if (jsonMode) {
99
107
  // Synthetic terminal event so subprocess consumers can read final
@@ -226,5 +234,36 @@ async function readAllStdin() {
226
234
  }
227
235
  return Buffer.concat(chunks).toString("utf8");
228
236
  }
237
+ /**
238
+ * Resolve the syncing user's `UploadAuthor` (sub + email) from the cached
239
+ * Cognito idToken. Returns `undefined` when no tokens are cached or the
240
+ * token is missing the required claims — share() then skips the metadata
241
+ * stamp gracefully (not an error).
242
+ *
243
+ * We deliberately decode the JWT here instead of verifying it: Cognito
244
+ * already verified at issuance, and we only use the public claims to
245
+ * label the upload's S3 user metadata (no auth decision rides on it).
246
+ */
247
+ function resolveUploadAuthorFromCache() {
248
+ const tokens = loadCachedTokens();
249
+ if (!tokens?.idToken)
250
+ return undefined;
251
+ const parts = tokens.idToken.split(".");
252
+ if (parts.length !== 3)
253
+ return undefined;
254
+ try {
255
+ const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
256
+ const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4);
257
+ const json = Buffer.from(padded, "base64").toString("utf-8");
258
+ const claims = JSON.parse(json);
259
+ if (claims.sub && claims.email) {
260
+ return { userSub: claims.sub, email: claims.email };
261
+ }
262
+ return undefined;
263
+ }
264
+ catch {
265
+ return undefined;
266
+ }
267
+ }
229
268
  //# sourceMappingURL=cloud.js.map
230
- //# debugId=5ddbce6a-fdde-506e-8cdd-2e5b0ad20fff
269
+ //# debugId=213f074e-13c2-5b95-9519-813adf9adfa9
@@ -1,6 +1,6 @@
1
1
  import { Command } from "commander";
2
- import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
2
+ import { vaultApiFetch, getCompanyUid, getEntityUid } from "../utils/vault-api.js";
3
3
  export type { VaultApiOptions } from "../utils/vault-api.js";
4
- export { vaultApiFetch, getCompanyUid };
4
+ export { vaultApiFetch, getCompanyUid, getEntityUid };
5
5
  export declare function registerSecretsCommand(program: Command): void;
6
6
  //# sourceMappingURL=secrets.d.ts.map
@@ -1,13 +1,26 @@
1
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]="0239c476-98b8-53c0-9458-1c82dc0d26a9")}catch(e){}}();
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]="217ceadb-01cd-5778-9cc0-6f6d895d8d67")}catch(e){}}();
3
3
  import chalk from "chalk";
4
4
  import * as readline from "node:readline";
5
5
  import { spawn } from "node:child_process";
6
6
  import { ensureCognitoToken } from "../utils/cognito-session.js";
7
7
  import { readCache, writeCache, removeCacheEntry, clearAllCache, } from "../utils/secrets-cache.js";
8
8
  import { SECRET_NAME_PATTERN, GROUP_ID_PATTERN } from "./_patterns.js";
9
- import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
10
- export { vaultApiFetch, getCompanyUid };
9
+ import { vaultApiFetch, getCompanyUid, getEntityUid, } from "../utils/vault-api.js";
10
+ export { vaultApiFetch, getCompanyUid, getEntityUid };
11
+ function scopeOpts(opts) {
12
+ if (opts.personal && opts.company) {
13
+ console.error(chalk.red("Error: --personal cannot be combined with --company."));
14
+ process.exit(1);
15
+ }
16
+ return { personal: !!opts.personal, companySlug: opts.company };
17
+ }
18
+ function rejectIfPersonal(opts, action) {
19
+ if (opts.personal) {
20
+ console.error(chalk.red(`Error: ${action} is not supported with --personal.`));
21
+ process.exit(1);
22
+ }
23
+ }
11
24
  function shellSingleQuote(value) {
12
25
  return "'" + value.replace(/'/g, "'\\''") + "'";
13
26
  }
@@ -115,7 +128,8 @@ export function registerSecretsCommand(program) {
115
128
  const secrets = program
116
129
  .command("secrets")
117
130
  .description("Manage secrets in HQ vault (SSM Parameter Store)")
118
- .option("--company <slug>", "Company slug (resolves to companyUid)");
131
+ .option("--company <slug>", "Company slug (resolves to companyUid)")
132
+ .option("--personal", "Operate on the caller's personal vault (no sharing)");
119
133
  secrets
120
134
  .command("set <name>")
121
135
  .description("Create or update a secret")
@@ -150,8 +164,7 @@ export function registerSecretsCommand(program) {
150
164
  process.exit(1);
151
165
  }
152
166
  const token = await ensureCognitoToken();
153
- const companySlug = secrets.opts().company;
154
- const companyUid = await getCompanyUid(token, companySlug);
167
+ const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
155
168
  const res = await vaultApiFetch({
156
169
  token,
157
170
  path: `/secrets/${encodeURIComponent(companyUid)}`,
@@ -178,8 +191,7 @@ export function registerSecretsCommand(program) {
178
191
  .action(async (name, opts) => {
179
192
  try {
180
193
  const token = await ensureCognitoToken();
181
- const companySlug = secrets.opts().company;
182
- const companyUid = await getCompanyUid(token, companySlug);
194
+ const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
183
195
  const query = {};
184
196
  if (opts.reveal) {
185
197
  query.reveal = "true";
@@ -233,8 +245,7 @@ export function registerSecretsCommand(program) {
233
245
  normalizedPrefix = normalized;
234
246
  }
235
247
  const token = await ensureCognitoToken();
236
- const companySlug = secrets.opts().company;
237
- const companyUid = await getCompanyUid(token, companySlug);
248
+ const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
238
249
  const query = {};
239
250
  if (normalizedPrefix) {
240
251
  query.prefix = normalizedPrefix;
@@ -298,8 +309,7 @@ export function registerSecretsCommand(program) {
298
309
  }
299
310
  }
300
311
  const token = await ensureCognitoToken();
301
- const companySlug = secrets.opts().company;
302
- const companyUid = await getCompanyUid(token, companySlug);
312
+ const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
303
313
  const res = await vaultApiFetch({
304
314
  token,
305
315
  path: buildSecretNamePath(companyUid, name),
@@ -350,8 +360,7 @@ export function registerSecretsCommand(program) {
350
360
  }
351
361
  }
352
362
  const token = await ensureCognitoToken();
353
- const companySlug = secrets.opts().company;
354
- const companyUid = await getCompanyUid(token, companySlug);
363
+ const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
355
364
  const revealed = await Promise.all(keys.map(async (key) => {
356
365
  const cached = readCache(companyUid, key);
357
366
  if (cached !== null) {
@@ -420,8 +429,7 @@ export function registerSecretsCommand(program) {
420
429
  }
421
430
  }
422
431
  const token = await ensureCognitoToken();
423
- const companySlug = secrets.opts().company;
424
- const companyUid = await getCompanyUid(token, companySlug);
432
+ const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
425
433
  const revealed = await Promise.all(keys.map(async (key) => {
426
434
  const cached = readCache(companyUid, key);
427
435
  if (cached !== null) {
@@ -459,6 +467,7 @@ export function registerSecretsCommand(program) {
459
467
  .option("--expires <duration>", "Token expiry duration (e.g. 24h, 2d, 30m)", "24h")
460
468
  .action(async (name, opts) => {
461
469
  try {
470
+ rejectIfPersonal(secrets.opts(), "generate-link");
462
471
  if (!SECRET_NAME_PATTERN.test(name)) {
463
472
  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)`));
464
473
  process.exit(1);
@@ -474,8 +483,7 @@ export function registerSecretsCommand(program) {
474
483
  process.exit(1);
475
484
  }
476
485
  const token = await ensureCognitoToken();
477
- const companySlug = secrets.opts().company;
478
- const companyUid = await getCompanyUid(token, companySlug);
486
+ const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
479
487
  const res = await vaultApiFetch({
480
488
  token,
481
489
  path: buildSecretNamePath(companyUid, name),
@@ -507,6 +515,7 @@ export function registerSecretsCommand(program) {
507
515
  .requiredOption("--permission <level>", "Permission level: read | write | admin")
508
516
  .action(async (path, opts) => {
509
517
  try {
518
+ rejectIfPersonal(secrets.opts(), "share");
510
519
  if (!SECRET_NAME_PATTERN.test(path)) {
511
520
  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)`));
512
521
  process.exit(1);
@@ -523,8 +532,7 @@ export function registerSecretsCommand(program) {
523
532
  const granteeType = isEmail ? "email" : "group";
524
533
  const granteeId = opts.with;
525
534
  const token = await ensureCognitoToken();
526
- const companySlug = secrets.opts().company;
527
- const companyUid = await getCompanyUid(token, companySlug);
535
+ const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
528
536
  const res = await vaultApiFetch({
529
537
  token,
530
538
  path: `/secrets/${encodeURIComponent(companyUid)}/acl/grant`,
@@ -566,6 +574,7 @@ export function registerSecretsCommand(program) {
566
574
  .requiredOption("--from <principal>", "Email address or group id to remove")
567
575
  .action(async (path, opts) => {
568
576
  try {
577
+ rejectIfPersonal(secrets.opts(), "unshare");
569
578
  if (!SECRET_NAME_PATTERN.test(path)) {
570
579
  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)`));
571
580
  process.exit(1);
@@ -578,8 +587,7 @@ export function registerSecretsCommand(program) {
578
587
  const granteeType = isEmailFrom ? "email" : "group";
579
588
  const granteeId = opts.from;
580
589
  const token = await ensureCognitoToken();
581
- const companySlug = secrets.opts().company;
582
- const companyUid = await getCompanyUid(token, companySlug);
590
+ const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
583
591
  const res = await vaultApiFetch({
584
592
  token,
585
593
  path: `/secrets/${encodeURIComponent(companyUid)}/acl/revoke`,
@@ -618,13 +626,13 @@ export function registerSecretsCommand(program) {
618
626
  .description("Show the ACL (access control list) for a secret path")
619
627
  .action(async (path) => {
620
628
  try {
629
+ rejectIfPersonal(secrets.opts(), "acl");
621
630
  if (!SECRET_NAME_PATTERN.test(path)) {
622
631
  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)`));
623
632
  process.exit(1);
624
633
  }
625
634
  const token = await ensureCognitoToken();
626
- const companySlug = secrets.opts().company;
627
- const companyUid = await getCompanyUid(token, companySlug);
635
+ const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
628
636
  const secretPath = path;
629
637
  const res = await vaultApiFetch({
630
638
  token,
@@ -705,4 +713,4 @@ export function registerSecretsCommand(program) {
705
713
  });
706
714
  }
707
715
  //# sourceMappingURL=secrets.js.map
708
- //# debugId=0239c476-98b8-53c0-9458-1c82dc0d26a9
716
+ //# debugId=217ceadb-01cd-5778-9cc0-6f6d895d8d67
@@ -7,4 +7,9 @@ export interface VaultApiOptions {
7
7
  }
8
8
  export declare function vaultApiFetch(opts: VaultApiOptions): Promise<Response>;
9
9
  export declare function getCompanyUid(token: string, companySlug: string | undefined): Promise<string>;
10
+ export declare function resolveCallerPersonUid(token: string): Promise<string>;
11
+ export declare function getEntityUid(token: string, opts: {
12
+ personal?: boolean;
13
+ companySlug?: string;
14
+ }): Promise<string>;
10
15
  //# sourceMappingURL=vault-api.d.ts.map
@@ -1,5 +1,5 @@
1
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]="4e482805-d129-5563-a77b-97b80410154b")}catch(e){}}();
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]="05d73a12-54b0-559c-a964-de7dff2d48eb")}catch(e){}}();
3
3
  import { DEFAULT_VAULT_API_URL } from './cognito-session.js';
4
4
  export async function vaultApiFetch(opts) {
5
5
  const url = new URL(opts.path, DEFAULT_VAULT_API_URL);
@@ -54,5 +54,38 @@ export async function getCompanyUid(token, companySlug) {
54
54
  }
55
55
  return resolveCompanyFromMemberships(token);
56
56
  }
57
+ // Same selection rule as the backend's `resolveCallerPersonUid`: ascending by
58
+ // createdAt, tie-break by uid ascending. Returns the `prs_*` UID.
59
+ export async function resolveCallerPersonUid(token) {
60
+ const res = await vaultApiFetch({
61
+ token,
62
+ path: '/entity/by-type/person',
63
+ });
64
+ if (!res.ok) {
65
+ throw new Error("Failed to fetch person entity — run `hq login` and try again");
66
+ }
67
+ const data = (await res.json());
68
+ const persons = (data.entities ?? []).filter((e) => e.type === 'person');
69
+ if (persons.length === 0) {
70
+ throw new Error('No person entity found for the caller. Sign in to HQ once to provision one.');
71
+ }
72
+ persons.sort((a, b) => {
73
+ const ac = a.createdAt ?? '';
74
+ const bc = b.createdAt ?? '';
75
+ if (ac !== bc)
76
+ return ac < bc ? -1 : 1;
77
+ return a.uid < b.uid ? -1 : 1;
78
+ });
79
+ return persons[0].uid;
80
+ }
81
+ // Resolves the scope UID (cmp_* or prs_*) for a secrets command. Precedence:
82
+ // `--personal` → caller's canonical person entity; else `--company <slug>` →
83
+ // resolved company UID; else fallback to single active company membership.
84
+ export async function getEntityUid(token, opts) {
85
+ if (opts.personal) {
86
+ return resolveCallerPersonUid(token);
87
+ }
88
+ return getCompanyUid(token, opts.companySlug);
89
+ }
57
90
  //# sourceMappingURL=vault-api.js.map
58
- //# debugId=4e482805-d129-5563-a77b-97b80410154b
91
+ //# debugId=05d73a12-54b0-559c-a964-de7dff2d48eb
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.10.1",
3
+ "version": "5.11.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -23,9 +23,11 @@ import {
23
23
  sync,
24
24
  readJournal,
25
25
  getJournalPath,
26
+ loadCachedTokens,
26
27
  type ConflictStrategy,
27
28
  type EntityContext,
28
29
  type SyncProgressEvent,
30
+ type UploadAuthor,
29
31
  } from "@indigoai-us/hq-cloud";
30
32
 
31
33
  import {
@@ -146,6 +148,14 @@ export function registerCloudCommands(program: Command): void {
146
148
  emitJson(event as unknown as Record<string, unknown>)
147
149
  : undefined;
148
150
 
151
+ // Stamp every uploaded object's S3 user metadata with the syncing
152
+ // user's Cognito identity (`Metadata['created-by']`). The hq-console
153
+ // vault UI's CREATED BY column reads this back via HEAD; without it,
154
+ // every row renders `—`. Resolved best-effort from the cached
155
+ // idToken — pre-vended `--creds-from-stdin` paths still get author
156
+ // attribution as long as the caller is logged in locally.
157
+ const author = resolveUploadAuthorFromCache();
158
+
149
159
  const result = await share({
150
160
  paths: targetPaths,
151
161
  company: options.company,
@@ -155,6 +165,7 @@ export function registerCloudCommands(program: Command): void {
155
165
  entityContext,
156
166
  hqRoot: options.hqRoot,
157
167
  onEvent,
168
+ ...(author ? { author } : {}),
158
169
  });
159
170
 
160
171
  if (jsonMode) {
@@ -343,3 +354,32 @@ async function readAllStdin(): Promise<string> {
343
354
  }
344
355
  return Buffer.concat(chunks).toString("utf8");
345
356
  }
357
+
358
+ /**
359
+ * Resolve the syncing user's `UploadAuthor` (sub + email) from the cached
360
+ * Cognito idToken. Returns `undefined` when no tokens are cached or the
361
+ * token is missing the required claims — share() then skips the metadata
362
+ * stamp gracefully (not an error).
363
+ *
364
+ * We deliberately decode the JWT here instead of verifying it: Cognito
365
+ * already verified at issuance, and we only use the public claims to
366
+ * label the upload's S3 user metadata (no auth decision rides on it).
367
+ */
368
+ function resolveUploadAuthorFromCache(): UploadAuthor | undefined {
369
+ const tokens = loadCachedTokens();
370
+ if (!tokens?.idToken) return undefined;
371
+ const parts = tokens.idToken.split(".");
372
+ if (parts.length !== 3) return undefined;
373
+ try {
374
+ const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
375
+ const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4);
376
+ const json = Buffer.from(padded, "base64").toString("utf-8");
377
+ const claims = JSON.parse(json) as { sub?: string; email?: string };
378
+ if (claims.sub && claims.email) {
379
+ return { userSub: claims.sub, email: claims.email };
380
+ }
381
+ return undefined;
382
+ } catch {
383
+ return undefined;
384
+ }
385
+ }
@@ -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({
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+
3
+ import { getEntityUid, resolveCallerPersonUid } from './vault-api.js';
4
+
5
+ const fetchMock = vi.fn();
6
+ const originalFetch = globalThis.fetch;
7
+
8
+ function mockResponse(status: number, body: unknown): Response {
9
+ return new Response(JSON.stringify(body), {
10
+ status,
11
+ headers: { 'Content-Type': 'application/json' },
12
+ });
13
+ }
14
+
15
+ beforeEach(() => {
16
+ fetchMock.mockReset();
17
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
18
+ });
19
+
20
+ afterEach(() => {
21
+ globalThis.fetch = originalFetch;
22
+ });
23
+
24
+ describe('resolveCallerPersonUid', () => {
25
+ it('returns the canonical person uid (oldest createdAt, uid tie-break)', async () => {
26
+ fetchMock.mockResolvedValueOnce(
27
+ mockResponse(200, {
28
+ entities: [
29
+ { uid: 'prs_b', type: 'person', createdAt: '2026-01-02T00:00:00Z' },
30
+ { uid: 'prs_a', type: 'person', createdAt: '2026-01-01T00:00:00Z' },
31
+ { uid: 'prs_c', type: 'person', createdAt: '2026-01-01T00:00:00Z' },
32
+ ],
33
+ }),
34
+ );
35
+
36
+ const uid = await resolveCallerPersonUid('tok');
37
+ expect(uid).toBe('prs_a');
38
+ });
39
+
40
+ it('throws when the caller has no person entity', async () => {
41
+ fetchMock.mockResolvedValueOnce(mockResponse(200, { entities: [] }));
42
+ await expect(resolveCallerPersonUid('tok')).rejects.toThrow(
43
+ /No person entity/,
44
+ );
45
+ });
46
+
47
+ it('throws when the API returns an error status', async () => {
48
+ fetchMock.mockResolvedValueOnce(mockResponse(401, { error: 'unauth' }));
49
+ await expect(resolveCallerPersonUid('tok')).rejects.toThrow(
50
+ /Failed to fetch person entity/,
51
+ );
52
+ });
53
+
54
+ it('filters out non-person entries before sorting', async () => {
55
+ fetchMock.mockResolvedValueOnce(
56
+ mockResponse(200, {
57
+ entities: [
58
+ { uid: 'cmp_a', type: 'company', createdAt: '2025-01-01T00:00:00Z' },
59
+ { uid: 'prs_a', type: 'person', createdAt: '2026-01-01T00:00:00Z' },
60
+ ],
61
+ }),
62
+ );
63
+ const uid = await resolveCallerPersonUid('tok');
64
+ expect(uid).toBe('prs_a');
65
+ });
66
+ });
67
+
68
+ describe('getEntityUid', () => {
69
+ it('routes to person resolution when personal=true', async () => {
70
+ fetchMock.mockResolvedValueOnce(
71
+ mockResponse(200, {
72
+ entities: [
73
+ { uid: 'prs_a', type: 'person', createdAt: '2026-01-01T00:00:00Z' },
74
+ ],
75
+ }),
76
+ );
77
+ const uid = await getEntityUid('tok', { personal: true });
78
+ expect(uid).toBe('prs_a');
79
+ const url = fetchMock.mock.calls[0][0] as string;
80
+ expect(url).toMatch(/\/entity\/by-type\/person/);
81
+ });
82
+
83
+ it('routes to company-slug resolution when companySlug is set', async () => {
84
+ fetchMock.mockResolvedValueOnce(
85
+ mockResponse(200, { entity: { uid: 'cmp_acme' } }),
86
+ );
87
+ const uid = await getEntityUid('tok', { companySlug: 'acme' });
88
+ expect(uid).toBe('cmp_acme');
89
+ const url = fetchMock.mock.calls[0][0] as string;
90
+ expect(url).toMatch(/\/entity\/by-slug\/company\/acme/);
91
+ });
92
+
93
+ it('falls back to membership lookup when neither personal nor slug is set', async () => {
94
+ fetchMock.mockResolvedValueOnce(
95
+ mockResponse(200, {
96
+ memberships: [
97
+ {
98
+ companyUid: 'cmp_only',
99
+ role: 'member',
100
+ status: 'active',
101
+ membershipKey: 'k',
102
+ },
103
+ ],
104
+ }),
105
+ );
106
+ const uid = await getEntityUid('tok', {});
107
+ expect(uid).toBe('cmp_only');
108
+ const url = fetchMock.mock.calls[0][0] as string;
109
+ expect(url).toMatch(/\/membership\/me/);
110
+ });
111
+ });
@@ -78,3 +78,46 @@ export async function getCompanyUid(
78
78
  }
79
79
  return resolveCompanyFromMemberships(token);
80
80
  }
81
+
82
+ interface PersonEntity {
83
+ uid: string;
84
+ type: string;
85
+ createdAt?: string;
86
+ }
87
+
88
+ // Same selection rule as the backend's `resolveCallerPersonUid`: ascending by
89
+ // createdAt, tie-break by uid ascending. Returns the `prs_*` UID.
90
+ export async function resolveCallerPersonUid(token: string): Promise<string> {
91
+ const res = await vaultApiFetch({
92
+ token,
93
+ path: '/entity/by-type/person',
94
+ });
95
+ if (!res.ok) {
96
+ throw new Error("Failed to fetch person entity — run `hq login` and try again");
97
+ }
98
+ const data = (await res.json()) as { entities: PersonEntity[] };
99
+ const persons = (data.entities ?? []).filter((e) => e.type === 'person');
100
+ if (persons.length === 0) {
101
+ throw new Error('No person entity found for the caller. Sign in to HQ once to provision one.');
102
+ }
103
+ persons.sort((a, b) => {
104
+ const ac = a.createdAt ?? '';
105
+ const bc = b.createdAt ?? '';
106
+ if (ac !== bc) return ac < bc ? -1 : 1;
107
+ return a.uid < b.uid ? -1 : 1;
108
+ });
109
+ return persons[0].uid;
110
+ }
111
+
112
+ // Resolves the scope UID (cmp_* or prs_*) for a secrets command. Precedence:
113
+ // `--personal` → caller's canonical person entity; else `--company <slug>` →
114
+ // resolved company UID; else fallback to single active company membership.
115
+ export async function getEntityUid(
116
+ token: string,
117
+ opts: { personal?: boolean; companySlug?: string },
118
+ ): Promise<string> {
119
+ if (opts.personal) {
120
+ return resolveCallerPersonUid(token);
121
+ }
122
+ return getCompanyUid(token, opts.companySlug);
123
+ }