@indigoai-us/hq-cli 5.25.0 → 5.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { Command } from 'commander';
8
8
  import chalk from 'chalk';
9
- import { findHqRoot } from '../utils/hq-root.js';
9
+ import { resolveDefaultHqRoot } from '../utils/cognito-session.js';
10
10
  import { readRegistry } from '../utils/registry.js';
11
11
  import { loadCachedTokens, isExpiring } from '@indigoai-us/hq-cloud';
12
12
  import {
@@ -34,7 +34,7 @@ export function registerPackageListCommand(parent: Command): void {
34
34
  }
35
35
 
36
36
  async function listPackages(): Promise<void> {
37
- const hqRoot = findHqRoot();
37
+ const hqRoot = resolveDefaultHqRoot({ onMissing: 'throw' });
38
38
  const installed = readRegistry(hqRoot);
39
39
 
40
40
  // Print installed packages
@@ -10,7 +10,7 @@ import * as fs from 'fs';
10
10
  import * as path from 'path';
11
11
  import { Command } from 'commander';
12
12
  import chalk from 'chalk';
13
- import { findHqRoot } from '../utils/hq-root.js';
13
+ import { resolveDefaultHqRoot } from '../utils/cognito-session.js';
14
14
  import { removeFromRegistry, readRegistry } from '../utils/registry.js';
15
15
 
16
16
  export function registerPackageRemoveCommand(parent: Command): void {
@@ -31,7 +31,7 @@ export function registerPackageRemoveCommand(parent: Command): void {
31
31
  }
32
32
 
33
33
  async function removePackage(slug: string): Promise<void> {
34
- const hqRoot = findHqRoot();
34
+ const hqRoot = resolveDefaultHqRoot({ onMissing: 'throw' });
35
35
  const installDir = path.resolve(hqRoot, 'packages', 'installed', slug);
36
36
 
37
37
  // Verify it is actually installed
@@ -13,7 +13,7 @@ import { execSync } from 'child_process';
13
13
  import { Command } from 'commander';
14
14
  import chalk from 'chalk';
15
15
  import { ensureCognitoToken } from '../utils/cognito-session.js';
16
- import { findHqRoot } from '../utils/hq-root.js';
16
+ import { resolveDefaultHqRoot } from '../utils/cognito-session.js';
17
17
  import {
18
18
  getRegistryUrl,
19
19
  RegistryClient,
@@ -43,7 +43,7 @@ export function registerPackageUpdateCommand(parent: Command): void {
43
43
  }
44
44
 
45
45
  async function updatePackages(slug?: string): Promise<void> {
46
- const hqRoot = findHqRoot();
46
+ const hqRoot = resolveDefaultHqRoot({ onMissing: 'throw' });
47
47
  const entries = readRegistry(hqRoot);
48
48
 
49
49
  if (entries.length === 0) {
@@ -16,7 +16,7 @@ import { execSync } from 'child_process';
16
16
  import { Command } from 'commander';
17
17
  import chalk from 'chalk';
18
18
  import simpleGit from 'simple-git';
19
- import { findHqRoot } from '../utils/hq-root.js';
19
+ import { resolveDefaultHqRoot } from '../utils/cognito-session.js';
20
20
  import { ensureCognitoToken } from '../utils/cognito-session.js';
21
21
 
22
22
  // ─── Types ──────────────────────────────────────────────────────────────────
@@ -433,7 +433,7 @@ export function registerTeamSyncCommand(program: Command): void {
433
433
  .action(
434
434
  async (options: { team?: string; dryRun?: boolean }) => {
435
435
  try {
436
- const hqRoot = findHqRoot();
436
+ const hqRoot = resolveDefaultHqRoot({ onMissing: 'throw' });
437
437
 
438
438
  // 1. Discover team directories
439
439
  let teamDirs = discoverTeamDirs(hqRoot);
package/src/index.ts CHANGED
@@ -40,6 +40,10 @@ import {
40
40
  maybeWarnNewVersion,
41
41
  refreshVersionCache,
42
42
  } from "./utils/version-check.js";
43
+ import {
44
+ enforceVersionGate,
45
+ shouldSkipGate,
46
+ } from "./utils/version-gate.js";
43
47
  import { CLI_VERSION } from "./cli-version.js";
44
48
 
45
49
  // Swallow EPIPE when a downstream reader (e.g. `source <(…)`, `| head`) closes the pipe early.
@@ -157,6 +161,14 @@ registerSignalsCommand(program);
157
161
  message: sanitizeArgv(process.argv.slice(2)).join(" "),
158
162
  level: "info",
159
163
  });
164
+ // Hard version gate: ask hq-pro whether this CLI is below the floor and
165
+ // auto-update if so (exits the process on update). Skipped for inspection
166
+ // flags (`--version`, `--help`) so users debugging a broken install can
167
+ // still introspect what they have. Silent on any failure — never blocks
168
+ // the CLI on a flaky network or hq-pro hiccup. See `utils/version-gate.ts`.
169
+ if (!shouldSkipGate(process.argv)) {
170
+ await enforceVersionGate();
171
+ }
160
172
  await program.parseAsync();
161
173
  } catch (err) {
162
174
  Sentry.captureException(err);
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { describe, it, expect, beforeEach, afterEach } from "vitest";
7
- import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
7
+ import { mkdtempSync, realpathSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
8
8
  import { tmpdir } from "node:os";
9
9
  import { join } from "node:path";
10
10
 
@@ -114,6 +114,61 @@ describe("resolveDefaultHqRoot", () => {
114
114
 
115
115
  expect(resolveDefaultHqRoot()).toBe(explicit);
116
116
  });
117
+
118
+ // ── onMissing: throw vs fallback ──────────────────────────────────────
119
+ //
120
+ // Default behavior (no opts) is fallback to ~/hq — preserves the
121
+ // pre-dedup module-load contract of `DEFAULT_HQ_ROOT = resolveDefaultHqRoot()`.
122
+ // Module-management commands (pkg-*, team-sync) opt into throw mode so a
123
+ // user invoking the command from outside an HQ gets a clear error instead
124
+ // of a silent default-path miss.
125
+ it("onMissing: 'throw' throws when no HQ root is found above cwd", () => {
126
+ const stranded = join(tmpRoot, "stranded");
127
+ mkdirSync(stranded, { recursive: true });
128
+ process.chdir(stranded);
129
+
130
+ expect(() => resolveDefaultHqRoot({ onMissing: "throw" })).toThrow(
131
+ /Could not find HQ root/i,
132
+ );
133
+ });
134
+
135
+ it("onMissing: 'throw' still honors $HQ_ROOT even though dir wouldn't otherwise be detected", () => {
136
+ // HQ_ROOT is treated as an explicit assertion by the caller — no
137
+ // walk-and-throw applies. Module-management commands run with
138
+ // HQ_ROOT=/abs/path should succeed regardless of cwd.
139
+ const explicit = join(tmpRoot, "explicit");
140
+ mkdirSync(explicit, { recursive: true });
141
+ process.env.HQ_ROOT = explicit;
142
+ process.chdir(tmpRoot);
143
+
144
+ expect(resolveDefaultHqRoot({ onMissing: "throw" })).toBe(explicit);
145
+ });
146
+
147
+ it("onMissing: 'throw' still resolves when cwd IS an HQ root", () => {
148
+ const hqDir = join(tmpRoot, "hq");
149
+ mkdirSync(join(hqDir, "companies"), { recursive: true });
150
+ writeFileSync(join(hqDir, "core.yaml"), "version: 14.1.1\n");
151
+
152
+ process.chdir(hqDir);
153
+ // realpath both sides: macOS /tmp -> /private/var/folders symlink would
154
+ // otherwise produce a spurious mismatch (same root cause as the two
155
+ // pre-existing failing `priority 2:` tests above).
156
+ expect(realpathSync(resolveDefaultHqRoot({ onMissing: "throw" }))).toBe(
157
+ realpathSync(hqDir),
158
+ );
159
+ });
160
+
161
+ it("default (no opts) falls back to ~/hq — back-compat with module-load DEFAULT_HQ_ROOT", () => {
162
+ // Regression pin: any code that imports `DEFAULT_HQ_ROOT` (resolved at
163
+ // module load) MUST NOT throw if the loader's cwd is outside an HQ.
164
+ // This contract pre-dates the onMissing parameter and several commands
165
+ // depend on it (commander.js .option() registration time).
166
+ const stranded = join(tmpRoot, "stranded");
167
+ mkdirSync(stranded, { recursive: true });
168
+ process.chdir(stranded);
169
+
170
+ expect(() => resolveDefaultHqRoot()).not.toThrow();
171
+ });
117
172
  });
118
173
 
119
174
  describe("CLI_CLIENT_INFO + buildVaultConfig", () => {
@@ -80,13 +80,40 @@ export const DEFAULT_VAULT_API_URL =
80
80
  * value at registration time, which matches the user's actual cwd at process
81
81
  * start. Re-importable as a function for tests and command-time resolution.
82
82
  */
83
- export function resolveDefaultHqRoot(): string {
83
+ /**
84
+ * Resolve the HQ root directory.
85
+ *
86
+ * Resolution order:
87
+ * 1. $HQ_ROOT env var (treated as an explicit assertion by the caller)
88
+ * 2. Walk up from cwd looking for `core.yaml` AND `companies/` siblings
89
+ * 3. Fall back to `~/hq` (or throw, per `opts.onMissing`)
90
+ *
91
+ * `opts.onMissing` controls the third arm:
92
+ * - `'fallback'` (default) — return `~/hq` if no HQ root is found above cwd.
93
+ * This preserves the module-load contract of `DEFAULT_HQ_ROOT`, which
94
+ * several commander.js `.option()` callers pin at registration time.
95
+ * - `'throw'` — throw with a user-actionable error. Used by module-management
96
+ * commands (pkg-install, pkg-remove, pkg-list, pkg-update, team-sync) where
97
+ * a silent default-path miss would silently target the wrong directory.
98
+ *
99
+ * `$HQ_ROOT` short-circuits both arms — if the env var is set, it's used
100
+ * as-is regardless of `onMissing`.
101
+ */
102
+ export function resolveDefaultHqRoot(opts: {
103
+ onMissing?: "throw" | "fallback";
104
+ } = {}): string {
84
105
  if (process.env.HQ_ROOT) return path.resolve(process.env.HQ_ROOT);
85
106
  let cur = path.resolve(process.cwd());
86
107
  while (cur !== path.dirname(cur)) {
87
108
  if (isHqRoot(cur)) return cur;
88
109
  cur = path.dirname(cur);
89
110
  }
111
+ if (opts.onMissing === "throw") {
112
+ throw new Error(
113
+ "Could not find HQ root. Run this command from within your HQ directory " +
114
+ "(must contain core.yaml AND a companies/ subdirectory), or set $HQ_ROOT.",
115
+ );
116
+ }
90
117
  return path.join(os.homedir(), "hq");
91
118
  }
92
119
 
@@ -5,7 +5,7 @@
5
5
  import * as crypto from 'crypto';
6
6
  import * as fs from 'fs';
7
7
  import * as path from 'path';
8
- import { findHqRoot } from './hq-root.js';
8
+ import { resolveDefaultHqRoot } from './cognito-session.js';
9
9
 
10
10
  /**
11
11
  * Verify a file's SHA256 hash matches the expected value.
@@ -39,7 +39,7 @@ export function verifyRsaSignature(
39
39
  ): boolean {
40
40
  const keyPath =
41
41
  publicKeyPath ??
42
- path.resolve(findHqRoot(), 'packages', '.keys', 'registry-public.pem');
42
+ path.resolve(resolveDefaultHqRoot({ onMissing: 'throw' }), 'packages', '.keys', 'registry-public.pem');
43
43
 
44
44
  if (!fs.existsSync(keyPath)) {
45
45
  return false;
@@ -8,7 +8,7 @@
8
8
  import * as fs from 'fs';
9
9
  import * as path from 'path';
10
10
  import * as yaml from 'js-yaml';
11
- import { findHqRoot } from './hq-root.js';
11
+ import { resolveDefaultHqRoot } from './cognito-session.js';
12
12
 
13
13
  // ---------------------------------------------------------------------------
14
14
  // Types
@@ -76,7 +76,7 @@ export interface DownloadResponse {
76
76
  * Throws if sources.yaml is missing or has no sources.
77
77
  */
78
78
  export function getRegistryUrl(): string {
79
- const hqRoot = findHqRoot();
79
+ const hqRoot = resolveDefaultHqRoot({ onMissing: 'throw' });
80
80
  const sourcesPath = path.join(hqRoot, 'packages', 'sources.yaml');
81
81
 
82
82
  if (!fs.existsSync(sourcesPath)) {
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Tests for the version-gate module. Hermetic — no network, no spawn.
3
+ *
4
+ * Pinning CLI_VERSION via the same mock pattern used by version-check.test.ts
5
+ * (`vi.mock('../cli-version.js', …)`) so the gate request body is
6
+ * deterministic.
7
+ */
8
+
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
+
11
+ vi.mock("../cli-version.js", () => ({
12
+ CLI_VERSION: "5.10.0",
13
+ CLI_NAME: "@indigoai-us/hq-cli",
14
+ }));
15
+
16
+ beforeEach(() => {
17
+ vi.unstubAllEnvs();
18
+ vi.restoreAllMocks();
19
+ });
20
+
21
+ afterEach(() => {
22
+ vi.unstubAllEnvs();
23
+ vi.restoreAllMocks();
24
+ });
25
+
26
+ async function loadModule() {
27
+ vi.resetModules();
28
+ return await import("./version-gate.js");
29
+ }
30
+
31
+ describe("shouldSkipGate", () => {
32
+ it("returns true for --version", async () => {
33
+ const { shouldSkipGate } = await loadModule();
34
+ expect(shouldSkipGate(["node", "hq", "--version"])).toBe(true);
35
+ });
36
+
37
+ it("returns true for -V, -v, --help, -h", async () => {
38
+ const { shouldSkipGate } = await loadModule();
39
+ expect(shouldSkipGate(["node", "hq", "-V"])).toBe(true);
40
+ expect(shouldSkipGate(["node", "hq", "-v"])).toBe(true);
41
+ expect(shouldSkipGate(["node", "hq", "--help"])).toBe(true);
42
+ expect(shouldSkipGate(["node", "hq", "-h"])).toBe(true);
43
+ });
44
+
45
+ it("returns false for a regular subcommand", async () => {
46
+ const { shouldSkipGate } = await loadModule();
47
+ expect(shouldSkipGate(["node", "hq", "sync"])).toBe(false);
48
+ expect(shouldSkipGate(["node", "hq", "members", "list"])).toBe(false);
49
+ });
50
+ });
51
+
52
+ describe("enforceVersionGate — opt-out + soft paths (no process.exit)", () => {
53
+ it("is silent + no fetch when HQ_NO_UPDATE_CHECK=1", async () => {
54
+ vi.stubEnv("HQ_NO_UPDATE_CHECK", "1");
55
+ const fetchMock = vi.fn();
56
+ vi.stubGlobal("fetch", fetchMock);
57
+ const { enforceVersionGate } = await loadModule();
58
+ await enforceVersionGate();
59
+ expect(fetchMock).not.toHaveBeenCalled();
60
+ });
61
+
62
+ it("is silent on non-200 response (treats as no-gate)", async () => {
63
+ vi.stubGlobal(
64
+ "fetch",
65
+ vi.fn().mockResolvedValue(
66
+ new Response("", { status: 500 }),
67
+ ),
68
+ );
69
+ // process.exit must HALT execution — mocking it as undefined lets code
70
+ // fall through past the exit point and crash on later lines. Throwing a
71
+ // sentinel error reproduces the "never returns" semantics in unit tests.
72
+ const exitSpy = vi
73
+ .spyOn(process, "exit")
74
+ .mockImplementation(((code?: number) => {
75
+ throw new Error(`__process_exit__:${code ?? 0}`);
76
+ }) as never);
77
+ const { enforceVersionGate } = await loadModule();
78
+ await enforceVersionGate();
79
+ expect(exitSpy).not.toHaveBeenCalled();
80
+ });
81
+
82
+ it("is silent on malformed response body", async () => {
83
+ vi.stubGlobal(
84
+ "fetch",
85
+ vi.fn().mockResolvedValue(
86
+ new Response(JSON.stringify({ ok: true }), { status: 200 }),
87
+ ),
88
+ );
89
+ // process.exit must HALT execution — mocking it as undefined lets code
90
+ // fall through past the exit point and crash on later lines. Throwing a
91
+ // sentinel error reproduces the "never returns" semantics in unit tests.
92
+ const exitSpy = vi
93
+ .spyOn(process, "exit")
94
+ .mockImplementation(((code?: number) => {
95
+ throw new Error(`__process_exit__:${code ?? 0}`);
96
+ }) as never);
97
+ const { enforceVersionGate } = await loadModule();
98
+ await enforceVersionGate();
99
+ expect(exitSpy).not.toHaveBeenCalled();
100
+ });
101
+
102
+ it("is silent on fetch rejection (network down / timeout)", async () => {
103
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network down")));
104
+ // process.exit must HALT execution — mocking it as undefined lets code
105
+ // fall through past the exit point and crash on later lines. Throwing a
106
+ // sentinel error reproduces the "never returns" semantics in unit tests.
107
+ const exitSpy = vi
108
+ .spyOn(process, "exit")
109
+ .mockImplementation(((code?: number) => {
110
+ throw new Error(`__process_exit__:${code ?? 0}`);
111
+ }) as never);
112
+ const { enforceVersionGate } = await loadModule();
113
+ await expect(enforceVersionGate()).resolves.toBeUndefined();
114
+ expect(exitSpy).not.toHaveBeenCalled();
115
+ });
116
+
117
+ it("nudges on updateRecommended without exiting", async () => {
118
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
119
+ // process.exit must HALT execution — mocking it as undefined lets code
120
+ // fall through past the exit point and crash on later lines. Throwing a
121
+ // sentinel error reproduces the "never returns" semantics in unit tests.
122
+ const exitSpy = vi
123
+ .spyOn(process, "exit")
124
+ .mockImplementation(((code?: number) => {
125
+ throw new Error(`__process_exit__:${code ?? 0}`);
126
+ }) as never);
127
+ vi.stubGlobal(
128
+ "fetch",
129
+ vi.fn().mockResolvedValue(
130
+ new Response(
131
+ JSON.stringify({
132
+ clientId: "hq-cli",
133
+ currentVersion: "5.10.0",
134
+ minVersion: "5.0.0",
135
+ latestVersion: "5.24.0",
136
+ updateRequired: false,
137
+ updateRecommended: true,
138
+ updateCommand: "npm install -g @indigoai-us/hq-cli@latest",
139
+ }),
140
+ { status: 200 },
141
+ ),
142
+ ),
143
+ );
144
+ const { enforceVersionGate } = await loadModule();
145
+ await enforceVersionGate();
146
+ expect(exitSpy).not.toHaveBeenCalled();
147
+ const printed = errSpy.mock.calls.map((c) => c.join(" ")).join("\n");
148
+ expect(printed).toContain("5.24.0");
149
+ });
150
+
151
+ it("does NOT nudge or block when current = latest", async () => {
152
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
153
+ // process.exit must HALT execution — mocking it as undefined lets code
154
+ // fall through past the exit point and crash on later lines. Throwing a
155
+ // sentinel error reproduces the "never returns" semantics in unit tests.
156
+ const exitSpy = vi
157
+ .spyOn(process, "exit")
158
+ .mockImplementation(((code?: number) => {
159
+ throw new Error(`__process_exit__:${code ?? 0}`);
160
+ }) as never);
161
+ vi.stubGlobal(
162
+ "fetch",
163
+ vi.fn().mockResolvedValue(
164
+ new Response(
165
+ JSON.stringify({
166
+ clientId: "hq-cli",
167
+ currentVersion: "5.10.0",
168
+ minVersion: "5.0.0",
169
+ latestVersion: "5.10.0",
170
+ updateRequired: false,
171
+ updateRecommended: false,
172
+ }),
173
+ { status: 200 },
174
+ ),
175
+ ),
176
+ );
177
+ const { enforceVersionGate } = await loadModule();
178
+ await enforceVersionGate();
179
+ expect(exitSpy).not.toHaveBeenCalled();
180
+ expect(errSpy).not.toHaveBeenCalled();
181
+ });
182
+ });
183
+
184
+ describe("enforceVersionGate — hard-update path", () => {
185
+ it("calls process.exit when updateRequired is true and no updateCommand", async () => {
186
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
187
+ // process.exit must HALT execution — mocking it as undefined lets code
188
+ // fall through past the exit point and crash on later lines. Throwing a
189
+ // sentinel error reproduces the "never returns" semantics in unit tests.
190
+ const exitSpy = vi
191
+ .spyOn(process, "exit")
192
+ .mockImplementation(((code?: number) => {
193
+ throw new Error(`__process_exit__:${code ?? 0}`);
194
+ }) as never);
195
+ vi.stubGlobal(
196
+ "fetch",
197
+ vi.fn().mockResolvedValue(
198
+ new Response(
199
+ JSON.stringify({
200
+ clientId: "hq-cli",
201
+ currentVersion: "5.10.0",
202
+ minVersion: "5.20.0",
203
+ latestVersion: "5.24.0",
204
+ updateRequired: true,
205
+ updateRecommended: false,
206
+ // no updateCommand — exits with code 75 (manual update needed)
207
+ }),
208
+ { status: 200 },
209
+ ),
210
+ ),
211
+ );
212
+ const { enforceVersionGate } = await loadModule();
213
+ await expect(enforceVersionGate()).rejects.toThrow(/__process_exit__:75/);
214
+ expect(exitSpy).toHaveBeenCalledWith(75);
215
+ const printed = errSpy.mock.calls.map((c) => c.join(" ")).join("\n");
216
+ expect(printed).toContain("below the minimum");
217
+ });
218
+
219
+ it("posts the expected request body to /v1/client-version/check", async () => {
220
+ const fetchMock = vi.fn().mockResolvedValue(
221
+ new Response(
222
+ JSON.stringify({
223
+ clientId: "hq-cli",
224
+ currentVersion: "5.10.0",
225
+ minVersion: "5.0.0",
226
+ latestVersion: "5.10.0",
227
+ updateRequired: false,
228
+ updateRecommended: false,
229
+ }),
230
+ { status: 200 },
231
+ ),
232
+ );
233
+ vi.stubGlobal("fetch", fetchMock);
234
+ const { enforceVersionGate } = await loadModule();
235
+ await enforceVersionGate();
236
+
237
+ expect(fetchMock).toHaveBeenCalledTimes(1);
238
+ const [url, init] = fetchMock.mock.calls[0]!;
239
+ expect(String(url)).toContain("/v1/client-version/check");
240
+ const body = JSON.parse((init as RequestInit).body as string);
241
+ expect(body.clientId).toBe("hq-cli");
242
+ expect(body.currentVersion).toBe("5.10.0");
243
+ expect(typeof body.platform).toBe("string");
244
+ });
245
+ });