@indigoai-us/hq-cli 5.25.0 → 5.26.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.
- package/.github/workflows/publish.yml +21 -1
- package/dist/commands/dm.d.ts +41 -0
- package/dist/commands/dm.js +148 -0
- package/dist/commands/pkg-install.js +4 -4
- package/dist/commands/pkg-list.js +4 -4
- package/dist/commands/pkg-remove.js +4 -4
- package/dist/commands/pkg-update.js +4 -4
- package/dist/commands/team-sync.js +4 -4
- package/dist/index.js +13 -2
- package/dist/utils/cognito-session.d.ts +22 -1
- package/dist/utils/cognito-session.js +26 -3
- package/dist/utils/integrity.js +4 -4
- package/dist/utils/registry-client.js +4 -4
- package/dist/utils/version-gate.d.ts +64 -0
- package/dist/utils/version-gate.js +185 -0
- package/package.json +1 -1
- package/src/commands/dm.test.ts +88 -0
- package/src/commands/dm.ts +222 -0
- package/src/commands/pkg-install.ts +2 -2
- package/src/commands/pkg-list.ts +2 -2
- package/src/commands/pkg-remove.ts +2 -2
- package/src/commands/pkg-update.ts +2 -2
- package/src/commands/team-sync.ts +2 -2
- package/src/index.ts +14 -0
- package/src/utils/cognito-session.test.ts +56 -1
- package/src/utils/cognito-session.ts +28 -1
- package/src/utils/integrity.ts +2 -2
- package/src/utils/registry-client.ts +2 -2
- package/src/utils/version-gate.test.ts +245 -0
- package/src/utils/version-gate.ts +219 -0
- package/dist/utils/hq-root.d.ts +0 -10
- package/dist/utils/hq-root.js +0 -25
- package/src/utils/hq-root.ts +0 -27
|
@@ -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
|
-
|
|
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
|
|
package/src/utils/integrity.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
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 {
|
|
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 =
|
|
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
|
+
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hard version-gate: ask hq-pro whether the current CLI is below the minimum
|
|
3
|
+
* acceptable version and, if so, run `npm install -g …@latest` synchronously
|
|
4
|
+
* before any commander parsing happens. Distinct from the existing
|
|
5
|
+
* `version-check.ts` which is a passive (cached, opt-in) stderr nag against
|
|
6
|
+
* the npm registry.
|
|
7
|
+
*
|
|
8
|
+
* Why both?
|
|
9
|
+
* - `version-check.ts` answers "is there something newer?" by polling npm
|
|
10
|
+
* directly. It's a soft hint, lives on a 24h cache, and never blocks.
|
|
11
|
+
* - `version-gate.ts` answers "is the team currently allowing your version
|
|
12
|
+
* to run?" via an authoritative hq-pro endpoint. The server can yank a
|
|
13
|
+
* known-bad release without waiting for the npm `latest` dist-tag move.
|
|
14
|
+
*
|
|
15
|
+
* The endpoint is reusable across clients (hq-sync, hq-installer, create-hq).
|
|
16
|
+
* See `apps/hq-pro/src/vault-service/handlers/client-version-check.ts` for the
|
|
17
|
+
* source-of-truth table.
|
|
18
|
+
*
|
|
19
|
+
* Trust model: anonymous. The CLI may be running pre-login (e.g. fresh
|
|
20
|
+
* install) so we never send credentials. The endpoint identifies the client
|
|
21
|
+
* by `clientId` + `currentVersion`.
|
|
22
|
+
*
|
|
23
|
+
* Failure mode: silent. Network down, hq-pro returning 5xx, malformed body —
|
|
24
|
+
* the gate must never break the CLI for a user who's otherwise fine. We log
|
|
25
|
+
* to Sentry as a breadcrumb (best-effort) and return.
|
|
26
|
+
*
|
|
27
|
+
* Opt-out: `HQ_NO_UPDATE_CHECK=1` (same env as `version-check.ts` — one knob
|
|
28
|
+
* to silence both check + gate).
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { spawnSync } from "node:child_process";
|
|
32
|
+
import chalk from "chalk";
|
|
33
|
+
import { CLI_VERSION } from "../cli-version.js";
|
|
34
|
+
import { DEFAULT_VAULT_API_URL } from "./cognito-session.js";
|
|
35
|
+
|
|
36
|
+
const CLIENT_ID = "hq-cli";
|
|
37
|
+
const ENDPOINT_PATH = "/v1/client-version/check";
|
|
38
|
+
const FETCH_TIMEOUT_MS = 3_000;
|
|
39
|
+
|
|
40
|
+
interface VersionCheckResponse {
|
|
41
|
+
clientId: string;
|
|
42
|
+
currentVersion: string;
|
|
43
|
+
minVersion: string;
|
|
44
|
+
latestVersion: string;
|
|
45
|
+
updateRequired: boolean;
|
|
46
|
+
updateRecommended: boolean;
|
|
47
|
+
updateCommand?: string;
|
|
48
|
+
downloadUrl?: string;
|
|
49
|
+
message?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isOptedOut(): boolean {
|
|
53
|
+
return process.env.HQ_NO_UPDATE_CHECK === "1";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Hit POST /v1/client-version/check. Returns the parsed body on 200, or
|
|
58
|
+
* `null` on any failure (caller treats as "no gate"). Tight 3s timeout —
|
|
59
|
+
* a hung server must not delay CLI startup.
|
|
60
|
+
*/
|
|
61
|
+
async function fetchVersionDecision(): Promise<VersionCheckResponse | null> {
|
|
62
|
+
try {
|
|
63
|
+
const url = `${DEFAULT_VAULT_API_URL}${ENDPOINT_PATH}`;
|
|
64
|
+
const res = await fetch(url, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: {
|
|
67
|
+
"Content-Type": "application/json",
|
|
68
|
+
Accept: "application/json",
|
|
69
|
+
},
|
|
70
|
+
body: JSON.stringify({
|
|
71
|
+
clientId: CLIENT_ID,
|
|
72
|
+
currentVersion: CLI_VERSION,
|
|
73
|
+
platform: `${process.platform}-${process.arch}`,
|
|
74
|
+
}),
|
|
75
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
76
|
+
});
|
|
77
|
+
if (!res.ok) return null;
|
|
78
|
+
const body = (await res.json()) as Partial<VersionCheckResponse>;
|
|
79
|
+
if (
|
|
80
|
+
typeof body.minVersion !== "string" ||
|
|
81
|
+
typeof body.latestVersion !== "string" ||
|
|
82
|
+
typeof body.updateRequired !== "boolean"
|
|
83
|
+
) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
return body as VersionCheckResponse;
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Run the upgrade command in a blocking subprocess. Inherits stdio so the
|
|
94
|
+
* user sees the npm progress. We do NOT auto-rerun the CLI on completion —
|
|
95
|
+
* forcing a re-invocation would run twice on the same process and feel
|
|
96
|
+
* janky; instead we print a clear "rerun your command" message and exit.
|
|
97
|
+
*/
|
|
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);
|
|
105
|
+
try {
|
|
106
|
+
const result = spawnSync(cmd, args, { stdio: "inherit" });
|
|
107
|
+
if (result.status !== 0) {
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
detail: `exit ${result.status ?? "signal"}`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return { ok: true };
|
|
114
|
+
} catch (err) {
|
|
115
|
+
return { ok: false, detail: err instanceof Error ? err.message : String(err) };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Soft notify when the server says we're below `latestVersion` but still ≥
|
|
121
|
+
* `minVersion`. Single chalk-yellow line on stderr; never blocks.
|
|
122
|
+
*/
|
|
123
|
+
function nudgeUpdateRecommended(decision: VersionCheckResponse): void {
|
|
124
|
+
const msg = chalk.yellow(
|
|
125
|
+
`⚠ A new version of hq-cli is available: ${decision.latestVersion} (current: ${decision.currentVersion}).`,
|
|
126
|
+
);
|
|
127
|
+
console.error(msg);
|
|
128
|
+
if (decision.updateCommand) {
|
|
129
|
+
console.error(chalk.dim(` Update: ${decision.updateCommand}`));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Hard enforcement when the server says we're below `minVersion`. Print a
|
|
135
|
+
* red banner, attempt the update, then exit so the user reruns against the
|
|
136
|
+
* fresh binary. Sequence chosen so a user with a broken `npm` global prefix
|
|
137
|
+
* still gets a clear error rather than an opaque silent failure.
|
|
138
|
+
*
|
|
139
|
+
* Exit codes:
|
|
140
|
+
* 0 — update succeeded; user must rerun their command
|
|
141
|
+
* 75 — update failed (EX_TEMPFAIL; common for sudo/EACCES on system npm)
|
|
142
|
+
*/
|
|
143
|
+
function enforceUpdateRequired(decision: VersionCheckResponse): never {
|
|
144
|
+
const banner = chalk.red.bold(
|
|
145
|
+
`✗ hq-cli ${decision.currentVersion} is below the minimum required version (${decision.minVersion}).`,
|
|
146
|
+
);
|
|
147
|
+
console.error(banner);
|
|
148
|
+
if (decision.message) console.error(chalk.dim(` ${decision.message}`));
|
|
149
|
+
|
|
150
|
+
const command = decision.updateCommand;
|
|
151
|
+
if (!command) {
|
|
152
|
+
console.error(
|
|
153
|
+
chalk.red(
|
|
154
|
+
" No updateCommand provided by hq-pro — see https://hq.indigo.ai/docs/cli-update for manual steps.",
|
|
155
|
+
),
|
|
156
|
+
);
|
|
157
|
+
if (decision.downloadUrl) {
|
|
158
|
+
console.error(chalk.dim(` Download: ${decision.downloadUrl}`));
|
|
159
|
+
}
|
|
160
|
+
process.exit(75);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
console.error(chalk.dim(` Running: ${command}`));
|
|
164
|
+
const result = performUpdate(command);
|
|
165
|
+
if (!result.ok) {
|
|
166
|
+
console.error(
|
|
167
|
+
chalk.red(`✗ Update failed${result.detail ? `: ${result.detail}` : ""}.`),
|
|
168
|
+
);
|
|
169
|
+
console.error(chalk.dim(` Try manually: ${command}`));
|
|
170
|
+
process.exit(75);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
console.error(
|
|
174
|
+
chalk.green(
|
|
175
|
+
`✓ Updated to hq-cli ${decision.latestVersion}. Rerun your command.`,
|
|
176
|
+
),
|
|
177
|
+
);
|
|
178
|
+
process.exit(0);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Public entry point. Call before commander parses argv. Blocks the CLI on
|
|
183
|
+
* network IO for up to FETCH_TIMEOUT_MS — acceptable because the alternative
|
|
184
|
+
* (a fire-and-forget background check) gives the user no chance to bail out
|
|
185
|
+
* of a known-bad version before it does damage.
|
|
186
|
+
*
|
|
187
|
+
* `--version` / `-v` callers MUST skip the gate (the user is debugging a
|
|
188
|
+
* broken install and shouldn't be force-upgraded mid-investigation). Caller
|
|
189
|
+
* is responsible for checking argv before invoking us — see index.ts.
|
|
190
|
+
*/
|
|
191
|
+
export async function enforceVersionGate(): Promise<void> {
|
|
192
|
+
if (isOptedOut()) return;
|
|
193
|
+
const decision = await fetchVersionDecision();
|
|
194
|
+
if (!decision) return; // best-effort: silent on any failure
|
|
195
|
+
if (decision.updateRequired) {
|
|
196
|
+
enforceUpdateRequired(decision); // exits process
|
|
197
|
+
}
|
|
198
|
+
if (decision.updateRecommended) {
|
|
199
|
+
nudgeUpdateRecommended(decision);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Cheap argv pre-check: skip the gate for `--version` / `-V` so users
|
|
205
|
+
* inspecting a broken install can still see what they have without being
|
|
206
|
+
* force-upgraded.
|
|
207
|
+
*/
|
|
208
|
+
export function shouldSkipGate(argv: readonly string[]): boolean {
|
|
209
|
+
return argv.some(
|
|
210
|
+
(a) => a === "--version" || a === "-V" || a === "-v" || a === "--help" || a === "-h",
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export const __test__ = {
|
|
215
|
+
CLIENT_ID,
|
|
216
|
+
ENDPOINT_PATH,
|
|
217
|
+
FETCH_TIMEOUT_MS,
|
|
218
|
+
performUpdate,
|
|
219
|
+
};
|
package/dist/utils/hq-root.d.ts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HQ root detection — walks up from cwd looking for HQ markers (US-004)
|
|
3
|
-
*/
|
|
4
|
-
/**
|
|
5
|
-
* Find the HQ root directory by walking up from cwd.
|
|
6
|
-
* Looks for CLAUDE.md or .claude/ directory as markers.
|
|
7
|
-
* Throws if not found.
|
|
8
|
-
*/
|
|
9
|
-
export declare function findHqRoot(): string;
|
|
10
|
-
//# sourceMappingURL=hq-root.d.ts.map
|
package/dist/utils/hq-root.js
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HQ root detection — walks up from cwd looking for HQ markers (US-004)
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="aebb967b-5f70-5107-9d81-bb495be1c405")}catch(e){}}();
|
|
6
|
-
import * as fs from 'fs';
|
|
7
|
-
import * as path from 'path';
|
|
8
|
-
/**
|
|
9
|
-
* Find the HQ root directory by walking up from cwd.
|
|
10
|
-
* Looks for CLAUDE.md or .claude/ directory as markers.
|
|
11
|
-
* Throws if not found.
|
|
12
|
-
*/
|
|
13
|
-
export function findHqRoot() {
|
|
14
|
-
let dir = process.cwd();
|
|
15
|
-
while (dir !== path.dirname(dir)) {
|
|
16
|
-
if (fs.existsSync(path.join(dir, 'CLAUDE.md')) ||
|
|
17
|
-
fs.existsSync(path.join(dir, '.claude'))) {
|
|
18
|
-
return dir;
|
|
19
|
-
}
|
|
20
|
-
dir = path.dirname(dir);
|
|
21
|
-
}
|
|
22
|
-
throw new Error('Could not find HQ root. Run this command from within your HQ directory.');
|
|
23
|
-
}
|
|
24
|
-
//# sourceMappingURL=hq-root.js.map
|
|
25
|
-
//# debugId=aebb967b-5f70-5107-9d81-bb495be1c405
|
package/src/utils/hq-root.ts
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HQ root detection — walks up from cwd looking for HQ markers (US-004)
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import * as fs from 'fs';
|
|
6
|
-
import * as path from 'path';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Find the HQ root directory by walking up from cwd.
|
|
10
|
-
* Looks for CLAUDE.md or .claude/ directory as markers.
|
|
11
|
-
* Throws if not found.
|
|
12
|
-
*/
|
|
13
|
-
export function findHqRoot(): string {
|
|
14
|
-
let dir = process.cwd();
|
|
15
|
-
while (dir !== path.dirname(dir)) {
|
|
16
|
-
if (
|
|
17
|
-
fs.existsSync(path.join(dir, 'CLAUDE.md')) ||
|
|
18
|
-
fs.existsSync(path.join(dir, '.claude'))
|
|
19
|
-
) {
|
|
20
|
-
return dir;
|
|
21
|
-
}
|
|
22
|
-
dir = path.dirname(dir);
|
|
23
|
-
}
|
|
24
|
-
throw new Error(
|
|
25
|
-
'Could not find HQ root. Run this command from within your HQ directory.'
|
|
26
|
-
);
|
|
27
|
-
}
|