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 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
- async function githubFetch(path) {
8
- const url = `${GITHUB_API_BASE}${path}`;
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
- throw new Error(`GitHub API request failed: ${res.status} ${res.statusText}${body ? ` - ${body}` : ''}`);
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
- const version = (0, version_1.cleanVersionTag)(json.tag_name);
31
- return { version, release: json };
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 getReleaseByVersion(repo, version) {
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 res = await githubFetch(`/repos/${repo}/releases/tags/${encodeURIComponent(tag)}`);
36
- return (await res.json());
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
  }
@@ -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 explicitVersion = cliOptions.binaryVersion;
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
- release = await (0, github_1.getReleaseByVersion)(effectiveEnv.githubRepo, explicitVersion);
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
- // No cached versions yet: fall back to a one-time fetch of the latest release.
66
- const { version: latestRemoteVersion, release: latestRelease } = await (0, github_1.getLatestRelease)(effectiveEnv.githubRepo);
67
- targetVersion = latestRemoteVersion;
68
- release = latestRelease;
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
- release = await (0, github_1.getReleaseByVersion)(effectiveEnv.githubRepo, targetVersion);
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
  }
@@ -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
- getLatestRelease: () => Promise.resolve({
74
- version: "1.0.0",
75
- release: {
76
- tag_name: "v1.0.0",
77
- body: "Test changelog body",
78
- assets: [
79
- {
80
- name: "kanban-ai-linux-x64",
81
- browser_download_url: "https://example.com/bin",
82
- },
83
- ],
84
- },
85
- }),
86
- getReleaseByVersion: () => Promise.resolve({
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",
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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kanban-ai",
3
- "version": "0.19.2",
3
+ "version": "0.19.3",
4
4
  "description": "Thin CLI wrapper that downloads and runs the KanbanAI binary from GitHub releases.",
5
5
  "bin": {
6
6
  "kanban-ai": "dist/index.js"