feat: use github.com for releases instead of api to avoid rate limiting

This commit is contained in:
Carlos Florêncio
2026-02-25 02:50:00 +00:00
parent d3e9daad22
commit 98330b05e4
14 changed files with 777 additions and 111 deletions
+8 -2
View File
@@ -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
#
+3 -1
View File
@@ -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"
}
}
+75
View File
@@ -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);
});
});
+52
View File
@@ -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();
});
});
+115 -67
View File
@@ -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<string, string> {
if (process.env.KANDEV_GITHUB_TOKEN) {
return { Authorization: `Bearer ${process.env.KANDEV_GITHUB_TOKEN}` };
}
return {};
}
function requestJson<T>(url: string): Promise<T> {
/**
* 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<string> {
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<void> {
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<ReleaseInfo> {
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<ReleaseResponse> {
if (version) {
return requestJson<ReleaseResponse>(`${API_BASE}/releases/tags/${version}`);
}
return requestJson<ReleaseResponse>(`${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<string> {
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;
}
+45
View File
@@ -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();
});
});
+189
View File
@@ -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"]);
});
});
+100 -13
View File
@@ -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<string>([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<PreparedRelease> {
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"));
+2 -12
View File
@@ -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<T>(url: string): Promise<T> {
return new Promise((resolve, reject) => {
const req = https.get(url, { headers: { "User-Agent": "kandev-npx" } }, (res) => {
@@ -32,18 +34,6 @@ async function getLatestNpmVersion(): Promise<string | undefined> {
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<boolean> {
return new Promise<boolean>((resolve) => {
if (!process.stdin.isTTY) {
+105
View File
@@ -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"]);
});
});
+57
View File
@@ -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));
}
+2 -1
View File
@@ -10,5 +10,6 @@
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts"],
},
});
+16 -15
View File
@@ -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: {}