@indigoai-us/hq-cli 5.14.1 → 5.16.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/.github/workflows/ci.yml +21 -0
- package/.github/workflows/publish.yml +86 -0
- package/.github/workflows/scripts/smoke-test-pkg.sh +97 -0
- package/dist/commands/cloud-provision.d.ts +25 -0
- package/dist/commands/cloud-provision.js +89 -10
- package/dist/commands/meetings.d.ts +3 -0
- package/dist/commands/meetings.js +374 -0
- package/dist/index.js +5 -2
- package/package.json +3 -2
- package/src/commands/cloud-provision.test.ts +43 -2
- package/src/commands/cloud-provision.ts +135 -8
- package/src/commands/meetings.ts +487 -0
- package/src/index.ts +4 -0
- package/tsconfig.json +12 -1
|
@@ -0,0 +1,374 @@
|
|
|
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]="8237a835-5842-52be-bb27-c4ffb7944d89")}catch(e){}}();
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
5
|
+
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
6
|
+
function formatDuration(seconds) {
|
|
7
|
+
const h = Math.floor(seconds / 3600);
|
|
8
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
9
|
+
const s = Math.floor(seconds % 60);
|
|
10
|
+
if (h > 0)
|
|
11
|
+
return `${h}h ${m}m`;
|
|
12
|
+
if (m > 0)
|
|
13
|
+
return `${m}m ${s}s`;
|
|
14
|
+
return `${s}s`;
|
|
15
|
+
}
|
|
16
|
+
function formatTimestamp(ts) {
|
|
17
|
+
const m = Math.floor(ts / 60);
|
|
18
|
+
const s = Math.floor(ts % 60);
|
|
19
|
+
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
20
|
+
}
|
|
21
|
+
function statusBadge(status) {
|
|
22
|
+
switch (status) {
|
|
23
|
+
case "completed":
|
|
24
|
+
return chalk.green(status);
|
|
25
|
+
case "recording":
|
|
26
|
+
return chalk.red(status);
|
|
27
|
+
case "processing":
|
|
28
|
+
return chalk.yellow(status);
|
|
29
|
+
case "failed":
|
|
30
|
+
return chalk.red(status);
|
|
31
|
+
default:
|
|
32
|
+
return chalk.dim(status);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function resolveShortId(token, prefix, query) {
|
|
36
|
+
if (prefix.includes("-") && prefix.length > 8)
|
|
37
|
+
return prefix;
|
|
38
|
+
const res = await vaultApiFetch({ token, path: "/v1/meetings", query });
|
|
39
|
+
if (!res.ok)
|
|
40
|
+
return prefix;
|
|
41
|
+
const data = (await res.json());
|
|
42
|
+
const matches = data.meetings.filter((m) => m.meetingId.startsWith(prefix));
|
|
43
|
+
if (matches.length === 1)
|
|
44
|
+
return matches[0].meetingId;
|
|
45
|
+
if (matches.length > 1) {
|
|
46
|
+
console.error(chalk.red(`Ambiguous ID prefix "${prefix}" — matches ${matches.length} meetings. Use a longer prefix.`));
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
return prefix;
|
|
50
|
+
}
|
|
51
|
+
async function handleApiError(res) {
|
|
52
|
+
const body = (await res.json().catch(() => ({})));
|
|
53
|
+
if (res.status === 401) {
|
|
54
|
+
console.error(chalk.red("Not authenticated — run `hq login` first"));
|
|
55
|
+
}
|
|
56
|
+
else if (res.status === 403) {
|
|
57
|
+
console.error(chalk.red(body.error ?? "Not authorized"));
|
|
58
|
+
}
|
|
59
|
+
else if (res.status === 404) {
|
|
60
|
+
console.error(chalk.red(body.error ?? "Not found"));
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
console.error(chalk.red(`API error (${res.status}): ${body.error ?? res.statusText}`));
|
|
64
|
+
}
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
function printMeetingTable(meetings) {
|
|
68
|
+
if (meetings.length === 0) {
|
|
69
|
+
console.log(chalk.dim(" No meetings found."));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const ID_W = 8;
|
|
73
|
+
const TITLE_W = Math.min(40, Math.max(10, ...meetings.map((m) => m.title.length)));
|
|
74
|
+
const DATE_W = 16;
|
|
75
|
+
const DUR_W = 8;
|
|
76
|
+
const STATUS_W = 12;
|
|
77
|
+
const PARTS_W = 6;
|
|
78
|
+
const FLAGS_W = 5;
|
|
79
|
+
console.log(chalk.bold([
|
|
80
|
+
"ID".padEnd(ID_W),
|
|
81
|
+
"TITLE".padEnd(TITLE_W),
|
|
82
|
+
"DATE".padEnd(DATE_W),
|
|
83
|
+
"DUR".padEnd(DUR_W),
|
|
84
|
+
"STATUS".padEnd(STATUS_W),
|
|
85
|
+
"PARTS".padEnd(PARTS_W),
|
|
86
|
+
"FLAGS",
|
|
87
|
+
].join(" ")));
|
|
88
|
+
for (const m of meetings) {
|
|
89
|
+
const id = m.meetingId.slice(0, 8);
|
|
90
|
+
const title = m.title.length > TITLE_W ? m.title.slice(0, TITLE_W - 1) + "…" : m.title;
|
|
91
|
+
const date = new Date(m.startTime).toLocaleDateString("en-US", {
|
|
92
|
+
month: "short",
|
|
93
|
+
day: "numeric",
|
|
94
|
+
hour: "2-digit",
|
|
95
|
+
minute: "2-digit",
|
|
96
|
+
});
|
|
97
|
+
const dur = formatDuration(m.duration);
|
|
98
|
+
const flags = [
|
|
99
|
+
m.hasTranscript ? "T" : "",
|
|
100
|
+
m.hasNotes ? "N" : "",
|
|
101
|
+
].filter(Boolean).join("") || "-";
|
|
102
|
+
console.log([
|
|
103
|
+
chalk.cyan(id.padEnd(ID_W)),
|
|
104
|
+
title.padEnd(TITLE_W),
|
|
105
|
+
chalk.dim(date.padEnd(DATE_W)),
|
|
106
|
+
dur.padEnd(DUR_W),
|
|
107
|
+
statusBadge(m.status).padEnd(STATUS_W + 10), // chalk adds escape chars
|
|
108
|
+
String(m.participantCount).padEnd(PARTS_W),
|
|
109
|
+
flags,
|
|
110
|
+
].join(" "));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export function registerMeetingsCommand(program) {
|
|
114
|
+
const meetings = program
|
|
115
|
+
.command("meetings")
|
|
116
|
+
.description("View and search meeting recordings, transcripts, and notes")
|
|
117
|
+
.option("--company <slug>", "Company slug (for multi-company users)")
|
|
118
|
+
.option("--json", "Output raw JSON instead of formatted text");
|
|
119
|
+
// ── hq meetings list ──────────────────────────────────────────────
|
|
120
|
+
meetings
|
|
121
|
+
.command("list")
|
|
122
|
+
.description("List recorded meetings (newest first)")
|
|
123
|
+
.option("--limit <n>", "Number of meetings to return (default: 20)")
|
|
124
|
+
.option("--next <token>", "Pagination token from a previous response")
|
|
125
|
+
.action(async (opts) => {
|
|
126
|
+
try {
|
|
127
|
+
const token = await ensureCognitoToken();
|
|
128
|
+
const query = {};
|
|
129
|
+
const companySlug = meetings.opts().company;
|
|
130
|
+
if (opts.limit)
|
|
131
|
+
query.limit = opts.limit;
|
|
132
|
+
if (opts.next)
|
|
133
|
+
query.nextToken = opts.next;
|
|
134
|
+
if (companySlug)
|
|
135
|
+
query.companyId = companySlug;
|
|
136
|
+
const res = await vaultApiFetch({ token, path: "/v1/meetings", query });
|
|
137
|
+
if (!res.ok)
|
|
138
|
+
await handleApiError(res);
|
|
139
|
+
const data = (await res.json());
|
|
140
|
+
if (meetings.opts().json) {
|
|
141
|
+
console.log(JSON.stringify(data, null, 2));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
console.log(chalk.bold(`\nMeetings (${data.meetings.length}):\n`));
|
|
145
|
+
printMeetingTable(data.meetings);
|
|
146
|
+
if (data.nextToken) {
|
|
147
|
+
console.log(chalk.dim(`\n More results available. Run with --next ${data.nextToken}`));
|
|
148
|
+
}
|
|
149
|
+
console.log();
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
// ── hq meetings get <id> ──────────────────────────────────────────
|
|
157
|
+
meetings
|
|
158
|
+
.command("get <meetingId>")
|
|
159
|
+
.description("Show meeting details")
|
|
160
|
+
.action(async (rawId) => {
|
|
161
|
+
try {
|
|
162
|
+
const token = await ensureCognitoToken();
|
|
163
|
+
const query = {};
|
|
164
|
+
const companySlug = meetings.opts().company;
|
|
165
|
+
if (companySlug)
|
|
166
|
+
query.companyId = companySlug;
|
|
167
|
+
const meetingId = await resolveShortId(token, rawId, query);
|
|
168
|
+
const res = await vaultApiFetch({
|
|
169
|
+
token,
|
|
170
|
+
path: `/v1/meetings/${encodeURIComponent(meetingId)}`,
|
|
171
|
+
query,
|
|
172
|
+
});
|
|
173
|
+
if (!res.ok)
|
|
174
|
+
await handleApiError(res);
|
|
175
|
+
const detail = (await res.json());
|
|
176
|
+
if (meetings.opts().json) {
|
|
177
|
+
console.log(JSON.stringify(detail, null, 2));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
console.log(chalk.bold(`\n${detail.title}\n`));
|
|
181
|
+
console.log(` ID: ${chalk.cyan(detail.meetingId)}`);
|
|
182
|
+
console.log(` Status: ${statusBadge(detail.status)}`);
|
|
183
|
+
console.log(` Date: ${new Date(detail.startTime).toLocaleString()}`);
|
|
184
|
+
console.log(` Duration: ${formatDuration(detail.duration)}`);
|
|
185
|
+
console.log(` Source: ${detail.sourceApp} (${detail.botProvider})`);
|
|
186
|
+
console.log(` Shared: ${detail.isShared ? "yes" : "no"}`);
|
|
187
|
+
if (detail.participants.length > 0) {
|
|
188
|
+
console.log(chalk.bold("\n Participants:"));
|
|
189
|
+
for (const p of detail.participants) {
|
|
190
|
+
const name = p.name ?? p.email;
|
|
191
|
+
const role = p.role === "organizer" ? chalk.yellow(" (organizer)") : "";
|
|
192
|
+
console.log(` - ${name}${role}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const flags = [];
|
|
196
|
+
if (detail.hasTranscript)
|
|
197
|
+
flags.push("transcript");
|
|
198
|
+
if (detail.hasNotes)
|
|
199
|
+
flags.push("notes");
|
|
200
|
+
if (flags.length > 0) {
|
|
201
|
+
console.log(chalk.dim(`\n Available: ${flags.join(", ")}. Use \`hq meetings transcript ${detail.meetingId.slice(0, 8)}\` or \`hq meetings notes ${detail.meetingId.slice(0, 8)}\`.`));
|
|
202
|
+
}
|
|
203
|
+
console.log();
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
// ── hq meetings search <query> ─────────────────────────────────────
|
|
211
|
+
meetings
|
|
212
|
+
.command("search <query>")
|
|
213
|
+
.description("Search meetings by title or participant name")
|
|
214
|
+
.action(async (query) => {
|
|
215
|
+
try {
|
|
216
|
+
const token = await ensureCognitoToken();
|
|
217
|
+
const params = { q: query };
|
|
218
|
+
const companySlug = meetings.opts().company;
|
|
219
|
+
if (companySlug)
|
|
220
|
+
params.companyId = companySlug;
|
|
221
|
+
const res = await vaultApiFetch({
|
|
222
|
+
token,
|
|
223
|
+
path: "/v1/meetings/search",
|
|
224
|
+
query: params,
|
|
225
|
+
});
|
|
226
|
+
if (!res.ok)
|
|
227
|
+
await handleApiError(res);
|
|
228
|
+
const data = (await res.json());
|
|
229
|
+
if (meetings.opts().json) {
|
|
230
|
+
console.log(JSON.stringify(data, null, 2));
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
console.log(chalk.bold(`\nSearch results for "${data.query}" (${data.results.length}):\n`));
|
|
234
|
+
printMeetingTable(data.results);
|
|
235
|
+
console.log();
|
|
236
|
+
}
|
|
237
|
+
catch (err) {
|
|
238
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
// ── hq meetings transcript <id> ────────────────────────────────────
|
|
243
|
+
meetings
|
|
244
|
+
.command("transcript <meetingId>")
|
|
245
|
+
.description("Print the meeting transcript")
|
|
246
|
+
.action(async (rawId) => {
|
|
247
|
+
try {
|
|
248
|
+
const token = await ensureCognitoToken();
|
|
249
|
+
const query = {};
|
|
250
|
+
const companySlug = meetings.opts().company;
|
|
251
|
+
if (companySlug)
|
|
252
|
+
query.companyId = companySlug;
|
|
253
|
+
const meetingId = await resolveShortId(token, rawId, query);
|
|
254
|
+
const res = await vaultApiFetch({
|
|
255
|
+
token,
|
|
256
|
+
path: `/v1/meetings/${encodeURIComponent(meetingId)}`,
|
|
257
|
+
query,
|
|
258
|
+
});
|
|
259
|
+
if (!res.ok)
|
|
260
|
+
await handleApiError(res);
|
|
261
|
+
const detail = (await res.json());
|
|
262
|
+
if (!detail.documentUrl) {
|
|
263
|
+
console.error(chalk.red("No document URL available for this meeting."));
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
const docRes = await fetch(detail.documentUrl);
|
|
267
|
+
if (!docRes.ok) {
|
|
268
|
+
console.error(chalk.red(`Failed to download meeting document (${docRes.status})`));
|
|
269
|
+
process.exit(1);
|
|
270
|
+
}
|
|
271
|
+
const doc = (await docRes.json());
|
|
272
|
+
if (meetings.opts().json) {
|
|
273
|
+
console.log(JSON.stringify(doc.transcript, null, 2));
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (!doc.transcript || doc.transcript.length === 0) {
|
|
277
|
+
console.log(chalk.yellow("No transcript available for this meeting."));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
console.log(chalk.bold(`\nTranscript: ${doc.title}\n`));
|
|
281
|
+
for (const seg of doc.transcript) {
|
|
282
|
+
const time = formatTimestamp(seg.startTime);
|
|
283
|
+
console.log(`${chalk.dim(time)} ${chalk.cyan(seg.speaker)}`);
|
|
284
|
+
console.log(` ${seg.text}\n`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
289
|
+
process.exit(1);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
// ── hq meetings notes <id> ─────────────────────────────────────────
|
|
293
|
+
meetings
|
|
294
|
+
.command("notes <meetingId>")
|
|
295
|
+
.description("Print AI-generated meeting notes")
|
|
296
|
+
.action(async (rawId) => {
|
|
297
|
+
try {
|
|
298
|
+
const token = await ensureCognitoToken();
|
|
299
|
+
const query = {};
|
|
300
|
+
const companySlug = meetings.opts().company;
|
|
301
|
+
if (companySlug)
|
|
302
|
+
query.companyId = companySlug;
|
|
303
|
+
const meetingId = await resolveShortId(token, rawId, query);
|
|
304
|
+
const res = await vaultApiFetch({
|
|
305
|
+
token,
|
|
306
|
+
path: `/v1/meetings/${encodeURIComponent(meetingId)}`,
|
|
307
|
+
query,
|
|
308
|
+
});
|
|
309
|
+
if (!res.ok)
|
|
310
|
+
await handleApiError(res);
|
|
311
|
+
const detail = (await res.json());
|
|
312
|
+
if (!detail.documentUrl) {
|
|
313
|
+
console.error(chalk.red("No document URL available for this meeting."));
|
|
314
|
+
process.exit(1);
|
|
315
|
+
}
|
|
316
|
+
const docRes = await fetch(detail.documentUrl);
|
|
317
|
+
if (!docRes.ok) {
|
|
318
|
+
console.error(chalk.red(`Failed to download meeting document (${docRes.status})`));
|
|
319
|
+
process.exit(1);
|
|
320
|
+
}
|
|
321
|
+
const doc = (await docRes.json());
|
|
322
|
+
if (meetings.opts().json) {
|
|
323
|
+
console.log(JSON.stringify(doc.notes, null, 2));
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (!doc.notes) {
|
|
327
|
+
console.log(chalk.yellow("No notes available for this meeting."));
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const notes = doc.notes;
|
|
331
|
+
console.log(chalk.bold(`\nMeeting Notes: ${doc.title}\n`));
|
|
332
|
+
console.log(chalk.bold("Summary"));
|
|
333
|
+
console.log(` ${notes.summary}\n`);
|
|
334
|
+
if (notes.keyPoints.length > 0) {
|
|
335
|
+
console.log(chalk.bold("Key Points"));
|
|
336
|
+
for (const point of notes.keyPoints) {
|
|
337
|
+
console.log(` • ${point}`);
|
|
338
|
+
}
|
|
339
|
+
console.log();
|
|
340
|
+
}
|
|
341
|
+
if (notes.decisions.length > 0) {
|
|
342
|
+
console.log(chalk.bold("Decisions"));
|
|
343
|
+
for (const d of notes.decisions) {
|
|
344
|
+
console.log(` ✓ ${d}`);
|
|
345
|
+
}
|
|
346
|
+
console.log();
|
|
347
|
+
}
|
|
348
|
+
if (notes.actionItems.length > 0) {
|
|
349
|
+
console.log(chalk.bold("Action Items"));
|
|
350
|
+
for (const item of notes.actionItems) {
|
|
351
|
+
const assignee = item.assignee ? chalk.dim(` → ${item.assignee}`) : "";
|
|
352
|
+
console.log(` □ ${item.task}${assignee}`);
|
|
353
|
+
}
|
|
354
|
+
console.log();
|
|
355
|
+
}
|
|
356
|
+
if (notes.participantContributions.length > 0) {
|
|
357
|
+
console.log(chalk.bold("Participant Contributions"));
|
|
358
|
+
for (const p of notes.participantContributions) {
|
|
359
|
+
console.log(` ${p.name} (${p.speakingTimePercent}%)`);
|
|
360
|
+
console.log(` ${chalk.dim(p.topicsSummary)}`);
|
|
361
|
+
}
|
|
362
|
+
console.log();
|
|
363
|
+
}
|
|
364
|
+
console.log(chalk.dim(`Generated by ${notes.model} at ${notes.generatedAt}`));
|
|
365
|
+
console.log();
|
|
366
|
+
}
|
|
367
|
+
catch (err) {
|
|
368
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
369
|
+
process.exit(1);
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
//# sourceMappingURL=meetings.js.map
|
|
374
|
+
//# debugId=8237a835-5842-52be-bb27-c4ffb7944d89
|
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]="15889dc3-5d7e-5fb9-8df3-33d5aebdc945")}catch(e){}}();
|
|
7
7
|
import { Command } from "commander";
|
|
8
8
|
import { initSentry, Sentry } from "./sentry.js";
|
|
9
9
|
import { registerAddCommand } from "./commands/add.js";
|
|
@@ -29,6 +29,7 @@ import { registerGroupsCommand } from "./commands/groups.js";
|
|
|
29
29
|
import { registerFilesCommand } from "./commands/files.js";
|
|
30
30
|
import { registerMembersCommand } from "./commands/members.js";
|
|
31
31
|
import { registerFeedbackCommand } from "./commands/feedback.js";
|
|
32
|
+
import { registerMeetingsCommand } from "./commands/meetings.js";
|
|
32
33
|
import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
|
|
33
34
|
import { maybeWarnNewVersion, refreshVersionCache, } from "./utils/version-check.js";
|
|
34
35
|
import { CLI_VERSION } from "./cli-version.js";
|
|
@@ -102,6 +103,8 @@ registerMembersCommand(program);
|
|
|
102
103
|
registerOnboardCommand(program);
|
|
103
104
|
// Feedback (subcommand group — hq feedback bug|feature)
|
|
104
105
|
registerFeedbackCommand(program);
|
|
106
|
+
// Meetings (subcommand group — hq meetings list|get|search|transcript|notes)
|
|
107
|
+
registerMeetingsCommand(program);
|
|
105
108
|
(async () => {
|
|
106
109
|
try {
|
|
107
110
|
Sentry.addBreadcrumb({
|
|
@@ -120,4 +123,4 @@ registerFeedbackCommand(program);
|
|
|
120
123
|
}
|
|
121
124
|
})();
|
|
122
125
|
//# sourceMappingURL=index.js.map
|
|
123
|
-
//# debugId=
|
|
126
|
+
//# debugId=15889dc3-5d7e-5fb9-8df3-33d5aebdc945
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.16.0",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -29,8 +29,9 @@
|
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@types/js-yaml": "^4.0.9",
|
|
31
31
|
"@types/node": "^22.0.0",
|
|
32
|
+
"@types/semver": "^7.5.8",
|
|
32
33
|
"typescript": "^5.7.0",
|
|
33
|
-
"
|
|
34
|
+
"vitest": "^4.1.2"
|
|
34
35
|
},
|
|
35
36
|
"repository": {
|
|
36
37
|
"type": "git",
|
|
@@ -236,6 +236,22 @@ describe("ensureManifestEntryForProvision", () => {
|
|
|
236
236
|
expect(() => validateManifestAndDir(tmpRoot, "indigo")).toThrow();
|
|
237
237
|
});
|
|
238
238
|
|
|
239
|
+
it("does NOT insert when companies/<slug> exists as a regular file (not a dir)", () => {
|
|
240
|
+
seedManifest(tmpRoot, { other: { status: "active" } });
|
|
241
|
+
// Create a regular file at companies/indigo (not a directory). Without
|
|
242
|
+
// the isDirectory() guard, existsSync would return true and the helper
|
|
243
|
+
// would auto-insert — then provisionCompany would create the vault
|
|
244
|
+
// entity + patch manifest BEFORE writeCompanyConfig's mkdir failed with
|
|
245
|
+
// ENOTDIR. Verify no mutation.
|
|
246
|
+
const companiesDir = path.join(tmpRoot, "companies");
|
|
247
|
+
fs.mkdirSync(companiesDir, { recursive: true });
|
|
248
|
+
fs.writeFileSync(path.join(companiesDir, "indigo"), "not a directory");
|
|
249
|
+
const before = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
|
|
250
|
+
ensureManifestEntryForProvision(tmpRoot, "indigo");
|
|
251
|
+
const after = fs.readFileSync(manifestPath(tmpRoot), "utf-8");
|
|
252
|
+
expect(after).toBe(before);
|
|
253
|
+
});
|
|
254
|
+
|
|
239
255
|
it("does NOT insert when manifest file is missing entirely", () => {
|
|
240
256
|
seedCompanyDir(tmpRoot, "indigo");
|
|
241
257
|
expect(() =>
|
|
@@ -566,6 +582,20 @@ describe("provisionCompany", () => {
|
|
|
566
582
|
listMyPersonEntities: vi.fn().mockResolvedValue([
|
|
567
583
|
{ uid: "prs_01H", type: "person", slug: "test-user", name: "Test User" },
|
|
568
584
|
]),
|
|
585
|
+
// Default to "slug available in caller's namespace" so happy-path
|
|
586
|
+
// tests fall through to createCompanyEntity. Reuse-path tests
|
|
587
|
+
// override with {available: false, conflictingCompanyUid: ...}
|
|
588
|
+
// and supply a getCompanyByUid that returns the existing entity.
|
|
589
|
+
checkSlugInMyNamespace: vi
|
|
590
|
+
.fn()
|
|
591
|
+
.mockResolvedValue({ available: true }),
|
|
592
|
+
getCompanyByUid: vi
|
|
593
|
+
.fn()
|
|
594
|
+
.mockRejectedValue(
|
|
595
|
+
new Error(
|
|
596
|
+
"getCompanyByUid called without an explicit per-test mock — happy path should never hit it",
|
|
597
|
+
),
|
|
598
|
+
),
|
|
569
599
|
findCompanyBySlug: vi.fn().mockResolvedValue(null),
|
|
570
600
|
createCompanyEntity: vi.fn(),
|
|
571
601
|
...overrides,
|
|
@@ -700,7 +730,13 @@ describe("provisionCompany", () => {
|
|
|
700
730
|
kmsKeyId: null,
|
|
701
731
|
};
|
|
702
732
|
const vaultClient = makeVaultClient({
|
|
703
|
-
|
|
733
|
+
// Reuse path under the per-user-namespace model: checkSlugInMyNamespace
|
|
734
|
+
// reports `available: false` with the existing entity's uid, and
|
|
735
|
+
// getCompanyByUid materializes the full entity for downstream use.
|
|
736
|
+
checkSlugInMyNamespace: vi
|
|
737
|
+
.fn()
|
|
738
|
+
.mockResolvedValue({ available: false, conflictingCompanyUid: entity.uid }),
|
|
739
|
+
getCompanyByUid: vi.fn().mockResolvedValue(entity),
|
|
704
740
|
createCompanyEntity: vi.fn(),
|
|
705
741
|
});
|
|
706
742
|
const result = await provisionCompany({
|
|
@@ -715,6 +751,7 @@ describe("provisionCompany", () => {
|
|
|
715
751
|
expect(result.created_entity).toBe(false);
|
|
716
752
|
expect(result.kms_key_id).toBeNull();
|
|
717
753
|
expect(vaultClient.createCompanyEntity).not.toHaveBeenCalled();
|
|
754
|
+
expect(vaultClient.getCompanyByUid).toHaveBeenCalledWith(entity.uid);
|
|
718
755
|
});
|
|
719
756
|
|
|
720
757
|
it("throws code 1 when entity has no bucketName (incomplete provisioning)", async () => {
|
|
@@ -727,7 +764,11 @@ describe("provisionCompany", () => {
|
|
|
727
764
|
// bucketName intentionally absent
|
|
728
765
|
};
|
|
729
766
|
const vaultClient = makeVaultClient({
|
|
730
|
-
|
|
767
|
+
// Same reuse-path mock shape as the idempotent-path test above.
|
|
768
|
+
checkSlugInMyNamespace: vi
|
|
769
|
+
.fn()
|
|
770
|
+
.mockResolvedValue({ available: false, conflictingCompanyUid: entity.uid }),
|
|
771
|
+
getCompanyByUid: vi.fn().mockResolvedValue(entity),
|
|
731
772
|
});
|
|
732
773
|
try {
|
|
733
774
|
await provisionCompany({
|
|
@@ -133,7 +133,32 @@ export interface VaultClient {
|
|
|
133
133
|
* entity.
|
|
134
134
|
*/
|
|
135
135
|
listMyPersonEntities(): Promise<VaultEntity[]>;
|
|
136
|
+
/**
|
|
137
|
+
* Legacy global-uniqueness lookup. Under the per-user-namespace model
|
|
138
|
+
* (hq-pro 2026-05-15) this can return any tenant's entity when more
|
|
139
|
+
* than one user holds the same slug, OR `null` when the caller doesn't
|
|
140
|
+
* have it but a different user does. Kept on the interface for any
|
|
141
|
+
* remaining callers, but `provisionCompany` now uses
|
|
142
|
+
* `checkSlugInMyNamespace` instead — same-slug-different-owner is
|
|
143
|
+
* legitimate and should NOT trigger reuse of the stranger's entity.
|
|
144
|
+
*/
|
|
136
145
|
findCompanyBySlug(slug: string): Promise<VaultEntity | null>;
|
|
146
|
+
/**
|
|
147
|
+
* Caller-scoped slug availability check via
|
|
148
|
+
* `GET /entity/check-slug/me?type=company&slug=...`. Returns
|
|
149
|
+
* `{available: true}` when the caller's namespace
|
|
150
|
+
* (owned ∪ active-member-of, soft-deleted excluded) doesn't hold the
|
|
151
|
+
* slug, or `{available: false, conflictingCompanyUid}` when it does
|
|
152
|
+
* — `provisionCompany` reuses the `conflictingCompanyUid` as the
|
|
153
|
+
* idempotent entity instead of creating a duplicate.
|
|
154
|
+
*/
|
|
155
|
+
checkSlugInMyNamespace(slug: string): Promise<{
|
|
156
|
+
available: boolean;
|
|
157
|
+
conflictingCompanyUid?: string;
|
|
158
|
+
}>;
|
|
159
|
+
/** Fetch a company entity by uid. Used to materialize the entity
|
|
160
|
+
* after `checkSlugInMyNamespace` reports a same-namespace collision. */
|
|
161
|
+
getCompanyByUid(uid: string): Promise<VaultEntity>;
|
|
137
162
|
createCompanyEntity(input: {
|
|
138
163
|
slug: string;
|
|
139
164
|
name: string;
|
|
@@ -302,7 +327,19 @@ export function ensureManifestEntryForProvision(
|
|
|
302
327
|
if (expectedParent !== companiesDir) return;
|
|
303
328
|
if (path.basename(expected) !== slug) return;
|
|
304
329
|
const dir = companyDirPath(hqRoot, slug);
|
|
305
|
-
|
|
330
|
+
// Must be an actual directory, not a stray file. fs.existsSync returns true
|
|
331
|
+
// for regular files too — without this guard, auto-insert would fire on a
|
|
332
|
+
// file at `companies/<slug>`, provisionCompany would then create the vault
|
|
333
|
+
// entity and patch manifest.yaml before writeCompanyConfig's `mkdir -p .hq`
|
|
334
|
+
// exploded with ENOTDIR. statSync swallows the not-found case so a missing
|
|
335
|
+
// path is treated the same as before (no auto-insert).
|
|
336
|
+
let dirStat: fs.Stats;
|
|
337
|
+
try {
|
|
338
|
+
dirStat = fs.statSync(dir);
|
|
339
|
+
} catch {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (!dirStat.isDirectory()) return;
|
|
306
343
|
const raw = fs.readFileSync(mPath, "utf-8");
|
|
307
344
|
let parsed: unknown;
|
|
308
345
|
try {
|
|
@@ -463,6 +500,45 @@ export function createDefaultVaultClient(
|
|
|
463
500
|
}
|
|
464
501
|
return data.entity;
|
|
465
502
|
},
|
|
503
|
+
async checkSlugInMyNamespace(slug: string): Promise<{
|
|
504
|
+
available: boolean;
|
|
505
|
+
conflictingCompanyUid?: string;
|
|
506
|
+
}> {
|
|
507
|
+
const url = `${apiUrl.replace(/\/$/, "")}/entity/check-slug/me?type=company&slug=${encodeURIComponent(
|
|
508
|
+
slug,
|
|
509
|
+
)}`;
|
|
510
|
+
const res = await fetch(url, { method: "GET", headers });
|
|
511
|
+
if (!res.ok) {
|
|
512
|
+
const body = await safeBody(res);
|
|
513
|
+
throw new ProvisionError(
|
|
514
|
+
1,
|
|
515
|
+
`Vault GET /entity/check-slug/me failed: ${res.status} ${res.statusText} — ${body}`,
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
return (await res.json()) as {
|
|
519
|
+
available: boolean;
|
|
520
|
+
conflictingCompanyUid?: string;
|
|
521
|
+
};
|
|
522
|
+
},
|
|
523
|
+
async getCompanyByUid(uid: string): Promise<VaultEntity> {
|
|
524
|
+
const url = `${apiUrl.replace(/\/$/, "")}/entity/${encodeURIComponent(uid)}`;
|
|
525
|
+
const res = await fetch(url, { method: "GET", headers });
|
|
526
|
+
if (!res.ok) {
|
|
527
|
+
const body = await safeBody(res);
|
|
528
|
+
throw new ProvisionError(
|
|
529
|
+
1,
|
|
530
|
+
`Vault GET /entity/${uid} failed: ${res.status} ${res.statusText} — ${body}`,
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
const data = (await res.json()) as { entity?: VaultEntity };
|
|
534
|
+
if (!data.entity) {
|
|
535
|
+
throw new ProvisionError(
|
|
536
|
+
1,
|
|
537
|
+
`Vault GET /entity/${uid} returned 200 with no entity body`,
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
return data.entity;
|
|
541
|
+
},
|
|
466
542
|
async createCompanyEntity(input: {
|
|
467
543
|
slug: string;
|
|
468
544
|
name: string;
|
|
@@ -482,9 +558,15 @@ export function createDefaultVaultClient(
|
|
|
482
558
|
});
|
|
483
559
|
if (!res.ok) {
|
|
484
560
|
const text = await safeBody(res);
|
|
485
|
-
// 409
|
|
486
|
-
//
|
|
487
|
-
//
|
|
561
|
+
// 409 SLUG_IN_USE_FOR_PERSON: the caller already has the slug
|
|
562
|
+
// in their namespace (owned ∪ active-member-of). Under the
|
|
563
|
+
// per-user-namespace model this is the new same-user-collision
|
|
564
|
+
// signal — distinct from the legacy global EntityAlreadyExists.
|
|
565
|
+
// The CLI normally reaches `createCompanyEntity` only after
|
|
566
|
+
// `checkSlugInMyNamespace` reported `available: true`, so a
|
|
567
|
+
// 409 here means a race between the pre-check and the POST.
|
|
568
|
+
// Surface the response body verbatim so the caller can see the
|
|
569
|
+
// `code` + `conflictingCompanyUid` and resolve / retry.
|
|
488
570
|
throw new ProvisionError(
|
|
489
571
|
1,
|
|
490
572
|
`Vault POST /entity failed: ${res.status} ${res.statusText} — ${text}`,
|
|
@@ -588,12 +670,57 @@ export async function provisionCompany(
|
|
|
588
670
|
}
|
|
589
671
|
log(`pre-flight ok — caller has ${persons.length} person entity(ies)`);
|
|
590
672
|
|
|
591
|
-
|
|
673
|
+
// Per-user-namespace-aware reuse-or-create. Replaces the legacy
|
|
674
|
+
// global `findCompanyBySlug` lookup, which under the per-user model
|
|
675
|
+
// (hq-pro 2026-05-15) returns ANY tenant's entity when more than one
|
|
676
|
+
// user holds the same slug, OR null when a different user has it —
|
|
677
|
+
// both wrong for the CLI's "reuse mine, or create" intent.
|
|
678
|
+
//
|
|
679
|
+
// `--owner` override: `options.ownerUid`, when set, lets a caller
|
|
680
|
+
// create the entity under a DIFFERENT person's ownership (e.g. an
|
|
681
|
+
// admin provisioning on behalf of someone). `/entity/check-slug/me`
|
|
682
|
+
// answers about the CALLER's namespace, not the target owner's, so
|
|
683
|
+
// the pre-check is meaningless in that case. Codex P2 on PR 7
|
|
684
|
+
// flagged this. The gate: only run the namespace check when the
|
|
685
|
+
// owner is the caller (or defaulted to the caller — i.e. no
|
|
686
|
+
// --owner supplied). On override, fall through to
|
|
687
|
+
// `createCompanyEntity` and let the server's authoritative 409
|
|
688
|
+
// (which IS scoped to the target's namespace, per the
|
|
689
|
+
// callerIsOwner gate on POST /entity in hq-pro PR 67) surface any
|
|
690
|
+
// real conflict.
|
|
691
|
+
//
|
|
692
|
+
// `callerIsOwner` is `true` whenever `options.ownerUid` is unset
|
|
693
|
+
// (defaults to caller server-side) OR — when set — happens to
|
|
694
|
+
// match the caller's own person UID(s) from `listMyPersonEntities`.
|
|
695
|
+
const callerOwnedUids = new Set(persons.map((p) => p.uid));
|
|
696
|
+
const callerIsOwner =
|
|
697
|
+
!options.ownerUid || callerOwnedUids.has(options.ownerUid);
|
|
698
|
+
|
|
699
|
+
let entity: VaultEntity;
|
|
592
700
|
let createdEntity = false;
|
|
593
|
-
if (
|
|
594
|
-
|
|
701
|
+
if (callerIsOwner) {
|
|
702
|
+
const slugCheck = await vaultClient.checkSlugInMyNamespace(options.slug);
|
|
703
|
+
if (!slugCheck.available && slugCheck.conflictingCompanyUid) {
|
|
704
|
+
log(
|
|
705
|
+
`reusing existing vault entity uid=${slugCheck.conflictingCompanyUid} (slug already in caller's namespace)`,
|
|
706
|
+
);
|
|
707
|
+
entity = await vaultClient.getCompanyByUid(
|
|
708
|
+
slugCheck.conflictingCompanyUid,
|
|
709
|
+
);
|
|
710
|
+
} else {
|
|
711
|
+
log(`slug available in caller's namespace — creating vault entity`);
|
|
712
|
+
entity = await vaultClient.createCompanyEntity({
|
|
713
|
+
slug: options.slug,
|
|
714
|
+
name: options.name ?? options.slug,
|
|
715
|
+
ownerUid: options.ownerUid,
|
|
716
|
+
});
|
|
717
|
+
createdEntity = true;
|
|
718
|
+
log(`created vault entity uid=${entity.uid}`);
|
|
719
|
+
}
|
|
595
720
|
} else {
|
|
596
|
-
log(
|
|
721
|
+
log(
|
|
722
|
+
`--owner ${options.ownerUid} differs from caller's person(s); skipping namespace pre-check (server authoritatively gates per-target-namespace)`,
|
|
723
|
+
);
|
|
597
724
|
entity = await vaultClient.createCompanyEntity({
|
|
598
725
|
slug: options.slug,
|
|
599
726
|
name: options.name ?? options.slug,
|