@indigoai-us/hq-cli 5.18.3 → 5.20.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.20.0] — 2026-05-21
6
+
7
+ ### Added
8
+
9
+ - **`hq sync {push,pull,now} --all --no-personal`** — skip the
10
+ canonical-person leg of the `--all` fanout (companies-only sync).
11
+ Mirrors the upstream `hq-sync-runner --skip-personal` flag added in
12
+ `@indigoai-us/hq-cloud@5.25.0`. Wired so the AppBar HQ Sync menubar
13
+ can drop personal sync via a toggle, and so CLI users can opt out
14
+ one-off. Plumbed through `pullAll` / `pushAll` / `runNowAll` via a
15
+ new `skipPersonal` option; outside `--all` mode the flag is a no-op.
16
+ - Bumps `@indigoai-us/hq-cloud` to `~5.25.0` (currency-gated personal-
17
+ vault default exclusions + skip-personal CLI/env surface).
18
+
5
19
  ## [5.18.3] — 2026-05-21
6
20
 
7
21
  ### Fixed
@@ -77,6 +77,17 @@ export interface PullAllOptions {
77
77
  * legacy behavior for this run via `--mode-all`.
78
78
  */
79
79
  modeAllOverride?: boolean;
80
+ /**
81
+ * When `true`, skip the canonical-person-entity leg entirely — the
82
+ * fanout only visits the caller's company memberships. Mirrors the
83
+ * `--skip-personal` flag and `HQ_SYNC_SKIP_PERSONAL` env var that
84
+ * `@indigoai-us/hq-cloud`'s `sync-runner` exposes in `--companies`
85
+ * mode (see hq-cloud 5.25.0 `resolveSkipPersonal`). Surfaced on the
86
+ * CLI as `hq sync pull --all --no-personal` so the AppBar HQ Sync
87
+ * menubar toggle (and CLI users opting out one-off) can drop the
88
+ * personal vault from the run without touching the rest of the plan.
89
+ */
90
+ skipPersonal?: boolean;
80
91
  }
81
92
  export interface PullAllRow {
82
93
  slug: string;
@@ -110,6 +121,13 @@ export interface PushAllOptions {
110
121
  hqRoot: string;
111
122
  onConflict?: ConflictStrategy;
112
123
  message?: string;
124
+ /**
125
+ * When `true`, skip the canonical-person-entity leg entirely — the
126
+ * fanout only visits the caller's company memberships. Symmetric with
127
+ * `PullAllOptions.skipPersonal`; surfaced on the CLI as
128
+ * `hq sync push --all --no-personal`.
129
+ */
130
+ skipPersonal?: boolean;
113
131
  }
114
132
  export interface PushAllRow {
115
133
  slug: string;
@@ -153,6 +171,23 @@ export declare function pushAll(options: PushAllOptions, deps: PushAllDeps): Pro
153
171
  * no person entity (typically means they haven't run `hq onboard`).
154
172
  */
155
173
  export declare function resolveCanonicalPersonUid(vaultClient: PullAllVaultClient): Promise<string>;
174
+ /**
175
+ * Refuse `hq sync push --personal <path>` — the combination silently
176
+ * bypasses `PERSONAL_VAULT_EXCLUDED_TOP_LEVEL` (which is only applied by
177
+ * `computePersonalVaultPaths`), risking cross-scope upload of `companies/`,
178
+ * `repos/`, `workspace/`, or `.git/` content to the personal vault. Real
179
+ * incident (2026-05-21): a single command uploaded 196 `companies/{slug}/**`
180
+ * objects to a personal vault before being killed. Cleanup required a
181
+ * hand-rolled S3 sweep. Closes hq-cli#25.
182
+ *
183
+ * Refusal — not silent filtering — is intentional: explicit is better than
184
+ * implicit guesswork, and the legitimate "I want to push a subset of my
185
+ * personal vault" use case has a clean workaround (drop `--personal`, the
186
+ * subset upload targets the active company via standard semantics).
187
+ */
188
+ export declare function assertNoPersonalPositionalPaths(opts: {
189
+ personal?: boolean;
190
+ }, paths: string[] | undefined): void;
156
191
  /**
157
192
  * Refuse ambiguous selector combinations. `--all`, `--personal`, and
158
193
  * `--company` are mutually exclusive — at most one may be set per
@@ -13,7 +13,7 @@
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]="4672d875-1dc8-56a2-bef1-49c7f4ff6c76")}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]="bdaf154f-b17d-5482-9e4d-feaebe606a1b")}catch(e){}}();
17
17
  import chalk from "chalk";
18
18
  import * as fs from "fs";
19
19
  import * as path from "path";
@@ -60,7 +60,7 @@ export async function pullAll(options, deps) {
60
60
  },
61
61
  });
62
62
  }
63
- const personal = pickCanonicalPerson(persons);
63
+ const personal = options.skipPersonal ? null : pickCanonicalPerson(persons);
64
64
  if (personal) {
65
65
  plan.push({
66
66
  slug: "personal",
@@ -176,7 +176,7 @@ export async function pushAll(options, deps) {
176
176
  },
177
177
  });
178
178
  }
179
- const personal = pickCanonicalPerson(persons);
179
+ const personal = options.skipPersonal ? null : pickCanonicalPerson(persons);
180
180
  if (personal) {
181
181
  plan.push({
182
182
  slug: "personal",
@@ -234,6 +234,30 @@ export async function resolveCanonicalPersonUid(vaultClient) {
234
234
  }
235
235
  return pick.uid;
236
236
  }
237
+ /**
238
+ * Refuse `hq sync push --personal <path>` — the combination silently
239
+ * bypasses `PERSONAL_VAULT_EXCLUDED_TOP_LEVEL` (which is only applied by
240
+ * `computePersonalVaultPaths`), risking cross-scope upload of `companies/`,
241
+ * `repos/`, `workspace/`, or `.git/` content to the personal vault. Real
242
+ * incident (2026-05-21): a single command uploaded 196 `companies/{slug}/**`
243
+ * objects to a personal vault before being killed. Cleanup required a
244
+ * hand-rolled S3 sweep. Closes hq-cli#25.
245
+ *
246
+ * Refusal — not silent filtering — is intentional: explicit is better than
247
+ * implicit guesswork, and the legitimate "I want to push a subset of my
248
+ * personal vault" use case has a clean workaround (drop `--personal`, the
249
+ * subset upload targets the active company via standard semantics).
250
+ */
251
+ export function assertNoPersonalPositionalPaths(opts, paths) {
252
+ if (opts.personal && paths && paths.length > 0) {
253
+ throw new Error("`--personal` cannot be combined with explicit [paths]: " +
254
+ "positional paths bypass the PERSONAL_VAULT_EXCLUDED_TOP_LEVEL " +
255
+ "guard (skips .git/, companies/, repos/, workspace/), risking " +
256
+ "cross-scope upload of company data to the personal vault. " +
257
+ "Use bare `--personal` to push the whole personal scope, OR " +
258
+ "drop `--personal` to push specific paths to the active company.");
259
+ }
260
+ }
237
261
  /**
238
262
  * Refuse ambiguous selector combinations. `--all`, `--personal`, and
239
263
  * `--company` are mutually exclusive — at most one may be set per
@@ -330,6 +354,12 @@ export function registerCloudCommands(program) {
330
354
  "every top-level entry under <hq-root> minus the excluded set " +
331
355
  "(.git, companies, repos, workspace) — same scope as `--all`'s " +
332
356
  "personal slot. Mutually exclusive with --company and --all.")
357
+ .option("--no-personal", "In `--all` mode, skip the canonical-person leg of the fanout — " +
358
+ "only push to the caller's company memberships. Mirrors the " +
359
+ "upstream `hq-sync-runner --skip-personal` flag (hq-cloud 5.25.0); " +
360
+ "wired so the AppBar HQ Sync menubar can drop personal sync via " +
361
+ "a toggle, and so CLI users can opt out one-off. Ignored outside " +
362
+ "`--all`.")
333
363
  .action(async (paths, options) => {
334
364
  try {
335
365
  assertSingleSelector(options, "push");
@@ -352,7 +382,13 @@ export function registerCloudCommands(program) {
352
382
  "target instead.");
353
383
  process.exit(1);
354
384
  }
355
- await runPushAll(options.hqRoot, options.message, options.onConflict);
385
+ // `options.personal === false` happens when the user passed
386
+ // `--no-personal` (Commander's auto-negation of the `--personal`
387
+ // selector). In `--all` mode that means "skip the personal leg of
388
+ // the fanout" — wired through to `pushAll.skipPersonal`. Outside
389
+ // `--all` the flag has no effect (logged above as part of the
390
+ // option's help text).
391
+ await runPushAll(options.hqRoot, options.message, options.onConflict, options.personal === false);
356
392
  return;
357
393
  }
358
394
  const jsonMode = options.json === true;
@@ -372,6 +408,8 @@ export function registerCloudCommands(program) {
372
408
  "Cognito session, while --creds-from-stdin expects the caller " +
373
409
  "to have already resolved entity + credentials. Pick one.");
374
410
  }
411
+ // Closes hq-cli#25 — see `assertNoPersonalPositionalPaths` doc-block.
412
+ assertNoPersonalPositionalPaths(options, paths);
375
413
  log(chalk.bold("\nHQ Sync — Push"));
376
414
  log(` HQ root: ${options.hqRoot}`);
377
415
  // Resolve credentials. Two paths:
@@ -505,6 +543,10 @@ export function registerCloudCommands(program) {
505
543
  "(no companies/<slug>/ prefix). Resolves the person UID automatically " +
506
544
  "from the cached Cognito session. Mutually exclusive with --company " +
507
545
  "and --all.")
546
+ .option("--no-personal", "In `--all` mode, skip the canonical-person leg of the fanout — " +
547
+ "only pull the caller's company memberships. Mirrors the upstream " +
548
+ "`hq-sync-runner --skip-personal` flag (hq-cloud 5.25.0). Ignored " +
549
+ "outside `--all`.")
508
550
  .option("--mode-all", "US-011: opt out of the strict narrow-hint refusal for this run. " +
509
551
  "Has no effect today (default narrow-hint level is 'hint'); " +
510
552
  "wired so future hq-core-staging releases can flip the default to " +
@@ -518,7 +560,10 @@ export function registerCloudCommands(program) {
518
560
  process.exit(1);
519
561
  }
520
562
  if (options.all) {
521
- await runPullAll(options.hqRoot, options.onConflict, options.modeAll === true);
563
+ // `options.personal === false` is Commander's auto-negation of
564
+ // `--personal`; in `--all` mode that means "drop the personal
565
+ // leg from the fanout" (see `--no-personal` option above).
566
+ await runPullAll(options.hqRoot, options.onConflict, options.modeAll === true, options.personal === false);
522
567
  return;
523
568
  }
524
569
  if (options.personal) {
@@ -641,6 +686,10 @@ export function registerCloudCommands(program) {
641
686
  "--personal.")
642
687
  .option("--personal", "Sync the caller's canonical personal vault bidirectionally. " +
643
688
  "Mutually exclusive with --company and --all.")
689
+ .option("--no-personal", "In `--all` mode, skip the canonical-person leg of both the push " +
690
+ "and pull fanouts — only sync the caller's company memberships. " +
691
+ "Mirrors the upstream `hq-sync-runner --skip-personal` flag " +
692
+ "(hq-cloud 5.25.0). Ignored outside `--all`.")
644
693
  .option("--mode-all", "US-011: opt out of the strict narrow-hint refusal for this run. " +
645
694
  "No-op today; wired so future hq-core-staging releases can flip " +
646
695
  "the default narrow-hint level to 'strict'.")
@@ -648,7 +697,11 @@ export function registerCloudCommands(program) {
648
697
  try {
649
698
  assertSingleSelector(options, "now");
650
699
  if (options.all) {
651
- await runNowAll(options.hqRoot, options.message, options.onConflict, options.modeAll === true);
700
+ // `options.personal === false` is Commander's auto-negation
701
+ // of `--personal`; in `--all` mode that means "drop the
702
+ // personal leg from both legs of the bidirectional fanout"
703
+ // (see `--no-personal` option above).
704
+ await runNowAll(options.hqRoot, options.message, options.onConflict, options.modeAll === true, options.personal === false);
652
705
  return;
653
706
  }
654
707
  await runNowSingle(options.hqRoot, options.company, options.personal === true, options.message, options.onConflict, options.modeAll === true);
@@ -659,10 +712,14 @@ export function registerCloudCommands(program) {
659
712
  }
660
713
  });
661
714
  }
662
- async function runPullAll(hqRoot, onConflict, modeAllOverride) {
715
+ async function runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal) {
663
716
  console.log(chalk.bold("\nHQ Sync — Pull (all)"));
664
717
  console.log(` HQ root: ${hqRoot}`);
665
- console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
718
+ console.log(` Strategy: ${onConflict ?? "(interactive)"}`);
719
+ if (skipPersonal) {
720
+ console.log(` Personal: skipped (--no-personal)`);
721
+ }
722
+ console.log("");
666
723
  let result;
667
724
  try {
668
725
  const accessToken = await ensureCognitoToken();
@@ -686,6 +743,7 @@ async function runPullAll(hqRoot, onConflict, modeAllOverride) {
686
743
  ...(onConflict ? { onConflict } : {}),
687
744
  narrowHintLevel: resolveBannerLevel(),
688
745
  ...(modeAllOverride ? { modeAllOverride: true } : {}),
746
+ ...(skipPersonal ? { skipPersonal: true } : {}),
689
747
  }, {
690
748
  vaultClient: adapter,
691
749
  sync: (opts) => sync({
@@ -759,10 +817,14 @@ async function runPullPersonal(hqRoot, onConflict) {
759
817
  process.exit(1);
760
818
  }
761
819
  }
762
- async function runPushAll(hqRoot, message, onConflict) {
820
+ async function runPushAll(hqRoot, message, onConflict, skipPersonal) {
763
821
  console.log(chalk.bold("\nHQ Sync — Push (all)"));
764
822
  console.log(` HQ root: ${hqRoot}`);
765
- console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
823
+ console.log(` Strategy: ${onConflict ?? "(interactive)"}`);
824
+ if (skipPersonal) {
825
+ console.log(` Personal: skipped (--no-personal)`);
826
+ }
827
+ console.log("");
766
828
  let result;
767
829
  try {
768
830
  const accessToken = await ensureCognitoToken();
@@ -785,6 +847,7 @@ async function runPushAll(hqRoot, message, onConflict) {
785
847
  hqRoot,
786
848
  ...(onConflict ? { onConflict } : {}),
787
849
  ...(message ? { message } : {}),
850
+ ...(skipPersonal ? { skipPersonal: true } : {}),
788
851
  }, {
789
852
  vaultClient: adapter,
790
853
  share: (opts) => share({
@@ -976,20 +1039,24 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
976
1039
  process.exit(1);
977
1040
  }
978
1041
  }
979
- async function runNowAll(hqRoot, message, onConflict, modeAllOverride) {
1042
+ async function runNowAll(hqRoot, message, onConflict, modeAllOverride, skipPersonal) {
980
1043
  console.log(chalk.bold("\nHQ Sync — Now (all)"));
981
1044
  console.log(` HQ root: ${hqRoot}`);
982
- console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
1045
+ console.log(` Strategy: ${onConflict ?? "(interactive)"}`);
1046
+ if (skipPersonal) {
1047
+ console.log(` Personal: skipped (--no-personal)`);
1048
+ }
1049
+ console.log("");
983
1050
  // Push first (matches runner), then pull. Re-uses the per-leg orchestrators
984
1051
  // so the per-target rendering, error isolation, and exit codes are
985
1052
  // identical to running `push --all` then `pull --all` back-to-back.
986
1053
  console.log(chalk.dim("→ push --all"));
987
- await runPushAll(hqRoot, message, onConflict);
1054
+ await runPushAll(hqRoot, message, onConflict, skipPersonal);
988
1055
  console.log(chalk.dim("\n→ pull --all"));
989
1056
  // US-011: forward --mode-all so the strict refusal applies to the
990
1057
  // pull leg (push doesn't need a narrow-hint — the narrow ritual is
991
1058
  // pull-side).
992
- await runPullAll(hqRoot, onConflict, modeAllOverride);
1059
+ await runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal);
993
1060
  }
994
1061
  /**
995
1062
  * Best-effort read of the active company slug from `<hqRoot>/.hq/config.json`.
@@ -1063,4 +1130,4 @@ function resolveUploadAuthorFromCache() {
1063
1130
  }
1064
1131
  }
1065
1132
  //# sourceMappingURL=cloud.js.map
1066
- //# debugId=4672d875-1dc8-56a2-bef1-49c7f4ff6c76
1133
+ //# debugId=bdaf154f-b17d-5482-9e4d-feaebe606a1b
@@ -72,8 +72,17 @@ export type S3ClientFactory = (input: {
72
72
  sessionToken: string;
73
73
  };
74
74
  }) => FilesBrowseS3Client;
75
- /** ACL provenance for a single listed key. */
76
- export type AclSource = "shared-with-you" | "role-bypass";
75
+ /**
76
+ * ACL provenance for a single listed key.
77
+ * - `shared-with-you`: an explicit grant the caller holds covers the key.
78
+ * - `role-bypass`: the caller has no covering explicit grant, but
79
+ * owner/admin role widened the browse-vend policy to include it.
80
+ * - `personal-vault`: the key lives in the caller's own person-entity
81
+ * vault, where no grants graph applies — the caller is the only
82
+ * principal with access by construction. Emitted only when
83
+ * `runBrowse({ personalMode: true })`.
84
+ */
85
+ export type AclSource = "shared-with-you" | "role-bypass" | "personal-vault";
77
86
  export interface BrowseRow {
78
87
  key: string;
79
88
  size: number;
@@ -112,10 +121,30 @@ export declare function assertOutPathOutsideCompanies(outPath: string, hqRoot: s
112
121
  */
113
122
  export declare function formatBrowseTable(rows: BrowseRow[]): string;
114
123
  export interface RunBrowseInput {
115
- /** Vault path prefix, e.g. `companies/indigo/scratch/`. */
124
+ /**
125
+ * Vault path prefix.
126
+ * - Company mode (`personalMode: false | undefined`): must start with
127
+ * `companies/<slug>/`, e.g. `companies/indigo/scratch/`.
128
+ * - Personal mode (`personalMode: true`): bucket-relative; empty string
129
+ * lists the whole personal vault root.
130
+ */
116
131
  pathPrefix: string;
117
- /** Caller-overridden company slug (defaults to slug parsed from path). */
132
+ /** Caller-overridden company slug (defaults to slug parsed from path). Ignored under `personalMode`. */
118
133
  companySlug?: string;
134
+ /**
135
+ * Personal-vault mode. Skips the `companies/<slug>/` path requirement,
136
+ * resolves the entity via `entity.get(personalUid)` instead of the
137
+ * company namespace, omits the explicit-grants fetch (no grants graph
138
+ * on a person bucket), and marks every row's `aclSource` as
139
+ * `"personal-vault"`. Closes hq-cli#26 (audit gap for personal vault).
140
+ */
141
+ personalMode?: boolean;
142
+ /**
143
+ * Canonical person-entity UID (e.g. `prs_…`). Required when
144
+ * `personalMode: true`; ignored otherwise. Caller resolves via
145
+ * `resolveCanonicalPersonUid` to keep this orchestrator pure.
146
+ */
147
+ personalUid?: string;
119
148
  vaultClient: FilesBrowseVaultClient;
120
149
  s3Factory: S3ClientFactory;
121
150
  region: string;
@@ -137,7 +166,11 @@ export interface RunBrowseResult {
137
166
  */
138
167
  export declare function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>;
139
168
  export interface RunCatInput {
140
- /** Single vault key, e.g. `companies/indigo/scratch/foo.txt`. */
169
+ /**
170
+ * Single vault key.
171
+ * - Company mode: must be a `companies/<slug>/...` path.
172
+ * - Personal mode: bucket-relative, e.g. `.claude/CLAUDE.md`.
173
+ */
141
174
  key: string;
142
175
  /**
143
176
  * Where to write the body. `undefined` ⇒ stdout. Bright-line-guarded
@@ -146,6 +179,10 @@ export interface RunCatInput {
146
179
  out?: string;
147
180
  hqRoot: string;
148
181
  companySlug?: string;
182
+ /** Personal-vault mode — see `RunBrowseInput.personalMode`. */
183
+ personalMode?: boolean;
184
+ /** Canonical person-entity UID; required when `personalMode: true`. */
185
+ personalUid?: string;
149
186
  vaultClient: FilesBrowseVaultClient;
150
187
  s3Factory: S3ClientFactory;
151
188
  region: string;
@@ -29,7 +29,7 @@
29
29
  * `pnpm.overrides` until that release ships to npm.
30
30
  */
31
31
 
32
- !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]="7a4467c5-bef0-5f15-99f9-9db157497caa")}catch(e){}}();
32
+ !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]="55a4ece7-6b33-5809-97c0-475c423bc012")}catch(e){}}();
33
33
  import chalk from "chalk";
34
34
  import * as fs from "node:fs";
35
35
  import * as path from "node:path";
@@ -38,6 +38,7 @@ import { S3Client, ListObjectsV2Command, GetObjectCommand, } from "@aws-sdk/clie
38
38
  import { VaultClient, } from "@indigoai-us/hq-cloud";
39
39
  import { DEFAULT_HQ_ROOT, DEFAULT_COGNITO, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
40
40
  import { getCompanyUid } from "../utils/vault-api.js";
41
+ import { resolveCanonicalPersonUid } from "./cloud.js";
41
42
  // ── Pure helpers ────────────────────────────────────────────────────────────
42
43
  /**
43
44
  * Parse the company slug from a vault prefix. Vault paths are anchored at
@@ -122,19 +123,43 @@ export function formatBrowseTable(rows) {
122
123
  * Pure-ish: no console output, no process.exit — caller renders + exits.
123
124
  */
124
125
  export async function runBrowse(input) {
125
- const { pathPrefix, vaultClient, s3Factory, region } = input;
126
- const slug = input.companySlug ?? parseCompanySlugFromPath(pathPrefix);
127
- const entity = await vaultClient.entity.findInMyNamespace("company", slug);
128
- if (!entity) {
129
- throw new Error(`No company found for slug '${slug}' in your namespace. Confirm you have an active membership.`);
126
+ const { pathPrefix, vaultClient, s3Factory, region, personalMode } = input;
127
+ // Branch by mode. Company mode parses slug from path and looks up by
128
+ // namespace; personal mode resolves the entity directly by the supplied
129
+ // person UID and skips the slug + grants machinery (a person bucket has
130
+ // no grants graph the owner is the only principal). The vend call is
131
+ // identical for both modes once we have the entity in hand.
132
+ let bucket;
133
+ let entityUid;
134
+ if (personalMode) {
135
+ if (!input.personalUid) {
136
+ throw new Error("runBrowse: personalMode requires personalUid. Resolve via " +
137
+ "resolveCanonicalPersonUid() before calling.");
138
+ }
139
+ const entity = await vaultClient.entity.get(input.personalUid);
140
+ if (!entity.bucketName) {
141
+ throw new Error(`Personal entity '${input.personalUid}' has no provisioned bucket.`);
142
+ }
143
+ entityUid = entity.uid;
144
+ bucket = entity.bucketName;
130
145
  }
131
- if (!entity.bucketName) {
132
- throw new Error(`Company '${slug}' (${entity.uid}) has no provisioned bucket.`);
146
+ else {
147
+ const slug = input.companySlug ?? parseCompanySlugFromPath(pathPrefix);
148
+ const entity = await vaultClient.entity.findInMyNamespace("company", slug);
149
+ if (!entity) {
150
+ throw new Error(`No company found for slug '${slug}' in your namespace. Confirm you have an active membership.`);
151
+ }
152
+ if (!entity.bucketName) {
153
+ throw new Error(`Company '${slug}' (${entity.uid}) has no provisioned bucket.`);
154
+ }
155
+ entityUid = entity.uid;
156
+ bucket = entity.bucketName;
133
157
  }
134
- const companyUid = entity.uid;
135
- const bucket = entity.bucketName;
136
158
  // Distinct vend call from sync — `purpose: 'browse'` opts the request
137
- // into the role-bypass-allowed code path on the server (US-009).
159
+ // into the role-bypass-allowed code path on the server (US-009). The
160
+ // personal mode vends against the person entity which is owner-only by
161
+ // construction; the vend response shape is identical so downstream
162
+ // S3Client construction doesn't branch.
138
163
  const vend = await vaultClient.vend({
139
164
  paths: [pathPrefix],
140
165
  operations: "read-only",
@@ -149,8 +174,12 @@ export async function runBrowse(input) {
149
174
  },
150
175
  });
151
176
  // Pull the caller's explicit-grant graph once so per-key classification
152
- // is O(grants) without N round-trips.
153
- const grants = await vaultClient.listMyExplicitGrants(companyUid);
177
+ // is O(grants) without N round-trips. Skipped in personal mode — the
178
+ // grants graph is a company concept; a person bucket marks every row
179
+ // as `"personal-vault"` directly.
180
+ const grants = personalMode
181
+ ? []
182
+ : await vaultClient.listMyExplicitGrants(entityUid);
154
183
  const rows = [];
155
184
  let continuationToken;
156
185
  do {
@@ -169,7 +198,7 @@ export async function runBrowse(input) {
169
198
  key: obj.Key,
170
199
  size: obj.Size ?? 0,
171
200
  lastModified: obj.LastModified,
172
- aclSource: classifyAclSource(obj.Key, grants),
201
+ aclSource: personalMode ? "personal-vault" : classifyAclSource(obj.Key, grants),
173
202
  });
174
203
  }
175
204
  continuationToken = resp.NextContinuationToken ?? undefined;
@@ -182,20 +211,37 @@ export async function runBrowse(input) {
182
211
  * containment guard). Refuses ahead of any I/O when `--out` is unsafe.
183
212
  */
184
213
  export async function runCat(input) {
185
- const { key, vaultClient, s3Factory, region, hqRoot } = input;
186
- const slug = input.companySlug ?? parseCompanySlugFromPath(key);
214
+ const { key, vaultClient, s3Factory, region, hqRoot, personalMode } = input;
187
215
  // Acceptance 5: refuse BEFORE vending — no point pulling credentials
188
216
  // for a request we're already going to abort.
189
217
  let absOut;
190
218
  if (input.out !== undefined) {
191
219
  absOut = assertOutPathOutsideCompanies(input.out, hqRoot);
192
220
  }
193
- const entity = await vaultClient.entity.findInMyNamespace("company", slug);
194
- if (!entity) {
195
- throw new Error(`No company found for slug '${slug}' in your namespace. Confirm you have an active membership.`);
221
+ // Same branch logic as runBrowse — see that function's doc-block for
222
+ // the personal-vs-company rationale.
223
+ let bucket;
224
+ if (personalMode) {
225
+ if (!input.personalUid) {
226
+ throw new Error("runCat: personalMode requires personalUid. Resolve via " +
227
+ "resolveCanonicalPersonUid() before calling.");
228
+ }
229
+ const entity = await vaultClient.entity.get(input.personalUid);
230
+ if (!entity.bucketName) {
231
+ throw new Error(`Personal entity '${input.personalUid}' has no provisioned bucket.`);
232
+ }
233
+ bucket = entity.bucketName;
196
234
  }
197
- if (!entity.bucketName) {
198
- throw new Error(`Company '${slug}' (${entity.uid}) has no provisioned bucket.`);
235
+ else {
236
+ const slug = input.companySlug ?? parseCompanySlugFromPath(key);
237
+ const entity = await vaultClient.entity.findInMyNamespace("company", slug);
238
+ if (!entity) {
239
+ throw new Error(`No company found for slug '${slug}' in your namespace. Confirm you have an active membership.`);
240
+ }
241
+ if (!entity.bucketName) {
242
+ throw new Error(`Company '${slug}' (${entity.uid}) has no provisioned bucket.`);
243
+ }
244
+ bucket = entity.bucketName;
199
245
  }
200
246
  const vend = await vaultClient.vend({
201
247
  paths: [key],
@@ -210,7 +256,7 @@ export async function runCat(input) {
210
256
  sessionToken: vend.credentials.sessionToken,
211
257
  },
212
258
  });
213
- const resp = (await s3.send(new GetObjectCommand({ Bucket: entity.bucketName, Key: key })));
259
+ const resp = (await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key })));
214
260
  if (!resp.Body) {
215
261
  throw new Error(`GetObject for '${key}' returned no body.`);
216
262
  }
@@ -247,15 +293,47 @@ const defaultS3Factory = ({ region, credentials }) => new S3Client({ region, cre
247
293
  */
248
294
  export function registerFilesBrowseCommands(filesCmd) {
249
295
  filesCmd
250
- .command("browse <path>")
251
- .description("List vault objects under <path> without syncing them locally. Uses the browse-vend path (role-bypass allowed).")
296
+ .command("browse [path]")
297
+ .description("List vault objects under [path] without syncing them locally. Uses the browse-vend path (role-bypass allowed). Pass --personal to browse the caller's personal vault; otherwise [path] must start with companies/<slug>/.")
252
298
  .option("--company <slug>", "Company slug (defaults to the slug parsed from <path>)")
299
+ .option("--personal", "Browse the caller's canonical personal vault. [path] is treated as " +
300
+ "bucket-relative (omit it to list the vault root). Mutually exclusive " +
301
+ "with --company.")
253
302
  .option("--hq-root <path>", `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`, DEFAULT_HQ_ROOT)
254
303
  .action(async (pathArg, options) => {
255
304
  try {
305
+ if (options.personal && options.company) {
306
+ throw new Error("--personal and --company are mutually exclusive. Pick one.");
307
+ }
256
308
  const accessToken = await ensureCognitoToken();
257
309
  const vaultConfig = buildVaultConfig(accessToken);
258
310
  const client = new VaultClient(vaultConfig);
311
+ if (options.personal) {
312
+ // Personal-vault path. Resolve the caller's canonical person
313
+ // entity once; the orchestrator does the bucket lookup + vend.
314
+ // Empty [path] → list bucket root.
315
+ const personalUid = await resolveCanonicalPersonUid({
316
+ listMyMemberships: () => client.listMyMemberships(),
317
+ listPersonEntities: () => client.entity.listByType("person"),
318
+ getEntity: async () => null,
319
+ });
320
+ const result = await runBrowse({
321
+ pathPrefix: pathArg ?? "",
322
+ personalMode: true,
323
+ personalUid,
324
+ vaultClient: client,
325
+ s3Factory: defaultS3Factory,
326
+ region: DEFAULT_COGNITO.region,
327
+ });
328
+ console.log(formatBrowseTable(result.rows));
329
+ return;
330
+ }
331
+ // Company path. [path] is required here — the slug parse needs it.
332
+ if (!pathArg) {
333
+ throw new Error("browse: [path] is required when --personal is not set. " +
334
+ "Pass a companies/<slug>/... path, or add --personal to " +
335
+ "browse your personal vault.");
336
+ }
259
337
  // Resolve slug — CLI flag wins, otherwise parse from path arg.
260
338
  const slug = options.company ?? parseCompanySlugFromPath(pathArg);
261
339
  // If the user passed `--company` AND the path doesn't begin with
@@ -301,15 +379,41 @@ export function registerFilesBrowseCommands(filesCmd) {
301
379
  });
302
380
  filesCmd
303
381
  .command("cat <path>")
304
- .description("Stream a single vault object to stdout (or --out <file>) without syncing it. Uses the browse-vend path.")
382
+ .description("Stream a single vault object to stdout (or --out <file>) without syncing it. Uses the browse-vend path. Pass --personal to read from the caller's personal vault.")
305
383
  .option("--out <file>", "Write the object body to <file> instead of stdout. Refused under <hqRoot>/companies/.")
306
384
  .option("--company <slug>", "Company slug (defaults to the slug parsed from <path>)")
385
+ .option("--personal", "Read from the caller's canonical personal vault. <path> is treated as " +
386
+ "bucket-relative. Mutually exclusive with --company.")
307
387
  .option("--hq-root <path>", `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`, DEFAULT_HQ_ROOT)
308
388
  .action(async (keyArg, options) => {
309
389
  try {
390
+ if (options.personal && options.company) {
391
+ throw new Error("--personal and --company are mutually exclusive. Pick one.");
392
+ }
310
393
  const accessToken = await ensureCognitoToken();
311
394
  const vaultConfig = buildVaultConfig(accessToken);
312
395
  const client = new VaultClient(vaultConfig);
396
+ if (options.personal) {
397
+ const personalUid = await resolveCanonicalPersonUid({
398
+ listMyMemberships: () => client.listMyMemberships(),
399
+ listPersonEntities: () => client.entity.listByType("person"),
400
+ getEntity: async () => null,
401
+ });
402
+ const result = await runCat({
403
+ key: keyArg,
404
+ out: options.out,
405
+ hqRoot: options.hqRoot,
406
+ personalMode: true,
407
+ personalUid,
408
+ vaultClient: client,
409
+ s3Factory: defaultS3Factory,
410
+ region: DEFAULT_COGNITO.region,
411
+ });
412
+ if (result.destination.kind === "file") {
413
+ console.error(chalk.green("✓"), `Wrote ${result.bytesWritten} bytes to ${result.destination.absPath}`);
414
+ }
415
+ return;
416
+ }
313
417
  const slug = options.company ?? parseCompanySlugFromPath(keyArg);
314
418
  if (options.company !== undefined) {
315
419
  const fromPath = (() => {
@@ -345,4 +449,4 @@ export function registerFilesBrowseCommands(filesCmd) {
345
449
  });
346
450
  }
347
451
  //# sourceMappingURL=files-browse.js.map
348
- //# debugId=7a4467c5-bef0-5f15-99f9-9db157497caa
452
+ //# debugId=55a4ece7-6b33-5809-97c0-475c423bc012
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.18.3",
3
+ "version": "5.20.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -15,7 +15,7 @@
15
15
  "clean": "rm -rf dist"
16
16
  },
17
17
  "dependencies": {
18
- "@indigoai-us/hq-cloud": "^5.23.0",
18
+ "@indigoai-us/hq-cloud": "~5.25.0",
19
19
  "@indigoai-us/hq-onboarding": "^0.1.0",
20
20
  "@sentry/node": "^10.49.0",
21
21
  "chalk": "^5.3.0",