@timo972/cc-router 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/Dockerfile +42 -0
  3. package/LICENSE +21 -0
  4. package/README.md +716 -0
  5. package/accounts.example.json +25 -0
  6. package/dist/cli/cmd-accounts.js +248 -0
  7. package/dist/cli/cmd-client.js +612 -0
  8. package/dist/cli/cmd-configure.js +145 -0
  9. package/dist/cli/cmd-docker.js +140 -0
  10. package/dist/cli/cmd-logs.js +85 -0
  11. package/dist/cli/cmd-models.js +125 -0
  12. package/dist/cli/cmd-service.js +193 -0
  13. package/dist/cli/cmd-setup.js +501 -0
  14. package/dist/cli/cmd-start.js +318 -0
  15. package/dist/cli/cmd-status.js +177 -0
  16. package/dist/cli/cmd-stop.js +100 -0
  17. package/dist/cli/cmd-telemetry.js +58 -0
  18. package/dist/cli/cmd-update.js +37 -0
  19. package/dist/cli/index.js +59 -0
  20. package/dist/config/manager.js +262 -0
  21. package/dist/config/paths.js +21 -0
  22. package/dist/config/telemetry.js +64 -0
  23. package/dist/daemon/launcher.js +163 -0
  24. package/dist/daemon/pid.js +98 -0
  25. package/dist/daemon/service.js +260 -0
  26. package/dist/interceptor/mitmproxy-manager.js +616 -0
  27. package/dist/protocol/anthropic-to-openai.js +51 -0
  28. package/dist/protocol/anthropic-types.js +1 -0
  29. package/dist/protocol/model-ref.js +36 -0
  30. package/dist/protocol/model-routing-config.js +30 -0
  31. package/dist/protocol/openai-response-to-anthropic.js +20 -0
  32. package/dist/protocol/openai-responses-types.js +1 -0
  33. package/dist/protocol/openai-stream-to-anthropic.js +75 -0
  34. package/dist/protocol/openai-to-anthropic.js +61 -0
  35. package/dist/protocol/sse.js +17 -0
  36. package/dist/providers/model-discovery.js +71 -0
  37. package/dist/providers/openai/account-pool.js +11 -0
  38. package/dist/providers/openai/account-record.js +33 -0
  39. package/dist/providers/openai/codex-transport.js +36 -0
  40. package/dist/providers/openai/device-oauth.js +116 -0
  41. package/dist/providers/openai/token-refresher.js +56 -0
  42. package/dist/providers/route-selector.js +8 -0
  43. package/dist/providers/types.js +1 -0
  44. package/dist/proxy/account-deletion.js +44 -0
  45. package/dist/proxy/anthropic-proxy.js +26 -0
  46. package/dist/proxy/anthropic-routing.js +90 -0
  47. package/dist/proxy/lease-lifecycle.js +68 -0
  48. package/dist/proxy/logger.js +39 -0
  49. package/dist/proxy/messages-cross-route.js +179 -0
  50. package/dist/proxy/models-server.js +150 -0
  51. package/dist/proxy/provider-routing.js +14 -0
  52. package/dist/proxy/responses-server.js +91 -0
  53. package/dist/proxy/server.js +875 -0
  54. package/dist/proxy/session-router.js +171 -0
  55. package/dist/proxy/stats.js +25 -0
  56. package/dist/proxy/stream-lifecycle.js +83 -0
  57. package/dist/proxy/token-pool.js +407 -0
  58. package/dist/proxy/token-refresher.js +209 -0
  59. package/dist/proxy/types.js +29 -0
  60. package/dist/ui/Dashboard.js +640 -0
  61. package/dist/ui/accountsApi.js +48 -0
  62. package/dist/ui/modelsApi.js +47 -0
  63. package/dist/utils/claude-config.js +185 -0
  64. package/dist/utils/codex-config.js +62 -0
  65. package/dist/utils/network.js +16 -0
  66. package/dist/utils/platform.js +13 -0
  67. package/dist/utils/self-update.js +239 -0
  68. package/dist/utils/telemetry.js +88 -0
  69. package/dist/utils/token-extractor.js +95 -0
  70. package/dist/utils/token-validator.js +26 -0
  71. package/docker-compose.yml +63 -0
  72. package/litellm-config.yaml +44 -0
  73. package/package.json +69 -0
  74. package/src/interceptor/addon.py +78 -0
@@ -0,0 +1,37 @@
1
+ import chalk from "chalk";
2
+ import { checkForUpdate, performUpdate, getCurrentVersion, PKG_NAME } from "../utils/self-update.js";
3
+ export function registerUpdate(program) {
4
+ program
5
+ .command("update")
6
+ .description("Check for updates and install the latest version")
7
+ .option("--check", "Only check, don't install")
8
+ .action(async (opts) => {
9
+ console.log(chalk.gray(`Current version: v${getCurrentVersion()}\n`));
10
+ const check = await checkForUpdate(true);
11
+ if (!check.updateAvailable) {
12
+ console.log(chalk.green("✓ Already on the latest version."));
13
+ return;
14
+ }
15
+ console.log(chalk.cyan("New version available: ") +
16
+ chalk.gray(`v${check.current}`) +
17
+ chalk.cyan(" → ") +
18
+ chalk.green.bold(`v${check.latest}`) +
19
+ chalk.gray(` (${check.diff})`));
20
+ if (opts.check)
21
+ return;
22
+ if (check.diff === "major") {
23
+ console.log(chalk.yellow("\n⚠ Major version update — may contain breaking changes."));
24
+ console.log(chalk.yellow(` Install manually: npm i -g ${PKG_NAME}@${check.latest}`));
25
+ return;
26
+ }
27
+ const ok = await performUpdate(check.latest);
28
+ if (!ok) {
29
+ console.log(chalk.red("\nUpdate failed. Try manually:"));
30
+ console.log(chalk.cyan(` npm i -g ${PKG_NAME}@${check.latest}`));
31
+ return;
32
+ }
33
+ console.log(chalk.green("\n✓ Update complete."));
34
+ console.log(chalk.gray(" Restart the proxy to use the new version:"));
35
+ console.log(chalk.cyan(" cc-router stop && cc-router start"));
36
+ });
37
+ }
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { registerSetup } from "./cmd-setup.js";
4
+ import { registerStart } from "./cmd-start.js";
5
+ import { registerStop, registerRevert } from "./cmd-stop.js";
6
+ import { registerStatus } from "./cmd-status.js";
7
+ import { registerAccounts } from "./cmd-accounts.js";
8
+ import { registerConfigure } from "./cmd-configure.js";
9
+ import { registerDocker } from "./cmd-docker.js";
10
+ import { registerUpdate } from "./cmd-update.js";
11
+ import { registerClient } from "./cmd-client.js";
12
+ import { registerTelemetry } from "./cmd-telemetry.js";
13
+ import { registerLogs } from "./cmd-logs.js";
14
+ import { registerModels } from "./cmd-models.js";
15
+ import { getCurrentVersion, checkForUpdate, printUpdateBanner } from "../utils/self-update.js";
16
+ const program = new Command();
17
+ program
18
+ .name("cc-router")
19
+ .description("Round-robin proxy for Claude Max OAuth tokens.\n" +
20
+ "Distributes Claude Code requests across multiple Claude Max accounts.")
21
+ .version(getCurrentVersion())
22
+ .addHelpText("after", `
23
+ Examples:
24
+ $ cc-router setup # First-time wizard: extract tokens + configure Claude Code
25
+ $ cc-router start # Start proxy (asks preferences on first run, then remembers)
26
+ $ cc-router start --foreground # Start in foreground (this terminal)
27
+ $ cc-router start --reconfigure# Re-ask run preferences
28
+ $ cc-router stop # Stop proxy (offers to remove auto-start / config)
29
+ $ cc-router status # Live dashboard with account stats
30
+ $ cc-router models list # List dynamically discovered provider models
31
+ $ cc-router logs # View proxy logs (background mode)
32
+ $ cc-router accounts list # Show all configured accounts
33
+ $ cc-router revert # Restore Claude Code to normal (remove all proxy config)
34
+ $ cc-router docker up # Full stack: cc-router + LiteLLM in Docker
35
+ $ cc-router client connect <url> # Route Claude Code through a remote CC-Router
36
+ `);
37
+ registerSetup(program);
38
+ registerStart(program);
39
+ registerStop(program);
40
+ registerRevert(program);
41
+ registerStatus(program);
42
+ registerModels(program);
43
+ registerAccounts(program);
44
+ registerConfigure(program);
45
+ registerDocker(program);
46
+ registerUpdate(program);
47
+ registerClient(program);
48
+ registerTelemetry(program);
49
+ registerLogs(program);
50
+ // Background update check — fires on every CLI invocation, uses 6h disk cache
51
+ // so it's essentially free after the first check. Notify on process exit.
52
+ if (!process.env["NO_UPDATE_NOTIFIER"] && !process.env["CI"]) {
53
+ checkForUpdate().then((check) => {
54
+ if (check.updateAvailable) {
55
+ process.on("exit", () => printUpdateBanner(check));
56
+ }
57
+ }).catch(() => { });
58
+ }
59
+ program.parse();
@@ -0,0 +1,262 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, copyFileSync, chmodSync } from "fs";
2
+ import { randomBytes } from "crypto";
3
+ import { CONFIG_DIR, ACCOUNTS_PATH, CONFIG_PATH } from "./paths.js";
4
+ import { DEFAULT_RATE_LIMITS, ACCOUNT_USER_DEFAULTS, clampPercent } from "../proxy/types.js";
5
+ export const DEFAULT_PROXY_REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
6
+ /** Owner-only permissions for files/dirs that hold OAuth tokens or the proxy secret. */
7
+ const SECRET_FILE_MODE = 0o600;
8
+ const SECRET_DIR_MODE = 0o700;
9
+ export function ensureConfigDir() {
10
+ if (!existsSync(CONFIG_DIR)) {
11
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: SECRET_DIR_MODE });
12
+ return;
13
+ }
14
+ // Tighten an existing dir that may predate this hardening. No-op on Windows.
15
+ try {
16
+ chmodSync(CONFIG_DIR, SECRET_DIR_MODE);
17
+ }
18
+ catch { /* best effort */ }
19
+ }
20
+ /**
21
+ * Atomic + private write for credential files: write tmp as 0600 (umask can
22
+ * clear bits, so chmod defensively), then rename. rename preserves the source
23
+ * inode's mode, so the destination ends up 0600 even if it previously existed
24
+ * world-readable. On Windows `mode` is largely ignored; the file lives under
25
+ * the user profile and is protected by the profile ACL.
26
+ */
27
+ function writeFileSecureSync(path, data) {
28
+ const tmp = path + ".tmp";
29
+ writeFileSync(tmp, data, { encoding: "utf-8", mode: SECRET_FILE_MODE });
30
+ try {
31
+ chmodSync(tmp, SECRET_FILE_MODE);
32
+ }
33
+ catch { /* best effort */ }
34
+ renameSync(tmp, path);
35
+ try {
36
+ chmodSync(path, SECRET_FILE_MODE);
37
+ }
38
+ catch { /* best effort */ }
39
+ }
40
+ export function accountsFileExists(path) {
41
+ return existsSync(path ?? ACCOUNTS_PATH);
42
+ }
43
+ export function readAccountsRaw() {
44
+ return readRawFromPath(ACCOUNTS_PATH);
45
+ }
46
+ function readRawFromPath(path) {
47
+ if (!existsSync(path))
48
+ return [];
49
+ try {
50
+ return JSON.parse(readFileSync(path, "utf-8"));
51
+ }
52
+ catch {
53
+ return [];
54
+ }
55
+ }
56
+ /** Deserialize Account[] from an explicit file path */
57
+ export function readAccountsFromPath(path) {
58
+ return deserialize(readRawFromPath(path));
59
+ }
60
+ // Escritura atómica: escribe a .tmp y renombra — evita JSON corrupto si el proceso muere mid-write
61
+ export function writeAccountsAtomic(data) {
62
+ ensureConfigDir();
63
+ writeAccountsAtomicToPath(ACCOUNTS_PATH, data);
64
+ }
65
+ function writeAccountsAtomicToPath(path, data) {
66
+ // accounts.json holds plaintext OAuth access + refresh tokens — owner-only.
67
+ writeFileSecureSync(path, JSON.stringify(data, null, 2));
68
+ }
69
+ export function writeAnthropicAccountsPreservingOtherProviders(data) {
70
+ ensureConfigDir();
71
+ const existing = readAccountsRaw();
72
+ const nonAnthropic = existing.filter(a => a.provider !== undefined && a.provider !== "anthropic_subscription");
73
+ writeAccountsAtomicToPath(ACCOUNTS_PATH, [...data, ...nonAnthropic]);
74
+ }
75
+ export function upsertAccountRecord(record) {
76
+ ensureConfigDir();
77
+ const existing = readAccountsRaw();
78
+ const next = [
79
+ ...existing.filter(a => !(a.id === record.id && a.provider === record.provider)),
80
+ record,
81
+ ];
82
+ writeAccountsAtomicToPath(ACCOUNTS_PATH, next);
83
+ }
84
+ export function removeAccountRecordById(id) {
85
+ ensureConfigDir();
86
+ const existing = readAccountsRaw();
87
+ const removed = existing.find(a => a.id === id) ?? null;
88
+ if (!removed)
89
+ return null;
90
+ writeAccountsAtomicToPath(ACCOUNTS_PATH, existing.filter(a => a.id !== id));
91
+ return removed;
92
+ }
93
+ function normalizeAccountProvider(record) {
94
+ return record.provider === "openai_subscription"
95
+ ? "openai_subscription"
96
+ : "anthropic_subscription";
97
+ }
98
+ export function migrateLegacyAccountProviders(path = ACCOUNTS_PATH) {
99
+ const records = readRawFromPath(path);
100
+ let changed = false;
101
+ const migrated = records.map(record => {
102
+ if (record.provider !== undefined)
103
+ return record;
104
+ changed = true;
105
+ return { ...record, provider: "anthropic_subscription" };
106
+ });
107
+ if (changed)
108
+ writeAccountsAtomicToPath(path, migrated);
109
+ return changed;
110
+ }
111
+ export function setProviderAccountsEnabled(provider, enabled, path = ACCOUNTS_PATH) {
112
+ const records = readRawFromPath(path);
113
+ let changed = 0;
114
+ const next = records.map(record => {
115
+ if (normalizeAccountProvider(record) !== provider)
116
+ return record;
117
+ changed++;
118
+ return { ...record, provider: normalizeAccountProvider(record), enabled };
119
+ });
120
+ if (changed > 0)
121
+ writeAccountsAtomicToPath(path, next);
122
+ return changed;
123
+ }
124
+ /** Deserialize flat AccountRecord[] from the default path into runtime Account[] */
125
+ export function loadAccounts() {
126
+ return deserialize(readAccountsRaw());
127
+ }
128
+ /** Load OpenAI ChatGPT/Codex subscription accounts without mixing them into the Anthropic pool. */
129
+ export function loadOpenAIAccounts(path) {
130
+ const records = readRawFromPath(path ?? ACCOUNTS_PATH);
131
+ return records
132
+ .filter(a => a.provider === "openai_subscription")
133
+ .map(a => ({
134
+ id: a.id,
135
+ provider: "openai_subscription",
136
+ accessToken: a.accessToken,
137
+ refreshToken: a.refreshToken,
138
+ expiresAt: a.expiresAt,
139
+ enabled: a.enabled !== false,
140
+ }));
141
+ }
142
+ export function saveOpenAIAccounts(accounts) {
143
+ ensureConfigDir();
144
+ const existing = readAccountsRaw();
145
+ const nonOpenAI = existing.filter(a => a.provider !== "openai_subscription");
146
+ const records = accounts.map(a => ({
147
+ id: a.id,
148
+ provider: "openai_subscription",
149
+ accessToken: a.accessToken,
150
+ refreshToken: a.refreshToken,
151
+ expiresAt: a.expiresAt,
152
+ scopes: ["openid", "profile", "email", "offline_access"],
153
+ enabled: a.enabled,
154
+ }));
155
+ writeAccountsAtomicToPath(ACCOUNTS_PATH, [...nonOpenAI, ...records]);
156
+ }
157
+ function parseProxyConfig(raw) {
158
+ const parsed = JSON.parse(raw);
159
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
160
+ throw new TypeError(`${CONFIG_PATH} must contain a JSON object`);
161
+ }
162
+ return parsed;
163
+ }
164
+ /**
165
+ * Read config for a read-modify-write operation.
166
+ *
167
+ * Unlike readConfig(), this deliberately propagates read and parse failures so
168
+ * callers cannot replace an unreadable or malformed user config with defaults.
169
+ */
170
+ export function readConfigStrict() {
171
+ try {
172
+ return parseProxyConfig(readFileSync(CONFIG_PATH, "utf-8"));
173
+ }
174
+ catch (err) {
175
+ if (err.code === "ENOENT")
176
+ return {};
177
+ throw err;
178
+ }
179
+ }
180
+ export function readConfig() {
181
+ if (!existsSync(CONFIG_PATH))
182
+ return {};
183
+ try {
184
+ return readConfigStrict();
185
+ }
186
+ catch (err) {
187
+ console.warn(`Warning: ${CONFIG_PATH} contains invalid JSON: ${err.message}`);
188
+ try {
189
+ const backupPath = CONFIG_PATH + ".bak";
190
+ copyFileSync(CONFIG_PATH, backupPath);
191
+ console.warn(` Backup saved to ${backupPath}`);
192
+ }
193
+ catch { /* best-effort backup */ }
194
+ console.warn(` Using default configuration for this session.`);
195
+ return {};
196
+ }
197
+ }
198
+ export function getProxyRequestTimeoutMs() {
199
+ const { proxyRequestTimeoutMs, proxyRequesTime } = readConfig();
200
+ const timeoutMs = proxyRequestTimeoutMs ?? proxyRequesTime;
201
+ return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0
202
+ ? timeoutMs
203
+ : DEFAULT_PROXY_REQUEST_TIMEOUT_MS;
204
+ }
205
+ function normalizeProxyConfig(cfg) {
206
+ const { proxyRequesTime, ...normalized } = cfg;
207
+ const timeoutMs = normalized.proxyRequestTimeoutMs ?? proxyRequesTime;
208
+ normalized.proxyRequestTimeoutMs =
209
+ typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0
210
+ ? timeoutMs
211
+ : DEFAULT_PROXY_REQUEST_TIMEOUT_MS;
212
+ return normalized;
213
+ }
214
+ export function writeConfig(cfg) {
215
+ ensureConfigDir();
216
+ // config.json holds proxySecret and client.remoteSecret — owner-only.
217
+ writeFileSecureSync(CONFIG_PATH, JSON.stringify(normalizeProxyConfig(cfg), null, 2));
218
+ }
219
+ export function generateProxySecret() {
220
+ return "cc-rtr-" + randomBytes(16).toString("hex");
221
+ }
222
+ // ─── Accounts ─────────────────────────────────────────────────────────────────
223
+ function deserialize(records) {
224
+ return records.filter(a => a.provider === undefined || a.provider === "anthropic_subscription").map(a => ({
225
+ id: a.id,
226
+ tokens: {
227
+ accessToken: a.accessToken,
228
+ refreshToken: a.refreshToken,
229
+ expiresAt: a.expiresAt,
230
+ scopes: a.scopes ?? ["user:inference", "user:profile"],
231
+ },
232
+ healthy: true,
233
+ busy: false,
234
+ requestCount: 0,
235
+ errorCount: 0,
236
+ lastUsed: 0,
237
+ lastRefresh: 0,
238
+ consecutiveErrors: 0,
239
+ rateLimits: { ...DEFAULT_RATE_LIMITS },
240
+ enabled: a.enabled !== false, // default true
241
+ sessionLimitPercent: a.sessionLimitPercent !== undefined
242
+ ? clampPercent(a.sessionLimitPercent)
243
+ : ACCOUNT_USER_DEFAULTS.sessionLimitPercent,
244
+ weeklyLimitPercent: a.weeklyLimitPercent !== undefined
245
+ ? clampPercent(a.weeklyLimitPercent)
246
+ : ACCOUNT_USER_DEFAULTS.weeklyLimitPercent,
247
+ }));
248
+ }
249
+ /** Serialize runtime Account[] back to the flat on-disk AccountRecord[] shape. */
250
+ export function serialize(accounts) {
251
+ return accounts.map(a => ({
252
+ id: a.id,
253
+ provider: "anthropic_subscription",
254
+ accessToken: a.tokens.accessToken,
255
+ refreshToken: a.tokens.refreshToken,
256
+ expiresAt: a.tokens.expiresAt,
257
+ scopes: a.tokens.scopes,
258
+ enabled: a.enabled,
259
+ sessionLimitPercent: a.sessionLimitPercent,
260
+ weeklyLimitPercent: a.weeklyLimitPercent,
261
+ }));
262
+ }
@@ -0,0 +1,21 @@
1
+ import os from "os";
2
+ import path from "path";
3
+ // All paths support env var overrides so Docker can inject them via environment
4
+ export const CONFIG_DIR = path.join(os.homedir(), ".cc-router");
5
+ export const ACCOUNTS_PATH = process.env["ACCOUNTS_PATH"] ??
6
+ path.join(CONFIG_DIR, "accounts.json");
7
+ export const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json");
8
+ export const PROXY_PORT = parseInt(process.env["PORT"] ?? "3456", 10);
9
+ export const LITELLM_PORT = 4000;
10
+ // When set, the server forwards to LiteLLM instead of Anthropic directly
11
+ export const LITELLM_URL = process.env["LITELLM_URL"];
12
+ // Proxy-level config (password, future settings) — separate from accounts.json
13
+ export const CONFIG_PATH = process.env["CONFIG_PATH"] ??
14
+ path.join(CONFIG_DIR, "config.json");
15
+ // Anonymous telemetry state — install id + opt-in flag
16
+ export const TELEMETRY_PATH = process.env["TELEMETRY_PATH"] ??
17
+ path.join(CONFIG_DIR, "telemetry.json");
18
+ // Daemon PID file — written by daemon process, read by stop/status
19
+ export const PID_PATH = path.join(CONFIG_DIR, "cc-router.pid");
20
+ // Log file — daemon stdout/stderr redirect here
21
+ export const LOG_PATH = path.join(CONFIG_DIR, "cc-router.log");
@@ -0,0 +1,64 @@
1
+ import { existsSync, readFileSync, writeFileSync, renameSync } from "fs";
2
+ import { randomUUID } from "crypto";
3
+ import { TELEMETRY_PATH } from "./paths.js";
4
+ import { ensureConfigDir } from "./manager.js";
5
+ function defaultState() {
6
+ return {
7
+ // Opt-in: telemetry stays off until the user explicitly enables it with
8
+ // `cc-router telemetry on`. Nothing is sent on first run.
9
+ enabled: false,
10
+ installId: randomUUID(),
11
+ firstRunAt: new Date().toISOString(),
12
+ };
13
+ }
14
+ // Read the telemetry state, creating and persisting a fresh one on first run.
15
+ // Malformed files are treated as missing so a corrupted file can't crash the CLI.
16
+ export function loadTelemetryState() {
17
+ if (!existsSync(TELEMETRY_PATH)) {
18
+ const state = defaultState();
19
+ writeTelemetryState(state);
20
+ return state;
21
+ }
22
+ try {
23
+ const raw = JSON.parse(readFileSync(TELEMETRY_PATH, "utf-8"));
24
+ // Fill any missing fields to keep the file forward-compatible. Missing
25
+ // `enabled` defaults to OFF (opt-in).
26
+ const state = {
27
+ enabled: raw.enabled ?? false,
28
+ installId: raw.installId ?? randomUUID(),
29
+ firstRunAt: raw.firstRunAt ?? new Date().toISOString(),
30
+ };
31
+ if (!raw.installId) {
32
+ writeTelemetryState(state);
33
+ }
34
+ return state;
35
+ }
36
+ catch {
37
+ const state = defaultState();
38
+ writeTelemetryState(state);
39
+ return state;
40
+ }
41
+ }
42
+ // Atomic write: .tmp + rename, same pattern as writeAccountsAtomic
43
+ export function writeTelemetryState(state) {
44
+ ensureConfigDir();
45
+ const tmp = TELEMETRY_PATH + ".tmp";
46
+ writeFileSync(tmp, JSON.stringify(state, null, 2), "utf-8");
47
+ renameSync(tmp, TELEMETRY_PATH);
48
+ }
49
+ // Returns true only if the user has not opted out through any mechanism:
50
+ // - DO_NOT_TRACK=1 (de-facto standard)
51
+ // - CC_ROUTER_TELEMETRY=0 (project-specific override)
52
+ // - `cc-router telemetry off` (persisted enabled: false)
53
+ export function isTelemetryEnabled() {
54
+ if (process.env["DO_NOT_TRACK"] === "1")
55
+ return false;
56
+ if (process.env["CC_ROUTER_TELEMETRY"] === "0")
57
+ return false;
58
+ try {
59
+ return loadTelemetryState().enabled;
60
+ }
61
+ catch {
62
+ return false;
63
+ }
64
+ }
@@ -0,0 +1,163 @@
1
+ import { spawn } from "child_process";
2
+ import { openSync, closeSync } from "fs";
3
+ import { fileURLToPath } from "url";
4
+ import { dirname, join } from "path";
5
+ import chalk from "chalk";
6
+ import { LOG_PATH, PROXY_PORT } from "../config/paths.js";
7
+ import { ensureConfigDir } from "../config/manager.js";
8
+ import { writePid, getRunningPid, isProcessAlive, removePid, isProxyRunning } from "./pid.js";
9
+ import { isWindows } from "../utils/platform.js";
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = dirname(__filename);
12
+ const CLI_ENTRY = join(__dirname, "..", "cli", "index.js");
13
+ /** Launch cc-router as a detached background process. */
14
+ export async function launchDaemon(opts = {}) {
15
+ const port = opts.port ?? PROXY_PORT;
16
+ // Already running?
17
+ if (await isProxyRunning(port)) {
18
+ console.log(chalk.green(`✓ CC-Router is already running on port ${port}`));
19
+ console.log(chalk.gray(` Logs: cc-router logs | Stop: cc-router stop`));
20
+ return true;
21
+ }
22
+ ensureConfigDir();
23
+ // Build args
24
+ const args = [CLI_ENTRY, "start", "--foreground", "--port", String(port)];
25
+ if (opts.litellmUrl)
26
+ args.push("--litellm", opts.litellmUrl);
27
+ if (opts.accountsPath)
28
+ args.push("--accounts", opts.accountsPath);
29
+ // Build env
30
+ const env = { ...process.env, CC_ROUTER_DAEMON: "1" };
31
+ if (opts.serverMode)
32
+ env["HOST"] = "0.0.0.0";
33
+ // Open log file (append mode) for stdout+stderr redirection
34
+ let logFd;
35
+ try {
36
+ logFd = openSync(LOG_PATH, "a");
37
+ }
38
+ catch (err) {
39
+ console.error(chalk.red(`✗ Cannot open log file: ${LOG_PATH}`));
40
+ console.error(chalk.gray(` ${err.message}`));
41
+ return false;
42
+ }
43
+ const child = spawn(process.execPath, args, {
44
+ detached: true,
45
+ stdio: ["ignore", logFd, logFd],
46
+ env,
47
+ windowsHide: true,
48
+ });
49
+ if (!child.pid) {
50
+ console.error(chalk.red("✗ Failed to start background process"));
51
+ return false;
52
+ }
53
+ writePid(child.pid);
54
+ child.unref();
55
+ closeSync(logFd);
56
+ // Wait for health endpoint to respond
57
+ console.log(chalk.gray(" Starting CC-Router in background..."));
58
+ const healthy = await waitForHealth(port, 5_000);
59
+ if (healthy) {
60
+ console.log(chalk.green(`✓ CC-Router running in background on port ${port}`));
61
+ return true;
62
+ }
63
+ else {
64
+ console.log(chalk.yellow(`⚠ Process started (PID ${child.pid}) but not yet responding.`));
65
+ console.log(chalk.gray(` Check logs: cc-router logs`));
66
+ return false;
67
+ }
68
+ }
69
+ /** Stop the cc-router daemon process. */
70
+ export async function stopDaemon(port = PROXY_PORT) {
71
+ // Try PID-based stop first
72
+ const pid = getRunningPid();
73
+ if (pid !== null) {
74
+ try {
75
+ process.kill(pid, "SIGTERM");
76
+ }
77
+ catch {
78
+ // Already dead
79
+ removePid();
80
+ return true;
81
+ }
82
+ // Wait for graceful shutdown (up to 5s)
83
+ const died = await waitForDeath(pid, 5_000);
84
+ if (!died) {
85
+ // Force kill
86
+ try {
87
+ process.kill(pid, "SIGKILL");
88
+ }
89
+ catch { /* already dead */ }
90
+ // Verify it actually died
91
+ await new Promise(r => setTimeout(r, 500));
92
+ if (isProcessAlive(pid)) {
93
+ console.log(chalk.yellow(` ⚠ Could not kill process ${pid}`));
94
+ return false; // don't remove PID file — process is still alive
95
+ }
96
+ }
97
+ removePid();
98
+ return true;
99
+ }
100
+ // Fallback: kill by port (handles foreground processes or legacy PM2)
101
+ return killByPort(port);
102
+ }
103
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
104
+ async function waitForHealth(port, timeoutMs) {
105
+ const start = Date.now();
106
+ while (Date.now() - start < timeoutMs) {
107
+ try {
108
+ const res = await fetch(`http://localhost:${port}/cc-router/health`, {
109
+ signal: AbortSignal.timeout(500),
110
+ });
111
+ if (res.ok)
112
+ return true;
113
+ }
114
+ catch { /* not ready yet */ }
115
+ await sleep(300);
116
+ }
117
+ return false;
118
+ }
119
+ async function waitForDeath(pid, timeoutMs) {
120
+ const start = Date.now();
121
+ while (Date.now() - start < timeoutMs) {
122
+ if (!isProcessAlive(pid))
123
+ return true;
124
+ await sleep(200);
125
+ }
126
+ return false;
127
+ }
128
+ async function killByPort(port) {
129
+ const { execFile } = await import("child_process");
130
+ const { promisify } = await import("util");
131
+ const execFileAsync = promisify(execFile);
132
+ try {
133
+ if (isWindows()) {
134
+ const { stdout } = await execFileAsync("netstat", ["-ano"]);
135
+ const match = stdout
136
+ .split("\n")
137
+ .find(line => line.includes(`:${port}`) && line.includes("LISTENING"));
138
+ if (!match)
139
+ return false;
140
+ const pid = match.trim().split(/\s+/).at(-1);
141
+ if (!pid || isNaN(Number(pid)))
142
+ return false;
143
+ await execFileAsync("taskkill", ["/PID", pid, "/F"]);
144
+ return true;
145
+ }
146
+ else {
147
+ const { stdout } = await execFileAsync("lsof", ["-ti", `:${port}`]);
148
+ const pids = stdout.trim().split("\n").filter(Boolean);
149
+ if (pids.length === 0)
150
+ return false;
151
+ for (const p of pids) {
152
+ await execFileAsync("kill", ["-TERM", p]);
153
+ }
154
+ return true;
155
+ }
156
+ }
157
+ catch {
158
+ return false;
159
+ }
160
+ }
161
+ function sleep(ms) {
162
+ return new Promise(r => setTimeout(r, ms));
163
+ }