ai-cc-router 0.1.3 → 0.1.5
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/dist/cli/cmd-configure.js +43 -1
- package/dist/cli/cmd-setup.js +280 -40
- package/dist/config/manager.js +22 -1
- package/dist/config/paths.js +3 -0
- package/dist/proxy/server.js +69 -8
- package/dist/proxy/stats.js +1 -1
- package/dist/ui/Dashboard.js +73 -7
- package/dist/utils/claude-config.js +12 -5
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { writeClaudeSettings, removeClaudeSettings, readClaudeProxySettings } from "../utils/claude-config.js";
|
|
3
|
+
import { readConfig, writeConfig, generateProxySecret } from "../config/manager.js";
|
|
3
4
|
import { PROXY_PORT, CLAUDE_SETTINGS_PATH } from "../config/paths.js";
|
|
4
5
|
export function registerConfigure(program) {
|
|
5
6
|
program
|
|
@@ -8,6 +9,9 @@ export function registerConfigure(program) {
|
|
|
8
9
|
.option("--remove", "Remove cc-router settings from ~/.claude/settings.json")
|
|
9
10
|
.option("--port <port>", "Proxy port to configure", String(PROXY_PORT))
|
|
10
11
|
.option("--show", "Show current Claude Code proxy settings")
|
|
12
|
+
.option("--generate-password", "Generate a new proxy secret and sync Claude Code settings")
|
|
13
|
+
.option("--set-password <secret>", "Set a specific proxy secret and sync Claude Code settings")
|
|
14
|
+
.option("--remove-password", "Remove proxy password protection (open access)")
|
|
11
15
|
.action((opts) => {
|
|
12
16
|
if (opts.show) {
|
|
13
17
|
const current = readClaudeProxySettings();
|
|
@@ -20,6 +24,9 @@ export function registerConfigure(program) {
|
|
|
20
24
|
console.log(chalk.yellow(" Claude Code is NOT configured to use cc-router."));
|
|
21
25
|
console.log(chalk.gray(` Run: cc-router configure`));
|
|
22
26
|
}
|
|
27
|
+
const { proxySecret } = readConfig();
|
|
28
|
+
const pwStatus = proxySecret ? chalk.green("yes") : chalk.gray("no");
|
|
29
|
+
console.log(` Password protected: ${pwStatus}`);
|
|
23
30
|
return;
|
|
24
31
|
}
|
|
25
32
|
if (opts.remove) {
|
|
@@ -28,10 +35,45 @@ export function registerConfigure(program) {
|
|
|
28
35
|
console.log(chalk.gray(" Claude Code will use its default authentication on next launch."));
|
|
29
36
|
return;
|
|
30
37
|
}
|
|
38
|
+
if (opts.generatePassword) {
|
|
39
|
+
const secret = generateProxySecret();
|
|
40
|
+
writeConfig({ ...readConfig(), proxySecret: secret });
|
|
41
|
+
const { baseUrl } = readClaudeProxySettings();
|
|
42
|
+
writeClaudeSettings(parseInt(opts.port, 10), baseUrl);
|
|
43
|
+
console.log(chalk.green("✓ Proxy password set."));
|
|
44
|
+
console.log(" " + chalk.bold.yellow("Save this — it will not be shown again:"));
|
|
45
|
+
console.log(" " + chalk.bold(secret));
|
|
46
|
+
console.log(chalk.gray(" Restart cc-router for the change to take effect."));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (opts.setPassword !== undefined) {
|
|
50
|
+
const secret = opts.setPassword.trim();
|
|
51
|
+
if (!secret) {
|
|
52
|
+
console.error(chalk.red("Secret cannot be empty."));
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
writeConfig({ ...readConfig(), proxySecret: secret });
|
|
56
|
+
const { baseUrl } = readClaudeProxySettings();
|
|
57
|
+
writeClaudeSettings(parseInt(opts.port, 10), baseUrl);
|
|
58
|
+
console.log(chalk.green("✓ Proxy password updated."));
|
|
59
|
+
console.log(chalk.gray(" Restart cc-router for the change to take effect."));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (opts.removePassword) {
|
|
63
|
+
const cfg = readConfig();
|
|
64
|
+
delete cfg.proxySecret;
|
|
65
|
+
writeConfig(cfg);
|
|
66
|
+
const { baseUrl } = readClaudeProxySettings();
|
|
67
|
+
writeClaudeSettings(parseInt(opts.port, 10), baseUrl);
|
|
68
|
+
console.log(chalk.green("✓ Proxy password removed. Access is now open."));
|
|
69
|
+
console.log(chalk.gray(" Restart cc-router for the change to take effect."));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
31
72
|
const port = parseInt(opts.port, 10);
|
|
32
73
|
writeClaudeSettings(port);
|
|
74
|
+
const { proxySecret } = readConfig();
|
|
33
75
|
console.log(chalk.green(`✓ Updated ${CLAUDE_SETTINGS_PATH}`));
|
|
34
76
|
console.log(chalk.gray(` ANTHROPIC_BASE_URL = http://localhost:${port}`));
|
|
35
|
-
console.log(chalk.gray(` ANTHROPIC_AUTH_TOKEN = proxy-managed`));
|
|
77
|
+
console.log(chalk.gray(` ANTHROPIC_AUTH_TOKEN = ${proxySecret ? chalk.green("(secret configured)") : "proxy-managed"}`));
|
|
36
78
|
});
|
|
37
79
|
}
|
package/dist/cli/cmd-setup.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { select, input, confirm, password } from "@inquirer/prompts";
|
|
2
|
+
import { execFile, spawn } from "child_process";
|
|
3
|
+
import { promisify } from "util";
|
|
2
4
|
import chalk from "chalk";
|
|
3
5
|
import { detectPlatform, isMacos } from "../utils/platform.js";
|
|
4
6
|
import { extractFromKeychain, extractFromCredentialsFile, formatExpiry, redactToken, } from "../utils/token-extractor.js";
|
|
5
7
|
import { validateToken } from "../utils/token-validator.js";
|
|
6
|
-
import { writeClaudeSettings } from "../utils/claude-config.js";
|
|
7
|
-
import { saveAccounts
|
|
8
|
-
import { loadAccounts, accountsFileExists } from "../config/manager.js";
|
|
8
|
+
import { writeClaudeSettings, readClaudeProxySettings } from "../utils/claude-config.js";
|
|
9
|
+
import { saveAccounts } from "../proxy/token-refresher.js";
|
|
10
|
+
import { loadAccounts, accountsFileExists, readConfig, writeConfig, generateProxySecret } from "../config/manager.js";
|
|
9
11
|
import { PROXY_PORT } from "../config/paths.js";
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
10
13
|
// ─── Public registration ──────────────────────────────────────────────────────
|
|
11
14
|
export function registerSetup(program) {
|
|
12
15
|
program
|
|
@@ -19,7 +22,6 @@ export function registerSetup(program) {
|
|
|
19
22
|
}
|
|
20
23
|
// ─── Shared single-account setup (also used by `accounts add`) ───────────────
|
|
21
24
|
export async function setupSingleAccount(index) {
|
|
22
|
-
const platform = detectPlatform();
|
|
23
25
|
const choices = [];
|
|
24
26
|
if (isMacos()) {
|
|
25
27
|
choices.push({ name: "Extract automatically from macOS Keychain (recommended)", value: "keychain" });
|
|
@@ -70,14 +72,12 @@ export async function setupSingleAccount(index) {
|
|
|
70
72
|
}
|
|
71
73
|
if (!tokens)
|
|
72
74
|
return null;
|
|
73
|
-
// Ask for account ID
|
|
74
75
|
const defaultId = `max-account-${index}`;
|
|
75
76
|
const accountId = await input({
|
|
76
77
|
message: "Account ID (press Enter to accept default):",
|
|
77
78
|
default: defaultId,
|
|
78
79
|
validate: (v) => /^[a-zA-Z0-9_-]+$/.test(v) || "Only letters, numbers, _ and - allowed",
|
|
79
80
|
});
|
|
80
|
-
// Validate tokens against Anthropic API
|
|
81
81
|
process.stdout.write(chalk.gray(" Validating tokens against Anthropic... "));
|
|
82
82
|
const validation = await validateToken(tokens.accessToken);
|
|
83
83
|
if (validation.valid) {
|
|
@@ -87,10 +87,7 @@ export async function setupSingleAccount(index) {
|
|
|
87
87
|
console.log(chalk.red("✗ Invalid"));
|
|
88
88
|
console.log(chalk.yellow(` Reason: ${validation.reason}`));
|
|
89
89
|
console.log(chalk.gray(" The token will be saved but may not work until refreshed."));
|
|
90
|
-
const keepAnyway = await confirm({
|
|
91
|
-
message: "Save this account anyway?",
|
|
92
|
-
default: false,
|
|
93
|
-
});
|
|
90
|
+
const keepAnyway = await confirm({ message: "Save this account anyway?", default: false });
|
|
94
91
|
if (!keepAnyway)
|
|
95
92
|
return null;
|
|
96
93
|
}
|
|
@@ -112,7 +109,6 @@ async function runSetupWizard({ addMode }) {
|
|
|
112
109
|
const hasExisting = accountsFileExists();
|
|
113
110
|
printBanner();
|
|
114
111
|
console.log(chalk.gray(`Platform: ${platform}\n`));
|
|
115
|
-
// If accounts already exist and we're not in add-mode, ask what to do
|
|
116
112
|
if (hasExisting && !addMode) {
|
|
117
113
|
const existing = loadAccounts();
|
|
118
114
|
console.log(chalk.yellow(` Found ${existing.length} existing account(s).\n`));
|
|
@@ -138,9 +134,7 @@ async function runSetupWizard({ addMode }) {
|
|
|
138
134
|
return;
|
|
139
135
|
}
|
|
140
136
|
}
|
|
141
|
-
// If 'add', we'll merge below
|
|
142
137
|
}
|
|
143
|
-
// Guide for multi-account setup
|
|
144
138
|
if (!addMode && isMacos()) {
|
|
145
139
|
console.log(chalk.cyan(" Tip: to add multiple accounts, you need to:"));
|
|
146
140
|
console.log(chalk.gray(" 1. Log in to Claude Code with account 1 (already done if you use CC normally)"));
|
|
@@ -160,14 +154,14 @@ async function runSetupWizard({ addMode }) {
|
|
|
160
154
|
for (let i = 0; i < numAccounts; i++) {
|
|
161
155
|
const label = numAccounts > 1 ? `${i + 1}/${numAccounts}` : "";
|
|
162
156
|
console.log(chalk.bold(`\n${"━".repeat(40)}\n Account ${label}\n${"━".repeat(40)}\n`));
|
|
163
|
-
// If on macOS and this isn't the first account, remind user to switch accounts
|
|
164
157
|
if (i > 0 && isMacos()) {
|
|
165
158
|
console.log(chalk.yellow(` Before extracting account ${i + 1}:\n` +
|
|
166
159
|
` 1. Run: ${chalk.white("claude logout")}\n` +
|
|
167
160
|
` 2. Run: ${chalk.white("claude login")} (log in with your next Max account)\n`));
|
|
168
161
|
await confirm({ message: "Ready?", default: true });
|
|
169
162
|
}
|
|
170
|
-
const
|
|
163
|
+
const existingCount = hasExisting ? loadAccounts().length : 0;
|
|
164
|
+
const account = await setupSingleAccount(i + 1 + existingCount);
|
|
171
165
|
if (account) {
|
|
172
166
|
newAccounts.push(account);
|
|
173
167
|
console.log(chalk.green(`\n ✓ Account "${account.id}" ready.\n`));
|
|
@@ -180,25 +174,281 @@ async function runSetupWizard({ addMode }) {
|
|
|
180
174
|
console.log(chalk.red("\n✗ No accounts configured. Run cc-router setup again.\n"));
|
|
181
175
|
return;
|
|
182
176
|
}
|
|
183
|
-
// Merge
|
|
184
|
-
const
|
|
185
|
-
const existingIds = new Set(existing.map(a => a.id));
|
|
177
|
+
// Merge: existing accounts minus any overwritten by ID, plus new ones
|
|
178
|
+
const existingAccounts = (hasExisting && !addMode) ? [] : (hasExisting ? loadAccounts() : []);
|
|
186
179
|
const merged = [
|
|
187
|
-
...
|
|
180
|
+
...existingAccounts.filter(a => !newAccounts.some(n => n.id === a.id)),
|
|
188
181
|
...newAccounts,
|
|
189
182
|
];
|
|
190
|
-
console.log(chalk.bold(`\n${"━".repeat(40)}\n Saving
|
|
191
|
-
// Save accounts.json (atomic write)
|
|
183
|
+
console.log(chalk.bold(`\n${"━".repeat(40)}\n Saving\n${"━".repeat(40)}\n`));
|
|
192
184
|
saveAccounts(merged);
|
|
193
|
-
console.log(chalk.green(` ✓
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
console.log(chalk.green(` ✓ Updated ~/.claude/settings.json`));
|
|
197
|
-
console.log(chalk.gray(` ANTHROPIC_BASE_URL = http://localhost:${PROXY_PORT}`));
|
|
198
|
-
console.log(chalk.gray(` ANTHROPIC_AUTH_TOKEN = proxy-managed`));
|
|
199
|
-
printNextSteps(merged.length);
|
|
185
|
+
console.log(chalk.green(` ✓ ${merged.length} account(s) saved to ~/.cc-router/accounts.json`));
|
|
186
|
+
// ─── Post-setup interactive flow ─────────────────────────────────────────
|
|
187
|
+
await runPostSetupFlow(merged.length);
|
|
200
188
|
}
|
|
201
|
-
// ───
|
|
189
|
+
// ─── Post-setup interactive flow ─────────────────────────────────────────────
|
|
190
|
+
async function runPostSetupFlow(accountCount) {
|
|
191
|
+
console.log(chalk.bold(`\n${"━".repeat(40)}\n Configure this machine\n${"━".repeat(40)}\n`));
|
|
192
|
+
// 1. Configure Claude Code on this machine
|
|
193
|
+
const currentSettings = readClaudeProxySettings();
|
|
194
|
+
const alreadyConfigured = currentSettings.baseUrl?.includes("localhost");
|
|
195
|
+
const configureLocal = await confirm({
|
|
196
|
+
message: alreadyConfigured
|
|
197
|
+
? `Claude Code is already pointing to ${currentSettings.baseUrl}. Reconfigure?`
|
|
198
|
+
: "Configure Claude Code on this machine to use the proxy?",
|
|
199
|
+
default: true,
|
|
200
|
+
});
|
|
201
|
+
if (configureLocal) {
|
|
202
|
+
// Ask if this is a local proxy or a remote one
|
|
203
|
+
const proxyLocation = await select({
|
|
204
|
+
message: "Where will cc-router run?",
|
|
205
|
+
choices: [
|
|
206
|
+
{ name: `On this machine (localhost:${PROXY_PORT})`, value: "local" },
|
|
207
|
+
{ name: "On another machine / VPS (I'll enter the address)", value: "remote" },
|
|
208
|
+
],
|
|
209
|
+
});
|
|
210
|
+
let proxyHost = `http://localhost:${PROXY_PORT}`;
|
|
211
|
+
if (proxyLocation === "remote") {
|
|
212
|
+
const remoteHost = await input({
|
|
213
|
+
message: "Proxy URL (e.g. http://192.168.1.50:3456 or https://cc-router.example.com):",
|
|
214
|
+
validate: (v) => {
|
|
215
|
+
try {
|
|
216
|
+
new URL(v);
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return "Enter a valid URL (http:// or https://)";
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
proxyHost = remoteHost.replace(/\/$/, ""); // strip trailing slash
|
|
225
|
+
}
|
|
226
|
+
const port = proxyLocation === "local"
|
|
227
|
+
? PROXY_PORT
|
|
228
|
+
: parseInt(new URL(proxyHost).port || "80", 10);
|
|
229
|
+
// ── Password setup for remote proxy ───────────────────────────────────────
|
|
230
|
+
if (proxyLocation === "remote") {
|
|
231
|
+
const pwChoice = await select({
|
|
232
|
+
message: "Set a proxy password? (strongly recommended for internet-exposed proxies)",
|
|
233
|
+
choices: [
|
|
234
|
+
{ name: "Generate automatically (recommended)", value: "generate" },
|
|
235
|
+
{ name: "Enter my own password", value: "manual" },
|
|
236
|
+
{ name: "Skip — no password protection", value: "skip" },
|
|
237
|
+
],
|
|
238
|
+
});
|
|
239
|
+
let chosenSecret;
|
|
240
|
+
if (pwChoice === "generate") {
|
|
241
|
+
chosenSecret = generateProxySecret();
|
|
242
|
+
writeConfig({ ...readConfig(), proxySecret: chosenSecret });
|
|
243
|
+
}
|
|
244
|
+
else if (pwChoice === "manual") {
|
|
245
|
+
const raw = await password({
|
|
246
|
+
message: "Enter proxy password:",
|
|
247
|
+
validate: (v) => v.trim().length >= 8 || "Minimum 8 characters",
|
|
248
|
+
});
|
|
249
|
+
chosenSecret = raw.trim();
|
|
250
|
+
writeConfig({ ...readConfig(), proxySecret: chosenSecret });
|
|
251
|
+
}
|
|
252
|
+
writeClaudeSettings(port, proxyHost);
|
|
253
|
+
if (chosenSecret) {
|
|
254
|
+
console.log(chalk.yellow("\n *** Save this password — you cannot recover it later ***"));
|
|
255
|
+
console.log(" " + chalk.bold(chosenSecret));
|
|
256
|
+
console.log(chalk.gray(" Claude Code has been configured to use it automatically."));
|
|
257
|
+
console.log(chalk.gray(" Other machines: cc-router configure --set-password <value>"));
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
console.log(chalk.green(`\n ✓ ~/.claude/settings.json updated`));
|
|
261
|
+
console.log(chalk.gray(` ANTHROPIC_BASE_URL = ${proxyHost}`));
|
|
262
|
+
console.log(chalk.gray(` ANTHROPIC_AUTH_TOKEN = proxy-managed`));
|
|
263
|
+
}
|
|
264
|
+
console.log(chalk.cyan(`\n On the remote machine, start cc-router with:`));
|
|
265
|
+
console.log(chalk.white(` HOST=0.0.0.0 cc-router start`));
|
|
266
|
+
console.log(chalk.cyan(` Or as a service:`));
|
|
267
|
+
console.log(chalk.white(` cc-router service install\n`));
|
|
268
|
+
// Nothing more to do on this machine
|
|
269
|
+
printDone(accountCount);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
writeClaudeSettings(port, proxyHost);
|
|
273
|
+
console.log(chalk.green(`\n ✓ ~/.claude/settings.json updated`));
|
|
274
|
+
console.log(chalk.gray(` ANTHROPIC_BASE_URL = ${proxyHost}`));
|
|
275
|
+
console.log(chalk.gray(` ANTHROPIC_AUTH_TOKEN = proxy-managed`));
|
|
276
|
+
}
|
|
277
|
+
// 2. Only ask about starting the proxy if it's local
|
|
278
|
+
console.log(chalk.bold(`\n${"━".repeat(40)}\n Start the proxy\n${"━".repeat(40)}\n`));
|
|
279
|
+
// Check if it's already running
|
|
280
|
+
const alreadyRunning = await isProxyRunning();
|
|
281
|
+
if (alreadyRunning) {
|
|
282
|
+
console.log(chalk.green(` ✓ Proxy is already running on http://localhost:${PROXY_PORT}`));
|
|
283
|
+
printDone(accountCount);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const startChoice = await select({
|
|
287
|
+
message: "How do you want to run the proxy?",
|
|
288
|
+
choices: [
|
|
289
|
+
{ name: "Install as system service (auto-start on boot — recommended)", value: "service" },
|
|
290
|
+
{ name: "Start in background now (current session only, via PM2)", value: "daemon" },
|
|
291
|
+
{ name: "Start in foreground now (this terminal, Ctrl+C to stop)", value: "foreground" },
|
|
292
|
+
{ name: "I'll start it manually later", value: "skip" },
|
|
293
|
+
],
|
|
294
|
+
});
|
|
295
|
+
if (startChoice === "service") {
|
|
296
|
+
await installService();
|
|
297
|
+
}
|
|
298
|
+
else if (startChoice === "daemon") {
|
|
299
|
+
await startDaemon();
|
|
300
|
+
}
|
|
301
|
+
else if (startChoice === "foreground") {
|
|
302
|
+
printDone(accountCount);
|
|
303
|
+
console.log(chalk.cyan("\nStarting proxy in foreground...\n"));
|
|
304
|
+
// Launch start as child — it blocks until Ctrl+C
|
|
305
|
+
await startForeground();
|
|
306
|
+
return; // startForeground never returns normally
|
|
307
|
+
}
|
|
308
|
+
printDone(accountCount);
|
|
309
|
+
}
|
|
310
|
+
// ─── Proxy launch helpers ─────────────────────────────────────────────────────
|
|
311
|
+
async function isProxyRunning() {
|
|
312
|
+
try {
|
|
313
|
+
const res = await fetch(`http://localhost:${PROXY_PORT}/cc-router/health`, {
|
|
314
|
+
signal: AbortSignal.timeout(800),
|
|
315
|
+
});
|
|
316
|
+
return res.ok;
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
async function installService() {
|
|
323
|
+
console.log(chalk.cyan("\n Installing as system service via PM2..."));
|
|
324
|
+
try {
|
|
325
|
+
// Ensure PM2 is installed
|
|
326
|
+
await execFileAsync("pm2", ["--version"]).catch(async () => {
|
|
327
|
+
console.log(chalk.gray(" Installing PM2..."));
|
|
328
|
+
await execFileAsync("npm", ["install", "-g", "pm2"]);
|
|
329
|
+
});
|
|
330
|
+
const { fileURLToPath } = await import("url");
|
|
331
|
+
const { dirname, join } = await import("path");
|
|
332
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
333
|
+
const cliEntry = join(__dirname, "index.js");
|
|
334
|
+
// Start in PM2
|
|
335
|
+
await execFileAsync("pm2", [
|
|
336
|
+
"start", cliEntry,
|
|
337
|
+
"--name", "cc-router",
|
|
338
|
+
"--interpreter", process.execPath,
|
|
339
|
+
"--max-memory-restart", "500M",
|
|
340
|
+
"--", "start",
|
|
341
|
+
]).catch(async (err) => {
|
|
342
|
+
// Already registered — restart instead
|
|
343
|
+
if (err.message?.includes("already")) {
|
|
344
|
+
await execFileAsync("pm2", ["restart", "cc-router"]);
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
throw err;
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
await execFileAsync("pm2", ["save"]);
|
|
351
|
+
console.log(chalk.green(" ✓ cc-router registered in PM2 and saved"));
|
|
352
|
+
// Generate startup hook
|
|
353
|
+
try {
|
|
354
|
+
const { stdout, stderr } = await execFileAsync("pm2", ["startup"]);
|
|
355
|
+
const combined = stdout + stderr;
|
|
356
|
+
const sudoMatch = combined.match(/sudo\s+\S.+/);
|
|
357
|
+
if (sudoMatch) {
|
|
358
|
+
console.log(chalk.yellow("\n Run this command to complete auto-start setup:"));
|
|
359
|
+
console.log(chalk.white(` ${sudoMatch[0]}`));
|
|
360
|
+
console.log(chalk.gray(" Then run: pm2 save"));
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
console.log(chalk.green(" ✓ Auto-start on boot configured"));
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
catch (err) {
|
|
367
|
+
const combined = (err.stdout ?? "") +
|
|
368
|
+
(err.stderr ?? "");
|
|
369
|
+
const sudoMatch = combined.match(/sudo\s+\S.+/);
|
|
370
|
+
if (sudoMatch) {
|
|
371
|
+
console.log(chalk.yellow("\n Run this command to complete auto-start setup:"));
|
|
372
|
+
console.log(chalk.white(` ${sudoMatch[0]}`));
|
|
373
|
+
console.log(chalk.gray(" Then run: pm2 save"));
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
// Wait a moment and confirm it started
|
|
377
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
378
|
+
const running = await isProxyRunning();
|
|
379
|
+
if (running) {
|
|
380
|
+
console.log(chalk.green(` ✓ Proxy is running on http://localhost:${PROXY_PORT}`));
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
console.log(chalk.yellow(" ⚠ Service registered but proxy not yet responding — it may still be starting."));
|
|
384
|
+
console.log(chalk.gray(" Check: cc-router service status"));
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
catch (err) {
|
|
388
|
+
console.log(chalk.red(` ✗ Service install failed: ${err.message}`));
|
|
389
|
+
console.log(chalk.gray(" Try manually: cc-router service install"));
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
async function startDaemon() {
|
|
393
|
+
console.log(chalk.cyan("\n Starting in background via PM2..."));
|
|
394
|
+
try {
|
|
395
|
+
const { fileURLToPath } = await import("url");
|
|
396
|
+
const { dirname, join } = await import("path");
|
|
397
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
398
|
+
const cliEntry = join(__dirname, "index.js");
|
|
399
|
+
await execFileAsync("pm2", [
|
|
400
|
+
"start", cliEntry,
|
|
401
|
+
"--name", "cc-router",
|
|
402
|
+
"--interpreter", process.execPath,
|
|
403
|
+
"--max-memory-restart", "500M",
|
|
404
|
+
"--", "start",
|
|
405
|
+
]).catch(async (err) => {
|
|
406
|
+
if (err.message?.includes("already")) {
|
|
407
|
+
await execFileAsync("pm2", ["restart", "cc-router"]);
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
throw err;
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
414
|
+
const running = await isProxyRunning();
|
|
415
|
+
if (running) {
|
|
416
|
+
console.log(chalk.green(` ✓ Proxy running in background on http://localhost:${PROXY_PORT}`));
|
|
417
|
+
console.log(chalk.gray(" Logs: pm2 logs cc-router | Stop: cc-router stop"));
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
console.log(chalk.yellow(" ⚠ PM2 registered but proxy not yet responding."));
|
|
421
|
+
console.log(chalk.gray(" Check: pm2 logs cc-router"));
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
catch (err) {
|
|
425
|
+
console.log(chalk.red(` ✗ Failed to start via PM2: ${err.message}`));
|
|
426
|
+
console.log(chalk.gray(" PM2 not installed? Run: npm install -g pm2"));
|
|
427
|
+
console.log(chalk.gray(" Or start manually: cc-router start"));
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
async function startForeground() {
|
|
431
|
+
const { fileURLToPath } = await import("url");
|
|
432
|
+
const { dirname, join } = await import("path");
|
|
433
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
434
|
+
const cliEntry = join(__dirname, "index.js");
|
|
435
|
+
const child = spawn(process.execPath, [cliEntry, "start"], { stdio: "inherit" });
|
|
436
|
+
await new Promise((resolve) => {
|
|
437
|
+
child.on("close", resolve);
|
|
438
|
+
child.on("error", (err) => {
|
|
439
|
+
console.error(chalk.red(` ✗ ${err.message}`));
|
|
440
|
+
resolve();
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
// ─── Done banner ──────────────────────────────────────────────────────────────
|
|
445
|
+
function printDone(accountCount) {
|
|
446
|
+
console.log(chalk.bold(`\n${"━".repeat(40)}\n All done — ${accountCount} account(s) ready\n${"━".repeat(40)}\n`));
|
|
447
|
+
console.log(` Dashboard: ${chalk.cyan("cc-router status")}`);
|
|
448
|
+
console.log(` Add more accounts: ${chalk.cyan("cc-router setup --add")}`);
|
|
449
|
+
console.log(` Stop & revert: ${chalk.cyan("cc-router revert")}\n`);
|
|
450
|
+
}
|
|
451
|
+
// ─── Manual token input ───────────────────────────────────────────────────────
|
|
202
452
|
async function promptManualTokens() {
|
|
203
453
|
console.log(chalk.gray("\n You can find your tokens by running:\n" +
|
|
204
454
|
" macOS: security find-generic-password -s 'Claude Code-credentials' -w\n" +
|
|
@@ -217,16 +467,13 @@ async function promptManualTokens() {
|
|
|
217
467
|
? true
|
|
218
468
|
: "Must start with sk-ant-ort01-",
|
|
219
469
|
});
|
|
220
|
-
// expiresAt is optional — default to 8h from now
|
|
221
470
|
const useDefaultExpiry = await confirm({
|
|
222
471
|
message: "Use default expiry (8 hours from now)?",
|
|
223
472
|
default: true,
|
|
224
473
|
});
|
|
225
474
|
const expiresAt = useDefaultExpiry
|
|
226
475
|
? Date.now() + 8 * 60 * 60 * 1000
|
|
227
|
-
: new Date(await input({
|
|
228
|
-
message: "Paste expiresAt (ISO date or ms timestamp):",
|
|
229
|
-
})).getTime();
|
|
476
|
+
: new Date(await input({ message: "Paste expiresAt (ISO date or ms timestamp):" })).getTime();
|
|
230
477
|
return {
|
|
231
478
|
accessToken,
|
|
232
479
|
refreshToken,
|
|
@@ -239,10 +486,3 @@ function printBanner() {
|
|
|
239
486
|
"║ CC-Router — Setup ║\n" +
|
|
240
487
|
"╚══════════════════════════════════════════╝\n"));
|
|
241
488
|
}
|
|
242
|
-
function printNextSteps(accountCount) {
|
|
243
|
-
console.log(chalk.bold(`\n${"━".repeat(40)}\n Done — ${accountCount} account(s) configured\n${"━".repeat(40)}\n`));
|
|
244
|
-
console.log(` Start proxy: ${chalk.cyan("cc-router start")}`);
|
|
245
|
-
console.log(` Auto-start: ${chalk.cyan("cc-router service install")}`);
|
|
246
|
-
console.log(` Live dashboard: ${chalk.cyan("cc-router status")}`);
|
|
247
|
-
console.log(` Add more accounts: ${chalk.cyan("cc-router setup --add")}\n`);
|
|
248
|
-
}
|
package/dist/config/manager.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "fs";
|
|
2
|
-
import {
|
|
2
|
+
import { randomBytes } from "crypto";
|
|
3
|
+
import { CONFIG_DIR, ACCOUNTS_PATH, CONFIG_PATH } from "./paths.js";
|
|
3
4
|
export function ensureConfigDir() {
|
|
4
5
|
if (!existsSync(CONFIG_DIR)) {
|
|
5
6
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
@@ -36,6 +37,26 @@ export function writeAccountsAtomic(data) {
|
|
|
36
37
|
export function loadAccounts() {
|
|
37
38
|
return deserialize(readAccountsRaw());
|
|
38
39
|
}
|
|
40
|
+
export function readConfig() {
|
|
41
|
+
if (!existsSync(CONFIG_PATH))
|
|
42
|
+
return {};
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export function writeConfig(cfg) {
|
|
51
|
+
ensureConfigDir();
|
|
52
|
+
const tmp = CONFIG_PATH + ".tmp";
|
|
53
|
+
writeFileSync(tmp, JSON.stringify(cfg, null, 2), "utf-8");
|
|
54
|
+
renameSync(tmp, CONFIG_PATH);
|
|
55
|
+
}
|
|
56
|
+
export function generateProxySecret() {
|
|
57
|
+
return "cc-rtr-" + randomBytes(16).toString("hex");
|
|
58
|
+
}
|
|
59
|
+
// ─── Accounts ─────────────────────────────────────────────────────────────────
|
|
39
60
|
function deserialize(records) {
|
|
40
61
|
return records.map(a => ({
|
|
41
62
|
id: a.id,
|
package/dist/config/paths.js
CHANGED
|
@@ -9,3 +9,6 @@ export const PROXY_PORT = parseInt(process.env["PORT"] ?? "3456", 10);
|
|
|
9
9
|
export const LITELLM_PORT = 4000;
|
|
10
10
|
// When set, the server forwards to LiteLLM instead of Anthropic directly
|
|
11
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");
|
package/dist/proxy/server.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import express from "express";
|
|
2
2
|
import { createProxyMiddleware } from "http-proxy-middleware";
|
|
3
3
|
import { ServerResponse } from "http";
|
|
4
|
+
import { timingSafeEqual } from "crypto";
|
|
4
5
|
import { TokenPool } from "./token-pool.js";
|
|
5
6
|
import { needsRefresh, refreshAccountToken, saveAccounts, startRefreshLoop } from "./token-refresher.js";
|
|
6
|
-
import { loadAccounts, accountsFileExists, readAccountsFromPath } from "../config/manager.js";
|
|
7
|
+
import { loadAccounts, accountsFileExists, readAccountsFromPath, readConfig } from "../config/manager.js";
|
|
7
8
|
import { logRoute, logError, logStartup } from "./logger.js";
|
|
8
9
|
import { stats } from "./stats.js";
|
|
9
10
|
import { PROXY_PORT, LITELLM_URL } from "../config/paths.js";
|
|
@@ -30,6 +31,30 @@ export async function startServer(opts = {}) {
|
|
|
30
31
|
const pool = new TokenPool(accounts);
|
|
31
32
|
startRefreshLoop(accounts);
|
|
32
33
|
const app = express();
|
|
34
|
+
// ─── Proxy auth middleware ─────────────────────────────────────────────────
|
|
35
|
+
// If a proxySecret is configured, all requests must present it as
|
|
36
|
+
// "Authorization: Bearer <secret>". The /cc-router/health endpoint is
|
|
37
|
+
// always exempt so monitoring and PM2 healthchecks keep working.
|
|
38
|
+
const { proxySecret } = readConfig();
|
|
39
|
+
if (proxySecret) {
|
|
40
|
+
const secretBuf = Buffer.from(proxySecret, "utf-8");
|
|
41
|
+
app.use((req, res, next) => {
|
|
42
|
+
if (req.path === "/cc-router/health")
|
|
43
|
+
return next();
|
|
44
|
+
const auth = req.headers["authorization"] ?? "";
|
|
45
|
+
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
|
|
46
|
+
const tokenBuf = Buffer.from(token, "utf-8");
|
|
47
|
+
if (tokenBuf.length !== secretBuf.length ||
|
|
48
|
+
!timingSafeEqual(tokenBuf, secretBuf)) {
|
|
49
|
+
res.status(401).json({
|
|
50
|
+
type: "error",
|
|
51
|
+
error: { type: "authentication_error", message: "Invalid or missing proxy authentication token" },
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
next();
|
|
56
|
+
});
|
|
57
|
+
}
|
|
33
58
|
// ─── Health endpoint (cc-router internal, NOT proxied) ────────────────────
|
|
34
59
|
app.get("/cc-router/health", (_req, res) => {
|
|
35
60
|
res.json({
|
|
@@ -41,7 +66,7 @@ export async function startServer(opts = {}) {
|
|
|
41
66
|
totalErrors: stats.totalErrors,
|
|
42
67
|
totalRefreshes: stats.totalRefreshes,
|
|
43
68
|
accounts: pool.getStats(),
|
|
44
|
-
recentLogs: stats.getRecentLogs(),
|
|
69
|
+
recentLogs: stats.getRecentLogs(50),
|
|
45
70
|
});
|
|
46
71
|
});
|
|
47
72
|
// ─── Proxy middleware ──────────────────────────────────────────────────────
|
|
@@ -90,42 +115,70 @@ export async function startServer(opts = {}) {
|
|
|
90
115
|
if (!account)
|
|
91
116
|
return;
|
|
92
117
|
const status = proxyRes.statusCode ?? 0;
|
|
118
|
+
const durationMs = req._startTime
|
|
119
|
+
? Date.now() - req._startTime
|
|
120
|
+
: undefined;
|
|
121
|
+
// Complete the pending log entry with response info
|
|
122
|
+
const pendingLog = req._pendingLog ?? {
|
|
123
|
+
ts: Date.now(),
|
|
124
|
+
accountId: account.id,
|
|
125
|
+
model: "-",
|
|
126
|
+
type: "route",
|
|
127
|
+
};
|
|
128
|
+
pendingLog.statusCode = status;
|
|
129
|
+
if (durationMs !== undefined)
|
|
130
|
+
pendingLog.durationMs = durationMs;
|
|
93
131
|
if (status === 401) {
|
|
94
132
|
// Token invalid or expired mid-request.
|
|
95
133
|
// Forward the 401 to the client (Claude Code will retry on 401).
|
|
96
134
|
// Schedule a background refresh so the next request succeeds.
|
|
97
135
|
stats.totalErrors++;
|
|
98
136
|
account.errorCount++;
|
|
137
|
+
pendingLog.type = "error";
|
|
138
|
+
pendingLog.details = "token invalid";
|
|
99
139
|
logError(account.id, 401, "Token invalid — scheduling background refresh");
|
|
100
|
-
stats.addLog({ ts: Date.now(), accountId: account.id, model: "-", type: "error", details: "401" });
|
|
101
140
|
refreshAccountToken(account).then(ok => {
|
|
102
141
|
if (ok)
|
|
103
142
|
saveAccounts(pool.getAll());
|
|
104
143
|
}).catch(console.error);
|
|
105
144
|
}
|
|
106
|
-
if (status === 429) {
|
|
145
|
+
else if (status === 429) {
|
|
107
146
|
// Rate limited — put account on cooldown for Retry-After seconds.
|
|
108
147
|
stats.totalErrors++;
|
|
109
148
|
account.errorCount++;
|
|
110
149
|
const retryAfter = Number(proxyRes.headers["retry-after"] ?? 60);
|
|
150
|
+
pendingLog.type = "error";
|
|
151
|
+
pendingLog.details = `rate limited — cooldown ${retryAfter}s`;
|
|
111
152
|
logError(account.id, 429, `Rate limited — cooldown ${retryAfter}s`);
|
|
112
|
-
stats.addLog({ ts: Date.now(), accountId: account.id, model: "-", type: "error", details: "429" });
|
|
113
153
|
account.busy = true;
|
|
114
154
|
setTimeout(() => { account.busy = false; }, retryAfter * 1_000);
|
|
115
155
|
}
|
|
116
|
-
if (status === 529) {
|
|
156
|
+
else if (status === 529) {
|
|
117
157
|
// Anthropic service overloaded — short cooldown on this account.
|
|
118
158
|
stats.totalErrors++;
|
|
119
159
|
account.errorCount++;
|
|
160
|
+
pendingLog.type = "error";
|
|
161
|
+
pendingLog.details = "service overloaded — cooldown 30s";
|
|
120
162
|
logError(account.id, 529, "Service overloaded — cooldown 30s");
|
|
121
|
-
stats.addLog({ ts: Date.now(), accountId: account.id, model: "-", type: "error", details: "529" });
|
|
122
163
|
account.busy = true;
|
|
123
164
|
setTimeout(() => { account.busy = false; }, 30_000);
|
|
124
165
|
}
|
|
166
|
+
stats.addLog(pendingLog);
|
|
125
167
|
},
|
|
126
168
|
error: (err, _req, res) => {
|
|
127
169
|
stats.totalErrors++;
|
|
128
170
|
logError("proxy", 0, err.message);
|
|
171
|
+
// Complete the pending log entry for connection-level errors
|
|
172
|
+
const pendingLog = _req._pendingLog;
|
|
173
|
+
if (pendingLog) {
|
|
174
|
+
pendingLog.type = "error";
|
|
175
|
+
pendingLog.statusCode = 0;
|
|
176
|
+
pendingLog.details = err.message;
|
|
177
|
+
if (_req._startTime) {
|
|
178
|
+
pendingLog.durationMs = Date.now() - _req._startTime;
|
|
179
|
+
}
|
|
180
|
+
stats.addLog(pendingLog);
|
|
181
|
+
}
|
|
129
182
|
// res may be a Socket (WebSocket upgrade) — only respond on HTTP ServerResponse
|
|
130
183
|
if (res instanceof ServerResponse && !res.headersSent) {
|
|
131
184
|
// Match Anthropic's error response format so Claude Code handles it gracefully
|
|
@@ -150,8 +203,16 @@ export async function startServer(opts = {}) {
|
|
|
150
203
|
saveAccounts(pool.getAll());
|
|
151
204
|
}
|
|
152
205
|
req._ccAccount = account;
|
|
206
|
+
req._startTime = Date.now();
|
|
207
|
+
req._pendingLog = {
|
|
208
|
+
ts: Date.now(),
|
|
209
|
+
accountId: account.id,
|
|
210
|
+
model: "-",
|
|
211
|
+
type: "route",
|
|
212
|
+
method: req.method,
|
|
213
|
+
path: req.path,
|
|
214
|
+
};
|
|
153
215
|
stats.totalRequests++;
|
|
154
|
-
stats.addLog({ ts: Date.now(), accountId: account.id, model: "?", type: "route" });
|
|
155
216
|
logRoute(account.id, account.requestCount, Math.round((account.tokens.expiresAt - Date.now()) / 60_000));
|
|
156
217
|
next();
|
|
157
218
|
}, proxy);
|
package/dist/proxy/stats.js
CHANGED
package/dist/ui/Dashboard.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useState, useEffect } from "react";
|
|
3
3
|
import { Box, Text, useInput, useApp } from "ink";
|
|
4
4
|
const POLL_INTERVAL_MS = 2_000;
|
|
5
|
+
const LOG_VISIBLE = 20;
|
|
5
6
|
// ─── Dashboard component ──────────────────────────────────────────────────────
|
|
6
7
|
export function Dashboard({ port }) {
|
|
7
8
|
const { exit } = useApp();
|
|
@@ -59,9 +60,28 @@ function ErrorScreen({ error, port, retries }) {
|
|
|
59
60
|
function LiveDashboard({ data, port, lastUpdate }) {
|
|
60
61
|
const healthyCount = data.accounts.filter(a => a.healthy).length;
|
|
61
62
|
const updatedAgo = Math.round((Date.now() - lastUpdate) / 1000);
|
|
62
|
-
|
|
63
|
+
const logs = data.recentLogs;
|
|
64
|
+
// Stable selection: track by timestamp so it survives log rotations
|
|
65
|
+
const [selectedTs, setSelectedTs] = useState(null);
|
|
66
|
+
// Derive index from timestamp; default to 0 (newest)
|
|
67
|
+
const selectedIndex = selectedTs !== null
|
|
68
|
+
? Math.max(0, logs.findIndex(l => l.ts === selectedTs))
|
|
69
|
+
: 0;
|
|
70
|
+
useInput((_input, key) => {
|
|
71
|
+
if (key.upArrow) {
|
|
72
|
+
const next = Math.max(0, selectedIndex - 1);
|
|
73
|
+
setSelectedTs(logs[next]?.ts ?? null);
|
|
74
|
+
}
|
|
75
|
+
if (key.downArrow) {
|
|
76
|
+
const next = Math.min(logs.length - 1, selectedIndex + 1);
|
|
77
|
+
setSelectedTs(logs[next]?.ts ?? null);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
const selectedLog = logs[selectedIndex] ?? null;
|
|
81
|
+
const visibleLogs = logs.slice(0, LOG_VISIBLE);
|
|
82
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: " CC-Router " }), _jsx(Text, { color: "gray", children: "\u00B7 " }), _jsx(Text, { color: "green", children: data.mode }), _jsxs(Text, { color: "gray", children: [" \u2192 ", data.target, " \u00B7 "] }), _jsxs(Text, { children: ["up ", formatUptime(data.uptime)] }), _jsxs(Text, { color: "gray", children: [" \u00B7 updated ", updatedAgo, "s ago \u00B7 [\u2191\u2193] navigate \u00B7 [q] quit"] })] }), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, children: [" ACCOUNTS ", _jsxs(Text, { color: healthyCount === data.accounts.length ? "green" : "yellow", children: [healthyCount, "/", data.accounts.length, " healthy"] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: data.accounts.map(a => (_jsx(AccountRow, { account: a }, a.id))) })] }), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: " TOTALS " }), _jsx(Text, { children: "requests " }), _jsx(Text, { color: "cyan", children: data.totalRequests }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { children: "errors " }), _jsx(Text, { color: data.totalErrors > 0 ? "red" : "green", children: data.totalErrors }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { children: "refreshes " }), _jsx(Text, { color: "yellow", children: data.totalRefreshes })] }), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: " RECENT ACTIVITY" }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: visibleLogs.length === 0
|
|
63
83
|
? _jsx(Text, { color: "gray", children: " No activity yet" })
|
|
64
|
-
:
|
|
84
|
+
: visibleLogs.map((log, i) => (_jsx(LogRow, { log: log, selected: i === selectedIndex }, log.ts))) })] }), selectedLog && (_jsxs(_Fragment, { children: [_jsx(Box, { marginTop: 1 }), _jsx(DetailPanel, { log: selectedLog })] }))] }));
|
|
65
85
|
}
|
|
66
86
|
// ─── Account row ──────────────────────────────────────────────────────────────
|
|
67
87
|
function AccountRow({ account: a }) {
|
|
@@ -76,11 +96,57 @@ function AccountRow({ account: a }) {
|
|
|
76
96
|
return (_jsxs(Box, { children: [_jsxs(Text, { color: dotColor, children: [" ", dot, " "] }), _jsx(Text, { children: a.id.slice(0, 22).padEnd(22) }), _jsx(Text, { color: statusColor, children: statusLabel }), _jsx(Text, { color: "gray", children: " req " }), _jsx(Text, { color: "white", children: String(a.requestCount).padStart(5) }), _jsx(Text, { color: "gray", children: " err " }), _jsx(Text, { color: a.errorCount > 0 ? "red" : "gray", children: String(a.errorCount).padStart(3) }), _jsx(Text, { color: "gray", children: " expires " }), _jsx(Text, { color: expiryColor, children: expiryLabel.padEnd(10) }), _jsx(Text, { color: "gray", children: " last " }), _jsx(Text, { color: "gray", children: formatAgo(a.lastUsedMs) })] }));
|
|
77
97
|
}
|
|
78
98
|
// ─── Log row ──────────────────────────────────────────────────────────────────
|
|
79
|
-
function LogRow({ log }) {
|
|
99
|
+
function LogRow({ log, selected }) {
|
|
80
100
|
const time = new Date(log.ts).toLocaleTimeString("en-GB", { hour12: false });
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
|
|
101
|
+
const isError = log.type === "error";
|
|
102
|
+
const isRefresh = log.type === "refresh";
|
|
103
|
+
const typeColor = isError ? "red" : isRefresh ? "yellow" : "gray";
|
|
104
|
+
const typeIcon = isError ? "✗" : isRefresh ? "↻" : "→";
|
|
105
|
+
const statusColor = log.statusCode === undefined ? undefined
|
|
106
|
+
: log.statusCode >= 500 ? "red"
|
|
107
|
+
: log.statusCode >= 400 ? "yellow"
|
|
108
|
+
: log.statusCode >= 200 ? "green"
|
|
109
|
+
: "gray";
|
|
110
|
+
const bg = selected ? "white" : undefined;
|
|
111
|
+
const fg = (c) => selected ? "black" : c;
|
|
112
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { backgroundColor: bg, color: fg(undefined), children: [selected ? "▶" : " ", " ", time, " "] }), _jsxs(Text, { backgroundColor: bg, color: fg(typeColor), children: [typeIcon, " "] }), _jsx(Text, { backgroundColor: bg, color: fg("cyan"), children: log.accountId.slice(0, 22).padEnd(22) }), log.method && log.path
|
|
113
|
+
? _jsxs(Text, { backgroundColor: bg, color: fg("white"), children: [" ", log.method, " ", log.path.padEnd(14)] })
|
|
114
|
+
: _jsxs(Text, { backgroundColor: bg, color: fg(typeColor), children: [" ", log.type.padEnd(9)] }), log.statusCode !== undefined && (_jsxs(Text, { backgroundColor: bg, color: fg(statusColor), children: [" ", log.statusCode] })), log.durationMs !== undefined && (_jsxs(Text, { backgroundColor: bg, color: fg("gray"), children: [" ", log.durationMs, "ms"] })), log.details && (_jsxs(Text, { backgroundColor: bg, color: fg("gray"), children: [" ", log.details] }))] }));
|
|
115
|
+
}
|
|
116
|
+
// ─── Detail panel ─────────────────────────────────────────────────────────────
|
|
117
|
+
function DetailPanel({ log }) {
|
|
118
|
+
const time = new Date(log.ts).toLocaleString("en-GB", {
|
|
119
|
+
hour12: false,
|
|
120
|
+
year: "numeric", month: "2-digit", day: "2-digit",
|
|
121
|
+
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
|
122
|
+
});
|
|
123
|
+
const isError = log.type === "error";
|
|
124
|
+
const statusLabel = log.statusCode === undefined ? "—"
|
|
125
|
+
: log.statusCode === 0 ? "connection error"
|
|
126
|
+
: `${log.statusCode} ${httpStatusText(log.statusCode)}`;
|
|
127
|
+
const statusColor = log.statusCode === undefined ? "gray"
|
|
128
|
+
: log.statusCode === 0 ? "red"
|
|
129
|
+
: log.statusCode >= 500 ? "red"
|
|
130
|
+
: log.statusCode >= 400 ? "yellow"
|
|
131
|
+
: "green";
|
|
132
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, color: isError ? "red" : "cyan", children: " DETAILS " }), _jsxs(Box, { marginTop: 1, flexDirection: "column", gap: 0, children: [_jsxs(Box, { gap: 2, children: [_jsx(Field, { label: "Time", value: time }), _jsx(Field, { label: "Account", value: log.accountId })] }), _jsxs(Box, { gap: 2, children: [_jsx(Field, { label: "Method", value: log.method ?? "—" }), _jsx(Field, { label: "Path", value: log.path ?? "—" })] }), _jsxs(Box, { gap: 2, children: [_jsx(FieldColored, { label: "Status", value: statusLabel, color: statusColor }), _jsx(Field, { label: "Duration", value: log.durationMs !== undefined ? `${log.durationMs}ms` : "—" }), _jsx(Field, { label: "Type", value: log.type })] }), log.details && (_jsx(Box, { children: _jsx(Field, { label: "Details", value: log.details }) }))] })] }));
|
|
133
|
+
}
|
|
134
|
+
function Field({ label, value }) {
|
|
135
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { color: "gray", children: [label, ": "] }), _jsx(Text, { color: "white", children: value })] }));
|
|
136
|
+
}
|
|
137
|
+
function FieldColored({ label, value, color }) {
|
|
138
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { color: "gray", children: [label, ": "] }), _jsx(Text, { color: color, children: value })] }));
|
|
139
|
+
}
|
|
140
|
+
// ─── HTTP status text ─────────────────────────────────────────────────────────
|
|
141
|
+
function httpStatusText(code) {
|
|
142
|
+
const map = {
|
|
143
|
+
200: "OK", 201: "Created", 204: "No Content",
|
|
144
|
+
400: "Bad Request", 401: "Unauthorized", 403: "Forbidden",
|
|
145
|
+
404: "Not Found", 429: "Too Many Requests",
|
|
146
|
+
500: "Internal Server Error", 502: "Bad Gateway",
|
|
147
|
+
503: "Service Unavailable", 529: "Overloaded",
|
|
148
|
+
};
|
|
149
|
+
return map[code] ?? "";
|
|
84
150
|
}
|
|
85
151
|
// ─── Formatters ───────────────────────────────────────────────────────────────
|
|
86
152
|
function formatUptime(seconds) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
2
2
|
import { dirname } from "path";
|
|
3
3
|
import { CLAUDE_SETTINGS_PATH } from "../config/paths.js";
|
|
4
|
+
import { readConfig } from "../config/manager.js";
|
|
4
5
|
/**
|
|
5
6
|
* Write ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN into ~/.claude/settings.json.
|
|
6
7
|
*
|
|
@@ -9,7 +10,12 @@ import { CLAUDE_SETTINGS_PATH } from "../config/paths.js";
|
|
|
9
10
|
* - Do NOT append /v1 to ANTHROPIC_BASE_URL — Claude Code adds it automatically
|
|
10
11
|
* - Merges with existing settings, preserving all other keys
|
|
11
12
|
*/
|
|
12
|
-
|
|
13
|
+
/**
|
|
14
|
+
* @param port - proxy port (used only when baseUrl is not provided)
|
|
15
|
+
* @param baseUrl - full proxy URL e.g. "http://192.168.1.50:3456" or "https://cc-router.example.com"
|
|
16
|
+
* If omitted, defaults to http://localhost:<port>
|
|
17
|
+
*/
|
|
18
|
+
export function writeClaudeSettings(port, baseUrl) {
|
|
13
19
|
const dir = dirname(CLAUDE_SETTINGS_PATH);
|
|
14
20
|
if (!existsSync(dir))
|
|
15
21
|
mkdirSync(dir, { recursive: true });
|
|
@@ -23,15 +29,16 @@ export function writeClaudeSettings(port) {
|
|
|
23
29
|
}
|
|
24
30
|
}
|
|
25
31
|
const existingEnv = existing["env"] ?? {};
|
|
32
|
+
// ANTHROPIC_BASE_URL: no trailing /v1 — Claude Code appends it automatically
|
|
33
|
+
const resolvedUrl = baseUrl ?? `http://localhost:${port}`;
|
|
26
34
|
const updated = {
|
|
27
35
|
...existing,
|
|
28
36
|
env: {
|
|
29
37
|
...existingEnv,
|
|
30
|
-
|
|
31
|
-
ANTHROPIC_BASE_URL: `http://localhost:${port}`,
|
|
38
|
+
ANTHROPIC_BASE_URL: resolvedUrl,
|
|
32
39
|
// ANTHROPIC_AUTH_TOKEN has higher precedence than ANTHROPIC_API_KEY in Claude Code.
|
|
33
|
-
//
|
|
34
|
-
ANTHROPIC_AUTH_TOKEN: "proxy-managed",
|
|
40
|
+
// Uses the configured proxy secret if set, otherwise falls back to the open placeholder.
|
|
41
|
+
ANTHROPIC_AUTH_TOKEN: readConfig().proxySecret ?? "proxy-managed",
|
|
35
42
|
},
|
|
36
43
|
};
|
|
37
44
|
writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(updated, null, 2), "utf-8");
|