@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
package/src/sentry.ts CHANGED
@@ -1,9 +1,29 @@
1
1
  import * as Sentry from "@sentry/node";
2
+ import type { ErrorEvent, EventHint } from "@sentry/node";
2
3
  import { BUNDLED_DSN } from "./sentry-dsn.generated.js";
3
4
  import { beforeSend } from "./sentry-before-send.js";
4
5
  import { beforeBreadcrumb } from "./utils/breadcrumb-buffer.js";
5
6
  import { CLI_VERSION } from "./cli-version.js";
6
7
  import { getCachedSentryUser } from "./utils/sentry-identity.js";
8
+ import { isEpipe } from "./utils/epipe.js";
9
+
10
+ /**
11
+ * Drop broken-pipe (EPIPE) crashes before scrubbing/send. A closed downstream
12
+ * reader (`hq … | head`, `source <(hq …)`, a parent that exited) is normal
13
+ * Unix behavior with no user-facing degradation — never a defect to report.
14
+ * This is the path-independent catch-all for HQ-6B: whichever way an EPIPE
15
+ * surfaces (a synchronous throw into the command catch, an async stream
16
+ * 'error', or a write outside the top-level try), it never ships a fatal. The
17
+ * clean-exit handlers in index.ts still keep exit code 0 for the common paths.
18
+ * Consistent with the swallow-EPIPE posture established in #138.
19
+ */
20
+ export function epipeAwareBeforeSend(
21
+ event: ErrorEvent,
22
+ hint: EventHint,
23
+ ): ErrorEvent | null {
24
+ if (isEpipe(hint?.originalException)) return null;
25
+ return beforeSend(event, hint);
26
+ }
7
27
 
8
28
  export function initSentry(): void {
9
29
  const dsn = BUNDLED_DSN || process.env.SENTRY_DSN;
@@ -18,7 +38,7 @@ export function initSentry(): void {
18
38
  initialScope: {
19
39
  tags: { repo: "hq-cli" },
20
40
  },
21
- beforeSend,
41
+ beforeSend: epipeAwareBeforeSend,
22
42
  beforeBreadcrumb,
23
43
  });
24
44
  // Attribute events to the logged-in HQ identity (best-effort; null when not
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;
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isEpipe } from "./epipe.js";
3
+
4
+ describe("isEpipe", () => {
5
+ it("matches a real Node EPIPE error", () => {
6
+ const err = Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
7
+ expect(isEpipe(err)).toBe(true);
8
+ });
9
+
10
+ it("matches a plain object carrying code EPIPE", () => {
11
+ expect(isEpipe({ code: "EPIPE" })).toBe(true);
12
+ });
13
+
14
+ it("does NOT match other errno codes (so real faults still report)", () => {
15
+ expect(isEpipe(Object.assign(new Error("disk full"), { code: "ENOSPC" }))).toBe(
16
+ false,
17
+ );
18
+ expect(isEpipe(Object.assign(new Error("nope"), { code: "EACCES" }))).toBe(false);
19
+ expect(isEpipe(new Error("plain error with no code"))).toBe(false);
20
+ });
21
+
22
+ it("does NOT match non-error values", () => {
23
+ expect(isEpipe(null)).toBe(false);
24
+ expect(isEpipe(undefined)).toBe(false);
25
+ expect(isEpipe("EPIPE")).toBe(false);
26
+ expect(isEpipe(42)).toBe(false);
27
+ });
28
+ });
@@ -0,0 +1,29 @@
1
+ // src/utils/epipe.ts
2
+ //
3
+ // Classify the "broken pipe" (EPIPE) error: a downstream reader closed the
4
+ // pipe before the CLI finished writing to stdout/stderr. This is normal,
5
+ // expected Unix behavior — `hq … | head`, `source <(hq …)`, or a parent
6
+ // process that exits while `hq` is still printing — NOT an HQ code defect.
7
+ //
8
+ // HQ-6B: a synchronous `write EPIPE` thrown from `console.log` (the Sentry
9
+ // console-instrumentation wraps it) propagated out of an awaited command into
10
+ // the CLI's top-level `catch`, which captured it via `Sentry.captureException`
11
+ // and shipped a fatal — even though the only thing that happened is the
12
+ // consumer of `hq`'s output went away. The async path (an emitted stream
13
+ // 'error' event) was already handled by the stdout/stderr listeners in
14
+ // index.ts; this predicate closes the synchronous-throw path too, mirroring
15
+ // the environmental-error carve-out (HQ-CLI-2).
16
+
17
+ /**
18
+ * True when `err` is a Node EPIPE error — the pipe `hq` was writing to was
19
+ * closed by its reader. Callers should treat this as a clean, expected exit
20
+ * (code 0) and SKIP Sentry capture: there is no defect and no user-facing
21
+ * degradation to fix.
22
+ */
23
+ export function isEpipe(err: unknown): boolean {
24
+ return (
25
+ typeof err === "object" &&
26
+ err !== null &&
27
+ (err as NodeJS.ErrnoException).code === "EPIPE"
28
+ );
29
+ }
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isInterceptedProcessExit } from "./intercepted-process-exit.js";
3
+
4
+ describe("isInterceptedProcessExit", () => {
5
+ it("matches the fuzz harness's intercepted-process.exit marker error", () => {
6
+ expect(isInterceptedProcessExit(new Error("fuzz-intercepted-process-exit"))).toBe(true);
7
+ });
8
+
9
+ it("matches when the marker is wrapped in a longer message", () => {
10
+ expect(
11
+ isInterceptedProcessExit(new Error("audit: fuzz-intercepted-process-exit code=1")),
12
+ ).toBe(true);
13
+ });
14
+
15
+ it("matches a plain object carrying the marker message", () => {
16
+ expect(isInterceptedProcessExit({ message: "fuzz-intercepted-process-exit" })).toBe(true);
17
+ });
18
+
19
+ it("matches a bare string carrying the marker", () => {
20
+ expect(isInterceptedProcessExit("fuzz-intercepted-process-exit")).toBe(true);
21
+ });
22
+
23
+ it("does NOT match genuine errors (so real faults still report)", () => {
24
+ expect(isInterceptedProcessExit(new Error("boom"))).toBe(false);
25
+ expect(isInterceptedProcessExit(new Error("ENOENT: no such file or directory"))).toBe(false);
26
+ expect(isInterceptedProcessExit(Object.assign(new Error("nope"), { code: "EACCES" }))).toBe(
27
+ false,
28
+ );
29
+ });
30
+
31
+ it("does NOT match non-error values", () => {
32
+ expect(isInterceptedProcessExit(null)).toBe(false);
33
+ expect(isInterceptedProcessExit(undefined)).toBe(false);
34
+ expect(isInterceptedProcessExit(42)).toBe(false);
35
+ expect(isInterceptedProcessExit({})).toBe(false);
36
+ });
37
+ });
@@ -0,0 +1,36 @@
1
+ // src/utils/intercepted-process-exit.ts
2
+ //
3
+ // Classify the "intercepted process.exit" synthetic error (HQ-CLI-3).
4
+ //
5
+ // Security/audit FUZZ harnesses (e.g. `/opt/audit/fuzz-exports.js`) monkey-patch
6
+ // `process.exit` to THROW a marker instead of actually terminating, so the
7
+ // fuzzer can keep exercising a binary's entrypoints. When such a harness drives
8
+ // `hq` (e.g. `hq <unknown-command>`), commander legitimately calls
9
+ // `process.exit(1)` — and the patched exit turns that normal CLI control-flow
10
+ // into a thrown `Error: fuzz-intercepted-process-exit` that propagates out of
11
+ // `program.parseAsync()` into the CLI's top-level catch, which then shipped it to
12
+ // Sentry as a fatal.
13
+ //
14
+ // This is a test-harness artifact, NOT an hq-cli defect: a real user's
15
+ // `process.exit` exits the process and nothing is ever thrown or captured.
16
+ // There is no user-facing degradation to fix, so the top-level catch skips
17
+ // Sentry capture for it — the genuine "expected, non-actionable" carve-out,
18
+ // mirroring the EPIPE (HQ-6B) and environmental-FS (HQ-CLI-2) carve-outs.
19
+
20
+ /** The marker a fuzz/audit harness throws in place of a real `process.exit`. */
21
+ const INTERCEPTED_PROCESS_EXIT_MARKER = "fuzz-intercepted-process-exit";
22
+
23
+ /**
24
+ * True when `err` is a fuzz/audit harness's intercepted-`process.exit` marker
25
+ * rather than a genuine fault. Callers should SKIP Sentry capture (no defect, no
26
+ * user-facing degradation) while preserving the intended exit code.
27
+ */
28
+ export function isInterceptedProcessExit(err: unknown): boolean {
29
+ if (typeof err === "object" && err !== null) {
30
+ const message = (err as { message?: unknown }).message;
31
+ if (typeof message === "string" && message.includes(INTERCEPTED_PROCESS_EXIT_MARKER)) {
32
+ return true;
33
+ }
34
+ }
35
+ return typeof err === "string" && err.includes(INTERCEPTED_PROCESS_EXIT_MARKER);
36
+ }
@@ -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
  });
@@ -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
  };