@indigoai-us/hq-cli 5.17.0 → 5.18.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/dist/commands/cloud.d.ts +26 -1
- package/dist/commands/cloud.js +128 -10
- package/dist/commands/files-browse.d.ts +178 -0
- package/dist/commands/files-browse.js +348 -0
- package/dist/commands/files.d.ts +1 -1
- package/dist/commands/files.js +6 -2
- package/dist/commands/sync-mode.d.ts +115 -0
- package/dist/commands/sync-mode.js +249 -0
- package/dist/commands/sync-narrow.d.ts +154 -0
- package/dist/commands/sync-narrow.js +327 -0
- package/dist/index.js +11 -3
- package/dist/lib/local-tree-diff.d.ts +94 -0
- package/dist/lib/local-tree-diff.js +244 -0
- package/dist/lib/narrow-hint-banner.d.ts +102 -0
- package/dist/lib/narrow-hint-banner.js +144 -0
- package/package.json +2 -2
- package/src/commands/cloud.pull-all.test.ts +170 -1
- package/src/commands/cloud.ts +195 -4
- package/src/commands/files-browse.test.ts +475 -0
- package/src/commands/files-browse.ts +561 -0
- package/src/commands/files.ts +6 -1
- package/src/commands/sync-mode.test.ts +366 -0
- package/src/commands/sync-mode.ts +387 -0
- package/src/commands/sync-narrow.test.ts +573 -0
- package/src/commands/sync-narrow.ts +541 -0
- package/src/index.ts +9 -1
- package/src/lib/hq-cloud-dep.smoke.test.ts +75 -0
- package/src/lib/local-tree-diff.test.ts +262 -0
- package/src/lib/local-tree-diff.ts +330 -0
- package/src/lib/narrow-hint-banner.test.ts +235 -0
- package/src/lib/narrow-hint-banner.ts +212 -0
package/dist/commands/cloud.d.ts
CHANGED
|
@@ -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;
|
package/dist/commands/cloud.js
CHANGED
|
@@ -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]="
|
|
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]="2eb7da1d-e84f-591c-b930-b84c5c96986b")}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);
|
|
@@ -407,6 +457,10 @@ export function registerCloudCommands(program) {
|
|
|
407
457
|
"(no companies/<slug>/ prefix). Resolves the person UID automatically " +
|
|
408
458
|
"from the cached Cognito session. Mutually exclusive with --company " +
|
|
409
459
|
"and --all.")
|
|
460
|
+
.option("--mode-all", "US-011: opt out of the strict narrow-hint refusal for this run. " +
|
|
461
|
+
"Has no effect today (default narrow-hint level is 'hint'); " +
|
|
462
|
+
"wired so future hq-core-staging releases can flip the default to " +
|
|
463
|
+
"'strict' without re-touching this command.")
|
|
410
464
|
.action(async (options) => {
|
|
411
465
|
try {
|
|
412
466
|
assertSingleSelector(options, "pull");
|
|
@@ -416,7 +470,7 @@ export function registerCloudCommands(program) {
|
|
|
416
470
|
process.exit(1);
|
|
417
471
|
}
|
|
418
472
|
if (options.all) {
|
|
419
|
-
await runPullAll(options.hqRoot, options.onConflict);
|
|
473
|
+
await runPullAll(options.hqRoot, options.onConflict, options.modeAll === true);
|
|
420
474
|
return;
|
|
421
475
|
}
|
|
422
476
|
if (options.personal) {
|
|
@@ -502,14 +556,17 @@ export function registerCloudCommands(program) {
|
|
|
502
556
|
"--personal.")
|
|
503
557
|
.option("--personal", "Sync the caller's canonical personal vault bidirectionally. " +
|
|
504
558
|
"Mutually exclusive with --company and --all.")
|
|
559
|
+
.option("--mode-all", "US-011: opt out of the strict narrow-hint refusal for this run. " +
|
|
560
|
+
"No-op today; wired so future hq-core-staging releases can flip " +
|
|
561
|
+
"the default narrow-hint level to 'strict'.")
|
|
505
562
|
.action(async (options) => {
|
|
506
563
|
try {
|
|
507
564
|
assertSingleSelector(options, "now");
|
|
508
565
|
if (options.all) {
|
|
509
|
-
await runNowAll(options.hqRoot, options.message, options.onConflict);
|
|
566
|
+
await runNowAll(options.hqRoot, options.message, options.onConflict, options.modeAll === true);
|
|
510
567
|
return;
|
|
511
568
|
}
|
|
512
|
-
await runNowSingle(options.hqRoot, options.company, options.personal === true, options.message, options.onConflict);
|
|
569
|
+
await runNowSingle(options.hqRoot, options.company, options.personal === true, options.message, options.onConflict, options.modeAll === true);
|
|
513
570
|
}
|
|
514
571
|
catch (err) {
|
|
515
572
|
console.error(chalk.red("\n✗ Sync now failed:"), err instanceof Error ? err.message : String(err));
|
|
@@ -517,7 +574,7 @@ export function registerCloudCommands(program) {
|
|
|
517
574
|
}
|
|
518
575
|
});
|
|
519
576
|
}
|
|
520
|
-
async function runPullAll(hqRoot, onConflict) {
|
|
577
|
+
async function runPullAll(hqRoot, onConflict, modeAllOverride) {
|
|
521
578
|
console.log(chalk.bold("\nHQ Sync — Pull (all)"));
|
|
522
579
|
console.log(` HQ root: ${hqRoot}`);
|
|
523
580
|
console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
|
|
@@ -537,8 +594,14 @@ async function runPullAll(hqRoot, onConflict) {
|
|
|
537
594
|
return null;
|
|
538
595
|
}
|
|
539
596
|
},
|
|
597
|
+
getMembershipSyncConfig: (id) => realClient.getMembershipSyncConfig(id),
|
|
540
598
|
};
|
|
541
|
-
result = await pullAll({
|
|
599
|
+
result = await pullAll({
|
|
600
|
+
hqRoot,
|
|
601
|
+
...(onConflict ? { onConflict } : {}),
|
|
602
|
+
narrowHintLevel: resolveBannerLevel(),
|
|
603
|
+
...(modeAllOverride ? { modeAllOverride: true } : {}),
|
|
604
|
+
}, {
|
|
542
605
|
vaultClient: adapter,
|
|
543
606
|
sync: (opts) => sync({
|
|
544
607
|
company: opts.company,
|
|
@@ -687,7 +750,7 @@ async function runPushAll(hqRoot, message, onConflict) {
|
|
|
687
750
|
if (errored > 0)
|
|
688
751
|
process.exit(1);
|
|
689
752
|
}
|
|
690
|
-
async function runNowSingle(hqRoot, company, personal, message, onConflict) {
|
|
753
|
+
async function runNowSingle(hqRoot, company, personal, message, onConflict, modeAllOverride) {
|
|
691
754
|
console.log(chalk.bold("\nHQ Sync — Now"));
|
|
692
755
|
console.log(` HQ root: ${hqRoot}`);
|
|
693
756
|
console.log(` Target: ${personal ? "(personal)" : (company ?? "(active company)")}`);
|
|
@@ -752,6 +815,49 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict) {
|
|
|
752
815
|
console.log(chalk.yellow("\n⚠ Sync now aborted on push leg; pull skipped."));
|
|
753
816
|
process.exit(1);
|
|
754
817
|
}
|
|
818
|
+
// US-011: resolve membership sync-config so we can either nudge an
|
|
819
|
+
// all-mode owner or refuse the pull when strict-mode is on. Skipped
|
|
820
|
+
// for personal targets (personal vault has no membership row) and
|
|
821
|
+
// for resolution failures (best-effort — never block sync). The
|
|
822
|
+
// lookup runs BEFORE the pull leg so strict refusal can short-circuit
|
|
823
|
+
// without burning a sync.
|
|
824
|
+
const narrowHintLevel = resolveBannerLevel();
|
|
825
|
+
let resolvedMode;
|
|
826
|
+
let resolvedCompanyUid;
|
|
827
|
+
if (!personalMode && targetCompany) {
|
|
828
|
+
try {
|
|
829
|
+
const client = new VaultClient(vaultConfig);
|
|
830
|
+
const memberships = await client.listMyMemberships();
|
|
831
|
+
const match = memberships.find((m) => m.companyUid === targetCompany || m.membershipKey === targetCompany);
|
|
832
|
+
if (match) {
|
|
833
|
+
resolvedCompanyUid = match.companyUid;
|
|
834
|
+
try {
|
|
835
|
+
const cfg = await client.getMembershipSyncConfig(match.membershipKey);
|
|
836
|
+
resolvedMode = cfg.syncMode;
|
|
837
|
+
}
|
|
838
|
+
catch {
|
|
839
|
+
resolvedMode = undefined;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
catch {
|
|
844
|
+
resolvedMode = undefined;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
if (resolvedMode === "all" &&
|
|
848
|
+
isStrictRefusal(resolvedMode, narrowHintLevel) &&
|
|
849
|
+
!modeAllOverride &&
|
|
850
|
+
resolvedCompanyUid) {
|
|
851
|
+
emitNarrowHint({
|
|
852
|
+
companyUid: resolvedCompanyUid,
|
|
853
|
+
syncMode: resolvedMode,
|
|
854
|
+
level: narrowHintLevel,
|
|
855
|
+
});
|
|
856
|
+
console.error(chalk.red("\n✗ Sync now refused: strict narrow-hint mode is on and this " +
|
|
857
|
+
"membership still pulls everything. Run `hq sync narrow --apply` " +
|
|
858
|
+
"to migrate, or re-run with --mode-all."));
|
|
859
|
+
process.exit(1);
|
|
860
|
+
}
|
|
755
861
|
console.log(chalk.dim(" → pull leg"));
|
|
756
862
|
const pullResult = await sync({
|
|
757
863
|
company: targetCompany,
|
|
@@ -769,6 +875,15 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict) {
|
|
|
769
875
|
console.log(chalk.yellow("\n⚠ Sync now finished with pull leg aborted."));
|
|
770
876
|
process.exit(1);
|
|
771
877
|
}
|
|
878
|
+
// US-011: emit the hint banner after a successful pull so it
|
|
879
|
+
// appears at the bottom of the summary rather than mid-stream.
|
|
880
|
+
if (resolvedMode === "all" && resolvedCompanyUid) {
|
|
881
|
+
emitNarrowHint({
|
|
882
|
+
companyUid: resolvedCompanyUid,
|
|
883
|
+
syncMode: resolvedMode,
|
|
884
|
+
level: narrowHintLevel,
|
|
885
|
+
});
|
|
886
|
+
}
|
|
772
887
|
console.log(chalk.green("\n✓ Sync now complete"));
|
|
773
888
|
}
|
|
774
889
|
catch (err) {
|
|
@@ -776,7 +891,7 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict) {
|
|
|
776
891
|
process.exit(1);
|
|
777
892
|
}
|
|
778
893
|
}
|
|
779
|
-
async function runNowAll(hqRoot, message, onConflict) {
|
|
894
|
+
async function runNowAll(hqRoot, message, onConflict, modeAllOverride) {
|
|
780
895
|
console.log(chalk.bold("\nHQ Sync — Now (all)"));
|
|
781
896
|
console.log(` HQ root: ${hqRoot}`);
|
|
782
897
|
console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
|
|
@@ -786,7 +901,10 @@ async function runNowAll(hqRoot, message, onConflict) {
|
|
|
786
901
|
console.log(chalk.dim("→ push --all"));
|
|
787
902
|
await runPushAll(hqRoot, message, onConflict);
|
|
788
903
|
console.log(chalk.dim("\n→ pull --all"));
|
|
789
|
-
|
|
904
|
+
// US-011: forward --mode-all so the strict refusal applies to the
|
|
905
|
+
// pull leg (push doesn't need a narrow-hint — the narrow ritual is
|
|
906
|
+
// pull-side).
|
|
907
|
+
await runPullAll(hqRoot, onConflict, modeAllOverride);
|
|
790
908
|
}
|
|
791
909
|
/**
|
|
792
910
|
* Best-effort read of the active company slug from `<hqRoot>/.hq/config.json`.
|
|
@@ -860,4 +978,4 @@ function resolveUploadAuthorFromCache() {
|
|
|
860
978
|
}
|
|
861
979
|
}
|
|
862
980
|
//# sourceMappingURL=cloud.js.map
|
|
863
|
-
//# debugId=
|
|
981
|
+
//# debugId=2eb7da1d-e84f-591c-b930-b84c5c96986b
|
|
@@ -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
|