@indigoai-us/hq-cli 5.17.0 → 5.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,10 +13,12 @@
13
13
  * hq sync status — show local journal summary
14
14
  */
15
15
  import { Command } from "commander";
16
- import { type ConflictStrategy } from "@indigoai-us/hq-cloud";
16
+ import { type ConflictStrategy, type MembershipSyncConfig } from "@indigoai-us/hq-cloud";
17
+ import { type BannerLevel } from "../lib/narrow-hint-banner.js";
17
18
  export interface PullAllVaultClient {
18
19
  listMyMemberships(): Promise<Array<{
19
20
  companyUid: string;
21
+ membershipKey?: string;
20
22
  }>>;
21
23
  listPersonEntities(): Promise<Array<{
22
24
  uid: string;
@@ -29,6 +31,13 @@ export interface PullAllVaultClient {
29
31
  slug?: string;
30
32
  name?: string;
31
33
  } | null>;
34
+ /**
35
+ * US-011: optional — when present, `pullAll` calls it once per
36
+ * membership to surface the narrow-hint banner for all-mode owners.
37
+ * Absent on legacy adapters (push-all et al.) where the banner is not
38
+ * applicable.
39
+ */
40
+ getMembershipSyncConfig?: (membershipId: string) => Promise<MembershipSyncConfig>;
32
41
  }
33
42
  export interface SyncCallOptions {
34
43
  company: string;
@@ -52,6 +61,22 @@ export interface PullAllDeps {
52
61
  export interface PullAllOptions {
53
62
  hqRoot: string;
54
63
  onConflict?: ConflictStrategy;
64
+ /**
65
+ * US-011: banner level for the narrow-hint nudge. Defaults to `'hint'`
66
+ * — see `resolveBannerLevel` for the env-driven override. The
67
+ * `'strict'` level causes `pullAll` to refuse to sync any membership
68
+ * still on `syncMode: 'all'` unless `modeAllOverride` is true.
69
+ *
70
+ * TODO(hq-core-staging release N+2): default flips to 'warning'.
71
+ * TODO(hq-core-staging release N+3): default flips to 'strict'.
72
+ */
73
+ narrowHintLevel?: BannerLevel;
74
+ /**
75
+ * US-011: when `true`, strict-mode does NOT refuse all-mode
76
+ * memberships — the operator has explicitly opted into keeping the
77
+ * legacy behavior for this run via `--mode-all`.
78
+ */
79
+ modeAllOverride?: boolean;
55
80
  }
56
81
  export interface PullAllRow {
57
82
  slug: string;
@@ -138,5 +163,42 @@ export declare function assertSingleSelector(opts: {
138
163
  personal?: boolean;
139
164
  company?: string;
140
165
  }, command: string): void;
166
+ /**
167
+ * Per-company pull resolution helper used by `hq sync pull --company <slug>`
168
+ * (US-011 fix, 2026-05-21). Mirrors the inline lookup that `runNowSingle`
169
+ * does for sync-now. Pulled out so the action handler stays thin AND so
170
+ * unit tests can exercise the banner / strict-refusal decision without
171
+ * spinning up commander + a real VaultClient.
172
+ *
173
+ * Input shape:
174
+ * - `targetCompany` — slug or UID the caller passed to `--company`. If
175
+ * undefined, the helper short-circuits to a "no resolution" result
176
+ * (the action handler falls back to .hq/config.json via sync()).
177
+ * - `client` — minimal VaultClient surface: listMyMemberships + entity.get
178
+ * + getMembershipSyncConfig.
179
+ *
180
+ * Output: `{ resolvedCompanyUid, resolvedMode }` — either may be undefined
181
+ * if the membership / sync-config call failed. Both undefined is a clean
182
+ * degradation — the caller pulls without a banner.
183
+ */
184
+ export interface PerCompanyPullResolveClient {
185
+ listMyMemberships(): Promise<Array<{
186
+ companyUid: string;
187
+ membershipKey: string;
188
+ }>>;
189
+ getMembershipSyncConfig(membershipKey: string): Promise<{
190
+ syncMode: MembershipSyncConfig["syncMode"];
191
+ }>;
192
+ entity: {
193
+ get(uid: string): Promise<{
194
+ slug?: string;
195
+ }>;
196
+ };
197
+ }
198
+ export interface PerCompanyPullResolveResult {
199
+ resolvedCompanyUid: string | undefined;
200
+ resolvedMode: MembershipSyncConfig["syncMode"] | undefined;
201
+ }
202
+ export declare function resolvePerCompanyPullPlan(client: PerCompanyPullResolveClient, targetCompany: string | undefined): Promise<PerCompanyPullResolveResult>;
141
203
  export declare function registerCloudCommands(program: Command): void;
142
204
  //# sourceMappingURL=cloud.d.ts.map
@@ -13,12 +13,13 @@
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]="7bc55d66-7b02-5b45-9444-710bb6d8dfd6")}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]="4672d875-1dc8-56a2-bef1-49c7f4ff6c76")}catch(e){}}();
17
17
  import chalk from "chalk";
18
18
  import * as fs from "fs";
19
19
  import * as path from "path";
20
20
  import { share, sync, readJournal, getJournalPath, loadCachedTokens, VaultClient, computePersonalVaultPaths, } from "@indigoai-us/hq-cloud";
21
21
  import { DEFAULT_HQ_ROOT, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
22
+ import { emitNarrowHint, isStrictRefusal, resolveBannerLevel, } from "../lib/narrow-hint-banner.js";
22
23
  // Oldest-first by createdAt, ties broken by uid lexicographic — matches
23
24
  // `pickCanonicalPersonEntity` in @indigoai-us/hq-cloud so the CLI lands on
24
25
  // the same person bucket that `hq-sync-runner` picks.
@@ -35,6 +36,8 @@ function pickCanonicalPerson(persons) {
35
36
  export async function pullAll(options, deps) {
36
37
  const memberships = await deps.vaultClient.listMyMemberships();
37
38
  const persons = await deps.vaultClient.listPersonEntities();
39
+ const narrowHintLevel = options.narrowHintLevel ?? "hint";
40
+ const getSyncConfig = deps.vaultClient.getMembershipSyncConfig;
38
41
  const plan = [];
39
42
  for (const m of memberships) {
40
43
  let slug = m.companyUid;
@@ -48,6 +51,8 @@ export async function pullAll(options, deps) {
48
51
  }
49
52
  plan.push({
50
53
  slug,
54
+ companyUid: m.companyUid,
55
+ ...(m.membershipKey ? { membershipKey: m.membershipKey } : {}),
51
56
  syncOptions: {
52
57
  company: m.companyUid,
53
58
  hqRoot: options.hqRoot,
@@ -78,12 +83,57 @@ export async function pullAll(options, deps) {
78
83
  };
79
84
  for (const entry of plan) {
80
85
  result.attempted += 1;
86
+ // US-011: resolve the membership's effective sync mode so we can
87
+ // either nudge an all-mode owner toward `hq sync narrow` OR refuse
88
+ // the leg outright when strict-mode is on and the operator didn't
89
+ // pass `--mode-all`. Sync-config lookup is best-effort — a 404 or
90
+ // network blip should never block the sync itself, so we fall back
91
+ // to syncMode='all' (the legacy default) and skip the banner.
92
+ let resolvedMode;
93
+ if (entry.membershipKey && getSyncConfig) {
94
+ try {
95
+ const cfg = await getSyncConfig(entry.membershipKey);
96
+ resolvedMode = cfg.syncMode;
97
+ }
98
+ catch {
99
+ resolvedMode = undefined;
100
+ }
101
+ }
102
+ if (resolvedMode === "all" &&
103
+ isStrictRefusal(resolvedMode, narrowHintLevel) &&
104
+ !options.modeAllOverride &&
105
+ entry.companyUid) {
106
+ // Emit the strict-level banner once, then mark the leg as errored
107
+ // without invoking sync(). The operator either narrows the
108
+ // membership (`hq sync narrow --apply`) or passes `--mode-all` to
109
+ // opt back in.
110
+ emitNarrowHint({
111
+ companyUid: entry.companyUid,
112
+ syncMode: resolvedMode,
113
+ level: narrowHintLevel,
114
+ });
115
+ const message = "Refusing to pull all-mode membership in strict mode. " +
116
+ "Run `hq sync narrow --apply` to migrate, or re-run with --mode-all.";
117
+ result.errors.push({ company: entry.slug, message });
118
+ result.perCompany.push({ slug: entry.slug, error: message });
119
+ continue;
120
+ }
81
121
  try {
82
122
  const r = await deps.sync(entry.syncOptions);
83
123
  result.filesDownloaded += r.filesDownloaded;
84
124
  result.bytesDownloaded += r.bytesDownloaded;
85
125
  result.conflicts += r.conflicts;
86
126
  result.perCompany.push({ slug: entry.slug, result: r });
127
+ // Banner emitted AFTER the leg succeeds so it appears alongside
128
+ // the per-company summary line and doesn't get scrolled off by
129
+ // sync chatter.
130
+ if (resolvedMode === "all" && entry.companyUid) {
131
+ emitNarrowHint({
132
+ companyUid: entry.companyUid,
133
+ syncMode: resolvedMode,
134
+ level: narrowHintLevel,
135
+ });
136
+ }
87
137
  }
88
138
  catch (err) {
89
139
  const message = err instanceof Error ? err.message : String(err);
@@ -202,6 +252,54 @@ export function assertSingleSelector(opts, command) {
202
252
  `--company; got: ${selectors.join(", ")}.`);
203
253
  }
204
254
  }
255
+ export async function resolvePerCompanyPullPlan(client, targetCompany) {
256
+ if (!targetCompany)
257
+ return { resolvedCompanyUid: undefined, resolvedMode: undefined };
258
+ try {
259
+ const memberships = await client.listMyMemberships();
260
+ // Direct UID / membershipKey match first (cheapest).
261
+ const direct = memberships.find((m) => m.companyUid === targetCompany || m.membershipKey === targetCompany);
262
+ if (direct) {
263
+ let mode;
264
+ try {
265
+ const cfg = await client.getMembershipSyncConfig(direct.membershipKey);
266
+ mode = cfg.syncMode;
267
+ }
268
+ catch {
269
+ mode = undefined;
270
+ }
271
+ return { resolvedCompanyUid: direct.companyUid, resolvedMode: mode };
272
+ }
273
+ // Slug match — listMyMemberships returns companyUid only, so fan out
274
+ // entity.get to find the row whose slug matches the caller's input.
275
+ for (const m of memberships) {
276
+ try {
277
+ const entity = await client.entity.get(m.companyUid);
278
+ if (entity.slug === targetCompany) {
279
+ let mode;
280
+ try {
281
+ const cfg = await client.getMembershipSyncConfig(m.membershipKey);
282
+ mode = cfg.syncMode;
283
+ }
284
+ catch {
285
+ mode = undefined;
286
+ }
287
+ return { resolvedCompanyUid: m.companyUid, resolvedMode: mode };
288
+ }
289
+ }
290
+ catch {
291
+ // Entity not visible — skip and continue. Worst case the loop ends
292
+ // with no match and we return undefined for both — the pull still
293
+ // proceeds, banner just stays quiet.
294
+ }
295
+ }
296
+ }
297
+ catch {
298
+ // listMyMemberships failed — degrade silently. Sync still works without
299
+ // the banner; this matches the runPullAll catch behavior.
300
+ }
301
+ return { resolvedCompanyUid: undefined, resolvedMode: undefined };
302
+ }
205
303
  export function registerCloudCommands(program) {
206
304
  program
207
305
  .command("push")
@@ -407,6 +505,10 @@ export function registerCloudCommands(program) {
407
505
  "(no companies/<slug>/ prefix). Resolves the person UID automatically " +
408
506
  "from the cached Cognito session. Mutually exclusive with --company " +
409
507
  "and --all.")
508
+ .option("--mode-all", "US-011: opt out of the strict narrow-hint refusal for this run. " +
509
+ "Has no effect today (default narrow-hint level is 'hint'); " +
510
+ "wired so future hq-core-staging releases can flip the default to " +
511
+ "'strict' without re-touching this command.")
410
512
  .action(async (options) => {
411
513
  try {
412
514
  assertSingleSelector(options, "pull");
@@ -416,7 +518,7 @@ export function registerCloudCommands(program) {
416
518
  process.exit(1);
417
519
  }
418
520
  if (options.all) {
419
- await runPullAll(options.hqRoot, options.onConflict);
521
+ await runPullAll(options.hqRoot, options.onConflict, options.modeAll === true);
420
522
  return;
421
523
  }
422
524
  if (options.personal) {
@@ -428,10 +530,37 @@ export function registerCloudCommands(program) {
428
530
  console.log(` HQ root: ${options.hqRoot}`);
429
531
  console.log(` Company: ${options.company ?? "(from .hq/config.json)"}\n`);
430
532
  const accessToken = await ensureCognitoToken();
533
+ const vaultConfig = buildVaultConfig(accessToken);
534
+ // US-011 (2026-05-21 fix): resolve the caller's sync-config for
535
+ // the targeted membership BEFORE the pull, so we can (a) emit the
536
+ // narrow-hint banner after success if still on all-mode and
537
+ // (b) respect strict-mode refusal mirror of the --all + sync-now
538
+ // paths. Failure to resolve degrades silently — pull still works,
539
+ // banner just stays quiet (same as the catch in runPullAll).
540
+ const narrowHintLevel = resolveBannerLevel();
541
+ const { resolvedCompanyUid, resolvedMode } = await resolvePerCompanyPullPlan(new VaultClient(vaultConfig), options.company);
542
+ // Strict-mode refusal: matches runPullAll + runNowSingle behavior.
543
+ // Default banner level is 'hint' which never triggers refusal —
544
+ // wired now so future hq-core-staging releases can flip the
545
+ // default to 'strict' without re-touching this command.
546
+ if (resolvedMode === "all" &&
547
+ isStrictRefusal(resolvedMode, narrowHintLevel) &&
548
+ options.modeAll !== true &&
549
+ resolvedCompanyUid) {
550
+ emitNarrowHint({
551
+ companyUid: resolvedCompanyUid,
552
+ syncMode: resolvedMode,
553
+ level: narrowHintLevel,
554
+ });
555
+ console.error(chalk.red("\n✗ Pull refused: strict narrow-hint mode is on and this " +
556
+ "membership still pulls everything. Run `hq sync narrow --apply` " +
557
+ "to migrate, or re-run with --mode-all."));
558
+ process.exit(1);
559
+ }
431
560
  const result = await sync({
432
561
  company: options.company,
433
562
  onConflict: options.onConflict,
434
- vaultConfig: buildVaultConfig(accessToken),
563
+ vaultConfig,
435
564
  hqRoot: options.hqRoot,
436
565
  });
437
566
  if (result.aborted) {
@@ -439,6 +568,16 @@ export function registerCloudCommands(program) {
439
568
  process.exit(1);
440
569
  }
441
570
  console.log(chalk.green(`\n✓ Pulled ${result.filesDownloaded} file(s) (${formatBytes(result.bytesDownloaded)}, ${result.filesSkipped} skipped, ${result.conflicts} conflicts)`));
571
+ // US-011 (2026-05-21 fix): emit the hint banner after success
572
+ // so it appears alongside the summary line. Mirrors the wiring
573
+ // in runPullAll (cloud.ts:331) and runNowSingle (cloud.ts:1371).
574
+ if (resolvedMode === "all" && resolvedCompanyUid) {
575
+ emitNarrowHint({
576
+ companyUid: resolvedCompanyUid,
577
+ syncMode: resolvedMode,
578
+ level: narrowHintLevel,
579
+ });
580
+ }
442
581
  }
443
582
  catch (err) {
444
583
  console.error(chalk.red("\n✗ Pull failed:"), err instanceof Error ? err.message : String(err));
@@ -502,14 +641,17 @@ export function registerCloudCommands(program) {
502
641
  "--personal.")
503
642
  .option("--personal", "Sync the caller's canonical personal vault bidirectionally. " +
504
643
  "Mutually exclusive with --company and --all.")
644
+ .option("--mode-all", "US-011: opt out of the strict narrow-hint refusal for this run. " +
645
+ "No-op today; wired so future hq-core-staging releases can flip " +
646
+ "the default narrow-hint level to 'strict'.")
505
647
  .action(async (options) => {
506
648
  try {
507
649
  assertSingleSelector(options, "now");
508
650
  if (options.all) {
509
- await runNowAll(options.hqRoot, options.message, options.onConflict);
651
+ await runNowAll(options.hqRoot, options.message, options.onConflict, options.modeAll === true);
510
652
  return;
511
653
  }
512
- await runNowSingle(options.hqRoot, options.company, options.personal === true, options.message, options.onConflict);
654
+ await runNowSingle(options.hqRoot, options.company, options.personal === true, options.message, options.onConflict, options.modeAll === true);
513
655
  }
514
656
  catch (err) {
515
657
  console.error(chalk.red("\n✗ Sync now failed:"), err instanceof Error ? err.message : String(err));
@@ -517,7 +659,7 @@ export function registerCloudCommands(program) {
517
659
  }
518
660
  });
519
661
  }
520
- async function runPullAll(hqRoot, onConflict) {
662
+ async function runPullAll(hqRoot, onConflict, modeAllOverride) {
521
663
  console.log(chalk.bold("\nHQ Sync — Pull (all)"));
522
664
  console.log(` HQ root: ${hqRoot}`);
523
665
  console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
@@ -537,8 +679,14 @@ async function runPullAll(hqRoot, onConflict) {
537
679
  return null;
538
680
  }
539
681
  },
682
+ getMembershipSyncConfig: (id) => realClient.getMembershipSyncConfig(id),
540
683
  };
541
- result = await pullAll({ hqRoot, ...(onConflict ? { onConflict } : {}) }, {
684
+ result = await pullAll({
685
+ hqRoot,
686
+ ...(onConflict ? { onConflict } : {}),
687
+ narrowHintLevel: resolveBannerLevel(),
688
+ ...(modeAllOverride ? { modeAllOverride: true } : {}),
689
+ }, {
542
690
  vaultClient: adapter,
543
691
  sync: (opts) => sync({
544
692
  company: opts.company,
@@ -687,7 +835,7 @@ async function runPushAll(hqRoot, message, onConflict) {
687
835
  if (errored > 0)
688
836
  process.exit(1);
689
837
  }
690
- async function runNowSingle(hqRoot, company, personal, message, onConflict) {
838
+ async function runNowSingle(hqRoot, company, personal, message, onConflict, modeAllOverride) {
691
839
  console.log(chalk.bold("\nHQ Sync — Now"));
692
840
  console.log(` HQ root: ${hqRoot}`);
693
841
  console.log(` Target: ${personal ? "(personal)" : (company ?? "(active company)")}`);
@@ -752,6 +900,49 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict) {
752
900
  console.log(chalk.yellow("\n⚠ Sync now aborted on push leg; pull skipped."));
753
901
  process.exit(1);
754
902
  }
903
+ // US-011: resolve membership sync-config so we can either nudge an
904
+ // all-mode owner or refuse the pull when strict-mode is on. Skipped
905
+ // for personal targets (personal vault has no membership row) and
906
+ // for resolution failures (best-effort — never block sync). The
907
+ // lookup runs BEFORE the pull leg so strict refusal can short-circuit
908
+ // without burning a sync.
909
+ const narrowHintLevel = resolveBannerLevel();
910
+ let resolvedMode;
911
+ let resolvedCompanyUid;
912
+ if (!personalMode && targetCompany) {
913
+ try {
914
+ const client = new VaultClient(vaultConfig);
915
+ const memberships = await client.listMyMemberships();
916
+ const match = memberships.find((m) => m.companyUid === targetCompany || m.membershipKey === targetCompany);
917
+ if (match) {
918
+ resolvedCompanyUid = match.companyUid;
919
+ try {
920
+ const cfg = await client.getMembershipSyncConfig(match.membershipKey);
921
+ resolvedMode = cfg.syncMode;
922
+ }
923
+ catch {
924
+ resolvedMode = undefined;
925
+ }
926
+ }
927
+ }
928
+ catch {
929
+ resolvedMode = undefined;
930
+ }
931
+ }
932
+ if (resolvedMode === "all" &&
933
+ isStrictRefusal(resolvedMode, narrowHintLevel) &&
934
+ !modeAllOverride &&
935
+ resolvedCompanyUid) {
936
+ emitNarrowHint({
937
+ companyUid: resolvedCompanyUid,
938
+ syncMode: resolvedMode,
939
+ level: narrowHintLevel,
940
+ });
941
+ console.error(chalk.red("\n✗ Sync now refused: strict narrow-hint mode is on and this " +
942
+ "membership still pulls everything. Run `hq sync narrow --apply` " +
943
+ "to migrate, or re-run with --mode-all."));
944
+ process.exit(1);
945
+ }
755
946
  console.log(chalk.dim(" → pull leg"));
756
947
  const pullResult = await sync({
757
948
  company: targetCompany,
@@ -769,6 +960,15 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict) {
769
960
  console.log(chalk.yellow("\n⚠ Sync now finished with pull leg aborted."));
770
961
  process.exit(1);
771
962
  }
963
+ // US-011: emit the hint banner after a successful pull so it
964
+ // appears at the bottom of the summary rather than mid-stream.
965
+ if (resolvedMode === "all" && resolvedCompanyUid) {
966
+ emitNarrowHint({
967
+ companyUid: resolvedCompanyUid,
968
+ syncMode: resolvedMode,
969
+ level: narrowHintLevel,
970
+ });
971
+ }
772
972
  console.log(chalk.green("\n✓ Sync now complete"));
773
973
  }
774
974
  catch (err) {
@@ -776,7 +976,7 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict) {
776
976
  process.exit(1);
777
977
  }
778
978
  }
779
- async function runNowAll(hqRoot, message, onConflict) {
979
+ async function runNowAll(hqRoot, message, onConflict, modeAllOverride) {
780
980
  console.log(chalk.bold("\nHQ Sync — Now (all)"));
781
981
  console.log(` HQ root: ${hqRoot}`);
782
982
  console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
@@ -786,7 +986,10 @@ async function runNowAll(hqRoot, message, onConflict) {
786
986
  console.log(chalk.dim("→ push --all"));
787
987
  await runPushAll(hqRoot, message, onConflict);
788
988
  console.log(chalk.dim("\n→ pull --all"));
789
- await runPullAll(hqRoot, onConflict);
989
+ // US-011: forward --mode-all so the strict refusal applies to the
990
+ // pull leg (push doesn't need a narrow-hint — the narrow ritual is
991
+ // pull-side).
992
+ await runPullAll(hqRoot, onConflict, modeAllOverride);
790
993
  }
791
994
  /**
792
995
  * Best-effort read of the active company slug from `<hqRoot>/.hq/config.json`.
@@ -860,4 +1063,4 @@ function resolveUploadAuthorFromCache() {
860
1063
  }
861
1064
  }
862
1065
  //# sourceMappingURL=cloud.js.map
863
- //# debugId=7bc55d66-7b02-5b45-9444-710bb6d8dfd6
1066
+ //# debugId=4672d875-1dc8-56a2-bef1-49c7f4ff6c76
@@ -0,0 +1,178 @@
1
+ /**
2
+ * `hq files browse <path>` + `hq files cat <path> [--out <file>]` (US-008).
3
+ *
4
+ * Peek at a company's vault files **without** ever materialising them under
5
+ * `companies/{co}/` in the local HQ tree. Distinct from the sync path:
6
+ *
7
+ * - `browse` — `ListObjectsV2` under the given prefix, prints
8
+ * `{key, size, lastModified, aclSource}` rows. The
9
+ * `aclSource` hint distinguishes prefixes the caller can
10
+ * see via an EXPLICIT grant (`shared-with-you`) from
11
+ * prefixes they can see only because owner/admin
12
+ * role-bypass widened the vended policy (`role-bypass`).
13
+ * - `cat` — `GetObject`, stream the body to stdout. With `--out
14
+ * <file>` write the body to a path the user picked, but
15
+ * only after a bright-line guard refuses any destination
16
+ * inside `<hqRoot>/companies/` — that's the exact tree
17
+ * `hq sync` owns, and writing a peeked object there would
18
+ * silently re-import it into the sync envelope.
19
+ *
20
+ * Both subcommands vend via the new `purpose: 'browse'` path
21
+ * (`VaultClient.vend`) shipped in hq-cloud US-009. The server treats that
22
+ * purpose as the role-bypass-allowed surface — sync vends NEVER widen, so
23
+ * keeping browse on its own vend call is the acceptance-criteria-1
24
+ * separation we need.
25
+ *
26
+ * Cross-package note: depends on `VendInput`/`VendResult` + the
27
+ * `VaultClient.vend` method from hq-cloud US-009 (commit 2f790c5).
28
+ * hq-cli pins `@indigoai-us/hq-cloud` to `file:../hq-cloud` via
29
+ * `pnpm.overrides` until that release ships to npm.
30
+ */
31
+ import { Command } from "commander";
32
+ import { ListObjectsV2Command, GetObjectCommand, type ListObjectsV2CommandOutput, type GetObjectCommandOutput } from "@aws-sdk/client-s3";
33
+ import { type VendResult, type ExplicitGrant } from "@indigoai-us/hq-cloud";
34
+ /**
35
+ * Subset of `VaultClient` this command actually uses — exposed so tests
36
+ * can stub vend + grants without standing up a real `VaultClient`.
37
+ */
38
+ export interface FilesBrowseVaultClient {
39
+ vend(input: {
40
+ paths: string[];
41
+ operations: "read-only" | "read-write" | "staged-write";
42
+ purpose: "sync" | "browse";
43
+ duration?: number;
44
+ }): Promise<VendResult>;
45
+ listMyExplicitGrants(companyUid: string): Promise<ExplicitGrant[]>;
46
+ entity: {
47
+ get(uid: string): Promise<{
48
+ uid: string;
49
+ slug: string;
50
+ name?: string;
51
+ bucketName?: string;
52
+ }>;
53
+ findInMyNamespace(type: string, slug: string): Promise<{
54
+ uid: string;
55
+ slug: string;
56
+ name?: string;
57
+ bucketName?: string;
58
+ } | null>;
59
+ };
60
+ }
61
+ /** Subset of `S3Client` this command actually uses — for test stubs. */
62
+ export interface FilesBrowseS3Client {
63
+ send(cmd: ListObjectsV2Command): Promise<ListObjectsV2CommandOutput>;
64
+ send(cmd: GetObjectCommand): Promise<GetObjectCommandOutput>;
65
+ }
66
+ /** Factory for an S3 client given vended credentials. Injectable for tests. */
67
+ export type S3ClientFactory = (input: {
68
+ region: string;
69
+ credentials: {
70
+ accessKeyId: string;
71
+ secretAccessKey: string;
72
+ sessionToken: string;
73
+ };
74
+ }) => FilesBrowseS3Client;
75
+ /** ACL provenance for a single listed key. */
76
+ export type AclSource = "shared-with-you" | "role-bypass";
77
+ export interface BrowseRow {
78
+ key: string;
79
+ size: number;
80
+ lastModified: Date | undefined;
81
+ aclSource: AclSource;
82
+ }
83
+ /**
84
+ * Parse the company slug from a vault prefix. Vault paths are anchored at
85
+ * `companies/<slug>/...`; anything else is rejected so we never try to
86
+ * browse a non-company tree (e.g. `personal/`) with a company-vend.
87
+ */
88
+ export declare function parseCompanySlugFromPath(prefix: string): string;
89
+ /**
90
+ * Classify a single S3 key against the caller's explicit-grant list. Any
91
+ * grant whose `path` is a prefix of the key contributes `shared-with-you`;
92
+ * otherwise the key is only visible via role-bypass on the vend call.
93
+ *
94
+ * Grant paths and S3 keys live in the same canonical form ("companies/<slug>/…");
95
+ * `coalescePrefixes` would shrink the list further but isn't required for
96
+ * correctness — `startsWith` already short-circuits on the first match.
97
+ */
98
+ export declare function classifyAclSource(key: string, grants: ExplicitGrant[]): AclSource;
99
+ /**
100
+ * Bright-line guard for `--out`: refuse to write any byte beneath
101
+ * `<hqRoot>/companies/`. We do NOT enumerate `companies/manifest.yaml`
102
+ * slug-by-slug — `companies/` is the entire surface hq-sync owns, so a
103
+ * containment check on that parent suffices and avoids drift with the
104
+ * manifest file. Returns the resolved absolute output path on success;
105
+ * throws when the destination would land inside the protected tree.
106
+ */
107
+ export declare function assertOutPathOutsideCompanies(outPath: string, hqRoot: string): string;
108
+ /**
109
+ * Render a browse listing as a padded table. Mirrors the chalk + padEnd
110
+ * pattern used by `hq sync mode --show` so the CLI surface stays
111
+ * stylistically consistent.
112
+ */
113
+ export declare function formatBrowseTable(rows: BrowseRow[]): string;
114
+ export interface RunBrowseInput {
115
+ /** Vault path prefix, e.g. `companies/indigo/scratch/`. */
116
+ pathPrefix: string;
117
+ /** Caller-overridden company slug (defaults to slug parsed from path). */
118
+ companySlug?: string;
119
+ vaultClient: FilesBrowseVaultClient;
120
+ s3Factory: S3ClientFactory;
121
+ region: string;
122
+ }
123
+ export interface RunBrowseResult {
124
+ rows: BrowseRow[];
125
+ vend: VendResult;
126
+ }
127
+ /**
128
+ * `hq files browse <path>` orchestrator.
129
+ *
130
+ * 1. Parse slug from prefix (or use override).
131
+ * 2. Resolve companyUid + bucketName via VaultClient.entity.
132
+ * 3. Vend with `purpose: 'browse'`, `operations: 'read-only'`, paths: [prefix].
133
+ * 4. Construct S3Client from vended creds, paginate ListObjectsV2.
134
+ * 5. Fetch explicit grants once, classify each key.
135
+ *
136
+ * Pure-ish: no console output, no process.exit — caller renders + exits.
137
+ */
138
+ export declare function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>;
139
+ export interface RunCatInput {
140
+ /** Single vault key, e.g. `companies/indigo/scratch/foo.txt`. */
141
+ key: string;
142
+ /**
143
+ * Where to write the body. `undefined` ⇒ stdout. Bright-line-guarded
144
+ * against `<hqRoot>/companies/` by `assertOutPathOutsideCompanies`.
145
+ */
146
+ out?: string;
147
+ hqRoot: string;
148
+ companySlug?: string;
149
+ vaultClient: FilesBrowseVaultClient;
150
+ s3Factory: S3ClientFactory;
151
+ region: string;
152
+ /** Destination stream for the stdout path. Injectable for tests. */
153
+ stdout?: NodeJS.WritableStream;
154
+ }
155
+ export interface RunCatResult {
156
+ bytesWritten: number;
157
+ destination: {
158
+ kind: "stdout";
159
+ } | {
160
+ kind: "file";
161
+ absPath: string;
162
+ };
163
+ vend: VendResult;
164
+ }
165
+ /**
166
+ * `hq files cat <path>` orchestrator. Vends with `purpose: 'browse'`, then
167
+ * streams the object body either to stdout or to `--out` (after the
168
+ * containment guard). Refuses ahead of any I/O when `--out` is unsafe.
169
+ */
170
+ export declare function runCat(input: RunCatInput): Promise<RunCatResult>;
171
+ /**
172
+ * Wire `hq files browse` + `hq files cat` onto an existing `files`
173
+ * Commander group. `registerFilesCommand` in files.ts builds the group
174
+ * and registers `share`/`unshare`/`acl`; this function appends the two
175
+ * new browse-vs-sync subcommands so they share the `--company` switch.
176
+ */
177
+ export declare function registerFilesBrowseCommands(filesCmd: Command): void;
178
+ //# sourceMappingURL=files-browse.d.ts.map