@indigoai-us/hq-cli 5.115.1 → 5.115.2
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 +10 -0
- package/assets/scaffold/core/scripts/qmd-reindex-after-sync.sh +10 -87
- package/dist/commands/bot.js +1 -1
- package/dist/commands/index-cmd.d.ts +8 -1
- package/dist/commands/index-cmd.js +15 -5
- package/dist/commands/onboard-identity-guard.d.ts +1 -1
- package/dist/commands/onboard.js +1 -1
- package/dist/lib/bot/prompt.d.ts +8 -1
- package/dist/lib/bot/prompt.js +10 -2
- package/dist/lib/bot/run.js +1 -1
- package/dist/lib/core-utils/qmd-reindex-after-sync.d.ts +4 -1
- package/dist/lib/core-utils/qmd-reindex-after-sync.js +11 -2
- package/dist/lib/onboarding/checkpoint.d.ts +13 -0
- package/dist/lib/onboarding/checkpoint.js +44 -0
- package/dist/lib/onboarding/cli/onboard.d.ts +53 -0
- package/dist/lib/onboarding/cli/onboard.js +135 -0
- package/dist/lib/onboarding/cli/prompts.d.ts +28 -0
- package/dist/lib/onboarding/cli/prompts.js +83 -0
- package/dist/lib/onboarding/errors.d.ts +33 -0
- package/dist/lib/onboarding/errors.js +59 -0
- package/dist/lib/onboarding/index.d.ts +17 -0
- package/dist/lib/onboarding/index.js +16 -0
- package/dist/lib/onboarding/orchestrator.d.ts +47 -0
- package/dist/lib/onboarding/orchestrator.js +569 -0
- package/dist/lib/onboarding/types.d.ts +79 -0
- package/dist/lib/onboarding/types.js +8 -0
- package/dist/lib/search-index/background.js +2 -200
- package/dist/lib/search-index/embed-lock.d.ts +38 -0
- package/dist/lib/search-index/embed-lock.js +286 -0
- package/dist/lib/search-index/index.d.ts +1 -0
- package/dist/lib/search-index/index.js +4 -1
- package/package.json +1 -2
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Onboarding orchestrator (VLT-9 US-001; vendored from @indigoai-us/hq-onboarding).
|
|
3
|
+
*
|
|
4
|
+
* Composes VLT-1 (entities), VLT-2 (bucket provisioning), VLT-3 (STS),
|
|
5
|
+
* VLT-5 (sync), VLT-6 (membership), and VLT-7 (invite/accept) into two
|
|
6
|
+
* end-to-end flows:
|
|
7
|
+
*
|
|
8
|
+
* createCompanyFlow — founder creates a new company + vault
|
|
9
|
+
* joinCompanyFlow — invitee accepts an invite and syncs
|
|
10
|
+
*
|
|
11
|
+
* Each step is idempotent via checkpoint/resume. Progress events are
|
|
12
|
+
* emitted via callback for the installer UI.
|
|
13
|
+
*/
|
|
14
|
+
import { writeFile, mkdir } from "node:fs/promises";
|
|
15
|
+
import { join, dirname } from "node:path";
|
|
16
|
+
import { VaultClient, VaultConflictError, VaultNotFoundError, resolveEntityContext, sync, parseToken, } from "@indigoai-us/hq-cloud";
|
|
17
|
+
import { PersonCreationError, CompanyCreationError, ProvisioningError, MembershipBootstrapError, StsVerifyError, InviteAcceptError, FirstSyncError, } from "./errors.js";
|
|
18
|
+
import { readCheckpoint, writeCheckpoint, isStepComplete, deleteCheckpoint, } from "./checkpoint.js";
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Public API
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
/**
|
|
23
|
+
* Create-company flow for founders.
|
|
24
|
+
*
|
|
25
|
+
* Steps:
|
|
26
|
+
* 1. Create person entity
|
|
27
|
+
* 2. Create company entity (the vault-service makes the caller its owner)
|
|
28
|
+
* 3. Provision bucket + KMS via vault-service
|
|
29
|
+
* 4. Bootstrap owner membership (library-direct, bypasses handler auth)
|
|
30
|
+
* 5. Verify STS vend works end-to-end
|
|
31
|
+
* 6. Write .hq/config.json
|
|
32
|
+
*/
|
|
33
|
+
export async function createCompanyFlow(input, config, onProgress) {
|
|
34
|
+
const client = new VaultClient(config.vaultConfig);
|
|
35
|
+
const checkpoint = await readCheckpoint(config.hqRoot) ?? makeCheckpoint("create-company");
|
|
36
|
+
// Step 1: Create person
|
|
37
|
+
//
|
|
38
|
+
// Three-layer idempotency:
|
|
39
|
+
// a. Resume from checkpoint UID if present (handles mid-flight retries)
|
|
40
|
+
// b. Look up by slug on the server (handles fresh runs against an existing
|
|
41
|
+
// account — defends against the server not enforcing slug uniqueness)
|
|
42
|
+
// c. Create, with a VaultConflictError fallback for the create-time race
|
|
43
|
+
if (!isStepComplete(checkpoint, "create-person")) {
|
|
44
|
+
emit(onProgress, "create-person", "running");
|
|
45
|
+
try {
|
|
46
|
+
const personSlug = slugFromEmail(input.personEmail);
|
|
47
|
+
let person = (checkpoint.personUid
|
|
48
|
+
? await safeGetEntity(client, checkpoint.personUid)
|
|
49
|
+
: null) ?? (await safeFindBySlug(client, "person", personSlug));
|
|
50
|
+
let detail;
|
|
51
|
+
if (person) {
|
|
52
|
+
detail = `Person already registered (${person.uid})`;
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
try {
|
|
56
|
+
person = await client.entity.create({
|
|
57
|
+
type: "person",
|
|
58
|
+
slug: personSlug,
|
|
59
|
+
name: input.personName,
|
|
60
|
+
email: input.personEmail,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
if (err instanceof VaultConflictError) {
|
|
65
|
+
// Lost the create-time race — another caller created between our
|
|
66
|
+
// findBySlug and create. Re-fetch.
|
|
67
|
+
person = await client.entity.findBySlug("person", personSlug);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
detail = `personUid: ${person.uid}`;
|
|
74
|
+
}
|
|
75
|
+
checkpoint.personUid = person.uid;
|
|
76
|
+
checkpoint.completedSteps.push("create-person");
|
|
77
|
+
await writeCheckpoint(config.hqRoot, checkpoint);
|
|
78
|
+
emit(onProgress, "create-person", "done", detail);
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
checkpoint.failedStep = "create-person";
|
|
82
|
+
checkpoint.error = String(err);
|
|
83
|
+
await writeCheckpoint(config.hqRoot, checkpoint);
|
|
84
|
+
emit(onProgress, "create-person", "failed");
|
|
85
|
+
throw new PersonCreationError(`Failed to create person entity: ${err}`, err instanceof Error ? err : undefined);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
emit(onProgress, "create-person", "skipped", "Already complete");
|
|
90
|
+
}
|
|
91
|
+
// Step 2: Create company (same three-layer idempotency as step 1)
|
|
92
|
+
//
|
|
93
|
+
// The slug lookup is scoped to the CALLER's namespace (owned or member-of):
|
|
94
|
+
// under the vault-service's per-user-namespace model a global slug lookup
|
|
95
|
+
// could adopt a stranger's company, or 409 when several tenants share the
|
|
96
|
+
// slug. A company the caller already owns (say, from an earlier run that
|
|
97
|
+
// died before the bucket was provisioned) is picked up here and the rest of
|
|
98
|
+
// the flow completes against it.
|
|
99
|
+
//
|
|
100
|
+
// No `ownerUid` is sent with the create: the vault-service authorizes a
|
|
101
|
+
// human caller as their sign-in and makes that sign-in the company's owner.
|
|
102
|
+
// Sending the person entity's `prs_…` uid instead is refused with
|
|
103
|
+
// "ownerUid must match the authenticated caller" (the bug that broke every
|
|
104
|
+
// `hq onboard create-company` in 2026-09).
|
|
105
|
+
if (!isStepComplete(checkpoint, "create-company")) {
|
|
106
|
+
emit(onProgress, "create-company", "running");
|
|
107
|
+
try {
|
|
108
|
+
let company = (checkpoint.companyUid
|
|
109
|
+
? await safeGetEntity(client, checkpoint.companyUid)
|
|
110
|
+
: null) ?? (await client.entity.findInMyNamespace("company", input.companySlug));
|
|
111
|
+
let detail;
|
|
112
|
+
if (company) {
|
|
113
|
+
detail = `Company already exists (${company.uid})`;
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
try {
|
|
117
|
+
company = await client.entity.create({
|
|
118
|
+
type: "company",
|
|
119
|
+
slug: input.companySlug,
|
|
120
|
+
name: input.companyName,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
if (err instanceof VaultConflictError) {
|
|
125
|
+
// Lost the create-time race — re-fetch from our own namespace. A
|
|
126
|
+
// conflict that is NOT ours means another account holds the slug.
|
|
127
|
+
const mine = await client.entity.findInMyNamespace("company", input.companySlug);
|
|
128
|
+
if (!mine) {
|
|
129
|
+
throw new Error(`Company slug "${input.companySlug}" is already taken by another account. Choose another.`);
|
|
130
|
+
}
|
|
131
|
+
company = mine;
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
throw err;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
detail = `companyUid: ${company.uid}`;
|
|
138
|
+
}
|
|
139
|
+
checkpoint.companyUid = company.uid;
|
|
140
|
+
checkpoint.companySlug = company.slug;
|
|
141
|
+
checkpoint.completedSteps.push("create-company");
|
|
142
|
+
await writeCheckpoint(config.hqRoot, checkpoint);
|
|
143
|
+
emit(onProgress, "create-company", "done", detail);
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
checkpoint.failedStep = "create-company";
|
|
147
|
+
checkpoint.error = String(err);
|
|
148
|
+
await writeCheckpoint(config.hqRoot, checkpoint);
|
|
149
|
+
emit(onProgress, "create-company", "failed");
|
|
150
|
+
throw new CompanyCreationError(`Failed to create company entity: ${err}`, err instanceof Error ? err : undefined);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
emit(onProgress, "create-company", "skipped", "Already complete");
|
|
155
|
+
}
|
|
156
|
+
// Step 3: Provision bucket + KMS
|
|
157
|
+
if (!isStepComplete(checkpoint, "provision-bucket")) {
|
|
158
|
+
emit(onProgress, "provision-bucket", "running");
|
|
159
|
+
try {
|
|
160
|
+
// Trigger provisioning via vault-service (server-side Lambda invocation)
|
|
161
|
+
const provisionResult = await client.provisionBucket(checkpoint.companyUid);
|
|
162
|
+
checkpoint.bucketName = provisionResult.bucketName;
|
|
163
|
+
checkpoint.completedSteps.push("provision-bucket");
|
|
164
|
+
await writeCheckpoint(config.hqRoot, checkpoint);
|
|
165
|
+
emit(onProgress, "provision-bucket", "done", `bucket: ${checkpoint.bucketName}`);
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
// If bucket already provisioned, that's fine — fetch entity to get bucketName
|
|
169
|
+
const entity = await safeGetEntity(client, checkpoint.companyUid);
|
|
170
|
+
if (entity?.bucketName) {
|
|
171
|
+
checkpoint.bucketName = entity.bucketName;
|
|
172
|
+
checkpoint.completedSteps.push("provision-bucket");
|
|
173
|
+
await writeCheckpoint(config.hqRoot, checkpoint);
|
|
174
|
+
emit(onProgress, "provision-bucket", "skipped", "Already provisioned");
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
checkpoint.failedStep = "provision-bucket";
|
|
178
|
+
checkpoint.error = String(err);
|
|
179
|
+
await writeCheckpoint(config.hqRoot, checkpoint);
|
|
180
|
+
emit(onProgress, "provision-bucket", "failed");
|
|
181
|
+
throw new ProvisioningError(`Bucket provisioning failed: ${err}`, err instanceof Error ? err : undefined);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
emit(onProgress, "provision-bucket", "skipped", "Already complete");
|
|
187
|
+
}
|
|
188
|
+
// Step 4: Bootstrap owner membership (library-direct, bypasses handler auth)
|
|
189
|
+
if (!isStepComplete(checkpoint, "bootstrap-membership")) {
|
|
190
|
+
emit(onProgress, "bootstrap-membership", "running");
|
|
191
|
+
try {
|
|
192
|
+
// Verify no existing memberships (optimistic concurrency guard)
|
|
193
|
+
const existing = await client.listMembersOfCompany(checkpoint.companyUid);
|
|
194
|
+
if (existing.length > 0) {
|
|
195
|
+
// Already has members — find our membership
|
|
196
|
+
const ours = existing.find(m => m.personUid === checkpoint.personUid);
|
|
197
|
+
if (ours) {
|
|
198
|
+
checkpoint.membershipKey = ours.membershipKey;
|
|
199
|
+
checkpoint.completedSteps.push("bootstrap-membership");
|
|
200
|
+
await writeCheckpoint(config.hqRoot, checkpoint);
|
|
201
|
+
emit(onProgress, "bootstrap-membership", "skipped", "Owner membership already exists");
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
throw new MembershipBootstrapError("Company already has members but none match the founder — possible race condition");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
// Create invite + immediately accept under founder's identity
|
|
209
|
+
const invite = await client.createInvite({
|
|
210
|
+
companyUid: checkpoint.companyUid,
|
|
211
|
+
personUid: checkpoint.personUid,
|
|
212
|
+
role: "owner",
|
|
213
|
+
invitedBy: checkpoint.personUid,
|
|
214
|
+
});
|
|
215
|
+
const accept = await client.acceptInvite(invite.inviteToken, checkpoint.personUid);
|
|
216
|
+
checkpoint.membershipKey = accept.membership.membershipKey;
|
|
217
|
+
checkpoint.completedSteps.push("bootstrap-membership");
|
|
218
|
+
await writeCheckpoint(config.hqRoot, checkpoint);
|
|
219
|
+
emit(onProgress, "bootstrap-membership", "done", `role: owner`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
if (err instanceof MembershipBootstrapError)
|
|
224
|
+
throw err;
|
|
225
|
+
checkpoint.failedStep = "bootstrap-membership";
|
|
226
|
+
checkpoint.error = String(err);
|
|
227
|
+
await writeCheckpoint(config.hqRoot, checkpoint);
|
|
228
|
+
emit(onProgress, "bootstrap-membership", "failed");
|
|
229
|
+
throw new MembershipBootstrapError(`Owner membership bootstrap failed: ${err}`, err instanceof Error ? err : undefined);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
emit(onProgress, "bootstrap-membership", "skipped", "Already complete");
|
|
234
|
+
}
|
|
235
|
+
// Step 5: Verify STS vend works end-to-end
|
|
236
|
+
if (!isStepComplete(checkpoint, "verify-sts")) {
|
|
237
|
+
emit(onProgress, "verify-sts", "running");
|
|
238
|
+
try {
|
|
239
|
+
const ctx = await resolveEntityContext(checkpoint.companyUid, config.vaultConfig);
|
|
240
|
+
if (!ctx.credentials?.accessKeyId) {
|
|
241
|
+
throw new Error("STS vend returned empty credentials");
|
|
242
|
+
}
|
|
243
|
+
checkpoint.completedSteps.push("verify-sts");
|
|
244
|
+
await writeCheckpoint(config.hqRoot, checkpoint);
|
|
245
|
+
emit(onProgress, "verify-sts", "done", `Credentials valid until ${ctx.expiresAt}`);
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
checkpoint.failedStep = "verify-sts";
|
|
249
|
+
checkpoint.error = String(err);
|
|
250
|
+
await writeCheckpoint(config.hqRoot, checkpoint);
|
|
251
|
+
emit(onProgress, "verify-sts", "failed");
|
|
252
|
+
throw new StsVerifyError(`STS verification failed: ${err}`, err instanceof Error ? err : undefined);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
emit(onProgress, "verify-sts", "skipped", "Already complete");
|
|
257
|
+
}
|
|
258
|
+
// Step 6: Write .hq/config.json
|
|
259
|
+
emit(onProgress, "write-config", "running");
|
|
260
|
+
const configPath = await writeHqConfig(config.hqRoot, {
|
|
261
|
+
companyUid: checkpoint.companyUid,
|
|
262
|
+
companySlug: checkpoint.companySlug ?? input.companySlug,
|
|
263
|
+
personUid: checkpoint.personUid,
|
|
264
|
+
role: "owner",
|
|
265
|
+
bucketName: checkpoint.bucketName,
|
|
266
|
+
vaultApiUrl: config.vaultConfig.apiUrl,
|
|
267
|
+
configuredAt: new Date().toISOString(),
|
|
268
|
+
});
|
|
269
|
+
checkpoint.completedSteps.push("write-config");
|
|
270
|
+
await writeCheckpoint(config.hqRoot, checkpoint);
|
|
271
|
+
emit(onProgress, "write-config", "done", configPath);
|
|
272
|
+
// Clean up checkpoint on success
|
|
273
|
+
await deleteCheckpoint(config.hqRoot);
|
|
274
|
+
return {
|
|
275
|
+
personUid: checkpoint.personUid,
|
|
276
|
+
companyUid: checkpoint.companyUid,
|
|
277
|
+
companySlug: checkpoint.companySlug ?? input.companySlug,
|
|
278
|
+
role: "owner",
|
|
279
|
+
bucketName: checkpoint.bucketName,
|
|
280
|
+
configPath,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Join-company flow for invitees.
|
|
285
|
+
*
|
|
286
|
+
* Steps:
|
|
287
|
+
* 1. Parse invite token
|
|
288
|
+
* 2. Create person entity (if not already registered)
|
|
289
|
+
* 3. Accept invite
|
|
290
|
+
* 4. Verify STS vend
|
|
291
|
+
* 5. First sync to pull initial vault contents
|
|
292
|
+
* 6. Write .hq/config.json
|
|
293
|
+
*/
|
|
294
|
+
export async function joinCompanyFlow(input, config, onProgress) {
|
|
295
|
+
const client = new VaultClient(config.vaultConfig);
|
|
296
|
+
const checkpoint = await readCheckpoint(config.hqRoot) ?? makeCheckpoint("join-company");
|
|
297
|
+
// Step 1: Parse token
|
|
298
|
+
emit(onProgress, "parse-token", "running");
|
|
299
|
+
const token = parseToken(input.inviteToken);
|
|
300
|
+
checkpoint.inviteToken = token;
|
|
301
|
+
await writeCheckpoint(config.hqRoot, checkpoint);
|
|
302
|
+
emit(onProgress, "parse-token", "done");
|
|
303
|
+
// Step 2: Create person (three-layer idempotency — same pattern as createCompanyFlow)
|
|
304
|
+
if (!isStepComplete(checkpoint, "create-person")) {
|
|
305
|
+
emit(onProgress, "create-person", "running");
|
|
306
|
+
try {
|
|
307
|
+
const personSlug = slugFromEmail(input.personEmail);
|
|
308
|
+
let person = (checkpoint.personUid
|
|
309
|
+
? await safeGetEntity(client, checkpoint.personUid)
|
|
310
|
+
: null) ?? (await safeFindBySlug(client, "person", personSlug));
|
|
311
|
+
let detail;
|
|
312
|
+
if (person) {
|
|
313
|
+
detail = `Person already registered (${person.uid})`;
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
try {
|
|
317
|
+
person = await client.entity.create({
|
|
318
|
+
type: "person",
|
|
319
|
+
slug: personSlug,
|
|
320
|
+
name: input.personName,
|
|
321
|
+
email: input.personEmail,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
if (err instanceof VaultConflictError) {
|
|
326
|
+
person = await client.entity.findBySlug("person", personSlug);
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
throw err;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
detail = `personUid: ${person.uid}`;
|
|
333
|
+
}
|
|
334
|
+
checkpoint.personUid = person.uid;
|
|
335
|
+
checkpoint.completedSteps.push("create-person");
|
|
336
|
+
await writeCheckpoint(config.hqRoot, checkpoint);
|
|
337
|
+
emit(onProgress, "create-person", "done", detail);
|
|
338
|
+
}
|
|
339
|
+
catch (err) {
|
|
340
|
+
checkpoint.failedStep = "create-person";
|
|
341
|
+
checkpoint.error = String(err);
|
|
342
|
+
await writeCheckpoint(config.hqRoot, checkpoint);
|
|
343
|
+
emit(onProgress, "create-person", "failed");
|
|
344
|
+
throw new PersonCreationError(`Failed to create person entity: ${err}`, err instanceof Error ? err : undefined);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
emit(onProgress, "create-person", "skipped", "Already complete");
|
|
349
|
+
}
|
|
350
|
+
// Step 3: Accept invite
|
|
351
|
+
if (!isStepComplete(checkpoint, "accept-invite")) {
|
|
352
|
+
emit(onProgress, "accept-invite", "running");
|
|
353
|
+
try {
|
|
354
|
+
const result = await client.acceptInvite(token, checkpoint.personUid);
|
|
355
|
+
checkpoint.companyUid = result.membership.companyUid;
|
|
356
|
+
checkpoint.membershipKey = result.membership.membershipKey;
|
|
357
|
+
// Resolve company slug
|
|
358
|
+
try {
|
|
359
|
+
const company = await client.entity.get(result.membership.companyUid);
|
|
360
|
+
checkpoint.companySlug = company.slug;
|
|
361
|
+
checkpoint.bucketName = company.bucketName;
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
// Non-critical — we have the UID
|
|
365
|
+
}
|
|
366
|
+
checkpoint.completedSteps.push("accept-invite");
|
|
367
|
+
await writeCheckpoint(config.hqRoot, checkpoint);
|
|
368
|
+
emit(onProgress, "accept-invite", "done", `role: ${result.membership.role}`);
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
if (err instanceof VaultConflictError) {
|
|
372
|
+
// Already accepted — that's fine for resume
|
|
373
|
+
emit(onProgress, "accept-invite", "skipped", "Already accepted");
|
|
374
|
+
checkpoint.completedSteps.push("accept-invite");
|
|
375
|
+
await writeCheckpoint(config.hqRoot, checkpoint);
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
checkpoint.failedStep = "accept-invite";
|
|
379
|
+
checkpoint.error = String(err);
|
|
380
|
+
await writeCheckpoint(config.hqRoot, checkpoint);
|
|
381
|
+
emit(onProgress, "accept-invite", "failed");
|
|
382
|
+
throw new InviteAcceptError(`Failed to accept invite: ${err}`, err instanceof Error ? err : undefined);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
emit(onProgress, "accept-invite", "skipped", "Already complete");
|
|
388
|
+
}
|
|
389
|
+
// Step 4: Verify STS vend
|
|
390
|
+
if (!isStepComplete(checkpoint, "verify-sts")) {
|
|
391
|
+
emit(onProgress, "verify-sts", "running");
|
|
392
|
+
try {
|
|
393
|
+
const ctx = await resolveEntityContext(checkpoint.companyUid, config.vaultConfig);
|
|
394
|
+
if (!ctx.credentials?.accessKeyId) {
|
|
395
|
+
throw new Error("STS vend returned empty credentials");
|
|
396
|
+
}
|
|
397
|
+
checkpoint.bucketName = ctx.bucketName;
|
|
398
|
+
checkpoint.completedSteps.push("verify-sts");
|
|
399
|
+
await writeCheckpoint(config.hqRoot, checkpoint);
|
|
400
|
+
emit(onProgress, "verify-sts", "done");
|
|
401
|
+
}
|
|
402
|
+
catch (err) {
|
|
403
|
+
checkpoint.failedStep = "verify-sts";
|
|
404
|
+
checkpoint.error = String(err);
|
|
405
|
+
await writeCheckpoint(config.hqRoot, checkpoint);
|
|
406
|
+
emit(onProgress, "verify-sts", "failed");
|
|
407
|
+
throw new StsVerifyError(`STS verification failed: ${err}`, err instanceof Error ? err : undefined);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
emit(onProgress, "verify-sts", "skipped", "Already complete");
|
|
412
|
+
}
|
|
413
|
+
// Step 5: First sync
|
|
414
|
+
if (!isStepComplete(checkpoint, "first-sync")) {
|
|
415
|
+
emit(onProgress, "first-sync", "running");
|
|
416
|
+
try {
|
|
417
|
+
await sync({
|
|
418
|
+
company: checkpoint.companyUid,
|
|
419
|
+
hqRoot: config.hqRoot,
|
|
420
|
+
vaultConfig: config.vaultConfig,
|
|
421
|
+
});
|
|
422
|
+
checkpoint.completedSteps.push("first-sync");
|
|
423
|
+
await writeCheckpoint(config.hqRoot, checkpoint);
|
|
424
|
+
emit(onProgress, "first-sync", "done");
|
|
425
|
+
}
|
|
426
|
+
catch (err) {
|
|
427
|
+
checkpoint.failedStep = "first-sync";
|
|
428
|
+
checkpoint.error = String(err);
|
|
429
|
+
await writeCheckpoint(config.hqRoot, checkpoint);
|
|
430
|
+
emit(onProgress, "first-sync", "failed");
|
|
431
|
+
throw new FirstSyncError(`First sync failed: ${err}`, err instanceof Error ? err : undefined);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
emit(onProgress, "first-sync", "skipped", "Already complete");
|
|
436
|
+
}
|
|
437
|
+
// Step 6: Write config
|
|
438
|
+
emit(onProgress, "write-config", "running");
|
|
439
|
+
// Get membership role from the accepted invite
|
|
440
|
+
const members = await client.listMembersOfCompany(checkpoint.companyUid);
|
|
441
|
+
const ours = members.find(m => m.personUid === checkpoint.personUid);
|
|
442
|
+
const role = ours?.role ?? "member";
|
|
443
|
+
const configPath = await writeHqConfig(config.hqRoot, {
|
|
444
|
+
companyUid: checkpoint.companyUid,
|
|
445
|
+
companySlug: checkpoint.companySlug ?? "",
|
|
446
|
+
personUid: checkpoint.personUid,
|
|
447
|
+
role,
|
|
448
|
+
bucketName: checkpoint.bucketName,
|
|
449
|
+
vaultApiUrl: config.vaultConfig.apiUrl,
|
|
450
|
+
configuredAt: new Date().toISOString(),
|
|
451
|
+
});
|
|
452
|
+
checkpoint.completedSteps.push("write-config");
|
|
453
|
+
await writeCheckpoint(config.hqRoot, checkpoint);
|
|
454
|
+
emit(onProgress, "write-config", "done", configPath);
|
|
455
|
+
// Clean up checkpoint on success
|
|
456
|
+
await deleteCheckpoint(config.hqRoot);
|
|
457
|
+
return {
|
|
458
|
+
personUid: checkpoint.personUid,
|
|
459
|
+
companyUid: checkpoint.companyUid,
|
|
460
|
+
companySlug: checkpoint.companySlug ?? "",
|
|
461
|
+
role,
|
|
462
|
+
bucketName: checkpoint.bucketName,
|
|
463
|
+
configPath,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Resume an interrupted onboarding flow from checkpoint.
|
|
468
|
+
*/
|
|
469
|
+
export async function resumeOnboarding(config, onProgress) {
|
|
470
|
+
const checkpoint = await readCheckpoint(config.hqRoot);
|
|
471
|
+
if (!checkpoint) {
|
|
472
|
+
throw new Error("No onboarding checkpoint found. Run /onboard to start a new flow.");
|
|
473
|
+
}
|
|
474
|
+
// We need the original input to resume — checkpoint has enough state
|
|
475
|
+
// to reconstruct which flow we're in, but we need to synthesize input
|
|
476
|
+
if (checkpoint.mode === "create-company") {
|
|
477
|
+
// We can't fully reconstruct the original input from checkpoint alone,
|
|
478
|
+
// but the orchestrator is idempotent — completed steps will be skipped.
|
|
479
|
+
// For create-company, we need at minimum the company slug.
|
|
480
|
+
const input = {
|
|
481
|
+
mode: "create-company",
|
|
482
|
+
personName: "", // Not needed for resume — person already created
|
|
483
|
+
personEmail: "", // Not needed for resume
|
|
484
|
+
companyName: "", // Not needed for resume
|
|
485
|
+
companySlug: checkpoint.companySlug ?? "",
|
|
486
|
+
};
|
|
487
|
+
return createCompanyFlow(input, config, onProgress);
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
const input = {
|
|
491
|
+
mode: "join-company",
|
|
492
|
+
personName: "",
|
|
493
|
+
personEmail: "",
|
|
494
|
+
inviteToken: checkpoint.inviteToken ?? "",
|
|
495
|
+
};
|
|
496
|
+
return joinCompanyFlow(input, config, onProgress);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Desktop installer contract implementation.
|
|
501
|
+
*/
|
|
502
|
+
export const onboardingContract = {
|
|
503
|
+
async runOnboarding(input, config, onProgress) {
|
|
504
|
+
if (input.mode === "create-company") {
|
|
505
|
+
return createCompanyFlow(input, config, onProgress);
|
|
506
|
+
}
|
|
507
|
+
return joinCompanyFlow(input, config, onProgress);
|
|
508
|
+
},
|
|
509
|
+
resumeOnboarding,
|
|
510
|
+
};
|
|
511
|
+
// ---------------------------------------------------------------------------
|
|
512
|
+
// Helpers
|
|
513
|
+
// ---------------------------------------------------------------------------
|
|
514
|
+
function makeCheckpoint(mode) {
|
|
515
|
+
const now = new Date().toISOString();
|
|
516
|
+
return {
|
|
517
|
+
mode,
|
|
518
|
+
startedAt: now,
|
|
519
|
+
updatedAt: now,
|
|
520
|
+
completedSteps: [],
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
function emit(cb, step, status, detail) {
|
|
524
|
+
cb?.({ step, status, detail });
|
|
525
|
+
}
|
|
526
|
+
function slugFromEmail(email) {
|
|
527
|
+
return email.split("@")[0].toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
528
|
+
}
|
|
529
|
+
async function safeGetEntity(client, uid) {
|
|
530
|
+
try {
|
|
531
|
+
return await client.entity.get(uid);
|
|
532
|
+
}
|
|
533
|
+
catch (err) {
|
|
534
|
+
if (err instanceof VaultNotFoundError)
|
|
535
|
+
return null;
|
|
536
|
+
throw err;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Look up an entity by slug, returning null on 404 instead of throwing.
|
|
541
|
+
*
|
|
542
|
+
* Used by createCompanyFlow / joinCompanyFlow to detect existing person and
|
|
543
|
+
* company entities BEFORE calling entity.create. This is defense-in-depth
|
|
544
|
+
* against the vault-service not enforcing slug uniqueness server-side — even
|
|
545
|
+
* if the server lets a duplicate create succeed, this guard ensures we never
|
|
546
|
+
* issue the create call in the first place when an entity with the same slug
|
|
547
|
+
* already exists.
|
|
548
|
+
*/
|
|
549
|
+
async function safeFindBySlug(client, type, slug) {
|
|
550
|
+
try {
|
|
551
|
+
return await client.entity.findBySlug(type, slug);
|
|
552
|
+
}
|
|
553
|
+
catch (err) {
|
|
554
|
+
if (err instanceof VaultNotFoundError)
|
|
555
|
+
return null;
|
|
556
|
+
// Defense against the mocked-error case where the SDK wrapper isn't an
|
|
557
|
+
// instanceof match (mocks across module boundaries can break instanceof).
|
|
558
|
+
if (err instanceof Error && err.name === "VaultNotFoundError")
|
|
559
|
+
return null;
|
|
560
|
+
throw err;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
async function writeHqConfig(hqRoot, config) {
|
|
564
|
+
const configPath = join(hqRoot, ".hq", "config.json");
|
|
565
|
+
await mkdir(dirname(configPath), { recursive: true });
|
|
566
|
+
await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
567
|
+
return configPath;
|
|
568
|
+
}
|
|
569
|
+
//# sourceMappingURL=orchestrator.js.map
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Onboarding types (VLT-9 US-001; vendored from @indigoai-us/hq-onboarding).
|
|
3
|
+
*
|
|
4
|
+
* Discriminated union for the two onboarding paths (create vs join)
|
|
5
|
+
* plus progress events for the installer UI callback.
|
|
6
|
+
*/
|
|
7
|
+
import type { VaultServiceConfig } from "@indigoai-us/hq-cloud";
|
|
8
|
+
export interface CreateCompanyInput {
|
|
9
|
+
mode: "create-company";
|
|
10
|
+
personName: string;
|
|
11
|
+
personEmail: string;
|
|
12
|
+
companyName: string;
|
|
13
|
+
companySlug: string;
|
|
14
|
+
}
|
|
15
|
+
export interface JoinCompanyInput {
|
|
16
|
+
mode: "join-company";
|
|
17
|
+
personName: string;
|
|
18
|
+
personEmail: string;
|
|
19
|
+
inviteToken: string;
|
|
20
|
+
}
|
|
21
|
+
export type OnboardingInput = CreateCompanyInput | JoinCompanyInput;
|
|
22
|
+
export interface OnboardingConfig {
|
|
23
|
+
vaultConfig: VaultServiceConfig;
|
|
24
|
+
/** Local HQ root directory for writing .hq/config.json */
|
|
25
|
+
hqRoot: string;
|
|
26
|
+
/** Stage name for resource tagging (e.g. "dev", "prod") */
|
|
27
|
+
stage?: string;
|
|
28
|
+
}
|
|
29
|
+
export type OnboardingStep = "create-person" | "create-company" | "provision-bucket" | "bootstrap-membership" | "verify-sts" | "write-config" | "parse-token" | "accept-invite" | "first-sync";
|
|
30
|
+
export type StepStatus = "pending" | "running" | "done" | "skipped" | "failed";
|
|
31
|
+
export interface OnboardingProgress {
|
|
32
|
+
step: OnboardingStep;
|
|
33
|
+
status: StepStatus;
|
|
34
|
+
detail?: string;
|
|
35
|
+
}
|
|
36
|
+
export type ProgressCallback = (progress: OnboardingProgress) => void;
|
|
37
|
+
export interface OnboardingResult {
|
|
38
|
+
personUid: string;
|
|
39
|
+
companyUid: string;
|
|
40
|
+
companySlug: string;
|
|
41
|
+
role: string;
|
|
42
|
+
bucketName?: string;
|
|
43
|
+
configPath: string;
|
|
44
|
+
}
|
|
45
|
+
export interface OnboardingCheckpoint {
|
|
46
|
+
mode: "create-company" | "join-company";
|
|
47
|
+
startedAt: string;
|
|
48
|
+
updatedAt: string;
|
|
49
|
+
personUid?: string;
|
|
50
|
+
companyUid?: string;
|
|
51
|
+
companySlug?: string;
|
|
52
|
+
bucketName?: string;
|
|
53
|
+
membershipKey?: string;
|
|
54
|
+
inviteToken?: string;
|
|
55
|
+
completedSteps: OnboardingStep[];
|
|
56
|
+
failedStep?: OnboardingStep;
|
|
57
|
+
error?: string;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* DesktopInstallerContract — stable API boundary the desktop app's onboarding
|
|
61
|
+
* screen MUST conform to. The installer calls `runOnboarding()` with input +
|
|
62
|
+
* config + progress callback and gets back a typed result or error.
|
|
63
|
+
*
|
|
64
|
+
* Breaking changes to this interface require desktop team coordination.
|
|
65
|
+
*/
|
|
66
|
+
export interface DesktopInstallerContract {
|
|
67
|
+
runOnboarding(input: OnboardingInput, config: OnboardingConfig, onProgress?: ProgressCallback): Promise<OnboardingResult>;
|
|
68
|
+
resumeOnboarding(config: OnboardingConfig, onProgress?: ProgressCallback): Promise<OnboardingResult>;
|
|
69
|
+
}
|
|
70
|
+
export interface HqConfig {
|
|
71
|
+
companyUid: string;
|
|
72
|
+
companySlug: string;
|
|
73
|
+
personUid: string;
|
|
74
|
+
role: string;
|
|
75
|
+
bucketName?: string;
|
|
76
|
+
vaultApiUrl: string;
|
|
77
|
+
configuredAt: string;
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Onboarding types (VLT-9 US-001; vendored from @indigoai-us/hq-onboarding).
|
|
3
|
+
*
|
|
4
|
+
* Discriminated union for the two onboarding paths (create vs join)
|
|
5
|
+
* plus progress events for the installer UI callback.
|
|
6
|
+
*/
|
|
7
|
+
export {};
|
|
8
|
+
//# sourceMappingURL=types.js.map
|