@indigoai-us/hq-cli 5.28.0 → 5.30.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.
|
@@ -17,16 +17,21 @@
|
|
|
17
17
|
* `hq sync` owns, and writing a peeked object there would
|
|
18
18
|
* silently re-import it into the sync envelope.
|
|
19
19
|
*
|
|
20
|
-
* Both subcommands vend
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
20
|
+
* Both subcommands vend through the multi-tenant STS routes —
|
|
21
|
+
* `VaultClient.sts.vend` (`/sts/vend`, company) and `.sts.vendSelf`
|
|
22
|
+
* (`/sts/vend-self`, personal). These resolve the caller's per-entity bucket
|
|
23
|
+
* and apply role/ACL scoping server-side (owner/admin → full access; member/
|
|
24
|
+
* guest → per-prefix). The legacy `POST /vend` is deliberately NOT used: it
|
|
25
|
+
* assumes a single static `BUCKET_ARN` that is unset in multi-tenant prod, so
|
|
26
|
+
* it builds an invalid policy and STS rejects it (`MalformedPolicyDocument`).
|
|
25
27
|
*
|
|
26
|
-
*
|
|
27
|
-
* `
|
|
28
|
-
*
|
|
29
|
-
* `
|
|
28
|
+
* Namespace note: company vault keys are company-relative (no
|
|
29
|
+
* `companies/<slug>/` prefix). The CLI speaks the anchored form for user
|
|
30
|
+
* familiarity and translates at the S3 boundary via `toBucketRelative` /
|
|
31
|
+
* `toCompanyAnchored`.
|
|
32
|
+
*
|
|
33
|
+
* Cross-package note: depends on the `VaultClient.sts.vend`/`.vendSelf`
|
|
34
|
+
* methods and `grantPathToPrefix` from hq-cloud.
|
|
30
35
|
*/
|
|
31
36
|
|
|
32
37
|
import { Command } from "commander";
|
|
@@ -46,7 +51,7 @@ import {
|
|
|
46
51
|
|
|
47
52
|
import {
|
|
48
53
|
VaultClient,
|
|
49
|
-
|
|
54
|
+
grantPathToPrefix,
|
|
50
55
|
type ExplicitGrant,
|
|
51
56
|
} from "@indigoai-us/hq-cloud";
|
|
52
57
|
|
|
@@ -61,17 +66,40 @@ import { resolveCanonicalPersonUid } from "./cloud.js";
|
|
|
61
66
|
|
|
62
67
|
// ── Types ───────────────────────────────────────────────────────────────────
|
|
63
68
|
|
|
69
|
+
/** STS-vended credential set the browse/cat path consumes. */
|
|
70
|
+
export interface BrowseCredentials {
|
|
71
|
+
accessKeyId: string;
|
|
72
|
+
secretAccessKey: string;
|
|
73
|
+
sessionToken: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Minimal STS-vend response shape (both `/sts/vend` and `/sts/vend-self`). */
|
|
77
|
+
export interface BrowseVendResult {
|
|
78
|
+
credentials: BrowseCredentials;
|
|
79
|
+
}
|
|
80
|
+
|
|
64
81
|
/**
|
|
65
82
|
* Subset of `VaultClient` this command actually uses — exposed so tests
|
|
66
83
|
* can stub vend + grants without standing up a real `VaultClient`.
|
|
84
|
+
*
|
|
85
|
+
* Browse/cat vend through the multi-tenant `/sts/vend` (company) and
|
|
86
|
+
* `/sts/vend-self` (personal) routes — NOT the legacy `POST /vend`, which
|
|
87
|
+
* assumes a single static bucket and is non-functional in multi-tenant
|
|
88
|
+
* production (it builds a policy against an undefined `BUCKET_ARN`, so STS
|
|
89
|
+
* rejects it with `MalformedPolicyDocument`). The STS routes resolve the
|
|
90
|
+
* caller's per-entity bucket and apply role/ACL scoping server-side.
|
|
67
91
|
*/
|
|
68
92
|
export interface FilesBrowseVaultClient {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
93
|
+
sts: {
|
|
94
|
+
vend(input: {
|
|
95
|
+
companyUid: string;
|
|
96
|
+
durationSeconds?: number;
|
|
97
|
+
}): Promise<BrowseVendResult>;
|
|
98
|
+
vendSelf(input: {
|
|
99
|
+
personUid: string;
|
|
100
|
+
durationSeconds?: number;
|
|
101
|
+
}): Promise<BrowseVendResult>;
|
|
102
|
+
};
|
|
75
103
|
listMyExplicitGrants(companyUid: string): Promise<ExplicitGrant[]>;
|
|
76
104
|
entity: {
|
|
77
105
|
get(uid: string): Promise<{ uid: string; slug: string; name?: string; bucketName?: string }>;
|
|
@@ -138,20 +166,48 @@ export function parseCompanySlugFromPath(prefix: string): string {
|
|
|
138
166
|
}
|
|
139
167
|
|
|
140
168
|
/**
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
169
|
+
* Translate a CLI `companies/<slug>/…` path into the company-relative S3 key
|
|
170
|
+
* the vault bucket actually stores. Company vault buckets are already
|
|
171
|
+
* company-scoped, so their keys carry NO `companies/<slug>/` prefix (e.g.
|
|
172
|
+
* `knowledge/foo.md`, not `companies/indigo/knowledge/foo.md`). The CLI speaks
|
|
173
|
+
* the anchored form for user familiarity; we strip the anchor at the S3
|
|
174
|
+
* boundary. A path without the anchor (or personal-mode, bucket-relative
|
|
175
|
+
* paths) passes through unchanged.
|
|
176
|
+
*/
|
|
177
|
+
export function toBucketRelative(pathOrPrefix: string, slug: string): string {
|
|
178
|
+
const anchor = `companies/${slug}/`;
|
|
179
|
+
const normalized = pathOrPrefix.replace(/^\/+/, "");
|
|
180
|
+
return normalized.startsWith(anchor)
|
|
181
|
+
? normalized.slice(anchor.length)
|
|
182
|
+
: normalized;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Re-attach the `companies/<slug>/` anchor to a company-relative bucket key
|
|
187
|
+
* for display + `hq files cat` round-trip, so the CLI surface keeps speaking
|
|
188
|
+
* the anchored form the user passed in.
|
|
189
|
+
*/
|
|
190
|
+
export function toCompanyAnchored(bucketRelKey: string, slug: string): string {
|
|
191
|
+
return `companies/${slug}/${bucketRelKey}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Classify a single company-relative S3 key against the caller's
|
|
196
|
+
* (already-normalized) explicit-grant prefixes. Any prefix that covers the
|
|
197
|
+
* key contributes `shared-with-you`; otherwise the key is visible only via
|
|
198
|
+
* the owner/admin role-bypass the `/sts/vend` policy applied. An empty-string
|
|
199
|
+
* prefix is a company-wide grant and matches everything.
|
|
144
200
|
*
|
|
145
|
-
* Grant
|
|
146
|
-
* `
|
|
147
|
-
*
|
|
201
|
+
* Grant `path`s arrive in inconsistent/glob form; the caller normalizes them
|
|
202
|
+
* to company-relative `startsWith` prefixes via `grantPathToPrefix` before
|
|
203
|
+
* calling this — keeping this helper pure and trivially testable.
|
|
148
204
|
*/
|
|
149
205
|
export function classifyAclSource(
|
|
150
|
-
|
|
151
|
-
|
|
206
|
+
bucketRelKey: string,
|
|
207
|
+
grantPrefixes: string[],
|
|
152
208
|
): AclSource {
|
|
153
|
-
for (const
|
|
154
|
-
if (
|
|
209
|
+
for (const p of grantPrefixes) {
|
|
210
|
+
if (p === "" || bucketRelKey.startsWith(p)) return "shared-with-you";
|
|
155
211
|
}
|
|
156
212
|
return "role-bypass";
|
|
157
213
|
}
|
|
@@ -242,7 +298,7 @@ export interface RunBrowseInput {
|
|
|
242
298
|
|
|
243
299
|
export interface RunBrowseResult {
|
|
244
300
|
rows: BrowseRow[];
|
|
245
|
-
vend:
|
|
301
|
+
vend: BrowseVendResult;
|
|
246
302
|
}
|
|
247
303
|
|
|
248
304
|
/**
|
|
@@ -250,8 +306,10 @@ export interface RunBrowseResult {
|
|
|
250
306
|
*
|
|
251
307
|
* 1. Parse slug from prefix (or use override).
|
|
252
308
|
* 2. Resolve companyUid + bucketName via VaultClient.entity.
|
|
253
|
-
* 3. Vend
|
|
254
|
-
*
|
|
309
|
+
* 3. Vend read creds via the multi-tenant STS route (`/sts/vend` company,
|
|
310
|
+
* `/sts/vend-self` personal) — resolves the per-entity bucket + role/ACL.
|
|
311
|
+
* 4. Construct S3Client from vended creds, paginate ListObjectsV2 over the
|
|
312
|
+
* company-relative key space.
|
|
255
313
|
* 5. Fetch explicit grants once, classify each key.
|
|
256
314
|
*
|
|
257
315
|
* Pure-ish: no console output, no process.exit — caller renders + exits.
|
|
@@ -262,10 +320,11 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
|
|
|
262
320
|
// Branch by mode. Company mode parses slug from path and looks up by
|
|
263
321
|
// namespace; personal mode resolves the entity directly by the supplied
|
|
264
322
|
// person UID and skips the slug + grants machinery (a person bucket has
|
|
265
|
-
// no grants graph — the owner is the only principal).
|
|
266
|
-
// identical for both modes once we have the entity in hand.
|
|
323
|
+
// no grants graph — the owner is the only principal).
|
|
267
324
|
let bucket: string;
|
|
268
325
|
let entityUid: string;
|
|
326
|
+
let slug: string | undefined;
|
|
327
|
+
let vend: BrowseVendResult;
|
|
269
328
|
if (personalMode) {
|
|
270
329
|
if (!input.personalUid) {
|
|
271
330
|
throw new Error(
|
|
@@ -281,8 +340,9 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
|
|
|
281
340
|
}
|
|
282
341
|
entityUid = entity.uid;
|
|
283
342
|
bucket = entity.bucketName;
|
|
343
|
+
vend = await vaultClient.sts.vendSelf({ personUid: entityUid });
|
|
284
344
|
} else {
|
|
285
|
-
|
|
345
|
+
slug = input.companySlug ?? parseCompanySlugFromPath(pathPrefix);
|
|
286
346
|
const entity = await vaultClient.entity.findInMyNamespace("company", slug);
|
|
287
347
|
if (!entity) {
|
|
288
348
|
throw new Error(
|
|
@@ -296,19 +356,12 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
|
|
|
296
356
|
}
|
|
297
357
|
entityUid = entity.uid;
|
|
298
358
|
bucket = entity.bucketName;
|
|
359
|
+
// Multi-tenant vend: the server resolves this company's bucket + applies
|
|
360
|
+
// owner/admin role-bypass (full access) or member/guest ACL scoping. The
|
|
361
|
+
// legacy `POST /vend` is unused here — see FilesBrowseVaultClient docs.
|
|
362
|
+
vend = await vaultClient.sts.vend({ companyUid: entityUid });
|
|
299
363
|
}
|
|
300
364
|
|
|
301
|
-
// Distinct vend call from sync — `purpose: 'browse'` opts the request
|
|
302
|
-
// into the role-bypass-allowed code path on the server (US-009). The
|
|
303
|
-
// personal mode vends against the person entity which is owner-only by
|
|
304
|
-
// construction; the vend response shape is identical so downstream
|
|
305
|
-
// S3Client construction doesn't branch.
|
|
306
|
-
const vend = await vaultClient.vend({
|
|
307
|
-
paths: [pathPrefix],
|
|
308
|
-
operations: "read-only",
|
|
309
|
-
purpose: "browse",
|
|
310
|
-
});
|
|
311
|
-
|
|
312
365
|
const s3 = s3Factory({
|
|
313
366
|
region,
|
|
314
367
|
credentials: {
|
|
@@ -318,13 +371,25 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
|
|
|
318
371
|
},
|
|
319
372
|
});
|
|
320
373
|
|
|
374
|
+
// Company vault keys are company-relative (no `companies/<slug>/` prefix), so
|
|
375
|
+
// translate the CLI's anchored prefix into the bucket-relative form before
|
|
376
|
+
// listing. Personal-mode paths are already bucket-relative.
|
|
377
|
+
const listPrefix =
|
|
378
|
+
personalMode || slug === undefined
|
|
379
|
+
? pathPrefix
|
|
380
|
+
: toBucketRelative(pathPrefix, slug);
|
|
381
|
+
|
|
321
382
|
// Pull the caller's explicit-grant graph once so per-key classification
|
|
322
|
-
// is O(grants) without N round-trips.
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
383
|
+
// is O(grants) without N round-trips. Grant `path`s are normalized to
|
|
384
|
+
// company-relative `startsWith` prefixes (matching the bucket key space)
|
|
385
|
+
// via `grantPathToPrefix`. Skipped in personal mode — the grants graph is a
|
|
386
|
+
// company concept; a person bucket marks every row `"personal-vault"`.
|
|
387
|
+
const grantPrefixes =
|
|
388
|
+
personalMode || slug === undefined
|
|
389
|
+
? []
|
|
390
|
+
: (await vaultClient.listMyExplicitGrants(entityUid)).map((g) =>
|
|
391
|
+
grantPathToPrefix(g.path, slug as string),
|
|
392
|
+
);
|
|
328
393
|
|
|
329
394
|
const rows: BrowseRow[] = [];
|
|
330
395
|
let continuationToken: string | undefined;
|
|
@@ -332,7 +397,7 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
|
|
|
332
397
|
const resp = (await s3.send(
|
|
333
398
|
new ListObjectsV2Command({
|
|
334
399
|
Bucket: bucket,
|
|
335
|
-
Prefix:
|
|
400
|
+
Prefix: listPrefix,
|
|
336
401
|
ContinuationToken: continuationToken,
|
|
337
402
|
}),
|
|
338
403
|
)) as ListObjectsV2CommandOutput;
|
|
@@ -342,11 +407,18 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
|
|
|
342
407
|
// Skip S3 "directory marker" objects (0-byte, trailing slash).
|
|
343
408
|
if (obj.Key.endsWith("/") && (obj.Size ?? 0) === 0) continue;
|
|
344
409
|
|
|
410
|
+
// `obj.Key` is company-relative. Classify in that space, then re-anchor
|
|
411
|
+
// for display so the CLI keeps speaking `companies/<slug>/...`.
|
|
345
412
|
rows.push({
|
|
346
|
-
key:
|
|
413
|
+
key:
|
|
414
|
+
personalMode || slug === undefined
|
|
415
|
+
? obj.Key
|
|
416
|
+
: toCompanyAnchored(obj.Key, slug),
|
|
347
417
|
size: obj.Size ?? 0,
|
|
348
418
|
lastModified: obj.LastModified,
|
|
349
|
-
aclSource: personalMode
|
|
419
|
+
aclSource: personalMode
|
|
420
|
+
? "personal-vault"
|
|
421
|
+
: classifyAclSource(obj.Key, grantPrefixes),
|
|
350
422
|
});
|
|
351
423
|
}
|
|
352
424
|
|
|
@@ -384,7 +456,7 @@ export interface RunCatInput {
|
|
|
384
456
|
export interface RunCatResult {
|
|
385
457
|
bytesWritten: number;
|
|
386
458
|
destination: { kind: "stdout" } | { kind: "file"; absPath: string };
|
|
387
|
-
vend:
|
|
459
|
+
vend: BrowseVendResult;
|
|
388
460
|
}
|
|
389
461
|
|
|
390
462
|
/**
|
|
@@ -405,6 +477,8 @@ export async function runCat(input: RunCatInput): Promise<RunCatResult> {
|
|
|
405
477
|
// Same branch logic as runBrowse — see that function's doc-block for
|
|
406
478
|
// the personal-vs-company rationale.
|
|
407
479
|
let bucket: string;
|
|
480
|
+
let s3Key: string;
|
|
481
|
+
let vend: BrowseVendResult;
|
|
408
482
|
if (personalMode) {
|
|
409
483
|
if (!input.personalUid) {
|
|
410
484
|
throw new Error(
|
|
@@ -419,6 +493,8 @@ export async function runCat(input: RunCatInput): Promise<RunCatResult> {
|
|
|
419
493
|
);
|
|
420
494
|
}
|
|
421
495
|
bucket = entity.bucketName;
|
|
496
|
+
s3Key = key; // personal-mode keys are already bucket-relative
|
|
497
|
+
vend = await vaultClient.sts.vendSelf({ personUid: entity.uid });
|
|
422
498
|
} else {
|
|
423
499
|
const slug = input.companySlug ?? parseCompanySlugFromPath(key);
|
|
424
500
|
const entity = await vaultClient.entity.findInMyNamespace("company", slug);
|
|
@@ -433,14 +509,11 @@ export async function runCat(input: RunCatInput): Promise<RunCatResult> {
|
|
|
433
509
|
);
|
|
434
510
|
}
|
|
435
511
|
bucket = entity.bucketName;
|
|
512
|
+
// Translate the anchored CLI key into the company-relative bucket key.
|
|
513
|
+
s3Key = toBucketRelative(key, slug);
|
|
514
|
+
vend = await vaultClient.sts.vend({ companyUid: entity.uid });
|
|
436
515
|
}
|
|
437
516
|
|
|
438
|
-
const vend = await vaultClient.vend({
|
|
439
|
-
paths: [key],
|
|
440
|
-
operations: "read-only",
|
|
441
|
-
purpose: "browse",
|
|
442
|
-
});
|
|
443
|
-
|
|
444
517
|
const s3 = s3Factory({
|
|
445
518
|
region,
|
|
446
519
|
credentials: {
|
|
@@ -451,7 +524,7 @@ export async function runCat(input: RunCatInput): Promise<RunCatResult> {
|
|
|
451
524
|
});
|
|
452
525
|
|
|
453
526
|
const resp = (await s3.send(
|
|
454
|
-
new GetObjectCommand({ Bucket: bucket, Key:
|
|
527
|
+
new GetObjectCommand({ Bucket: bucket, Key: s3Key }),
|
|
455
528
|
)) as GetObjectCommandOutput;
|
|
456
529
|
|
|
457
530
|
if (!resp.Body) {
|
|
@@ -603,6 +676,207 @@ export function formatSharedWithMeTable(rows: SharedWithMeRow[]): string {
|
|
|
603
676
|
].join("\n");
|
|
604
677
|
}
|
|
605
678
|
|
|
679
|
+
// ── search ───────────────────────────────────────────────────────────────
|
|
680
|
+
|
|
681
|
+
export interface RunSearchInput {
|
|
682
|
+
/** Case-insensitive substring matched against each object's full key. */
|
|
683
|
+
query: string;
|
|
684
|
+
/** Company slug to search (ignored under personalMode). */
|
|
685
|
+
companySlug: string;
|
|
686
|
+
personalMode?: boolean;
|
|
687
|
+
personalUid?: string;
|
|
688
|
+
vaultClient: FilesBrowseVaultClient;
|
|
689
|
+
s3Factory: S3ClientFactory;
|
|
690
|
+
region: string;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* `hq files search <query>` orchestrator. Lists the company (or personal)
|
|
695
|
+
* vault under its root via `runBrowse`, then filters to keys containing the
|
|
696
|
+
* query (case-insensitive). v1 is a name/path search over the listing — no
|
|
697
|
+
* content search. Rows carry the same ACL-source classification as browse.
|
|
698
|
+
*
|
|
699
|
+
* Pure-ish: no console output. The caller renders with `formatBrowseTable`.
|
|
700
|
+
*/
|
|
701
|
+
export async function runSearch(input: RunSearchInput): Promise<BrowseRow[]> {
|
|
702
|
+
const prefix = input.personalMode ? "" : `companies/${input.companySlug}/`;
|
|
703
|
+
const { rows } = await runBrowse({
|
|
704
|
+
pathPrefix: prefix,
|
|
705
|
+
companySlug: input.companySlug,
|
|
706
|
+
personalMode: input.personalMode,
|
|
707
|
+
personalUid: input.personalUid,
|
|
708
|
+
vaultClient: input.vaultClient,
|
|
709
|
+
s3Factory: input.s3Factory,
|
|
710
|
+
region: input.region,
|
|
711
|
+
});
|
|
712
|
+
const q = input.query.toLowerCase();
|
|
713
|
+
return rows.filter((r) => r.key.toLowerCase().includes(q));
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// ── get (materialize + pin) ─────────────────────────────────────────────────
|
|
717
|
+
|
|
718
|
+
interface PinFile {
|
|
719
|
+
version: number;
|
|
720
|
+
/** companySlug → sorted list of company-relative pinned prefixes. */
|
|
721
|
+
pins: Record<string, string[]>;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** Per-machine pin set path: `<hqRoot>/.hq/pins.json`. */
|
|
725
|
+
export function pinFilePath(hqRoot: string): string {
|
|
726
|
+
return path.join(hqRoot, ".hq", "pins.json");
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** Read the pin set, tolerating a missing or corrupt file (→ fresh). */
|
|
730
|
+
export function readPins(hqRoot: string): PinFile {
|
|
731
|
+
try {
|
|
732
|
+
const parsed = JSON.parse(fs.readFileSync(pinFilePath(hqRoot), "utf-8"));
|
|
733
|
+
if (parsed && typeof parsed === "object" && parsed.pins) {
|
|
734
|
+
return { version: parsed.version ?? 1, pins: parsed.pins };
|
|
735
|
+
}
|
|
736
|
+
} catch {
|
|
737
|
+
/* missing / unreadable / malformed → start fresh */
|
|
738
|
+
}
|
|
739
|
+
return { version: 1, pins: {} };
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Register a company-relative prefix in the per-machine pin set. Pins are what
|
|
744
|
+
* keep an on-demand `hq files get` from being pruned by the next *scoped*
|
|
745
|
+
* sync (`syncMode: shared|custom`): the sync runner unions the company's pins
|
|
746
|
+
* into its pull scope. Idempotent + sorted for stable diffs.
|
|
747
|
+
*/
|
|
748
|
+
export function addPin(hqRoot: string, companySlug: string, prefix: string): void {
|
|
749
|
+
const pf = readPins(hqRoot);
|
|
750
|
+
const list = pf.pins[companySlug] ?? [];
|
|
751
|
+
if (!list.includes(prefix)) list.push(prefix);
|
|
752
|
+
list.sort();
|
|
753
|
+
pf.pins[companySlug] = list;
|
|
754
|
+
const f = pinFilePath(hqRoot);
|
|
755
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
756
|
+
fs.writeFileSync(f, JSON.stringify(pf, null, 2) + "\n");
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
export interface RunGetInput {
|
|
760
|
+
/** Vault path to materialize: `companies/<slug>/...` (file or prefix). */
|
|
761
|
+
path: string;
|
|
762
|
+
/**
|
|
763
|
+
* Override destination directory. Default (omitted) writes in place under
|
|
764
|
+
* `<hqRoot>/companies/<slug>/...` — the tree `hq sync` manages — and
|
|
765
|
+
* registers a pin. `--into` writes outside that envelope and pins nothing.
|
|
766
|
+
*/
|
|
767
|
+
into?: string;
|
|
768
|
+
hqRoot: string;
|
|
769
|
+
companySlug?: string;
|
|
770
|
+
vaultClient: FilesBrowseVaultClient;
|
|
771
|
+
s3Factory: S3ClientFactory;
|
|
772
|
+
region: string;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
export interface RunGetResult {
|
|
776
|
+
filesWritten: number;
|
|
777
|
+
bytesWritten: number;
|
|
778
|
+
destinations: string[];
|
|
779
|
+
/** Set only for in-place (no `--into`) materialization. */
|
|
780
|
+
pinned?: { companySlug: string; prefix: string };
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* `hq files get <path>` orchestrator. Materializes a vault file or prefix into
|
|
785
|
+
* the local HQ tree on demand. Unlike `cat` (which refuses to write under
|
|
786
|
+
* `companies/`), `get` deliberately writes INTO `companies/<slug>/...` by
|
|
787
|
+
* default — that's the point: pull a path you have access to but don't sync.
|
|
788
|
+
* It then registers a pin so the next scoped sync keeps it.
|
|
789
|
+
*
|
|
790
|
+
* Company mode only in v1 — materializing a personal vault would target the
|
|
791
|
+
* HQ root itself, which is too broad to do implicitly.
|
|
792
|
+
*/
|
|
793
|
+
export async function runGet(input: RunGetInput): Promise<RunGetResult> {
|
|
794
|
+
const { path: vaultPath, vaultClient, s3Factory, region, hqRoot } = input;
|
|
795
|
+
const slug = input.companySlug ?? parseCompanySlugFromPath(vaultPath);
|
|
796
|
+
|
|
797
|
+
const entity = await vaultClient.entity.findInMyNamespace("company", slug);
|
|
798
|
+
if (!entity) {
|
|
799
|
+
throw new Error(
|
|
800
|
+
`No company found for slug '${slug}' in your namespace. Confirm you have an active membership.`,
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
if (!entity.bucketName) {
|
|
804
|
+
throw new Error(`Company '${slug}' (${entity.uid}) has no provisioned bucket.`);
|
|
805
|
+
}
|
|
806
|
+
const bucket = entity.bucketName;
|
|
807
|
+
const vend = await vaultClient.sts.vend({ companyUid: entity.uid });
|
|
808
|
+
const s3 = s3Factory({
|
|
809
|
+
region,
|
|
810
|
+
credentials: {
|
|
811
|
+
accessKeyId: vend.credentials.accessKeyId,
|
|
812
|
+
secretAccessKey: vend.credentials.secretAccessKey,
|
|
813
|
+
sessionToken: vend.credentials.sessionToken,
|
|
814
|
+
},
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
// Company-relative prefix to list/fetch (bucket keys carry no anchor).
|
|
818
|
+
const bucketPrefix = toBucketRelative(vaultPath, slug);
|
|
819
|
+
|
|
820
|
+
const keys: string[] = [];
|
|
821
|
+
let continuationToken: string | undefined;
|
|
822
|
+
do {
|
|
823
|
+
const resp = (await s3.send(
|
|
824
|
+
new ListObjectsV2Command({
|
|
825
|
+
Bucket: bucket,
|
|
826
|
+
Prefix: bucketPrefix,
|
|
827
|
+
ContinuationToken: continuationToken,
|
|
828
|
+
}),
|
|
829
|
+
)) as ListObjectsV2CommandOutput;
|
|
830
|
+
for (const obj of resp.Contents ?? []) {
|
|
831
|
+
if (!obj.Key) continue;
|
|
832
|
+
if (obj.Key.endsWith("/") && (obj.Size ?? 0) === 0) continue;
|
|
833
|
+
keys.push(obj.Key);
|
|
834
|
+
}
|
|
835
|
+
continuationToken = resp.NextContinuationToken ?? undefined;
|
|
836
|
+
} while (continuationToken);
|
|
837
|
+
|
|
838
|
+
if (keys.length === 0) {
|
|
839
|
+
throw new Error(`No objects under '${vaultPath}'.`);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const destinations: string[] = [];
|
|
843
|
+
let bytesWritten = 0;
|
|
844
|
+
for (const key of keys) {
|
|
845
|
+
// Default: in-place under companies/<slug>/<company-relative key>.
|
|
846
|
+
// --into: write the path relative to the requested prefix under <into>.
|
|
847
|
+
let destAbs: string;
|
|
848
|
+
if (input.into !== undefined) {
|
|
849
|
+
const rel = key.startsWith(bucketPrefix)
|
|
850
|
+
? key.slice(bucketPrefix.length)
|
|
851
|
+
: key;
|
|
852
|
+
destAbs = path.resolve(input.into, rel || path.basename(key));
|
|
853
|
+
} else {
|
|
854
|
+
destAbs = path.join(hqRoot, "companies", slug, key);
|
|
855
|
+
}
|
|
856
|
+
const resp = (await s3.send(
|
|
857
|
+
new GetObjectCommand({ Bucket: bucket, Key: key }),
|
|
858
|
+
)) as GetObjectCommandOutput;
|
|
859
|
+
if (!resp.Body) {
|
|
860
|
+
throw new Error(`GetObject for '${key}' returned no body.`);
|
|
861
|
+
}
|
|
862
|
+
const body = resp.Body as unknown as Readable;
|
|
863
|
+
fs.mkdirSync(path.dirname(destAbs), { recursive: true });
|
|
864
|
+
await pipeline(body, fs.createWriteStream(destAbs));
|
|
865
|
+
bytesWritten += fs.statSync(destAbs).size;
|
|
866
|
+
destinations.push(destAbs);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// Pin only the in-place case — `--into` writes outside the sync envelope, so
|
|
870
|
+
// there's nothing for a scoped sync to prune.
|
|
871
|
+
let pinned: RunGetResult["pinned"];
|
|
872
|
+
if (input.into === undefined) {
|
|
873
|
+
addPin(hqRoot, slug, bucketPrefix);
|
|
874
|
+
pinned = { companySlug: slug, prefix: bucketPrefix };
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
return { filesWritten: keys.length, bytesWritten, destinations, pinned };
|
|
878
|
+
}
|
|
879
|
+
|
|
606
880
|
// ── CLI registration ────────────────────────────────────────────────────────
|
|
607
881
|
|
|
608
882
|
const defaultS3Factory: S3ClientFactory = ({ region, credentials }) =>
|
|
@@ -624,6 +898,17 @@ interface FilesCatCliOptions extends FilesBrowseCliOptions {
|
|
|
624
898
|
out?: string;
|
|
625
899
|
}
|
|
626
900
|
|
|
901
|
+
interface FilesSearchCliOptions {
|
|
902
|
+
company?: string;
|
|
903
|
+
personal?: boolean;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
interface FilesGetCliOptions {
|
|
907
|
+
company?: string;
|
|
908
|
+
hqRoot: string;
|
|
909
|
+
into?: string;
|
|
910
|
+
}
|
|
911
|
+
|
|
627
912
|
/**
|
|
628
913
|
* Wire `hq files browse` + `hq files cat` onto an existing `files`
|
|
629
914
|
* Commander group. `registerFilesCommand` in files.ts builds the group
|
|
@@ -893,4 +1178,138 @@ export function registerFilesBrowseCommands(filesCmd: Command): void {
|
|
|
893
1178
|
process.exit(1);
|
|
894
1179
|
}
|
|
895
1180
|
});
|
|
1181
|
+
|
|
1182
|
+
filesCmd
|
|
1183
|
+
.command("search <query>")
|
|
1184
|
+
.description(
|
|
1185
|
+
"Search vault object keys (case-insensitive path/name match) under a company without downloading. Requires --company (or --personal). Content search is not supported in v1.",
|
|
1186
|
+
)
|
|
1187
|
+
.option("--company <slug>", "Company slug to search.")
|
|
1188
|
+
.option(
|
|
1189
|
+
"--personal",
|
|
1190
|
+
"Search the caller's canonical personal vault. Mutually exclusive with --company.",
|
|
1191
|
+
)
|
|
1192
|
+
.action(async (query: string, options: FilesSearchCliOptions) => {
|
|
1193
|
+
try {
|
|
1194
|
+
if (options.personal && options.company) {
|
|
1195
|
+
throw new Error(
|
|
1196
|
+
"--personal and --company are mutually exclusive. Pick one.",
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
const accessToken = await ensureCognitoToken();
|
|
1201
|
+
const client = new VaultClient(buildVaultConfig(accessToken));
|
|
1202
|
+
|
|
1203
|
+
if (options.personal) {
|
|
1204
|
+
const personalUid = await resolveCanonicalPersonUid({
|
|
1205
|
+
listMyMemberships: () => client.listMyMemberships(),
|
|
1206
|
+
listPersonEntities: () => client.entity.listByType("person"),
|
|
1207
|
+
getEntity: async () => null,
|
|
1208
|
+
});
|
|
1209
|
+
const rows = await runSearch({
|
|
1210
|
+
query,
|
|
1211
|
+
companySlug: "personal",
|
|
1212
|
+
personalMode: true,
|
|
1213
|
+
personalUid,
|
|
1214
|
+
vaultClient: client,
|
|
1215
|
+
s3Factory: defaultS3Factory,
|
|
1216
|
+
region: DEFAULT_COGNITO.region,
|
|
1217
|
+
});
|
|
1218
|
+
console.log(formatBrowseTable(rows));
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
if (!options.company) {
|
|
1223
|
+
throw new Error(
|
|
1224
|
+
"search: --company <slug> is required (or --personal to search your personal vault).",
|
|
1225
|
+
);
|
|
1226
|
+
}
|
|
1227
|
+
await getCompanyUid(accessToken, options.company);
|
|
1228
|
+
|
|
1229
|
+
const rows = await runSearch({
|
|
1230
|
+
query,
|
|
1231
|
+
companySlug: options.company,
|
|
1232
|
+
vaultClient: client,
|
|
1233
|
+
s3Factory: defaultS3Factory,
|
|
1234
|
+
region: DEFAULT_COGNITO.region,
|
|
1235
|
+
});
|
|
1236
|
+
console.log(formatBrowseTable(rows));
|
|
1237
|
+
} catch (err) {
|
|
1238
|
+
console.error(
|
|
1239
|
+
chalk.red("Error:"),
|
|
1240
|
+
err instanceof Error ? err.message : String(err),
|
|
1241
|
+
);
|
|
1242
|
+
process.exit(1);
|
|
1243
|
+
}
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
filesCmd
|
|
1247
|
+
.command("get <path>")
|
|
1248
|
+
.description(
|
|
1249
|
+
"Download (materialize) a vault file or prefix into local HQ on demand. Default writes in place under <hqRoot>/companies/<slug>/<path> and registers a pin so a scoped sync (mode shared|custom) won't prune it. Use --into to write elsewhere (no pin). Company mode only.",
|
|
1250
|
+
)
|
|
1251
|
+
.option(
|
|
1252
|
+
"--into <dir>",
|
|
1253
|
+
"Write into this directory instead of the in-place companies/<slug>/ location. No pin is registered.",
|
|
1254
|
+
)
|
|
1255
|
+
.option(
|
|
1256
|
+
"--company <slug>",
|
|
1257
|
+
"Company slug (defaults to the slug parsed from <path>).",
|
|
1258
|
+
)
|
|
1259
|
+
.option(
|
|
1260
|
+
"--hq-root <path>",
|
|
1261
|
+
`Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`,
|
|
1262
|
+
DEFAULT_HQ_ROOT,
|
|
1263
|
+
)
|
|
1264
|
+
.action(async (pathArg: string, options: FilesGetCliOptions) => {
|
|
1265
|
+
try {
|
|
1266
|
+
const accessToken = await ensureCognitoToken();
|
|
1267
|
+
const client = new VaultClient(buildVaultConfig(accessToken));
|
|
1268
|
+
|
|
1269
|
+
const slug = options.company ?? parseCompanySlugFromPath(pathArg);
|
|
1270
|
+
if (options.company !== undefined) {
|
|
1271
|
+
const fromPath = (() => {
|
|
1272
|
+
try {
|
|
1273
|
+
return parseCompanySlugFromPath(pathArg);
|
|
1274
|
+
} catch {
|
|
1275
|
+
return undefined;
|
|
1276
|
+
}
|
|
1277
|
+
})();
|
|
1278
|
+
if (fromPath && fromPath !== options.company) {
|
|
1279
|
+
throw new Error(
|
|
1280
|
+
`--company '${options.company}' disagrees with path slug '${fromPath}'.`,
|
|
1281
|
+
);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
await getCompanyUid(accessToken, slug);
|
|
1285
|
+
|
|
1286
|
+
const result = await runGet({
|
|
1287
|
+
path: pathArg,
|
|
1288
|
+
into: options.into,
|
|
1289
|
+
hqRoot: options.hqRoot,
|
|
1290
|
+
companySlug: slug,
|
|
1291
|
+
vaultClient: client,
|
|
1292
|
+
s3Factory: defaultS3Factory,
|
|
1293
|
+
region: DEFAULT_COGNITO.region,
|
|
1294
|
+
});
|
|
1295
|
+
|
|
1296
|
+
console.error(
|
|
1297
|
+
chalk.green("✓"),
|
|
1298
|
+
`Materialized ${result.filesWritten} file(s), ${result.bytesWritten} bytes.`,
|
|
1299
|
+
);
|
|
1300
|
+
if (result.pinned) {
|
|
1301
|
+
console.error(
|
|
1302
|
+
chalk.dim(
|
|
1303
|
+
`Pinned ${result.pinned.companySlug}:${result.pinned.prefix} — survives scoped sync.`,
|
|
1304
|
+
),
|
|
1305
|
+
);
|
|
1306
|
+
}
|
|
1307
|
+
} catch (err) {
|
|
1308
|
+
console.error(
|
|
1309
|
+
chalk.red("Error:"),
|
|
1310
|
+
err instanceof Error ? err.message : String(err),
|
|
1311
|
+
);
|
|
1312
|
+
process.exit(1);
|
|
1313
|
+
}
|
|
1314
|
+
});
|
|
896
1315
|
}
|