@indigoai-us/hq-cli 5.29.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.
|
@@ -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]="73529786-7369-5356-a6a8-4d55bd553147")}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
|
/**
|
|
@@ -591,6 +733,98 @@ export function registerFilesBrowseCommands(filesCmd) {
|
|
|
591
733
|
process.exit(1);
|
|
592
734
|
}
|
|
593
735
|
});
|
|
736
|
+
filesCmd
|
|
737
|
+
.command("search <query>")
|
|
738
|
+
.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.")
|
|
739
|
+
.option("--company <slug>", "Company slug to search.")
|
|
740
|
+
.option("--personal", "Search the caller's canonical personal vault. Mutually exclusive with --company.")
|
|
741
|
+
.action(async (query, options) => {
|
|
742
|
+
try {
|
|
743
|
+
if (options.personal && options.company) {
|
|
744
|
+
throw new Error("--personal and --company are mutually exclusive. Pick one.");
|
|
745
|
+
}
|
|
746
|
+
const accessToken = await ensureCognitoToken();
|
|
747
|
+
const client = new VaultClient(buildVaultConfig(accessToken));
|
|
748
|
+
if (options.personal) {
|
|
749
|
+
const personalUid = await resolveCanonicalPersonUid({
|
|
750
|
+
listMyMemberships: () => client.listMyMemberships(),
|
|
751
|
+
listPersonEntities: () => client.entity.listByType("person"),
|
|
752
|
+
getEntity: async () => null,
|
|
753
|
+
});
|
|
754
|
+
const rows = await runSearch({
|
|
755
|
+
query,
|
|
756
|
+
companySlug: "personal",
|
|
757
|
+
personalMode: true,
|
|
758
|
+
personalUid,
|
|
759
|
+
vaultClient: client,
|
|
760
|
+
s3Factory: defaultS3Factory,
|
|
761
|
+
region: DEFAULT_COGNITO.region,
|
|
762
|
+
});
|
|
763
|
+
console.log(formatBrowseTable(rows));
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
if (!options.company) {
|
|
767
|
+
throw new Error("search: --company <slug> is required (or --personal to search your personal vault).");
|
|
768
|
+
}
|
|
769
|
+
await getCompanyUid(accessToken, options.company);
|
|
770
|
+
const rows = await runSearch({
|
|
771
|
+
query,
|
|
772
|
+
companySlug: options.company,
|
|
773
|
+
vaultClient: client,
|
|
774
|
+
s3Factory: defaultS3Factory,
|
|
775
|
+
region: DEFAULT_COGNITO.region,
|
|
776
|
+
});
|
|
777
|
+
console.log(formatBrowseTable(rows));
|
|
778
|
+
}
|
|
779
|
+
catch (err) {
|
|
780
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
781
|
+
process.exit(1);
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
filesCmd
|
|
785
|
+
.command("get <path>")
|
|
786
|
+
.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.")
|
|
787
|
+
.option("--into <dir>", "Write into this directory instead of the in-place companies/<slug>/ location. No pin is registered.")
|
|
788
|
+
.option("--company <slug>", "Company slug (defaults to the slug parsed from <path>).")
|
|
789
|
+
.option("--hq-root <path>", `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`, DEFAULT_HQ_ROOT)
|
|
790
|
+
.action(async (pathArg, options) => {
|
|
791
|
+
try {
|
|
792
|
+
const accessToken = await ensureCognitoToken();
|
|
793
|
+
const client = new VaultClient(buildVaultConfig(accessToken));
|
|
794
|
+
const slug = options.company ?? parseCompanySlugFromPath(pathArg);
|
|
795
|
+
if (options.company !== undefined) {
|
|
796
|
+
const fromPath = (() => {
|
|
797
|
+
try {
|
|
798
|
+
return parseCompanySlugFromPath(pathArg);
|
|
799
|
+
}
|
|
800
|
+
catch {
|
|
801
|
+
return undefined;
|
|
802
|
+
}
|
|
803
|
+
})();
|
|
804
|
+
if (fromPath && fromPath !== options.company) {
|
|
805
|
+
throw new Error(`--company '${options.company}' disagrees with path slug '${fromPath}'.`);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
await getCompanyUid(accessToken, slug);
|
|
809
|
+
const result = await runGet({
|
|
810
|
+
path: pathArg,
|
|
811
|
+
into: options.into,
|
|
812
|
+
hqRoot: options.hqRoot,
|
|
813
|
+
companySlug: slug,
|
|
814
|
+
vaultClient: client,
|
|
815
|
+
s3Factory: defaultS3Factory,
|
|
816
|
+
region: DEFAULT_COGNITO.region,
|
|
817
|
+
});
|
|
818
|
+
console.error(chalk.green("✓"), `Materialized ${result.filesWritten} file(s), ${result.bytesWritten} bytes.`);
|
|
819
|
+
if (result.pinned) {
|
|
820
|
+
console.error(chalk.dim(`Pinned ${result.pinned.companySlug}:${result.pinned.prefix} — survives scoped sync.`));
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
catch (err) {
|
|
824
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
825
|
+
process.exit(1);
|
|
826
|
+
}
|
|
827
|
+
});
|
|
594
828
|
}
|
|
595
829
|
//# sourceMappingURL=files-browse.js.map
|
|
596
|
-
//# debugId=
|
|
830
|
+
//# debugId=73529786-7369-5356-a6a8-4d55bd553147
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.30.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",
|
|
@@ -34,6 +34,11 @@ import {
|
|
|
34
34
|
toCompanyAnchored,
|
|
35
35
|
runBrowse,
|
|
36
36
|
runCat,
|
|
37
|
+
runSearch,
|
|
38
|
+
runGet,
|
|
39
|
+
readPins,
|
|
40
|
+
addPin,
|
|
41
|
+
pinFilePath,
|
|
37
42
|
runSharedWithMe,
|
|
38
43
|
formatSharedWithMeTable,
|
|
39
44
|
type BrowseVendResult,
|
|
@@ -831,3 +836,201 @@ describe("runSharedWithMe", () => {
|
|
|
831
836
|
expect(out).toContain("knowledge/");
|
|
832
837
|
});
|
|
833
838
|
});
|
|
839
|
+
|
|
840
|
+
// ── runSearch ─────────────────────────────────────────────────────────────
|
|
841
|
+
|
|
842
|
+
describe("runSearch", () => {
|
|
843
|
+
it("filters the company listing by case-insensitive substring on the key", async () => {
|
|
844
|
+
const { client, spies } = makeStubVaultClient({});
|
|
845
|
+
const { factory } = makeStubS3Factory({
|
|
846
|
+
listResponses: [
|
|
847
|
+
{
|
|
848
|
+
Contents: [
|
|
849
|
+
{ Key: "knowledge/Roadmap.md", Size: 1, LastModified: new Date() },
|
|
850
|
+
{ Key: "reports/q3.pdf", Size: 2, LastModified: new Date() },
|
|
851
|
+
{ Key: "knowledge/notes.md", Size: 3, LastModified: new Date() },
|
|
852
|
+
],
|
|
853
|
+
},
|
|
854
|
+
],
|
|
855
|
+
});
|
|
856
|
+
const rows = await runSearch({
|
|
857
|
+
query: "roadmap", // lower-case query matches mixed-case key
|
|
858
|
+
companySlug: "indigo",
|
|
859
|
+
vaultClient: client,
|
|
860
|
+
s3Factory: factory,
|
|
861
|
+
region: "us-east-1",
|
|
862
|
+
});
|
|
863
|
+
// Keys are re-anchored for display; only the matching one survives.
|
|
864
|
+
expect(rows.map((r) => r.key)).toEqual([
|
|
865
|
+
"companies/indigo/knowledge/Roadmap.md",
|
|
866
|
+
]);
|
|
867
|
+
// Vends via the multi-tenant route (inherited from runBrowse).
|
|
868
|
+
expect(spies.stsVend).toHaveBeenCalledTimes(1);
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
it("returns an empty array when nothing matches", async () => {
|
|
872
|
+
const { client } = makeStubVaultClient({});
|
|
873
|
+
const { factory } = makeStubS3Factory({
|
|
874
|
+
listResponses: [
|
|
875
|
+
{ Contents: [{ Key: "knowledge/a.md", Size: 1, LastModified: new Date() }] },
|
|
876
|
+
],
|
|
877
|
+
});
|
|
878
|
+
const rows = await runSearch({
|
|
879
|
+
query: "zzz-no-match",
|
|
880
|
+
companySlug: "indigo",
|
|
881
|
+
vaultClient: client,
|
|
882
|
+
s3Factory: factory,
|
|
883
|
+
region: "us-east-1",
|
|
884
|
+
});
|
|
885
|
+
expect(rows).toEqual([]);
|
|
886
|
+
});
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
// ── pin set (readPins / addPin) ─────────────────────────────────────────────
|
|
890
|
+
|
|
891
|
+
describe("pin set", () => {
|
|
892
|
+
it("readPins returns an empty set when the file is missing", () => {
|
|
893
|
+
expect(readPins(tmpRoot)).toEqual({ version: 1, pins: {} });
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
it("addPin creates the file, dedups, and sorts prefixes", () => {
|
|
897
|
+
addPin(tmpRoot, "indigo", "knowledge/");
|
|
898
|
+
addPin(tmpRoot, "indigo", "knowledge/"); // duplicate — ignored
|
|
899
|
+
addPin(tmpRoot, "indigo", "data/");
|
|
900
|
+
const pf = readPins(tmpRoot);
|
|
901
|
+
expect(pf.pins.indigo).toEqual(["data/", "knowledge/"]);
|
|
902
|
+
expect(fs.existsSync(pinFilePath(tmpRoot))).toBe(true);
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
it("addPin keeps per-company lists separate", () => {
|
|
906
|
+
addPin(tmpRoot, "indigo", "knowledge/");
|
|
907
|
+
addPin(tmpRoot, "acme", "docs/");
|
|
908
|
+
const pf = readPins(tmpRoot);
|
|
909
|
+
expect(pf.pins).toEqual({ indigo: ["knowledge/"], acme: ["docs/"] });
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
it("readPins tolerates a corrupt pins.json (→ fresh)", () => {
|
|
913
|
+
fs.mkdirSync(path.dirname(pinFilePath(tmpRoot)), { recursive: true });
|
|
914
|
+
fs.writeFileSync(pinFilePath(tmpRoot), "{ not valid json");
|
|
915
|
+
expect(readPins(tmpRoot)).toEqual({ version: 1, pins: {} });
|
|
916
|
+
});
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
// ── runGet ────────────────────────────────────────────────────────────────
|
|
920
|
+
|
|
921
|
+
function makeGetStubS3(opts: {
|
|
922
|
+
listKeys: Array<{ Key: string; Size: number }>;
|
|
923
|
+
bodyFor: (key: string) => Buffer;
|
|
924
|
+
}): { factory: S3ClientFactory; sendSpy: ReturnType<typeof vi.fn> } {
|
|
925
|
+
const sendSpy = vi.fn(async (cmd: unknown) => {
|
|
926
|
+
if (cmd instanceof ListObjectsV2Command) {
|
|
927
|
+
return {
|
|
928
|
+
Contents: opts.listKeys.map((k) => ({ ...k, LastModified: new Date() })),
|
|
929
|
+
} as ListObjectsV2CommandOutput;
|
|
930
|
+
}
|
|
931
|
+
if (cmd instanceof GetObjectCommand) {
|
|
932
|
+
const key = (cmd as GetObjectCommand).input.Key as string;
|
|
933
|
+
return {
|
|
934
|
+
Body: Readable.from(opts.bodyFor(key)),
|
|
935
|
+
} as unknown as GetObjectCommandOutput;
|
|
936
|
+
}
|
|
937
|
+
throw new Error(`unexpected command: ${cmd}`);
|
|
938
|
+
});
|
|
939
|
+
const factory = vi.fn(
|
|
940
|
+
() => ({ send: sendSpy }) as unknown as FilesBrowseS3Client,
|
|
941
|
+
) as unknown as S3ClientFactory;
|
|
942
|
+
return { factory, sendSpy };
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
describe("runGet", () => {
|
|
946
|
+
it("materializes a prefix in-place under companies/<slug>/ and registers a pin", async () => {
|
|
947
|
+
const { client, spies } = makeStubVaultClient({});
|
|
948
|
+
const { factory, sendSpy } = makeGetStubS3({
|
|
949
|
+
listKeys: [
|
|
950
|
+
{ Key: "knowledge/a.md", Size: 3 },
|
|
951
|
+
{ Key: "knowledge/sub/b.md", Size: 3 },
|
|
952
|
+
],
|
|
953
|
+
bodyFor: (k) => Buffer.from(k === "knowledge/a.md" ? "AAA" : "BBB"),
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
const result = await runGet({
|
|
957
|
+
path: "companies/indigo/knowledge/",
|
|
958
|
+
hqRoot: tmpRoot,
|
|
959
|
+
companySlug: "indigo",
|
|
960
|
+
vaultClient: client,
|
|
961
|
+
s3Factory: factory,
|
|
962
|
+
region: "us-east-1",
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
// Vends via the multi-tenant company route.
|
|
966
|
+
expect(spies.stsVend).toHaveBeenCalledWith({ companyUid: "cmp_indigo" });
|
|
967
|
+
expect(result.filesWritten).toBe(2);
|
|
968
|
+
|
|
969
|
+
// Files landed in place under companies/<slug>/.
|
|
970
|
+
expect(
|
|
971
|
+
fs.readFileSync(
|
|
972
|
+
path.join(tmpRoot, "companies", "indigo", "knowledge", "a.md"),
|
|
973
|
+
"utf-8",
|
|
974
|
+
),
|
|
975
|
+
).toBe("AAA");
|
|
976
|
+
expect(
|
|
977
|
+
fs.readFileSync(
|
|
978
|
+
path.join(tmpRoot, "companies", "indigo", "knowledge", "sub", "b.md"),
|
|
979
|
+
"utf-8",
|
|
980
|
+
),
|
|
981
|
+
).toBe("BBB");
|
|
982
|
+
|
|
983
|
+
// GetObject used company-relative keys (no companies/<slug>/ anchor).
|
|
984
|
+
const getKeys = sendSpy.mock.calls
|
|
985
|
+
.filter((c) => c[0] instanceof GetObjectCommand)
|
|
986
|
+
.map((c) => (c[0] as GetObjectCommand).input.Key);
|
|
987
|
+
expect(getKeys).toEqual(["knowledge/a.md", "knowledge/sub/b.md"]);
|
|
988
|
+
|
|
989
|
+
// Pin registered for the in-place prefix.
|
|
990
|
+
expect(readPins(tmpRoot).pins.indigo).toEqual(["knowledge/"]);
|
|
991
|
+
expect(result.pinned).toEqual({ companySlug: "indigo", prefix: "knowledge/" });
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
it("--into writes outside companies/ and registers NO pin", async () => {
|
|
995
|
+
const into = path.join(tmpRoot, "extract");
|
|
996
|
+
const { client } = makeStubVaultClient({});
|
|
997
|
+
const { factory } = makeGetStubS3({
|
|
998
|
+
listKeys: [{ Key: "knowledge/a.md", Size: 3 }],
|
|
999
|
+
bodyFor: () => Buffer.from("AAA"),
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
const result = await runGet({
|
|
1003
|
+
path: "companies/indigo/knowledge/",
|
|
1004
|
+
into,
|
|
1005
|
+
hqRoot: tmpRoot,
|
|
1006
|
+
companySlug: "indigo",
|
|
1007
|
+
vaultClient: client,
|
|
1008
|
+
s3Factory: factory,
|
|
1009
|
+
region: "us-east-1",
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
// Written relative to the requested prefix, under --into.
|
|
1013
|
+
expect(fs.readFileSync(path.join(into, "a.md"), "utf-8")).toBe("AAA");
|
|
1014
|
+
// No pin — --into is outside the sync envelope.
|
|
1015
|
+
expect(result.pinned).toBeUndefined();
|
|
1016
|
+
expect(fs.existsSync(pinFilePath(tmpRoot))).toBe(false);
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
it("throws when no objects exist under the path", async () => {
|
|
1020
|
+
const { client } = makeStubVaultClient({});
|
|
1021
|
+
const { factory } = makeGetStubS3({
|
|
1022
|
+
listKeys: [],
|
|
1023
|
+
bodyFor: () => Buffer.from(""),
|
|
1024
|
+
});
|
|
1025
|
+
await expect(
|
|
1026
|
+
runGet({
|
|
1027
|
+
path: "companies/indigo/nope/",
|
|
1028
|
+
hqRoot: tmpRoot,
|
|
1029
|
+
companySlug: "indigo",
|
|
1030
|
+
vaultClient: client,
|
|
1031
|
+
s3Factory: factory,
|
|
1032
|
+
region: "us-east-1",
|
|
1033
|
+
}),
|
|
1034
|
+
).rejects.toThrow(/No objects under/);
|
|
1035
|
+
});
|
|
1036
|
+
});
|
|
@@ -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
|
|
@@ -966,4 +1178,138 @@ export function registerFilesBrowseCommands(filesCmd: Command): void {
|
|
|
966
1178
|
process.exit(1);
|
|
967
1179
|
}
|
|
968
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
|
+
});
|
|
969
1315
|
}
|