@indigoai-us/hq-cli 5.17.0 → 5.18.1

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,561 @@
1
+ /**
2
+ * `hq files browse <path>` + `hq files cat <path> [--out <file>]` (US-008).
3
+ *
4
+ * Peek at a company's vault files **without** ever materialising them under
5
+ * `companies/{co}/` in the local HQ tree. Distinct from the sync path:
6
+ *
7
+ * - `browse` — `ListObjectsV2` under the given prefix, prints
8
+ * `{key, size, lastModified, aclSource}` rows. The
9
+ * `aclSource` hint distinguishes prefixes the caller can
10
+ * see via an EXPLICIT grant (`shared-with-you`) from
11
+ * prefixes they can see only because owner/admin
12
+ * role-bypass widened the vended policy (`role-bypass`).
13
+ * - `cat` — `GetObject`, stream the body to stdout. With `--out
14
+ * <file>` write the body to a path the user picked, but
15
+ * only after a bright-line guard refuses any destination
16
+ * inside `<hqRoot>/companies/` — that's the exact tree
17
+ * `hq sync` owns, and writing a peeked object there would
18
+ * silently re-import it into the sync envelope.
19
+ *
20
+ * Both subcommands vend via the new `purpose: 'browse'` path
21
+ * (`VaultClient.vend`) shipped in hq-cloud US-009. The server treats that
22
+ * purpose as the role-bypass-allowed surface — sync vends NEVER widen, so
23
+ * keeping browse on its own vend call is the acceptance-criteria-1
24
+ * separation we need.
25
+ *
26
+ * Cross-package note: depends on `VendInput`/`VendResult` + the
27
+ * `VaultClient.vend` method from hq-cloud US-009 (commit 2f790c5).
28
+ * hq-cli pins `@indigoai-us/hq-cloud` to `file:../hq-cloud` via
29
+ * `pnpm.overrides` until that release ships to npm.
30
+ */
31
+
32
+ import { Command } from "commander";
33
+ import chalk from "chalk";
34
+ import * as fs from "node:fs";
35
+ import * as path from "node:path";
36
+ import { Readable } from "node:stream";
37
+ import { pipeline } from "node:stream/promises";
38
+
39
+ import {
40
+ S3Client,
41
+ ListObjectsV2Command,
42
+ GetObjectCommand,
43
+ type ListObjectsV2CommandOutput,
44
+ type GetObjectCommandOutput,
45
+ } from "@aws-sdk/client-s3";
46
+
47
+ import {
48
+ VaultClient,
49
+ type VendResult,
50
+ type ExplicitGrant,
51
+ } from "@indigoai-us/hq-cloud";
52
+
53
+ import {
54
+ DEFAULT_HQ_ROOT,
55
+ DEFAULT_COGNITO,
56
+ ensureCognitoToken,
57
+ buildVaultConfig,
58
+ } from "../utils/cognito-session.js";
59
+ import { getCompanyUid } from "../utils/vault-api.js";
60
+
61
+ // ── Types ───────────────────────────────────────────────────────────────────
62
+
63
+ /**
64
+ * Subset of `VaultClient` this command actually uses — exposed so tests
65
+ * can stub vend + grants without standing up a real `VaultClient`.
66
+ */
67
+ export interface FilesBrowseVaultClient {
68
+ vend(input: {
69
+ paths: string[];
70
+ operations: "read-only" | "read-write" | "staged-write";
71
+ purpose: "sync" | "browse";
72
+ duration?: number;
73
+ }): Promise<VendResult>;
74
+ listMyExplicitGrants(companyUid: string): Promise<ExplicitGrant[]>;
75
+ entity: {
76
+ get(uid: string): Promise<{ uid: string; slug: string; name?: string; bucketName?: string }>;
77
+ findInMyNamespace(
78
+ type: string,
79
+ slug: string,
80
+ ): Promise<{ uid: string; slug: string; name?: string; bucketName?: string } | null>;
81
+ };
82
+ }
83
+
84
+ /** Subset of `S3Client` this command actually uses — for test stubs. */
85
+ export interface FilesBrowseS3Client {
86
+ send(
87
+ cmd: ListObjectsV2Command,
88
+ ): Promise<ListObjectsV2CommandOutput>;
89
+ send(cmd: GetObjectCommand): Promise<GetObjectCommandOutput>;
90
+ }
91
+
92
+ /** Factory for an S3 client given vended credentials. Injectable for tests. */
93
+ export type S3ClientFactory = (input: {
94
+ region: string;
95
+ credentials: {
96
+ accessKeyId: string;
97
+ secretAccessKey: string;
98
+ sessionToken: string;
99
+ };
100
+ }) => FilesBrowseS3Client;
101
+
102
+ /** ACL provenance for a single listed key. */
103
+ export type AclSource = "shared-with-you" | "role-bypass";
104
+
105
+ export interface BrowseRow {
106
+ key: string;
107
+ size: number;
108
+ lastModified: Date | undefined;
109
+ aclSource: AclSource;
110
+ }
111
+
112
+ // ── Pure helpers ────────────────────────────────────────────────────────────
113
+
114
+ /**
115
+ * Parse the company slug from a vault prefix. Vault paths are anchored at
116
+ * `companies/<slug>/...`; anything else is rejected so we never try to
117
+ * browse a non-company tree (e.g. `personal/`) with a company-vend.
118
+ */
119
+ export function parseCompanySlugFromPath(prefix: string): string {
120
+ const normalized = prefix.replace(/^\/+/, "");
121
+ const parts = normalized.split("/");
122
+ if (parts.length < 2 || parts[0] !== "companies" || !parts[1]) {
123
+ throw new Error(
124
+ `Invalid browse path '${prefix}'. Expected a path starting with 'companies/<slug>/'.`,
125
+ );
126
+ }
127
+ return parts[1];
128
+ }
129
+
130
+ /**
131
+ * Classify a single S3 key against the caller's explicit-grant list. Any
132
+ * grant whose `path` is a prefix of the key contributes `shared-with-you`;
133
+ * otherwise the key is only visible via role-bypass on the vend call.
134
+ *
135
+ * Grant paths and S3 keys live in the same canonical form ("companies/<slug>/…");
136
+ * `coalescePrefixes` would shrink the list further but isn't required for
137
+ * correctness — `startsWith` already short-circuits on the first match.
138
+ */
139
+ export function classifyAclSource(
140
+ key: string,
141
+ grants: ExplicitGrant[],
142
+ ): AclSource {
143
+ for (const g of grants) {
144
+ if (g.path && key.startsWith(g.path)) return "shared-with-you";
145
+ }
146
+ return "role-bypass";
147
+ }
148
+
149
+ /**
150
+ * Bright-line guard for `--out`: refuse to write any byte beneath
151
+ * `<hqRoot>/companies/`. We do NOT enumerate `companies/manifest.yaml`
152
+ * slug-by-slug — `companies/` is the entire surface hq-sync owns, so a
153
+ * containment check on that parent suffices and avoids drift with the
154
+ * manifest file. Returns the resolved absolute output path on success;
155
+ * throws when the destination would land inside the protected tree.
156
+ */
157
+ export function assertOutPathOutsideCompanies(
158
+ outPath: string,
159
+ hqRoot: string,
160
+ ): string {
161
+ const absOut = path.resolve(outPath);
162
+ const protectedRoot = path.resolve(hqRoot, "companies") + path.sep;
163
+ if (absOut === path.resolve(hqRoot, "companies") || absOut.startsWith(protectedRoot)) {
164
+ throw new Error(
165
+ `Refusing to write '${absOut}': bytes peeked via 'hq files cat' must not land under ` +
166
+ `'${path.resolve(hqRoot, "companies")}'. Pick an --out path outside the HQ companies tree.`,
167
+ );
168
+ }
169
+ return absOut;
170
+ }
171
+
172
+ /**
173
+ * Render a browse listing as a padded table. Mirrors the chalk + padEnd
174
+ * pattern used by `hq sync mode --show` so the CLI surface stays
175
+ * stylistically consistent.
176
+ */
177
+ export function formatBrowseTable(rows: BrowseRow[]): string {
178
+ if (rows.length === 0) {
179
+ return "No objects under that prefix.";
180
+ }
181
+ const cols = ["KEY", "SIZE", "MODIFIED", "ACL"];
182
+ const data = rows.map((r) => [
183
+ r.key,
184
+ String(r.size),
185
+ r.lastModified ? r.lastModified.toISOString() : "—",
186
+ r.aclSource,
187
+ ]);
188
+ const widths = cols.map((c, i) =>
189
+ Math.max(c.length, ...data.map((row) => row[i].length)),
190
+ );
191
+ const renderRow = (row: string[]): string =>
192
+ row.map((cell, i) => cell.padEnd(widths[i])).join(" ");
193
+ const lines = [
194
+ chalk.bold(renderRow(cols)),
195
+ chalk.dim(renderRow(widths.map((w) => "─".repeat(w)))),
196
+ ...data.map(renderRow),
197
+ ];
198
+ return lines.join("\n");
199
+ }
200
+
201
+ // ── Orchestrators ───────────────────────────────────────────────────────────
202
+
203
+ export interface RunBrowseInput {
204
+ /** Vault path prefix, e.g. `companies/indigo/scratch/`. */
205
+ pathPrefix: string;
206
+ /** Caller-overridden company slug (defaults to slug parsed from path). */
207
+ companySlug?: string;
208
+ vaultClient: FilesBrowseVaultClient;
209
+ s3Factory: S3ClientFactory;
210
+ region: string;
211
+ }
212
+
213
+ export interface RunBrowseResult {
214
+ rows: BrowseRow[];
215
+ vend: VendResult;
216
+ }
217
+
218
+ /**
219
+ * `hq files browse <path>` orchestrator.
220
+ *
221
+ * 1. Parse slug from prefix (or use override).
222
+ * 2. Resolve companyUid + bucketName via VaultClient.entity.
223
+ * 3. Vend with `purpose: 'browse'`, `operations: 'read-only'`, paths: [prefix].
224
+ * 4. Construct S3Client from vended creds, paginate ListObjectsV2.
225
+ * 5. Fetch explicit grants once, classify each key.
226
+ *
227
+ * Pure-ish: no console output, no process.exit — caller renders + exits.
228
+ */
229
+ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult> {
230
+ const { pathPrefix, vaultClient, s3Factory, region } = input;
231
+ const slug = input.companySlug ?? parseCompanySlugFromPath(pathPrefix);
232
+
233
+ const entity = await vaultClient.entity.findInMyNamespace("company", slug);
234
+ if (!entity) {
235
+ throw new Error(
236
+ `No company found for slug '${slug}' in your namespace. Confirm you have an active membership.`,
237
+ );
238
+ }
239
+ if (!entity.bucketName) {
240
+ throw new Error(
241
+ `Company '${slug}' (${entity.uid}) has no provisioned bucket.`,
242
+ );
243
+ }
244
+ const companyUid = entity.uid;
245
+ const bucket = entity.bucketName;
246
+
247
+ // Distinct vend call from sync — `purpose: 'browse'` opts the request
248
+ // into the role-bypass-allowed code path on the server (US-009).
249
+ const vend = await vaultClient.vend({
250
+ paths: [pathPrefix],
251
+ operations: "read-only",
252
+ purpose: "browse",
253
+ });
254
+
255
+ const s3 = s3Factory({
256
+ region,
257
+ credentials: {
258
+ accessKeyId: vend.credentials.accessKeyId,
259
+ secretAccessKey: vend.credentials.secretAccessKey,
260
+ sessionToken: vend.credentials.sessionToken,
261
+ },
262
+ });
263
+
264
+ // Pull the caller's explicit-grant graph once so per-key classification
265
+ // is O(grants) without N round-trips.
266
+ const grants = await vaultClient.listMyExplicitGrants(companyUid);
267
+
268
+ const rows: BrowseRow[] = [];
269
+ let continuationToken: string | undefined;
270
+ do {
271
+ const resp = (await s3.send(
272
+ new ListObjectsV2Command({
273
+ Bucket: bucket,
274
+ Prefix: pathPrefix,
275
+ ContinuationToken: continuationToken,
276
+ }),
277
+ )) as ListObjectsV2CommandOutput;
278
+
279
+ for (const obj of resp.Contents ?? []) {
280
+ if (!obj.Key) continue;
281
+ // Skip S3 "directory marker" objects (0-byte, trailing slash).
282
+ if (obj.Key.endsWith("/") && (obj.Size ?? 0) === 0) continue;
283
+
284
+ rows.push({
285
+ key: obj.Key,
286
+ size: obj.Size ?? 0,
287
+ lastModified: obj.LastModified,
288
+ aclSource: classifyAclSource(obj.Key, grants),
289
+ });
290
+ }
291
+
292
+ continuationToken = resp.NextContinuationToken ?? undefined;
293
+ } while (continuationToken);
294
+
295
+ return { rows, vend };
296
+ }
297
+
298
+ export interface RunCatInput {
299
+ /** Single vault key, e.g. `companies/indigo/scratch/foo.txt`. */
300
+ key: string;
301
+ /**
302
+ * Where to write the body. `undefined` ⇒ stdout. Bright-line-guarded
303
+ * against `<hqRoot>/companies/` by `assertOutPathOutsideCompanies`.
304
+ */
305
+ out?: string;
306
+ hqRoot: string;
307
+ companySlug?: string;
308
+ vaultClient: FilesBrowseVaultClient;
309
+ s3Factory: S3ClientFactory;
310
+ region: string;
311
+ /** Destination stream for the stdout path. Injectable for tests. */
312
+ stdout?: NodeJS.WritableStream;
313
+ }
314
+
315
+ export interface RunCatResult {
316
+ bytesWritten: number;
317
+ destination: { kind: "stdout" } | { kind: "file"; absPath: string };
318
+ vend: VendResult;
319
+ }
320
+
321
+ /**
322
+ * `hq files cat <path>` orchestrator. Vends with `purpose: 'browse'`, then
323
+ * streams the object body either to stdout or to `--out` (after the
324
+ * containment guard). Refuses ahead of any I/O when `--out` is unsafe.
325
+ */
326
+ export async function runCat(input: RunCatInput): Promise<RunCatResult> {
327
+ const { key, vaultClient, s3Factory, region, hqRoot } = input;
328
+ const slug = input.companySlug ?? parseCompanySlugFromPath(key);
329
+
330
+ // Acceptance 5: refuse BEFORE vending — no point pulling credentials
331
+ // for a request we're already going to abort.
332
+ let absOut: string | undefined;
333
+ if (input.out !== undefined) {
334
+ absOut = assertOutPathOutsideCompanies(input.out, hqRoot);
335
+ }
336
+
337
+ const entity = await vaultClient.entity.findInMyNamespace("company", slug);
338
+ if (!entity) {
339
+ throw new Error(
340
+ `No company found for slug '${slug}' in your namespace. Confirm you have an active membership.`,
341
+ );
342
+ }
343
+ if (!entity.bucketName) {
344
+ throw new Error(
345
+ `Company '${slug}' (${entity.uid}) has no provisioned bucket.`,
346
+ );
347
+ }
348
+
349
+ const vend = await vaultClient.vend({
350
+ paths: [key],
351
+ operations: "read-only",
352
+ purpose: "browse",
353
+ });
354
+
355
+ const s3 = s3Factory({
356
+ region,
357
+ credentials: {
358
+ accessKeyId: vend.credentials.accessKeyId,
359
+ secretAccessKey: vend.credentials.secretAccessKey,
360
+ sessionToken: vend.credentials.sessionToken,
361
+ },
362
+ });
363
+
364
+ const resp = (await s3.send(
365
+ new GetObjectCommand({ Bucket: entity.bucketName, Key: key }),
366
+ )) as GetObjectCommandOutput;
367
+
368
+ if (!resp.Body) {
369
+ throw new Error(`GetObject for '${key}' returned no body.`);
370
+ }
371
+
372
+ // The SDK Body type in node is a Readable (it can also be a
373
+ // ReadableStream/Blob in other runtimes but those don't apply to the
374
+ // CLI). Cast through unknown so the type checker accepts the narrowing.
375
+ const body = resp.Body as unknown as Readable;
376
+ let bytesWritten = 0;
377
+ body.on("data", (chunk: Buffer | string) => {
378
+ bytesWritten += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk);
379
+ });
380
+
381
+ if (absOut !== undefined) {
382
+ // Ensure the parent directory exists — but ONLY if it's also outside
383
+ // the protected tree (the guard already validated absOut itself; the
384
+ // parent of an outside-tree path is by definition outside too).
385
+ fs.mkdirSync(path.dirname(absOut), { recursive: true });
386
+ await pipeline(body, fs.createWriteStream(absOut));
387
+ return {
388
+ bytesWritten,
389
+ destination: { kind: "file", absPath: absOut },
390
+ vend,
391
+ };
392
+ }
393
+
394
+ await pipeline(body, input.stdout ?? process.stdout);
395
+ return { bytesWritten, destination: { kind: "stdout" }, vend };
396
+ }
397
+
398
+ // ── CLI registration ────────────────────────────────────────────────────────
399
+
400
+ const defaultS3Factory: S3ClientFactory = ({ region, credentials }) =>
401
+ new S3Client({ region, credentials });
402
+
403
+ interface FilesBrowseCliOptions {
404
+ company?: string;
405
+ hqRoot: string;
406
+ }
407
+
408
+ interface FilesCatCliOptions extends FilesBrowseCliOptions {
409
+ out?: string;
410
+ }
411
+
412
+ /**
413
+ * Wire `hq files browse` + `hq files cat` onto an existing `files`
414
+ * Commander group. `registerFilesCommand` in files.ts builds the group
415
+ * and registers `share`/`unshare`/`acl`; this function appends the two
416
+ * new browse-vs-sync subcommands so they share the `--company` switch.
417
+ */
418
+ export function registerFilesBrowseCommands(filesCmd: Command): void {
419
+ filesCmd
420
+ .command("browse <path>")
421
+ .description(
422
+ "List vault objects under <path> without syncing them locally. Uses the browse-vend path (role-bypass allowed).",
423
+ )
424
+ .option(
425
+ "--company <slug>",
426
+ "Company slug (defaults to the slug parsed from <path>)",
427
+ )
428
+ .option(
429
+ "--hq-root <path>",
430
+ `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`,
431
+ DEFAULT_HQ_ROOT,
432
+ )
433
+ .action(async (pathArg: string, options: FilesBrowseCliOptions) => {
434
+ try {
435
+ const accessToken = await ensureCognitoToken();
436
+ const vaultConfig = buildVaultConfig(accessToken);
437
+ const client = new VaultClient(vaultConfig);
438
+
439
+ // Resolve slug — CLI flag wins, otherwise parse from path arg.
440
+ const slug = options.company ?? parseCompanySlugFromPath(pathArg);
441
+
442
+ // If the user passed `--company` AND the path doesn't begin with
443
+ // companies/<that-slug>/, refuse — we'd otherwise vend creds for
444
+ // one company and list keys from another tree, which never makes
445
+ // sense (defense in depth against operator typos).
446
+ if (options.company !== undefined) {
447
+ const fromPath = (() => {
448
+ try {
449
+ return parseCompanySlugFromPath(pathArg);
450
+ } catch {
451
+ return undefined;
452
+ }
453
+ })();
454
+ if (fromPath && fromPath !== options.company) {
455
+ throw new Error(
456
+ `--company '${options.company}' disagrees with path slug '${fromPath}'.`,
457
+ );
458
+ }
459
+ }
460
+
461
+ // Confirm the slug resolves to a known membership — same pattern
462
+ // sync-mode/sync-narrow use to surface "you're not a member" early.
463
+ await getCompanyUid(accessToken, slug);
464
+
465
+ const result = await runBrowse({
466
+ pathPrefix: pathArg,
467
+ companySlug: slug,
468
+ vaultClient: client,
469
+ s3Factory: defaultS3Factory,
470
+ region: DEFAULT_COGNITO.region,
471
+ });
472
+
473
+ console.log(formatBrowseTable(result.rows));
474
+ if (result.rows.length > 0) {
475
+ const bypassCount = result.rows.filter(
476
+ (r) => r.aclSource === "role-bypass",
477
+ ).length;
478
+ if (bypassCount > 0) {
479
+ console.log("");
480
+ console.log(
481
+ chalk.yellow(
482
+ `Heads-up: ${bypassCount} object(s) visible only via role-bypass (no explicit grant covers them).`,
483
+ ),
484
+ );
485
+ }
486
+ }
487
+ } catch (err) {
488
+ console.error(
489
+ chalk.red("Error:"),
490
+ err instanceof Error ? err.message : String(err),
491
+ );
492
+ process.exit(1);
493
+ }
494
+ });
495
+
496
+ filesCmd
497
+ .command("cat <path>")
498
+ .description(
499
+ "Stream a single vault object to stdout (or --out <file>) without syncing it. Uses the browse-vend path.",
500
+ )
501
+ .option(
502
+ "--out <file>",
503
+ "Write the object body to <file> instead of stdout. Refused under <hqRoot>/companies/.",
504
+ )
505
+ .option(
506
+ "--company <slug>",
507
+ "Company slug (defaults to the slug parsed from <path>)",
508
+ )
509
+ .option(
510
+ "--hq-root <path>",
511
+ `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`,
512
+ DEFAULT_HQ_ROOT,
513
+ )
514
+ .action(async (keyArg: string, options: FilesCatCliOptions) => {
515
+ try {
516
+ const accessToken = await ensureCognitoToken();
517
+ const vaultConfig = buildVaultConfig(accessToken);
518
+ const client = new VaultClient(vaultConfig);
519
+
520
+ const slug = options.company ?? parseCompanySlugFromPath(keyArg);
521
+ if (options.company !== undefined) {
522
+ const fromPath = (() => {
523
+ try {
524
+ return parseCompanySlugFromPath(keyArg);
525
+ } catch {
526
+ return undefined;
527
+ }
528
+ })();
529
+ if (fromPath && fromPath !== options.company) {
530
+ throw new Error(
531
+ `--company '${options.company}' disagrees with path slug '${fromPath}'.`,
532
+ );
533
+ }
534
+ }
535
+ await getCompanyUid(accessToken, slug);
536
+
537
+ const result = await runCat({
538
+ key: keyArg,
539
+ out: options.out,
540
+ hqRoot: options.hqRoot,
541
+ companySlug: slug,
542
+ vaultClient: client,
543
+ s3Factory: defaultS3Factory,
544
+ region: DEFAULT_COGNITO.region,
545
+ });
546
+
547
+ if (result.destination.kind === "file") {
548
+ console.error(
549
+ chalk.green("✓"),
550
+ `Wrote ${result.bytesWritten} bytes to ${result.destination.absPath}`,
551
+ );
552
+ }
553
+ } catch (err) {
554
+ console.error(
555
+ chalk.red("Error:"),
556
+ err instanceof Error ? err.message : String(err),
557
+ );
558
+ process.exit(1);
559
+ }
560
+ });
561
+ }
@@ -118,7 +118,7 @@ export function formatShareSessionError(err: ShareSessionHttpError): string {
118
118
  // Command registration
119
119
  // ---------------------------------------------------------------------------
120
120
 
121
- export function registerFilesCommand(program: Command): void {
121
+ export function registerFilesCommand(program: Command): Command {
122
122
  const files = program
123
123
  .command("files")
124
124
  .description("Manage file access controls in HQ vault")
@@ -407,6 +407,11 @@ export function registerFilesCommand(program: Command): void {
407
407
  process.exit(1);
408
408
  }
409
409
  });
410
+
411
+ // Return the `files` Commander group so callers (src/index.ts) can attach
412
+ // additional subcommands (e.g. `hq files browse`/`hq files cat` from
413
+ // files-browse.ts) onto the same group without re-creating it.
414
+ return files;
410
415
  }
411
416
 
412
417
  // ---------------------------------------------------------------------------