@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,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: "
|
|
299
|
-
{ rel: "
|
|
304
|
+
{ rel: "meetings/notes.md", contents: "stays" },
|
|
305
|
+
{ rel: "scratch/old.md", contents: "clean orphan" },
|
|
300
306
|
]);
|
|
301
307
|
|
|
302
308
|
const { client } = makeStubClient({
|
|
303
309
|
grants: [
|
|
304
|
-
fakeGrant("
|
|
305
|
-
fakeGrant("
|
|
310
|
+
fakeGrant("meetings/"),
|
|
311
|
+
fakeGrant("meetings/2026/"), // collapsed by coalesce
|
|
306
312
|
],
|
|
307
313
|
});
|
|
308
314
|
|
|
@@ -316,13 +322,67 @@ describe("computeNarrowPlan", () => {
|
|
|
316
322
|
journalIO: journalIO.io,
|
|
317
323
|
});
|
|
318
324
|
|
|
319
|
-
expect(result.prospectivePrefixSet).toEqual([
|
|
320
|
-
|
|
325
|
+
expect(result.prospectivePrefixSet).toEqual(["meetings/"]);
|
|
326
|
+
expect(result.plan.totalStayingCount).toBe(1);
|
|
327
|
+
expect(result.plan.totalCleanCount).toBe(1);
|
|
328
|
+
expect(result.plan.totalDirtyCount).toBe(0);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("normalizes real-world anchored + glob grant paths (grantPathToPrefix)", async () => {
|
|
332
|
+
writeFile("companies/acme/design-pack/logo.svg", "svg");
|
|
333
|
+
writeFile("companies/acme/scratch/old.md", "clean orphan");
|
|
334
|
+
|
|
335
|
+
const journal = journalFromFiles([
|
|
336
|
+
{ rel: "design-pack/logo.svg", contents: "svg" },
|
|
337
|
+
{ rel: "scratch/old.md", contents: "clean orphan" },
|
|
321
338
|
]);
|
|
339
|
+
|
|
340
|
+
// The exact messy shapes the live vault returns: full-anchored + glob,
|
|
341
|
+
// and slug-anchored + glob — neither startsWith-matches the
|
|
342
|
+
// company-relative local keys until grantPathToPrefix de-anchors them.
|
|
343
|
+
const { client } = makeStubClient({
|
|
344
|
+
grants: [
|
|
345
|
+
fakeGrant("companies/acme/design-pack/*"),
|
|
346
|
+
fakeGrant("acme/design-pack/2026/*"), // subsumed after normalize+coalesce
|
|
347
|
+
],
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
const result = await computeNarrowPlan({
|
|
351
|
+
hqRoot: tmpRoot,
|
|
352
|
+
companySlug: "acme",
|
|
353
|
+
companyUid: "cmp_acme",
|
|
354
|
+
vaultClient: client,
|
|
355
|
+
journalIO: makeStubJournalIO(journal).io,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
expect(result.prospectivePrefixSet).toEqual(["design-pack/"]);
|
|
359
|
+
// design-pack/logo.svg stays (covered); scratch/old.md is a clean orphan.
|
|
322
360
|
expect(result.plan.totalStayingCount).toBe(1);
|
|
323
361
|
expect(result.plan.totalCleanCount).toBe(1);
|
|
324
362
|
expect(result.plan.totalDirtyCount).toBe(0);
|
|
325
363
|
});
|
|
364
|
+
|
|
365
|
+
it("a wildcard '*' grant keeps everything (no orphans)", async () => {
|
|
366
|
+
writeFile("companies/acme/a.md", "a");
|
|
367
|
+
writeFile("companies/acme/sub/b.md", "b");
|
|
368
|
+
|
|
369
|
+
const { client } = makeStubClient({ grants: [fakeGrant("*")] });
|
|
370
|
+
|
|
371
|
+
const result = await computeNarrowPlan({
|
|
372
|
+
hqRoot: tmpRoot,
|
|
373
|
+
companySlug: "acme",
|
|
374
|
+
companyUid: "cmp_acme",
|
|
375
|
+
vaultClient: client,
|
|
376
|
+
journalIO: makeStubJournalIO(journalFromFiles([])).io,
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// "*" → "" → guarded to [""] (covers everything) so narrowing keeps the
|
|
380
|
+
// whole tree rather than collapsing to "nothing" and proposing deletes.
|
|
381
|
+
expect(result.prospectivePrefixSet).toEqual([""]);
|
|
382
|
+
expect(result.plan.totalCleanCount).toBe(0);
|
|
383
|
+
expect(result.plan.totalDirtyCount).toBe(0);
|
|
384
|
+
expect(result.plan.totalStayingCount).toBe(2);
|
|
385
|
+
});
|
|
326
386
|
});
|
|
327
387
|
|
|
328
388
|
// ── applyNarrow ─────────────────────────────────────────────────────────────
|
|
@@ -45,6 +45,7 @@ import * as fs from "node:fs";
|
|
|
45
45
|
import {
|
|
46
46
|
VaultClient,
|
|
47
47
|
coalescePrefixes,
|
|
48
|
+
grantPathToPrefix,
|
|
48
49
|
readJournal,
|
|
49
50
|
writeJournal,
|
|
50
51
|
tombstoneEntry,
|
|
@@ -218,7 +219,20 @@ export async function computeNarrowPlan(
|
|
|
218
219
|
const io = input.journalIO ?? realJournalIO;
|
|
219
220
|
|
|
220
221
|
const grants = await vaultClient.listMyExplicitGrants(companyUid);
|
|
221
|
-
|
|
222
|
+
// Normalize each grant into a company-relative, startsWith-friendly prefix
|
|
223
|
+
// (grantPathToPrefix, hq-cloud ≥5.42.0): real grants are anchored
|
|
224
|
+
// (`companies/<slug>/x/*`, `<slug>/x/*`) and glob-style (`x/*`, bare `*`),
|
|
225
|
+
// none of which startsWith-match the company-relative local-tree keys
|
|
226
|
+
// buildNarrowPlan emits. A wildcard grant normalizes to "" (everything);
|
|
227
|
+
// coalescePrefixes drops empties, so guard it explicitly to `[""]` (which
|
|
228
|
+
// isCoveredByAny treats as covering everything → nothing orphaned) rather
|
|
229
|
+
// than letting it collapse to "nothing" and propose deleting the tree.
|
|
230
|
+
const normalizedPrefixes = grants.map((g) =>
|
|
231
|
+
grantPathToPrefix(g.path, companySlug),
|
|
232
|
+
);
|
|
233
|
+
const prospectivePrefixSet = normalizedPrefixes.some((p) => p === "")
|
|
234
|
+
? [""]
|
|
235
|
+
: coalescePrefixes(normalizedPrefixes);
|
|
222
236
|
const journal = io.read(companySlug);
|
|
223
237
|
|
|
224
238
|
const plan = buildNarrowPlan({
|
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);
|