@indigoai-us/hq-cli 5.23.0 → 5.25.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/members.d.ts +48 -0
- package/dist/commands/members.js +154 -26
- package/package.json +2 -2
- package/src/commands/members.test.ts +291 -0
- package/src/commands/members.ts +284 -38
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.25.0] — 2026-05-26
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`hq members invite --send-email` (default true).** Passes
|
|
10
|
+
`sendEmail: true` to hq-pro's `/membership/invite`, which renders
|
|
11
|
+
and sends the invite email via Resend server-side. Pre-resend hq-pro
|
|
12
|
+
versions ignore the field; the CLI falls back to the legacy
|
|
13
|
+
manual-notify printout. `--no-send-email` opts out for scripted
|
|
14
|
+
bulk invites. Consumes hq-pro PR #140.
|
|
15
|
+
- **`hq members invite --groups <ids>`.** Comma-separated secret-group
|
|
16
|
+
ids forwarded as `groupIds[]`; invitee is added atomically on first
|
|
17
|
+
sign-in. Server already supported the field as `pendingGroupIds`;
|
|
18
|
+
this surfaces it on the CLI. CLI-side guard rejects the personUid
|
|
19
|
+
+ groupIds combo before the wire call.
|
|
20
|
+
- **`hq members invite <email> --resend`.** Short-circuits to hq-pro's
|
|
21
|
+
`resend: true` path (no DDB write, re-fires Resend email against the
|
|
22
|
+
existing pending row). New `resendInvite()` helper; email target only.
|
|
23
|
+
Surfaces 404 `INVITE_NOT_PENDING` via `InviteHttpError`.
|
|
24
|
+
|
|
25
|
+
### Notes
|
|
26
|
+
|
|
27
|
+
- Version skips 5.21.x–5.24.x because the hq-cloud-bump release train
|
|
28
|
+
(5.21.0, 5.22.0, 5.23.0, 5.24.0) shipped while this PR was open and
|
|
29
|
+
consumed those slots. Original branch bumped to 5.21.0; rebased twice
|
|
30
|
+
and rebumped to 5.25.0 just before merge.
|
|
31
|
+
|
|
5
32
|
## [5.22.0] — 2026-05-25
|
|
6
33
|
|
|
7
34
|
### Changed
|
|
@@ -16,6 +16,17 @@ export interface InviteOptions {
|
|
|
16
16
|
target: string;
|
|
17
17
|
role: string;
|
|
18
18
|
paths?: string;
|
|
19
|
+
/** Email-keyed invites only — secret-group ids to attach on claim. */
|
|
20
|
+
groupIds?: string[];
|
|
21
|
+
/**
|
|
22
|
+
* Opt the server-side Resend send in/out. When `true`, hq-pro renders +
|
|
23
|
+
* sends the invitation email server-side and returns `emailSent` /
|
|
24
|
+
* `emailSkipped` / `emailError`. When `false` or unset, the legacy
|
|
25
|
+
* no-email path runs and the CLI prints the manual sign-in instructions.
|
|
26
|
+
* Requires hq-pro that supports `sendEmail` (older servers ignore the
|
|
27
|
+
* field; CLI falls back to the legacy printout in that case).
|
|
28
|
+
*/
|
|
29
|
+
sendEmail?: boolean;
|
|
19
30
|
companyUid: string;
|
|
20
31
|
callerUid: string;
|
|
21
32
|
token: string;
|
|
@@ -33,6 +44,10 @@ export interface InviteOptions {
|
|
|
33
44
|
* instructions instead.
|
|
34
45
|
*
|
|
35
46
|
* `membership` is always populated when the server returned 2xx.
|
|
47
|
+
*
|
|
48
|
+
* Email-related fields are populated only when hq-pro performed a
|
|
49
|
+
* server-side Resend send (caller passed `sendEmail: true` AND the
|
|
50
|
+
* server supports it). Pre-resend servers omit them entirely.
|
|
36
51
|
*/
|
|
37
52
|
export interface InviteResult {
|
|
38
53
|
inviteToken?: string;
|
|
@@ -44,6 +59,29 @@ export interface InviteResult {
|
|
|
44
59
|
inviteToken?: string;
|
|
45
60
|
inviteeEmail?: string;
|
|
46
61
|
};
|
|
62
|
+
/** Resend send actually fired and accepted by Resend. */
|
|
63
|
+
emailSent?: boolean;
|
|
64
|
+
/** Resend skipped because the server has no RESEND_API_KEY configured. */
|
|
65
|
+
emailSkipped?: boolean;
|
|
66
|
+
/** Resend was attempted but failed — human-readable reason. */
|
|
67
|
+
emailError?: string;
|
|
68
|
+
}
|
|
69
|
+
export interface ResendInviteOptions {
|
|
70
|
+
inviteeEmail: string;
|
|
71
|
+
companyUid: string;
|
|
72
|
+
callerUid: string;
|
|
73
|
+
token: string;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Outcome of `hq members invite --resend`. No new membership row was
|
|
77
|
+
* created — the server's `resend: true` short-circuit just re-fires the
|
|
78
|
+
* Resend email against the existing pending row.
|
|
79
|
+
*/
|
|
80
|
+
export interface ResendInviteResult {
|
|
81
|
+
resent: true;
|
|
82
|
+
emailSent: boolean;
|
|
83
|
+
emailSkipped: boolean;
|
|
84
|
+
emailError?: string;
|
|
47
85
|
}
|
|
48
86
|
export interface DetectedTarget {
|
|
49
87
|
type: "email" | "person";
|
|
@@ -59,6 +97,16 @@ export declare function shortDate(iso: string): string;
|
|
|
59
97
|
export declare function getCallerPersonUid(token: string): Promise<string>;
|
|
60
98
|
/** Send a `/membership/invite` request and return the magic link. */
|
|
61
99
|
export declare function inviteMember(options: InviteOptions): Promise<InviteResult>;
|
|
100
|
+
/**
|
|
101
|
+
* Re-fire the invite email against an existing pending row without
|
|
102
|
+
* creating a new membership row. Maps to hq-pro `/membership/invite`
|
|
103
|
+
* with `{ resend: true }` (see hq-pro PR #140). Server gates on the
|
|
104
|
+
* caller's admin/owner role.
|
|
105
|
+
*
|
|
106
|
+
* Returns the email-send status. 404 here means "no pending row exists
|
|
107
|
+
* for this email+company pair — re-invite without --resend first."
|
|
108
|
+
*/
|
|
109
|
+
export declare function resendInvite(options: ResendInviteOptions): Promise<ResendInviteResult>;
|
|
62
110
|
export declare class InviteHttpError extends Error {
|
|
63
111
|
status: number;
|
|
64
112
|
code?: string | undefined;
|
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]="83c39bc9-e532-580d-bc3a-5f176eb5cba6")}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";
|
|
@@ -47,6 +47,9 @@ export async function inviteMember(options) {
|
|
|
47
47
|
if (!detected) {
|
|
48
48
|
throw new Error(`Invalid target '${options.target}': must be an email address or a personUid matching prs_<alphanumeric>`);
|
|
49
49
|
}
|
|
50
|
+
if (options.groupIds && options.groupIds.length > 0 && detected.type === "person") {
|
|
51
|
+
throw new Error("--groups is only valid on email-keyed invites (server rejects personUid + groupIds with 400)");
|
|
52
|
+
}
|
|
50
53
|
const allowedPrefixes = options.paths
|
|
51
54
|
? options.paths.split(",").map((p) => p.trim()).filter(Boolean)
|
|
52
55
|
: undefined;
|
|
@@ -61,6 +64,11 @@ export async function inviteMember(options) {
|
|
|
61
64
|
body.personUid = detected.value;
|
|
62
65
|
if (allowedPrefixes)
|
|
63
66
|
body.allowedPrefixes = allowedPrefixes;
|
|
67
|
+
if (options.groupIds && options.groupIds.length > 0) {
|
|
68
|
+
body.groupIds = options.groupIds;
|
|
69
|
+
}
|
|
70
|
+
if (options.sendEmail === true)
|
|
71
|
+
body.sendEmail = true;
|
|
64
72
|
const res = await vaultApiFetch({
|
|
65
73
|
token: options.token,
|
|
66
74
|
path: "/membership/invite",
|
|
@@ -92,6 +100,58 @@ export async function inviteMember(options) {
|
|
|
92
100
|
? { inviteToken, magicLink: `hq://accept/${inviteToken}` }
|
|
93
101
|
: {}),
|
|
94
102
|
membership: data.membership,
|
|
103
|
+
// Pre-resend hq-pro versions omit these fields entirely — leave the
|
|
104
|
+
// result fields undefined so the caller's "is server-side email
|
|
105
|
+
// supported?" check works via `typeof result.emailSent === 'boolean'`.
|
|
106
|
+
...(typeof data.emailSent === "boolean" ? { emailSent: data.emailSent } : {}),
|
|
107
|
+
...(typeof data.emailSkipped === "boolean"
|
|
108
|
+
? { emailSkipped: data.emailSkipped }
|
|
109
|
+
: {}),
|
|
110
|
+
...(data.emailError ? { emailError: data.emailError } : {}),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Re-fire the invite email against an existing pending row without
|
|
115
|
+
* creating a new membership row. Maps to hq-pro `/membership/invite`
|
|
116
|
+
* with `{ resend: true }` (see hq-pro PR #140). Server gates on the
|
|
117
|
+
* caller's admin/owner role.
|
|
118
|
+
*
|
|
119
|
+
* Returns the email-send status. 404 here means "no pending row exists
|
|
120
|
+
* for this email+company pair — re-invite without --resend first."
|
|
121
|
+
*/
|
|
122
|
+
export async function resendInvite(options) {
|
|
123
|
+
const detected = detectTarget(options.inviteeEmail);
|
|
124
|
+
if (!detected || detected.type !== "email") {
|
|
125
|
+
throw new Error("--resend requires an email target (the resend path is email-keyed-row only)");
|
|
126
|
+
}
|
|
127
|
+
const res = await vaultApiFetch({
|
|
128
|
+
token: options.token,
|
|
129
|
+
path: "/membership/invite",
|
|
130
|
+
method: "POST",
|
|
131
|
+
body: {
|
|
132
|
+
companyUid: options.companyUid,
|
|
133
|
+
// Server's invite handler still requires `role` even on the resend
|
|
134
|
+
// short-circuit. Pass the safe default — server ignores it on this
|
|
135
|
+
// path (the pending row already has its real role).
|
|
136
|
+
role: "member",
|
|
137
|
+
inviteeEmail: detected.value,
|
|
138
|
+
invitedBy: options.callerUid,
|
|
139
|
+
resend: true,
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
if (!res.ok) {
|
|
143
|
+
const err = (await res.json().catch(() => ({})));
|
|
144
|
+
throw new InviteHttpError(res.status, err.message ?? err.error ?? res.statusText, err.code);
|
|
145
|
+
}
|
|
146
|
+
const data = (await res.json());
|
|
147
|
+
if (data.resent !== true) {
|
|
148
|
+
throw new Error("Resend endpoint returned 2xx without `resent: true` — the connected hq-pro likely doesn't support `resend: true` yet. Upgrade hq-pro or re-invite without --resend.");
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
resent: true,
|
|
152
|
+
emailSent: data.emailSent === true,
|
|
153
|
+
emailSkipped: data.emailSkipped === true,
|
|
154
|
+
...(data.emailError ? { emailError: data.emailError } : {}),
|
|
95
155
|
};
|
|
96
156
|
}
|
|
97
157
|
export class InviteHttpError extends Error {
|
|
@@ -163,6 +223,21 @@ export function resolveRevokeTargetToMembershipKey(arg, companyUid) {
|
|
|
163
223
|
}
|
|
164
224
|
return arg;
|
|
165
225
|
}
|
|
226
|
+
function printEmailStatus(opts) {
|
|
227
|
+
if (opts.sent) {
|
|
228
|
+
console.log(chalk.green(`✓ Invitation email sent to ${opts.recipient.toLowerCase()}`));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (opts.skipped) {
|
|
232
|
+
console.log(chalk.yellow(`⚠ Email send skipped — hq-pro has no RESEND_API_KEY configured for this stage.`));
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (opts.error) {
|
|
236
|
+
console.log(chalk.yellow(`⚠ Email send failed: ${opts.error}`));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
console.log(chalk.yellow(`⚠ Email status unknown — hq-pro returned no email fields (likely a pre-resend deploy).`));
|
|
240
|
+
}
|
|
166
241
|
export async function revokeInvite(token, tokenOrKey, companyUid) {
|
|
167
242
|
const res = await vaultApiFetch({
|
|
168
243
|
token,
|
|
@@ -182,19 +257,46 @@ export function registerMembersCommand(program) {
|
|
|
182
257
|
.option("--company <slug>", "Company slug (resolves to companyUid)");
|
|
183
258
|
members
|
|
184
259
|
.command("invite <target>")
|
|
185
|
-
.description("Invite a person to the company by email or personUid (
|
|
260
|
+
.description("Invite a person to the company by email or personUid (sends an invitation email by default)")
|
|
186
261
|
.option("--role <role>", "Role for the invitee: owner, admin, member, or guest", "member")
|
|
187
262
|
.option("--paths <prefixes>", "Comma-separated allowed prefixes (only valid with --role guest)")
|
|
263
|
+
.option("--groups <ids>", "Comma-separated secret-group ids the invitee will be added to on claim (email-keyed invites only)")
|
|
264
|
+
.option("--send-email", "Have hq-pro send an invitation email via Resend (default true for email-keyed invites). Pre-resend hq-pro versions ignore this and the legacy 'no email sent' instructions print instead.", true)
|
|
265
|
+
.option("--no-send-email", "Skip the server-side Resend send — only create the pending DDB row. Useful when you're scripting bulk invites and will send the announcement out-of-band.")
|
|
266
|
+
.option("--resend", "Re-fire the invitation email against an existing pending row without creating a new row. Maps to hq-pro `resend: true` short-circuit. Email-keyed invites only.")
|
|
188
267
|
.action(async (target, opts) => {
|
|
189
268
|
try {
|
|
190
269
|
const token = await ensureCognitoToken();
|
|
191
270
|
const companySlug = members.opts().company;
|
|
192
271
|
const companyUid = await getCompanyUid(token, companySlug);
|
|
193
272
|
const callerUid = await getCallerPersonUid(token);
|
|
273
|
+
// --resend short-circuits to the server's re-fire path. No new row.
|
|
274
|
+
if (opts.resend === true) {
|
|
275
|
+
const resendResult = await resendInvite({
|
|
276
|
+
inviteeEmail: target,
|
|
277
|
+
companyUid,
|
|
278
|
+
callerUid,
|
|
279
|
+
token,
|
|
280
|
+
});
|
|
281
|
+
console.log(chalk.green(`Re-fired invite email for ${target.toLowerCase()} (existing pending row left untouched)`));
|
|
282
|
+
console.log();
|
|
283
|
+
printEmailStatus({
|
|
284
|
+
recipient: target,
|
|
285
|
+
sent: resendResult.emailSent,
|
|
286
|
+
skipped: resendResult.emailSkipped,
|
|
287
|
+
error: resendResult.emailError,
|
|
288
|
+
});
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const groupIds = opts.groups
|
|
292
|
+
? opts.groups.split(",").map((g) => g.trim()).filter(Boolean)
|
|
293
|
+
: undefined;
|
|
194
294
|
const result = await inviteMember({
|
|
195
295
|
target,
|
|
196
296
|
role: opts.role,
|
|
197
297
|
paths: opts.paths,
|
|
298
|
+
groupIds,
|
|
299
|
+
sendEmail: opts.sendEmail,
|
|
198
300
|
companyUid,
|
|
199
301
|
callerUid,
|
|
200
302
|
token,
|
|
@@ -207,34 +309,60 @@ export function registerMembersCommand(program) {
|
|
|
207
309
|
console.log(` ${result.magicLink}`);
|
|
208
310
|
console.log();
|
|
209
311
|
console.log(chalk.dim("Share this link with the invitee. They can run `hq onboard join --invite-token <token>` to accept."));
|
|
312
|
+
return;
|
|
210
313
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
console.log();
|
|
233
|
-
console.log(chalk.dim("The pending membership row claims itself on the invitee's first sign-in."));
|
|
314
|
+
// schemaVersion 2+ — email-keyed authoritative membership row.
|
|
315
|
+
// Three output branches depending on whether the server-side
|
|
316
|
+
// Resend send fired:
|
|
317
|
+
// (a) emailSent === true → "email sent" success line
|
|
318
|
+
// (b) emailSkipped === true → server has no RESEND_API_KEY;
|
|
319
|
+
// fall back to the manual-notify instructions
|
|
320
|
+
// (c) emailSent/emailSkipped both undefined → pre-resend
|
|
321
|
+
// hq-pro, doesn't understand `sendEmail` at all; fall
|
|
322
|
+
// back to the manual-notify instructions
|
|
323
|
+
const inviteeEmail = result.membership.inviteeEmail ??
|
|
324
|
+
(typeof target === "string" && target.includes("@")
|
|
325
|
+
? target
|
|
326
|
+
: undefined);
|
|
327
|
+
const emailDelivered = result.emailSent === true;
|
|
328
|
+
if (emailDelivered) {
|
|
329
|
+
printEmailStatus({
|
|
330
|
+
recipient: inviteeEmail ?? target,
|
|
331
|
+
sent: true,
|
|
332
|
+
skipped: false,
|
|
333
|
+
error: undefined,
|
|
334
|
+
});
|
|
234
335
|
if (result.membership.membershipKey) {
|
|
235
336
|
console.log();
|
|
236
337
|
console.log(chalk.dim(`Membership key: ${result.membership.membershipKey}`));
|
|
237
338
|
}
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
// Email-send failed or wasn't attempted — surface the manual path.
|
|
342
|
+
if (result.emailError) {
|
|
343
|
+
console.log(chalk.yellow(`⚠ Email send failed: ${result.emailError}`));
|
|
344
|
+
console.log();
|
|
345
|
+
}
|
|
346
|
+
console.log(chalk.yellow("⚠ No email was sent. The pending membership row exists; the invitee needs to sign in manually to claim it."));
|
|
347
|
+
console.log();
|
|
348
|
+
console.log(chalk.bold("To complete the invite, do ONE of:"));
|
|
349
|
+
console.log(` 1. Manually notify ${inviteeEmail ?? "the invitee"}: ask them to sign into HQ`);
|
|
350
|
+
console.log(` at https://hq.getindigo.ai with that email address.`);
|
|
351
|
+
console.log(` 2. Or use the hq-console UI at https://hq.getindigo.ai to issue the`);
|
|
352
|
+
console.log(` invite instead — the UI path triggers an automated email via Resend.`);
|
|
353
|
+
if (result.emailSkipped !== true && result.emailSent === undefined) {
|
|
354
|
+
console.log();
|
|
355
|
+
console.log(chalk.dim("Tip: this hq-pro deploy doesn't support server-side email yet (`sendEmail` ignored). Ask an operator to ship hq-pro PR #140 + provision the ResendApiKey SST secret."));
|
|
356
|
+
}
|
|
357
|
+
else if (result.emailSkipped === true) {
|
|
358
|
+
console.log();
|
|
359
|
+
console.log(chalk.dim("Tip: hq-pro reported `emailSkipped: true` — its `RESEND_API_KEY` SST secret isn't set for this stage."));
|
|
360
|
+
}
|
|
361
|
+
console.log();
|
|
362
|
+
console.log(chalk.dim("The pending membership row claims itself on the invitee's first sign-in."));
|
|
363
|
+
if (result.membership.membershipKey) {
|
|
364
|
+
console.log();
|
|
365
|
+
console.log(chalk.dim(`Membership key: ${result.membership.membershipKey}`));
|
|
238
366
|
}
|
|
239
367
|
}
|
|
240
368
|
catch (err) {
|
|
@@ -323,4 +451,4 @@ export function registerMembersCommand(program) {
|
|
|
323
451
|
});
|
|
324
452
|
}
|
|
325
453
|
//# sourceMappingURL=members.js.map
|
|
326
|
-
//# debugId=
|
|
454
|
+
//# debugId=83c39bc9-e532-580d-bc3a-5f176eb5cba6
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.25.0",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"clean": "rm -rf dist"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@indigoai-us/hq-cloud": "~5.
|
|
18
|
+
"@indigoai-us/hq-cloud": "~5.39.0",
|
|
19
19
|
"@indigoai-us/hq-onboarding": "^0.1.0",
|
|
20
20
|
"@sentry/node": "^10.49.0",
|
|
21
21
|
"chalk": "^5.3.0",
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
getCallerPersonUid,
|
|
19
19
|
inviteMember,
|
|
20
20
|
listPendingInvites,
|
|
21
|
+
resendInvite,
|
|
21
22
|
resolveRevokeTargetToMembershipKey,
|
|
22
23
|
revokeInvite,
|
|
23
24
|
} from "./members.js";
|
|
@@ -300,6 +301,296 @@ describe("inviteMember", () => {
|
|
|
300
301
|
}),
|
|
301
302
|
).rejects.toThrow(/no membership row/);
|
|
302
303
|
});
|
|
304
|
+
|
|
305
|
+
// ---- sendEmail flag (hq-pro PR #140 contract) ---------------------------
|
|
306
|
+
|
|
307
|
+
it("forwards `sendEmail: true` in the request body when the option is set", async () => {
|
|
308
|
+
fetchSpy.mockResolvedValueOnce(
|
|
309
|
+
jsonResponse(201, {
|
|
310
|
+
membership: {
|
|
311
|
+
role: "member",
|
|
312
|
+
status: "pending",
|
|
313
|
+
inviteeEmail: "alice@example.com",
|
|
314
|
+
schemaVersion: 2,
|
|
315
|
+
},
|
|
316
|
+
resent: false,
|
|
317
|
+
emailSent: true,
|
|
318
|
+
emailSkipped: false,
|
|
319
|
+
}),
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
const result = await inviteMember({
|
|
323
|
+
target: "alice@example.com",
|
|
324
|
+
role: "member",
|
|
325
|
+
sendEmail: true,
|
|
326
|
+
companyUid: "cmp_acme",
|
|
327
|
+
callerUid: "prs_admin",
|
|
328
|
+
token: "test-token",
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const body = JSON.parse((fetchSpy.mock.calls[0][1]?.body as string) ?? "{}");
|
|
332
|
+
expect(body.sendEmail).toBe(true);
|
|
333
|
+
expect(result.emailSent).toBe(true);
|
|
334
|
+
expect(result.emailSkipped).toBe(false);
|
|
335
|
+
expect(result.emailError).toBeUndefined();
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("omits sendEmail from the body when the option is unset (backward-compat with pre-resend hq-pro)", async () => {
|
|
339
|
+
fetchSpy.mockResolvedValueOnce(
|
|
340
|
+
jsonResponse(201, {
|
|
341
|
+
membership: {
|
|
342
|
+
role: "member",
|
|
343
|
+
status: "pending",
|
|
344
|
+
inviteeEmail: "alice@example.com",
|
|
345
|
+
schemaVersion: 2,
|
|
346
|
+
},
|
|
347
|
+
}),
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
const result = await inviteMember({
|
|
351
|
+
target: "alice@example.com",
|
|
352
|
+
role: "member",
|
|
353
|
+
companyUid: "cmp_acme",
|
|
354
|
+
callerUid: "prs_admin",
|
|
355
|
+
token: "test-token",
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const body = JSON.parse((fetchSpy.mock.calls[0][1]?.body as string) ?? "{}");
|
|
359
|
+
expect("sendEmail" in body).toBe(false);
|
|
360
|
+
// Pre-resend hq-pro doesn't send the email fields at all — the CLI
|
|
361
|
+
// result fields stay undefined so the caller can detect "server has no
|
|
362
|
+
// opinion" vs "server explicitly skipped".
|
|
363
|
+
expect(result.emailSent).toBeUndefined();
|
|
364
|
+
expect(result.emailSkipped).toBeUndefined();
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("surfaces emailSkipped: true when hq-pro has no RESEND_API_KEY", async () => {
|
|
368
|
+
fetchSpy.mockResolvedValueOnce(
|
|
369
|
+
jsonResponse(201, {
|
|
370
|
+
membership: {
|
|
371
|
+
role: "member",
|
|
372
|
+
status: "pending",
|
|
373
|
+
inviteeEmail: "alice@example.com",
|
|
374
|
+
schemaVersion: 2,
|
|
375
|
+
},
|
|
376
|
+
resent: false,
|
|
377
|
+
emailSent: false,
|
|
378
|
+
emailSkipped: true,
|
|
379
|
+
}),
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
const result = await inviteMember({
|
|
383
|
+
target: "alice@example.com",
|
|
384
|
+
role: "member",
|
|
385
|
+
sendEmail: true,
|
|
386
|
+
companyUid: "cmp_acme",
|
|
387
|
+
callerUid: "prs_admin",
|
|
388
|
+
token: "test-token",
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
expect(result.emailSent).toBe(false);
|
|
392
|
+
expect(result.emailSkipped).toBe(true);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("surfaces emailError when the server-side Resend send failed", async () => {
|
|
396
|
+
fetchSpy.mockResolvedValueOnce(
|
|
397
|
+
jsonResponse(201, {
|
|
398
|
+
membership: {
|
|
399
|
+
role: "member",
|
|
400
|
+
status: "pending",
|
|
401
|
+
inviteeEmail: "alice@example.com",
|
|
402
|
+
schemaVersion: 2,
|
|
403
|
+
},
|
|
404
|
+
resent: false,
|
|
405
|
+
emailSent: false,
|
|
406
|
+
emailSkipped: false,
|
|
407
|
+
emailError: "Resend 429: rate limited",
|
|
408
|
+
}),
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
const result = await inviteMember({
|
|
412
|
+
target: "alice@example.com",
|
|
413
|
+
role: "member",
|
|
414
|
+
sendEmail: true,
|
|
415
|
+
companyUid: "cmp_acme",
|
|
416
|
+
callerUid: "prs_admin",
|
|
417
|
+
token: "test-token",
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
expect(result.emailSent).toBe(false);
|
|
421
|
+
expect(result.emailError).toMatch(/Resend 429/);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// ---- groupIds (hq-pro PR #140 contract) ---------------------------------
|
|
425
|
+
|
|
426
|
+
it("forwards groupIds in the body when set on an email-keyed invite", async () => {
|
|
427
|
+
fetchSpy.mockResolvedValueOnce(
|
|
428
|
+
jsonResponse(201, {
|
|
429
|
+
membership: {
|
|
430
|
+
role: "member",
|
|
431
|
+
status: "pending",
|
|
432
|
+
inviteeEmail: "alice@example.com",
|
|
433
|
+
schemaVersion: 2,
|
|
434
|
+
},
|
|
435
|
+
}),
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
await inviteMember({
|
|
439
|
+
target: "alice@example.com",
|
|
440
|
+
role: "member",
|
|
441
|
+
groupIds: ["grp-1", "grp-2"],
|
|
442
|
+
companyUid: "cmp_acme",
|
|
443
|
+
callerUid: "prs_admin",
|
|
444
|
+
token: "test-token",
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
const body = JSON.parse((fetchSpy.mock.calls[0][1]?.body as string) ?? "{}");
|
|
448
|
+
expect(body.groupIds).toEqual(["grp-1", "grp-2"]);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("rejects --groups combined with a personUid target before hitting the server", async () => {
|
|
452
|
+
await expect(
|
|
453
|
+
inviteMember({
|
|
454
|
+
target: "prs_bob",
|
|
455
|
+
role: "member",
|
|
456
|
+
groupIds: ["grp-1"],
|
|
457
|
+
companyUid: "cmp_acme",
|
|
458
|
+
callerUid: "prs_admin",
|
|
459
|
+
token: "test-token",
|
|
460
|
+
}),
|
|
461
|
+
).rejects.toThrow(/email-keyed invites/);
|
|
462
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("omits groupIds from the body when the array is empty", async () => {
|
|
466
|
+
fetchSpy.mockResolvedValueOnce(
|
|
467
|
+
jsonResponse(201, {
|
|
468
|
+
membership: {
|
|
469
|
+
role: "member",
|
|
470
|
+
status: "pending",
|
|
471
|
+
inviteeEmail: "alice@example.com",
|
|
472
|
+
schemaVersion: 2,
|
|
473
|
+
},
|
|
474
|
+
}),
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
await inviteMember({
|
|
478
|
+
target: "alice@example.com",
|
|
479
|
+
role: "member",
|
|
480
|
+
groupIds: [],
|
|
481
|
+
companyUid: "cmp_acme",
|
|
482
|
+
callerUid: "prs_admin",
|
|
483
|
+
token: "test-token",
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
const body = JSON.parse((fetchSpy.mock.calls[0][1]?.body as string) ?? "{}");
|
|
487
|
+
expect("groupIds" in body).toBe(false);
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// ---------------------------------------------------------------------------
|
|
492
|
+
// resendInvite (hq-pro PR #140 resend: true short-circuit)
|
|
493
|
+
// ---------------------------------------------------------------------------
|
|
494
|
+
|
|
495
|
+
describe("resendInvite", () => {
|
|
496
|
+
it("posts { resend: true, inviteeEmail, companyUid, role: 'member' } and returns the resent result", async () => {
|
|
497
|
+
fetchSpy.mockResolvedValueOnce(
|
|
498
|
+
jsonResponse(200, {
|
|
499
|
+
resent: true,
|
|
500
|
+
emailSent: true,
|
|
501
|
+
emailSkipped: false,
|
|
502
|
+
}),
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
const result = await resendInvite({
|
|
506
|
+
inviteeEmail: "Alice@Example.com",
|
|
507
|
+
companyUid: "cmp_acme",
|
|
508
|
+
callerUid: "prs_admin",
|
|
509
|
+
token: "test-token",
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
expect(result).toEqual({
|
|
513
|
+
resent: true,
|
|
514
|
+
emailSent: true,
|
|
515
|
+
emailSkipped: false,
|
|
516
|
+
});
|
|
517
|
+
const body = JSON.parse((fetchSpy.mock.calls[0][1]?.body as string) ?? "{}");
|
|
518
|
+
expect(body).toEqual({
|
|
519
|
+
companyUid: "cmp_acme",
|
|
520
|
+
// Server still requires `role` even on the resend short-circuit; the
|
|
521
|
+
// CLI passes the safe default. Server ignores it on this path.
|
|
522
|
+
role: "member",
|
|
523
|
+
// Email normalized to lowercase before send.
|
|
524
|
+
inviteeEmail: "alice@example.com",
|
|
525
|
+
invitedBy: "prs_admin",
|
|
526
|
+
resend: true,
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("surfaces emailSkipped: true when hq-pro has no RESEND_API_KEY", async () => {
|
|
531
|
+
fetchSpy.mockResolvedValueOnce(
|
|
532
|
+
jsonResponse(200, {
|
|
533
|
+
resent: true,
|
|
534
|
+
emailSent: false,
|
|
535
|
+
emailSkipped: true,
|
|
536
|
+
}),
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
const result = await resendInvite({
|
|
540
|
+
inviteeEmail: "alice@example.com",
|
|
541
|
+
companyUid: "cmp_acme",
|
|
542
|
+
callerUid: "prs_admin",
|
|
543
|
+
token: "test-token",
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
expect(result.emailSent).toBe(false);
|
|
547
|
+
expect(result.emailSkipped).toBe(true);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it("throws on a non-email target — the resend path is email-keyed-row only", async () => {
|
|
551
|
+
await expect(
|
|
552
|
+
resendInvite({
|
|
553
|
+
inviteeEmail: "prs_bob",
|
|
554
|
+
companyUid: "cmp_acme",
|
|
555
|
+
callerUid: "prs_admin",
|
|
556
|
+
token: "test-token",
|
|
557
|
+
}),
|
|
558
|
+
).rejects.toThrow(/email target/);
|
|
559
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it("wraps a 404 INVITE_NOT_PENDING in InviteHttpError so the action handler can format it", async () => {
|
|
563
|
+
fetchSpy.mockResolvedValueOnce(
|
|
564
|
+
jsonResponse(404, {
|
|
565
|
+
error: "no pending row",
|
|
566
|
+
code: "INVITE_NOT_PENDING",
|
|
567
|
+
}),
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
await expect(
|
|
571
|
+
resendInvite({
|
|
572
|
+
inviteeEmail: "alice@example.com",
|
|
573
|
+
companyUid: "cmp_acme",
|
|
574
|
+
callerUid: "prs_admin",
|
|
575
|
+
token: "test-token",
|
|
576
|
+
}),
|
|
577
|
+
).rejects.toBeInstanceOf(InviteHttpError);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it("rejects a 2xx response that lacks `resent: true` — guard against a pre-resend hq-pro silently dropping the flag", async () => {
|
|
581
|
+
fetchSpy.mockResolvedValueOnce(
|
|
582
|
+
jsonResponse(200, { membership: { role: "member", status: "pending" } }),
|
|
583
|
+
);
|
|
584
|
+
|
|
585
|
+
await expect(
|
|
586
|
+
resendInvite({
|
|
587
|
+
inviteeEmail: "alice@example.com",
|
|
588
|
+
companyUid: "cmp_acme",
|
|
589
|
+
callerUid: "prs_admin",
|
|
590
|
+
token: "test-token",
|
|
591
|
+
}),
|
|
592
|
+
).rejects.toThrow(/doesn't support `resend: true` yet/);
|
|
593
|
+
});
|
|
303
594
|
});
|
|
304
595
|
|
|
305
596
|
// ---------------------------------------------------------------------------
|
package/src/commands/members.ts
CHANGED
|
@@ -33,6 +33,17 @@ export interface InviteOptions {
|
|
|
33
33
|
target: string;
|
|
34
34
|
role: string;
|
|
35
35
|
paths?: string;
|
|
36
|
+
/** Email-keyed invites only — secret-group ids to attach on claim. */
|
|
37
|
+
groupIds?: string[];
|
|
38
|
+
/**
|
|
39
|
+
* Opt the server-side Resend send in/out. When `true`, hq-pro renders +
|
|
40
|
+
* sends the invitation email server-side and returns `emailSent` /
|
|
41
|
+
* `emailSkipped` / `emailError`. When `false` or unset, the legacy
|
|
42
|
+
* no-email path runs and the CLI prints the manual sign-in instructions.
|
|
43
|
+
* Requires hq-pro that supports `sendEmail` (older servers ignore the
|
|
44
|
+
* field; CLI falls back to the legacy printout in that case).
|
|
45
|
+
*/
|
|
46
|
+
sendEmail?: boolean;
|
|
36
47
|
companyUid: string;
|
|
37
48
|
callerUid: string;
|
|
38
49
|
token: string;
|
|
@@ -51,6 +62,10 @@ export interface InviteOptions {
|
|
|
51
62
|
* instructions instead.
|
|
52
63
|
*
|
|
53
64
|
* `membership` is always populated when the server returned 2xx.
|
|
65
|
+
*
|
|
66
|
+
* Email-related fields are populated only when hq-pro performed a
|
|
67
|
+
* server-side Resend send (caller passed `sendEmail: true` AND the
|
|
68
|
+
* server supports it). Pre-resend servers omit them entirely.
|
|
54
69
|
*/
|
|
55
70
|
export interface InviteResult {
|
|
56
71
|
inviteToken?: string;
|
|
@@ -62,6 +77,31 @@ export interface InviteResult {
|
|
|
62
77
|
inviteToken?: string;
|
|
63
78
|
inviteeEmail?: string;
|
|
64
79
|
};
|
|
80
|
+
/** Resend send actually fired and accepted by Resend. */
|
|
81
|
+
emailSent?: boolean;
|
|
82
|
+
/** Resend skipped because the server has no RESEND_API_KEY configured. */
|
|
83
|
+
emailSkipped?: boolean;
|
|
84
|
+
/** Resend was attempted but failed — human-readable reason. */
|
|
85
|
+
emailError?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface ResendInviteOptions {
|
|
89
|
+
inviteeEmail: string;
|
|
90
|
+
companyUid: string;
|
|
91
|
+
callerUid: string;
|
|
92
|
+
token: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Outcome of `hq members invite --resend`. No new membership row was
|
|
97
|
+
* created — the server's `resend: true` short-circuit just re-fires the
|
|
98
|
+
* Resend email against the existing pending row.
|
|
99
|
+
*/
|
|
100
|
+
export interface ResendInviteResult {
|
|
101
|
+
resent: true;
|
|
102
|
+
emailSent: boolean;
|
|
103
|
+
emailSkipped: boolean;
|
|
104
|
+
emailError?: string;
|
|
65
105
|
}
|
|
66
106
|
|
|
67
107
|
export interface DetectedTarget {
|
|
@@ -127,6 +167,12 @@ export async function inviteMember(
|
|
|
127
167
|
);
|
|
128
168
|
}
|
|
129
169
|
|
|
170
|
+
if (options.groupIds && options.groupIds.length > 0 && detected.type === "person") {
|
|
171
|
+
throw new Error(
|
|
172
|
+
"--groups is only valid on email-keyed invites (server rejects personUid + groupIds with 400)",
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
130
176
|
const allowedPrefixes = options.paths
|
|
131
177
|
? options.paths.split(",").map((p) => p.trim()).filter(Boolean)
|
|
132
178
|
: undefined;
|
|
@@ -139,6 +185,10 @@ export async function inviteMember(
|
|
|
139
185
|
if (detected.type === "email") body.inviteeEmail = detected.value;
|
|
140
186
|
else body.personUid = detected.value;
|
|
141
187
|
if (allowedPrefixes) body.allowedPrefixes = allowedPrefixes;
|
|
188
|
+
if (options.groupIds && options.groupIds.length > 0) {
|
|
189
|
+
body.groupIds = options.groupIds;
|
|
190
|
+
}
|
|
191
|
+
if (options.sendEmail === true) body.sendEmail = true;
|
|
142
192
|
|
|
143
193
|
const res = await vaultApiFetch({
|
|
144
194
|
token: options.token,
|
|
@@ -166,6 +216,9 @@ export async function inviteMember(
|
|
|
166
216
|
schemaVersion?: number;
|
|
167
217
|
};
|
|
168
218
|
inviteToken?: string;
|
|
219
|
+
emailSent?: boolean;
|
|
220
|
+
emailSkipped?: boolean;
|
|
221
|
+
emailError?: string;
|
|
169
222
|
};
|
|
170
223
|
if (!data.membership) {
|
|
171
224
|
const keys = Object.keys(data ?? {}).join(", ") || "<empty>";
|
|
@@ -189,6 +242,77 @@ export async function inviteMember(
|
|
|
189
242
|
? { inviteToken, magicLink: `hq://accept/${inviteToken}` }
|
|
190
243
|
: {}),
|
|
191
244
|
membership: data.membership,
|
|
245
|
+
// Pre-resend hq-pro versions omit these fields entirely — leave the
|
|
246
|
+
// result fields undefined so the caller's "is server-side email
|
|
247
|
+
// supported?" check works via `typeof result.emailSent === 'boolean'`.
|
|
248
|
+
...(typeof data.emailSent === "boolean" ? { emailSent: data.emailSent } : {}),
|
|
249
|
+
...(typeof data.emailSkipped === "boolean"
|
|
250
|
+
? { emailSkipped: data.emailSkipped }
|
|
251
|
+
: {}),
|
|
252
|
+
...(data.emailError ? { emailError: data.emailError } : {}),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Re-fire the invite email against an existing pending row without
|
|
258
|
+
* creating a new membership row. Maps to hq-pro `/membership/invite`
|
|
259
|
+
* with `{ resend: true }` (see hq-pro PR #140). Server gates on the
|
|
260
|
+
* caller's admin/owner role.
|
|
261
|
+
*
|
|
262
|
+
* Returns the email-send status. 404 here means "no pending row exists
|
|
263
|
+
* for this email+company pair — re-invite without --resend first."
|
|
264
|
+
*/
|
|
265
|
+
export async function resendInvite(
|
|
266
|
+
options: ResendInviteOptions,
|
|
267
|
+
): Promise<ResendInviteResult> {
|
|
268
|
+
const detected = detectTarget(options.inviteeEmail);
|
|
269
|
+
if (!detected || detected.type !== "email") {
|
|
270
|
+
throw new Error(
|
|
271
|
+
"--resend requires an email target (the resend path is email-keyed-row only)",
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const res = await vaultApiFetch({
|
|
276
|
+
token: options.token,
|
|
277
|
+
path: "/membership/invite",
|
|
278
|
+
method: "POST",
|
|
279
|
+
body: {
|
|
280
|
+
companyUid: options.companyUid,
|
|
281
|
+
// Server's invite handler still requires `role` even on the resend
|
|
282
|
+
// short-circuit. Pass the safe default — server ignores it on this
|
|
283
|
+
// path (the pending row already has its real role).
|
|
284
|
+
role: "member",
|
|
285
|
+
inviteeEmail: detected.value,
|
|
286
|
+
invitedBy: options.callerUid,
|
|
287
|
+
resend: true,
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
if (!res.ok) {
|
|
292
|
+
const err = (await res.json().catch(() => ({}))) as Record<string, string>;
|
|
293
|
+
throw new InviteHttpError(
|
|
294
|
+
res.status,
|
|
295
|
+
err.message ?? err.error ?? res.statusText,
|
|
296
|
+
err.code,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const data = (await res.json()) as {
|
|
301
|
+
resent?: boolean;
|
|
302
|
+
emailSent?: boolean;
|
|
303
|
+
emailSkipped?: boolean;
|
|
304
|
+
emailError?: string;
|
|
305
|
+
};
|
|
306
|
+
if (data.resent !== true) {
|
|
307
|
+
throw new Error(
|
|
308
|
+
"Resend endpoint returned 2xx without `resent: true` — the connected hq-pro likely doesn't support `resend: true` yet. Upgrade hq-pro or re-invite without --resend.",
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
resent: true,
|
|
313
|
+
emailSent: data.emailSent === true,
|
|
314
|
+
emailSkipped: data.emailSkipped === true,
|
|
315
|
+
...(data.emailError ? { emailError: data.emailError } : {}),
|
|
192
316
|
};
|
|
193
317
|
}
|
|
194
318
|
|
|
@@ -279,6 +403,37 @@ export function resolveRevokeTargetToMembershipKey(
|
|
|
279
403
|
return arg;
|
|
280
404
|
}
|
|
281
405
|
|
|
406
|
+
function printEmailStatus(opts: {
|
|
407
|
+
recipient: string;
|
|
408
|
+
sent: boolean;
|
|
409
|
+
skipped: boolean;
|
|
410
|
+
error?: string;
|
|
411
|
+
}): void {
|
|
412
|
+
if (opts.sent) {
|
|
413
|
+
console.log(
|
|
414
|
+
chalk.green(`✓ Invitation email sent to ${opts.recipient.toLowerCase()}`),
|
|
415
|
+
);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (opts.skipped) {
|
|
419
|
+
console.log(
|
|
420
|
+
chalk.yellow(
|
|
421
|
+
`⚠ Email send skipped — hq-pro has no RESEND_API_KEY configured for this stage.`,
|
|
422
|
+
),
|
|
423
|
+
);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
if (opts.error) {
|
|
427
|
+
console.log(chalk.yellow(`⚠ Email send failed: ${opts.error}`));
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
console.log(
|
|
431
|
+
chalk.yellow(
|
|
432
|
+
`⚠ Email status unknown — hq-pro returned no email fields (likely a pre-resend deploy).`,
|
|
433
|
+
),
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
282
437
|
export async function revokeInvite(
|
|
283
438
|
token: string,
|
|
284
439
|
tokenOrKey: string,
|
|
@@ -309,7 +464,7 @@ export function registerMembersCommand(program: Command): void {
|
|
|
309
464
|
members
|
|
310
465
|
.command("invite <target>")
|
|
311
466
|
.description(
|
|
312
|
-
"Invite a person to the company by email or personUid (
|
|
467
|
+
"Invite a person to the company by email or personUid (sends an invitation email by default)",
|
|
313
468
|
)
|
|
314
469
|
.option(
|
|
315
470
|
"--role <role>",
|
|
@@ -320,10 +475,33 @@ export function registerMembersCommand(program: Command): void {
|
|
|
320
475
|
"--paths <prefixes>",
|
|
321
476
|
"Comma-separated allowed prefixes (only valid with --role guest)",
|
|
322
477
|
)
|
|
478
|
+
.option(
|
|
479
|
+
"--groups <ids>",
|
|
480
|
+
"Comma-separated secret-group ids the invitee will be added to on claim (email-keyed invites only)",
|
|
481
|
+
)
|
|
482
|
+
.option(
|
|
483
|
+
"--send-email",
|
|
484
|
+
"Have hq-pro send an invitation email via Resend (default true for email-keyed invites). Pre-resend hq-pro versions ignore this and the legacy 'no email sent' instructions print instead.",
|
|
485
|
+
true,
|
|
486
|
+
)
|
|
487
|
+
.option(
|
|
488
|
+
"--no-send-email",
|
|
489
|
+
"Skip the server-side Resend send — only create the pending DDB row. Useful when you're scripting bulk invites and will send the announcement out-of-band.",
|
|
490
|
+
)
|
|
491
|
+
.option(
|
|
492
|
+
"--resend",
|
|
493
|
+
"Re-fire the invitation email against an existing pending row without creating a new row. Maps to hq-pro `resend: true` short-circuit. Email-keyed invites only.",
|
|
494
|
+
)
|
|
323
495
|
.action(
|
|
324
496
|
async (
|
|
325
497
|
target: string,
|
|
326
|
-
opts: {
|
|
498
|
+
opts: {
|
|
499
|
+
role: string;
|
|
500
|
+
paths?: string;
|
|
501
|
+
groups?: string;
|
|
502
|
+
sendEmail: boolean;
|
|
503
|
+
resend?: boolean;
|
|
504
|
+
},
|
|
327
505
|
) => {
|
|
328
506
|
try {
|
|
329
507
|
const token = await ensureCognitoToken();
|
|
@@ -331,10 +509,39 @@ export function registerMembersCommand(program: Command): void {
|
|
|
331
509
|
const companyUid = await getCompanyUid(token, companySlug);
|
|
332
510
|
const callerUid = await getCallerPersonUid(token);
|
|
333
511
|
|
|
512
|
+
// --resend short-circuits to the server's re-fire path. No new row.
|
|
513
|
+
if (opts.resend === true) {
|
|
514
|
+
const resendResult = await resendInvite({
|
|
515
|
+
inviteeEmail: target,
|
|
516
|
+
companyUid,
|
|
517
|
+
callerUid,
|
|
518
|
+
token,
|
|
519
|
+
});
|
|
520
|
+
console.log(
|
|
521
|
+
chalk.green(
|
|
522
|
+
`Re-fired invite email for ${target.toLowerCase()} (existing pending row left untouched)`,
|
|
523
|
+
),
|
|
524
|
+
);
|
|
525
|
+
console.log();
|
|
526
|
+
printEmailStatus({
|
|
527
|
+
recipient: target,
|
|
528
|
+
sent: resendResult.emailSent,
|
|
529
|
+
skipped: resendResult.emailSkipped,
|
|
530
|
+
error: resendResult.emailError,
|
|
531
|
+
});
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const groupIds = opts.groups
|
|
536
|
+
? opts.groups.split(",").map((g) => g.trim()).filter(Boolean)
|
|
537
|
+
: undefined;
|
|
538
|
+
|
|
334
539
|
const result = await inviteMember({
|
|
335
540
|
target,
|
|
336
541
|
role: opts.role,
|
|
337
542
|
paths: opts.paths,
|
|
543
|
+
groupIds,
|
|
544
|
+
sendEmail: opts.sendEmail,
|
|
338
545
|
companyUid,
|
|
339
546
|
callerUid,
|
|
340
547
|
token,
|
|
@@ -356,52 +563,91 @@ export function registerMembersCommand(program: Command): void {
|
|
|
356
563
|
"Share this link with the invitee. They can run `hq onboard join --invite-token <token>` to accept.",
|
|
357
564
|
),
|
|
358
565
|
);
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// schemaVersion 2+ — email-keyed authoritative membership row.
|
|
570
|
+
// Three output branches depending on whether the server-side
|
|
571
|
+
// Resend send fired:
|
|
572
|
+
// (a) emailSent === true → "email sent" success line
|
|
573
|
+
// (b) emailSkipped === true → server has no RESEND_API_KEY;
|
|
574
|
+
// fall back to the manual-notify instructions
|
|
575
|
+
// (c) emailSent/emailSkipped both undefined → pre-resend
|
|
576
|
+
// hq-pro, doesn't understand `sendEmail` at all; fall
|
|
577
|
+
// back to the manual-notify instructions
|
|
578
|
+
const inviteeEmail =
|
|
579
|
+
result.membership.inviteeEmail ??
|
|
580
|
+
(typeof target === "string" && target.includes("@")
|
|
581
|
+
? target
|
|
582
|
+
: undefined);
|
|
583
|
+
const emailDelivered = result.emailSent === true;
|
|
584
|
+
if (emailDelivered) {
|
|
585
|
+
printEmailStatus({
|
|
586
|
+
recipient: inviteeEmail ?? target,
|
|
587
|
+
sent: true,
|
|
588
|
+
skipped: false,
|
|
589
|
+
error: undefined,
|
|
590
|
+
});
|
|
591
|
+
if (result.membership.membershipKey) {
|
|
592
|
+
console.log();
|
|
593
|
+
console.log(
|
|
594
|
+
chalk.dim(`Membership key: ${result.membership.membershipKey}`),
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
// Email-send failed or wasn't attempted — surface the manual path.
|
|
600
|
+
if (result.emailError) {
|
|
374
601
|
console.log(
|
|
375
|
-
chalk.yellow(
|
|
376
|
-
"⚠ No email was sent. `hq members invite` only creates the pending membership row.",
|
|
377
|
-
),
|
|
602
|
+
chalk.yellow(`⚠ Email send failed: ${result.emailError}`),
|
|
378
603
|
);
|
|
379
604
|
console.log();
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
605
|
+
}
|
|
606
|
+
console.log(
|
|
607
|
+
chalk.yellow(
|
|
608
|
+
"⚠ No email was sent. The pending membership row exists; the invitee needs to sign in manually to claim it.",
|
|
609
|
+
),
|
|
610
|
+
);
|
|
611
|
+
console.log();
|
|
612
|
+
console.log(chalk.bold("To complete the invite, do ONE of:"));
|
|
613
|
+
console.log(
|
|
614
|
+
` 1. Manually notify ${inviteeEmail ?? "the invitee"}: ask them to sign into HQ`,
|
|
615
|
+
);
|
|
616
|
+
console.log(
|
|
617
|
+
` at https://hq.getindigo.ai with that email address.`,
|
|
618
|
+
);
|
|
619
|
+
console.log(
|
|
620
|
+
` 2. Or use the hq-console UI at https://hq.getindigo.ai to issue the`,
|
|
621
|
+
);
|
|
622
|
+
console.log(
|
|
623
|
+
` invite instead — the UI path triggers an automated email via Resend.`,
|
|
624
|
+
);
|
|
625
|
+
if (result.emailSkipped !== true && result.emailSent === undefined) {
|
|
626
|
+
console.log();
|
|
390
627
|
console.log(
|
|
391
|
-
|
|
628
|
+
chalk.dim(
|
|
629
|
+
"Tip: this hq-pro deploy doesn't support server-side email yet (`sendEmail` ignored). Ask an operator to ship hq-pro PR #140 + provision the ResendApiKey SST secret.",
|
|
630
|
+
),
|
|
392
631
|
);
|
|
632
|
+
} else if (result.emailSkipped === true) {
|
|
393
633
|
console.log();
|
|
394
634
|
console.log(
|
|
395
635
|
chalk.dim(
|
|
396
|
-
"
|
|
636
|
+
"Tip: hq-pro reported `emailSkipped: true` — its `RESEND_API_KEY` SST secret isn't set for this stage.",
|
|
397
637
|
),
|
|
398
638
|
);
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
639
|
+
}
|
|
640
|
+
console.log();
|
|
641
|
+
console.log(
|
|
642
|
+
chalk.dim(
|
|
643
|
+
"The pending membership row claims itself on the invitee's first sign-in.",
|
|
644
|
+
),
|
|
645
|
+
);
|
|
646
|
+
if (result.membership.membershipKey) {
|
|
647
|
+
console.log();
|
|
648
|
+
console.log(
|
|
649
|
+
chalk.dim(`Membership key: ${result.membership.membershipKey}`),
|
|
650
|
+
);
|
|
405
651
|
}
|
|
406
652
|
} catch (err) {
|
|
407
653
|
if (err instanceof InviteHttpError) {
|