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:
oyi77
2026-04-02 13:37:43 +07:00
parent d82a7040f1
commit 2e2afa616d
18 changed files with 1721 additions and 1 deletions

View File

@@ -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>
);
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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 };
}
}

View File

@@ -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();
}