@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.
@@ -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]="55a4ece7-6b33-5809-97c0-475c423bc012")}catch(e){}}();
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=55a4ece7-6b33-5809-97c0-475c423bc012
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]="a6c1e2b9-5d9a-57b2-a80b-adfdbce88206")}catch(e){}}();
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
- const prospectivePrefixSet = coalescePrefixes(grants.map((g) => g.path));
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=a6c1e2b9-5d9a-57b2-a80b-adfdbce88206
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
- /** Path relative to `hqRoot` (matches journal key + S3 key naming). */
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
- * hq-root-relative (e.g. `companies/indigo/meetings/`).
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]="77563dec-63f2-52df-9138-c88bfd1600b9")}catch(e){}}();
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
- walkLocal(walkRoot, hqRoot, (file) => {
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, hqRoot, emit) {
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(hqRoot, absPath);
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, hqRoot, emit);
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=77563dec-63f2-52df-9138-c88bfd1600b9
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.26.0",
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.39.0",
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: "companies/acme/meetings/notes.md", contents: "stays" },
299
- { rel: "companies/acme/scratch/old.md", contents: "clean orphan" },
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("companies/acme/meetings/"),
305
- fakeGrant("companies/acme/meetings/2026/"), // collapsed by coalesce
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
- "companies/acme/meetings/",
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
- const prospectivePrefixSet = coalescePrefixes(grants.map((g) => g.path));
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
- /** Build a journal that "knows about" the given files at their current hash + mtime. */
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: ["companies/acme/meetings/"],
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: ["companies/acme/meetings/"],
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: ["companies/acme/meetings/"],
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: ["companies/acme/meetings/"],
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: ["companies/acme/meetings/"],
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: ["companies/ghost/"],
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
- prospectivePrefixSet: ["companies/acme/"],
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
- /** Path relative to `hqRoot` (matches journal key + S3 key naming). */
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
- * hq-root-relative (e.g. `companies/indigo/meetings/`).
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
- walkLocal(walkRoot, hqRoot, (file) => {
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: string,
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(hqRoot, absPath);
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, hqRoot, emit);
223
+ walkLocal(absPath, relRoot, emit);
208
224
  continue;
209
225
  }
210
226