feat: use github.com for releases instead of api to avoid rate limiting
This commit is contained in:
@@ -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
|
||||
#
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -10,5 +10,6 @@
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Generated
+16
-15
@@ -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: {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user