kanban-ai 0.19.2 → 0.19.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.
- package/dist/github.js +302 -12
- package/dist/github.test.js +449 -1
- package/dist/index.js +110 -8
- package/dist/index.test.js +204 -25
- package/package.json +1 -1
package/dist/github.js
CHANGED
|
@@ -1,11 +1,186 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.GithubRateLimitError = void 0;
|
|
3
7
|
exports.getLatestRelease = getLatestRelease;
|
|
4
8
|
exports.getReleaseByVersion = getReleaseByVersion;
|
|
9
|
+
exports.resolveLatestReleaseAssetViaRedirect = resolveLatestReleaseAssetViaRedirect;
|
|
10
|
+
exports.resolveReleaseAssetViaRedirect = resolveReleaseAssetViaRedirect;
|
|
11
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
12
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
13
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
14
|
const version_1 = require("./version");
|
|
15
|
+
class GithubRateLimitError extends Error {
|
|
16
|
+
constructor(message, options) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = 'GithubRateLimitError';
|
|
19
|
+
this.status = options.status;
|
|
20
|
+
this.retryAfterSeconds = options.retryAfterSeconds;
|
|
21
|
+
this.rateLimitResetSeconds = options.rateLimitResetSeconds;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
exports.GithubRateLimitError = GithubRateLimitError;
|
|
6
25
|
const GITHUB_API_BASE = 'https://api.github.com';
|
|
7
|
-
|
|
8
|
-
|
|
26
|
+
const DEFAULT_CACHE_TTL_MS = 30 * 60000;
|
|
27
|
+
function nowMs(cache) {
|
|
28
|
+
return cache?.now ?? Date.now();
|
|
29
|
+
}
|
|
30
|
+
function resolveTtlMs(cache) {
|
|
31
|
+
if (cache?.ttlMs === undefined)
|
|
32
|
+
return DEFAULT_CACHE_TTL_MS;
|
|
33
|
+
return cache.ttlMs;
|
|
34
|
+
}
|
|
35
|
+
function isFresh(entry, ttlMs, now) {
|
|
36
|
+
if (entry.fetchedAt > now)
|
|
37
|
+
return false;
|
|
38
|
+
return now - entry.fetchedAt < ttlMs;
|
|
39
|
+
}
|
|
40
|
+
function cacheFilePath(cacheDir, repo, apiPath) {
|
|
41
|
+
const baseDir = node_path_1.default.resolve(cacheDir);
|
|
42
|
+
const repoKey = repo.replace(/[^a-zA-Z0-9._-]+/g, '__');
|
|
43
|
+
const repoHash = (0, node_crypto_1.createHash)('sha256').update(repo).digest('hex').slice(0, 12);
|
|
44
|
+
const repoSafe = repoKey === '' || repoKey === '.' || repoKey === '..' ? `repo-${repoHash}` : repoKey;
|
|
45
|
+
const key = (0, node_crypto_1.createHash)('sha256')
|
|
46
|
+
.update(`${GITHUB_API_BASE}${apiPath}`)
|
|
47
|
+
.digest('hex')
|
|
48
|
+
.slice(0, 32);
|
|
49
|
+
const fileName = `${key}.json`;
|
|
50
|
+
const candidate = node_path_1.default.resolve(baseDir, repoSafe, fileName);
|
|
51
|
+
if (!candidate.startsWith(`${baseDir}${node_path_1.default.sep}`)) {
|
|
52
|
+
return node_path_1.default.join(baseDir, `repo-${repoHash}`, fileName);
|
|
53
|
+
}
|
|
54
|
+
return candidate;
|
|
55
|
+
}
|
|
56
|
+
async function readCacheEntry(filePath) {
|
|
57
|
+
try {
|
|
58
|
+
const raw = await node_fs_1.default.promises.readFile(filePath, 'utf8');
|
|
59
|
+
const parsed = JSON.parse(raw);
|
|
60
|
+
if (!parsed || typeof parsed !== 'object')
|
|
61
|
+
return null;
|
|
62
|
+
if (typeof parsed.fetchedAt !== 'number')
|
|
63
|
+
return null;
|
|
64
|
+
if (!('data' in parsed))
|
|
65
|
+
return null;
|
|
66
|
+
return parsed;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function tryUnlink(filePath) {
|
|
73
|
+
try {
|
|
74
|
+
await node_fs_1.default.promises.unlink(filePath);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async function tryRename(from, to) {
|
|
82
|
+
try {
|
|
83
|
+
await node_fs_1.default.promises.rename(from, to);
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async function tryCopyFile(from, to) {
|
|
91
|
+
try {
|
|
92
|
+
await node_fs_1.default.promises.copyFile(from, to);
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async function writeCacheEntry(filePath, entry) {
|
|
100
|
+
const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
101
|
+
try {
|
|
102
|
+
await node_fs_1.default.promises.mkdir(node_path_1.default.dirname(filePath), { recursive: true });
|
|
103
|
+
await node_fs_1.default.promises.writeFile(tmpPath, JSON.stringify(entry), 'utf8');
|
|
104
|
+
if (await tryRename(tmpPath, filePath))
|
|
105
|
+
return;
|
|
106
|
+
if (await tryCopyFile(tmpPath, filePath))
|
|
107
|
+
return;
|
|
108
|
+
await tryUnlink(filePath);
|
|
109
|
+
await tryRename(tmpPath, filePath);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
await tryUnlink(tmpPath);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function parseRateLimitInfo(res) {
|
|
119
|
+
const retryAfterRaw = res.headers.get('retry-after');
|
|
120
|
+
const resetRaw = res.headers.get('x-ratelimit-reset');
|
|
121
|
+
const remainingRaw = res.headers.get('x-ratelimit-remaining');
|
|
122
|
+
const retryAfterSeconds = retryAfterRaw ? Number.parseInt(retryAfterRaw, 10) : undefined;
|
|
123
|
+
const rateLimitResetSeconds = resetRaw ? Number.parseInt(resetRaw, 10) : undefined;
|
|
124
|
+
const rateLimitRemaining = remainingRaw ? Number.parseInt(remainingRaw, 10) : undefined;
|
|
125
|
+
return {
|
|
126
|
+
retryAfterSeconds: Number.isFinite(retryAfterSeconds) ? retryAfterSeconds : undefined,
|
|
127
|
+
rateLimitResetSeconds: Number.isFinite(rateLimitResetSeconds) ? rateLimitResetSeconds : undefined,
|
|
128
|
+
rateLimitRemaining: Number.isFinite(rateLimitRemaining) ? rateLimitRemaining : undefined,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function formatWaitMs(ms) {
|
|
132
|
+
const seconds = Math.ceil(ms / 1000);
|
|
133
|
+
if (seconds < 60)
|
|
134
|
+
return `${seconds}s`;
|
|
135
|
+
const minutes = Math.ceil(seconds / 60);
|
|
136
|
+
return `${minutes}m`;
|
|
137
|
+
}
|
|
138
|
+
function formatRateLimitMessage(info, now) {
|
|
139
|
+
if (info.retryAfterSeconds && info.retryAfterSeconds > 0) {
|
|
140
|
+
return `GitHub API rate limited (retry after ${formatWaitMs(info.retryAfterSeconds * 1000)}).`;
|
|
141
|
+
}
|
|
142
|
+
if (info.rateLimitRemaining === 0 && info.rateLimitResetSeconds) {
|
|
143
|
+
const resetAtMs = info.rateLimitResetSeconds * 1000;
|
|
144
|
+
const waitMs = Math.max(0, resetAtMs - now);
|
|
145
|
+
const resetIso = new Date(resetAtMs).toISOString();
|
|
146
|
+
return `GitHub API rate limit exceeded (resets at ${resetIso}, ~${formatWaitMs(waitMs)}).`;
|
|
147
|
+
}
|
|
148
|
+
return 'GitHub API is temporarily rate limiting requests.';
|
|
149
|
+
}
|
|
150
|
+
function isRedirectStatus(status) {
|
|
151
|
+
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
|
152
|
+
}
|
|
153
|
+
function assertAllowedRedirectUrl(url) {
|
|
154
|
+
const parsed = new URL(url);
|
|
155
|
+
if (parsed.protocol !== 'https:') {
|
|
156
|
+
throw new Error(`Unexpected redirect protocol: ${parsed.protocol}`);
|
|
157
|
+
}
|
|
158
|
+
if (parsed.hostname === 'github.com') {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (parsed.hostname.endsWith('.githubusercontent.com')) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
throw new Error(`Unexpected redirect host: ${parsed.hostname}`);
|
|
165
|
+
}
|
|
166
|
+
function extractTagFromDownloadUrl(url) {
|
|
167
|
+
try {
|
|
168
|
+
const u = new URL(url);
|
|
169
|
+
const parts = u.pathname.split('/').filter(Boolean);
|
|
170
|
+
const downloadIdx = parts.indexOf('download');
|
|
171
|
+
if (downloadIdx <= 0)
|
|
172
|
+
return null;
|
|
173
|
+
if (parts[downloadIdx - 1] !== 'releases')
|
|
174
|
+
return null;
|
|
175
|
+
const tag = parts[downloadIdx + 1];
|
|
176
|
+
return tag || null;
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
async function githubFetchJson(repo, apiPath, options) {
|
|
183
|
+
const url = `${GITHUB_API_BASE}${apiPath}`;
|
|
9
184
|
const headers = {
|
|
10
185
|
Accept: 'application/vnd.github+json',
|
|
11
186
|
'User-Agent': 'kanban-ai-cli',
|
|
@@ -14,24 +189,139 @@ async function githubFetch(path) {
|
|
|
14
189
|
if (token) {
|
|
15
190
|
headers.Authorization = `Bearer ${token}`;
|
|
16
191
|
}
|
|
192
|
+
const cache = options?.cache;
|
|
193
|
+
const ttlMs = resolveTtlMs(cache);
|
|
194
|
+
const now = nowMs(cache);
|
|
195
|
+
const filePath = cache ? cacheFilePath(cache.dir, repo, apiPath) : null;
|
|
196
|
+
const cached = filePath ? await readCacheEntry(filePath) : null;
|
|
197
|
+
if (cached && isFresh(cached, ttlMs, now)) {
|
|
198
|
+
return { data: cached.data, meta: { source: 'cache' } };
|
|
199
|
+
}
|
|
200
|
+
if (cached?.etag) {
|
|
201
|
+
headers['If-None-Match'] = cached.etag;
|
|
202
|
+
}
|
|
17
203
|
const res = await fetch(url, {
|
|
18
204
|
headers,
|
|
19
205
|
redirect: 'follow',
|
|
20
206
|
});
|
|
207
|
+
if (res.status === 304 && cached) {
|
|
208
|
+
if (filePath) {
|
|
209
|
+
const etag = res.headers.get('etag') || cached.etag;
|
|
210
|
+
await writeCacheEntry(filePath, {
|
|
211
|
+
etag: etag || undefined,
|
|
212
|
+
fetchedAt: now,
|
|
213
|
+
data: cached.data,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
return { data: cached.data, meta: { source: 'cache' } };
|
|
217
|
+
}
|
|
21
218
|
if (!res.ok) {
|
|
219
|
+
const info = res.status === 403 || res.status === 429 ? parseRateLimitInfo(res) : null;
|
|
22
220
|
const body = await res.text().catch(() => '');
|
|
23
|
-
|
|
221
|
+
const bodySnippet = body ? body.slice(0, 500) : '';
|
|
222
|
+
const looksLikeRateLimit = res.status === 429 ||
|
|
223
|
+
/rate limit/i.test(bodySnippet) ||
|
|
224
|
+
Boolean(info?.retryAfterSeconds) ||
|
|
225
|
+
info?.rateLimitRemaining === 0;
|
|
226
|
+
if (looksLikeRateLimit && info && cached) {
|
|
227
|
+
return {
|
|
228
|
+
data: cached.data,
|
|
229
|
+
meta: {
|
|
230
|
+
source: 'stale-cache',
|
|
231
|
+
warning: `${formatRateLimitMessage(info, now)} Tip: set GITHUB_TOKEN or GH_TOKEN for higher GitHub rate limits.`,
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
if (looksLikeRateLimit && info) {
|
|
236
|
+
const message = `${formatRateLimitMessage(info, now)} Tip: set GITHUB_TOKEN or GH_TOKEN for higher GitHub rate limits.`;
|
|
237
|
+
throw new GithubRateLimitError(message, {
|
|
238
|
+
status: res.status,
|
|
239
|
+
retryAfterSeconds: info.retryAfterSeconds,
|
|
240
|
+
rateLimitResetSeconds: info.rateLimitResetSeconds,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
throw new Error(`GitHub API request failed: ${res.status} ${res.statusText}${bodySnippet ? ` - ${bodySnippet}` : ''}`);
|
|
24
244
|
}
|
|
25
|
-
return res;
|
|
26
|
-
}
|
|
27
|
-
async function getLatestRelease(repo) {
|
|
28
|
-
const res = await githubFetch(`/repos/${repo}/releases/latest`);
|
|
29
245
|
const json = (await res.json());
|
|
30
|
-
|
|
31
|
-
|
|
246
|
+
if (filePath) {
|
|
247
|
+
const etag = res.headers.get('etag') || undefined;
|
|
248
|
+
await writeCacheEntry(filePath, { etag, fetchedAt: now, data: json });
|
|
249
|
+
}
|
|
250
|
+
return { data: json, meta: { source: 'network' } };
|
|
32
251
|
}
|
|
33
|
-
async function
|
|
252
|
+
async function getLatestRelease(repo, options) {
|
|
253
|
+
const result = await githubFetchJson(repo, `/repos/${repo}/releases/latest`, options);
|
|
254
|
+
const version = (0, version_1.cleanVersionTag)(result.data.tag_name);
|
|
255
|
+
return { version, release: result.data, meta: result.meta };
|
|
256
|
+
}
|
|
257
|
+
async function getReleaseByVersion(repo, version, options) {
|
|
258
|
+
const tag = version.startsWith('v') ? version : `v${version}`;
|
|
259
|
+
const result = await githubFetchJson(repo, `/repos/${repo}/releases/tags/${encodeURIComponent(tag)}`, options);
|
|
260
|
+
const resolvedVersion = (0, version_1.cleanVersionTag)(result.data.tag_name);
|
|
261
|
+
return { version: resolvedVersion, release: result.data, meta: result.meta };
|
|
262
|
+
}
|
|
263
|
+
async function resolveLatestReleaseAssetViaRedirect(repo, assetNameCandidates) {
|
|
264
|
+
for (const assetName of assetNameCandidates) {
|
|
265
|
+
const latestUrl = `https://github.com/${repo}/releases/latest/download/${assetName}`;
|
|
266
|
+
const res = await fetch(latestUrl, { redirect: 'manual', method: 'HEAD' });
|
|
267
|
+
if (!isRedirectStatus(res.status)) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const firstLocation = res.headers.get('location');
|
|
271
|
+
if (!firstLocation) {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const releaseUrl = new URL(firstLocation, latestUrl).toString();
|
|
275
|
+
assertAllowedRedirectUrl(releaseUrl);
|
|
276
|
+
const tag = extractTagFromDownloadUrl(releaseUrl);
|
|
277
|
+
if (!tag) {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const res2 = await fetch(releaseUrl, { redirect: 'manual', method: 'HEAD' });
|
|
281
|
+
if (!isRedirectStatus(res2.status)) {
|
|
282
|
+
if (res2.status >= 200 && res2.status < 300) {
|
|
283
|
+
return { tag, version: (0, version_1.cleanVersionTag)(tag), assetName, url: releaseUrl };
|
|
284
|
+
}
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const secondLocation = res2.headers.get('location');
|
|
288
|
+
if (!secondLocation) {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
const assetUrl = new URL(secondLocation, releaseUrl).toString();
|
|
292
|
+
assertAllowedRedirectUrl(assetUrl);
|
|
293
|
+
return { tag, version: (0, version_1.cleanVersionTag)(tag), assetName, url: assetUrl };
|
|
294
|
+
}
|
|
295
|
+
throw new Error(`Could not resolve latest release download URL for ${repo} (tried: ${assetNameCandidates.join(', ')})`);
|
|
296
|
+
}
|
|
297
|
+
async function resolveReleaseAssetViaRedirect(repo, version, assetNameCandidates) {
|
|
34
298
|
const tag = version.startsWith('v') ? version : `v${version}`;
|
|
35
|
-
const
|
|
36
|
-
|
|
299
|
+
for (const assetName of assetNameCandidates) {
|
|
300
|
+
const releaseUrl = `https://github.com/${repo}/releases/download/${tag}/${assetName}`;
|
|
301
|
+
const res = await fetch(releaseUrl, { redirect: 'manual', method: 'HEAD' });
|
|
302
|
+
if (!isRedirectStatus(res.status)) {
|
|
303
|
+
if (res.status >= 200 && res.status < 300) {
|
|
304
|
+
return {
|
|
305
|
+
tag,
|
|
306
|
+
version: (0, version_1.cleanVersionTag)(tag),
|
|
307
|
+
assetName,
|
|
308
|
+
url: releaseUrl,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const location = res.headers.get('location');
|
|
314
|
+
if (!location) {
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const assetUrl = new URL(location, releaseUrl).toString();
|
|
318
|
+
assertAllowedRedirectUrl(assetUrl);
|
|
319
|
+
return {
|
|
320
|
+
tag,
|
|
321
|
+
version: (0, version_1.cleanVersionTag)(tag),
|
|
322
|
+
assetName,
|
|
323
|
+
url: assetUrl,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
throw new Error(`Could not resolve release download URL for ${repo}@${tag} (tried: ${assetNameCandidates.join(', ')})`);
|
|
37
327
|
}
|
package/dist/github.test.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
7
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
8
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
3
9
|
const vitest_1 = require("vitest");
|
|
4
10
|
const github_1 = require("./github");
|
|
5
11
|
const originalFetch = globalThis.fetch;
|
|
@@ -41,7 +47,8 @@ const originalEnv = { ...process.env };
|
|
|
41
47
|
text: async () => "",
|
|
42
48
|
});
|
|
43
49
|
globalThis.fetch = fetchMock;
|
|
44
|
-
const release = await (0, github_1.getReleaseByVersion)("owner/repo", "2.0.0");
|
|
50
|
+
const { version, release } = await (0, github_1.getReleaseByVersion)("owner/repo", "2.0.0");
|
|
51
|
+
(0, vitest_1.expect)(version).toBe("2.0.0");
|
|
45
52
|
(0, vitest_1.expect)(release.tag_name).toBe("v2.0.0");
|
|
46
53
|
(0, vitest_1.expect)(fetchMock).toHaveBeenCalledWith("https://api.github.com/repos/owner/repo/releases/tags/v2.0.0", vitest_1.expect.any(Object));
|
|
47
54
|
});
|
|
@@ -73,4 +80,445 @@ const originalEnv = { ...process.env };
|
|
|
73
80
|
globalThis.fetch = fetchMock;
|
|
74
81
|
await (0, vitest_1.expect)((0, github_1.getLatestRelease)("owner/repo")).rejects.toThrow(/GitHub API request failed: 404 Not Found/);
|
|
75
82
|
});
|
|
83
|
+
(0, vitest_1.it)("caches latest release responses on disk with TTL", async () => {
|
|
84
|
+
const tmpDir = await node_fs_1.default.promises.mkdtemp(node_path_1.default.join(node_os_1.default.tmpdir(), "kanban-ai-cli-github-"));
|
|
85
|
+
try {
|
|
86
|
+
const fetchMock = vitest_1.vi.fn().mockResolvedValue({
|
|
87
|
+
ok: true,
|
|
88
|
+
status: 200,
|
|
89
|
+
statusText: "OK",
|
|
90
|
+
headers: {
|
|
91
|
+
get: (name) => name.toLowerCase() === "etag" ? "\"etag\"" : null,
|
|
92
|
+
},
|
|
93
|
+
json: async () => ({
|
|
94
|
+
tag_name: "v1.2.3",
|
|
95
|
+
assets: [],
|
|
96
|
+
}),
|
|
97
|
+
text: async () => "",
|
|
98
|
+
});
|
|
99
|
+
globalThis.fetch = fetchMock;
|
|
100
|
+
const first = await (0, github_1.getLatestRelease)("owner/repo", {
|
|
101
|
+
cache: { dir: tmpDir, ttlMs: 60000, now: 1000 },
|
|
102
|
+
});
|
|
103
|
+
const second = await (0, github_1.getLatestRelease)("owner/repo", {
|
|
104
|
+
cache: { dir: tmpDir, ttlMs: 60000, now: 2000 },
|
|
105
|
+
});
|
|
106
|
+
(0, vitest_1.expect)(first.version).toBe("1.2.3");
|
|
107
|
+
(0, vitest_1.expect)(second.version).toBe("1.2.3");
|
|
108
|
+
(0, vitest_1.expect)(fetchMock).toHaveBeenCalledTimes(1);
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
await node_fs_1.default.promises.rm(tmpDir, { recursive: true, force: true });
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
(0, vitest_1.it)("uses If-None-Match and reuses cached body on 304", async () => {
|
|
115
|
+
const tmpDir = await node_fs_1.default.promises.mkdtemp(node_path_1.default.join(node_os_1.default.tmpdir(), "kanban-ai-cli-github-"));
|
|
116
|
+
try {
|
|
117
|
+
const fetchMock = vitest_1.vi
|
|
118
|
+
.fn()
|
|
119
|
+
.mockResolvedValueOnce({
|
|
120
|
+
ok: true,
|
|
121
|
+
status: 200,
|
|
122
|
+
statusText: "OK",
|
|
123
|
+
headers: {
|
|
124
|
+
get: (name) => name.toLowerCase() === "etag" ? "\"etag\"" : null,
|
|
125
|
+
},
|
|
126
|
+
json: async () => ({
|
|
127
|
+
tag_name: "v1.2.3",
|
|
128
|
+
assets: [],
|
|
129
|
+
}),
|
|
130
|
+
text: async () => "",
|
|
131
|
+
})
|
|
132
|
+
.mockResolvedValueOnce({
|
|
133
|
+
ok: false,
|
|
134
|
+
status: 304,
|
|
135
|
+
statusText: "Not Modified",
|
|
136
|
+
headers: {
|
|
137
|
+
get: (name) => name.toLowerCase() === "etag" ? "\"etag\"" : null,
|
|
138
|
+
},
|
|
139
|
+
json: async () => ({}),
|
|
140
|
+
text: async () => "",
|
|
141
|
+
});
|
|
142
|
+
globalThis.fetch = fetchMock;
|
|
143
|
+
await (0, github_1.getLatestRelease)("owner/repo", {
|
|
144
|
+
cache: { dir: tmpDir, ttlMs: 1, now: 0 },
|
|
145
|
+
});
|
|
146
|
+
const second = await (0, github_1.getLatestRelease)("owner/repo", {
|
|
147
|
+
cache: { dir: tmpDir, ttlMs: 1, now: 10 },
|
|
148
|
+
});
|
|
149
|
+
(0, vitest_1.expect)(second.version).toBe("1.2.3");
|
|
150
|
+
(0, vitest_1.expect)(fetchMock).toHaveBeenCalledTimes(2);
|
|
151
|
+
const [, options] = fetchMock.mock.calls[1];
|
|
152
|
+
(0, vitest_1.expect)(options.headers["If-None-Match"]).toBe("\"etag\"");
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
await node_fs_1.default.promises.rm(tmpDir, { recursive: true, force: true });
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
(0, vitest_1.it)("respects ttlMs=0 for immediate revalidation", async () => {
|
|
159
|
+
const tmpDir = await node_fs_1.default.promises.mkdtemp(node_path_1.default.join(node_os_1.default.tmpdir(), "kanban-ai-cli-github-"));
|
|
160
|
+
try {
|
|
161
|
+
const fetchMock = vitest_1.vi
|
|
162
|
+
.fn()
|
|
163
|
+
.mockResolvedValueOnce({
|
|
164
|
+
ok: true,
|
|
165
|
+
status: 200,
|
|
166
|
+
statusText: "OK",
|
|
167
|
+
headers: {
|
|
168
|
+
get: (name) => name.toLowerCase() === "etag" ? "\"etag\"" : null,
|
|
169
|
+
},
|
|
170
|
+
json: async () => ({
|
|
171
|
+
tag_name: "v1.2.3",
|
|
172
|
+
assets: [],
|
|
173
|
+
}),
|
|
174
|
+
text: async () => "",
|
|
175
|
+
})
|
|
176
|
+
.mockResolvedValueOnce({
|
|
177
|
+
ok: false,
|
|
178
|
+
status: 304,
|
|
179
|
+
statusText: "Not Modified",
|
|
180
|
+
headers: {
|
|
181
|
+
get: (name) => name.toLowerCase() === "etag" ? "\"etag\"" : null,
|
|
182
|
+
},
|
|
183
|
+
json: async () => ({}),
|
|
184
|
+
text: async () => "",
|
|
185
|
+
});
|
|
186
|
+
globalThis.fetch = fetchMock;
|
|
187
|
+
await (0, github_1.getLatestRelease)("owner/repo", {
|
|
188
|
+
cache: { dir: tmpDir, ttlMs: 0, now: 0 },
|
|
189
|
+
});
|
|
190
|
+
await (0, github_1.getLatestRelease)("owner/repo", {
|
|
191
|
+
cache: { dir: tmpDir, ttlMs: 0, now: 1 },
|
|
192
|
+
});
|
|
193
|
+
(0, vitest_1.expect)(fetchMock).toHaveBeenCalledTimes(2);
|
|
194
|
+
const [, options] = fetchMock.mock.calls[1];
|
|
195
|
+
(0, vitest_1.expect)(options.headers["If-None-Match"]).toBe("\"etag\"");
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
await node_fs_1.default.promises.rm(tmpDir, { recursive: true, force: true });
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
(0, vitest_1.it)("writes cache entries even when rename fails", async () => {
|
|
202
|
+
const tmpDir = await node_fs_1.default.promises.mkdtemp(node_path_1.default.join(node_os_1.default.tmpdir(), "kanban-ai-cli-github-"));
|
|
203
|
+
const renameSpy = vitest_1.vi
|
|
204
|
+
.spyOn(node_fs_1.default.promises, "rename")
|
|
205
|
+
.mockRejectedValue(new Error("rename failed"));
|
|
206
|
+
try {
|
|
207
|
+
const fetchMock = vitest_1.vi.fn().mockResolvedValue({
|
|
208
|
+
ok: true,
|
|
209
|
+
status: 200,
|
|
210
|
+
statusText: "OK",
|
|
211
|
+
headers: {
|
|
212
|
+
get: (name) => name.toLowerCase() === "etag" ? "\"etag\"" : null,
|
|
213
|
+
},
|
|
214
|
+
json: async () => ({
|
|
215
|
+
tag_name: "v1.2.3",
|
|
216
|
+
assets: [],
|
|
217
|
+
}),
|
|
218
|
+
text: async () => "",
|
|
219
|
+
});
|
|
220
|
+
globalThis.fetch = fetchMock;
|
|
221
|
+
await (0, github_1.getLatestRelease)("owner/repo", {
|
|
222
|
+
cache: { dir: tmpDir, ttlMs: 60000, now: 1000 },
|
|
223
|
+
});
|
|
224
|
+
await (0, github_1.getLatestRelease)("owner/repo", {
|
|
225
|
+
cache: { dir: tmpDir, ttlMs: 60000, now: 2000 },
|
|
226
|
+
});
|
|
227
|
+
(0, vitest_1.expect)(fetchMock).toHaveBeenCalledTimes(1);
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
renameSpy.mockRestore();
|
|
231
|
+
await node_fs_1.default.promises.rm(tmpDir, { recursive: true, force: true });
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
(0, vitest_1.it)("keeps cache writes within cache dir for invalid repo names", async () => {
|
|
235
|
+
const tmpDir = await node_fs_1.default.promises.mkdtemp(node_path_1.default.join(node_os_1.default.tmpdir(), "kanban-ai-cli-github-"));
|
|
236
|
+
const writeSpy = vitest_1.vi.spyOn(node_fs_1.default.promises, "writeFile");
|
|
237
|
+
try {
|
|
238
|
+
const fetchMock = vitest_1.vi.fn().mockResolvedValue({
|
|
239
|
+
ok: true,
|
|
240
|
+
status: 200,
|
|
241
|
+
statusText: "OK",
|
|
242
|
+
headers: {
|
|
243
|
+
get: (name) => name.toLowerCase() === "etag" ? "\"etag\"" : null,
|
|
244
|
+
},
|
|
245
|
+
json: async () => ({
|
|
246
|
+
tag_name: "v1.2.3",
|
|
247
|
+
assets: [],
|
|
248
|
+
}),
|
|
249
|
+
text: async () => "",
|
|
250
|
+
});
|
|
251
|
+
globalThis.fetch = fetchMock;
|
|
252
|
+
await (0, github_1.getLatestRelease)("..", {
|
|
253
|
+
cache: { dir: tmpDir, ttlMs: 60000, now: 0 },
|
|
254
|
+
});
|
|
255
|
+
(0, vitest_1.expect)(writeSpy).toHaveBeenCalled();
|
|
256
|
+
const cacheWritePath = String(writeSpy.mock.calls[0]?.[0]);
|
|
257
|
+
const resolvedTmpDir = node_path_1.default.resolve(tmpDir);
|
|
258
|
+
(0, vitest_1.expect)(node_path_1.default.resolve(cacheWritePath).startsWith(`${resolvedTmpDir}${node_path_1.default.sep}`)).toBe(true);
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
writeSpy.mockRestore();
|
|
262
|
+
await node_fs_1.default.promises.rm(tmpDir, { recursive: true, force: true });
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
(0, vitest_1.it)("treats future cache timestamps as stale", async () => {
|
|
266
|
+
const tmpDir = await node_fs_1.default.promises.mkdtemp(node_path_1.default.join(node_os_1.default.tmpdir(), "kanban-ai-cli-github-"));
|
|
267
|
+
try {
|
|
268
|
+
const fetchMock = vitest_1.vi.fn().mockResolvedValue({
|
|
269
|
+
ok: true,
|
|
270
|
+
status: 200,
|
|
271
|
+
statusText: "OK",
|
|
272
|
+
headers: {
|
|
273
|
+
get: (name) => name.toLowerCase() === "etag" ? "\"etag\"" : null,
|
|
274
|
+
},
|
|
275
|
+
json: async () => ({
|
|
276
|
+
tag_name: "v1.2.3",
|
|
277
|
+
assets: [],
|
|
278
|
+
}),
|
|
279
|
+
text: async () => "",
|
|
280
|
+
});
|
|
281
|
+
globalThis.fetch = fetchMock;
|
|
282
|
+
await (0, github_1.getLatestRelease)("owner/repo", {
|
|
283
|
+
cache: { dir: tmpDir, ttlMs: 60000, now: 1000 },
|
|
284
|
+
});
|
|
285
|
+
await (0, github_1.getLatestRelease)("owner/repo", {
|
|
286
|
+
cache: { dir: tmpDir, ttlMs: 60000, now: 0 },
|
|
287
|
+
});
|
|
288
|
+
(0, vitest_1.expect)(fetchMock).toHaveBeenCalledTimes(2);
|
|
289
|
+
}
|
|
290
|
+
finally {
|
|
291
|
+
await node_fs_1.default.promises.rm(tmpDir, { recursive: true, force: true });
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
(0, vitest_1.it)("returns cached data with a warning when rate-limited", async () => {
|
|
295
|
+
const tmpDir = await node_fs_1.default.promises.mkdtemp(node_path_1.default.join(node_os_1.default.tmpdir(), "kanban-ai-cli-github-"));
|
|
296
|
+
try {
|
|
297
|
+
const fetchMock = vitest_1.vi
|
|
298
|
+
.fn()
|
|
299
|
+
.mockResolvedValueOnce({
|
|
300
|
+
ok: true,
|
|
301
|
+
status: 200,
|
|
302
|
+
statusText: "OK",
|
|
303
|
+
headers: {
|
|
304
|
+
get: (name) => name.toLowerCase() === "etag" ? "\"etag\"" : null,
|
|
305
|
+
},
|
|
306
|
+
json: async () => ({
|
|
307
|
+
tag_name: "v1.2.3",
|
|
308
|
+
assets: [],
|
|
309
|
+
}),
|
|
310
|
+
text: async () => "",
|
|
311
|
+
})
|
|
312
|
+
.mockResolvedValueOnce({
|
|
313
|
+
ok: false,
|
|
314
|
+
status: 403,
|
|
315
|
+
statusText: "Forbidden",
|
|
316
|
+
headers: {
|
|
317
|
+
get: (name) => {
|
|
318
|
+
const key = name.toLowerCase();
|
|
319
|
+
if (key === "x-ratelimit-remaining")
|
|
320
|
+
return "0";
|
|
321
|
+
if (key === "x-ratelimit-reset")
|
|
322
|
+
return "9999999999";
|
|
323
|
+
return null;
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
json: async () => ({}),
|
|
327
|
+
text: async () => "API rate limit exceeded",
|
|
328
|
+
});
|
|
329
|
+
globalThis.fetch = fetchMock;
|
|
330
|
+
await (0, github_1.getLatestRelease)("owner/repo", {
|
|
331
|
+
cache: { dir: tmpDir, ttlMs: 1, now: 0 },
|
|
332
|
+
});
|
|
333
|
+
const second = await (0, github_1.getLatestRelease)("owner/repo", {
|
|
334
|
+
cache: { dir: tmpDir, ttlMs: 1, now: 10 },
|
|
335
|
+
});
|
|
336
|
+
(0, vitest_1.expect)(second.version).toBe("1.2.3");
|
|
337
|
+
(0, vitest_1.expect)(second.meta?.source).toBe("stale-cache");
|
|
338
|
+
(0, vitest_1.expect)(second.meta?.warning).toMatch(/GITHUB_TOKEN|GH_TOKEN/);
|
|
339
|
+
}
|
|
340
|
+
finally {
|
|
341
|
+
await node_fs_1.default.promises.rm(tmpDir, { recursive: true, force: true });
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
(0, vitest_1.it)("throws GithubRateLimitError when rate-limited without cache", async () => {
|
|
345
|
+
const fetchMock = vitest_1.vi.fn().mockResolvedValue({
|
|
346
|
+
ok: false,
|
|
347
|
+
status: 403,
|
|
348
|
+
statusText: "Forbidden",
|
|
349
|
+
headers: {
|
|
350
|
+
get: (name) => {
|
|
351
|
+
const key = name.toLowerCase();
|
|
352
|
+
if (key === "x-ratelimit-remaining")
|
|
353
|
+
return "0";
|
|
354
|
+
if (key === "x-ratelimit-reset")
|
|
355
|
+
return "9999999999";
|
|
356
|
+
return null;
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
json: async () => ({}),
|
|
360
|
+
text: async () => "API rate limit exceeded",
|
|
361
|
+
});
|
|
362
|
+
globalThis.fetch = fetchMock;
|
|
363
|
+
await (0, vitest_1.expect)((0, github_1.getLatestRelease)("owner/repo")).rejects.toBeInstanceOf(github_1.GithubRateLimitError);
|
|
364
|
+
});
|
|
365
|
+
(0, vitest_1.it)("resolves latest release asset URL via redirect", async () => {
|
|
366
|
+
const fetchMock = vitest_1.vi
|
|
367
|
+
.fn()
|
|
368
|
+
.mockResolvedValueOnce({
|
|
369
|
+
status: 404,
|
|
370
|
+
headers: { get: () => null },
|
|
371
|
+
})
|
|
372
|
+
.mockResolvedValueOnce({
|
|
373
|
+
status: 302,
|
|
374
|
+
headers: {
|
|
375
|
+
get: (name) => name.toLowerCase() === "location"
|
|
376
|
+
? "https://github.com/owner/repo/releases/download/v9.9.9/asset"
|
|
377
|
+
: null,
|
|
378
|
+
},
|
|
379
|
+
})
|
|
380
|
+
.mockResolvedValueOnce({
|
|
381
|
+
status: 302,
|
|
382
|
+
headers: {
|
|
383
|
+
get: (name) => name.toLowerCase() === "location"
|
|
384
|
+
? "https://objects.githubusercontent.com/github-production-release-asset-2e65be/asset"
|
|
385
|
+
: null,
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
globalThis.fetch = fetchMock;
|
|
389
|
+
const resolved = await (0, github_1.resolveLatestReleaseAssetViaRedirect)("owner/repo", ["missing", "asset"]);
|
|
390
|
+
(0, vitest_1.expect)(resolved.tag).toBe("v9.9.9");
|
|
391
|
+
(0, vitest_1.expect)(resolved.version).toBe("9.9.9");
|
|
392
|
+
(0, vitest_1.expect)(resolved.assetName).toBe("asset");
|
|
393
|
+
(0, vitest_1.expect)(resolved.url).toBe("https://objects.githubusercontent.com/github-production-release-asset-2e65be/asset");
|
|
394
|
+
});
|
|
395
|
+
(0, vitest_1.it)("does not return a failing release URL in latest redirect flow", async () => {
|
|
396
|
+
const fetchMock = vitest_1.vi
|
|
397
|
+
.fn()
|
|
398
|
+
.mockResolvedValueOnce({
|
|
399
|
+
status: 302,
|
|
400
|
+
headers: {
|
|
401
|
+
get: (name) => name.toLowerCase() === "location"
|
|
402
|
+
? "https://github.com/owner/repo/releases/download/v9.9.9/asset"
|
|
403
|
+
: null,
|
|
404
|
+
},
|
|
405
|
+
})
|
|
406
|
+
.mockResolvedValueOnce({
|
|
407
|
+
status: 404,
|
|
408
|
+
headers: { get: () => null },
|
|
409
|
+
});
|
|
410
|
+
globalThis.fetch = fetchMock;
|
|
411
|
+
await (0, vitest_1.expect)((0, github_1.resolveLatestReleaseAssetViaRedirect)("owner/repo", ["asset"])).rejects.toThrow(/Could not resolve latest release download URL/);
|
|
412
|
+
});
|
|
413
|
+
(0, vitest_1.it)("returns the GitHub release download URL when no redirect occurs", async () => {
|
|
414
|
+
const fetchMock = vitest_1.vi
|
|
415
|
+
.fn()
|
|
416
|
+
.mockResolvedValueOnce({
|
|
417
|
+
status: 302,
|
|
418
|
+
headers: {
|
|
419
|
+
get: (name) => name.toLowerCase() === "location"
|
|
420
|
+
? "https://github.com/owner/repo/releases/download/v9.9.9/asset"
|
|
421
|
+
: null,
|
|
422
|
+
},
|
|
423
|
+
})
|
|
424
|
+
.mockResolvedValueOnce({
|
|
425
|
+
status: 200,
|
|
426
|
+
headers: { get: () => null },
|
|
427
|
+
});
|
|
428
|
+
globalThis.fetch = fetchMock;
|
|
429
|
+
const resolved = await (0, github_1.resolveLatestReleaseAssetViaRedirect)("owner/repo", [
|
|
430
|
+
"asset",
|
|
431
|
+
]);
|
|
432
|
+
(0, vitest_1.expect)(resolved.url).toBe("https://github.com/owner/repo/releases/download/v9.9.9/asset");
|
|
433
|
+
});
|
|
434
|
+
(0, vitest_1.it)("fails closed when latest redirect is missing a location", async () => {
|
|
435
|
+
const fetchMock = vitest_1.vi
|
|
436
|
+
.fn()
|
|
437
|
+
.mockResolvedValueOnce({
|
|
438
|
+
status: 302,
|
|
439
|
+
headers: {
|
|
440
|
+
get: (name) => name.toLowerCase() === "location"
|
|
441
|
+
? "/owner/repo/releases/download/v9.9.9/asset"
|
|
442
|
+
: null,
|
|
443
|
+
},
|
|
444
|
+
})
|
|
445
|
+
.mockResolvedValueOnce({
|
|
446
|
+
status: 302,
|
|
447
|
+
headers: { get: () => null },
|
|
448
|
+
});
|
|
449
|
+
globalThis.fetch = fetchMock;
|
|
450
|
+
await (0, vitest_1.expect)((0, github_1.resolveLatestReleaseAssetViaRedirect)("owner/repo", ["asset"])).rejects.toThrow(/Could not resolve latest release download URL/);
|
|
451
|
+
});
|
|
452
|
+
(0, vitest_1.it)("resolves a tagged release asset URL via redirect", async () => {
|
|
453
|
+
const fetchMock = vitest_1.vi
|
|
454
|
+
.fn()
|
|
455
|
+
.mockResolvedValueOnce({
|
|
456
|
+
status: 404,
|
|
457
|
+
headers: { get: () => null },
|
|
458
|
+
})
|
|
459
|
+
.mockResolvedValueOnce({
|
|
460
|
+
status: 302,
|
|
461
|
+
headers: {
|
|
462
|
+
get: (name) => name.toLowerCase() === "location"
|
|
463
|
+
? "https://objects.githubusercontent.com/github-production-release-asset-2e65be/asset"
|
|
464
|
+
: null,
|
|
465
|
+
},
|
|
466
|
+
});
|
|
467
|
+
globalThis.fetch = fetchMock;
|
|
468
|
+
const resolved = await (0, github_1.resolveReleaseAssetViaRedirect)("owner/repo", "1.2.3", [
|
|
469
|
+
"missing",
|
|
470
|
+
"asset",
|
|
471
|
+
]);
|
|
472
|
+
(0, vitest_1.expect)(resolved.tag).toBe("v1.2.3");
|
|
473
|
+
(0, vitest_1.expect)(resolved.version).toBe("1.2.3");
|
|
474
|
+
(0, vitest_1.expect)(resolved.assetName).toBe("asset");
|
|
475
|
+
(0, vitest_1.expect)(resolved.url).toBe("https://objects.githubusercontent.com/github-production-release-asset-2e65be/asset");
|
|
476
|
+
});
|
|
477
|
+
(0, vitest_1.it)("returns the GitHub download URL when no redirect occurs", async () => {
|
|
478
|
+
const fetchMock = vitest_1.vi.fn().mockResolvedValueOnce({
|
|
479
|
+
status: 200,
|
|
480
|
+
headers: { get: () => null },
|
|
481
|
+
});
|
|
482
|
+
globalThis.fetch = fetchMock;
|
|
483
|
+
const resolved = await (0, github_1.resolveReleaseAssetViaRedirect)("owner/repo", "1.2.3", ["asset"]);
|
|
484
|
+
(0, vitest_1.expect)(resolved.url).toBe("https://github.com/owner/repo/releases/download/v1.2.3/asset");
|
|
485
|
+
});
|
|
486
|
+
(0, vitest_1.it)("skips redirects without a location header", async () => {
|
|
487
|
+
const fetchMock = vitest_1.vi
|
|
488
|
+
.fn()
|
|
489
|
+
.mockResolvedValueOnce({
|
|
490
|
+
status: 302,
|
|
491
|
+
headers: { get: () => null },
|
|
492
|
+
})
|
|
493
|
+
.mockResolvedValueOnce({
|
|
494
|
+
status: 302,
|
|
495
|
+
headers: {
|
|
496
|
+
get: (name) => name.toLowerCase() === "location"
|
|
497
|
+
? "https://objects.githubusercontent.com/github-production-release-asset-2e65be/asset"
|
|
498
|
+
: null,
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
globalThis.fetch = fetchMock;
|
|
502
|
+
const resolved = await (0, github_1.resolveReleaseAssetViaRedirect)("owner/repo", "1.2.3", ["missing", "asset"]);
|
|
503
|
+
(0, vitest_1.expect)(resolved.assetName).toBe("asset");
|
|
504
|
+
(0, vitest_1.expect)(resolved.url).toBe("https://objects.githubusercontent.com/github-production-release-asset-2e65be/asset");
|
|
505
|
+
});
|
|
506
|
+
(0, vitest_1.it)("rejects unexpected redirect protocols", async () => {
|
|
507
|
+
const fetchMock = vitest_1.vi.fn().mockResolvedValueOnce({
|
|
508
|
+
status: 302,
|
|
509
|
+
headers: {
|
|
510
|
+
get: (name) => name.toLowerCase() === "location" ? "http://evil.test/asset" : null,
|
|
511
|
+
},
|
|
512
|
+
});
|
|
513
|
+
globalThis.fetch = fetchMock;
|
|
514
|
+
await (0, vitest_1.expect)((0, github_1.resolveReleaseAssetViaRedirect)("owner/repo", "1.2.3", ["asset"])).rejects.toThrow(/Unexpected redirect protocol/);
|
|
515
|
+
});
|
|
516
|
+
(0, vitest_1.it)("throws when tagged release asset cannot be resolved", async () => {
|
|
517
|
+
const fetchMock = vitest_1.vi.fn().mockResolvedValue({
|
|
518
|
+
status: 404,
|
|
519
|
+
headers: { get: () => null },
|
|
520
|
+
});
|
|
521
|
+
globalThis.fetch = fetchMock;
|
|
522
|
+
await (0, vitest_1.expect)((0, github_1.resolveReleaseAssetViaRedirect)("owner/repo", "1.2.3", ["asset"])).rejects.toThrow(/Could not resolve release download URL/);
|
|
523
|
+
});
|
|
76
524
|
});
|
package/dist/index.js
CHANGED
|
@@ -18,6 +18,7 @@ const updates_1 = require("./updates");
|
|
|
18
18
|
const download_1 = require("./download");
|
|
19
19
|
const cache_1 = require("./cache");
|
|
20
20
|
const help_1 = require("./help");
|
|
21
|
+
const version_1 = require("./version");
|
|
21
22
|
async function runCli() {
|
|
22
23
|
const env = (0, env_1.resolveEnvOptions)();
|
|
23
24
|
const argv = process.argv.slice(2);
|
|
@@ -38,7 +39,12 @@ async function runCli() {
|
|
|
38
39
|
}
|
|
39
40
|
const platformArch = (0, platform_1.detectPlatformArch)();
|
|
40
41
|
const binaryInfo = (0, platform_1.resolveBinaryInfo)(platformArch.platform, platformArch.arch);
|
|
41
|
-
const
|
|
42
|
+
const githubApiCache = {
|
|
43
|
+
dir: node_path_1.default.join(node_path_1.default.dirname(effectiveEnv.baseCacheDir), "github-api"),
|
|
44
|
+
};
|
|
45
|
+
const explicitVersion = cliOptions.binaryVersion
|
|
46
|
+
? (0, version_1.cleanVersionTag)(cliOptions.binaryVersion)
|
|
47
|
+
: undefined;
|
|
42
48
|
let targetVersion;
|
|
43
49
|
let binaryPath;
|
|
44
50
|
let release;
|
|
@@ -51,7 +57,33 @@ async function runCli() {
|
|
|
51
57
|
}
|
|
52
58
|
else {
|
|
53
59
|
targetVersion = explicitVersion;
|
|
54
|
-
|
|
60
|
+
try {
|
|
61
|
+
const resolved = await (0, github_1.getReleaseByVersion)(effectiveEnv.githubRepo, explicitVersion, { cache: githubApiCache });
|
|
62
|
+
if (resolved.meta?.warning) {
|
|
63
|
+
process.stderr.write(`${resolved.meta.warning}\n`);
|
|
64
|
+
}
|
|
65
|
+
targetVersion = resolved.version;
|
|
66
|
+
release = resolved.release;
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
if (err instanceof github_1.GithubRateLimitError) {
|
|
70
|
+
// eslint-disable-next-line no-console
|
|
71
|
+
console.error(err.message);
|
|
72
|
+
const fallback = await (0, github_1.resolveReleaseAssetViaRedirect)(effectiveEnv.githubRepo, explicitVersion, binaryInfo.assetNameCandidates);
|
|
73
|
+
release = {
|
|
74
|
+
tag_name: fallback.tag,
|
|
75
|
+
assets: [
|
|
76
|
+
{
|
|
77
|
+
name: fallback.assetName,
|
|
78
|
+
browser_download_url: fallback.url,
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
55
87
|
}
|
|
56
88
|
}
|
|
57
89
|
else if (effectiveEnv.noUpdateCheck || effectiveEnv.assumeNo) {
|
|
@@ -62,16 +94,45 @@ async function runCli() {
|
|
|
62
94
|
binaryPath = (0, cache_1.getBinaryPath)(effectiveEnv.baseCacheDir, targetVersion, platformArch, binaryInfo);
|
|
63
95
|
}
|
|
64
96
|
else {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
97
|
+
try {
|
|
98
|
+
const latest = await (0, github_1.getLatestRelease)(effectiveEnv.githubRepo, { cache: githubApiCache });
|
|
99
|
+
if (latest.meta?.warning) {
|
|
100
|
+
// eslint-disable-next-line no-console
|
|
101
|
+
console.error(latest.meta.warning);
|
|
102
|
+
}
|
|
103
|
+
targetVersion = latest.version;
|
|
104
|
+
release = latest.release;
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
if (err instanceof github_1.GithubRateLimitError) {
|
|
108
|
+
// eslint-disable-next-line no-console
|
|
109
|
+
console.error(err.message);
|
|
110
|
+
const fallback = await (0, github_1.resolveLatestReleaseAssetViaRedirect)(effectiveEnv.githubRepo, binaryInfo.assetNameCandidates);
|
|
111
|
+
targetVersion = fallback.version;
|
|
112
|
+
release = {
|
|
113
|
+
tag_name: fallback.tag,
|
|
114
|
+
assets: [
|
|
115
|
+
{
|
|
116
|
+
name: fallback.assetName,
|
|
117
|
+
browser_download_url: fallback.url,
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
throw err;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
69
126
|
}
|
|
70
127
|
}
|
|
71
128
|
else {
|
|
72
129
|
// Default behavior: consult GitHub to pick the appropriate version, then use cache or download.
|
|
73
130
|
try {
|
|
74
|
-
const { version: latestRemoteVersion, release: latestRelease } = await (0, github_1.getLatestRelease)(effectiveEnv.githubRepo);
|
|
131
|
+
const { version: latestRemoteVersion, release: latestRelease, meta } = await (0, github_1.getLatestRelease)(effectiveEnv.githubRepo, { cache: githubApiCache });
|
|
132
|
+
if (meta?.warning) {
|
|
133
|
+
// eslint-disable-next-line no-console
|
|
134
|
+
console.error(meta.warning);
|
|
135
|
+
}
|
|
75
136
|
const decision = await (0, updates_1.decideVersionToUse)({
|
|
76
137
|
env: effectiveEnv,
|
|
77
138
|
platformArch,
|
|
@@ -98,7 +159,33 @@ async function runCli() {
|
|
|
98
159
|
release = latestRelease;
|
|
99
160
|
}
|
|
100
161
|
else {
|
|
101
|
-
|
|
162
|
+
try {
|
|
163
|
+
const resolved = await (0, github_1.getReleaseByVersion)(effectiveEnv.githubRepo, targetVersion, { cache: githubApiCache });
|
|
164
|
+
if (resolved.meta?.warning) {
|
|
165
|
+
process.stderr.write(`${resolved.meta.warning}\n`);
|
|
166
|
+
}
|
|
167
|
+
targetVersion = resolved.version;
|
|
168
|
+
release = resolved.release;
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
if (err instanceof github_1.GithubRateLimitError) {
|
|
172
|
+
// eslint-disable-next-line no-console
|
|
173
|
+
console.error(err.message);
|
|
174
|
+
const fallback = await (0, github_1.resolveReleaseAssetViaRedirect)(effectiveEnv.githubRepo, targetVersion, binaryInfo.assetNameCandidates);
|
|
175
|
+
release = {
|
|
176
|
+
tag_name: fallback.tag,
|
|
177
|
+
assets: [
|
|
178
|
+
{
|
|
179
|
+
name: fallback.assetName,
|
|
180
|
+
browser_download_url: fallback.url,
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
throw err;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
102
189
|
}
|
|
103
190
|
}
|
|
104
191
|
catch (err) {
|
|
@@ -109,6 +196,21 @@ async function runCli() {
|
|
|
109
196
|
// eslint-disable-next-line no-console
|
|
110
197
|
console.error(`Could not check for updates on GitHub (${err.message}). Using cached KanbanAI ${targetVersion}.`);
|
|
111
198
|
}
|
|
199
|
+
else if (err instanceof github_1.GithubRateLimitError) {
|
|
200
|
+
// eslint-disable-next-line no-console
|
|
201
|
+
console.error(err.message);
|
|
202
|
+
const fallback = await (0, github_1.resolveLatestReleaseAssetViaRedirect)(effectiveEnv.githubRepo, binaryInfo.assetNameCandidates);
|
|
203
|
+
targetVersion = fallback.version;
|
|
204
|
+
release = {
|
|
205
|
+
tag_name: fallback.tag,
|
|
206
|
+
assets: [
|
|
207
|
+
{
|
|
208
|
+
name: fallback.assetName,
|
|
209
|
+
browser_download_url: fallback.url,
|
|
210
|
+
},
|
|
211
|
+
],
|
|
212
|
+
};
|
|
213
|
+
}
|
|
112
214
|
else {
|
|
113
215
|
throw err;
|
|
114
216
|
}
|
package/dist/index.test.js
CHANGED
|
@@ -69,31 +69,65 @@ const defaultCliOptions = {
|
|
|
69
69
|
vitest_1.vi.mock("./args", () => ({
|
|
70
70
|
parseCliArgs: vitest_1.vi.fn(() => ({ ...defaultCliOptions })),
|
|
71
71
|
}));
|
|
72
|
-
vitest_1.vi.mock("./github", () =>
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
72
|
+
vitest_1.vi.mock("./github", () => {
|
|
73
|
+
class GithubRateLimitError extends Error {
|
|
74
|
+
constructor(message, options) {
|
|
75
|
+
super(message);
|
|
76
|
+
this.name = "GithubRateLimitError";
|
|
77
|
+
this.status = options?.status ?? 403;
|
|
78
|
+
this.retryAfterSeconds = options?.retryAfterSeconds;
|
|
79
|
+
this.rateLimitResetSeconds = options?.rateLimitResetSeconds;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
GithubRateLimitError,
|
|
84
|
+
getLatestRelease: () => Promise.resolve({
|
|
85
|
+
version: "1.0.0",
|
|
86
|
+
release: {
|
|
87
|
+
tag_name: "v1.0.0",
|
|
88
|
+
body: "Test changelog body",
|
|
89
|
+
assets: [
|
|
90
|
+
{
|
|
91
|
+
name: "kanban-ai-linux-x64",
|
|
92
|
+
browser_download_url: "https://example.com/bin",
|
|
93
|
+
},
|
|
94
|
+
],
|
|
93
95
|
},
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
96
|
+
}),
|
|
97
|
+
getReleaseByVersion: () => Promise.resolve({
|
|
98
|
+
version: "1.0.0",
|
|
99
|
+
release: {
|
|
100
|
+
tag_name: "v1.0.0",
|
|
101
|
+
body: "Test changelog body",
|
|
102
|
+
assets: [
|
|
103
|
+
{
|
|
104
|
+
name: "kanban-ai-linux-x64",
|
|
105
|
+
browser_download_url: "https://example.com/bin",
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
}),
|
|
110
|
+
resolveLatestReleaseAssetViaRedirect: (repo, assetNameCandidates) => {
|
|
111
|
+
const assetName = assetNameCandidates[0] ?? "kanban-ai-linux-x64";
|
|
112
|
+
return Promise.resolve({
|
|
113
|
+
tag: "v1.0.0",
|
|
114
|
+
version: "1.0.0",
|
|
115
|
+
assetName,
|
|
116
|
+
url: `https://github.com/${repo}/releases/download/v1.0.0/${assetName}`,
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
resolveReleaseAssetViaRedirect: (repo, version, assetNameCandidates) => {
|
|
120
|
+
const tag = version.startsWith("v") ? version : `v${version}`;
|
|
121
|
+
const assetName = assetNameCandidates[0] ?? "kanban-ai-linux-x64";
|
|
122
|
+
return Promise.resolve({
|
|
123
|
+
tag,
|
|
124
|
+
version: tag.replace(/^v/, ""),
|
|
125
|
+
assetName,
|
|
126
|
+
url: `https://github.com/${repo}/releases/download/${tag}/${assetName}`,
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
});
|
|
97
131
|
vitest_1.vi.mock("./updates", () => ({
|
|
98
132
|
decideVersionToUse: vitest_1.vi.fn(async (opts) => {
|
|
99
133
|
if (opts && typeof opts.onNewVersionAvailable === "function") {
|
|
@@ -212,6 +246,50 @@ vitest_1.vi.mock("node:child_process", () => {
|
|
|
212
246
|
logSpy.mockRestore();
|
|
213
247
|
}
|
|
214
248
|
});
|
|
249
|
+
(0, vitest_1.it)("prints a fallback message when no changelog is available", async () => {
|
|
250
|
+
const exitSpy = vitest_1.vi.fn();
|
|
251
|
+
const originalExit = process.exit;
|
|
252
|
+
const githubModule = await Promise.resolve().then(() => __importStar(require("./github")));
|
|
253
|
+
const getLatestReleaseSpy = vitest_1.vi
|
|
254
|
+
.spyOn(githubModule, "getLatestRelease")
|
|
255
|
+
.mockResolvedValueOnce({
|
|
256
|
+
version: "1.0.0",
|
|
257
|
+
release: {
|
|
258
|
+
tag_name: "v1.0.0",
|
|
259
|
+
body: "",
|
|
260
|
+
assets: [
|
|
261
|
+
{
|
|
262
|
+
name: "kanban-ai-linux-x64",
|
|
263
|
+
browser_download_url: "https://example.com/bin",
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
const updatesModule = await Promise.resolve().then(() => __importStar(require("./updates")));
|
|
269
|
+
const decideMock = updatesModule.decideVersionToUse;
|
|
270
|
+
decideMock.mockImplementationOnce(async (opts) => {
|
|
271
|
+
if (opts && typeof opts.onNewVersionAvailable === "function") {
|
|
272
|
+
await opts.onNewVersionAvailable({
|
|
273
|
+
latestRemoteVersion: opts.latestRemoteVersion ?? "1.0.0",
|
|
274
|
+
latestCachedVersion: undefined,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
return { versionToUse: "1.0.0", fromCache: false };
|
|
278
|
+
});
|
|
279
|
+
const logSpy = vitest_1.vi.spyOn(console, "log").mockImplementation(() => { });
|
|
280
|
+
// @ts-expect-error override for tests
|
|
281
|
+
process.exit = exitSpy;
|
|
282
|
+
try {
|
|
283
|
+
await (0, index_1.runCli)();
|
|
284
|
+
(0, vitest_1.expect)(logSpy.mock.calls.some((call) => String(call[0]).includes("No changelog was provided for this release."))).toBe(true);
|
|
285
|
+
}
|
|
286
|
+
finally {
|
|
287
|
+
// @ts-expect-error restore original
|
|
288
|
+
process.exit = originalExit;
|
|
289
|
+
logSpy.mockRestore();
|
|
290
|
+
getLatestReleaseSpy.mockRestore();
|
|
291
|
+
}
|
|
292
|
+
});
|
|
215
293
|
(0, vitest_1.it)("uses cached version path when decideVersionToUse returns fromCache=true", async () => {
|
|
216
294
|
const exitSpy = vitest_1.vi.fn();
|
|
217
295
|
const originalExit = process.exit;
|
|
@@ -241,12 +319,43 @@ vitest_1.vi.mock("node:child_process", () => {
|
|
|
241
319
|
process.exit = exitSpy;
|
|
242
320
|
try {
|
|
243
321
|
await (0, index_1.runCli)();
|
|
244
|
-
(0, vitest_1.expect)(getReleaseByVersionSpy).toHaveBeenCalledWith("owner/repo", "0.9.0"
|
|
322
|
+
(0, vitest_1.expect)(getReleaseByVersionSpy).toHaveBeenCalledWith("owner/repo", "0.9.0", vitest_1.expect.objectContaining({
|
|
323
|
+
cache: vitest_1.expect.any(Object),
|
|
324
|
+
}));
|
|
325
|
+
}
|
|
326
|
+
finally {
|
|
327
|
+
// @ts-expect-error restore original
|
|
328
|
+
process.exit = originalExit;
|
|
329
|
+
getReleaseByVersionSpy.mockRestore();
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
(0, vitest_1.it)("throws when release lookup fails and no cached versions exist", async () => {
|
|
333
|
+
const exitSpy = vitest_1.vi.fn();
|
|
334
|
+
const originalExit = process.exit;
|
|
335
|
+
const updatesModule = await Promise.resolve().then(() => __importStar(require("./updates")));
|
|
336
|
+
const decideMock = updatesModule.decideVersionToUse;
|
|
337
|
+
decideMock.mockReturnValueOnce(Promise.resolve({ versionToUse: "0.9.0", fromCache: false }));
|
|
338
|
+
const githubModule = await Promise.resolve().then(() => __importStar(require("./github")));
|
|
339
|
+
const getReleaseByVersionSpy = vitest_1.vi
|
|
340
|
+
.spyOn(githubModule, "getReleaseByVersion")
|
|
341
|
+
.mockRejectedValueOnce(new Error("boom"));
|
|
342
|
+
const cacheModule = await Promise.resolve().then(() => __importStar(require("./cache")));
|
|
343
|
+
const getCachedVersionsSpy = vitest_1.vi
|
|
344
|
+
.spyOn(cacheModule, "getCachedVersionsForPlatform")
|
|
345
|
+
.mockReturnValueOnce([]);
|
|
346
|
+
// @ts-expect-error override for tests
|
|
347
|
+
process.exit = exitSpy;
|
|
348
|
+
try {
|
|
349
|
+
await (0, vitest_1.expect)((0, index_1.runCli)()).rejects.toThrow("boom");
|
|
350
|
+
(0, vitest_1.expect)(getReleaseByVersionSpy).toHaveBeenCalled();
|
|
351
|
+
(0, vitest_1.expect)(getCachedVersionsSpy).toHaveBeenCalled();
|
|
352
|
+
(0, vitest_1.expect)(exitSpy).not.toHaveBeenCalled();
|
|
245
353
|
}
|
|
246
354
|
finally {
|
|
247
355
|
// @ts-expect-error restore original
|
|
248
356
|
process.exit = originalExit;
|
|
249
357
|
getReleaseByVersionSpy.mockRestore();
|
|
358
|
+
getCachedVersionsSpy.mockRestore();
|
|
250
359
|
}
|
|
251
360
|
});
|
|
252
361
|
(0, vitest_1.it)("falls back to cached version when update check fails", async () => {
|
|
@@ -306,4 +415,74 @@ vitest_1.vi.mock("node:child_process", () => {
|
|
|
306
415
|
ensureBinaryDownloadedMock.mockClear();
|
|
307
416
|
}
|
|
308
417
|
});
|
|
418
|
+
(0, vitest_1.it)("falls back to redirect when update check is rate-limited and no binaries are cached", async () => {
|
|
419
|
+
const exitSpy = vitest_1.vi.fn();
|
|
420
|
+
const originalExit = process.exit;
|
|
421
|
+
const githubModule = await Promise.resolve().then(() => __importStar(require("./github")));
|
|
422
|
+
const getLatestReleaseSpy = vitest_1.vi
|
|
423
|
+
.spyOn(githubModule, "getLatestRelease")
|
|
424
|
+
.mockRejectedValueOnce(new githubModule.GithubRateLimitError("rate limited", { status: 403 }));
|
|
425
|
+
const redirectSpy = vitest_1.vi.spyOn(githubModule, "resolveLatestReleaseAssetViaRedirect");
|
|
426
|
+
const cacheModule = await Promise.resolve().then(() => __importStar(require("./cache")));
|
|
427
|
+
const getCachedVersionsSpy = vitest_1.vi
|
|
428
|
+
.spyOn(cacheModule, "getCachedVersionsForPlatform")
|
|
429
|
+
.mockReturnValueOnce([]);
|
|
430
|
+
const errorSpy = vitest_1.vi.spyOn(console, "error").mockImplementation(() => { });
|
|
431
|
+
// @ts-expect-error override for tests
|
|
432
|
+
process.exit = exitSpy;
|
|
433
|
+
try {
|
|
434
|
+
ensureBinaryDownloadedMock.mockClear();
|
|
435
|
+
await (0, index_1.runCli)();
|
|
436
|
+
(0, vitest_1.expect)(getLatestReleaseSpy).toHaveBeenCalled();
|
|
437
|
+
(0, vitest_1.expect)(getCachedVersionsSpy).toHaveBeenCalled();
|
|
438
|
+
(0, vitest_1.expect)(redirectSpy).toHaveBeenCalled();
|
|
439
|
+
(0, vitest_1.expect)(ensureBinaryDownloadedMock).toHaveBeenCalledWith(vitest_1.expect.objectContaining({ version: "1.0.0" }));
|
|
440
|
+
(0, vitest_1.expect)(exitSpy).toHaveBeenCalled();
|
|
441
|
+
}
|
|
442
|
+
finally {
|
|
443
|
+
// @ts-expect-error restore original
|
|
444
|
+
process.exit = originalExit;
|
|
445
|
+
getLatestReleaseSpy.mockRestore();
|
|
446
|
+
redirectSpy.mockRestore();
|
|
447
|
+
getCachedVersionsSpy.mockRestore();
|
|
448
|
+
errorSpy.mockRestore();
|
|
449
|
+
ensureBinaryDownloadedMock.mockClear();
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
(0, vitest_1.it)("falls back to redirect download when pinned release lookup is rate-limited", async () => {
|
|
453
|
+
const exitSpy = vitest_1.vi.fn();
|
|
454
|
+
const originalExit = process.exit;
|
|
455
|
+
const argsModule = await Promise.resolve().then(() => __importStar(require("./args")));
|
|
456
|
+
const parseCliArgsMock = argsModule.parseCliArgs;
|
|
457
|
+
parseCliArgsMock.mockReturnValueOnce({
|
|
458
|
+
...defaultCliOptions,
|
|
459
|
+
binaryVersion: "v1.2.3",
|
|
460
|
+
});
|
|
461
|
+
const githubModule = await Promise.resolve().then(() => __importStar(require("./github")));
|
|
462
|
+
const getReleaseByVersionSpy = vitest_1.vi
|
|
463
|
+
.spyOn(githubModule, "getReleaseByVersion")
|
|
464
|
+
.mockRejectedValueOnce(new githubModule.GithubRateLimitError("rate limited", { status: 403 }));
|
|
465
|
+
const redirectSpy = vitest_1.vi.spyOn(githubModule, "resolveReleaseAssetViaRedirect");
|
|
466
|
+
const existsSpy = vitest_1.vi.spyOn(node_fs_1.default, "existsSync").mockReturnValueOnce(false);
|
|
467
|
+
const errorSpy = vitest_1.vi.spyOn(console, "error").mockImplementation(() => { });
|
|
468
|
+
// @ts-expect-error override for tests
|
|
469
|
+
process.exit = exitSpy;
|
|
470
|
+
try {
|
|
471
|
+
ensureBinaryDownloadedMock.mockClear();
|
|
472
|
+
await (0, index_1.runCli)();
|
|
473
|
+
(0, vitest_1.expect)(getReleaseByVersionSpy).toHaveBeenCalled();
|
|
474
|
+
(0, vitest_1.expect)(redirectSpy).toHaveBeenCalledWith("owner/repo", "1.2.3", vitest_1.expect.any(Array));
|
|
475
|
+
(0, vitest_1.expect)(ensureBinaryDownloadedMock).toHaveBeenCalledWith(vitest_1.expect.objectContaining({ version: "1.2.3" }));
|
|
476
|
+
(0, vitest_1.expect)(exitSpy).toHaveBeenCalled();
|
|
477
|
+
}
|
|
478
|
+
finally {
|
|
479
|
+
// @ts-expect-error restore original
|
|
480
|
+
process.exit = originalExit;
|
|
481
|
+
getReleaseByVersionSpy.mockRestore();
|
|
482
|
+
redirectSpy.mockRestore();
|
|
483
|
+
existsSpy.mockRestore();
|
|
484
|
+
errorSpy.mockRestore();
|
|
485
|
+
ensureBinaryDownloadedMock.mockClear();
|
|
486
|
+
}
|
|
487
|
+
});
|
|
309
488
|
});
|