@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 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:
@@ -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 = resolveRevokeTargetToMembershipKey(target, companyUid);
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
- const token = await ensureCognitoToken();
393
- const companySlug = members.opts().company;
394
- const companyUid = await getCompanyUid(token, companySlug);
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
- // The target is ALREADY an active member (409 MEMBERSHIP_ALREADY_EXISTS).
515
- // Terminal, not a failure: the person is already in the company, so
516
- // there's nothing to invite or retry. Print a plain note and exit 0 —
517
- // re-attempting just re-fires the server's (correctly captured) 409,
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.9",
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.19",
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",
@@ -1,4 +1,4 @@
1
1
  allowBuilds:
2
2
  better-sqlite3: true
3
3
  minimumReleaseAgeExclude:
4
- - '@indigoai-us/hq-cloud@6.14.14 || 6.14.18 || 6.14.19'
4
+ - '@indigoai-us/hq-cloud@6.14.14 || 6.14.18 || 6.14.19 || 6.14.27'
@@ -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.mockResolvedValueOnce(jsonResponse(200, {}));
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
- const call = fetchSpy.mock.calls[0];
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: "email:alice@example.com#cmp_acme",
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
  // ---------------------------------------------------------------------------
@@ -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 = resolveRevokeTargetToMembershipKey(
635
- target,
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
- const token = await ensureCognitoToken();
713
- const companySlug = members.opts().company as string | undefined;
714
- const companyUid = await getCompanyUid(token, companySlug);
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
- // The target is ALREADY an active member (409 MEMBERSHIP_ALREADY_EXISTS).
891
- // Terminal, not a failure: the person is already in the company, so
892
- // there's nothing to invite or retry. Print a plain note and exit 0 —
893
- // re-attempting just re-fires the server's (correctly captured) 409,
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.`,