/** * 端口注册文件:单一真相源,供看门狗 / 面板 / 手动脚本定位「实际端口」。 * 零 pi 依赖的纯业务模块。 * * 背景:Web 面板的端口可能因冲突而从 8123 漂移到 8124/8125。任何一方要 * 「找面板」,都不该写死端口,而应读注册文件拿到实际端口。 */ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { homedir } from "node:os"; export const DEFAULT_WEB_PORT = 8123; export const PORT_FILE = join(homedir(), ".pi", "agent", "ocgo-web-port"); export interface PortIo { exists(path: string): boolean; read(path: string): string | undefined; write(path: string, data: string): void; mkdir(dir: string): void; dirname(path: string): string; } const nodePortIo: PortIo = { exists: existsSync, read: (p) => (existsSync(p) ? readFileSync(p, "utf-8") : undefined), write: writeFileSync, mkdir: (d) => mkdirSync(d, { recursive: true }), dirname, }; /** 把实际端口写入注册文件。 */ export function writePortFile(port: number, path = PORT_FILE, io: PortIo = nodePortIo): void { io.mkdir(io.dirname(path)); io.write(path, String(port)); } /** 读取注册文件里的端口;未注册返回 DEFAULT_WEB_PORT。 */ export function readPortFile(path = PORT_FILE, io: PortIo = nodePortIo): number { const raw = io.read(path)?.trim(); const n = Number(raw); return Number.isInteger(n) && n > 0 ? n : DEFAULT_WEB_PORT; } /** 探测给定端口上的 Web 面板是否在响应(GET /api/status 返回 ok)。 */ export async function probePort( port: number, host = "127.0.0.1", timeoutMs = 1500, fetchImpl: (url: string, init?: { headers?: Record; signal?: AbortSignal }) => Promise<{ ok: boolean }> = fetch as never, ): Promise { try { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), timeoutMs); const res = await fetchImpl(`http://${host}:${port}/api/status`, { headers: { Accept: "application/json" }, signal: ctrl.signal, }); clearTimeout(t); return res.ok; } catch { return false; } }