@indigoai-us/hq-cli 5.50.1 → 5.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/bin/hq-auth-refresh.d.ts +1 -1
  2. package/dist/bin/hq-auth-refresh.js +5 -2
  3. package/dist/commands/members.d.ts +17 -0
  4. package/dist/commands/members.js +65 -28
  5. package/dist/commands/onboard-warning.d.ts +7 -0
  6. package/dist/commands/onboard-warning.js +14 -0
  7. package/dist/commands/onboard.js +5 -5
  8. package/dist/commands/pack-install.d.ts +12 -0
  9. package/dist/commands/pack-install.js +74 -3
  10. package/dist/commands/packs.js +17 -3
  11. package/dist/commands/people.d.ts +26 -1
  12. package/dist/commands/people.js +70 -7
  13. package/dist/commands/secrets-scope.d.ts +20 -0
  14. package/dist/commands/secrets-scope.js +19 -0
  15. package/dist/commands/secrets.js +21 -6
  16. package/dist/index.d.ts +1 -1
  17. package/dist/index.js +44 -14
  18. package/dist/node-preflight.d.ts +39 -0
  19. package/dist/node-preflight.js +55 -0
  20. package/dist/sentry.d.ts +12 -0
  21. package/dist/sentry.js +19 -3
  22. package/dist/types.d.ts +18 -0
  23. package/dist/utils/epipe.d.ts +8 -0
  24. package/dist/utils/epipe.js +30 -0
  25. package/dist/utils/intercepted-process-exit.d.ts +7 -0
  26. package/dist/utils/intercepted-process-exit.js +38 -0
  27. package/dist/utils/pack-contributions.d.ts +7 -0
  28. package/dist/utils/pack-contributions.js +12 -2
  29. package/dist/utils/version-gate.d.ts +40 -1
  30. package/dist/utils/version-gate.js +91 -20
  31. package/e2e/cli.test.ts +35 -0
  32. package/package.json +1 -1
  33. package/src/bin/hq-auth-refresh.ts +3 -0
  34. package/src/commands/members.test.ts +176 -0
  35. package/src/commands/members.ts +113 -28
  36. package/src/commands/onboard-warning.test.ts +26 -0
  37. package/src/commands/onboard-warning.ts +12 -0
  38. package/src/commands/onboard.ts +4 -7
  39. package/src/commands/pack-install.test.ts +144 -0
  40. package/src/commands/pack-install.ts +86 -1
  41. package/src/commands/packs.ts +19 -0
  42. package/src/commands/people.test.ts +212 -5
  43. package/src/commands/people.ts +141 -5
  44. package/src/commands/secrets-scope.test.ts +56 -0
  45. package/src/commands/secrets-scope.ts +32 -0
  46. package/src/commands/secrets.ts +24 -10
  47. package/src/index.ts +40 -12
  48. package/src/node-preflight.test.ts +60 -0
  49. package/src/node-preflight.ts +67 -0
  50. package/src/sentry-epipe.test.ts +37 -0
  51. package/src/sentry-release.test.ts +54 -0
  52. package/src/sentry.ts +21 -1
  53. package/src/types.ts +19 -1
  54. package/src/utils/epipe.test.ts +28 -0
  55. package/src/utils/epipe.ts +29 -0
  56. package/src/utils/intercepted-process-exit.test.ts +37 -0
  57. package/src/utils/intercepted-process-exit.ts +36 -0
  58. package/src/utils/pack-contributions.test.ts +53 -0
  59. package/src/utils/pack-contributions.ts +17 -0
  60. package/src/utils/version-gate.test.ts +122 -0
  61. package/src/utils/version-gate.ts +109 -13
@@ -26,6 +26,7 @@ import {
26
26
  runScanPackages,
27
27
  stampInstallSource,
28
28
  validateManifest,
29
+ assertPackDependencies,
29
30
  getStartedLine,
30
31
  toMarketplaceListing,
31
32
  fetchMarketplace,
@@ -1319,3 +1320,146 @@ describe('pack-install: fetchMarketplace honors the envelope-mapped contentHash'
1319
1320
  }
1320
1321
  });
1321
1322
  });
1323
+
1324
+ // ---------------------------------------------------------------------------
1325
+ // requires.packs — pack-to-pack dependencies (M0)
1326
+ // ---------------------------------------------------------------------------
1327
+
1328
+ describe('requires.packs validation (M0)', () => {
1329
+ // Mirrors the author/capabilities helper: a minimal otherwise-valid payload
1330
+ // whose only variable is the `requires.packs` block under test.
1331
+ function writePackWithRequires(extraYaml: string): string {
1332
+ const dir = mkFakePackPayload({ 'knowledge/demo/README.md': '# demo' });
1333
+ fs.writeFileSync(
1334
+ path.join(dir, 'package.yaml'),
1335
+ [
1336
+ 'name: hq-pack-accounting',
1337
+ 'version: 1.0.0',
1338
+ "publisher: '@indigoai-us'",
1339
+ 'access: public',
1340
+ 'requires:',
1341
+ " hqCore: '>=15.1.0'",
1342
+ extraYaml,
1343
+ 'contributes:',
1344
+ ' knowledge:',
1345
+ ' - demo',
1346
+ '',
1347
+ ].join('\n'),
1348
+ );
1349
+ return dir;
1350
+ }
1351
+
1352
+ it('a manifest WITHOUT requires.packs validates (backwards-compatible)', () => {
1353
+ const dir = writePackWithRequires('');
1354
+ const m = validateManifest(dir, '15.1.0');
1355
+ expect(m.requires.packs).toBeUndefined();
1356
+ fs.rmSync(dir, { recursive: true, force: true });
1357
+ });
1358
+
1359
+ it('a well-formed requires.packs (with and without version) is accepted', () => {
1360
+ const dir = writePackWithRequires(
1361
+ [
1362
+ ' packs:',
1363
+ ' - name: hq-pack-crm',
1364
+ " version: '>=1.0.0'",
1365
+ ' - name: hq-pack-other',
1366
+ ].join('\n'),
1367
+ );
1368
+ const m = validateManifest(dir, '15.1.0');
1369
+ expect(m.requires.packs).toEqual([
1370
+ { name: 'hq-pack-crm', version: '>=1.0.0' },
1371
+ { name: 'hq-pack-other' },
1372
+ ]);
1373
+ fs.rmSync(dir, { recursive: true, force: true });
1374
+ });
1375
+
1376
+ it('rejects requires.packs that is not a list', () => {
1377
+ const dir = writePackWithRequires(' packs: hq-pack-crm');
1378
+ expect(() => validateManifest(dir, '15.1.0')).toThrow(/requires\.packs must be a list/);
1379
+ fs.rmSync(dir, { recursive: true, force: true });
1380
+ });
1381
+
1382
+ it('rejects an entry with an invalid pack name', () => {
1383
+ const dir = writePackWithRequires([' packs:', ' - name: not-an-hq-pack'].join('\n'));
1384
+ expect(() => validateManifest(dir, '15.1.0')).toThrow(/requires\.packs\[\]\.name/);
1385
+ fs.rmSync(dir, { recursive: true, force: true });
1386
+ });
1387
+
1388
+ it('rejects an entry with an invalid version range', () => {
1389
+ const dir = writePackWithRequires(
1390
+ [' packs:', ' - name: hq-pack-crm', ' version: not-a-range'].join('\n'),
1391
+ );
1392
+ expect(() => validateManifest(dir, '15.1.0')).toThrow(/invalid version range/);
1393
+ fs.rmSync(dir, { recursive: true, force: true });
1394
+ });
1395
+ });
1396
+
1397
+ describe('assertPackDependencies (M0)', () => {
1398
+ let hqRoot: string;
1399
+ beforeEach(() => {
1400
+ hqRoot = mkFakeHq();
1401
+ });
1402
+ afterEach(() => {
1403
+ fs.rmSync(hqRoot, { recursive: true, force: true });
1404
+ });
1405
+
1406
+ // Seed an installed pack by writing core/packages/<name>/package.yaml — the
1407
+ // filesystem-presence source of truth listInstalledPacks reads (NOT modules.yaml).
1408
+ function seedInstalledPack(name: string, version?: string): void {
1409
+ const dir = path.join(hqRoot, 'core', 'packages', name);
1410
+ fs.mkdirSync(dir, { recursive: true });
1411
+ const lines = [`name: ${name}`];
1412
+ if (version) lines.push(`version: ${version}`);
1413
+ fs.writeFileSync(path.join(dir, 'package.yaml'), lines.join('\n') + '\n');
1414
+ }
1415
+
1416
+ it('is a no-op when the pack declares no requires.packs', () => {
1417
+ const pkg = fakeManifest({ requires: { hqCore: '>=15.1.0' } });
1418
+ expect(() => assertPackDependencies(hqRoot, pkg)).not.toThrow();
1419
+ });
1420
+
1421
+ it('passes when every required pack is installed (no version constraint)', () => {
1422
+ seedInstalledPack('hq-pack-crm', '1.2.0');
1423
+ const pkg = fakeManifest({
1424
+ name: 'hq-pack-accounting',
1425
+ requires: { hqCore: '>=15.1.0', packs: [{ name: 'hq-pack-crm' }] },
1426
+ });
1427
+ expect(() => assertPackDependencies(hqRoot, pkg)).not.toThrow();
1428
+ });
1429
+
1430
+ it('throws a clear, actionable error when a required pack is missing', () => {
1431
+ const pkg = fakeManifest({
1432
+ name: 'hq-pack-accounting',
1433
+ requires: { hqCore: '>=15.1.0', packs: [{ name: 'hq-pack-crm' }] },
1434
+ });
1435
+ expect(() => assertPackDependencies(hqRoot, pkg)).toThrow(
1436
+ /requires hq-pack-crm, which is not installed/,
1437
+ );
1438
+ });
1439
+
1440
+ it('passes when the installed version satisfies the required range', () => {
1441
+ seedInstalledPack('hq-pack-crm', '1.2.0');
1442
+ const pkg = fakeManifest({
1443
+ name: 'hq-pack-accounting',
1444
+ requires: { hqCore: '>=15.1.0', packs: [{ name: 'hq-pack-crm', version: '>=1.0.0' }] },
1445
+ });
1446
+ expect(() => assertPackDependencies(hqRoot, pkg)).not.toThrow();
1447
+ });
1448
+
1449
+ it('throws when the installed version does NOT satisfy the required range', () => {
1450
+ seedInstalledPack('hq-pack-crm', '0.9.0');
1451
+ const pkg = fakeManifest({
1452
+ name: 'hq-pack-accounting',
1453
+ requires: { hqCore: '>=15.1.0', packs: [{ name: 'hq-pack-crm', version: '>=1.0.0' }] },
1454
+ });
1455
+ expect(() => assertPackDependencies(hqRoot, pkg)).toThrow(/requires hq-pack-crm >=1\.0\.0/);
1456
+ });
1457
+
1458
+ it('rejects a pack that lists itself as a dependency', () => {
1459
+ const pkg = fakeManifest({
1460
+ name: 'hq-pack-accounting',
1461
+ requires: { hqCore: '>=15.1.0', packs: [{ name: 'hq-pack-accounting' }] },
1462
+ });
1463
+ expect(() => assertPackDependencies(hqRoot, pkg)).toThrow(/cannot list itself/);
1464
+ });
1465
+ });
@@ -53,7 +53,7 @@ import semverValid from 'semver/functions/valid.js';
53
53
  import semverValidRange from 'semver/ranges/valid.js';
54
54
  import semverGt from 'semver/functions/gt.js';
55
55
  import { findHqRoot } from '../utils/manifest.js';
56
- import { readHqVersion, routeContribution } from '../utils/pack-contributions.js';
56
+ import { readHqVersion, routeContribution, listInstalledPacks } from '../utils/pack-contributions.js';
57
57
  import { CONTRIBUTION_TABLE, payloadFor } from '../utils/contribution-table.js';
58
58
  import { safeExtractTarball } from './safe-extract.js';
59
59
  import { vaultApiFetchPublic } from '../utils/vault-api.js';
@@ -1201,6 +1201,40 @@ export function validateManifest(
1201
1201
  `Host hqCore ${hqVersion} does not satisfy pack requirement ${range}`
1202
1202
  );
1203
1203
  }
1204
+ // 6b. requires.packs (M0) — OPTIONAL pack-to-pack dependencies. Absent → legacy
1205
+ // behavior (hqCore is the only prerequisite). Present → a list of
1206
+ // { name, version? } where `name` is a valid hq-pack name and `version` (if
1207
+ // given) is a valid semver RANGE. SHAPE validation only (no filesystem) so a
1208
+ // malformed dependency can't masquerade as valid; whether the named packs are
1209
+ // actually INSTALLED is enforced at install time by assertPackDependencies
1210
+ // (which needs hqRoot — a pure manifest validator doesn't have it).
1211
+ const reqPacks = m.requires?.packs;
1212
+ if (reqPacks !== undefined) {
1213
+ if (!Array.isArray(reqPacks)) {
1214
+ throw new Error('requires.packs must be a list of { name, version? } entries');
1215
+ }
1216
+ for (const dep of reqPacks) {
1217
+ if (!dep || typeof dep !== 'object' || Array.isArray(dep)) {
1218
+ throw new Error(
1219
+ 'requires.packs entries must be mappings with a name (and optional version)'
1220
+ );
1221
+ }
1222
+ const d = dep as unknown as Record<string, unknown>;
1223
+ if (typeof d.name !== 'string' || !/^hq-pack-[a-z0-9][a-z0-9-]*$/.test(d.name)) {
1224
+ throw new Error(
1225
+ `requires.packs[].name "${d.name}" must match ^hq-pack-[a-z0-9][a-z0-9-]*$`
1226
+ );
1227
+ }
1228
+ if (
1229
+ d.version !== undefined &&
1230
+ (typeof d.version !== 'string' || !semverValidRange(d.version))
1231
+ ) {
1232
+ throw new Error(
1233
+ `requires.packs entry for "${d.name}" has an invalid version range "${d.version}"`
1234
+ );
1235
+ }
1236
+ }
1237
+ }
1204
1238
  // 7. contributes has at least one non-empty subfield
1205
1239
  const contributes = (m.contributes ?? {}) as PackManifest['contributes'];
1206
1240
  const nonEmpty = Object.values(contributes).some(
@@ -1312,6 +1346,51 @@ export function validateManifest(
1312
1346
  return m as PackManifest;
1313
1347
  }
1314
1348
 
1349
+ // ---------------------------------------------------------------------------
1350
+ // Pack-to-pack dependency pre-flight (M0)
1351
+ // ---------------------------------------------------------------------------
1352
+
1353
+ /**
1354
+ * Enforce a pack's `requires.packs`: every named dependency MUST already be
1355
+ * installed (and satisfy its optional semver RANGE) before we write anything.
1356
+ *
1357
+ * Installed packs are discovered by FILESYSTEM PRESENCE via `listInstalledPacks`
1358
+ * — deliberately NOT `modules.yaml`, which `installPack` no longer writes under
1359
+ * the v12+ layout (a modules.yaml-based check would silently ignore every modern
1360
+ * pack). Throws on the first unmet dependency so the install aborts with NO
1361
+ * partial state (it is called before installToPackages). No-op when
1362
+ * `requires.packs` is absent/empty, keeping legacy packs unaffected.
1363
+ */
1364
+ export function assertPackDependencies(hqRoot: string, pkg: PackManifest): void {
1365
+ const deps = pkg.requires?.packs ?? [];
1366
+ if (deps.length === 0) return;
1367
+ const installed = new Map<string, string | undefined>();
1368
+ for (const p of listInstalledPacks(hqRoot)) {
1369
+ // Key on the manifest name when readable, else the directory name.
1370
+ installed.set(p.manifest?.name ?? p.name, p.manifest?.version);
1371
+ }
1372
+ for (const dep of deps) {
1373
+ if (dep.name === pkg.name) {
1374
+ throw new Error(`Pack ${pkg.name} cannot list itself in requires.packs.`);
1375
+ }
1376
+ if (!installed.has(dep.name)) {
1377
+ throw new Error(
1378
+ `Pack ${pkg.name} requires ${dep.name}, which is not installed. ` +
1379
+ `Install it first, e.g.: hq install marketplace:${dep.name}`
1380
+ );
1381
+ }
1382
+ if (dep.version) {
1383
+ const have = installed.get(dep.name);
1384
+ if (!have || !semverSatisfies(have, dep.version, { includePrerelease: true })) {
1385
+ throw new Error(
1386
+ `Pack ${pkg.name} requires ${dep.name} ${dep.version}, but ` +
1387
+ `${dep.name}${have ? ` ${have}` : ' (version unknown)'} is installed.`
1388
+ );
1389
+ }
1390
+ }
1391
+ }
1392
+ }
1393
+
1315
1394
  // ---------------------------------------------------------------------------
1316
1395
  // Post-install get-started line (US-005)
1317
1396
  // ---------------------------------------------------------------------------
@@ -1919,6 +1998,12 @@ export async function installPack(
1919
1998
 
1920
1999
  const pkg = validateManifest(fetched.payloadDir, hqVersion);
1921
2000
 
2001
+ // M0 — pack-to-pack dependency pre-flight. Runs BEFORE any prompts or writes
2002
+ // so a missing/unsatisfied `requires.packs` aborts with no partial state, and
2003
+ // before we bother the operator with hook/MCP trust prompts for a pack that
2004
+ // can't install anyway.
2005
+ assertPackDependencies(hqRoot, pkg);
2006
+
1922
2007
  if (pkg.conditional) {
1923
2008
  const allowed = await confirmConditional(pkg, opts.allowHooks ?? false);
1924
2009
  if (!allowed) {
@@ -38,6 +38,7 @@ import {
38
38
  contributionLinks,
39
39
  linkStatus,
40
40
  listInstalledPacks,
41
+ findDependentPacks,
41
42
  readPackManifest,
42
43
  unwirePack,
43
44
  unwirePackMcp,
@@ -394,6 +395,7 @@ interface UninstallResult {
394
395
  interface UninstallOpts extends CommonOpts {
395
396
  yes?: boolean;
396
397
  archive?: boolean; // commander sets false for --no-archive
398
+ force?: boolean; // bypass the dependents guard (M0)
397
399
  }
398
400
 
399
401
  function archiveTimestamp(): string {
@@ -413,6 +415,22 @@ async function runUninstall(name: string, opts: UninstallOpts): Promise<Uninstal
413
415
  warnings.push('package.yaml unreadable -- host symlinks could not be computed precisely; ran a re-scan to reconcile.');
414
416
  }
415
417
 
418
+ // 0. Dependents guard (M0): refuse to remove a pack that another installed pack
419
+ // lists in its `requires.packs`, unless --force. Filesystem presence is the
420
+ // source of truth (listInstalledPacks), consistent with the install-time
421
+ // assertPackDependencies check. Runs BEFORE any un-wiring so a blocked uninstall
422
+ // leaves the pack fully intact.
423
+ if (!opts.force) {
424
+ const dependents = findDependentPacks(listInstalledPacks(hqRoot), name);
425
+ if (dependents.length > 0) {
426
+ const who = dependents.map((p) => p.manifest?.name ?? p.name).join(', ');
427
+ throw new Error(
428
+ `Cannot uninstall "${name}": required by ${who}. ` +
429
+ `Uninstall the dependent pack(s) first, or pass --force to override.`,
430
+ );
431
+ }
432
+ }
433
+
416
434
  // 1. Un-wire only our symlinks.
417
435
  const { unlinked, skipped } = unwirePack(hqRoot, packDir, contributes);
418
436
 
@@ -550,6 +568,7 @@ export function registerPacksCommand(parent: Command): void {
550
568
  .option('--hq-root <path>', 'HQ root (default: auto-detect)')
551
569
  .option('-y, --yes', 'Skip confirmation')
552
570
  .option('--no-archive', 'Delete instead of archiving')
571
+ .option('--force', 'Uninstall even if other installed packs require this one')
553
572
  .action(async (name: string, opts: UninstallOpts) => {
554
573
  try {
555
574
  if (!opts.yes) {
@@ -19,7 +19,11 @@ import {
19
19
  resolveNameToEmail,
20
20
  type PersonRecord,
21
21
  } from "../utils/people.js";
22
- import { registerPeopleCommand, resolveCompanySlug } from "./people.js";
22
+ import {
23
+ registerPeopleCommand,
24
+ resolveCompanySlug,
25
+ type RefreshPeopleRoster,
26
+ } from "./people.js";
23
27
 
24
28
  // ---------------------------------------------------------------------------
25
29
  // Filesystem fixture helpers
@@ -335,15 +339,20 @@ describe("companyPeopleDir", () => {
335
339
  // Wired CLI surface (list / search / resolve)
336
340
  // ---------------------------------------------------------------------------
337
341
 
338
- function buildProgram(): Command {
342
+ function buildProgram(deps?: { refreshRoster?: RefreshPeopleRoster }): Command {
339
343
  const program = new Command();
340
344
  program.exitOverride(); // throw instead of process.exit on commander errors
341
- registerPeopleCommand(program);
345
+ registerPeopleCommand(program, deps);
342
346
  return program;
343
347
  }
344
348
 
345
- async function run(args: string[]): Promise<void> {
346
- await buildProgram().parseAsync(["node", "hq", ...args]);
349
+ async function run(
350
+ args: string[],
351
+ deps: { refreshRoster?: RefreshPeopleRoster } = {
352
+ refreshRoster: async () => {},
353
+ },
354
+ ): Promise<void> {
355
+ await buildProgram(deps).parseAsync(["node", "hq", ...args]);
347
356
  }
348
357
 
349
358
  describe("hq people (wired)", () => {
@@ -396,6 +405,104 @@ describe("hq people (wired)", () => {
396
405
  expect(out[0].email).toBe("jane@acme.com");
397
406
  });
398
407
 
408
+ it("search local hit does not refresh the roster", async () => {
409
+ const refreshRoster = vi.fn<RefreshPeopleRoster>();
410
+ await run(
411
+ [
412
+ "people",
413
+ "search",
414
+ "jane",
415
+ "--company",
416
+ "acme",
417
+ "--hq-root",
418
+ tmpRoot,
419
+ "--json",
420
+ ],
421
+ { refreshRoster },
422
+ );
423
+ expect(refreshRoster).not.toHaveBeenCalled();
424
+ });
425
+
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
+ });
434
+
435
+ await run(
436
+ [
437
+ "people",
438
+ "search",
439
+ "ada",
440
+ "--company",
441
+ "acme",
442
+ "--hq-root",
443
+ tmpRoot,
444
+ "--json",
445
+ ],
446
+ { refreshRoster },
447
+ );
448
+
449
+ expect(refreshRoster).toHaveBeenCalledTimes(1);
450
+ expect(refreshRoster).toHaveBeenCalledWith(tmpRoot, "acme");
451
+ const out = JSON.parse(logSpy.mock.calls[0][0] as string);
452
+ expect(out).toHaveLength(1);
453
+ expect(out[0]).toMatchObject({
454
+ name: "Ada Lovelace",
455
+ email: "ada@acme.com",
456
+ });
457
+ });
458
+
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 () => {
461
+ throw new Error("offline");
462
+ });
463
+
464
+ await run(
465
+ [
466
+ "people",
467
+ "search",
468
+ "ada",
469
+ "--company",
470
+ "acme",
471
+ "--hq-root",
472
+ tmpRoot,
473
+ "--json",
474
+ ],
475
+ { refreshRoster },
476
+ );
477
+
478
+ expect(refreshRoster).toHaveBeenCalledTimes(1);
479
+ expect(JSON.parse(logSpy.mock.calls[0][0] as string)).toEqual([]);
480
+ expect(errSpy).toHaveBeenCalledWith(
481
+ expect.stringContaining("Could not refresh people roster"),
482
+ );
483
+ });
484
+
485
+ it("search --local-only skips refresh on a miss", async () => {
486
+ const refreshRoster = vi.fn<RefreshPeopleRoster>();
487
+ await run(
488
+ [
489
+ "people",
490
+ "search",
491
+ "ada",
492
+ "--company",
493
+ "acme",
494
+ "--hq-root",
495
+ tmpRoot,
496
+ "--json",
497
+ "--local-only",
498
+ ],
499
+ { refreshRoster },
500
+ );
501
+
502
+ expect(refreshRoster).not.toHaveBeenCalled();
503
+ expect(JSON.parse(logSpy.mock.calls[0][0] as string)).toEqual([]);
504
+ });
505
+
399
506
  it("resolve prints the bare email on stdout", async () => {
400
507
  await run([
401
508
  "people",
@@ -409,6 +516,106 @@ describe("hq people (wired)", () => {
409
516
  expect(logSpy).toHaveBeenCalledWith("jane@acme.com");
410
517
  });
411
518
 
519
+ it("resolve local hit does not refresh the roster", async () => {
520
+ const refreshRoster = vi.fn<RefreshPeopleRoster>();
521
+ await run(
522
+ [
523
+ "people",
524
+ "resolve",
525
+ "Jane Smith",
526
+ "--company",
527
+ "acme",
528
+ "--hq-root",
529
+ tmpRoot,
530
+ ],
531
+ { refreshRoster },
532
+ );
533
+ expect(refreshRoster).not.toHaveBeenCalled();
534
+ });
535
+
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
+ });
544
+
545
+ await run(
546
+ [
547
+ "people",
548
+ "resolve",
549
+ "Ada Lovelace",
550
+ "--company",
551
+ "acme",
552
+ "--hq-root",
553
+ tmpRoot,
554
+ ],
555
+ { refreshRoster },
556
+ );
557
+
558
+ expect(refreshRoster).toHaveBeenCalledTimes(1);
559
+ expect(refreshRoster).toHaveBeenCalledWith(tmpRoot, "acme");
560
+ expect(logSpy).toHaveBeenCalledWith("ada@acme.com");
561
+ });
562
+
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 () => {
565
+ throw new Error("not signed in");
566
+ });
567
+
568
+ await expect(
569
+ run(
570
+ [
571
+ "people",
572
+ "resolve",
573
+ "Ada Lovelace",
574
+ "--company",
575
+ "acme",
576
+ "--hq-root",
577
+ tmpRoot,
578
+ "--json",
579
+ ],
580
+ { refreshRoster },
581
+ ),
582
+ ).rejects.toThrow("__exit__");
583
+
584
+ expect(refreshRoster).toHaveBeenCalledTimes(1);
585
+ expect(JSON.parse(logSpy.mock.calls[0][0] as string)).toEqual({
586
+ status: "not_found",
587
+ });
588
+ expect(errSpy).toHaveBeenCalledWith(
589
+ expect.stringContaining("Could not refresh people roster"),
590
+ );
591
+ expect(exitSpy).toHaveBeenCalledWith(1);
592
+ });
593
+
594
+ it("resolve --local-only skips refresh on a miss", async () => {
595
+ const refreshRoster = vi.fn<RefreshPeopleRoster>();
596
+ await expect(
597
+ run(
598
+ [
599
+ "people",
600
+ "resolve",
601
+ "Ada Lovelace",
602
+ "--company",
603
+ "acme",
604
+ "--hq-root",
605
+ tmpRoot,
606
+ "--json",
607
+ "--local-only",
608
+ ],
609
+ { refreshRoster },
610
+ ),
611
+ ).rejects.toThrow("__exit__");
612
+
613
+ expect(refreshRoster).not.toHaveBeenCalled();
614
+ expect(JSON.parse(logSpy.mock.calls[0][0] as string)).toEqual({
615
+ status: "not_found",
616
+ });
617
+ });
618
+
412
619
  it("resolve exits non-zero when not found", async () => {
413
620
  await expect(
414
621
  run([