@indigoai-us/hq-cli 5.55.0 → 5.57.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 +27 -0
- package/dist/commands/cloud.d.ts +9 -0
- package/dist/commands/cloud.js +47 -9
- package/dist/commands/groups.d.ts +4 -0
- package/dist/commands/groups.js +13 -8
- package/dist/commands/meetings.js +19 -8
- package/dist/commands/secrets.d.ts +2 -1
- package/dist/commands/secrets.js +98 -24
- package/dist/utils/sandbox-runner-client.d.ts +39 -0
- package/dist/utils/sandbox-runner-client.js +113 -0
- package/package.json +1 -1
- package/src/commands/cloud.push-all.test.ts +29 -0
- package/src/commands/cloud.scope-excluded-warning.test.ts +22 -0
- package/src/commands/cloud.ts +60 -11
- package/src/commands/groups.test.ts +44 -0
- package/src/commands/groups.ts +13 -6
- package/src/commands/meetings.test.ts +125 -0
- package/src/commands/meetings.ts +24 -5
- package/src/commands/secrets.test.ts +194 -1
- package/src/commands/secrets.ts +122 -24
- package/src/utils/sandbox-runner-client.test.ts +125 -0
- package/src/utils/sandbox-runner-client.ts +175 -0
package/package.json
CHANGED
|
@@ -82,6 +82,7 @@ function makeShareSpy(
|
|
|
82
82
|
bytesUploaded: 0,
|
|
83
83
|
filesSkipped: 0,
|
|
84
84
|
filesDeleted: 0,
|
|
85
|
+
filesExcludedByScope: 0,
|
|
85
86
|
conflictPaths: [],
|
|
86
87
|
aborted: false,
|
|
87
88
|
...override,
|
|
@@ -307,6 +308,7 @@ describe("pushAll", () => {
|
|
|
307
308
|
filesUploaded: 0,
|
|
308
309
|
bytesUploaded: 0,
|
|
309
310
|
filesDeleted: 0,
|
|
311
|
+
filesExcludedByScope: 0,
|
|
310
312
|
errors: [],
|
|
311
313
|
perCompany: [],
|
|
312
314
|
});
|
|
@@ -379,6 +381,33 @@ describe("pushAll", () => {
|
|
|
379
381
|
expect(result.filesDeleted).toBe(4);
|
|
380
382
|
});
|
|
381
383
|
|
|
384
|
+
it("aggregates filesExcludedByScope across every successful target", async () => {
|
|
385
|
+
const hqRoot = makeHqRoot(["companies", "docs"]);
|
|
386
|
+
const vaultClient = makeVaultClient({
|
|
387
|
+
memberships: [{ companyUid: "cmp_acme" }],
|
|
388
|
+
persons: [
|
|
389
|
+
{
|
|
390
|
+
uid: "psn_alice",
|
|
391
|
+
type: "person",
|
|
392
|
+
slug: "alice",
|
|
393
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
394
|
+
},
|
|
395
|
+
],
|
|
396
|
+
entitiesBySlug: { cmp_acme: { slug: "acme" } },
|
|
397
|
+
});
|
|
398
|
+
const share = makeShareSpy({
|
|
399
|
+
cmp_acme: { filesExcludedByScope: 2 },
|
|
400
|
+
psn_alice: { filesExcludedByScope: 3 },
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
const result = await pushAll(
|
|
404
|
+
{ hqRoot },
|
|
405
|
+
{ vaultClient, share: share.fn },
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
expect(result.filesExcludedByScope).toBe(5);
|
|
409
|
+
});
|
|
410
|
+
|
|
382
411
|
// ── skipPersonal — fanout omits the canonical-person leg ──────────────────
|
|
383
412
|
//
|
|
384
413
|
// Symmetric with the `pullAll` skipPersonal coverage in
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { scopeExcludedWarning } from "./cloud.js";
|
|
4
|
+
|
|
5
|
+
describe("scopeExcludedWarning", () => {
|
|
6
|
+
it("returns null for zero and negative counts", () => {
|
|
7
|
+
expect(scopeExcludedWarning(0)).toBeNull();
|
|
8
|
+
expect(scopeExcludedWarning(-1)).toBeNull();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("returns a loud warning for positive counts", () => {
|
|
12
|
+
const warning = scopeExcludedWarning(3);
|
|
13
|
+
|
|
14
|
+
expect(warning).toBeTruthy();
|
|
15
|
+
expect(warning).toContain("3");
|
|
16
|
+
// The warning must loudly state the files did NOT reach the vault and
|
|
17
|
+
// explain why (outside the caller's write access) — without leaning on
|
|
18
|
+
// the jargon word "scope" in user-facing copy.
|
|
19
|
+
expect(warning).toContain("NOT uploaded");
|
|
20
|
+
expect(warning?.toLowerCase()).toContain("write access");
|
|
21
|
+
});
|
|
22
|
+
});
|
package/src/commands/cloud.ts
CHANGED
|
@@ -50,6 +50,22 @@ import {
|
|
|
50
50
|
type BannerLevel,
|
|
51
51
|
} from "../lib/narrow-hint-banner.js";
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Build a loud, human-readable warning when a push dropped files because
|
|
55
|
+
* they fell outside the caller's granted write scope. Returns null when
|
|
56
|
+
* nothing was scope-excluded. Keeping this pure makes the "never silently
|
|
57
|
+
* succeed when files were dropped" guarantee unit-testable.
|
|
58
|
+
*/
|
|
59
|
+
export function scopeExcludedWarning(count: number): string | null {
|
|
60
|
+
if (count <= 0) return null;
|
|
61
|
+
return (
|
|
62
|
+
`⚠ ${count} file(s) were NOT uploaded — they fall outside the ` +
|
|
63
|
+
`prefixes you have write access to (company-wide or direct grants). ` +
|
|
64
|
+
`They were skipped, not synced. Re-run with --json to list them, or ` +
|
|
65
|
+
`ask an admin to grant you write on those paths.`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
53
69
|
/**
|
|
54
70
|
* Resolve the `propagateDeletePolicy` for share() calls.
|
|
55
71
|
*
|
|
@@ -234,6 +250,7 @@ export interface ShareCallResult {
|
|
|
234
250
|
bytesUploaded: number;
|
|
235
251
|
filesSkipped: number;
|
|
236
252
|
filesDeleted: number;
|
|
253
|
+
filesExcludedByScope: number;
|
|
237
254
|
conflictPaths: string[];
|
|
238
255
|
aborted: boolean;
|
|
239
256
|
}
|
|
@@ -267,6 +284,7 @@ export interface PushAllResult {
|
|
|
267
284
|
filesUploaded: number;
|
|
268
285
|
bytesUploaded: number;
|
|
269
286
|
filesDeleted: number;
|
|
287
|
+
filesExcludedByScope: number;
|
|
270
288
|
errors: Array<{ company: string; message: string }>;
|
|
271
289
|
perCompany: PushAllRow[];
|
|
272
290
|
}
|
|
@@ -513,6 +531,7 @@ export async function pushAll(
|
|
|
513
531
|
filesUploaded: 0,
|
|
514
532
|
bytesUploaded: 0,
|
|
515
533
|
filesDeleted: 0,
|
|
534
|
+
filesExcludedByScope: 0,
|
|
516
535
|
errors: [],
|
|
517
536
|
perCompany: [],
|
|
518
537
|
};
|
|
@@ -524,6 +543,7 @@ export async function pushAll(
|
|
|
524
543
|
result.filesUploaded += r.filesUploaded;
|
|
525
544
|
result.bytesUploaded += r.bytesUploaded;
|
|
526
545
|
result.filesDeleted += r.filesDeleted;
|
|
546
|
+
result.filesExcludedByScope += r.filesExcludedByScope;
|
|
527
547
|
result.perCompany.push({ slug: entry.slug, result: r });
|
|
528
548
|
} catch (err) {
|
|
529
549
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -1016,11 +1036,21 @@ export function registerCloudCommands(program: Command): void {
|
|
|
1016
1036
|
process.exit(1);
|
|
1017
1037
|
}
|
|
1018
1038
|
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1039
|
+
if (result.filesExcludedByScope > 0) {
|
|
1040
|
+
log(
|
|
1041
|
+
chalk.yellow(
|
|
1042
|
+
`\n⚠ Pushed ${result.filesUploaded} file(s) (${formatBytes(result.bytesUploaded)}, ` +
|
|
1043
|
+
`${result.filesSkipped} skipped, ${result.filesExcludedByScope} scope-excluded)`,
|
|
1044
|
+
),
|
|
1045
|
+
);
|
|
1046
|
+
log(chalk.yellow(scopeExcludedWarning(result.filesExcludedByScope)!));
|
|
1047
|
+
} else {
|
|
1048
|
+
log(
|
|
1049
|
+
chalk.green(
|
|
1050
|
+
`\n✓ Pushed ${result.filesUploaded} file(s) (${formatBytes(result.bytesUploaded)}, ${result.filesSkipped} skipped)`,
|
|
1051
|
+
),
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1024
1054
|
} catch (err) {
|
|
1025
1055
|
const message = err instanceof Error ? err.message : String(err);
|
|
1026
1056
|
if (jsonMode) {
|
|
@@ -1657,11 +1687,15 @@ async function runPushAll(
|
|
|
1657
1687
|
console.log(chalk.red(` ✗ ${row.slug}: ${row.error}`));
|
|
1658
1688
|
} else if (row.result) {
|
|
1659
1689
|
const r = row.result;
|
|
1660
|
-
const status =
|
|
1690
|
+
const status =
|
|
1691
|
+
r.aborted || r.filesExcludedByScope > 0
|
|
1692
|
+
? chalk.yellow("⚠")
|
|
1693
|
+
: chalk.green("✓");
|
|
1661
1694
|
console.log(
|
|
1662
1695
|
` ${status} ${row.slug}: ${r.filesUploaded} file(s), ` +
|
|
1663
1696
|
`${formatBytes(r.bytesUploaded)}, ${r.filesSkipped} skipped, ` +
|
|
1664
|
-
`${r.filesDeleted} deleted, ${r.
|
|
1697
|
+
`${r.filesDeleted} deleted, ${r.filesExcludedByScope} scope-excluded, ` +
|
|
1698
|
+
`${r.conflictPaths.length} conflict(s)` +
|
|
1665
1699
|
(r.aborted ? " — aborted" : ""),
|
|
1666
1700
|
);
|
|
1667
1701
|
}
|
|
@@ -1671,8 +1705,16 @@ async function runPushAll(
|
|
|
1671
1705
|
const summary =
|
|
1672
1706
|
`\nPushed ${result.filesUploaded} file(s) ` +
|
|
1673
1707
|
`(${formatBytes(result.bytesUploaded)}) across ${result.attempted} ` +
|
|
1674
|
-
`target(s); ${result.filesDeleted} deleted; ${errored} error(s)
|
|
1675
|
-
|
|
1708
|
+
`target(s); ${result.filesDeleted} deleted; ${errored} error(s); ` +
|
|
1709
|
+
`${result.filesExcludedByScope} scope-excluded`;
|
|
1710
|
+
console.log(
|
|
1711
|
+
errored > 0 || result.filesExcludedByScope > 0
|
|
1712
|
+
? chalk.yellow(summary)
|
|
1713
|
+
: chalk.green(summary),
|
|
1714
|
+
);
|
|
1715
|
+
if (result.filesExcludedByScope > 0) {
|
|
1716
|
+
console.log(chalk.yellow(scopeExcludedWarning(result.filesExcludedByScope)!));
|
|
1717
|
+
}
|
|
1676
1718
|
if (errored > 0) process.exit(1);
|
|
1677
1719
|
}
|
|
1678
1720
|
|
|
@@ -1747,12 +1789,19 @@ async function runNowSingle(
|
|
|
1747
1789
|
...(journalSlug !== undefined ? { journalSlug } : {}),
|
|
1748
1790
|
...(author ? { author } : {}),
|
|
1749
1791
|
});
|
|
1792
|
+
const pushStatus =
|
|
1793
|
+
pushResult.aborted || pushResult.filesExcludedByScope > 0
|
|
1794
|
+
? chalk.yellow("⚠")
|
|
1795
|
+
: chalk.green("✓");
|
|
1750
1796
|
console.log(
|
|
1751
|
-
` ${
|
|
1797
|
+
` ${pushStatus} ` +
|
|
1752
1798
|
`${pushResult.filesUploaded} uploaded, ${pushResult.filesSkipped} skipped, ` +
|
|
1753
|
-
`${pushResult.filesDeleted} deleted` +
|
|
1799
|
+
`${pushResult.filesDeleted} deleted, ${pushResult.filesExcludedByScope} scope-excluded` +
|
|
1754
1800
|
(pushResult.aborted ? " — aborted" : ""),
|
|
1755
1801
|
);
|
|
1802
|
+
if (pushResult.filesExcludedByScope > 0) {
|
|
1803
|
+
console.log(chalk.yellow(scopeExcludedWarning(pushResult.filesExcludedByScope)!));
|
|
1804
|
+
}
|
|
1756
1805
|
if (pushResult.aborted) {
|
|
1757
1806
|
console.log(chalk.yellow("\n⚠ Sync now aborted on push leg; pull skipped."));
|
|
1758
1807
|
process.exit(1);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `hq groups` principal detection (groups.ts).
|
|
3
|
+
*
|
|
4
|
+
* Regression coverage for feedback_edb1796a-24be-4b9f-bc60-a06f39d1c9f2:
|
|
5
|
+
* `hq groups add` rejected agent identities (agt_<ULID>), forcing per-agent
|
|
6
|
+
* secret shares instead of group-carried ACLs. Agent uids ride the same
|
|
7
|
+
* personUid wire slot as people (server contract — the group-members handler
|
|
8
|
+
* accepts granteeType "person" with an agt_* granteeId, and every ACL
|
|
9
|
+
* evaluation keys off caller.personUid, which for agent JWTs IS the agt_ uid).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from "vitest";
|
|
13
|
+
|
|
14
|
+
import { detectPrincipalType } from "./groups.js";
|
|
15
|
+
|
|
16
|
+
describe("detectPrincipalType", () => {
|
|
17
|
+
it("detects an email principal and normalizes it", () => {
|
|
18
|
+
expect(detectPrincipalType("Jane@Example.COM")).toEqual({
|
|
19
|
+
granteeType: "email",
|
|
20
|
+
granteeId: "jane@example.com",
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("detects a personUid principal", () => {
|
|
25
|
+
expect(detectPrincipalType("prs_01ABCDEF")).toEqual({
|
|
26
|
+
granteeType: "person",
|
|
27
|
+
granteeId: "prs_01ABCDEF",
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("detects an agentUid principal and sends it in the person slot (regression)", () => {
|
|
32
|
+
expect(detectPrincipalType("agt_01KWFVWZYX8H1DN57ZPNZNKM6C")).toEqual({
|
|
33
|
+
granteeType: "person",
|
|
34
|
+
granteeId: "agt_01KWFVWZYX8H1DN57ZPNZNKM6C",
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("rejects malformed principals", () => {
|
|
39
|
+
expect(detectPrincipalType("agt_")).toBeNull();
|
|
40
|
+
expect(detectPrincipalType("prs_")).toBeNull();
|
|
41
|
+
expect(detectPrincipalType("grp_finance")).toBeNull();
|
|
42
|
+
expect(detectPrincipalType("not a principal")).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
});
|
package/src/commands/groups.ts
CHANGED
|
@@ -6,8 +6,12 @@ import { GROUP_ID_PATTERN } from "./_patterns.js";
|
|
|
6
6
|
|
|
7
7
|
const EMAIL_PATTERN = /^[^\s]+@[^\s]+$/;
|
|
8
8
|
const PERSON_UID_PATTERN = /^prs_[A-Za-z0-9_-]+$/;
|
|
9
|
+
const AGENT_UID_PATTERN = /^agt_[A-Za-z0-9_-]+$/;
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
const INVALID_PRINCIPAL_HINT =
|
|
12
|
+
"must be an email address, a personUid (prs_…), or an agentUid (agt_…)";
|
|
13
|
+
|
|
14
|
+
export function detectPrincipalType(
|
|
11
15
|
principal: string,
|
|
12
16
|
): { granteeType: "email" | "person"; granteeId: string } | null {
|
|
13
17
|
if (EMAIL_PATTERN.test(principal)) {
|
|
@@ -15,7 +19,10 @@ function detectPrincipalType(
|
|
|
15
19
|
// cache keys agree with the server-side canonicalization.
|
|
16
20
|
return { granteeType: "email", granteeId: principal.trim().toLowerCase() };
|
|
17
21
|
}
|
|
18
|
-
|
|
22
|
+
// Agent uids ride the same personUid wire slot as people — that is the
|
|
23
|
+
// server contract (group members, DM recipients, and memberships all carry
|
|
24
|
+
// agt_* in the personUid field; see hq-pro handlers).
|
|
25
|
+
if (PERSON_UID_PATTERN.test(principal) || AGENT_UID_PATTERN.test(principal)) {
|
|
19
26
|
return { granteeType: "person", granteeId: principal };
|
|
20
27
|
}
|
|
21
28
|
return null;
|
|
@@ -130,7 +137,7 @@ export function registerGroupsCommand(program: Command): void {
|
|
|
130
137
|
|
|
131
138
|
groups
|
|
132
139
|
.command("add <groupId> <principal>")
|
|
133
|
-
.description("Add a person to a group (principal: email or
|
|
140
|
+
.description("Add a person or agent to a group (principal: email, personUid, or agentUid)")
|
|
134
141
|
.action(async (groupId: string, principal: string) => {
|
|
135
142
|
try {
|
|
136
143
|
if (!GROUP_ID_PATTERN.test(groupId)) {
|
|
@@ -140,7 +147,7 @@ export function registerGroupsCommand(program: Command): void {
|
|
|
140
147
|
|
|
141
148
|
const detected = detectPrincipalType(principal);
|
|
142
149
|
if (!detected) {
|
|
143
|
-
console.error(chalk.red(`Invalid principal '${principal}':
|
|
150
|
+
console.error(chalk.red(`Invalid principal '${principal}': ${INVALID_PRINCIPAL_HINT}`));
|
|
144
151
|
process.exit(1);
|
|
145
152
|
}
|
|
146
153
|
|
|
@@ -181,7 +188,7 @@ export function registerGroupsCommand(program: Command): void {
|
|
|
181
188
|
|
|
182
189
|
groups
|
|
183
190
|
.command("remove <groupId> <principal>")
|
|
184
|
-
.description("Remove a person from a group (principal: email or
|
|
191
|
+
.description("Remove a person or agent from a group (principal: email, personUid, or agentUid)")
|
|
185
192
|
.action(async (groupId: string, principal: string) => {
|
|
186
193
|
try {
|
|
187
194
|
if (!GROUP_ID_PATTERN.test(groupId)) {
|
|
@@ -191,7 +198,7 @@ export function registerGroupsCommand(program: Command): void {
|
|
|
191
198
|
|
|
192
199
|
const detected = detectPrincipalType(principal);
|
|
193
200
|
if (!detected) {
|
|
194
|
-
console.error(chalk.red(`Invalid principal '${principal}':
|
|
201
|
+
console.error(chalk.red(`Invalid principal '${principal}': ${INVALID_PRINCIPAL_HINT}`));
|
|
195
202
|
process.exit(1);
|
|
196
203
|
}
|
|
197
204
|
|
|
@@ -65,6 +65,85 @@ function jsonRes(body: unknown, status = 200): Response {
|
|
|
65
65
|
});
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
describe("meetings get — short id resolution", () => {
|
|
69
|
+
it("resolves an 8-char short id to the full id before calling the by-id endpoint", async () => {
|
|
70
|
+
const fullId = "589bfd78-aaaa-bbbb-cccc-1234567890ab";
|
|
71
|
+
vi.mocked(vaultApiFetch)
|
|
72
|
+
.mockResolvedValueOnce(
|
|
73
|
+
jsonRes({
|
|
74
|
+
meetings: [
|
|
75
|
+
{
|
|
76
|
+
meetingId: fullId,
|
|
77
|
+
title: "T",
|
|
78
|
+
status: "ready",
|
|
79
|
+
startTime: "2026-01-01T00:00:00Z",
|
|
80
|
+
endTime: "2026-01-01T00:00:00Z",
|
|
81
|
+
duration: 0,
|
|
82
|
+
participantCount: 0,
|
|
83
|
+
hasTranscript: false,
|
|
84
|
+
hasNotes: false,
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
}),
|
|
88
|
+
)
|
|
89
|
+
.mockResolvedValueOnce(
|
|
90
|
+
jsonRes({
|
|
91
|
+
meetingId: fullId,
|
|
92
|
+
title: "T",
|
|
93
|
+
startTime: "2026-01-01T00:00:00Z",
|
|
94
|
+
endTime: "2026-01-01T00:00:00Z",
|
|
95
|
+
duration: 0,
|
|
96
|
+
participants: [],
|
|
97
|
+
calendarEventId: null,
|
|
98
|
+
botProvider: "recall",
|
|
99
|
+
sourceApp: "calendar",
|
|
100
|
+
companyId: "cmp_acme",
|
|
101
|
+
status: "ready",
|
|
102
|
+
recallBotId: null,
|
|
103
|
+
isShared: false,
|
|
104
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
105
|
+
updatedAt: "2026-01-01T00:00:00Z",
|
|
106
|
+
documentUrl: "",
|
|
107
|
+
hasTranscript: false,
|
|
108
|
+
hasNotes: false,
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const program = buildProgram();
|
|
113
|
+
await program.parseAsync(["node", "hq", "meetings", "get", "589bfd78"]);
|
|
114
|
+
|
|
115
|
+
expect(vaultApiFetch).toHaveBeenNthCalledWith(1, {
|
|
116
|
+
token: "test-token",
|
|
117
|
+
path: "/v1/meetings",
|
|
118
|
+
query: { limit: "500" },
|
|
119
|
+
});
|
|
120
|
+
expect(vaultApiFetch).toHaveBeenNthCalledWith(2, {
|
|
121
|
+
token: "test-token",
|
|
122
|
+
path: `/v1/meetings/${fullId}`,
|
|
123
|
+
query: {},
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("errors and never sends the truncated id when the short id matches no meeting", async () => {
|
|
128
|
+
vi.spyOn(process, "exit").mockImplementation((() => {
|
|
129
|
+
throw new Error("process.exit");
|
|
130
|
+
}) as never);
|
|
131
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(jsonRes({ meetings: [] }));
|
|
132
|
+
|
|
133
|
+
const program = buildProgram();
|
|
134
|
+
await expect(
|
|
135
|
+
program.parseAsync(["node", "hq", "meetings", "get", "589bfd78"]),
|
|
136
|
+
).rejects.toThrow("process.exit");
|
|
137
|
+
|
|
138
|
+
expect(errSpy).toHaveBeenCalledWith(
|
|
139
|
+
expect.stringContaining("No meeting matches ID \"589bfd78\""),
|
|
140
|
+
);
|
|
141
|
+
expect(vaultApiFetch).not.toHaveBeenCalledWith(
|
|
142
|
+
expect.objectContaining({ path: "/v1/meetings/589bfd78" }),
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
68
147
|
describe("meetings set-company", () => {
|
|
69
148
|
it("POSTs the resolved company id and applies to the recurring series by default", async () => {
|
|
70
149
|
vi.mocked(vaultApiFetch).mockResolvedValueOnce(
|
|
@@ -225,3 +304,49 @@ describe("meetings set-company", () => {
|
|
|
225
304
|
expect(output).not.toContain("Future occurrences of this recurring series will inherit this attribution.");
|
|
226
305
|
});
|
|
227
306
|
});
|
|
307
|
+
|
|
308
|
+
describe("meetings list — null-safe rendering", () => {
|
|
309
|
+
it("does not crash when a meeting has an undefined or null title", async () => {
|
|
310
|
+
// The API can return meetings with a missing/null title even though the
|
|
311
|
+
// type says string. This list previously crashed with
|
|
312
|
+
// "Cannot read properties of undefined (reading 'length')" after printing
|
|
313
|
+
// the header. Assert the command completes and renders a placeholder.
|
|
314
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(
|
|
315
|
+
jsonRes({
|
|
316
|
+
meetings: [
|
|
317
|
+
{
|
|
318
|
+
meetingId: "589bfd78-aaaa-bbbb-cccc-1234567890ab",
|
|
319
|
+
// title intentionally omitted (undefined)
|
|
320
|
+
status: "ready",
|
|
321
|
+
startTime: "2026-01-01T00:00:00Z",
|
|
322
|
+
endTime: "2026-01-01T00:30:00Z",
|
|
323
|
+
duration: 1800,
|
|
324
|
+
participantCount: 3,
|
|
325
|
+
hasTranscript: true,
|
|
326
|
+
hasNotes: false,
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
meetingId: "689bfd78-aaaa-bbbb-cccc-1234567890ab",
|
|
330
|
+
title: null,
|
|
331
|
+
status: "ready",
|
|
332
|
+
startTime: "2026-01-02T00:00:00Z",
|
|
333
|
+
endTime: "2026-01-02T00:30:00Z",
|
|
334
|
+
duration: 1800,
|
|
335
|
+
participantCount: 1,
|
|
336
|
+
hasTranscript: false,
|
|
337
|
+
hasNotes: true,
|
|
338
|
+
},
|
|
339
|
+
],
|
|
340
|
+
}),
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
const program = buildProgram();
|
|
344
|
+
await expect(
|
|
345
|
+
program.parseAsync(["node", "hq", "meetings", "list", "--company", "indigo"]),
|
|
346
|
+
).resolves.toBeDefined();
|
|
347
|
+
|
|
348
|
+
const printed = logSpy.mock.calls.map(([line]) => String(line)).join("\n");
|
|
349
|
+
expect(printed).toContain("Meetings (2)");
|
|
350
|
+
expect(printed).toContain("(untitled)");
|
|
351
|
+
});
|
|
352
|
+
});
|
package/src/commands/meetings.ts
CHANGED
|
@@ -105,8 +105,16 @@ async function resolveShortId(
|
|
|
105
105
|
query: Record<string, string>,
|
|
106
106
|
): Promise<string> {
|
|
107
107
|
if (prefix.includes("-") && prefix.length > 8) return prefix;
|
|
108
|
-
const res = await vaultApiFetch({
|
|
109
|
-
|
|
108
|
+
const res = await vaultApiFetch({
|
|
109
|
+
token,
|
|
110
|
+
path: "/v1/meetings",
|
|
111
|
+
query: { ...query, limit: "500" },
|
|
112
|
+
});
|
|
113
|
+
if (!res.ok) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`Could not resolve meeting ID "${prefix}" — failed to list meetings (${res.status}). Pass the full meeting id.`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
110
118
|
const data = (await res.json()) as { meetings: MeetingListItem[] };
|
|
111
119
|
const matches = data.meetings.filter((m) => m.meetingId.startsWith(prefix));
|
|
112
120
|
if (matches.length === 1) return matches[0].meetingId;
|
|
@@ -114,7 +122,12 @@ async function resolveShortId(
|
|
|
114
122
|
console.error(chalk.red(`Ambiguous ID prefix "${prefix}" — matches ${matches.length} meetings. Use a longer prefix.`));
|
|
115
123
|
process.exit(1);
|
|
116
124
|
}
|
|
117
|
-
|
|
125
|
+
console.error(
|
|
126
|
+
chalk.red(
|
|
127
|
+
`No meeting matches ID "${prefix}". It may be older than the meetings shown by \`hq meetings list\`, still processing, or attributed to a different company. Pass the full meeting id, add --company <slug>, or widen the list with \`hq meetings list --limit <n>\`.`,
|
|
128
|
+
),
|
|
129
|
+
);
|
|
130
|
+
process.exit(1);
|
|
118
131
|
}
|
|
119
132
|
|
|
120
133
|
async function handleApiError(res: Response): Promise<never> {
|
|
@@ -137,8 +150,13 @@ function printMeetingTable(meetings: MeetingListItem[]): void {
|
|
|
137
150
|
return;
|
|
138
151
|
}
|
|
139
152
|
|
|
153
|
+
// Titles can come back null/undefined from the API even though the type says
|
|
154
|
+
// string; fall back to a placeholder so width calc + rendering never crash on
|
|
155
|
+
// `undefined.length`.
|
|
156
|
+
const displayTitle = (m: MeetingListItem): string => m.title ?? "(untitled)";
|
|
157
|
+
|
|
140
158
|
const ID_W = 8;
|
|
141
|
-
const TITLE_W = Math.min(40, Math.max(10, ...meetings.map((m) => m.
|
|
159
|
+
const TITLE_W = Math.min(40, Math.max(10, ...meetings.map((m) => displayTitle(m).length)));
|
|
142
160
|
const DATE_W = 16;
|
|
143
161
|
const DUR_W = 8;
|
|
144
162
|
const STATUS_W = 12;
|
|
@@ -161,7 +179,8 @@ function printMeetingTable(meetings: MeetingListItem[]): void {
|
|
|
161
179
|
|
|
162
180
|
for (const m of meetings) {
|
|
163
181
|
const id = m.meetingId.slice(0, 8);
|
|
164
|
-
const
|
|
182
|
+
const fullTitle = displayTitle(m);
|
|
183
|
+
const title = fullTitle.length > TITLE_W ? fullTitle.slice(0, TITLE_W - 1) + "…" : fullTitle;
|
|
165
184
|
const date = new Date(m.startTime).toLocaleDateString("en-US", {
|
|
166
185
|
month: "short",
|
|
167
186
|
day: "numeric",
|