From 98330b05e4aac80833c48467a4f589f232ce45cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Wed, 25 Feb 2026 02:50:00 +0000 Subject: [PATCH] feat: use github.com for releases instead of api to avoid rate limiting --- Makefile | 10 +- apps/cli/package.json | 4 +- apps/cli/src/bundle.test.ts | 75 ++++++++++++++ apps/cli/src/github.test.ts | 52 ++++++++++ apps/cli/src/github.ts | 182 ++++++++++++++++++++------------- apps/cli/src/ports.test.ts | 45 +++++++++ apps/cli/src/run.test.ts | 189 +++++++++++++++++++++++++++++++++++ apps/cli/src/run.ts | 113 ++++++++++++++++++--- apps/cli/src/update.ts | 14 +-- apps/cli/src/version.test.ts | 105 +++++++++++++++++++ apps/cli/src/version.ts | 57 +++++++++++ apps/cli/tsconfig.json | 3 +- apps/cli/vitest.config.ts | 8 ++ apps/pnpm-lock.yaml | 31 +++--- 14 files changed, 777 insertions(+), 111 deletions(-) create mode 100644 apps/cli/src/bundle.test.ts create mode 100644 apps/cli/src/github.test.ts create mode 100644 apps/cli/src/ports.test.ts create mode 100644 apps/cli/src/run.test.ts create mode 100644 apps/cli/src/version.test.ts create mode 100644 apps/cli/src/version.ts create mode 100644 apps/cli/vitest.config.ts diff --git a/Makefile b/Makefile index 6e52f11d0f..197ea79f13 100644 --- a/Makefile +++ b/Makefile @@ -74,9 +74,10 @@ help: @echo " install-web Install web dependencies (uses pnpm workspace)" @echo "" @echo "Testing:" - @echo " test Run all tests (backend + web)" + @echo " test Run all tests (backend + web + cli)" @echo " test-backend Run backend tests" @echo " test-web Run web app tests" + @echo " test-cli Run CLI tests" @echo "" @echo "Code Quality:" @echo " lint Run linters for both components" @@ -192,7 +193,7 @@ install-web: # .PHONY: test -test: test-backend test-web +test: test-backend test-web test-cli @printf "\n$(GREEN)$(BOLD)✓ All tests complete!$(RESET)\n" .PHONY: test-sprites-e2e @@ -209,6 +210,11 @@ test-web: @printf "$(CYAN)Running web app tests...$(RESET)\n" @cd $(APPS_DIR) && $(PNPM) --filter @kandev/web test +.PHONY: test-cli +test-cli: + @printf "$(CYAN)Running CLI tests...$(RESET)\n" + @cd $(APPS_DIR) && $(PNPM) --filter kandev test + # # Code Quality # diff --git a/apps/cli/package.json b/apps/cli/package.json index 75f2f744a6..06be60f04a 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -20,12 +20,14 @@ "devDependencies": { "@types/node": "^20", "tsx": "^4.15.7", - "typescript": "^5" + "typescript": "^5", + "vitest": "^1.6.0" }, "scripts": { "dev": "tsx src/cli.ts", "build": "tsc -p tsconfig.json", "start": "node dist/cli.js", + "test": "vitest run", "prepublishOnly": "pnpm build" } } diff --git a/apps/cli/src/bundle.test.ts b/apps/cli/src/bundle.test.ts new file mode 100644 index 0000000000..31fb964d2b --- /dev/null +++ b/apps/cli/src/bundle.test.ts @@ -0,0 +1,75 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { findBundleRoot, resolveWebServerPath } from "./bundle"; + +describe("findBundleRoot", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-bundle-test-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("returns kandev/ subdir when it exists", () => { + const kandevDir = path.join(tmpDir, "kandev"); + fs.mkdirSync(kandevDir); + expect(findBundleRoot(tmpDir)).toBe(kandevDir); + }); + + it("returns cacheDir itself when kandev/ does not exist", () => { + expect(findBundleRoot(tmpDir)).toBe(tmpDir); + }); +}); + +describe("resolveWebServerPath", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-web-test-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("finds web/server.js (non-monorepo)", () => { + const serverPath = path.join(tmpDir, "web", "server.js"); + fs.mkdirSync(path.dirname(serverPath), { recursive: true }); + fs.writeFileSync(serverPath, ""); + expect(resolveWebServerPath(tmpDir)).toBe(serverPath); + }); + + it("finds web/web/server.js (monorepo, apps root)", () => { + const serverPath = path.join(tmpDir, "web", "web", "server.js"); + fs.mkdirSync(path.dirname(serverPath), { recursive: true }); + fs.writeFileSync(serverPath, ""); + expect(resolveWebServerPath(tmpDir)).toBe(serverPath); + }); + + it("finds web/apps/web/server.js (monorepo, project root)", () => { + const serverPath = path.join(tmpDir, "web", "apps", "web", "server.js"); + fs.mkdirSync(path.dirname(serverPath), { recursive: true }); + fs.writeFileSync(serverPath, ""); + expect(resolveWebServerPath(tmpDir)).toBe(serverPath); + }); + + it("returns null when no server.js found", () => { + expect(resolveWebServerPath(tmpDir)).toBeNull(); + }); + + it("prefers first match when multiple exist", () => { + const first = path.join(tmpDir, "web", "server.js"); + const second = path.join(tmpDir, "web", "web", "server.js"); + fs.mkdirSync(path.dirname(first), { recursive: true }); + fs.writeFileSync(first, ""); + fs.mkdirSync(path.dirname(second), { recursive: true }); + fs.writeFileSync(second, ""); + expect(resolveWebServerPath(tmpDir)).toBe(first); + }); +}); diff --git a/apps/cli/src/github.test.ts b/apps/cli/src/github.test.ts new file mode 100644 index 0000000000..f064a21bbc --- /dev/null +++ b/apps/cli/src/github.test.ts @@ -0,0 +1,52 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { readSha256 } from "./github"; + +describe("readSha256", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-sha-test-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("returns null when file does not exist", () => { + expect(readSha256(path.join(tmpDir, "nonexistent.sha256"))).toBeNull(); + }); + + it("reads a plain hash", () => { + const shaPath = path.join(tmpDir, "test.sha256"); + fs.writeFileSync(shaPath, "abc123def456"); + expect(readSha256(shaPath)).toBe("abc123def456"); + }); + + it("reads hash from sha256sum format (hash + filename)", () => { + const shaPath = path.join(tmpDir, "test.sha256"); + fs.writeFileSync(shaPath, "abc123def456 kandev-macos-arm64.tar.gz\n"); + expect(readSha256(shaPath)).toBe("abc123def456"); + }); + + it("lowercases uppercase hash", () => { + const shaPath = path.join(tmpDir, "test.sha256"); + fs.writeFileSync(shaPath, "ABC123DEF456"); + expect(readSha256(shaPath)).toBe("abc123def456"); + }); + + it("trims whitespace", () => { + const shaPath = path.join(tmpDir, "test.sha256"); + fs.writeFileSync(shaPath, " abc123def456 \n"); + expect(readSha256(shaPath)).toBe("abc123def456"); + }); + + it("returns null for empty file", () => { + const shaPath = path.join(tmpDir, "test.sha256"); + fs.writeFileSync(shaPath, ""); + expect(readSha256(shaPath)).toBeNull(); + }); +}); diff --git a/apps/cli/src/github.ts b/apps/cli/src/github.ts index 831ec3d17f..1324494878 100644 --- a/apps/cli/src/github.ts +++ b/apps/cli/src/github.ts @@ -3,59 +3,123 @@ import fs from "node:fs"; import https from "node:https"; import path from "node:path"; -import { CACHE_DIR } from "./constants"; - // Allow overriding the GitHub repo for forks/testing. const OWNER = process.env.KANDEV_GITHUB_OWNER || "kdlbs"; const REPO = process.env.KANDEV_GITHUB_REPO || "kandev"; -const API_BASE = `https://api.github.com/repos/${OWNER}/${REPO}`; +const WEB_BASE = `https://github.com/${OWNER}/${REPO}`; -type ReleaseAsset = { - name: string; - url: string; - browser_download_url: string; +export type ReleaseInfo = { + tag_name: string; }; -type ReleaseResponse = { - tag_name?: string; - assets?: ReleaseAsset[]; -}; +function authHeaders(): Record { + if (process.env.KANDEV_GITHUB_TOKEN) { + return { Authorization: `Bearer ${process.env.KANDEV_GITHUB_TOKEN}` }; + } + return {}; +} -function requestJson(url: string): Promise { +/** + * Resolve the latest release tag by following the redirect from + * github.com/{owner}/{repo}/releases/latest. + * + * Uses github.com (not api.github.com) so it is not subject to + * the REST API rate limit (60 req/hour per IP). + */ +function resolveLatestTag(): Promise { + const url = `${WEB_BASE}/releases/latest`; return new Promise((resolve, reject) => { const req = https.get( url, { - headers: { - "User-Agent": "kandev-npx", - Accept: "application/vnd.github+json", - ...(process.env.KANDEV_GITHUB_TOKEN - ? { Authorization: `Bearer ${process.env.KANDEV_GITHUB_TOKEN}` } - : {}), - }, - }, + headers: { "User-Agent": "kandev-npx", ...authHeaders() }, + // Do not follow redirects — we just need the Location header. + followRedirect: false, + } as https.RequestOptions, (res) => { - if (res.statusCode !== 200) { - return reject(new Error(`HTTP ${res.statusCode} fetching ${url}`)); - } - let body = ""; - res.on("data", (chunk) => (body += chunk)); - res.on("end", () => { - try { - resolve(JSON.parse(body) as T); - } catch { - reject(new Error(`Failed to parse JSON from ${url}`)); + // Drain the response body to free the socket. + res.resume(); + + if ((res.statusCode === 301 || res.statusCode === 302) && res.headers.location) { + // Location: https://github.com/{owner}/{repo}/releases/tag/v0.2 + const match = res.headers.location.match(/\/releases\/tag\/(.+)$/); + if (match) { + return resolve(match[1]); } - }); + return reject(new Error(`Could not parse tag from redirect: ${res.headers.location}`)); + } + + // GitHub returns 200 if there's only one release (no redirect). + // In that case we need to parse the page — but this is uncommon. + // Fall back to a HEAD request on the resolved URL. + if (res.statusCode === 200 && res.headers.location) { + const match = res.headers.location.match(/\/releases\/tag\/(.+)$/); + if (match) return resolve(match[1]); + } + + reject(new Error(`Failed to resolve latest release (HTTP ${res.statusCode})`)); }, ); req.setTimeout(5000, () => { - req.destroy(new Error(`Request timed out fetching ${url}`)); + req.destroy(new Error("Request timed out resolving latest release")); }); req.on("error", reject); }); } +/** + * Verify that a specific release tag exists. + */ +function verifyTagExists(tag: string): Promise { + const url = `${WEB_BASE}/releases/tag/${tag}`; + return new Promise((resolve, reject) => { + const req = https.request( + url, + { + method: "HEAD", + headers: { "User-Agent": "kandev-npx", ...authHeaders() }, + }, + (res) => { + res.resume(); + // GitHub returns 200 for the tag page, or 302 redirect to the tag page. + if (res.statusCode === 200 || res.statusCode === 301 || res.statusCode === 302) { + return resolve(); + } + reject(new Error(`Release tag '${tag}' not found (HTTP ${res.statusCode})`)); + }, + ); + req.setTimeout(5000, () => { + req.destroy(new Error(`Request timed out verifying tag '${tag}'`)); + }); + req.on("error", reject); + req.end(); + }); +} + +/** + * Get release info. Uses github.com web URLs (not api.github.com) + * to avoid REST API rate limits. + */ +export async function getRelease(version?: string): Promise { + if (version) { + await verifyTagExists(version); + return { tag_name: version }; + } + const tag = await resolveLatestTag(); + return { tag_name: tag }; +} + +// -- Asset downloading -------------------------------------------------------- + +export function readSha256(pathToSha: string): string | null { + if (!fs.existsSync(pathToSha)) { + return null; + } + const content = fs.readFileSync(pathToSha, "utf8").trim(); + const first = content.split(/\s+/)[0]; + return first?.toLowerCase() || null; +} + function downloadFile( url: string, destPath: string, @@ -74,7 +138,7 @@ function downloadFile( }; const handleResponse = (res: import("node:http").IncomingMessage) => { - // Follow redirects (GitHub API returns 302 to signed S3 URL). + // Follow redirects (GitHub returns 302 to signed S3/CDN URL). // Strip auth header on redirect to avoid S3 rejecting it. if ((res.statusCode === 301 || res.statusCode === 302) && res.headers.location) { const redirectReq = https.get( @@ -130,10 +194,7 @@ function downloadFile( { headers: { "User-Agent": "kandev-npx", - Accept: "application/octet-stream", - ...(process.env.KANDEV_GITHUB_TOKEN - ? { Authorization: `Bearer ${process.env.KANDEV_GITHUB_TOKEN}` } - : {}), + ...authHeaders(), }, }, handleResponse, @@ -149,50 +210,35 @@ function downloadFile( }); } -function findAsset(release: ReleaseResponse, name: string): ReleaseAsset | undefined { - return release.assets?.find((asset) => asset.name === name); -} - -function readSha256(pathToSha: string): string | null { - if (!fs.existsSync(pathToSha)) { - return null; - } - const content = fs.readFileSync(pathToSha, "utf8").trim(); - const first = content.split(/\s+/)[0]; - return first?.toLowerCase() || null; -} - -export async function getRelease(version?: string): Promise { - if (version) { - return requestJson(`${API_BASE}/releases/tags/${version}`); - } - return requestJson(`${API_BASE}/releases/latest`); -} - +/** + * Ensure a release asset is downloaded and cached. + * + * Downloads directly from github.com/{owner}/{repo}/releases/download/{tag}/{asset} + * instead of the API, avoiding rate limits. + */ export async function ensureAsset( - release: ReleaseResponse, + tag: string, assetName: string, cacheDir: string, onProgress?: (downloaded: number, total: number) => void, ): Promise { - const asset = findAsset(release, assetName); - if (!asset) { - throw new Error(`Release asset not found: ${assetName}`); - } - fs.mkdirSync(cacheDir, { recursive: true }); const destPath = path.join(cacheDir, assetName); const shaPath = `${destPath}.sha256`; + // Download sha256 checksum if not already cached. let expectedSha = readSha256(shaPath); if (!expectedSha) { - const shaAsset = findAsset(release, `${assetName}.sha256`); - if (shaAsset) { - await downloadFile(shaAsset.url, shaPath); + const shaUrl = `${WEB_BASE}/releases/download/${tag}/${assetName}.sha256`; + try { + await downloadFile(shaUrl, shaPath); expectedSha = readSha256(shaPath); + } catch { + // sha256 file may not exist for this release — continue without it. } } + // Return cached tarball if it exists and checksum matches. if (fs.existsSync(destPath)) { if (!expectedSha) { return destPath; @@ -206,6 +252,8 @@ export async function ensureAsset( fs.unlinkSync(destPath); } - await downloadFile(asset.url, destPath, expectedSha, onProgress); + // Download the asset. + const assetUrl = `${WEB_BASE}/releases/download/${tag}/${assetName}`; + await downloadFile(assetUrl, destPath, expectedSha, onProgress); return destPath; } diff --git a/apps/cli/src/ports.test.ts b/apps/cli/src/ports.test.ts new file mode 100644 index 0000000000..5b499a9cf7 --- /dev/null +++ b/apps/cli/src/ports.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { ensureValidPort } from "./ports"; + +describe("ensureValidPort", () => { + it("returns undefined for undefined input", () => { + expect(ensureValidPort(undefined, "test")).toBeUndefined(); + }); + + it("returns the port for valid values", () => { + expect(ensureValidPort(8080, "test")).toBe(8080); + }); + + it("accepts port 1 (minimum)", () => { + expect(ensureValidPort(1, "test")).toBe(1); + }); + + it("accepts port 65535 (maximum)", () => { + expect(ensureValidPort(65535, "test")).toBe(65535); + }); + + it("throws for port 0", () => { + expect(() => ensureValidPort(0, "backend")).toThrow( + "backend must be an integer between 1 and 65535", + ); + }); + + it("throws for port above 65535", () => { + expect(() => ensureValidPort(65536, "web")).toThrow( + "web must be an integer between 1 and 65535", + ); + }); + + it("throws for negative port", () => { + expect(() => ensureValidPort(-1, "test")).toThrow(); + }); + + it("throws for floating point port", () => { + expect(() => ensureValidPort(8080.5, "test")).toThrow(); + }); + + it("throws for NaN", () => { + expect(() => ensureValidPort(NaN, "test")).toThrow(); + }); +}); diff --git a/apps/cli/src/run.test.ts b/apps/cli/src/run.test.ts new file mode 100644 index 0000000000..735e022249 --- /dev/null +++ b/apps/cli/src/run.test.ts @@ -0,0 +1,189 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { cleanOldReleases, findCachedRelease } from "./run"; + +// Mock CACHE_DIR to use a temp directory. +const mockCacheDir = { value: "" }; +vi.mock("./constants", () => ({ + get CACHE_DIR() { + return mockCacheDir.value; + }, + DATA_DIR: "/tmp/kandev-test-data", + DEFAULT_BACKEND_PORT: 8080, + DEFAULT_WEB_PORT: 3000, + DEFAULT_AGENTCTL_PORT: 9999, + HEALTH_TIMEOUT_MS_RELEASE: 15000, + RANDOM_PORT_MIN: 10000, + RANDOM_PORT_MAX: 60000, + RANDOM_PORT_RETRIES: 10, +})); + +const PLATFORM = "macos-arm64"; + +function createFakeCachedRelease(cacheDir: string, tag: string, platform: string) { + const binDir = path.join(cacheDir, tag, platform, "kandev", "bin"); + fs.mkdirSync(binDir, { recursive: true }); + fs.writeFileSync(path.join(binDir, "kandev"), "fake-binary"); +} + +describe("findCachedRelease", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-test-")); + mockCacheDir.value = tmpDir; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe("with specific version", () => { + it("returns cache info when version exists", () => { + createFakeCachedRelease(tmpDir, "v0.2", PLATFORM); + const result = findCachedRelease(PLATFORM, "v0.2"); + expect(result).not.toBeNull(); + expect(result!.tag).toBe("v0.2"); + expect(result!.cacheDir).toBe(path.join(tmpDir, "v0.2", PLATFORM)); + }); + + it("returns null when version does not exist", () => { + const result = findCachedRelease(PLATFORM, "v9.9"); + expect(result).toBeNull(); + }); + + it("returns null when tag dir exists but no binary", () => { + fs.mkdirSync(path.join(tmpDir, "v0.1", PLATFORM), { recursive: true }); + const result = findCachedRelease(PLATFORM, "v0.1"); + expect(result).toBeNull(); + }); + }); + + describe("without version (latest)", () => { + it("picks the highest semver tag", () => { + createFakeCachedRelease(tmpDir, "v0.1", PLATFORM); + createFakeCachedRelease(tmpDir, "v0.9", PLATFORM); + createFakeCachedRelease(tmpDir, "v1.2", PLATFORM); + const result = findCachedRelease(PLATFORM); + expect(result).not.toBeNull(); + expect(result!.tag).toBe("v1.2"); + }); + + it("sorts major version correctly: v2.0 > v1.9", () => { + createFakeCachedRelease(tmpDir, "v1.9", PLATFORM); + createFakeCachedRelease(tmpDir, "v2.0", PLATFORM); + const result = findCachedRelease(PLATFORM); + expect(result!.tag).toBe("v2.0"); + }); + + it("sorts numerically not lexicographically: v1.10 > v1.9", () => { + createFakeCachedRelease(tmpDir, "v1.9", PLATFORM); + createFakeCachedRelease(tmpDir, "v1.10", PLATFORM); + const result = findCachedRelease(PLATFORM); + expect(result!.tag).toBe("v1.10"); + }); + + it("skips tags without extracted binary", () => { + createFakeCachedRelease(tmpDir, "v0.1", PLATFORM); + // v0.2 exists as dir but no binary inside + fs.mkdirSync(path.join(tmpDir, "v0.2", PLATFORM), { recursive: true }); + const result = findCachedRelease(PLATFORM); + expect(result!.tag).toBe("v0.1"); + }); + + it("returns null when cache dir does not exist", () => { + mockCacheDir.value = path.join(tmpDir, "nonexistent"); + const result = findCachedRelease(PLATFORM); + expect(result).toBeNull(); + }); + + it("returns null when cache dir is empty", () => { + const result = findCachedRelease(PLATFORM); + expect(result).toBeNull(); + }); + + it("ignores non-v prefixed directories", () => { + fs.mkdirSync(path.join(tmpDir, "latest", PLATFORM), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, "temp", PLATFORM), { recursive: true }); + const result = findCachedRelease(PLATFORM); + expect(result).toBeNull(); + }); + + it("picks valid release when highest tag has no binary", () => { + createFakeCachedRelease(tmpDir, "v0.1", PLATFORM); + createFakeCachedRelease(tmpDir, "v0.2", PLATFORM); + // v1.0 dir exists but is empty + fs.mkdirSync(path.join(tmpDir, "v1.0", PLATFORM), { recursive: true }); + const result = findCachedRelease(PLATFORM); + expect(result!.tag).toBe("v0.2"); + }); + }); +}); + +describe("cleanOldReleases", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-clean-test-")); + mockCacheDir.value = tmpDir; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("keeps the 2 most recent tags and removes the rest", () => { + for (const tag of ["v0.1", "v0.2", "v0.3", "v0.4"]) { + fs.mkdirSync(path.join(tmpDir, tag), { recursive: true }); + } + cleanOldReleases("v0.4"); + const remaining = fs.readdirSync(tmpDir).sort(); + expect(remaining).toEqual(["v0.3", "v0.4"]); + }); + + it("does nothing when 2 or fewer tags exist", () => { + fs.mkdirSync(path.join(tmpDir, "v0.1"), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, "v0.2"), { recursive: true }); + cleanOldReleases("v0.2"); + const remaining = fs.readdirSync(tmpDir).sort(); + expect(remaining).toEqual(["v0.1", "v0.2"]); + }); + + it("always keeps currentTag even if not in top 2 by semver", () => { + for (const tag of ["v0.1", "v1.0", "v2.0"]) { + fs.mkdirSync(path.join(tmpDir, tag), { recursive: true }); + } + // currentTag is v0.1 (old), but top 2 by semver are v2.0, v1.0 + cleanOldReleases("v0.1"); + const remaining = fs.readdirSync(tmpDir).sort(); + expect(remaining).toEqual(["v0.1", "v1.0", "v2.0"]); + }); + + it("does nothing when cache dir does not exist", () => { + mockCacheDir.value = path.join(tmpDir, "nonexistent"); + expect(() => cleanOldReleases("v1.0")).not.toThrow(); + }); + + it("ignores non-v prefixed directories", () => { + fs.mkdirSync(path.join(tmpDir, "latest"), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, "temp"), { recursive: true }); + for (const tag of ["v0.1", "v0.2", "v0.3"]) { + fs.mkdirSync(path.join(tmpDir, tag), { recursive: true }); + } + cleanOldReleases("v0.3"); + const remaining = fs.readdirSync(tmpDir).sort(); + expect(remaining).toEqual(["latest", "temp", "v0.2", "v0.3"]); + }); + + it("sorts numerically: keeps v1.10 over v1.9 when both are old", () => { + for (const tag of ["v1.1", "v1.9", "v1.10", "v2.0"]) { + fs.mkdirSync(path.join(tmpDir, tag), { recursive: true }); + } + cleanOldReleases("v2.0"); + const remaining = fs.readdirSync(tmpDir).sort(); + expect(remaining).toEqual(["v1.10", "v2.0"]); + }); +}); diff --git a/apps/cli/src/run.ts b/apps/cli/src/run.ts index 904a2848d7..465a4ef8f1 100644 --- a/apps/cli/src/run.ts +++ b/apps/cli/src/run.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { ensureExtracted, findBundleRoot, resolveWebServerPath } from "./bundle"; import { + CACHE_DIR, DATA_DIR, DEFAULT_AGENTCTL_PORT, DEFAULT_BACKEND_PORT, @@ -11,9 +12,9 @@ import { HEALTH_TIMEOUT_MS_RELEASE, } from "./constants"; import { ensureAsset, getRelease } from "./github"; -import { CACHE_DIR } from "./constants"; import { resolveHealthTimeoutMs, waitForHealth } from "./health"; import { getBinaryName, getPlatformDir } from "./platform"; +import { sortVersionsDesc } from "./version"; import { pickAvailablePort } from "./ports"; import { createProcessSupervisor } from "./process"; import { attachBackendExitHandler, logStartupInfo } from "./shared"; @@ -39,26 +40,112 @@ type PreparedRelease = { logLevel: string; }; +/** + * Find a cached release binary to use when GitHub is unreachable. + * If version is specified, checks that exact tag. Otherwise, picks + * the highest semver tag available in the cache. + */ +export function findCachedRelease( + platformDir: string, + version?: string, +): { cacheDir: string; tag: string } | null { + if (version) { + const cacheDir = path.join(CACHE_DIR, version, platformDir); + const bundleDir = path.join(cacheDir, "kandev"); + const backendBin = path.join(bundleDir, "bin", getBinaryName("kandev")); + if (fs.existsSync(backendBin)) { + return { cacheDir, tag: version }; + } + return null; + } + + // No version specified — scan for cached tags and pick the latest. + if (!fs.existsSync(CACHE_DIR)) return null; + + const entries = fs.readdirSync(CACHE_DIR).filter((d) => d.startsWith("v")); + if (entries.length === 0) return null; + + const sorted = sortVersionsDesc(entries); + + for (const tag of sorted) { + const cacheDir = path.join(CACHE_DIR, tag, platformDir); + const bundleDir = path.join(cacheDir, "kandev"); + const backendBin = path.join(bundleDir, "bin", getBinaryName("kandev")); + if (fs.existsSync(backendBin)) { + return { cacheDir, tag }; + } + } + + return null; +} + +/** + * Remove old cached releases, keeping only the 2 most recent tags. + * Runs after a successful download so we don't accumulate stale versions. + * The previous version is kept as a fallback for offline use. + */ +export function cleanOldReleases(currentTag: string) { + try { + if (!fs.existsSync(CACHE_DIR)) return; + const entries = fs.readdirSync(CACHE_DIR).filter((d) => d.startsWith("v")); + if (entries.length <= 2) return; + + const sorted = sortVersionsDesc(entries); + + // Always keep currentTag + the next most recent. + const keep = new Set([currentTag, sorted[0], sorted[1]]); + for (const entry of entries) { + if (!keep.has(entry)) { + fs.rmSync(path.join(CACHE_DIR, entry), { recursive: true, force: true }); + } + } + } catch { + // Non-critical — don't fail the launch if cleanup errors. + } +} + async function prepareReleaseBundle({ version, backendPort, webPort, }: RunOptions): Promise { const platformDir = getPlatformDir(); - const release = await getRelease(version); - const tag = release.tag_name || "latest"; - const assetName = `kandev-${platformDir}.tar.gz`; - const cacheDir = path.join(CACHE_DIR, tag, platformDir); + let tag: string; + let cacheDir: string; - const archivePath = await ensureAsset(release, assetName, cacheDir, (downloaded, total) => { - const percent = total ? Math.round((downloaded / total) * 100) : 0; - const mb = (downloaded / (1024 * 1024)).toFixed(1); - const totalMb = total ? (total / (1024 * 1024)).toFixed(1) : "?"; - process.stderr.write(`\r Downloading: ${mb}MB / ${totalMb}MB (${percent}%)`); - }); - process.stderr.write("\n"); + try { + const release = await getRelease(version); + tag = release.tag_name; + const assetName = `kandev-${platformDir}.tar.gz`; + cacheDir = path.join(CACHE_DIR, tag, platformDir); + + const archivePath = await ensureAsset(tag, assetName, cacheDir, (downloaded, total) => { + const percent = total ? Math.round((downloaded / total) * 100) : 0; + const mb = (downloaded / (1024 * 1024)).toFixed(1); + const totalMb = total ? (total / (1024 * 1024)).toFixed(1) : "?"; + process.stderr.write(`\r Downloading: ${mb}MB / ${totalMb}MB (${percent}%)`); + }); + process.stderr.write("\n"); + + ensureExtracted(archivePath, cacheDir); + cleanOldReleases(tag); + } catch (err) { + // GitHub unreachable — try to launch from cache. + const cached = findCachedRelease(platformDir, version); + if (!cached) { + const target = version ? `version ${version}` : "latest version"; + const reason = err instanceof Error ? err.message : String(err); + throw new Error( + `Failed to fetch ${target} and no cached release found.\n` + + ` Reason: ${reason}\n` + + ` Run kandev once while online to cache a release for offline use.`, + ); + } + tag = cached.tag; + cacheDir = cached.cacheDir; + process.stderr.write(`[kandev] GitHub unreachable — using cached release ${tag}\n`); + } - ensureExtracted(archivePath, cacheDir); const bundleDir = findBundleRoot(cacheDir); const backendBin = path.join(bundleDir, "bin", getBinaryName("kandev")); diff --git a/apps/cli/src/update.ts b/apps/cli/src/update.ts index 7e899ab4b3..749d7a999f 100644 --- a/apps/cli/src/update.ts +++ b/apps/cli/src/update.ts @@ -2,6 +2,8 @@ import { spawn } from "node:child_process"; import https from "node:https"; import readline from "node:readline"; +import { compareVersions } from "./version"; + function requestJson(url: string): Promise { return new Promise((resolve, reject) => { const req = https.get(url, { headers: { "User-Agent": "kandev-npx" } }, (res) => { @@ -32,18 +34,6 @@ async function getLatestNpmVersion(): Promise { return data?.["dist-tags"]?.latest; } -function compareVersions(a: string, b: string): number { - const pa = String(a).replace(/^v/, "").split(".").map(Number); - const pb = String(b).replace(/^v/, "").split(".").map(Number); - for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) { - const av = pa[i] ?? 0; - const bv = pb[i] ?? 0; - if (av > bv) return 1; - if (av < bv) return -1; - } - return 0; -} - function promptYesNo(question: string, defaultYes = false): Promise { return new Promise((resolve) => { if (!process.stdin.isTTY) { diff --git a/apps/cli/src/version.test.ts b/apps/cli/src/version.test.ts new file mode 100644 index 0000000000..1f556c7d6a --- /dev/null +++ b/apps/cli/src/version.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; + +import { compareVersions, parseVersion, sortVersionsDesc } from "./version"; + +describe("parseVersion", () => { + it("parses simple version", () => { + const v = parseVersion("1.2.3"); + expect(v.segments).toEqual([1, 2, 3]); + expect(v.raw).toBe("1.2.3"); + }); + + it("strips v prefix", () => { + expect(parseVersion("v1.2").segments).toEqual([1, 2]); + }); + + it("handles single segment", () => { + expect(parseVersion("3").segments).toEqual([3]); + }); + + it("handles two segments", () => { + expect(parseVersion("v0.9").segments).toEqual([0, 9]); + }); + + it("returns empty segments for non-numeric input", () => { + expect(parseVersion("latest").segments).toEqual([]); + }); + + it("returns empty segments for partially numeric input", () => { + expect(parseVersion("1.2.beta").segments).toEqual([]); + }); +}); + +describe("compareVersions", () => { + it("returns 0 for equal versions", () => { + expect(compareVersions("1.0.0", "1.0.0")).toBe(0); + }); + + it("returns 1 when a > b (patch)", () => { + expect(compareVersions("1.0.1", "1.0.0")).toBe(1); + }); + + it("returns -1 when a < b (patch)", () => { + expect(compareVersions("1.0.0", "1.0.1")).toBe(-1); + }); + + it("returns 1 when a > b (minor)", () => { + expect(compareVersions("1.1.0", "1.0.0")).toBe(1); + }); + + it("returns 1 when a > b (major)", () => { + expect(compareVersions("2.0.0", "1.9.9")).toBe(1); + }); + + it("strips v prefix", () => { + expect(compareVersions("v1.2.0", "1.2.0")).toBe(0); + }); + + it("strips v prefix on both sides", () => { + expect(compareVersions("v2.0.0", "v1.0.0")).toBe(1); + }); + + it("treats missing segments as 0", () => { + expect(compareVersions("1.0", "1.0.0")).toBe(0); + }); + + it("compares different length versions", () => { + expect(compareVersions("1.0.1", "1.0")).toBe(1); + }); + + it("handles single-segment versions", () => { + expect(compareVersions("2", "1")).toBe(1); + }); + + it("handles single vs multi-segment", () => { + expect(compareVersions("1", "1.0.0")).toBe(0); + }); +}); + +describe("sortVersionsDesc", () => { + it("sorts versions newest first", () => { + expect(sortVersionsDesc(["v0.1", "v1.2", "v0.9"])).toEqual(["v1.2", "v0.9", "v0.1"]); + }); + + it("sorts major versions correctly", () => { + expect(sortVersionsDesc(["v1.9", "v2.0"])).toEqual(["v2.0", "v1.9"]); + }); + + it("sorts numerically not lexicographically", () => { + expect(sortVersionsDesc(["v1.9", "v1.10", "v1.2"])).toEqual(["v1.10", "v1.9", "v1.2"]); + }); + + it("handles single element", () => { + expect(sortVersionsDesc(["v1.0"])).toEqual(["v1.0"]); + }); + + it("handles empty array", () => { + expect(sortVersionsDesc([])).toEqual([]); + }); + + it("does not mutate the original array", () => { + const input = ["v0.2", "v0.1"]; + sortVersionsDesc(input); + expect(input).toEqual(["v0.2", "v0.1"]); + }); +}); diff --git a/apps/cli/src/version.ts b/apps/cli/src/version.ts new file mode 100644 index 0000000000..b4413c6a0e --- /dev/null +++ b/apps/cli/src/version.ts @@ -0,0 +1,57 @@ +/** + * Centralized version parsing and comparison for the CLI. + * + * Version format: optional "v" prefix + dot-separated numeric segments. + * Examples: "v1.2", "0.3.0", "v2.0.1", "1" + */ + +export type ParsedVersion = { + raw: string; + segments: number[]; +}; + +/** + * Parse a version string into numeric segments. + * Strips leading "v" prefix if present. + * + * "v1.2.3" -> { raw: "v1.2.3", segments: [1, 2, 3] } + * "0.3" -> { raw: "0.3", segments: [0, 3] } + * "bad" -> { raw: "bad", segments: [] } + */ +export function parseVersion(version: string): ParsedVersion { + const cleaned = String(version).replace(/^v/, ""); + const parts = cleaned.split("."); + const segments: number[] = []; + for (const part of parts) { + const n = parseInt(part, 10); + if (Number.isNaN(n)) return { raw: version, segments: [] }; + segments.push(n); + } + return { raw: version, segments }; +} + +/** + * Compare two version strings. + * Returns 1 if a > b, -1 if a < b, 0 if equal. + * Missing segments are treated as 0 (e.g. "1.0" == "1.0.0"). + */ +export function compareVersions(a: string, b: string): number { + const pa = parseVersion(a).segments; + const pb = parseVersion(b).segments; + const len = Math.max(pa.length, pb.length); + for (let i = 0; i < len; i++) { + const av = pa[i] ?? 0; + const bv = pb[i] ?? 0; + if (av > bv) return 1; + if (av < bv) return -1; + } + return 0; +} + +/** + * Sort version strings in descending order (newest first). + * Non-parseable strings sort to the end. + */ +export function sortVersionsDesc(versions: string[]): string[] { + return [...versions].sort((a, b) => compareVersions(b, a)); +} diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 9d796efd9f..5202bf5ef6 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -10,5 +10,6 @@ "strict": true, "skipLibCheck": true }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] } diff --git a/apps/cli/vitest.config.ts b/apps/cli/vitest.config.ts new file mode 100644 index 0000000000..c1433e6ef3 --- /dev/null +++ b/apps/cli/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, +}); diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index 8b3ba6236c..b8a1cc6f24 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -40,6 +40,9 @@ importers: typescript: specifier: ^5 version: 5.9.3 + vitest: + specifier: ^1.6.0 + version: 1.6.1(@types/node@20.19.28)(happy-dom@20.1.0)(lightningcss@1.30.2) packages/theme: dependencies: @@ -385,7 +388,7 @@ importers: version: 0.55.1 next: specifier: 16.1.1 - version: 16.1.1(@babel/core@7.28.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -430,7 +433,7 @@ importers: version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) styled-jsx: specifier: 5.1.6 - version: 5.1.6(@babel/core@7.28.6)(react@19.2.3) + version: 5.1.6(react@19.2.3) tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -10244,8 +10247,8 @@ snapshots: '@next/eslint-plugin-next': 16.1.1 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1)) @@ -10267,7 +10270,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -10278,22 +10281,22 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -10304,7 +10307,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -11765,7 +11768,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - next@16.1.1(@babel/core@7.28.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + next@16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@next/env': 16.1.1 '@swc/helpers': 0.5.15 @@ -11774,7 +11777,7 @@ snapshots: postcss: 8.4.31 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - styled-jsx: 5.1.6(@babel/core@7.28.6)(react@19.2.3) + styled-jsx: 5.1.6(react@19.2.3) optionalDependencies: '@next/swc-darwin-arm64': 16.1.1 '@next/swc-darwin-x64': 16.1.1 @@ -12877,12 +12880,10 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(@babel/core@7.28.6)(react@19.2.3): + styled-jsx@5.1.6(react@19.2.3): dependencies: client-only: 0.0.1 react: 19.2.3 - optionalDependencies: - '@babel/core': 7.28.6 stylis@4.3.6: {}