TUI Source

This commit is contained in:
decolua
2026-05-12 20:26:08 +07:00
parent 42f0736d28
commit 58788a0d31
57 changed files with 5887 additions and 1871 deletions

3
.gitmodules vendored
View File

@@ -1,3 +0,0 @@
[submodule "src/mitm/dev"]
path = src/mitm/dev
url = https://github.com/decolua/9router-dev.git

View File

@@ -1,3 +1,9 @@
# v0.4.33 (2026-05-12)
## Improvements
- Windows: replace systray (Go binary, AV flagged) with native PowerShell NotifyIcon
- Auto-cleanup legacy `tray_windows.exe` on install/startup
# v0.4.31 (2026-05-12)
## Features

2
cli/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
app/*
node_modules/*

9
cli/.npmignore Normal file
View File

@@ -0,0 +1,9 @@
# Ignore everything except what's in package.json "files"
*
!cli.js
!hooks/
!app/
!package.json
!README.md
!LICENSE

42
cli/LICENSE Normal file
View File

@@ -0,0 +1,42 @@
MIT License
Copyright (c) 2026 9Router Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

112
cli/README.md Normal file
View File

@@ -0,0 +1,112 @@
# 9Router - FREE AI Router & Token Saver
**Never stop coding. Save 20-40% tokens with RTK + auto-fallback to FREE & cheap AI models.**
**Connect All AI Code Tools (Claude Code, Cursor, Antigravity, Copilot, Codex, Gemini, OpenCode, Cline, OpenClaw...) to 40+ AI Providers & 100+ Models.**
[![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router)
[![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router)
[![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE)
<a href="https://trendshift.io/repositories/22628" target="_blank"><img src="https://trendshift.io/api/badge/repositories/22628" alt="decolua%2F9router | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[🌐 Website](https://9router.com) • [📖 Full Docs](https://github.com/decolua/9router)
---
## 🤔 Why 9Router?
**Stop wasting money, tokens and hitting limits:**
- ❌ Subscription quota expires unused every month
- ❌ Rate limits stop you mid-coding
- ❌ Tool outputs (git diff, grep, ls...) burn tokens fast
- ❌ Expensive APIs ($20-50/month per provider)
**9Router solves this:**
-**RTK Token Saver** - Auto-compress tool_result, save 20-40% tokens
-**Maximize subscriptions** - Track quota, use every bit before reset
-**Auto fallback** - Subscription → Cheap → Free, zero downtime
-**Multi-account** - Round-robin between accounts per provider
-**Universal** - Works with any OpenAI/Claude-compatible CLI
---
## ⚡ Quick Start
**1. Install & run:**
```bash
npm install -g 9router
9router
# Or run directly with npx
npx 9router
```
🎉 Dashboard opens at `http://localhost:20128`
**2. Connect a FREE provider (no signup needed):**
Dashboard → Providers → Connect **Kiro AI** (free Claude unlimited) or **OpenCode Free** (no auth) → Done!
**3. Use in your CLI tool:**
```
Claude Code/Codex/OpenClaw/Cursor/Cline Settings:
Endpoint: http://localhost:20128/v1
API Key: [copy from dashboard]
Model: kr/claude-sonnet-4.5
```
That's it! Start coding with FREE AI models.
---
## 🚀 CLI Options
```bash
9router # Start with default settings
9router --port 8080 # Custom port
9router --no-browser # Don't open browser
9router --skip-update # Skip auto-update check
9router --help # Show all options
```
**Dashboard**: `http://localhost:20128/dashboard`
---
## 🛠️ Supported CLI Tools
Claude-Code • OpenClaw • Codex • OpenCode • Cursor • Antigravity • Cline • Continue • Droid • Roo • Copilot • Kilo Code • Gemini CLI • Qwen Code • iFlow • Crush • Crusher • Aider
Any tool supporting OpenAI/Claude-compatible API works.
---
## 💾 Data Location
- **macOS/Linux**: `~/.9router/db.json`
- **Windows**: `%APPDATA%/9router/db.json`
---
## 📚 Documentation
Full docs, advanced setup, video tutorials & development guide:
- **GitHub**: https://github.com/decolua/9router
- **Full README**: https://github.com/decolua/9router/blob/main/app/README.md
- **Website**: https://9router.com
---
## 🙏 Acknowledgments
- **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** - Original Go implementation
## 📄 License
MIT License - see [LICENSE](LICENSE) for details.

782
cli/cli.js Executable file
View File

@@ -0,0 +1,782 @@
#!/usr/bin/env node
const { spawn, exec, execSync } = require("child_process");
const path = require("path");
const fs = require("fs");
const https = require("https");
const os = require("os");
// Native spinner - no external dependency
function createSpinner(text) {
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let i = 0;
let interval = null;
let currentText = text;
return {
start() {
if (process.stdout.isTTY) {
process.stdout.write(`\r${frames[0]} ${currentText}`);
interval = setInterval(() => {
process.stdout.write(`\r${frames[i++ % frames.length]} ${currentText}`);
}, 80);
}
return this;
},
stop() {
if (interval) {
clearInterval(interval);
interval = null;
}
if (process.stdout.isTTY) {
process.stdout.write("\r\x1b[K");
}
},
succeed(msg) {
this.stop();
console.log(`${msg}`);
},
fail(msg) {
this.stop();
console.log(`${msg}`);
}
};
}
const pkg = require("./package.json");
const { ensureSqliteRuntime, buildEnvWithRuntime } = require("./hooks/sqliteRuntime");
const { ensureTrayRuntime } = require("./hooks/trayRuntime");
const args = process.argv.slice(2);
// Self-heal SQLite runtime deps (sql.js + better-sqlite3) into ~/.9router/runtime
// so the server can resolve them via NODE_PATH. Best-effort — sql.js is required,
// better-sqlite3 is optional. Logs to stderr only on failure.
try { ensureSqliteRuntime({ silent: true }); } catch {}
// Self-heal tray runtime (systray for macOS/Linux only). Windows skipped.
try { ensureTrayRuntime({ silent: true }); } catch {}
// Configuration constants
const APP_NAME = pkg.name; // Use from package.json
const INSTALL_CMD_LATEST = `npm i -g ${APP_NAME}@latest --prefer-online`;
const DEFAULT_PORT = 20128;
const DEFAULT_HOST = "0.0.0.0";
const MAX_PORT_ATTEMPTS = 10;
// Identifiers for killAllAppProcesses - only kill 9router specifically
const PROCESS_IDENTIFIERS = [
'9router' // Only package name - avoid killing other apps
];
// Parse arguments
let port = DEFAULT_PORT;
let host = DEFAULT_HOST;
let noBrowser = false;
let skipUpdate = false;
let showLog = false;
let trayMode = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === "--port" || args[i] === "-p") {
port = parseInt(args[i + 1], 10) || DEFAULT_PORT;
i++;
} else if (args[i] === "--host" || args[i] === "-H") {
host = args[i + 1] || DEFAULT_HOST;
i++;
} else if (args[i] === "--no-browser" || args[i] === "-n") {
noBrowser = true;
} else if (args[i] === "--log" || args[i] === "-l") {
showLog = true;
} else if (args[i] === "--skip-update") {
skipUpdate = true;
} else if (args[i] === "--tray" || args[i] === "-t") {
trayMode = true;
process.env.TRAY_MODE = "1";
} else if (args[i] === "--help" || args[i] === "-h") {
console.log(`
Usage: ${APP_NAME} [options]
Options:
-p, --port <port> Port to run the server (default: ${DEFAULT_PORT})
-H, --host <host> Host to bind (default: ${DEFAULT_HOST})
-n, --no-browser Don't open browser automatically
-l, --log Show server logs (default: hidden)
-t, --tray Run in system tray mode (background)
--skip-update Skip auto-update check
-h, --help Show this help message
-v, --version Show version
`);
process.exit(0);
} else if (args[i] === "--version" || args[i] === "-v") {
console.log(pkg.version);
process.exit(0);
}
}
// Auto-relaunch after update: detached process has no TTY → fallback to tray
if (skipUpdate && !trayMode && !process.stdin.isTTY) {
trayMode = true;
process.env.TRAY_MODE = "1";
}
// Always use Node.js runtime with absolute path
const RUNTIME = process.execPath;
// Compare semver versions: returns 1 if a > b, -1 if a < b, 0 if equal
function compareVersions(a, b) {
const partsA = a.split(".").map(Number);
const partsB = b.split(".").map(Number);
for (let i = 0; i < 3; i++) {
if (partsA[i] > partsB[i]) return 1;
if (partsA[i] < partsB[i]) return -1;
}
return 0;
}
// Get app data dir (matches app/src/lib/dataDir.js convention)
function getAppDataDir() {
return process.platform === "win32"
? path.join(process.env.APPDATA || "", "9router")
: path.join(os.homedir(), ".9router");
}
// Kill PID from file (best-effort, removes file after)
function killByPidFile(pidFile) {
try {
if (!fs.existsSync(pidFile)) return;
const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10);
if (!pid) return;
try {
if (process.platform === "win32") {
execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 3000 });
} else {
process.kill(pid, "SIGKILL");
}
} catch { }
try { fs.unlinkSync(pidFile); } catch { }
} catch { }
}
// Kill tunnel processes (cloudflared/tailscale) by their PID files
function killTunnelByPidFile() {
const tunnelDir = path.join(getAppDataDir(), "tunnel");
killByPidFile(path.join(tunnelDir, "cloudflared.pid"));
killByPidFile(path.join(tunnelDir, "tailscale.pid"));
}
// Kill cloudflared whose --url targets this app's port (covers stale PID file case)
function killCloudflaredByAppPort(appPort) {
if (!appPort) return [];
const portMatchers = [`localhost:${appPort}`, `127.0.0.1:${appPort}`];
const pids = [];
try {
if (process.platform === "win32") {
const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "Get-WmiObject Win32_Process -Filter 'Name=\\"cloudflared.exe\\"' | Select-Object ProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"`;
const output = execSync(psCmd, { encoding: "utf8", windowsHide: true, timeout: 5000 });
const lines = output.split("\n").slice(1).filter(l => l.trim());
lines.forEach(line => {
if (portMatchers.some(m => line.includes(m))) {
const match = line.match(/^"(\d+)"/);
if (match && match[1]) pids.push(match[1]);
}
});
} else {
const output = execSync("ps -eo pid,command 2>/dev/null", { encoding: "utf8", timeout: 5000 });
output.split("\n").forEach(line => {
if (line.includes("cloudflared") && portMatchers.some(m => line.includes(m))) {
const parts = line.trim().split(/\s+/);
const pid = parts[0];
if (pid && !isNaN(pid)) pids.push(pid);
}
});
}
} catch { }
return pids;
}
// Kill all 9router processes
function killAllAppProcesses(appPort) {
return new Promise((resolve) => {
try {
// Kill MITM first (admin/sudo process, needs special handling)
killMitmByPidFile();
// Kill cloudflared/tailscale by PID file (precise, only this app's tunnel)
killTunnelByPidFile();
const platform = process.platform;
let pids = [];
// Catch stale PID files: kill cloudflared bound to this app's port
pids.push(...killCloudflaredByAppPort(appPort));
if (platform === "win32") {
// Windows: use WMI to get full CommandLine (tasklist /V doesn't include it)
try {
const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "Get-WmiObject Win32_Process -Filter 'Name=\\"node.exe\\"' | Select-Object ProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"`;
const output = execSync(psCmd, {
encoding: "utf8",
windowsHide: true,
timeout: 5000
});
const lines = output.split("\n").slice(1).filter(l => l.trim());
lines.forEach(line => {
const isAppProcess = line.toLowerCase().includes("9router") ||
line.toLowerCase().includes("next-server");
if (isAppProcess) {
const match = line.match(/^"(\d+)"/);
if (match && match[1] && match[1] !== process.pid.toString()) {
pids.push(match[1]);
}
}
});
} catch (e) {
// No processes found or error - continue
}
} else {
// macOS/Linux: use ps to find all matching processes
try {
const output = execSync('ps aux 2>/dev/null', {
encoding: 'utf8',
timeout: 5000
});
const lines = output.split('\n');
lines.forEach(line => {
const isAppProcess = line.includes("9router") || line.includes("next-server");
if (isAppProcess) {
const parts = line.trim().split(/\s+/);
const pid = parts[1];
if (pid && !isNaN(pid) && pid !== process.pid.toString()) {
pids.push(pid);
}
}
});
} catch (e) {
// No processes found or error - continue
}
}
// Kill all found processes
if (pids.length > 0) {
pids.forEach(pid => {
try {
if (platform === "win32") {
execSync(`taskkill /F /PID ${pid} 2>nul`, { stdio: 'ignore', shell: true, windowsHide: true, timeout: 3000 });
} else {
execSync(`kill -9 ${pid} 2>/dev/null`, { stdio: 'ignore', timeout: 3000 });
}
} catch (err) {
// Process already dead or can't kill - continue
}
});
// Wait for processes to fully terminate
setTimeout(() => resolve(), 1000);
} else {
resolve();
}
} catch (err) {
// Silent fail - continue anyway
resolve();
}
});
}
// Sleep helper using SharedArrayBuffer wait (sync, no busy-loop)
function sleepSync(ms) {
try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* ignore */ }
}
// Wait until process dies or timeout reached
function waitForExit(pid, timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try { process.kill(pid, 0); } catch { return true; }
sleepSync(100);
}
return false;
}
// Kill MITM server by PID file (MITM runs as admin/sudo, needs special handling)
// Sends SIGTERM first so MITM can clean up /etc/hosts entries before dying.
function killMitmByPidFile() {
try {
const mitmPidFile = path.join(getAppDataDir(), "mitm", ".mitm.pid");
if (!fs.existsSync(mitmPidFile)) return;
const pid = parseInt(fs.readFileSync(mitmPidFile, "utf8").trim(), 10);
if (!pid) return;
if (process.platform === "win32") {
// Graceful first (lets server cleanup hosts), then force
try { execSync(`taskkill /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 2000 }); } catch { }
if (!waitForExit(pid, 1500)) {
try { execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 3000 }); } catch { }
}
// Last-resort: PowerShell Stop-Process (sometimes succeeds where taskkill fails on admin processes)
if (!waitForExit(pid, 500)) {
try { execSync(`powershell -NonInteractive -WindowStyle Hidden -Command "Stop-Process -Id ${pid} -Force"`, { stdio: "ignore", windowsHide: true, timeout: 3000 }); } catch { }
}
} else {
// SIGTERM via cached sudo token first
try { execSync(`sudo -n kill -TERM ${pid} 2>/dev/null`, { stdio: "ignore", timeout: 2000 }); }
catch { try { process.kill(pid, "SIGTERM"); } catch { } }
if (!waitForExit(pid, 1500)) {
try { execSync(`sudo -n kill -9 ${pid} 2>/dev/null`, { stdio: "ignore", timeout: 2000 }); }
catch { try { process.kill(pid, "SIGKILL"); } catch { } }
}
}
try { fs.unlinkSync(mitmPidFile); } catch { }
} catch { }
}
// Kill any process on specific port
function killProcessOnPort(port) {
return new Promise((resolve) => {
try {
const platform = process.platform;
let pid;
if (platform === "win32") {
try {
const output = execSync(`netstat -ano | findstr :${port}`, {
encoding: 'utf8',
shell: true,
windowsHide: true,
timeout: 5000
}).trim();
const lines = output.split('\n').filter(l => l.includes('LISTENING'));
if (lines.length > 0) {
pid = lines[0].trim().split(/\s+/).pop();
execSync(`taskkill /F /PID ${pid} 2>nul`, { stdio: 'ignore', shell: true, windowsHide: true, timeout: 3000 });
}
} catch (e) {
// Port is free or error
}
} else {
// macOS/Linux
try {
const pidOutput = execSync(`lsof -ti:${port}`, {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore']
}).trim();
if (pidOutput) {
pid = pidOutput.split('\n')[0];
execSync(`kill -9 ${pid} 2>/dev/null`, { stdio: 'ignore', timeout: 3000 });
}
} catch (e) {
// Port is free or error
}
}
// Wait for port to be released
setTimeout(() => resolve(), 500);
} catch (err) {
// Silent fail - continue anyway
resolve();
}
});
}
// Detect if running in restricted environment (Codespaces, Docker)
function isRestrictedEnvironment() {
// Check for Codespaces
if (process.env.CODESPACES === "true" || process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN) {
return "GitHub Codespaces";
}
// Check for Docker
if (fs.existsSync("/.dockerenv") || (fs.existsSync("/proc/1/cgroup") && fs.readFileSync("/proc/1/cgroup", "utf8").includes("docker"))) {
return "Docker";
}
return null;
}
// Check if new version available, return latest version or null
function checkForUpdate() {
return new Promise((resolve) => {
if (skipUpdate) {
resolve(null);
return;
}
const spinner = createSpinner("Checking for updates...").start();
let resolved = false;
const safetyTimeout = setTimeout(() => {
if (!resolved) {
resolved = true;
spinner.stop();
resolve(null);
}
}, 8000);
const done = (version) => {
if (resolved) return;
resolved = true;
clearTimeout(safetyTimeout);
spinner.stop();
resolve(version);
};
const req = https.get(`https://registry.npmjs.org/${pkg.name}/latest`, { timeout: 3000 }, (res) => {
let data = "";
res.on("data", chunk => data += chunk);
res.on("end", () => {
try {
const latest = JSON.parse(data);
if (latest.version && compareVersions(latest.version, pkg.version) > 0) {
done(latest.version);
} else {
done(null);
}
} catch (e) {
done(null);
}
});
});
req.on("error", () => done(null));
req.on("timeout", () => { req.destroy(); done(null); });
});
}
// Open browser
function openBrowser(url) {
const platform = process.platform;
let cmd;
if (platform === "darwin") {
cmd = `open "${url}"`;
} else if (platform === "win32") {
cmd = `start "" "${url}"`;
} else {
cmd = `xdg-open "${url}"`;
}
exec(cmd, { windowsHide: true }, (err) => {
if (err) {
console.log(`Open browser manually: ${url}`);
}
});
}
// Find standalone server (bundled in bin/app for published package)
const standaloneDir = path.join(__dirname, "app");
const serverPath = path.join(standaloneDir, "server.js");
if (!fs.existsSync(serverPath)) {
console.error("Error: Standalone build not found.");
console.error("Please run 'npm run build:cli' first.");
process.exit(1);
}
// Check for updates FIRST, then start server
checkForUpdate().then((latestVersion) => {
killAllAppProcesses(port).then(() => {
return killProcessOnPort(port);
}).then(() => {
startServer(latestVersion);
});
});
// Show interface selection menu
async function showInterfaceMenu(latestVersion) {
const { selectMenu } = require("./src/cli/utils/input");
const { clearScreen } = require("./src/cli/utils/display");
const { getEndpoint } = require("./src/cli/utils/endpoint");
clearScreen();
const displayHost = host === DEFAULT_HOST ? "localhost" : host;
// Detect tunnel/local mode for server URL display
let serverUrl;
try {
const { endpoint, tunnelEnabled } = await getEndpoint(port);
serverUrl = tunnelEnabled ? endpoint.replace(/\/v1$/, "") : `http://${displayHost}:${port}`;
} catch (e) {
serverUrl = `http://${displayHost}:${port}`;
}
const subtitle = `🚀 Server: \x1b[32m${serverUrl}\x1b[0m`;
const menuItems = [];
if (latestVersion) {
menuItems.push({ label: `Update to v${latestVersion} (current: v${pkg.version})`, icon: "⬆" });
}
menuItems.push(
{ label: "Web UI (Open in Browser)", icon: "🌐" },
{ label: "Terminal UI (Interactive CLI)", icon: "💻" },
{ label: "Hide to Tray (Background)", icon: "🔔" },
{ label: "Exit", icon: "🚪" }
);
const selected = await selectMenu(`Choose Interface (v${pkg.version})`, menuItems, 0, subtitle);
const offset = latestVersion ? 1 : 0;
if (latestVersion && selected === 0) return "update";
if (selected === offset) return "web";
if (selected === offset + 1) return "terminal";
if (selected === offset + 2) return "hide";
return "exit";
}
const MAX_RESTARTS = 2;
const RESTART_RESET_MS = 30000; // Reset counter if alive > 30s
function startServer(latestVersion) {
const displayHost = host === DEFAULT_HOST ? "localhost" : host;
const url = `http://${displayHost}:${port}/dashboard`;
let restartCount = 0;
let serverStartTime = Date.now();
const CRASH_LOG_LINES = 50;
let crashLog = [];
function spawnServer() {
serverStartTime = Date.now();
crashLog = [];
const child = spawn(RUNTIME, ["--max-old-space-size=6144", serverPath], {
cwd: standaloneDir,
stdio: showLog ? "inherit" : ["ignore", "ignore", "pipe"],
detached: true,
windowsHide: true,
env: {
...buildEnvWithRuntime(process.env),
PORT: port.toString(),
HOSTNAME: host
}
});
if (!showLog && child.stderr) {
child.stderr.on("data", (data) => {
const lines = data.toString().split("\n").filter(Boolean);
crashLog.push(...lines);
if (crashLog.length > CRASH_LOG_LINES) crashLog = crashLog.slice(-CRASH_LOG_LINES);
});
}
return child;
}
let server = spawnServer();
// Cleanup function - force kill server process
let isCleaningUp = false;
function cleanup() {
if (isCleaningUp) return;
isCleaningUp = true;
try {
// Kill tray if running
try {
const { killTray } = require("./src/cli/tray/tray");
killTray();
} catch (e) { }
// Kill MITM server (admin/sudo process) via PID file
killMitmByPidFile();
// Kill cloudflared/tailscale via PID file (only this app's tunnel)
killTunnelByPidFile();
// Kill server process directly
if (server.pid) {
process.kill(server.pid, "SIGKILL");
}
// Also try to kill process group
process.kill(-server.pid, "SIGKILL");
} catch (e) { }
}
// Suppress all errors during shutdown (systray lib throws JSON parse errors)
let isShuttingDown = false;
process.on("uncaughtException", (err) => {
if (isShuttingDown) return;
console.error("Error:", err.message);
});
// Handle all exit scenarios
process.on("SIGINT", () => {
if (isShuttingDown) return;
isShuttingDown = true;
console.log("\nExiting...");
cleanup();
setTimeout(() => process.exit(0), 100);
});
process.on("SIGTERM", () => {
if (isShuttingDown) return;
isShuttingDown = true;
cleanup();
setTimeout(() => process.exit(0), 100);
});
process.on("SIGHUP", () => {
if (isShuttingDown) return;
isShuttingDown = true;
cleanup();
setTimeout(() => process.exit(0), 100);
});
// Initialize tray icon (runs alongside TUI)
const initTrayIcon = () => {
try {
const { initTray } = require("./src/cli/tray/tray");
initTray({
port,
onQuit: () => {
isShuttingDown = true;
console.log("\n👋 Shutting down from tray...");
cleanup();
setTimeout(() => process.exit(0), 100);
},
onOpenDashboard: () => openBrowser(url)
});
} catch (err) {
// Tray not available - continue without it
}
};
// Tray-only mode: no TUI, just tray icon
if (trayMode) {
console.log(`\n🚀 ${pkg.name} v${pkg.version}`);
console.log(`Server: http://${displayHost}:${port}`);
setTimeout(() => {
initTrayIcon();
console.log("\n💡 Router is now running in system tray. Close this terminal if you want.");
console.log(" Right-click tray icon to open dashboard or quit.\n");
}, 2000);
return;
}
// Wait for server to be ready, then show interface menu loop + tray
setTimeout(async () => {
// Start tray icon alongside TUI
initTrayIcon();
try {
while (true) {
const choice = await showInterfaceMenu(latestVersion);
if (choice === "update") {
isShuttingDown = true;
const { clearScreen } = require("./src/cli/utils/display");
clearScreen();
console.log(`\n⬆ Update v${pkg.version} → v${latestVersion}\n`);
console.log(`Run this after exit:\n`);
console.log(` \x1b[33m${INSTALL_CMD_LATEST}\x1b[0m\n`);
cleanup();
await killAllAppProcesses(port);
await killProcessOnPort(port);
setTimeout(() => process.exit(0), 200);
return;
} else if (choice === "web") {
openBrowser(url);
// Wait for user to come back
const { pause } = require("./src/cli/utils/input");
await pause("\nPress Enter to go back to menu...");
} else if (choice === "terminal") {
// Start Terminal UI - it will return when user selects Back
const { startTerminalUI } = require("./src/cli/terminalUI");
await startTerminalUI(port);
// Loop continues, show menu again
} else if (choice === "hide") {
// Hide to tray - spawn detached background process
const { clearScreen } = require("./src/cli/utils/display");
clearScreen();
// Enable auto startup on OS boot
try {
const { enableAutoStart } = require("./src/cli/tray/autostart");
const enabled = enableAutoStart(__filename);
if (enabled) {
console.log("✅ Auto-start enabled (will run on OS boot)");
}
} catch (e) { }
// Spawn new detached process with --tray flag
const bgProcess = spawn(process.execPath, [__filename, "--tray", "--skip-update", "-p", port.toString()], {
detached: true,
stdio: "ignore",
windowsHide: true,
env: { ...process.env }
});
bgProcess.unref();
console.log(`\n🔔 9Router is now running in background (PID: ${bgProcess.pid})`);
console.log(` Server: http://${displayHost}:${port}`);
console.log(`\n💡 You can close this terminal. Right-click tray icon to:`);
console.log(` • Open Dashboard`);
console.log(` • Quit\n`);
// Exit current process - background process takes over
cleanup();
process.exit(0);
} else if (choice === "exit") {
isShuttingDown = true;
console.log("\nExiting...");
cleanup();
setTimeout(() => process.exit(0), 100);
}
}
} catch (err) {
console.error("Error:", err.message);
cleanup();
process.exit(1);
}
}, 3000);
function attachServerEvents() {
server.on("error", (err) => {
console.error("Failed to start server:", err.message);
if (!isShuttingDown) tryRestart();
else { cleanup(); process.exit(1); }
});
server.on("close", (code) => {
if (isShuttingDown || code === 0) {
process.exit(code || 0);
return;
}
tryRestart(code);
});
}
function tryRestart(code) {
const aliveMs = Date.now() - serverStartTime;
// Reset counter if last run was stable
if (aliveMs >= RESTART_RESET_MS) restartCount = 0;
if (restartCount >= MAX_RESTARTS) {
console.error(`\n⚠️ Server crashed ${MAX_RESTARTS} times. Disabling MITM and restarting...`);
try {
const dbPath = path.join(os.homedir(), process.platform === "win32" ? path.join("AppData", "Roaming", "9router", "db.json") : path.join(".9router", "db.json"));
if (fs.existsSync(dbPath)) {
const db = JSON.parse(fs.readFileSync(dbPath, "utf-8"));
if (db.settings) db.settings.mitmEnabled = false;
fs.writeFileSync(dbPath, JSON.stringify(db, null, 2));
}
} catch { /* best effort */ }
restartCount = 0;
server = spawnServer();
attachServerEvents();
return;
}
restartCount++;
const delay = Math.min(1000 * restartCount, 10000);
console.error(`\n⚠️ Server exited (code=${code ?? "unknown"}). Restarting in ${delay / 1000}s... (${restartCount}/${MAX_RESTARTS})`);
if (crashLog.length) {
console.error("\n--- Server crash log ---");
crashLog.forEach(l => console.error(l));
console.error("--- End crash log ---\n");
}
setTimeout(() => {
server = spawnServer();
attachServerEvents();
}, delay);
}
attachServerEvents();
}

22
cli/hooks/postinstall.js Normal file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env node
// Postinstall: warm-up SQLite deps into ~/.9router/runtime so the first
// `9router` start doesn't need network. Failure here is non-fatal —
// cli.js will retry at runtime if anything is missing.
const { ensureSqliteRuntime } = require("./sqliteRuntime");
const { ensureTrayRuntime } = require("./trayRuntime");
try {
ensureSqliteRuntime({ silent: false });
console.log("[9router] runtime SQLite deps ready");
} catch (e) {
console.warn(`[9router] runtime warm-up skipped: ${e.message}`);
}
try {
ensureTrayRuntime({ silent: false });
} catch (e) {
console.warn(`[9router] tray runtime skipped: ${e.message}`);
}
process.exit(0);

114
cli/hooks/sqliteRuntime.js Normal file
View File

@@ -0,0 +1,114 @@
// Ensure better-sqlite3 is installed in USER_DATA_DIR/runtime/node_modules
// (user-writable, avoids Windows EBUSY locks during npm i -g updates).
// sql.js is bundled in bin/app already; node:sqlite / bun:sqlite are built-in.
const { execSync, spawnSync } = require("child_process");
const fs = require("fs");
const os = require("os");
const path = require("path");
const BETTER_SQLITE3_VERSION = "12.6.2";
function getDataDir() {
if (process.env.DATA_DIR) return process.env.DATA_DIR;
return process.platform === "win32"
? path.join(process.env.APPDATA || os.homedir(), "9router")
: path.join(os.homedir(), ".9router");
}
function getRuntimeDir() {
return path.join(getDataDir(), "runtime");
}
function getRuntimeNodeModules() {
return path.join(getRuntimeDir(), "node_modules");
}
function ensureRuntimeDir() {
const dir = getRuntimeDir();
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
// Minimal package.json so npm treats it as a project root
const pkgPath = path.join(dir, "package.json");
if (!fs.existsSync(pkgPath)) {
fs.writeFileSync(pkgPath, JSON.stringify({
name: "9router-runtime",
version: "1.0.0",
private: true,
description: "User-writable runtime deps for 9router (better-sqlite3 native binary)",
}, null, 2));
}
return dir;
}
function hasModule(name) {
return fs.existsSync(path.join(getRuntimeNodeModules(), name, "package.json"));
}
function isBetterSqliteBinaryValid() {
const binary = path.join(getRuntimeNodeModules(), "better-sqlite3", "build", "Release", "better_sqlite3.node");
if (!fs.existsSync(binary)) return false;
try {
const fd = fs.openSync(binary, "r");
const buf = Buffer.alloc(4);
fs.readSync(fd, buf, 0, 4, 0);
fs.closeSync(fd);
const magic = buf.toString("hex");
if (process.platform === "linux") return magic.startsWith("7f454c46");
if (process.platform === "darwin") return magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe");
if (process.platform === "win32") return magic.startsWith("4d5a");
return true;
} catch { return false; }
}
function npmInstall(pkgs, opts = {}) {
const cwd = ensureRuntimeDir();
const args = ["install", ...pkgs, "--no-audit", "--no-fund", "--prefer-online"];
if (opts.optional) args.push("--no-save");
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
console.log(`[9router][runtime] ${npmCmd} ${args.join(" ")} (cwd: ${cwd})`);
const res = spawnSync(npmCmd, args, {
cwd,
stdio: opts.silent ? "ignore" : "inherit",
timeout: opts.timeout || 180000,
shell: process.platform === "win32",
});
return res.status === 0;
}
// Public: ensure better-sqlite3 native module is installed in user-writable
// runtime dir. sql.js is bundled in bin/app already; node:sqlite is built-in.
// This is purely a *speed optimization* — app works without it via fallbacks.
function ensureSqliteRuntime({ silent = false } = {}) {
ensureRuntimeDir();
const needBetterSqlite = !hasModule("better-sqlite3") || !isBetterSqliteBinaryValid();
if (!needBetterSqlite) {
if (!silent) console.log("[9router][runtime] better-sqlite3 OK");
return { betterSqlite: true };
}
const ok = npmInstall([`better-sqlite3@${BETTER_SQLITE3_VERSION}`], { optional: true, silent });
if (!ok && !silent) {
console.warn("[9router][runtime] better-sqlite3 install failed (will use node:sqlite or sql.js fallback)");
}
return {
betterSqlite: ok && hasModule("better-sqlite3") && isBetterSqliteBinaryValid(),
};
}
// Inject runtime + bundled node_modules into NODE_PATH so child Node processes
// resolve sql.js (bundled in bin/app/node_modules) and better-sqlite3 (runtime).
function buildEnvWithRuntime(baseEnv = process.env) {
const runtimeNm = getRuntimeNodeModules();
const bundledNm = path.join(__dirname, "..", "app", "node_modules");
const existing = baseEnv.NODE_PATH || "";
const NODE_PATH = [runtimeNm, bundledNm, existing].filter(Boolean).join(path.delimiter);
return { ...baseEnv, NODE_PATH };
}
module.exports = {
ensureSqliteRuntime,
buildEnvWithRuntime,
getRuntimeDir,
getRuntimeNodeModules,
};

84
cli/hooks/trayRuntime.js Normal file
View File

@@ -0,0 +1,84 @@
// Lazy install systray for macOS/Linux into USER_DATA_DIR/runtime/node_modules.
// Windows uses PowerShell NotifyIcon (no binary) → no systray needed.
// This keeps the published npm tarball free of unsigned Go binaries that
// trigger antivirus false positives (e.g. Kaspersky flagging tray_windows.exe).
const { spawnSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const { getRuntimeDir, getRuntimeNodeModules } = require("./sqliteRuntime");
const SYSTRAY_VERSION = "1.0.5";
function hasSystray() {
return fs.existsSync(path.join(getRuntimeNodeModules(), "systray", "package.json"));
}
// Remove legacy systray from all known locations on Windows (AV false positive cleanup)
function cleanupWindowsSystray({ silent = false } = {}) {
if (process.platform !== "win32") return;
// 1) Runtime dir: %APPDATA%\9router\runtime\node_modules\systray
// 2) npm global nested: <npm_prefix>\node_modules\9router\node_modules\systray
// __dirname here = <npm_prefix>\node_modules\9router\hooks → up 1 = pkg root
const targets = [
path.join(getRuntimeNodeModules(), "systray"),
path.join(__dirname, "..", "node_modules", "systray")
];
for (const dir of targets) {
if (fs.existsSync(dir)) {
try {
fs.rmSync(dir, { recursive: true, force: true });
if (!silent) console.log(`[9router][runtime] removed legacy systray: ${dir}`);
} catch (e) {
if (!silent) console.warn(`[9router][runtime] failed to remove ${dir}: ${e.message}`);
}
}
}
}
function ensureRuntimeDir() {
const dir = getRuntimeDir();
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const pkgPath = path.join(dir, "package.json");
if (!fs.existsSync(pkgPath)) {
fs.writeFileSync(pkgPath, JSON.stringify({
name: "9router-runtime",
version: "1.0.0",
private: true
}, null, 2));
}
return dir;
}
function npmInstall(pkgs, { silent = false } = {}) {
const cwd = ensureRuntimeDir();
const args = ["install", ...pkgs, "--no-audit", "--no-fund", "--no-save", "--prefer-online"];
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
if (!silent) console.log(`[9router][runtime] ${npmCmd} ${args.join(" ")} (cwd: ${cwd})`);
const res = spawnSync(npmCmd, args, {
cwd,
stdio: silent ? "ignore" : "inherit",
timeout: 120000,
shell: process.platform === "win32"
});
return res.status === 0;
}
// Public: ensure systray is installed on macOS/Linux only.
// Windows skips entirely (uses PowerShell tray).
function ensureTrayRuntime({ silent = false } = {}) {
if (process.platform === "win32") {
cleanupWindowsSystray({ silent });
return { systray: false, skipped: true };
}
if (hasSystray()) {
if (!silent) console.log("[9router][runtime] systray OK");
return { systray: true };
}
const ok = npmInstall([`systray@${SYSTRAY_VERSION}`], { silent });
if (!ok && !silent) {
console.warn("[9router][runtime] systray install failed (tray will be disabled)");
}
return { systray: ok && hasSystray() };
}
module.exports = { ensureTrayRuntime };

39
cli/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "9router",
"version": "0.4.33",
"description": "9Router CLI - Start and manage 9Router server",
"bin": {
"9router": "./cli.js"
},
"files": [
"cli.js",
"src",
"hooks",
"app",
"README.md",
"LICENSE"
],
"scripts": {
"postinstall": "node hooks/postinstall.js",
"prepublishOnly": "cd .. && npm run build:cli"
},
"dependencies": {
"node-forge": "^1.3.3",
"node-machine-id": "^1.1.12",
"react": "19.2.1",
"react-dom": "19.2.1"
},
"comment_sqlite": "sql.js + better-sqlite3 are NOT bundled here. They are installed into ~/.9router/runtime/node_modules by hooks/postinstall.js (and re-checked at runtime by cli.js). This avoids Windows EBUSY errors when updating the global CLI, since native .node files no longer live under the locked install dir.",
"comment_systray": "systray is NOT bundled here. It is lazy-installed into ~/.9router/runtime/node_modules by hooks/postinstall.js on macOS/Linux only. Windows uses PowerShell NotifyIcon (zero binary). This avoids shipping the unsigned Go binary tray_windows.exe that triggers antivirus false positives (Kaspersky).",
"engines": {
"node": ">=18.0.0"
},
"keywords": [
"9router",
"cli",
"proxy",
"ai",
"api"
],
"license": "MIT"
}

84
cli/scripts/buildMitm.js Normal file
View File

@@ -0,0 +1,84 @@
const esbuild = require("esbuild");
const fs = require("fs");
const path = require("path");
// ── Build config ─────────────────────────────────────────
const BUILD_CONFIG = {
bundle: true,
minify: true,
obfuscate: false,
cleanPlainFiles: true,
};
// ─────────────────────────────────────────────────────────
const binDir = path.resolve(__dirname, "..");
const appDir = path.resolve(binDir, "..", "app");
const binMitmDir = path.join(binDir, "app", "src", "mitm");
// Bundle everything — no externals. This keeps MITM runtime self-contained so
// it can be copied to DATA_DIR/runtime/ and spawned from there (escapes
// node_modules file locks that block `npm i -g 9router@latest` on Windows).
const EXTERNALS = [];
const ENTRIES = ["server.js"];
async function buildEntry(entry) {
const mitmSrc = path.join(appDir, "src", "mitm");
const output = path.join(binMitmDir, entry);
const buildPlugin = {
name: "build-plugin",
setup(build) {
// Stub .git file scanned by esbuild
build.onResolve({ filter: /\.git/ }, args => ({ path: args.path, namespace: "git-stub" }));
build.onLoad({ filter: /.*/, namespace: "git-stub" }, () => ({ contents: "module.exports={}", loader: "js" }));
},
};
const steps = [];
if (BUILD_CONFIG.bundle) {
const useTemp = BUILD_CONFIG.obfuscate;
const outfile = useTemp ? output.replace(".js", ".bundled.js") : output;
await esbuild.build({
entryPoints: [path.join(mitmSrc, entry)],
bundle: true,
minify: BUILD_CONFIG.minify,
platform: "node",
target: "node18",
external: EXTERNALS,
plugins: [buildPlugin],
outfile,
});
steps.push("bundled");
if (BUILD_CONFIG.minify) steps.push("minified");
if (BUILD_CONFIG.obfuscate) {
const { execSync } = require("child_process");
execSync(
`npx javascript-obfuscator "${outfile}" --output "${output}" --compact true --string-array true --string-array-encoding base64`,
{ stdio: "inherit", cwd: appDir }
);
fs.unlinkSync(outfile);
steps.push("obfuscated");
}
}
console.log(`${steps.join(" + ")}${output}`);
}
async function run() {
const flags = Object.entries(BUILD_CONFIG).filter(([, v]) => v).map(([k]) => k).join(", ");
console.log(`⚙️ Config: ${flags}`);
for (const entry of ENTRIES) await buildEntry(entry);
if (BUILD_CONFIG.cleanPlainFiles) {
const keep = new Set(ENTRIES);
for (const name of fs.readdirSync(binMitmDir)) {
if (!keep.has(name)) fs.rmSync(path.join(binMitmDir, name), { recursive: true, force: true });
}
console.log("✅ Removed plain MITM files from bin");
}
}
run().catch((e) => { console.error(e); process.exit(1); });

509
cli/src/cli/api/client.js Normal file
View File

@@ -0,0 +1,509 @@
const http = require("http");
const https = require("https");
const crypto = require("crypto");
const { machineIdSync } = require("node-machine-id");
// Default configuration
const DEFAULT_CONFIG = {
host: "localhost",
port: 20128,
protocol: "http:",
};
const CLI_TOKEN_HEADER = "x-9r-cli-token";
const CLI_TOKEN_SALT = "9r-cli-auth";
let config = { ...DEFAULT_CONFIG };
let cachedCliToken = null;
function getCliToken() {
if (cachedCliToken !== null) return cachedCliToken;
try {
const mid = machineIdSync();
cachedCliToken = crypto.createHash("sha256").update(mid + CLI_TOKEN_SALT).digest("hex").substring(0, 16);
} catch {
cachedCliToken = "";
}
return cachedCliToken;
}
/**
* Configure API client
* @param {Object} options - Configuration options
* @param {string} options.host - API host
* @param {number} options.port - API port
* @param {string} options.protocol - Protocol (http: or https:)
*/
function configure(options = {}) {
config = { ...config, ...options };
}
/**
* Make HTTP request to API
* @param {string} method - HTTP method
* @param {string} path - API path
* @param {Object} body - Request body (optional)
* @returns {Promise<Object>} Response with { success, data/error }
*/
function makeRequest(method, path, body = null) {
return new Promise((resolve) => {
const httpModule = config.protocol === "https:" ? https : http;
const options = {
hostname: config.host,
port: config.port,
path: path,
method: method,
headers: {
"Content-Type": "application/json",
[CLI_TOKEN_HEADER]: getCliToken(),
},
};
// Add Content-Length for POST/PUT requests
if (body && (method === "POST" || method === "PUT" || method === "PATCH")) {
const bodyString = JSON.stringify(body);
options.headers["Content-Length"] = Buffer.byteLength(bodyString);
}
const req = httpModule.request(options, (res) => {
let data = "";
res.on("data", (chunk) => {
data += chunk;
});
res.on("end", () => {
try {
const parsed = data ? JSON.parse(data) : {};
// Check if response indicates error
if (res.statusCode >= 400 || parsed.error) {
resolve({
success: false,
error: parsed.error || `HTTP ${res.statusCode}`,
statusCode: res.statusCode,
});
} else {
resolve({
success: true,
data: parsed,
statusCode: res.statusCode,
});
}
} catch (err) {
resolve({
success: false,
error: `Failed to parse response: ${err.message}`,
});
}
});
});
req.on("error", (err) => {
resolve({
success: false,
error: `Network error: ${err.message}`,
});
});
req.on("timeout", () => {
req.destroy();
resolve({
success: false,
error: "Request timeout",
});
});
// Set timeout (30 seconds)
req.setTimeout(30000);
// Write body if present
if (body && (method === "POST" || method === "PUT" || method === "PATCH")) {
req.write(JSON.stringify(body));
}
req.end();
});
}
// ============================================================================
// PROVIDERS API
// ============================================================================
/**
* Get all providers
* @returns {Promise<Object>} { success, data: { connections } }
*/
async function getProviders() {
return makeRequest("GET", "/api/providers");
}
/**
* Get provider by ID
* @param {string} id - Provider ID
* @returns {Promise<Object>} { success, data: { connection } }
*/
async function getProviderById(id) {
return makeRequest("GET", `/api/providers/${id}`);
}
/**
* Test provider connection
* @param {string} id - Provider ID
* @returns {Promise<Object>} { success, data: { valid, error } }
*/
async function testProvider(id) {
return makeRequest("POST", `/api/providers/${id}/test`);
}
/**
* Delete provider
* @param {string} id - Provider ID
* @returns {Promise<Object>} { success, data: { message } }
*/
async function deleteProvider(id) {
return makeRequest("DELETE", `/api/providers/${id}`);
}
/**
* Get provider models
* @param {string} id - Provider ID
* @returns {Promise<Object>} { success, data: { provider, connectionId, models } }
*/
async function getProviderModels(id) {
return makeRequest("GET", `/api/providers/${id}/models`);
}
// ============================================================================
// OAUTH API
// ============================================================================
/**
* Get OAuth authorization URL
* @param {string} provider - Provider ID
* @returns {Promise<Object>} { success, data: { authUrl, codeVerifier, state, redirectUri } }
*/
async function getOAuthAuthUrl(provider) {
// Codex requires fixed port 1455 and path /auth/callback
const redirectUri = provider === "codex"
? "http://localhost:1455/auth/callback"
: "http://localhost:20128/callback";
return makeRequest("GET", `/api/oauth/${provider}/authorize?redirect_uri=${encodeURIComponent(redirectUri)}`);
}
/**
* Exchange OAuth authorization code for token
* @param {string} provider - Provider ID
* @param {Object} data - { code, redirectUri, codeVerifier, state }
* @returns {Promise<Object>} { success, data }
*/
async function exchangeOAuthCode(provider, data) {
return makeRequest("POST", `/api/oauth/${provider}/exchange`, data);
}
/**
* Get OAuth device code
* @param {string} provider - Provider ID
* @returns {Promise<Object>} { success, data: { device_code, user_code, verification_uri, verification_uri_complete, codeVerifier, extraData } }
*/
async function getOAuthDeviceCode(provider) {
return makeRequest("GET", `/api/oauth/${provider}/device-code`);
}
/**
* Poll OAuth token using device code
* @param {string} provider - Provider ID
* @param {Object} data - { deviceCode, codeVerifier, extraData }
* @returns {Promise<Object>} { success, data: { pending } }
*/
async function pollOAuthToken(provider, data) {
return makeRequest("POST", `/api/oauth/${provider}/poll`, data);
}
/**
* Create API key provider connection
* @param {Object} data - { provider, name, apiKey }
* @returns {Promise<Object>} { success, data }
*/
async function createApiKeyProvider(data) {
return makeRequest("POST", "/api/providers", data);
}
/**
* Update provider connection
* @param {string} id - Connection ID
* @param {Object} data - { name, priority, defaultModel, isActive }
* @returns {Promise<Object>} { success, data: { connection } }
*/
async function updateConnection(id, data) {
return makeRequest("PUT", `/api/providers/${id}`, data);
}
// ============================================================================
// API KEYS API
// ============================================================================
/**
* Get all API keys
* @returns {Promise<Object>} { success, data: { keys } }
*/
async function getApiKeys() {
return makeRequest("GET", "/api/keys");
}
/**
* Create new API key
* @param {string} name - Key name
* @returns {Promise<Object>} { success, data: { key, name, id, machineId } }
*/
async function createApiKey(name) {
return makeRequest("POST", "/api/keys", { name });
}
/**
* Delete API key
* @param {string} id - Key ID
* @returns {Promise<Object>} { success, data: { success } }
*/
async function deleteApiKey(id) {
return makeRequest("DELETE", `/api/keys/${id}`);
}
// ============================================================================
// COMBOS API
// ============================================================================
/**
* Get all combos
* @returns {Promise<Object>} { success, data: { combos } }
*/
async function getCombos() {
return makeRequest("GET", "/api/combos");
}
/**
* Get combo by ID
* @param {string} id - Combo ID
* @returns {Promise<Object>} { success, data: combo }
*/
async function getComboById(id) {
return makeRequest("GET", `/api/combos/${id}`);
}
/**
* Create new combo
* @param {Object} data - Combo data { name, models }
* @returns {Promise<Object>} { success, data: combo }
*/
async function createCombo(data) {
return makeRequest("POST", "/api/combos", data);
}
/**
* Update combo
* @param {string} id - Combo ID
* @param {Object} data - Update data { name?, models? }
* @returns {Promise<Object>} { success, data: combo }
*/
async function updateCombo(id, data) {
return makeRequest("PUT", `/api/combos/${id}`, data);
}
/**
* Delete combo
* @param {string} id - Combo ID
* @returns {Promise<Object>} { success, data: { success } }
*/
async function deleteCombo(id) {
return makeRequest("DELETE", `/api/combos/${id}`);
}
// ============================================================================
// CLI TOOLS API
// ============================================================================
/**
* Get CLI tool settings
* @param {string} tool - Tool name: claude | codex | droid | openclaw
* @returns {Promise<Object>} { success, data: { installed, has9Router, ... } }
*/
async function getCliToolSettings(tool) {
return makeRequest("GET", `/api/cli-tools/${tool}-settings`);
}
/**
* Apply CLI tool settings (POST)
* @param {string} tool - Tool name: claude | codex | droid | openclaw
* @param {Object} body - Payload depends on tool
* @returns {Promise<Object>} { success, data }
*/
async function applyCliToolSettings(tool, body) {
return makeRequest("POST", `/api/cli-tools/${tool}-settings`, body);
}
/**
* Reset CLI tool settings (DELETE)
* @param {string} tool - Tool name: claude | codex | droid | openclaw
* @returns {Promise<Object>} { success, data }
*/
async function resetCliToolSettings(tool) {
return makeRequest("DELETE", `/api/cli-tools/${tool}-settings`);
}
// ============================================================================
// SETTINGS API
// ============================================================================
/**
* Get settings
* @returns {Promise<Object>} { success, data: settings }
*/
async function getSettings() {
return makeRequest("GET", "/api/settings");
}
/**
* Update settings
* @param {Object} data - Settings data
* @returns {Promise<Object>} { success, data: settings }
*/
async function updateSettings(data) {
return makeRequest("PATCH", "/api/settings", data);
}
// ============================================================================
// MODELS API
// ============================================================================
/**
* Get all models (internal API)
* @returns {Promise<Object>} { success, data: { models } }
*/
async function getModels() {
return makeRequest("GET", "/api/models");
}
/**
* Get available models from active providers + combos (OpenAI compatible)
* @returns {Promise<Object>} { success, data: { object, data: [...models] } }
*/
async function getAvailableModels() {
return makeRequest("GET", "/v1/models");
}
// ============================================================================
// PROVIDER NODES API (custom providers)
// ============================================================================
async function getProviderNodes() {
return makeRequest("GET", "/api/provider-nodes");
}
async function createProviderNode(data) {
return makeRequest("POST", "/api/provider-nodes", data);
}
async function updateProviderNode(id, data) {
return makeRequest("PUT", `/api/provider-nodes/${id}`, data);
}
async function deleteProviderNode(id) {
return makeRequest("DELETE", `/api/provider-nodes/${id}`);
}
async function validateProviderNode(data) {
return makeRequest("POST", "/api/provider-nodes/validate", data);
}
// ============================================================================
// TUNNEL API
// ============================================================================
/**
* Get tunnel status
* @returns {Promise<Object>} { success, data: { enabled, tunnelUrl, shortId, running } }
*/
async function getTunnelStatus() {
return makeRequest("GET", "/api/tunnel/status");
}
/**
* Enable tunnel
* @returns {Promise<Object>} { success, data: { tunnelUrl, shortId } }
*/
async function enableTunnel() {
return makeRequest("POST", "/api/tunnel/enable");
}
/**
* Disable tunnel
* @returns {Promise<Object>} { success, data: { success } }
*/
async function disableTunnel() {
return makeRequest("POST", "/api/tunnel/disable");
}
// ============================================================================
// EXPORTS
// ============================================================================
module.exports = {
configure,
// Providers
getProviders,
getProviderById,
testProvider,
deleteProvider,
getProviderModels,
// Connection aliases
testConnection: testProvider,
deleteConnection: deleteProvider,
updateConnection,
// OAuth
getOAuthAuthUrl,
exchangeOAuthCode,
getOAuthDeviceCode,
pollOAuthToken,
createApiKeyProvider,
// API Keys
getApiKeys,
createApiKey,
deleteApiKey,
// Combos
getCombos,
getComboById,
createCombo,
updateCombo,
deleteCombo,
// CLI Tools
getCliToolSettings,
applyCliToolSettings,
resetCliToolSettings,
// Settings
getSettings,
updateSettings,
// Tunnel
getTunnelStatus,
enableTunnel,
disableTunnel,
// Models
getModels,
getAvailableModels,
// Provider Nodes (custom providers)
getProviderNodes,
createProviderNode,
updateProviderNode,
deleteProviderNode,
validateProviderNode,
};

View File

@@ -0,0 +1,233 @@
const api = require("../api/client");
const { prompt, confirm, pause } = require("../utils/input");
const { clearScreen, showStatus, showHeader } = require("../utils/display");
const { maskKey, formatDate, getRelativeTime } = require("../utils/format");
const { showMenuWithBack } = require("../utils/menuHelper");
const { copyToClipboard } = require("../utils/clipboard");
const { getEndpoint } = require("../utils/endpoint");
/**
* Display API keys list with formatted output
* @param {Array} keys - Array of API key objects
* @param {number} port - Server port
*/
function displayApiKeys(keys, port) {
console.log("┌─────────────────────────────────────────────────────────┐");
console.log("│ 🔑 API Keys Management │");
console.log("├─────────────────────────────────────────────────────────┤");
// Note: This function is legacy, endpoint shown in menu header instead
console.log("│ │");
if (keys.length === 0) {
console.log("│ No API keys found. │");
} else {
console.log(`│ Your API Keys (${keys.length}):${" ".repeat(42 - String(keys.length).length)}`);
keys.forEach((key, index) => {
console.log("│ │");
console.log(`${index + 1}. ${key.name}${" ".repeat(52 - String(index + 1).length - key.name.length)}`);
const maskedKey = maskKey(key.key);
console.log(`│ Key: ${maskedKey}${" ".repeat(47 - maskedKey.length)}`);
const created = formatDate(key.createdAt);
console.log(`│ Created: ${created}${" ".repeat(43 - created.length)}`);
if (key.lastUsedAt) {
const lastUsed = getRelativeTime(key.lastUsedAt);
console.log(`│ Last used: ${lastUsed}${" ".repeat(41 - lastUsed.length)}`);
} else {
console.log("│ Last used: Never │");
}
});
}
console.log("│ │");
console.log("│ Actions: │");
console.log("│ 1. Create New API Key │");
console.log("│ 2. View Full Key (by number) │");
console.log("│ 3. Copy Key to Clipboard (by number) │");
console.log("│ 4. Delete Key (by number) │");
console.log("│ 0. ← Back to Main Menu │");
console.log("└─────────────────────────────────────────────────────────┘");
}
/**
* Handle creating new API key
* @returns {Promise<boolean>} Success status
*/
async function handleCreateKey() {
console.log("\n📝 Create New API Key");
console.log("─".repeat(30));
const name = await prompt("Enter key name: ");
if (!name) {
showStatus("Key name cannot be empty", "error");
await pause();
return false;
}
const result = await api.createApiKey(name);
if (!result.success) {
showStatus(`Failed to create key: ${result.error}`, "error");
await pause();
return false;
}
console.log("\n✅ API Key created successfully!");
console.log("\n⚠ IMPORTANT: Save this key now. You won't be able to see it again!");
console.log(`\nKey: ${result.data.key}`);
console.log(`Name: ${result.data.name}`);
console.log(`ID: ${result.data.id}`);
const shouldCopy = await confirm("\nCopy key to clipboard?");
if (shouldCopy) {
if (copyToClipboard(result.data.key)) {
showStatus("Key copied to clipboard!", "success");
} else {
showStatus("Failed to copy to clipboard", "error");
}
}
await pause();
return true;
}
/**
* Handle viewing full API key
* @param {Object} key - API key object
*/
async function handleViewFullKey(key) {
console.log("\n🔍 Full API Key");
console.log("─".repeat(30));
console.log(`Name: ${key.name}`);
console.log(`Key: ${key.key}`);
console.log(`ID: ${key.id}`);
console.log(`Created: ${formatDate(key.createdAt)}`);
if (key.lastUsedAt) {
console.log(`Last used: ${getRelativeTime(key.lastUsedAt)}`);
} else {
console.log("Last used: Never");
}
await pause();
}
/**
* Handle copying API key to clipboard
* @param {Object} key - API key object
*/
async function handleCopyKey(key) {
if (copyToClipboard(key.key)) {
showStatus(`Key "${key.name}" copied to clipboard!`, "success");
} else {
showStatus("Failed to copy to clipboard", "error");
}
await pause();
}
/**
* Handle deleting API key
* @param {Object} key - API key object
* @returns {Promise<boolean>} Success status
*/
async function handleDeleteKey(key) {
console.log(`\n⚠️ Delete API Key: ${key.name}`);
console.log("─".repeat(30));
console.log(`Key: ${maskKey(key.key)}`);
console.log(`Created: ${formatDate(key.createdAt)}`);
const confirmed = await confirm("\nAre you sure you want to delete this key?");
if (!confirmed) {
showStatus("Deletion cancelled", "info");
await pause();
return false;
}
const result = await api.deleteApiKey(key.id);
if (!result.success) {
showStatus(`Failed to delete key: ${result.error}`, "error");
await pause();
return false;
}
showStatus("API key deleted successfully", "success");
await pause();
return true;
}
/**
* Show actions for a specific key
* @param {Object} key - API key object
* @param {number} port - Server port
* @param {Array<string>} breadcrumb - Breadcrumb path
*/
async function showKeyActions(key, port, breadcrumb = []) {
const { endpoint } = await getEndpoint(port);
await showMenuWithBack({
title: `🔑 ${key.name}`,
breadcrumb: [...breadcrumb, key.name],
headerContent: `Name: ${key.name}\nKey: ${key.key}\nEndpoint: ${endpoint}`,
items: [
{
label: "Copy to Clipboard",
action: async () => {
await handleCopyKey(key);
return true;
}
},
{
label: "Delete Key",
action: async () => {
await handleDeleteKey(key);
return false; // Exit after delete
}
}
]
});
}
/**
* Main API Keys menu
* @param {number} port - Server port number
* @param {Array<string>} breadcrumb - Breadcrumb path
*/
async function showApiKeysMenu(port, breadcrumb = []) {
const { showListMenu } = require("../utils/menuHelper");
const { endpoint } = await getEndpoint(port);
await showListMenu({
title: "🔑 API Keys Management",
breadcrumb,
headerContent: `Endpoint: ${endpoint}`,
fetchItems: async () => {
const result = await api.getApiKeys();
if (!result.success) {
clearScreen();
showStatus(`Failed to fetch API keys: ${result.error}`, "error");
await pause();
return null;
}
return { items: result.data.keys || [] };
},
formatItem: (key) => `${key.name} (${maskKey(key.key)})`,
onSelect: async (key) => {
await showKeyActions(key, port, breadcrumb);
},
createAction: {
label: "Create New API Key",
action: async () => {
await handleCreateKey();
}
}
});
}
module.exports = {
showApiKeysMenu
};

View File

@@ -0,0 +1,618 @@
const api = require("../api/client");
const { pause, confirm } = require("../utils/input");
const { showStatus } = require("../utils/display");
const { selectModelFromList } = require("../utils/modelSelector");
const { showMenuWithBack } = require("../utils/menuHelper");
const { getEndpoint } = require("../utils/endpoint");
const COLORS = {
reset: "\x1b[0m",
green: "\x1b[32m",
red: "\x1b[31m",
dim: "\x1b[2m",
cyan: "\x1b[36m"
};
// Claude model types with defaults (matching Web UI)
const CLAUDE_MODEL_TYPES = [
{ id: "sonnet", name: "Sonnet", envKey: "ANTHROPIC_DEFAULT_SONNET_MODEL", defaultValue: "cc/claude-sonnet-4-5-20250929" },
{ id: "opus", name: "Opus", envKey: "ANTHROPIC_DEFAULT_OPUS_MODEL", defaultValue: "cc/claude-opus-4-5-20251101" },
{ id: "haiku", name: "Haiku", envKey: "ANTHROPIC_DEFAULT_HAIKU_MODEL", defaultValue: "cc/claude-haiku-4-5-20251001" },
];
// ─── Shared helpers ───────────────────────────────────────────────────────────
/**
* Get first available API key from server
* @returns {Promise<string|null>}
*/
async function getFirstApiKey() {
const result = await api.getApiKeys();
const keys = result.success ? (result.data.keys || []) : [];
return keys.length > 0 ? keys[0].key : null;
}
// ─── Claude Code ──────────────────────────────────────────────────────────────
/**
* Build header showing current Claude config status
* @returns {Promise<string>}
*/
async function buildClaudeHeader() {
const result = await api.getCliToolSettings("claude");
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
const settings = result.data.settings;
const currentUrl = settings?.env?.ANTHROPIC_BASE_URL;
const currentKey = settings?.env?.ANTHROPIC_AUTH_TOKEN;
const lines = [];
if (currentUrl) {
lines.push(`Status: ${COLORS.green}✓ Configured${COLORS.reset}`);
lines.push(`Endpoint: ${COLORS.cyan}${currentUrl}${COLORS.reset}`);
if (currentKey) {
lines.push(`API Key: ${COLORS.dim}${currentKey.substring(0, 10)}...${COLORS.reset}`);
}
} else {
lines.push(`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`);
lines.push(`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`);
}
return lines.join("\n");
}
/**
* Get current Claude model from settings
* @param {string} envKey
* @returns {Promise<string>}
*/
async function getClaudeModel(envKey) {
const result = await api.getCliToolSettings("claude");
return result.success ? (result.data.settings?.env?.[envKey] || "Not set") : "Not set";
}
/**
* Quick setup for Claude Code — sets endpoint, key, and all default models
* @param {number} port
*/
async function claudeQuickSetup(port) {
const { endpoint } = await getEndpoint(port);
const apiKey = await getFirstApiKey();
if (!apiKey) {
showStatus("No API keys found. Create one in API Keys menu first.", "error");
await pause();
return;
}
const env = { ANTHROPIC_BASE_URL: endpoint, ANTHROPIC_AUTH_TOKEN: apiKey, API_TIMEOUT_MS: "600000" };
CLAUDE_MODEL_TYPES.forEach(t => { env[t.envKey] = t.defaultValue; });
const result = await api.applyCliToolSettings("claude", { env });
showStatus(result.success ? "Quick Setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Select and save a specific Claude model type
* @param {Object} modelType
* @param {number} port
*/
async function claudeSelectModel(modelType, port) {
const current = await getClaudeModel(modelType.envKey);
const selected = await selectModelFromList(`Select ${modelType.name} Model`, current, { excludeCombos: true });
if (!selected) return;
const env = { [modelType.envKey]: selected };
// Also set base URL if not configured yet
const settingsResult = await api.getCliToolSettings("claude");
if (!settingsResult.data?.settings?.env?.ANTHROPIC_BASE_URL) {
const { endpoint } = await getEndpoint(port);
const apiKey = await getFirstApiKey();
env.ANTHROPIC_BASE_URL = endpoint;
env.API_TIMEOUT_MS = "600000";
if (apiKey) env.ANTHROPIC_AUTH_TOKEN = apiKey;
}
const result = await api.applyCliToolSettings("claude", { env });
showStatus(result.success ? `${modelType.name}${selected} saved!` : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Reset Claude Code settings
*/
async function claudeReset() {
const result = await api.resetCliToolSettings("claude");
showStatus(result.success ? "Settings reset successfully!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Claude Code submenu
* @param {number} port
* @param {Array<string>} breadcrumb
*/
async function showClaudeCodeMenu(port, breadcrumb = []) {
await showMenuWithBack({
title: "🔧 Claude Code Settings",
breadcrumb,
headerContent: buildClaudeHeader,
refresh: async () => ({
sonnet: await getClaudeModel("ANTHROPIC_DEFAULT_SONNET_MODEL"),
opus: await getClaudeModel("ANTHROPIC_DEFAULT_OPUS_MODEL"),
haiku: await getClaudeModel("ANTHROPIC_DEFAULT_HAIKU_MODEL"),
}),
items: [
{
label: "⚡ Quick Setup (recommended)",
action: async () => { await claudeQuickSetup(port); return true; }
},
{
label: (d) => `Sonnet → ${d.sonnet}`,
action: async () => { await claudeSelectModel(CLAUDE_MODEL_TYPES[0], port); return true; }
},
{
label: (d) => `Opus → ${d.opus}`,
action: async () => { await claudeSelectModel(CLAUDE_MODEL_TYPES[1], port); return true; }
},
{
label: (d) => `Haiku → ${d.haiku}`,
action: async () => { await claudeSelectModel(CLAUDE_MODEL_TYPES[2], port); return true; }
},
{
label: "Reset to Default",
action: async () => { await claudeReset(); return true; }
}
]
});
}
// ─── Codex CLI ────────────────────────────────────────────────────────────────
/**
* Build header showing current Codex config status
* @returns {Promise<string>}
*/
async function buildCodexHeader() {
const result = await api.getCliToolSettings("codex");
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
const { installed, has9Router, config } = result.data;
if (!installed) return `Status: ${COLORS.red}✗ Codex CLI not installed${COLORS.reset}`;
if (!has9Router) {
return [
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
].join("\n");
}
// Parse base_url and model from raw TOML string
const baseUrlMatch = config && config.match(/base_url\s*=\s*"([^"]+)"/);
const modelMatch = config && config.match(/^model\s*=\s*"([^"]+)"/m);
const baseUrl = baseUrlMatch ? baseUrlMatch[1] : "";
const model = modelMatch ? modelMatch[1] : "";
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
if (baseUrl) lines.push(`Endpoint: ${COLORS.cyan}${baseUrl}${COLORS.reset}`);
if (model) lines.push(`Model: ${COLORS.dim}${model}${COLORS.reset}`);
return lines.join("\n");
}
/**
* Quick setup for Codex CLI
* @param {number} port
*/
async function codexQuickSetup(port) {
const { endpoint } = await getEndpoint(port);
const apiKey = await getFirstApiKey();
if (!apiKey) {
showStatus("No API keys found. Create one in API Keys menu first.", "error");
await pause();
return;
}
// Get model selection
const model = await selectModelFromList("Select Codex Model", "cx/claude-sonnet-4-5-20250929", { excludeCombos: true });
if (!model) return;
const result = await api.applyCliToolSettings("codex", { baseUrl: endpoint, apiKey, model });
showStatus(result.success ? "Codex setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Reset Codex CLI settings
*/
async function codexReset() {
const result = await api.resetCliToolSettings("codex");
showStatus(result.success ? "Codex settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Codex CLI submenu
* @param {number} port
* @param {Array<string>} breadcrumb
*/
async function showCodexMenu(port, breadcrumb = []) {
await showMenuWithBack({
title: "🤖 Codex CLI Settings",
breadcrumb,
headerContent: buildCodexHeader,
refresh: async () => ({}),
items: [
{
label: "⚡ Quick Setup",
action: async () => { await codexQuickSetup(port); return true; }
},
{
label: "Reset to Default",
action: async () => { await codexReset(); return true; }
}
]
});
}
// ─── Factory Droid ────────────────────────────────────────────────────────────
/**
* Build header showing current Droid config status
* @returns {Promise<string>}
*/
async function buildDroidHeader() {
const result = await api.getCliToolSettings("droid");
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
const { installed, has9Router, settings } = result.data;
if (!installed) return `Status: ${COLORS.red}✗ Factory Droid not installed${COLORS.reset}`;
if (!has9Router) {
return [
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
].join("\n");
}
// Extract 9Router custom model config
const custom = settings?.customModels?.find(m => m.id === "custom:9Router-0");
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
if (custom?.baseUrl) lines.push(`Endpoint: ${COLORS.cyan}${custom.baseUrl}${COLORS.reset}`);
if (custom?.model) lines.push(`Model: ${COLORS.dim}${custom.model}${COLORS.reset}`);
return lines.join("\n");
}
/**
* Quick setup for Factory Droid
* @param {number} port
*/
async function droidQuickSetup(port) {
const { endpoint } = await getEndpoint(port);
const apiKey = await getFirstApiKey();
if (!apiKey) {
showStatus("No API keys found. Create one in API Keys menu first.", "error");
await pause();
return;
}
const model = await selectModelFromList("Select Droid Model", "cc/claude-sonnet-4-5-20250929", { excludeCombos: true });
if (!model) return;
const result = await api.applyCliToolSettings("droid", { baseUrl: endpoint, apiKey, model });
showStatus(result.success ? "Factory Droid setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Reset Factory Droid settings
*/
async function droidReset() {
const result = await api.resetCliToolSettings("droid");
showStatus(result.success ? "Factory Droid settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Factory Droid submenu
* @param {number} port
* @param {Array<string>} breadcrumb
*/
async function showDroidMenu(port, breadcrumb = []) {
await showMenuWithBack({
title: "🤖 Factory Droid Settings",
breadcrumb,
headerContent: buildDroidHeader,
refresh: async () => ({}),
items: [
{
label: "⚡ Quick Setup",
action: async () => { await droidQuickSetup(port); return true; }
},
{
label: "Reset to Default",
action: async () => { await droidReset(); return true; }
}
]
});
}
// ─── Open Claw ────────────────────────────────────────────────────────────────
/**
* Build header showing current OpenClaw config status
* @returns {Promise<string>}
*/
async function buildOpenClawHeader() {
const result = await api.getCliToolSettings("openclaw");
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
const { installed, has9Router, settings } = result.data;
if (!installed) return `Status: ${COLORS.red}✗ Open Claw not installed${COLORS.reset}`;
if (!has9Router) {
return [
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
].join("\n");
}
// Extract 9Router provider config
const provider = settings?.models?.providers?.["9router"];
const primary = settings?.agents?.defaults?.model?.primary || "";
const model = primary.startsWith("9router/") ? primary.replace("9router/", "") : (provider?.models?.[0]?.id || "");
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
if (provider?.baseUrl) lines.push(`Endpoint: ${COLORS.cyan}${provider.baseUrl}${COLORS.reset}`);
if (model) lines.push(`Model: ${COLORS.dim}${model}${COLORS.reset}`);
return lines.join("\n");
}
/**
* Quick setup for Open Claw
* @param {number} port
*/
async function openClawQuickSetup(port) {
const { endpoint } = await getEndpoint(port);
const apiKey = await getFirstApiKey();
if (!apiKey) {
showStatus("No API keys found. Create one in API Keys menu first.", "error");
await pause();
return;
}
const model = await selectModelFromList("Select OpenClaw Model", "cc/claude-sonnet-4-5-20250929", { excludeCombos: true });
if (!model) return;
const result = await api.applyCliToolSettings("openclaw", { baseUrl: endpoint, apiKey, model });
showStatus(result.success ? "Open Claw setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Reset Open Claw settings
*/
async function openClawReset() {
const result = await api.resetCliToolSettings("openclaw");
showStatus(result.success ? "Open Claw settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Open Claw submenu
* @param {number} port
* @param {Array<string>} breadcrumb
*/
async function showOpenClawMenu(port, breadcrumb = []) {
await showMenuWithBack({
title: "🦞 Open Claw Settings",
breadcrumb,
headerContent: buildOpenClawHeader,
refresh: async () => ({}),
items: [
{
label: "⚡ Quick Setup",
action: async () => { await openClawQuickSetup(port); return true; }
},
{
label: "Reset to Default",
action: async () => { await openClawReset(); return true; }
}
]
});
}
// ─── OpenCode CLI ─────────────────────────────────────────────────────────────
async function buildOpenCodeHeader() {
const result = await api.getCliToolSettings("opencode");
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
const { installed, has9Router, opencode } = result.data;
if (!installed) return `Status: ${COLORS.red}✗ OpenCode CLI not installed${COLORS.reset}`;
if (!has9Router) {
return [
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
].join("\n");
}
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
if (opencode?.baseURL) lines.push(`Endpoint: ${COLORS.cyan}${opencode.baseURL}${COLORS.reset}`);
if (opencode?.activeModel) lines.push(`Active: ${COLORS.dim}${opencode.activeModel}${COLORS.reset}`);
if (Array.isArray(opencode?.models) && opencode.models.length > 0) {
lines.push(`Models: ${COLORS.dim}${opencode.models.join(", ")}${COLORS.reset}`);
}
return lines.join("\n");
}
async function openCodeQuickSetup(port) {
const { endpoint } = await getEndpoint(port);
const apiKey = await getFirstApiKey();
if (!apiKey) {
showStatus("No API keys found. Create one in API Keys menu first.", "error");
await pause();
return;
}
// Pick first model (also becomes active model by default)
const firstModel = await selectModelFromList("Select Active Model (OpenCode)", "", { excludeCombos: true });
if (!firstModel) return;
const models = [firstModel];
// Optionally add more models
while (true) {
const more = await confirm(`Add another model? (current: ${models.length})`);
if (!more) break;
const next = await selectModelFromList(`Add Model #${models.length + 1}`, models.join(", "), { excludeCombos: true });
if (!next) break;
if (!models.includes(next)) models.push(next);
}
// Optional subagent model
let subagentModel = firstModel;
const wantSubagent = await confirm(`Set a different subagent model? (default: ${firstModel})`);
if (wantSubagent) {
const picked = await selectModelFromList("Select Subagent Model", firstModel, { excludeCombos: true });
if (picked) subagentModel = picked;
}
const result = await api.applyCliToolSettings("opencode", {
baseUrl: endpoint,
apiKey,
models,
activeModel: firstModel,
subagentModel,
});
showStatus(result.success ? "OpenCode setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
async function openCodeReset() {
const result = await api.resetCliToolSettings("opencode");
showStatus(result.success ? "OpenCode settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
async function showOpenCodeMenu(port, breadcrumb = []) {
await showMenuWithBack({
title: "💻 OpenCode CLI Settings",
breadcrumb,
headerContent: buildOpenCodeHeader,
refresh: async () => ({}),
items: [
{ label: "⚡ Quick Setup", action: async () => { await openCodeQuickSetup(port); return true; } },
{ label: "Reset to Default", action: async () => { await openCodeReset(); return true; } }
]
});
}
// ─── Hermes Agent ─────────────────────────────────────────────────────────────
async function buildHermesHeader() {
const result = await api.getCliToolSettings("hermes");
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
const { installed, has9Router, settings } = result.data;
if (!installed) return `Status: ${COLORS.red}✗ Hermes Agent not installed${COLORS.reset}`;
if (!has9Router) {
return [
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
].join("\n");
}
const model = settings?.model || {};
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
if (model.base_url) lines.push(`Endpoint: ${COLORS.cyan}${model.base_url}${COLORS.reset}`);
if (model.default) lines.push(`Model: ${COLORS.dim}${model.default}${COLORS.reset}`);
return lines.join("\n");
}
async function hermesQuickSetup(port) {
const { endpoint } = await getEndpoint(port);
const apiKey = await getFirstApiKey();
if (!apiKey) {
showStatus("No API keys found. Create one in API Keys menu first.", "error");
await pause();
return;
}
const model = await selectModelFromList("Select Hermes Model", "", { excludeCombos: true });
if (!model) return;
const result = await api.applyCliToolSettings("hermes", { baseUrl: endpoint, apiKey, model });
showStatus(result.success ? "Hermes setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
async function hermesReset() {
const result = await api.resetCliToolSettings("hermes");
showStatus(result.success ? "Hermes settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
async function showHermesMenu(port, breadcrumb = []) {
await showMenuWithBack({
title: "⚡ Hermes Agent Settings",
breadcrumb,
headerContent: buildHermesHeader,
refresh: async () => ({}),
items: [
{ label: "⚡ Quick Setup", action: async () => { await hermesQuickSetup(port); return true; } },
{ label: "Reset to Default", action: async () => { await hermesReset(); return true; } }
]
});
}
// ─── Main CLI Tools Menu ──────────────────────────────────────────────────────
/**
* Main CLI Tools menu
* @param {number} port
* @param {Array<string>} breadcrumb
*/
async function showCliToolsMenu(port, breadcrumb = []) {
const { endpoint } = await getEndpoint(port);
await showMenuWithBack({
title: "🔧 CLI Tools",
breadcrumb,
headerContent: `Configure CLI tools to use 9Router\nEndpoint: ${endpoint}`,
items: [
{
label: "Claude Code",
action: async () => { await showClaudeCodeMenu(port, [...breadcrumb, "Claude Code"]); return true; }
},
{
label: "Codex CLI",
action: async () => { await showCodexMenu(port, [...breadcrumb, "Codex CLI"]); return true; }
},
{
label: "Factory Droid",
action: async () => { await showDroidMenu(port, [...breadcrumb, "Factory Droid"]); return true; }
},
{
label: "Open Claw",
action: async () => { await showOpenClawMenu(port, [...breadcrumb, "Open Claw"]); return true; }
},
{
label: "OpenCode",
action: async () => { await showOpenCodeMenu(port, [...breadcrumb, "OpenCode"]); return true; }
},
{
label: "Hermes",
action: async () => { await showHermesMenu(port, [...breadcrumb, "Hermes"]); return true; }
}
]
});
}
module.exports = { showCliToolsMenu };

477
cli/src/cli/menus/combos.js Normal file
View File

@@ -0,0 +1,477 @@
const api = require("../api/client");
const { prompt, confirm, pause } = require("../utils/input");
const { clearScreen, showStatus, showHeader } = require("../utils/display");
const { formatDate } = require("../utils/format");
const { selectModelFromList } = require("../utils/modelSelector");
const { showMenuWithBack } = require("../utils/menuHelper");
/**
* Format model to string (handle both string and object)
*/
function formatModel(model) {
if (typeof model === "string") return model;
if (model && typeof model === "object") {
return model.id || model.name || `${model.provider}/${model.model}` || JSON.stringify(model);
}
return String(model);
}
/**
* Show actions for a specific combo
* @param {Object} combo - Combo object
* @param {Array<string>} breadcrumb - Breadcrumb path
*/
async function showComboActions(combo, breadcrumb = []) {
const modelsChain = Array.isArray(combo.models)
? combo.models.map(formatModel).join(" → ")
: "";
await showMenuWithBack({
title: `🔀 ${combo.name}`,
breadcrumb: [...breadcrumb, combo.name],
headerContent: `Name: ${combo.name}\nModels: ${modelsChain}`,
items: [
{
label: "Edit Combo",
action: async () => {
await handleEditSingleCombo(combo);
return true;
}
},
{
label: "Delete Combo",
action: async () => {
await handleDeleteSingleCombo(combo);
return false; // Exit after delete
}
}
]
});
}
/**
* Handle editing a single combo
* @param {Object} combo - Combo to edit
*/
async function handleEditSingleCombo(combo) {
clearScreen();
console.log(`\n✏️ Edit Combo: ${combo.name}\n`);
const newName = await prompt(`New name (Enter to keep "${combo.name}"): `);
const name = newName || combo.name;
console.log("\nCurrent models: " + (Array.isArray(combo.models) ? combo.models.map(formatModel).join(" → ") : ""));
console.log("\nSelect models for this combo (add one by one):");
const models = [];
let addMore = true;
while (addMore) {
const currentChain = models.length > 0 ? models.join(" → ") : "None";
const model = await selectModelFromList(`Add Model #${models.length + 1}`, `Chain: ${currentChain}`);
if (model) {
models.push(model);
console.log(`\n✓ Added: ${model}`);
console.log(`Current chain: ${models.join(" → ")}\n`);
const continueAdding = await confirm("Add another model?");
addMore = continueAdding;
} else {
addMore = false;
}
}
// Use new models if any were added, otherwise keep current
const finalModels = models.length > 0 ? models : combo.models;
const result = await api.updateCombo(combo.id, { name, models: finalModels });
if (result.success) {
showStatus("Combo updated!", "success");
} else {
showStatus(`Update failed: ${result.error}`, "error");
}
await pause();
}
/**
* Handle deleting a single combo
* @param {Object} combo - Combo to delete
*/
async function handleDeleteSingleCombo(combo) {
const confirmed = await confirm(`Delete combo "${combo.name}"?`);
if (confirmed) {
const result = await api.deleteCombo(combo.id);
if (result.success) {
showStatus("Combo deleted!", "success");
} else {
showStatus(`Delete failed: ${result.error}`, "error");
}
await pause();
}
}
/**
* Main combos menu - list all combos and actions
* @param {Array<string>} breadcrumb - Breadcrumb path
*/
async function showCombosMenu(breadcrumb = []) {
const { showListMenu } = require("../utils/menuHelper");
await showListMenu({
title: "🔀 Combos Management",
breadcrumb,
fetchItems: async () => {
const result = await api.getCombos();
if (!result.success) {
clearScreen();
showStatus(`Failed to load combos: ${result.error}`, "error");
await pause();
return null;
}
return { items: result.data.combos || [] };
},
formatItem: (combo) => {
const modelsChain = Array.isArray(combo.models) ? combo.models.map(formatModel).join(" → ") : "";
const maxLen = 35;
const displayModels = modelsChain.length > maxLen
? modelsChain.substring(0, maxLen - 3) + "..."
: modelsChain;
return `${combo.name}: ${displayModels}`;
},
onSelect: async (combo) => {
await showComboActions(combo, breadcrumb);
},
createAction: {
label: "Create New Combo",
action: async () => {
await handleCreateCombo();
}
}
});
}
/**
* Show combo detail with stats
*/
async function showComboDetail(comboId) {
clearScreen();
const result = await api.getComboById(comboId);
if (!result.success) {
showStatus(`Failed to load combo: ${result.error}`, "error");
await pause();
return;
}
const combo = result.data;
console.log("┌─────────────────────────────────────────────────────────┐");
console.log(`│ 🔀 Combo: ${combo.name.padEnd(46)}`);
console.log("├─────────────────────────────────────────────────────────┤");
console.log("│ │");
console.log(`│ ID: ${combo.id.padEnd(51)}`);
console.log(`│ Created: ${formatDate(combo.createdAt).padEnd(46)}`);
console.log(`│ Updated: ${formatDate(combo.updatedAt).padEnd(46)}`);
console.log("│ │");
console.log("│ Model Chain: │");
// Models is array of strings like ["ag/claude-sonnet-4-5", "kr/claude-sonnet-4.5"]
const models = Array.isArray(combo.models) ? combo.models : [];
models.forEach((modelStr, index) => {
const arrow = index < models.length - 1 ? " →" : " ";
const displayText = `${index + 1}. ${modelStr}${arrow}`;
const padding = Math.max(0, 54 - displayText.length);
console.log(`${displayText}${" ".repeat(padding)}`);
});
console.log("│ │");
console.log("└─────────────────────────────────────────────────────────┘");
await pause();
}
/**
* Format combo for menu display
*/
function formatComboLabel(combo) {
const modelsChain = Array.isArray(combo.models) ? combo.models.map(formatModel).join(" → ") : "";
const maxLen = 40;
const displayModels = modelsChain.length > maxLen
? modelsChain.substring(0, maxLen - 3) + "..."
: modelsChain;
return `${combo.name}: ${displayModels}`;
}
/**
* Create new combo
*/
async function handleCreateCombo() {
clearScreen();
showStatus("Create New Combo", "info");
console.log();
// Get combo name
const name = await prompt("Combo name: ");
if (!name) {
showStatus("Combo name is required", "error");
await pause();
return;
}
// Fetch available models
showStatus("Loading available models...", "info");
const modelsResult = await api.getModels();
if (!modelsResult.success) {
showStatus(`Failed to load models: ${modelsResult.error}`, "error");
await pause();
return;
}
const availableModels = modelsResult.data.models || [];
if (availableModels.length === 0) {
showStatus("No models available. Please add providers first.", "warning");
await pause();
return;
}
// Select models for chain
const selectedModels = [];
console.log();
showStatus("Select models for the chain (minimum 2)", "info");
while (true) {
clearScreen();
console.log(`Creating combo: ${name}`);
console.log(`Selected models (${selectedModels.length}):`);
if (selectedModels.length > 0) {
selectedModels.forEach((m, i) => {
console.log(` ${i + 1}. ${m.provider}/${m.model}`);
});
} else {
console.log(" (none)");
}
console.log();
console.log("Available models:");
availableModels.forEach((m, i) => {
console.log(` ${i + 1}. ${m.provider}/${m.model}`);
});
console.log();
console.log("Actions:");
console.log(" - Enter number to add model");
console.log(" - Type 'done' to finish (min 2 models)");
console.log(" - Type 'cancel' to abort");
const input = await prompt("\nAction: ");
if (input.toLowerCase() === "cancel") {
showStatus("Cancelled", "warning");
await pause();
return;
}
if (input.toLowerCase() === "done") {
if (selectedModels.length < 2) {
showStatus("Please select at least 2 models", "error");
await pause();
continue;
}
break;
}
const num = parseInt(input, 10);
if (isNaN(num) || num < 1 || num > availableModels.length) {
showStatus("Invalid model number", "error");
await pause();
continue;
}
selectedModels.push(availableModels[num - 1]);
}
// Create combo
showStatus("Creating combo...", "info");
const createResult = await api.createCombo({
name,
models: selectedModels
});
if (!createResult.success) {
showStatus(`Failed to create combo: ${createResult.error}`, "error");
await pause();
return;
}
showStatus(`Combo "${name}" created successfully!`, "success");
await pause();
}
/**
* Edit combo - select which combo to edit
*/
async function handleEditCombo(combos) {
if (combos.length === 0) {
showStatus("No combos available", "warning");
await pause();
return;
}
let selectedCombo = null;
await showMenuWithBack({
title: "✏️ Select Combo to Edit",
items: combos.map(combo => ({
label: formatComboLabel(combo),
action: async () => {
selectedCombo = combo;
return false;
}
}))
});
if (!selectedCombo) return;
await editSingleCombo(selectedCombo);
}
/**
* Edit a single combo
*/
async function editSingleCombo(combo) {
clearScreen();
showStatus(`Editing combo: ${combo.name}`, "info");
console.log();
const newName = await prompt(`New name (current: ${combo.name}, press Enter to keep): `);
const editModels = await confirm("Edit model chain?");
let newModels = combo.models;
if (editModels) {
newModels = [];
while (true) {
clearScreen();
console.log(`Editing combo: ${combo.name}`);
console.log(`Selected models (${newModels.length}):`);
if (newModels.length > 0) {
newModels.forEach((m, i) => console.log(` ${i + 1}. ${m}`));
} else {
console.log(" (none)");
}
console.log("\nType 'done' to finish (min 2 models) or 'cancel' to abort\n");
const model = await selectModelFromList("Add Model", "");
if (model === null) {
showStatus("Cancelled", "warning");
await pause();
return;
}
if (model === "done") {
if (newModels.length < 2) {
showStatus("Please select at least 2 models", "error");
await pause();
continue;
}
break;
}
newModels.push(model);
showStatus(`Added: ${model}`, "success");
await pause();
}
}
const updateData = {};
if (newName) updateData.name = newName;
if (editModels) updateData.models = newModels;
if (Object.keys(updateData).length === 0) {
showStatus("No changes made", "warning");
await pause();
return;
}
showStatus("Updating combo...", "info");
const updateResult = await api.updateCombo(combo.id, updateData);
if (!updateResult.success) {
showStatus(`Failed to update combo: ${updateResult.error}`, "error");
await pause();
return;
}
showStatus("Combo updated successfully!", "success");
await pause();
}
/**
* Delete combo - select which combo to delete
*/
async function handleDeleteCombo(combos) {
if (combos.length === 0) {
showStatus("No combos available", "warning");
await pause();
return;
}
let selectedCombo = null;
await showMenuWithBack({
title: "🗑️ Select Combo to Delete",
items: combos.map(combo => ({
label: formatComboLabel(combo),
action: async () => {
selectedCombo = combo;
return false;
}
}))
});
if (!selectedCombo) return;
clearScreen();
showStatus(`Combo: ${selectedCombo.name}`, "warning");
const modelsDisplay = Array.isArray(selectedCombo.models)
? selectedCombo.models.map(formatModel).join(" → ")
: "";
console.log(`Models: ${modelsDisplay}`);
console.log();
const confirmed = await confirm("Are you sure you want to delete this combo?");
if (!confirmed) {
showStatus("Cancelled", "info");
await pause();
return;
}
showStatus("Deleting combo...", "info");
const deleteResult = await api.deleteCombo(selectedCombo.id);
if (!deleteResult.success) {
showStatus(`Failed to delete combo: ${deleteResult.error}`, "error");
await pause();
return;
}
showStatus("Combo deleted successfully!", "success");
await pause();
}
module.exports = { showCombosMenu };

View File

@@ -0,0 +1,844 @@
const api = require("../api/client");
const { prompt, confirm, pause } = require("../utils/input");
const { clearScreen, showStatus, showHeader } = require("../utils/display");
const { formatDate, getRelativeTime } = require("../utils/format");
const { showMenuWithBack } = require("../utils/menuHelper");
const { copyToClipboard } = require("../utils/clipboard");
// ANSI colors for styling
const COLORS = {
reset: "\x1b[0m",
bold: "\x1b[1m",
cyan: "\x1b[36m",
dim: "\x1b[2m"
};
// Provider models - static config (synced from open-sse/config/providerModels.js)
const PROVIDER_MODELS = {
cc: [
{ id: "claude-opus-4-5-20251101" },
{ id: "claude-sonnet-4-5-20250929" },
{ id: "claude-haiku-4-5-20251001" },
],
cx: [
{ id: "gpt-5.2-codex" },
{ id: "gpt-5.2" },
{ id: "gpt-5.1-codex-max" },
{ id: "gpt-5.1-codex" },
{ id: "gpt-5.1-codex-mini" },
{ id: "gpt-5.1" },
{ id: "gpt-5-codex" },
{ id: "gpt-5-codex-mini" },
],
gc: [
{ id: "gemini-3-flash-preview" },
{ id: "gemini-3-pro-preview" },
{ id: "gemini-2.5-pro" },
{ id: "gemini-2.5-flash" },
{ id: "gemini-2.5-flash-lite" },
],
qw: [
{ id: "qwen3-coder-plus" },
{ id: "qwen3-coder-flash" },
{ id: "vision-model" },
],
if: [
{ id: "qwen3-coder-plus" },
{ id: "kimi-k2" },
{ id: "kimi-k2-thinking" },
{ id: "deepseek-r1" },
{ id: "deepseek-v3.2-chat" },
{ id: "deepseek-v3.2-reasoner" },
{ id: "minimax-m2" },
{ id: "glm-4.7" },
],
ag: [
{ id: "gemini-3-pro-low" },
{ id: "gemini-3-pro-high" },
{ id: "gemini-3-flash" },
{ id: "gemini-2.5-flash" },
{ id: "claude-sonnet-4-5" },
{ id: "claude-sonnet-4-5-thinking" },
{ id: "claude-opus-4-5-thinking" },
],
gh: [
{ id: "gpt-5" },
{ id: "gpt-5-mini" },
{ id: "gpt-5.1-codex" },
{ id: "gpt-5.1-codex-max" },
{ id: "gpt-4.1" },
{ id: "claude-4.5-sonnet" },
{ id: "claude-4.5-opus" },
{ id: "claude-4.5-haiku" },
{ id: "gemini-3-pro" },
{ id: "gemini-3-flash" },
{ id: "gemini-2.5-pro" },
{ id: "grok-code-fast-1" },
],
kr: [
{ id: "claude-sonnet-4.5" },
{ id: "claude-haiku-4.5" },
],
openai: [
{ id: "gpt-4o" },
{ id: "gpt-4o-mini" },
{ id: "gpt-4-turbo" },
{ id: "o1" },
{ id: "o1-mini" },
],
anthropic: [
{ id: "claude-sonnet-4-20250514" },
{ id: "claude-opus-4-20250514" },
{ id: "claude-3-5-sonnet-20241022" },
],
gemini: [
{ id: "gemini-3-pro-preview" },
{ id: "gemini-2.5-pro" },
{ id: "gemini-2.5-flash" },
{ id: "gemini-2.5-flash-lite" },
],
openrouter: [
{ id: "auto" },
],
glm: [
{ id: "glm-4.7" },
{ id: "glm-4.6v" },
],
kimi: [
{ id: "kimi-latest" },
],
minimax: [
{ id: "MiniMax-M2.1" },
],
};
// Provider definitions
const OAUTH_PROVIDERS = {
claude: { id: "claude", alias: "cc", name: "Claude Code" },
codex: { id: "codex", alias: "cx", name: "OpenAI Codex" },
"gemini-cli": { id: "gemini-cli", alias: "gc", name: "Gemini CLI" },
github: { id: "github", alias: "gh", name: "GitHub Copilot" },
antigravity: { id: "antigravity", alias: "ag", name: "Antigravity" },
iflow: { id: "iflow", alias: "if", name: "iFlow AI" },
qwen: { id: "qwen", alias: "qw", name: "Qwen Code" },
kiro: { id: "kiro", alias: "kr", name: "Kiro AI" },
};
const APIKEY_PROVIDERS = {
openrouter: { id: "openrouter", name: "OpenRouter" },
glm: { id: "glm", name: "GLM Coding" },
minimax: { id: "minimax", name: "Minimax Coding" },
kimi: { id: "kimi", name: "Kimi Coding" },
openai: { id: "openai", name: "OpenAI" },
anthropic: { id: "anthropic", name: "Anthropic" },
gemini: { id: "gemini", name: "Gemini" },
};
const ALL_PROVIDERS = { ...OAUTH_PROVIDERS, ...APIKEY_PROVIDERS };
/**
* Get auth type for provider
* @param {string} providerId - Provider ID
* @returns {string} "oauth" or "apikey"
*/
function getAuthType(providerId) {
return OAUTH_PROVIDERS[providerId] ? "oauth" : "apikey";
}
/**
* Count connections by provider
* @param {Array} connections - Array of connection objects
* @returns {Object} Map of providerId -> count
*/
function countConnectionsByProvider(connections) {
const counts = {};
connections.forEach(conn => {
const providerId = conn.provider || conn.providerId;
counts[providerId] = (counts[providerId] || 0) + 1;
});
return counts;
}
/**
* Show main providers menu
* @param {Array<string>} breadcrumb - Breadcrumb path
*/
async function showProvidersMenu(breadcrumb = []) {
// Build provider items list
const providerItems = [];
Object.values(OAUTH_PROVIDERS).forEach(provider => {
providerItems.push({
provider,
authType: "oauth",
label: (data) => {
const count = data.counts[provider.id] || 0;
return `${provider.name} (OAuth) - ${count} Connected`;
},
action: async (data) => {
await showProviderDetail(provider.id, "oauth", data.connections, [...breadcrumb, provider.name]);
return true;
}
});
});
Object.values(APIKEY_PROVIDERS).forEach(provider => {
providerItems.push({
provider,
authType: "apikey",
label: (data) => {
const count = data.counts[provider.id] || 0;
return `${provider.name} (API) - ${count} Connected`;
},
action: async (data) => {
await showProviderDetail(provider.id, "apikey", data.connections, [...breadcrumb, provider.name]);
return true;
}
});
});
// Custom provider nodes section
providerItems.push({
label: () => `${COLORS.dim}── Custom Providers ──${COLORS.reset}`,
action: async () => true, // separator, no-op
isSeparator: true,
});
providerItems.push({
label: (data) => {
const count = data.nodeCount || 0;
return `Custom Providers - ${count} Configured`;
},
action: async () => {
await showCustomProvidersMenu([...breadcrumb, "Custom Providers"]);
return true;
}
});
await showMenuWithBack({
title: "🔌 Providers Management",
breadcrumb,
refresh: async () => {
const [provRes, nodeRes] = await Promise.all([api.getProviders(), api.getProviderNodes()]);
if (!provRes.success) {
showStatus(`Failed to fetch providers: ${provRes.error}`, "error");
await pause();
return null;
}
const connections = provRes.data.connections || [];
const nodes = nodeRes.success ? (nodeRes.data.nodes || nodeRes.data || []) : [];
return {
connections,
counts: countConnectionsByProvider(connections),
nodeCount: nodes.length,
};
},
items: providerItems
});
}
/**
* Build provider header with alias and models
* @param {string} providerId - Provider ID
* @returns {string}
*/
function buildProviderHeader(providerId) {
const provider = ALL_PROVIDERS[providerId];
const alias = provider.alias || providerId;
const lines = [];
lines.push(`Alias: ${COLORS.cyan}${alias}${COLORS.reset}`);
// Get models from static config
const models = PROVIDER_MODELS[alias] || [];
if (models.length > 0) {
const modelList = models
.slice(0, 5)
.map(m => `${alias}/${m.id}`)
.join(", ");
const more = models.length > 5 ? ` (+${models.length - 5} more)` : "";
lines.push(`Models: ${COLORS.dim}${modelList}${more}${COLORS.reset}`);
} else {
lines.push(`Models: ${COLORS.dim}No models configured${COLORS.reset}`);
}
return lines.join("\n");
}
/**
* Show provider detail with connections and actions
* @param {string} providerId - Provider ID
* @param {string} authType - "oauth" or "apikey"
* @param {Array} allConnections - All connections
* @param {Array<string>} breadcrumb - Breadcrumb path
*/
async function showProviderDetail(providerId, authType, allConnections, breadcrumb = []) {
const provider = ALL_PROVIDERS[providerId];
const { showListMenu } = require("../utils/menuHelper");
await showListMenu({
title: `🔌 ${provider.name} (${authType.toUpperCase()})`,
breadcrumb,
backLabel: "← Back to Providers",
headerContent: buildProviderHeader(providerId),
fetchItems: async () => {
const response = await api.getProviders();
if (response.success) {
allConnections.length = 0;
allConnections.push(...(response.data.connections || []));
}
const providerConns = allConnections.filter(conn =>
(conn.provider || conn.providerId) === providerId
);
return { items: providerConns };
},
formatItem: (conn) => {
const status = conn.testStatus === "active" ? "✓" : conn.testStatus === "error" ? "✗" : "?";
const name = conn.name || conn.email || conn.displayName || "Unnamed";
return `${name} (${status})`;
},
onSelect: async (conn) => {
await showConnectionActions(conn, providerId, breadcrumb);
},
createAction: {
label: "Add New Connection",
action: async () => {
await handleAddConnection(providerId, authType);
}
}
});
}
/**
* Show actions for a specific connection
* @param {Object} connection - Connection object
* @param {string} providerId - Provider ID
* @param {Array<string>} breadcrumb - Breadcrumb path
*/
async function showConnectionActions(connection, providerId, breadcrumb = []) {
const name = connection.name || connection.email || connection.displayName || "Unnamed";
const status = connection.testStatus === "active" ? "✓ Active" :
connection.testStatus === "error" ? "✗ Error" : "? Unknown";
await showMenuWithBack({
title: `🔌 ${name}`,
breadcrumb: [...breadcrumb, name],
headerContent: `Connection: ${name}\nStatus: ${status}`,
items: [
{
label: "Rename Connection",
action: async () => {
const newName = await prompt(`New name (current: ${name}): `);
if (newName && newName.trim()) {
showStatus("Renaming connection...", "info");
const result = await api.updateConnection(connection.id, { name: newName.trim() });
if (result.success) {
showStatus("Connection renamed!", "success");
connection.name = newName.trim();
} else {
showStatus(`Rename failed: ${result.error}`, "error");
}
await pause();
}
return true;
}
},
{
label: "Test Connection",
action: async () => {
showStatus("Testing connection...", "info");
const result = await api.testConnection(connection.id);
if (result.success) {
showStatus("Connection is working!", "success");
} else {
showStatus(`Test failed: ${result.error}`, "error");
}
await pause();
return true;
}
},
{
label: "Delete Connection",
action: async () => {
const confirmed = await confirm(`Delete connection "${name}"?`);
if (confirmed) {
const result = await api.deleteConnection(connection.id);
if (result.success) {
showStatus("Connection deleted!", "success");
} else {
showStatus(`Delete failed: ${result.error}`, "error");
}
await pause();
return false; // Exit menu after delete
}
return true;
}
}
]
});
}
/**
* Handle adding new connection
* @param {string} providerId - Provider ID
* @param {string} authType - "oauth" or "apikey"
*/
// Providers that use Device Code Flow (terminal-based polling)
const DEVICE_CODE_PROVIDERS = ["github", "qwen", "kiro"];
/**
* Handle adding new connection - auto-detect flow type
* @param {string} providerId - Provider ID
* @param {string} authType - "oauth" or "apikey"
*/
async function handleAddConnection(providerId, authType) {
if (authType === "apikey") {
await handleAddApiKeyConnection(providerId);
} else {
// OAuth: auto-detect flow type based on provider
if (DEVICE_CODE_PROVIDERS.includes(providerId)) {
// Device Code Flow for GitHub, Qwen, Kiro
await handleAddDeviceCodeConnection(providerId);
} else {
// Authorization Code Flow for Claude, Codex, Gemini, etc.
await handleAddOAuthConnection(providerId);
}
}
}
/**
* Handle adding API Key connection
* @param {string} providerId - Provider ID
*/
async function handleAddApiKeyConnection(providerId) {
clearScreen();
const provider = ALL_PROVIDERS[providerId];
console.log(`\n Add ${provider.name} API Key Connection\n`);
const name = await prompt("Connection Name: ");
if (!name) {
showStatus("Cancelled", "warning");
await pause();
return;
}
const apiKey = await prompt("API Key: ");
if (!apiKey) {
showStatus("Cancelled", "warning");
await pause();
return;
}
showStatus("Creating connection...", "info");
const result = await api.createApiKeyProvider({
provider: providerId,
name,
apiKey
});
if (result.success) {
showStatus("✓ Connection created successfully!", "success");
} else {
showStatus(`✗ Failed: ${result.error}`, "error");
}
await pause();
}
/**
* Handle adding OAuth Authorization Code connection
* User opens URL manually and pastes callback URL
* @param {string} providerId - Provider ID
*/
async function handleAddOAuthConnection(providerId) {
clearScreen();
const provider = ALL_PROVIDERS[providerId];
// Step 1: Get auth URL
showStatus("Requesting authorization URL...", "info");
const authResult = await api.getOAuthAuthUrl(providerId);
if (!authResult.success) {
showStatus(`Failed: ${authResult.error}`, "error");
await pause();
return;
}
const authData = authResult.data || authResult;
const authUrl = authData.authUrl;
const codeVerifier = authData.codeVerifier;
const state = authData.state;
const redirectUri = authData.redirectUri;
if (!authUrl) {
showStatus("Failed: No auth URL received", "error");
await pause();
return;
}
// Step 2: Show URL and instructions
clearScreen();
showHeader("🔐 OAuth Login", `Providers > ${provider.name} > Add Connection`);
console.log(` ${COLORS.bold}${COLORS.cyan}1.${COLORS.reset} Open this URL in your browser:`);
console.log(` ${COLORS.dim}${authUrl}${COLORS.reset}`);
if (copyToClipboard(authUrl)) {
console.log(` \x1b[32m✓ Link copied to clipboard!\x1b[0m`);
}
console.log();
console.log(` ${COLORS.bold}${COLORS.cyan}2.${COLORS.reset} Complete authorization in browser`);
console.log();
console.log(` ${COLORS.bold}${COLORS.cyan}3.${COLORS.reset} Copy the callback URL from address bar`);
console.log(` ${COLORS.dim}(looks like: http://localhost:20128/callback?code=...)${COLORS.reset}`);
console.log();
const callbackUrl = await prompt(" Paste callback URL: ");
if (!callbackUrl) {
showStatus("Cancelled", "warning");
await pause();
return;
}
// Step 3: Parse callback URL and extract code
let code, urlState, error;
try {
const url = new URL(callbackUrl.trim());
code = url.searchParams.get("code");
urlState = url.searchParams.get("state");
error = url.searchParams.get("error");
if (error) {
const errorDesc = url.searchParams.get("error_description") || error;
showStatus(`Authorization failed: ${errorDesc}`, "error");
await pause();
return;
}
if (!code) {
showStatus("No authorization code found in URL", "error");
await pause();
return;
}
} catch (err) {
showStatus("Invalid URL format", "error");
await pause();
return;
}
// Step 4: Exchange code for tokens
console.log();
showStatus("Exchanging code for tokens...", "info");
const exchangeResult = await api.exchangeOAuthCode(providerId, {
code,
redirectUri,
codeVerifier,
state: urlState || state
});
if (exchangeResult.success) {
showStatus("Connection created successfully!", "success");
} else {
showStatus(`Failed: ${exchangeResult.error}`, "error");
}
await pause();
}
/**
* Handle adding OAuth Device Code connection
* @param {string} providerId - Provider ID
*/
async function handleAddDeviceCodeConnection(providerId) {
clearScreen();
const provider = ALL_PROVIDERS[providerId];
// Step 1: Request device code
showStatus("Requesting device code...", "info");
const deviceResult = await api.getOAuthDeviceCode(providerId);
if (!deviceResult.success) {
showStatus(`Failed: ${deviceResult.error}`, "error");
await pause();
return;
}
const deviceData = deviceResult.data || deviceResult;
const device_code = deviceData.device_code;
const user_code = deviceData.user_code;
const verification_uri = deviceData.verification_uri;
const verification_uri_complete = deviceData.verification_uri_complete;
const codeVerifier = deviceData.codeVerifier;
const extraData = deviceData.extraData || deviceData;
if (!device_code) {
showStatus("Failed: No device code received", "error");
await pause();
return;
}
// Step 2: Show instructions
clearScreen();
const deviceUrl = verification_uri_complete || verification_uri;
showHeader("📱 Device Login", `Providers > ${provider.name} > Add Connection`);
console.log(` ${COLORS.bold}${COLORS.cyan}1.${COLORS.reset} Open: ${COLORS.dim}${deviceUrl}${COLORS.reset}`);
if (copyToClipboard(deviceUrl)) {
console.log(` \x1b[32m✓ Link copied to clipboard!\x1b[0m`);
}
console.log();
if (!verification_uri_complete && user_code) {
console.log(` ${COLORS.bold}${COLORS.cyan}2.${COLORS.reset} Enter code: ${COLORS.bold}${user_code}${COLORS.reset}`);
console.log();
}
console.log(` ${COLORS.dim}Waiting for authorization...${COLORS.reset}`);
console.log();
// Step 3: Poll for token
const maxAttempts = 60; // 5 minutes (5s interval)
for (let i = 0; i < maxAttempts; i++) {
await new Promise(resolve => setTimeout(resolve, 5000));
const pollResult = await api.pollOAuthToken(providerId, {
deviceCode: device_code,
codeVerifier,
extraData
});
if (pollResult.success) {
showStatus("\nConnection created successfully!", "success");
await pause();
return;
}
// Check if still pending (pending flag is at root level, not in data)
const isPending = pollResult.pending || pollResult.error === "authorization_pending" || pollResult.error === "slow_down";
if (!isPending) {
showStatus(`\nFailed: ${pollResult.error || "Unknown error"}`, "error");
await pause();
return;
}
process.stdout.write(".");
}
showStatus("\nTimeout waiting for authorization", "error");
await pause();
}
// ============================================================================
// CUSTOM PROVIDERS (provider nodes)
// ============================================================================
const CUSTOM_NODE_TYPES = ["openai-compatible", "anthropic-compatible"];
const OPENAI_API_TYPES = ["chat", "responses"];
/**
* Show custom providers section in main providers menu
* @param {Array} nodes - List of provider nodes
* @param {Array} connections - All connections
* @param {Array<string>} breadcrumb
*/
async function showCustomProvidersMenu(breadcrumb = []) {
const { showListMenu } = require("../utils/menuHelper");
await showListMenu({
title: "🔧 Custom Providers",
breadcrumb,
backLabel: "← Back to Providers",
fetchItems: async () => {
const res = await api.getProviderNodes();
if (!res.success) return { items: [] };
return { items: res.data.nodes || res.data || [] };
},
formatItem: (node) => `[${node.prefix}] ${node.name} (${node.type})`,
onSelect: async (node) => {
await showCustomNodeDetail(node, [...breadcrumb, node.name]);
},
createAction: {
label: " Add Custom Provider",
action: async () => {
await handleAddCustomNode();
}
}
});
}
/**
* Show detail menu for a custom provider node
*/
async function showCustomNodeDetail(node, breadcrumb = []) {
await showMenuWithBack({
title: `🔧 ${node.name}`,
breadcrumb,
headerContent: [
`Type: ${node.type}`,
`Prefix: ${COLORS.cyan}${node.prefix}${COLORS.reset}`,
`Base URL: ${COLORS.dim}${node.baseUrl}${COLORS.reset}`,
].join("\n"),
items: [
{
label: "Connections",
action: async () => {
await showCustomNodeConnections(node, breadcrumb);
return true;
}
},
{
label: "Edit Node",
action: async () => {
await handleEditCustomNode(node);
return true;
}
},
{
label: "Delete Node",
action: async () => {
const confirmed = await confirm(`Delete "${node.name}" and all its connections?`);
if (confirmed) {
const res = await api.deleteProviderNode(node.id);
if (res.success) {
showStatus("Node deleted!", "success");
} else {
showStatus(`Delete failed: ${res.error}`, "error");
}
await pause();
return false;
}
return true;
}
}
]
});
}
/**
* Show connections for a custom provider node
*/
async function showCustomNodeConnections(node, breadcrumb = []) {
const { showListMenu } = require("../utils/menuHelper");
await showListMenu({
title: `🔌 ${node.name} Connections`,
breadcrumb,
backLabel: "← Back",
fetchItems: async () => {
const res = await api.getProviders();
if (!res.success) return { items: [] };
const all = res.data.connections || [];
const items = all.filter(c => c.provider === node.id);
return { items };
},
formatItem: (conn) => {
const status = conn.testStatus === "active" ? "✓" : conn.testStatus === "error" ? "✗" : "?";
return `${conn.name || "Unnamed"} (${status})`;
},
onSelect: async (conn) => {
await showConnectionActions(conn, node.id, breadcrumb);
},
createAction: {
label: "Add API Key Connection",
action: async () => {
await handleAddCustomNodeConnection(node);
}
}
});
}
/**
* Add API key connection to a custom provider node
*/
async function handleAddCustomNodeConnection(node) {
clearScreen();
console.log(`\n Add Connection to ${node.name}\n`);
const name = await prompt("Connection Name: ");
if (!name) { showStatus("Cancelled", "warning"); await pause(); return; }
const apiKey = await prompt("API Key: ");
if (!apiKey) { showStatus("Cancelled", "warning"); await pause(); return; }
showStatus("Creating connection...", "info");
const res = await api.createApiKeyProvider({ provider: node.id, name, apiKey });
showStatus(res.success ? "✓ Connection created!" : `✗ Failed: ${res.error}`, res.success ? "success" : "error");
await pause();
}
/**
* Handle adding a new custom provider node
*/
async function handleAddCustomNode() {
clearScreen();
console.log("\n Add Custom Provider\n");
// Step 1: Select type
const typeChoices = CUSTOM_NODE_TYPES.map((t, i) => ` ${i + 1}. ${t}`).join("\n");
console.log(`Select type:\n${typeChoices}\n`);
const typeInput = await prompt("Type (1/2): ");
const typeIdx = parseInt(typeInput) - 1;
if (isNaN(typeIdx) || !CUSTOM_NODE_TYPES[typeIdx]) {
showStatus("Cancelled", "warning"); await pause(); return;
}
const type = CUSTOM_NODE_TYPES[typeIdx];
// Step 2: Inputs
const name = await prompt("Name: ");
if (!name) { showStatus("Cancelled", "warning"); await pause(); return; }
const prefix = await prompt("Prefix (used in model IDs, e.g. myapi): ");
if (!prefix) { showStatus("Cancelled", "warning"); await pause(); return; }
const baseUrl = await prompt("Base URL (e.g. https://api.example.com/v1): ");
if (!baseUrl) { showStatus("Cancelled", "warning"); await pause(); return; }
// Step 3: API type (OpenAI only)
let apiType;
if (type === "openai-compatible") {
const apiTypeChoices = OPENAI_API_TYPES.map((t, i) => ` ${i + 1}. ${t}`).join("\n");
console.log(`\nAPI Type:\n${apiTypeChoices}\n`);
const apiTypeInput = await prompt("API Type (1/2, default 1): ");
const apiTypeIdx = parseInt(apiTypeInput) - 1;
apiType = OPENAI_API_TYPES[apiTypeIdx] || "chat";
}
showStatus("Creating provider node...", "info");
const body = { name, prefix, baseUrl, type, ...(apiType && { apiType }) };
const res = await api.createProviderNode(body);
showStatus(res.success ? "✓ Provider created!" : `✗ Failed: ${res.error}`, res.success ? "success" : "error");
await pause();
}
/**
* Handle editing a custom provider node
*/
async function handleEditCustomNode(node) {
clearScreen();
console.log(`\n✏️ Edit ${node.name}\n`);
console.log(`${COLORS.dim}Leave blank to keep current value${COLORS.reset}\n`);
const name = await prompt(`Name (${node.name}): `);
const baseUrl = await prompt(`Base URL (${node.baseUrl}): `);
const prefix = await prompt(`Prefix (${node.prefix}): `);
const updates = {};
if (name && name.trim()) updates.name = name.trim();
if (baseUrl && baseUrl.trim()) updates.baseUrl = baseUrl.trim();
if (prefix && prefix.trim()) updates.prefix = prefix.trim();
if (!Object.keys(updates).length) {
showStatus("No changes", "warning"); await pause(); return;
}
showStatus("Updating...", "info");
const res = await api.updateProviderNode(node.id, updates);
if (res.success) {
Object.assign(node, updates);
showStatus("✓ Updated!", "success");
} else {
showStatus(`✗ Failed: ${res.error}`, "error");
}
await pause();
}
module.exports = { showProvidersMenu };

View File

@@ -0,0 +1,207 @@
const path = require("path");
const fs = require("fs");
const os = require("os");
const api = require("../api/client");
const { confirm, pause } = require("../utils/input");
const { showStatus } = require("../utils/display");
const { showMenuWithBack } = require("../utils/menuHelper");
// ANSI colors
const COLORS = {
reset: "\x1b[0m",
green: "\x1b[32m",
red: "\x1b[31m",
yellow: "\x1b[33m",
dim: "\x1b[2m",
cyan: "\x1b[36m"
};
const DEFAULT_PASSWORD = "123456";
// Resolve db.json path (matches app/src/lib/dataDir.js convention)
function getDbPath() {
return process.platform === "win32"
? path.join(process.env.APPDATA || "", "9router", "db.json")
: path.join(os.homedir(), ".9router", "db.json");
}
/**
* Show settings menu (tunnel + RTK + reset password)
* @param {Array<string>} breadcrumb - Breadcrumb path
*/
async function showSettingsMenu(breadcrumb = []) {
await showMenuWithBack({
title: "⚙️ Settings",
breadcrumb,
headerContent: async (data) => {
const lines = [];
// Tunnel section
const tunnel = data?.tunnel || {};
if (tunnel.enabled && tunnel.publicUrl) {
lines.push(` Endpoint: ${COLORS.green}${tunnel.publicUrl}/v1${COLORS.reset}`);
lines.push(` Tunnel: ${COLORS.green}ON${COLORS.reset} ${COLORS.dim}(${tunnel.shortId})${COLORS.reset}`);
} else {
lines.push(` Endpoint: http://localhost:20128/v1`);
lines.push(` Tunnel: ${COLORS.red}OFF${COLORS.reset} ${COLORS.dim}(local only)${COLORS.reset}`);
}
// RTK section
const rtkOn = data?.settings?.rtkEnabled !== false;
lines.push(` RTK: ${rtkOn ? `${COLORS.green}ON${COLORS.reset}` : `${COLORS.red}OFF${COLORS.reset}`} ${COLORS.dim}(Token Saver)${COLORS.reset}`);
// Auth mode section
const authMode = data?.settings?.authMode || "password";
const authColor = authMode === "password" ? COLORS.green : COLORS.yellow;
lines.push(` Auth: ${authColor}${authMode.toUpperCase()}${COLORS.reset} ${COLORS.dim}(login mode)${COLORS.reset}`);
return lines.join("\n");
},
refresh: async () => {
const [tunnelRes, settingsRes] = await Promise.all([
api.getTunnelStatus(),
api.getSettings()
]);
return {
tunnel: tunnelRes.success ? (tunnelRes.data || {}) : {},
settings: settingsRes.success ? (settingsRes.data || {}) : {}
};
},
items: [
{
label: "Tunnel ON",
action: async () => { await enableTunnel(); return true; }
},
{
label: "Tunnel OFF",
action: async () => { await disableTunnel(); return true; }
},
{
label: (d) => {
const on = d?.settings?.rtkEnabled !== false;
return `Token Saver (RTK): ${on ? "ON" : "OFF"} → toggle`;
},
action: async (d) => { await toggleRtk(d?.settings?.rtkEnabled !== false); return true; }
},
{
label: "🔑 Reset Password to Default",
action: async () => { await resetPassword(); return true; }
},
{
label: (d) => {
const mode = d?.settings?.authMode || "password";
return mode === "password" ? "🔓 Reset Auth Mode (already password)" : `🔓 Reset Auth Mode to Password (current: ${mode})`;
},
action: async () => { await resetAuthMode(); return true; }
}
]
});
}
/**
* Reset authMode to "password" via API. Used when OIDC is misconfigured
* and user is locked out of dashboard. CLI bypasses auth via x-9r-cli-token.
*/
async function resetAuthMode() {
const ok = await confirm("Reset auth mode to PASSWORD (disable OIDC)?");
if (!ok) {
showStatus("Cancelled", "info");
await pause();
return;
}
const result = await api.updateSettings({ authMode: "password" });
if (result.success) {
showStatus("Auth mode reset to password. OIDC disabled.", "success");
} else {
showStatus(`Failed: ${result.error}`, "error");
}
await pause();
}
/**
* Enable tunnel via API
*/
async function enableTunnel() {
showStatus("Creating tunnel...", "info");
const result = await api.enableTunnel();
if (result.success) {
const { publicUrl, shortId, alreadyRunning } = result.data || {};
if (alreadyRunning) {
showStatus(`Tunnel already running: ${publicUrl}`, "success");
} else {
showStatus(`Tunnel enabled: ${publicUrl} (${shortId})`, "success");
}
} else {
showStatus(`Failed: ${result.error}`, "error");
}
await pause();
}
/**
* Disable tunnel via API
*/
async function disableTunnel() {
const result = await api.disableTunnel();
if (result.success) {
showStatus("Tunnel disabled", "success");
} else {
showStatus(`Failed: ${result.error}`, "error");
}
await pause();
}
/**
* Toggle RTK (Token Saver) via API
* @param {boolean} currentlyOn
*/
async function toggleRtk(currentlyOn) {
const next = !currentlyOn;
const result = await api.updateSettings({ rtkEnabled: next });
if (result.success) {
showStatus(`Token Saver ${next ? "enabled" : "disabled"}`, "success");
} else {
showStatus(`Failed: ${result.error}`, "error");
}
await pause();
}
/**
* Reset dashboard password by clearing the hash in db.json (Phase B).
* After reset, user can log in with the default password "123456".
*/
async function resetPassword() {
const dbPath = getDbPath();
if (!fs.existsSync(dbPath)) {
showStatus(`db.json not found at ${dbPath}`, "error");
await pause();
return;
}
const ok = await confirm(`Reset dashboard password to default "${DEFAULT_PASSWORD}"?`);
if (!ok) {
showStatus("Cancelled", "info");
await pause();
return;
}
try {
const raw = fs.readFileSync(dbPath, "utf-8");
const db = JSON.parse(raw);
if (db.settings && Object.prototype.hasOwnProperty.call(db.settings, "password")) {
delete db.settings.password;
}
fs.writeFileSync(dbPath, JSON.stringify(db, null, 2));
showStatus(`Password reset. Default: ${DEFAULT_PASSWORD}`, "success");
} catch (err) {
showStatus(`Failed to reset password: ${err.message}`, "error");
}
await pause();
}
module.exports = { showSettingsMenu };

109
cli/src/cli/terminalUI.js Normal file
View File

@@ -0,0 +1,109 @@
const api = require("./api/client");
const { showMenuWithBack } = require("./utils/menuHelper");
const { showProvidersMenu } = require("./menus/providers");
const { showApiKeysMenu } = require("./menus/apiKeys");
const { showCombosMenu } = require("./menus/combos");
const { showSettingsMenu } = require("./menus/settings");
const { showCliToolsMenu } = require("./menus/cliTools");
const COLORS = {
reset: "\x1b[0m",
green: "\x1b[32m",
red: "\x1b[31m",
dim: "\x1b[2m",
cyan: "\x1b[36m"
};
/**
* Build header content with endpoint and API keys
* @param {number} port - Server port
* @returns {Promise<string>} Header content string
*/
async function buildHeaderContent(port) {
const [keysResult, tunnelResult] = await Promise.all([
api.getApiKeys(),
api.getTunnelStatus()
]);
const keys = keysResult.success ? (keysResult.data.keys || []) : [];
const tunnel = tunnelResult.success ? (tunnelResult.data || {}) : {};
const tunnelEnabled = tunnel.enabled === true;
const lines = [];
if (tunnelEnabled && tunnel.publicUrl) {
lines.push(`Endpoint: ${COLORS.green}${tunnel.publicUrl}/v1${COLORS.reset}`);
lines.push(`Tunnel: ${COLORS.green}ON${COLORS.reset} ${COLORS.dim}(${tunnel.shortId})${COLORS.reset}`);
} else {
lines.push(`Endpoint: http://localhost:${port}/v1`);
lines.push(`Tunnel: ${COLORS.red}OFF${COLORS.reset} ${COLORS.dim}(local only)${COLORS.reset}`);
}
if (keys.length === 0) {
lines.push(`Key: ${COLORS.dim}No API keys yet${COLORS.reset}`);
} else {
lines.push(`Key: ${COLORS.cyan}${keys[0].key}${COLORS.reset}`);
keys.slice(1).forEach(k => lines.push(` ${COLORS.cyan}${k.key}${COLORS.reset}`));
}
return lines.join("\n");
}
/**
* Start Terminal UI
* @param {number} port - Server port number
*/
async function startTerminalUI(port) {
// Configure API client
api.configure({ port });
const basePath = ["9Router"];
// Main menu
await showMenuWithBack({
title: "📡 9Router Terminal UI",
breadcrumb: basePath,
headerContent: async () => await buildHeaderContent(port),
refresh: async () => ({}), // Refresh header on each loop
items: [
{
label: "Providers",
action: async () => {
await showProvidersMenu([...basePath, "Providers"]);
return true; // Continue
}
},
{
label: "API Keys",
action: async () => {
await showApiKeysMenu(port, [...basePath, "API Keys"]);
return true;
}
},
{
label: "Combos",
action: async () => {
await showCombosMenu([...basePath, "Combos"]);
return true;
}
},
{
label: "CLI Tools",
action: async () => {
await showCliToolsMenu(port, [...basePath, "CLI Tools"]);
return true;
}
},
{
label: "Settings",
action: async () => {
await showSettingsMenu([...basePath, "Settings"]);
return true;
}
}
],
backLabel: "← Back to Interface Menu"
});
}
module.exports = { startTerminalUI };

View File

@@ -0,0 +1,321 @@
const fs = require("fs");
const path = require("path");
const os = require("os");
const { execSync } = require("child_process");
const APP_NAME = "9router";
const APP_LABEL = "com.9router.autostart";
/**
* Get the command to run 9router in tray mode
*/
function getStartCommand() {
// Find the global npm bin path for 9router
try {
const npmBin = execSync("npm bin -g", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
const routerPath = path.join(npmBin, "9router");
if (fs.existsSync(routerPath)) {
return `"${routerPath}" --tray --skip-update`;
}
} catch (e) {
// npm not available or failed
}
// Fallback: use npx
return "npx 9router --tray --skip-update";
}
/**
* Enable auto startup on OS boot
* @param {string} cliPath - Optional path to cli.js (defaults to auto-detect)
* @returns {boolean} success
*/
function enableAutoStart(cliPath) {
const platform = process.platform;
// Skip on unsupported platforms
if (!["darwin", "win32", "linux"].includes(platform)) {
return false;
}
// Skip on Linux without GUI
if (platform === "linux" && !process.env.DISPLAY) {
return false;
}
try {
if (platform === "darwin") {
return enableMacOS(cliPath);
} else if (platform === "win32") {
return enableWindows(cliPath);
} else if (platform === "linux") {
return enableLinux(cliPath);
}
} catch (err) {
// Silent fail - autostart is optional
}
return false;
}
/**
* Disable auto startup
* @returns {boolean} success
*/
function disableAutoStart() {
const platform = process.platform;
try {
if (platform === "darwin") {
return disableMacOS();
} else if (platform === "win32") {
return disableWindows();
} else if (platform === "linux") {
return disableLinux();
}
} catch (err) {
// Silent fail
}
return false;
}
/**
* Check if autostart is enabled
* @returns {boolean}
*/
function isAutoStartEnabled() {
const platform = process.platform;
try {
if (platform === "darwin") {
const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", `${APP_LABEL}.plist`);
return fs.existsSync(plistPath);
} else if (platform === "win32") {
const startupPath = path.join(process.env.APPDATA, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", `${APP_NAME}.vbs`);
return fs.existsSync(startupPath);
} else if (platform === "linux") {
const desktopPath = path.join(os.homedir(), ".config", "autostart", `${APP_NAME}.desktop`);
return fs.existsSync(desktopPath);
}
} catch (e) {}
return false;
}
// ============ macOS ============
function enableMacOS(cliPath) {
const launchAgentsDir = path.join(os.homedir(), "Library", "LaunchAgents");
const plistPath = path.join(launchAgentsDir, `${APP_LABEL}.plist`);
// Ensure directory exists
if (!fs.existsSync(launchAgentsDir)) {
fs.mkdirSync(launchAgentsDir, { recursive: true });
}
// Get absolute paths for node and 9router script
const nodePath = process.execPath;
let routerScript;
if (cliPath) {
// Use provided path (from running cli.js)
routerScript = path.resolve(cliPath);
} else {
// Fallback: try to resolve from npm bin
try {
const npmBin = execSync("npm bin -g", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
const routerLink = path.join(npmBin, "9router");
routerScript = fs.realpathSync(routerLink);
} catch (e) {
// Last resort fallback
routerScript = "/usr/local/lib/node_modules/9router/cli.js";
}
}
// Determine user shell
const userShell = process.env.SHELL || '/bin/zsh';
const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${APP_LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>${userShell}</string>
<string>-l</string>
<string>-c</string>
<string>${nodePath} ${routerScript} --tray --skip-update</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<false/>
<key>StandardOutPath</key>
<string>/tmp/9router.log</string>
<key>StandardErrorPath</key>
<string>/tmp/9router.error.log</string>
</dict>
</plist>`;
fs.writeFileSync(plistPath, plistContent);
// Load the launch agent
try {
execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { stdio: "ignore" });
} catch (e) {}
return true;
}
function disableMacOS() {
const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", `${APP_LABEL}.plist`);
try {
execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { stdio: "ignore" });
} catch (e) {}
if (fs.existsSync(plistPath)) {
fs.unlinkSync(plistPath);
}
return true;
}
// ============ Windows ============
function enableWindows(cliPath) {
const startupDir = path.join(process.env.APPDATA, "Microsoft", "Windows", "Start Menu", "Programs", "Startup");
const vbsPath = path.join(startupDir, `${APP_NAME}.vbs`);
// Ensure startup directory exists
if (!fs.existsSync(startupDir)) {
return false;
}
// Get absolute paths
const nodePath = process.execPath;
let routerScript;
if (cliPath) {
// Use provided path (from running cli.js)
routerScript = path.resolve(cliPath);
} else {
// Fallback: try to resolve from npm bin
try {
const npmBin = execSync("npm bin -g", { encoding: "utf8", shell: true, stdio: ["ignore", "pipe", "ignore"] }).trim();
const routerLink = path.join(npmBin, "9router.cmd");
if (fs.existsSync(routerLink)) {
routerScript = routerLink;
} else {
// Try to resolve actual script
const routerJs = path.join(npmBin, "../lib/node_modules/9router/cli.js");
if (fs.existsSync(routerJs)) {
routerScript = routerJs;
}
}
} catch (e) {
// Fallback
}
}
// Create VBS script to run hidden (no console window)
let vbsContent;
if (routerScript && routerScript.endsWith(".js")) {
// Run node directly with script
vbsContent = `Set WshShell = CreateObject("WScript.Shell")
WshShell.Run """${nodePath}"" ""${routerScript}"" --tray --skip-update", 0, False
`;
} else if (routerScript) {
// Run .cmd file
vbsContent = `Set WshShell = CreateObject("WScript.Shell")
WshShell.Run """${routerScript}"" --tray --skip-update", 0, False
`;
} else {
// Fallback to npx
vbsContent = `Set WshShell = CreateObject("WScript.Shell")
WshShell.Run "npx 9router --tray --skip-update", 0, False
`;
}
fs.writeFileSync(vbsPath, vbsContent);
return true;
}
function disableWindows() {
const vbsPath = path.join(process.env.APPDATA, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", `${APP_NAME}.vbs`);
if (fs.existsSync(vbsPath)) {
fs.unlinkSync(vbsPath);
}
return true;
}
// ============ Linux ============
function enableLinux(cliPath) {
const autostartDir = path.join(os.homedir(), ".config", "autostart");
const desktopPath = path.join(autostartDir, `${APP_NAME}.desktop`);
// Ensure directory exists
if (!fs.existsSync(autostartDir)) {
try {
fs.mkdirSync(autostartDir, { recursive: true });
} catch (e) {
return false;
}
}
// Get absolute paths
const nodePath = process.execPath;
let routerScript;
if (cliPath) {
// Use provided path (from running cli.js)
routerScript = path.resolve(cliPath);
} else {
// Fallback: try to resolve from npm bin
try {
const npmBin = execSync("npm bin -g", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
const routerLink = path.join(npmBin, "9router");
if (fs.existsSync(routerLink)) {
routerScript = fs.realpathSync(routerLink);
}
} catch (e) {
// Last resort fallback
routerScript = "/usr/local/lib/node_modules/9router/cli.js";
}
}
const desktopContent = `[Desktop Entry]
Type=Application
Name=9Router
Comment=9Router API Proxy
Exec=${nodePath} ${routerScript} --tray --skip-update
Hidden=false
NoDisplay=false
X-GNOME-Autostart-enabled=true
`;
fs.writeFileSync(desktopPath, desktopContent);
return true;
}
function disableLinux() {
const desktopPath = path.join(os.homedir(), ".config", "autostart", `${APP_NAME}.desktop`);
if (fs.existsSync(desktopPath)) {
fs.unlinkSync(desktopPath);
}
return true;
}
module.exports = {
enableAutoStart,
disableAutoStart,
isAutoStartEnabled
};

BIN
cli/src/cli/tray/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 969 B

BIN
cli/src/cli/tray/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 947 B

236
cli/src/cli/tray/tray.js Normal file
View File

@@ -0,0 +1,236 @@
const { exec } = require("child_process");
const fs = require("fs");
const path = require("path");
let trayInstance = null;
let isWinTray = false;
/**
* Get icon base64 from file — used for systray (mac/linux)
*/
function getIconBase64() {
const isWin = process.platform === "win32";
const iconFile = isWin ? "icon.ico" : "icon.png";
try {
const iconPath = path.join(__dirname, iconFile);
if (fs.existsSync(iconPath)) {
return fs.readFileSync(iconPath).toString("base64");
}
} catch (e) {}
// Fallback: minimal green dot icon (PNG)
return "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGPC/xhBQAAAAlwSFlzAAALEwAACxMBAJqcGAAAAHpJREFUOE9jYBgFgwEwMjIy/Gdg+P8fyP4PxP8ZGBgEcBnGyMjIsICBgSEAhyH/gfgBUNN8XJoZsdkCVL8Ah+b/QPwbqvkBMvk/AwMDAzYX/GdgYAhAN+A/SICRWAMYGfFEJSMjzriEiwDR/xmIa2RkZCSqnZERb3QCAAo3KxzxbKe1AAAAAElFTkSuQmCC";
}
/**
* Check if system tray is supported on current OS
* Supported: macOS, Windows, Linux (with GUI)
*/
function isTraySupported() {
const platform = process.platform;
if (!["darwin", "win32", "linux"].includes(platform)) {
return false;
}
if (platform === "linux" && !process.env.DISPLAY) {
return false;
}
return true;
}
/**
* Initialize system tray with menu
* @param {Object} options - { port, onQuit, onOpenDashboard }
* @returns {Object|null} tray instance or null if not supported/failed
*/
function initTray(options) {
if (!isTraySupported()) {
return null;
}
// Windows uses PowerShell NotifyIcon (AV-safe), others use systray
if (process.platform === "win32") {
return initWindowsTray(options);
}
return initUnixTray(options);
}
/**
* Build menu items array shared between platforms
*/
function buildMenuItems(port, autostartEnabled) {
return [
{ title: `9Router (Port ${port})`, tooltip: "Server is running", enabled: false },
{ title: "Open Dashboard", tooltip: "Open in browser", enabled: true },
{
title: autostartEnabled ? "✓ Auto-start Enabled" : "Enable Auto-start",
tooltip: "Run on OS startup",
enabled: true
},
{ title: "Quit", tooltip: "Stop server and exit", enabled: true }
];
}
// Menu item indexes
const MENU_INDEX = { STATUS: 0, DASHBOARD: 1, AUTOSTART: 2, QUIT: 3 };
/**
* Get current autostart state
*/
function getAutostartEnabled() {
try {
const { isAutoStartEnabled } = require("./autostart");
return isAutoStartEnabled();
} catch (e) {
return false;
}
}
/**
* Handle menu item click (shared logic)
*/
function handleClick(index, options, onAutostartToggle) {
const { onQuit, onOpenDashboard, port } = options;
if (index === MENU_INDEX.DASHBOARD) {
if (onOpenDashboard) onOpenDashboard();
else openBrowser(`http://localhost:${port}/dashboard`);
} else if (index === MENU_INDEX.AUTOSTART) {
const enabled = getAutostartEnabled();
try {
const { enableAutoStart, disableAutoStart } = require("./autostart");
if (enabled) disableAutoStart();
else enableAutoStart();
onAutostartToggle(!enabled);
} catch (e) {}
} else if (index === MENU_INDEX.QUIT) {
console.log("\n👋 Shutting down...");
if (onQuit) onQuit();
killTray();
setTimeout(() => process.exit(0), 500);
}
}
/**
* Windows tray via PowerShell NotifyIcon
*/
function initWindowsTray(options) {
const { port } = options;
try {
const { initWinTray } = require("./trayWin");
const iconPath = path.join(__dirname, "icon.ico");
const autostartEnabled = getAutostartEnabled();
const items = buildMenuItems(port, autostartEnabled);
trayInstance = initWinTray({
iconPath,
tooltip: `9Router - Port ${port}`,
items,
onClick: (index) => {
handleClick(index, options, (newEnabled) => {
const newTitle = newEnabled ? "✓ Auto-start Enabled" : "Enable Auto-start";
trayInstance.updateItem(MENU_INDEX.AUTOSTART, newTitle, true);
});
}
});
isWinTray = true;
return trayInstance;
} catch (err) {
return null;
}
}
/**
* macOS/Linux tray via systray binary
*/
function resolveSystray() {
// Try local first (dev), then runtime dir (production lazy install)
try {
return require("systray").default;
} catch (e) {}
try {
const { getRuntimeNodeModules } = require("../../../hooks/sqliteRuntime");
const systrayPath = path.join(getRuntimeNodeModules(), "systray");
return require(systrayPath).default;
} catch (e) {
return null;
}
}
function initUnixTray(options) {
const { port } = options;
try {
const SysTray = resolveSystray();
if (!SysTray) return null;
const autostartEnabled = getAutostartEnabled();
const items = buildMenuItems(port, autostartEnabled);
const menu = {
icon: getIconBase64(),
title: "",
tooltip: `9Router - Port ${port}`,
items
};
trayInstance = new SysTray({ menu, debug: false, copyDir: true });
isWinTray = false;
trayInstance.onClick((action) => {
handleClick(action.seq_id, options, (newEnabled) => {
trayInstance.sendAction({
type: "update-item",
item: {
title: newEnabled ? "✓ Auto-start Enabled" : "Enable Auto-start",
tooltip: "Run on OS startup",
enabled: true
},
seq_id: MENU_INDEX.AUTOSTART
});
});
});
trayInstance.onReady(() => {});
trayInstance.onError(() => {});
return trayInstance;
} catch (err) {
return null;
}
}
/**
* Kill/close system tray gracefully
*/
function killTray() {
const instance = trayInstance;
const wasWin = isWinTray;
trayInstance = null;
if (instance) {
try {
if (wasWin) instance.kill();
else instance.kill(true);
} catch (e) {}
}
}
/**
* Open browser
*/
function openBrowser(url) {
const platform = process.platform;
let cmd;
if (platform === "darwin") {
cmd = `open "${url}"`;
} else if (platform === "win32") {
cmd = `start "" "${url}"`;
} else {
cmd = `xdg-open "${url}"`;
}
exec(cmd);
}
module.exports = {
initTray,
killTray
};

79
cli/src/cli/tray/tray.ps1 Normal file
View File

@@ -0,0 +1,79 @@
# 9Router tray icon for Windows using NotifyIcon
# IPC: stdin JSON commands, stdout JSON events
param([string]$IconPath, [string]$Tooltip)
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$ErrorActionPreference = "Stop"
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
$OutputEncoding = [System.Text.Encoding]::UTF8
$script:notifyIcon = New-Object System.Windows.Forms.NotifyIcon
$script:notifyIcon.Icon = New-Object System.Drawing.Icon($IconPath)
$script:notifyIcon.Text = $Tooltip
$script:notifyIcon.Visible = $true
$script:menu = New-Object System.Windows.Forms.ContextMenuStrip
$script:notifyIcon.ContextMenuStrip = $script:menu
$script:items = @()
function Write-Event($obj) {
$json = $obj | ConvertTo-Json -Compress
[Console]::Out.WriteLine($json)
[Console]::Out.Flush()
}
function Add-MenuItem($index, $title, $enabled) {
$item = New-Object System.Windows.Forms.ToolStripMenuItem
$item.Text = $title
$item.Enabled = $enabled
$idx = $index
$item.Add_Click({ Write-Event @{ type = "click"; index = $idx } }.GetNewClosure())
$script:menu.Items.Add($item) | Out-Null
$script:items += $item
}
function Update-MenuItem($index, $title, $enabled) {
if ($index -lt $script:items.Count) {
$script:items[$index].Text = $title
$script:items[$index].Enabled = $enabled
}
}
function Set-Tooltip($text) {
# NotifyIcon.Text max 63 chars
if ($text.Length -gt 63) { $text = $text.Substring(0, 63) }
$script:notifyIcon.Text = $text
}
# Background reader thread polls stdin via timer on UI thread
$script:timer = New-Object System.Windows.Forms.Timer
$script:timer.Interval = 100
$script:timer.Add_Tick({
try {
while ([Console]::In.Peek() -ne -1) {
$line = [Console]::In.ReadLine()
if ([string]::IsNullOrWhiteSpace($line)) { continue }
$cmd = $line | ConvertFrom-Json
switch ($cmd.action) {
"add-item" { Add-MenuItem $cmd.index $cmd.title $cmd.enabled }
"update-item" { Update-MenuItem $cmd.index $cmd.title $cmd.enabled }
"set-tooltip" { Set-Tooltip $cmd.text }
"ready" { Write-Event @{ type = "ready" } }
"kill" {
$script:notifyIcon.Visible = $false
$script:notifyIcon.Dispose()
[System.Windows.Forms.Application]::Exit()
}
}
}
} catch {
Write-Event @{ type = "error"; message = $_.Exception.Message }
}
})
$script:timer.Start()
Write-Event @{ type = "started" }
[System.Windows.Forms.Application]::Run()

View File

@@ -0,0 +1,89 @@
const { spawn } = require("child_process");
const path = require("path");
const readline = require("readline");
// PowerShell-based tray for Windows (AV-safe, zero binary deps)
let psProcess = null;
let clickHandler = null;
/**
* Send JSON command to PowerShell tray process via stdin
*/
function sendCommand(cmd) {
if (psProcess && psProcess.stdin.writable) {
psProcess.stdin.write(`${JSON.stringify(cmd)}\n`, "utf8");
}
}
/**
* Initialize Windows tray using PowerShell NotifyIcon
* @param {Object} options - { iconPath, tooltip, items, onClick }
* items: [{ title, enabled }]
* @returns {Object|null} controller with sendAction/kill
*/
function initWinTray(options) {
const { iconPath, tooltip, items, onClick } = options;
clickHandler = onClick;
const scriptPath = path.join(__dirname, "tray.ps1");
try {
psProcess = spawn(
"powershell.exe",
[
"-NoProfile",
"-ExecutionPolicy", "Bypass",
"-WindowStyle", "Hidden",
"-InputFormat", "Text",
"-OutputFormat", "Text",
"-File", scriptPath,
"-IconPath", iconPath,
"-Tooltip", tooltip
],
{ windowsHide: true, stdio: ["pipe", "pipe", "pipe"] }
);
} catch (err) {
return null;
}
const rl = readline.createInterface({ input: psProcess.stdout });
rl.on("line", (line) => {
try {
const evt = JSON.parse(line);
if (evt.type === "click" && clickHandler) {
clickHandler(evt.index);
}
} catch (e) {}
});
psProcess.on("error", () => {});
psProcess.stderr.on("data", () => {});
// Send initial menu items
items.forEach((item, index) => {
sendCommand({ action: "add-item", index, title: item.title, enabled: item.enabled });
});
return {
updateItem(index, title, enabled) {
sendCommand({ action: "update-item", index, title, enabled });
},
setTooltip(text) {
sendCommand({ action: "set-tooltip", text });
},
kill() {
try {
sendCommand({ action: "kill" });
} catch (e) {}
setTimeout(() => {
if (psProcess && !psProcess.killed) {
try { psProcess.kill(); } catch (e) {}
}
psProcess = null;
}, 300);
}
};
}
module.exports = { initWinTray };

View File

@@ -0,0 +1,30 @@
const { execSync } = require("child_process");
/**
* Copy text to clipboard based on OS
* @param {string} text - Text to copy
* @returns {boolean} Success status
*/
function copyToClipboard(text) {
try {
const platform = process.platform;
if (platform === "darwin") {
execSync("pbcopy", { input: text });
} else if (platform === "win32") {
execSync("clip", { input: text });
} else {
// Linux - try xclip first, then xsel
try {
execSync("xclip -selection clipboard", { input: text });
} catch {
execSync("xsel --clipboard --input", { input: text });
}
}
return true;
} catch (error) {
return false;
}
}
module.exports = { copyToClipboard };

View File

@@ -0,0 +1,156 @@
const { formatNumber } = require("./format");
// ANSI color codes
const COLORS = {
reset: "\x1b[0m",
success: "\x1b[32m",
error: "\x1b[31m",
warning: "\x1b[33m",
info: "\x1b[36m",
dim: "\x1b[2m",
bold: "\x1b[1m",
bright: "\x1b[1m",
cyan: "\x1b[36m"
};
// Box drawing characters
const BOX_CHARS = {
topLeft: "┌",
topRight: "┐",
bottomLeft: "└",
bottomRight: "┘",
horizontal: "─",
vertical: "│"
};
/**
* Draw a box with border around content
* @param {string} title - Box title
* @param {string} content - Content to display inside box
* @param {number} [width=60] - Box width
*/
function showBox(title, content, width = 60) {
const innerWidth = width - 4;
const lines = content.split("\n");
// Top border with title
const topBorder = BOX_CHARS.topLeft + BOX_CHARS.horizontal.repeat(2) +
` ${title} ` +
BOX_CHARS.horizontal.repeat(Math.max(0, innerWidth - title.length - 3)) +
BOX_CHARS.topRight;
console.log(topBorder);
// Content lines
lines.forEach(line => {
const paddedLine = line.padEnd(innerWidth);
console.log(`${BOX_CHARS.vertical} ${paddedLine} ${BOX_CHARS.vertical}`);
});
// Bottom border
const bottomBorder = BOX_CHARS.bottomLeft +
BOX_CHARS.horizontal.repeat(innerWidth + 2) +
BOX_CHARS.bottomRight;
console.log(bottomBorder);
}
/**
* Display a menu with numbered items
* @param {string} title - Menu title
* @param {string[]} items - Array of menu items
* @param {string} [footer] - Optional footer text
*/
function showMenu(title, items, footer) {
console.log(`\n${COLORS.bold}${title}${COLORS.reset}`);
console.log(COLORS.dim + "─".repeat(title.length) + COLORS.reset);
items.forEach((item, index) => {
console.log(` ${COLORS.info}${index + 1}.${COLORS.reset} ${item}`);
});
if (footer) {
console.log(`\n${COLORS.dim}${footer}${COLORS.reset}`);
}
console.log();
}
/**
* Display data in table format
* @param {string[]} headers - Array of column headers
* @param {Array<Array<string|number>>} rows - Array of row data
*/
function showTable(headers, rows) {
if (!headers.length || !rows.length) {
return;
}
// Calculate column widths
const colWidths = headers.map((header, i) => {
const maxDataWidth = Math.max(...rows.map(row => String(row[i] || "").length));
return Math.max(header.length, maxDataWidth);
});
// Print header
const headerRow = headers.map((h, i) => h.padEnd(colWidths[i])).join(" │ ");
console.log(COLORS.bold + headerRow + COLORS.reset);
// Print separator
const separator = colWidths.map(w => "─".repeat(w)).join("─┼─");
console.log(COLORS.dim + separator + COLORS.reset);
// Print rows
rows.forEach(row => {
const rowStr = row.map((cell, i) => String(cell || "").padEnd(colWidths[i])).join(" │ ");
console.log(rowStr);
});
}
/**
* Show colored status message
* @param {string} message - Message to display
* @param {string} [type="info"] - Status type: success, error, warning, info
*/
function showStatus(message, type = "info") {
const symbols = {
success: "✓",
error: "✗",
warning: "⚠",
info: ""
};
const color = COLORS[type] || COLORS.info;
const symbol = symbols[type] || symbols.info;
console.log(`${color}${symbol} ${message}${COLORS.reset}`);
}
/**
* Clear the terminal screen
*/
function clearScreen() {
console.clear();
}
/**
* Show menu header with title and subtitle
* @param {string} title - Main title
* @param {string} subtitle - Optional subtitle
*/
function showHeader(title, subtitle) {
console.log(`\n${"=".repeat(60)}`);
console.log(` ${COLORS.bright}${COLORS.cyan}${title}${COLORS.reset}`);
if (subtitle) {
console.log(` ${COLORS.dim}${subtitle}${COLORS.reset}`);
}
console.log(`${"=".repeat(60)}\n`);
}
module.exports = {
showBox,
showMenu,
showTable,
showStatus,
clearScreen,
showHeader
};

View File

@@ -0,0 +1,32 @@
const api = require("../api/client");
const COLORS = {
reset: "\x1b[0m",
green: "\x1b[32m"
};
/**
* Get endpoint URL based on tunnel status
* @param {number} port - Local server port
* @returns {Promise<{endpoint: string, tunnelEnabled: boolean}>}
*/
async function getEndpoint(port) {
const result = await api.getTunnelStatus();
const tunnelEnabled = result.success && result.data?.enabled === true;
const publicUrl = result.success ? result.data?.publicUrl : "";
const endpoint = tunnelEnabled && publicUrl ? `${publicUrl}/v1` : `http://localhost:${port}/v1`;
return { endpoint, tunnelEnabled };
}
/**
* Get endpoint with color formatting
* @param {number} port - Local server port
* @returns {Promise<string>} Colored endpoint string
*/
async function getEndpointColored(port) {
const { endpoint, tunnelEnabled } = await getEndpoint(port);
return tunnelEnabled ? `${COLORS.green}${endpoint}${COLORS.reset}` : endpoint;
}
module.exports = { getEndpoint, getEndpointColored };

125
cli/src/cli/utils/format.js Normal file
View File

@@ -0,0 +1,125 @@
/**
* Truncate text with ellipsis
* @param {string} text - Text to truncate
* @param {number} maxLength - Maximum length
* @returns {string} Truncated text
*/
function truncate(text, maxLength) {
if (!text || text.length <= maxLength) {
return text;
}
return text.substring(0, maxLength - 3) + "...";
}
/**
* Mask API key showing only first and last characters
* @param {string} key - API key to mask
* @returns {string} Masked key
*/
function maskKey(key) {
if (!key || key.length < 8) {
return "***";
}
const firstChars = key.substring(0, 4);
const lastChars = key.substring(key.length - 4);
return `${firstChars}${"*".repeat(key.length - 8)}${lastChars}`;
}
/**
* Format date to readable string
* @param {Date|string|number} date - Date to format
* @returns {string} Formatted date string
*/
function formatDate(date) {
const d = new Date(date);
if (isNaN(d.getTime())) {
return "Invalid Date";
}
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
const hours = String(d.getHours()).padStart(2, "0");
const minutes = String(d.getMinutes()).padStart(2, "0");
const seconds = String(d.getSeconds()).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
/**
* Format number with commas
* @param {number} num - Number to format
* @returns {string} Formatted number
*/
function formatNumber(num) {
if (typeof num !== "number" || isNaN(num)) {
return "0";
}
return num.toLocaleString("en-US");
}
/**
* Format bytes to human readable size
* @param {number} bytes - Bytes to format
* @returns {string} Formatted size string
*/
function formatBytes(bytes) {
if (typeof bytes !== "number" || isNaN(bytes) || bytes < 0) {
return "0 B";
}
const units = ["B", "KB", "MB", "GB", "TB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
}
/**
* Get relative time string
* @param {Date|string|number} date - Date to compare
* @returns {string} Relative time string
*/
function getRelativeTime(date) {
const d = new Date(date);
if (isNaN(d.getTime())) {
return "Invalid Date";
}
const now = new Date();
const diffMs = now - d;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
const diffMonth = Math.floor(diffDay / 30);
const diffYear = Math.floor(diffDay / 365);
if (diffSec < 60) {
return "just now";
} else if (diffMin < 60) {
return `${diffMin} minute${diffMin > 1 ? "s" : ""} ago`;
} else if (diffHour < 24) {
return `${diffHour} hour${diffHour > 1 ? "s" : ""} ago`;
} else if (diffDay < 30) {
return `${diffDay} day${diffDay > 1 ? "s" : ""} ago`;
} else if (diffMonth < 12) {
return `${diffMonth} month${diffMonth > 1 ? "s" : ""} ago`;
} else {
return `${diffYear} year${diffYear > 1 ? "s" : ""} ago`;
}
}
module.exports = {
truncate,
maskKey,
formatDate,
formatNumber,
formatBytes,
getRelativeTime
};

229
cli/src/cli/utils/input.js Normal file
View File

@@ -0,0 +1,229 @@
const readline = require("readline");
const COLORS = {
reset: "\x1b[0m",
bright: "\x1b[1m",
dim: "\x1b[2m",
underline: "\x1b[4m",
reverse: "\x1b[7m",
cyan: "\x1b[36m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
white: "\x1b[37m",
bgGreen: "\x1b[42m",
bgBlue: "\x1b[44m",
black: "\x1b[30m",
// Terracotta/Earth orange - using RGB escape code
terracotta: "\x1b[38;2;217;119;87m", // #D97757
bgTerracotta: "\x1b[48;2;217;119;87m"
};
/**
* Ask a question and return the user's answer
* @param {string} question - The question to ask
* @returns {Promise<string>} The user's answer
*/
async function prompt(question) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
/**
* Show a numbered menu and return the selected option number
* @param {string} question - The question to ask
* @param {string[]} options - Array of options to display
* @returns {Promise<number>} The selected option index (0-based)
*/
async function select(question, options) {
console.log(question);
options.forEach((option, index) => {
console.log(` ${index + 1}. ${option}`);
});
while (true) {
const answer = await prompt("\nSelect option (number): ");
const num = parseInt(answer, 10);
if (!isNaN(num) && num >= 1 && num <= options.length) {
return num - 1;
}
console.log(`Invalid selection. Please enter a number between 1 and ${options.length}`);
}
}
/**
* Ask a yes/no question and return boolean
* @param {string} question - The question to ask
* @returns {Promise<boolean>} True for yes, false for no
*/
async function confirm(question) {
while (true) {
const answer = await prompt(`${question} (y/n): `);
const lower = answer.toLowerCase();
if (lower === "y" || lower === "yes") {
return true;
}
if (lower === "n" || lower === "no") {
return false;
}
console.log("Please answer 'y' or 'n'");
}
}
/**
* Pause execution until user presses Enter
* @param {string} [message="Press Enter to continue..."] - Message to display
* @returns {Promise<void>}
*/
async function pause(message = "Press Enter to continue...") {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
return new Promise((resolve) => {
rl.question(message, () => {
rl.close();
resolve();
});
});
}
/**
* Show interactive menu with arrow key navigation
* @param {string} title - Menu title
* @param {Array<{label: string, icon?: string}>} items - Menu items
* @param {number} defaultIndex - Default selected index
* @param {string} headerContent - Optional content to show above menu
* @param {Array<string>} breadcrumb - Optional breadcrumb path
* @returns {Promise<number>} Selected index, or -1 if ESC pressed
*/
async function selectMenu(title, items, defaultIndex = 0, subtitle = "", headerContent = "", breadcrumb = []) {
return new Promise((resolve) => {
let selectedIndex = defaultIndex;
let isActive = true;
// Remove any existing keypress listeners first
process.stdin.removeAllListeners("keypress");
readline.emitKeypressEvents(process.stdin);
if (process.stdin.isTTY) {
try {
process.stdin.setRawMode(true);
} catch (err) {
// TTY disconnected or EIO error - exit gracefully
resolve(-1);
return;
}
}
const renderMenu = () => {
if (!isActive) return;
// Clear previous menu
process.stdout.write("\x1b[2J\x1b[H");
// Show title with terracotta color
const width = Math.min(process.stdout.columns || 40, 40);
console.log(`\n${COLORS.terracotta}${"=".repeat(width)}${COLORS.reset}`);
console.log(` ${COLORS.bright}${COLORS.terracotta}${title}${COLORS.reset}`);
// Show subtitle inside the frame
if (subtitle) {
console.log(` ${COLORS.dim}${subtitle}${COLORS.reset}`);
}
console.log(`${COLORS.terracotta}${"=".repeat(width)}${COLORS.reset}`);
// Show breadcrumb if provided
if (breadcrumb.length > 0) {
console.log(` ${COLORS.dim}${breadcrumb.join(" > ")}${COLORS.reset}`);
}
console.log();
// Show header content if provided
if (headerContent) {
console.log(headerContent);
console.log();
}
// Show menu items with proper alignment
items.forEach((item, index) => {
const isSelected = index === selectedIndex;
// Fallback to ASCII on Windows (cmd/powershell can't render unicode stars)
const isWin = process.platform === "win32";
const icon = isSelected ? (isWin ? ">" : "★") : (isWin ? " " : "☆");
if (isSelected) {
// Selected: reverse + bright for high visibility on any terminal
console.log(` ${COLORS.reverse}${COLORS.bright}${icon} ${item.label}${COLORS.reset}`);
} else {
// Not selected: plain text with empty star
console.log(` ${icon} ${item.label}`);
}
});
};
const cleanup = () => {
if (!isActive) return;
isActive = false;
if (process.stdin.isTTY) {
try {
process.stdin.setRawMode(false);
} catch (err) {
// Ignore cleanup errors
}
}
process.stdin.removeListener("keypress", onKeypress);
process.stdin.pause();
};
const onKeypress = (str, key) => {
if (!isActive || !key) return;
if (key.name === "up") {
selectedIndex = (selectedIndex - 1 + items.length) % items.length;
renderMenu();
} else if (key.name === "down") {
selectedIndex = (selectedIndex + 1) % items.length;
renderMenu();
} else if (key.name === "return") {
cleanup();
resolve(selectedIndex);
} else if (key.name === "escape") {
cleanup();
resolve(-1);
} else if (key.ctrl && key.name === "c") {
cleanup();
process.exit(0);
}
};
process.stdin.on("keypress", onKeypress);
process.stdin.resume();
renderMenu();
});
}
module.exports = {
prompt,
select,
confirm,
pause,
selectMenu
};

View File

@@ -0,0 +1,156 @@
const { selectMenu } = require("./input");
/**
* Show a menu with back button at top and handle selection
* @param {Object} config - Menu configuration
* @param {string} config.title - Menu title
* @param {string} config.headerContent - Optional header content
* @param {Array<{label: string, action: Function}>} config.items - Menu items with actions
* @param {string} config.backLabel - Back button label (default: "← Back")
* @param {number} config.defaultIndex - Default selected index (default: 0)
* @param {Function} config.refresh - Optional refresh function to call after each action
* @param {Array<string>} config.breadcrumb - Optional breadcrumb path
* @returns {Promise<void>}
*/
async function showMenuWithBack(config) {
const {
title,
headerContent = "",
items,
backLabel = "← Back",
defaultIndex = 0,
refresh = null,
breadcrumb = []
} = config;
while (true) {
// Call refresh if provided
let refreshedData = null;
if (refresh) {
refreshedData = await refresh();
if (refreshedData === null) {
// Refresh failed, exit menu
return;
}
}
// Build menu items with back at top
const menuItems = [
{ label: backLabel, icon: "☆" },
...items.map(item => ({
label: typeof item.label === "function" ? item.label(refreshedData) : item.label,
icon: "☆"
}))
];
// Resolve headerContent if it's a function
const resolvedHeader = typeof headerContent === "function"
? await headerContent(refreshedData)
: headerContent;
const selected = await selectMenu(
title,
menuItems,
defaultIndex,
"",
resolvedHeader,
breadcrumb
);
// Back or ESC
if (selected === -1 || selected === 0) {
return;
}
// Execute action for selected item
const actionIndex = selected - 1;
const item = items[actionIndex];
if (item && item.action) {
const shouldContinue = await item.action(refreshedData);
// If action returns false, exit menu
if (shouldContinue === false) {
return;
}
}
}
}
/**
* Show a list menu where items are fetched dynamically
* @param {Object} config - Menu configuration
* @param {string} config.title - Menu title
* @param {string} config.headerContent - Optional header content
* @param {Function} config.fetchItems - Async function to fetch items array
* @param {Function} config.formatItem - Function to format each item to {label, data}
* @param {Function} config.onSelect - Action when item is selected
* @param {Object} config.createAction - Optional create action {label, action}
* @param {string} config.backLabel - Back button label
* @param {Array<string>} config.breadcrumb - Optional breadcrumb path
* @returns {Promise<void>}
*/
async function showListMenu(config) {
const {
title,
headerContent = "",
fetchItems,
formatItem,
onSelect,
createAction = null,
backLabel = "← Back",
breadcrumb = []
} = config;
while (true) {
// Fetch items
const result = await fetchItems();
if (!result) {
return;
}
const items = result.items || [];
const metadata = result.metadata || {};
// Build menu items
const menuItems = [{ label: backLabel, icon: "☆" }];
if (createAction) {
menuItems.push({ label: createAction.label, icon: "☆" });
}
items.forEach(item => {
const formatted = formatItem(item);
menuItems.push({ label: formatted, icon: "☆" });
});
const header = typeof headerContent === "function"
? await headerContent(metadata)
: headerContent;
const selected = await selectMenu(title, menuItems, 0, "", header, breadcrumb);
// Back or ESC
if (selected === -1 || selected === 0) {
return;
}
// Create action
if (createAction && selected === 1) {
await createAction.action();
continue;
}
// Select item
const offset = createAction ? 2 : 1;
const itemIndex = selected - offset;
if (itemIndex >= 0 && itemIndex < items.length) {
await onSelect(items[itemIndex]);
}
}
}
module.exports = {
showMenuWithBack,
showListMenu
};

View File

@@ -0,0 +1,136 @@
const api = require("../api/client");
const { prompt } = require("./input");
const { clearScreen } = require("./display");
// Provider alias order: OAuth first, then API Key (matches ModelSelectModal)
const PROVIDER_ALIAS_ORDER = [
"cc", "ag", "cx", "if", "qw", "gc", "gh", "kr",
"openrouter", "glm", "kimi", "minimax", "openai", "anthropic", "gemini"
];
// Alias to display name mapping
const PROVIDER_ALIAS_NAMES = {
cc: "Claude Code",
ag: "Antigravity",
cx: "OpenAI Codex",
if: "iFlow AI",
qw: "Qwen Code",
gc: "Gemini CLI",
gh: "GitHub Copilot",
kr: "Kiro AI",
openrouter: "OpenRouter",
glm: "GLM Coding",
kimi: "Kimi Coding",
minimax: "Minimax Coding",
openai: "OpenAI",
anthropic: "Anthropic",
gemini: "Gemini"
};
/**
* Get all available models grouped by provider + combos
* @returns {Promise<{combos: Array, groups: Object}>}
*/
async function getAvailableModelsGrouped() {
const result = await api.getAvailableModels();
if (!result.success) return { combos: [], groups: {} };
const models = result.data?.data || [];
const combos = [];
const groups = {};
models.forEach(m => {
if (m.owned_by === "combo") {
combos.push(m.id);
} else {
const provider = m.owned_by;
if (!groups[provider]) {
groups[provider] = [];
}
groups[provider].push(m.id);
}
});
return { combos, groups };
}
/**
* Display model list and prompt for selection
* @param {string} title - Title to display
* @param {string} currentValue - Current selected value (optional)
* @param {Object} options - { excludeCombos?: boolean }
* @returns {Promise<string|null>} Selected model ID or null if cancelled
*/
async function selectModelFromList(title, currentValue = "", options = {}) {
const { excludeCombos = false } = options;
const { combos: rawCombos, groups } = await getAvailableModelsGrouped();
const combos = excludeCombos ? [] : rawCombos;
const totalModels = combos.length + Object.values(groups).flat().length;
if (totalModels === 0) {
return null;
}
// Build flat list for selection
const allModels = [];
// Display
clearScreen();
console.log(`\n🎯 ${title}`);
console.log("=".repeat(50));
if (currentValue) {
console.log(`Current: ${currentValue}\n`);
} else {
console.log();
}
let idx = 1;
// Combos first (skipped when excludeCombos is true)
if (combos.length > 0) {
console.log("[Combos]");
combos.forEach(combo => {
console.log(` ${idx}. ${combo}`);
allModels.push(combo);
idx++;
});
console.log();
}
// Provider groups in order (by alias)
const sortedProviders = Object.keys(groups).sort((a, b) => {
const idxA = PROVIDER_ALIAS_ORDER.indexOf(a);
const idxB = PROVIDER_ALIAS_ORDER.indexOf(b);
return (idxA === -1 ? 999 : idxA) - (idxB === -1 ? 999 : idxB);
});
sortedProviders.forEach(provider => {
const providerName = PROVIDER_ALIAS_NAMES[provider] || provider;
console.log(`[${providerName}]`);
groups[provider].forEach(model => {
console.log(` ${idx}. ${model}`);
allModels.push(model);
idx++;
});
console.log();
});
console.log(" 0. Cancel\n");
// Prompt for number input
const input = await prompt("Enter number: ");
const num = parseInt(input, 10);
if (isNaN(num) || num === 0 || num < 0 || num > allModels.length) {
return null;
}
return allModels[num - 1];
}
module.exports = {
selectModelFromList,
getAvailableModelsGrouped,
PROVIDER_ALIAS_ORDER,
PROVIDER_ALIAS_NAMES
};

3
cloud/.gitignore vendored
View File

@@ -1,3 +0,0 @@
.wrangler/*
node_modules/*
**node_modules/*

View File

@@ -1,25 +0,0 @@
# 9Router Cloud Worker
Deploy your own Cloudflare Worker to access 9Router from anywhere.
## Setup
```bash
# 1. Login to Cloudflare
npm install -g wrangler
wrangler login
# 2. Install dependencies
cd app/cloud
npm install
# 3. Create KV & D1, then paste IDs into wrangler.toml
wrangler kv namespace create KV
wrangler d1 create proxy-db
# 4. Init database & deploy
wrangler d1 execute proxy-db --remote --file=./migrations/0001_init.sql
npm run deploy
```
Copy your Worker URL → 9Router Dashboard → **Endpoint****Setup Cloud** → paste → **Save****Enable Cloud**.

View File

@@ -1,12 +0,0 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"open-sse": ["../open-sse"],
"open-sse/*": ["../open-sse/*"]
},
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ESNext"
}
}

View File

@@ -1,9 +0,0 @@
-- Migration: Create machines table
CREATE TABLE IF NOT EXISTS machines (
machineId TEXT PRIMARY KEY,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
-- Index for faster lookups
CREATE INDEX IF NOT EXISTS idx_machines_updatedAt ON machines(updatedAt);

View File

@@ -1,17 +0,0 @@
{
"name": "9router-cloud",
"version": "0.2.13",
"private": true,
"type": "module",
"description": "9Router Cloud Worker - Self-hosted Cloudflare Worker proxy",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy"
},
"dependencies": {
"open-sse": "file:../open-sse"
},
"devDependencies": {
"wrangler": "^3.0.0"
}
}

View File

@@ -1,37 +0,0 @@
import { errorResponse } from "open-sse/utils/error.js";
import { extractBearerToken, parseApiKey } from "../utils/apiKey.js";
import * as log from "../utils/logger.js";
export async function handleCacheClear(request, env) {
const apiKey = extractBearerToken(request);
if (!apiKey) {
return errorResponse(401, "Missing API key");
}
try {
const body = await request.json().catch(() => ({}));
// Get machineId from API key or body
let machineId = body.machineId;
if (!machineId) {
const parsed = await parseApiKey(apiKey);
machineId = parsed?.machineId;
}
if (!machineId) {
return errorResponse(400, "Missing machineId");
}
// No cache layer to clear anymore
log.info("CACHE", `Cache clear requested for machine: ${machineId} (no-op)`);
return new Response(JSON.stringify({ success: true, machineId, message: "No cache layer" }), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*"
}
});
} catch (error) {
return errorResponse(500, error.message);
}
}

View File

@@ -1,313 +0,0 @@
import { getModelInfoCore } from "open-sse/services/model.js";
import { handleChatCore } from "open-sse/handlers/chatCore.js";
import { errorResponse } from "open-sse/utils/error.js";
import { checkFallbackError, isAccountUnavailable, getUnavailableUntil, getEarliestRateLimitedUntil, formatRetryAfter } from "open-sse/services/accountFallback.js";
import { MAX_RATE_LIMIT_COOLDOWN_MS } from "open-sse/config/errorConfig.js";
import { getComboModelsFromData, handleComboChat } from "open-sse/services/combo.js";
import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
import * as log from "../utils/logger.js";
import { refreshTokenByProvider } from "../services/tokenRefresh.js";
import { parseApiKey, extractBearerToken } from "../utils/apiKey.js";
import { getMachineData, saveMachineData } from "../services/storage.js";
const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
async function getModelInfo(modelStr, machineId, env) {
const data = await getMachineData(machineId, env);
return getModelInfoCore(modelStr, data?.modelAliases || {});
}
/**
* Handle chat request
* @param {Request} request
* @param {Object} env
* @param {Object} ctx
* @param {string|null} machineIdOverride - machineId from URL (old format) or null (new format - extract from key)
*/
export async function handleChat(request, env, ctx, machineIdOverride = null) {
if (request.method === "OPTIONS") {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "*"
}
});
}
// Determine machineId: from URL (old) or from API key (new)
let machineId = machineIdOverride;
if (!machineId) {
// New format: extract machineId from API key
const apiKey = extractBearerToken(request);
if (!apiKey) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
const parsed = await parseApiKey(apiKey);
if (!parsed) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key format");
if (!parsed.isNewFormat || !parsed.machineId) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "API key does not contain machineId. Use /{machineId}/v1/... endpoint for old format keys.");
}
machineId = parsed.machineId;
}
if (!await validateApiKey(request, machineId, env)) {
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
}
let body;
try {
body = await request.json();
} catch {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
}
log.info("CHAT", `${machineId} | ${body.model}`, { stream: body.stream !== false });
const modelStr = body.model;
if (!modelStr) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");
// Check if model is a combo
const data = await getMachineData(machineId, env);
const comboModels = getComboModelsFromData(modelStr, data?.combos || []);
if (comboModels) {
log.info("COMBO", `"${modelStr}" with ${comboModels.length} models`);
return handleComboChat({
body,
models: comboModels,
handleSingleModel: (reqBody, model) => handleSingleModelChat(reqBody, model, machineId, env),
log
});
}
// Single model request
return handleSingleModelChat(body, modelStr, machineId, env);
}
/**
* Handle single model chat request
*/
async function handleSingleModelChat(body, modelStr, machineId, env) {
const modelInfo = await getModelInfo(modelStr, machineId, env);
if (!modelInfo.provider) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format");
const { provider, model } = modelInfo;
log.info("MODEL", `${provider.toUpperCase()} | ${model}`);
let excludeConnectionId = null;
let lastError = null;
let lastStatus = null;
while (true) {
const credentials = await getProviderCredentials(machineId, provider, env, excludeConnectionId);
if (!credentials || credentials.allRateLimited) {
if (credentials?.allRateLimited) {
const retryAfterSec = Math.ceil((new Date(credentials.retryAfter).getTime() - Date.now()) / 1000);
const errorMsg = lastError || credentials.lastError || "Unavailable";
const msg = `[${provider}/${model}] ${errorMsg} (${credentials.retryAfterHuman})`;
const status = lastStatus || Number(credentials.lastErrorCode) || HTTP_STATUS.SERVICE_UNAVAILABLE;
log.warn("CHAT", `${provider.toUpperCase()} | ${msg}`);
return new Response(
JSON.stringify({ error: { message: msg } }),
{ status, headers: { "Content-Type": "application/json", "Retry-After": String(Math.max(retryAfterSec, 1)) } }
);
}
if (!excludeConnectionId) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
}
log.warn("CHAT", `${provider.toUpperCase()} | no more accounts`);
return new Response(
JSON.stringify({ error: lastError || "All accounts unavailable" }),
{ status: lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, headers: { "Content-Type": "application/json" } }
);
}
log.debug("CHAT", `account=${credentials.id}`, { provider });
const refreshedCredentials = await checkAndRefreshToken(machineId, provider, credentials, env);
// Use shared chatCore
const result = await handleChatCore({
body,
modelInfo: { provider, model },
credentials: refreshedCredentials,
log,
onCredentialsRefreshed: async (newCreds) => {
await updateCredentials(machineId, credentials.id, newCreds, env);
},
onRequestSuccess: async () => {
// Clear error status only if currently has error (optimization)
await clearAccountError(machineId, credentials.id, credentials, env);
}
});
if (result.success) return result.response;
const { shouldFallback } = checkFallbackError(result.status, result.error);
if (shouldFallback) {
log.warn("FALLBACK", `${provider.toUpperCase()} | ${credentials.id} | ${result.status}`);
await markAccountUnavailable(machineId, credentials.id, result.status, result.error, env, result.resetsAtMs);
excludeConnectionId = credentials.id;
lastError = result.error;
lastStatus = result.status;
continue;
}
return result.response;
}
}
async function checkAndRefreshToken(machineId, provider, credentials, env) {
if (!credentials.expiresAt) return credentials;
const expiresAt = new Date(credentials.expiresAt).getTime();
if (expiresAt - Date.now() >= TOKEN_EXPIRY_BUFFER_MS) return credentials;
log.debug("TOKEN", `${provider.toUpperCase()} | expiring, refreshing`);
const newCredentials = await refreshTokenByProvider(provider, credentials);
if (newCredentials?.accessToken) {
await updateCredentials(machineId, credentials.id, newCredentials, env);
return {
...credentials,
accessToken: newCredentials.accessToken,
refreshToken: newCredentials.refreshToken || credentials.refreshToken,
expiresAt: newCredentials.expiresIn
? new Date(Date.now() + newCredentials.expiresIn * 1000).toISOString()
: credentials.expiresAt
};
}
return credentials;
}
async function validateApiKey(request, machineId, env) {
const authHeader = request.headers.get("Authorization");
if (!authHeader?.startsWith("Bearer ")) return false;
const apiKey = authHeader.slice(7);
const data = await getMachineData(machineId, env);
return data?.apiKeys?.some(k => k.key === apiKey) || false;
}
async function getProviderCredentials(machineId, provider, env, excludeConnectionId = null) {
const data = await getMachineData(machineId, env);
if (!data?.providers) return null;
const providerConnections = Object.entries(data.providers)
.filter(([connId, conn]) => {
if (conn.provider !== provider || !conn.isActive) return false;
if (excludeConnectionId && connId === excludeConnectionId) return false;
if (isAccountUnavailable(conn.rateLimitedUntil)) return false;
return true;
})
.sort((a, b) => (a[1].priority || 999) - (b[1].priority || 999));
if (providerConnections.length === 0) {
// Check if accounts exist but all rate limited
const allConnections = Object.entries(data.providers)
.filter(([, conn]) => conn.provider === provider && conn.isActive)
.map(([, conn]) => conn);
const earliest = getEarliestRateLimitedUntil(allConnections);
if (earliest) {
const rateLimitedConns = allConnections.filter(c => c.rateLimitedUntil && new Date(c.rateLimitedUntil).getTime() > Date.now());
const earliestConn = rateLimitedConns.sort((a, b) => new Date(a.rateLimitedUntil) - new Date(b.rateLimitedUntil))[0];
return {
allRateLimited: true,
retryAfter: earliest,
retryAfterHuman: formatRetryAfter(earliest),
lastError: earliestConn?.lastError || null,
lastErrorCode: earliestConn?.errorCode || null
};
}
return null;
}
const [connectionId, connection] = providerConnections[0];
return {
id: connectionId,
apiKey: connection.apiKey,
accessToken: connection.accessToken,
refreshToken: connection.refreshToken,
expiresAt: connection.expiresAt,
projectId: connection.projectId,
copilotToken: connection.providerSpecificData?.copilotToken,
providerSpecificData: connection.providerSpecificData,
// Include current status for optimization check
status: connection.status,
lastError: connection.lastError,
rateLimitedUntil: connection.rateLimitedUntil
};
}
async function markAccountUnavailable(machineId, connectionId, status, errorText, env, resetsAtMs = null) {
const data = await getMachineData(machineId, env);
if (!data?.providers?.[connectionId]) return;
const conn = data.providers[connectionId];
const backoffLevel = conn.backoffLevel || 0;
// Provider-specific precise cooldown (e.g. codex usage_limit_reached) overrides backoff
let cooldownMs, newBackoffLevel;
if (resetsAtMs && resetsAtMs > Date.now()) {
cooldownMs = Math.min(resetsAtMs - Date.now(), MAX_RATE_LIMIT_COOLDOWN_MS);
newBackoffLevel = 0;
} else {
({ cooldownMs, newBackoffLevel } = checkFallbackError(status, errorText, backoffLevel));
}
const rateLimitedUntil = getUnavailableUntil(cooldownMs);
const reason = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error";
data.providers[connectionId].rateLimitedUntil = rateLimitedUntil;
data.providers[connectionId].status = "unavailable";
data.providers[connectionId].lastError = reason;
data.providers[connectionId].errorCode = status || null;
data.providers[connectionId].lastErrorAt = new Date().toISOString();
data.providers[connectionId].backoffLevel = newBackoffLevel ?? backoffLevel;
data.providers[connectionId].updatedAt = new Date().toISOString();
await saveMachineData(machineId, data, env);
log.warn("ACCOUNT", `${connectionId} | unavailable until ${rateLimitedUntil} (backoff=${newBackoffLevel ?? backoffLevel})`);
}
async function clearAccountError(machineId, connectionId, currentCredentials, env) {
// Only update if currently has error status (optimization)
const hasError = currentCredentials.status === "unavailable" ||
currentCredentials.lastError ||
currentCredentials.rateLimitedUntil;
if (!hasError) return; // Skip if already clean
const data = await getMachineData(machineId, env);
if (!data?.providers?.[connectionId]) return;
data.providers[connectionId].status = "active";
data.providers[connectionId].lastError = null;
data.providers[connectionId].lastErrorAt = null;
data.providers[connectionId].rateLimitedUntil = null;
data.providers[connectionId].backoffLevel = 0;
data.providers[connectionId].updatedAt = new Date().toISOString();
await saveMachineData(machineId, data, env);
log.info("ACCOUNT", `${connectionId} | error cleared`);
}
async function updateCredentials(machineId, connectionId, newCredentials, env) {
const data = await getMachineData(machineId, env);
if (!data?.providers?.[connectionId]) return;
data.providers[connectionId].accessToken = newCredentials.accessToken;
if (newCredentials.refreshToken) data.providers[connectionId].refreshToken = newCredentials.refreshToken;
if (newCredentials.expiresIn) {
data.providers[connectionId].expiresAt = new Date(Date.now() + newCredentials.expiresIn * 1000).toISOString();
data.providers[connectionId].expiresIn = newCredentials.expiresIn;
}
data.providers[connectionId].updatedAt = new Date().toISOString();
await saveMachineData(machineId, data, env);
log.debug("TOKEN", `credentials updated | ${connectionId}`);
}

View File

@@ -1,34 +0,0 @@
import * as log from "../utils/logger.js";
const RETENTION_DAYS = 7;
/**
* Cleanup old machine data from D1
* Runs daily via cron trigger
*/
export async function handleCleanup(env) {
const cutoffDate = new Date(Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000).toISOString();
log.info("CLEANUP", `Deleting records older than ${cutoffDate}`);
try {
const result = await env.DB.prepare("DELETE FROM machines WHERE updatedAt < ?")
.bind(cutoffDate)
.run();
log.info("CLEANUP", `Deleted ${result.meta?.changes || 0} old records`);
return {
success: true,
deleted: result.meta?.changes || 0,
cutoffDate
};
} catch (error) {
log.error("CLEANUP", error.message);
return {
success: false,
error: error.message
};
}
}

View File

@@ -1,46 +0,0 @@
import { errorResponse } from "open-sse/utils/error.js";
const CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "*"
};
/**
* Handle POST /{machineId}/v1/messages/count_tokens
* Mock token count response based on content length
*/
export async function handleCountTokens(request, env) {
let body;
try {
body = await request.json();
} catch {
return errorResponse(400, "Invalid JSON body");
}
// Estimate token count based on content length
const messages = body.messages || [];
let totalChars = 0;
for (const msg of messages) {
if (typeof msg.content === "string") {
totalChars += msg.content.length;
} else if (Array.isArray(msg.content)) {
for (const part of msg.content) {
if (part.type === "text" && part.text) {
totalChars += part.text.length;
}
}
}
}
// Rough estimate: ~4 chars per token
const inputTokens = Math.ceil(totalChars / 4);
return new Response(JSON.stringify({
input_tokens: inputTokens
}), {
headers: { "Content-Type": "application/json", ...CORS_HEADERS }
});
}

View File

@@ -1,285 +0,0 @@
import { getModelInfoCore } from "open-sse/services/model.js";
import { handleEmbeddingsCore } from "open-sse/handlers/embeddingsCore.js";
import { errorResponse } from "open-sse/utils/error.js";
import {
checkFallbackError,
isAccountUnavailable,
getEarliestRateLimitedUntil,
getUnavailableUntil,
formatRetryAfter
} from "open-sse/services/accountFallback.js";
import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
import * as log from "../utils/logger.js";
import { parseApiKey, extractBearerToken } from "../utils/apiKey.js";
import { getMachineData, saveMachineData } from "../services/storage.js";
/**
* Handle POST /v1/embeddings and /{machineId}/v1/embeddings requests.
*
* Follows the same auth + fallback pattern as handleChat:
* 1. Resolve machineId (from URL or API key)
* 2. Validate API key
* 3. Parse model → provider/model
* 4. Get provider credentials with fallback loop
* 5. Delegate to handleEmbeddingsCore (open-sse)
*
* @param {Request} request
* @param {object} env - Cloudflare env bindings
* @param {object} ctx - Execution context
* @param {string|null} machineIdOverride - From URL path (old format), or null (new format)
*/
export async function handleEmbeddings(request, env, ctx, machineIdOverride = null) {
if (request.method === "OPTIONS") {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "*"
}
});
}
// Resolve machineId
let machineId = machineIdOverride;
if (!machineId) {
const apiKey = extractBearerToken(request);
if (!apiKey) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
const parsed = await parseApiKey(apiKey);
if (!parsed) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key format");
if (!parsed.isNewFormat || !parsed.machineId) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
"API key does not contain machineId. Use /{machineId}/v1/... endpoint for old format keys."
);
}
machineId = parsed.machineId;
}
// Validate API key
if (!await validateApiKey(request, machineId, env)) {
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
}
// Parse body
let body;
try {
body = await request.json();
} catch {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
}
const modelStr = body.model;
if (!modelStr) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");
if (!body.input) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: input");
log.info("EMBEDDINGS", `${machineId} | ${modelStr}`);
// Resolve model info
const data = await getMachineData(machineId, env);
const modelInfo = await getModelInfoCore(modelStr, data?.modelAliases || {});
if (!modelInfo.provider) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format");
const { provider, model } = modelInfo;
log.info("EMBEDDINGS_MODEL", `${provider.toUpperCase()} | ${model}`);
// Provider credential + fallback loop (mirrors handleChat)
let excludeConnectionId = null;
let lastError = null;
let lastStatus = null;
while (true) {
const credentials = await getProviderCredentials(machineId, provider, env, excludeConnectionId);
if (!credentials || credentials.allRateLimited) {
if (credentials?.allRateLimited) {
const retryAfterSec = Math.ceil(
(new Date(credentials.retryAfter).getTime() - Date.now()) / 1000
);
const errorMsg = lastError || credentials.lastError || "Unavailable";
const msg = `[${provider}/${model}] ${errorMsg} (${credentials.retryAfterHuman})`;
const status = lastStatus || Number(credentials.lastErrorCode) || HTTP_STATUS.SERVICE_UNAVAILABLE;
log.warn("EMBEDDINGS", `${provider.toUpperCase()} | ${msg}`);
return new Response(
JSON.stringify({ error: { message: msg } }),
{
status,
headers: {
"Content-Type": "application/json",
"Retry-After": String(Math.max(retryAfterSec, 1))
}
}
);
}
if (!excludeConnectionId) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
}
log.warn("EMBEDDINGS", `${provider.toUpperCase()} | no more accounts`);
return new Response(
JSON.stringify({ error: lastError || "All accounts unavailable" }),
{
status: lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE,
headers: { "Content-Type": "application/json" }
}
);
}
log.debug("EMBEDDINGS", `account=${credentials.id}`, { provider });
const result = await handleEmbeddingsCore({
body,
modelInfo: { provider, model },
credentials,
log,
onCredentialsRefreshed: async (newCreds) => {
await updateCredentials(machineId, credentials.id, newCreds, env);
},
onRequestSuccess: async () => {
await clearAccountError(machineId, credentials.id, credentials, env);
}
});
if (result.success) return result.response;
const { shouldFallback } = checkFallbackError(result.status, result.error);
if (shouldFallback) {
log.warn("EMBEDDINGS_FALLBACK", `${provider.toUpperCase()} | ${credentials.id} | ${result.status}`);
await markAccountUnavailable(machineId, credentials.id, result.status, result.error, env);
excludeConnectionId = credentials.id;
lastError = result.error;
lastStatus = result.status;
continue;
}
return result.response;
}
}
// ─── Helpers (same as chat.js) ───────────────────────────────────────────────
async function validateApiKey(request, machineId, env) {
const authHeader = request.headers.get("Authorization");
if (!authHeader?.startsWith("Bearer ")) return false;
const apiKey = authHeader.slice(7);
const data = await getMachineData(machineId, env);
return data?.apiKeys?.some(k => k.key === apiKey) || false;
}
async function getProviderCredentials(machineId, provider, env, excludeConnectionId = null) {
const data = await getMachineData(machineId, env);
if (!data?.providers) return null;
const providerConnections = Object.entries(data.providers)
.filter(([connId, conn]) => {
if (conn.provider !== provider || !conn.isActive) return false;
if (excludeConnectionId && connId === excludeConnectionId) return false;
if (isAccountUnavailable(conn.rateLimitedUntil)) return false;
return true;
})
.sort((a, b) => (a[1].priority || 999) - (b[1].priority || 999));
if (providerConnections.length === 0) {
const allConnections = Object.entries(data.providers)
.filter(([, conn]) => conn.provider === provider && conn.isActive)
.map(([, conn]) => conn);
const earliest = getEarliestRateLimitedUntil(allConnections);
if (earliest) {
const rateLimitedConns = allConnections.filter(
c => c.rateLimitedUntil && new Date(c.rateLimitedUntil).getTime() > Date.now()
);
const earliestConn = rateLimitedConns.sort(
(a, b) => new Date(a.rateLimitedUntil) - new Date(b.rateLimitedUntil)
)[0];
return {
allRateLimited: true,
retryAfter: earliest,
retryAfterHuman: formatRetryAfter(earliest),
lastError: earliestConn?.lastError || null,
lastErrorCode: earliestConn?.errorCode || null
};
}
return null;
}
const [connectionId, connection] = providerConnections[0];
return {
id: connectionId,
apiKey: connection.apiKey,
accessToken: connection.accessToken,
refreshToken: connection.refreshToken,
expiresAt: connection.expiresAt,
projectId: connection.projectId,
providerSpecificData: connection.providerSpecificData,
status: connection.status,
lastError: connection.lastError,
rateLimitedUntil: connection.rateLimitedUntil
};
}
async function markAccountUnavailable(machineId, connectionId, status, errorText, env) {
const data = await getMachineData(machineId, env);
if (!data?.providers?.[connectionId]) return;
const conn = data.providers[connectionId];
const backoffLevel = conn.backoffLevel || 0;
const { cooldownMs, newBackoffLevel } = checkFallbackError(status, errorText, backoffLevel);
const rateLimitedUntil = getUnavailableUntil(cooldownMs);
const reason = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error";
data.providers[connectionId].rateLimitedUntil = rateLimitedUntil;
data.providers[connectionId].status = "unavailable";
data.providers[connectionId].lastError = reason;
data.providers[connectionId].errorCode = status || null;
data.providers[connectionId].lastErrorAt = new Date().toISOString();
data.providers[connectionId].backoffLevel = newBackoffLevel ?? backoffLevel;
data.providers[connectionId].updatedAt = new Date().toISOString();
await saveMachineData(machineId, data, env);
log.warn("EMBEDDINGS_ACCOUNT", `${connectionId} | unavailable until ${rateLimitedUntil}`);
}
async function clearAccountError(machineId, connectionId, currentCredentials, env) {
const hasError =
currentCredentials.status === "unavailable" ||
currentCredentials.lastError ||
currentCredentials.rateLimitedUntil;
if (!hasError) return;
const data = await getMachineData(machineId, env);
if (!data?.providers?.[connectionId]) return;
data.providers[connectionId].status = "active";
data.providers[connectionId].lastError = null;
data.providers[connectionId].lastErrorAt = null;
data.providers[connectionId].rateLimitedUntil = null;
data.providers[connectionId].backoffLevel = 0;
data.providers[connectionId].updatedAt = new Date().toISOString();
await saveMachineData(machineId, data, env);
log.info("EMBEDDINGS_ACCOUNT", `${connectionId} | error cleared`);
}
async function updateCredentials(machineId, connectionId, newCredentials, env) {
const data = await getMachineData(machineId, env);
if (!data?.providers?.[connectionId]) return;
data.providers[connectionId].accessToken = newCredentials.accessToken;
if (newCredentials.refreshToken)
data.providers[connectionId].refreshToken = newCredentials.refreshToken;
if (newCredentials.expiresIn) {
data.providers[connectionId].expiresAt = new Date(
Date.now() + newCredentials.expiresIn * 1000
).toISOString();
data.providers[connectionId].expiresIn = newCredentials.expiresIn;
}
data.providers[connectionId].updatedAt = new Date().toISOString();
await saveMachineData(machineId, data, env);
log.debug("EMBEDDINGS_TOKEN", `credentials updated | ${connectionId}`);
}

View File

@@ -1,75 +0,0 @@
// CF headers to remove
const CF_HEADERS = [
"cf-connecting-ip", "cf-connecting-ip6", "cf-ray", "cf-visitor",
"cf-ipcountry", "cf-tracking-id", "cf-connecting-ip6-policy",
"x-real-ip", "x-forwarded-for", "x-forwarded-proto", "x-forwarded-host"
];
// Forward request to any endpoint
export async function handleForward(request) {
try {
const url = new URL(request.url);
const clientIp = request.headers.get("CF-Connecting-IP") || "";
const { targetUrl, headers = {}, body } = await request.json();
if (!targetUrl) {
return new Response(JSON.stringify({ error: "targetUrl is required" }), {
status: 400,
headers: { "Content-Type": "application/json" }
});
}
// Filter out CF headers from input
const cleanHeaders = {};
for (const [key, value] of Object.entries(headers)) {
if (!CF_HEADERS.includes(key.toLowerCase())) {
cleanHeaders[key] = value;
}
}
// Set standard forwarding headers
cleanHeaders["X-Client-IP"] = clientIp;
cleanHeaders["X-Forwarded-Proto"] = url.protocol.replace(":", "");
cleanHeaders["X-Forwarded-Host"] = url.host;
cleanHeaders["X-From-Worker"] = "1";
console.log("[FORWARD] Target:", targetUrl);
console.log("[FORWARD] Headers:", JSON.stringify(cleanHeaders));
// Create Request object to have more control over headers
const outgoingRequest = new Request(targetUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
...cleanHeaders
},
body: JSON.stringify(body)
});
// Use fetch with cf options to minimize auto-added headers
const response = await fetch(outgoingRequest, {
cf: {
// Disable automatic features that add headers
scrapeShield: false,
minify: false,
mirage: false,
polish: "off"
}
});
// Stream response back to client
return new Response(response.body, {
status: response.status,
headers: {
"Content-Type": response.headers.get("Content-Type") || "application/json",
"Access-Control-Allow-Origin": "*"
}
});
} catch (error) {
console.error("[FORWARD] Error:", error.message);
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { "Content-Type": "application/json" }
});
}
}

View File

@@ -1,173 +0,0 @@
import { connect } from "cloudflare:sockets";
// Forward request via raw TCP socket (bypasses CF auto headers)
export async function handleForwardRaw(request) {
try {
const { targetUrl, headers = {}, body } = await request.json();
if (!targetUrl) {
return new Response(JSON.stringify({ error: "targetUrl is required" }), {
status: 400,
headers: { "Content-Type": "application/json" }
});
}
const url = new URL(targetUrl);
const host = url.hostname;
const port = url.port || (url.protocol === "https:" ? 443 : 80);
const path = url.pathname + url.search;
const isHttps = url.protocol === "https:";
console.log("[FORWARD_RAW] Connecting to:", host, port, isHttps ? "(TLS)" : "");
// Connect to target server
let secureSocket;
if (isHttps) {
// For HTTPS, connect directly with TLS enabled
console.log("[FORWARD_RAW] Creating TLS socket...");
secureSocket = connect({
hostname: host,
port: parseInt(port),
secureTransport: "on"
});
console.log("[FORWARD_RAW] TLS socket created");
} else {
secureSocket = connect({ hostname: host, port: parseInt(port) });
}
console.log("[FORWARD_RAW] Socket object:", secureSocket);
console.log("[FORWARD_RAW] Socket opened:", secureSocket.opened);
// Wait for socket to be ready
try {
console.log("[FORWARD_RAW] Waiting for socket to open...");
await secureSocket.opened;
console.log("[FORWARD_RAW] Socket opened successfully");
} catch (openError) {
console.error("[FORWARD_RAW] Socket open error:", openError.message);
throw openError;
}
console.log("[FORWARD_RAW] Getting writer and reader...");
const writer = secureSocket.writable.getWriter();
const reader = secureSocket.readable.getReader();
console.log("[FORWARD_RAW] Writer and reader obtained");
// Build raw HTTP request
const bodyStr = JSON.stringify(body);
const requestHeaders = {
"Host": host,
"Content-Type": "application/json",
"Content-Length": new TextEncoder().encode(bodyStr).length.toString(),
"Connection": "close",
...headers
};
// Build HTTP request string
let httpRequest = `POST ${path} HTTP/1.1\r\n`;
for (const [key, value] of Object.entries(requestHeaders)) {
httpRequest += `${key}: ${value}\r\n`;
}
httpRequest += `\r\n${bodyStr}`;
console.log("[FORWARD_RAW] Sending request:", httpRequest.substring(0, 300));
console.log("[FORWARD_RAW] Full request length:", httpRequest.length);
// Send request
try {
console.log("[FORWARD_RAW] Writing to socket...");
await writer.write(new TextEncoder().encode(httpRequest));
console.log("[FORWARD_RAW] Write complete, closing writer...");
await writer.close();
console.log("[FORWARD_RAW] Writer closed");
} catch (writeError) {
console.error("[FORWARD_RAW] Write error:", writeError.message);
throw writeError;
}
// Read response with timeout
console.log("[FORWARD_RAW] Starting to read response...");
let responseData = new Uint8Array(0);
let attempts = 0;
const maxAttempts = 100; // 10 seconds max
while (attempts < maxAttempts) {
console.log("[FORWARD_RAW] Reading attempt:", attempts);
const { done, value } = await reader.read();
console.log("[FORWARD_RAW] Read result - done:", done, "value length:", value?.length);
if (done) break;
if (value) {
const newData = new Uint8Array(responseData.length + value.length);
newData.set(responseData);
newData.set(value, responseData.length);
responseData = newData;
// Check if we have complete response (has headers end marker)
const text = new TextDecoder().decode(responseData);
if (text.includes("\r\n\r\n")) {
// Check if we have Content-Length and received all body
const headerEnd = text.indexOf("\r\n\r\n");
const headers = text.substring(0, headerEnd).toLowerCase();
const contentLengthMatch = headers.match(/content-length:\s*(\d+)/);
if (contentLengthMatch) {
const expectedLength = parseInt(contentLengthMatch[1]);
const bodyReceived = text.length - headerEnd - 4;
if (bodyReceived >= expectedLength) {
console.log("[FORWARD_RAW] Complete response received");
break;
}
}
}
}
attempts++;
}
console.log("[FORWARD_RAW] Read loop finished, total bytes:", responseData.length);
const responseText = new TextDecoder().decode(responseData);
console.log("[FORWARD_RAW] Response received:", responseText.substring(0, 500));
// Parse HTTP response
const headerEndIndex = responseText.indexOf("\r\n\r\n");
if (headerEndIndex === -1) {
console.log("[FORWARD_RAW] Full response data:", responseText);
throw new Error("Invalid HTTP response - no header end found");
}
const headerPart = responseText.substring(0, headerEndIndex);
const bodyPart = responseText.substring(headerEndIndex + 4);
// Parse status line
const statusLine = headerPart.split("\r\n")[0];
const statusMatch = statusLine.match(/HTTP\/[\d.]+ (\d+)/);
const status = statusMatch ? parseInt(statusMatch[1]) : 200;
// Parse headers
const responseHeaders = {};
const headerLines = headerPart.split("\r\n").slice(1);
for (const line of headerLines) {
const colonIndex = line.indexOf(":");
if (colonIndex > 0) {
const key = line.substring(0, colonIndex).trim();
const value = line.substring(colonIndex + 1).trim();
responseHeaders[key.toLowerCase()] = value;
}
}
return new Response(bodyPart, {
status,
headers: {
"Content-Type": responseHeaders["content-type"] || "application/json",
"Access-Control-Allow-Origin": "*"
}
});
} catch (error) {
console.error("[FORWARD_RAW] Error:", error.message, error.stack);
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { "Content-Type": "application/json" }
});
}
}

View File

@@ -1,227 +0,0 @@
import * as log from "../utils/logger.js";
import { getMachineData, saveMachineData, deleteMachineData } from "../services/storage.js";
const CORS_HEADERS = {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*"
};
// Removed: WORKER_FIELDS and WORKER_SPECIFIC_FIELDS
// Now syncing entire provider based on updatedAt (simpler logic)
export async function handleSync(request, env, ctx) {
const url = new URL(request.url);
const machineId = url.pathname.split("/")[2]; // /sync/:machineId
// Handle CORS preflight
if (request.method === "OPTIONS") {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "*"
}
});
}
if (!machineId) {
log.warn("SYNC", "Missing machineId in path");
return jsonResponse({ error: "Missing machineId" }, 400);
}
// Route by method
switch (request.method) {
case "GET":
return handleGet(machineId, env);
case "POST":
return handlePost(request, machineId, env);
case "DELETE":
return handleDelete(machineId, env);
default:
return jsonResponse({ error: "Method not allowed" }, 405);
}
}
/**
* GET /sync/:machineId - Return merged data for Web to update
*/
async function handleGet(machineId, env) {
const data = await getMachineData(machineId, env);
if (!data) {
log.warn("SYNC", "No data found", { machineId });
return jsonResponse({ error: "No data found" }, 404);
}
log.info("SYNC", "Data retrieved", { machineId });
return jsonResponse({
success: true,
data
});
}
/**
* POST /sync/:machineId - Merge Web data with Worker data
* providers stored by ID (supports multiple connections per provider)
*/
async function handlePost(request, machineId, env) {
let body;
try {
body = await request.json();
} catch {
log.warn("SYNC", "Invalid JSON body", { machineId });
return jsonResponse({ error: "Invalid JSON body" }, 400);
}
// Validate required fields
if (!body.providers || !Array.isArray(body.providers)) {
log.warn("SYNC", "Missing or invalid providers array", { machineId });
return jsonResponse({ error: "Missing providers array" }, 400);
}
const existingData = await getMachineData(machineId, env) || { providers: {}, modelAliases: {}, apiKeys: [] };
// Merge providers by ID
const mergedProviders = {};
const changes = { updated: [], fromWorker: [] };
for (const webProvider of body.providers) {
const providerId = webProvider.id;
if (!providerId) {
log.warn("SYNC", "Provider missing id", { provider: webProvider.provider });
continue;
}
const workerProvider = existingData.providers[providerId];
if (workerProvider) {
// Merge: token fields from Worker, config fields from Web
mergedProviders[providerId] = mergeProvider(webProvider, workerProvider, changes, providerId);
} else {
// New provider from Web
mergedProviders[providerId] = formatProviderData(webProvider);
changes.updated.push(providerId);
}
}
// Prepare final data - modelAliases, apiKeys, combos always from Web
const finalData = {
providers: mergedProviders,
modelAliases: body.modelAliases || existingData.modelAliases || {},
combos: body.combos || existingData.combos || [],
apiKeys: body.apiKeys || existingData.apiKeys || [],
updatedAt: new Date().toISOString()
};
// Store in D1 + invalidate cache
await saveMachineData(machineId, finalData, env);
log.info("SYNC", "Data synced successfully", {
machineId,
providerCount: Object.keys(mergedProviders).length,
changes
});
return jsonResponse({
success: true,
data: finalData,
changes
});
}
/**
* DELETE /sync/:machineId - Clear cache when Worker is disabled
*/
async function handleDelete(machineId, env) {
await deleteMachineData(machineId, env);
log.info("SYNC", "Data deleted", { machineId });
return jsonResponse({
success: true,
message: "Data deleted successfully"
});
}
/**
* Merge provider data: compare updatedAt to decide which source to use
* Simple logic: newer wins (sync entire provider)
*/
function mergeProvider(webProvider, workerProvider, changes, providerId) {
const webTime = new Date(webProvider.updatedAt || 0).getTime();
const workerTime = new Date(workerProvider.updatedAt || 0).getTime();
let merged;
if (workerTime > webTime) {
// Cloud has newer data - use entire Cloud provider
merged = formatProviderData(workerProvider);
changes.fromWorker.push(providerId);
} else {
// Server has newer data - use entire Server provider
merged = formatProviderData(webProvider);
changes.updated.push(providerId);
}
// Always update timestamp
merged.updatedAt = new Date().toISOString();
return merged;
}
/**
* Format provider data for storage
*/
function formatProviderData(provider) {
return {
id: provider.id,
provider: provider.provider,
authType: provider.authType,
name: provider.name,
displayName: provider.displayName,
email: provider.email,
priority: provider.priority,
globalPriority: provider.globalPriority,
defaultModel: provider.defaultModel,
accessToken: provider.accessToken,
refreshToken: provider.refreshToken,
expiresAt: provider.expiresAt,
expiresIn: provider.expiresIn,
tokenType: provider.tokenType,
scope: provider.scope,
idToken: provider.idToken,
projectId: provider.projectId,
apiKey: provider.apiKey,
providerSpecificData: provider.providerSpecificData || {},
isActive: provider.isActive,
status: provider.status || "active",
lastError: provider.lastError || null,
lastErrorAt: provider.lastErrorAt || null,
errorCode: provider.errorCode || null,
rateLimitedUntil: provider.rateLimitedUntil || null,
createdAt: provider.createdAt,
updatedAt: provider.updatedAt || new Date().toISOString()
};
}
/**
* Update provider status (called when token refresh fails or API errors)
*/
export function updateProviderStatus(providers, providerId, status, error = null, errorCode = null) {
if (providers[providerId]) {
providers[providerId].status = status;
providers[providerId].lastError = error;
providers[providerId].lastErrorAt = error ? new Date().toISOString() : null;
providers[providerId].errorCode = errorCode;
providers[providerId].updatedAt = new Date().toISOString();
}
return providers;
}
/**
* Helper to create JSON response
*/
function jsonResponse(data, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: CORS_HEADERS
});
}

View File

@@ -1,60 +0,0 @@
import { parseApiKey, extractBearerToken } from "../utils/apiKey.js";
import { getMachineData } from "../services/storage.js";
/**
* Verify API key endpoint
* @param {Request} request
* @param {Object} env
* @param {string|null} machineIdOverride - machineId from URL (old format) or null (new format)
*/
export async function handleVerify(request, env, machineIdOverride = null) {
const apiKey = extractBearerToken(request);
if (!apiKey) {
return jsonResponse({ error: "Missing or invalid Authorization header" }, 401);
}
// Determine machineId: from URL (old) or from API key (new)
let machineId = machineIdOverride;
if (!machineId) {
const parsed = await parseApiKey(apiKey);
if (!parsed) {
return jsonResponse({ error: "Invalid API key format" }, 401);
}
if (!parsed.isNewFormat || !parsed.machineId) {
return jsonResponse({ error: "API key does not contain machineId" }, 400);
}
machineId = parsed.machineId;
}
const data = await getMachineData(machineId, env);
if (!data) {
return jsonResponse({ error: "Machine not found" }, 404);
}
const isValid = data.apiKeys?.some(k => k.key === apiKey) || false;
if (!isValid) {
return jsonResponse({ error: "Invalid API key" }, 401);
}
return jsonResponse({
valid: true,
machineId,
providersCount: Object.keys(data.providers || {}).length
});
}
function jsonResponse(data, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*"
}
});
}

View File

@@ -1,233 +0,0 @@
import { initTranslators } from "open-sse/translator/index.js";
import { ollamaModels } from "open-sse/config/ollamaModels.js";
import { transformToOllama } from "open-sse/utils/ollamaTransform.js";
import * as log from "./utils/logger.js";
// Static imports for handlers (avoid dynamic import CPU cost)
import { handleCleanup } from "./handlers/cleanup.js";
import { handleCacheClear } from "./handlers/cache.js";
import { handleSync } from "./handlers/sync.js";
import { handleChat } from "./handlers/chat.js";
import { handleVerify } from "./handlers/verify.js";
import { handleTestClaude } from "./handlers/testClaude.js";
import { handleForward } from "./handlers/forward.js";
import { handleForwardRaw } from "./handlers/forwardRaw.js";
import { handleEmbeddings } from "./handlers/embeddings.js";
import { createLandingPageResponse } from "./services/landingPage.js";
// Initialize translators at module load (static imports)
initTranslators();
// Helper to add CORS headers to response
function addCorsHeaders(response) {
const newHeaders = new Headers(response.headers);
newHeaders.set("Access-Control-Allow-Origin", "*");
newHeaders.set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
newHeaders.set("Access-Control-Allow-Headers", "*");
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: newHeaders
});
}
const worker = {
async scheduled(event, env, ctx) {
const result = await handleCleanup(env);
log.info("SCHEDULED", "Cleanup completed", result);
},
async fetch(request, env, ctx) {
const startTime = Date.now();
const url = new URL(request.url);
let path = url.pathname;
// Normalize /v1/v1/* → /v1/*
if (path.startsWith("/v1/v1/")) {
path = path.replace("/v1/v1/", "/v1/");
} else if (path === "/v1/v1") {
path = "/v1";
}
log.request(request.method, path);
// CORS preflight
if (request.method === "OPTIONS") {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "*"
}
});
}
try {
// Routes
// Landing page
if (path === "/" && request.method === "GET") {
const response = createLandingPageResponse();
log.response(response.status, Date.now() - startTime);
return response;
}
if (path === "/health" && request.method === "GET") {
log.response(200, Date.now() - startTime);
return new Response(JSON.stringify({ status: "ok" }), {
headers: { "Content-Type": "application/json" }
});
}
// Ollama compatible - list models
if (path === "/api/tags" && request.method === "GET") {
log.response(200, Date.now() - startTime);
return new Response(JSON.stringify(ollamaModels), {
headers: { "Content-Type": "application/json" }
});
}
if (path === "/cache/clear" && request.method === "POST") {
const response = await handleCacheClear(request, env);
log.response(response.status, Date.now() - startTime);
return response;
}
// Sync provider data by machineId (GET, POST, DELETE)
if (path.startsWith("/sync/") && ["GET", "POST", "DELETE"].includes(request.method)) {
const response = await handleSync(request, env, ctx);
log.response(response.status, Date.now() - startTime);
return response;
}
// ========== NEW FORMAT: /v1/... (machineId in API key) ==========
// New format: /v1/chat/completions
if (path === "/v1/chat/completions" && request.method === "POST") {
const response = await handleChat(request, env, ctx, null);
log.response(response.status, Date.now() - startTime);
return addCorsHeaders(response);
}
// New format: /v1/messages (Claude format)
if (path === "/v1/messages" && request.method === "POST") {
const response = await handleChat(request, env, ctx, null);
log.response(response.status, Date.now() - startTime);
return addCorsHeaders(response);
}
// New format: /v1/embeddings
if (path === "/v1/embeddings" && request.method === "POST") {
const response = await handleEmbeddings(request, env, ctx, null);
log.response(response.status, Date.now() - startTime);
return addCorsHeaders(response);
}
// New format: /v1/responses (OpenAI Responses API - Codex CLI)
if (path === "/v1/responses" && request.method === "POST") {
const response = await handleChat(request, env, ctx, null);
log.response(response.status, Date.now() - startTime);
return response;
}
// New format: /v1/verify
if (path === "/v1/verify" && request.method === "GET") {
const response = await handleVerify(request, env, null);
log.response(response.status, Date.now() - startTime);
return addCorsHeaders(response);
}
// New format: /v1/api/chat (Ollama format)
if (path === "/v1/api/chat" && request.method === "POST") {
const clonedReq = request.clone();
const body = await clonedReq.json();
const response = await handleChat(request, env, ctx, null);
const ollamaResponse = transformToOllama(response, body.model || "llama3.2");
log.response(200, Date.now() - startTime);
return ollamaResponse;
}
// ========== OLD FORMAT: /{machineId}/v1/... ==========
// Machine ID based chat endpoint
if (path.match(/^\/[^\/]+\/v1\/chat\/completions$/) && request.method === "POST") {
const machineId = path.split("/")[1];
const response = await handleChat(request, env, ctx, machineId);
log.response(response.status, Date.now() - startTime);
return response;
}
// Machine ID based embeddings endpoint
if (path.match(/^\/[^\/]+\/v1\/embeddings$/) && request.method === "POST") {
const machineId = path.split("/")[1];
const response = await handleEmbeddings(request, env, ctx, machineId);
log.response(response.status, Date.now() - startTime);
return addCorsHeaders(response);
}
// Machine ID based messages endpoint (Claude format)
if (path.match(/^\/[^\/]+\/v1\/messages$/) && request.method === "POST") {
const machineId = path.split("/")[1];
const response = await handleChat(request, env, ctx, machineId);
log.response(response.status, Date.now() - startTime);
return response;
}
// Machine ID based api/chat endpoint (Ollama format)
if (path.match(/^\/[^\/]+\/v1\/api\/chat$/) && request.method === "POST") {
const machineId = path.split("/")[1];
const clonedReq = request.clone();
const body = await clonedReq.json();
const response = await handleChat(request, env, ctx, machineId);
const ollamaResponse = transformToOllama(response, body.model || "llama3.2");
log.response(200, Date.now() - startTime);
return ollamaResponse;
}
// Machine ID based verify endpoint
if (path.match(/^\/[^\/]+\/v1\/verify$/) && request.method === "GET") {
const machineId = path.split("/")[1];
const response = await handleVerify(request, env, machineId);
log.response(response.status, Date.now() - startTime);
return response;
}
// Test Claude - forward to Anthropic API
if (path === "/testClaude" && request.method === "POST") {
const response = await handleTestClaude(request);
log.response(response.status, Date.now() - startTime);
return response;
}
// Forward request to any endpoint
if (path === "/forward" && request.method === "POST") {
const response = await handleForward(request);
log.response(response.status, Date.now() - startTime);
return response;
}
// Forward request via raw TCP socket (bypasses CF auto headers)
if (path === "/forward-raw" && request.method === "POST") {
const response = await handleForwardRaw(request);
log.response(response.status, Date.now() - startTime);
return response;
}
log.warn("ROUTER", "Not found", { path });
return new Response(JSON.stringify({ error: "Not Found" }), {
status: 404,
headers: { "Content-Type": "application/json" }
});
} catch (error) {
log.error("ROUTER", error.message, { stack: error.stack });
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { "Content-Type": "application/json" }
});
}
}
};
export default worker;

View File

@@ -1,27 +0,0 @@
/**
* Landing Page Service
* Simple health check page for self-hosted worker
*/
/**
* Create landing page response
* @returns {Response} HTML response
*/
export function createLandingPageResponse() {
const html = `<!DOCTYPE html>
<html><head><title>9Router Worker</title></head>
<body style="font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#0a0a0a;color:#fff">
<div style="text-align:center">
<h1>9Router Worker</h1>
<p style="color:#888">Worker is running. Configure this URL in your 9Router dashboard.</p>
</div>
</body></html>`;
return new Response(html, {
status: 200,
headers: {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "public, max-age=3600"
}
});
}

View File

@@ -1,88 +0,0 @@
import * as log from "../utils/logger.js";
// Request-scoped cache for getMachineData (avoids multiple D1 queries per request)
const requestCache = new Map();
const CACHE_TTL_MS = 5000;
/**
* Get machine data from D1 (with request-scope caching)
* @param {string} machineId
* @param {Object} env
* @returns {Promise<Object|null>}
*/
export async function getMachineData(machineId, env) {
const cached = requestCache.get(machineId);
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
return cached.data;
}
const row = await env.DB.prepare("SELECT data FROM machines WHERE machineId = ?")
.bind(machineId)
.first();
if (!row) {
log.debug("STORAGE", `Not found: ${machineId}`);
return null;
}
const data = JSON.parse(row.data);
requestCache.set(machineId, { data, timestamp: Date.now() });
log.debug("STORAGE", `Retrieved: ${machineId}`);
return data;
}
/**
* Save machine data to D1
* @param {string} machineId
* @param {Object} data
* @param {Object} env
*/
export async function saveMachineData(machineId, data, env) {
const now = new Date().toISOString();
data.updatedAt = now;
// Upsert to D1
await env.DB.prepare(`
INSERT INTO machines (machineId, data, updatedAt)
VALUES (?, ?, ?)
ON CONFLICT(machineId) DO UPDATE SET data = ?, updatedAt = ?
`)
.bind(machineId, JSON.stringify(data), now, JSON.stringify(data), now)
.run();
// Update cache after save
requestCache.set(machineId, { data, timestamp: Date.now() });
log.debug("STORAGE", `Saved: ${machineId}`);
}
/**
* Delete machine data from D1
* @param {string} machineId
* @param {Object} env
*/
export async function deleteMachineData(machineId, env) {
await env.DB.prepare("DELETE FROM machines WHERE machineId = ?")
.bind(machineId)
.run();
// Clear cache after delete
requestCache.delete(machineId);
log.debug("STORAGE", `Deleted: ${machineId}`);
}
/**
* Update specific fields in machine data (for token refresh, rate limit, etc.)
* @param {string} machineId
* @param {string} connectionId
* @param {Object} updates
* @param {Object} env
*/
export async function updateMachineProvider(machineId, connectionId, updates, env) {
const data = await getMachineData(machineId, env);
if (!data?.providers?.[connectionId]) return;
Object.assign(data.providers[connectionId], updates);
data.providers[connectionId].updatedAt = new Date().toISOString();
await saveMachineData(machineId, data, env);
}

View File

@@ -1,11 +0,0 @@
// Re-export from open-sse with worker logger
import * as log from "../utils/logger.js";
import {
TOKEN_EXPIRY_BUFFER_MS as BUFFER_MS,
refreshTokenByProvider as _refreshTokenByProvider
} from "open-sse/services/tokenRefresh.js";
export const TOKEN_EXPIRY_BUFFER_MS = BUFFER_MS;
export const refreshTokenByProvider = (provider, credentials) =>
_refreshTokenByProvider(provider, credentials, log);

View File

@@ -1,8 +0,0 @@
// Stub for cloud worker - no-op async functions
export async function saveRequestUsage() {}
export function trackPendingRequest() {}
export async function appendRequestLog() {}
export async function getUsageDb() { return { data: { history: [] } }; }
export async function getUsageHistory() { return []; }
export async function getUsageStats() { return {}; }
export async function getRecentLogs() { return []; }

View File

@@ -1,72 +0,0 @@
/**
* API Key utilities for Worker
* Supports both formats:
* - New: sk-{machineId}-{keyId}-{crc8}
* - Old: sk-{random8}
*/
const API_KEY_SECRET = "endpoint-proxy-api-key-secret";
/**
* Generate CRC (8-char HMAC) using Web Crypto API
*/
async function generateCrc(machineId, keyId) {
const encoder = new TextEncoder();
const keyData = encoder.encode(API_KEY_SECRET);
const data = encoder.encode(machineId + keyId);
const key = await crypto.subtle.importKey(
"raw",
keyData,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const signature = await crypto.subtle.sign("HMAC", key, data);
const hashArray = Array.from(new Uint8Array(signature));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
return hashHex.slice(0, 8);
}
/**
* Parse API key and extract machineId + keyId
* @param {string} apiKey
* @returns {Promise<{ machineId: string, keyId: string, isNewFormat: boolean } | null>}
*/
export async function parseApiKey(apiKey) {
if (!apiKey || !apiKey.startsWith("sk-")) return null;
const parts = apiKey.split("-");
// New format: sk-{machineId}-{keyId}-{crc8} = 4 parts
if (parts.length === 4) {
const [, machineId, keyId, crc] = parts;
// Verify CRC
const expectedCrc = await generateCrc(machineId, keyId);
if (crc !== expectedCrc) return null;
return { machineId, keyId, isNewFormat: true };
}
// Old format: sk-{random8} = 2 parts
if (parts.length === 2) {
return { machineId: null, keyId: parts[1], isNewFormat: false };
}
return null;
}
/**
* Extract Bearer token from Authorization header
* @param {Request} request
* @returns {string | null}
*/
export function extractBearerToken(request) {
const authHeader = request.headers.get("Authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) return null;
return authHeader.slice(7);
}

View File

@@ -1,84 +0,0 @@
// Logger utility for worker
const LOG_LEVELS = {
DEBUG: 0,
INFO: 1,
WARN: 2,
ERROR: 3
};
const LEVEL = LOG_LEVELS.INFO;
// ANSI color codes
const COLORS = {
reset: "\x1b[0m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
cyan: "\x1b[36m"
};
function formatTime() {
return new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" });
}
function formatInline(data) {
if (!data) return "";
if (typeof data === "string") return data;
try {
return Object.entries(data).map(([k, v]) => `${k}=${v}`).join(" | ");
} catch {
return String(data);
}
}
export function debug(tag, message, data) {
if (LEVEL <= LOG_LEVELS.DEBUG) {
const extra = data ? ` | ${formatInline(data)}` : "";
console.log(`[${formatTime()}] 🔍 [${tag}] ${message}${extra}`);
}
}
export function info(tag, message, data) {
if (LEVEL <= LOG_LEVELS.INFO) {
const extra = data ? ` | ${formatInline(data)}` : "";
console.log(`[${formatTime()}] [${tag}] ${message}${extra}`);
}
}
export function warn(tag, message, data) {
if (LEVEL <= LOG_LEVELS.WARN) {
const extra = data ? ` | ${formatInline(data)}` : "";
console.warn(`${COLORS.yellow}[${formatTime()}] ⚠️ [${tag}] ${message}${extra}${COLORS.reset}`);
}
}
export function error(tag, message, data) {
if (LEVEL <= LOG_LEVELS.ERROR) {
const extra = data ? ` | ${formatInline(data)}` : "";
console.error(`${COLORS.red}[${formatTime()}] ❌ [${tag}] ${message}${extra}${COLORS.reset}`);
}
}
export function request(method, path, extra) {
const data = extra ? ` | ${formatInline(extra)}` : "";
console.log(`[${formatTime()}] 📥 ${method} ${path}${data}`);
}
export function response(status, duration, extra) {
const icon = status < 400 ? "📤" : "💥";
const data = extra ? ` | ${formatInline(extra)}` : "";
console.log(`[${formatTime()}] ${icon} ${status} (${duration}ms)${data}`);
}
export function stream(event, data) {
const extra = data ? ` | ${formatInline(data)}` : "";
console.log(`[${formatTime()}] 🌊 [STREAM] ${event}${extra}`);
}
// Mask sensitive data
export function maskKey(key) {
if (!key || key.length < 8) return "***";
return `${key.slice(0, 4)}...${key.slice(-4)}`;
}

View File

@@ -1,17 +0,0 @@
name = "9router"
main = "src/index.js"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]
[alias]
"@/lib/usageDb.js" = "./src/stubs/usageDb.js"
# Step 3: Paste your KV & D1 IDs here
[[kv_namespaces]]
binding = "KV"
id = "YOUR_KV_NAMESPACE_ID"
[[d1_databases]]
binding = "DB"
database_name = "proxy-db"
database_id = "YOUR_D1_DATABASE_ID"

View File

@@ -1,6 +1,6 @@
{
"name": "9router-app",
"version": "0.4.31",
"version": "0.4.33",
"description": "9Router web dashboard",
"private": true,
"scripts": {

Submodule src/mitm/dev deleted from ea459d42d0

View File

@@ -20,17 +20,11 @@ const HOST_REWRITE = {
"cloudcode-pa.googleapis.com": "daily-cloudcode-pa.googleapis.com",
};
// Load handlers — dev/ overrides handlers/ for private implementations
function loadHandler(name) {
try { return require(`./dev/${name}`); } catch {}
return require(`./handlers/${name}`);
}
const handlers = {
antigravity: loadHandler("antigravity"),
copilot: loadHandler("copilot"),
kiro: loadHandler("kiro"),
cursor: loadHandler("cursor"),
antigravity: require("./handlers/antigravity"),
copilot: require("./handlers/copilot"),
kiro: require("./handlers/kiro"),
cursor: require("./handlers/cursor"),
};
// ── SSL / SNI ─────────────────────────────────────────────────