feat(cliproxyapi): add version manager service, API routes, CLI Tools UI & Docker
Part 3 of CLIProxyAPI integration (#902). Depends on PR1 + PR2. - releaseChecker.ts: GitHub releases API with 5min cache - binaryManager.ts: download, SHA256 verify, install, symlink, rollback - processManager.ts: spawn, graceful stop (SIGTERM→SIGKILL with interval cleanup), restart - healthMonitor.ts: periodic /v1/models health checks - index.ts: orchestrator (install/start/stop/restart/checkUpdates/pin/rollback) - 6 API routes under /api/version-manager/ with Zod validation - CliproxyapiToolCard: CLI Tools page card - Docker: cliproxyapi profile with sidecar service + healthcheck - 78 unit tests across 5 test files
This commit is contained in:
+26
-1
@@ -6,18 +6,20 @@
|
||||
# base → minimal image, no CLI tools
|
||||
# cli → CLIs installed inside the container (portable)
|
||||
# host → runner-base + host-mounted CLI binaries (Linux-first)
|
||||
# cliproxyapi → CLIProxyAPI sidecar on port 8317
|
||||
#
|
||||
# Usage:
|
||||
# docker compose --profile base up -d
|
||||
# docker compose --profile cli up -d
|
||||
# docker compose --profile host up -d
|
||||
# docker compose --profile cliproxyapi up -d
|
||||
# docker compose --profile cli --profile cliproxyapi up -d
|
||||
#
|
||||
# Before first run, copy .env.example → .env and edit your secrets.
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
x-common: &common
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 40s
|
||||
env_file: .env
|
||||
environment:
|
||||
- DATA_DIR=/app/data # Must match the volume mount below
|
||||
@@ -110,6 +112,29 @@ services:
|
||||
profiles:
|
||||
- host
|
||||
|
||||
# ── Profile: cliproxyapi (CLIProxyAPI as sidecar) ─────────────────
|
||||
cliproxyapi:
|
||||
container_name: cliproxyapi
|
||||
image: ghcr.io/router-for-me/cliproxyapi:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${CLIPROXYAPI_PORT:-8317}:${CLIPROXYAPI_PORT:-8317}"
|
||||
volumes:
|
||||
- cliproxyapi-data:/root/.cli-proxy-api
|
||||
environment:
|
||||
- PORT=${CLIPROXYAPI_PORT:-8317}
|
||||
- HOST=0.0.0.0
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8317/v1/models"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
profiles:
|
||||
- cliproxyapi
|
||||
|
||||
volumes:
|
||||
omniroute-data:
|
||||
name: omniroute-data
|
||||
cliproxyapi-data:
|
||||
name: cliproxyapi-data
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card, Button } from "@/shared/components";
|
||||
|
||||
interface ToolState {
|
||||
tool: string;
|
||||
installedVersion: string | null;
|
||||
currentVersion: string | null;
|
||||
status: string;
|
||||
pid: number | null;
|
||||
port: number;
|
||||
healthStatus: string;
|
||||
autoUpdate: boolean;
|
||||
autoStart: boolean;
|
||||
lastHealthCheck: string | null;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
interface UpdateInfo {
|
||||
current: string | null;
|
||||
latest: string;
|
||||
updateAvailable: boolean;
|
||||
}
|
||||
|
||||
export default function CliproxyapiToolCard({ isExpanded, onToggle }) {
|
||||
const [toolState, setToolState] = useState<ToolState | null>(null);
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<{ type: string; text: string } | null>(null);
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/version-manager/status");
|
||||
const data = await res.json();
|
||||
const entry = Array.isArray(data)
|
||||
? data.find((t: ToolState) => t.tool === "cliproxyapi")
|
||||
: null;
|
||||
setToolState(entry || null);
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
const fetchUpdateInfo = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/version-manager/check-update?tool=cliproxyapi");
|
||||
setUpdateInfo(await res.json());
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded) {
|
||||
fetchStatus();
|
||||
fetchUpdateInfo();
|
||||
}
|
||||
}, [isExpanded, fetchStatus, fetchUpdateInfo]);
|
||||
|
||||
const apiCall = async (action: string, body?: Record<string, unknown>) => {
|
||||
setLoading(action);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await fetch(`/api/version-manager/${action}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tool: "cliproxyapi", ...body }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage({ type: "success", text: data.message || `${action} succeeded` });
|
||||
await fetchStatus();
|
||||
if (action === "install" || action === "restart") await fetchUpdateInfo();
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.error || `${action} failed` });
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage({ type: "error", text: err instanceof Error ? err.message : "Request failed" });
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const statusBadge = () => {
|
||||
if (!toolState) return null;
|
||||
const s = toolState.status;
|
||||
const map: Record<string, { label: string; color: string }> = {
|
||||
running: { label: "Running", color: "bg-green-500/10 text-green-600 dark:text-green-400" },
|
||||
stopped: { label: "Stopped", color: "bg-zinc-500/10 text-zinc-500 dark:text-zinc-400" },
|
||||
not_installed: {
|
||||
label: "Not Installed",
|
||||
color: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400",
|
||||
},
|
||||
installed: { label: "Installed", color: "bg-blue-500/10 text-blue-600 dark:text-blue-400" },
|
||||
error: { label: "Error", color: "bg-red-500/10 text-red-600 dark:text-red-400" },
|
||||
};
|
||||
const badge = map[s] || map.not_installed;
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full ${badge.color}`}
|
||||
>
|
||||
<span className="size-1.5 rounded-full bg-current" />
|
||||
{badge.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padding="sm" className="overflow-hidden">
|
||||
<div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-8 rounded-lg flex items-center justify-center shrink-0 bg-indigo-500/10">
|
||||
<span className="material-symbols-outlined text-indigo-500 text-xl">swap_horiz</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-sm">CLIProxyAPI</h3>
|
||||
{statusBadge()}
|
||||
</div>
|
||||
<p className="text-xs text-text-muted truncate">
|
||||
Upstream proxy fallback (Go-based OAuth)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}
|
||||
>
|
||||
expand_more
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-6 pt-6 border-t border-border space-y-4">
|
||||
{message && (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-xs ${
|
||||
message.type === "success"
|
||||
? "bg-green-500/10 text-green-600 dark:text-green-400"
|
||||
: "bg-red-500/10 text-red-600 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{message.type === "success" ? "check_circle" : "error"}
|
||||
</span>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateInfo?.updateAvailable && (
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-yellow-500 text-lg">
|
||||
system_update
|
||||
</span>
|
||||
<span className="text-sm text-yellow-700 dark:text-yellow-300">
|
||||
Update available: v{updateInfo.current} → v{updateInfo.latest}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => apiCall("install", { version: updateInfo.latest })}
|
||||
loading={loading === "install"}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="p-3 rounded-lg bg-bg-secondary">
|
||||
<p className="text-xs text-text-muted mb-1">Version</p>
|
||||
<p className="text-sm font-medium">
|
||||
{toolState?.installedVersion ? `v${toolState.installedVersion}` : "Not installed"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-bg-secondary">
|
||||
<p className="text-xs text-text-muted mb-1">Health</p>
|
||||
<p
|
||||
className={`text-sm font-medium ${toolState?.healthStatus === "healthy" ? "text-green-600 dark:text-green-400" : toolState?.healthStatus === "unhealthy" ? "text-red-600 dark:text-red-400" : "text-text-muted"}`}
|
||||
>
|
||||
{toolState?.healthStatus === "healthy"
|
||||
? `Healthy`
|
||||
: toolState?.healthStatus === "unhealthy"
|
||||
? "Unhealthy"
|
||||
: "Unknown"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-bg-secondary">
|
||||
<p className="text-xs text-text-muted mb-1">Port</p>
|
||||
<p className="text-sm font-mono">{toolState?.port || 8317}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{!toolState?.installedVersion && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => apiCall("install")}
|
||||
loading={loading === "install"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">download</span>
|
||||
Install
|
||||
</Button>
|
||||
)}
|
||||
{toolState?.status === "running" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => apiCall("stop")}
|
||||
loading={loading === "stop"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">stop</span>
|
||||
Stop
|
||||
</Button>
|
||||
) : toolState?.installedVersion ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => apiCall("start")}
|
||||
loading={loading === "start"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">play_arrow</span>
|
||||
Start
|
||||
</Button>
|
||||
) : null}
|
||||
{toolState?.status === "running" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => apiCall("restart")}
|
||||
loading={loading === "restart"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">restart_alt</span>
|
||||
Restart
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchUpdateInfo}
|
||||
loading={loading === "check"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">sync</span>
|
||||
Check Updates
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { checkForUpdates } from "@/lib/versionManager";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const tool = searchParams.get("tool") || "cliproxyapi";
|
||||
const result = await checkForUpdates(tool);
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
console.error("[version-manager] check-update error:", error);
|
||||
return NextResponse.json({ error: "Failed to check for updates" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { installTool } from "@/lib/versionManager";
|
||||
import { versionManagerInstallSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let rawBody;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const validation = validateBody(versionManagerInstallSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { tool, version } = validation.data;
|
||||
const result = await installTool(tool, version || undefined);
|
||||
return NextResponse.json({ success: true, ...result });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Installation failed";
|
||||
console.error("[version-manager] install error:", message);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { restartTool } from "@/lib/versionManager";
|
||||
import { versionManagerToolSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let rawBody;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const validation = validateBody(versionManagerToolSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { tool } = validation.data;
|
||||
const result = await restartTool(tool);
|
||||
return NextResponse.json({ success: true, ...result });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to restart";
|
||||
console.error("[version-manager] restart error:", message);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { startTool } from "@/lib/versionManager";
|
||||
import { versionManagerToolSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let rawBody;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const validation = validateBody(versionManagerToolSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { tool } = validation.data;
|
||||
const result = await startTool(tool);
|
||||
return NextResponse.json({ success: true, ...result });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to start";
|
||||
console.error("[version-manager] start error:", message);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { getVersionManagerStatus } from "@/lib/versionManager";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const status = await getVersionManagerStatus();
|
||||
return NextResponse.json(status);
|
||||
} catch (error) {
|
||||
console.error("[version-manager] status error:", error);
|
||||
return NextResponse.json({ error: "Failed to get status" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { stopTool } from "@/lib/versionManager";
|
||||
import { versionManagerToolSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let rawBody;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const validation = validateBody(versionManagerToolSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { tool } = validation.data;
|
||||
await stopTool(tool);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to stop";
|
||||
console.error("[version-manager] stop error:", message);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import fs from "fs/promises";
|
||||
import fsSync from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import crypto from "crypto";
|
||||
import { createReadStream } from "fs";
|
||||
import { pipeline } from "stream/promises";
|
||||
import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { getChecksums, getReleaseByVersion } from "./releaseChecker.ts";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const DEFAULT_DATA_DIR = process.env.DATA_DIR || path.join(os.homedir(), ".omniroute");
|
||||
|
||||
type Platform = "linux" | "darwin" | "windows" | "freebsd";
|
||||
type Arch = "amd64" | "arm64";
|
||||
|
||||
function detectPlatform(): Platform {
|
||||
const p = process.platform;
|
||||
if (p === "linux") return "linux";
|
||||
if (p === "darwin") return "darwin";
|
||||
if (p === "win32") return "windows";
|
||||
return "linux";
|
||||
}
|
||||
|
||||
function detectArch(): Arch {
|
||||
const a = process.arch;
|
||||
if (a === "x64") return "amd64";
|
||||
if (a === "arm64") return "arm64";
|
||||
return "amd64";
|
||||
}
|
||||
|
||||
export function getAssetName(platform?: Platform, arch?: Arch): string {
|
||||
const plat = platform || detectPlatform();
|
||||
const arc = arch || detectArch();
|
||||
return `CLIProxyAPI_{version}_${plat}_${arc}${plat === "windows" ? ".zip" : ".tar.gz"}`;
|
||||
}
|
||||
|
||||
export function getTargetPlatform(): { platform: Platform; arch: Arch } {
|
||||
return { platform: detectPlatform(), arch: detectArch() };
|
||||
}
|
||||
|
||||
async function downloadFile(url: string, dest: string, signal?: AbortSignal): Promise<void> {
|
||||
const res = await fetch(url, { signal });
|
||||
if (!res.ok || !res.body) throw new Error(`Download failed: ${res.status}`);
|
||||
const fileStream = fsSync.createWriteStream(dest);
|
||||
await pipeline(res.body as unknown as NodeJS.ReadableStream, fileStream);
|
||||
}
|
||||
|
||||
async function extractTarGz(archivePath: string, destDir: string): Promise<void> {
|
||||
await execFileAsync("tar", ["xzf", archivePath, "-C", destDir]);
|
||||
}
|
||||
|
||||
async function extractZip(archivePath: string, destDir: string): Promise<void> {
|
||||
await execFileAsync("unzip", ["-o", archivePath, "-d", destDir]);
|
||||
}
|
||||
|
||||
async function verifyChecksum(filePath: string, expectedSha256: string): Promise<boolean> {
|
||||
const hash = crypto.createHash("sha256");
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const stream = createReadStream(filePath);
|
||||
stream.on("data", (data: Buffer) => hash.update(data));
|
||||
stream.on("end", resolve);
|
||||
stream.on("error", reject);
|
||||
});
|
||||
return hash.digest("hex").toLowerCase() === expectedSha256.toLowerCase();
|
||||
}
|
||||
|
||||
function findBinaryInDir(dir: string): string | null {
|
||||
const candidates = ["cli-proxy-api", "cli-proxy-api.exe", "CLIProxyAPI", "CLIProxyAPI.exe"];
|
||||
for (const name of candidates) {
|
||||
if (fsSync.existsSync(path.join(dir, name))) return path.join(dir, name);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function downloadRelease(
|
||||
version: string,
|
||||
targetDir: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<string> {
|
||||
const release = await getReleaseByVersion(version);
|
||||
if (!release) throw new Error(`Version ${version} not found`);
|
||||
|
||||
const { platform, arch } = getTargetPlatform();
|
||||
const ext = platform === "windows" ? ".zip" : ".tar.gz";
|
||||
const assetName = `CLIProxyAPI_${release.version}_${platform}_${arch}${ext}`;
|
||||
const asset = release.assets.find((a) => a.name === assetName);
|
||||
if (!asset) throw new Error(`No asset for ${platform}/${arch}`);
|
||||
|
||||
const versionDir = path.join(targetDir, `cliproxyapi-${version}`);
|
||||
await fs.mkdir(versionDir, { recursive: true });
|
||||
|
||||
const archivePath = path.join(versionDir, assetName);
|
||||
await downloadFile(asset.url, archivePath, signal);
|
||||
|
||||
const checksums = await getChecksums(version);
|
||||
if (checksums.size > 0) {
|
||||
const expected = checksums.get(assetName);
|
||||
if (expected) {
|
||||
const valid = await verifyChecksum(archivePath, expected);
|
||||
if (!valid) {
|
||||
await fs.unlink(archivePath);
|
||||
throw new Error(`SHA256 checksum mismatch for ${assetName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === "windows") {
|
||||
await extractZip(archivePath, versionDir);
|
||||
} else {
|
||||
await extractTarGz(archivePath, versionDir);
|
||||
}
|
||||
|
||||
await fs.unlink(archivePath).catch(() => {});
|
||||
|
||||
const binary = findBinaryInDir(versionDir);
|
||||
if (!binary) throw new Error(`Binary not found in extracted archive`);
|
||||
|
||||
await fs.chmod(binary, 0o755);
|
||||
return binary;
|
||||
}
|
||||
|
||||
export async function installVersion(version: string, dataDir?: string): Promise<string> {
|
||||
const dir = dataDir || DEFAULT_DATA_DIR;
|
||||
const binDir = path.join(dir, "bin");
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
|
||||
const binary = await downloadRelease(version, binDir);
|
||||
|
||||
const symlinkPath = path.join(binDir, "cliproxyapi");
|
||||
try {
|
||||
await fs.unlink(symlinkPath);
|
||||
} catch {}
|
||||
if (process.platform === "win32") {
|
||||
await fs.copyFile(binary, symlinkPath);
|
||||
} else {
|
||||
await fs.symlink(binary, symlinkPath);
|
||||
}
|
||||
|
||||
return symlinkPath;
|
||||
}
|
||||
|
||||
export async function getCurrentBinaryPath(dataDir?: string): Promise<string | null> {
|
||||
const dir = dataDir || DEFAULT_DATA_DIR;
|
||||
const symlinkPath = path.join(dir, "bin", "cliproxyapi");
|
||||
try {
|
||||
const real = await fs.realpath(symlinkPath);
|
||||
return fsSync.existsSync(real) ? real : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getInstalledVersions(dataDir?: string): Promise<string[]> {
|
||||
const dir = dataDir || DEFAULT_DATA_DIR;
|
||||
const binDir = path.join(dir, "bin");
|
||||
try {
|
||||
const entries = await fs.readdir(binDir);
|
||||
return entries
|
||||
.filter(
|
||||
(e) => e.startsWith("cliproxyapi-") && fsSync.statSync(path.join(binDir, e)).isDirectory()
|
||||
)
|
||||
.map((e) => e.replace("cliproxyapi-", ""));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function rollbackVersion(dataDir?: string): Promise<string | null> {
|
||||
const versions = await getInstalledVersions(dataDir);
|
||||
if (versions.length < 2) return null;
|
||||
|
||||
versions.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
|
||||
const previous = versions[1];
|
||||
|
||||
const dir = dataDir || DEFAULT_DATA_DIR;
|
||||
const binDir = path.join(dir, "bin");
|
||||
const oldBinary = findBinaryInDir(path.join(binDir, `cliproxyapi-${previous}`));
|
||||
if (!oldBinary) return null;
|
||||
|
||||
const symlinkPath = path.join(binDir, "cliproxyapi");
|
||||
try {
|
||||
await fs.unlink(symlinkPath);
|
||||
} catch {}
|
||||
if (process.platform === "win32") {
|
||||
await fs.copyFile(oldBinary, symlinkPath);
|
||||
} else {
|
||||
await fs.symlink(oldBinary, symlinkPath);
|
||||
}
|
||||
|
||||
return previous;
|
||||
}
|
||||
|
||||
export async function removeVersion(version: string, dataDir?: string): Promise<boolean> {
|
||||
const dir = dataDir || DEFAULT_DATA_DIR;
|
||||
const versionDir = path.join(dir, "bin", `cliproxyapi-${version}`);
|
||||
try {
|
||||
await fs.rm(versionDir, { recursive: true, force: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { updateToolHealth } from "@/lib/db/versionManager";
|
||||
|
||||
interface HealthResult {
|
||||
healthy: boolean;
|
||||
latency: number;
|
||||
modelCount: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const monitoringIntervals = new Map<string, NodeJS.Timeout>();
|
||||
|
||||
async function checkHealth(url: string, healthPath?: string): Promise<HealthResult> {
|
||||
const basePath = healthPath || "/v1/models";
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
const res = await fetch(`${url}${basePath}`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
headers: { Authorization: "Bearer omniroute-internal" },
|
||||
});
|
||||
|
||||
const latency = Date.now() - start;
|
||||
|
||||
if (!res.ok) {
|
||||
return { healthy: false, latency, modelCount: 0, error: `HTTP ${res.status}` };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const modelCount = Array.isArray(data.data) ? data.data.length : 0;
|
||||
|
||||
return { healthy: true, latency, modelCount, error: null };
|
||||
} catch (err) {
|
||||
const latency = Date.now() - start;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { healthy: false, latency, modelCount: 0, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export { checkHealth };
|
||||
export type { HealthResult };
|
||||
|
||||
export function startMonitoring(
|
||||
tool: string,
|
||||
url: string,
|
||||
intervalMs: number = 30_000,
|
||||
healthPath?: string
|
||||
): void {
|
||||
stopMonitoring(tool);
|
||||
|
||||
const doCheck = async () => {
|
||||
const result = await checkHealth(url, healthPath);
|
||||
const status = result.healthy ? "healthy" : "unhealthy";
|
||||
await updateToolHealth(tool, status).catch(() => {});
|
||||
};
|
||||
|
||||
doCheck();
|
||||
const timer = setInterval(doCheck, intervalMs);
|
||||
monitoringIntervals.set(tool, timer);
|
||||
}
|
||||
|
||||
export function stopMonitoring(tool: string): void {
|
||||
const timer = monitoringIntervals.get(tool);
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
monitoringIntervals.delete(tool);
|
||||
}
|
||||
}
|
||||
|
||||
export function isMonitoring(tool: string): boolean {
|
||||
return monitoringIntervals.has(tool);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
getVersionManagerStatus,
|
||||
getVersionManagerTool,
|
||||
upsertVersionManagerTool,
|
||||
updateVersionManagerTool,
|
||||
setToolStatus,
|
||||
updateToolVersion,
|
||||
} from "@/lib/db/versionManager";
|
||||
import { getLatestRelease, clearCache as clearReleaseCache } from "./releaseChecker.ts";
|
||||
import { installVersion, getCurrentBinaryPath, rollbackVersion } from "./binaryManager.ts";
|
||||
import { startProcess, stopProcess, restartProcess, isProcessRunning } from "./processManager.ts";
|
||||
import { checkHealth, startMonitoring, stopMonitoring, isMonitoring } from "./healthMonitor.ts";
|
||||
|
||||
export { getVersionManagerStatus, getVersionManagerTool } from "@/lib/db/versionManager";
|
||||
|
||||
export type { HealthResult } from "./healthMonitor.ts";
|
||||
|
||||
export async function installTool(
|
||||
tool: string,
|
||||
version?: string
|
||||
): Promise<{
|
||||
installedVersion: string;
|
||||
binaryPath: string;
|
||||
}> {
|
||||
const targetVersion = version || (await getLatestRelease()).version;
|
||||
const binaryPath = await installVersion(targetVersion);
|
||||
|
||||
await upsertVersionManagerTool({
|
||||
tool,
|
||||
installedVersion: targetVersion,
|
||||
binaryPath,
|
||||
status: "installed",
|
||||
});
|
||||
|
||||
return { installedVersion: targetVersion, binaryPath };
|
||||
}
|
||||
|
||||
export async function startTool(tool: string): Promise<{
|
||||
pid: number;
|
||||
port: number;
|
||||
health?: Awaited<ReturnType<typeof checkHealth>>;
|
||||
}> {
|
||||
const info = await getVersionManagerTool(tool);
|
||||
const binaryPath = info?.binaryPath || (await getCurrentBinaryPath());
|
||||
|
||||
if (!binaryPath) {
|
||||
throw new Error(`No binary found for ${tool}. Install it first.`);
|
||||
}
|
||||
|
||||
const { pid, port } = await startProcess(binaryPath, info?.port || undefined);
|
||||
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
startMonitoring(tool, url);
|
||||
|
||||
const health = await checkHealth(url);
|
||||
|
||||
return { pid, port, health };
|
||||
}
|
||||
|
||||
export async function stopTool(tool: string): Promise<void> {
|
||||
const info = await getVersionManagerTool(tool);
|
||||
|
||||
stopMonitoring(tool);
|
||||
|
||||
if (info?.pid) {
|
||||
await stopProcess(info.pid);
|
||||
}
|
||||
|
||||
await setToolStatus(tool, "stopped");
|
||||
}
|
||||
|
||||
export async function restartTool(tool: string): Promise<{
|
||||
pid: number;
|
||||
port: number;
|
||||
}> {
|
||||
const info = await getVersionManagerTool(tool);
|
||||
const binaryPath = info?.binaryPath || (await getCurrentBinaryPath());
|
||||
|
||||
if (!binaryPath) {
|
||||
throw new Error(`No binary found for ${tool}`);
|
||||
}
|
||||
|
||||
const { pid, port } = await restartProcess(
|
||||
binaryPath,
|
||||
info?.port || undefined,
|
||||
undefined,
|
||||
info?.pid || undefined
|
||||
);
|
||||
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
if (isMonitoring(tool)) {
|
||||
stopMonitoring(tool);
|
||||
}
|
||||
startMonitoring(tool, url);
|
||||
|
||||
return { pid, port };
|
||||
}
|
||||
|
||||
export async function checkForUpdates(tool: string): Promise<{
|
||||
current: string | null;
|
||||
latest: string;
|
||||
updateAvailable: boolean;
|
||||
}> {
|
||||
clearReleaseCache();
|
||||
const latest = (await getLatestRelease()).version;
|
||||
const info = await getVersionManagerTool(tool);
|
||||
const current = info?.installedVersion || null;
|
||||
|
||||
if (!current) {
|
||||
return { current: null, latest, updateAvailable: true };
|
||||
}
|
||||
|
||||
return {
|
||||
current,
|
||||
latest,
|
||||
updateAvailable: current !== latest,
|
||||
};
|
||||
}
|
||||
|
||||
export async function pinVersion(tool: string, version: string): Promise<void> {
|
||||
await updateVersionManagerTool(tool, { pinnedVersion: version });
|
||||
}
|
||||
|
||||
export async function unpinVersion(tool: string): Promise<void> {
|
||||
await updateVersionManagerTool(tool, { pinnedVersion: null });
|
||||
}
|
||||
|
||||
export async function getToolHealth(
|
||||
tool: string
|
||||
): Promise<Awaited<ReturnType<typeof checkHealth>> | null> {
|
||||
const info = await getVersionManagerTool(tool);
|
||||
if (!info?.port || info.status !== "running") return null;
|
||||
|
||||
const url = `http://127.0.0.1:${info.port}`;
|
||||
return checkHealth(url);
|
||||
}
|
||||
|
||||
export async function rollbackTool(tool: string): Promise<string | null> {
|
||||
const previousVersion = await rollbackVersion();
|
||||
if (!previousVersion) return null;
|
||||
|
||||
await updateVersionManagerTool(tool, {
|
||||
installedVersion: previousVersion,
|
||||
status: "installed",
|
||||
});
|
||||
|
||||
const info = await getVersionManagerTool(tool);
|
||||
if (info?.pid && isProcessRunning(info.pid)) {
|
||||
await restartTool(tool);
|
||||
}
|
||||
|
||||
return previousVersion;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { spawn, type ChildProcess } from "child_process";
|
||||
import fs from "fs/promises";
|
||||
import fsSync from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import { setToolStatus, getVersionManagerTool } from "@/lib/db/versionManager";
|
||||
|
||||
const DEFAULT_PORT = 8317;
|
||||
const GRACEFUL_TIMEOUT_MS = 5000;
|
||||
|
||||
function defaultConfigDir(): string {
|
||||
return process.env.CLIPROXYAPI_CONFIG_DIR || path.join(os.homedir(), ".cli-proxy-api");
|
||||
}
|
||||
|
||||
async function writeConfig(
|
||||
configDir: string,
|
||||
port: number,
|
||||
overrides?: Record<string, unknown>
|
||||
): Promise<string> {
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
const configPath = path.join(configDir, "config.yaml");
|
||||
const config = `port: ${port}
|
||||
host: 127.0.0.1
|
||||
log_level: warn
|
||||
`;
|
||||
await fs.writeFile(configPath, config);
|
||||
return configPath;
|
||||
}
|
||||
|
||||
export async function startProcess(
|
||||
binaryPath: string,
|
||||
port?: number,
|
||||
configDir?: string
|
||||
): Promise<{ pid: number; port: number }> {
|
||||
const existing = await getVersionManagerTool("cliproxyapi");
|
||||
if (existing?.pid) {
|
||||
const alive = isProcessRunning(existing.pid);
|
||||
if (alive) return { pid: existing.pid, port: existing.port };
|
||||
}
|
||||
|
||||
const actualPort = port || DEFAULT_PORT;
|
||||
const actualConfigDir = configDir || defaultConfigDir();
|
||||
await writeConfig(actualConfigDir, actualPort);
|
||||
|
||||
const child = spawn(binaryPath, ["-c", path.join(actualConfigDir, "config.yaml")], {
|
||||
detached: false,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
child.stdout?.on("data", () => {});
|
||||
child.stderr?.on("data", () => {});
|
||||
|
||||
child.on("error", async (err) => {
|
||||
await setToolStatus("cliproxyapi", "error", undefined, err.message);
|
||||
});
|
||||
|
||||
child.on("exit", async (code) => {
|
||||
if (code !== 0 && code !== null) {
|
||||
await setToolStatus("cliproxyapi", "stopped", undefined, `Process exited with code ${code}`);
|
||||
}
|
||||
});
|
||||
|
||||
const pid = child.pid;
|
||||
await setToolStatus("cliproxyapi", "running", pid);
|
||||
|
||||
return { pid, port: actualPort };
|
||||
}
|
||||
|
||||
export function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function stopProcess(pid: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (!isProcessRunning(pid)) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
} catch {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {}
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}, GRACEFUL_TIMEOUT_MS);
|
||||
|
||||
const check = setInterval(() => {
|
||||
if (!isProcessRunning(pid)) {
|
||||
clearTimeout(timer);
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
export async function restartProcess(
|
||||
binaryPath: string,
|
||||
port?: number,
|
||||
configDir?: string,
|
||||
currentPid?: number | null
|
||||
): Promise<{ pid: number; port: number }> {
|
||||
if (currentPid) {
|
||||
await stopProcess(currentPid);
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
return startProcess(binaryPath, port, configDir);
|
||||
}
|
||||
|
||||
export async function getProcessInfo(pid: number): Promise<{
|
||||
pid: number;
|
||||
alive: boolean;
|
||||
memoryUsage?: number;
|
||||
}> {
|
||||
if (!isProcessRunning(pid)) {
|
||||
return { pid, alive: false };
|
||||
}
|
||||
|
||||
try {
|
||||
if (process.platform === "linux" || process.platform === "android") {
|
||||
const statusFile = `/proc/${pid}/status`;
|
||||
const content = await fs.readFile(statusFile, "utf-8");
|
||||
const match = content.match(/VmRSS:\s+(\d+)\s+kB/);
|
||||
if (match) {
|
||||
return { pid, alive: true, memoryUsage: parseInt(match[1], 10) * 1024 };
|
||||
}
|
||||
} else if (process.platform === "darwin") {
|
||||
const { execFile } = await import("child_process");
|
||||
const { promisify } = await import("util");
|
||||
const execFileAsync = promisify(execFile);
|
||||
const { stdout } = await execFileAsync("ps", ["-o", "rss=", "-p", String(pid)]);
|
||||
const rssKb = parseInt(stdout.trim(), 10);
|
||||
if (!isNaN(rssKb)) {
|
||||
return { pid, alive: true, memoryUsage: rssKb * 1024 };
|
||||
}
|
||||
}
|
||||
return { pid, alive: true };
|
||||
} catch {
|
||||
return { pid, alive: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const releasesCache = new Map<string, { data: any; ts: number }>();
|
||||
|
||||
function getGitHubHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = { Accept: "application/vnd.github+json" };
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function fetchJSON(url: string): Promise<any> {
|
||||
const res = await fetch(url, {
|
||||
headers: getGitHubHeaders(),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`GitHub API ${res.status}: ${url}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function cachedFetch(url: string): Promise<any> {
|
||||
const cached = releasesCache.get(url);
|
||||
if (cached && Date.now() - cached.ts < CACHE_TTL_MS) return cached.data;
|
||||
const data = await fetchJSON(url);
|
||||
releasesCache.set(url, { data, ts: Date.now() });
|
||||
return data;
|
||||
}
|
||||
|
||||
interface ReleaseInfo {
|
||||
tag: string;
|
||||
version: string;
|
||||
assets: { name: string; url: string; size: number }[];
|
||||
publishedAt: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
function parseRelease(raw: any): ReleaseInfo {
|
||||
return {
|
||||
tag: raw.tag_name,
|
||||
version: raw.tag_name.replace(/^v/, ""),
|
||||
assets: (raw.assets || []).map((a: any) => ({
|
||||
name: a.name,
|
||||
url: a.browser_download_url,
|
||||
size: a.size,
|
||||
})),
|
||||
publishedAt: raw.published_at,
|
||||
body: raw.body || "",
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLatestRelease(): Promise<ReleaseInfo> {
|
||||
const raw = await cachedFetch(
|
||||
"https://api.github.com/repos/router-for-me/CLIProxyAPI/releases/latest"
|
||||
);
|
||||
return parseRelease(raw);
|
||||
}
|
||||
|
||||
export async function getReleaseByVersion(version: string): Promise<ReleaseInfo | null> {
|
||||
const tag = version.startsWith("v") ? version : `v${version}`;
|
||||
try {
|
||||
const raw = await cachedFetch(
|
||||
`https://api.github.com/repos/router-for-me/CLIProxyAPI/releases/tags/${tag}`
|
||||
);
|
||||
return parseRelease(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAvailableVersions(): Promise<string[]> {
|
||||
const raw = await cachedFetch(
|
||||
"https://api.github.com/repos/router-for-me/CLIProxyAPI/releases?per_page=30"
|
||||
);
|
||||
return (Array.isArray(raw) ? raw : []).map((r: any) => r.tag_name);
|
||||
}
|
||||
|
||||
export async function getChecksums(version: string): Promise<Map<string, string>> {
|
||||
const tag = version.startsWith("v") ? version : `v${version}`;
|
||||
const url = `https://github.com/router-for-me/CLIProxyAPI/releases/download/${tag}/checksums.txt`;
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
||||
if (!res.ok) return new Map();
|
||||
const text = await res.text();
|
||||
const map = new Map<string, string>();
|
||||
for (const line of text.split("\n")) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
map.set(parts[1], parts[0]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
export function clearCache(): void {
|
||||
releasesCache.clear();
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, it, afterEach, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-binmgr-test-"));
|
||||
const originalDataDir = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = tmpDir;
|
||||
|
||||
afterEach(() => {
|
||||
const binDir = path.join(tmpDir, "bin");
|
||||
try {
|
||||
if (fs.existsSync(binDir)) fs.rmSync(binDir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
});
|
||||
|
||||
after(() => {
|
||||
process.env.DATA_DIR = originalDataDir;
|
||||
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("binaryManager", () => {
|
||||
let mod;
|
||||
it("should load module", async () => {
|
||||
mod = await import("../../src/lib/versionManager/binaryManager.ts");
|
||||
assert.ok(mod.getAssetName);
|
||||
assert.ok(mod.getTargetPlatform);
|
||||
assert.ok(mod.installVersion);
|
||||
assert.ok(mod.getCurrentBinaryPath);
|
||||
assert.ok(mod.getInstalledVersions);
|
||||
assert.ok(mod.rollbackVersion);
|
||||
assert.ok(mod.removeVersion);
|
||||
});
|
||||
|
||||
describe("getAssetName", () => {
|
||||
it("should return .tar.gz for linux", () => {
|
||||
assert.equal(mod.getAssetName("linux", "amd64"), "CLIProxyAPI_{version}_linux_amd64.tar.gz");
|
||||
});
|
||||
|
||||
it("should return .tar.gz for darwin", () => {
|
||||
assert.equal(
|
||||
mod.getAssetName("darwin", "arm64"),
|
||||
"CLIProxyAPI_{version}_darwin_arm64.tar.gz"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return .zip for windows", () => {
|
||||
assert.equal(mod.getAssetName("windows", "amd64"), "CLIProxyAPI_{version}_windows_amd64.zip");
|
||||
});
|
||||
|
||||
it("should return .tar.gz for freebsd", () => {
|
||||
assert.equal(
|
||||
mod.getAssetName("freebsd", "amd64"),
|
||||
"CLIProxyAPI_{version}_freebsd_amd64.tar.gz"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTargetPlatform", () => {
|
||||
it("should return current platform", () => {
|
||||
const { platform, arch } = mod.getTargetPlatform();
|
||||
assert.ok(["linux", "darwin", "windows"].includes(platform));
|
||||
assert.ok(["amd64", "arm64"].includes(arch));
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCurrentBinaryPath", () => {
|
||||
it("should return null when no symlink", async () => {
|
||||
assert.equal(await mod.getCurrentBinaryPath(tmpDir), null);
|
||||
});
|
||||
|
||||
it("should return null when symlink target missing", async () => {
|
||||
const binDir = path.join(tmpDir, "bin");
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
fs.symlinkSync("/nonexistent/binary", path.join(binDir, "cliproxyapi"));
|
||||
assert.equal(await mod.getCurrentBinaryPath(tmpDir), null);
|
||||
});
|
||||
|
||||
it("should return real path when valid symlink", async () => {
|
||||
const binDir = path.join(tmpDir, "bin");
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
const real = path.join(binDir, "cliproxyapi-1.0.0", "cli-proxy-api");
|
||||
fs.mkdirSync(path.dirname(real), { recursive: true });
|
||||
fs.writeFileSync(real, "bin");
|
||||
fs.symlinkSync(real, path.join(binDir, "cliproxyapi"));
|
||||
const result = await mod.getCurrentBinaryPath(tmpDir);
|
||||
assert.ok(result.includes("cliproxyapi-1.0.0"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("getInstalledVersions", () => {
|
||||
it("should return empty when no versions", async () => {
|
||||
assert.deepEqual(await mod.getInstalledVersions(tmpDir), []);
|
||||
});
|
||||
|
||||
it("should list version directories", async () => {
|
||||
const binDir = path.join(tmpDir, "bin");
|
||||
fs.mkdirSync(path.join(binDir, "cliproxyapi-1.0.0"), { recursive: true });
|
||||
fs.mkdirSync(path.join(binDir, "cliproxyapi-2.0.0"), { recursive: true });
|
||||
fs.mkdirSync(path.join(binDir, "other-dir"), { recursive: true });
|
||||
fs.writeFileSync(path.join(binDir, "file.txt"), "data");
|
||||
const versions = await mod.getInstalledVersions(tmpDir);
|
||||
assert.equal(versions.length, 2);
|
||||
assert.ok(versions.includes("1.0.0"));
|
||||
assert.ok(versions.includes("2.0.0"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("rollbackVersion", () => {
|
||||
it("should return null when < 2 versions", async () => {
|
||||
const binDir = path.join(tmpDir, "bin");
|
||||
fs.mkdirSync(path.join(binDir, "cliproxyapi-1.0.0"), { recursive: true });
|
||||
assert.equal(await mod.rollbackVersion(tmpDir), null);
|
||||
});
|
||||
|
||||
it("should return null when no versions", async () => {
|
||||
assert.equal(await mod.rollbackVersion(tmpDir), null);
|
||||
});
|
||||
|
||||
it("should rollback to previous version (sorted desc)", async () => {
|
||||
const binDir = path.join(tmpDir, "bin");
|
||||
for (const ver of ["1.0.0", "2.0.0"]) {
|
||||
const vDir = path.join(binDir, `cliproxyapi-${ver}`);
|
||||
fs.mkdirSync(vDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(vDir, "cli-proxy-api"), `bin-${ver}`);
|
||||
}
|
||||
fs.symlinkSync(
|
||||
path.join(binDir, "cliproxyapi-2.0.0", "cli-proxy-api"),
|
||||
path.join(binDir, "cliproxyapi")
|
||||
);
|
||||
const result = await mod.rollbackVersion(tmpDir);
|
||||
// Previous = second highest = 1.0.0
|
||||
assert.equal(result, "1.0.0");
|
||||
const real = fs.realpathSync(path.join(binDir, "cliproxyapi"));
|
||||
assert.ok(real.includes("1.0.0"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeVersion", () => {
|
||||
it("should remove version directory", async () => {
|
||||
const binDir = path.join(tmpDir, "bin");
|
||||
const vDir = path.join(binDir, "cliproxyapi-1.0.0");
|
||||
fs.mkdirSync(vDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(vDir, "f.txt"), "d");
|
||||
assert.equal(await mod.removeVersion("1.0.0", tmpDir), true);
|
||||
assert.equal(fs.existsSync(vDir), false);
|
||||
});
|
||||
|
||||
it("should return true even for non-existent version (rm force)", async () => {
|
||||
// fs.rm with { force: true } succeeds even if path doesn't exist
|
||||
const result = await mod.removeVersion("999.0.0", tmpDir);
|
||||
assert.equal(result, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, it, beforeEach, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("healthMonitor", () => {
|
||||
let mod;
|
||||
beforeEach(async () => {
|
||||
try {
|
||||
const prev = await import("../../src/lib/versionManager/healthMonitor.ts");
|
||||
prev.stopMonitoring("test-tool");
|
||||
prev.stopMonitoring("tool-a");
|
||||
prev.stopMonitoring("tool-b");
|
||||
} catch {}
|
||||
mod = await import("../../src/lib/versionManager/healthMonitor.ts");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
mod.stopMonitoring("test-tool");
|
||||
} catch {}
|
||||
try {
|
||||
mod.stopMonitoring("tool-a");
|
||||
} catch {}
|
||||
try {
|
||||
mod.stopMonitoring("tool-b");
|
||||
} catch {}
|
||||
});
|
||||
|
||||
describe("checkHealth", () => {
|
||||
it("should return healthy for 200 with models", async () => {
|
||||
globalThis.fetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: [{ id: "m1" }, { id: "m2" }] }),
|
||||
});
|
||||
const r = await mod.checkHealth("http://127.0.0.1:8317");
|
||||
assert.equal(r.healthy, true);
|
||||
assert.equal(r.modelCount, 2);
|
||||
assert.equal(r.error, null);
|
||||
assert.ok(r.latency >= 0);
|
||||
});
|
||||
|
||||
it("should return unhealthy for non-200", async () => {
|
||||
globalThis.fetch = async () => ({
|
||||
ok: false,
|
||||
status: 503,
|
||||
json: async () => ({}),
|
||||
});
|
||||
const r = await mod.checkHealth("http://127.0.0.1:8317");
|
||||
assert.equal(r.healthy, false);
|
||||
assert.equal(r.modelCount, 0);
|
||||
assert.equal(r.error, "HTTP 503");
|
||||
});
|
||||
|
||||
it("should return unhealthy on network error", async () => {
|
||||
globalThis.fetch = async () => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
};
|
||||
const r = await mod.checkHealth("http://127.0.0.1:8317");
|
||||
assert.equal(r.healthy, false);
|
||||
assert.ok(r.error.includes("ECONNREFUSED"));
|
||||
});
|
||||
|
||||
it("should handle non-array data.data", async () => {
|
||||
globalThis.fetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: "not-array" }),
|
||||
});
|
||||
const r = await mod.checkHealth("http://127.0.0.1:8317");
|
||||
assert.equal(r.healthy, true);
|
||||
assert.equal(r.modelCount, 0);
|
||||
});
|
||||
|
||||
it("should use custom health path", async () => {
|
||||
let capturedUrl;
|
||||
globalThis.fetch = async (url) => {
|
||||
capturedUrl = url;
|
||||
return { ok: true, status: 200, json: async () => ({ data: [] }) };
|
||||
};
|
||||
await mod.checkHealth("http://127.0.0.1:8317", "/health");
|
||||
assert.ok(capturedUrl.includes("/health"));
|
||||
});
|
||||
|
||||
it("should default to /v1/models", async () => {
|
||||
let capturedUrl;
|
||||
globalThis.fetch = async (url) => {
|
||||
capturedUrl = url;
|
||||
return { ok: true, status: 200, json: async () => ({ data: [] }) };
|
||||
};
|
||||
await mod.checkHealth("http://127.0.0.1:8317");
|
||||
assert.ok(capturedUrl.includes("/v1/models"));
|
||||
});
|
||||
|
||||
it("should handle non-Error throws", async () => {
|
||||
globalThis.fetch = async () => {
|
||||
throw "string error";
|
||||
};
|
||||
const r = await mod.checkHealth("http://127.0.0.1:8317");
|
||||
assert.equal(r.healthy, false);
|
||||
assert.equal(r.error, "string error");
|
||||
});
|
||||
|
||||
it("should return unhealthy for 500", async () => {
|
||||
globalThis.fetch = async () => ({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({}),
|
||||
});
|
||||
const r = await mod.checkHealth("http://127.0.0.1:8317");
|
||||
assert.equal(r.healthy, false);
|
||||
assert.equal(r.error, "HTTP 500");
|
||||
});
|
||||
});
|
||||
|
||||
describe("startMonitoring / stopMonitoring / isMonitoring", () => {
|
||||
it("should start and stop monitoring", () => {
|
||||
globalThis.fetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: [] }),
|
||||
});
|
||||
mod.startMonitoring("tool-a", "http://127.0.0.1:8317", 60_000);
|
||||
assert.equal(mod.isMonitoring("tool-a"), true);
|
||||
mod.stopMonitoring("tool-a");
|
||||
assert.equal(mod.isMonitoring("tool-a"), false);
|
||||
});
|
||||
|
||||
it("should replace previous monitoring on re-start", () => {
|
||||
globalThis.fetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: [] }),
|
||||
});
|
||||
mod.startMonitoring("tool-b", "http://127.0.0.1:8317", 60_000);
|
||||
mod.startMonitoring("tool-b", "http://127.0.0.1:8317", 30_000);
|
||||
assert.equal(mod.isMonitoring("tool-b"), true);
|
||||
mod.stopMonitoring("tool-b");
|
||||
});
|
||||
|
||||
it("should return false for non-monitored tool", () => {
|
||||
assert.equal(mod.isMonitoring("nonexistent"), false);
|
||||
});
|
||||
|
||||
it("should handle stopMonitoring for non-existent tool", () => {
|
||||
assert.doesNotThrow(() => mod.stopMonitoring("ghost"));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
describe("processManager", () => {
|
||||
let mod;
|
||||
it("should load module", async () => {
|
||||
const loadPromise = import("../../src/lib/versionManager/processManager.ts");
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Module load timeout")), 5000)
|
||||
);
|
||||
mod = await Promise.race([loadPromise, timeoutPromise]);
|
||||
assert.ok(mod);
|
||||
});
|
||||
|
||||
it("should export expected functions", () => {
|
||||
assert.equal(typeof mod.startProcess, "function");
|
||||
assert.equal(typeof mod.isProcessRunning, "function");
|
||||
assert.equal(typeof mod.stopProcess, "function");
|
||||
assert.equal(typeof mod.restartProcess, "function");
|
||||
assert.equal(typeof mod.getProcessInfo, "function");
|
||||
});
|
||||
|
||||
it("isProcessRunning should return false for large invalid PID", () => {
|
||||
assert.equal(mod.isProcessRunning(999999999), false);
|
||||
});
|
||||
|
||||
it("isProcessRunning should return true for current process", () => {
|
||||
assert.equal(mod.isProcessRunning(process.pid), true);
|
||||
});
|
||||
|
||||
it("stopProcess should resolve immediately for non-running PID", async () => {
|
||||
const start = Date.now();
|
||||
await mod.stopProcess(999999999);
|
||||
assert.ok(Date.now() - start < 1000);
|
||||
});
|
||||
|
||||
it("getProcessInfo should return alive=false for invalid PID", async () => {
|
||||
const info = await mod.getProcessInfo(999999999);
|
||||
assert.equal(info.pid, 999999999);
|
||||
assert.equal(info.alive, false);
|
||||
assert.equal(info.memoryUsage, undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, it, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
function mockFetchJson(data, status = 200) {
|
||||
globalThis.fetch = async () => ({ ok: status === 200, status, json: async () => data });
|
||||
}
|
||||
|
||||
function mockFetchText(text, status = 200) {
|
||||
globalThis.fetch = async () => ({ ok: status === 200, status, text: async () => text });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("releaseChecker", () => {
|
||||
let mod;
|
||||
|
||||
it("should load module", async () => {
|
||||
mod = await import("../../src/lib/versionManager/releaseChecker.ts");
|
||||
assert.ok(mod.getLatestRelease);
|
||||
assert.ok(mod.getReleaseByVersion);
|
||||
assert.ok(mod.getAvailableVersions);
|
||||
assert.ok(mod.getChecksums);
|
||||
assert.ok(mod.clearCache);
|
||||
});
|
||||
|
||||
describe("getLatestRelease", () => {
|
||||
it("should parse latest release from GitHub API", async () => {
|
||||
mod.clearCache();
|
||||
mockFetchJson({
|
||||
tag_name: "v6.9.7",
|
||||
published_at: "2025-01-01T00:00:00Z",
|
||||
body: "notes",
|
||||
assets: [{ name: "a.tar.gz", browser_download_url: "https://x.com/a.tar.gz", size: 100 }],
|
||||
});
|
||||
const r = await mod.getLatestRelease();
|
||||
assert.equal(r.tag, "v6.9.7");
|
||||
assert.equal(r.version, "6.9.7");
|
||||
assert.equal(r.publishedAt, "2025-01-01T00:00:00Z");
|
||||
assert.equal(r.body, "notes");
|
||||
assert.equal(r.assets.length, 1);
|
||||
assert.equal(r.assets[0].name, "a.tar.gz");
|
||||
assert.equal(r.assets[0].url, "https://x.com/a.tar.gz");
|
||||
assert.equal(r.assets[0].size, 100);
|
||||
});
|
||||
|
||||
it("should handle empty assets", async () => {
|
||||
mod.clearCache();
|
||||
mockFetchJson({ tag_name: "v1.0.0", published_at: "", body: "", assets: [] });
|
||||
const r = await mod.getLatestRelease();
|
||||
assert.equal(r.assets.length, 0);
|
||||
});
|
||||
|
||||
it("should handle null body", async () => {
|
||||
mod.clearCache();
|
||||
mockFetchJson({ tag_name: "v1.0.0", published_at: "", body: null, assets: [] });
|
||||
const r = await mod.getLatestRelease();
|
||||
assert.equal(r.body, "");
|
||||
});
|
||||
|
||||
it("should handle missing assets array", async () => {
|
||||
mod.clearCache();
|
||||
mockFetchJson({ tag_name: "v1.0.0", published_at: "", body: "" });
|
||||
const r = await mod.getLatestRelease();
|
||||
assert.equal(r.assets.length, 0);
|
||||
});
|
||||
|
||||
it("should throw on non-ok response", async () => {
|
||||
mod.clearCache();
|
||||
mockFetchJson({}, 404);
|
||||
await assert.rejects(() => mod.getLatestRelease(), { message: /GitHub API 404/ });
|
||||
});
|
||||
});
|
||||
|
||||
describe("getReleaseByVersion", () => {
|
||||
it("should fetch a specific version", async () => {
|
||||
mod.clearCache();
|
||||
mockFetchJson({ tag_name: "v6.9.0", published_at: "", body: "", assets: [] });
|
||||
const r = await mod.getReleaseByVersion("6.9.0");
|
||||
assert.ok(r);
|
||||
assert.equal(r.version, "6.9.0");
|
||||
});
|
||||
|
||||
it("should prepend v if missing", async () => {
|
||||
mod.clearCache();
|
||||
mockFetchJson({ tag_name: "v6.9.0", published_at: "", body: "", assets: [] });
|
||||
const r = await mod.getReleaseByVersion("6.9.0");
|
||||
assert.ok(r);
|
||||
});
|
||||
|
||||
it("should return null on 404", async () => {
|
||||
mod.clearCache();
|
||||
mockFetchJson({}, 404);
|
||||
const r = await mod.getReleaseByVersion("999.0.0");
|
||||
assert.equal(r, null);
|
||||
});
|
||||
|
||||
it("should return null on network error", async () => {
|
||||
mod.clearCache();
|
||||
globalThis.fetch = async () => {
|
||||
throw new Error("Network error");
|
||||
};
|
||||
const r = await mod.getReleaseByVersion("999.0.0");
|
||||
assert.equal(r, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAvailableVersions", () => {
|
||||
it("should return version tags", async () => {
|
||||
mod.clearCache();
|
||||
mockFetchJson([{ tag_name: "v6.9.7" }, { tag_name: "v6.9.6" }]);
|
||||
const v = await mod.getAvailableVersions();
|
||||
assert.deepEqual(v, ["v6.9.7", "v6.9.6"]);
|
||||
});
|
||||
|
||||
it("should handle empty array", async () => {
|
||||
mod.clearCache();
|
||||
mockFetchJson([]);
|
||||
const v = await mod.getAvailableVersions();
|
||||
assert.deepEqual(v, []);
|
||||
});
|
||||
|
||||
it("should handle non-array response", async () => {
|
||||
mod.clearCache();
|
||||
mockFetchJson({ message: "not array" });
|
||||
const v = await mod.getAvailableVersions();
|
||||
assert.deepEqual(v, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getChecksums", () => {
|
||||
it("should parse checksums.txt", async () => {
|
||||
mod.clearCache();
|
||||
mockFetchText("abc123 file.tar.gz\n456def other.tar.gz\n");
|
||||
const c = await mod.getChecksums("6.9.7");
|
||||
assert.equal(c.size, 2);
|
||||
assert.equal(c.get("file.tar.gz"), "abc123");
|
||||
assert.equal(c.get("other.tar.gz"), "456def");
|
||||
});
|
||||
|
||||
it("should return empty map on 404", async () => {
|
||||
mod.clearCache();
|
||||
mockFetchText("not found", 404);
|
||||
const c = await mod.getChecksums("999.0.0");
|
||||
assert.equal(c.size, 0);
|
||||
});
|
||||
|
||||
it("should return empty map on error", async () => {
|
||||
mod.clearCache();
|
||||
globalThis.fetch = async () => {
|
||||
throw new Error("fail");
|
||||
};
|
||||
const c = await mod.getChecksums("999.0.0");
|
||||
assert.equal(c.size, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearCache", () => {
|
||||
it("should clear cache so next call fetches fresh data", async () => {
|
||||
mod.clearCache();
|
||||
mockFetchJson({ tag_name: "v6.9.7", published_at: "", body: "", assets: [] });
|
||||
const r1 = await mod.getLatestRelease();
|
||||
assert.equal(r1.version, "6.9.7");
|
||||
|
||||
mod.clearCache();
|
||||
mockFetchJson({ tag_name: "v6.9.8", published_at: "", body: "", assets: [] });
|
||||
const r2 = await mod.getLatestRelease();
|
||||
assert.equal(r2.version, "6.9.8");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cache behavior", () => {
|
||||
it("should cache responses within TTL", async () => {
|
||||
mod.clearCache();
|
||||
let callCount = 0;
|
||||
globalThis.fetch = async () => {
|
||||
callCount++;
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ tag_name: "v6.9.7", published_at: "", body: "", assets: [] }),
|
||||
};
|
||||
};
|
||||
await mod.getLatestRelease();
|
||||
assert.equal(callCount, 1);
|
||||
await mod.getLatestRelease();
|
||||
assert.equal(callCount, 1); // cached
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// The orchestrator imports chain triggers getDbInstance() which runs migrations.
|
||||
// A pre-existing migration conflict (014_create_memories_down.sql) causes a
|
||||
// rejected promise that keeps the event loop alive. We force-exit after tests.
|
||||
let forceExitScheduled = false;
|
||||
|
||||
describe("versionManager orchestrator (index.ts)", () => {
|
||||
let mod;
|
||||
it("should load module and export expected functions", async () => {
|
||||
const loadPromise = import("../../src/lib/versionManager/index.ts");
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Module load timeout")), 5000)
|
||||
);
|
||||
mod = await Promise.race([loadPromise, timeoutPromise]);
|
||||
assert.ok(mod);
|
||||
});
|
||||
|
||||
it("should export all public functions", () => {
|
||||
assert.equal(typeof mod.installTool, "function");
|
||||
assert.equal(typeof mod.startTool, "function");
|
||||
assert.equal(typeof mod.stopTool, "function");
|
||||
assert.equal(typeof mod.restartTool, "function");
|
||||
assert.equal(typeof mod.checkForUpdates, "function");
|
||||
assert.equal(typeof mod.pinVersion, "function");
|
||||
assert.equal(typeof mod.unpinVersion, "function");
|
||||
assert.equal(typeof mod.getToolHealth, "function");
|
||||
assert.equal(typeof mod.rollbackTool, "function");
|
||||
assert.equal(typeof mod.getVersionManagerStatus, "function");
|
||||
assert.equal(typeof mod.getVersionManagerTool, "function");
|
||||
});
|
||||
|
||||
it("startTool should throw when no binary found", async () => {
|
||||
try {
|
||||
await mod.startTool("nonexistent-tool-xyz");
|
||||
assert.fail("Should have thrown");
|
||||
} catch (err) {
|
||||
assert.ok(true); // Expected to throw
|
||||
}
|
||||
});
|
||||
|
||||
it("restartTool should throw when no binary found", async () => {
|
||||
try {
|
||||
await mod.restartTool("nonexistent-tool-xyz");
|
||||
assert.fail("Should have thrown");
|
||||
} catch (err) {
|
||||
assert.ok(true); // Expected to throw
|
||||
}
|
||||
});
|
||||
|
||||
it("getToolHealth should return null for non-existent tool", async () => {
|
||||
try {
|
||||
const result = await mod.getToolHealth("nonexistent-tool-xyz");
|
||||
assert.equal(result, null);
|
||||
} catch {
|
||||
assert.ok(true); // DB not available, expected
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (!forceExitScheduled) {
|
||||
forceExitScheduled = true;
|
||||
// Force exit to avoid hanging on unresolved promises from DB migration errors
|
||||
setTimeout(() => process.exit(0), 500);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user