diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index b5240b4..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "src/mitm/dev"] - path = src/mitm/dev - url = https://github.com/decolua/9router-dev.git diff --git a/CHANGELOG.md b/CHANGELOG.md index 36a7508..c0d28cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 0000000..55fd8c4 --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,2 @@ +app/* +node_modules/* diff --git a/cli/.npmignore b/cli/.npmignore new file mode 100644 index 0000000..231c936 --- /dev/null +++ b/cli/.npmignore @@ -0,0 +1,9 @@ +# Ignore everything except what's in package.json "files" +* +!cli.js +!hooks/ +!app/ +!package.json +!README.md +!LICENSE + diff --git a/cli/LICENSE b/cli/LICENSE new file mode 100644 index 0000000..ddd5dd4 --- /dev/null +++ b/cli/LICENSE @@ -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. + + + + + + + + + + + + + + + + + + + + + diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..8e4ee69 --- /dev/null +++ b/cli/README.md @@ -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) + +decolua%2F9router | Trendshift + +[🌐 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. diff --git a/cli/cli.js b/cli/cli.js new file mode 100755 index 0000000..9a1599a --- /dev/null +++ b/cli/cli.js @@ -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 to run the server (default: ${DEFAULT_PORT}) + -H, --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(); +} diff --git a/cli/hooks/postinstall.js b/cli/hooks/postinstall.js new file mode 100644 index 0000000..3a59332 --- /dev/null +++ b/cli/hooks/postinstall.js @@ -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); diff --git a/cli/hooks/sqliteRuntime.js b/cli/hooks/sqliteRuntime.js new file mode 100644 index 0000000..b8288cb --- /dev/null +++ b/cli/hooks/sqliteRuntime.js @@ -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, +}; diff --git a/cli/hooks/trayRuntime.js b/cli/hooks/trayRuntime.js new file mode 100644 index 0000000..323fc8f --- /dev/null +++ b/cli/hooks/trayRuntime.js @@ -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: \node_modules\9router\node_modules\systray + // __dirname here = \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 }; diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..217bdeb --- /dev/null +++ b/cli/package.json @@ -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" +} diff --git a/cli/scripts/buildMitm.js b/cli/scripts/buildMitm.js new file mode 100644 index 0000000..02fceab --- /dev/null +++ b/cli/scripts/buildMitm.js @@ -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); }); diff --git a/cli/src/cli/api/client.js b/cli/src/cli/api/client.js new file mode 100644 index 0000000..cd9ad40 --- /dev/null +++ b/cli/src/cli/api/client.js @@ -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} 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} { success, data: { connections } } + */ +async function getProviders() { + return makeRequest("GET", "/api/providers"); +} + +/** + * Get provider by ID + * @param {string} id - Provider ID + * @returns {Promise} { success, data: { connection } } + */ +async function getProviderById(id) { + return makeRequest("GET", `/api/providers/${id}`); +} + +/** + * Test provider connection + * @param {string} id - Provider ID + * @returns {Promise} { success, data: { valid, error } } + */ +async function testProvider(id) { + return makeRequest("POST", `/api/providers/${id}/test`); +} + +/** + * Delete provider + * @param {string} id - Provider ID + * @returns {Promise} { success, data: { message } } + */ +async function deleteProvider(id) { + return makeRequest("DELETE", `/api/providers/${id}`); +} + +/** + * Get provider models + * @param {string} id - Provider ID + * @returns {Promise} { 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} { 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} { 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} { 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} { 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} { 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} { success, data: { connection } } + */ +async function updateConnection(id, data) { + return makeRequest("PUT", `/api/providers/${id}`, data); +} + +// ============================================================================ +// API KEYS API +// ============================================================================ + +/** + * Get all API keys + * @returns {Promise} { success, data: { keys } } + */ +async function getApiKeys() { + return makeRequest("GET", "/api/keys"); +} + +/** + * Create new API key + * @param {string} name - Key name + * @returns {Promise} { 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} { success, data: { success } } + */ +async function deleteApiKey(id) { + return makeRequest("DELETE", `/api/keys/${id}`); +} + +// ============================================================================ +// COMBOS API +// ============================================================================ + +/** + * Get all combos + * @returns {Promise} { success, data: { combos } } + */ +async function getCombos() { + return makeRequest("GET", "/api/combos"); +} + +/** + * Get combo by ID + * @param {string} id - Combo ID + * @returns {Promise} { 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} { 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} { success, data: combo } + */ +async function updateCombo(id, data) { + return makeRequest("PUT", `/api/combos/${id}`, data); +} + +/** + * Delete combo + * @param {string} id - Combo ID + * @returns {Promise} { 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} { 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} { 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} { success, data } + */ +async function resetCliToolSettings(tool) { + return makeRequest("DELETE", `/api/cli-tools/${tool}-settings`); +} + +// ============================================================================ +// SETTINGS API +// ============================================================================ + +/** + * Get settings + * @returns {Promise} { success, data: settings } + */ +async function getSettings() { + return makeRequest("GET", "/api/settings"); +} + +/** + * Update settings + * @param {Object} data - Settings data + * @returns {Promise} { success, data: settings } + */ +async function updateSettings(data) { + return makeRequest("PATCH", "/api/settings", data); +} + +// ============================================================================ +// MODELS API +// ============================================================================ + +/** + * Get all models (internal API) + * @returns {Promise} { success, data: { models } } + */ +async function getModels() { + return makeRequest("GET", "/api/models"); +} + +/** + * Get available models from active providers + combos (OpenAI compatible) + * @returns {Promise} { 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} { success, data: { enabled, tunnelUrl, shortId, running } } + */ +async function getTunnelStatus() { + return makeRequest("GET", "/api/tunnel/status"); +} + +/** + * Enable tunnel + * @returns {Promise} { success, data: { tunnelUrl, shortId } } + */ +async function enableTunnel() { + return makeRequest("POST", "/api/tunnel/enable"); +} + +/** + * Disable tunnel + * @returns {Promise} { 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, +}; diff --git a/cli/src/cli/menus/apiKeys.js b/cli/src/cli/menus/apiKeys.js new file mode 100644 index 0000000..ecd4e68 --- /dev/null +++ b/cli/src/cli/menus/apiKeys.js @@ -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} 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} 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} 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} 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 +}; diff --git a/cli/src/cli/menus/cliTools.js b/cli/src/cli/menus/cliTools.js new file mode 100644 index 0000000..3a84a07 --- /dev/null +++ b/cli/src/cli/menus/cliTools.js @@ -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} + */ +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} + */ +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} + */ +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} 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} + */ +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} 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} + */ +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} 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} + */ +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} 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} 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 }; diff --git a/cli/src/cli/menus/combos.js b/cli/src/cli/menus/combos.js new file mode 100644 index 0000000..5a8b202 --- /dev/null +++ b/cli/src/cli/menus/combos.js @@ -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} 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} 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 }; diff --git a/cli/src/cli/menus/providers.js b/cli/src/cli/menus/providers.js new file mode 100644 index 0000000..c913754 --- /dev/null +++ b/cli/src/cli/menus/providers.js @@ -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} 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} 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} 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} 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 }; diff --git a/cli/src/cli/menus/settings.js b/cli/src/cli/menus/settings.js new file mode 100644 index 0000000..e57490b --- /dev/null +++ b/cli/src/cli/menus/settings.js @@ -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} 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 }; diff --git a/cli/src/cli/terminalUI.js b/cli/src/cli/terminalUI.js new file mode 100644 index 0000000..c4426bb --- /dev/null +++ b/cli/src/cli/terminalUI.js @@ -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} 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 }; diff --git a/cli/src/cli/tray/autostart.js b/cli/src/cli/tray/autostart.js new file mode 100644 index 0000000..df34fff --- /dev/null +++ b/cli/src/cli/tray/autostart.js @@ -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 = ` + + + + Label + ${APP_LABEL} + ProgramArguments + + ${userShell} + -l + -c + ${nodePath} ${routerScript} --tray --skip-update + + RunAtLoad + + KeepAlive + + StandardOutPath + /tmp/9router.log + StandardErrorPath + /tmp/9router.error.log + +`; + + 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 +}; diff --git a/cli/src/cli/tray/icon.ico b/cli/src/cli/tray/icon.ico new file mode 100644 index 0000000..9cf0e3d Binary files /dev/null and b/cli/src/cli/tray/icon.ico differ diff --git a/cli/src/cli/tray/icon.png b/cli/src/cli/tray/icon.png new file mode 100644 index 0000000..55bf515 Binary files /dev/null and b/cli/src/cli/tray/icon.png differ diff --git a/cli/src/cli/tray/tray.js b/cli/src/cli/tray/tray.js new file mode 100644 index 0000000..cb55aa7 --- /dev/null +++ b/cli/src/cli/tray/tray.js @@ -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 +}; diff --git a/cli/src/cli/tray/tray.ps1 b/cli/src/cli/tray/tray.ps1 new file mode 100644 index 0000000..30562e3 --- /dev/null +++ b/cli/src/cli/tray/tray.ps1 @@ -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() diff --git a/cli/src/cli/tray/trayWin.js b/cli/src/cli/tray/trayWin.js new file mode 100644 index 0000000..8c11280 --- /dev/null +++ b/cli/src/cli/tray/trayWin.js @@ -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 }; diff --git a/cli/src/cli/utils/clipboard.js b/cli/src/cli/utils/clipboard.js new file mode 100644 index 0000000..c9cc5b5 --- /dev/null +++ b/cli/src/cli/utils/clipboard.js @@ -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 }; diff --git a/cli/src/cli/utils/display.js b/cli/src/cli/utils/display.js new file mode 100644 index 0000000..a68d830 --- /dev/null +++ b/cli/src/cli/utils/display.js @@ -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>} 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 +}; diff --git a/cli/src/cli/utils/endpoint.js b/cli/src/cli/utils/endpoint.js new file mode 100644 index 0000000..20226e6 --- /dev/null +++ b/cli/src/cli/utils/endpoint.js @@ -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} 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 }; diff --git a/cli/src/cli/utils/format.js b/cli/src/cli/utils/format.js new file mode 100644 index 0000000..5bf2ea4 --- /dev/null +++ b/cli/src/cli/utils/format.js @@ -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 +}; diff --git a/cli/src/cli/utils/input.js b/cli/src/cli/utils/input.js new file mode 100644 index 0000000..b4482cb --- /dev/null +++ b/cli/src/cli/utils/input.js @@ -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} 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} 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} 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} + */ +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} breadcrumb - Optional breadcrumb path + * @returns {Promise} 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 +}; diff --git a/cli/src/cli/utils/menuHelper.js b/cli/src/cli/utils/menuHelper.js new file mode 100644 index 0000000..41d2906 --- /dev/null +++ b/cli/src/cli/utils/menuHelper.js @@ -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} config.breadcrumb - Optional breadcrumb path + * @returns {Promise} + */ +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} config.breadcrumb - Optional breadcrumb path + * @returns {Promise} + */ +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 +}; diff --git a/cli/src/cli/utils/modelSelector.js b/cli/src/cli/utils/modelSelector.js new file mode 100644 index 0000000..438220d --- /dev/null +++ b/cli/src/cli/utils/modelSelector.js @@ -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} 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 +}; diff --git a/cloud/.gitignore b/cloud/.gitignore deleted file mode 100644 index 03d963c..0000000 --- a/cloud/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -.wrangler/* -node_modules/* -**node_modules/* diff --git a/cloud/README.md b/cloud/README.md deleted file mode 100644 index 5b096dd..0000000 --- a/cloud/README.md +++ /dev/null @@ -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**. diff --git a/cloud/jsconfig.json b/cloud/jsconfig.json deleted file mode 100644 index 6f1c2a8..0000000 --- a/cloud/jsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "baseUrl": ".", - "paths": { - "open-sse": ["../open-sse"], - "open-sse/*": ["../open-sse/*"] - }, - "module": "ESNext", - "moduleResolution": "bundler", - "target": "ESNext" - } -} diff --git a/cloud/migrations/0001_init.sql b/cloud/migrations/0001_init.sql deleted file mode 100644 index ce13021..0000000 --- a/cloud/migrations/0001_init.sql +++ /dev/null @@ -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); diff --git a/cloud/package.json b/cloud/package.json deleted file mode 100644 index ee1ba8a..0000000 --- a/cloud/package.json +++ /dev/null @@ -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" - } -} diff --git a/cloud/src/handlers/cache.js b/cloud/src/handlers/cache.js deleted file mode 100644 index b5a50b8..0000000 --- a/cloud/src/handlers/cache.js +++ /dev/null @@ -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); - } -} \ No newline at end of file diff --git a/cloud/src/handlers/chat.js b/cloud/src/handlers/chat.js deleted file mode 100644 index 4ed8e41..0000000 --- a/cloud/src/handlers/chat.js +++ /dev/null @@ -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}`); -} diff --git a/cloud/src/handlers/cleanup.js b/cloud/src/handlers/cleanup.js deleted file mode 100644 index 3c63845..0000000 --- a/cloud/src/handlers/cleanup.js +++ /dev/null @@ -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 - }; - } -} - diff --git a/cloud/src/handlers/countTokens.js b/cloud/src/handlers/countTokens.js deleted file mode 100644 index bfb2cf1..0000000 --- a/cloud/src/handlers/countTokens.js +++ /dev/null @@ -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 } - }); -} - diff --git a/cloud/src/handlers/embeddings.js b/cloud/src/handlers/embeddings.js deleted file mode 100644 index 41a9108..0000000 --- a/cloud/src/handlers/embeddings.js +++ /dev/null @@ -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}`); -} diff --git a/cloud/src/handlers/forward.js b/cloud/src/handlers/forward.js deleted file mode 100644 index ab89aec..0000000 --- a/cloud/src/handlers/forward.js +++ /dev/null @@ -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" } - }); - } -} diff --git a/cloud/src/handlers/forwardRaw.js b/cloud/src/handlers/forwardRaw.js deleted file mode 100644 index 87d3419..0000000 --- a/cloud/src/handlers/forwardRaw.js +++ /dev/null @@ -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" } - }); - } -} - diff --git a/cloud/src/handlers/sync.js b/cloud/src/handlers/sync.js deleted file mode 100644 index 93d4b76..0000000 --- a/cloud/src/handlers/sync.js +++ /dev/null @@ -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 - }); -} diff --git a/cloud/src/handlers/verify.js b/cloud/src/handlers/verify.js deleted file mode 100644 index cf5e4a7..0000000 --- a/cloud/src/handlers/verify.js +++ /dev/null @@ -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": "*" - } - }); -} - diff --git a/cloud/src/index.js b/cloud/src/index.js deleted file mode 100644 index 1385b8c..0000000 --- a/cloud/src/index.js +++ /dev/null @@ -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; - diff --git a/cloud/src/services/landingPage.js b/cloud/src/services/landingPage.js deleted file mode 100644 index dd6e91a..0000000 --- a/cloud/src/services/landingPage.js +++ /dev/null @@ -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 = ` -9Router Worker - -
-

9Router Worker

-

Worker is running. Configure this URL in your 9Router dashboard.

-
-`; - - return new Response(html, { - status: 200, - headers: { - "Content-Type": "text/html; charset=utf-8", - "Cache-Control": "public, max-age=3600" - } - }); -} diff --git a/cloud/src/services/storage.js b/cloud/src/services/storage.js deleted file mode 100644 index 11a4fb6..0000000 --- a/cloud/src/services/storage.js +++ /dev/null @@ -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} - */ -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); -} \ No newline at end of file diff --git a/cloud/src/services/tokenRefresh.js b/cloud/src/services/tokenRefresh.js deleted file mode 100644 index a9ad8e6..0000000 --- a/cloud/src/services/tokenRefresh.js +++ /dev/null @@ -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); diff --git a/cloud/src/stubs/usageDb.js b/cloud/src/stubs/usageDb.js deleted file mode 100644 index 0f3a252..0000000 --- a/cloud/src/stubs/usageDb.js +++ /dev/null @@ -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 []; } diff --git a/cloud/src/utils/apiKey.js b/cloud/src/utils/apiKey.js deleted file mode 100644 index 680466a..0000000 --- a/cloud/src/utils/apiKey.js +++ /dev/null @@ -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); -} - diff --git a/cloud/src/utils/logger.js b/cloud/src/utils/logger.js deleted file mode 100644 index 8fabcf0..0000000 --- a/cloud/src/utils/logger.js +++ /dev/null @@ -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)}`; -} diff --git a/cloud/wrangler.toml b/cloud/wrangler.toml deleted file mode 100644 index 18c7a7a..0000000 --- a/cloud/wrangler.toml +++ /dev/null @@ -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" diff --git a/package.json b/package.json index 174d72a..c59530b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.4.31", + "version": "0.4.33", "description": "9Router web dashboard", "private": true, "scripts": { diff --git a/src/mitm/dev b/src/mitm/dev deleted file mode 160000 index ea459d4..0000000 --- a/src/mitm/dev +++ /dev/null @@ -1 +0,0 @@ -Subproject commit ea459d42d0963147c987752078d9de53001779ab diff --git a/src/mitm/server.js b/src/mitm/server.js index 895e44f..fc4aacc 100644 --- a/src/mitm/server.js +++ b/src/mitm/server.js @@ -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 ─────────────────────────────────────────────────