@indigoai-us/hq-cli 5.26.0 → 5.28.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 +49 -0
- package/dist/commands/files-browse.js +102 -2
- 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 +89 -0
- package/src/commands/files-browse.ts +157 -0
- 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
|
@@ -205,6 +205,55 @@ export interface RunCatResult {
|
|
|
205
205
|
* containment guard). Refuses ahead of any I/O when `--out` is unsafe.
|
|
206
206
|
*/
|
|
207
207
|
export declare function runCat(input: RunCatInput): Promise<RunCatResult>;
|
|
208
|
+
/**
|
|
209
|
+
* Subset of `VaultClient` the `shared-with-me` orchestrator uses. No vend / S3
|
|
210
|
+
* — this is a pure read of the caller's explicit-grant graph, so it never
|
|
211
|
+
* touches the credential/browse vend surface.
|
|
212
|
+
*/
|
|
213
|
+
export interface FilesSharedWithMeVaultClient {
|
|
214
|
+
listMyMemberships(): Promise<Array<{
|
|
215
|
+
companyUid: string;
|
|
216
|
+
}>>;
|
|
217
|
+
listMyExplicitGrants(companyUid: string): Promise<ExplicitGrant[]>;
|
|
218
|
+
entity: {
|
|
219
|
+
get(uid: string): Promise<{
|
|
220
|
+
uid: string;
|
|
221
|
+
slug: string;
|
|
222
|
+
name?: string;
|
|
223
|
+
}>;
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
export interface SharedWithMeRow {
|
|
227
|
+
companySlug: string;
|
|
228
|
+
/** Company-relative grant path (e.g. `knowledge/`, `reports/q3.pdf`). */
|
|
229
|
+
path: string;
|
|
230
|
+
permission: ExplicitGrant["permission"];
|
|
231
|
+
source: ExplicitGrant["source"];
|
|
232
|
+
}
|
|
233
|
+
export interface RunSharedWithMeInput {
|
|
234
|
+
vaultClient: FilesSharedWithMeVaultClient;
|
|
235
|
+
/**
|
|
236
|
+
* Scope to a single company by UID. When omitted, rolls up across every
|
|
237
|
+
* company the caller has a membership in (the cross-company "what's shared
|
|
238
|
+
* with me everywhere" view).
|
|
239
|
+
*/
|
|
240
|
+
companyUid?: string;
|
|
241
|
+
/** Display slug for the single-company case (avoids an extra entity.get). */
|
|
242
|
+
companySlug?: string;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* `hq files shared-with-me` orchestrator. Lists the caller's EXPLICIT
|
|
246
|
+
* file-ACL grants — the canonical "what's been shared with me" surface.
|
|
247
|
+
* Role-bypass access (owner/admin) is intentionally excluded server-side by
|
|
248
|
+
* `listMyExplicitGrants`, so this shows real grants, not role-implied reach.
|
|
249
|
+
*
|
|
250
|
+
* Pure data — no console output, no S3, no vend. The caller renders + exits.
|
|
251
|
+
*/
|
|
252
|
+
export declare function runSharedWithMe(input: RunSharedWithMeInput): Promise<SharedWithMeRow[]>;
|
|
253
|
+
/**
|
|
254
|
+
* Render `shared-with-me` rows as a padded table. Mirrors `formatBrowseTable`.
|
|
255
|
+
*/
|
|
256
|
+
export declare function formatSharedWithMeTable(rows: SharedWithMeRow[]): string;
|
|
208
257
|
/**
|
|
209
258
|
* Wire `hq files browse` + `hq files cat` onto an existing `files`
|
|
210
259
|
* Commander group. `registerFilesCommand` in files.ts builds the group
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* `pnpm.overrides` until that release ships to npm.
|
|
30
30
|
*/
|
|
31
31
|
|
|
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]="
|
|
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]="102456e4-666a-5484-b432-7f0b73275818")}catch(e){}}();
|
|
33
33
|
import chalk from "chalk";
|
|
34
34
|
import * as fs from "node:fs";
|
|
35
35
|
import * as path from "node:path";
|
|
@@ -283,6 +283,79 @@ export async function runCat(input) {
|
|
|
283
283
|
await pipeline(body, input.stdout ?? process.stdout);
|
|
284
284
|
return { bytesWritten, destination: { kind: "stdout" }, vend };
|
|
285
285
|
}
|
|
286
|
+
/**
|
|
287
|
+
* `hq files shared-with-me` orchestrator. Lists the caller's EXPLICIT
|
|
288
|
+
* file-ACL grants — the canonical "what's been shared with me" surface.
|
|
289
|
+
* Role-bypass access (owner/admin) is intentionally excluded server-side by
|
|
290
|
+
* `listMyExplicitGrants`, so this shows real grants, not role-implied reach.
|
|
291
|
+
*
|
|
292
|
+
* Pure data — no console output, no S3, no vend. The caller renders + exits.
|
|
293
|
+
*/
|
|
294
|
+
export async function runSharedWithMe(input) {
|
|
295
|
+
const { vaultClient } = input;
|
|
296
|
+
// Resolve the (companyUid, slug) pairs to query. Single-company when a UID
|
|
297
|
+
// was supplied; otherwise fan out across every membership.
|
|
298
|
+
let targets;
|
|
299
|
+
if (input.companyUid) {
|
|
300
|
+
targets = [{ uid: input.companyUid, slug: input.companySlug ?? input.companyUid }];
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
const memberships = await vaultClient.listMyMemberships();
|
|
304
|
+
targets = await Promise.all(memberships.map(async (m) => {
|
|
305
|
+
try {
|
|
306
|
+
const ent = await vaultClient.entity.get(m.companyUid);
|
|
307
|
+
return { uid: m.companyUid, slug: ent.slug || m.companyUid };
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
// Entity not visible — fall back to the UID as the display label
|
|
311
|
+
// rather than dropping the company's grants entirely.
|
|
312
|
+
return { uid: m.companyUid, slug: m.companyUid };
|
|
313
|
+
}
|
|
314
|
+
}));
|
|
315
|
+
}
|
|
316
|
+
const rows = [];
|
|
317
|
+
for (const t of targets) {
|
|
318
|
+
let grants;
|
|
319
|
+
try {
|
|
320
|
+
grants = await vaultClient.listMyExplicitGrants(t.uid);
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
// A single company's grant fetch failing shouldn't sink the whole
|
|
324
|
+
// roll-up — skip it and continue (best-effort discovery view).
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
for (const g of grants) {
|
|
328
|
+
rows.push({
|
|
329
|
+
companySlug: t.slug,
|
|
330
|
+
path: g.path,
|
|
331
|
+
permission: g.permission,
|
|
332
|
+
source: g.source,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// Stable sort: company, then path — deterministic output for humans + tests.
|
|
337
|
+
rows.sort((a, b) => a.companySlug === b.companySlug
|
|
338
|
+
? a.path.localeCompare(b.path)
|
|
339
|
+
: a.companySlug.localeCompare(b.companySlug));
|
|
340
|
+
return rows;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Render `shared-with-me` rows as a padded table. Mirrors `formatBrowseTable`.
|
|
344
|
+
*/
|
|
345
|
+
export function formatSharedWithMeTable(rows) {
|
|
346
|
+
if (rows.length === 0) {
|
|
347
|
+
return "Nothing is explicitly shared with you. (Owner/admin role-bypass access is not listed here — only explicit grants.)";
|
|
348
|
+
}
|
|
349
|
+
const cols = ["COMPANY", "PATH", "PERMISSION", "SOURCE"];
|
|
350
|
+
const data = rows.map((r) => [r.companySlug, r.path, r.permission, r.source]);
|
|
351
|
+
const widths = cols.map((c, i) => Math.max(c.length, ...data.map((row) => row[i].length)));
|
|
352
|
+
const renderRow = (row) => row.map((cell, i) => cell.padEnd(widths[i])).join(" ");
|
|
353
|
+
return [
|
|
354
|
+
chalk.bold(renderRow(cols)),
|
|
355
|
+
chalk.dim(renderRow(widths.map((w) => "─".repeat(w)))),
|
|
356
|
+
...data.map(renderRow),
|
|
357
|
+
].join("\n");
|
|
358
|
+
}
|
|
286
359
|
// ── CLI registration ────────────────────────────────────────────────────────
|
|
287
360
|
const defaultS3Factory = ({ region, credentials }) => new S3Client({ region, credentials });
|
|
288
361
|
/**
|
|
@@ -447,6 +520,33 @@ export function registerFilesBrowseCommands(filesCmd) {
|
|
|
447
520
|
process.exit(1);
|
|
448
521
|
}
|
|
449
522
|
});
|
|
523
|
+
filesCmd
|
|
524
|
+
.command("shared-with-me")
|
|
525
|
+
.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).")
|
|
526
|
+
.option("--company <slug>", "Scope to a single company (defaults to a cross-company roll-up).")
|
|
527
|
+
.action(async (options) => {
|
|
528
|
+
try {
|
|
529
|
+
const accessToken = await ensureCognitoToken();
|
|
530
|
+
const vaultConfig = buildVaultConfig(accessToken);
|
|
531
|
+
const client = new VaultClient(vaultConfig);
|
|
532
|
+
let companyUid;
|
|
533
|
+
if (options.company) {
|
|
534
|
+
// Confirm membership + resolve UID, same early-failure pattern as
|
|
535
|
+
// browse/cat. Roll-up mode skips this and fans out internally.
|
|
536
|
+
companyUid = await getCompanyUid(accessToken, options.company);
|
|
537
|
+
}
|
|
538
|
+
const rows = await runSharedWithMe({
|
|
539
|
+
vaultClient: client,
|
|
540
|
+
companyUid,
|
|
541
|
+
companySlug: options.company,
|
|
542
|
+
});
|
|
543
|
+
console.log(formatSharedWithMeTable(rows));
|
|
544
|
+
}
|
|
545
|
+
catch (err) {
|
|
546
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
547
|
+
process.exit(1);
|
|
548
|
+
}
|
|
549
|
+
});
|
|
450
550
|
}
|
|
451
551
|
//# sourceMappingURL=files-browse.js.map
|
|
452
|
-
//# debugId=
|
|
552
|
+
//# debugId=102456e4-666a-5484-b432-7f0b73275818
|
|
@@ -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
|
/**
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* for the destructive side effects (delete, tombstone, PUT sync-config).
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
!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]="
|
|
27
|
+
!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]="20a280f3-0fed-5868-9ad4-fcc562253486")}catch(e){}}();
|
|
28
28
|
import * as fs from "node:fs";
|
|
29
29
|
import * as path from "node:path";
|
|
30
30
|
import { hashFile, isCoveredByAny, } from "@indigoai-us/hq-cloud";
|
|
@@ -55,7 +55,16 @@ export function buildNarrowPlan(input) {
|
|
|
55
55
|
if (!fs.existsSync(walkRoot)) {
|
|
56
56
|
return emptyPlan();
|
|
57
57
|
}
|
|
58
|
-
|
|
58
|
+
// Walk with `walkRoot` as the rel-root so each file's `relPath` is
|
|
59
|
+
// COMPANY-RELATIVE (e.g. `meetings/a.md`) — the same namespace as the
|
|
60
|
+
// server's explicit-grant paths, the per-company journal keys, and the
|
|
61
|
+
// hq-cloud sync engine's `RemoteFile.key`. Computing it relative to `hqRoot`
|
|
62
|
+
// (the old behavior) produced `companies/<slug>/meetings/a.md`, which
|
|
63
|
+
// matched neither the company-relative grants nor the journal keys — so
|
|
64
|
+
// every file fell out of `prospectivePrefixSet` AND missed its journal
|
|
65
|
+
// entry, making narrow flag the entire tree as dirty orphans. See the
|
|
66
|
+
// namespace contract in hq-cloud `scope-shrink.ts` / `prefix-coalesce.ts`.
|
|
67
|
+
walkLocal(walkRoot, walkRoot, (file) => {
|
|
59
68
|
if (isCoveredByAny(file.relPath, prospectivePrefixSet)) {
|
|
60
69
|
staying.push(file);
|
|
61
70
|
return;
|
|
@@ -100,7 +109,10 @@ function emptyPlan() {
|
|
|
100
109
|
* Uses `lstat` rather than `stat` so a symlink's size doesn't follow the
|
|
101
110
|
* target chain (matches the share-engine convention).
|
|
102
111
|
*/
|
|
103
|
-
function walkLocal(dir,
|
|
112
|
+
function walkLocal(dir,
|
|
113
|
+
// Rel-root for `relPath` — the company walk root (`<hqRoot>/companies/<slug>`),
|
|
114
|
+
// so emitted `relPath`s are company-relative. Fixed across recursion.
|
|
115
|
+
relRoot, emit) {
|
|
104
116
|
let entries;
|
|
105
117
|
try {
|
|
106
118
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -115,7 +127,7 @@ function walkLocal(dir, hqRoot, emit) {
|
|
|
115
127
|
}
|
|
116
128
|
for (const entry of entries) {
|
|
117
129
|
const absPath = path.join(dir, entry.name);
|
|
118
|
-
const relPath = path.relative(
|
|
130
|
+
const relPath = path.relative(relRoot, absPath);
|
|
119
131
|
if (entry.isSymbolicLink()) {
|
|
120
132
|
// Record the link as a file-like entry. Don't descend — narrow is
|
|
121
133
|
// about pruning files that the LOCAL tree has materialized here; a
|
|
@@ -132,7 +144,7 @@ function walkLocal(dir, hqRoot, emit) {
|
|
|
132
144
|
continue;
|
|
133
145
|
}
|
|
134
146
|
if (entry.isDirectory()) {
|
|
135
|
-
walkLocal(absPath,
|
|
147
|
+
walkLocal(absPath, relRoot, emit);
|
|
136
148
|
continue;
|
|
137
149
|
}
|
|
138
150
|
if (entry.isFile()) {
|
|
@@ -241,4 +253,4 @@ export function formatBytes(n) {
|
|
|
241
253
|
return `${v.toFixed(2)} ${units[i]}`;
|
|
242
254
|
}
|
|
243
255
|
//# sourceMappingURL=local-tree-diff.js.map
|
|
244
|
-
//# debugId=
|
|
256
|
+
//# debugId=20a280f3-0fed-5868-9ad4-fcc562253486
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.28.0",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"clean": "rm -rf dist"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@indigoai-us/hq-cloud": "~5.
|
|
18
|
+
"@indigoai-us/hq-cloud": "~5.42.0",
|
|
19
19
|
"@indigoai-us/hq-onboarding": "^0.1.0",
|
|
20
20
|
"@sentry/node": "^10.49.0",
|
|
21
21
|
"chalk": "^5.3.0",
|
|
@@ -27,8 +27,11 @@ import {
|
|
|
27
27
|
parseCompanySlugFromPath,
|
|
28
28
|
runBrowse,
|
|
29
29
|
runCat,
|
|
30
|
+
runSharedWithMe,
|
|
31
|
+
formatSharedWithMeTable,
|
|
30
32
|
type FilesBrowseS3Client,
|
|
31
33
|
type FilesBrowseVaultClient,
|
|
34
|
+
type FilesSharedWithMeVaultClient,
|
|
32
35
|
type S3ClientFactory,
|
|
33
36
|
} from "./files-browse.js";
|
|
34
37
|
import type { ExplicitGrant, VendResult } from "@indigoai-us/hq-cloud";
|
|
@@ -685,3 +688,89 @@ describe("runCat", () => {
|
|
|
685
688
|
).rejects.toThrow(/personalMode requires personalUid/);
|
|
686
689
|
});
|
|
687
690
|
});
|
|
691
|
+
|
|
692
|
+
// ── runSharedWithMe ──────────────────────────────────────────────────────────
|
|
693
|
+
|
|
694
|
+
function sharedWithMeClient(opts: {
|
|
695
|
+
memberships?: Array<{ companyUid: string }>;
|
|
696
|
+
grantsByCompany?: Record<string, ExplicitGrant[]>;
|
|
697
|
+
slugByUid?: Record<string, string>;
|
|
698
|
+
}): FilesSharedWithMeVaultClient {
|
|
699
|
+
return {
|
|
700
|
+
listMyMemberships: async () => opts.memberships ?? [],
|
|
701
|
+
listMyExplicitGrants: async (companyUid: string) =>
|
|
702
|
+
opts.grantsByCompany?.[companyUid] ?? [],
|
|
703
|
+
entity: {
|
|
704
|
+
get: async (uid: string) => ({
|
|
705
|
+
uid,
|
|
706
|
+
slug: opts.slugByUid?.[uid] ?? uid,
|
|
707
|
+
}),
|
|
708
|
+
},
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
describe("runSharedWithMe", () => {
|
|
713
|
+
it("lists explicit grants for a single company (companyUid supplied)", async () => {
|
|
714
|
+
const rows = await runSharedWithMe({
|
|
715
|
+
vaultClient: sharedWithMeClient({
|
|
716
|
+
grantsByCompany: {
|
|
717
|
+
cmp_indigo: [fakeGrant("knowledge/"), fakeGrant("reports/q3.pdf")],
|
|
718
|
+
},
|
|
719
|
+
}),
|
|
720
|
+
companyUid: "cmp_indigo",
|
|
721
|
+
companySlug: "indigo",
|
|
722
|
+
});
|
|
723
|
+
expect(rows).toEqual([
|
|
724
|
+
{ companySlug: "indigo", path: "knowledge/", permission: "read", source: "person" },
|
|
725
|
+
{ companySlug: "indigo", path: "reports/q3.pdf", permission: "read", source: "person" },
|
|
726
|
+
]);
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
it("rolls up across every membership when no company is supplied", async () => {
|
|
730
|
+
const rows = await runSharedWithMe({
|
|
731
|
+
vaultClient: sharedWithMeClient({
|
|
732
|
+
memberships: [{ companyUid: "cmp_b" }, { companyUid: "cmp_a" }],
|
|
733
|
+
slugByUid: { cmp_a: "acme", cmp_b: "beta" },
|
|
734
|
+
grantsByCompany: {
|
|
735
|
+
cmp_a: [fakeGrant("docs/")],
|
|
736
|
+
cmp_b: [fakeGrant("shared/")],
|
|
737
|
+
},
|
|
738
|
+
}),
|
|
739
|
+
});
|
|
740
|
+
// Sorted by company slug, then path.
|
|
741
|
+
expect(rows.map((r) => [r.companySlug, r.path])).toEqual([
|
|
742
|
+
["acme", "docs/"],
|
|
743
|
+
["beta", "shared/"],
|
|
744
|
+
]);
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
it("skips a company whose grant fetch throws (best-effort roll-up)", async () => {
|
|
748
|
+
const client: FilesSharedWithMeVaultClient = {
|
|
749
|
+
listMyMemberships: async () => [{ companyUid: "cmp_ok" }, { companyUid: "cmp_bad" }],
|
|
750
|
+
listMyExplicitGrants: async (uid: string) => {
|
|
751
|
+
if (uid === "cmp_bad") throw new Error("boom");
|
|
752
|
+
return [fakeGrant("ok/")];
|
|
753
|
+
},
|
|
754
|
+
entity: { get: async (uid: string) => ({ uid, slug: uid }) },
|
|
755
|
+
};
|
|
756
|
+
const rows = await runSharedWithMe({ vaultClient: client });
|
|
757
|
+
expect(rows).toEqual([
|
|
758
|
+
{ companySlug: "cmp_ok", path: "ok/", permission: "read", source: "person" },
|
|
759
|
+
]);
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
it("formats an empty result with the role-bypass caveat", () => {
|
|
763
|
+
const out = formatSharedWithMeTable([]);
|
|
764
|
+
expect(out).toContain("Nothing is explicitly shared with you");
|
|
765
|
+
expect(out).toContain("role-bypass");
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it("formats rows as a table with company + path + permission + source", () => {
|
|
769
|
+
const out = formatSharedWithMeTable([
|
|
770
|
+
{ companySlug: "indigo", path: "knowledge/", permission: "read", source: "person" },
|
|
771
|
+
]);
|
|
772
|
+
expect(out).toContain("COMPANY");
|
|
773
|
+
expect(out).toContain("indigo");
|
|
774
|
+
expect(out).toContain("knowledge/");
|
|
775
|
+
});
|
|
776
|
+
});
|
|
@@ -484,6 +484,125 @@ export async function runCat(input: RunCatInput): Promise<RunCatResult> {
|
|
|
484
484
|
return { bytesWritten, destination: { kind: "stdout" }, vend };
|
|
485
485
|
}
|
|
486
486
|
|
|
487
|
+
// ── shared-with-me ────────────────────────────────────────────────────────
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Subset of `VaultClient` the `shared-with-me` orchestrator uses. No vend / S3
|
|
491
|
+
* — this is a pure read of the caller's explicit-grant graph, so it never
|
|
492
|
+
* touches the credential/browse vend surface.
|
|
493
|
+
*/
|
|
494
|
+
export interface FilesSharedWithMeVaultClient {
|
|
495
|
+
listMyMemberships(): Promise<Array<{ companyUid: string }>>;
|
|
496
|
+
listMyExplicitGrants(companyUid: string): Promise<ExplicitGrant[]>;
|
|
497
|
+
entity: {
|
|
498
|
+
get(uid: string): Promise<{ uid: string; slug: string; name?: string }>;
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export interface SharedWithMeRow {
|
|
503
|
+
companySlug: string;
|
|
504
|
+
/** Company-relative grant path (e.g. `knowledge/`, `reports/q3.pdf`). */
|
|
505
|
+
path: string;
|
|
506
|
+
permission: ExplicitGrant["permission"];
|
|
507
|
+
source: ExplicitGrant["source"];
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export interface RunSharedWithMeInput {
|
|
511
|
+
vaultClient: FilesSharedWithMeVaultClient;
|
|
512
|
+
/**
|
|
513
|
+
* Scope to a single company by UID. When omitted, rolls up across every
|
|
514
|
+
* company the caller has a membership in (the cross-company "what's shared
|
|
515
|
+
* with me everywhere" view).
|
|
516
|
+
*/
|
|
517
|
+
companyUid?: string;
|
|
518
|
+
/** Display slug for the single-company case (avoids an extra entity.get). */
|
|
519
|
+
companySlug?: string;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* `hq files shared-with-me` orchestrator. Lists the caller's EXPLICIT
|
|
524
|
+
* file-ACL grants — the canonical "what's been shared with me" surface.
|
|
525
|
+
* Role-bypass access (owner/admin) is intentionally excluded server-side by
|
|
526
|
+
* `listMyExplicitGrants`, so this shows real grants, not role-implied reach.
|
|
527
|
+
*
|
|
528
|
+
* Pure data — no console output, no S3, no vend. The caller renders + exits.
|
|
529
|
+
*/
|
|
530
|
+
export async function runSharedWithMe(
|
|
531
|
+
input: RunSharedWithMeInput,
|
|
532
|
+
): Promise<SharedWithMeRow[]> {
|
|
533
|
+
const { vaultClient } = input;
|
|
534
|
+
|
|
535
|
+
// Resolve the (companyUid, slug) pairs to query. Single-company when a UID
|
|
536
|
+
// was supplied; otherwise fan out across every membership.
|
|
537
|
+
let targets: Array<{ uid: string; slug: string }>;
|
|
538
|
+
if (input.companyUid) {
|
|
539
|
+
targets = [{ uid: input.companyUid, slug: input.companySlug ?? input.companyUid }];
|
|
540
|
+
} else {
|
|
541
|
+
const memberships = await vaultClient.listMyMemberships();
|
|
542
|
+
targets = await Promise.all(
|
|
543
|
+
memberships.map(async (m) => {
|
|
544
|
+
try {
|
|
545
|
+
const ent = await vaultClient.entity.get(m.companyUid);
|
|
546
|
+
return { uid: m.companyUid, slug: ent.slug || m.companyUid };
|
|
547
|
+
} catch {
|
|
548
|
+
// Entity not visible — fall back to the UID as the display label
|
|
549
|
+
// rather than dropping the company's grants entirely.
|
|
550
|
+
return { uid: m.companyUid, slug: m.companyUid };
|
|
551
|
+
}
|
|
552
|
+
}),
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const rows: SharedWithMeRow[] = [];
|
|
557
|
+
for (const t of targets) {
|
|
558
|
+
let grants: ExplicitGrant[];
|
|
559
|
+
try {
|
|
560
|
+
grants = await vaultClient.listMyExplicitGrants(t.uid);
|
|
561
|
+
} catch {
|
|
562
|
+
// A single company's grant fetch failing shouldn't sink the whole
|
|
563
|
+
// roll-up — skip it and continue (best-effort discovery view).
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
for (const g of grants) {
|
|
567
|
+
rows.push({
|
|
568
|
+
companySlug: t.slug,
|
|
569
|
+
path: g.path,
|
|
570
|
+
permission: g.permission,
|
|
571
|
+
source: g.source,
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Stable sort: company, then path — deterministic output for humans + tests.
|
|
577
|
+
rows.sort((a, b) =>
|
|
578
|
+
a.companySlug === b.companySlug
|
|
579
|
+
? a.path.localeCompare(b.path)
|
|
580
|
+
: a.companySlug.localeCompare(b.companySlug),
|
|
581
|
+
);
|
|
582
|
+
return rows;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Render `shared-with-me` rows as a padded table. Mirrors `formatBrowseTable`.
|
|
587
|
+
*/
|
|
588
|
+
export function formatSharedWithMeTable(rows: SharedWithMeRow[]): string {
|
|
589
|
+
if (rows.length === 0) {
|
|
590
|
+
return "Nothing is explicitly shared with you. (Owner/admin role-bypass access is not listed here — only explicit grants.)";
|
|
591
|
+
}
|
|
592
|
+
const cols = ["COMPANY", "PATH", "PERMISSION", "SOURCE"];
|
|
593
|
+
const data = rows.map((r) => [r.companySlug, r.path, r.permission, r.source]);
|
|
594
|
+
const widths = cols.map((c, i) =>
|
|
595
|
+
Math.max(c.length, ...data.map((row) => row[i].length)),
|
|
596
|
+
);
|
|
597
|
+
const renderRow = (row: string[]): string =>
|
|
598
|
+
row.map((cell, i) => cell.padEnd(widths[i])).join(" ");
|
|
599
|
+
return [
|
|
600
|
+
chalk.bold(renderRow(cols)),
|
|
601
|
+
chalk.dim(renderRow(widths.map((w) => "─".repeat(w)))),
|
|
602
|
+
...data.map(renderRow),
|
|
603
|
+
].join("\n");
|
|
604
|
+
}
|
|
605
|
+
|
|
487
606
|
// ── CLI registration ────────────────────────────────────────────────────────
|
|
488
607
|
|
|
489
608
|
const defaultS3Factory: S3ClientFactory = ({ region, credentials }) =>
|
|
@@ -736,4 +855,42 @@ export function registerFilesBrowseCommands(filesCmd: Command): void {
|
|
|
736
855
|
process.exit(1);
|
|
737
856
|
}
|
|
738
857
|
});
|
|
858
|
+
|
|
859
|
+
filesCmd
|
|
860
|
+
.command("shared-with-me")
|
|
861
|
+
.description(
|
|
862
|
+
"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).",
|
|
863
|
+
)
|
|
864
|
+
.option(
|
|
865
|
+
"--company <slug>",
|
|
866
|
+
"Scope to a single company (defaults to a cross-company roll-up).",
|
|
867
|
+
)
|
|
868
|
+
.action(async (options: { company?: string }) => {
|
|
869
|
+
try {
|
|
870
|
+
const accessToken = await ensureCognitoToken();
|
|
871
|
+
const vaultConfig = buildVaultConfig(accessToken);
|
|
872
|
+
const client = new VaultClient(vaultConfig);
|
|
873
|
+
|
|
874
|
+
let companyUid: string | undefined;
|
|
875
|
+
if (options.company) {
|
|
876
|
+
// Confirm membership + resolve UID, same early-failure pattern as
|
|
877
|
+
// browse/cat. Roll-up mode skips this and fans out internally.
|
|
878
|
+
companyUid = await getCompanyUid(accessToken, options.company);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const rows = await runSharedWithMe({
|
|
882
|
+
vaultClient: client,
|
|
883
|
+
companyUid,
|
|
884
|
+
companySlug: options.company,
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
console.log(formatSharedWithMeTable(rows));
|
|
888
|
+
} catch (err) {
|
|
889
|
+
console.error(
|
|
890
|
+
chalk.red("Error:"),
|
|
891
|
+
err instanceof Error ? err.message : String(err),
|
|
892
|
+
);
|
|
893
|
+
process.exit(1);
|
|
894
|
+
}
|
|
895
|
+
});
|
|
739
896
|
}
|
|
@@ -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({
|
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
* Uses a tmpdir fixture (same pattern as cloud-demote.test.ts) — vitest +
|
|
5
5
|
* real fs — so the symmetric-diff + dirty-classification logic is exercised
|
|
6
6
|
* end-to-end against an actual journal + filesystem.
|
|
7
|
+
*
|
|
8
|
+
* NAMESPACE CONTRACT (the bug these tests previously encoded): files are
|
|
9
|
+
* written on disk at their hq-root-relative path (`companies/<slug>/...`),
|
|
10
|
+
* but `buildNarrowPlan` emits — and the journal + grants endpoint use —
|
|
11
|
+
* COMPANY-RELATIVE keys (`meetings/a.md`). The `key()` helper derives the
|
|
12
|
+
* company-relative form from the on-disk path so journals, prefix sets, and
|
|
13
|
+
* expectations all speak the same namespace the real server does.
|
|
7
14
|
*/
|
|
8
15
|
|
|
9
16
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
@@ -30,11 +37,23 @@ function writeFile(rel: string, contents: string): { abs: string; rel: string }
|
|
|
30
37
|
return { abs, rel };
|
|
31
38
|
}
|
|
32
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Strip the `companies/<slug>/` prefix from an on-disk hq-root-relative path
|
|
42
|
+
* to get the COMPANY-RELATIVE key — the namespace `buildNarrowPlan` emits and
|
|
43
|
+
* the journal + grants endpoint use.
|
|
44
|
+
*/
|
|
45
|
+
function key(rel: string): string {
|
|
46
|
+
return rel.replace(/^companies\/[^/]+\//, "");
|
|
47
|
+
}
|
|
48
|
+
|
|
33
49
|
function sha256(s: string): string {
|
|
34
50
|
return crypto.createHash("sha256").update(s).digest("hex");
|
|
35
51
|
}
|
|
36
52
|
|
|
37
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Build a journal that "knows about" the given files at their current hash +
|
|
55
|
+
* mtime. Journal keys are COMPANY-RELATIVE (via `key()`), matching the engine.
|
|
56
|
+
*/
|
|
38
57
|
function journalFromFiles(
|
|
39
58
|
files: Array<{ rel: string; contents: string; syncedAt?: string }>,
|
|
40
59
|
): SyncJournal {
|
|
@@ -45,7 +64,7 @@ function journalFromFiles(
|
|
|
45
64
|
pulls: [],
|
|
46
65
|
};
|
|
47
66
|
for (const f of files) {
|
|
48
|
-
j.files[f.rel] = {
|
|
67
|
+
j.files[key(f.rel)] = {
|
|
49
68
|
hash: sha256(f.contents),
|
|
50
69
|
size: Buffer.byteLength(f.contents),
|
|
51
70
|
// Stamp syncedAt slightly in the future so on-disk mtime <= syncedAt
|
|
@@ -97,13 +116,13 @@ describe("buildNarrowPlan — partition by prefix coverage", () => {
|
|
|
97
116
|
const plan = buildNarrowPlan({
|
|
98
117
|
hqRoot: tmpRoot,
|
|
99
118
|
companySlug: "acme",
|
|
100
|
-
prospectivePrefixSet: ["
|
|
119
|
+
prospectivePrefixSet: ["meetings/"],
|
|
101
120
|
journal,
|
|
102
121
|
});
|
|
103
122
|
|
|
104
|
-
expect(plan.staying.map((f) => f.relPath)).toEqual([staying.rel]);
|
|
105
|
-
expect(plan.clean.map((f) => f.relPath)).toEqual([cleanOrphan.rel]);
|
|
106
|
-
expect(plan.dirty.map((f) => f.relPath)).toEqual([dirtyOrphan.rel]);
|
|
123
|
+
expect(plan.staying.map((f) => f.relPath)).toEqual([key(staying.rel)]);
|
|
124
|
+
expect(plan.clean.map((f) => f.relPath)).toEqual([key(cleanOrphan.rel)]);
|
|
125
|
+
expect(plan.dirty.map((f) => f.relPath)).toEqual([key(dirtyOrphan.rel)]);
|
|
107
126
|
expect(plan.dirty[0].reason).toBe("hash-mismatch");
|
|
108
127
|
|
|
109
128
|
expect(plan.totalStayingCount).toBe(1);
|
|
@@ -117,10 +136,10 @@ describe("buildNarrowPlan — partition by prefix coverage", () => {
|
|
|
117
136
|
const plan = buildNarrowPlan({
|
|
118
137
|
hqRoot: tmpRoot,
|
|
119
138
|
companySlug: "acme",
|
|
120
|
-
prospectivePrefixSet: ["
|
|
139
|
+
prospectivePrefixSet: ["meetings/"],
|
|
121
140
|
journal: journalFromFiles([]),
|
|
122
141
|
});
|
|
123
|
-
expect(plan.dirty.map((f) => f.relPath)).toEqual([orphan.rel]);
|
|
142
|
+
expect(plan.dirty.map((f) => f.relPath)).toEqual([key(orphan.rel)]);
|
|
124
143
|
expect(plan.dirty[0].reason).toBe("not-in-journal");
|
|
125
144
|
});
|
|
126
145
|
|
|
@@ -130,16 +149,16 @@ describe("buildNarrowPlan — partition by prefix coverage", () => {
|
|
|
130
149
|
{ rel: orphan.rel, contents: "still here" },
|
|
131
150
|
]);
|
|
132
151
|
// Mark the entry tombstoned post-hoc.
|
|
133
|
-
journal.files[orphan.rel]!.removedAt = new Date().toISOString();
|
|
134
|
-
journal.files[orphan.rel]!.removedReason = "scope_shrink";
|
|
152
|
+
journal.files[key(orphan.rel)]!.removedAt = new Date().toISOString();
|
|
153
|
+
journal.files[key(orphan.rel)]!.removedReason = "scope_shrink";
|
|
135
154
|
|
|
136
155
|
const plan = buildNarrowPlan({
|
|
137
156
|
hqRoot: tmpRoot,
|
|
138
157
|
companySlug: "acme",
|
|
139
|
-
prospectivePrefixSet: ["
|
|
158
|
+
prospectivePrefixSet: ["meetings/"],
|
|
140
159
|
journal,
|
|
141
160
|
});
|
|
142
|
-
expect(plan.dirty.map((f) => f.relPath)).toEqual([orphan.rel]);
|
|
161
|
+
expect(plan.dirty.map((f) => f.relPath)).toEqual([key(orphan.rel)]);
|
|
143
162
|
expect(plan.dirty[0].reason).toBe("not-in-journal");
|
|
144
163
|
});
|
|
145
164
|
|
|
@@ -151,10 +170,10 @@ describe("buildNarrowPlan — partition by prefix coverage", () => {
|
|
|
151
170
|
const plan = buildNarrowPlan({
|
|
152
171
|
hqRoot: tmpRoot,
|
|
153
172
|
companySlug: "acme",
|
|
154
|
-
prospectivePrefixSet: ["
|
|
173
|
+
prospectivePrefixSet: ["meetings/"],
|
|
155
174
|
journal,
|
|
156
175
|
});
|
|
157
|
-
expect(plan.dirty.map((f) => f.relPath)).toEqual([orphan.rel]);
|
|
176
|
+
expect(plan.dirty.map((f) => f.relPath)).toEqual([key(orphan.rel)]);
|
|
158
177
|
expect(plan.dirty[0].reason).toBe("modified-after-sync");
|
|
159
178
|
});
|
|
160
179
|
|
|
@@ -166,7 +185,7 @@ describe("buildNarrowPlan — partition by prefix coverage", () => {
|
|
|
166
185
|
const plan = buildNarrowPlan({
|
|
167
186
|
hqRoot: tmpRoot,
|
|
168
187
|
companySlug: "acme",
|
|
169
|
-
prospectivePrefixSet: ["
|
|
188
|
+
prospectivePrefixSet: ["meetings/"],
|
|
170
189
|
journal: journalFromFiles([
|
|
171
190
|
{ rel: a.rel, contents: "a" },
|
|
172
191
|
{ rel: b.rel, contents: "b" },
|
|
@@ -175,15 +194,15 @@ describe("buildNarrowPlan — partition by prefix coverage", () => {
|
|
|
175
194
|
});
|
|
176
195
|
|
|
177
196
|
const stayingPaths = plan.staying.map((f) => f.relPath).sort();
|
|
178
|
-
expect(stayingPaths).toEqual([a.rel, b.rel].sort());
|
|
179
|
-
expect(plan.clean.map((f) => f.relPath)).toEqual([c.rel]);
|
|
197
|
+
expect(stayingPaths).toEqual([key(a.rel), key(b.rel)].sort());
|
|
198
|
+
expect(plan.clean.map((f) => f.relPath)).toEqual([key(c.rel)]);
|
|
180
199
|
});
|
|
181
200
|
|
|
182
201
|
it("returns an empty plan when the company folder is missing", () => {
|
|
183
202
|
const plan = buildNarrowPlan({
|
|
184
203
|
hqRoot: tmpRoot,
|
|
185
204
|
companySlug: "ghost",
|
|
186
|
-
prospectivePrefixSet: ["
|
|
205
|
+
prospectivePrefixSet: [""],
|
|
187
206
|
journal: journalFromFiles([]),
|
|
188
207
|
});
|
|
189
208
|
expect(plan.totalStayingCount).toBe(0);
|
|
@@ -214,7 +233,8 @@ describe("buildNarrowPlan — partition by prefix coverage", () => {
|
|
|
214
233
|
const plan = buildNarrowPlan({
|
|
215
234
|
hqRoot: tmpRoot,
|
|
216
235
|
companySlug: "acme",
|
|
217
|
-
|
|
236
|
+
// Empty-string prefix = "covers everything" per isCoveredByAny.
|
|
237
|
+
prospectivePrefixSet: [""],
|
|
218
238
|
journal: journalFromFiles([]),
|
|
219
239
|
});
|
|
220
240
|
expect(plan.totalStayingCount).toBe(2);
|
|
@@ -223,7 +243,7 @@ describe("buildNarrowPlan — partition by prefix coverage", () => {
|
|
|
223
243
|
// both are staying, regardless of journal state
|
|
224
244
|
expect(
|
|
225
245
|
plan.staying.map((f) => f.relPath).sort(),
|
|
226
|
-
).toEqual([a.rel, b.rel].sort());
|
|
246
|
+
).toEqual([key(a.rel), key(b.rel)].sort());
|
|
227
247
|
});
|
|
228
248
|
});
|
|
229
249
|
|
|
@@ -43,7 +43,11 @@ export type DirtyReason =
|
|
|
43
43
|
| "stat-error";
|
|
44
44
|
|
|
45
45
|
export interface NarrowFile {
|
|
46
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* COMPANY-RELATIVE path (e.g. `meetings/a.md`) — the canonical namespace
|
|
48
|
+
* shared by the per-company journal keys, the vault S3 keys, and the
|
|
49
|
+
* server's explicit-grant paths. NOT hq-root-relative.
|
|
50
|
+
*/
|
|
47
51
|
relPath: string;
|
|
48
52
|
/** Absolute path on disk (convenience for the CLI delete loop). */
|
|
49
53
|
absPath: string;
|
|
@@ -79,7 +83,8 @@ export interface BuildNarrowPlanInput {
|
|
|
79
83
|
/**
|
|
80
84
|
* Coalesced prospective `shared`-mode prefix set (the result of running
|
|
81
85
|
* the caller's explicit grants through `coalescePrefixes`). Prefixes are
|
|
82
|
-
*
|
|
86
|
+
* COMPANY-RELATIVE (e.g. `meetings/`) — the namespace the grants endpoint
|
|
87
|
+
* returns and the namespace `relPath` is now computed in.
|
|
83
88
|
*/
|
|
84
89
|
prospectivePrefixSet: readonly string[];
|
|
85
90
|
/**
|
|
@@ -120,7 +125,16 @@ export function buildNarrowPlan(input: BuildNarrowPlanInput): NarrowPlan {
|
|
|
120
125
|
return emptyPlan();
|
|
121
126
|
}
|
|
122
127
|
|
|
123
|
-
|
|
128
|
+
// Walk with `walkRoot` as the rel-root so each file's `relPath` is
|
|
129
|
+
// COMPANY-RELATIVE (e.g. `meetings/a.md`) — the same namespace as the
|
|
130
|
+
// server's explicit-grant paths, the per-company journal keys, and the
|
|
131
|
+
// hq-cloud sync engine's `RemoteFile.key`. Computing it relative to `hqRoot`
|
|
132
|
+
// (the old behavior) produced `companies/<slug>/meetings/a.md`, which
|
|
133
|
+
// matched neither the company-relative grants nor the journal keys — so
|
|
134
|
+
// every file fell out of `prospectivePrefixSet` AND missed its journal
|
|
135
|
+
// entry, making narrow flag the entire tree as dirty orphans. See the
|
|
136
|
+
// namespace contract in hq-cloud `scope-shrink.ts` / `prefix-coalesce.ts`.
|
|
137
|
+
walkLocal(walkRoot, walkRoot, (file) => {
|
|
124
138
|
if (isCoveredByAny(file.relPath, prospectivePrefixSet)) {
|
|
125
139
|
staying.push(file);
|
|
126
140
|
return;
|
|
@@ -170,7 +184,9 @@ function emptyPlan(): NarrowPlan {
|
|
|
170
184
|
*/
|
|
171
185
|
function walkLocal(
|
|
172
186
|
dir: string,
|
|
173
|
-
hqRoot
|
|
187
|
+
// Rel-root for `relPath` — the company walk root (`<hqRoot>/companies/<slug>`),
|
|
188
|
+
// so emitted `relPath`s are company-relative. Fixed across recursion.
|
|
189
|
+
relRoot: string,
|
|
174
190
|
emit: (file: NarrowFile) => void,
|
|
175
191
|
): void {
|
|
176
192
|
let entries: fs.Dirent[];
|
|
@@ -186,7 +202,7 @@ function walkLocal(
|
|
|
186
202
|
|
|
187
203
|
for (const entry of entries) {
|
|
188
204
|
const absPath = path.join(dir, entry.name);
|
|
189
|
-
const relPath = path.relative(
|
|
205
|
+
const relPath = path.relative(relRoot, absPath);
|
|
190
206
|
|
|
191
207
|
if (entry.isSymbolicLink()) {
|
|
192
208
|
// Record the link as a file-like entry. Don't descend — narrow is
|
|
@@ -204,7 +220,7 @@ function walkLocal(
|
|
|
204
220
|
}
|
|
205
221
|
|
|
206
222
|
if (entry.isDirectory()) {
|
|
207
|
-
walkLocal(absPath,
|
|
223
|
+
walkLocal(absPath, relRoot, emit);
|
|
208
224
|
continue;
|
|
209
225
|
}
|
|
210
226
|
|