@indigoai-us/hq-cli 5.25.1 → 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.
@@ -0,0 +1,222 @@
1
+ import { Command } from "commander";
2
+ import chalk from "chalk";
3
+ import { readFileSync } from "node:fs";
4
+ import { ensureCognitoToken } from "../utils/cognito-session.js";
5
+ import { vaultApiFetch } from "../utils/vault-api.js";
6
+
7
+ const EMAIL_PATTERN = /^[^\s]+@[^\s]+$/;
8
+ const PERSON_UID_PATTERN = /^prs_[A-Za-z0-9_-]+$/;
9
+
10
+ export interface DmRecipient {
11
+ toEmail?: string;
12
+ toPersonUid?: string;
13
+ }
14
+
15
+ /**
16
+ * Classify a recipient arg as an email or a personUid. Mirrors the
17
+ * email/prs_ heuristic used by `hq members`. Returns null for neither.
18
+ */
19
+ export function detectRecipient(recipient: string): DmRecipient | null {
20
+ const r = recipient.trim();
21
+ if (EMAIL_PATTERN.test(r)) return { toEmail: r.toLowerCase() };
22
+ if (PERSON_UID_PATTERN.test(r)) return { toPersonUid: r };
23
+ return null;
24
+ }
25
+
26
+ /**
27
+ * Parse a relative duration like `30s`, `10m`, `2h`, `1d` into milliseconds.
28
+ * Returns null on anything that doesn't match. Pure → unit-testable.
29
+ */
30
+ export function parseDuration(input: string): number | null {
31
+ const m = /^(\d+)\s*(s|m|h|d)$/.exec(input.trim());
32
+ if (!m) return null;
33
+ const n = parseInt(m[1], 10);
34
+ const mult: Record<string, number> = {
35
+ s: 1000,
36
+ m: 60_000,
37
+ h: 3_600_000,
38
+ d: 86_400_000,
39
+ };
40
+ return n * mult[m[2]];
41
+ }
42
+
43
+ export interface DmSendBody {
44
+ toEmail?: string;
45
+ toPersonUid?: string;
46
+ body: string;
47
+ prompt?: string;
48
+ details?: string;
49
+ deliverAt?: string;
50
+ }
51
+
52
+ /**
53
+ * Build the POST /v1/notify/dm request body from CLI inputs. Pure (no I/O,
54
+ * no clock) so the option-resolution logic is unit-testable; the caller
55
+ * supplies `now` for the `--in` relative-delay computation.
56
+ *
57
+ * Throws Error with a user-facing message on invalid input.
58
+ */
59
+ export function buildDmBody(args: {
60
+ recipient: string;
61
+ message: string;
62
+ prompt?: string;
63
+ details?: string;
64
+ at?: string;
65
+ inDelay?: string;
66
+ now: number;
67
+ }): DmSendBody {
68
+ const rcpt = detectRecipient(args.recipient);
69
+ if (!rcpt) {
70
+ throw new Error(
71
+ `Invalid recipient '${args.recipient}': must be an email address or a personUid (prs_…).`,
72
+ );
73
+ }
74
+ const body = (args.message ?? "").trim();
75
+ if (!body) {
76
+ throw new Error("A message body is required: hq dm <recipient> <message>");
77
+ }
78
+
79
+ if (args.at && args.inDelay) {
80
+ throw new Error("Use only one of --at or --in, not both.");
81
+ }
82
+ let deliverAt: string | undefined;
83
+ if (args.at) {
84
+ const when = new Date(args.at);
85
+ if (isNaN(when.getTime())) {
86
+ throw new Error(`Invalid --at '${args.at}': must be an ISO8601 date.`);
87
+ }
88
+ deliverAt = when.toISOString();
89
+ } else if (args.inDelay) {
90
+ const ms = parseDuration(args.inDelay);
91
+ if (ms === null) {
92
+ throw new Error(
93
+ `Invalid --in '${args.inDelay}': use a relative delay like 30s, 10m, 2h, 1d.`,
94
+ );
95
+ }
96
+ deliverAt = new Date(args.now + ms).toISOString();
97
+ }
98
+
99
+ const prompt = args.prompt?.trim();
100
+ const details = args.details?.trim();
101
+
102
+ return {
103
+ ...rcpt,
104
+ body,
105
+ ...(prompt ? { prompt } : {}),
106
+ ...(details ? { details } : {}),
107
+ ...(deliverAt ? { deliverAt } : {}),
108
+ };
109
+ }
110
+
111
+ function friendlyDmError(status: number, code: string | undefined, fallback: string): string {
112
+ if (status === 401) return "Not authenticated — run `hq login` and try again.";
113
+ if (status === 404 || code === "RECIPIENT_NOT_FOUND") {
114
+ return "Recipient not found or not reachable — you can only DM someone you share an active company with.";
115
+ }
116
+ if (status >= 500) return `Server error: ${fallback}`;
117
+ return fallback;
118
+ }
119
+
120
+ export function registerDmCommand(program: Command): void {
121
+ program
122
+ .command("dm <recipient> [message]")
123
+ .description(
124
+ "Send a direct message to a teammate (email or personUid). They receive it as an HQ Sync notification.",
125
+ )
126
+ .option(
127
+ "--prompt <text>",
128
+ "Agent-context prompt the recipient can one-click copy into their agent",
129
+ )
130
+ .option("--prompt-file <path>", "Read the agent prompt from a file")
131
+ .option(
132
+ "--details <text>",
133
+ "Longer detail shown in the recipient's DM detail window",
134
+ )
135
+ .option("--details-file <path>", "Read the details from a file")
136
+ .option(
137
+ "--at <iso>",
138
+ "Schedule delivery at an ISO8601 time (store-and-forward; delivered within ~60s of the time)",
139
+ )
140
+ .option(
141
+ "--in <duration>",
142
+ "Schedule delivery after a relative delay: 30s, 10m, 2h, 1d",
143
+ )
144
+ .action(
145
+ async (
146
+ recipient: string,
147
+ message: string | undefined,
148
+ opts: {
149
+ prompt?: string;
150
+ promptFile?: string;
151
+ details?: string;
152
+ detailsFile?: string;
153
+ at?: string;
154
+ in?: string;
155
+ },
156
+ ) => {
157
+ try {
158
+ // Resolve prompt/details from inline text or a file.
159
+ let prompt = opts.prompt;
160
+ if (opts.promptFile) prompt = readFileSync(opts.promptFile, "utf8");
161
+ let details = opts.details;
162
+ if (opts.detailsFile) details = readFileSync(opts.detailsFile, "utf8");
163
+
164
+ const reqBody = buildDmBody({
165
+ recipient,
166
+ message: message ?? "",
167
+ prompt,
168
+ details,
169
+ at: opts.at,
170
+ inDelay: opts.in,
171
+ now: Date.now(),
172
+ });
173
+
174
+ const token = await ensureCognitoToken();
175
+ const res = await vaultApiFetch({
176
+ token,
177
+ path: "/v1/notify/dm",
178
+ method: "POST",
179
+ body: reqBody as unknown as Record<string, unknown>,
180
+ });
181
+
182
+ if (!res.ok) {
183
+ const err = (await res.json().catch(() => ({}))) as Record<string, string>;
184
+ console.error(
185
+ chalk.red(
186
+ friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText),
187
+ ),
188
+ );
189
+ process.exit(1);
190
+ }
191
+
192
+ const data = (await res.json()) as {
193
+ eventId?: string;
194
+ createdAt?: string;
195
+ scheduled?: boolean;
196
+ deliverAt?: string;
197
+ };
198
+
199
+ if (data.scheduled) {
200
+ console.log(
201
+ chalk.green(
202
+ `Scheduled DM to ${recipient} for ${data.deliverAt} (eventId ${data.eventId}).`,
203
+ ),
204
+ );
205
+ console.log(
206
+ chalk.dim("It delivers within ~60s of that time, even if you're offline."),
207
+ );
208
+ } else {
209
+ console.log(
210
+ chalk.green(`DM sent to ${recipient} (eventId ${data.eventId}).`),
211
+ );
212
+ }
213
+ } catch (err) {
214
+ console.error(
215
+ chalk.red("Error:"),
216
+ err instanceof Error ? err.message : String(err),
217
+ );
218
+ process.exit(1);
219
+ }
220
+ },
221
+ );
222
+ }
@@ -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({
package/src/index.ts CHANGED
@@ -31,6 +31,7 @@ import { registerGroupsCommand } from "./commands/groups.js";
31
31
  import { registerFilesCommand } from "./commands/files.js";
32
32
  import { registerFilesBrowseCommands } from "./commands/files-browse.js";
33
33
  import { registerMembersCommand } from "./commands/members.js";
34
+ import { registerDmCommand } from "./commands/dm.js";
34
35
  import { registerFeedbackCommand } from "./commands/feedback.js";
35
36
  import { registerMeetingsCommand } from "./commands/meetings.js";
36
37
  import { registerSourcesCommand } from "./commands/sources.js";
@@ -138,6 +139,7 @@ registerFilesBrowseCommands(filesCmd);
138
139
 
139
140
  // Membership management (subcommand group — hq members invite|list|revoke)
140
141
  registerMembersCommand(program);
142
+ registerDmCommand(program);
141
143
 
142
144
  // Onboarding (top-level — Cognito + vault-service provisioning)
143
145
  registerOnboardCommand(program);