@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.
- package/dist/commands/dm.d.ts +41 -0
- package/dist/commands/dm.js +148 -0
- package/dist/commands/files-browse.d.ts +49 -0
- package/dist/commands/files-browse.js +102 -2
- package/dist/commands/sync-narrow.js +15 -4
- package/dist/index.js +4 -2
- package/dist/lib/local-tree-diff.d.ts +7 -2
- package/dist/lib/local-tree-diff.js +18 -6
- package/package.json +2 -2
- package/src/commands/dm.test.ts +88 -0
- package/src/commands/dm.ts +222 -0
- package/src/commands/files-browse.test.ts +89 -0
- package/src/commands/files-browse.ts +157 -0
- package/src/commands/sync-narrow.test.ts +66 -6
- package/src/commands/sync-narrow.ts +15 -1
- package/src/index.ts +2 -0
- package/src/lib/local-tree-diff.test.ts +40 -20
- package/src/lib/local-tree-diff.ts +22 -6
|
@@ -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]="
|
|
32
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="102456e4-666a-5484-b432-7f0b73275818")}catch(e){}}();
|
|
33
33
|
import chalk from "chalk";
|
|
34
34
|
import * as fs from "node:fs";
|
|
35
35
|
import * as path from "node:path";
|
|
@@ -283,6 +283,79 @@ export async function runCat(input) {
|
|
|
283
283
|
await pipeline(body, input.stdout ?? process.stdout);
|
|
284
284
|
return { bytesWritten, destination: { kind: "stdout" }, vend };
|
|
285
285
|
}
|
|
286
|
+
/**
|
|
287
|
+
* `hq files shared-with-me` orchestrator. Lists the caller's EXPLICIT
|
|
288
|
+
* file-ACL grants — the canonical "what's been shared with me" surface.
|
|
289
|
+
* Role-bypass access (owner/admin) is intentionally excluded server-side by
|
|
290
|
+
* `listMyExplicitGrants`, so this shows real grants, not role-implied reach.
|
|
291
|
+
*
|
|
292
|
+
* Pure data — no console output, no S3, no vend. The caller renders + exits.
|
|
293
|
+
*/
|
|
294
|
+
export async function runSharedWithMe(input) {
|
|
295
|
+
const { vaultClient } = input;
|
|
296
|
+
// Resolve the (companyUid, slug) pairs to query. Single-company when a UID
|
|
297
|
+
// was supplied; otherwise fan out across every membership.
|
|
298
|
+
let targets;
|
|
299
|
+
if (input.companyUid) {
|
|
300
|
+
targets = [{ uid: input.companyUid, slug: input.companySlug ?? input.companyUid }];
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
const memberships = await vaultClient.listMyMemberships();
|
|
304
|
+
targets = await Promise.all(memberships.map(async (m) => {
|
|
305
|
+
try {
|
|
306
|
+
const ent = await vaultClient.entity.get(m.companyUid);
|
|
307
|
+
return { uid: m.companyUid, slug: ent.slug || m.companyUid };
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
// Entity not visible — fall back to the UID as the display label
|
|
311
|
+
// rather than dropping the company's grants entirely.
|
|
312
|
+
return { uid: m.companyUid, slug: m.companyUid };
|
|
313
|
+
}
|
|
314
|
+
}));
|
|
315
|
+
}
|
|
316
|
+
const rows = [];
|
|
317
|
+
for (const t of targets) {
|
|
318
|
+
let grants;
|
|
319
|
+
try {
|
|
320
|
+
grants = await vaultClient.listMyExplicitGrants(t.uid);
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
// A single company's grant fetch failing shouldn't sink the whole
|
|
324
|
+
// roll-up — skip it and continue (best-effort discovery view).
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
for (const g of grants) {
|
|
328
|
+
rows.push({
|
|
329
|
+
companySlug: t.slug,
|
|
330
|
+
path: g.path,
|
|
331
|
+
permission: g.permission,
|
|
332
|
+
source: g.source,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// Stable sort: company, then path — deterministic output for humans + tests.
|
|
337
|
+
rows.sort((a, b) => a.companySlug === b.companySlug
|
|
338
|
+
? a.path.localeCompare(b.path)
|
|
339
|
+
: a.companySlug.localeCompare(b.companySlug));
|
|
340
|
+
return rows;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Render `shared-with-me` rows as a padded table. Mirrors `formatBrowseTable`.
|
|
344
|
+
*/
|
|
345
|
+
export function formatSharedWithMeTable(rows) {
|
|
346
|
+
if (rows.length === 0) {
|
|
347
|
+
return "Nothing is explicitly shared with you. (Owner/admin role-bypass access is not listed here — only explicit grants.)";
|
|
348
|
+
}
|
|
349
|
+
const cols = ["COMPANY", "PATH", "PERMISSION", "SOURCE"];
|
|
350
|
+
const data = rows.map((r) => [r.companySlug, r.path, r.permission, r.source]);
|
|
351
|
+
const widths = cols.map((c, i) => Math.max(c.length, ...data.map((row) => row[i].length)));
|
|
352
|
+
const renderRow = (row) => row.map((cell, i) => cell.padEnd(widths[i])).join(" ");
|
|
353
|
+
return [
|
|
354
|
+
chalk.bold(renderRow(cols)),
|
|
355
|
+
chalk.dim(renderRow(widths.map((w) => "─".repeat(w)))),
|
|
356
|
+
...data.map(renderRow),
|
|
357
|
+
].join("\n");
|
|
358
|
+
}
|
|
286
359
|
// ── CLI registration ────────────────────────────────────────────────────────
|
|
287
360
|
const defaultS3Factory = ({ region, credentials }) => new S3Client({ region, credentials });
|
|
288
361
|
/**
|
|
@@ -447,6 +520,33 @@ export function registerFilesBrowseCommands(filesCmd) {
|
|
|
447
520
|
process.exit(1);
|
|
448
521
|
}
|
|
449
522
|
});
|
|
523
|
+
filesCmd
|
|
524
|
+
.command("shared-with-me")
|
|
525
|
+
.description("List the files/prefixes explicitly shared with you. Omit --company to roll up across every company you're a member of. Pure read — no download, no credentials vended. Owner/admin role-bypass access is NOT listed (only explicit grants).")
|
|
526
|
+
.option("--company <slug>", "Scope to a single company (defaults to a cross-company roll-up).")
|
|
527
|
+
.action(async (options) => {
|
|
528
|
+
try {
|
|
529
|
+
const accessToken = await ensureCognitoToken();
|
|
530
|
+
const vaultConfig = buildVaultConfig(accessToken);
|
|
531
|
+
const client = new VaultClient(vaultConfig);
|
|
532
|
+
let companyUid;
|
|
533
|
+
if (options.company) {
|
|
534
|
+
// Confirm membership + resolve UID, same early-failure pattern as
|
|
535
|
+
// browse/cat. Roll-up mode skips this and fans out internally.
|
|
536
|
+
companyUid = await getCompanyUid(accessToken, options.company);
|
|
537
|
+
}
|
|
538
|
+
const rows = await runSharedWithMe({
|
|
539
|
+
vaultClient: client,
|
|
540
|
+
companyUid,
|
|
541
|
+
companySlug: options.company,
|
|
542
|
+
});
|
|
543
|
+
console.log(formatSharedWithMeTable(rows));
|
|
544
|
+
}
|
|
545
|
+
catch (err) {
|
|
546
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
547
|
+
process.exit(1);
|
|
548
|
+
}
|
|
549
|
+
});
|
|
450
550
|
}
|
|
451
551
|
//# sourceMappingURL=files-browse.js.map
|
|
452
|
-
//# debugId=
|
|
552
|
+
//# debugId=102456e4-666a-5484-b432-7f0b73275818
|
|
@@ -37,11 +37,11 @@
|
|
|
37
37
|
* `file:../hq-cloud` via `pnpm.overrides`.
|
|
38
38
|
*/
|
|
39
39
|
|
|
40
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
40
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="8cdf980e-e3cf-585c-b2e5-1615765675a1")}catch(e){}}();
|
|
41
41
|
import chalk from "chalk";
|
|
42
42
|
import * as readline from "node:readline";
|
|
43
43
|
import * as fs from "node:fs";
|
|
44
|
-
import { VaultClient, coalescePrefixes, readJournal, writeJournal, tombstoneEntry, } from "@indigoai-us/hq-cloud";
|
|
44
|
+
import { VaultClient, coalescePrefixes, grantPathToPrefix, readJournal, writeJournal, tombstoneEntry, } from "@indigoai-us/hq-cloud";
|
|
45
45
|
import { DEFAULT_HQ_ROOT, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
|
|
46
46
|
import { readActiveCompanySlug } from "./sync-mode.js";
|
|
47
47
|
import { buildNarrowPlan, formatBytes, formatNarrowPlanSummary, } from "../lib/local-tree-diff.js";
|
|
@@ -115,7 +115,18 @@ export async function computeNarrowPlan(input) {
|
|
|
115
115
|
const { hqRoot, companySlug, companyUid, vaultClient } = input;
|
|
116
116
|
const io = input.journalIO ?? realJournalIO;
|
|
117
117
|
const grants = await vaultClient.listMyExplicitGrants(companyUid);
|
|
118
|
-
|
|
118
|
+
// Normalize each grant into a company-relative, startsWith-friendly prefix
|
|
119
|
+
// (grantPathToPrefix, hq-cloud ≥5.42.0): real grants are anchored
|
|
120
|
+
// (`companies/<slug>/x/*`, `<slug>/x/*`) and glob-style (`x/*`, bare `*`),
|
|
121
|
+
// none of which startsWith-match the company-relative local-tree keys
|
|
122
|
+
// buildNarrowPlan emits. A wildcard grant normalizes to "" (everything);
|
|
123
|
+
// coalescePrefixes drops empties, so guard it explicitly to `[""]` (which
|
|
124
|
+
// isCoveredByAny treats as covering everything → nothing orphaned) rather
|
|
125
|
+
// than letting it collapse to "nothing" and propose deleting the tree.
|
|
126
|
+
const normalizedPrefixes = grants.map((g) => grantPathToPrefix(g.path, companySlug));
|
|
127
|
+
const prospectivePrefixSet = normalizedPrefixes.some((p) => p === "")
|
|
128
|
+
? [""]
|
|
129
|
+
: coalescePrefixes(normalizedPrefixes);
|
|
119
130
|
const journal = io.read(companySlug);
|
|
120
131
|
const plan = buildNarrowPlan({
|
|
121
132
|
hqRoot,
|
|
@@ -324,4 +335,4 @@ export function registerSyncNarrowCommand(syncCmd) {
|
|
|
324
335
|
});
|
|
325
336
|
}
|
|
326
337
|
//# sourceMappingURL=sync-narrow.js.map
|
|
327
|
-
//# debugId=
|
|
338
|
+
//# debugId=8cdf980e-e3cf-585c-b2e5-1615765675a1
|
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]="
|
|
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=
|
|
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
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* COMPANY-RELATIVE path (e.g. `meetings/a.md`) — the canonical namespace
|
|
31
|
+
* shared by the per-company journal keys, the vault S3 keys, and the
|
|
32
|
+
* server's explicit-grant paths. NOT hq-root-relative.
|
|
33
|
+
*/
|
|
30
34
|
relPath: string;
|
|
31
35
|
/** Absolute path on disk (convenience for the CLI delete loop). */
|
|
32
36
|
absPath: string;
|
|
@@ -57,7 +61,8 @@ export interface BuildNarrowPlanInput {
|
|
|
57
61
|
/**
|
|
58
62
|
* Coalesced prospective `shared`-mode prefix set (the result of running
|
|
59
63
|
* the caller's explicit grants through `coalescePrefixes`). Prefixes are
|
|
60
|
-
*
|
|
64
|
+
* COMPANY-RELATIVE (e.g. `meetings/`) — the namespace the grants endpoint
|
|
65
|
+
* returns and the namespace `relPath` is now computed in.
|
|
61
66
|
*/
|
|
62
67
|
prospectivePrefixSet: readonly string[];
|
|
63
68
|
/**
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* for the destructive side effects (delete, tombstone, PUT sync-config).
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
27
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="20a280f3-0fed-5868-9ad4-fcc562253486")}catch(e){}}();
|
|
28
28
|
import * as fs from "node:fs";
|
|
29
29
|
import * as path from "node:path";
|
|
30
30
|
import { hashFile, isCoveredByAny, } from "@indigoai-us/hq-cloud";
|
|
@@ -55,7 +55,16 @@ export function buildNarrowPlan(input) {
|
|
|
55
55
|
if (!fs.existsSync(walkRoot)) {
|
|
56
56
|
return emptyPlan();
|
|
57
57
|
}
|
|
58
|
-
|
|
58
|
+
// Walk with `walkRoot` as the rel-root so each file's `relPath` is
|
|
59
|
+
// COMPANY-RELATIVE (e.g. `meetings/a.md`) — the same namespace as the
|
|
60
|
+
// server's explicit-grant paths, the per-company journal keys, and the
|
|
61
|
+
// hq-cloud sync engine's `RemoteFile.key`. Computing it relative to `hqRoot`
|
|
62
|
+
// (the old behavior) produced `companies/<slug>/meetings/a.md`, which
|
|
63
|
+
// matched neither the company-relative grants nor the journal keys — so
|
|
64
|
+
// every file fell out of `prospectivePrefixSet` AND missed its journal
|
|
65
|
+
// entry, making narrow flag the entire tree as dirty orphans. See the
|
|
66
|
+
// namespace contract in hq-cloud `scope-shrink.ts` / `prefix-coalesce.ts`.
|
|
67
|
+
walkLocal(walkRoot, walkRoot, (file) => {
|
|
59
68
|
if (isCoveredByAny(file.relPath, prospectivePrefixSet)) {
|
|
60
69
|
staying.push(file);
|
|
61
70
|
return;
|
|
@@ -100,7 +109,10 @@ function emptyPlan() {
|
|
|
100
109
|
* Uses `lstat` rather than `stat` so a symlink's size doesn't follow the
|
|
101
110
|
* target chain (matches the share-engine convention).
|
|
102
111
|
*/
|
|
103
|
-
function walkLocal(dir,
|
|
112
|
+
function walkLocal(dir,
|
|
113
|
+
// Rel-root for `relPath` — the company walk root (`<hqRoot>/companies/<slug>`),
|
|
114
|
+
// so emitted `relPath`s are company-relative. Fixed across recursion.
|
|
115
|
+
relRoot, emit) {
|
|
104
116
|
let entries;
|
|
105
117
|
try {
|
|
106
118
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -115,7 +127,7 @@ function walkLocal(dir, hqRoot, emit) {
|
|
|
115
127
|
}
|
|
116
128
|
for (const entry of entries) {
|
|
117
129
|
const absPath = path.join(dir, entry.name);
|
|
118
|
-
const relPath = path.relative(
|
|
130
|
+
const relPath = path.relative(relRoot, absPath);
|
|
119
131
|
if (entry.isSymbolicLink()) {
|
|
120
132
|
// Record the link as a file-like entry. Don't descend — narrow is
|
|
121
133
|
// about pruning files that the LOCAL tree has materialized here; a
|
|
@@ -132,7 +144,7 @@ function walkLocal(dir, hqRoot, emit) {
|
|
|
132
144
|
continue;
|
|
133
145
|
}
|
|
134
146
|
if (entry.isDirectory()) {
|
|
135
|
-
walkLocal(absPath,
|
|
147
|
+
walkLocal(absPath, relRoot, emit);
|
|
136
148
|
continue;
|
|
137
149
|
}
|
|
138
150
|
if (entry.isFile()) {
|
|
@@ -241,4 +253,4 @@ export function formatBytes(n) {
|
|
|
241
253
|
return `${v.toFixed(2)} ${units[i]}`;
|
|
242
254
|
}
|
|
243
255
|
//# sourceMappingURL=local-tree-diff.js.map
|
|
244
|
-
//# debugId=
|
|
256
|
+
//# debugId=20a280f3-0fed-5868-9ad4-fcc562253486
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.28.0",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"clean": "rm -rf dist"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@indigoai-us/hq-cloud": "~5.
|
|
18
|
+
"@indigoai-us/hq-cloud": "~5.42.0",
|
|
19
19
|
"@indigoai-us/hq-onboarding": "^0.1.0",
|
|
20
20
|
"@sentry/node": "^10.49.0",
|
|
21
21
|
"chalk": "^5.3.0",
|
|
@@ -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
|
+
});
|