mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-11-04 08:41:58 +08:00
797 lines
24 KiB
JavaScript
797 lines
24 KiB
JavaScript
#!/usr/bin/env node
|
|
import { spawn } from "node:child_process";
|
|
import { randomBytes } from "node:crypto";
|
|
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env";
|
|
import { applyClaudeAppGatewayConfig } from "@ccr/core/agents/claude-app/gateway-service";
|
|
import { launchClaudeAppProfile, resolveClaudeAppProfileUserDataDir } from "@ccr/core/agents/claude-app/launch";
|
|
import { launchCodexAppProfile, launchZcodeAppProfile } from "@ccr/core/agents/codex/app-launch";
|
|
import { loadAppConfig } from "@ccr/core/config/config";
|
|
import { CONFIGDIR } from "@ccr/core/config/constants";
|
|
import { applyProfileConfig, applyProfileRuntimeConfig } from "@ccr/core/profiles/service";
|
|
import { ensureProfileGateway } from "@ccr/core/profiles/launch-service";
|
|
import { buildProfileLaunchPlan, defaultProfileOpenSurface, findProfileForOpen, profileLaunchSpawnCommand, resolveProfileOpenSurface } from "@ccr/core/profiles/launch-core";
|
|
import { openSystemExternal, startWebManagementServer } from "@ccr/core/web/management-server";
|
|
import { assertAvailableGatewayModels, type ProfileConfig, type ProfileOpenSurface } from "@ccr/core/contracts/app";
|
|
|
|
type ProfileCliOptions = {
|
|
agentArgs: string[];
|
|
command: "profile";
|
|
help: boolean;
|
|
profileRef: string;
|
|
surface?: ProfileOpenSurface;
|
|
};
|
|
|
|
type WebCliOptions = {
|
|
command: "start" | "ui" | "web";
|
|
daemonChild: boolean;
|
|
help: boolean;
|
|
host?: string;
|
|
open: boolean;
|
|
port?: number;
|
|
startGateway: boolean;
|
|
};
|
|
|
|
type StopCliOptions = {
|
|
command: "stop";
|
|
help: boolean;
|
|
};
|
|
|
|
type CliOptions = ProfileCliOptions | StopCliOptions | WebCliOptions;
|
|
|
|
type ServiceState = {
|
|
host?: string;
|
|
pid: number;
|
|
serviceToken?: string;
|
|
startedAt: string;
|
|
startGateway: boolean;
|
|
url: string;
|
|
};
|
|
|
|
const serviceStateFileName = "service.json";
|
|
const serviceInstanceTokenEnv = "CCR_SERVICE_INSTANCE_TOKEN";
|
|
const serviceRpcTimeoutMs = 2_000;
|
|
const serviceStartTimeoutMs = 30_000;
|
|
const serviceStopTimeoutMs = 10_000;
|
|
const webAuthHeader = "x-ccr-web-auth";
|
|
const webAuthQueryParam = "ccr_web_token";
|
|
const defaultCliCommandName = "ccr";
|
|
|
|
async function main(): Promise<void> {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
if (options.command === "start") {
|
|
if (options.help) {
|
|
printStartHelp(0);
|
|
return;
|
|
}
|
|
await startService(options);
|
|
return;
|
|
}
|
|
if (options.command === "ui") {
|
|
if (options.help) {
|
|
printUiHelp(0);
|
|
return;
|
|
}
|
|
await openManagementUi(options);
|
|
return;
|
|
}
|
|
if (options.command === "stop") {
|
|
if (options.help) {
|
|
printStopHelp(0);
|
|
return;
|
|
}
|
|
await stopService();
|
|
return;
|
|
}
|
|
if (options.command === "web") {
|
|
if (options.help) {
|
|
printWebHelp(0);
|
|
return;
|
|
}
|
|
await runWebServer(options);
|
|
return;
|
|
}
|
|
|
|
const profileOptions = options as ProfileCliOptions;
|
|
if (profileOptions.help || !profileOptions.profileRef) {
|
|
printHelp(profileOptions.help ? 0 : 2);
|
|
return;
|
|
}
|
|
|
|
const configDir = CONFIGDIR;
|
|
const config = await loadAppConfig();
|
|
assertAvailableGatewayModels(config);
|
|
await applyProfileConfig(config);
|
|
const profile = findProfileForOpen(config, profileOptions.profileRef);
|
|
const surface = profileOptions.surface ?? defaultProfileOpenSurface(profile);
|
|
const resolvedSurface = resolveProfileOpenSurface(profile, surface);
|
|
if (profile.agent === "zcode" && profileOptions.agentArgs.length > 0) {
|
|
throw new Error("ZCode profiles can only open the app; agent arguments are not supported.");
|
|
}
|
|
if (profile.agent === "claude-code" && resolvedSurface === "app" && profileOptions.agentArgs.length > 0) {
|
|
throw new Error("Claude App profiles do not support agent arguments.");
|
|
}
|
|
|
|
const launchConfig = await ensureProfileGateway(config, profile, resolvedSurface === "app" ? profileAppName(profile) : profile.name || profile.id || "profile", {
|
|
reuseExisting: true,
|
|
startIfMissing: false
|
|
});
|
|
if (resolvedSurface === "cli") {
|
|
const runtimeResult = applyProfileRuntimeConfig(launchConfig, profile, launchConfig.APIKEY);
|
|
if (!runtimeResult.ok) {
|
|
throw new Error(runtimeResult.message);
|
|
}
|
|
}
|
|
if (profile.agent === "claude-code" && resolvedSurface === "app") {
|
|
applyClaudeAppGatewayConfig(launchConfig);
|
|
applyClaudeAppGatewayConfig(launchConfig, {
|
|
backup: false,
|
|
dataDir: resolveClaudeAppProfileUserDataDir(configDir, profile),
|
|
refreshModelDiscoveryCache: true
|
|
});
|
|
const launch = await launchClaudeAppProfile(configDir, profile, launchConfig);
|
|
const spawnError = await waitForImmediateSpawnError(launch.child, 500);
|
|
if (spawnError) {
|
|
throw new Error(`Failed to open Claude App: ${spawnError}`);
|
|
}
|
|
process.stdout.write(`Opened Claude App with ${profile.name || profile.id}.\n`);
|
|
return;
|
|
}
|
|
if ((profile.agent === "codex" || profile.agent === "zcode") && resolvedSurface === "app" && profileOptions.agentArgs.length === 0) {
|
|
if (profile.agent === "zcode") {
|
|
const launch = launchZcodeAppProfile(configDir, profile, launchConfig);
|
|
const spawnError = await waitForImmediateSpawnError(launch.child, 500);
|
|
if (spawnError) {
|
|
throw new Error(`Failed to open ZCode App: ${spawnError}`);
|
|
}
|
|
process.stdout.write(`Opened ZCode App with ${profile.name || profile.id}.\n`);
|
|
} else {
|
|
const launch = launchCodexAppProfile(configDir, profile, launchConfig);
|
|
const spawnError = await waitForImmediateSpawnError(launch.child, 500);
|
|
if (spawnError) {
|
|
throw new Error(`Failed to open Codex App: ${spawnError}`);
|
|
}
|
|
process.stdout.write(`Opened Codex App with ${profile.name || profile.id}.\n`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const plan = buildProfileLaunchPlan(configDir, profile, resolvedSurface, profileOptions.agentArgs);
|
|
|
|
if (path.isAbsolute(plan.command) && !existsSync(plan.command)) {
|
|
throw new Error(`Profile launcher was not found: ${plan.command}. Open CCR once or re-save the profile.`);
|
|
}
|
|
|
|
const childEnv = {
|
|
...process.env,
|
|
...plan.env,
|
|
...botGatewayProfileEnv(launchConfig, profile, resolvedSurface)
|
|
};
|
|
delete childEnv.ELECTRON_RUN_AS_NODE;
|
|
|
|
const launch = profileLaunchSpawnCommand(plan);
|
|
const child = spawn(launch.command, launch.args, {
|
|
env: childEnv,
|
|
stdio: "inherit",
|
|
windowsVerbatimArguments: !!launch.windowsVerbatimArguments
|
|
});
|
|
const code = await waitForChild(child);
|
|
process.exitCode = code;
|
|
}
|
|
|
|
function parseArgs(args: string[]): CliOptions {
|
|
if (args[0] === "start") {
|
|
return parseWebArgs(args.slice(1), "start");
|
|
}
|
|
if (args[0] === "ui") {
|
|
return parseWebArgs(args.slice(1), "ui", true);
|
|
}
|
|
if (args[0] === "stop") {
|
|
return parseStopArgs(args.slice(1));
|
|
}
|
|
if (args[0] === "serve" || args[0] === "web") {
|
|
return parseWebArgs(args.slice(1), "web");
|
|
}
|
|
|
|
const options: ProfileCliOptions = {
|
|
agentArgs: [],
|
|
command: "profile",
|
|
help: false,
|
|
profileRef: ""
|
|
};
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg === "--") {
|
|
options.agentArgs.push(...args.slice(index + 1));
|
|
break;
|
|
}
|
|
if (arg === "--help" || arg === "-h") {
|
|
options.help = true;
|
|
continue;
|
|
}
|
|
if (arg === "--app") {
|
|
options.surface = "app";
|
|
continue;
|
|
}
|
|
if (arg === "--cli") {
|
|
options.surface = "cli";
|
|
continue;
|
|
}
|
|
if (options.profileRef && !options.surface && (arg === "cli" || arg === "app")) {
|
|
options.surface = arg;
|
|
continue;
|
|
}
|
|
if (!options.profileRef) {
|
|
options.profileRef = arg;
|
|
continue;
|
|
}
|
|
options.agentArgs.push(arg);
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function profileAppName(profile: Pick<ProfileConfig, "agent">): string {
|
|
if (profile.agent === "claude-code") {
|
|
return "Claude App";
|
|
}
|
|
if (profile.agent === "zcode") {
|
|
return "ZCode App";
|
|
}
|
|
return "Codex App";
|
|
}
|
|
|
|
function parseStopArgs(args: string[]): StopCliOptions {
|
|
const options: StopCliOptions = {
|
|
command: "stop",
|
|
help: false
|
|
};
|
|
for (const arg of args) {
|
|
if (arg === "--help" || arg === "-h") {
|
|
options.help = true;
|
|
continue;
|
|
}
|
|
throw new Error(`Unknown stop option: ${arg}`);
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function parseWebArgs(args: string[], command: WebCliOptions["command"], defaultOpen = false): WebCliOptions {
|
|
const options: WebCliOptions = {
|
|
command,
|
|
daemonChild: false,
|
|
help: false,
|
|
open: defaultOpen,
|
|
startGateway: true
|
|
};
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg === "--help" || arg === "-h") {
|
|
options.help = true;
|
|
continue;
|
|
}
|
|
if (arg === "--open") {
|
|
options.open = true;
|
|
continue;
|
|
}
|
|
if (arg === "--no-open") {
|
|
options.open = false;
|
|
continue;
|
|
}
|
|
if (arg === "--gateway") {
|
|
options.startGateway = true;
|
|
continue;
|
|
}
|
|
if (arg === "--no-gateway") {
|
|
options.startGateway = false;
|
|
continue;
|
|
}
|
|
if (arg === "--daemon-child") {
|
|
options.daemonChild = true;
|
|
continue;
|
|
}
|
|
if (arg === "--host") {
|
|
index += 1;
|
|
options.host = requiredArg(args[index], "--host");
|
|
continue;
|
|
}
|
|
if (arg.startsWith("--host=")) {
|
|
options.host = requiredArg(arg.slice("--host=".length), "--host");
|
|
continue;
|
|
}
|
|
if (arg === "--port") {
|
|
index += 1;
|
|
options.port = parsePort(requiredArg(args[index], "--port"));
|
|
continue;
|
|
}
|
|
if (arg.startsWith("--port=")) {
|
|
options.port = parsePort(requiredArg(arg.slice("--port=".length), "--port"));
|
|
continue;
|
|
}
|
|
throw new Error(`Unknown web option: ${arg}`);
|
|
}
|
|
return options;
|
|
}
|
|
|
|
async function startService(options: WebCliOptions): Promise<void> {
|
|
const current = readServiceState();
|
|
const currentVerification = current ? await verifyServiceState(current) : undefined;
|
|
if (current && currentVerification?.ok) {
|
|
process.stdout.write(`CCR service is already running at ${current.url} (pid ${current.pid}).\n`);
|
|
if (options.open) {
|
|
await openManagementUrl(current.url);
|
|
}
|
|
return;
|
|
}
|
|
if (current) {
|
|
clearServiceState(current.pid);
|
|
}
|
|
|
|
const serviceToken = generateServiceToken();
|
|
const childArgs = [
|
|
currentCliScript(),
|
|
"serve",
|
|
"--daemon-child",
|
|
...(options.host ? ["--host", options.host] : []),
|
|
...(options.port ? ["--port", String(options.port)] : []),
|
|
"--no-open",
|
|
...(options.startGateway ? [] : ["--no-gateway"])
|
|
];
|
|
const child = spawn(process.execPath, childArgs, {
|
|
detached: true,
|
|
env: serviceChildEnv(serviceToken),
|
|
stdio: "ignore",
|
|
windowsHide: true
|
|
});
|
|
const spawnError = await waitForImmediateSpawnError(child, 1000);
|
|
if (spawnError) {
|
|
throw new Error(`Failed to start CCR service: ${spawnError}`);
|
|
}
|
|
child.unref();
|
|
|
|
const state = await waitForServiceState(child.pid, serviceStartTimeoutMs);
|
|
if (!state) {
|
|
throw new Error(`CCR service did not report ready within ${serviceStartTimeoutMs}ms.`);
|
|
}
|
|
process.stdout.write(`CCR service started at ${state.url} (pid ${state.pid}).\n`);
|
|
if (options.open) {
|
|
await openManagementUrl(state.url);
|
|
}
|
|
}
|
|
|
|
async function openManagementUi(options: WebCliOptions): Promise<void> {
|
|
await startService({
|
|
...options,
|
|
command: "start"
|
|
});
|
|
}
|
|
|
|
async function openManagementUrl(url: string): Promise<void> {
|
|
try {
|
|
await openSystemExternal(url);
|
|
process.stdout.write(`Opened CCR management UI at ${url}\n`);
|
|
} catch (error) {
|
|
process.stderr.write(`Failed to open browser: ${formatError(error)}\n`);
|
|
process.stdout.write(`CCR management UI is available at ${url}\n`);
|
|
}
|
|
}
|
|
|
|
function serviceChildEnv(serviceToken: string): NodeJS.ProcessEnv {
|
|
const env = { ...process.env };
|
|
env[serviceInstanceTokenEnv] = serviceToken;
|
|
if (process.versions.electron) {
|
|
env.ELECTRON_RUN_AS_NODE = "1";
|
|
} else {
|
|
delete env.ELECTRON_RUN_AS_NODE;
|
|
}
|
|
return env;
|
|
}
|
|
|
|
async function runWebServer(options: WebCliOptions): Promise<void> {
|
|
const runtime = await startWebManagementServer({
|
|
host: options.host,
|
|
open: options.open,
|
|
port: options.port,
|
|
startGateway: options.startGateway
|
|
});
|
|
if (options.daemonChild) {
|
|
const serviceToken = process.env[serviceInstanceTokenEnv]?.trim() || undefined;
|
|
writeServiceState({
|
|
host: options.host,
|
|
pid: process.pid,
|
|
...(serviceToken ? { serviceToken } : {}),
|
|
startedAt: new Date().toISOString(),
|
|
startGateway: options.startGateway,
|
|
url: runtime.url
|
|
});
|
|
}
|
|
process.stdout.write(`CCR web management is running at ${runtime.url}\n`);
|
|
|
|
let closing = false;
|
|
const shutdown = (signal: NodeJS.Signals) => {
|
|
if (closing) {
|
|
return;
|
|
}
|
|
closing = true;
|
|
void runtime.close().finally(() => {
|
|
if (options.daemonChild) {
|
|
clearServiceState(process.pid);
|
|
}
|
|
process.exit(signal === "SIGINT" ? 130 : 143);
|
|
});
|
|
};
|
|
process.once("SIGINT", shutdown);
|
|
process.once("SIGTERM", shutdown);
|
|
await new Promise(() => undefined);
|
|
}
|
|
|
|
async function stopService(): Promise<void> {
|
|
const state = readServiceState();
|
|
if (!state) {
|
|
process.stdout.write("CCR service is not running.\n");
|
|
return;
|
|
}
|
|
const verification = await verifyServiceState(state);
|
|
if (!verification.ok) {
|
|
clearServiceState(state.pid);
|
|
process.stdout.write("CCR service is not running.\n");
|
|
return;
|
|
}
|
|
|
|
await callServiceRpc(state, "quitApp");
|
|
const stopped = verification.trustedPid
|
|
? await waitForProcessExit(state.pid, serviceStopTimeoutMs)
|
|
: await waitForServiceUnavailable(state, serviceStopTimeoutMs);
|
|
if (!stopped) {
|
|
throw new Error(`CCR service did not stop within ${serviceStopTimeoutMs}ms.`);
|
|
}
|
|
clearServiceState(state.pid);
|
|
process.stdout.write("CCR service stopped.\n");
|
|
}
|
|
|
|
function printHelp(exitCode: number): void {
|
|
const command = cliCommandName();
|
|
const output = [
|
|
"Usage:",
|
|
` ${command} start [--host <host>] [--port <port>] [--open] [--no-gateway]`,
|
|
` ${command} ui [--host <host>] [--port <port>] [--no-gateway]`,
|
|
` ${command} stop`,
|
|
` ${command} <profile-name-or-id> [cli|app] [-- <agent args>]`,
|
|
"",
|
|
"Examples:",
|
|
` ${command} start`,
|
|
` ${command} ui`,
|
|
` ${command} stop`,
|
|
` ${command} Codex`,
|
|
` ${command} default-codex -- --model gpt-5-codex`,
|
|
` ${command} default-codex app`
|
|
].join("\n");
|
|
const stream = exitCode === 0 ? process.stdout : process.stderr;
|
|
stream.write(`${output}\n`);
|
|
process.exitCode = exitCode;
|
|
}
|
|
|
|
function printStartHelp(exitCode: number): void {
|
|
const command = cliCommandName();
|
|
const output = [
|
|
"Usage:",
|
|
` ${command} start [--host <host>] [--port <port>] [--open] [--no-gateway]`,
|
|
"",
|
|
"Options:",
|
|
" --host <host> Management server host. Defaults to 127.0.0.1.",
|
|
" --port <port> Management server port. Defaults to 3458.",
|
|
" --open Open the management page in the default browser.",
|
|
" --no-open Do not open the management page.",
|
|
" --no-gateway Start only the web management server.",
|
|
"",
|
|
"Environment:",
|
|
" CCR_WEB_AUTH_TOKEN Use this token for management UI and RPC authentication."
|
|
].join("\n");
|
|
const stream = exitCode === 0 ? process.stdout : process.stderr;
|
|
stream.write(`${output}\n`);
|
|
process.exitCode = exitCode;
|
|
}
|
|
|
|
function printUiHelp(exitCode: number): void {
|
|
const command = cliCommandName();
|
|
const output = [
|
|
"Usage:",
|
|
` ${command} ui [--host <host>] [--port <port>] [--no-gateway]`,
|
|
"",
|
|
"Starts the background CCR service if needed and opens the management UI in the default browser.",
|
|
"",
|
|
"Options:",
|
|
" --host <host> Management server host. Defaults to 127.0.0.1.",
|
|
" --port <port> Management server port. Defaults to 3458.",
|
|
" --no-open Start or find the service and print the management URL without opening a browser.",
|
|
" --no-gateway Start only the web management server when the service is not already running.",
|
|
"",
|
|
"Environment:",
|
|
" CCR_WEB_AUTH_TOKEN Use this token for management UI and RPC authentication."
|
|
].join("\n");
|
|
const stream = exitCode === 0 ? process.stdout : process.stderr;
|
|
stream.write(`${output}\n`);
|
|
process.exitCode = exitCode;
|
|
}
|
|
|
|
function printStopHelp(exitCode: number): void {
|
|
const command = cliCommandName();
|
|
const output = [
|
|
"Usage:",
|
|
` ${command} stop`,
|
|
"",
|
|
`Stops the background CCR service started by \`${command} start\`.`
|
|
].join("\n");
|
|
const stream = exitCode === 0 ? process.stdout : process.stderr;
|
|
stream.write(`${output}\n`);
|
|
process.exitCode = exitCode;
|
|
}
|
|
|
|
function printWebHelp(exitCode: number): void {
|
|
const command = cliCommandName();
|
|
const output = [
|
|
"Usage:",
|
|
` ${command} serve [--host <host>] [--port <port>] [--open] [--no-gateway]`,
|
|
"",
|
|
"Options:",
|
|
" --host <host> Management server host. Defaults to 127.0.0.1.",
|
|
" --port <port> Management server port. Defaults to 3458.",
|
|
" --open Open the management page in the default browser.",
|
|
" --no-gateway Start only the web management server.",
|
|
"",
|
|
"Environment:",
|
|
" CCR_WEB_AUTH_TOKEN Use this token for management UI and RPC authentication."
|
|
].join("\n");
|
|
const stream = exitCode === 0 ? process.stdout : process.stderr;
|
|
stream.write(`${output}\n`);
|
|
process.exitCode = exitCode;
|
|
}
|
|
|
|
function cliCommandName(): string {
|
|
const configured = process.env.CCR_CLI_COMMAND_NAME?.trim();
|
|
return configured && /^[A-Za-z0-9._-]+$/.test(configured)
|
|
? configured
|
|
: defaultCliCommandName;
|
|
}
|
|
|
|
function readServiceState(): ServiceState | undefined {
|
|
const file = serviceStateFile();
|
|
if (!existsSync(file)) {
|
|
return undefined;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(readFileSync(file, "utf8")) as Partial<ServiceState>;
|
|
const pid = Number(parsed.pid);
|
|
if (!Number.isInteger(pid) || pid <= 0 || typeof parsed.url !== "string") {
|
|
return undefined;
|
|
}
|
|
return {
|
|
host: parsed.host,
|
|
pid,
|
|
serviceToken: typeof parsed.serviceToken === "string" && parsed.serviceToken.trim() ? parsed.serviceToken.trim() : undefined,
|
|
startedAt: parsed.startedAt || "",
|
|
startGateway: parsed.startGateway !== false,
|
|
url: parsed.url
|
|
};
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function writeServiceState(state: ServiceState): void {
|
|
const file = serviceStateFile();
|
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
}
|
|
|
|
function clearServiceState(pid?: number): void {
|
|
const state = readServiceState();
|
|
if (pid !== undefined && state && state.pid !== pid) {
|
|
return;
|
|
}
|
|
try {
|
|
unlinkSync(serviceStateFile());
|
|
} catch {
|
|
// Stale state cleanup is best effort.
|
|
}
|
|
}
|
|
|
|
function serviceStateFile(): string {
|
|
return path.join(CONFIGDIR, serviceStateFileName);
|
|
}
|
|
|
|
function currentCliScript(): string {
|
|
return __filename;
|
|
}
|
|
|
|
async function waitForServiceState(pid: number | undefined, timeoutMs: number): Promise<ServiceState | undefined> {
|
|
const startedAt = Date.now();
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|
const state = readServiceState();
|
|
if (state && (!pid || state.pid === pid) && (await verifyServiceState(state)).ok) {
|
|
return state;
|
|
}
|
|
await delay(150);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
async function waitForProcessExit(pid: number, timeoutMs: number): Promise<boolean> {
|
|
const startedAt = Date.now();
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|
if (!isProcessRunning(pid)) {
|
|
return true;
|
|
}
|
|
await delay(150);
|
|
}
|
|
return !isProcessRunning(pid);
|
|
}
|
|
|
|
function isProcessRunning(pid: number | undefined): boolean {
|
|
if (!pid || pid <= 0) {
|
|
return false;
|
|
}
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch (error) {
|
|
const code = typeof error === "object" && error !== null && "code" in error ? (error as { code?: unknown }).code : undefined;
|
|
return code === "EPERM";
|
|
}
|
|
}
|
|
|
|
type ServiceStateVerification =
|
|
| { ok: true; trustedPid: boolean }
|
|
| { ok: false };
|
|
|
|
type ServiceIdentity = {
|
|
pid?: unknown;
|
|
serviceTokenConfigured?: unknown;
|
|
serviceTokenMatches?: unknown;
|
|
};
|
|
|
|
async function verifyServiceState(state: ServiceState): Promise<ServiceStateVerification> {
|
|
if (!isProcessRunning(state.pid)) {
|
|
return { ok: false };
|
|
}
|
|
|
|
if (state.serviceToken) {
|
|
const identity = await callServiceRpc<ServiceIdentity>(state, "getServiceIdentity", [state.serviceToken]).catch(() => undefined);
|
|
if (identity?.serviceTokenMatches === true && Number(identity.pid) === state.pid) {
|
|
return { ok: true, trustedPid: true };
|
|
}
|
|
return { ok: false };
|
|
}
|
|
|
|
const appInfo = await callServiceRpc<{ name?: unknown }>(state, "getAppInfo").catch(() => undefined);
|
|
return appInfo?.name === "Claude Code Router"
|
|
? { ok: true, trustedPid: false }
|
|
: { ok: false };
|
|
}
|
|
|
|
async function waitForServiceUnavailable(state: ServiceState, timeoutMs: number): Promise<boolean> {
|
|
const startedAt = Date.now();
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|
const appInfo = await callServiceRpc<{ name?: unknown }>(state, "getAppInfo").catch(() => undefined);
|
|
if (appInfo?.name !== "Claude Code Router") {
|
|
return true;
|
|
}
|
|
await delay(150);
|
|
}
|
|
const appInfo = await callServiceRpc<{ name?: unknown }>(state, "getAppInfo").catch(() => undefined);
|
|
return appInfo?.name !== "Claude Code Router";
|
|
}
|
|
|
|
async function callServiceRpc<T>(state: ServiceState, method: string, args: unknown[] = []): Promise<T> {
|
|
const endpoint = serviceRpcEndpoint(state.url);
|
|
const authToken = serviceAuthToken(state.url);
|
|
if (!endpoint || !authToken) {
|
|
throw new Error("CCR service state does not include a usable management URL.");
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), serviceRpcTimeoutMs);
|
|
try {
|
|
const response = await fetch(endpoint, {
|
|
body: JSON.stringify({ args, method }),
|
|
headers: {
|
|
"content-type": "application/json",
|
|
[webAuthHeader]: authToken
|
|
},
|
|
method: "POST",
|
|
signal: controller.signal
|
|
});
|
|
const payload = await response.json().catch(() => undefined) as { ok?: boolean; value?: T } | undefined;
|
|
if (!response.ok || !payload?.ok) {
|
|
throw new Error(`CCR service RPC ${method} failed with HTTP ${response.status}`);
|
|
}
|
|
return payload.value as T;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
function serviceRpcEndpoint(url: string): string | undefined {
|
|
try {
|
|
const parsed = new URL(url);
|
|
return `${parsed.origin}/api/ccr/rpc`;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function serviceAuthToken(url: string): string {
|
|
try {
|
|
return new URL(url).searchParams.get(webAuthQueryParam)?.trim() ?? "";
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function generateServiceToken(): string {
|
|
return randomBytes(32).toString("base64url");
|
|
}
|
|
|
|
function delay(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function requiredArg(value: string | undefined, option: string): string {
|
|
if (!value?.trim()) {
|
|
throw new Error(`${option} requires a value.`);
|
|
}
|
|
return value.trim();
|
|
}
|
|
|
|
function parsePort(value: string): number {
|
|
const port = Number(value);
|
|
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
throw new Error(`Invalid port: ${value}`);
|
|
}
|
|
return port;
|
|
}
|
|
|
|
function waitForChild(child: ReturnType<typeof spawn>): Promise<number> {
|
|
return new Promise((resolve) => {
|
|
child.on("exit", (code, signal) => resolve(code ?? (signal === "SIGINT" ? 130 : 1)));
|
|
child.on("error", (error) => {
|
|
process.stderr.write(`${formatError(error)}\n`);
|
|
resolve(1);
|
|
});
|
|
});
|
|
}
|
|
|
|
function waitForImmediateSpawnError(child: ReturnType<typeof spawn>, timeoutMs: number): Promise<string | undefined> {
|
|
return new Promise((resolve) => {
|
|
let settled = false;
|
|
let timer: NodeJS.Timeout | undefined;
|
|
const finish = (message: string | undefined) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
}
|
|
child.off("error", onError);
|
|
child.off("spawn", onSpawn);
|
|
resolve(message);
|
|
};
|
|
const onError = (error: Error) => finish(formatError(error));
|
|
const onSpawn = () => finish(undefined);
|
|
child.once("error", onError);
|
|
child.once("spawn", onSpawn);
|
|
timer = setTimeout(() => finish(undefined), timeoutMs);
|
|
timer.unref?.();
|
|
});
|
|
}
|
|
|
|
function formatError(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
process.stderr.write(`${formatError(error)}\n`);
|
|
process.exitCode = 1;
|
|
});
|