@indigoai-us/hq-cli 5.17.0 → 5.18.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.
- package/dist/commands/cloud.d.ts +26 -1
- package/dist/commands/cloud.js +128 -10
- package/dist/commands/files-browse.d.ts +178 -0
- package/dist/commands/files-browse.js +348 -0
- package/dist/commands/files.d.ts +1 -1
- package/dist/commands/files.js +6 -2
- package/dist/commands/sync-mode.d.ts +115 -0
- package/dist/commands/sync-mode.js +249 -0
- package/dist/commands/sync-narrow.d.ts +154 -0
- package/dist/commands/sync-narrow.js +327 -0
- package/dist/index.js +11 -3
- package/dist/lib/local-tree-diff.d.ts +94 -0
- package/dist/lib/local-tree-diff.js +244 -0
- package/dist/lib/narrow-hint-banner.d.ts +102 -0
- package/dist/lib/narrow-hint-banner.js +144 -0
- package/package.json +2 -2
- package/src/commands/cloud.pull-all.test.ts +170 -1
- package/src/commands/cloud.ts +195 -4
- package/src/commands/files-browse.test.ts +475 -0
- package/src/commands/files-browse.ts +561 -0
- package/src/commands/files.ts +6 -1
- package/src/commands/sync-mode.test.ts +366 -0
- package/src/commands/sync-mode.ts +387 -0
- package/src/commands/sync-narrow.test.ts +573 -0
- package/src/commands/sync-narrow.ts +541 -0
- package/src/index.ts +9 -1
- package/src/lib/hq-cloud-dep.smoke.test.ts +75 -0
- package/src/lib/local-tree-diff.test.ts +262 -0
- package/src/lib/local-tree-diff.ts +330 -0
- package/src/lib/narrow-hint-banner.test.ts +235 -0
- package/src/lib/narrow-hint-banner.ts +212 -0
|
@@ -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
|
+
}
|
package/src/commands/files.ts
CHANGED
|
@@ -118,7 +118,7 @@ export function formatShareSessionError(err: ShareSessionHttpError): string {
|
|
|
118
118
|
// Command registration
|
|
119
119
|
// ---------------------------------------------------------------------------
|
|
120
120
|
|
|
121
|
-
export function registerFilesCommand(program: Command):
|
|
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
|
// ---------------------------------------------------------------------------
|