@indigoai-us/hq-cli 5.29.0 → 5.31.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.
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: hq-cli-interactive-reads-use-sts-vend
|
|
3
|
+
title: Interactive vault reads vend via /sts/vend, never the legacy POST /vend
|
|
4
|
+
scope: repo
|
|
5
|
+
trigger: any hq-cli command that vends vault credentials to read/list company objects (files browse, files cat, files get/search, or any new interactive read surface)
|
|
6
|
+
enforcement: hard
|
|
7
|
+
public: false
|
|
8
|
+
version: 1
|
|
9
|
+
created: 2026-05-29
|
|
10
|
+
updated: 2026-05-29
|
|
11
|
+
source: back-pressure-failure
|
|
12
|
+
applies_to: [aws]
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Rule
|
|
16
|
+
|
|
17
|
+
Interactive vault reads in hq-cli MUST vend through the multi-tenant STS
|
|
18
|
+
routes — `VaultClient.sts.vend({ companyUid })` (`POST /sts/vend`, company) or
|
|
19
|
+
`VaultClient.sts.vendSelf({ personUid })` (`POST /sts/vend-self`, personal).
|
|
20
|
+
NEVER vend through the legacy `VaultClient.vend(...)` (`POST /vend`).
|
|
21
|
+
|
|
22
|
+
The legacy `POST /vend` (hq-pro `VaultVendFunction` → `vend.handler`) builds
|
|
23
|
+
its IAM policy against a static `process.env.BUCKET_ARN`, but the hq-pro infra
|
|
24
|
+
(`infra/vault-service.ts`) deliberately does NOT set `BUCKET_ARN` — production
|
|
25
|
+
is multi-tenant with one bucket per company. At runtime `BUCKET_ARN` is
|
|
26
|
+
`undefined`, so the session policy contains `"Resource":[null,"undefined/…"]`
|
|
27
|
+
and STS rejects it with `MalformedPolicyDocument: Syntax error at position
|
|
28
|
+
(-1,-1)` → the client sees a 500 "Failed to generate credentials". Only
|
|
29
|
+
`/sts/vend{,-self,-child}` resolve the per-entity bucket (`entity.bucketArn`)
|
|
30
|
+
and apply role/ACL scoping server-side (owner/admin → full access; member/
|
|
31
|
+
guest → per-prefix). `policy-builder.ts` even documents that `POST /vend` "has
|
|
32
|
+
no production callers" — treat it as dead.
|
|
33
|
+
|
|
34
|
+
Related namespace rule: company vault bucket keys are **company-relative**
|
|
35
|
+
(`knowledge/foo.md`, NOT `companies/<slug>/knowledge/foo.md`). Translate the
|
|
36
|
+
CLI's anchored `companies/<slug>/…` form to the bucket-relative key at the S3
|
|
37
|
+
boundary (`toBucketRelative` / `toCompanyAnchored` in `files-browse.ts`), and
|
|
38
|
+
normalize ACL grant paths with `grantPathToPrefix` before `startsWith`
|
|
39
|
+
classification. Listing/getting with the anchored prefix returns nothing.
|
|
40
|
+
|
|
41
|
+
## Rationale
|
|
42
|
+
|
|
43
|
+
`hq files browse`/`cat` (US-009) were wired to `POST /vend`, which 500'd on
|
|
44
|
+
every multi-tenant call. The root cause was an undefined `BUCKET_ARN` env on
|
|
45
|
+
the vend Lambda, not a stale deploy — redeploying clean source did not fix it.
|
|
46
|
+
The working fix routed both commands through `/sts/vend` + `/sts/vend-self`
|
|
47
|
+
(client-only, hq-cloud 5.43.0 / hq-cli 5.29.0) and fixed the company-relative
|
|
48
|
+
namespace translation. Captured so future interactive-read surfaces reach for
|
|
49
|
+
the STS routes from the start and don't resurrect the dead `POST /vend`.
|
|
@@ -300,6 +300,78 @@ export declare function runSharedWithMe(input: RunSharedWithMeInput): Promise<Sh
|
|
|
300
300
|
* Render `shared-with-me` rows as a padded table. Mirrors `formatBrowseTable`.
|
|
301
301
|
*/
|
|
302
302
|
export declare function formatSharedWithMeTable(rows: SharedWithMeRow[]): string;
|
|
303
|
+
export interface RunSearchInput {
|
|
304
|
+
/** Case-insensitive substring matched against each object's full key. */
|
|
305
|
+
query: string;
|
|
306
|
+
/** Company slug to search (ignored under personalMode). */
|
|
307
|
+
companySlug: string;
|
|
308
|
+
personalMode?: boolean;
|
|
309
|
+
personalUid?: string;
|
|
310
|
+
vaultClient: FilesBrowseVaultClient;
|
|
311
|
+
s3Factory: S3ClientFactory;
|
|
312
|
+
region: string;
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* `hq files search <query>` orchestrator. Lists the company (or personal)
|
|
316
|
+
* vault under its root via `runBrowse`, then filters to keys containing the
|
|
317
|
+
* query (case-insensitive). v1 is a name/path search over the listing — no
|
|
318
|
+
* content search. Rows carry the same ACL-source classification as browse.
|
|
319
|
+
*
|
|
320
|
+
* Pure-ish: no console output. The caller renders with `formatBrowseTable`.
|
|
321
|
+
*/
|
|
322
|
+
export declare function runSearch(input: RunSearchInput): Promise<BrowseRow[]>;
|
|
323
|
+
interface PinFile {
|
|
324
|
+
version: number;
|
|
325
|
+
/** companySlug → sorted list of company-relative pinned prefixes. */
|
|
326
|
+
pins: Record<string, string[]>;
|
|
327
|
+
}
|
|
328
|
+
/** Per-machine pin set path: `<hqRoot>/.hq/pins.json`. */
|
|
329
|
+
export declare function pinFilePath(hqRoot: string): string;
|
|
330
|
+
/** Read the pin set, tolerating a missing or corrupt file (→ fresh). */
|
|
331
|
+
export declare function readPins(hqRoot: string): PinFile;
|
|
332
|
+
/**
|
|
333
|
+
* Register a company-relative prefix in the per-machine pin set. Pins are what
|
|
334
|
+
* keep an on-demand `hq files get` from being pruned by the next *scoped*
|
|
335
|
+
* sync (`syncMode: shared|custom`): the sync runner unions the company's pins
|
|
336
|
+
* into its pull scope. Idempotent + sorted for stable diffs.
|
|
337
|
+
*/
|
|
338
|
+
export declare function addPin(hqRoot: string, companySlug: string, prefix: string): void;
|
|
339
|
+
export interface RunGetInput {
|
|
340
|
+
/** Vault path to materialize: `companies/<slug>/...` (file or prefix). */
|
|
341
|
+
path: string;
|
|
342
|
+
/**
|
|
343
|
+
* Override destination directory. Default (omitted) writes in place under
|
|
344
|
+
* `<hqRoot>/companies/<slug>/...` — the tree `hq sync` manages — and
|
|
345
|
+
* registers a pin. `--into` writes outside that envelope and pins nothing.
|
|
346
|
+
*/
|
|
347
|
+
into?: string;
|
|
348
|
+
hqRoot: string;
|
|
349
|
+
companySlug?: string;
|
|
350
|
+
vaultClient: FilesBrowseVaultClient;
|
|
351
|
+
s3Factory: S3ClientFactory;
|
|
352
|
+
region: string;
|
|
353
|
+
}
|
|
354
|
+
export interface RunGetResult {
|
|
355
|
+
filesWritten: number;
|
|
356
|
+
bytesWritten: number;
|
|
357
|
+
destinations: string[];
|
|
358
|
+
/** Set only for in-place (no `--into`) materialization. */
|
|
359
|
+
pinned?: {
|
|
360
|
+
companySlug: string;
|
|
361
|
+
prefix: string;
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* `hq files get <path>` orchestrator. Materializes a vault file or prefix into
|
|
366
|
+
* the local HQ tree on demand. Unlike `cat` (which refuses to write under
|
|
367
|
+
* `companies/`), `get` deliberately writes INTO `companies/<slug>/...` by
|
|
368
|
+
* default — that's the point: pull a path you have access to but don't sync.
|
|
369
|
+
* It then registers a pin so the next scoped sync keeps it.
|
|
370
|
+
*
|
|
371
|
+
* Company mode only in v1 — materializing a personal vault would target the
|
|
372
|
+
* HQ root itself, which is too broad to do implicitly.
|
|
373
|
+
*/
|
|
374
|
+
export declare function runGet(input: RunGetInput): Promise<RunGetResult>;
|
|
303
375
|
/**
|
|
304
376
|
* Wire `hq files browse` + `hq files cat` onto an existing `files`
|
|
305
377
|
* Commander group. `registerFilesCommand` in files.ts builds the group
|
|
@@ -307,4 +379,5 @@ export declare function formatSharedWithMeTable(rows: SharedWithMeRow[]): string
|
|
|
307
379
|
* new browse-vs-sync subcommands so they share the `--company` switch.
|
|
308
380
|
*/
|
|
309
381
|
export declare function registerFilesBrowseCommands(filesCmd: Command): void;
|
|
382
|
+
export {};
|
|
310
383
|
//# sourceMappingURL=files-browse.d.ts.map
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
* methods and `grantPathToPrefix` from hq-cloud.
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
|
-
!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]="
|
|
37
|
+
!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]="cad165f9-6445-5434-969b-e0705274aa56")}catch(e){}}();
|
|
38
38
|
import chalk from "chalk";
|
|
39
39
|
import * as fs from "node:fs";
|
|
40
40
|
import * as path from "node:path";
|
|
@@ -400,6 +400,148 @@ export function formatSharedWithMeTable(rows) {
|
|
|
400
400
|
...data.map(renderRow),
|
|
401
401
|
].join("\n");
|
|
402
402
|
}
|
|
403
|
+
/**
|
|
404
|
+
* `hq files search <query>` orchestrator. Lists the company (or personal)
|
|
405
|
+
* vault under its root via `runBrowse`, then filters to keys containing the
|
|
406
|
+
* query (case-insensitive). v1 is a name/path search over the listing — no
|
|
407
|
+
* content search. Rows carry the same ACL-source classification as browse.
|
|
408
|
+
*
|
|
409
|
+
* Pure-ish: no console output. The caller renders with `formatBrowseTable`.
|
|
410
|
+
*/
|
|
411
|
+
export async function runSearch(input) {
|
|
412
|
+
const prefix = input.personalMode ? "" : `companies/${input.companySlug}/`;
|
|
413
|
+
const { rows } = await runBrowse({
|
|
414
|
+
pathPrefix: prefix,
|
|
415
|
+
companySlug: input.companySlug,
|
|
416
|
+
personalMode: input.personalMode,
|
|
417
|
+
personalUid: input.personalUid,
|
|
418
|
+
vaultClient: input.vaultClient,
|
|
419
|
+
s3Factory: input.s3Factory,
|
|
420
|
+
region: input.region,
|
|
421
|
+
});
|
|
422
|
+
const q = input.query.toLowerCase();
|
|
423
|
+
return rows.filter((r) => r.key.toLowerCase().includes(q));
|
|
424
|
+
}
|
|
425
|
+
/** Per-machine pin set path: `<hqRoot>/.hq/pins.json`. */
|
|
426
|
+
export function pinFilePath(hqRoot) {
|
|
427
|
+
return path.join(hqRoot, ".hq", "pins.json");
|
|
428
|
+
}
|
|
429
|
+
/** Read the pin set, tolerating a missing or corrupt file (→ fresh). */
|
|
430
|
+
export function readPins(hqRoot) {
|
|
431
|
+
try {
|
|
432
|
+
const parsed = JSON.parse(fs.readFileSync(pinFilePath(hqRoot), "utf-8"));
|
|
433
|
+
if (parsed && typeof parsed === "object" && parsed.pins) {
|
|
434
|
+
return { version: parsed.version ?? 1, pins: parsed.pins };
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
/* missing / unreadable / malformed → start fresh */
|
|
439
|
+
}
|
|
440
|
+
return { version: 1, pins: {} };
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Register a company-relative prefix in the per-machine pin set. Pins are what
|
|
444
|
+
* keep an on-demand `hq files get` from being pruned by the next *scoped*
|
|
445
|
+
* sync (`syncMode: shared|custom`): the sync runner unions the company's pins
|
|
446
|
+
* into its pull scope. Idempotent + sorted for stable diffs.
|
|
447
|
+
*/
|
|
448
|
+
export function addPin(hqRoot, companySlug, prefix) {
|
|
449
|
+
const pf = readPins(hqRoot);
|
|
450
|
+
const list = pf.pins[companySlug] ?? [];
|
|
451
|
+
if (!list.includes(prefix))
|
|
452
|
+
list.push(prefix);
|
|
453
|
+
list.sort();
|
|
454
|
+
pf.pins[companySlug] = list;
|
|
455
|
+
const f = pinFilePath(hqRoot);
|
|
456
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
457
|
+
fs.writeFileSync(f, JSON.stringify(pf, null, 2) + "\n");
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* `hq files get <path>` orchestrator. Materializes a vault file or prefix into
|
|
461
|
+
* the local HQ tree on demand. Unlike `cat` (which refuses to write under
|
|
462
|
+
* `companies/`), `get` deliberately writes INTO `companies/<slug>/...` by
|
|
463
|
+
* default — that's the point: pull a path you have access to but don't sync.
|
|
464
|
+
* It then registers a pin so the next scoped sync keeps it.
|
|
465
|
+
*
|
|
466
|
+
* Company mode only in v1 — materializing a personal vault would target the
|
|
467
|
+
* HQ root itself, which is too broad to do implicitly.
|
|
468
|
+
*/
|
|
469
|
+
export async function runGet(input) {
|
|
470
|
+
const { path: vaultPath, vaultClient, s3Factory, region, hqRoot } = input;
|
|
471
|
+
const slug = input.companySlug ?? parseCompanySlugFromPath(vaultPath);
|
|
472
|
+
const entity = await vaultClient.entity.findInMyNamespace("company", slug);
|
|
473
|
+
if (!entity) {
|
|
474
|
+
throw new Error(`No company found for slug '${slug}' in your namespace. Confirm you have an active membership.`);
|
|
475
|
+
}
|
|
476
|
+
if (!entity.bucketName) {
|
|
477
|
+
throw new Error(`Company '${slug}' (${entity.uid}) has no provisioned bucket.`);
|
|
478
|
+
}
|
|
479
|
+
const bucket = entity.bucketName;
|
|
480
|
+
const vend = await vaultClient.sts.vend({ companyUid: entity.uid });
|
|
481
|
+
const s3 = s3Factory({
|
|
482
|
+
region,
|
|
483
|
+
credentials: {
|
|
484
|
+
accessKeyId: vend.credentials.accessKeyId,
|
|
485
|
+
secretAccessKey: vend.credentials.secretAccessKey,
|
|
486
|
+
sessionToken: vend.credentials.sessionToken,
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
// Company-relative prefix to list/fetch (bucket keys carry no anchor).
|
|
490
|
+
const bucketPrefix = toBucketRelative(vaultPath, slug);
|
|
491
|
+
const keys = [];
|
|
492
|
+
let continuationToken;
|
|
493
|
+
do {
|
|
494
|
+
const resp = (await s3.send(new ListObjectsV2Command({
|
|
495
|
+
Bucket: bucket,
|
|
496
|
+
Prefix: bucketPrefix,
|
|
497
|
+
ContinuationToken: continuationToken,
|
|
498
|
+
})));
|
|
499
|
+
for (const obj of resp.Contents ?? []) {
|
|
500
|
+
if (!obj.Key)
|
|
501
|
+
continue;
|
|
502
|
+
if (obj.Key.endsWith("/") && (obj.Size ?? 0) === 0)
|
|
503
|
+
continue;
|
|
504
|
+
keys.push(obj.Key);
|
|
505
|
+
}
|
|
506
|
+
continuationToken = resp.NextContinuationToken ?? undefined;
|
|
507
|
+
} while (continuationToken);
|
|
508
|
+
if (keys.length === 0) {
|
|
509
|
+
throw new Error(`No objects under '${vaultPath}'.`);
|
|
510
|
+
}
|
|
511
|
+
const destinations = [];
|
|
512
|
+
let bytesWritten = 0;
|
|
513
|
+
for (const key of keys) {
|
|
514
|
+
// Default: in-place under companies/<slug>/<company-relative key>.
|
|
515
|
+
// --into: write the path relative to the requested prefix under <into>.
|
|
516
|
+
let destAbs;
|
|
517
|
+
if (input.into !== undefined) {
|
|
518
|
+
const rel = key.startsWith(bucketPrefix)
|
|
519
|
+
? key.slice(bucketPrefix.length)
|
|
520
|
+
: key;
|
|
521
|
+
destAbs = path.resolve(input.into, rel || path.basename(key));
|
|
522
|
+
}
|
|
523
|
+
else {
|
|
524
|
+
destAbs = path.join(hqRoot, "companies", slug, key);
|
|
525
|
+
}
|
|
526
|
+
const resp = (await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key })));
|
|
527
|
+
if (!resp.Body) {
|
|
528
|
+
throw new Error(`GetObject for '${key}' returned no body.`);
|
|
529
|
+
}
|
|
530
|
+
const body = resp.Body;
|
|
531
|
+
fs.mkdirSync(path.dirname(destAbs), { recursive: true });
|
|
532
|
+
await pipeline(body, fs.createWriteStream(destAbs));
|
|
533
|
+
bytesWritten += fs.statSync(destAbs).size;
|
|
534
|
+
destinations.push(destAbs);
|
|
535
|
+
}
|
|
536
|
+
// Pin only the in-place case — `--into` writes outside the sync envelope, so
|
|
537
|
+
// there's nothing for a scoped sync to prune.
|
|
538
|
+
let pinned;
|
|
539
|
+
if (input.into === undefined) {
|
|
540
|
+
addPin(hqRoot, slug, bucketPrefix);
|
|
541
|
+
pinned = { companySlug: slug, prefix: bucketPrefix };
|
|
542
|
+
}
|
|
543
|
+
return { filesWritten: keys.length, bytesWritten, destinations, pinned };
|
|
544
|
+
}
|
|
403
545
|
// ── CLI registration ────────────────────────────────────────────────────────
|
|
404
546
|
const defaultS3Factory = ({ region, credentials }) => new S3Client({ region, credentials });
|
|
405
547
|
/**
|
|
@@ -417,9 +559,12 @@ export function registerFilesBrowseCommands(filesCmd) {
|
|
|
417
559
|
"bucket-relative (omit it to list the vault root). Mutually exclusive " +
|
|
418
560
|
"with --company.")
|
|
419
561
|
.option("--hq-root <path>", `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`, DEFAULT_HQ_ROOT)
|
|
420
|
-
.action(async (pathArg, options) => {
|
|
562
|
+
.action(async (pathArg, options, command) => {
|
|
421
563
|
try {
|
|
422
|
-
|
|
564
|
+
// `--company` is declared on the parent `files` group, so commander
|
|
565
|
+
// binds it there — read merged opts to see it from the subcommand.
|
|
566
|
+
const company = command.optsWithGlobals().company;
|
|
567
|
+
if (options.personal && company) {
|
|
423
568
|
throw new Error("--personal and --company are mutually exclusive. Pick one.");
|
|
424
569
|
}
|
|
425
570
|
const accessToken = await ensureCognitoToken();
|
|
@@ -452,12 +597,12 @@ export function registerFilesBrowseCommands(filesCmd) {
|
|
|
452
597
|
"browse your personal vault.");
|
|
453
598
|
}
|
|
454
599
|
// Resolve slug — CLI flag wins, otherwise parse from path arg.
|
|
455
|
-
const slug =
|
|
600
|
+
const slug = company ?? parseCompanySlugFromPath(pathArg);
|
|
456
601
|
// If the user passed `--company` AND the path doesn't begin with
|
|
457
602
|
// companies/<that-slug>/, refuse — we'd otherwise vend creds for
|
|
458
603
|
// one company and list keys from another tree, which never makes
|
|
459
604
|
// sense (defense in depth against operator typos).
|
|
460
|
-
if (
|
|
605
|
+
if (company !== undefined) {
|
|
461
606
|
const fromPath = (() => {
|
|
462
607
|
try {
|
|
463
608
|
return parseCompanySlugFromPath(pathArg);
|
|
@@ -466,8 +611,8 @@ export function registerFilesBrowseCommands(filesCmd) {
|
|
|
466
611
|
return undefined;
|
|
467
612
|
}
|
|
468
613
|
})();
|
|
469
|
-
if (fromPath && fromPath !==
|
|
470
|
-
throw new Error(`--company '${
|
|
614
|
+
if (fromPath && fromPath !== company) {
|
|
615
|
+
throw new Error(`--company '${company}' disagrees with path slug '${fromPath}'.`);
|
|
471
616
|
}
|
|
472
617
|
}
|
|
473
618
|
// Confirm the slug resolves to a known membership — same pattern
|
|
@@ -502,9 +647,11 @@ export function registerFilesBrowseCommands(filesCmd) {
|
|
|
502
647
|
.option("--personal", "Read from the caller's canonical personal vault. <path> is treated as " +
|
|
503
648
|
"bucket-relative. Mutually exclusive with --company.")
|
|
504
649
|
.option("--hq-root <path>", `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`, DEFAULT_HQ_ROOT)
|
|
505
|
-
.action(async (keyArg, options) => {
|
|
650
|
+
.action(async (keyArg, options, command) => {
|
|
506
651
|
try {
|
|
507
|
-
|
|
652
|
+
// `--company` is bound on the parent `files` group — read merged opts.
|
|
653
|
+
const company = command.optsWithGlobals().company;
|
|
654
|
+
if (options.personal && company) {
|
|
508
655
|
throw new Error("--personal and --company are mutually exclusive. Pick one.");
|
|
509
656
|
}
|
|
510
657
|
const accessToken = await ensureCognitoToken();
|
|
@@ -531,8 +678,8 @@ export function registerFilesBrowseCommands(filesCmd) {
|
|
|
531
678
|
}
|
|
532
679
|
return;
|
|
533
680
|
}
|
|
534
|
-
const slug =
|
|
535
|
-
if (
|
|
681
|
+
const slug = company ?? parseCompanySlugFromPath(keyArg);
|
|
682
|
+
if (company !== undefined) {
|
|
536
683
|
const fromPath = (() => {
|
|
537
684
|
try {
|
|
538
685
|
return parseCompanySlugFromPath(keyArg);
|
|
@@ -541,8 +688,8 @@ export function registerFilesBrowseCommands(filesCmd) {
|
|
|
541
688
|
return undefined;
|
|
542
689
|
}
|
|
543
690
|
})();
|
|
544
|
-
if (fromPath && fromPath !==
|
|
545
|
-
throw new Error(`--company '${
|
|
691
|
+
if (fromPath && fromPath !== company) {
|
|
692
|
+
throw new Error(`--company '${company}' disagrees with path slug '${fromPath}'.`);
|
|
546
693
|
}
|
|
547
694
|
}
|
|
548
695
|
await getCompanyUid(accessToken, slug);
|
|
@@ -568,21 +715,23 @@ export function registerFilesBrowseCommands(filesCmd) {
|
|
|
568
715
|
.command("shared-with-me")
|
|
569
716
|
.description("List the files/prefixes explicitly shared with you. Omit --company to roll up across every company you're a member of. Pure read — no download, no credentials vended. Owner/admin role-bypass access is NOT listed (only explicit grants).")
|
|
570
717
|
.option("--company <slug>", "Scope to a single company (defaults to a cross-company roll-up).")
|
|
571
|
-
.action(async (options) => {
|
|
718
|
+
.action(async (options, command) => {
|
|
572
719
|
try {
|
|
720
|
+
// `--company` is bound on the parent `files` group — read merged opts.
|
|
721
|
+
const company = command.optsWithGlobals().company;
|
|
573
722
|
const accessToken = await ensureCognitoToken();
|
|
574
723
|
const vaultConfig = buildVaultConfig(accessToken);
|
|
575
724
|
const client = new VaultClient(vaultConfig);
|
|
576
725
|
let companyUid;
|
|
577
|
-
if (
|
|
726
|
+
if (company) {
|
|
578
727
|
// Confirm membership + resolve UID, same early-failure pattern as
|
|
579
728
|
// browse/cat. Roll-up mode skips this and fans out internally.
|
|
580
|
-
companyUid = await getCompanyUid(accessToken,
|
|
729
|
+
companyUid = await getCompanyUid(accessToken, company);
|
|
581
730
|
}
|
|
582
731
|
const rows = await runSharedWithMe({
|
|
583
732
|
vaultClient: client,
|
|
584
733
|
companyUid,
|
|
585
|
-
companySlug:
|
|
734
|
+
companySlug: company,
|
|
586
735
|
});
|
|
587
736
|
console.log(formatSharedWithMeTable(rows));
|
|
588
737
|
}
|
|
@@ -591,6 +740,103 @@ export function registerFilesBrowseCommands(filesCmd) {
|
|
|
591
740
|
process.exit(1);
|
|
592
741
|
}
|
|
593
742
|
});
|
|
743
|
+
filesCmd
|
|
744
|
+
.command("search <query>")
|
|
745
|
+
.description("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.")
|
|
746
|
+
.option("--company <slug>", "Company slug to search.")
|
|
747
|
+
.option("--personal", "Search the caller's canonical personal vault. Mutually exclusive with --company.")
|
|
748
|
+
.action(async (query, options, command) => {
|
|
749
|
+
try {
|
|
750
|
+
// `--company` is declared on the parent `files` group too, so commander
|
|
751
|
+
// binds it there; read the merged (global+local) opts to see it.
|
|
752
|
+
const company = command.optsWithGlobals().company;
|
|
753
|
+
if (options.personal && company) {
|
|
754
|
+
throw new Error("--personal and --company are mutually exclusive. Pick one.");
|
|
755
|
+
}
|
|
756
|
+
const accessToken = await ensureCognitoToken();
|
|
757
|
+
const client = new VaultClient(buildVaultConfig(accessToken));
|
|
758
|
+
if (options.personal) {
|
|
759
|
+
const personalUid = await resolveCanonicalPersonUid({
|
|
760
|
+
listMyMemberships: () => client.listMyMemberships(),
|
|
761
|
+
listPersonEntities: () => client.entity.listByType("person"),
|
|
762
|
+
getEntity: async () => null,
|
|
763
|
+
});
|
|
764
|
+
const rows = await runSearch({
|
|
765
|
+
query,
|
|
766
|
+
companySlug: "personal",
|
|
767
|
+
personalMode: true,
|
|
768
|
+
personalUid,
|
|
769
|
+
vaultClient: client,
|
|
770
|
+
s3Factory: defaultS3Factory,
|
|
771
|
+
region: DEFAULT_COGNITO.region,
|
|
772
|
+
});
|
|
773
|
+
console.log(formatBrowseTable(rows));
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
if (!company) {
|
|
777
|
+
throw new Error("search: --company <slug> is required (or --personal to search your personal vault).");
|
|
778
|
+
}
|
|
779
|
+
await getCompanyUid(accessToken, company);
|
|
780
|
+
const rows = await runSearch({
|
|
781
|
+
query,
|
|
782
|
+
companySlug: company,
|
|
783
|
+
vaultClient: client,
|
|
784
|
+
s3Factory: defaultS3Factory,
|
|
785
|
+
region: DEFAULT_COGNITO.region,
|
|
786
|
+
});
|
|
787
|
+
console.log(formatBrowseTable(rows));
|
|
788
|
+
}
|
|
789
|
+
catch (err) {
|
|
790
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
791
|
+
process.exit(1);
|
|
792
|
+
}
|
|
793
|
+
});
|
|
794
|
+
filesCmd
|
|
795
|
+
.command("get <path>")
|
|
796
|
+
.description("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.")
|
|
797
|
+
.option("--into <dir>", "Write into this directory instead of the in-place companies/<slug>/ location. No pin is registered.")
|
|
798
|
+
.option("--company <slug>", "Company slug (defaults to the slug parsed from <path>).")
|
|
799
|
+
.option("--hq-root <path>", `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`, DEFAULT_HQ_ROOT)
|
|
800
|
+
.action(async (pathArg, options, command) => {
|
|
801
|
+
try {
|
|
802
|
+
const accessToken = await ensureCognitoToken();
|
|
803
|
+
const client = new VaultClient(buildVaultConfig(accessToken));
|
|
804
|
+
// `--company` is bound on the parent `files` group — read merged opts.
|
|
805
|
+
const companyOpt = command.optsWithGlobals().company;
|
|
806
|
+
const slug = companyOpt ?? parseCompanySlugFromPath(pathArg);
|
|
807
|
+
if (companyOpt !== undefined) {
|
|
808
|
+
const fromPath = (() => {
|
|
809
|
+
try {
|
|
810
|
+
return parseCompanySlugFromPath(pathArg);
|
|
811
|
+
}
|
|
812
|
+
catch {
|
|
813
|
+
return undefined;
|
|
814
|
+
}
|
|
815
|
+
})();
|
|
816
|
+
if (fromPath && fromPath !== companyOpt) {
|
|
817
|
+
throw new Error(`--company '${companyOpt}' disagrees with path slug '${fromPath}'.`);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
await getCompanyUid(accessToken, slug);
|
|
821
|
+
const result = await runGet({
|
|
822
|
+
path: pathArg,
|
|
823
|
+
into: options.into,
|
|
824
|
+
hqRoot: options.hqRoot,
|
|
825
|
+
companySlug: slug,
|
|
826
|
+
vaultClient: client,
|
|
827
|
+
s3Factory: defaultS3Factory,
|
|
828
|
+
region: DEFAULT_COGNITO.region,
|
|
829
|
+
});
|
|
830
|
+
console.error(chalk.green("✓"), `Materialized ${result.filesWritten} file(s), ${result.bytesWritten} bytes.`);
|
|
831
|
+
if (result.pinned) {
|
|
832
|
+
console.error(chalk.dim(`Pinned ${result.pinned.companySlug}:${result.pinned.prefix} — survives scoped sync.`));
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
catch (err) {
|
|
836
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
837
|
+
process.exit(1);
|
|
838
|
+
}
|
|
839
|
+
});
|
|
594
840
|
}
|
|
595
841
|
//# sourceMappingURL=files-browse.js.map
|
|
596
|
-
//# debugId=
|
|
842
|
+
//# debugId=cad165f9-6445-5434-969b-e0705274aa56
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.31.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.
|
|
18
|
+
"@indigoai-us/hq-cloud": "~5.44.0",
|
|
19
19
|
"@indigoai-us/hq-onboarding": "^0.1.0",
|
|
20
20
|
"@sentry/node": "^10.49.0",
|
|
21
21
|
"chalk": "^5.3.0",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
23
|
+
import { Command } from "commander";
|
|
23
24
|
import * as fs from "node:fs";
|
|
24
25
|
import * as os from "node:os";
|
|
25
26
|
import * as path from "node:path";
|
|
@@ -34,6 +35,11 @@ import {
|
|
|
34
35
|
toCompanyAnchored,
|
|
35
36
|
runBrowse,
|
|
36
37
|
runCat,
|
|
38
|
+
runSearch,
|
|
39
|
+
runGet,
|
|
40
|
+
readPins,
|
|
41
|
+
addPin,
|
|
42
|
+
pinFilePath,
|
|
37
43
|
runSharedWithMe,
|
|
38
44
|
formatSharedWithMeTable,
|
|
39
45
|
type BrowseVendResult,
|
|
@@ -831,3 +837,249 @@ describe("runSharedWithMe", () => {
|
|
|
831
837
|
expect(out).toContain("knowledge/");
|
|
832
838
|
});
|
|
833
839
|
});
|
|
840
|
+
|
|
841
|
+
// ── runSearch ─────────────────────────────────────────────────────────────
|
|
842
|
+
|
|
843
|
+
describe("runSearch", () => {
|
|
844
|
+
it("filters the company listing by case-insensitive substring on the key", async () => {
|
|
845
|
+
const { client, spies } = makeStubVaultClient({});
|
|
846
|
+
const { factory } = makeStubS3Factory({
|
|
847
|
+
listResponses: [
|
|
848
|
+
{
|
|
849
|
+
Contents: [
|
|
850
|
+
{ Key: "knowledge/Roadmap.md", Size: 1, LastModified: new Date() },
|
|
851
|
+
{ Key: "reports/q3.pdf", Size: 2, LastModified: new Date() },
|
|
852
|
+
{ Key: "knowledge/notes.md", Size: 3, LastModified: new Date() },
|
|
853
|
+
],
|
|
854
|
+
},
|
|
855
|
+
],
|
|
856
|
+
});
|
|
857
|
+
const rows = await runSearch({
|
|
858
|
+
query: "roadmap", // lower-case query matches mixed-case key
|
|
859
|
+
companySlug: "indigo",
|
|
860
|
+
vaultClient: client,
|
|
861
|
+
s3Factory: factory,
|
|
862
|
+
region: "us-east-1",
|
|
863
|
+
});
|
|
864
|
+
// Keys are re-anchored for display; only the matching one survives.
|
|
865
|
+
expect(rows.map((r) => r.key)).toEqual([
|
|
866
|
+
"companies/indigo/knowledge/Roadmap.md",
|
|
867
|
+
]);
|
|
868
|
+
// Vends via the multi-tenant route (inherited from runBrowse).
|
|
869
|
+
expect(spies.stsVend).toHaveBeenCalledTimes(1);
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
it("returns an empty array when nothing matches", async () => {
|
|
873
|
+
const { client } = makeStubVaultClient({});
|
|
874
|
+
const { factory } = makeStubS3Factory({
|
|
875
|
+
listResponses: [
|
|
876
|
+
{ Contents: [{ Key: "knowledge/a.md", Size: 1, LastModified: new Date() }] },
|
|
877
|
+
],
|
|
878
|
+
});
|
|
879
|
+
const rows = await runSearch({
|
|
880
|
+
query: "zzz-no-match",
|
|
881
|
+
companySlug: "indigo",
|
|
882
|
+
vaultClient: client,
|
|
883
|
+
s3Factory: factory,
|
|
884
|
+
region: "us-east-1",
|
|
885
|
+
});
|
|
886
|
+
expect(rows).toEqual([]);
|
|
887
|
+
});
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
// ── pin set (readPins / addPin) ─────────────────────────────────────────────
|
|
891
|
+
|
|
892
|
+
describe("pin set", () => {
|
|
893
|
+
it("readPins returns an empty set when the file is missing", () => {
|
|
894
|
+
expect(readPins(tmpRoot)).toEqual({ version: 1, pins: {} });
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
it("addPin creates the file, dedups, and sorts prefixes", () => {
|
|
898
|
+
addPin(tmpRoot, "indigo", "knowledge/");
|
|
899
|
+
addPin(tmpRoot, "indigo", "knowledge/"); // duplicate — ignored
|
|
900
|
+
addPin(tmpRoot, "indigo", "data/");
|
|
901
|
+
const pf = readPins(tmpRoot);
|
|
902
|
+
expect(pf.pins.indigo).toEqual(["data/", "knowledge/"]);
|
|
903
|
+
expect(fs.existsSync(pinFilePath(tmpRoot))).toBe(true);
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
it("addPin keeps per-company lists separate", () => {
|
|
907
|
+
addPin(tmpRoot, "indigo", "knowledge/");
|
|
908
|
+
addPin(tmpRoot, "acme", "docs/");
|
|
909
|
+
const pf = readPins(tmpRoot);
|
|
910
|
+
expect(pf.pins).toEqual({ indigo: ["knowledge/"], acme: ["docs/"] });
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
it("readPins tolerates a corrupt pins.json (→ fresh)", () => {
|
|
914
|
+
fs.mkdirSync(path.dirname(pinFilePath(tmpRoot)), { recursive: true });
|
|
915
|
+
fs.writeFileSync(pinFilePath(tmpRoot), "{ not valid json");
|
|
916
|
+
expect(readPins(tmpRoot)).toEqual({ version: 1, pins: {} });
|
|
917
|
+
});
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
// ── runGet ────────────────────────────────────────────────────────────────
|
|
921
|
+
|
|
922
|
+
function makeGetStubS3(opts: {
|
|
923
|
+
listKeys: Array<{ Key: string; Size: number }>;
|
|
924
|
+
bodyFor: (key: string) => Buffer;
|
|
925
|
+
}): { factory: S3ClientFactory; sendSpy: ReturnType<typeof vi.fn> } {
|
|
926
|
+
const sendSpy = vi.fn(async (cmd: unknown) => {
|
|
927
|
+
if (cmd instanceof ListObjectsV2Command) {
|
|
928
|
+
return {
|
|
929
|
+
Contents: opts.listKeys.map((k) => ({ ...k, LastModified: new Date() })),
|
|
930
|
+
} as ListObjectsV2CommandOutput;
|
|
931
|
+
}
|
|
932
|
+
if (cmd instanceof GetObjectCommand) {
|
|
933
|
+
const key = (cmd as GetObjectCommand).input.Key as string;
|
|
934
|
+
return {
|
|
935
|
+
Body: Readable.from(opts.bodyFor(key)),
|
|
936
|
+
} as unknown as GetObjectCommandOutput;
|
|
937
|
+
}
|
|
938
|
+
throw new Error(`unexpected command: ${cmd}`);
|
|
939
|
+
});
|
|
940
|
+
const factory = vi.fn(
|
|
941
|
+
() => ({ send: sendSpy }) as unknown as FilesBrowseS3Client,
|
|
942
|
+
) as unknown as S3ClientFactory;
|
|
943
|
+
return { factory, sendSpy };
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
describe("runGet", () => {
|
|
947
|
+
it("materializes a prefix in-place under companies/<slug>/ and registers a pin", async () => {
|
|
948
|
+
const { client, spies } = makeStubVaultClient({});
|
|
949
|
+
const { factory, sendSpy } = makeGetStubS3({
|
|
950
|
+
listKeys: [
|
|
951
|
+
{ Key: "knowledge/a.md", Size: 3 },
|
|
952
|
+
{ Key: "knowledge/sub/b.md", Size: 3 },
|
|
953
|
+
],
|
|
954
|
+
bodyFor: (k) => Buffer.from(k === "knowledge/a.md" ? "AAA" : "BBB"),
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
const result = await runGet({
|
|
958
|
+
path: "companies/indigo/knowledge/",
|
|
959
|
+
hqRoot: tmpRoot,
|
|
960
|
+
companySlug: "indigo",
|
|
961
|
+
vaultClient: client,
|
|
962
|
+
s3Factory: factory,
|
|
963
|
+
region: "us-east-1",
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
// Vends via the multi-tenant company route.
|
|
967
|
+
expect(spies.stsVend).toHaveBeenCalledWith({ companyUid: "cmp_indigo" });
|
|
968
|
+
expect(result.filesWritten).toBe(2);
|
|
969
|
+
|
|
970
|
+
// Files landed in place under companies/<slug>/.
|
|
971
|
+
expect(
|
|
972
|
+
fs.readFileSync(
|
|
973
|
+
path.join(tmpRoot, "companies", "indigo", "knowledge", "a.md"),
|
|
974
|
+
"utf-8",
|
|
975
|
+
),
|
|
976
|
+
).toBe("AAA");
|
|
977
|
+
expect(
|
|
978
|
+
fs.readFileSync(
|
|
979
|
+
path.join(tmpRoot, "companies", "indigo", "knowledge", "sub", "b.md"),
|
|
980
|
+
"utf-8",
|
|
981
|
+
),
|
|
982
|
+
).toBe("BBB");
|
|
983
|
+
|
|
984
|
+
// GetObject used company-relative keys (no companies/<slug>/ anchor).
|
|
985
|
+
const getKeys = sendSpy.mock.calls
|
|
986
|
+
.filter((c) => c[0] instanceof GetObjectCommand)
|
|
987
|
+
.map((c) => (c[0] as GetObjectCommand).input.Key);
|
|
988
|
+
expect(getKeys).toEqual(["knowledge/a.md", "knowledge/sub/b.md"]);
|
|
989
|
+
|
|
990
|
+
// Pin registered for the in-place prefix.
|
|
991
|
+
expect(readPins(tmpRoot).pins.indigo).toEqual(["knowledge/"]);
|
|
992
|
+
expect(result.pinned).toEqual({ companySlug: "indigo", prefix: "knowledge/" });
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
it("--into writes outside companies/ and registers NO pin", async () => {
|
|
996
|
+
const into = path.join(tmpRoot, "extract");
|
|
997
|
+
const { client } = makeStubVaultClient({});
|
|
998
|
+
const { factory } = makeGetStubS3({
|
|
999
|
+
listKeys: [{ Key: "knowledge/a.md", Size: 3 }],
|
|
1000
|
+
bodyFor: () => Buffer.from("AAA"),
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
const result = await runGet({
|
|
1004
|
+
path: "companies/indigo/knowledge/",
|
|
1005
|
+
into,
|
|
1006
|
+
hqRoot: tmpRoot,
|
|
1007
|
+
companySlug: "indigo",
|
|
1008
|
+
vaultClient: client,
|
|
1009
|
+
s3Factory: factory,
|
|
1010
|
+
region: "us-east-1",
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
// Written relative to the requested prefix, under --into.
|
|
1014
|
+
expect(fs.readFileSync(path.join(into, "a.md"), "utf-8")).toBe("AAA");
|
|
1015
|
+
// No pin — --into is outside the sync envelope.
|
|
1016
|
+
expect(result.pinned).toBeUndefined();
|
|
1017
|
+
expect(fs.existsSync(pinFilePath(tmpRoot))).toBe(false);
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
it("throws when no objects exist under the path", async () => {
|
|
1021
|
+
const { client } = makeStubVaultClient({});
|
|
1022
|
+
const { factory } = makeGetStubS3({
|
|
1023
|
+
listKeys: [],
|
|
1024
|
+
bodyFor: () => Buffer.from(""),
|
|
1025
|
+
});
|
|
1026
|
+
await expect(
|
|
1027
|
+
runGet({
|
|
1028
|
+
path: "companies/indigo/nope/",
|
|
1029
|
+
hqRoot: tmpRoot,
|
|
1030
|
+
companySlug: "indigo",
|
|
1031
|
+
vaultClient: client,
|
|
1032
|
+
s3Factory: factory,
|
|
1033
|
+
region: "us-east-1",
|
|
1034
|
+
}),
|
|
1035
|
+
).rejects.toThrow(/No objects under/);
|
|
1036
|
+
});
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
// ── --company parent-binding (regression) ───────────────────────────────────
|
|
1040
|
+
//
|
|
1041
|
+
// The `files` parent group declares `--company`, so commander binds the flag
|
|
1042
|
+
// to the PARENT command, not the subcommand — `subcommand.opts().company` is
|
|
1043
|
+
// empty. The browse/cat/search/get/shared-with-me actions therefore resolve
|
|
1044
|
+
// it via `command.optsWithGlobals().company`. This guards that contract so a
|
|
1045
|
+
// future refactor doesn't silently regress `hq files <sub> ... --company X`
|
|
1046
|
+
// back to the swallowed-flag bug.
|
|
1047
|
+
|
|
1048
|
+
describe("--company parent binding (optsWithGlobals)", () => {
|
|
1049
|
+
it("a subcommand sees --company declared on the parent group", () => {
|
|
1050
|
+
const program = new Command();
|
|
1051
|
+
const files = program
|
|
1052
|
+
.command("files")
|
|
1053
|
+
.option("--company <slug>", "Company slug");
|
|
1054
|
+
let seen: string | undefined = "UNSET";
|
|
1055
|
+
files
|
|
1056
|
+
.command("search <query>")
|
|
1057
|
+
.option("--personal")
|
|
1058
|
+
.action((_query: string, _options: unknown, command: Command) => {
|
|
1059
|
+
seen = command.optsWithGlobals().company as string | undefined;
|
|
1060
|
+
});
|
|
1061
|
+
program.parse(
|
|
1062
|
+
["files", "search", "README", "--company", "indigo"],
|
|
1063
|
+
{ from: "user" } as never,
|
|
1064
|
+
);
|
|
1065
|
+
expect(seen).toBe("indigo");
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
it("documents the bug: subcommand-local opts.company is empty for a parent-bound flag", () => {
|
|
1069
|
+
const program = new Command();
|
|
1070
|
+
const files = program
|
|
1071
|
+
.command("files")
|
|
1072
|
+
.option("--company <slug>", "Company slug");
|
|
1073
|
+
let local: string | undefined = "UNSET";
|
|
1074
|
+
files
|
|
1075
|
+
.command("search <query>")
|
|
1076
|
+
.action((_query: string, options: { company?: string }) => {
|
|
1077
|
+
local = options.company;
|
|
1078
|
+
});
|
|
1079
|
+
program.parse(
|
|
1080
|
+
["files", "search", "README", "--company", "indigo"],
|
|
1081
|
+
{ from: "user" } as never,
|
|
1082
|
+
);
|
|
1083
|
+
expect(local).toBeUndefined();
|
|
1084
|
+
});
|
|
1085
|
+
});
|
|
@@ -676,6 +676,207 @@ export function formatSharedWithMeTable(rows: SharedWithMeRow[]): string {
|
|
|
676
676
|
].join("\n");
|
|
677
677
|
}
|
|
678
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
|
+
|
|
679
880
|
// ── CLI registration ────────────────────────────────────────────────────────
|
|
680
881
|
|
|
681
882
|
const defaultS3Factory: S3ClientFactory = ({ region, credentials }) =>
|
|
@@ -697,6 +898,17 @@ interface FilesCatCliOptions extends FilesBrowseCliOptions {
|
|
|
697
898
|
out?: string;
|
|
698
899
|
}
|
|
699
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
|
+
|
|
700
912
|
/**
|
|
701
913
|
* Wire `hq files browse` + `hq files cat` onto an existing `files`
|
|
702
914
|
* Commander group. `registerFilesCommand` in files.ts builds the group
|
|
@@ -724,9 +936,12 @@ export function registerFilesBrowseCommands(filesCmd: Command): void {
|
|
|
724
936
|
`Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`,
|
|
725
937
|
DEFAULT_HQ_ROOT,
|
|
726
938
|
)
|
|
727
|
-
.action(async (pathArg: string | undefined, options: FilesBrowseCliOptions) => {
|
|
939
|
+
.action(async (pathArg: string | undefined, options: FilesBrowseCliOptions, command: Command) => {
|
|
728
940
|
try {
|
|
729
|
-
|
|
941
|
+
// `--company` is declared on the parent `files` group, so commander
|
|
942
|
+
// binds it there — read merged opts to see it from the subcommand.
|
|
943
|
+
const company = command.optsWithGlobals().company as string | undefined;
|
|
944
|
+
if (options.personal && company) {
|
|
730
945
|
throw new Error(
|
|
731
946
|
"--personal and --company are mutually exclusive. Pick one.",
|
|
732
947
|
);
|
|
@@ -769,13 +984,13 @@ export function registerFilesBrowseCommands(filesCmd: Command): void {
|
|
|
769
984
|
}
|
|
770
985
|
|
|
771
986
|
// Resolve slug — CLI flag wins, otherwise parse from path arg.
|
|
772
|
-
const slug =
|
|
987
|
+
const slug = company ?? parseCompanySlugFromPath(pathArg);
|
|
773
988
|
|
|
774
989
|
// If the user passed `--company` AND the path doesn't begin with
|
|
775
990
|
// companies/<that-slug>/, refuse — we'd otherwise vend creds for
|
|
776
991
|
// one company and list keys from another tree, which never makes
|
|
777
992
|
// sense (defense in depth against operator typos).
|
|
778
|
-
if (
|
|
993
|
+
if (company !== undefined) {
|
|
779
994
|
const fromPath = (() => {
|
|
780
995
|
try {
|
|
781
996
|
return parseCompanySlugFromPath(pathArg);
|
|
@@ -783,9 +998,9 @@ export function registerFilesBrowseCommands(filesCmd: Command): void {
|
|
|
783
998
|
return undefined;
|
|
784
999
|
}
|
|
785
1000
|
})();
|
|
786
|
-
if (fromPath && fromPath !==
|
|
1001
|
+
if (fromPath && fromPath !== company) {
|
|
787
1002
|
throw new Error(
|
|
788
|
-
`--company '${
|
|
1003
|
+
`--company '${company}' disagrees with path slug '${fromPath}'.`,
|
|
789
1004
|
);
|
|
790
1005
|
}
|
|
791
1006
|
}
|
|
@@ -848,9 +1063,11 @@ export function registerFilesBrowseCommands(filesCmd: Command): void {
|
|
|
848
1063
|
`Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`,
|
|
849
1064
|
DEFAULT_HQ_ROOT,
|
|
850
1065
|
)
|
|
851
|
-
.action(async (keyArg: string, options: FilesCatCliOptions) => {
|
|
1066
|
+
.action(async (keyArg: string, options: FilesCatCliOptions, command: Command) => {
|
|
852
1067
|
try {
|
|
853
|
-
|
|
1068
|
+
// `--company` is bound on the parent `files` group — read merged opts.
|
|
1069
|
+
const company = command.optsWithGlobals().company as string | undefined;
|
|
1070
|
+
if (options.personal && company) {
|
|
854
1071
|
throw new Error(
|
|
855
1072
|
"--personal and --company are mutually exclusive. Pick one.",
|
|
856
1073
|
);
|
|
@@ -887,8 +1104,8 @@ export function registerFilesBrowseCommands(filesCmd: Command): void {
|
|
|
887
1104
|
return;
|
|
888
1105
|
}
|
|
889
1106
|
|
|
890
|
-
const slug =
|
|
891
|
-
if (
|
|
1107
|
+
const slug = company ?? parseCompanySlugFromPath(keyArg);
|
|
1108
|
+
if (company !== undefined) {
|
|
892
1109
|
const fromPath = (() => {
|
|
893
1110
|
try {
|
|
894
1111
|
return parseCompanySlugFromPath(keyArg);
|
|
@@ -896,9 +1113,9 @@ export function registerFilesBrowseCommands(filesCmd: Command): void {
|
|
|
896
1113
|
return undefined;
|
|
897
1114
|
}
|
|
898
1115
|
})();
|
|
899
|
-
if (fromPath && fromPath !==
|
|
1116
|
+
if (fromPath && fromPath !== company) {
|
|
900
1117
|
throw new Error(
|
|
901
|
-
`--company '${
|
|
1118
|
+
`--company '${company}' disagrees with path slug '${fromPath}'.`,
|
|
902
1119
|
);
|
|
903
1120
|
}
|
|
904
1121
|
}
|
|
@@ -938,23 +1155,25 @@ export function registerFilesBrowseCommands(filesCmd: Command): void {
|
|
|
938
1155
|
"--company <slug>",
|
|
939
1156
|
"Scope to a single company (defaults to a cross-company roll-up).",
|
|
940
1157
|
)
|
|
941
|
-
.action(async (options: { company?: string }) => {
|
|
1158
|
+
.action(async (options: { company?: string }, command: Command) => {
|
|
942
1159
|
try {
|
|
1160
|
+
// `--company` is bound on the parent `files` group — read merged opts.
|
|
1161
|
+
const company = command.optsWithGlobals().company as string | undefined;
|
|
943
1162
|
const accessToken = await ensureCognitoToken();
|
|
944
1163
|
const vaultConfig = buildVaultConfig(accessToken);
|
|
945
1164
|
const client = new VaultClient(vaultConfig);
|
|
946
1165
|
|
|
947
1166
|
let companyUid: string | undefined;
|
|
948
|
-
if (
|
|
1167
|
+
if (company) {
|
|
949
1168
|
// Confirm membership + resolve UID, same early-failure pattern as
|
|
950
1169
|
// browse/cat. Roll-up mode skips this and fans out internally.
|
|
951
|
-
companyUid = await getCompanyUid(accessToken,
|
|
1170
|
+
companyUid = await getCompanyUid(accessToken, company);
|
|
952
1171
|
}
|
|
953
1172
|
|
|
954
1173
|
const rows = await runSharedWithMe({
|
|
955
1174
|
vaultClient: client,
|
|
956
1175
|
companyUid,
|
|
957
|
-
companySlug:
|
|
1176
|
+
companySlug: company,
|
|
958
1177
|
});
|
|
959
1178
|
|
|
960
1179
|
console.log(formatSharedWithMeTable(rows));
|
|
@@ -966,4 +1185,143 @@ export function registerFilesBrowseCommands(filesCmd: Command): void {
|
|
|
966
1185
|
process.exit(1);
|
|
967
1186
|
}
|
|
968
1187
|
});
|
|
1188
|
+
|
|
1189
|
+
filesCmd
|
|
1190
|
+
.command("search <query>")
|
|
1191
|
+
.description(
|
|
1192
|
+
"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.",
|
|
1193
|
+
)
|
|
1194
|
+
.option("--company <slug>", "Company slug to search.")
|
|
1195
|
+
.option(
|
|
1196
|
+
"--personal",
|
|
1197
|
+
"Search the caller's canonical personal vault. Mutually exclusive with --company.",
|
|
1198
|
+
)
|
|
1199
|
+
.action(async (query: string, options: FilesSearchCliOptions, command: Command) => {
|
|
1200
|
+
try {
|
|
1201
|
+
// `--company` is declared on the parent `files` group too, so commander
|
|
1202
|
+
// binds it there; read the merged (global+local) opts to see it.
|
|
1203
|
+
const company = command.optsWithGlobals().company as string | undefined;
|
|
1204
|
+
if (options.personal && company) {
|
|
1205
|
+
throw new Error(
|
|
1206
|
+
"--personal and --company are mutually exclusive. Pick one.",
|
|
1207
|
+
);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
const accessToken = await ensureCognitoToken();
|
|
1211
|
+
const client = new VaultClient(buildVaultConfig(accessToken));
|
|
1212
|
+
|
|
1213
|
+
if (options.personal) {
|
|
1214
|
+
const personalUid = await resolveCanonicalPersonUid({
|
|
1215
|
+
listMyMemberships: () => client.listMyMemberships(),
|
|
1216
|
+
listPersonEntities: () => client.entity.listByType("person"),
|
|
1217
|
+
getEntity: async () => null,
|
|
1218
|
+
});
|
|
1219
|
+
const rows = await runSearch({
|
|
1220
|
+
query,
|
|
1221
|
+
companySlug: "personal",
|
|
1222
|
+
personalMode: true,
|
|
1223
|
+
personalUid,
|
|
1224
|
+
vaultClient: client,
|
|
1225
|
+
s3Factory: defaultS3Factory,
|
|
1226
|
+
region: DEFAULT_COGNITO.region,
|
|
1227
|
+
});
|
|
1228
|
+
console.log(formatBrowseTable(rows));
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
if (!company) {
|
|
1233
|
+
throw new Error(
|
|
1234
|
+
"search: --company <slug> is required (or --personal to search your personal vault).",
|
|
1235
|
+
);
|
|
1236
|
+
}
|
|
1237
|
+
await getCompanyUid(accessToken, company);
|
|
1238
|
+
|
|
1239
|
+
const rows = await runSearch({
|
|
1240
|
+
query,
|
|
1241
|
+
companySlug: company,
|
|
1242
|
+
vaultClient: client,
|
|
1243
|
+
s3Factory: defaultS3Factory,
|
|
1244
|
+
region: DEFAULT_COGNITO.region,
|
|
1245
|
+
});
|
|
1246
|
+
console.log(formatBrowseTable(rows));
|
|
1247
|
+
} catch (err) {
|
|
1248
|
+
console.error(
|
|
1249
|
+
chalk.red("Error:"),
|
|
1250
|
+
err instanceof Error ? err.message : String(err),
|
|
1251
|
+
);
|
|
1252
|
+
process.exit(1);
|
|
1253
|
+
}
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
filesCmd
|
|
1257
|
+
.command("get <path>")
|
|
1258
|
+
.description(
|
|
1259
|
+
"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.",
|
|
1260
|
+
)
|
|
1261
|
+
.option(
|
|
1262
|
+
"--into <dir>",
|
|
1263
|
+
"Write into this directory instead of the in-place companies/<slug>/ location. No pin is registered.",
|
|
1264
|
+
)
|
|
1265
|
+
.option(
|
|
1266
|
+
"--company <slug>",
|
|
1267
|
+
"Company slug (defaults to the slug parsed from <path>).",
|
|
1268
|
+
)
|
|
1269
|
+
.option(
|
|
1270
|
+
"--hq-root <path>",
|
|
1271
|
+
`Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`,
|
|
1272
|
+
DEFAULT_HQ_ROOT,
|
|
1273
|
+
)
|
|
1274
|
+
.action(async (pathArg: string, options: FilesGetCliOptions, command: Command) => {
|
|
1275
|
+
try {
|
|
1276
|
+
const accessToken = await ensureCognitoToken();
|
|
1277
|
+
const client = new VaultClient(buildVaultConfig(accessToken));
|
|
1278
|
+
|
|
1279
|
+
// `--company` is bound on the parent `files` group — read merged opts.
|
|
1280
|
+
const companyOpt = command.optsWithGlobals().company as string | undefined;
|
|
1281
|
+
const slug = companyOpt ?? parseCompanySlugFromPath(pathArg);
|
|
1282
|
+
if (companyOpt !== undefined) {
|
|
1283
|
+
const fromPath = (() => {
|
|
1284
|
+
try {
|
|
1285
|
+
return parseCompanySlugFromPath(pathArg);
|
|
1286
|
+
} catch {
|
|
1287
|
+
return undefined;
|
|
1288
|
+
}
|
|
1289
|
+
})();
|
|
1290
|
+
if (fromPath && fromPath !== companyOpt) {
|
|
1291
|
+
throw new Error(
|
|
1292
|
+
`--company '${companyOpt}' disagrees with path slug '${fromPath}'.`,
|
|
1293
|
+
);
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
await getCompanyUid(accessToken, slug);
|
|
1297
|
+
|
|
1298
|
+
const result = await runGet({
|
|
1299
|
+
path: pathArg,
|
|
1300
|
+
into: options.into,
|
|
1301
|
+
hqRoot: options.hqRoot,
|
|
1302
|
+
companySlug: slug,
|
|
1303
|
+
vaultClient: client,
|
|
1304
|
+
s3Factory: defaultS3Factory,
|
|
1305
|
+
region: DEFAULT_COGNITO.region,
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
console.error(
|
|
1309
|
+
chalk.green("✓"),
|
|
1310
|
+
`Materialized ${result.filesWritten} file(s), ${result.bytesWritten} bytes.`,
|
|
1311
|
+
);
|
|
1312
|
+
if (result.pinned) {
|
|
1313
|
+
console.error(
|
|
1314
|
+
chalk.dim(
|
|
1315
|
+
`Pinned ${result.pinned.companySlug}:${result.pinned.prefix} — survives scoped sync.`,
|
|
1316
|
+
),
|
|
1317
|
+
);
|
|
1318
|
+
}
|
|
1319
|
+
} catch (err) {
|
|
1320
|
+
console.error(
|
|
1321
|
+
chalk.red("Error:"),
|
|
1322
|
+
err instanceof Error ? err.message : String(err),
|
|
1323
|
+
);
|
|
1324
|
+
process.exit(1);
|
|
1325
|
+
}
|
|
1326
|
+
});
|
|
969
1327
|
}
|