@indigoai-us/hq-cli 5.18.1 → 5.18.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/dist/commands/members.d.ts +18 -2
- package/dist/commands/members.js +45 -17
- package/package.json +1 -1
- package/src/commands/members.test.ts +81 -4
- package/src/commands/members.ts +90 -24
|
@@ -20,13 +20,29 @@ export interface InviteOptions {
|
|
|
20
20
|
callerUid: string;
|
|
21
21
|
token: string;
|
|
22
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Outcome of `hq members invite`. Two shapes depending on server schema:
|
|
25
|
+
*
|
|
26
|
+
* - **schemaVersion ≤ 1** — server returns a random `inviteToken` the
|
|
27
|
+
* invitee redeems via the `hq://accept/{token}` magic link. `magicLink`
|
|
28
|
+
* is populated so the caller can print or paste it.
|
|
29
|
+
* - **schemaVersion 2+** (current production) — membership row is
|
|
30
|
+
* email-keyed and authoritative. There is no token; the invitee
|
|
31
|
+
* accepts by signing into HQ with the same email. `inviteToken` +
|
|
32
|
+
* `magicLink` are both `undefined`; the caller prints sign-in
|
|
33
|
+
* instructions instead.
|
|
34
|
+
*
|
|
35
|
+
* `membership` is always populated when the server returned 2xx.
|
|
36
|
+
*/
|
|
23
37
|
export interface InviteResult {
|
|
24
|
-
inviteToken
|
|
25
|
-
magicLink
|
|
38
|
+
inviteToken?: string;
|
|
39
|
+
magicLink?: string;
|
|
26
40
|
membership: {
|
|
41
|
+
membershipKey?: string;
|
|
27
42
|
role: string;
|
|
28
43
|
status: string;
|
|
29
44
|
inviteToken?: string;
|
|
45
|
+
inviteeEmail?: string;
|
|
30
46
|
};
|
|
31
47
|
}
|
|
32
48
|
export interface DetectedTarget {
|
package/dist/commands/members.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
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]="
|
|
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]="20c76490-0485-592f-856b-3b20e403a392")}catch(e){}}();
|
|
3
3
|
import chalk from "chalk";
|
|
4
4
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
5
5
|
import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
|
|
@@ -72,19 +72,26 @@ export async function inviteMember(options) {
|
|
|
72
72
|
throw new InviteHttpError(res.status, err.message ?? err.error ?? res.statusText, err.code);
|
|
73
73
|
}
|
|
74
74
|
const data = (await res.json());
|
|
75
|
-
|
|
76
|
-
// depending on vault-service version. Resolve from either; never emit
|
|
77
|
-
// `hq://accept/undefined` (a broken link that looks like success).
|
|
78
|
-
const inviteToken = data.inviteToken ?? data.membership?.inviteToken;
|
|
79
|
-
if (!inviteToken) {
|
|
75
|
+
if (!data.membership) {
|
|
80
76
|
const keys = Object.keys(data ?? {}).join(", ") || "<empty>";
|
|
81
|
-
throw new Error(`Invite
|
|
82
|
-
"
|
|
77
|
+
throw new Error(`Invite endpoint returned 2xx with no membership row (response keys: ${keys}). ` +
|
|
78
|
+
"This is a server-side regression — file an issue.");
|
|
83
79
|
}
|
|
80
|
+
// Two server schemas in the wild:
|
|
81
|
+
// - Legacy (schemaVersion ≤ 1): response carries a random `inviteToken`
|
|
82
|
+
// the invitee redeems via `hq://accept/{token}`.
|
|
83
|
+
// - Current (schemaVersion 2+): membership row is email-keyed and
|
|
84
|
+
// authoritative — there is no token. The invitee accepts by signing
|
|
85
|
+
// into HQ with the invited email. The CLI must NOT throw here
|
|
86
|
+
// (previously did: "response did not include an invite token") — the
|
|
87
|
+
// invite IS successfully created on the server; the caller just gets
|
|
88
|
+
// undefined for inviteToken/magicLink and prints sign-in instructions.
|
|
89
|
+
const inviteToken = data.inviteToken ?? data.membership.inviteToken;
|
|
84
90
|
return {
|
|
85
|
-
inviteToken
|
|
86
|
-
|
|
87
|
-
|
|
91
|
+
...(inviteToken
|
|
92
|
+
? { inviteToken, magicLink: `hq://accept/${inviteToken}` }
|
|
93
|
+
: {}),
|
|
94
|
+
membership: data.membership,
|
|
88
95
|
};
|
|
89
96
|
}
|
|
90
97
|
export class InviteHttpError extends Error {
|
|
@@ -122,8 +129,11 @@ export async function listPendingInvites(token, companyUid) {
|
|
|
122
129
|
const err = (await res.json().catch(() => ({})));
|
|
123
130
|
throw new InviteHttpError(res.status, err.message ?? err.error ?? res.statusText, err.code);
|
|
124
131
|
}
|
|
132
|
+
// Server schema: `{ pending: [...] }`. Earlier dev branches used
|
|
133
|
+
// `{ invites: [...] }` which the CLI still accepts as a fallback for
|
|
134
|
+
// operators running staging stages that haven't caught up yet.
|
|
125
135
|
const data = (await res.json());
|
|
126
|
-
return data?.invites ?? [];
|
|
136
|
+
return data?.pending ?? data?.invites ?? [];
|
|
127
137
|
}
|
|
128
138
|
export async function revokeInvite(token, tokenOrKey, companyUid) {
|
|
129
139
|
const res = await vaultApiFetch({
|
|
@@ -163,10 +173,28 @@ export function registerMembersCommand(program) {
|
|
|
163
173
|
});
|
|
164
174
|
console.log(chalk.green(`Invited ${target} as ${result.membership.role} (status: ${result.membership.status})`));
|
|
165
175
|
console.log();
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
176
|
+
if (result.magicLink) {
|
|
177
|
+
// Legacy server schema — magic-link redemption.
|
|
178
|
+
console.log(chalk.bold("Magic link:"));
|
|
179
|
+
console.log(` ${result.magicLink}`);
|
|
180
|
+
console.log();
|
|
181
|
+
console.log(chalk.dim("Share this link with the invitee. They can run `hq onboard join --invite-token <token>` to accept."));
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
// schemaVersion 2+ — email-keyed authoritative membership row.
|
|
185
|
+
// No magic link to share; invitee accepts by signing into HQ.
|
|
186
|
+
const inviteeEmail = result.membership.inviteeEmail ??
|
|
187
|
+
(typeof target === "string" && target.includes("@")
|
|
188
|
+
? target
|
|
189
|
+
: undefined);
|
|
190
|
+
console.log(chalk.bold("Next step:"));
|
|
191
|
+
console.log(` Tell ${inviteeEmail ?? "the invitee"} to sign into HQ at https://hq.getindigo.ai with that email.`);
|
|
192
|
+
console.log(chalk.dim(" The pending membership row claims itself on first sign-in — no separate token redemption."));
|
|
193
|
+
if (result.membership.membershipKey) {
|
|
194
|
+
console.log();
|
|
195
|
+
console.log(chalk.dim(` Membership key: ${result.membership.membershipKey}`));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
170
198
|
}
|
|
171
199
|
catch (err) {
|
|
172
200
|
if (err instanceof InviteHttpError) {
|
|
@@ -253,4 +281,4 @@ export function registerMembersCommand(program) {
|
|
|
253
281
|
});
|
|
254
282
|
}
|
|
255
283
|
//# sourceMappingURL=members.js.map
|
|
256
|
-
//# debugId=
|
|
284
|
+
//# debugId=20c76490-0485-592f-856b-3b20e403a392
|
package/package.json
CHANGED
|
@@ -246,13 +246,49 @@ describe("inviteMember", () => {
|
|
|
246
246
|
expect(result.magicLink).toBe("hq://accept/tok_nested");
|
|
247
247
|
});
|
|
248
248
|
|
|
249
|
-
it("
|
|
249
|
+
it("schemaVersion 2: no inviteToken in response → success with undefined magicLink", async () => {
|
|
250
|
+
// 2026-05-21 fix: the server moved to email-keyed authoritative
|
|
251
|
+
// membership rows (schemaVersion 2) — the invite IS created, but
|
|
252
|
+
// there is no token to redeem. The CLI must NOT throw here; instead
|
|
253
|
+
// the action handler prints "sign in with the invited email"
|
|
254
|
+
// instructions. Previously this case threw "did not include an
|
|
255
|
+
// invite token" which produced false-failure UX on a working invite.
|
|
250
256
|
fetchSpy.mockResolvedValueOnce(
|
|
251
|
-
jsonResponse(
|
|
252
|
-
membership: {
|
|
257
|
+
jsonResponse(201, {
|
|
258
|
+
membership: {
|
|
259
|
+
membershipKey: "email:alice@example.com#cmp_acme",
|
|
260
|
+
role: "member",
|
|
261
|
+
status: "pending",
|
|
262
|
+
inviteeEmail: "alice@example.com",
|
|
263
|
+
schemaVersion: 2,
|
|
264
|
+
},
|
|
253
265
|
}),
|
|
254
266
|
);
|
|
255
267
|
|
|
268
|
+
const result = await inviteMember({
|
|
269
|
+
target: "alice@example.com",
|
|
270
|
+
role: "member",
|
|
271
|
+
companyUid: "cmp_acme",
|
|
272
|
+
callerUid: "prs_admin",
|
|
273
|
+
token: "test-token",
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
expect(result.inviteToken).toBeUndefined();
|
|
277
|
+
expect(result.magicLink).toBeUndefined();
|
|
278
|
+
expect(result.membership.role).toBe("member");
|
|
279
|
+
expect(result.membership.status).toBe("pending");
|
|
280
|
+
expect(result.membership.membershipKey).toBe(
|
|
281
|
+
"email:alice@example.com#cmp_acme",
|
|
282
|
+
);
|
|
283
|
+
expect(result.membership.inviteeEmail).toBe("alice@example.com");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("throws when the response has no membership row at all (server bug)", async () => {
|
|
287
|
+
// Belt-and-suspenders: a 2xx response with NO membership row is a
|
|
288
|
+
// server-side regression — surface it loudly so it doesn't silently
|
|
289
|
+
// succeed-but-do-nothing.
|
|
290
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(201, {}));
|
|
291
|
+
|
|
256
292
|
await expect(
|
|
257
293
|
inviteMember({
|
|
258
294
|
target: "alice@example.com",
|
|
@@ -261,7 +297,7 @@ describe("inviteMember", () => {
|
|
|
261
297
|
callerUid: "prs_admin",
|
|
262
298
|
token: "test-token",
|
|
263
299
|
}),
|
|
264
|
-
).rejects.toThrow(/
|
|
300
|
+
).rejects.toThrow(/no membership row/);
|
|
265
301
|
});
|
|
266
302
|
});
|
|
267
303
|
|
|
@@ -313,6 +349,47 @@ describe("listPendingInvites", () => {
|
|
|
313
349
|
fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
|
|
314
350
|
await expect(listPendingInvites("test-token", "cmp_acme")).resolves.toEqual([]);
|
|
315
351
|
});
|
|
352
|
+
|
|
353
|
+
it("schemaVersion 2: reads `pending` key (the canonical server response)", async () => {
|
|
354
|
+
// 2026-05-21 fix: live server returns `{ pending: [...] }`, not
|
|
355
|
+
// `{ invites: [...] }`. The CLI must read `pending` as the primary
|
|
356
|
+
// and fall back to `invites` for older stages.
|
|
357
|
+
fetchSpy.mockResolvedValueOnce(
|
|
358
|
+
jsonResponse(200, {
|
|
359
|
+
pending: [
|
|
360
|
+
{
|
|
361
|
+
membershipKey: "email:alice@example.com#cmp_acme",
|
|
362
|
+
inviteeEmail: "alice@example.com",
|
|
363
|
+
companyUid: "cmp_acme",
|
|
364
|
+
role: "member",
|
|
365
|
+
status: "pending",
|
|
366
|
+
invitedBy: "prs_admin",
|
|
367
|
+
invitedAt: "2026-05-21T12:00:00Z",
|
|
368
|
+
schemaVersion: 2,
|
|
369
|
+
},
|
|
370
|
+
],
|
|
371
|
+
}),
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
const invites = await listPendingInvites("test-token", "cmp_acme");
|
|
375
|
+
expect(invites).toHaveLength(1);
|
|
376
|
+
expect(invites[0].membershipKey).toBe("email:alice@example.com#cmp_acme");
|
|
377
|
+
expect(invites[0].inviteeEmail).toBe("alice@example.com");
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("prefers `pending` over legacy `invites` when both present (server transition)", async () => {
|
|
381
|
+
// Defensive: an in-flight server deploy could briefly return BOTH
|
|
382
|
+
// fields. CLI takes the canonical `pending` key.
|
|
383
|
+
fetchSpy.mockResolvedValueOnce(
|
|
384
|
+
jsonResponse(200, {
|
|
385
|
+
pending: [{ membershipKey: "k_pending" } as never],
|
|
386
|
+
invites: [{ membershipKey: "k_legacy" } as never],
|
|
387
|
+
}),
|
|
388
|
+
);
|
|
389
|
+
const invites = await listPendingInvites("test-token", "cmp_acme");
|
|
390
|
+
expect(invites).toHaveLength(1);
|
|
391
|
+
expect(invites[0].membershipKey).toBe("k_pending");
|
|
392
|
+
});
|
|
316
393
|
});
|
|
317
394
|
|
|
318
395
|
// ---------------------------------------------------------------------------
|
package/src/commands/members.ts
CHANGED
|
@@ -38,10 +38,30 @@ export interface InviteOptions {
|
|
|
38
38
|
token: string;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Outcome of `hq members invite`. Two shapes depending on server schema:
|
|
43
|
+
*
|
|
44
|
+
* - **schemaVersion ≤ 1** — server returns a random `inviteToken` the
|
|
45
|
+
* invitee redeems via the `hq://accept/{token}` magic link. `magicLink`
|
|
46
|
+
* is populated so the caller can print or paste it.
|
|
47
|
+
* - **schemaVersion 2+** (current production) — membership row is
|
|
48
|
+
* email-keyed and authoritative. There is no token; the invitee
|
|
49
|
+
* accepts by signing into HQ with the same email. `inviteToken` +
|
|
50
|
+
* `magicLink` are both `undefined`; the caller prints sign-in
|
|
51
|
+
* instructions instead.
|
|
52
|
+
*
|
|
53
|
+
* `membership` is always populated when the server returned 2xx.
|
|
54
|
+
*/
|
|
41
55
|
export interface InviteResult {
|
|
42
|
-
inviteToken
|
|
43
|
-
magicLink
|
|
44
|
-
membership: {
|
|
56
|
+
inviteToken?: string;
|
|
57
|
+
magicLink?: string;
|
|
58
|
+
membership: {
|
|
59
|
+
membershipKey?: string;
|
|
60
|
+
role: string;
|
|
61
|
+
status: string;
|
|
62
|
+
inviteToken?: string;
|
|
63
|
+
inviteeEmail?: string;
|
|
64
|
+
};
|
|
45
65
|
}
|
|
46
66
|
|
|
47
67
|
export interface DetectedTarget {
|
|
@@ -137,24 +157,38 @@ export async function inviteMember(
|
|
|
137
157
|
}
|
|
138
158
|
|
|
139
159
|
const data = (await res.json()) as {
|
|
140
|
-
membership?: {
|
|
160
|
+
membership?: {
|
|
161
|
+
membershipKey?: string;
|
|
162
|
+
role: string;
|
|
163
|
+
status: string;
|
|
164
|
+
inviteToken?: string;
|
|
165
|
+
inviteeEmail?: string;
|
|
166
|
+
schemaVersion?: number;
|
|
167
|
+
};
|
|
141
168
|
inviteToken?: string;
|
|
142
169
|
};
|
|
143
|
-
|
|
144
|
-
// depending on vault-service version. Resolve from either; never emit
|
|
145
|
-
// `hq://accept/undefined` (a broken link that looks like success).
|
|
146
|
-
const inviteToken = data.inviteToken ?? data.membership?.inviteToken;
|
|
147
|
-
if (!inviteToken) {
|
|
170
|
+
if (!data.membership) {
|
|
148
171
|
const keys = Object.keys(data ?? {}).join(", ") || "<empty>";
|
|
149
172
|
throw new Error(
|
|
150
|
-
`Invite
|
|
151
|
-
"
|
|
173
|
+
`Invite endpoint returned 2xx with no membership row (response keys: ${keys}). ` +
|
|
174
|
+
"This is a server-side regression — file an issue.",
|
|
152
175
|
);
|
|
153
176
|
}
|
|
177
|
+
// Two server schemas in the wild:
|
|
178
|
+
// - Legacy (schemaVersion ≤ 1): response carries a random `inviteToken`
|
|
179
|
+
// the invitee redeems via `hq://accept/{token}`.
|
|
180
|
+
// - Current (schemaVersion 2+): membership row is email-keyed and
|
|
181
|
+
// authoritative — there is no token. The invitee accepts by signing
|
|
182
|
+
// into HQ with the invited email. The CLI must NOT throw here
|
|
183
|
+
// (previously did: "response did not include an invite token") — the
|
|
184
|
+
// invite IS successfully created on the server; the caller just gets
|
|
185
|
+
// undefined for inviteToken/magicLink and prints sign-in instructions.
|
|
186
|
+
const inviteToken = data.inviteToken ?? data.membership.inviteToken;
|
|
154
187
|
return {
|
|
155
|
-
inviteToken
|
|
156
|
-
|
|
157
|
-
|
|
188
|
+
...(inviteToken
|
|
189
|
+
? { inviteToken, magicLink: `hq://accept/${inviteToken}` }
|
|
190
|
+
: {}),
|
|
191
|
+
membership: data.membership,
|
|
158
192
|
};
|
|
159
193
|
}
|
|
160
194
|
|
|
@@ -204,8 +238,14 @@ export async function listPendingInvites(
|
|
|
204
238
|
err.code,
|
|
205
239
|
);
|
|
206
240
|
}
|
|
207
|
-
|
|
208
|
-
|
|
241
|
+
// Server schema: `{ pending: [...] }`. Earlier dev branches used
|
|
242
|
+
// `{ invites: [...] }` which the CLI still accepts as a fallback for
|
|
243
|
+
// operators running staging stages that haven't caught up yet.
|
|
244
|
+
const data = (await res.json()) as {
|
|
245
|
+
pending?: PendingInvite[] | null;
|
|
246
|
+
invites?: PendingInvite[] | null;
|
|
247
|
+
};
|
|
248
|
+
return data?.pending ?? data?.invites ?? [];
|
|
209
249
|
}
|
|
210
250
|
|
|
211
251
|
export async function revokeInvite(
|
|
@@ -275,14 +315,40 @@ export function registerMembersCommand(program: Command): void {
|
|
|
275
315
|
),
|
|
276
316
|
);
|
|
277
317
|
console.log();
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
318
|
+
if (result.magicLink) {
|
|
319
|
+
// Legacy server schema — magic-link redemption.
|
|
320
|
+
console.log(chalk.bold("Magic link:"));
|
|
321
|
+
console.log(` ${result.magicLink}`);
|
|
322
|
+
console.log();
|
|
323
|
+
console.log(
|
|
324
|
+
chalk.dim(
|
|
325
|
+
"Share this link with the invitee. They can run `hq onboard join --invite-token <token>` to accept.",
|
|
326
|
+
),
|
|
327
|
+
);
|
|
328
|
+
} else {
|
|
329
|
+
// schemaVersion 2+ — email-keyed authoritative membership row.
|
|
330
|
+
// No magic link to share; invitee accepts by signing into HQ.
|
|
331
|
+
const inviteeEmail =
|
|
332
|
+
result.membership.inviteeEmail ??
|
|
333
|
+
(typeof target === "string" && target.includes("@")
|
|
334
|
+
? target
|
|
335
|
+
: undefined);
|
|
336
|
+
console.log(chalk.bold("Next step:"));
|
|
337
|
+
console.log(
|
|
338
|
+
` Tell ${inviteeEmail ?? "the invitee"} to sign into HQ at https://hq.getindigo.ai with that email.`,
|
|
339
|
+
);
|
|
340
|
+
console.log(
|
|
341
|
+
chalk.dim(
|
|
342
|
+
" The pending membership row claims itself on first sign-in — no separate token redemption.",
|
|
343
|
+
),
|
|
344
|
+
);
|
|
345
|
+
if (result.membership.membershipKey) {
|
|
346
|
+
console.log();
|
|
347
|
+
console.log(
|
|
348
|
+
chalk.dim(` Membership key: ${result.membership.membershipKey}`),
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
286
352
|
} catch (err) {
|
|
287
353
|
if (err instanceof InviteHttpError) {
|
|
288
354
|
console.error(
|