@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,41 @@
1
+ import { Command } from "commander";
2
+ export interface DmRecipient {
3
+ toEmail?: string;
4
+ toPersonUid?: string;
5
+ }
6
+ /**
7
+ * Classify a recipient arg as an email or a personUid. Mirrors the
8
+ * email/prs_ heuristic used by `hq members`. Returns null for neither.
9
+ */
10
+ export declare function detectRecipient(recipient: string): DmRecipient | null;
11
+ /**
12
+ * Parse a relative duration like `30s`, `10m`, `2h`, `1d` into milliseconds.
13
+ * Returns null on anything that doesn't match. Pure → unit-testable.
14
+ */
15
+ export declare function parseDuration(input: string): number | null;
16
+ export interface DmSendBody {
17
+ toEmail?: string;
18
+ toPersonUid?: string;
19
+ body: string;
20
+ prompt?: string;
21
+ details?: string;
22
+ deliverAt?: string;
23
+ }
24
+ /**
25
+ * Build the POST /v1/notify/dm request body from CLI inputs. Pure (no I/O,
26
+ * no clock) so the option-resolution logic is unit-testable; the caller
27
+ * supplies `now` for the `--in` relative-delay computation.
28
+ *
29
+ * Throws Error with a user-facing message on invalid input.
30
+ */
31
+ export declare function buildDmBody(args: {
32
+ recipient: string;
33
+ message: string;
34
+ prompt?: string;
35
+ details?: string;
36
+ at?: string;
37
+ inDelay?: string;
38
+ now: number;
39
+ }): DmSendBody;
40
+ export declare function registerDmCommand(program: Command): void;
41
+ //# sourceMappingURL=dm.d.ts.map
@@ -0,0 +1,148 @@
1
+
2
+ !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]="69d48194-d5ae-508e-b614-68f2066db6ee")}catch(e){}}();
3
+ import chalk from "chalk";
4
+ import { readFileSync } from "node:fs";
5
+ import { ensureCognitoToken } from "../utils/cognito-session.js";
6
+ import { vaultApiFetch } from "../utils/vault-api.js";
7
+ const EMAIL_PATTERN = /^[^\s]+@[^\s]+$/;
8
+ const PERSON_UID_PATTERN = /^prs_[A-Za-z0-9_-]+$/;
9
+ /**
10
+ * Classify a recipient arg as an email or a personUid. Mirrors the
11
+ * email/prs_ heuristic used by `hq members`. Returns null for neither.
12
+ */
13
+ export function detectRecipient(recipient) {
14
+ const r = recipient.trim();
15
+ if (EMAIL_PATTERN.test(r))
16
+ return { toEmail: r.toLowerCase() };
17
+ if (PERSON_UID_PATTERN.test(r))
18
+ return { toPersonUid: r };
19
+ return null;
20
+ }
21
+ /**
22
+ * Parse a relative duration like `30s`, `10m`, `2h`, `1d` into milliseconds.
23
+ * Returns null on anything that doesn't match. Pure → unit-testable.
24
+ */
25
+ export function parseDuration(input) {
26
+ const m = /^(\d+)\s*(s|m|h|d)$/.exec(input.trim());
27
+ if (!m)
28
+ return null;
29
+ const n = parseInt(m[1], 10);
30
+ const mult = {
31
+ s: 1000,
32
+ m: 60_000,
33
+ h: 3_600_000,
34
+ d: 86_400_000,
35
+ };
36
+ return n * mult[m[2]];
37
+ }
38
+ /**
39
+ * Build the POST /v1/notify/dm request body from CLI inputs. Pure (no I/O,
40
+ * no clock) so the option-resolution logic is unit-testable; the caller
41
+ * supplies `now` for the `--in` relative-delay computation.
42
+ *
43
+ * Throws Error with a user-facing message on invalid input.
44
+ */
45
+ export function buildDmBody(args) {
46
+ const rcpt = detectRecipient(args.recipient);
47
+ if (!rcpt) {
48
+ throw new Error(`Invalid recipient '${args.recipient}': must be an email address or a personUid (prs_…).`);
49
+ }
50
+ const body = (args.message ?? "").trim();
51
+ if (!body) {
52
+ throw new Error("A message body is required: hq dm <recipient> <message>");
53
+ }
54
+ if (args.at && args.inDelay) {
55
+ throw new Error("Use only one of --at or --in, not both.");
56
+ }
57
+ let deliverAt;
58
+ if (args.at) {
59
+ const when = new Date(args.at);
60
+ if (isNaN(when.getTime())) {
61
+ throw new Error(`Invalid --at '${args.at}': must be an ISO8601 date.`);
62
+ }
63
+ deliverAt = when.toISOString();
64
+ }
65
+ else if (args.inDelay) {
66
+ const ms = parseDuration(args.inDelay);
67
+ if (ms === null) {
68
+ throw new Error(`Invalid --in '${args.inDelay}': use a relative delay like 30s, 10m, 2h, 1d.`);
69
+ }
70
+ deliverAt = new Date(args.now + ms).toISOString();
71
+ }
72
+ const prompt = args.prompt?.trim();
73
+ const details = args.details?.trim();
74
+ return {
75
+ ...rcpt,
76
+ body,
77
+ ...(prompt ? { prompt } : {}),
78
+ ...(details ? { details } : {}),
79
+ ...(deliverAt ? { deliverAt } : {}),
80
+ };
81
+ }
82
+ function friendlyDmError(status, code, fallback) {
83
+ if (status === 401)
84
+ return "Not authenticated — run `hq login` and try again.";
85
+ if (status === 404 || code === "RECIPIENT_NOT_FOUND") {
86
+ return "Recipient not found or not reachable — you can only DM someone you share an active company with.";
87
+ }
88
+ if (status >= 500)
89
+ return `Server error: ${fallback}`;
90
+ return fallback;
91
+ }
92
+ export function registerDmCommand(program) {
93
+ program
94
+ .command("dm <recipient> [message]")
95
+ .description("Send a direct message to a teammate (email or personUid). They receive it as an HQ Sync notification.")
96
+ .option("--prompt <text>", "Agent-context prompt the recipient can one-click copy into their agent")
97
+ .option("--prompt-file <path>", "Read the agent prompt from a file")
98
+ .option("--details <text>", "Longer detail shown in the recipient's DM detail window")
99
+ .option("--details-file <path>", "Read the details from a file")
100
+ .option("--at <iso>", "Schedule delivery at an ISO8601 time (store-and-forward; delivered within ~60s of the time)")
101
+ .option("--in <duration>", "Schedule delivery after a relative delay: 30s, 10m, 2h, 1d")
102
+ .action(async (recipient, message, opts) => {
103
+ try {
104
+ // Resolve prompt/details from inline text or a file.
105
+ let prompt = opts.prompt;
106
+ if (opts.promptFile)
107
+ prompt = readFileSync(opts.promptFile, "utf8");
108
+ let details = opts.details;
109
+ if (opts.detailsFile)
110
+ details = readFileSync(opts.detailsFile, "utf8");
111
+ const reqBody = buildDmBody({
112
+ recipient,
113
+ message: message ?? "",
114
+ prompt,
115
+ details,
116
+ at: opts.at,
117
+ inDelay: opts.in,
118
+ now: Date.now(),
119
+ });
120
+ const token = await ensureCognitoToken();
121
+ const res = await vaultApiFetch({
122
+ token,
123
+ path: "/v1/notify/dm",
124
+ method: "POST",
125
+ body: reqBody,
126
+ });
127
+ if (!res.ok) {
128
+ const err = (await res.json().catch(() => ({})));
129
+ console.error(chalk.red(friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText)));
130
+ process.exit(1);
131
+ }
132
+ const data = (await res.json());
133
+ if (data.scheduled) {
134
+ console.log(chalk.green(`Scheduled DM to ${recipient} for ${data.deliverAt} (eventId ${data.eventId}).`));
135
+ console.log(chalk.dim("It delivers within ~60s of that time, even if you're offline."));
136
+ }
137
+ else {
138
+ console.log(chalk.green(`DM sent to ${recipient} (eventId ${data.eventId}).`));
139
+ }
140
+ }
141
+ catch (err) {
142
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
143
+ process.exit(1);
144
+ }
145
+ });
146
+ }
147
+ //# sourceMappingURL=dm.js.map
148
+ //# debugId=69d48194-d5ae-508e-b614-68f2066db6ee
@@ -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
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * HQ CLI - Module management, package management, and cloud sync for HQ
4
4
  */
5
5
 
6
- !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]="0ed956d3-5fde-54aa-96cb-1859a15f7723")}catch(e){}}();
6
+ !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]="d7693093-4011-58fa-b5be-805b5f9f5421")}catch(e){}}();
7
7
  import { Command } from "commander";
8
8
  import { initSentry, Sentry } from "./sentry.js";
9
9
  import { registerAddCommand } from "./commands/add.js";
@@ -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";
@@ -110,6 +111,7 @@ const filesCmd = registerFilesCommand(program);
110
111
  registerFilesBrowseCommands(filesCmd);
111
112
  // Membership management (subcommand group — hq members invite|list|revoke)
112
113
  registerMembersCommand(program);
114
+ registerDmCommand(program);
113
115
  // Onboarding (top-level — Cognito + vault-service provisioning)
114
116
  registerOnboardCommand(program);
115
117
  // Feedback (subcommand group — hq feedback bug|feature)
@@ -146,4 +148,4 @@ registerSignalsCommand(program);
146
148
  }
147
149
  })();
148
150
  //# sourceMappingURL=index.js.map
149
- //# debugId=0ed956d3-5fde-54aa-96cb-1859a15f7723
151
+ //# debugId=d7693093-4011-58fa-b5be-805b5f9f5421
@@ -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.25.1",
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",
@@ -0,0 +1,88 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { detectRecipient, parseDuration, buildDmBody } from "./dm.js";
3
+
4
+ describe("detectRecipient", () => {
5
+ it("classifies an email", () => {
6
+ expect(detectRecipient("Stefan@Getindigo.ai")).toEqual({
7
+ toEmail: "stefan@getindigo.ai",
8
+ });
9
+ });
10
+ it("classifies a personUid", () => {
11
+ expect(detectRecipient("prs_01ABC")).toEqual({ toPersonUid: "prs_01ABC" });
12
+ });
13
+ it("rejects anything else", () => {
14
+ expect(detectRecipient("not-an-email")).toBeNull();
15
+ expect(detectRecipient("")).toBeNull();
16
+ });
17
+ });
18
+
19
+ describe("parseDuration", () => {
20
+ it("parses units", () => {
21
+ expect(parseDuration("30s")).toBe(30_000);
22
+ expect(parseDuration("10m")).toBe(600_000);
23
+ expect(parseDuration("2h")).toBe(7_200_000);
24
+ expect(parseDuration("1d")).toBe(86_400_000);
25
+ expect(parseDuration(" 5m ")).toBe(300_000);
26
+ });
27
+ it("returns null on garbage", () => {
28
+ expect(parseDuration("soon")).toBeNull();
29
+ expect(parseDuration("10")).toBeNull();
30
+ expect(parseDuration("10x")).toBeNull();
31
+ });
32
+ });
33
+
34
+ describe("buildDmBody", () => {
35
+ const now = Date.parse("2026-05-29T00:00:00.000Z");
36
+
37
+ it("builds an email DM with body", () => {
38
+ expect(
39
+ buildDmBody({ recipient: "a@b.com", message: " hi ", now }),
40
+ ).toEqual({ toEmail: "a@b.com", body: "hi" });
41
+ });
42
+
43
+ it("includes prompt + details when present, omits when blank", () => {
44
+ expect(
45
+ buildDmBody({
46
+ recipient: "prs_x",
47
+ message: "m",
48
+ prompt: "do the thing",
49
+ details: " ",
50
+ now,
51
+ }),
52
+ ).toEqual({ toPersonUid: "prs_x", body: "m", prompt: "do the thing" });
53
+ });
54
+
55
+ it("resolves --in to a future deliverAt", () => {
56
+ const out = buildDmBody({ recipient: "a@b.com", message: "m", inDelay: "10m", now });
57
+ expect(out.deliverAt).toBe("2026-05-29T00:10:00.000Z");
58
+ });
59
+
60
+ it("resolves --at to a normalized ISO deliverAt", () => {
61
+ const out = buildDmBody({
62
+ recipient: "a@b.com",
63
+ message: "m",
64
+ at: "2026-06-01T12:00:00Z",
65
+ now,
66
+ });
67
+ expect(out.deliverAt).toBe("2026-06-01T12:00:00.000Z");
68
+ });
69
+
70
+ it("rejects an invalid recipient", () => {
71
+ expect(() => buildDmBody({ recipient: "nope", message: "m", now })).toThrow(/Invalid recipient/);
72
+ });
73
+
74
+ it("requires a body", () => {
75
+ expect(() => buildDmBody({ recipient: "a@b.com", message: " ", now })).toThrow(/body is required/);
76
+ });
77
+
78
+ it("rejects both --at and --in", () => {
79
+ expect(() =>
80
+ buildDmBody({ recipient: "a@b.com", message: "m", at: "2026-06-01T12:00:00Z", inDelay: "10m", now }),
81
+ ).toThrow(/only one of --at or --in/);
82
+ });
83
+
84
+ it("rejects an invalid --at and --in", () => {
85
+ expect(() => buildDmBody({ recipient: "a@b.com", message: "m", at: "nope", now })).toThrow(/Invalid --at/);
86
+ expect(() => buildDmBody({ recipient: "a@b.com", message: "m", inDelay: "soon", now })).toThrow(/Invalid --in/);
87
+ });
88
+ });