213 lines
5.4 KiB
TypeScript
213 lines
5.4 KiB
TypeScript
import { constants } from "node:fs";
|
|
import { access } from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
export interface HermesBackendConfig {
|
|
enabled: boolean;
|
|
command: string;
|
|
args: string[];
|
|
cwd?: string;
|
|
timeoutMs: number;
|
|
defaultModel?: string;
|
|
toolsets: string[];
|
|
skills: string[];
|
|
sourceTag: string;
|
|
}
|
|
|
|
export type HermesBackendAvailabilityStatus = "disabled" | "misconfigured" | "ready";
|
|
|
|
export interface HermesBackendAvailability {
|
|
status: HermesBackendAvailabilityStatus;
|
|
selectable: boolean;
|
|
configured: boolean;
|
|
reason:
|
|
| "disabled"
|
|
| "command_not_set"
|
|
| "command_not_found"
|
|
| "workdir_not_found"
|
|
| "script_not_found"
|
|
| "ready";
|
|
reasonLabel: string;
|
|
command?: string;
|
|
cwd?: string;
|
|
}
|
|
|
|
function parseBoolean(value: string | undefined) {
|
|
return value?.trim().toLowerCase() === "true";
|
|
}
|
|
|
|
function parseArgs(value: string | undefined) {
|
|
return String(value || "")
|
|
.trim()
|
|
.split(/\s+/)
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function parseCsv(value: string | undefined) {
|
|
return String(value || "")
|
|
.split(",")
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function parseTimeoutMs(value: string | undefined) {
|
|
const parsed = Number.parseInt(value || "", 10);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 120000;
|
|
}
|
|
|
|
export function getHermesBackendConfig(): HermesBackendConfig {
|
|
return {
|
|
enabled: parseBoolean(process.env.BOSS_HERMES_ENABLED),
|
|
command: process.env.BOSS_HERMES_COMMAND?.trim() || "hermes",
|
|
args: parseArgs(process.env.BOSS_HERMES_ARGS),
|
|
cwd: process.env.BOSS_HERMES_WORKDIR?.trim() || undefined,
|
|
timeoutMs: parseTimeoutMs(process.env.BOSS_HERMES_TIMEOUT_MS),
|
|
defaultModel: process.env.BOSS_HERMES_DEFAULT_MODEL?.trim() || undefined,
|
|
toolsets: parseCsv(process.env.BOSS_HERMES_TOOLSETS),
|
|
skills: parseCsv(process.env.BOSS_HERMES_SKILLS),
|
|
sourceTag: process.env.BOSS_HERMES_SOURCE_TAG?.trim() || "tool",
|
|
};
|
|
}
|
|
|
|
export function isHermesBackendConfigured(config: HermesBackendConfig) {
|
|
return config.enabled && Boolean(config.command);
|
|
}
|
|
|
|
function commandLooksLikePath(command: string) {
|
|
return command.includes("/") || command.includes("\\");
|
|
}
|
|
|
|
async function fileExists(filePath: string, mode = constants.F_OK) {
|
|
try {
|
|
await access(filePath, mode);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function resolveScriptCandidate(config: HermesBackendConfig) {
|
|
if (!config.command || config.args.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const commandName = path.basename(config.command).toLowerCase();
|
|
const scriptRuntimes = new Set([
|
|
"node",
|
|
"node.exe",
|
|
"tsx",
|
|
"tsx.cmd",
|
|
"bun",
|
|
"bun.exe",
|
|
"deno",
|
|
"deno.exe",
|
|
"python",
|
|
"python.exe",
|
|
"python3",
|
|
"python3.exe",
|
|
]);
|
|
if (!scriptRuntimes.has(commandName)) {
|
|
return null;
|
|
}
|
|
|
|
const candidate = config.args[0];
|
|
if (!candidate || candidate.startsWith("-")) {
|
|
return null;
|
|
}
|
|
|
|
return path.isAbsolute(candidate)
|
|
? candidate
|
|
: path.resolve(config.cwd?.trim() || process.cwd(), candidate);
|
|
}
|
|
|
|
async function isCommandReachable(command: string) {
|
|
if (commandLooksLikePath(command)) {
|
|
return fileExists(path.resolve(command), constants.X_OK);
|
|
}
|
|
|
|
const searchPaths = (process.env.PATH || "")
|
|
.split(path.delimiter)
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean);
|
|
for (const entry of searchPaths) {
|
|
const candidate = path.join(entry, command);
|
|
if (await fileExists(candidate, constants.X_OK)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export async function getHermesBackendAvailability(
|
|
config: HermesBackendConfig = getHermesBackendConfig(),
|
|
): Promise<HermesBackendAvailability> {
|
|
const base = {
|
|
command: config.command,
|
|
cwd: config.cwd,
|
|
configured: isHermesBackendConfigured(config),
|
|
};
|
|
|
|
if (!config.enabled) {
|
|
return {
|
|
...base,
|
|
status: "disabled",
|
|
selectable: false,
|
|
reason: "disabled",
|
|
reasonLabel: "Hermes Runtime 当前未启用。",
|
|
};
|
|
}
|
|
|
|
if (!config.command) {
|
|
return {
|
|
...base,
|
|
status: "misconfigured",
|
|
selectable: false,
|
|
reason: "command_not_set",
|
|
reasonLabel: "Hermes Runtime 缺少启动命令。",
|
|
};
|
|
}
|
|
|
|
if (!(await isCommandReachable(config.command))) {
|
|
return {
|
|
...base,
|
|
status: "misconfigured",
|
|
selectable: false,
|
|
reason: "command_not_found",
|
|
reasonLabel: "未检测到可执行的 Hermes 启动命令。",
|
|
};
|
|
}
|
|
|
|
if (config.cwd && !(await fileExists(config.cwd, constants.F_OK))) {
|
|
return {
|
|
...base,
|
|
status: "misconfigured",
|
|
selectable: false,
|
|
reason: "workdir_not_found",
|
|
reasonLabel: "Hermes Runtime 工作目录不存在。",
|
|
};
|
|
}
|
|
|
|
const scriptCandidate = resolveScriptCandidate(config);
|
|
if (scriptCandidate && !(await fileExists(scriptCandidate, constants.F_OK))) {
|
|
return {
|
|
...base,
|
|
status: "misconfigured",
|
|
selectable: false,
|
|
reason: "script_not_found",
|
|
reasonLabel: "未检测到有效的 Hermes 启动脚本,将自动回退到默认后端。",
|
|
};
|
|
}
|
|
|
|
return {
|
|
...base,
|
|
status: "ready",
|
|
selectable: true,
|
|
reason: "ready",
|
|
reasonLabel: "Hermes Runtime 可用。",
|
|
};
|
|
}
|
|
|
|
export const getHermesBackendConfigForTesting = getHermesBackendConfig;
|
|
export const isHermesBackendConfiguredForTesting = isHermesBackendConfigured;
|
|
export const getHermesBackendAvailabilityForTesting = getHermesBackendAvailability;
|