@indigoai-us/hq-cli 5.51.0 → 5.53.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/CHANGELOG.md +41 -0
- package/dist/commands/company.d.ts +3 -0
- package/dist/commands/company.js +82 -0
- package/dist/commands/crm.d.ts +3 -0
- package/dist/commands/crm.js +162 -0
- package/dist/commands/files.d.ts +2 -0
- package/dist/commands/files.js +72 -14
- package/dist/commands/members.d.ts +10 -0
- package/dist/commands/members.js +32 -11
- package/dist/index.js +11 -2
- package/package.json +1 -1
- package/src/commands/company.test.ts +177 -0
- package/src/commands/company.ts +132 -0
- package/src/commands/crm.test.ts +179 -0
- package/src/commands/crm.ts +236 -0
- package/src/commands/files-delete.test.ts +84 -0
- package/src/commands/files.test.ts +130 -0
- package/src/commands/files.ts +109 -16
- package/src/commands/members.test.ts +116 -0
- package/src/commands/members.ts +40 -15
- package/src/index.ts +11 -0
|
@@ -0,0 +1,236 @@
|
|
|
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, getEntityUid } from "../utils/vault-api.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `hq crm entity upsert` — agent-facing controlled entity upsert
|
|
9
|
+
* (hq-native-crm US-002). Wraps `POST /crm/entities`, which fronts the ontology
|
|
10
|
+
* write gate so an authenticated company MEMBER (or higher) can create/update
|
|
11
|
+
* canonical CRM entities in their company's vault.
|
|
12
|
+
*
|
|
13
|
+
* Conventions mirror `secrets.ts`: ensureCognitoToken() → getEntityUid() →
|
|
14
|
+
* vaultApiFetch() → error-check → chalk output.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The CRM/ontology entity types the upsert route accepts via `--type`. */
|
|
18
|
+
const CRM_ENTITY_TYPES = [
|
|
19
|
+
"contact",
|
|
20
|
+
"deal",
|
|
21
|
+
"contract",
|
|
22
|
+
"invoice",
|
|
23
|
+
"company",
|
|
24
|
+
] as const;
|
|
25
|
+
type CrmEntityType = (typeof CRM_ENTITY_TYPES)[number];
|
|
26
|
+
|
|
27
|
+
/** A minimal entity payload in the write-gate ExtractedEntity shape. */
|
|
28
|
+
interface CrmEntityInput {
|
|
29
|
+
type: string;
|
|
30
|
+
canonical_name: string;
|
|
31
|
+
aliases?: string[];
|
|
32
|
+
confidence?: number;
|
|
33
|
+
domain?: string[];
|
|
34
|
+
relationships?: unknown[];
|
|
35
|
+
external_ids?: Record<string, string>;
|
|
36
|
+
web_domains?: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface UpsertFlags {
|
|
40
|
+
type?: string;
|
|
41
|
+
name?: string;
|
|
42
|
+
attioId?: string;
|
|
43
|
+
stripeId?: string;
|
|
44
|
+
pandadocId?: string;
|
|
45
|
+
neonId?: string;
|
|
46
|
+
json?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Read the `--json <file|->` payload: a single entity object or an array of
|
|
51
|
+
* entities. `-` reads from stdin. Returns the entities array (always
|
|
52
|
+
* normalized to an array) or throws a descriptive Error.
|
|
53
|
+
*/
|
|
54
|
+
function readJsonEntities(source: string): CrmEntityInput[] {
|
|
55
|
+
const raw =
|
|
56
|
+
source === "-"
|
|
57
|
+
? readFileSync(0, "utf8") // fd 0 = stdin
|
|
58
|
+
: readFileSync(source, "utf8");
|
|
59
|
+
let parsed: unknown;
|
|
60
|
+
try {
|
|
61
|
+
parsed = JSON.parse(raw);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`--json payload is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
const entities = Array.isArray(parsed) ? parsed : [parsed];
|
|
68
|
+
if (entities.length === 0) {
|
|
69
|
+
throw new Error("--json payload contained no entities");
|
|
70
|
+
}
|
|
71
|
+
for (const e of entities) {
|
|
72
|
+
if (!e || typeof e !== "object") {
|
|
73
|
+
throw new Error("--json entities must be objects");
|
|
74
|
+
}
|
|
75
|
+
const obj = e as Record<string, unknown>;
|
|
76
|
+
if (typeof obj.type !== "string" || typeof obj.canonical_name !== "string") {
|
|
77
|
+
throw new Error(
|
|
78
|
+
"each --json entity needs a string `type` and `canonical_name`",
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return entities as CrmEntityInput[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build a single entity from the flag form (`--type` + `--name` + optional
|
|
87
|
+
* external-id flags). The route fills the additive defaults (aliases/domain/
|
|
88
|
+
* relationships/confidence) when omitted, but we send a complete minimal shape.
|
|
89
|
+
*/
|
|
90
|
+
function buildEntityFromFlags(opts: UpsertFlags): CrmEntityInput {
|
|
91
|
+
const externalIds: Record<string, string> = {};
|
|
92
|
+
if (opts.attioId) externalIds.attio = opts.attioId;
|
|
93
|
+
if (opts.stripeId) externalIds.stripe = opts.stripeId;
|
|
94
|
+
if (opts.pandadocId) externalIds.pandadoc = opts.pandadocId;
|
|
95
|
+
if (opts.neonId) externalIds.neon = opts.neonId;
|
|
96
|
+
|
|
97
|
+
const entity: CrmEntityInput = {
|
|
98
|
+
type: opts.type as CrmEntityType,
|
|
99
|
+
canonical_name: opts.name as string,
|
|
100
|
+
aliases: [],
|
|
101
|
+
confidence: 1,
|
|
102
|
+
domain: [],
|
|
103
|
+
relationships: [],
|
|
104
|
+
};
|
|
105
|
+
if (Object.keys(externalIds).length > 0) {
|
|
106
|
+
entity.external_ids = externalIds;
|
|
107
|
+
}
|
|
108
|
+
return entity;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function registerCrmCommand(program: Command): void {
|
|
112
|
+
const crm = program
|
|
113
|
+
.command("crm")
|
|
114
|
+
.description("Native CRM — upsert canonical entities into a company vault")
|
|
115
|
+
.option("--company <slug>", "Company slug (resolves to companyUid)");
|
|
116
|
+
|
|
117
|
+
const entity = crm
|
|
118
|
+
.command("entity")
|
|
119
|
+
.description("CRM entity operations");
|
|
120
|
+
|
|
121
|
+
entity
|
|
122
|
+
.command("upsert")
|
|
123
|
+
.description(
|
|
124
|
+
"Create or update a canonical CRM entity (POST /crm/entities). " +
|
|
125
|
+
"Use --type/--name (+ optional external-id flags) or --json <file|-> " +
|
|
126
|
+
"to pass a full entity/array.",
|
|
127
|
+
)
|
|
128
|
+
.option(
|
|
129
|
+
"--type <type>",
|
|
130
|
+
`Entity type: ${CRM_ENTITY_TYPES.join(" | ")}`,
|
|
131
|
+
)
|
|
132
|
+
.option("--name <canonical_name>", "Canonical entity name")
|
|
133
|
+
.option("--attio-id <id>", "Attio record id → external_ids.attio")
|
|
134
|
+
.option("--stripe-id <id>", "Stripe object id → external_ids.stripe")
|
|
135
|
+
.option("--pandadoc-id <id>", "PandaDoc document id → external_ids.pandadoc")
|
|
136
|
+
.option("--neon-id <id>", "Neon row id → external_ids.neon")
|
|
137
|
+
.option(
|
|
138
|
+
"--json <file|->",
|
|
139
|
+
"Path to a JSON file (or '-' for stdin) with a full entity object or array",
|
|
140
|
+
)
|
|
141
|
+
.action(async (opts: UpsertFlags) => {
|
|
142
|
+
try {
|
|
143
|
+
const companySlug = crm.opts().company as string | undefined;
|
|
144
|
+
if (!companySlug) {
|
|
145
|
+
console.error(
|
|
146
|
+
chalk.red("Error: --company <slug> is required."),
|
|
147
|
+
);
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Resolve the entity payload: --json is the alternative to flags.
|
|
152
|
+
let entities: CrmEntityInput[];
|
|
153
|
+
if (opts.json) {
|
|
154
|
+
entities = readJsonEntities(opts.json);
|
|
155
|
+
} else {
|
|
156
|
+
if (!opts.type) {
|
|
157
|
+
console.error(
|
|
158
|
+
chalk.red(
|
|
159
|
+
`Error: --type <${CRM_ENTITY_TYPES.join("|")}> is required (or pass --json).`,
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
if (!CRM_ENTITY_TYPES.includes(opts.type as CrmEntityType)) {
|
|
165
|
+
console.error(
|
|
166
|
+
chalk.red(
|
|
167
|
+
`Error: --type must be one of ${CRM_ENTITY_TYPES.join(", ")}.`,
|
|
168
|
+
),
|
|
169
|
+
);
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
if (!opts.name) {
|
|
173
|
+
console.error(
|
|
174
|
+
chalk.red("Error: --name <canonical_name> is required (or pass --json)."),
|
|
175
|
+
);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
entities = [buildEntityFromFlags(opts)];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const token = await ensureCognitoToken();
|
|
182
|
+
const companyUid = await getEntityUid(token, { companySlug });
|
|
183
|
+
|
|
184
|
+
const res = await vaultApiFetch({
|
|
185
|
+
token,
|
|
186
|
+
path: "/crm/entities",
|
|
187
|
+
method: "POST",
|
|
188
|
+
body: { companyUid, entities },
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
if (!res.ok) {
|
|
192
|
+
const body = (await res.json().catch(() => ({}))) as Record<
|
|
193
|
+
string,
|
|
194
|
+
unknown
|
|
195
|
+
>;
|
|
196
|
+
// CrmDisabledError surfaces as a 403 with a helpful message.
|
|
197
|
+
if (res.status === 403 && body.code === "CRM_DISABLED") {
|
|
198
|
+
console.error(
|
|
199
|
+
chalk.red(
|
|
200
|
+
"CRM is not enabled for this company. Enable it first: " +
|
|
201
|
+
`hq company settings set --company ${companySlug} --crm-enabled true`,
|
|
202
|
+
),
|
|
203
|
+
);
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
console.error(
|
|
207
|
+
chalk.red(
|
|
208
|
+
`Failed to upsert entities: ${(body.error as string) ?? res.statusText}`,
|
|
209
|
+
),
|
|
210
|
+
);
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const result = (await res.json().catch(() => ({}))) as {
|
|
215
|
+
created?: number;
|
|
216
|
+
updated?: number;
|
|
217
|
+
skipped?: number;
|
|
218
|
+
status?: string;
|
|
219
|
+
};
|
|
220
|
+
console.log(
|
|
221
|
+
chalk.green(
|
|
222
|
+
`Upsert ${result.status ?? "ok"}: ` +
|
|
223
|
+
`${result.created ?? 0} created, ` +
|
|
224
|
+
`${result.updated ?? 0} updated, ` +
|
|
225
|
+
`${result.skipped ?? 0} skipped.`,
|
|
226
|
+
),
|
|
227
|
+
);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
console.error(
|
|
230
|
+
chalk.red("Error:"),
|
|
231
|
+
err instanceof Error ? err.message : String(err),
|
|
232
|
+
);
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
}
|
|
@@ -398,3 +398,87 @@ describe("hq files delete — server error mapping", () => {
|
|
|
398
398
|
expect(formatFilesDeleteError(new FilesDeleteHttpError(500, "boom"), "p")).toMatch(/Server error: boom/);
|
|
399
399
|
});
|
|
400
400
|
});
|
|
401
|
+
|
|
402
|
+
describe("hq files delete — --personal (personal vault scope)", () => {
|
|
403
|
+
// Body parser tolerant of both scope shapes (company vs personal).
|
|
404
|
+
function bodies(): Array<Record<string, unknown>> {
|
|
405
|
+
return deleteCalls().map((c) => JSON.parse((c[1]?.body as string) ?? "{}"));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
it("--dry-run sends { personal: true } with NO company membership lookup", async () => {
|
|
409
|
+
// First (and only) fetch is the delete preview — getCompanyUid is skipped.
|
|
410
|
+
fetchSpy.mockResolvedValueOnce(
|
|
411
|
+
deleteResponse({
|
|
412
|
+
dryRun: true,
|
|
413
|
+
matched: 2,
|
|
414
|
+
keys: ["installer/a.txt", "installer/b.txt"],
|
|
415
|
+
}),
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
const program = buildProgram();
|
|
419
|
+
await program.parseAsync(
|
|
420
|
+
["files", "delete", "installer/", "--personal", "--dry-run"],
|
|
421
|
+
{ from: "user" },
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
const b = bodies();
|
|
425
|
+
expect(b).toHaveLength(1);
|
|
426
|
+
expect(b[0]).toEqual({ personal: true, prefix: "installer/*", dryRun: true });
|
|
427
|
+
expect(b[0]).not.toHaveProperty("company");
|
|
428
|
+
expect(printed()).toContain("[dry-run]");
|
|
429
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("real delete with --yes previews then deletes, both bodies personal-scoped", async () => {
|
|
433
|
+
fetchSpy.mockResolvedValueOnce(
|
|
434
|
+
deleteResponse({ dryRun: true, matched: 2, keys: ["installer/a.txt", "installer/b.txt"] }),
|
|
435
|
+
);
|
|
436
|
+
fetchSpy.mockResolvedValueOnce(
|
|
437
|
+
deleteResponse({ dryRun: false, matched: 2, deleted: 2, tombstoned: 2 }),
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
const program = buildProgram();
|
|
441
|
+
await program.parseAsync(
|
|
442
|
+
["files", "delete", "installer/*", "--personal", "--yes"],
|
|
443
|
+
{ from: "user" },
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
const b = bodies();
|
|
447
|
+
expect(b).toHaveLength(2);
|
|
448
|
+
expect(b[0]).toEqual({ personal: true, prefix: "installer/*", dryRun: true });
|
|
449
|
+
expect(b[1]).toEqual({ personal: true, prefix: "installer/*", dryRun: false });
|
|
450
|
+
expect(printed()).toContain("Deleted 2 objects");
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("confirmation copy says 'your personal vault' (not the shared-vault wording)", async () => {
|
|
454
|
+
fetchSpy.mockResolvedValueOnce(
|
|
455
|
+
deleteResponse({ dryRun: true, matched: 1, keys: ["installer/a.txt"] }),
|
|
456
|
+
);
|
|
457
|
+
let prompt = "";
|
|
458
|
+
await runFilesDelete(
|
|
459
|
+
{ prefix: "installer/*", dryRun: false, yes: false, personal: true, companySlug: undefined },
|
|
460
|
+
{
|
|
461
|
+
confirm: async (m) => {
|
|
462
|
+
prompt = m;
|
|
463
|
+
return false;
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
);
|
|
467
|
+
expect(prompt).toContain("your personal vault");
|
|
468
|
+
expect(prompt).not.toContain("shared vault");
|
|
469
|
+
// Declined → no real delete (only the preview fetch happened).
|
|
470
|
+
expect(bodies()).toHaveLength(1);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("rejects --personal combined with --company before any network call", async () => {
|
|
474
|
+
const program = buildProgram();
|
|
475
|
+
await expect(
|
|
476
|
+
program.parseAsync(
|
|
477
|
+
["files", "--company", "acme", "delete", "installer/*", "--personal"],
|
|
478
|
+
{ from: "user" },
|
|
479
|
+
),
|
|
480
|
+
).rejects.toThrow(/__EXIT__:1/);
|
|
481
|
+
expect(printedErr()).toContain("Pass either --personal or --company");
|
|
482
|
+
expect(deleteCalls()).toHaveLength(0);
|
|
483
|
+
});
|
|
484
|
+
});
|
|
@@ -501,6 +501,136 @@ describe("hq files share — direct-grant fork (with --with)", () => {
|
|
|
501
501
|
expect(errs).toMatch(/--permission is required/);
|
|
502
502
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
503
503
|
});
|
|
504
|
+
|
|
505
|
+
it("--full --with email grants the '*' wildcard at the default write permission (no glob to quote)", async () => {
|
|
506
|
+
// 1) /membership/me for company resolution
|
|
507
|
+
fetchSpy.mockResolvedValueOnce(
|
|
508
|
+
jsonResponse(200, {
|
|
509
|
+
memberships: [
|
|
510
|
+
{
|
|
511
|
+
membershipKey: "k1",
|
|
512
|
+
companyUid: "cmp_acme",
|
|
513
|
+
role: "member",
|
|
514
|
+
status: "active",
|
|
515
|
+
},
|
|
516
|
+
],
|
|
517
|
+
}),
|
|
518
|
+
);
|
|
519
|
+
// 2) POST /files/cmp_acme/acl/grant
|
|
520
|
+
fetchSpy.mockResolvedValueOnce(
|
|
521
|
+
jsonResponse(200, { acl: { path: "*" } }),
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
const program = buildProgram();
|
|
525
|
+
// No positional path, no --permission — --full supplies prefix '*' and
|
|
526
|
+
// defaults permission to write.
|
|
527
|
+
await program.parseAsync(
|
|
528
|
+
["files", "share", "--full", "--with", "user@example.com"],
|
|
529
|
+
{ from: "user" },
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
const mintCalls = fetchSpy.mock.calls.filter((c) =>
|
|
533
|
+
String(c[0]).includes("/share-session"),
|
|
534
|
+
);
|
|
535
|
+
expect(mintCalls).toHaveLength(0);
|
|
536
|
+
|
|
537
|
+
const grantCall = fetchSpy.mock.calls.find((c) =>
|
|
538
|
+
String(c[0]).includes("/acl/grant"),
|
|
539
|
+
);
|
|
540
|
+
expect(grantCall).toBeDefined();
|
|
541
|
+
const body = JSON.parse((grantCall![1]?.body as string) ?? "{}");
|
|
542
|
+
expect(body).toEqual({
|
|
543
|
+
prefix: "*",
|
|
544
|
+
granteeType: "email",
|
|
545
|
+
granteeId: "user@example.com",
|
|
546
|
+
permission: "write",
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
const printed = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
550
|
+
expect(printed).toMatch(/ENTIRE vault/);
|
|
551
|
+
expect(open).not.toHaveBeenCalled();
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it("--full honors an explicit --permission read (read-only full vault)", async () => {
|
|
555
|
+
fetchSpy.mockResolvedValueOnce(
|
|
556
|
+
jsonResponse(200, {
|
|
557
|
+
memberships: [
|
|
558
|
+
{ membershipKey: "k1", companyUid: "cmp_acme", role: "member", status: "active" },
|
|
559
|
+
],
|
|
560
|
+
}),
|
|
561
|
+
);
|
|
562
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { acl: { path: "*" } }));
|
|
563
|
+
|
|
564
|
+
const program = buildProgram();
|
|
565
|
+
await program.parseAsync(
|
|
566
|
+
["files", "share", "--full", "--with", "user@example.com", "--permission", "read"],
|
|
567
|
+
{ from: "user" },
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
const grantCall = fetchSpy.mock.calls.find((c) =>
|
|
571
|
+
String(c[0]).includes("/acl/grant"),
|
|
572
|
+
);
|
|
573
|
+
const body = JSON.parse((grantCall![1]?.body as string) ?? "{}");
|
|
574
|
+
expect(body).toEqual({
|
|
575
|
+
prefix: "*",
|
|
576
|
+
granteeType: "email",
|
|
577
|
+
granteeId: "user@example.com",
|
|
578
|
+
permission: "read",
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it("--full composes with @all (whole vault for the whole company → company-wide '*')", async () => {
|
|
583
|
+
fetchSpy.mockResolvedValueOnce(
|
|
584
|
+
jsonResponse(200, {
|
|
585
|
+
memberships: [
|
|
586
|
+
{ membershipKey: "k1", companyUid: "cmp_acme", role: "member", status: "active" },
|
|
587
|
+
],
|
|
588
|
+
}),
|
|
589
|
+
);
|
|
590
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { acl: { path: "*" } }));
|
|
591
|
+
|
|
592
|
+
const program = buildProgram();
|
|
593
|
+
await program.parseAsync(
|
|
594
|
+
["files", "share", "--full", "--with", "@all"],
|
|
595
|
+
{ from: "user" },
|
|
596
|
+
);
|
|
597
|
+
|
|
598
|
+
const grantCall = fetchSpy.mock.calls.find((c) =>
|
|
599
|
+
String(c[0]).includes("/acl/grant"),
|
|
600
|
+
);
|
|
601
|
+
const body = JSON.parse((grantCall![1]?.body as string) ?? "{}");
|
|
602
|
+
expect(body).toEqual({
|
|
603
|
+
prefix: "*",
|
|
604
|
+
granteeType: "company-wide",
|
|
605
|
+
granteeId: "",
|
|
606
|
+
permission: "write",
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it("rejects --full without --with (and never calls the network)", async () => {
|
|
611
|
+
const program = buildProgram();
|
|
612
|
+
await expect(
|
|
613
|
+
program.parseAsync(["files", "share", "--full"], { from: "user" }),
|
|
614
|
+
).rejects.toThrow(/__EXIT__:1/);
|
|
615
|
+
|
|
616
|
+
const errs = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
617
|
+
expect(errs).toMatch(/--full.*requires --with/);
|
|
618
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it("rejects --full when file paths are also passed", async () => {
|
|
622
|
+
const program = buildProgram();
|
|
623
|
+
await expect(
|
|
624
|
+
program.parseAsync(
|
|
625
|
+
["files", "share", "somePath", "--full", "--with", "user@example.com"],
|
|
626
|
+
{ from: "user" },
|
|
627
|
+
),
|
|
628
|
+
).rejects.toThrow(/__EXIT__:1/);
|
|
629
|
+
|
|
630
|
+
const errs = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
631
|
+
expect(errs).toMatch(/entire vault; do not also pass file paths/);
|
|
632
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
633
|
+
});
|
|
504
634
|
});
|
|
505
635
|
|
|
506
636
|
// ---------------------------------------------------------------------------
|
package/src/commands/files.ts
CHANGED
|
@@ -135,6 +135,10 @@ export function registerFilesCommand(program: Command): Command {
|
|
|
135
135
|
"Email address, group id, or '@all' to share with every active company member",
|
|
136
136
|
)
|
|
137
137
|
.option("--permission <level>", "Permission level (only with --with): read | write")
|
|
138
|
+
.option(
|
|
139
|
+
"--full",
|
|
140
|
+
"Grant access to the ENTIRE vault (the '*' wildcard prefix) — no need to quote a glob. Requires --with; defaults to write permission.",
|
|
141
|
+
)
|
|
138
142
|
.option(
|
|
139
143
|
"--expires <duration>",
|
|
140
144
|
"Token expiry duration for share-session URL (e.g. 15m, 1h, 24h). Default 15m. Max 24h.",
|
|
@@ -146,11 +150,45 @@ export function registerFilesCommand(program: Command): Command {
|
|
|
146
150
|
opts: {
|
|
147
151
|
with?: string;
|
|
148
152
|
permission?: string;
|
|
153
|
+
full?: boolean;
|
|
149
154
|
expires?: string;
|
|
150
155
|
open: boolean;
|
|
151
156
|
},
|
|
152
157
|
) => {
|
|
153
158
|
try {
|
|
159
|
+
// Full-vault grant: a glob-safe affordance for "give this person the
|
|
160
|
+
// whole vault" so admins never have to quote a `*` (an unquoted glob
|
|
161
|
+
// expands to local filenames and instantly fails the one-prefix
|
|
162
|
+
// check). Maps to the single `*` wildcard grant, which the server
|
|
163
|
+
// coalesces to one policy entry — sidestepping the per-prefix STS
|
|
164
|
+
// session-policy budget. Defaults to write permission.
|
|
165
|
+
if (opts.full) {
|
|
166
|
+
if (opts.with === undefined) {
|
|
167
|
+
console.error(
|
|
168
|
+
chalk.red(
|
|
169
|
+
"--full grants whole-vault access to a principal and requires --with <principal>.",
|
|
170
|
+
),
|
|
171
|
+
);
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
if (paths && paths.length > 0) {
|
|
175
|
+
console.error(
|
|
176
|
+
chalk.red(
|
|
177
|
+
"--full grants the entire vault; do not also pass file paths.",
|
|
178
|
+
),
|
|
179
|
+
);
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
await runDirectGrant({
|
|
183
|
+
prefix: "*",
|
|
184
|
+
principal: opts.with,
|
|
185
|
+
permission: opts.permission ?? "write",
|
|
186
|
+
companySlug: files.opts().company as string | undefined,
|
|
187
|
+
fullVault: true,
|
|
188
|
+
});
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
154
192
|
if (!paths || paths.length === 0) {
|
|
155
193
|
console.error(
|
|
156
194
|
chalk.red("usage: hq files share <paths...> [--with <principal>]"),
|
|
@@ -430,21 +468,39 @@ export function registerFilesCommand(program: Command): Command {
|
|
|
430
468
|
files
|
|
431
469
|
.command("delete <prefix>")
|
|
432
470
|
.description(
|
|
433
|
-
"Delete vault objects under a prefix (bounded + scoped). Always previews the exact count first; prompts for confirmation unless --yes.",
|
|
471
|
+
"Delete vault objects under a prefix (bounded + scoped). Always previews the exact count first; prompts for confirmation unless --yes. Use --personal to target your own personal vault instead of a company.",
|
|
434
472
|
)
|
|
435
473
|
.option(
|
|
436
474
|
"--dry-run",
|
|
437
475
|
"List what WOULD be deleted without deleting anything",
|
|
438
476
|
)
|
|
477
|
+
.option(
|
|
478
|
+
"--personal",
|
|
479
|
+
"Target your own personal vault instead of a company vault (mutually exclusive with --company)",
|
|
480
|
+
)
|
|
439
481
|
.option("-y, --yes", "Skip the confirmation prompt (for scripts)")
|
|
440
482
|
.action(
|
|
441
|
-
async (
|
|
483
|
+
async (
|
|
484
|
+
prefix: string,
|
|
485
|
+
opts: { dryRun?: boolean; yes?: boolean; personal?: boolean },
|
|
486
|
+
) => {
|
|
442
487
|
try {
|
|
488
|
+
const companySlug = files.opts().company as string | undefined;
|
|
489
|
+
const personal = opts.personal === true;
|
|
490
|
+
if (personal && companySlug) {
|
|
491
|
+
console.error(
|
|
492
|
+
chalk.red(
|
|
493
|
+
"Pass either --personal or --company, not both.",
|
|
494
|
+
),
|
|
495
|
+
);
|
|
496
|
+
process.exit(1);
|
|
497
|
+
}
|
|
443
498
|
await runFilesDelete({
|
|
444
499
|
prefix,
|
|
445
500
|
dryRun: opts.dryRun === true,
|
|
446
501
|
yes: opts.yes === true,
|
|
447
|
-
|
|
502
|
+
personal,
|
|
503
|
+
companySlug,
|
|
448
504
|
});
|
|
449
505
|
} catch (err) {
|
|
450
506
|
console.error(
|
|
@@ -472,6 +528,12 @@ interface DirectGrantParams {
|
|
|
472
528
|
principal: string;
|
|
473
529
|
permission: string | undefined;
|
|
474
530
|
companySlug: string | undefined;
|
|
531
|
+
/**
|
|
532
|
+
* Set by the `--full` affordance: the grant targets the whole vault (the
|
|
533
|
+
* `*` wildcard prefix). Only affects the success message wording — the
|
|
534
|
+
* request shape is identical to any other prefix grant.
|
|
535
|
+
*/
|
|
536
|
+
fullVault?: boolean;
|
|
475
537
|
}
|
|
476
538
|
|
|
477
539
|
async function runDirectGrant(params: DirectGrantParams): Promise<void> {
|
|
@@ -572,9 +634,17 @@ async function runDirectGrant(params: DirectGrantParams): Promise<void> {
|
|
|
572
634
|
};
|
|
573
635
|
const printedPrefix = data.acl?.path ?? data.acl?.prefix ?? canonicalPrefix;
|
|
574
636
|
const verb = autoCreated ? "Created ACL and granted" : "Granted";
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
637
|
+
if (params.fullVault) {
|
|
638
|
+
console.log(
|
|
639
|
+
chalk.green(
|
|
640
|
+
`${verb} ${params.permission} on the ENTIRE vault to ${principalLabel}`,
|
|
641
|
+
),
|
|
642
|
+
);
|
|
643
|
+
} else {
|
|
644
|
+
console.log(
|
|
645
|
+
chalk.green(`${verb} ${params.permission} on ${printedPrefix} to ${principalLabel}`),
|
|
646
|
+
);
|
|
647
|
+
}
|
|
578
648
|
}
|
|
579
649
|
|
|
580
650
|
// ---------------------------------------------------------------------------
|
|
@@ -731,10 +801,16 @@ function realConfirm(message: string): Promise<boolean> {
|
|
|
731
801
|
/**
|
|
732
802
|
* POST /v1/files/delete. Throws FilesDeleteHttpError on any non-2xx so the one
|
|
733
803
|
* caller renders a single consistent error path.
|
|
804
|
+
*
|
|
805
|
+
* Scope is EITHER a company vault (`companyUid` set) OR the caller's personal
|
|
806
|
+
* vault (`personal: true`). For the personal case the server resolves the target
|
|
807
|
+
* person + bucket from the authenticated caller — we send NO uid, just the
|
|
808
|
+
* `personal` flag — so there is nothing for the client to get wrong or spoof.
|
|
734
809
|
*/
|
|
735
810
|
async function callDeleteEndpoint(params: {
|
|
736
811
|
token: string;
|
|
737
|
-
companyUid
|
|
812
|
+
companyUid?: string;
|
|
813
|
+
personal?: boolean;
|
|
738
814
|
prefix: string;
|
|
739
815
|
dryRun: boolean;
|
|
740
816
|
}): Promise<FilesDeleteResponse> {
|
|
@@ -742,11 +818,17 @@ async function callDeleteEndpoint(params: {
|
|
|
742
818
|
token: params.token,
|
|
743
819
|
path: "/v1/files/delete",
|
|
744
820
|
method: "POST",
|
|
745
|
-
body:
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
821
|
+
body: params.personal
|
|
822
|
+
? {
|
|
823
|
+
personal: true,
|
|
824
|
+
prefix: params.prefix,
|
|
825
|
+
dryRun: params.dryRun,
|
|
826
|
+
}
|
|
827
|
+
: {
|
|
828
|
+
company: params.companyUid,
|
|
829
|
+
prefix: params.prefix,
|
|
830
|
+
dryRun: params.dryRun,
|
|
831
|
+
},
|
|
750
832
|
});
|
|
751
833
|
if (!res.ok) {
|
|
752
834
|
const body = (await res.json().catch(() => ({}))) as Record<string, string>;
|
|
@@ -763,6 +845,8 @@ interface RunFilesDeleteParams {
|
|
|
763
845
|
prefix: string;
|
|
764
846
|
dryRun: boolean;
|
|
765
847
|
yes: boolean;
|
|
848
|
+
/** Target the caller's personal vault instead of a company vault. */
|
|
849
|
+
personal?: boolean;
|
|
766
850
|
companySlug: string | undefined;
|
|
767
851
|
}
|
|
768
852
|
|
|
@@ -844,7 +928,14 @@ export async function runFilesDelete(
|
|
|
844
928
|
}
|
|
845
929
|
|
|
846
930
|
const token = await ensureCognitoToken();
|
|
847
|
-
|
|
931
|
+
// Personal scope resolves the vault server-side from the caller's identity —
|
|
932
|
+
// no company to look up. Company scope resolves the companyUid as before.
|
|
933
|
+
const companyUid = params.personal
|
|
934
|
+
? undefined
|
|
935
|
+
: await getCompanyUid(token, params.companySlug);
|
|
936
|
+
const scopeArgs = params.personal
|
|
937
|
+
? { personal: true as const }
|
|
938
|
+
: { companyUid };
|
|
848
939
|
|
|
849
940
|
// 1. Always preview first — this is how we print the EXACT key count before
|
|
850
941
|
// deleting anything (and the whole behavior of --dry-run).
|
|
@@ -852,7 +943,7 @@ export async function runFilesDelete(
|
|
|
852
943
|
try {
|
|
853
944
|
preview = await callDeleteEndpoint({
|
|
854
945
|
token,
|
|
855
|
-
|
|
946
|
+
...scopeArgs,
|
|
856
947
|
prefix: normalized,
|
|
857
948
|
dryRun: true,
|
|
858
949
|
});
|
|
@@ -904,7 +995,9 @@ export async function runFilesDelete(
|
|
|
904
995
|
|
|
905
996
|
if (!params.yes) {
|
|
906
997
|
const ok = await confirm(
|
|
907
|
-
|
|
998
|
+
params.personal
|
|
999
|
+
? `Delete ${preview.matched} ${noun}? This removes them from your personal vault.`
|
|
1000
|
+
: `Delete ${preview.matched} ${noun}? This removes them from the shared vault for everyone.`,
|
|
908
1001
|
);
|
|
909
1002
|
if (!ok) {
|
|
910
1003
|
console.log(chalk.dim("Aborted — nothing was deleted."));
|
|
@@ -917,7 +1010,7 @@ export async function runFilesDelete(
|
|
|
917
1010
|
try {
|
|
918
1011
|
result = await callDeleteEndpoint({
|
|
919
1012
|
token,
|
|
920
|
-
|
|
1013
|
+
...scopeArgs,
|
|
921
1014
|
prefix: normalized,
|
|
922
1015
|
dryRun: false,
|
|
923
1016
|
});
|