@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,31 +17,57 @@
|
|
|
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
|
import { Command } from "commander";
|
|
32
37
|
import { ListObjectsV2Command, GetObjectCommand, type ListObjectsV2CommandOutput, type GetObjectCommandOutput } from "@aws-sdk/client-s3";
|
|
33
|
-
import { type
|
|
38
|
+
import { type ExplicitGrant } from "@indigoai-us/hq-cloud";
|
|
39
|
+
/** STS-vended credential set the browse/cat path consumes. */
|
|
40
|
+
export interface BrowseCredentials {
|
|
41
|
+
accessKeyId: string;
|
|
42
|
+
secretAccessKey: string;
|
|
43
|
+
sessionToken: string;
|
|
44
|
+
}
|
|
45
|
+
/** Minimal STS-vend response shape (both `/sts/vend` and `/sts/vend-self`). */
|
|
46
|
+
export interface BrowseVendResult {
|
|
47
|
+
credentials: BrowseCredentials;
|
|
48
|
+
}
|
|
34
49
|
/**
|
|
35
50
|
* Subset of `VaultClient` this command actually uses — exposed so tests
|
|
36
51
|
* can stub vend + grants without standing up a real `VaultClient`.
|
|
52
|
+
*
|
|
53
|
+
* Browse/cat vend through the multi-tenant `/sts/vend` (company) and
|
|
54
|
+
* `/sts/vend-self` (personal) routes — NOT the legacy `POST /vend`, which
|
|
55
|
+
* assumes a single static bucket and is non-functional in multi-tenant
|
|
56
|
+
* production (it builds a policy against an undefined `BUCKET_ARN`, so STS
|
|
57
|
+
* rejects it with `MalformedPolicyDocument`). The STS routes resolve the
|
|
58
|
+
* caller's per-entity bucket and apply role/ACL scoping server-side.
|
|
37
59
|
*/
|
|
38
60
|
export interface FilesBrowseVaultClient {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
61
|
+
sts: {
|
|
62
|
+
vend(input: {
|
|
63
|
+
companyUid: string;
|
|
64
|
+
durationSeconds?: number;
|
|
65
|
+
}): Promise<BrowseVendResult>;
|
|
66
|
+
vendSelf(input: {
|
|
67
|
+
personUid: string;
|
|
68
|
+
durationSeconds?: number;
|
|
69
|
+
}): Promise<BrowseVendResult>;
|
|
70
|
+
};
|
|
45
71
|
listMyExplicitGrants(companyUid: string): Promise<ExplicitGrant[]>;
|
|
46
72
|
entity: {
|
|
47
73
|
get(uid: string): Promise<{
|
|
@@ -96,15 +122,33 @@ export interface BrowseRow {
|
|
|
96
122
|
*/
|
|
97
123
|
export declare function parseCompanySlugFromPath(prefix: string): string;
|
|
98
124
|
/**
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
125
|
+
* Translate a CLI `companies/<slug>/…` path into the company-relative S3 key
|
|
126
|
+
* the vault bucket actually stores. Company vault buckets are already
|
|
127
|
+
* company-scoped, so their keys carry NO `companies/<slug>/` prefix (e.g.
|
|
128
|
+
* `knowledge/foo.md`, not `companies/indigo/knowledge/foo.md`). The CLI speaks
|
|
129
|
+
* the anchored form for user familiarity; we strip the anchor at the S3
|
|
130
|
+
* boundary. A path without the anchor (or personal-mode, bucket-relative
|
|
131
|
+
* paths) passes through unchanged.
|
|
132
|
+
*/
|
|
133
|
+
export declare function toBucketRelative(pathOrPrefix: string, slug: string): string;
|
|
134
|
+
/**
|
|
135
|
+
* Re-attach the `companies/<slug>/` anchor to a company-relative bucket key
|
|
136
|
+
* for display + `hq files cat` round-trip, so the CLI surface keeps speaking
|
|
137
|
+
* the anchored form the user passed in.
|
|
138
|
+
*/
|
|
139
|
+
export declare function toCompanyAnchored(bucketRelKey: string, slug: string): string;
|
|
140
|
+
/**
|
|
141
|
+
* Classify a single company-relative S3 key against the caller's
|
|
142
|
+
* (already-normalized) explicit-grant prefixes. Any prefix that covers the
|
|
143
|
+
* key contributes `shared-with-you`; otherwise the key is visible only via
|
|
144
|
+
* the owner/admin role-bypass the `/sts/vend` policy applied. An empty-string
|
|
145
|
+
* prefix is a company-wide grant and matches everything.
|
|
102
146
|
*
|
|
103
|
-
* Grant
|
|
104
|
-
* `
|
|
105
|
-
*
|
|
147
|
+
* Grant `path`s arrive in inconsistent/glob form; the caller normalizes them
|
|
148
|
+
* to company-relative `startsWith` prefixes via `grantPathToPrefix` before
|
|
149
|
+
* calling this — keeping this helper pure and trivially testable.
|
|
106
150
|
*/
|
|
107
|
-
export declare function classifyAclSource(
|
|
151
|
+
export declare function classifyAclSource(bucketRelKey: string, grantPrefixes: string[]): AclSource;
|
|
108
152
|
/**
|
|
109
153
|
* Bright-line guard for `--out`: refuse to write any byte beneath
|
|
110
154
|
* `<hqRoot>/companies/`. We do NOT enumerate `companies/manifest.yaml`
|
|
@@ -151,15 +195,17 @@ export interface RunBrowseInput {
|
|
|
151
195
|
}
|
|
152
196
|
export interface RunBrowseResult {
|
|
153
197
|
rows: BrowseRow[];
|
|
154
|
-
vend:
|
|
198
|
+
vend: BrowseVendResult;
|
|
155
199
|
}
|
|
156
200
|
/**
|
|
157
201
|
* `hq files browse <path>` orchestrator.
|
|
158
202
|
*
|
|
159
203
|
* 1. Parse slug from prefix (or use override).
|
|
160
204
|
* 2. Resolve companyUid + bucketName via VaultClient.entity.
|
|
161
|
-
* 3. Vend
|
|
162
|
-
*
|
|
205
|
+
* 3. Vend read creds via the multi-tenant STS route (`/sts/vend` company,
|
|
206
|
+
* `/sts/vend-self` personal) — resolves the per-entity bucket + role/ACL.
|
|
207
|
+
* 4. Construct S3Client from vended creds, paginate ListObjectsV2 over the
|
|
208
|
+
* company-relative key space.
|
|
163
209
|
* 5. Fetch explicit grants once, classify each key.
|
|
164
210
|
*
|
|
165
211
|
* Pure-ish: no console output, no process.exit — caller renders + exits.
|
|
@@ -197,7 +243,7 @@ export interface RunCatResult {
|
|
|
197
243
|
kind: "file";
|
|
198
244
|
absPath: string;
|
|
199
245
|
};
|
|
200
|
-
vend:
|
|
246
|
+
vend: BrowseVendResult;
|
|
201
247
|
}
|
|
202
248
|
/**
|
|
203
249
|
* `hq files cat <path>` orchestrator. Vends with `purpose: 'browse'`, then
|
|
@@ -205,6 +251,55 @@ export interface RunCatResult {
|
|
|
205
251
|
* containment guard). Refuses ahead of any I/O when `--out` is unsafe.
|
|
206
252
|
*/
|
|
207
253
|
export declare function runCat(input: RunCatInput): Promise<RunCatResult>;
|
|
254
|
+
/**
|
|
255
|
+
* Subset of `VaultClient` the `shared-with-me` orchestrator uses. No vend / S3
|
|
256
|
+
* — this is a pure read of the caller's explicit-grant graph, so it never
|
|
257
|
+
* touches the credential/browse vend surface.
|
|
258
|
+
*/
|
|
259
|
+
export interface FilesSharedWithMeVaultClient {
|
|
260
|
+
listMyMemberships(): Promise<Array<{
|
|
261
|
+
companyUid: string;
|
|
262
|
+
}>>;
|
|
263
|
+
listMyExplicitGrants(companyUid: string): Promise<ExplicitGrant[]>;
|
|
264
|
+
entity: {
|
|
265
|
+
get(uid: string): Promise<{
|
|
266
|
+
uid: string;
|
|
267
|
+
slug: string;
|
|
268
|
+
name?: string;
|
|
269
|
+
}>;
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
export interface SharedWithMeRow {
|
|
273
|
+
companySlug: string;
|
|
274
|
+
/** Company-relative grant path (e.g. `knowledge/`, `reports/q3.pdf`). */
|
|
275
|
+
path: string;
|
|
276
|
+
permission: ExplicitGrant["permission"];
|
|
277
|
+
source: ExplicitGrant["source"];
|
|
278
|
+
}
|
|
279
|
+
export interface RunSharedWithMeInput {
|
|
280
|
+
vaultClient: FilesSharedWithMeVaultClient;
|
|
281
|
+
/**
|
|
282
|
+
* Scope to a single company by UID. When omitted, rolls up across every
|
|
283
|
+
* company the caller has a membership in (the cross-company "what's shared
|
|
284
|
+
* with me everywhere" view).
|
|
285
|
+
*/
|
|
286
|
+
companyUid?: string;
|
|
287
|
+
/** Display slug for the single-company case (avoids an extra entity.get). */
|
|
288
|
+
companySlug?: string;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* `hq files shared-with-me` orchestrator. Lists the caller's EXPLICIT
|
|
292
|
+
* file-ACL grants — the canonical "what's been shared with me" surface.
|
|
293
|
+
* Role-bypass access (owner/admin) is intentionally excluded server-side by
|
|
294
|
+
* `listMyExplicitGrants`, so this shows real grants, not role-implied reach.
|
|
295
|
+
*
|
|
296
|
+
* Pure data — no console output, no S3, no vend. The caller renders + exits.
|
|
297
|
+
*/
|
|
298
|
+
export declare function runSharedWithMe(input: RunSharedWithMeInput): Promise<SharedWithMeRow[]>;
|
|
299
|
+
/**
|
|
300
|
+
* Render `shared-with-me` rows as a padded table. Mirrors `formatBrowseTable`.
|
|
301
|
+
*/
|
|
302
|
+
export declare function formatSharedWithMeTable(rows: SharedWithMeRow[]): string;
|
|
208
303
|
/**
|
|
209
304
|
* Wire `hq files browse` + `hq files cat` onto an existing `files`
|
|
210
305
|
* Commander group. `registerFilesCommand` in files.ts builds the group
|
|
@@ -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]="e2f338ce-5871-59ad-ae56-99bd29e1711c")}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
|
}
|
|
@@ -283,6 +327,79 @@ export async function runCat(input) {
|
|
|
283
327
|
await pipeline(body, input.stdout ?? process.stdout);
|
|
284
328
|
return { bytesWritten, destination: { kind: "stdout" }, vend };
|
|
285
329
|
}
|
|
330
|
+
/**
|
|
331
|
+
* `hq files shared-with-me` orchestrator. Lists the caller's EXPLICIT
|
|
332
|
+
* file-ACL grants — the canonical "what's been shared with me" surface.
|
|
333
|
+
* Role-bypass access (owner/admin) is intentionally excluded server-side by
|
|
334
|
+
* `listMyExplicitGrants`, so this shows real grants, not role-implied reach.
|
|
335
|
+
*
|
|
336
|
+
* Pure data — no console output, no S3, no vend. The caller renders + exits.
|
|
337
|
+
*/
|
|
338
|
+
export async function runSharedWithMe(input) {
|
|
339
|
+
const { vaultClient } = input;
|
|
340
|
+
// Resolve the (companyUid, slug) pairs to query. Single-company when a UID
|
|
341
|
+
// was supplied; otherwise fan out across every membership.
|
|
342
|
+
let targets;
|
|
343
|
+
if (input.companyUid) {
|
|
344
|
+
targets = [{ uid: input.companyUid, slug: input.companySlug ?? input.companyUid }];
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
const memberships = await vaultClient.listMyMemberships();
|
|
348
|
+
targets = await Promise.all(memberships.map(async (m) => {
|
|
349
|
+
try {
|
|
350
|
+
const ent = await vaultClient.entity.get(m.companyUid);
|
|
351
|
+
return { uid: m.companyUid, slug: ent.slug || m.companyUid };
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
// Entity not visible — fall back to the UID as the display label
|
|
355
|
+
// rather than dropping the company's grants entirely.
|
|
356
|
+
return { uid: m.companyUid, slug: m.companyUid };
|
|
357
|
+
}
|
|
358
|
+
}));
|
|
359
|
+
}
|
|
360
|
+
const rows = [];
|
|
361
|
+
for (const t of targets) {
|
|
362
|
+
let grants;
|
|
363
|
+
try {
|
|
364
|
+
grants = await vaultClient.listMyExplicitGrants(t.uid);
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
// A single company's grant fetch failing shouldn't sink the whole
|
|
368
|
+
// roll-up — skip it and continue (best-effort discovery view).
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
for (const g of grants) {
|
|
372
|
+
rows.push({
|
|
373
|
+
companySlug: t.slug,
|
|
374
|
+
path: g.path,
|
|
375
|
+
permission: g.permission,
|
|
376
|
+
source: g.source,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// Stable sort: company, then path — deterministic output for humans + tests.
|
|
381
|
+
rows.sort((a, b) => a.companySlug === b.companySlug
|
|
382
|
+
? a.path.localeCompare(b.path)
|
|
383
|
+
: a.companySlug.localeCompare(b.companySlug));
|
|
384
|
+
return rows;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Render `shared-with-me` rows as a padded table. Mirrors `formatBrowseTable`.
|
|
388
|
+
*/
|
|
389
|
+
export function formatSharedWithMeTable(rows) {
|
|
390
|
+
if (rows.length === 0) {
|
|
391
|
+
return "Nothing is explicitly shared with you. (Owner/admin role-bypass access is not listed here — only explicit grants.)";
|
|
392
|
+
}
|
|
393
|
+
const cols = ["COMPANY", "PATH", "PERMISSION", "SOURCE"];
|
|
394
|
+
const data = rows.map((r) => [r.companySlug, r.path, r.permission, r.source]);
|
|
395
|
+
const widths = cols.map((c, i) => Math.max(c.length, ...data.map((row) => row[i].length)));
|
|
396
|
+
const renderRow = (row) => row.map((cell, i) => cell.padEnd(widths[i])).join(" ");
|
|
397
|
+
return [
|
|
398
|
+
chalk.bold(renderRow(cols)),
|
|
399
|
+
chalk.dim(renderRow(widths.map((w) => "─".repeat(w)))),
|
|
400
|
+
...data.map(renderRow),
|
|
401
|
+
].join("\n");
|
|
402
|
+
}
|
|
286
403
|
// ── CLI registration ────────────────────────────────────────────────────────
|
|
287
404
|
const defaultS3Factory = ({ region, credentials }) => new S3Client({ region, credentials });
|
|
288
405
|
/**
|
|
@@ -447,6 +564,33 @@ export function registerFilesBrowseCommands(filesCmd) {
|
|
|
447
564
|
process.exit(1);
|
|
448
565
|
}
|
|
449
566
|
});
|
|
567
|
+
filesCmd
|
|
568
|
+
.command("shared-with-me")
|
|
569
|
+
.description("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).")
|
|
570
|
+
.option("--company <slug>", "Scope to a single company (defaults to a cross-company roll-up).")
|
|
571
|
+
.action(async (options) => {
|
|
572
|
+
try {
|
|
573
|
+
const accessToken = await ensureCognitoToken();
|
|
574
|
+
const vaultConfig = buildVaultConfig(accessToken);
|
|
575
|
+
const client = new VaultClient(vaultConfig);
|
|
576
|
+
let companyUid;
|
|
577
|
+
if (options.company) {
|
|
578
|
+
// Confirm membership + resolve UID, same early-failure pattern as
|
|
579
|
+
// browse/cat. Roll-up mode skips this and fans out internally.
|
|
580
|
+
companyUid = await getCompanyUid(accessToken, options.company);
|
|
581
|
+
}
|
|
582
|
+
const rows = await runSharedWithMe({
|
|
583
|
+
vaultClient: client,
|
|
584
|
+
companyUid,
|
|
585
|
+
companySlug: options.company,
|
|
586
|
+
});
|
|
587
|
+
console.log(formatSharedWithMeTable(rows));
|
|
588
|
+
}
|
|
589
|
+
catch (err) {
|
|
590
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
591
|
+
process.exit(1);
|
|
592
|
+
}
|
|
593
|
+
});
|
|
450
594
|
}
|
|
451
595
|
//# sourceMappingURL=files-browse.js.map
|
|
452
|
-
//# debugId=
|
|
596
|
+
//# debugId=e2f338ce-5871-59ad-ae56-99bd29e1711c
|
|
@@ -37,11 +37,11 @@
|
|
|
37
37
|
* `file:../hq-cloud` via `pnpm.overrides`.
|
|
38
38
|
*/
|
|
39
39
|
|
|
40
|
-
!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]="
|
|
40
|
+
!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]="8cdf980e-e3cf-585c-b2e5-1615765675a1")}catch(e){}}();
|
|
41
41
|
import chalk from "chalk";
|
|
42
42
|
import * as readline from "node:readline";
|
|
43
43
|
import * as fs from "node:fs";
|
|
44
|
-
import { VaultClient, coalescePrefixes, readJournal, writeJournal, tombstoneEntry, } from "@indigoai-us/hq-cloud";
|
|
44
|
+
import { VaultClient, coalescePrefixes, grantPathToPrefix, readJournal, writeJournal, tombstoneEntry, } from "@indigoai-us/hq-cloud";
|
|
45
45
|
import { DEFAULT_HQ_ROOT, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
|
|
46
46
|
import { readActiveCompanySlug } from "./sync-mode.js";
|
|
47
47
|
import { buildNarrowPlan, formatBytes, formatNarrowPlanSummary, } from "../lib/local-tree-diff.js";
|
|
@@ -115,7 +115,18 @@ export async function computeNarrowPlan(input) {
|
|
|
115
115
|
const { hqRoot, companySlug, companyUid, vaultClient } = input;
|
|
116
116
|
const io = input.journalIO ?? realJournalIO;
|
|
117
117
|
const grants = await vaultClient.listMyExplicitGrants(companyUid);
|
|
118
|
-
|
|
118
|
+
// Normalize each grant into a company-relative, startsWith-friendly prefix
|
|
119
|
+
// (grantPathToPrefix, hq-cloud ≥5.42.0): real grants are anchored
|
|
120
|
+
// (`companies/<slug>/x/*`, `<slug>/x/*`) and glob-style (`x/*`, bare `*`),
|
|
121
|
+
// none of which startsWith-match the company-relative local-tree keys
|
|
122
|
+
// buildNarrowPlan emits. A wildcard grant normalizes to "" (everything);
|
|
123
|
+
// coalescePrefixes drops empties, so guard it explicitly to `[""]` (which
|
|
124
|
+
// isCoveredByAny treats as covering everything → nothing orphaned) rather
|
|
125
|
+
// than letting it collapse to "nothing" and propose deleting the tree.
|
|
126
|
+
const normalizedPrefixes = grants.map((g) => grantPathToPrefix(g.path, companySlug));
|
|
127
|
+
const prospectivePrefixSet = normalizedPrefixes.some((p) => p === "")
|
|
128
|
+
? [""]
|
|
129
|
+
: coalescePrefixes(normalizedPrefixes);
|
|
119
130
|
const journal = io.read(companySlug);
|
|
120
131
|
const plan = buildNarrowPlan({
|
|
121
132
|
hqRoot,
|
|
@@ -324,4 +335,4 @@ export function registerSyncNarrowCommand(syncCmd) {
|
|
|
324
335
|
});
|
|
325
336
|
}
|
|
326
337
|
//# sourceMappingURL=sync-narrow.js.map
|
|
327
|
-
//# debugId=
|
|
338
|
+
//# debugId=8cdf980e-e3cf-585c-b2e5-1615765675a1
|
|
@@ -26,7 +26,11 @@
|
|
|
26
26
|
import { type SyncJournal } from "@indigoai-us/hq-cloud";
|
|
27
27
|
export type DirtyReason = "modified-after-sync" | "hash-mismatch" | "not-in-journal" | "stat-error";
|
|
28
28
|
export interface NarrowFile {
|
|
29
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* COMPANY-RELATIVE path (e.g. `meetings/a.md`) — the canonical namespace
|
|
31
|
+
* shared by the per-company journal keys, the vault S3 keys, and the
|
|
32
|
+
* server's explicit-grant paths. NOT hq-root-relative.
|
|
33
|
+
*/
|
|
30
34
|
relPath: string;
|
|
31
35
|
/** Absolute path on disk (convenience for the CLI delete loop). */
|
|
32
36
|
absPath: string;
|
|
@@ -57,7 +61,8 @@ export interface BuildNarrowPlanInput {
|
|
|
57
61
|
/**
|
|
58
62
|
* Coalesced prospective `shared`-mode prefix set (the result of running
|
|
59
63
|
* the caller's explicit grants through `coalescePrefixes`). Prefixes are
|
|
60
|
-
*
|
|
64
|
+
* COMPANY-RELATIVE (e.g. `meetings/`) — the namespace the grants endpoint
|
|
65
|
+
* returns and the namespace `relPath` is now computed in.
|
|
61
66
|
*/
|
|
62
67
|
prospectivePrefixSet: readonly string[];
|
|
63
68
|
/**
|