mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-01-30 06:12:06 +00:00
279 lines
8.2 KiB
TypeScript
279 lines
8.2 KiB
TypeScript
import fs from "node:fs/promises";
|
||
import readline from "node:readline";
|
||
import JSON5 from "json5";
|
||
import path from "node:path";
|
||
import { createHash } from "node:crypto";
|
||
import {
|
||
CONFIG_FILE,
|
||
HOME_DIR, PID_FILE,
|
||
PLUGINS_DIR,
|
||
PRESETS_DIR,
|
||
REFERENCE_COUNT_FILE,
|
||
readPresetFile,
|
||
} from "@CCR/shared";
|
||
import { getServer } from "@CCR/server";
|
||
import { writeFileSync, existsSync, readFileSync } from "fs";
|
||
import { checkForUpdates, performUpdate } from "./update";
|
||
import { version } from "../../package.json";
|
||
import { spawn } from "child_process";
|
||
import {cleanupPidFile, isServiceRunning} from "./processCheck";
|
||
|
||
// Function to interpolate environment variables in config values
|
||
const interpolateEnvVars = (obj: any): any => {
|
||
if (typeof obj === "string") {
|
||
// Replace $VAR_NAME or ${VAR_NAME} with environment variable values
|
||
return obj.replace(/\$\{([^}]+)\}|\$([A-Z_][A-Z0-9_]*)/g, (match, braced, unbraced) => {
|
||
const varName = braced || unbraced;
|
||
return process.env[varName] || match; // Keep original if env var doesn't exist
|
||
});
|
||
} else if (Array.isArray(obj)) {
|
||
return obj.map(interpolateEnvVars);
|
||
} else if (obj !== null && typeof obj === "object") {
|
||
const result: any = {};
|
||
for (const [key, value] of Object.entries(obj)) {
|
||
result[key] = interpolateEnvVars(value);
|
||
}
|
||
return result;
|
||
}
|
||
return obj;
|
||
};
|
||
|
||
const ensureDir = async (dir_path: string) => {
|
||
try {
|
||
await fs.access(dir_path);
|
||
} catch {
|
||
await fs.mkdir(dir_path, { recursive: true });
|
||
}
|
||
};
|
||
|
||
export const initDir = async () => {
|
||
await ensureDir(HOME_DIR);
|
||
await ensureDir(PLUGINS_DIR);
|
||
await ensureDir(PRESETS_DIR);
|
||
await ensureDir(path.join(HOME_DIR, "logs"));
|
||
};
|
||
|
||
const createReadline = () => {
|
||
return readline.createInterface({
|
||
input: process.stdin,
|
||
output: process.stdout,
|
||
});
|
||
};
|
||
|
||
const question = (query: string): Promise<string> => {
|
||
return new Promise((resolve) => {
|
||
const rl = createReadline();
|
||
rl.question(query, (answer) => {
|
||
rl.close();
|
||
resolve(answer);
|
||
});
|
||
});
|
||
};
|
||
|
||
const confirm = async (query: string): Promise<boolean> => {
|
||
const answer = await question(query);
|
||
return answer.toLowerCase() !== "n";
|
||
};
|
||
|
||
export const readConfigFile = async () => {
|
||
try {
|
||
const config = await fs.readFile(CONFIG_FILE, "utf-8");
|
||
try {
|
||
// Try to parse with JSON5 first (which also supports standard JSON)
|
||
const parsedConfig = JSON5.parse(config);
|
||
// Interpolate environment variables in the parsed config
|
||
return interpolateEnvVars(parsedConfig);
|
||
} catch (parseError) {
|
||
console.error(`Failed to parse config file at ${CONFIG_FILE}`);
|
||
console.error("Error details:", (parseError as Error).message);
|
||
console.error("Please check your config file syntax.");
|
||
process.exit(1);
|
||
}
|
||
} catch (readError: any) {
|
||
if (readError.code === "ENOENT") {
|
||
// Config file doesn't exist, prompt user for initial setup
|
||
try {
|
||
// Initialize directories
|
||
await initDir();
|
||
|
||
// Backup existing config file if it exists
|
||
const backupPath = await backupConfigFile();
|
||
if (backupPath) {
|
||
console.log(
|
||
`Backed up existing configuration file to ${backupPath}`
|
||
);
|
||
}
|
||
const config = {
|
||
PORT: 3456,
|
||
Providers: [],
|
||
Router: {},
|
||
}
|
||
// Create a minimal default config file
|
||
await writeConfigFile(config);
|
||
console.log(
|
||
"Created minimal default configuration file at ~/.claude-code-router/config.json"
|
||
);
|
||
console.log(
|
||
"Please edit this file with your actual configuration."
|
||
);
|
||
return config
|
||
} catch (error: any) {
|
||
console.error(
|
||
"Failed to create default configuration:",
|
||
error.message
|
||
);
|
||
process.exit(1);
|
||
}
|
||
} else {
|
||
console.error(`Failed to read config file at ${CONFIG_FILE}`);
|
||
console.error("Error details:", readError.message);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
};
|
||
|
||
export const backupConfigFile = async () => {
|
||
try {
|
||
if (await fs.access(CONFIG_FILE).then(() => true).catch(() => false)) {
|
||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||
const backupPath = `${CONFIG_FILE}.${timestamp}.bak`;
|
||
await fs.copyFile(CONFIG_FILE, backupPath);
|
||
|
||
// Clean up old backups, keeping only the 3 most recent
|
||
try {
|
||
const configDir = path.dirname(CONFIG_FILE);
|
||
const configFileName = path.basename(CONFIG_FILE);
|
||
const files = await fs.readdir(configDir);
|
||
|
||
// Find all backup files for this config
|
||
const backupFiles = files
|
||
.filter(file => file.startsWith(configFileName) && file.endsWith('.bak'))
|
||
.sort()
|
||
.reverse(); // Sort in descending order (newest first)
|
||
|
||
// Delete all but the 3 most recent backups
|
||
if (backupFiles.length > 3) {
|
||
for (let i = 3; i < backupFiles.length; i++) {
|
||
const oldBackupPath = path.join(configDir, backupFiles[i]);
|
||
await fs.unlink(oldBackupPath);
|
||
}
|
||
}
|
||
} catch (cleanupError) {
|
||
console.warn("Failed to clean up old backups:", cleanupError);
|
||
}
|
||
|
||
return backupPath;
|
||
}
|
||
} catch (error) {
|
||
console.error("Failed to backup config file:", error);
|
||
}
|
||
return null;
|
||
};
|
||
|
||
export const writeConfigFile = async (config: any) => {
|
||
await ensureDir(HOME_DIR);
|
||
const configWithComment = `${JSON.stringify(config, null, 2)}`;
|
||
await fs.writeFile(CONFIG_FILE, configWithComment);
|
||
};
|
||
|
||
export const initConfig = async () => {
|
||
const config = await readConfigFile();
|
||
Object.assign(process.env, config);
|
||
return config;
|
||
};
|
||
|
||
export const run = async (args: string[] = []) => {
|
||
const isRunning = isServiceRunning()
|
||
if (isRunning) {
|
||
console.log('claude-code-router server is running');
|
||
return;
|
||
}
|
||
const server = await getServer();
|
||
const app = server.app;
|
||
// Save the PID of the background process
|
||
writeFileSync(PID_FILE, process.pid.toString());
|
||
|
||
app.post('/api/update/perform', async () => {
|
||
return await performUpdate();
|
||
})
|
||
|
||
app.get('/api/update/check', async () => {
|
||
return await checkForUpdates(version);
|
||
})
|
||
|
||
app.post("/api/restart", async () => {
|
||
setTimeout(async () => {
|
||
spawn("ccr", ["restart"], {
|
||
detached: true,
|
||
stdio: "ignore",
|
||
}).unref();
|
||
}, 100);
|
||
|
||
return { success: true, message: "Service restart initiated" }
|
||
});
|
||
|
||
// await server.start() to ensure it starts successfully and keep process alive
|
||
await server.start();
|
||
}
|
||
|
||
export const restartService = async () => {
|
||
// Stop the service if it's running
|
||
try {
|
||
const pid = parseInt(readFileSync(PID_FILE, "utf-8"));
|
||
process.kill(pid);
|
||
cleanupPidFile();
|
||
if (existsSync(REFERENCE_COUNT_FILE)) {
|
||
try {
|
||
await fs.unlink(REFERENCE_COUNT_FILE);
|
||
} catch (e) {
|
||
// Ignore cleanup errors
|
||
}
|
||
}
|
||
console.log("claude code router service has been stopped.");
|
||
} catch (e) {
|
||
console.log("Service was not running or failed to stop.");
|
||
cleanupPidFile();
|
||
}
|
||
|
||
// Start the service again in the background
|
||
console.log("Starting claude code router service...");
|
||
const cliPath = path.join(__dirname, "../cli.js");
|
||
const startProcess = spawn("node", [cliPath, "start"], {
|
||
detached: true,
|
||
stdio: "ignore",
|
||
});
|
||
|
||
startProcess.on("error", (error) => {
|
||
console.error("Failed to start service:", error);
|
||
throw error;
|
||
});
|
||
|
||
startProcess.unref();
|
||
console.log("✅ Service started successfully in the background.");
|
||
};
|
||
|
||
|
||
/**
|
||
* 获取设置文件的临时路径
|
||
* 对内容进行 hash,如果临时目录中已存在该 hash 的文件则直接返回,否则创建新文件
|
||
* @param content 设置内容字符串
|
||
* @returns 临时文件的完整路径
|
||
*/
|
||
export const getSettingsPath = (content: string): string => {
|
||
// 对内容进行 hash(使用 SHA256 算法)
|
||
const hash = createHash('sha256').update(content, 'utf-8').digest('hex');
|
||
|
||
const fileName = `ccr-settings-${hash}.json`;
|
||
const tempFilePath = path.join(HOME_DIR, fileName);
|
||
|
||
// 检查文件是否已存在
|
||
if (existsSync(tempFilePath)) {
|
||
return tempFilePath;
|
||
}
|
||
|
||
// 文件不存在,创建并写入内容
|
||
writeFileSync(tempFilePath, content, 'utf-8');
|
||
|
||
return tempFilePath;
|
||
}
|