@indigoai-us/hq-cli 5.12.1 → 5.12.3

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.
@@ -0,0 +1,146 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+
6
+ vi.mock("../cli-version.js", () => ({ CLI_VERSION: "5.12.1" }));
7
+
8
+ const tmpHome = path.join(os.tmpdir(), `hq-cli-version-check-${process.pid}`);
9
+
10
+ beforeEach(() => {
11
+ fs.rmSync(tmpHome, { recursive: true, force: true });
12
+ fs.mkdirSync(tmpHome, { recursive: true });
13
+ vi.stubEnv("HOME", tmpHome);
14
+ vi.unstubAllEnvs();
15
+ vi.stubEnv("HOME", tmpHome);
16
+ });
17
+
18
+ afterEach(() => {
19
+ vi.unstubAllEnvs();
20
+ vi.restoreAllMocks();
21
+ fs.rmSync(tmpHome, { recursive: true, force: true });
22
+ });
23
+
24
+ async function loadModule() {
25
+ vi.resetModules();
26
+ return await import("./version-check.js");
27
+ }
28
+
29
+ function writeCache(latest: string, ageMs = 0): void {
30
+ const cacheDir = path.join(tmpHome, ".hq");
31
+ fs.mkdirSync(cacheDir, { recursive: true });
32
+ fs.writeFileSync(
33
+ path.join(cacheDir, "version-check.json"),
34
+ JSON.stringify({ latest, fetchedAt: Date.now() - ageMs }),
35
+ );
36
+ }
37
+
38
+ describe("maybeWarnNewVersion", () => {
39
+ it("warns on stderr when cached latest is newer than CLI_VERSION", async () => {
40
+ writeCache("5.99.0");
41
+ const warn = vi.spyOn(console, "error").mockImplementation(() => {});
42
+ const mod = await loadModule();
43
+ mod.maybeWarnNewVersion();
44
+ expect(warn).toHaveBeenCalledTimes(1);
45
+ const msg = warn.mock.calls[0]?.join(" ") ?? "";
46
+ expect(msg).toContain("5.99.0");
47
+ expect(msg.toLowerCase()).toContain("update");
48
+ });
49
+
50
+ it("is silent when cached latest equals CLI_VERSION", async () => {
51
+ writeCache("5.12.1");
52
+ const warn = vi.spyOn(console, "error").mockImplementation(() => {});
53
+ const mod = await loadModule();
54
+ mod.maybeWarnNewVersion();
55
+ expect(warn).not.toHaveBeenCalled();
56
+ });
57
+
58
+ it("is silent when cached latest is older than CLI_VERSION", async () => {
59
+ writeCache("5.0.0");
60
+ const warn = vi.spyOn(console, "error").mockImplementation(() => {});
61
+ const mod = await loadModule();
62
+ mod.maybeWarnNewVersion();
63
+ expect(warn).not.toHaveBeenCalled();
64
+ });
65
+
66
+ it("is silent when there is no cache yet (first run)", async () => {
67
+ const warn = vi.spyOn(console, "error").mockImplementation(() => {});
68
+ const mod = await loadModule();
69
+ mod.maybeWarnNewVersion();
70
+ expect(warn).not.toHaveBeenCalled();
71
+ });
72
+
73
+ it("ignores cache entries older than the TTL (24h default)", async () => {
74
+ writeCache("5.99.0", 25 * 60 * 60 * 1000);
75
+ const warn = vi.spyOn(console, "error").mockImplementation(() => {});
76
+ const mod = await loadModule();
77
+ mod.maybeWarnNewVersion();
78
+ expect(warn).not.toHaveBeenCalled();
79
+ });
80
+
81
+ it("is silent when HQ_NO_UPDATE_CHECK=1 is set, even with newer cached version", async () => {
82
+ writeCache("5.99.0");
83
+ vi.stubEnv("HQ_NO_UPDATE_CHECK", "1");
84
+ const warn = vi.spyOn(console, "error").mockImplementation(() => {});
85
+ const mod = await loadModule();
86
+ mod.maybeWarnNewVersion();
87
+ expect(warn).not.toHaveBeenCalled();
88
+ });
89
+
90
+ it("does not throw on malformed cache JSON", async () => {
91
+ const cacheDir = path.join(tmpHome, ".hq");
92
+ fs.mkdirSync(cacheDir, { recursive: true });
93
+ fs.writeFileSync(path.join(cacheDir, "version-check.json"), "not json{");
94
+ const warn = vi.spyOn(console, "error").mockImplementation(() => {});
95
+ const mod = await loadModule();
96
+ expect(() => mod.maybeWarnNewVersion()).not.toThrow();
97
+ expect(warn).not.toHaveBeenCalled();
98
+ });
99
+ });
100
+
101
+ describe("refreshVersionCache", () => {
102
+ it("writes the latest version from the npm registry to the cache file", async () => {
103
+ const fetchMock = vi.fn().mockResolvedValue({
104
+ ok: true,
105
+ json: async () => ({ version: "5.99.0" }),
106
+ } as unknown as Response);
107
+ vi.stubGlobal("fetch", fetchMock);
108
+
109
+ const mod = await loadModule();
110
+ await mod.refreshVersionCache();
111
+
112
+ expect(fetchMock).toHaveBeenCalledTimes(1);
113
+ const url = fetchMock.mock.calls[0]?.[0] as string;
114
+ expect(url).toContain("registry.npmjs.org");
115
+ expect(url).toContain("%40indigoai-us%2Fhq-cli");
116
+
117
+ const cachePath = path.join(tmpHome, ".hq", "version-check.json");
118
+ const written = JSON.parse(fs.readFileSync(cachePath, "utf-8"));
119
+ expect(written.latest).toBe("5.99.0");
120
+ expect(typeof written.fetchedAt).toBe("number");
121
+ });
122
+
123
+ it("does not throw or write a cache when fetch fails", async () => {
124
+ const fetchMock = vi.fn().mockRejectedValue(new Error("network down"));
125
+ vi.stubGlobal("fetch", fetchMock);
126
+
127
+ const mod = await loadModule();
128
+ await expect(mod.refreshVersionCache()).resolves.toBeUndefined();
129
+
130
+ const cachePath = path.join(tmpHome, ".hq", "version-check.json");
131
+ expect(fs.existsSync(cachePath)).toBe(false);
132
+ });
133
+
134
+ it("does not fetch or write cache when HQ_NO_UPDATE_CHECK=1", async () => {
135
+ vi.stubEnv("HQ_NO_UPDATE_CHECK", "1");
136
+ const fetchMock = vi.fn();
137
+ vi.stubGlobal("fetch", fetchMock);
138
+
139
+ const mod = await loadModule();
140
+ await mod.refreshVersionCache();
141
+
142
+ expect(fetchMock).not.toHaveBeenCalled();
143
+ const cachePath = path.join(tmpHome, ".hq", "version-check.json");
144
+ expect(fs.existsSync(cachePath)).toBe(false);
145
+ });
146
+ });
@@ -0,0 +1,83 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import semver from "semver";
5
+ import chalk from "chalk";
6
+ import { CLI_VERSION } from "../cli-version.js";
7
+
8
+ const PACKAGE_NAME = "@indigoai-us/hq-cli";
9
+ const REGISTRY_URL = `https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME)}/latest`;
10
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
11
+ const FETCH_TIMEOUT_MS = 3_000;
12
+
13
+ interface CacheEntry {
14
+ latest: string;
15
+ fetchedAt: number;
16
+ }
17
+
18
+ function cachePath(): string {
19
+ return path.join(os.homedir(), ".hq", "version-check.json");
20
+ }
21
+
22
+ function isOptedOut(): boolean {
23
+ return process.env.HQ_NO_UPDATE_CHECK === "1";
24
+ }
25
+
26
+ function readCache(): CacheEntry | null {
27
+ try {
28
+ const raw = fs.readFileSync(cachePath(), "utf-8");
29
+ const parsed = JSON.parse(raw) as Partial<CacheEntry>;
30
+ if (
31
+ typeof parsed.latest !== "string" ||
32
+ typeof parsed.fetchedAt !== "number"
33
+ ) {
34
+ return null;
35
+ }
36
+ return { latest: parsed.latest, fetchedAt: parsed.fetchedAt };
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ function writeCache(entry: CacheEntry): void {
43
+ try {
44
+ const file = cachePath();
45
+ fs.mkdirSync(path.dirname(file), { recursive: true });
46
+ fs.writeFileSync(file, JSON.stringify(entry));
47
+ } catch {
48
+ // best-effort; never break the CLI on cache write failure
49
+ }
50
+ }
51
+
52
+ export function maybeWarnNewVersion(): void {
53
+ if (isOptedOut()) return;
54
+ const entry = readCache();
55
+ if (!entry) return;
56
+ if (Date.now() - entry.fetchedAt > CACHE_TTL_MS) return;
57
+
58
+ const current = semver.valid(CLI_VERSION);
59
+ const latest = semver.valid(entry.latest);
60
+ if (!current || !latest) return;
61
+ if (!semver.gt(latest, current)) return;
62
+
63
+ const msg = chalk.yellow(
64
+ `⚠ A new version of hq is available: ${entry.latest} (current: ${CLI_VERSION}). It's recommended to update.`,
65
+ );
66
+ console.error(msg);
67
+ }
68
+
69
+ export async function refreshVersionCache(): Promise<void> {
70
+ if (isOptedOut()) return;
71
+ try {
72
+ const res = await fetch(REGISTRY_URL, {
73
+ headers: { Accept: "application/json" },
74
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
75
+ });
76
+ if (!res.ok) return;
77
+ const body = (await res.json()) as { version?: unknown };
78
+ if (typeof body.version !== "string") return;
79
+ writeCache({ latest: body.version, fetchedAt: Date.now() });
80
+ } catch {
81
+ // best-effort; offline / registry down / timeout — silent
82
+ }
83
+ }