@indigoai-us/hq-cli 5.50.0 → 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.
Files changed (46) hide show
  1. package/dist/commands/mcp-registration.d.ts +905 -0
  2. package/dist/commands/mcp-registration.js +2001 -0
  3. package/dist/commands/mcp-status.d.ts +130 -0
  4. package/dist/commands/mcp-status.js +406 -0
  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 +74 -0
  9. package/dist/commands/pack-install.js +493 -14
  10. package/dist/commands/packs.js +42 -4
  11. package/dist/commands/pkg-install.js +5 -2
  12. package/dist/index.js +7 -2
  13. package/dist/types.d.ts +26 -1
  14. package/dist/utils/contribution-table.d.ts +103 -0
  15. package/dist/utils/contribution-table.js +65 -0
  16. package/dist/utils/pack-contributions.d.ts +93 -10
  17. package/dist/utils/pack-contributions.js +140 -48
  18. package/dist/utils/secrets-cache.d.ts +9 -0
  19. package/dist/utils/secrets-cache.js +24 -2
  20. package/dist/utils/version-gate.d.ts +40 -1
  21. package/dist/utils/version-gate.js +91 -20
  22. package/package.json +3 -2
  23. package/scripts/generate-scan-packages-table.mjs +113 -0
  24. package/src/commands/mcp-registration.test.ts +2787 -0
  25. package/src/commands/mcp-registration.ts +2612 -0
  26. package/src/commands/mcp-status.test.ts +483 -0
  27. package/src/commands/mcp-status.ts +575 -0
  28. package/src/commands/mcp-status.us011.test.ts +243 -0
  29. package/src/commands/onboard-warning.test.ts +26 -0
  30. package/src/commands/onboard-warning.ts +12 -0
  31. package/src/commands/onboard.ts +4 -7
  32. package/src/commands/pack-install.test.ts +733 -0
  33. package/src/commands/pack-install.ts +582 -13
  34. package/src/commands/packs.ts +45 -1
  35. package/src/commands/pkg-install.ts +4 -1
  36. package/src/index.ts +6 -0
  37. package/src/types.ts +28 -9
  38. package/src/utils/contribution-table.ts +83 -0
  39. package/src/utils/pack-contributions.test.ts +310 -25
  40. package/src/utils/pack-contributions.ts +194 -47
  41. package/src/utils/secrets-cache.ts +22 -0
  42. package/src/utils/version-gate.test.ts +122 -0
  43. package/src/utils/version-gate.ts +109 -13
  44. package/test/e2e/smoke-install-mcp.sh +113 -0
  45. package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +1 -0
  46. package/test/fixtures/hq-pack-smoke-mcp/package.yaml +11 -0
@@ -1,26 +1,97 @@
1
1
  /**
2
- * Content-pack contribution helpers -- the single source of truth (in TS) for
3
- * the `contributes.* -> host-path` symlink mapping that `hq install` wires via
2
+ * Content-pack contribution helpers -- the SINGLE SOURCE OF TRUTH (US-003) for
3
+ * the `contributes.* -> host` mapping that `hq install` wires via
4
4
  * `core/scripts/scan-packages.sh`.
5
5
  *
6
6
  * `pack-install.ts` only INSTALLS content packs (into `core/packages/<name>/`,
7
7
  * tracked by filesystem presence -- there is no registry file). The list /
8
8
  * update / uninstall lifecycle in `commands/packs.ts` needs to reason about the
9
9
  * SAME mapping so it can report link health and cleanly un-wire a pack without
10
- * leaving dangling symlinks. That mapping is duplicated today in two places:
10
+ * leaving dangling symlinks.
11
11
  *
12
- * - core/scripts/scan-packages.sh (bash `case`, the wiring authority)
13
- * - pack-install.ts validateManifest's `subpaths` record (payload validation)
12
+ * Historically that mapping was RESTATED in five places (the scan-packages.sh
13
+ * bash `case`, pack-install.ts:validateManifest's `subpaths` record,
14
+ * `linkFor`'s switch, `contributionLinks`, and the `PackContributeKey` union),
15
+ * drift-guarded only by a substring check. US-003 collapses it to ONE
16
+ * declarative table -- `CONTRIBUTION_TABLE` below -- with a row per key
17
+ * `{ payload, host, wire }`:
14
18
  *
15
- * This module re-encodes it once for TS callers. A parity test
16
- * (`pack-contributions.test.ts`) asserts it matches scan-packages.sh's `case`
17
- * arms so the three copies cannot drift.
19
+ * - `PackContributeKey` is DERIVED from the table keys (see types.ts).
20
+ * - `linkFor` / `contributionLinks` / the `subpaths` validator READ the table.
21
+ * - `core/scripts/scan-packages.sh` reads a generated data block emitted from
22
+ * this same table (regenerate via `scripts/generate-scan-packages-table.mjs`).
23
+ *
24
+ * A parity test (`pack-contributions.test.ts`) asserts FULL key-set +
25
+ * payload-suffix + wire-mode equivalence across every surface, so the copies
26
+ * cannot drift.
27
+ *
28
+ * `wire` discriminates HOW a contribution reaches the host:
29
+ * - `symlink`: a single `ln -s` into a well-known host directory (every
30
+ * contribution type that shipped before MCP).
31
+ * - `merge`: the contribution is MERGED into a shared host config rather than
32
+ * symlinked (the `mcp` row -- registered into the Claude + Codex agent
33
+ * configs). The merge WIRING is delivered in US-004/US-005; US-003 only
34
+ * DECLARES the row as data so the table is the single source and the parity
35
+ * test guards the real invariant. `symlink`-only readers (linkFor,
36
+ * contributionLinks, the symlink validator, scan-packages) must SKIP
37
+ * `merge` rows -- they never produce a symlink.
18
38
  */
19
39
 
20
40
  import * as fs from 'fs';
21
41
  import * as path from 'path';
22
42
  import * as yaml from 'js-yaml';
23
43
  import type { PackManifest, PackContributeKey } from '../types.js';
44
+ import { CONTRIBUTION_TABLE, payloadFor, type WireMode } from './contribution-table.js';
45
+ import {
46
+ unregisterMcpServers,
47
+ type UnregisterServerResult,
48
+ type SafeWriteEnv,
49
+ type AcquireLockOptions,
50
+ } from '../commands/mcp-registration.js';
51
+
52
+ // NOTE on dependency direction: this module imports from ../commands/mcp-registration.js
53
+ // to un-register `wire:merge` (mcp) servers on uninstall. mcp-registration.ts imports
54
+ // ONLY fs/os/path/smol-toml (it does NOT import from pack-contributions.ts), so there
55
+ // is NO import cycle. If that ever changes, invert via a callback injected by packs.ts.
56
+
57
+ export { CONTRIBUTION_TABLE };
58
+ export type { WireMode };
59
+
60
+ /**
61
+ * Where a contributes key is ROUTED (US-005). Reads the single-source table's
62
+ * `wire` field — `'symlink'` keys go through `linkFor`/`contributionLinks` (a
63
+ * host `ln -s`); `'merge'` keys (e.g. `mcp`) go through the `registerMcpServers`
64
+ * seam in `commands/mcp-registration.ts` and are NEVER symlinked.
65
+ *
66
+ * This is the explicit, code-enforced discriminator behind the invariant that
67
+ * merge keys never reach the symlink path: callers fan out on the return value
68
+ * rather than relying on `linkFor` happening to return `null`.
69
+ */
70
+ export function routeContribution(key: PackContributeKey): WireMode {
71
+ return CONTRIBUTION_TABLE[key].wire;
72
+ }
73
+
74
+ /**
75
+ * The subset of a pack's declared `contributes` keys that are `wire: 'merge'`
76
+ * (e.g. `mcp`) — the keys that MUST be routed to `registerMcpServers` (US-006)
77
+ * and MUST NOT be symlinked. Unknown keys and non-array/empty values are
78
+ * ignored, mirroring `contributionLinks`. Returned in `contributes` iteration
79
+ * order, de-duplicated.
80
+ */
81
+ export function mergeKeys(
82
+ contributes: Partial<Record<PackContributeKey, string[]>>,
83
+ ): PackContributeKey[] {
84
+ const out: PackContributeKey[] = [];
85
+ for (const [key, items] of Object.entries(contributes) as [
86
+ PackContributeKey,
87
+ unknown,
88
+ ][]) {
89
+ if (!(key in CONTRIBUTION_TABLE)) continue; // unknown key -> ignore
90
+ if (!Array.isArray(items) || items.length === 0) continue;
91
+ if (routeContribution(key) === 'merge' && !out.includes(key)) out.push(key);
92
+ }
93
+ return out;
94
+ }
24
95
 
25
96
  /** A single symlink a pack contributes: dst (host path) -> src (inside pack). */
26
97
  export interface WiredLink {
@@ -37,58 +108,40 @@ export type LinkStatus =
37
108
  | 'foreign'; // dst exists but points elsewhere or is a real file (collision)
38
109
 
39
110
  /**
40
- * Map one contributes entry to its source/host paths. MUST stay in lockstep
41
- * with scan-packages.sh:wire_one_package and pack-install.ts:validateManifest.
111
+ * Map one SYMLINK contributes entry to its source/host paths, READING the
112
+ * single-source `CONTRIBUTION_TABLE` (US-003). The src is the expanded pack
113
+ * payload; the dst is `<host-dir>/<payload-basename>` so per-key suffixes
114
+ * (`.md`, `.sh`, none) carry through.
115
+ *
116
+ * Returns `null` for a `wire: 'merge'` key (e.g. `mcp`): a merge contribution
117
+ * is not a symlink and has no `WiredLink`. Callers that only handle symlinks
118
+ * (contributionLinks/unwirePack) skip those keys.
42
119
  */
43
120
  function linkFor(
44
121
  hqRoot: string,
45
122
  packDir: string,
46
123
  key: PackContributeKey,
47
124
  item: string,
48
- ): WiredLink {
49
- let srcRel: string;
50
- let dstRel: string;
51
- switch (key) {
52
- case 'workers':
53
- srcRel = path.join('workers', item);
54
- dstRel = path.join('core', 'workers', 'public', item);
55
- break;
56
- case 'knowledge':
57
- srcRel = path.join('knowledge', item);
58
- dstRel = path.join('core', 'knowledge', 'public', item);
59
- break;
60
- case 'skills':
61
- srcRel = path.join('skills', item);
62
- dstRel = path.join('.claude', 'skills', item);
63
- break;
64
- case 'commands':
65
- srcRel = path.join('commands', `${item}.md`);
66
- dstRel = path.join('.claude', 'commands', `${item}.md`);
67
- break;
68
- case 'hooks':
69
- srcRel = path.join('hooks', `${item}.sh`);
70
- dstRel = path.join('.claude', 'hooks', `${item}.sh`);
71
- break;
72
- case 'policies':
73
- srcRel = path.join('policies', `${item}.md`);
74
- dstRel = path.join('core', 'policies', `${item}.md`);
75
- break;
76
- case 'scripts':
77
- srcRel = path.join('scripts', item);
78
- dstRel = path.join('core', 'scripts', item);
79
- break;
80
- }
125
+ ): WiredLink | null {
126
+ const row = CONTRIBUTION_TABLE[key];
127
+ if (row.wire !== 'symlink') return null;
128
+ const payloadRel = payloadFor(key, item); // e.g. commands/foo.md
129
+ // dst keeps the payload's basename (so the suffix carries through) under the
130
+ // host directory declared in the table.
131
+ const dstRel = path.join(row.host, path.basename(payloadRel));
81
132
  return {
82
133
  key,
83
134
  item,
84
- src: path.join(packDir, srcRel),
135
+ src: path.join(packDir, payloadRel),
85
136
  dst: path.join(hqRoot, dstRel),
86
137
  };
87
138
  }
88
139
 
89
140
  /**
90
- * Every symlink a pack's `contributes` block declares. Empty subfields and
91
- * non-array values are ignored, mirroring scan-packages.sh.
141
+ * Every SYMLINK a pack's `contributes` block declares. Empty subfields and
142
+ * non-array values are ignored, mirroring scan-packages.sh. `wire: 'merge'`
143
+ * keys (e.g. `mcp`) produce no symlink and are skipped here -- their host
144
+ * effect (a config merge) is handled separately (US-004/US-005).
92
145
  */
93
146
  export function contributionLinks(
94
147
  hqRoot: string,
@@ -100,10 +153,17 @@ export function contributionLinks(
100
153
  PackContributeKey,
101
154
  unknown,
102
155
  ][]) {
156
+ if (!(key in CONTRIBUTION_TABLE)) continue; // unknown key -> ignore
103
157
  if (!Array.isArray(items)) continue;
158
+ // INVARIANT (US-005): merge keys are NEVER symlinked. They route to
159
+ // registerMcpServers (commands/mcp-registration.ts) instead. Skip them
160
+ // explicitly here -- not just via linkFor returning null -- so the symlink
161
+ // path provably excludes them.
162
+ if (routeContribution(key) === 'merge') continue;
104
163
  for (const item of items) {
105
164
  if (typeof item !== 'string' || item.length === 0) continue;
106
- links.push(linkFor(hqRoot, packDir, key, item));
165
+ const link = linkFor(hqRoot, packDir, key, item);
166
+ if (link) links.push(link); // null for merge keys -- skip
107
167
  }
108
168
  }
109
169
  return links;
@@ -204,6 +264,23 @@ export function listInstalledPacks(hqRoot: string): InstalledPack[] {
204
264
  return out;
205
265
  }
206
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
+
207
284
  // ---------------------------------------------------------------------------
208
285
  // Un-wiring (the uninstall guarantee)
209
286
  // ---------------------------------------------------------------------------
@@ -256,6 +333,76 @@ export function unwirePack(
256
333
  return result;
257
334
  }
258
335
 
336
+ // ---------------------------------------------------------------------------
337
+ // MCP un-wiring (US-009) — the `wire: 'merge'` parallel to symlink un-wiring.
338
+ //
339
+ // `unwirePack` above only removes SYMLINKS: it iterates `contributionLinks`, which
340
+ // SKIPS every `wire: 'merge'` key (e.g. `mcp`). So a pack's MCP servers — registered
341
+ // into ~/.claude.json + ~/.codex/config.toml, NOT symlinked — are INVISIBLE to the
342
+ // symlink unwire and would leak after uninstall. `unwirePackMcp` is the parallel
343
+ // path: for each name in `contributes.mcp` it calls `unregisterMcpServers` (US-009),
344
+ // which removes ONLY entries whose `_hqPack` provenance matches THIS pack and
345
+ // skip-and-warns on any foreign/unstamped same-named entry.
346
+ //
347
+ // Kept as a SEPARATE, composable call from `unwirePack` (not folded into it) so the
348
+ // existing `unwirePack` signature + tests stay intact and each path is testable in
349
+ // isolation. The uninstall command composes both (symlink unwire + MCP unwire).
350
+ // ---------------------------------------------------------------------------
351
+
352
+ /** The result of {@link unwirePackMcp}: the per-server un-registration outcomes (empty when no `mcp` keys). */
353
+ export interface UnwireMcpResult {
354
+ /** One {@link UnregisterServerResult} per declared `contributes.mcp` server, in declaration order. */
355
+ servers: UnregisterServerResult[];
356
+ }
357
+
358
+ /** Tuning passthrough for {@link unwirePackMcp} (tests inject a tmpdir `env.home` + tiny lock timeouts). */
359
+ export interface UnwirePackMcpOptions {
360
+ /** Injectable env — tests MUST pass a tmpdir `home` so the real ~/.claude.json / ~/.codex are untouched. */
361
+ env?: Partial<SafeWriteEnv>;
362
+ /** Lock tuning (tests use tiny timeouts). */
363
+ lock?: AcquireLockOptions;
364
+ /** Backup timestamp override (deterministic tests). */
365
+ stamp?: string;
366
+ }
367
+
368
+ /**
369
+ * Un-register the `wire: 'merge'` (MCP) servers a pack declares — the parallel to
370
+ * {@link unwirePack}'s symlink removal. For each name in `mergeKeys(contributes)` →
371
+ * `contributes.<mergeKey>` (today only `mcp`), this delegates to
372
+ * {@link unregisterMcpServers}, which removes ONLY entries stamped with THIS pack's
373
+ * `_hqPack` provenance and SKIPS-AND-WARNS on a foreign/unstamped same-named entry.
374
+ *
375
+ * No-ops cleanly when the pack declares no merge keys (returns `{ servers: [] }`),
376
+ * when the runtime config is absent (Codex-less host → first-class skip; missing
377
+ * ~/.claude.json → `absent`), and on a re-run (already-removed → `absent`). It does
378
+ * NOT throw on a foreign entry — uninstall of one pack must not abort on a server
379
+ * another pack (or the user) owns.
380
+ *
381
+ * @param packName the pack being uninstalled — the provenance to match
382
+ * @param contributes the pack's `contributes` block (its `mcp` list drives removal)
383
+ */
384
+ export function unwirePackMcp(
385
+ packName: string,
386
+ contributes: Partial<Record<PackContributeKey, string[]>>,
387
+ options?: UnwirePackMcpOptions,
388
+ ): UnwireMcpResult {
389
+ const servers: UnregisterServerResult[] = [];
390
+ for (const key of mergeKeys(contributes)) {
391
+ const names = (contributes[key] ?? []).filter(
392
+ (n): n is string => typeof n === 'string' && n.length > 0,
393
+ );
394
+ if (names.length === 0) continue;
395
+ servers.push(
396
+ ...unregisterMcpServers(packName, names, {
397
+ env: options?.env,
398
+ lock: options?.lock,
399
+ stamp: options?.stamp,
400
+ }),
401
+ );
402
+ }
403
+ return { servers };
404
+ }
405
+
259
406
  // ---------------------------------------------------------------------------
260
407
  // Host introspection: hqVersion + recommended_packages catalog
261
408
  // ---------------------------------------------------------------------------
@@ -142,6 +142,28 @@ export function writeCache(
142
142
  }
143
143
  }
144
144
 
145
+ /**
146
+ * List the scope UIDs (`cmp_*` / `prs_*` subdirectories) that currently have a
147
+ * secrets-cache directory on disk. Used by offline callers (e.g. install-time MCP
148
+ * registration) that have no `--company` flag and no network token and so cannot
149
+ * resolve a single active company UID up front: they instead probe every cached
150
+ * scope for a given secret name. Returns `[]` when the cache root is absent or
151
+ * unreadable (the desired graceful-deferral behavior — no scopes, no hits).
152
+ */
153
+ export function listSecretCacheScopes(): string[] {
154
+ try {
155
+ return fs
156
+ .readdirSync(CACHE_DIR, { withFileTypes: true })
157
+ .filter((e) => e.isDirectory())
158
+ .map((e) => e.name)
159
+ // Only real entity scopes (cmp_*/prs_*); validateInputs in readCache also
160
+ // rejects anything with `/` or `..`, so this is belt-and-suspenders.
161
+ .filter((name) => !name.startsWith("."));
162
+ } catch {
163
+ return [];
164
+ }
165
+ }
166
+
145
167
  export function removeCacheEntry(companyUid: string, name: string): void {
146
168
  if (!validateInputs(companyUid, name)) return;
147
169
  const filePath = path.join(CACHE_DIR, companyUid, name);
@@ -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
  });
@@ -29,13 +29,17 @@
29
29
  */
30
30
 
31
31
  import { spawnSync } from "node:child_process";
32
+ import { readFileSync } from "node:fs";
33
+ import path from "node:path";
34
+ import { fileURLToPath } from "node:url";
32
35
  import chalk from "chalk";
33
- import { CLI_VERSION } from "../cli-version.js";
36
+ import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
34
37
  import { DEFAULT_VAULT_API_URL } from "./cognito-session.js";
35
38
 
36
39
  const CLIENT_ID = "hq-cli";
37
40
  const ENDPOINT_PATH = "/v1/client-version/check";
38
41
  const FETCH_TIMEOUT_MS = 3_000;
42
+ const LATEST_PACKAGE_SPEC = `${CLI_NAME}@latest`;
39
43
 
40
44
  interface VersionCheckResponse {
41
45
  clientId: string;
@@ -53,6 +57,53 @@ function isOptedOut(): boolean {
53
57
  return process.env.HQ_NO_UPDATE_CHECK === "1";
54
58
  }
55
59
 
60
+ function findRunningPackageRoot(): string | null {
61
+ let dir = path.dirname(fileURLToPath(import.meta.url));
62
+ while (true) {
63
+ try {
64
+ const pkg = JSON.parse(
65
+ readFileSync(path.join(dir, "package.json"), "utf-8"),
66
+ ) as { name?: unknown };
67
+ if (pkg.name === CLI_NAME) return dir;
68
+ } catch {
69
+ // Keep walking; compiled installs usually start under dist/.
70
+ }
71
+
72
+ const parent = path.dirname(dir);
73
+ if (parent === dir) return null;
74
+ dir = parent;
75
+ }
76
+ }
77
+
78
+ export function npmPrefixFromPackageDir(pkgDir: string): string | null {
79
+ const normalized = pkgDir.replace(/\\/g, "/").replace(/\/+$/, "");
80
+ const segments = normalized.split("/");
81
+ const nodeModulesIndex = segments.lastIndexOf("node_modules");
82
+ if (nodeModulesIndex === -1) return null;
83
+
84
+ const prefixEnd =
85
+ segments[nodeModulesIndex - 1] === "lib"
86
+ ? nodeModulesIndex - 1
87
+ : nodeModulesIndex;
88
+ const prefix = segments.slice(0, prefixEnd).join("/");
89
+ if (prefix === "" && normalized.startsWith("/")) return "/";
90
+ return prefix || null;
91
+ }
92
+
93
+ export function resolveRunningPrefix(): string | null {
94
+ try {
95
+ const pkgRoot = findRunningPackageRoot();
96
+ if (!pkgRoot) return null;
97
+ return npmPrefixFromPackageDir(pkgRoot);
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ export function buildPrefixedInstallArgv(prefix: string): string[] {
104
+ return ["install", "-g", "--prefix", prefix, LATEST_PACKAGE_SPEC];
105
+ }
106
+
56
107
  /**
57
108
  * Hit POST /v1/client-version/check. Returns the parsed body on 200, or
58
109
  * `null` on any failure (caller treats as "no gate"). Tight 3s timeout —
@@ -95,13 +146,10 @@ async function fetchVersionDecision(): Promise<VersionCheckResponse | null> {
95
146
  * forcing a re-invocation would run twice on the same process and feel
96
147
  * janky; instead we print a clear "rerun your command" message and exit.
97
148
  */
98
- function performUpdate(
99
- command: string,
100
- ): { ok: boolean; detail?: string } {
101
- const parts = command.split(/\s+/).filter(Boolean);
102
- if (parts.length === 0) return { ok: false, detail: "empty command" };
103
- const cmd = parts[0]!;
104
- const args = parts.slice(1);
149
+ type UpdateResult = { ok: boolean; detail?: string };
150
+ type UpdateRunner = (cmd: string, args: string[]) => UpdateResult;
151
+
152
+ function runUpdateCommand(cmd: string, args: string[]): UpdateResult {
105
153
  try {
106
154
  const result = spawnSync(cmd, args, { stdio: "inherit" });
107
155
  if (result.status !== 0) {
@@ -116,6 +164,25 @@ function performUpdate(
116
164
  }
117
165
  }
118
166
 
167
+ function performUpdateCommand(
168
+ cmd: string,
169
+ args: string[],
170
+ runner: UpdateRunner = runUpdateCommand,
171
+ ): UpdateResult {
172
+ return runner(cmd, args);
173
+ }
174
+
175
+ function performUpdate(
176
+ command: string,
177
+ runner: UpdateRunner = runUpdateCommand,
178
+ ): UpdateResult {
179
+ const parts = command.split(/\s+/).filter(Boolean);
180
+ if (parts.length === 0) return { ok: false, detail: "empty command" };
181
+ const cmd = parts[0]!;
182
+ const args = parts.slice(1);
183
+ return performUpdateCommand(cmd, args, runner);
184
+ }
185
+
119
186
  /**
120
187
  * Soft notify when the server says we're below `latestVersion` but still ≥
121
188
  * `minVersion`. Single chalk-yellow line on stderr; never blocks.
@@ -140,7 +207,14 @@ function nudgeUpdateRecommended(decision: VersionCheckResponse): void {
140
207
  * 0 — update succeeded; user must rerun their command
141
208
  * 75 — update failed (EX_TEMPFAIL; common for sudo/EACCES on system npm)
142
209
  */
143
- function enforceUpdateRequired(decision: VersionCheckResponse): never {
210
+ function enforceUpdateRequired(
211
+ decision: VersionCheckResponse,
212
+ deps: {
213
+ performUpdateString?: (command: string) => UpdateResult;
214
+ resolvePrefix?: () => string | null;
215
+ runner?: UpdateRunner;
216
+ } = {},
217
+ ): never {
144
218
  const banner = chalk.red.bold(
145
219
  `✗ hq-cli ${decision.currentVersion} is below the minimum required version (${decision.minVersion}).`,
146
220
  );
@@ -148,7 +222,8 @@ function enforceUpdateRequired(decision: VersionCheckResponse): never {
148
222
  if (decision.message) console.error(chalk.dim(` ${decision.message}`));
149
223
 
150
224
  const command = decision.updateCommand;
151
- if (!command) {
225
+ const prefix = (deps.resolvePrefix ?? resolveRunningPrefix)();
226
+ if (!command && !prefix) {
152
227
  console.error(
153
228
  chalk.red(
154
229
  " No updateCommand provided by hq-pro — see https://hq.indigo.ai/docs/cli-update for manual steps.",
@@ -160,13 +235,28 @@ function enforceUpdateRequired(decision: VersionCheckResponse): never {
160
235
  process.exit(75);
161
236
  }
162
237
 
163
- console.error(chalk.dim(` Running: ${command}`));
164
- const result = performUpdate(command);
238
+ const runner = deps.runner ?? runUpdateCommand;
239
+ const result = prefix
240
+ ? (() => {
241
+ const args = buildPrefixedInstallArgv(prefix);
242
+ console.error(chalk.dim(` Installing into npm prefix: ${prefix}`));
243
+ console.error(chalk.dim(` Running: npm ${args.join(" ")}`));
244
+ return performUpdateCommand("npm", args, runner);
245
+ })()
246
+ : (() => {
247
+ console.error(chalk.dim(` Running: ${command}`));
248
+ return deps.performUpdateString
249
+ ? deps.performUpdateString(command!)
250
+ : performUpdate(command!, runner);
251
+ })();
165
252
  if (!result.ok) {
166
253
  console.error(
167
254
  chalk.red(`✗ Update failed${result.detail ? `: ${result.detail}` : ""}.`),
168
255
  );
169
- console.error(chalk.dim(` Try manually: ${command}`));
256
+ const manual = prefix
257
+ ? `npm ${buildPrefixedInstallArgv(prefix).join(" ")}`
258
+ : command!;
259
+ console.error(chalk.dim(` Try manually: ${manual}`));
170
260
  process.exit(75);
171
261
  }
172
262
 
@@ -215,5 +305,11 @@ export const __test__ = {
215
305
  CLIENT_ID,
216
306
  ENDPOINT_PATH,
217
307
  FETCH_TIMEOUT_MS,
308
+ buildPrefixedInstallArgv,
309
+ enforceUpdateRequired,
310
+ npmPrefixFromPackageDir,
218
311
  performUpdate,
312
+ performUpdateCommand,
313
+ runUpdateCommand,
314
+ resolveRunningPrefix,
219
315
  };