@indigoai-us/hq-cli 5.50.1 → 5.50.2

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.
@@ -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) {
package/src/types.ts CHANGED
@@ -95,12 +95,30 @@ export interface PackAuthor {
95
95
  displayName: string;
96
96
  }
97
97
 
98
+ /**
99
+ * A pack-to-pack dependency (M0). OPTIONAL and backwards-compatible — packs
100
+ * published before this field omit `requires.packs` and install unchanged. When
101
+ * present, each entry names another content pack that MUST already be installed
102
+ * before this one (enforced at install time by `assertPackDependencies`, which
103
+ * tracks installed packs by FILESYSTEM PRESENCE — not `modules.yaml`). `version`
104
+ * is an optional semver RANGE the installed dependency must satisfy.
105
+ */
106
+ export interface PackDependency {
107
+ name: string; // ^hq-pack-[a-z0-9][a-z0-9-]*$
108
+ version?: string; // optional semver range
109
+ }
110
+
98
111
  export interface PackManifest {
99
112
  name: string; // ^hq-pack-[a-z0-9][a-z0-9-]*$
100
113
  version: string; // semver
101
114
  publisher: string; // @scope
102
115
  access: 'public' | 'private';
103
- requires: { hqCore: string }; // semver range
116
+ /**
117
+ * Host + pack prerequisites. `hqCore` is a required semver RANGE the host HQ
118
+ * must satisfy. `packs` (M0) is an OPTIONAL list of other content packs that
119
+ * must be installed first — see PackDependency.
120
+ */
121
+ requires: { hqCore: string; packs?: PackDependency[] };
104
122
  contributes: Partial<Record<PackContributeKey, string[]>>;
105
123
  description?: string;
106
124
  license?: string;
@@ -16,11 +16,13 @@ import {
16
16
  contributionLinks,
17
17
  linkStatus,
18
18
  listInstalledPacks,
19
+ findDependentPacks,
19
20
  unwirePack,
20
21
  unwirePackMcp,
21
22
  packagesDir,
22
23
  readHqVersion,
23
24
  type WiredLink,
25
+ type InstalledPack,
24
26
  } from './pack-contributions.js';
25
27
  import { parse as parseToml } from 'smol-toml';
26
28
  import {
@@ -489,3 +491,54 @@ describe('US-009: unwirePackMcp (mcp un-registration parallel to symlink unwire)
489
491
  expect(fs.readFileSync(codexConfigPath(env), 'utf-8')).toBe(original);
490
492
  });
491
493
  });
494
+
495
+ // ---------------------------------------------------------------------------
496
+ // findDependentPacks — uninstall dependents guard (M0)
497
+ // ---------------------------------------------------------------------------
498
+
499
+ describe('findDependentPacks (M0)', () => {
500
+ function ip(
501
+ name: string,
502
+ requiresPacks?: Array<{ name: string; version?: string }>,
503
+ ): InstalledPack {
504
+ return {
505
+ name,
506
+ dir: `/tmp/nope/${name}`,
507
+ manifest: {
508
+ name,
509
+ version: '1.0.0',
510
+ publisher: '@indigoai-us',
511
+ access: 'public',
512
+ requires: { hqCore: '>=15.0.0', packs: requiresPacks },
513
+ contributes: { skills: ['x'] },
514
+ },
515
+ } as InstalledPack;
516
+ }
517
+
518
+ it('returns the packs that require the named pack', () => {
519
+ const installed = [
520
+ ip('hq-pack-crm'),
521
+ ip('hq-pack-accounting', [{ name: 'hq-pack-crm', version: '>=1.0.0' }]),
522
+ ip('hq-pack-unrelated'),
523
+ ];
524
+ const deps = findDependentPacks(installed, 'hq-pack-crm');
525
+ expect(deps.map((p) => p.name)).toEqual(['hq-pack-accounting']);
526
+ });
527
+
528
+ it('returns empty when no installed pack requires it', () => {
529
+ const installed = [ip('hq-pack-crm'), ip('hq-pack-unrelated')];
530
+ expect(findDependentPacks(installed, 'hq-pack-crm')).toEqual([]);
531
+ });
532
+
533
+ it('never counts a pack that lists itself as its own dependent', () => {
534
+ // Defensive: a self-referencing manifest (rejected at install) must not make
535
+ // a pack un-removable.
536
+ const installed = [ip('hq-pack-weird', [{ name: 'hq-pack-weird' }])];
537
+ expect(findDependentPacks(installed, 'hq-pack-weird')).toEqual([]);
538
+ });
539
+
540
+ it('tolerates an unreadable manifest (manifest === null)', () => {
541
+ const broken: InstalledPack = { name: 'hq-pack-broken', dir: '/tmp/nope', manifest: null };
542
+ expect(findDependentPacks([broken], 'hq-pack-crm')).toEqual([]);
543
+ });
544
+ });
@@ -264,6 +264,23 @@ export function listInstalledPacks(hqRoot: string): InstalledPack[] {
264
264
  return out;
265
265
  }
266
266
 
267
+ /**
268
+ * Installed packs that declare `name` in their `requires.packs` (M0). Pure over
269
+ * the supplied list — the uninstall dependents guard calls this with
270
+ * `listInstalledPacks(hqRoot)`. Excludes the pack named `name` itself, so a
271
+ * self-reference never counts as its own dependent.
272
+ */
273
+ export function findDependentPacks(
274
+ installed: InstalledPack[],
275
+ name: string,
276
+ ): InstalledPack[] {
277
+ return installed.filter(
278
+ (p) =>
279
+ p.name !== name &&
280
+ (p.manifest?.requires?.packs ?? []).some((d) => d.name === name),
281
+ );
282
+ }
283
+
267
284
  // ---------------------------------------------------------------------------
268
285
  // Un-wiring (the uninstall guarantee)
269
286
  // ---------------------------------------------------------------------------
@@ -49,6 +49,51 @@ describe("shouldSkipGate", () => {
49
49
  });
50
50
  });
51
51
 
52
+ describe("prefix install helpers", () => {
53
+ it("derives the npm prefix from unix global package layouts", async () => {
54
+ const { __test__ } = await loadModule();
55
+ expect(
56
+ __test__.npmPrefixFromPackageDir(
57
+ "/Users/x/Library/Application Support/Indigo HQ/toolchain/npm-global/lib/node_modules/@indigoai-us/hq-cli",
58
+ ),
59
+ ).toBe("/Users/x/Library/Application Support/Indigo HQ/toolchain/npm-global");
60
+ expect(
61
+ __test__.npmPrefixFromPackageDir(
62
+ "/usr/local/lib/node_modules/@indigoai-us/hq-cli",
63
+ ),
64
+ ).toBe("/usr/local");
65
+ });
66
+
67
+ it("derives the npm prefix from a windows-style package layout", async () => {
68
+ const { __test__ } = await loadModule();
69
+ expect(
70
+ __test__.npmPrefixFromPackageDir(
71
+ "C:\\Users\\x\\AppData\\Roaming\\npm\\node_modules\\@indigoai-us\\hq-cli",
72
+ ),
73
+ ).toBe("C:/Users/x/AppData/Roaming/npm");
74
+ });
75
+
76
+ it("returns null when the package path is not under node_modules", async () => {
77
+ const { __test__ } = await loadModule();
78
+ expect(
79
+ __test__.npmPrefixFromPackageDir(
80
+ "/Users/x/dev/hq/packages/hq-cli",
81
+ ),
82
+ ).toBeNull();
83
+ });
84
+
85
+ it("builds the prefixed npm install argv", async () => {
86
+ const { __test__ } = await loadModule();
87
+ expect(__test__.buildPrefixedInstallArgv("/tmp/npm-global")).toEqual([
88
+ "install",
89
+ "-g",
90
+ "--prefix",
91
+ "/tmp/npm-global",
92
+ "@indigoai-us/hq-cli@latest",
93
+ ]);
94
+ });
95
+ });
96
+
52
97
  describe("enforceVersionGate — opt-out + soft paths (no process.exit)", () => {
53
98
  it("is silent + no fetch when HQ_NO_UPDATE_CHECK=1", async () => {
54
99
  vi.stubEnv("HQ_NO_UPDATE_CHECK", "1");
@@ -242,4 +287,81 @@ describe("enforceVersionGate — hard-update path", () => {
242
287
  expect(body.currentVersion).toBe("5.10.0");
243
288
  expect(typeof body.platform).toBe("string");
244
289
  });
290
+
291
+ it("installs with the resolved running prefix instead of the bare server command", async () => {
292
+ vi.spyOn(console, "error").mockImplementation(() => {});
293
+ const exitSpy = vi
294
+ .spyOn(process, "exit")
295
+ .mockImplementation(((code?: number) => {
296
+ throw new Error(`__process_exit__:${code ?? 0}`);
297
+ }) as never);
298
+ const runner = vi.fn().mockReturnValue({ ok: true });
299
+ const { __test__ } = await loadModule();
300
+ const prefix = "/Users/x/Library/Application Support/Indigo HQ/toolchain/npm-global";
301
+
302
+ expect(() =>
303
+ __test__.enforceUpdateRequired(
304
+ {
305
+ clientId: "hq-cli",
306
+ currentVersion: "5.10.0",
307
+ minVersion: "5.20.0",
308
+ latestVersion: "5.24.0",
309
+ updateRequired: true,
310
+ updateRecommended: false,
311
+ updateCommand: "npm install -g @indigoai-us/hq-cli@latest",
312
+ },
313
+ {
314
+ resolvePrefix: () => prefix,
315
+ runner,
316
+ },
317
+ ),
318
+ ).toThrow(/__process_exit__:0/);
319
+
320
+ expect(exitSpy).toHaveBeenCalledWith(0);
321
+ expect(runner).toHaveBeenCalledWith("npm", [
322
+ "install",
323
+ "-g",
324
+ "--prefix",
325
+ prefix,
326
+ "@indigoai-us/hq-cli@latest",
327
+ ]);
328
+ expect(runner).not.toHaveBeenCalledWith("npm", [
329
+ "install",
330
+ "-g",
331
+ "@indigoai-us/hq-cli@latest",
332
+ ]);
333
+ });
334
+
335
+ it("falls back to the server updateCommand when no running prefix resolves", async () => {
336
+ vi.spyOn(console, "error").mockImplementation(() => {});
337
+ const exitSpy = vi
338
+ .spyOn(process, "exit")
339
+ .mockImplementation(((code?: number) => {
340
+ throw new Error(`__process_exit__:${code ?? 0}`);
341
+ }) as never);
342
+ const performUpdateString = vi.fn().mockReturnValue({ ok: true });
343
+ const { __test__ } = await loadModule();
344
+ const updateCommand = "npm install -g @indigoai-us/hq-cli@latest";
345
+
346
+ expect(() =>
347
+ __test__.enforceUpdateRequired(
348
+ {
349
+ clientId: "hq-cli",
350
+ currentVersion: "5.10.0",
351
+ minVersion: "5.20.0",
352
+ latestVersion: "5.24.0",
353
+ updateRequired: true,
354
+ updateRecommended: false,
355
+ updateCommand,
356
+ },
357
+ {
358
+ performUpdateString,
359
+ resolvePrefix: () => null,
360
+ },
361
+ ),
362
+ ).toThrow(/__process_exit__:0/);
363
+
364
+ expect(exitSpy).toHaveBeenCalledWith(0);
365
+ expect(performUpdateString).toHaveBeenCalledWith(updateCommand);
366
+ });
245
367
  });