@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.
- package/CHANGELOG.md +96 -0
- package/Dockerfile +42 -0
- package/LICENSE +21 -0
- package/README.md +716 -0
- package/accounts.example.json +25 -0
- package/dist/cli/cmd-accounts.js +248 -0
- package/dist/cli/cmd-client.js +612 -0
- package/dist/cli/cmd-configure.js +145 -0
- package/dist/cli/cmd-docker.js +140 -0
- package/dist/cli/cmd-logs.js +85 -0
- package/dist/cli/cmd-models.js +125 -0
- package/dist/cli/cmd-service.js +193 -0
- package/dist/cli/cmd-setup.js +501 -0
- package/dist/cli/cmd-start.js +318 -0
- package/dist/cli/cmd-status.js +177 -0
- package/dist/cli/cmd-stop.js +100 -0
- package/dist/cli/cmd-telemetry.js +58 -0
- package/dist/cli/cmd-update.js +37 -0
- package/dist/cli/index.js +59 -0
- package/dist/config/manager.js +262 -0
- package/dist/config/paths.js +21 -0
- package/dist/config/telemetry.js +64 -0
- package/dist/daemon/launcher.js +163 -0
- package/dist/daemon/pid.js +98 -0
- package/dist/daemon/service.js +260 -0
- package/dist/interceptor/mitmproxy-manager.js +616 -0
- package/dist/protocol/anthropic-to-openai.js +51 -0
- package/dist/protocol/anthropic-types.js +1 -0
- package/dist/protocol/model-ref.js +36 -0
- package/dist/protocol/model-routing-config.js +30 -0
- package/dist/protocol/openai-response-to-anthropic.js +20 -0
- package/dist/protocol/openai-responses-types.js +1 -0
- package/dist/protocol/openai-stream-to-anthropic.js +75 -0
- package/dist/protocol/openai-to-anthropic.js +61 -0
- package/dist/protocol/sse.js +17 -0
- package/dist/providers/model-discovery.js +71 -0
- package/dist/providers/openai/account-pool.js +11 -0
- package/dist/providers/openai/account-record.js +33 -0
- package/dist/providers/openai/codex-transport.js +36 -0
- package/dist/providers/openai/device-oauth.js +116 -0
- package/dist/providers/openai/token-refresher.js +56 -0
- package/dist/providers/route-selector.js +8 -0
- package/dist/providers/types.js +1 -0
- package/dist/proxy/account-deletion.js +44 -0
- package/dist/proxy/anthropic-proxy.js +26 -0
- package/dist/proxy/anthropic-routing.js +90 -0
- package/dist/proxy/lease-lifecycle.js +68 -0
- package/dist/proxy/logger.js +39 -0
- package/dist/proxy/messages-cross-route.js +179 -0
- package/dist/proxy/models-server.js +150 -0
- package/dist/proxy/provider-routing.js +14 -0
- package/dist/proxy/responses-server.js +91 -0
- package/dist/proxy/server.js +875 -0
- package/dist/proxy/session-router.js +171 -0
- package/dist/proxy/stats.js +25 -0
- package/dist/proxy/stream-lifecycle.js +83 -0
- package/dist/proxy/token-pool.js +407 -0
- package/dist/proxy/token-refresher.js +209 -0
- package/dist/proxy/types.js +29 -0
- package/dist/ui/Dashboard.js +640 -0
- package/dist/ui/accountsApi.js +48 -0
- package/dist/ui/modelsApi.js +47 -0
- package/dist/utils/claude-config.js +185 -0
- package/dist/utils/codex-config.js +62 -0
- package/dist/utils/network.js +16 -0
- package/dist/utils/platform.js +13 -0
- package/dist/utils/self-update.js +239 -0
- package/dist/utils/telemetry.js +88 -0
- package/dist/utils/token-extractor.js +95 -0
- package/dist/utils/token-validator.js +26 -0
- package/docker-compose.yml +63 -0
- package/litellm-config.yaml +44 -0
- package/package.json +69 -0
- package/src/interceptor/addon.py +78 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny authenticated HTTP client for /cc-router/accounts.
|
|
3
|
+
*
|
|
4
|
+
* Used by the Ink dashboard to mutate account settings (enable/disable,
|
|
5
|
+
* set per-account caps, delete) without exiting the TUI. The `addAccount`
|
|
6
|
+
* flow is NOT in here — that runs inquirer and must exit Ink first; see
|
|
7
|
+
* src/cli/cmd-status.ts `runAddAccountFlow`.
|
|
8
|
+
*/
|
|
9
|
+
const REQUEST_TIMEOUT_MS = 3_000;
|
|
10
|
+
export function createAccountsApi(baseUrl, authToken) {
|
|
11
|
+
const base = baseUrl.replace(/\/+$/, "") + "/cc-router/accounts";
|
|
12
|
+
const authHeaders = authToken
|
|
13
|
+
? { authorization: `Bearer ${authToken}` }
|
|
14
|
+
: {};
|
|
15
|
+
async function send(method, path, body) {
|
|
16
|
+
const res = await fetch(base + path, {
|
|
17
|
+
method,
|
|
18
|
+
headers: {
|
|
19
|
+
...authHeaders,
|
|
20
|
+
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
|
21
|
+
},
|
|
22
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
23
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
24
|
+
});
|
|
25
|
+
if (!res.ok) {
|
|
26
|
+
// Try to surface the server's error message if we can read one
|
|
27
|
+
let detail = "";
|
|
28
|
+
try {
|
|
29
|
+
const data = await res.json();
|
|
30
|
+
if (data?.error)
|
|
31
|
+
detail = `: ${data.error}`;
|
|
32
|
+
}
|
|
33
|
+
catch { /* best effort */ }
|
|
34
|
+
throw new Error(`HTTP ${res.status}${detail}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
patch(id, patch) {
|
|
39
|
+
return send("PATCH", `/${encodeURIComponent(id)}`, patch);
|
|
40
|
+
},
|
|
41
|
+
setProviderEnabled(provider, enabled) {
|
|
42
|
+
return send("PATCH", `/providers/${encodeURIComponent(provider)}`, { enabled });
|
|
43
|
+
},
|
|
44
|
+
remove(id) {
|
|
45
|
+
return send("DELETE", `/${encodeURIComponent(id)}`);
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authenticated HTTP client for /cc-router/models.
|
|
3
|
+
*
|
|
4
|
+
* Used by the dashboard so local and remote client mode can inspect discovered
|
|
5
|
+
* provider models and change router defaults through the same management API.
|
|
6
|
+
*/
|
|
7
|
+
const REQUEST_TIMEOUT_MS = 5_000;
|
|
8
|
+
export function createModelsApi(baseUrl, authToken) {
|
|
9
|
+
const endpoint = baseUrl.replace(/\/+$/, "") + "/cc-router/models";
|
|
10
|
+
const authHeaders = authToken
|
|
11
|
+
? { authorization: `Bearer ${authToken}` }
|
|
12
|
+
: {};
|
|
13
|
+
async function send(method, body) {
|
|
14
|
+
const res = await fetch(endpoint, {
|
|
15
|
+
method,
|
|
16
|
+
headers: {
|
|
17
|
+
...authHeaders,
|
|
18
|
+
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
|
19
|
+
},
|
|
20
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
21
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
22
|
+
});
|
|
23
|
+
if (!res.ok) {
|
|
24
|
+
let detail = "";
|
|
25
|
+
try {
|
|
26
|
+
const data = await res.json();
|
|
27
|
+
if (data?.error)
|
|
28
|
+
detail = `: ${data.error}`;
|
|
29
|
+
}
|
|
30
|
+
catch { /* best effort */ }
|
|
31
|
+
throw new Error(`HTTP ${res.status}${detail}`);
|
|
32
|
+
}
|
|
33
|
+
const data = await res.json();
|
|
34
|
+
return {
|
|
35
|
+
routing: data.routing ?? {},
|
|
36
|
+
models: data.models ?? [],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
list() {
|
|
41
|
+
return send("GET");
|
|
42
|
+
},
|
|
43
|
+
setDefaults(patch) {
|
|
44
|
+
return send("PATCH", patch);
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
2
|
+
import { dirname } from "path";
|
|
3
|
+
import { CLAUDE_SETTINGS_PATH } from "../config/paths.js";
|
|
4
|
+
import { readConfigStrict, writeConfig } from "../config/manager.js";
|
|
5
|
+
const MANAGED_STREAM_IDLE_TIMEOUT_MS = "1800000";
|
|
6
|
+
const MANAGED_STREAM_ENV_KEYS = [
|
|
7
|
+
"CLAUDE_STREAM_IDLE_TIMEOUT_MS",
|
|
8
|
+
"CLAUDE_BYTE_STREAM_IDLE_TIMEOUT_MS",
|
|
9
|
+
];
|
|
10
|
+
function captureClaudeEnvBackup(env) {
|
|
11
|
+
const capture = (key) => {
|
|
12
|
+
const value = env[key];
|
|
13
|
+
return typeof value === "string" ? { existed: true, value } : { existed: false };
|
|
14
|
+
};
|
|
15
|
+
return {
|
|
16
|
+
CLAUDE_STREAM_IDLE_TIMEOUT_MS: capture("CLAUDE_STREAM_IDLE_TIMEOUT_MS"),
|
|
17
|
+
CLAUDE_BYTE_STREAM_IDLE_TIMEOUT_MS: capture("CLAUDE_BYTE_STREAM_IDLE_TIMEOUT_MS"),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function isRecord(value) {
|
|
21
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
22
|
+
}
|
|
23
|
+
function validateClaudeEnvBackup(config) {
|
|
24
|
+
const raw = config["claudeEnvBackup"];
|
|
25
|
+
if (raw === undefined)
|
|
26
|
+
return undefined;
|
|
27
|
+
if (!isRecord(raw)) {
|
|
28
|
+
throw new TypeError("claudeEnvBackup must contain an object");
|
|
29
|
+
}
|
|
30
|
+
for (const key of MANAGED_STREAM_ENV_KEYS) {
|
|
31
|
+
const entry = raw[key];
|
|
32
|
+
if (!isRecord(entry) || typeof entry["existed"] !== "boolean") {
|
|
33
|
+
throw new TypeError(`claudeEnvBackup.${key} must contain a boolean existed field`);
|
|
34
|
+
}
|
|
35
|
+
if (entry["existed"] === true && typeof entry["value"] !== "string") {
|
|
36
|
+
throw new TypeError(`claudeEnvBackup.${key}.value must be a string when it existed`);
|
|
37
|
+
}
|
|
38
|
+
if (entry["existed"] === false && Object.hasOwn(entry, "value")) {
|
|
39
|
+
throw new TypeError(`claudeEnvBackup.${key}.value must be absent when it did not exist`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return raw;
|
|
43
|
+
}
|
|
44
|
+
function clearClaudeEnvBackup() {
|
|
45
|
+
const config = readConfigStrict();
|
|
46
|
+
if (!validateClaudeEnvBackup(config))
|
|
47
|
+
return;
|
|
48
|
+
const { claudeEnvBackup: _removed, ...rest } = config;
|
|
49
|
+
writeConfig(rest);
|
|
50
|
+
}
|
|
51
|
+
function parseClaudeSettings(raw) {
|
|
52
|
+
const parsed = JSON.parse(raw);
|
|
53
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
54
|
+
throw new TypeError(`${CLAUDE_SETTINGS_PATH} must contain a JSON object`);
|
|
55
|
+
}
|
|
56
|
+
const settings = parsed;
|
|
57
|
+
const env = settings["env"];
|
|
58
|
+
if (env !== undefined && !isRecord(env)) {
|
|
59
|
+
throw new TypeError(`${CLAUDE_SETTINGS_PATH} env must contain a JSON object`);
|
|
60
|
+
}
|
|
61
|
+
return settings;
|
|
62
|
+
}
|
|
63
|
+
function readClaudeSettingsIfPresent() {
|
|
64
|
+
try {
|
|
65
|
+
return parseClaudeSettings(readFileSync(CLAUDE_SETTINGS_PATH, "utf-8"));
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
if (err.code === "ENOENT")
|
|
69
|
+
return undefined;
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Write ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN into ~/.claude/settings.json.
|
|
75
|
+
*
|
|
76
|
+
* Rules from official Claude Code docs:
|
|
77
|
+
* - ANTHROPIC_AUTH_TOKEN is sent as "Authorization: Bearer <value>"
|
|
78
|
+
* - Do NOT append /v1 to ANTHROPIC_BASE_URL — Claude Code adds it automatically
|
|
79
|
+
* - Merges with existing settings, preserving all other keys
|
|
80
|
+
*/
|
|
81
|
+
/**
|
|
82
|
+
* @param port - proxy port (used only when baseUrl is not provided)
|
|
83
|
+
* @param baseUrl - full proxy URL e.g. "http://192.168.1.50:3456" or "https://cc-router.example.com"
|
|
84
|
+
* If omitted, defaults to http://localhost:<port>
|
|
85
|
+
* @param authToken - explicit auth token; when omitted, reads proxySecret from config or uses "proxy-managed"
|
|
86
|
+
* @param defaultModel - optional Claude Code model, e.g. "openai/default"
|
|
87
|
+
*/
|
|
88
|
+
export function writeClaudeSettings(port, baseUrl, authToken, defaultModel) {
|
|
89
|
+
const existing = readClaudeSettingsIfPresent() ?? {};
|
|
90
|
+
const existingEnv = existing["env"] ?? {};
|
|
91
|
+
const config = readConfigStrict();
|
|
92
|
+
const backup = validateClaudeEnvBackup(config);
|
|
93
|
+
// Do not create directories or write either user file until all existing
|
|
94
|
+
// settings and backup state have been read and validated successfully.
|
|
95
|
+
mkdirSync(dirname(CLAUDE_SETTINGS_PATH), { recursive: true });
|
|
96
|
+
if (!backup) {
|
|
97
|
+
config.claudeEnvBackup = captureClaudeEnvBackup(existingEnv);
|
|
98
|
+
writeConfig(config);
|
|
99
|
+
}
|
|
100
|
+
// ANTHROPIC_BASE_URL: no trailing /v1 — Claude Code appends it automatically
|
|
101
|
+
const resolvedUrl = baseUrl ?? `http://localhost:${port}`;
|
|
102
|
+
const updated = {
|
|
103
|
+
...existing,
|
|
104
|
+
...(defaultModel ? { model: defaultModel } : {}),
|
|
105
|
+
env: {
|
|
106
|
+
...existingEnv,
|
|
107
|
+
ANTHROPIC_BASE_URL: resolvedUrl,
|
|
108
|
+
// ANTHROPIC_AUTH_TOKEN has higher precedence than ANTHROPIC_API_KEY in Claude Code.
|
|
109
|
+
// Explicit authToken wins (client mode points at a remote secret); otherwise
|
|
110
|
+
// uses the local proxy secret, or the open placeholder if neither is set.
|
|
111
|
+
ANTHROPIC_AUTH_TOKEN: authToken ?? config.proxySecret ?? "proxy-managed",
|
|
112
|
+
CLAUDE_STREAM_IDLE_TIMEOUT_MS: MANAGED_STREAM_IDLE_TIMEOUT_MS,
|
|
113
|
+
CLAUDE_BYTE_STREAM_IDLE_TIMEOUT_MS: MANAGED_STREAM_IDLE_TIMEOUT_MS,
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(updated, null, 2), "utf-8");
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Remove cc-router settings from ~/.claude/settings.json.
|
|
120
|
+
* Called when uninstalling cc-router so Claude Code goes back to its default auth.
|
|
121
|
+
*/
|
|
122
|
+
export function removeClaudeSettings() {
|
|
123
|
+
const config = readConfigStrict();
|
|
124
|
+
const backup = validateClaudeEnvBackup(config);
|
|
125
|
+
let rawSettings;
|
|
126
|
+
try {
|
|
127
|
+
rawSettings = readFileSync(CLAUDE_SETTINGS_PATH, "utf-8");
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
if (err.code !== "ENOENT")
|
|
131
|
+
throw err;
|
|
132
|
+
clearClaudeEnvBackup();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
let existing;
|
|
136
|
+
try {
|
|
137
|
+
existing = parseClaudeSettings(rawSettings);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// Malformed settings are user-owned. Leave both the file and backup intact.
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const env = existing["env"];
|
|
144
|
+
if (env) {
|
|
145
|
+
delete env["ANTHROPIC_BASE_URL"];
|
|
146
|
+
delete env["ANTHROPIC_AUTH_TOKEN"];
|
|
147
|
+
if (backup) {
|
|
148
|
+
for (const key of MANAGED_STREAM_ENV_KEYS) {
|
|
149
|
+
if (env[key] !== MANAGED_STREAM_IDLE_TIMEOUT_MS)
|
|
150
|
+
continue;
|
|
151
|
+
const previous = backup[key];
|
|
152
|
+
if (previous.existed && previous.value !== undefined) {
|
|
153
|
+
env[key] = previous.value;
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
delete env[key];
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (Object.keys(env).length === 0)
|
|
161
|
+
delete existing["env"];
|
|
162
|
+
}
|
|
163
|
+
// Persist settings first. If this fails, keep the backup for a retry.
|
|
164
|
+
writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(existing, null, 2), "utf-8");
|
|
165
|
+
clearClaudeEnvBackup();
|
|
166
|
+
}
|
|
167
|
+
/** Read current Claude Code proxy settings (for display) */
|
|
168
|
+
export function readClaudeProxySettings() {
|
|
169
|
+
if (!existsSync(CLAUDE_SETTINGS_PATH))
|
|
170
|
+
return {};
|
|
171
|
+
try {
|
|
172
|
+
const raw = JSON.parse(readFileSync(CLAUDE_SETTINGS_PATH, "utf-8"));
|
|
173
|
+
const env = raw["env"];
|
|
174
|
+
if (!env)
|
|
175
|
+
return {};
|
|
176
|
+
return {
|
|
177
|
+
baseUrl: env?.["ANTHROPIC_BASE_URL"],
|
|
178
|
+
authToken: env?.["ANTHROPIC_AUTH_TOKEN"],
|
|
179
|
+
model: raw["model"],
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return {};
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import os from "os";
|
|
4
|
+
const START = "# cc-router:start";
|
|
5
|
+
const END = "# cc-router:end";
|
|
6
|
+
export function codexBaseUrlFromRouterUrl(remoteUrl) {
|
|
7
|
+
const base = remoteUrl.trim().replace(/\/+$/, "");
|
|
8
|
+
return base.endsWith("/v1") ? base : `${base}/v1`;
|
|
9
|
+
}
|
|
10
|
+
function managedBlock(baseUrl, tokenEnvKey, defaultModel) {
|
|
11
|
+
return [
|
|
12
|
+
START,
|
|
13
|
+
...(defaultModel ? [`model = "${defaultModel}"`] : []),
|
|
14
|
+
"model_provider = \"cc-router\"",
|
|
15
|
+
"",
|
|
16
|
+
"[model_providers.cc-router]",
|
|
17
|
+
"name = \"CC-Router\"",
|
|
18
|
+
`base_url = "${baseUrl}"`,
|
|
19
|
+
"wire_api = \"responses\"",
|
|
20
|
+
`env_key = "${tokenEnvKey}"`,
|
|
21
|
+
END,
|
|
22
|
+
].join("\n");
|
|
23
|
+
}
|
|
24
|
+
function replaceManagedBlock(existing, block) {
|
|
25
|
+
const start = existing.indexOf(START);
|
|
26
|
+
const end = existing.indexOf(END);
|
|
27
|
+
if (start >= 0 && end >= start) {
|
|
28
|
+
const before = existing.slice(0, start).trimEnd();
|
|
29
|
+
const after = existing.slice(end + END.length).trimStart();
|
|
30
|
+
return [before, block, after].filter(Boolean).join("\n\n") + "\n";
|
|
31
|
+
}
|
|
32
|
+
return [existing.trimEnd(), block].filter(Boolean).join("\n\n") + "\n";
|
|
33
|
+
}
|
|
34
|
+
export function writeCodexRouterConfig(opts) {
|
|
35
|
+
const fs = opts.fs ?? { existsSync, readFileSync, writeFileSync, mkdirSync };
|
|
36
|
+
const homeDir = opts.homeDir ?? os.homedir();
|
|
37
|
+
const codexDir = join(homeDir, ".codex");
|
|
38
|
+
const configPath = join(codexDir, "config.toml");
|
|
39
|
+
const tokenEnvKey = opts.tokenEnvKey ?? "CC_ROUTER_TOKEN";
|
|
40
|
+
if (!fs.existsSync(codexDir))
|
|
41
|
+
fs.mkdirSync(codexDir, { recursive: true });
|
|
42
|
+
const existing = fs.existsSync(configPath)
|
|
43
|
+
? fs.readFileSync(configPath, "utf-8")
|
|
44
|
+
: "";
|
|
45
|
+
const next = replaceManagedBlock(existing, managedBlock(opts.baseUrl, tokenEnvKey, opts.defaultModel));
|
|
46
|
+
fs.writeFileSync(configPath, next, "utf-8");
|
|
47
|
+
return { path: configPath };
|
|
48
|
+
}
|
|
49
|
+
export function writeCodexRouterConfigFromClient(cfg, opts = {}) {
|
|
50
|
+
if (!cfg.client?.remoteUrl) {
|
|
51
|
+
throw new Error("Client mode is not configured. Run: cc-router client connect <url>");
|
|
52
|
+
}
|
|
53
|
+
const result = writeCodexRouterConfig({
|
|
54
|
+
...opts,
|
|
55
|
+
baseUrl: codexBaseUrlFromRouterUrl(cfg.client.remoteUrl),
|
|
56
|
+
tokenEnvKey: "CC_ROUTER_TOKEN",
|
|
57
|
+
});
|
|
58
|
+
return {
|
|
59
|
+
...result,
|
|
60
|
+
hasSecret: Boolean(cfg.client.remoteSecret),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import os from "os";
|
|
2
|
+
/** Return non-internal IPv4 addresses (useful for printing server mode instructions). */
|
|
3
|
+
export function getLocalIPs() {
|
|
4
|
+
const interfaces = os.networkInterfaces();
|
|
5
|
+
const ips = [];
|
|
6
|
+
for (const addrs of Object.values(interfaces)) {
|
|
7
|
+
if (!addrs)
|
|
8
|
+
continue;
|
|
9
|
+
for (const addr of addrs) {
|
|
10
|
+
if (addr.family === "IPv4" && !addr.internal) {
|
|
11
|
+
ips.push(addr.address);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return ips;
|
|
16
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function detectPlatform() {
|
|
2
|
+
switch (process.platform) {
|
|
3
|
+
case "darwin": return "macos";
|
|
4
|
+
case "win32": return "windows";
|
|
5
|
+
default: return "linux";
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export function isWindows() {
|
|
9
|
+
return process.platform === "win32";
|
|
10
|
+
}
|
|
11
|
+
export function isMacos() {
|
|
12
|
+
return process.platform === "darwin";
|
|
13
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { spawn, execFileSync } from "child_process";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, realpathSync } from "fs";
|
|
3
|
+
import { join, resolve } from "path";
|
|
4
|
+
import { createRequire } from "module";
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
import { CONFIG_DIR } from "../config/paths.js";
|
|
7
|
+
import { removePid } from "../daemon/pid.js";
|
|
8
|
+
// Single source of truth for the published package name: CLI hints that tell a
|
|
9
|
+
// user what to install must never drift from what self-update actually pulls.
|
|
10
|
+
export const PKG_NAME = "@timo972/cc-router";
|
|
11
|
+
const REGISTRY_URL = `https://registry.npmjs.org/${PKG_NAME}/latest`;
|
|
12
|
+
const CHECK_CACHE_PATH = join(CONFIG_DIR, "update-check.json");
|
|
13
|
+
const LAST_GOOD_PATH = join(CONFIG_DIR, "last-good-version.json");
|
|
14
|
+
const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
|
|
15
|
+
// ─── Version helpers ─────────────────────────────────────────────────────────
|
|
16
|
+
// Strict semver. The registry response is untrusted input: it is interpolated
|
|
17
|
+
// into the `npm install @timo972/cc-router@<version>` argument, so any value that is
|
|
18
|
+
// not a clean semver string must be rejected before it reaches a child process.
|
|
19
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
20
|
+
export function isValidVersion(value) {
|
|
21
|
+
return typeof value === "string" && SEMVER_RE.test(value);
|
|
22
|
+
}
|
|
23
|
+
export function getCurrentVersion() {
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const pkg = require("../../package.json");
|
|
26
|
+
return pkg.version;
|
|
27
|
+
}
|
|
28
|
+
/** Simple semver diff: returns "major" | "minor" | "patch" | null */
|
|
29
|
+
function semverDiff(current, latest) {
|
|
30
|
+
const c = current.split(".").map(Number);
|
|
31
|
+
const l = latest.split(".").map(Number);
|
|
32
|
+
if (l[0] > c[0])
|
|
33
|
+
return "major";
|
|
34
|
+
if (l[1] > c[1])
|
|
35
|
+
return "minor";
|
|
36
|
+
if (l[2] > c[2])
|
|
37
|
+
return "patch";
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
function readCache() {
|
|
41
|
+
try {
|
|
42
|
+
if (!existsSync(CHECK_CACHE_PATH))
|
|
43
|
+
return null;
|
|
44
|
+
return JSON.parse(readFileSync(CHECK_CACHE_PATH, "utf-8"));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function writeCache(latest) {
|
|
51
|
+
try {
|
|
52
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
53
|
+
writeFileSync(CHECK_CACHE_PATH, JSON.stringify({ latest, checkedAt: Date.now() }), "utf-8");
|
|
54
|
+
}
|
|
55
|
+
catch { /* non-critical */ }
|
|
56
|
+
}
|
|
57
|
+
/** Check npm registry for a newer version. Uses a 6h disk cache. */
|
|
58
|
+
export async function checkForUpdate(force = false) {
|
|
59
|
+
const current = getCurrentVersion();
|
|
60
|
+
// Use cache if fresh enough
|
|
61
|
+
if (!force) {
|
|
62
|
+
const cached = readCache();
|
|
63
|
+
if (cached && Date.now() - cached.checkedAt < CHECK_INTERVAL_MS) {
|
|
64
|
+
const diff = semverDiff(current, cached.latest);
|
|
65
|
+
return { current, latest: cached.latest, diff, updateAvailable: diff !== null };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Fetch from registry
|
|
69
|
+
try {
|
|
70
|
+
const res = await fetch(REGISTRY_URL, {
|
|
71
|
+
signal: AbortSignal.timeout(5_000),
|
|
72
|
+
headers: { accept: "application/json" },
|
|
73
|
+
});
|
|
74
|
+
if (!res.ok)
|
|
75
|
+
return { current, latest: current, diff: null, updateAvailable: false };
|
|
76
|
+
const data = (await res.json());
|
|
77
|
+
// Reject anything that is not a clean semver string — a malicious or
|
|
78
|
+
// compromised registry response must never flow into the install command.
|
|
79
|
+
if (!isValidVersion(data.version)) {
|
|
80
|
+
return { current, latest: current, diff: null, updateAvailable: false };
|
|
81
|
+
}
|
|
82
|
+
writeCache(data.version);
|
|
83
|
+
const diff = semverDiff(current, data.version);
|
|
84
|
+
return { current, latest: data.version, diff, updateAvailable: diff !== null };
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return { current, latest: current, diff: null, updateAvailable: false };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// ─── Install prefix detection ────────────────────────────────────────────────
|
|
91
|
+
// Detect from process.argv[1] (the actual script), NOT from `npm config get prefix`
|
|
92
|
+
// which can return a wrong path under nvm/volta/fnm.
|
|
93
|
+
function detectInstallPrefix() {
|
|
94
|
+
try {
|
|
95
|
+
const scriptPath = realpathSync(process.argv[1]);
|
|
96
|
+
// scriptPath is like: /prefix/lib/node_modules/@timo972/cc-router/dist/cli/index.js
|
|
97
|
+
// We need to walk up to the prefix root. join() normalizes the scope
|
|
98
|
+
// separator, so the marker matches on Windows backslash paths too.
|
|
99
|
+
const marker = join("node_modules", PKG_NAME);
|
|
100
|
+
const idx = scriptPath.indexOf(marker);
|
|
101
|
+
if (idx !== -1) {
|
|
102
|
+
// Walk up from .../lib/node_modules/@timo972/cc-router → .../
|
|
103
|
+
const libDir = scriptPath.slice(0, idx); // .../lib/
|
|
104
|
+
return resolve(libDir, "..");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch { /* fallback */ }
|
|
108
|
+
// Fallback: ask npm (less reliable but better than nothing)
|
|
109
|
+
try {
|
|
110
|
+
return execFileSync("npm", ["config", "get", "prefix"], { encoding: "utf-8" }).trim();
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return "/usr/local";
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// ─── Perform update ──────────────────────────────────────────────────────────
|
|
117
|
+
export async function performUpdate(targetVersion) {
|
|
118
|
+
// Defense in depth: never build an install command from an unvalidated
|
|
119
|
+
// version, even if a caller bypassed checkForUpdate.
|
|
120
|
+
if (!isValidVersion(targetVersion)) {
|
|
121
|
+
console.error(chalk.red(`✗ Refusing to update: "${targetVersion}" is not a valid version`));
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
const prefix = detectInstallPrefix();
|
|
125
|
+
console.log(chalk.cyan(`\nUpdating ${PKG_NAME} to v${targetVersion}...`));
|
|
126
|
+
console.log(chalk.gray(` prefix: ${prefix}`));
|
|
127
|
+
return new Promise((resolve) => {
|
|
128
|
+
// No shell: pass argv directly. On Windows npm is a .cmd shim, so invoke it
|
|
129
|
+
// by name without shell:true (which would re-parse the joined string through
|
|
130
|
+
// cmd.exe and turn the version arg into a command-injection sink).
|
|
131
|
+
const npmBin = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
132
|
+
const child = spawn(npmBin, ["install", "-g", `${PKG_NAME}@${targetVersion}`, `--prefix=${prefix}`], { stdio: "inherit" });
|
|
133
|
+
child.on("error", (err) => {
|
|
134
|
+
console.error(chalk.red(`✗ Update failed: ${err.message}`));
|
|
135
|
+
resolve(false);
|
|
136
|
+
});
|
|
137
|
+
child.on("exit", (code) => {
|
|
138
|
+
if (code === 0) {
|
|
139
|
+
console.log(chalk.green(`✓ Updated to v${targetVersion}`));
|
|
140
|
+
writeLastGood(targetVersion);
|
|
141
|
+
resolve(true);
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
console.error(chalk.red(`✗ npm install exited with code ${code}`));
|
|
145
|
+
resolve(false);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
// ─── Restart ─────────────────────────────────────────────────────────────────
|
|
151
|
+
/** Detect if running under an OS-level service manager or PM2. */
|
|
152
|
+
function isRunningAsService() {
|
|
153
|
+
// Custom env var set by our own service definitions (launchd/systemd)
|
|
154
|
+
if (process.env["CC_ROUTER_SERVICE"] === "1")
|
|
155
|
+
return true;
|
|
156
|
+
// PM2 (legacy — users who haven't migrated yet)
|
|
157
|
+
if (process.env["PM2_HOME"] || process.env["pm_id"])
|
|
158
|
+
return true;
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Restart the process.
|
|
163
|
+
* - Under a service manager (launchd/systemd/PM2) → exit and let the manager restart us.
|
|
164
|
+
* - Standalone daemon → spawn a replacement, then exit.
|
|
165
|
+
* - Foreground → spawn a replacement, then exit.
|
|
166
|
+
*/
|
|
167
|
+
export function restartSelf() {
|
|
168
|
+
if (isRunningAsService()) {
|
|
169
|
+
console.log(chalk.gray("Restarting via service manager..."));
|
|
170
|
+
// Clean up PID file before exit
|
|
171
|
+
removePid();
|
|
172
|
+
// Exit with non-zero so KeepAlive/Restart=on-failure triggers a restart.
|
|
173
|
+
// Note: exit(1) is intentional — launchd's SuccessfulExit=false and
|
|
174
|
+
// systemd's Restart=on-failure both require a non-zero exit to restart.
|
|
175
|
+
// A cleaner approach (custom exit code or env-based signal) would avoid
|
|
176
|
+
// polluting logs with "FAILURE" entries, but requires service file changes.
|
|
177
|
+
process.exit(1);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
console.log(chalk.gray("Restarting..."));
|
|
181
|
+
const child = spawn(process.execPath, process.argv.slice(1), {
|
|
182
|
+
detached: true,
|
|
183
|
+
stdio: "ignore",
|
|
184
|
+
env: { ...process.env, CC_ROUTER_UPDATED: "1" },
|
|
185
|
+
});
|
|
186
|
+
child.unref();
|
|
187
|
+
process.exit(0);
|
|
188
|
+
}
|
|
189
|
+
// ─── Last good version (rollback safety) ─────────────────────────────────────
|
|
190
|
+
function writeLastGood(version) {
|
|
191
|
+
try {
|
|
192
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
193
|
+
writeFileSync(LAST_GOOD_PATH, JSON.stringify({ version, ts: Date.now() }), "utf-8");
|
|
194
|
+
}
|
|
195
|
+
catch { /* non-critical */ }
|
|
196
|
+
}
|
|
197
|
+
export function getLastGoodVersion() {
|
|
198
|
+
try {
|
|
199
|
+
if (!existsSync(LAST_GOOD_PATH))
|
|
200
|
+
return null;
|
|
201
|
+
const data = JSON.parse(readFileSync(LAST_GOOD_PATH, "utf-8"));
|
|
202
|
+
return data.version;
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// ─── High-level: check + update + restart ────────────────────────────────────
|
|
209
|
+
/** Background auto-update check. Only patches/minor. Returns true if update was started. */
|
|
210
|
+
export async function autoUpdateIfAvailable() {
|
|
211
|
+
const check = await checkForUpdate();
|
|
212
|
+
if (!check.updateAvailable || check.diff === "major")
|
|
213
|
+
return false;
|
|
214
|
+
console.log(chalk.cyan(`\nNew version available: v${check.current} → v${check.latest}`));
|
|
215
|
+
const ok = await performUpdate(check.latest);
|
|
216
|
+
if (ok) {
|
|
217
|
+
restartSelf();
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
// ─── Notification banner (for interactive CLI) ──────────────────────────────
|
|
223
|
+
export function printUpdateBanner(check) {
|
|
224
|
+
if (!check.updateAvailable)
|
|
225
|
+
return;
|
|
226
|
+
const border = "─".repeat(50);
|
|
227
|
+
console.log();
|
|
228
|
+
console.log(chalk.yellow(border));
|
|
229
|
+
console.log(chalk.yellow(" Update available: ") +
|
|
230
|
+
chalk.gray(`v${check.current}`) +
|
|
231
|
+
chalk.yellow(" → ") +
|
|
232
|
+
chalk.green.bold(`v${check.latest}`));
|
|
233
|
+
console.log(chalk.yellow(" Run: ") +
|
|
234
|
+
chalk.cyan("cc-router update") +
|
|
235
|
+
chalk.yellow(" or ") +
|
|
236
|
+
chalk.cyan(`npm i -g ${PKG_NAME}@${check.latest}`));
|
|
237
|
+
console.log(chalk.yellow(border));
|
|
238
|
+
console.log();
|
|
239
|
+
}
|