@indigoai-us/hq-cli 5.60.0 → 5.61.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/dist/commands/pack-install.d.ts +7 -1
- package/dist/commands/pack-install.js +86 -15
- package/dist/commands/packs.d.ts +2 -1
- package/dist/commands/packs.js +13 -8
- package/dist/commands/secrets.d.ts +5 -0
- package/dist/commands/secrets.js +129 -5
- package/dist/index.d.ts +5 -3
- package/dist/index.js +14 -240
- package/dist/main.d.ts +7 -0
- package/dist/main.js +247 -0
- package/dist/utils/sandbox-runner-client.d.ts +13 -0
- package/dist/utils/sandbox-runner-client.js +82 -5
- package/dist/utils/version-check.d.ts +6 -0
- package/dist/utils/version-check.js +78 -2
- package/package.json +1 -1
- package/src/commands/pack-install.ts +115 -18
- package/src/commands/pack-update-cache.test.ts +149 -0
- package/src/commands/packs.ts +28 -7
- package/src/commands/secrets.test.ts +318 -0
- package/src/commands/secrets.ts +198 -4
- package/src/index.test.ts +32 -0
- package/src/index.ts +11 -274
- package/src/main.ts +283 -0
- package/src/utils/sandbox-runner-client.test.ts +128 -0
- package/src/utils/sandbox-runner-client.ts +99 -3
- package/src/utils/version-check.test.ts +30 -0
- package/src/utils/version-check.ts +72 -0
|
@@ -39,6 +39,100 @@ describe("SandboxRunnerClient", () => {
|
|
|
39
39
|
});
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
+
it("retries startJob after fetch rejects twice then succeeds", async () => {
|
|
43
|
+
const fetchImpl = vi
|
|
44
|
+
.fn<typeof fetch>()
|
|
45
|
+
.mockRejectedValueOnce(new TypeError("fetch failed"))
|
|
46
|
+
.mockRejectedValueOnce(new Error("ECONNRESET"))
|
|
47
|
+
.mockResolvedValueOnce(jsonRes({ jobId: "job_1", status: "queued" }));
|
|
48
|
+
const sleep = vi.fn(async () => {});
|
|
49
|
+
const client = new SandboxRunnerClient({
|
|
50
|
+
baseUrl: "https://runner.example",
|
|
51
|
+
fetchImpl,
|
|
52
|
+
sleep,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
await expect(
|
|
56
|
+
client.startJob("jwt-token", {
|
|
57
|
+
companyUid: "cmp_123",
|
|
58
|
+
secretNames: ["API_KEY"],
|
|
59
|
+
command: "node script.js",
|
|
60
|
+
}),
|
|
61
|
+
).resolves.toEqual({ jobId: "job_1", status: "queued" });
|
|
62
|
+
expect(fetchImpl).toHaveBeenCalledTimes(3);
|
|
63
|
+
expect(sleep).toHaveBeenCalledTimes(2);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("retries startJob after a retryable 503 response", async () => {
|
|
67
|
+
const fetchImpl = vi
|
|
68
|
+
.fn<typeof fetch>()
|
|
69
|
+
.mockResolvedValueOnce(jsonRes({ message: "warming up" }, 503))
|
|
70
|
+
.mockResolvedValueOnce(jsonRes({ jobId: "job_1", status: "queued" }));
|
|
71
|
+
const sleep = vi.fn(async () => {});
|
|
72
|
+
const client = new SandboxRunnerClient({
|
|
73
|
+
baseUrl: "https://runner.example",
|
|
74
|
+
fetchImpl,
|
|
75
|
+
sleep,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await expect(
|
|
79
|
+
client.startJob("jwt-token", {
|
|
80
|
+
companyUid: "cmp_123",
|
|
81
|
+
secretNames: ["API_KEY"],
|
|
82
|
+
command: "node script.js",
|
|
83
|
+
}),
|
|
84
|
+
).resolves.toEqual({ jobId: "job_1", status: "queued" });
|
|
85
|
+
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
|
86
|
+
expect(sleep).toHaveBeenCalledTimes(1);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it.each([400, 401])("does not retry startJob on %i responses", async (status) => {
|
|
90
|
+
const fetchImpl = vi.fn<typeof fetch>(async () =>
|
|
91
|
+
jsonRes({ message: "bad request" }, status),
|
|
92
|
+
);
|
|
93
|
+
const sleep = vi.fn(async () => {});
|
|
94
|
+
const client = new SandboxRunnerClient({
|
|
95
|
+
baseUrl: "https://runner.example",
|
|
96
|
+
fetchImpl,
|
|
97
|
+
sleep,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await expect(
|
|
101
|
+
client.startJob("jwt-token", {
|
|
102
|
+
companyUid: "cmp_123",
|
|
103
|
+
secretNames: ["API_KEY"],
|
|
104
|
+
command: "node script.js",
|
|
105
|
+
}),
|
|
106
|
+
).rejects.toThrow("Sandbox Runner rejected job: bad request");
|
|
107
|
+
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
108
|
+
expect(sleep).not.toHaveBeenCalled();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("throws a clear exhaustion error after maxAttempts transient failures", async () => {
|
|
112
|
+
const fetchImpl = vi
|
|
113
|
+
.fn<typeof fetch>()
|
|
114
|
+
.mockRejectedValue(new TypeError("fetch failed"));
|
|
115
|
+
const sleep = vi.fn(async () => {});
|
|
116
|
+
const client = new SandboxRunnerClient({
|
|
117
|
+
baseUrl: "https://runner.example",
|
|
118
|
+
fetchImpl,
|
|
119
|
+
retry: { maxAttempts: 3 },
|
|
120
|
+
sleep,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await expect(
|
|
124
|
+
client.startJob("jwt-token", {
|
|
125
|
+
companyUid: "cmp_123",
|
|
126
|
+
secretNames: ["API_KEY"],
|
|
127
|
+
command: "node script.js",
|
|
128
|
+
}),
|
|
129
|
+
).rejects.toThrow(
|
|
130
|
+
"Sandbox Runner did not respond after 3 attempts (cold start or transient network); last error: fetch failed",
|
|
131
|
+
);
|
|
132
|
+
expect(fetchImpl).toHaveBeenCalledTimes(3);
|
|
133
|
+
expect(sleep).toHaveBeenCalledTimes(2);
|
|
134
|
+
});
|
|
135
|
+
|
|
42
136
|
it("polls queued and running states until succeeded", async () => {
|
|
43
137
|
const fetchImpl = vi
|
|
44
138
|
.fn<typeof fetch>()
|
|
@@ -77,6 +171,40 @@ describe("SandboxRunnerClient", () => {
|
|
|
77
171
|
);
|
|
78
172
|
});
|
|
79
173
|
|
|
174
|
+
it("tolerates one transient fetch rejection while polling", async () => {
|
|
175
|
+
const fetchImpl = vi
|
|
176
|
+
.fn<typeof fetch>()
|
|
177
|
+
.mockResolvedValueOnce(jsonRes({ jobId: "job_1", status: "queued" }))
|
|
178
|
+
.mockRejectedValueOnce(new TypeError("fetch failed"))
|
|
179
|
+
.mockResolvedValueOnce(jsonRes({ jobId: "job_1", status: "running" }))
|
|
180
|
+
.mockResolvedValueOnce(
|
|
181
|
+
jsonRes({
|
|
182
|
+
jobId: "job_1",
|
|
183
|
+
status: "succeeded",
|
|
184
|
+
output: "ok\n",
|
|
185
|
+
exitCode: 0,
|
|
186
|
+
success: true,
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
const sleep = vi.fn(async () => {});
|
|
190
|
+
const client = new SandboxRunnerClient({
|
|
191
|
+
baseUrl: "https://runner.example",
|
|
192
|
+
fetchImpl,
|
|
193
|
+
sleep,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
await expect(
|
|
197
|
+
client.pollJob("jwt-token", "job_1", { intervalMs: 0 }),
|
|
198
|
+
).resolves.toMatchObject({
|
|
199
|
+
jobId: "job_1",
|
|
200
|
+
status: "succeeded",
|
|
201
|
+
output: "ok\n",
|
|
202
|
+
exitCode: 0,
|
|
203
|
+
success: true,
|
|
204
|
+
});
|
|
205
|
+
expect(fetchImpl).toHaveBeenCalledTimes(4);
|
|
206
|
+
});
|
|
207
|
+
|
|
80
208
|
it("returns failed terminal jobs with error details", async () => {
|
|
81
209
|
const fetchImpl = vi.fn<typeof fetch>(async () =>
|
|
82
210
|
jsonRes({
|
|
@@ -22,6 +22,8 @@ export interface SandboxRunnerJob {
|
|
|
22
22
|
export interface SandboxRunnerClientOptions {
|
|
23
23
|
baseUrl?: string;
|
|
24
24
|
fetchImpl?: typeof fetch;
|
|
25
|
+
retry?: SandboxRunnerRetryOptions;
|
|
26
|
+
sleep?: (ms: number) => Promise<void>;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
export interface SandboxRunnerPollOptions {
|
|
@@ -29,7 +31,21 @@ export interface SandboxRunnerPollOptions {
|
|
|
29
31
|
maxPolls?: number;
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
export interface SandboxRunnerRetryOptions {
|
|
35
|
+
maxAttempts?: number;
|
|
36
|
+
maxElapsedMs?: number;
|
|
37
|
+
baseDelayMs?: number;
|
|
38
|
+
maxDelayMs?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
32
41
|
const DEFAULT_SANDBOX_RUNNER_URL = "https://hqapi.getindigo.ai/sandbox";
|
|
42
|
+
const RETRYABLE_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
43
|
+
const DEFAULT_RETRY_OPTIONS: Required<SandboxRunnerRetryOptions> = {
|
|
44
|
+
maxAttempts: 8,
|
|
45
|
+
maxElapsedMs: 90_000,
|
|
46
|
+
baseDelayMs: 500,
|
|
47
|
+
maxDelayMs: 5_000,
|
|
48
|
+
};
|
|
33
49
|
|
|
34
50
|
function normalizeBaseUrl(baseUrl: string): string {
|
|
35
51
|
return baseUrl.replace(/\/+$/, "");
|
|
@@ -82,20 +98,100 @@ function delay(ms: number): Promise<void> {
|
|
|
82
98
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
83
99
|
}
|
|
84
100
|
|
|
101
|
+
function getErrorMessage(error: unknown): string {
|
|
102
|
+
if (error instanceof Error) {
|
|
103
|
+
return error.message;
|
|
104
|
+
}
|
|
105
|
+
if (typeof error === "string") {
|
|
106
|
+
return error;
|
|
107
|
+
}
|
|
108
|
+
return String(error);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseRetryAfterMs(value: string | null): number | undefined {
|
|
112
|
+
if (!value) return undefined;
|
|
113
|
+
const seconds = Number(value);
|
|
114
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
115
|
+
return seconds * 1000;
|
|
116
|
+
}
|
|
117
|
+
const dateMs = Date.parse(value);
|
|
118
|
+
if (!Number.isNaN(dateMs)) {
|
|
119
|
+
return Math.max(0, dateMs - Date.now());
|
|
120
|
+
}
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
85
124
|
export class SandboxRunnerClient {
|
|
86
125
|
private readonly baseUrl: string;
|
|
87
126
|
private readonly fetchImpl: typeof fetch;
|
|
127
|
+
private readonly retry: Required<SandboxRunnerRetryOptions>;
|
|
128
|
+
private readonly sleep: (ms: number) => Promise<void>;
|
|
88
129
|
|
|
89
130
|
constructor(options: SandboxRunnerClientOptions = {}) {
|
|
90
131
|
this.baseUrl = normalizeBaseUrl(options.baseUrl ?? getSandboxRunnerBaseUrl());
|
|
91
132
|
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
133
|
+
this.retry = { ...DEFAULT_RETRY_OPTIONS, ...options.retry };
|
|
134
|
+
this.sleep = options.sleep ?? delay;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private async fetchWithRetry(
|
|
138
|
+
url: string,
|
|
139
|
+
init: RequestInit,
|
|
140
|
+
): Promise<Response> {
|
|
141
|
+
const startedAt = Date.now();
|
|
142
|
+
let lastError = "unknown error";
|
|
143
|
+
let attempts = 0;
|
|
144
|
+
|
|
145
|
+
for (let attempt = 1; attempt <= this.retry.maxAttempts; attempt += 1) {
|
|
146
|
+
attempts = attempt;
|
|
147
|
+
try {
|
|
148
|
+
const res = await this.fetchImpl(url, init);
|
|
149
|
+
if (!RETRYABLE_STATUS_CODES.has(res.status)) {
|
|
150
|
+
return res;
|
|
151
|
+
}
|
|
152
|
+
lastError = `HTTP ${res.status} ${res.statusText}`.trim();
|
|
153
|
+
if (!this.shouldRetry(attempt, startedAt)) {
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
await this.sleep(this.nextDelayMs(attempt, res));
|
|
157
|
+
} catch (error) {
|
|
158
|
+
lastError = getErrorMessage(error);
|
|
159
|
+
if (!this.shouldRetry(attempt, startedAt)) {
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
await this.sleep(this.nextDelayMs(attempt));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
throw new Error(
|
|
167
|
+
`Sandbox Runner did not respond after ${attempts} attempts ` +
|
|
168
|
+
`(cold start or transient network); last error: ${lastError}`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private shouldRetry(attempt: number, startedAt: number): boolean {
|
|
173
|
+
if (attempt >= this.retry.maxAttempts) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
return Date.now() - startedAt < this.retry.maxElapsedMs;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private nextDelayMs(attempt: number, res?: Response): number {
|
|
180
|
+
const retryAfterMs = parseRetryAfterMs(res?.headers.get("Retry-After") ?? null);
|
|
181
|
+
if (retryAfterMs !== undefined) {
|
|
182
|
+
return Math.min(retryAfterMs, this.retry.maxDelayMs);
|
|
183
|
+
}
|
|
184
|
+
const exponential = this.retry.baseDelayMs * 2 ** (attempt - 1);
|
|
185
|
+
const capped = Math.min(exponential, this.retry.maxDelayMs);
|
|
186
|
+
const jitter = Math.floor(Math.random() * Math.max(1, capped * 0.25));
|
|
187
|
+
return Math.min(capped + jitter, this.retry.maxDelayMs);
|
|
92
188
|
}
|
|
93
189
|
|
|
94
190
|
async startJob(
|
|
95
191
|
token: string,
|
|
96
192
|
request: SandboxRunnerStartRequest,
|
|
97
193
|
): Promise<SandboxRunnerStartResponse> {
|
|
98
|
-
const res = await this.
|
|
194
|
+
const res = await this.fetchWithRetry(`${this.baseUrl}/jobs`, {
|
|
99
195
|
method: "POST",
|
|
100
196
|
headers: {
|
|
101
197
|
Authorization: `Bearer ${token}`,
|
|
@@ -124,7 +220,7 @@ export class SandboxRunnerClient {
|
|
|
124
220
|
}
|
|
125
221
|
|
|
126
222
|
async getJob(token: string, jobId: string): Promise<SandboxRunnerJob> {
|
|
127
|
-
const res = await this.
|
|
223
|
+
const res = await this.fetchWithRetry(
|
|
128
224
|
`${this.baseUrl}/jobs/${encodeURIComponent(jobId)}`,
|
|
129
225
|
{
|
|
130
226
|
headers: { Authorization: `Bearer ${token}` },
|
|
@@ -155,7 +251,7 @@ export class SandboxRunnerClient {
|
|
|
155
251
|
if (job.status === "succeeded" || job.status === "failed") {
|
|
156
252
|
return job;
|
|
157
253
|
}
|
|
158
|
-
await
|
|
254
|
+
await this.sleep(intervalMs);
|
|
159
255
|
}
|
|
160
256
|
throw new Error(`Sandbox Runner job '${jobId}' did not finish before the poll limit.`);
|
|
161
257
|
}
|
|
@@ -120,6 +120,36 @@ describe("refreshVersionCache", () => {
|
|
|
120
120
|
expect(typeof written.fetchedAt).toBe("number");
|
|
121
121
|
});
|
|
122
122
|
|
|
123
|
+
it("returns without fetching when the cached version is still within the TTL", async () => {
|
|
124
|
+
writeCache("5.99.0", 60 * 1000);
|
|
125
|
+
const fetchMock = vi.fn();
|
|
126
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
127
|
+
|
|
128
|
+
const mod = await loadModule();
|
|
129
|
+
await mod.refreshVersionCache();
|
|
130
|
+
|
|
131
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
132
|
+
const cachePath = path.join(tmpHome, ".hq", "version-check.json");
|
|
133
|
+
const written = JSON.parse(fs.readFileSync(cachePath, "utf-8"));
|
|
134
|
+
expect(written.latest).toBe("5.99.0");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("skips passive refresh for noninteractive status probes", async () => {
|
|
138
|
+
const originalArgv = process.argv;
|
|
139
|
+
process.argv = ["node", "hq", "mcp", "status", "--json"];
|
|
140
|
+
const fetchMock = vi.fn();
|
|
141
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const mod = await loadModule();
|
|
145
|
+
await mod.refreshVersionCache();
|
|
146
|
+
} finally {
|
|
147
|
+
process.argv = originalArgv;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
151
|
+
});
|
|
152
|
+
|
|
123
153
|
it("does not throw or write a cache when fetch fails", async () => {
|
|
124
154
|
const fetchMock = vi.fn().mockRejectedValue(new Error("network down"));
|
|
125
155
|
vi.stubGlobal("fetch", fetchMock);
|
|
@@ -8,7 +8,9 @@ import { CLI_VERSION } from "../cli-version.js";
|
|
|
8
8
|
const PACKAGE_NAME = "@indigoai-us/hq-cli";
|
|
9
9
|
const REGISTRY_URL = `https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME)}/latest`;
|
|
10
10
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
11
|
+
const CACHE_TTL_JITTER_MS = 60 * 60 * 1000;
|
|
11
12
|
const FETCH_TIMEOUT_MS = 3_000;
|
|
13
|
+
const REFRESH_LOCK_STALE_MS = 10 * 60 * 1000;
|
|
12
14
|
|
|
13
15
|
interface CacheEntry {
|
|
14
16
|
latest: string;
|
|
@@ -19,6 +21,10 @@ function cachePath(): string {
|
|
|
19
21
|
return path.join(os.homedir(), ".hq", "version-check.json");
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
function lockPath(): string {
|
|
25
|
+
return path.join(os.homedir(), ".hq", "version-check.lock");
|
|
26
|
+
}
|
|
27
|
+
|
|
22
28
|
function isOptedOut(): boolean {
|
|
23
29
|
return process.env.HQ_NO_UPDATE_CHECK === "1";
|
|
24
30
|
}
|
|
@@ -49,6 +55,58 @@ function writeCache(entry: CacheEntry): void {
|
|
|
49
55
|
}
|
|
50
56
|
}
|
|
51
57
|
|
|
58
|
+
function freshEnough(entry: CacheEntry, now = Date.now()): boolean {
|
|
59
|
+
const jitter = Math.floor(Math.random() * CACHE_TTL_JITTER_MS);
|
|
60
|
+
return now - entry.fetchedAt <= CACHE_TTL_MS - jitter;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isKnownNoninteractiveStatusProbe(argv: readonly string[] = process.argv): boolean {
|
|
64
|
+
const args = argv.slice(2);
|
|
65
|
+
const positional = args.filter((arg) => !arg.startsWith("-"));
|
|
66
|
+
const json = args.includes("--json") || !process.stdout.isTTY;
|
|
67
|
+
if (!json) return false;
|
|
68
|
+
if (positional[0] === "mcp" && positional[1] === "status") return true;
|
|
69
|
+
if (positional[0] === "packs" && (positional[1] === "list" || positional[1] === "ls")) return true;
|
|
70
|
+
if (
|
|
71
|
+
positional[0] === "packages" &&
|
|
72
|
+
positional[1] === "packs" &&
|
|
73
|
+
(positional[2] === "list" || positional[2] === "ls")
|
|
74
|
+
) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
if ((positional[0] === "sources" || positional[0] === "signals") && positional[1] === "list") {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function acquireRefreshLock(now = Date.now()): (() => void) | null {
|
|
84
|
+
const dir = lockPath();
|
|
85
|
+
try {
|
|
86
|
+
fs.mkdirSync(path.dirname(dir), { recursive: true });
|
|
87
|
+
fs.mkdirSync(dir);
|
|
88
|
+
fs.writeFileSync(path.join(dir, "owner"), `${process.pid}\n${now}\n`);
|
|
89
|
+
return () => {
|
|
90
|
+
try {
|
|
91
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
92
|
+
} catch {
|
|
93
|
+
// best-effort lock cleanup
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
} catch {
|
|
97
|
+
try {
|
|
98
|
+
const stat = fs.statSync(dir);
|
|
99
|
+
if (now - stat.mtimeMs > REFRESH_LOCK_STALE_MS) {
|
|
100
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
101
|
+
return acquireRefreshLock(now);
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
// ignore lock inspection failures
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
52
110
|
export function maybeWarnNewVersion(): void {
|
|
53
111
|
if (isOptedOut()) return;
|
|
54
112
|
const entry = readCache();
|
|
@@ -68,7 +126,14 @@ export function maybeWarnNewVersion(): void {
|
|
|
68
126
|
|
|
69
127
|
export async function refreshVersionCache(): Promise<void> {
|
|
70
128
|
if (isOptedOut()) return;
|
|
129
|
+
if (isKnownNoninteractiveStatusProbe()) return;
|
|
130
|
+
const existing = readCache();
|
|
131
|
+
if (existing && freshEnough(existing)) return;
|
|
132
|
+
const releaseLock = acquireRefreshLock();
|
|
133
|
+
if (!releaseLock) return;
|
|
71
134
|
try {
|
|
135
|
+
const lockedExisting = readCache();
|
|
136
|
+
if (lockedExisting && freshEnough(lockedExisting)) return;
|
|
72
137
|
const res = await fetch(REGISTRY_URL, {
|
|
73
138
|
headers: { Accept: "application/json" },
|
|
74
139
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
@@ -79,5 +144,12 @@ export async function refreshVersionCache(): Promise<void> {
|
|
|
79
144
|
writeCache({ latest: body.version, fetchedAt: Date.now() });
|
|
80
145
|
} catch {
|
|
81
146
|
// best-effort; offline / registry down / timeout — silent
|
|
147
|
+
} finally {
|
|
148
|
+
releaseLock();
|
|
82
149
|
}
|
|
83
150
|
}
|
|
151
|
+
|
|
152
|
+
export const __test__ = {
|
|
153
|
+
CACHE_TTL_MS,
|
|
154
|
+
isKnownNoninteractiveStatusProbe,
|
|
155
|
+
};
|