@indigoai-us/hq-cli 5.26.0 → 5.29.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/files-browse.d.ts +122 -27
- package/dist/commands/files-browse.js +194 -50
- package/dist/commands/sync-narrow.js +15 -4
- package/dist/lib/local-tree-diff.d.ts +7 -2
- package/dist/lib/local-tree-diff.js +18 -6
- package/package.json +2 -2
- package/src/commands/files-browse.test.ts +225 -79
- package/src/commands/files-browse.ts +290 -60
- package/src/commands/sync-narrow.test.ts +66 -6
- package/src/commands/sync-narrow.ts +15 -1
- package/src/lib/local-tree-diff.test.ts +40 -20
- package/src/lib/local-tree-diff.ts +22 -6
|
@@ -17,16 +17,21 @@
|
|
|
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
37
|
import { Command } from "commander";
|
|
@@ -46,7 +51,7 @@ import {
|
|
|
46
51
|
|
|
47
52
|
import {
|
|
48
53
|
VaultClient,
|
|
49
|
-
|
|
54
|
+
grantPathToPrefix,
|
|
50
55
|
type ExplicitGrant,
|
|
51
56
|
} from "@indigoai-us/hq-cloud";
|
|
52
57
|
|
|
@@ -61,17 +66,40 @@ import { resolveCanonicalPersonUid } from "./cloud.js";
|
|
|
61
66
|
|
|
62
67
|
// ── Types ───────────────────────────────────────────────────────────────────
|
|
63
68
|
|
|
69
|
+
/** STS-vended credential set the browse/cat path consumes. */
|
|
70
|
+
export interface BrowseCredentials {
|
|
71
|
+
accessKeyId: string;
|
|
72
|
+
secretAccessKey: string;
|
|
73
|
+
sessionToken: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Minimal STS-vend response shape (both `/sts/vend` and `/sts/vend-self`). */
|
|
77
|
+
export interface BrowseVendResult {
|
|
78
|
+
credentials: BrowseCredentials;
|
|
79
|
+
}
|
|
80
|
+
|
|
64
81
|
/**
|
|
65
82
|
* Subset of `VaultClient` this command actually uses — exposed so tests
|
|
66
83
|
* can stub vend + grants without standing up a real `VaultClient`.
|
|
84
|
+
*
|
|
85
|
+
* Browse/cat vend through the multi-tenant `/sts/vend` (company) and
|
|
86
|
+
* `/sts/vend-self` (personal) routes — NOT the legacy `POST /vend`, which
|
|
87
|
+
* assumes a single static bucket and is non-functional in multi-tenant
|
|
88
|
+
* production (it builds a policy against an undefined `BUCKET_ARN`, so STS
|
|
89
|
+
* rejects it with `MalformedPolicyDocument`). The STS routes resolve the
|
|
90
|
+
* caller's per-entity bucket and apply role/ACL scoping server-side.
|
|
67
91
|
*/
|
|
68
92
|
export interface FilesBrowseVaultClient {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
93
|
+
sts: {
|
|
94
|
+
vend(input: {
|
|
95
|
+
companyUid: string;
|
|
96
|
+
durationSeconds?: number;
|
|
97
|
+
}): Promise<BrowseVendResult>;
|
|
98
|
+
vendSelf(input: {
|
|
99
|
+
personUid: string;
|
|
100
|
+
durationSeconds?: number;
|
|
101
|
+
}): Promise<BrowseVendResult>;
|
|
102
|
+
};
|
|
75
103
|
listMyExplicitGrants(companyUid: string): Promise<ExplicitGrant[]>;
|
|
76
104
|
entity: {
|
|
77
105
|
get(uid: string): Promise<{ uid: string; slug: string; name?: string; bucketName?: string }>;
|
|
@@ -138,20 +166,48 @@ export function parseCompanySlugFromPath(prefix: string): string {
|
|
|
138
166
|
}
|
|
139
167
|
|
|
140
168
|
/**
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
169
|
+
* Translate a CLI `companies/<slug>/…` path into the company-relative S3 key
|
|
170
|
+
* the vault bucket actually stores. Company vault buckets are already
|
|
171
|
+
* company-scoped, so their keys carry NO `companies/<slug>/` prefix (e.g.
|
|
172
|
+
* `knowledge/foo.md`, not `companies/indigo/knowledge/foo.md`). The CLI speaks
|
|
173
|
+
* the anchored form for user familiarity; we strip the anchor at the S3
|
|
174
|
+
* boundary. A path without the anchor (or personal-mode, bucket-relative
|
|
175
|
+
* paths) passes through unchanged.
|
|
176
|
+
*/
|
|
177
|
+
export function toBucketRelative(pathOrPrefix: string, slug: string): string {
|
|
178
|
+
const anchor = `companies/${slug}/`;
|
|
179
|
+
const normalized = pathOrPrefix.replace(/^\/+/, "");
|
|
180
|
+
return normalized.startsWith(anchor)
|
|
181
|
+
? normalized.slice(anchor.length)
|
|
182
|
+
: normalized;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Re-attach the `companies/<slug>/` anchor to a company-relative bucket key
|
|
187
|
+
* for display + `hq files cat` round-trip, so the CLI surface keeps speaking
|
|
188
|
+
* the anchored form the user passed in.
|
|
189
|
+
*/
|
|
190
|
+
export function toCompanyAnchored(bucketRelKey: string, slug: string): string {
|
|
191
|
+
return `companies/${slug}/${bucketRelKey}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Classify a single company-relative S3 key against the caller's
|
|
196
|
+
* (already-normalized) explicit-grant prefixes. Any prefix that covers the
|
|
197
|
+
* key contributes `shared-with-you`; otherwise the key is visible only via
|
|
198
|
+
* the owner/admin role-bypass the `/sts/vend` policy applied. An empty-string
|
|
199
|
+
* prefix is a company-wide grant and matches everything.
|
|
144
200
|
*
|
|
145
|
-
* Grant
|
|
146
|
-
* `
|
|
147
|
-
*
|
|
201
|
+
* Grant `path`s arrive in inconsistent/glob form; the caller normalizes them
|
|
202
|
+
* to company-relative `startsWith` prefixes via `grantPathToPrefix` before
|
|
203
|
+
* calling this — keeping this helper pure and trivially testable.
|
|
148
204
|
*/
|
|
149
205
|
export function classifyAclSource(
|
|
150
|
-
|
|
151
|
-
|
|
206
|
+
bucketRelKey: string,
|
|
207
|
+
grantPrefixes: string[],
|
|
152
208
|
): AclSource {
|
|
153
|
-
for (const
|
|
154
|
-
if (
|
|
209
|
+
for (const p of grantPrefixes) {
|
|
210
|
+
if (p === "" || bucketRelKey.startsWith(p)) return "shared-with-you";
|
|
155
211
|
}
|
|
156
212
|
return "role-bypass";
|
|
157
213
|
}
|
|
@@ -242,7 +298,7 @@ export interface RunBrowseInput {
|
|
|
242
298
|
|
|
243
299
|
export interface RunBrowseResult {
|
|
244
300
|
rows: BrowseRow[];
|
|
245
|
-
vend:
|
|
301
|
+
vend: BrowseVendResult;
|
|
246
302
|
}
|
|
247
303
|
|
|
248
304
|
/**
|
|
@@ -250,8 +306,10 @@ export interface RunBrowseResult {
|
|
|
250
306
|
*
|
|
251
307
|
* 1. Parse slug from prefix (or use override).
|
|
252
308
|
* 2. Resolve companyUid + bucketName via VaultClient.entity.
|
|
253
|
-
* 3. Vend
|
|
254
|
-
*
|
|
309
|
+
* 3. Vend read creds via the multi-tenant STS route (`/sts/vend` company,
|
|
310
|
+
* `/sts/vend-self` personal) — resolves the per-entity bucket + role/ACL.
|
|
311
|
+
* 4. Construct S3Client from vended creds, paginate ListObjectsV2 over the
|
|
312
|
+
* company-relative key space.
|
|
255
313
|
* 5. Fetch explicit grants once, classify each key.
|
|
256
314
|
*
|
|
257
315
|
* Pure-ish: no console output, no process.exit — caller renders + exits.
|
|
@@ -262,10 +320,11 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
|
|
|
262
320
|
// Branch by mode. Company mode parses slug from path and looks up by
|
|
263
321
|
// namespace; personal mode resolves the entity directly by the supplied
|
|
264
322
|
// person UID and skips the slug + grants machinery (a person bucket has
|
|
265
|
-
// no grants graph — the owner is the only principal).
|
|
266
|
-
// identical for both modes once we have the entity in hand.
|
|
323
|
+
// no grants graph — the owner is the only principal).
|
|
267
324
|
let bucket: string;
|
|
268
325
|
let entityUid: string;
|
|
326
|
+
let slug: string | undefined;
|
|
327
|
+
let vend: BrowseVendResult;
|
|
269
328
|
if (personalMode) {
|
|
270
329
|
if (!input.personalUid) {
|
|
271
330
|
throw new Error(
|
|
@@ -281,8 +340,9 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
|
|
|
281
340
|
}
|
|
282
341
|
entityUid = entity.uid;
|
|
283
342
|
bucket = entity.bucketName;
|
|
343
|
+
vend = await vaultClient.sts.vendSelf({ personUid: entityUid });
|
|
284
344
|
} else {
|
|
285
|
-
|
|
345
|
+
slug = input.companySlug ?? parseCompanySlugFromPath(pathPrefix);
|
|
286
346
|
const entity = await vaultClient.entity.findInMyNamespace("company", slug);
|
|
287
347
|
if (!entity) {
|
|
288
348
|
throw new Error(
|
|
@@ -296,19 +356,12 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
|
|
|
296
356
|
}
|
|
297
357
|
entityUid = entity.uid;
|
|
298
358
|
bucket = entity.bucketName;
|
|
359
|
+
// Multi-tenant vend: the server resolves this company's bucket + applies
|
|
360
|
+
// owner/admin role-bypass (full access) or member/guest ACL scoping. The
|
|
361
|
+
// legacy `POST /vend` is unused here — see FilesBrowseVaultClient docs.
|
|
362
|
+
vend = await vaultClient.sts.vend({ companyUid: entityUid });
|
|
299
363
|
}
|
|
300
364
|
|
|
301
|
-
// Distinct vend call from sync — `purpose: 'browse'` opts the request
|
|
302
|
-
// into the role-bypass-allowed code path on the server (US-009). The
|
|
303
|
-
// personal mode vends against the person entity which is owner-only by
|
|
304
|
-
// construction; the vend response shape is identical so downstream
|
|
305
|
-
// S3Client construction doesn't branch.
|
|
306
|
-
const vend = await vaultClient.vend({
|
|
307
|
-
paths: [pathPrefix],
|
|
308
|
-
operations: "read-only",
|
|
309
|
-
purpose: "browse",
|
|
310
|
-
});
|
|
311
|
-
|
|
312
365
|
const s3 = s3Factory({
|
|
313
366
|
region,
|
|
314
367
|
credentials: {
|
|
@@ -318,13 +371,25 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
|
|
|
318
371
|
},
|
|
319
372
|
});
|
|
320
373
|
|
|
374
|
+
// Company vault keys are company-relative (no `companies/<slug>/` prefix), so
|
|
375
|
+
// translate the CLI's anchored prefix into the bucket-relative form before
|
|
376
|
+
// listing. Personal-mode paths are already bucket-relative.
|
|
377
|
+
const listPrefix =
|
|
378
|
+
personalMode || slug === undefined
|
|
379
|
+
? pathPrefix
|
|
380
|
+
: toBucketRelative(pathPrefix, slug);
|
|
381
|
+
|
|
321
382
|
// Pull the caller's explicit-grant graph once so per-key classification
|
|
322
|
-
// is O(grants) without N round-trips.
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
383
|
+
// is O(grants) without N round-trips. Grant `path`s are normalized to
|
|
384
|
+
// company-relative `startsWith` prefixes (matching the bucket key space)
|
|
385
|
+
// via `grantPathToPrefix`. Skipped in personal mode — the grants graph is a
|
|
386
|
+
// company concept; a person bucket marks every row `"personal-vault"`.
|
|
387
|
+
const grantPrefixes =
|
|
388
|
+
personalMode || slug === undefined
|
|
389
|
+
? []
|
|
390
|
+
: (await vaultClient.listMyExplicitGrants(entityUid)).map((g) =>
|
|
391
|
+
grantPathToPrefix(g.path, slug as string),
|
|
392
|
+
);
|
|
328
393
|
|
|
329
394
|
const rows: BrowseRow[] = [];
|
|
330
395
|
let continuationToken: string | undefined;
|
|
@@ -332,7 +397,7 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
|
|
|
332
397
|
const resp = (await s3.send(
|
|
333
398
|
new ListObjectsV2Command({
|
|
334
399
|
Bucket: bucket,
|
|
335
|
-
Prefix:
|
|
400
|
+
Prefix: listPrefix,
|
|
336
401
|
ContinuationToken: continuationToken,
|
|
337
402
|
}),
|
|
338
403
|
)) as ListObjectsV2CommandOutput;
|
|
@@ -342,11 +407,18 @@ export async function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>
|
|
|
342
407
|
// Skip S3 "directory marker" objects (0-byte, trailing slash).
|
|
343
408
|
if (obj.Key.endsWith("/") && (obj.Size ?? 0) === 0) continue;
|
|
344
409
|
|
|
410
|
+
// `obj.Key` is company-relative. Classify in that space, then re-anchor
|
|
411
|
+
// for display so the CLI keeps speaking `companies/<slug>/...`.
|
|
345
412
|
rows.push({
|
|
346
|
-
key:
|
|
413
|
+
key:
|
|
414
|
+
personalMode || slug === undefined
|
|
415
|
+
? obj.Key
|
|
416
|
+
: toCompanyAnchored(obj.Key, slug),
|
|
347
417
|
size: obj.Size ?? 0,
|
|
348
418
|
lastModified: obj.LastModified,
|
|
349
|
-
aclSource: personalMode
|
|
419
|
+
aclSource: personalMode
|
|
420
|
+
? "personal-vault"
|
|
421
|
+
: classifyAclSource(obj.Key, grantPrefixes),
|
|
350
422
|
});
|
|
351
423
|
}
|
|
352
424
|
|
|
@@ -384,7 +456,7 @@ export interface RunCatInput {
|
|
|
384
456
|
export interface RunCatResult {
|
|
385
457
|
bytesWritten: number;
|
|
386
458
|
destination: { kind: "stdout" } | { kind: "file"; absPath: string };
|
|
387
|
-
vend:
|
|
459
|
+
vend: BrowseVendResult;
|
|
388
460
|
}
|
|
389
461
|
|
|
390
462
|
/**
|
|
@@ -405,6 +477,8 @@ export async function runCat(input: RunCatInput): Promise<RunCatResult> {
|
|
|
405
477
|
// Same branch logic as runBrowse — see that function's doc-block for
|
|
406
478
|
// the personal-vs-company rationale.
|
|
407
479
|
let bucket: string;
|
|
480
|
+
let s3Key: string;
|
|
481
|
+
let vend: BrowseVendResult;
|
|
408
482
|
if (personalMode) {
|
|
409
483
|
if (!input.personalUid) {
|
|
410
484
|
throw new Error(
|
|
@@ -419,6 +493,8 @@ export async function runCat(input: RunCatInput): Promise<RunCatResult> {
|
|
|
419
493
|
);
|
|
420
494
|
}
|
|
421
495
|
bucket = entity.bucketName;
|
|
496
|
+
s3Key = key; // personal-mode keys are already bucket-relative
|
|
497
|
+
vend = await vaultClient.sts.vendSelf({ personUid: entity.uid });
|
|
422
498
|
} else {
|
|
423
499
|
const slug = input.companySlug ?? parseCompanySlugFromPath(key);
|
|
424
500
|
const entity = await vaultClient.entity.findInMyNamespace("company", slug);
|
|
@@ -433,14 +509,11 @@ export async function runCat(input: RunCatInput): Promise<RunCatResult> {
|
|
|
433
509
|
);
|
|
434
510
|
}
|
|
435
511
|
bucket = entity.bucketName;
|
|
512
|
+
// Translate the anchored CLI key into the company-relative bucket key.
|
|
513
|
+
s3Key = toBucketRelative(key, slug);
|
|
514
|
+
vend = await vaultClient.sts.vend({ companyUid: entity.uid });
|
|
436
515
|
}
|
|
437
516
|
|
|
438
|
-
const vend = await vaultClient.vend({
|
|
439
|
-
paths: [key],
|
|
440
|
-
operations: "read-only",
|
|
441
|
-
purpose: "browse",
|
|
442
|
-
});
|
|
443
|
-
|
|
444
517
|
const s3 = s3Factory({
|
|
445
518
|
region,
|
|
446
519
|
credentials: {
|
|
@@ -451,7 +524,7 @@ export async function runCat(input: RunCatInput): Promise<RunCatResult> {
|
|
|
451
524
|
});
|
|
452
525
|
|
|
453
526
|
const resp = (await s3.send(
|
|
454
|
-
new GetObjectCommand({ Bucket: bucket, Key:
|
|
527
|
+
new GetObjectCommand({ Bucket: bucket, Key: s3Key }),
|
|
455
528
|
)) as GetObjectCommandOutput;
|
|
456
529
|
|
|
457
530
|
if (!resp.Body) {
|
|
@@ -484,6 +557,125 @@ export async function runCat(input: RunCatInput): Promise<RunCatResult> {
|
|
|
484
557
|
return { bytesWritten, destination: { kind: "stdout" }, vend };
|
|
485
558
|
}
|
|
486
559
|
|
|
560
|
+
// ── shared-with-me ────────────────────────────────────────────────────────
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Subset of `VaultClient` the `shared-with-me` orchestrator uses. No vend / S3
|
|
564
|
+
* — this is a pure read of the caller's explicit-grant graph, so it never
|
|
565
|
+
* touches the credential/browse vend surface.
|
|
566
|
+
*/
|
|
567
|
+
export interface FilesSharedWithMeVaultClient {
|
|
568
|
+
listMyMemberships(): Promise<Array<{ companyUid: string }>>;
|
|
569
|
+
listMyExplicitGrants(companyUid: string): Promise<ExplicitGrant[]>;
|
|
570
|
+
entity: {
|
|
571
|
+
get(uid: string): Promise<{ uid: string; slug: string; name?: string }>;
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export interface SharedWithMeRow {
|
|
576
|
+
companySlug: string;
|
|
577
|
+
/** Company-relative grant path (e.g. `knowledge/`, `reports/q3.pdf`). */
|
|
578
|
+
path: string;
|
|
579
|
+
permission: ExplicitGrant["permission"];
|
|
580
|
+
source: ExplicitGrant["source"];
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
export interface RunSharedWithMeInput {
|
|
584
|
+
vaultClient: FilesSharedWithMeVaultClient;
|
|
585
|
+
/**
|
|
586
|
+
* Scope to a single company by UID. When omitted, rolls up across every
|
|
587
|
+
* company the caller has a membership in (the cross-company "what's shared
|
|
588
|
+
* with me everywhere" view).
|
|
589
|
+
*/
|
|
590
|
+
companyUid?: string;
|
|
591
|
+
/** Display slug for the single-company case (avoids an extra entity.get). */
|
|
592
|
+
companySlug?: string;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* `hq files shared-with-me` orchestrator. Lists the caller's EXPLICIT
|
|
597
|
+
* file-ACL grants — the canonical "what's been shared with me" surface.
|
|
598
|
+
* Role-bypass access (owner/admin) is intentionally excluded server-side by
|
|
599
|
+
* `listMyExplicitGrants`, so this shows real grants, not role-implied reach.
|
|
600
|
+
*
|
|
601
|
+
* Pure data — no console output, no S3, no vend. The caller renders + exits.
|
|
602
|
+
*/
|
|
603
|
+
export async function runSharedWithMe(
|
|
604
|
+
input: RunSharedWithMeInput,
|
|
605
|
+
): Promise<SharedWithMeRow[]> {
|
|
606
|
+
const { vaultClient } = input;
|
|
607
|
+
|
|
608
|
+
// Resolve the (companyUid, slug) pairs to query. Single-company when a UID
|
|
609
|
+
// was supplied; otherwise fan out across every membership.
|
|
610
|
+
let targets: Array<{ uid: string; slug: string }>;
|
|
611
|
+
if (input.companyUid) {
|
|
612
|
+
targets = [{ uid: input.companyUid, slug: input.companySlug ?? input.companyUid }];
|
|
613
|
+
} else {
|
|
614
|
+
const memberships = await vaultClient.listMyMemberships();
|
|
615
|
+
targets = await Promise.all(
|
|
616
|
+
memberships.map(async (m) => {
|
|
617
|
+
try {
|
|
618
|
+
const ent = await vaultClient.entity.get(m.companyUid);
|
|
619
|
+
return { uid: m.companyUid, slug: ent.slug || m.companyUid };
|
|
620
|
+
} catch {
|
|
621
|
+
// Entity not visible — fall back to the UID as the display label
|
|
622
|
+
// rather than dropping the company's grants entirely.
|
|
623
|
+
return { uid: m.companyUid, slug: m.companyUid };
|
|
624
|
+
}
|
|
625
|
+
}),
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const rows: SharedWithMeRow[] = [];
|
|
630
|
+
for (const t of targets) {
|
|
631
|
+
let grants: ExplicitGrant[];
|
|
632
|
+
try {
|
|
633
|
+
grants = await vaultClient.listMyExplicitGrants(t.uid);
|
|
634
|
+
} catch {
|
|
635
|
+
// A single company's grant fetch failing shouldn't sink the whole
|
|
636
|
+
// roll-up — skip it and continue (best-effort discovery view).
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
for (const g of grants) {
|
|
640
|
+
rows.push({
|
|
641
|
+
companySlug: t.slug,
|
|
642
|
+
path: g.path,
|
|
643
|
+
permission: g.permission,
|
|
644
|
+
source: g.source,
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Stable sort: company, then path — deterministic output for humans + tests.
|
|
650
|
+
rows.sort((a, b) =>
|
|
651
|
+
a.companySlug === b.companySlug
|
|
652
|
+
? a.path.localeCompare(b.path)
|
|
653
|
+
: a.companySlug.localeCompare(b.companySlug),
|
|
654
|
+
);
|
|
655
|
+
return rows;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Render `shared-with-me` rows as a padded table. Mirrors `formatBrowseTable`.
|
|
660
|
+
*/
|
|
661
|
+
export function formatSharedWithMeTable(rows: SharedWithMeRow[]): string {
|
|
662
|
+
if (rows.length === 0) {
|
|
663
|
+
return "Nothing is explicitly shared with you. (Owner/admin role-bypass access is not listed here — only explicit grants.)";
|
|
664
|
+
}
|
|
665
|
+
const cols = ["COMPANY", "PATH", "PERMISSION", "SOURCE"];
|
|
666
|
+
const data = rows.map((r) => [r.companySlug, r.path, r.permission, r.source]);
|
|
667
|
+
const widths = cols.map((c, i) =>
|
|
668
|
+
Math.max(c.length, ...data.map((row) => row[i].length)),
|
|
669
|
+
);
|
|
670
|
+
const renderRow = (row: string[]): string =>
|
|
671
|
+
row.map((cell, i) => cell.padEnd(widths[i])).join(" ");
|
|
672
|
+
return [
|
|
673
|
+
chalk.bold(renderRow(cols)),
|
|
674
|
+
chalk.dim(renderRow(widths.map((w) => "─".repeat(w)))),
|
|
675
|
+
...data.map(renderRow),
|
|
676
|
+
].join("\n");
|
|
677
|
+
}
|
|
678
|
+
|
|
487
679
|
// ── CLI registration ────────────────────────────────────────────────────────
|
|
488
680
|
|
|
489
681
|
const defaultS3Factory: S3ClientFactory = ({ region, credentials }) =>
|
|
@@ -736,4 +928,42 @@ export function registerFilesBrowseCommands(filesCmd: Command): void {
|
|
|
736
928
|
process.exit(1);
|
|
737
929
|
}
|
|
738
930
|
});
|
|
931
|
+
|
|
932
|
+
filesCmd
|
|
933
|
+
.command("shared-with-me")
|
|
934
|
+
.description(
|
|
935
|
+
"List the files/prefixes explicitly shared with you. Omit --company to roll up across every company you're a member of. Pure read — no download, no credentials vended. Owner/admin role-bypass access is NOT listed (only explicit grants).",
|
|
936
|
+
)
|
|
937
|
+
.option(
|
|
938
|
+
"--company <slug>",
|
|
939
|
+
"Scope to a single company (defaults to a cross-company roll-up).",
|
|
940
|
+
)
|
|
941
|
+
.action(async (options: { company?: string }) => {
|
|
942
|
+
try {
|
|
943
|
+
const accessToken = await ensureCognitoToken();
|
|
944
|
+
const vaultConfig = buildVaultConfig(accessToken);
|
|
945
|
+
const client = new VaultClient(vaultConfig);
|
|
946
|
+
|
|
947
|
+
let companyUid: string | undefined;
|
|
948
|
+
if (options.company) {
|
|
949
|
+
// Confirm membership + resolve UID, same early-failure pattern as
|
|
950
|
+
// browse/cat. Roll-up mode skips this and fans out internally.
|
|
951
|
+
companyUid = await getCompanyUid(accessToken, options.company);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const rows = await runSharedWithMe({
|
|
955
|
+
vaultClient: client,
|
|
956
|
+
companyUid,
|
|
957
|
+
companySlug: options.company,
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
console.log(formatSharedWithMeTable(rows));
|
|
961
|
+
} catch (err) {
|
|
962
|
+
console.error(
|
|
963
|
+
chalk.red("Error:"),
|
|
964
|
+
err instanceof Error ? err.message : String(err),
|
|
965
|
+
);
|
|
966
|
+
process.exit(1);
|
|
967
|
+
}
|
|
968
|
+
});
|
|
739
969
|
}
|
|
@@ -291,18 +291,24 @@ describe("resolveNarrowTarget", () => {
|
|
|
291
291
|
|
|
292
292
|
describe("computeNarrowPlan", () => {
|
|
293
293
|
it("coalesces grants and returns a partitioned plan", async () => {
|
|
294
|
+
// Files live on disk at their hq-root-relative path…
|
|
294
295
|
writeFile("companies/acme/meetings/notes.md", "stays");
|
|
295
296
|
writeFile("companies/acme/scratch/old.md", "clean orphan");
|
|
296
297
|
|
|
298
|
+
// …but the journal keys + grant paths are COMPANY-RELATIVE — the
|
|
299
|
+
// namespace the real server + engine use. (The old fixtures used
|
|
300
|
+
// full `companies/acme/...` grant paths, which never matched the
|
|
301
|
+
// company-relative keys buildNarrowPlan emits — masking the namespace
|
|
302
|
+
// bug this test now guards against.)
|
|
297
303
|
const journal = journalFromFiles([
|
|
298
|
-
{ rel: "
|
|
299
|
-
{ rel: "
|
|
304
|
+
{ rel: "meetings/notes.md", contents: "stays" },
|
|
305
|
+
{ rel: "scratch/old.md", contents: "clean orphan" },
|
|
300
306
|
]);
|
|
301
307
|
|
|
302
308
|
const { client } = makeStubClient({
|
|
303
309
|
grants: [
|
|
304
|
-
fakeGrant("
|
|
305
|
-
fakeGrant("
|
|
310
|
+
fakeGrant("meetings/"),
|
|
311
|
+
fakeGrant("meetings/2026/"), // collapsed by coalesce
|
|
306
312
|
],
|
|
307
313
|
});
|
|
308
314
|
|
|
@@ -316,13 +322,67 @@ describe("computeNarrowPlan", () => {
|
|
|
316
322
|
journalIO: journalIO.io,
|
|
317
323
|
});
|
|
318
324
|
|
|
319
|
-
expect(result.prospectivePrefixSet).toEqual([
|
|
320
|
-
|
|
325
|
+
expect(result.prospectivePrefixSet).toEqual(["meetings/"]);
|
|
326
|
+
expect(result.plan.totalStayingCount).toBe(1);
|
|
327
|
+
expect(result.plan.totalCleanCount).toBe(1);
|
|
328
|
+
expect(result.plan.totalDirtyCount).toBe(0);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("normalizes real-world anchored + glob grant paths (grantPathToPrefix)", async () => {
|
|
332
|
+
writeFile("companies/acme/design-pack/logo.svg", "svg");
|
|
333
|
+
writeFile("companies/acme/scratch/old.md", "clean orphan");
|
|
334
|
+
|
|
335
|
+
const journal = journalFromFiles([
|
|
336
|
+
{ rel: "design-pack/logo.svg", contents: "svg" },
|
|
337
|
+
{ rel: "scratch/old.md", contents: "clean orphan" },
|
|
321
338
|
]);
|
|
339
|
+
|
|
340
|
+
// The exact messy shapes the live vault returns: full-anchored + glob,
|
|
341
|
+
// and slug-anchored + glob — neither startsWith-matches the
|
|
342
|
+
// company-relative local keys until grantPathToPrefix de-anchors them.
|
|
343
|
+
const { client } = makeStubClient({
|
|
344
|
+
grants: [
|
|
345
|
+
fakeGrant("companies/acme/design-pack/*"),
|
|
346
|
+
fakeGrant("acme/design-pack/2026/*"), // subsumed after normalize+coalesce
|
|
347
|
+
],
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
const result = await computeNarrowPlan({
|
|
351
|
+
hqRoot: tmpRoot,
|
|
352
|
+
companySlug: "acme",
|
|
353
|
+
companyUid: "cmp_acme",
|
|
354
|
+
vaultClient: client,
|
|
355
|
+
journalIO: makeStubJournalIO(journal).io,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
expect(result.prospectivePrefixSet).toEqual(["design-pack/"]);
|
|
359
|
+
// design-pack/logo.svg stays (covered); scratch/old.md is a clean orphan.
|
|
322
360
|
expect(result.plan.totalStayingCount).toBe(1);
|
|
323
361
|
expect(result.plan.totalCleanCount).toBe(1);
|
|
324
362
|
expect(result.plan.totalDirtyCount).toBe(0);
|
|
325
363
|
});
|
|
364
|
+
|
|
365
|
+
it("a wildcard '*' grant keeps everything (no orphans)", async () => {
|
|
366
|
+
writeFile("companies/acme/a.md", "a");
|
|
367
|
+
writeFile("companies/acme/sub/b.md", "b");
|
|
368
|
+
|
|
369
|
+
const { client } = makeStubClient({ grants: [fakeGrant("*")] });
|
|
370
|
+
|
|
371
|
+
const result = await computeNarrowPlan({
|
|
372
|
+
hqRoot: tmpRoot,
|
|
373
|
+
companySlug: "acme",
|
|
374
|
+
companyUid: "cmp_acme",
|
|
375
|
+
vaultClient: client,
|
|
376
|
+
journalIO: makeStubJournalIO(journalFromFiles([])).io,
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// "*" → "" → guarded to [""] (covers everything) so narrowing keeps the
|
|
380
|
+
// whole tree rather than collapsing to "nothing" and proposing deletes.
|
|
381
|
+
expect(result.prospectivePrefixSet).toEqual([""]);
|
|
382
|
+
expect(result.plan.totalCleanCount).toBe(0);
|
|
383
|
+
expect(result.plan.totalDirtyCount).toBe(0);
|
|
384
|
+
expect(result.plan.totalStayingCount).toBe(2);
|
|
385
|
+
});
|
|
326
386
|
});
|
|
327
387
|
|
|
328
388
|
// ── applyNarrow ─────────────────────────────────────────────────────────────
|
|
@@ -45,6 +45,7 @@ import * as fs from "node:fs";
|
|
|
45
45
|
import {
|
|
46
46
|
VaultClient,
|
|
47
47
|
coalescePrefixes,
|
|
48
|
+
grantPathToPrefix,
|
|
48
49
|
readJournal,
|
|
49
50
|
writeJournal,
|
|
50
51
|
tombstoneEntry,
|
|
@@ -218,7 +219,20 @@ export async function computeNarrowPlan(
|
|
|
218
219
|
const io = input.journalIO ?? realJournalIO;
|
|
219
220
|
|
|
220
221
|
const grants = await vaultClient.listMyExplicitGrants(companyUid);
|
|
221
|
-
|
|
222
|
+
// Normalize each grant into a company-relative, startsWith-friendly prefix
|
|
223
|
+
// (grantPathToPrefix, hq-cloud ≥5.42.0): real grants are anchored
|
|
224
|
+
// (`companies/<slug>/x/*`, `<slug>/x/*`) and glob-style (`x/*`, bare `*`),
|
|
225
|
+
// none of which startsWith-match the company-relative local-tree keys
|
|
226
|
+
// buildNarrowPlan emits. A wildcard grant normalizes to "" (everything);
|
|
227
|
+
// coalescePrefixes drops empties, so guard it explicitly to `[""]` (which
|
|
228
|
+
// isCoveredByAny treats as covering everything → nothing orphaned) rather
|
|
229
|
+
// than letting it collapse to "nothing" and propose deleting the tree.
|
|
230
|
+
const normalizedPrefixes = grants.map((g) =>
|
|
231
|
+
grantPathToPrefix(g.path, companySlug),
|
|
232
|
+
);
|
|
233
|
+
const prospectivePrefixSet = normalizedPrefixes.some((p) => p === "")
|
|
234
|
+
? [""]
|
|
235
|
+
: coalescePrefixes(normalizedPrefixes);
|
|
222
236
|
const journal = io.read(companySlug);
|
|
223
237
|
|
|
224
238
|
const plan = buildNarrowPlan({
|