@indigoai-us/hq-cli 5.57.0 → 5.58.1

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.
@@ -20,10 +20,13 @@ import {
20
20
  type PersonRecord,
21
21
  } from "../utils/people.js";
22
22
  import {
23
+ activeMemberToPersonRecord,
24
+ mergePeople,
23
25
  registerPeopleCommand,
24
26
  resolveCompanySlug,
25
- type RefreshPeopleRoster,
27
+ type FetchPeopleRoster,
26
28
  } from "./people.js";
29
+ import type { ActiveMember } from "./members.js";
27
30
 
28
31
  // ---------------------------------------------------------------------------
29
32
  // Filesystem fixture helpers
@@ -335,11 +338,119 @@ describe("companyPeopleDir", () => {
335
338
  });
336
339
  });
337
340
 
341
+ // ---------------------------------------------------------------------------
342
+ // Membership roster fallback helpers
343
+ // ---------------------------------------------------------------------------
344
+
345
+ function rosterPerson(overrides: Partial<PersonRecord>): PersonRecord {
346
+ return {
347
+ slug: "ada-lovelace",
348
+ name: "Ada Lovelace",
349
+ email: "ada@acme.com",
350
+ type: "internal",
351
+ source: "test roster",
352
+ ...overrides,
353
+ };
354
+ }
355
+
356
+ describe("activeMemberToPersonRecord", () => {
357
+ const base: ActiveMember = {
358
+ membershipKey: "m_1",
359
+ personUid: "prs_123",
360
+ companyUid: "co_123",
361
+ role: "admin",
362
+ status: "active",
363
+ };
364
+
365
+ it("maps membership person fields into an internal person record", () => {
366
+ expect(
367
+ activeMemberToPersonRecord({
368
+ ...base,
369
+ personName: " Ada Lovelace ",
370
+ personEmail: " ada@acme.com ",
371
+ personSlug: " ada ",
372
+ }),
373
+ ).toEqual({
374
+ slug: "ada",
375
+ name: "Ada Lovelace",
376
+ email: "ada@acme.com",
377
+ role: "admin",
378
+ type: "internal",
379
+ source: "hq-pro membership: /membership/company/co_123",
380
+ });
381
+ });
382
+
383
+ it("falls back from name to email to slug, and slugifies names", () => {
384
+ expect(
385
+ activeMemberToPersonRecord({
386
+ ...base,
387
+ personName: "Grace Hopper",
388
+ personEmail: "grace@acme.com",
389
+ }),
390
+ ).toMatchObject({ name: "Grace Hopper", slug: "grace-hopper" });
391
+
392
+ expect(
393
+ activeMemberToPersonRecord({
394
+ ...base,
395
+ personEmail: "only-email@acme.com",
396
+ }),
397
+ ).toMatchObject({ name: "only-email@acme.com", slug: "only-email-acme-com" });
398
+
399
+ expect(
400
+ activeMemberToPersonRecord({
401
+ ...base,
402
+ personSlug: "only-slug",
403
+ }),
404
+ ).toMatchObject({ name: "only-slug", slug: "only-slug" });
405
+ });
406
+
407
+ it("returns null when all name-like fields are empty", () => {
408
+ expect(
409
+ activeMemberToPersonRecord({
410
+ ...base,
411
+ personName: " ",
412
+ personEmail: "",
413
+ personSlug: " ",
414
+ }),
415
+ ).toBeNull();
416
+ });
417
+ });
418
+
419
+ describe("mergePeople", () => {
420
+ it("deduplicates by email, slug, then name with primary records winning", () => {
421
+ const primary = [
422
+ rosterPerson({
423
+ slug: "local-email",
424
+ name: "Local Email",
425
+ email: "same@acme.com",
426
+ role: "CEO",
427
+ }),
428
+ rosterPerson({ slug: "same-slug", name: "Local Slug", email: undefined }),
429
+ rosterPerson({ slug: "", name: "Same Name", email: undefined }),
430
+ ];
431
+ const extra = [
432
+ rosterPerson({
433
+ slug: "remote-email",
434
+ name: "Remote Email",
435
+ email: "SAME@acme.com",
436
+ }),
437
+ rosterPerson({ slug: "SAME-SLUG", name: "Remote Slug", email: undefined }),
438
+ rosterPerson({ slug: "", name: "same name", email: undefined }),
439
+ rosterPerson({ slug: "new-person", name: "New Person", email: undefined }),
440
+ ];
441
+
442
+ expect(mergePeople(primary, extra)).toEqual([
443
+ ...primary,
444
+ rosterPerson({ slug: "new-person", name: "New Person", email: undefined }),
445
+ ]);
446
+ });
447
+ });
448
+
338
449
  // ---------------------------------------------------------------------------
339
450
  // Wired CLI surface (list / search / resolve)
340
451
  // ---------------------------------------------------------------------------
341
452
 
342
- function buildProgram(deps?: { refreshRoster?: RefreshPeopleRoster }): Command {
453
+ function buildProgram(deps?: { fetchRoster?: FetchPeopleRoster }): Command {
343
454
  const program = new Command();
344
455
  program.exitOverride(); // throw instead of process.exit on commander errors
345
456
  registerPeopleCommand(program, deps);
@@ -348,8 +459,8 @@ function buildProgram(deps?: { refreshRoster?: RefreshPeopleRoster }): Command {
348
459
 
349
460
  async function run(
350
461
  args: string[],
351
- deps: { refreshRoster?: RefreshPeopleRoster } = {
352
- refreshRoster: async () => {},
462
+ deps: { fetchRoster?: FetchPeopleRoster } = {
463
+ fetchRoster: async () => [],
353
464
  },
354
465
  ): Promise<void> {
355
466
  await buildProgram(deps).parseAsync(["node", "hq", ...args]);
@@ -389,6 +500,68 @@ describe("hq people (wired)", () => {
389
500
  ]);
390
501
  });
391
502
 
503
+ it("list includes roster people, sorts them, and deduplicates with local winning", async () => {
504
+ const fetchRoster = vi.fn<FetchPeopleRoster>(async () => [
505
+ rosterPerson({
506
+ slug: "remote-jane",
507
+ name: "Remote Jane",
508
+ email: "JANE@acme.com",
509
+ role: "member",
510
+ }),
511
+ rosterPerson({
512
+ slug: "ada-lovelace",
513
+ name: "Ada Lovelace",
514
+ email: "ada@acme.com",
515
+ role: "admin",
516
+ }),
517
+ ]);
518
+
519
+ await run(
520
+ ["people", "list", "--company", "acme", "--hq-root", tmpRoot, "--json"],
521
+ { fetchRoster },
522
+ );
523
+
524
+ expect(fetchRoster).toHaveBeenCalledTimes(1);
525
+ expect(fetchRoster).toHaveBeenCalledWith(tmpRoot, "acme");
526
+ const out = JSON.parse(logSpy.mock.calls[0][0] as string);
527
+ expect(out.map((p: PersonRecord) => p.name)).toEqual([
528
+ "Ada Lovelace",
529
+ "Jane Smith",
530
+ "John Doe",
531
+ ]);
532
+ expect(out.find((p: PersonRecord) => p.email === "jane@acme.com")).toMatchObject({
533
+ name: "Jane Smith",
534
+ role: "CEO",
535
+ });
536
+ });
537
+
538
+ it("list --local-only skips roster fetch", async () => {
539
+ const fetchRoster = vi.fn<FetchPeopleRoster>(async () => [
540
+ rosterPerson({ slug: "ada-lovelace", name: "Ada Lovelace" }),
541
+ ]);
542
+
543
+ await run(
544
+ [
545
+ "people",
546
+ "list",
547
+ "--company",
548
+ "acme",
549
+ "--hq-root",
550
+ tmpRoot,
551
+ "--json",
552
+ "--local-only",
553
+ ],
554
+ { fetchRoster },
555
+ );
556
+
557
+ expect(fetchRoster).not.toHaveBeenCalled();
558
+ const out = JSON.parse(logSpy.mock.calls[0][0] as string);
559
+ expect(out.map((p: PersonRecord) => p.name).sort()).toEqual([
560
+ "Jane Smith",
561
+ "John Doe",
562
+ ]);
563
+ });
564
+
392
565
  it("search narrows to the matching person", async () => {
393
566
  await run([
394
567
  "people",
@@ -405,8 +578,8 @@ describe("hq people (wired)", () => {
405
578
  expect(out[0].email).toBe("jane@acme.com");
406
579
  });
407
580
 
408
- it("search local hit does not refresh the roster", async () => {
409
- const refreshRoster = vi.fn<RefreshPeopleRoster>();
581
+ it("search local hit does not fetch the roster", async () => {
582
+ const fetchRoster = vi.fn<FetchPeopleRoster>();
410
583
  await run(
411
584
  [
412
585
  "people",
@@ -418,19 +591,15 @@ describe("hq people (wired)", () => {
418
591
  tmpRoot,
419
592
  "--json",
420
593
  ],
421
- { refreshRoster },
594
+ { fetchRoster },
422
595
  );
423
- expect(refreshRoster).not.toHaveBeenCalled();
596
+ expect(fetchRoster).not.toHaveBeenCalled();
424
597
  });
425
598
 
426
- it("search local miss refreshes once and retries from the refreshed roster", async () => {
427
- const refreshRoster = vi.fn<RefreshPeopleRoster>(async () => {
428
- makePerson("acme", "ada-lovelace", {
429
- name: "Ada Lovelace",
430
- email: "ada@acme.com",
431
- type: "internal",
432
- });
433
- });
599
+ it("search finds a teammate from the roster on a local miss", async () => {
600
+ const fetchRoster = vi.fn<FetchPeopleRoster>(async () => [
601
+ rosterPerson({ slug: "ada-lovelace", name: "Ada Lovelace" }),
602
+ ]);
434
603
 
435
604
  await run(
436
605
  [
@@ -443,11 +612,11 @@ describe("hq people (wired)", () => {
443
612
  tmpRoot,
444
613
  "--json",
445
614
  ],
446
- { refreshRoster },
615
+ { fetchRoster },
447
616
  );
448
617
 
449
- expect(refreshRoster).toHaveBeenCalledTimes(1);
450
- expect(refreshRoster).toHaveBeenCalledWith(tmpRoot, "acme");
618
+ expect(fetchRoster).toHaveBeenCalledTimes(1);
619
+ expect(fetchRoster).toHaveBeenCalledWith(tmpRoot, "acme");
451
620
  const out = JSON.parse(logSpy.mock.calls[0][0] as string);
452
621
  expect(out).toHaveLength(1);
453
622
  expect(out[0]).toMatchObject({
@@ -456,8 +625,8 @@ describe("hq people (wired)", () => {
456
625
  });
457
626
  });
458
627
 
459
- it("search local miss with refresh failure reports the original empty JSON and logs a note", async () => {
460
- const refreshRoster = vi.fn<RefreshPeopleRoster>(async () => {
628
+ it("search local miss with fetch failure reports the original empty JSON and logs a note", async () => {
629
+ const fetchRoster = vi.fn<FetchPeopleRoster>(async () => {
461
630
  throw new Error("offline");
462
631
  });
463
632
 
@@ -472,18 +641,18 @@ describe("hq people (wired)", () => {
472
641
  tmpRoot,
473
642
  "--json",
474
643
  ],
475
- { refreshRoster },
644
+ { fetchRoster },
476
645
  );
477
646
 
478
- expect(refreshRoster).toHaveBeenCalledTimes(1);
647
+ expect(fetchRoster).toHaveBeenCalledTimes(1);
479
648
  expect(JSON.parse(logSpy.mock.calls[0][0] as string)).toEqual([]);
480
649
  expect(errSpy).toHaveBeenCalledWith(
481
- expect.stringContaining("Could not refresh people roster"),
650
+ expect.stringContaining("Could not fetch people roster"),
482
651
  );
483
652
  });
484
653
 
485
- it("search --local-only skips refresh on a miss", async () => {
486
- const refreshRoster = vi.fn<RefreshPeopleRoster>();
654
+ it("search --local-only skips fetch on a miss", async () => {
655
+ const fetchRoster = vi.fn<FetchPeopleRoster>();
487
656
  await run(
488
657
  [
489
658
  "people",
@@ -496,10 +665,10 @@ describe("hq people (wired)", () => {
496
665
  "--json",
497
666
  "--local-only",
498
667
  ],
499
- { refreshRoster },
668
+ { fetchRoster },
500
669
  );
501
670
 
502
- expect(refreshRoster).not.toHaveBeenCalled();
671
+ expect(fetchRoster).not.toHaveBeenCalled();
503
672
  expect(JSON.parse(logSpy.mock.calls[0][0] as string)).toEqual([]);
504
673
  });
505
674
 
@@ -516,8 +685,8 @@ describe("hq people (wired)", () => {
516
685
  expect(logSpy).toHaveBeenCalledWith("jane@acme.com");
517
686
  });
518
687
 
519
- it("resolve local hit does not refresh the roster", async () => {
520
- const refreshRoster = vi.fn<RefreshPeopleRoster>();
688
+ it("resolve local hit does not fetch the roster", async () => {
689
+ const fetchRoster = vi.fn<FetchPeopleRoster>();
521
690
  await run(
522
691
  [
523
692
  "people",
@@ -528,19 +697,15 @@ describe("hq people (wired)", () => {
528
697
  "--hq-root",
529
698
  tmpRoot,
530
699
  ],
531
- { refreshRoster },
700
+ { fetchRoster },
532
701
  );
533
- expect(refreshRoster).not.toHaveBeenCalled();
702
+ expect(fetchRoster).not.toHaveBeenCalled();
534
703
  });
535
704
 
536
- it("resolve local miss refreshes once and retries from the refreshed roster", async () => {
537
- const refreshRoster = vi.fn<RefreshPeopleRoster>(async () => {
538
- makePerson("acme", "ada-lovelace", {
539
- name: "Ada Lovelace",
540
- email: "ada@acme.com",
541
- type: "internal",
542
- });
543
- });
705
+ it("resolve finds a teammate that exists only in the fetched roster", async () => {
706
+ const fetchRoster = vi.fn<FetchPeopleRoster>(async () => [
707
+ rosterPerson({ slug: "ada-lovelace", name: "Ada Lovelace" }),
708
+ ]);
544
709
 
545
710
  await run(
546
711
  [
@@ -552,16 +717,16 @@ describe("hq people (wired)", () => {
552
717
  "--hq-root",
553
718
  tmpRoot,
554
719
  ],
555
- { refreshRoster },
720
+ { fetchRoster },
556
721
  );
557
722
 
558
- expect(refreshRoster).toHaveBeenCalledTimes(1);
559
- expect(refreshRoster).toHaveBeenCalledWith(tmpRoot, "acme");
723
+ expect(fetchRoster).toHaveBeenCalledTimes(1);
724
+ expect(fetchRoster).toHaveBeenCalledWith(tmpRoot, "acme");
560
725
  expect(logSpy).toHaveBeenCalledWith("ada@acme.com");
561
726
  });
562
727
 
563
- it("resolve local miss with refresh failure reports unchanged not_found JSON and logs a note", async () => {
564
- const refreshRoster = vi.fn<RefreshPeopleRoster>(async () => {
728
+ it("resolve local miss with fetch failure reports unchanged not_found JSON and logs a note", async () => {
729
+ const fetchRoster = vi.fn<FetchPeopleRoster>(async () => {
565
730
  throw new Error("not signed in");
566
731
  });
567
732
 
@@ -577,22 +742,22 @@ describe("hq people (wired)", () => {
577
742
  tmpRoot,
578
743
  "--json",
579
744
  ],
580
- { refreshRoster },
745
+ { fetchRoster },
581
746
  ),
582
747
  ).rejects.toThrow("__exit__");
583
748
 
584
- expect(refreshRoster).toHaveBeenCalledTimes(1);
749
+ expect(fetchRoster).toHaveBeenCalledTimes(1);
585
750
  expect(JSON.parse(logSpy.mock.calls[0][0] as string)).toEqual({
586
751
  status: "not_found",
587
752
  });
588
753
  expect(errSpy).toHaveBeenCalledWith(
589
- expect.stringContaining("Could not refresh people roster"),
754
+ expect.stringContaining("Could not fetch people roster"),
590
755
  );
591
756
  expect(exitSpy).toHaveBeenCalledWith(1);
592
757
  });
593
758
 
594
- it("resolve --local-only skips refresh on a miss", async () => {
595
- const refreshRoster = vi.fn<RefreshPeopleRoster>();
759
+ it("resolve --local-only skips fetch on a miss", async () => {
760
+ const fetchRoster = vi.fn<FetchPeopleRoster>();
596
761
  await expect(
597
762
  run(
598
763
  [
@@ -606,11 +771,11 @@ describe("hq people (wired)", () => {
606
771
  "--json",
607
772
  "--local-only",
608
773
  ],
609
- { refreshRoster },
774
+ { fetchRoster },
610
775
  ),
611
776
  ).rejects.toThrow("__exit__");
612
777
 
613
- expect(refreshRoster).not.toHaveBeenCalled();
778
+ expect(fetchRoster).not.toHaveBeenCalled();
614
779
  expect(JSON.parse(logSpy.mock.calls[0][0] as string)).toEqual({
615
780
  status: "not_found",
616
781
  });