@indigoai-us/hq-cli 5.28.0 → 5.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -17,25 +17,30 @@
|
|
|
17
17
|
* `hq sync` owns, and writing a peeked object there would
|
|
18
18
|
* silently re-import it into the sync envelope.
|
|
19
19
|
*
|
|
20
|
-
* Both subcommands vend
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
20
|
+
* Both subcommands vend through the multi-tenant STS routes —
|
|
21
|
+
* `VaultClient.sts.vend` (`/sts/vend`, company) and `.sts.vendSelf`
|
|
22
|
+
* (`/sts/vend-self`, personal). These resolve the caller's per-entity bucket
|
|
23
|
+
* and apply role/ACL scoping server-side (owner/admin → full access; member/
|
|
24
|
+
* guest → per-prefix). The legacy `POST /vend` is deliberately NOT used: it
|
|
25
|
+
* assumes a single static `BUCKET_ARN` that is unset in multi-tenant prod, so
|
|
26
|
+
* it builds an invalid policy and STS rejects it (`MalformedPolicyDocument`).
|
|
25
27
|
*
|
|
26
|
-
*
|
|
27
|
-
* `
|
|
28
|
-
*
|
|
29
|
-
* `
|
|
28
|
+
* Namespace note: company vault keys are company-relative (no
|
|
29
|
+
* `companies/<slug>/` prefix). The CLI speaks the anchored form for user
|
|
30
|
+
* familiarity and translates at the S3 boundary via `toBucketRelative` /
|
|
31
|
+
* `toCompanyAnchored`.
|
|
32
|
+
*
|
|
33
|
+
* Cross-package note: depends on the `VaultClient.sts.vend`/`.vendSelf`
|
|
34
|
+
* methods and `grantPathToPrefix` from hq-cloud.
|
|
30
35
|
*/
|
|
31
36
|
|
|
32
|
-
!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){}}();
|
|
33
38
|
import chalk from "chalk";
|
|
34
39
|
import * as fs from "node:fs";
|
|
35
40
|
import * as path from "node:path";
|
|
36
41
|
import { pipeline } from "node:stream/promises";
|
|
37
42
|
import { S3Client, ListObjectsV2Command, GetObjectCommand, } from "@aws-sdk/client-s3";
|
|
38
|
-
import { VaultClient, } from "@indigoai-us/hq-cloud";
|
|
43
|
+
import { VaultClient, grantPathToPrefix, } from "@indigoai-us/hq-cloud";
|
|
39
44
|
import { DEFAULT_HQ_ROOT, DEFAULT_COGNITO, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
|
|
40
45
|
import { getCompanyUid } from "../utils/vault-api.js";
|
|
41
46
|
import { resolveCanonicalPersonUid } from "./cloud.js";
|
|
@@ -54,17 +59,43 @@ export function parseCompanySlugFromPath(prefix) {
|
|
|
54
59
|
return parts[1];
|
|
55
60
|
}
|
|
56
61
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
62
|
+
* Translate a CLI `companies/<slug>/…` path into the company-relative S3 key
|
|
63
|
+
* the vault bucket actually stores. Company vault buckets are already
|
|
64
|
+
* company-scoped, so their keys carry NO `companies/<slug>/` prefix (e.g.
|
|
65
|
+
* `knowledge/foo.md`, not `companies/indigo/knowledge/foo.md`). The CLI speaks
|
|
66
|
+
* the anchored form for user familiarity; we strip the anchor at the S3
|
|
67
|
+
* boundary. A path without the anchor (or personal-mode, bucket-relative
|
|
68
|
+
* paths) passes through unchanged.
|
|
69
|
+
*/
|
|
70
|
+
export function toBucketRelative(pathOrPrefix, slug) {
|
|
71
|
+
const anchor = `companies/${slug}/`;
|
|
72
|
+
const normalized = pathOrPrefix.replace(/^\/+/, "");
|
|
73
|
+
return normalized.startsWith(anchor)
|
|
74
|
+
? normalized.slice(anchor.length)
|
|
75
|
+
: normalized;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Re-attach the `companies/<slug>/` anchor to a company-relative bucket key
|
|
79
|
+
* for display + `hq files cat` round-trip, so the CLI surface keeps speaking
|
|
80
|
+
* the anchored form the user passed in.
|
|
81
|
+
*/
|
|
82
|
+
export function toCompanyAnchored(bucketRelKey, slug) {
|
|
83
|
+
return `companies/${slug}/${bucketRelKey}`;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Classify a single company-relative S3 key against the caller's
|
|
87
|
+
* (already-normalized) explicit-grant prefixes. Any prefix that covers the
|
|
88
|
+
* key contributes `shared-with-you`; otherwise the key is visible only via
|
|
89
|
+
* the owner/admin role-bypass the `/sts/vend` policy applied. An empty-string
|
|
90
|
+
* prefix is a company-wide grant and matches everything.
|
|
60
91
|
*
|
|
61
|
-
* Grant
|
|
62
|
-
* `
|
|
63
|
-
*
|
|
92
|
+
* Grant `path`s arrive in inconsistent/glob form; the caller normalizes them
|
|
93
|
+
* to company-relative `startsWith` prefixes via `grantPathToPrefix` before
|
|
94
|
+
* calling this — keeping this helper pure and trivially testable.
|
|
64
95
|
*/
|
|
65
|
-
export function classifyAclSource(
|
|
66
|
-
for (const
|
|
67
|
-
if (
|
|
96
|
+
export function classifyAclSource(bucketRelKey, grantPrefixes) {
|
|
97
|
+
for (const p of grantPrefixes) {
|
|
98
|
+
if (p === "" || bucketRelKey.startsWith(p))
|
|
68
99
|
return "shared-with-you";
|
|
69
100
|
}
|
|
70
101
|
return "role-bypass";
|
|
@@ -116,8 +147,10 @@ export function formatBrowseTable(rows) {
|
|
|
116
147
|
*
|
|
117
148
|
* 1. Parse slug from prefix (or use override).
|
|
118
149
|
* 2. Resolve companyUid + bucketName via VaultClient.entity.
|
|
119
|
-
* 3. Vend
|
|
120
|
-
*
|
|
150
|
+
* 3. Vend read creds via the multi-tenant STS route (`/sts/vend` company,
|
|
151
|
+
* `/sts/vend-self` personal) — resolves the per-entity bucket + role/ACL.
|
|
152
|
+
* 4. Construct S3Client from vended creds, paginate ListObjectsV2 over the
|
|
153
|
+
* company-relative key space.
|
|
121
154
|
* 5. Fetch explicit grants once, classify each key.
|
|
122
155
|
*
|
|
123
156
|
* Pure-ish: no console output, no process.exit — caller renders + exits.
|
|
@@ -127,10 +160,11 @@ export async function runBrowse(input) {
|
|
|
127
160
|
// Branch by mode. Company mode parses slug from path and looks up by
|
|
128
161
|
// namespace; personal mode resolves the entity directly by the supplied
|
|
129
162
|
// person UID and skips the slug + grants machinery (a person bucket has
|
|
130
|
-
// no grants graph — the owner is the only principal).
|
|
131
|
-
// identical for both modes once we have the entity in hand.
|
|
163
|
+
// no grants graph — the owner is the only principal).
|
|
132
164
|
let bucket;
|
|
133
165
|
let entityUid;
|
|
166
|
+
let slug;
|
|
167
|
+
let vend;
|
|
134
168
|
if (personalMode) {
|
|
135
169
|
if (!input.personalUid) {
|
|
136
170
|
throw new Error("runBrowse: personalMode requires personalUid. Resolve via " +
|
|
@@ -142,9 +176,10 @@ export async function runBrowse(input) {
|
|
|
142
176
|
}
|
|
143
177
|
entityUid = entity.uid;
|
|
144
178
|
bucket = entity.bucketName;
|
|
179
|
+
vend = await vaultClient.sts.vendSelf({ personUid: entityUid });
|
|
145
180
|
}
|
|
146
181
|
else {
|
|
147
|
-
|
|
182
|
+
slug = input.companySlug ?? parseCompanySlugFromPath(pathPrefix);
|
|
148
183
|
const entity = await vaultClient.entity.findInMyNamespace("company", slug);
|
|
149
184
|
if (!entity) {
|
|
150
185
|
throw new Error(`No company found for slug '${slug}' in your namespace. Confirm you have an active membership.`);
|
|
@@ -154,17 +189,11 @@ export async function runBrowse(input) {
|
|
|
154
189
|
}
|
|
155
190
|
entityUid = entity.uid;
|
|
156
191
|
bucket = entity.bucketName;
|
|
192
|
+
// Multi-tenant vend: the server resolves this company's bucket + applies
|
|
193
|
+
// owner/admin role-bypass (full access) or member/guest ACL scoping. The
|
|
194
|
+
// legacy `POST /vend` is unused here — see FilesBrowseVaultClient docs.
|
|
195
|
+
vend = await vaultClient.sts.vend({ companyUid: entityUid });
|
|
157
196
|
}
|
|
158
|
-
// Distinct vend call from sync — `purpose: 'browse'` opts the request
|
|
159
|
-
// into the role-bypass-allowed code path on the server (US-009). The
|
|
160
|
-
// personal mode vends against the person entity which is owner-only by
|
|
161
|
-
// construction; the vend response shape is identical so downstream
|
|
162
|
-
// S3Client construction doesn't branch.
|
|
163
|
-
const vend = await vaultClient.vend({
|
|
164
|
-
paths: [pathPrefix],
|
|
165
|
-
operations: "read-only",
|
|
166
|
-
purpose: "browse",
|
|
167
|
-
});
|
|
168
197
|
const s3 = s3Factory({
|
|
169
198
|
region,
|
|
170
199
|
credentials: {
|
|
@@ -173,19 +202,26 @@ export async function runBrowse(input) {
|
|
|
173
202
|
sessionToken: vend.credentials.sessionToken,
|
|
174
203
|
},
|
|
175
204
|
});
|
|
205
|
+
// Company vault keys are company-relative (no `companies/<slug>/` prefix), so
|
|
206
|
+
// translate the CLI's anchored prefix into the bucket-relative form before
|
|
207
|
+
// listing. Personal-mode paths are already bucket-relative.
|
|
208
|
+
const listPrefix = personalMode || slug === undefined
|
|
209
|
+
? pathPrefix
|
|
210
|
+
: toBucketRelative(pathPrefix, slug);
|
|
176
211
|
// Pull the caller's explicit-grant graph once so per-key classification
|
|
177
|
-
// is O(grants) without N round-trips.
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
|
|
212
|
+
// is O(grants) without N round-trips. Grant `path`s are normalized to
|
|
213
|
+
// company-relative `startsWith` prefixes (matching the bucket key space)
|
|
214
|
+
// via `grantPathToPrefix`. Skipped in personal mode — the grants graph is a
|
|
215
|
+
// company concept; a person bucket marks every row `"personal-vault"`.
|
|
216
|
+
const grantPrefixes = personalMode || slug === undefined
|
|
181
217
|
? []
|
|
182
|
-
: await vaultClient.listMyExplicitGrants(entityUid);
|
|
218
|
+
: (await vaultClient.listMyExplicitGrants(entityUid)).map((g) => grantPathToPrefix(g.path, slug));
|
|
183
219
|
const rows = [];
|
|
184
220
|
let continuationToken;
|
|
185
221
|
do {
|
|
186
222
|
const resp = (await s3.send(new ListObjectsV2Command({
|
|
187
223
|
Bucket: bucket,
|
|
188
|
-
Prefix:
|
|
224
|
+
Prefix: listPrefix,
|
|
189
225
|
ContinuationToken: continuationToken,
|
|
190
226
|
})));
|
|
191
227
|
for (const obj of resp.Contents ?? []) {
|
|
@@ -194,11 +230,17 @@ export async function runBrowse(input) {
|
|
|
194
230
|
// Skip S3 "directory marker" objects (0-byte, trailing slash).
|
|
195
231
|
if (obj.Key.endsWith("/") && (obj.Size ?? 0) === 0)
|
|
196
232
|
continue;
|
|
233
|
+
// `obj.Key` is company-relative. Classify in that space, then re-anchor
|
|
234
|
+
// for display so the CLI keeps speaking `companies/<slug>/...`.
|
|
197
235
|
rows.push({
|
|
198
|
-
key:
|
|
236
|
+
key: personalMode || slug === undefined
|
|
237
|
+
? obj.Key
|
|
238
|
+
: toCompanyAnchored(obj.Key, slug),
|
|
199
239
|
size: obj.Size ?? 0,
|
|
200
240
|
lastModified: obj.LastModified,
|
|
201
|
-
aclSource: personalMode
|
|
241
|
+
aclSource: personalMode
|
|
242
|
+
? "personal-vault"
|
|
243
|
+
: classifyAclSource(obj.Key, grantPrefixes),
|
|
202
244
|
});
|
|
203
245
|
}
|
|
204
246
|
continuationToken = resp.NextContinuationToken ?? undefined;
|
|
@@ -221,6 +263,8 @@ export async function runCat(input) {
|
|
|
221
263
|
// Same branch logic as runBrowse — see that function's doc-block for
|
|
222
264
|
// the personal-vs-company rationale.
|
|
223
265
|
let bucket;
|
|
266
|
+
let s3Key;
|
|
267
|
+
let vend;
|
|
224
268
|
if (personalMode) {
|
|
225
269
|
if (!input.personalUid) {
|
|
226
270
|
throw new Error("runCat: personalMode requires personalUid. Resolve via " +
|
|
@@ -231,6 +275,8 @@ export async function runCat(input) {
|
|
|
231
275
|
throw new Error(`Personal entity '${input.personalUid}' has no provisioned bucket.`);
|
|
232
276
|
}
|
|
233
277
|
bucket = entity.bucketName;
|
|
278
|
+
s3Key = key; // personal-mode keys are already bucket-relative
|
|
279
|
+
vend = await vaultClient.sts.vendSelf({ personUid: entity.uid });
|
|
234
280
|
}
|
|
235
281
|
else {
|
|
236
282
|
const slug = input.companySlug ?? parseCompanySlugFromPath(key);
|
|
@@ -242,12 +288,10 @@ export async function runCat(input) {
|
|
|
242
288
|
throw new Error(`Company '${slug}' (${entity.uid}) has no provisioned bucket.`);
|
|
243
289
|
}
|
|
244
290
|
bucket = entity.bucketName;
|
|
291
|
+
// Translate the anchored CLI key into the company-relative bucket key.
|
|
292
|
+
s3Key = toBucketRelative(key, slug);
|
|
293
|
+
vend = await vaultClient.sts.vend({ companyUid: entity.uid });
|
|
245
294
|
}
|
|
246
|
-
const vend = await vaultClient.vend({
|
|
247
|
-
paths: [key],
|
|
248
|
-
operations: "read-only",
|
|
249
|
-
purpose: "browse",
|
|
250
|
-
});
|
|
251
295
|
const s3 = s3Factory({
|
|
252
296
|
region,
|
|
253
297
|
credentials: {
|
|
@@ -256,7 +300,7 @@ export async function runCat(input) {
|
|
|
256
300
|
sessionToken: vend.credentials.sessionToken,
|
|
257
301
|
},
|
|
258
302
|
});
|
|
259
|
-
const resp = (await s3.send(new GetObjectCommand({ Bucket: bucket, Key:
|
|
303
|
+
const resp = (await s3.send(new GetObjectCommand({ Bucket: bucket, Key: s3Key })));
|
|
260
304
|
if (!resp.Body) {
|
|
261
305
|
throw new Error(`GetObject for '${key}' returned no body.`);
|
|
262
306
|
}
|
|
@@ -356,6 +400,148 @@ export function formatSharedWithMeTable(rows) {
|
|
|
356
400
|
...data.map(renderRow),
|
|
357
401
|
].join("\n");
|
|
358
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
|
+
}
|
|
359
545
|
// ── CLI registration ────────────────────────────────────────────────────────
|
|
360
546
|
const defaultS3Factory = ({ region, credentials }) => new S3Client({ region, credentials });
|
|
361
547
|
/**
|
|
@@ -547,6 +733,98 @@ export function registerFilesBrowseCommands(filesCmd) {
|
|
|
547
733
|
process.exit(1);
|
|
548
734
|
}
|
|
549
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
|
+
});
|
|
550
828
|
}
|
|
551
829
|
//# sourceMappingURL=files-browse.js.map
|
|
552
|
-
//# 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",
|