@indigoai-us/hq-cli 5.77.9 → 5.77.11
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 +21 -0
- package/dist/commands/members.d.ts +8 -0
- package/dist/commands/members.js +82 -9
- package/package.json +2 -2
- package/pnpm-workspace.yaml +1 -1
- package/src/commands/members.test.ts +195 -3
- package/src/commands/members.ts +124 -10
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.77.11]
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- `hq members promote <email>` now looks up the active member roster by
|
|
10
|
+
`personEmail` (and maps fleet-agent machine emails like revoke) instead of
|
|
11
|
+
synthesizing a pending-only `email:…#cmp` membership key that the role API
|
|
12
|
+
cannot find. (#269)
|
|
13
|
+
- Re-inviting an email that already has a pending invite no longer claims the
|
|
14
|
+
person is already a member; the CLI distinguishes pending vs active and
|
|
15
|
+
suggests `hq members invite <email> --company <slug> --resend` when
|
|
16
|
+
appropriate. (#269)
|
|
17
|
+
|
|
18
|
+
## [5.77.10]
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Updated `@indigoai-us/hq-cloud` to 6.14.27 so the sync watcher no longer
|
|
23
|
+
descends into directory-only ignores such as nested `node_modules/`, which
|
|
24
|
+
could exhaust host inotify watches. (#268)
|
|
25
|
+
|
|
5
26
|
## [5.77.9]
|
|
6
27
|
|
|
7
28
|
### Added
|
|
@@ -142,6 +142,14 @@ export declare class InviteHttpError extends Error {
|
|
|
142
142
|
export declare function formatInviteHttpError(status: number, fallback: string, code?: string): string;
|
|
143
143
|
export declare function listPendingInvites(token: string, companyUid: string): Promise<PendingInvite[]>;
|
|
144
144
|
export declare function listActiveMembers(token: string, companyUid: string): Promise<ActiveMember[]>;
|
|
145
|
+
/**
|
|
146
|
+
* Resolve a role-change target to an active membership key.
|
|
147
|
+
*
|
|
148
|
+
* Pending invites are email-keyed, but claimed active memberships are keyed by
|
|
149
|
+
* personUid. Consequently, role changes must look up email targets in the
|
|
150
|
+
* active member list instead of synthesizing an `email:...#company` key.
|
|
151
|
+
*/
|
|
152
|
+
export declare function resolveRoleChangeTarget(token: string, companyUid: string, target: string): Promise<string>;
|
|
145
153
|
/**
|
|
146
154
|
* Resolve a `revoke` CLI argument into the canonical `membershipKey` shape
|
|
147
155
|
* the server requires. Accepts three input forms:
|
package/dist/commands/members.js
CHANGED
|
@@ -244,6 +244,39 @@ export async function listActiveMembers(token, companyUid) {
|
|
|
244
244
|
const data = (await res.json());
|
|
245
245
|
return data?.members ?? [];
|
|
246
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* Resolve a role-change target to an active membership key.
|
|
249
|
+
*
|
|
250
|
+
* Pending invites are email-keyed, but claimed active memberships are keyed by
|
|
251
|
+
* personUid. Consequently, role changes must look up email targets in the
|
|
252
|
+
* active member list instead of synthesizing an `email:...#company` key.
|
|
253
|
+
*/
|
|
254
|
+
export async function resolveRoleChangeTarget(token, companyUid, target) {
|
|
255
|
+
if (target.includes("#"))
|
|
256
|
+
return target;
|
|
257
|
+
const detected = detectTarget(target);
|
|
258
|
+
if (detected?.type === "person" || detected?.type === "agent") {
|
|
259
|
+
return `${detected.value}#${companyUid}`;
|
|
260
|
+
}
|
|
261
|
+
if (detected?.type === "email") {
|
|
262
|
+
// Active fleet-agent guest memberships are keyed agt_…#cmp_…, not by
|
|
263
|
+
// personEmail enrichment. Map machine emails the same way revoke does
|
|
264
|
+
// before falling through to the human roster lookup.
|
|
265
|
+
if (detected.isAgent) {
|
|
266
|
+
const local = detected.value.split("@")[0] ?? "";
|
|
267
|
+
const m = local.match(/^agt-(.+)$/i);
|
|
268
|
+
if (m)
|
|
269
|
+
return `agt_${m[1].toUpperCase()}#${companyUid}`;
|
|
270
|
+
}
|
|
271
|
+
const members = await listActiveMembers(token, companyUid);
|
|
272
|
+
const member = members.find((candidate) => candidate.personEmail?.trim().toLowerCase() === detected.value);
|
|
273
|
+
if (member)
|
|
274
|
+
return member.membershipKey;
|
|
275
|
+
throw new Error(`No active member has email '${detected.value}' in this company. ` +
|
|
276
|
+
"Run `hq members list` to find the member, or use their prs_ personUid.");
|
|
277
|
+
}
|
|
278
|
+
throw new Error(`Invalid target '${target}': use an email, prs_ personUid, agt_ agentUid, or full membership key`);
|
|
279
|
+
}
|
|
247
280
|
/**
|
|
248
281
|
* Resolve a `revoke` CLI argument into the canonical `membershipKey` shape
|
|
249
282
|
* the server requires. Accepts three input forms:
|
|
@@ -356,7 +389,7 @@ export function registerMembersCommand(program) {
|
|
|
356
389
|
const token = await ensureCognitoToken();
|
|
357
390
|
const companySlug = members.opts().company;
|
|
358
391
|
const companyUid = await getCompanyUid(token, companySlug);
|
|
359
|
-
const membershipKey =
|
|
392
|
+
const membershipKey = await resolveRoleChangeTarget(token, companyUid, target);
|
|
360
393
|
await changeMemberRole(token, companyUid, membershipKey, role);
|
|
361
394
|
console.log(chalk.green(`Updated role for '${target}' to ${role}`));
|
|
362
395
|
if (role === "owner" || role === "admin") {
|
|
@@ -388,10 +421,13 @@ export function registerMembersCommand(program) {
|
|
|
388
421
|
.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.")
|
|
389
422
|
.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.")
|
|
390
423
|
.action(async (target, opts) => {
|
|
424
|
+
let token;
|
|
425
|
+
let companyUid;
|
|
426
|
+
let companySlug;
|
|
391
427
|
try {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
428
|
+
token = await ensureCognitoToken();
|
|
429
|
+
companySlug = members.opts().company;
|
|
430
|
+
companyUid = await getCompanyUid(token, companySlug);
|
|
395
431
|
const callerUid = await getCallerPersonUid(token);
|
|
396
432
|
// --resend short-circuits to the server's re-fire path. No new row.
|
|
397
433
|
if (opts.resend === true) {
|
|
@@ -511,13 +547,50 @@ export function registerMembersCommand(program) {
|
|
|
511
547
|
}
|
|
512
548
|
}
|
|
513
549
|
catch (err) {
|
|
514
|
-
//
|
|
515
|
-
//
|
|
516
|
-
//
|
|
517
|
-
//
|
|
518
|
-
// and a bulk-invite script shouldn't abort on an already-member. HQ-11.
|
|
550
|
+
// A 409 may refer to either an active membership or an existing
|
|
551
|
+
// pending invite. Probe both views so email targets get actionable
|
|
552
|
+
// guidance while preserving the terminal exit-0 behavior for bulk
|
|
553
|
+
// invite scripts.
|
|
519
554
|
if (err instanceof InviteHttpError &&
|
|
520
555
|
err.code === "MEMBERSHIP_ALREADY_EXISTS") {
|
|
556
|
+
const detected = detectTarget(target);
|
|
557
|
+
const normalizedEmail = detected?.type === "email" ? detected.value : undefined;
|
|
558
|
+
if (normalizedEmail && token && companyUid) {
|
|
559
|
+
// Preserve --company in copy-paste hints so multi-company
|
|
560
|
+
// callers don't hit "Multiple active companies" on --resend.
|
|
561
|
+
const companyFlag = companySlug
|
|
562
|
+
? ` --company ${companySlug}`
|
|
563
|
+
: "";
|
|
564
|
+
const resendHint = `hq members invite ${normalizedEmail}${companyFlag} --resend`;
|
|
565
|
+
try {
|
|
566
|
+
const pending = await listPendingInvites(token, companyUid);
|
|
567
|
+
if (pending.some((invite) => invite.inviteeEmail?.trim().toLowerCase() ===
|
|
568
|
+
normalizedEmail)) {
|
|
569
|
+
console.log(chalk.yellow(`${normalizedEmail} already has a pending invite. ` +
|
|
570
|
+
`Use \`${resendHint}\` to re-send it.`));
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
// Pending-list visibility can vary by server version or role.
|
|
576
|
+
// Fall through to the active-member probe and generic copy.
|
|
577
|
+
}
|
|
578
|
+
try {
|
|
579
|
+
const activeMembers = await listActiveMembers(token, companyUid);
|
|
580
|
+
if (activeMembers.some((member) => member.personEmail?.trim().toLowerCase() ===
|
|
581
|
+
normalizedEmail)) {
|
|
582
|
+
console.log(chalk.yellow(`${normalizedEmail} is already a member of this company — nothing to do.`));
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
// Preserve the original conflict as the source of truth and
|
|
588
|
+
// use copy that remains accurate when the probes are hidden.
|
|
589
|
+
}
|
|
590
|
+
console.log(chalk.yellow(`${normalizedEmail} already has a membership or pending invite for this company. ` +
|
|
591
|
+
`If the invite is pending, use \`${resendHint}\`.`));
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
521
594
|
console.log(chalk.yellow(`${target.toLowerCase()} is already a member of this company — nothing to do.`));
|
|
522
595
|
return;
|
|
523
596
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.77.
|
|
3
|
+
"version": "5.77.11",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@aws-sdk/client-s3": "^3.1049.0",
|
|
25
|
-
"@indigoai-us/hq-cloud": "^6.14.
|
|
25
|
+
"@indigoai-us/hq-cloud": "^6.14.27",
|
|
26
26
|
"@indigoai-us/hq-onboarding": "^0.1.0",
|
|
27
27
|
"@sentry/node": "^10.49.0",
|
|
28
28
|
"better-sqlite3": "^12.11.1",
|
package/pnpm-workspace.yaml
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
listPendingInvites,
|
|
44
44
|
registerMembersCommand,
|
|
45
45
|
resendInvite,
|
|
46
|
+
resolveRoleChangeTarget,
|
|
46
47
|
resolveRevokeTargetToMembershipKey,
|
|
47
48
|
revokeInvite,
|
|
48
49
|
} from "./members.js";
|
|
@@ -1092,7 +1093,22 @@ describe("changeMemberRole", () => {
|
|
|
1092
1093
|
|
|
1093
1094
|
describe("registerMembersCommand promote", () => {
|
|
1094
1095
|
it("resolves an email target and promotes to a full-set role the old set-role couldn't (admin)", async () => {
|
|
1095
|
-
fetchSpy
|
|
1096
|
+
fetchSpy
|
|
1097
|
+
.mockResolvedValueOnce(
|
|
1098
|
+
jsonResponse(200, {
|
|
1099
|
+
members: [
|
|
1100
|
+
{
|
|
1101
|
+
membershipKey: "prs_alice#cmp_acme",
|
|
1102
|
+
personUid: "prs_alice",
|
|
1103
|
+
companyUid: "cmp_acme",
|
|
1104
|
+
role: "member",
|
|
1105
|
+
status: "active",
|
|
1106
|
+
personEmail: "Alice@Example.com",
|
|
1107
|
+
},
|
|
1108
|
+
],
|
|
1109
|
+
}),
|
|
1110
|
+
)
|
|
1111
|
+
.mockResolvedValueOnce(jsonResponse(200, {}));
|
|
1096
1112
|
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
1097
1113
|
|
|
1098
1114
|
await buildMembersProgram().parseAsync(
|
|
@@ -1100,14 +1116,18 @@ describe("registerMembersCommand promote", () => {
|
|
|
1100
1116
|
{ from: "user" },
|
|
1101
1117
|
);
|
|
1102
1118
|
|
|
1103
|
-
|
|
1119
|
+
expect(String(fetchSpy.mock.calls[0][0])).toMatch(
|
|
1120
|
+
/\/membership\/company\/cmp_acme$/,
|
|
1121
|
+
);
|
|
1122
|
+
const call = fetchSpy.mock.calls[1];
|
|
1104
1123
|
expect(String(call[0])).toMatch(/\/membership\/role$/);
|
|
1105
1124
|
const body = JSON.parse((call[1]?.body as string) ?? "{}");
|
|
1106
1125
|
expect(body).toEqual({
|
|
1107
1126
|
companyUid: "cmp_acme",
|
|
1108
|
-
membershipKey: "
|
|
1127
|
+
membershipKey: "prs_alice#cmp_acme",
|
|
1109
1128
|
newRole: "admin",
|
|
1110
1129
|
});
|
|
1130
|
+
expect(body.membershipKey).not.toContain("email:");
|
|
1111
1131
|
expect(logSpy).toHaveBeenCalledWith(
|
|
1112
1132
|
expect.stringContaining("Updated role for 'alice@example.com' to admin"),
|
|
1113
1133
|
);
|
|
@@ -1123,6 +1143,43 @@ describe("registerMembersCommand promote", () => {
|
|
|
1123
1143
|
);
|
|
1124
1144
|
});
|
|
1125
1145
|
|
|
1146
|
+
it("fails clearly when no active member matches an email and never posts a role change", async () => {
|
|
1147
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { members: [] }));
|
|
1148
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
1149
|
+
vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
|
1150
|
+
throw new Error(`__EXIT__:${code ?? 0}`);
|
|
1151
|
+
}) as never);
|
|
1152
|
+
|
|
1153
|
+
await expect(
|
|
1154
|
+
buildMembersProgram().parseAsync(
|
|
1155
|
+
[
|
|
1156
|
+
"members",
|
|
1157
|
+
"--company",
|
|
1158
|
+
"acme",
|
|
1159
|
+
"promote",
|
|
1160
|
+
"missing@example.com",
|
|
1161
|
+
"admin",
|
|
1162
|
+
],
|
|
1163
|
+
{ from: "user" },
|
|
1164
|
+
),
|
|
1165
|
+
).rejects.toThrow("__EXIT__:1");
|
|
1166
|
+
|
|
1167
|
+
const errorOutput = errSpy.mock.calls
|
|
1168
|
+
.flatMap((call) => call.map(String))
|
|
1169
|
+
.join(" ");
|
|
1170
|
+
expect(errorOutput).toContain(
|
|
1171
|
+
"No active member has email 'missing@example.com'",
|
|
1172
|
+
);
|
|
1173
|
+
expect(errorOutput).toContain("hq members list");
|
|
1174
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
1175
|
+
expect(
|
|
1176
|
+
fetchSpy.mock.calls.some(
|
|
1177
|
+
([url, init]) =>
|
|
1178
|
+
String(url).endsWith("/membership/role") && init?.method === "POST",
|
|
1179
|
+
),
|
|
1180
|
+
).toBe(false);
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1126
1183
|
it("forwards a personUid target and the guest role (beyond set-role's admin|member cap)", async () => {
|
|
1127
1184
|
fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
|
|
1128
1185
|
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
@@ -1189,6 +1246,141 @@ describe("registerMembersCommand promote", () => {
|
|
|
1189
1246
|
});
|
|
1190
1247
|
});
|
|
1191
1248
|
|
|
1249
|
+
describe("resolveRoleChangeTarget", () => {
|
|
1250
|
+
it("passes through a full membership key without fetching members", async () => {
|
|
1251
|
+
await expect(
|
|
1252
|
+
resolveRoleChangeTarget(
|
|
1253
|
+
"test-token",
|
|
1254
|
+
"cmp_acme",
|
|
1255
|
+
"prs_alice#cmp_acme",
|
|
1256
|
+
),
|
|
1257
|
+
).resolves.toBe("prs_alice#cmp_acme");
|
|
1258
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
it("wraps bare person and agent uids without fetching members", async () => {
|
|
1262
|
+
await expect(
|
|
1263
|
+
resolveRoleChangeTarget("test-token", "cmp_acme", "prs_alice"),
|
|
1264
|
+
).resolves.toBe("prs_alice#cmp_acme");
|
|
1265
|
+
await expect(
|
|
1266
|
+
resolveRoleChangeTarget("test-token", "cmp_acme", "agt_ops"),
|
|
1267
|
+
).resolves.toBe("agt_ops#cmp_acme");
|
|
1268
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
it("maps agent machine emails to agt_…#companyUid without fetching members", async () => {
|
|
1272
|
+
await expect(
|
|
1273
|
+
resolveRoleChangeTarget(
|
|
1274
|
+
"test-token",
|
|
1275
|
+
"cmp_acme",
|
|
1276
|
+
"agt-01hxyzabcdefghjkmnpqrstvwx@agents.getindigo.ai",
|
|
1277
|
+
),
|
|
1278
|
+
).resolves.toBe("agt_01HXYZABCDEFGHJKMNPQRSTVWX#cmp_acme");
|
|
1279
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
1280
|
+
});
|
|
1281
|
+
});
|
|
1282
|
+
|
|
1283
|
+
// ---------------------------------------------------------------------------
|
|
1284
|
+
// registerMembersCommand invite — MEMBERSHIP_ALREADY_EXISTS messaging
|
|
1285
|
+
// ---------------------------------------------------------------------------
|
|
1286
|
+
|
|
1287
|
+
describe("registerMembersCommand invite 409 messaging", () => {
|
|
1288
|
+
function mockCallerIdentity(): void {
|
|
1289
|
+
fetchSpy.mockResolvedValueOnce(
|
|
1290
|
+
jsonResponse(200, {
|
|
1291
|
+
memberships: [
|
|
1292
|
+
{
|
|
1293
|
+
membershipKey: "prs_admin#cmp_acme",
|
|
1294
|
+
personUid: "prs_admin",
|
|
1295
|
+
companyUid: "cmp_acme",
|
|
1296
|
+
role: "owner",
|
|
1297
|
+
status: "active",
|
|
1298
|
+
},
|
|
1299
|
+
],
|
|
1300
|
+
}),
|
|
1301
|
+
);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
it("guides --resend when the conflict is a pending invite", async () => {
|
|
1305
|
+
mockCallerIdentity();
|
|
1306
|
+
fetchSpy
|
|
1307
|
+
.mockResolvedValueOnce(
|
|
1308
|
+
jsonResponse(409, {
|
|
1309
|
+
error: "Membership already exists",
|
|
1310
|
+
code: "MEMBERSHIP_ALREADY_EXISTS",
|
|
1311
|
+
}),
|
|
1312
|
+
)
|
|
1313
|
+
.mockResolvedValueOnce(
|
|
1314
|
+
jsonResponse(200, {
|
|
1315
|
+
pending: [
|
|
1316
|
+
{
|
|
1317
|
+
membershipKey: "email:alice@example.com#cmp_acme",
|
|
1318
|
+
inviteeEmail: "alice@example.com",
|
|
1319
|
+
companyUid: "cmp_acme",
|
|
1320
|
+
role: "member",
|
|
1321
|
+
status: "pending",
|
|
1322
|
+
invitedBy: "prs_admin",
|
|
1323
|
+
invitedAt: "2026-05-21T12:00:00Z",
|
|
1324
|
+
},
|
|
1325
|
+
],
|
|
1326
|
+
}),
|
|
1327
|
+
);
|
|
1328
|
+
|
|
1329
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
1330
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
1331
|
+
|
|
1332
|
+
await buildMembersProgram().parseAsync(
|
|
1333
|
+
["members", "--company", "acme", "invite", "Alice@Example.com"],
|
|
1334
|
+
{ from: "user" },
|
|
1335
|
+
);
|
|
1336
|
+
|
|
1337
|
+
const output = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
1338
|
+
expect(output).toContain("already has a pending invite");
|
|
1339
|
+
expect(output).toContain(
|
|
1340
|
+
"hq members invite alice@example.com --company acme --resend",
|
|
1341
|
+
);
|
|
1342
|
+
expect(output).not.toContain("already a member of this company");
|
|
1343
|
+
expect(errSpy).not.toHaveBeenCalled();
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
it("keeps the already-member note when the conflict is an active member", async () => {
|
|
1347
|
+
mockCallerIdentity();
|
|
1348
|
+
fetchSpy
|
|
1349
|
+
.mockResolvedValueOnce(
|
|
1350
|
+
jsonResponse(409, {
|
|
1351
|
+
error: "Membership already exists",
|
|
1352
|
+
code: "MEMBERSHIP_ALREADY_EXISTS",
|
|
1353
|
+
}),
|
|
1354
|
+
)
|
|
1355
|
+
.mockResolvedValueOnce(jsonResponse(200, { pending: [] }))
|
|
1356
|
+
.mockResolvedValueOnce(
|
|
1357
|
+
jsonResponse(200, {
|
|
1358
|
+
members: [
|
|
1359
|
+
{
|
|
1360
|
+
membershipKey: "prs_alice#cmp_acme",
|
|
1361
|
+
personUid: "prs_alice",
|
|
1362
|
+
companyUid: "cmp_acme",
|
|
1363
|
+
role: "member",
|
|
1364
|
+
status: "active",
|
|
1365
|
+
personEmail: "alice@example.com",
|
|
1366
|
+
},
|
|
1367
|
+
],
|
|
1368
|
+
}),
|
|
1369
|
+
);
|
|
1370
|
+
|
|
1371
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
1372
|
+
|
|
1373
|
+
await buildMembersProgram().parseAsync(
|
|
1374
|
+
["members", "--company", "acme", "invite", "alice@example.com"],
|
|
1375
|
+
{ from: "user" },
|
|
1376
|
+
);
|
|
1377
|
+
|
|
1378
|
+
const output = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
1379
|
+
expect(output).toContain("already a member of this company");
|
|
1380
|
+
expect(output).not.toContain("pending invite");
|
|
1381
|
+
});
|
|
1382
|
+
});
|
|
1383
|
+
|
|
1192
1384
|
// ---------------------------------------------------------------------------
|
|
1193
1385
|
// resolveRevokeTargetToMembershipKey
|
|
1194
1386
|
// ---------------------------------------------------------------------------
|
package/src/commands/members.ts
CHANGED
|
@@ -471,6 +471,52 @@ export async function listActiveMembers(
|
|
|
471
471
|
return data?.members ?? [];
|
|
472
472
|
}
|
|
473
473
|
|
|
474
|
+
/**
|
|
475
|
+
* Resolve a role-change target to an active membership key.
|
|
476
|
+
*
|
|
477
|
+
* Pending invites are email-keyed, but claimed active memberships are keyed by
|
|
478
|
+
* personUid. Consequently, role changes must look up email targets in the
|
|
479
|
+
* active member list instead of synthesizing an `email:...#company` key.
|
|
480
|
+
*/
|
|
481
|
+
export async function resolveRoleChangeTarget(
|
|
482
|
+
token: string,
|
|
483
|
+
companyUid: string,
|
|
484
|
+
target: string,
|
|
485
|
+
): Promise<string> {
|
|
486
|
+
if (target.includes("#")) return target;
|
|
487
|
+
|
|
488
|
+
const detected = detectTarget(target);
|
|
489
|
+
if (detected?.type === "person" || detected?.type === "agent") {
|
|
490
|
+
return `${detected.value}#${companyUid}`;
|
|
491
|
+
}
|
|
492
|
+
if (detected?.type === "email") {
|
|
493
|
+
// Active fleet-agent guest memberships are keyed agt_…#cmp_…, not by
|
|
494
|
+
// personEmail enrichment. Map machine emails the same way revoke does
|
|
495
|
+
// before falling through to the human roster lookup.
|
|
496
|
+
if (detected.isAgent) {
|
|
497
|
+
const local = detected.value.split("@")[0] ?? "";
|
|
498
|
+
const m = local.match(/^agt-(.+)$/i);
|
|
499
|
+
if (m) return `agt_${m[1].toUpperCase()}#${companyUid}`;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const members = await listActiveMembers(token, companyUid);
|
|
503
|
+
const member = members.find(
|
|
504
|
+
(candidate) =>
|
|
505
|
+
candidate.personEmail?.trim().toLowerCase() === detected.value,
|
|
506
|
+
);
|
|
507
|
+
if (member) return member.membershipKey;
|
|
508
|
+
|
|
509
|
+
throw new Error(
|
|
510
|
+
`No active member has email '${detected.value}' in this company. ` +
|
|
511
|
+
"Run `hq members list` to find the member, or use their prs_ personUid.",
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
throw new Error(
|
|
516
|
+
`Invalid target '${target}': use an email, prs_ personUid, agt_ agentUid, or full membership key`,
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
474
520
|
/**
|
|
475
521
|
* Resolve a `revoke` CLI argument into the canonical `membershipKey` shape
|
|
476
522
|
* the server requires. Accepts three input forms:
|
|
@@ -631,9 +677,10 @@ export function registerMembersCommand(program: Command): void {
|
|
|
631
677
|
const token = await ensureCognitoToken();
|
|
632
678
|
const companySlug = members.opts().company as string | undefined;
|
|
633
679
|
const companyUid = await getCompanyUid(token, companySlug);
|
|
634
|
-
const membershipKey =
|
|
635
|
-
|
|
680
|
+
const membershipKey = await resolveRoleChangeTarget(
|
|
681
|
+
token,
|
|
636
682
|
companyUid,
|
|
683
|
+
target,
|
|
637
684
|
);
|
|
638
685
|
|
|
639
686
|
await changeMemberRole(token, companyUid, membershipKey, role as Role);
|
|
@@ -708,10 +755,13 @@ export function registerMembersCommand(program: Command): void {
|
|
|
708
755
|
resend?: boolean;
|
|
709
756
|
},
|
|
710
757
|
) => {
|
|
758
|
+
let token: string | undefined;
|
|
759
|
+
let companyUid: string | undefined;
|
|
760
|
+
let companySlug: string | undefined;
|
|
711
761
|
try {
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
762
|
+
token = await ensureCognitoToken();
|
|
763
|
+
companySlug = members.opts().company as string | undefined;
|
|
764
|
+
companyUid = await getCompanyUid(token, companySlug);
|
|
715
765
|
const callerUid = await getCallerPersonUid(token);
|
|
716
766
|
|
|
717
767
|
// --resend short-circuits to the server's re-fire path. No new row.
|
|
@@ -887,15 +937,79 @@ export function registerMembersCommand(program: Command): void {
|
|
|
887
937
|
);
|
|
888
938
|
}
|
|
889
939
|
} catch (err) {
|
|
890
|
-
//
|
|
891
|
-
//
|
|
892
|
-
//
|
|
893
|
-
//
|
|
894
|
-
// and a bulk-invite script shouldn't abort on an already-member. HQ-11.
|
|
940
|
+
// A 409 may refer to either an active membership or an existing
|
|
941
|
+
// pending invite. Probe both views so email targets get actionable
|
|
942
|
+
// guidance while preserving the terminal exit-0 behavior for bulk
|
|
943
|
+
// invite scripts.
|
|
895
944
|
if (
|
|
896
945
|
err instanceof InviteHttpError &&
|
|
897
946
|
err.code === "MEMBERSHIP_ALREADY_EXISTS"
|
|
898
947
|
) {
|
|
948
|
+
const detected = detectTarget(target);
|
|
949
|
+
const normalizedEmail =
|
|
950
|
+
detected?.type === "email" ? detected.value : undefined;
|
|
951
|
+
|
|
952
|
+
if (normalizedEmail && token && companyUid) {
|
|
953
|
+
// Preserve --company in copy-paste hints so multi-company
|
|
954
|
+
// callers don't hit "Multiple active companies" on --resend.
|
|
955
|
+
const companyFlag = companySlug
|
|
956
|
+
? ` --company ${companySlug}`
|
|
957
|
+
: "";
|
|
958
|
+
const resendHint =
|
|
959
|
+
`hq members invite ${normalizedEmail}${companyFlag} --resend`;
|
|
960
|
+
|
|
961
|
+
try {
|
|
962
|
+
const pending = await listPendingInvites(token, companyUid);
|
|
963
|
+
if (
|
|
964
|
+
pending.some(
|
|
965
|
+
(invite) =>
|
|
966
|
+
invite.inviteeEmail?.trim().toLowerCase() ===
|
|
967
|
+
normalizedEmail,
|
|
968
|
+
)
|
|
969
|
+
) {
|
|
970
|
+
console.log(
|
|
971
|
+
chalk.yellow(
|
|
972
|
+
`${normalizedEmail} already has a pending invite. ` +
|
|
973
|
+
`Use \`${resendHint}\` to re-send it.`,
|
|
974
|
+
),
|
|
975
|
+
);
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
} catch {
|
|
979
|
+
// Pending-list visibility can vary by server version or role.
|
|
980
|
+
// Fall through to the active-member probe and generic copy.
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
try {
|
|
984
|
+
const activeMembers = await listActiveMembers(token, companyUid);
|
|
985
|
+
if (
|
|
986
|
+
activeMembers.some(
|
|
987
|
+
(member) =>
|
|
988
|
+
member.personEmail?.trim().toLowerCase() ===
|
|
989
|
+
normalizedEmail,
|
|
990
|
+
)
|
|
991
|
+
) {
|
|
992
|
+
console.log(
|
|
993
|
+
chalk.yellow(
|
|
994
|
+
`${normalizedEmail} is already a member of this company — nothing to do.`,
|
|
995
|
+
),
|
|
996
|
+
);
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
} catch {
|
|
1000
|
+
// Preserve the original conflict as the source of truth and
|
|
1001
|
+
// use copy that remains accurate when the probes are hidden.
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
console.log(
|
|
1005
|
+
chalk.yellow(
|
|
1006
|
+
`${normalizedEmail} already has a membership or pending invite for this company. ` +
|
|
1007
|
+
`If the invite is pending, use \`${resendHint}\`.`,
|
|
1008
|
+
),
|
|
1009
|
+
);
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
899
1013
|
console.log(
|
|
900
1014
|
chalk.yellow(
|
|
901
1015
|
`${target.toLowerCase()} is already a member of this company — nothing to do.`,
|