@indigoai-us/hq-cli 5.58.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.
@@ -6,21 +6,25 @@
6
6
  * hq people search <keyword> [--company <slug>] [--json]
7
7
  * hq people resolve <name> [--company <slug>] [--json]
8
8
  *
9
- * Source of truth is `companies/<company>/people/<person>/meta.yaml`. Every
10
- * subcommand operates on exactly ONE company (the active company, or the one
11
- * named by `--company`); nothing reads across company boundaries.
9
+ * Local records from `companies/<company>/people/<person>/meta.yaml` are the
10
+ * curated primary source. On misses, commands may fall back to the membership
11
+ * roster for exactly ONE company (the active company, or the one named by
12
+ * `--company`); nothing reads across company boundaries.
12
13
  */
13
14
  import { Command } from "commander";
15
+ import { type ActiveMember } from "./members.js";
14
16
  import { resolveNameToEmail, type PersonRecord } from "../utils/people.js";
15
- export type RefreshPeopleRoster = (hqRoot: string, companySlug: string) => Promise<void>;
17
+ export type FetchPeopleRoster = (hqRoot: string, companySlug: string) => Promise<PersonRecord[]>;
16
18
  interface PeopleCommandDeps {
17
- refreshRoster?: RefreshPeopleRoster;
19
+ fetchRoster?: FetchPeopleRoster;
18
20
  }
19
21
  interface PeopleLookupOpts {
20
22
  localOnly?: boolean;
21
23
  json?: boolean;
22
24
  }
23
- export declare function refreshPeopleRosterFromCloud(hqRoot: string, companySlug: string): Promise<void>;
25
+ export declare function activeMemberToPersonRecord(m: ActiveMember): PersonRecord | null;
26
+ export declare function fetchMembershipRoster(hqRoot: string, companySlug: string): Promise<PersonRecord[]>;
27
+ export declare function mergePeople(primary: PersonRecord[], extra: PersonRecord[]): PersonRecord[];
24
28
  /**
25
29
  * Resolve the single company to operate on. Explicit `--company` always wins
26
30
  * (after a path-safety check). Otherwise the active company is inferred from
@@ -33,14 +37,14 @@ export declare function resolvePersonWithRosterFallback(input: {
33
37
  slug: string;
34
38
  name: string;
35
39
  opts?: PeopleLookupOpts;
36
- refreshRoster?: RefreshPeopleRoster;
40
+ fetchRoster?: FetchPeopleRoster;
37
41
  }): Promise<ReturnType<typeof resolveNameToEmail>>;
38
42
  export declare function searchPeopleWithRosterFallback(input: {
39
43
  hqRoot: string;
40
44
  slug: string;
41
45
  keyword: string;
42
46
  opts?: PeopleLookupOpts;
43
- refreshRoster?: RefreshPeopleRoster;
47
+ fetchRoster?: FetchPeopleRoster;
44
48
  }): Promise<PersonRecord[]>;
45
49
  export declare function registerPeopleCommand(program: Command, deps?: PeopleCommandDeps): void;
46
50
  export {};
@@ -6,35 +6,73 @@
6
6
  * hq people search <keyword> [--company <slug>] [--json]
7
7
  * hq people resolve <name> [--company <slug>] [--json]
8
8
  *
9
- * Source of truth is `companies/<company>/people/<person>/meta.yaml`. Every
10
- * subcommand operates on exactly ONE company (the active company, or the one
11
- * named by `--company`); nothing reads across company boundaries.
9
+ * Local records from `companies/<company>/people/<person>/meta.yaml` are the
10
+ * curated primary source. On misses, commands may fall back to the membership
11
+ * roster for exactly ONE company (the active company, or the one named by
12
+ * `--company`); nothing reads across company boundaries.
12
13
  */
13
14
 
14
- !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]="79abc1f5-4f5a-5c95-b29b-10f04417355f")}catch(e){}}();
15
+ !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]="555055d9-98e2-5fcd-b736-4b1df12daf4d")}catch(e){}}();
15
16
  import * as fs from "fs";
16
17
  import { Option } from "commander";
17
18
  import chalk from "chalk";
18
- import { VaultClient } from "@indigoai-us/hq-cloud";
19
19
  import * as yaml from "js-yaml";
20
20
  import { findHqRoot } from "../utils/manifest.js";
21
21
  import { manifestPath } from "./cloud-provision.js";
22
- import { DEFAULT_COGNITO, buildVaultConfig, ensureCognitoToken, } from "../utils/cognito-session.js";
22
+ import { ensureCognitoToken } from "../utils/cognito-session.js";
23
23
  import { getCompanyUid } from "../utils/vault-api.js";
24
- import { createCompanyPresignClient, runGet } from "./files-browse.js";
24
+ import { listActiveMembers } from "./members.js";
25
25
  import { assertSafeCompanySlug, listCompanyPeople, searchPeople, resolveNameToEmail, companyPeopleDir, } from "../utils/people.js";
26
- export async function refreshPeopleRosterFromCloud(hqRoot, companySlug) {
27
- const accessToken = await ensureCognitoToken();
28
- const client = new VaultClient(buildVaultConfig(accessToken));
29
- await getCompanyUid(accessToken, companySlug);
30
- await runGet({
31
- path: `companies/${companySlug}/people/`,
32
- hqRoot,
33
- companySlug,
34
- vaultClient: client,
35
- companyClient: ({ companyUid }) => createCompanyPresignClient({ token: accessToken, companyUid }),
36
- region: DEFAULT_COGNITO.region,
37
- });
26
+ function slugify(value) {
27
+ return value
28
+ .toLowerCase()
29
+ .replace(/[^a-z0-9]+/g, "-")
30
+ .replace(/-+/g, "-")
31
+ .replace(/^-|-$/g, "");
32
+ }
33
+ export function activeMemberToPersonRecord(m) {
34
+ const name = m.personName?.trim() ||
35
+ m.personEmail?.trim() ||
36
+ m.personSlug?.trim() ||
37
+ "";
38
+ if (!name)
39
+ return null;
40
+ const email = m.personEmail?.trim() || undefined;
41
+ const slug = m.personSlug?.trim() || slugify(name) || m.personUid;
42
+ return {
43
+ slug,
44
+ name,
45
+ email,
46
+ role: m.role || undefined,
47
+ type: "internal",
48
+ source: `hq-pro membership: /membership/company/${m.companyUid}`,
49
+ };
50
+ }
51
+ export async function fetchMembershipRoster(hqRoot, companySlug) {
52
+ void hqRoot;
53
+ const token = await ensureCognitoToken();
54
+ const companyUid = await getCompanyUid(token, companySlug);
55
+ const members = await listActiveMembers(token, companyUid);
56
+ return members
57
+ .map(activeMemberToPersonRecord)
58
+ .filter((r) => r !== null);
59
+ }
60
+ function personIdentityKey(person) {
61
+ return (person.email?.toLowerCase() ||
62
+ person.slug?.toLowerCase() ||
63
+ person.name.toLowerCase());
64
+ }
65
+ export function mergePeople(primary, extra) {
66
+ const seen = new Set(primary.map(personIdentityKey));
67
+ const merged = [...primary];
68
+ for (const person of extra) {
69
+ const key = personIdentityKey(person);
70
+ if (seen.has(key))
71
+ continue;
72
+ seen.add(key);
73
+ merged.push(person);
74
+ }
75
+ return merged;
38
76
  }
39
77
  /** Companies that still exist (anything not explicitly `status: archived`). */
40
78
  function activeCompanySlugs(manifest) {
@@ -103,37 +141,38 @@ function fail(message) {
103
141
  console.error(chalk.red(message));
104
142
  process.exit(1);
105
143
  }
106
- function logRefreshFailure(companySlug, err) {
144
+ function logRosterFetchFailure(companySlug, err) {
107
145
  const message = err instanceof Error ? err.message : String(err);
108
- console.error(chalk.dim(` Could not refresh people roster for '${companySlug}': ${message}`));
146
+ console.error(chalk.dim(` Could not fetch people roster for '${companySlug}': ${message}`));
109
147
  }
110
- async function tryRefreshRoster(refreshRoster, hqRoot, slug) {
148
+ async function tryFetchRoster(fetchRoster, hqRoot, slug) {
111
149
  try {
112
- await refreshRoster(hqRoot, slug);
113
- return true;
150
+ return await fetchRoster(hqRoot, slug);
114
151
  }
115
152
  catch (err) {
116
- logRefreshFailure(slug, err);
117
- return false;
153
+ logRosterFetchFailure(slug, err);
154
+ return null;
118
155
  }
119
156
  }
120
157
  export async function resolvePersonWithRosterFallback(input) {
121
- const local = resolveNameToEmail(listCompanyPeople(input.hqRoot, input.slug), input.name);
158
+ const localPeople = listCompanyPeople(input.hqRoot, input.slug);
159
+ const local = resolveNameToEmail(localPeople, input.name);
122
160
  if (local.status !== "not_found" || input.opts?.localOnly)
123
161
  return local;
124
- const refreshed = await tryRefreshRoster(input.refreshRoster ?? refreshPeopleRosterFromCloud, input.hqRoot, input.slug);
125
- if (!refreshed)
162
+ const roster = await tryFetchRoster(input.fetchRoster ?? fetchMembershipRoster, input.hqRoot, input.slug);
163
+ if (!roster)
126
164
  return local;
127
- return resolveNameToEmail(listCompanyPeople(input.hqRoot, input.slug), input.name);
165
+ return resolveNameToEmail(mergePeople(localPeople, roster), input.name);
128
166
  }
129
167
  export async function searchPeopleWithRosterFallback(input) {
130
- const local = searchPeople(listCompanyPeople(input.hqRoot, input.slug), input.keyword);
168
+ const localPeople = listCompanyPeople(input.hqRoot, input.slug);
169
+ const local = searchPeople(localPeople, input.keyword);
131
170
  if (local.length > 0 || input.opts?.localOnly)
132
171
  return local;
133
- const refreshed = await tryRefreshRoster(input.refreshRoster ?? refreshPeopleRosterFromCloud, input.hqRoot, input.slug);
134
- if (!refreshed)
172
+ const roster = await tryFetchRoster(input.fetchRoster ?? fetchMembershipRoster, input.hqRoot, input.slug);
173
+ if (!roster)
135
174
  return local;
136
- return searchPeople(listCompanyPeople(input.hqRoot, input.slug), input.keyword);
175
+ return searchPeople(mergePeople(localPeople, roster), input.keyword);
137
176
  }
138
177
  export function registerPeopleCommand(program, deps = {}) {
139
178
  const people = program
@@ -147,12 +186,19 @@ export function registerPeopleCommand(program, deps = {}) {
147
186
  .command("list")
148
187
  .description("List all people recorded for the company")
149
188
  .option("--json", "Output JSON instead of a table")
150
- .action((opts) => {
189
+ .option("--local-only", "Skip network fallback; list only the local people roster")
190
+ .action(async (opts) => {
151
191
  try {
152
192
  const scope = people.opts();
153
193
  const hqRoot = resolveHqRoot(scope);
154
194
  const slug = resolveCompanySlug(hqRoot, scope.company);
155
- const records = listCompanyPeople(hqRoot, slug);
195
+ let records = listCompanyPeople(hqRoot, slug);
196
+ if (!opts.localOnly) {
197
+ const roster = await tryFetchRoster(deps.fetchRoster ?? fetchMembershipRoster, hqRoot, slug);
198
+ if (roster)
199
+ records = mergePeople(records, roster);
200
+ }
201
+ records = records.sort((a, b) => a.name.localeCompare(b.name));
156
202
  if (opts.json) {
157
203
  console.log(JSON.stringify(records, null, 2));
158
204
  return;
@@ -171,7 +217,7 @@ export function registerPeopleCommand(program, deps = {}) {
171
217
  .command("search <keyword>")
172
218
  .description("Keyword search over people names and emails")
173
219
  .option("--json", "Output JSON instead of a table")
174
- .option("--local-only", "Skip cloud fallback; search only the local people roster")
220
+ .option("--local-only", "Skip network fallback; search only the local people roster")
175
221
  .action(async (keyword, opts) => {
176
222
  try {
177
223
  const scope = people.opts();
@@ -182,7 +228,7 @@ export function registerPeopleCommand(program, deps = {}) {
182
228
  slug,
183
229
  keyword,
184
230
  opts,
185
- refreshRoster: deps.refreshRoster,
231
+ fetchRoster: deps.fetchRoster,
186
232
  });
187
233
  if (opts.json) {
188
234
  console.log(JSON.stringify(matches, null, 2));
@@ -202,7 +248,7 @@ export function registerPeopleCommand(program, deps = {}) {
202
248
  .command("resolve <name>")
203
249
  .description("Resolve a person name to their email address")
204
250
  .option("--json", "Output JSON instead of plain text")
205
- .option("--local-only", "Skip cloud fallback; resolve only from the local people roster")
251
+ .option("--local-only", "Skip network fallback; resolve only from the local people roster")
206
252
  .action(async (name, opts) => {
207
253
  try {
208
254
  const scope = people.opts();
@@ -213,7 +259,7 @@ export function registerPeopleCommand(program, deps = {}) {
213
259
  slug,
214
260
  name,
215
261
  opts,
216
- refreshRoster: deps.refreshRoster,
262
+ fetchRoster: deps.fetchRoster,
217
263
  });
218
264
  if (opts.json) {
219
265
  console.log(JSON.stringify(result, null, 2));
@@ -247,4 +293,4 @@ export function registerPeopleCommand(program, deps = {}) {
247
293
  });
248
294
  }
249
295
  //# sourceMappingURL=people.js.map
250
- //# debugId=79abc1f5-4f5a-5c95-b29b-10f04417355f
296
+ //# debugId=555055d9-98e2-5fcd-b736-4b1df12daf4d
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.58.0",
3
+ "version": "5.58.1",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -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
  });
@@ -6,25 +6,21 @@
6
6
  * hq people search <keyword> [--company <slug>] [--json]
7
7
  * hq people resolve <name> [--company <slug>] [--json]
8
8
  *
9
- * Source of truth is `companies/<company>/people/<person>/meta.yaml`. Every
10
- * subcommand operates on exactly ONE company (the active company, or the one
11
- * named by `--company`); nothing reads across company boundaries.
9
+ * Local records from `companies/<company>/people/<person>/meta.yaml` are the
10
+ * curated primary source. On misses, commands may fall back to the membership
11
+ * roster for exactly ONE company (the active company, or the one named by
12
+ * `--company`); nothing reads across company boundaries.
12
13
  */
13
14
 
14
15
  import * as fs from "fs";
15
16
  import { Command, Option } from "commander";
16
17
  import chalk from "chalk";
17
- import { VaultClient } from "@indigoai-us/hq-cloud";
18
18
  import * as yaml from "js-yaml";
19
19
  import { findHqRoot } from "../utils/manifest.js";
20
20
  import { manifestPath, type ManifestDoc } from "./cloud-provision.js";
21
- import {
22
- DEFAULT_COGNITO,
23
- buildVaultConfig,
24
- ensureCognitoToken,
25
- } from "../utils/cognito-session.js";
21
+ import { ensureCognitoToken } from "../utils/cognito-session.js";
26
22
  import { getCompanyUid } from "../utils/vault-api.js";
27
- import { createCompanyPresignClient, runGet } from "./files-browse.js";
23
+ import { listActiveMembers, type ActiveMember } from "./members.js";
28
24
  import {
29
25
  assertSafeCompanySlug,
30
26
  listCompanyPeople,
@@ -39,13 +35,13 @@ interface PeopleScopeOpts {
39
35
  hqRoot?: string;
40
36
  }
41
37
 
42
- export type RefreshPeopleRoster = (
38
+ export type FetchPeopleRoster = (
43
39
  hqRoot: string,
44
40
  companySlug: string,
45
- ) => Promise<void>;
41
+ ) => Promise<PersonRecord[]>;
46
42
 
47
43
  interface PeopleCommandDeps {
48
- refreshRoster?: RefreshPeopleRoster;
44
+ fetchRoster?: FetchPeopleRoster;
49
45
  }
50
46
 
51
47
  interface PeopleLookupOpts {
@@ -53,23 +49,71 @@ interface PeopleLookupOpts {
53
49
  json?: boolean;
54
50
  }
55
51
 
56
- export async function refreshPeopleRosterFromCloud(
52
+ function slugify(value: string): string {
53
+ return value
54
+ .toLowerCase()
55
+ .replace(/[^a-z0-9]+/g, "-")
56
+ .replace(/-+/g, "-")
57
+ .replace(/^-|-$/g, "");
58
+ }
59
+
60
+ export function activeMemberToPersonRecord(
61
+ m: ActiveMember,
62
+ ): PersonRecord | null {
63
+ const name =
64
+ m.personName?.trim() ||
65
+ m.personEmail?.trim() ||
66
+ m.personSlug?.trim() ||
67
+ "";
68
+ if (!name) return null;
69
+
70
+ const email = m.personEmail?.trim() || undefined;
71
+ const slug = m.personSlug?.trim() || slugify(name) || m.personUid;
72
+
73
+ return {
74
+ slug,
75
+ name,
76
+ email,
77
+ role: m.role || undefined,
78
+ type: "internal",
79
+ source: `hq-pro membership: /membership/company/${m.companyUid}`,
80
+ };
81
+ }
82
+
83
+ export async function fetchMembershipRoster(
57
84
  hqRoot: string,
58
85
  companySlug: string,
59
- ): Promise<void> {
60
- const accessToken = await ensureCognitoToken();
61
- const client = new VaultClient(buildVaultConfig(accessToken));
62
- await getCompanyUid(accessToken, companySlug);
63
-
64
- await runGet({
65
- path: `companies/${companySlug}/people/`,
66
- hqRoot,
67
- companySlug,
68
- vaultClient: client,
69
- companyClient: ({ companyUid }) =>
70
- createCompanyPresignClient({ token: accessToken, companyUid }),
71
- region: DEFAULT_COGNITO.region,
72
- });
86
+ ): Promise<PersonRecord[]> {
87
+ void hqRoot;
88
+ const token = await ensureCognitoToken();
89
+ const companyUid = await getCompanyUid(token, companySlug);
90
+ const members = await listActiveMembers(token, companyUid);
91
+ return members
92
+ .map(activeMemberToPersonRecord)
93
+ .filter((r): r is PersonRecord => r !== null);
94
+ }
95
+
96
+ function personIdentityKey(person: PersonRecord): string {
97
+ return (
98
+ person.email?.toLowerCase() ||
99
+ person.slug?.toLowerCase() ||
100
+ person.name.toLowerCase()
101
+ );
102
+ }
103
+
104
+ export function mergePeople(
105
+ primary: PersonRecord[],
106
+ extra: PersonRecord[],
107
+ ): PersonRecord[] {
108
+ const seen = new Set(primary.map(personIdentityKey));
109
+ const merged = [...primary];
110
+ for (const person of extra) {
111
+ const key = personIdentityKey(person);
112
+ if (seen.has(key)) continue;
113
+ seen.add(key);
114
+ merged.push(person);
115
+ }
116
+ return merged;
73
117
  }
74
118
 
75
119
  /** Companies that still exist (anything not explicitly `status: archived`). */
@@ -159,26 +203,25 @@ function fail(message: string): never {
159
203
  process.exit(1);
160
204
  }
161
205
 
162
- function logRefreshFailure(companySlug: string, err: unknown): void {
206
+ function logRosterFetchFailure(companySlug: string, err: unknown): void {
163
207
  const message = err instanceof Error ? err.message : String(err);
164
208
  console.error(
165
209
  chalk.dim(
166
- ` Could not refresh people roster for '${companySlug}': ${message}`,
210
+ ` Could not fetch people roster for '${companySlug}': ${message}`,
167
211
  ),
168
212
  );
169
213
  }
170
214
 
171
- async function tryRefreshRoster(
172
- refreshRoster: RefreshPeopleRoster,
215
+ async function tryFetchRoster(
216
+ fetchRoster: FetchPeopleRoster,
173
217
  hqRoot: string,
174
218
  slug: string,
175
- ): Promise<boolean> {
219
+ ): Promise<PersonRecord[] | null> {
176
220
  try {
177
- await refreshRoster(hqRoot, slug);
178
- return true;
221
+ return await fetchRoster(hqRoot, slug);
179
222
  } catch (err) {
180
- logRefreshFailure(slug, err);
181
- return false;
223
+ logRosterFetchFailure(slug, err);
224
+ return null;
182
225
  }
183
226
  }
184
227
 
@@ -188,25 +231,20 @@ export async function resolvePersonWithRosterFallback(
188
231
  slug: string;
189
232
  name: string;
190
233
  opts?: PeopleLookupOpts;
191
- refreshRoster?: RefreshPeopleRoster;
234
+ fetchRoster?: FetchPeopleRoster;
192
235
  },
193
236
  ): Promise<ReturnType<typeof resolveNameToEmail>> {
194
- const local = resolveNameToEmail(
195
- listCompanyPeople(input.hqRoot, input.slug),
196
- input.name,
197
- );
237
+ const localPeople = listCompanyPeople(input.hqRoot, input.slug);
238
+ const local = resolveNameToEmail(localPeople, input.name);
198
239
  if (local.status !== "not_found" || input.opts?.localOnly) return local;
199
240
 
200
- const refreshed = await tryRefreshRoster(
201
- input.refreshRoster ?? refreshPeopleRosterFromCloud,
241
+ const roster = await tryFetchRoster(
242
+ input.fetchRoster ?? fetchMembershipRoster,
202
243
  input.hqRoot,
203
244
  input.slug,
204
245
  );
205
- if (!refreshed) return local;
206
- return resolveNameToEmail(
207
- listCompanyPeople(input.hqRoot, input.slug),
208
- input.name,
209
- );
246
+ if (!roster) return local;
247
+ return resolveNameToEmail(mergePeople(localPeople, roster), input.name);
210
248
  }
211
249
 
212
250
  export async function searchPeopleWithRosterFallback(input: {
@@ -214,21 +252,19 @@ export async function searchPeopleWithRosterFallback(input: {
214
252
  slug: string;
215
253
  keyword: string;
216
254
  opts?: PeopleLookupOpts;
217
- refreshRoster?: RefreshPeopleRoster;
255
+ fetchRoster?: FetchPeopleRoster;
218
256
  }): Promise<PersonRecord[]> {
219
- const local = searchPeople(
220
- listCompanyPeople(input.hqRoot, input.slug),
221
- input.keyword,
222
- );
257
+ const localPeople = listCompanyPeople(input.hqRoot, input.slug);
258
+ const local = searchPeople(localPeople, input.keyword);
223
259
  if (local.length > 0 || input.opts?.localOnly) return local;
224
260
 
225
- const refreshed = await tryRefreshRoster(
226
- input.refreshRoster ?? refreshPeopleRosterFromCloud,
261
+ const roster = await tryFetchRoster(
262
+ input.fetchRoster ?? fetchMembershipRoster,
227
263
  input.hqRoot,
228
264
  input.slug,
229
265
  );
230
- if (!refreshed) return local;
231
- return searchPeople(listCompanyPeople(input.hqRoot, input.slug), input.keyword);
266
+ if (!roster) return local;
267
+ return searchPeople(mergePeople(localPeople, roster), input.keyword);
232
268
  }
233
269
 
234
270
  export function registerPeopleCommand(
@@ -257,12 +293,26 @@ export function registerPeopleCommand(
257
293
  .command("list")
258
294
  .description("List all people recorded for the company")
259
295
  .option("--json", "Output JSON instead of a table")
260
- .action((opts: { json?: boolean }) => {
296
+ .option(
297
+ "--local-only",
298
+ "Skip network fallback; list only the local people roster",
299
+ )
300
+ .action(async (opts: PeopleLookupOpts) => {
261
301
  try {
262
302
  const scope = people.opts() as PeopleScopeOpts;
263
303
  const hqRoot = resolveHqRoot(scope);
264
304
  const slug = resolveCompanySlug(hqRoot, scope.company);
265
- const records = listCompanyPeople(hqRoot, slug);
305
+ let records = listCompanyPeople(hqRoot, slug);
306
+
307
+ if (!opts.localOnly) {
308
+ const roster = await tryFetchRoster(
309
+ deps.fetchRoster ?? fetchMembershipRoster,
310
+ hqRoot,
311
+ slug,
312
+ );
313
+ if (roster) records = mergePeople(records, roster);
314
+ }
315
+ records = records.sort((a, b) => a.name.localeCompare(b.name));
266
316
 
267
317
  if (opts.json) {
268
318
  console.log(JSON.stringify(records, null, 2));
@@ -288,7 +338,7 @@ export function registerPeopleCommand(
288
338
  .option("--json", "Output JSON instead of a table")
289
339
  .option(
290
340
  "--local-only",
291
- "Skip cloud fallback; search only the local people roster",
341
+ "Skip network fallback; search only the local people roster",
292
342
  )
293
343
  .action(async (keyword: string, opts: PeopleLookupOpts) => {
294
344
  try {
@@ -300,7 +350,7 @@ export function registerPeopleCommand(
300
350
  slug,
301
351
  keyword,
302
352
  opts,
303
- refreshRoster: deps.refreshRoster,
353
+ fetchRoster: deps.fetchRoster,
304
354
  });
305
355
 
306
356
  if (opts.json) {
@@ -323,7 +373,7 @@ export function registerPeopleCommand(
323
373
  .option("--json", "Output JSON instead of plain text")
324
374
  .option(
325
375
  "--local-only",
326
- "Skip cloud fallback; resolve only from the local people roster",
376
+ "Skip network fallback; resolve only from the local people roster",
327
377
  )
328
378
  .action(async (name: string, opts: PeopleLookupOpts) => {
329
379
  try {
@@ -335,7 +385,7 @@ export function registerPeopleCommand(
335
385
  slug,
336
386
  name,
337
387
  opts,
338
- refreshRoster: deps.refreshRoster,
388
+ fetchRoster: deps.fetchRoster,
339
389
  });
340
390
 
341
391
  if (opts.json) {