TUI Source
This commit is contained in:
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -1,3 +0,0 @@
|
||||
[submodule "src/mitm/dev"]
|
||||
path = src/mitm/dev
|
||||
url = https://github.com/decolua/9router-dev.git
|
||||
@@ -1,3 +1,9 @@
|
||||
# v0.4.33 (2026-05-12)
|
||||
|
||||
## Improvements
|
||||
- Windows: replace systray (Go binary, AV flagged) with native PowerShell NotifyIcon
|
||||
- Auto-cleanup legacy `tray_windows.exe` on install/startup
|
||||
|
||||
# v0.4.31 (2026-05-12)
|
||||
|
||||
## Features
|
||||
|
||||
2
cli/.gitignore
vendored
Normal file
2
cli/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
app/*
|
||||
node_modules/*
|
||||
9
cli/.npmignore
Normal file
9
cli/.npmignore
Normal file
@@ -0,0 +1,9 @@
|
||||
# Ignore everything except what's in package.json "files"
|
||||
*
|
||||
!cli.js
|
||||
!hooks/
|
||||
!app/
|
||||
!package.json
|
||||
!README.md
|
||||
!LICENSE
|
||||
|
||||
42
cli/LICENSE
Normal file
42
cli/LICENSE
Normal file
@@ -0,0 +1,42 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 9Router Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
112
cli/README.md
Normal file
112
cli/README.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# 9Router - FREE AI Router & Token Saver
|
||||
|
||||
**Never stop coding. Save 20-40% tokens with RTK + auto-fallback to FREE & cheap AI models.**
|
||||
|
||||
**Connect All AI Code Tools (Claude Code, Cursor, Antigravity, Copilot, Codex, Gemini, OpenCode, Cline, OpenClaw...) to 40+ AI Providers & 100+ Models.**
|
||||
|
||||
[](https://www.npmjs.com/package/9router)
|
||||
[](https://www.npmjs.com/package/9router)
|
||||
[](https://github.com/decolua/9router/blob/main/LICENSE)
|
||||
|
||||
<a href="https://trendshift.io/repositories/22628" target="_blank"><img src="https://trendshift.io/api/badge/repositories/22628" alt="decolua%2F9router | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
[🌐 Website](https://9router.com) • [📖 Full Docs](https://github.com/decolua/9router)
|
||||
|
||||
---
|
||||
|
||||
## 🤔 Why 9Router?
|
||||
|
||||
**Stop wasting money, tokens and hitting limits:**
|
||||
|
||||
- ❌ Subscription quota expires unused every month
|
||||
- ❌ Rate limits stop you mid-coding
|
||||
- ❌ Tool outputs (git diff, grep, ls...) burn tokens fast
|
||||
- ❌ Expensive APIs ($20-50/month per provider)
|
||||
|
||||
**9Router solves this:**
|
||||
|
||||
- ✅ **RTK Token Saver** - Auto-compress tool_result, save 20-40% tokens
|
||||
- ✅ **Maximize subscriptions** - Track quota, use every bit before reset
|
||||
- ✅ **Auto fallback** - Subscription → Cheap → Free, zero downtime
|
||||
- ✅ **Multi-account** - Round-robin between accounts per provider
|
||||
- ✅ **Universal** - Works with any OpenAI/Claude-compatible CLI
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Quick Start
|
||||
|
||||
**1. Install & run:**
|
||||
|
||||
```bash
|
||||
npm install -g 9router
|
||||
9router
|
||||
|
||||
# Or run directly with npx
|
||||
npx 9router
|
||||
```
|
||||
|
||||
🎉 Dashboard opens at `http://localhost:20128`
|
||||
|
||||
**2. Connect a FREE provider (no signup needed):**
|
||||
|
||||
Dashboard → Providers → Connect **Kiro AI** (free Claude unlimited) or **OpenCode Free** (no auth) → Done!
|
||||
|
||||
**3. Use in your CLI tool:**
|
||||
|
||||
```
|
||||
Claude Code/Codex/OpenClaw/Cursor/Cline Settings:
|
||||
Endpoint: http://localhost:20128/v1
|
||||
API Key: [copy from dashboard]
|
||||
Model: kr/claude-sonnet-4.5
|
||||
```
|
||||
|
||||
That's it! Start coding with FREE AI models.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 CLI Options
|
||||
|
||||
```bash
|
||||
9router # Start with default settings
|
||||
9router --port 8080 # Custom port
|
||||
9router --no-browser # Don't open browser
|
||||
9router --skip-update # Skip auto-update check
|
||||
9router --help # Show all options
|
||||
```
|
||||
|
||||
**Dashboard**: `http://localhost:20128/dashboard`
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Supported CLI Tools
|
||||
|
||||
Claude-Code • OpenClaw • Codex • OpenCode • Cursor • Antigravity • Cline • Continue • Droid • Roo • Copilot • Kilo Code • Gemini CLI • Qwen Code • iFlow • Crush • Crusher • Aider
|
||||
|
||||
Any tool supporting OpenAI/Claude-compatible API works.
|
||||
|
||||
---
|
||||
|
||||
## 💾 Data Location
|
||||
|
||||
- **macOS/Linux**: `~/.9router/db.json`
|
||||
- **Windows**: `%APPDATA%/9router/db.json`
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
Full docs, advanced setup, video tutorials & development guide:
|
||||
|
||||
- **GitHub**: https://github.com/decolua/9router
|
||||
- **Full README**: https://github.com/decolua/9router/blob/main/app/README.md
|
||||
- **Website**: https://9router.com
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
- **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** - Original Go implementation
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) for details.
|
||||
782
cli/cli.js
Executable file
782
cli/cli.js
Executable file
@@ -0,0 +1,782 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { spawn, exec, execSync } = require("child_process");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const https = require("https");
|
||||
const os = require("os");
|
||||
|
||||
// Native spinner - no external dependency
|
||||
function createSpinner(text) {
|
||||
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
let i = 0;
|
||||
let interval = null;
|
||||
let currentText = text;
|
||||
return {
|
||||
start() {
|
||||
if (process.stdout.isTTY) {
|
||||
process.stdout.write(`\r${frames[0]} ${currentText}`);
|
||||
interval = setInterval(() => {
|
||||
process.stdout.write(`\r${frames[i++ % frames.length]} ${currentText}`);
|
||||
}, 80);
|
||||
}
|
||||
return this;
|
||||
},
|
||||
stop() {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
if (process.stdout.isTTY) {
|
||||
process.stdout.write("\r\x1b[K");
|
||||
}
|
||||
},
|
||||
succeed(msg) {
|
||||
this.stop();
|
||||
console.log(`✅ ${msg}`);
|
||||
},
|
||||
fail(msg) {
|
||||
this.stop();
|
||||
console.log(`❌ ${msg}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const pkg = require("./package.json");
|
||||
const { ensureSqliteRuntime, buildEnvWithRuntime } = require("./hooks/sqliteRuntime");
|
||||
const { ensureTrayRuntime } = require("./hooks/trayRuntime");
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
// Self-heal SQLite runtime deps (sql.js + better-sqlite3) into ~/.9router/runtime
|
||||
// so the server can resolve them via NODE_PATH. Best-effort — sql.js is required,
|
||||
// better-sqlite3 is optional. Logs to stderr only on failure.
|
||||
try { ensureSqliteRuntime({ silent: true }); } catch {}
|
||||
|
||||
// Self-heal tray runtime (systray for macOS/Linux only). Windows skipped.
|
||||
try { ensureTrayRuntime({ silent: true }); } catch {}
|
||||
|
||||
// Configuration constants
|
||||
const APP_NAME = pkg.name; // Use from package.json
|
||||
const INSTALL_CMD_LATEST = `npm i -g ${APP_NAME}@latest --prefer-online`;
|
||||
|
||||
const DEFAULT_PORT = 20128;
|
||||
const DEFAULT_HOST = "0.0.0.0";
|
||||
const MAX_PORT_ATTEMPTS = 10;
|
||||
// Identifiers for killAllAppProcesses - only kill 9router specifically
|
||||
const PROCESS_IDENTIFIERS = [
|
||||
'9router' // Only package name - avoid killing other apps
|
||||
];
|
||||
|
||||
// Parse arguments
|
||||
let port = DEFAULT_PORT;
|
||||
let host = DEFAULT_HOST;
|
||||
let noBrowser = false;
|
||||
let skipUpdate = false;
|
||||
let showLog = false;
|
||||
let trayMode = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "--port" || args[i] === "-p") {
|
||||
port = parseInt(args[i + 1], 10) || DEFAULT_PORT;
|
||||
i++;
|
||||
} else if (args[i] === "--host" || args[i] === "-H") {
|
||||
host = args[i + 1] || DEFAULT_HOST;
|
||||
i++;
|
||||
} else if (args[i] === "--no-browser" || args[i] === "-n") {
|
||||
noBrowser = true;
|
||||
} else if (args[i] === "--log" || args[i] === "-l") {
|
||||
showLog = true;
|
||||
} else if (args[i] === "--skip-update") {
|
||||
skipUpdate = true;
|
||||
} else if (args[i] === "--tray" || args[i] === "-t") {
|
||||
trayMode = true;
|
||||
process.env.TRAY_MODE = "1";
|
||||
} else if (args[i] === "--help" || args[i] === "-h") {
|
||||
console.log(`
|
||||
Usage: ${APP_NAME} [options]
|
||||
|
||||
Options:
|
||||
-p, --port <port> Port to run the server (default: ${DEFAULT_PORT})
|
||||
-H, --host <host> Host to bind (default: ${DEFAULT_HOST})
|
||||
-n, --no-browser Don't open browser automatically
|
||||
-l, --log Show server logs (default: hidden)
|
||||
-t, --tray Run in system tray mode (background)
|
||||
--skip-update Skip auto-update check
|
||||
-h, --help Show this help message
|
||||
-v, --version Show version
|
||||
`);
|
||||
process.exit(0);
|
||||
} else if (args[i] === "--version" || args[i] === "-v") {
|
||||
console.log(pkg.version);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-relaunch after update: detached process has no TTY → fallback to tray
|
||||
if (skipUpdate && !trayMode && !process.stdin.isTTY) {
|
||||
trayMode = true;
|
||||
process.env.TRAY_MODE = "1";
|
||||
}
|
||||
|
||||
// Always use Node.js runtime with absolute path
|
||||
const RUNTIME = process.execPath;
|
||||
|
||||
// Compare semver versions: returns 1 if a > b, -1 if a < b, 0 if equal
|
||||
function compareVersions(a, b) {
|
||||
const partsA = a.split(".").map(Number);
|
||||
const partsB = b.split(".").map(Number);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (partsA[i] > partsB[i]) return 1;
|
||||
if (partsA[i] < partsB[i]) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Get app data dir (matches app/src/lib/dataDir.js convention)
|
||||
function getAppDataDir() {
|
||||
return process.platform === "win32"
|
||||
? path.join(process.env.APPDATA || "", "9router")
|
||||
: path.join(os.homedir(), ".9router");
|
||||
}
|
||||
|
||||
// Kill PID from file (best-effort, removes file after)
|
||||
function killByPidFile(pidFile) {
|
||||
try {
|
||||
if (!fs.existsSync(pidFile)) return;
|
||||
const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10);
|
||||
if (!pid) return;
|
||||
try {
|
||||
if (process.platform === "win32") {
|
||||
execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 3000 });
|
||||
} else {
|
||||
process.kill(pid, "SIGKILL");
|
||||
}
|
||||
} catch { }
|
||||
try { fs.unlinkSync(pidFile); } catch { }
|
||||
} catch { }
|
||||
}
|
||||
|
||||
// Kill tunnel processes (cloudflared/tailscale) by their PID files
|
||||
function killTunnelByPidFile() {
|
||||
const tunnelDir = path.join(getAppDataDir(), "tunnel");
|
||||
killByPidFile(path.join(tunnelDir, "cloudflared.pid"));
|
||||
killByPidFile(path.join(tunnelDir, "tailscale.pid"));
|
||||
}
|
||||
|
||||
// Kill cloudflared whose --url targets this app's port (covers stale PID file case)
|
||||
function killCloudflaredByAppPort(appPort) {
|
||||
if (!appPort) return [];
|
||||
const portMatchers = [`localhost:${appPort}`, `127.0.0.1:${appPort}`];
|
||||
const pids = [];
|
||||
try {
|
||||
if (process.platform === "win32") {
|
||||
const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "Get-WmiObject Win32_Process -Filter 'Name=\\"cloudflared.exe\\"' | Select-Object ProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"`;
|
||||
const output = execSync(psCmd, { encoding: "utf8", windowsHide: true, timeout: 5000 });
|
||||
const lines = output.split("\n").slice(1).filter(l => l.trim());
|
||||
lines.forEach(line => {
|
||||
if (portMatchers.some(m => line.includes(m))) {
|
||||
const match = line.match(/^"(\d+)"/);
|
||||
if (match && match[1]) pids.push(match[1]);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const output = execSync("ps -eo pid,command 2>/dev/null", { encoding: "utf8", timeout: 5000 });
|
||||
output.split("\n").forEach(line => {
|
||||
if (line.includes("cloudflared") && portMatchers.some(m => line.includes(m))) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
const pid = parts[0];
|
||||
if (pid && !isNaN(pid)) pids.push(pid);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch { }
|
||||
return pids;
|
||||
}
|
||||
|
||||
// Kill all 9router processes
|
||||
function killAllAppProcesses(appPort) {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
// Kill MITM first (admin/sudo process, needs special handling)
|
||||
killMitmByPidFile();
|
||||
// Kill cloudflared/tailscale by PID file (precise, only this app's tunnel)
|
||||
killTunnelByPidFile();
|
||||
|
||||
const platform = process.platform;
|
||||
let pids = [];
|
||||
|
||||
// Catch stale PID files: kill cloudflared bound to this app's port
|
||||
pids.push(...killCloudflaredByAppPort(appPort));
|
||||
|
||||
if (platform === "win32") {
|
||||
// Windows: use WMI to get full CommandLine (tasklist /V doesn't include it)
|
||||
try {
|
||||
const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "Get-WmiObject Win32_Process -Filter 'Name=\\"node.exe\\"' | Select-Object ProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"`;
|
||||
const output = execSync(psCmd, {
|
||||
encoding: "utf8",
|
||||
windowsHide: true,
|
||||
timeout: 5000
|
||||
});
|
||||
const lines = output.split("\n").slice(1).filter(l => l.trim());
|
||||
lines.forEach(line => {
|
||||
const isAppProcess = line.toLowerCase().includes("9router") ||
|
||||
line.toLowerCase().includes("next-server");
|
||||
if (isAppProcess) {
|
||||
const match = line.match(/^"(\d+)"/);
|
||||
if (match && match[1] && match[1] !== process.pid.toString()) {
|
||||
pids.push(match[1]);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// No processes found or error - continue
|
||||
}
|
||||
} else {
|
||||
// macOS/Linux: use ps to find all matching processes
|
||||
try {
|
||||
const output = execSync('ps aux 2>/dev/null', {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000
|
||||
});
|
||||
const lines = output.split('\n');
|
||||
|
||||
lines.forEach(line => {
|
||||
const isAppProcess = line.includes("9router") || line.includes("next-server");
|
||||
if (isAppProcess) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
const pid = parts[1];
|
||||
if (pid && !isNaN(pid) && pid !== process.pid.toString()) {
|
||||
pids.push(pid);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// No processes found or error - continue
|
||||
}
|
||||
}
|
||||
|
||||
// Kill all found processes
|
||||
if (pids.length > 0) {
|
||||
pids.forEach(pid => {
|
||||
try {
|
||||
if (platform === "win32") {
|
||||
execSync(`taskkill /F /PID ${pid} 2>nul`, { stdio: 'ignore', shell: true, windowsHide: true, timeout: 3000 });
|
||||
} else {
|
||||
execSync(`kill -9 ${pid} 2>/dev/null`, { stdio: 'ignore', timeout: 3000 });
|
||||
}
|
||||
} catch (err) {
|
||||
// Process already dead or can't kill - continue
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for processes to fully terminate
|
||||
setTimeout(() => resolve(), 1000);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
} catch (err) {
|
||||
// Silent fail - continue anyway
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sleep helper using SharedArrayBuffer wait (sync, no busy-loop)
|
||||
function sleepSync(ms) {
|
||||
try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Wait until process dies or timeout reached
|
||||
function waitForExit(pid, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try { process.kill(pid, 0); } catch { return true; }
|
||||
sleepSync(100);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Kill MITM server by PID file (MITM runs as admin/sudo, needs special handling)
|
||||
// Sends SIGTERM first so MITM can clean up /etc/hosts entries before dying.
|
||||
function killMitmByPidFile() {
|
||||
try {
|
||||
const mitmPidFile = path.join(getAppDataDir(), "mitm", ".mitm.pid");
|
||||
if (!fs.existsSync(mitmPidFile)) return;
|
||||
const pid = parseInt(fs.readFileSync(mitmPidFile, "utf8").trim(), 10);
|
||||
if (!pid) return;
|
||||
|
||||
if (process.platform === "win32") {
|
||||
// Graceful first (lets server cleanup hosts), then force
|
||||
try { execSync(`taskkill /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 2000 }); } catch { }
|
||||
if (!waitForExit(pid, 1500)) {
|
||||
try { execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 3000 }); } catch { }
|
||||
}
|
||||
// Last-resort: PowerShell Stop-Process (sometimes succeeds where taskkill fails on admin processes)
|
||||
if (!waitForExit(pid, 500)) {
|
||||
try { execSync(`powershell -NonInteractive -WindowStyle Hidden -Command "Stop-Process -Id ${pid} -Force"`, { stdio: "ignore", windowsHide: true, timeout: 3000 }); } catch { }
|
||||
}
|
||||
} else {
|
||||
// SIGTERM via cached sudo token first
|
||||
try { execSync(`sudo -n kill -TERM ${pid} 2>/dev/null`, { stdio: "ignore", timeout: 2000 }); }
|
||||
catch { try { process.kill(pid, "SIGTERM"); } catch { } }
|
||||
if (!waitForExit(pid, 1500)) {
|
||||
try { execSync(`sudo -n kill -9 ${pid} 2>/dev/null`, { stdio: "ignore", timeout: 2000 }); }
|
||||
catch { try { process.kill(pid, "SIGKILL"); } catch { } }
|
||||
}
|
||||
}
|
||||
try { fs.unlinkSync(mitmPidFile); } catch { }
|
||||
} catch { }
|
||||
}
|
||||
|
||||
// Kill any process on specific port
|
||||
function killProcessOnPort(port) {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const platform = process.platform;
|
||||
let pid;
|
||||
|
||||
if (platform === "win32") {
|
||||
try {
|
||||
const output = execSync(`netstat -ano | findstr :${port}`, {
|
||||
encoding: 'utf8',
|
||||
shell: true,
|
||||
windowsHide: true,
|
||||
timeout: 5000
|
||||
}).trim();
|
||||
const lines = output.split('\n').filter(l => l.includes('LISTENING'));
|
||||
if (lines.length > 0) {
|
||||
pid = lines[0].trim().split(/\s+/).pop();
|
||||
execSync(`taskkill /F /PID ${pid} 2>nul`, { stdio: 'ignore', shell: true, windowsHide: true, timeout: 3000 });
|
||||
}
|
||||
} catch (e) {
|
||||
// Port is free or error
|
||||
}
|
||||
} else {
|
||||
// macOS/Linux
|
||||
try {
|
||||
const pidOutput = execSync(`lsof -ti:${port}`, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore']
|
||||
}).trim();
|
||||
if (pidOutput) {
|
||||
pid = pidOutput.split('\n')[0];
|
||||
execSync(`kill -9 ${pid} 2>/dev/null`, { stdio: 'ignore', timeout: 3000 });
|
||||
}
|
||||
} catch (e) {
|
||||
// Port is free or error
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for port to be released
|
||||
setTimeout(() => resolve(), 500);
|
||||
} catch (err) {
|
||||
// Silent fail - continue anyway
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Detect if running in restricted environment (Codespaces, Docker)
|
||||
function isRestrictedEnvironment() {
|
||||
// Check for Codespaces
|
||||
if (process.env.CODESPACES === "true" || process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN) {
|
||||
return "GitHub Codespaces";
|
||||
}
|
||||
|
||||
// Check for Docker
|
||||
if (fs.existsSync("/.dockerenv") || (fs.existsSync("/proc/1/cgroup") && fs.readFileSync("/proc/1/cgroup", "utf8").includes("docker"))) {
|
||||
return "Docker";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if new version available, return latest version or null
|
||||
function checkForUpdate() {
|
||||
return new Promise((resolve) => {
|
||||
if (skipUpdate) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const spinner = createSpinner("Checking for updates...").start();
|
||||
let resolved = false;
|
||||
|
||||
const safetyTimeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
spinner.stop();
|
||||
resolve(null);
|
||||
}
|
||||
}, 8000);
|
||||
|
||||
const done = (version) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
clearTimeout(safetyTimeout);
|
||||
spinner.stop();
|
||||
resolve(version);
|
||||
};
|
||||
|
||||
const req = https.get(`https://registry.npmjs.org/${pkg.name}/latest`, { timeout: 3000 }, (res) => {
|
||||
let data = "";
|
||||
res.on("data", chunk => data += chunk);
|
||||
res.on("end", () => {
|
||||
try {
|
||||
const latest = JSON.parse(data);
|
||||
if (latest.version && compareVersions(latest.version, pkg.version) > 0) {
|
||||
done(latest.version);
|
||||
} else {
|
||||
done(null);
|
||||
}
|
||||
} catch (e) {
|
||||
done(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on("error", () => done(null));
|
||||
req.on("timeout", () => { req.destroy(); done(null); });
|
||||
});
|
||||
}
|
||||
|
||||
// Open browser
|
||||
function openBrowser(url) {
|
||||
const platform = process.platform;
|
||||
let cmd;
|
||||
|
||||
if (platform === "darwin") {
|
||||
cmd = `open "${url}"`;
|
||||
} else if (platform === "win32") {
|
||||
cmd = `start "" "${url}"`;
|
||||
} else {
|
||||
cmd = `xdg-open "${url}"`;
|
||||
}
|
||||
|
||||
exec(cmd, { windowsHide: true }, (err) => {
|
||||
if (err) {
|
||||
console.log(`Open browser manually: ${url}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Find standalone server (bundled in bin/app for published package)
|
||||
const standaloneDir = path.join(__dirname, "app");
|
||||
const serverPath = path.join(standaloneDir, "server.js");
|
||||
|
||||
if (!fs.existsSync(serverPath)) {
|
||||
console.error("Error: Standalone build not found.");
|
||||
console.error("Please run 'npm run build:cli' first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check for updates FIRST, then start server
|
||||
checkForUpdate().then((latestVersion) => {
|
||||
killAllAppProcesses(port).then(() => {
|
||||
return killProcessOnPort(port);
|
||||
}).then(() => {
|
||||
startServer(latestVersion);
|
||||
});
|
||||
});
|
||||
|
||||
// Show interface selection menu
|
||||
async function showInterfaceMenu(latestVersion) {
|
||||
const { selectMenu } = require("./src/cli/utils/input");
|
||||
const { clearScreen } = require("./src/cli/utils/display");
|
||||
const { getEndpoint } = require("./src/cli/utils/endpoint");
|
||||
|
||||
clearScreen();
|
||||
|
||||
const displayHost = host === DEFAULT_HOST ? "localhost" : host;
|
||||
|
||||
// Detect tunnel/local mode for server URL display
|
||||
let serverUrl;
|
||||
try {
|
||||
const { endpoint, tunnelEnabled } = await getEndpoint(port);
|
||||
serverUrl = tunnelEnabled ? endpoint.replace(/\/v1$/, "") : `http://${displayHost}:${port}`;
|
||||
} catch (e) {
|
||||
serverUrl = `http://${displayHost}:${port}`;
|
||||
}
|
||||
|
||||
const subtitle = `🚀 Server: \x1b[32m${serverUrl}\x1b[0m`;
|
||||
|
||||
const menuItems = [];
|
||||
|
||||
if (latestVersion) {
|
||||
menuItems.push({ label: `Update to v${latestVersion} (current: v${pkg.version})`, icon: "⬆" });
|
||||
}
|
||||
|
||||
menuItems.push(
|
||||
{ label: "Web UI (Open in Browser)", icon: "🌐" },
|
||||
{ label: "Terminal UI (Interactive CLI)", icon: "💻" },
|
||||
{ label: "Hide to Tray (Background)", icon: "🔔" },
|
||||
{ label: "Exit", icon: "🚪" }
|
||||
);
|
||||
|
||||
const selected = await selectMenu(`Choose Interface (v${pkg.version})`, menuItems, 0, subtitle);
|
||||
|
||||
const offset = latestVersion ? 1 : 0;
|
||||
|
||||
if (latestVersion && selected === 0) return "update";
|
||||
if (selected === offset) return "web";
|
||||
if (selected === offset + 1) return "terminal";
|
||||
if (selected === offset + 2) return "hide";
|
||||
return "exit";
|
||||
}
|
||||
|
||||
const MAX_RESTARTS = 2;
|
||||
const RESTART_RESET_MS = 30000; // Reset counter if alive > 30s
|
||||
|
||||
function startServer(latestVersion) {
|
||||
const displayHost = host === DEFAULT_HOST ? "localhost" : host;
|
||||
const url = `http://${displayHost}:${port}/dashboard`;
|
||||
|
||||
let restartCount = 0;
|
||||
let serverStartTime = Date.now();
|
||||
|
||||
const CRASH_LOG_LINES = 50;
|
||||
let crashLog = [];
|
||||
|
||||
function spawnServer() {
|
||||
serverStartTime = Date.now();
|
||||
crashLog = [];
|
||||
const child = spawn(RUNTIME, ["--max-old-space-size=6144", serverPath], {
|
||||
cwd: standaloneDir,
|
||||
stdio: showLog ? "inherit" : ["ignore", "ignore", "pipe"],
|
||||
detached: true,
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...buildEnvWithRuntime(process.env),
|
||||
PORT: port.toString(),
|
||||
HOSTNAME: host
|
||||
}
|
||||
});
|
||||
if (!showLog && child.stderr) {
|
||||
child.stderr.on("data", (data) => {
|
||||
const lines = data.toString().split("\n").filter(Boolean);
|
||||
crashLog.push(...lines);
|
||||
if (crashLog.length > CRASH_LOG_LINES) crashLog = crashLog.slice(-CRASH_LOG_LINES);
|
||||
});
|
||||
}
|
||||
return child;
|
||||
}
|
||||
|
||||
let server = spawnServer();
|
||||
|
||||
// Cleanup function - force kill server process
|
||||
let isCleaningUp = false;
|
||||
function cleanup() {
|
||||
if (isCleaningUp) return;
|
||||
isCleaningUp = true;
|
||||
try {
|
||||
// Kill tray if running
|
||||
try {
|
||||
const { killTray } = require("./src/cli/tray/tray");
|
||||
killTray();
|
||||
} catch (e) { }
|
||||
// Kill MITM server (admin/sudo process) via PID file
|
||||
killMitmByPidFile();
|
||||
// Kill cloudflared/tailscale via PID file (only this app's tunnel)
|
||||
killTunnelByPidFile();
|
||||
// Kill server process directly
|
||||
if (server.pid) {
|
||||
process.kill(server.pid, "SIGKILL");
|
||||
}
|
||||
// Also try to kill process group
|
||||
process.kill(-server.pid, "SIGKILL");
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
// Suppress all errors during shutdown (systray lib throws JSON parse errors)
|
||||
let isShuttingDown = false;
|
||||
process.on("uncaughtException", (err) => {
|
||||
if (isShuttingDown) return;
|
||||
console.error("Error:", err.message);
|
||||
});
|
||||
|
||||
// Handle all exit scenarios
|
||||
process.on("SIGINT", () => {
|
||||
if (isShuttingDown) return;
|
||||
isShuttingDown = true;
|
||||
console.log("\nExiting...");
|
||||
cleanup();
|
||||
setTimeout(() => process.exit(0), 100);
|
||||
});
|
||||
process.on("SIGTERM", () => {
|
||||
if (isShuttingDown) return;
|
||||
isShuttingDown = true;
|
||||
cleanup();
|
||||
setTimeout(() => process.exit(0), 100);
|
||||
});
|
||||
process.on("SIGHUP", () => {
|
||||
if (isShuttingDown) return;
|
||||
isShuttingDown = true;
|
||||
cleanup();
|
||||
setTimeout(() => process.exit(0), 100);
|
||||
});
|
||||
|
||||
// Initialize tray icon (runs alongside TUI)
|
||||
const initTrayIcon = () => {
|
||||
try {
|
||||
const { initTray } = require("./src/cli/tray/tray");
|
||||
initTray({
|
||||
port,
|
||||
onQuit: () => {
|
||||
isShuttingDown = true;
|
||||
console.log("\n👋 Shutting down from tray...");
|
||||
cleanup();
|
||||
setTimeout(() => process.exit(0), 100);
|
||||
},
|
||||
onOpenDashboard: () => openBrowser(url)
|
||||
});
|
||||
} catch (err) {
|
||||
// Tray not available - continue without it
|
||||
}
|
||||
};
|
||||
|
||||
// Tray-only mode: no TUI, just tray icon
|
||||
if (trayMode) {
|
||||
console.log(`\n🚀 ${pkg.name} v${pkg.version}`);
|
||||
console.log(`Server: http://${displayHost}:${port}`);
|
||||
|
||||
setTimeout(() => {
|
||||
initTrayIcon();
|
||||
console.log("\n💡 Router is now running in system tray. Close this terminal if you want.");
|
||||
console.log(" Right-click tray icon to open dashboard or quit.\n");
|
||||
}, 2000);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for server to be ready, then show interface menu loop + tray
|
||||
setTimeout(async () => {
|
||||
// Start tray icon alongside TUI
|
||||
initTrayIcon();
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const choice = await showInterfaceMenu(latestVersion);
|
||||
|
||||
if (choice === "update") {
|
||||
isShuttingDown = true;
|
||||
const { clearScreen } = require("./src/cli/utils/display");
|
||||
clearScreen();
|
||||
console.log(`\n⬆ Update v${pkg.version} → v${latestVersion}\n`);
|
||||
console.log(`Run this after exit:\n`);
|
||||
console.log(` \x1b[33m${INSTALL_CMD_LATEST}\x1b[0m\n`);
|
||||
cleanup();
|
||||
await killAllAppProcesses(port);
|
||||
await killProcessOnPort(port);
|
||||
setTimeout(() => process.exit(0), 200);
|
||||
return;
|
||||
} else if (choice === "web") {
|
||||
openBrowser(url);
|
||||
// Wait for user to come back
|
||||
const { pause } = require("./src/cli/utils/input");
|
||||
await pause("\nPress Enter to go back to menu...");
|
||||
} else if (choice === "terminal") {
|
||||
// Start Terminal UI - it will return when user selects Back
|
||||
const { startTerminalUI } = require("./src/cli/terminalUI");
|
||||
await startTerminalUI(port);
|
||||
// Loop continues, show menu again
|
||||
} else if (choice === "hide") {
|
||||
// Hide to tray - spawn detached background process
|
||||
const { clearScreen } = require("./src/cli/utils/display");
|
||||
clearScreen();
|
||||
|
||||
// Enable auto startup on OS boot
|
||||
try {
|
||||
const { enableAutoStart } = require("./src/cli/tray/autostart");
|
||||
const enabled = enableAutoStart(__filename);
|
||||
if (enabled) {
|
||||
console.log("✅ Auto-start enabled (will run on OS boot)");
|
||||
}
|
||||
} catch (e) { }
|
||||
|
||||
// Spawn new detached process with --tray flag
|
||||
const bgProcess = spawn(process.execPath, [__filename, "--tray", "--skip-update", "-p", port.toString()], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
env: { ...process.env }
|
||||
});
|
||||
bgProcess.unref();
|
||||
|
||||
console.log(`\n🔔 9Router is now running in background (PID: ${bgProcess.pid})`);
|
||||
console.log(` Server: http://${displayHost}:${port}`);
|
||||
console.log(`\n💡 You can close this terminal. Right-click tray icon to:`);
|
||||
console.log(` • Open Dashboard`);
|
||||
console.log(` • Quit\n`);
|
||||
|
||||
// Exit current process - background process takes over
|
||||
cleanup();
|
||||
process.exit(0);
|
||||
} else if (choice === "exit") {
|
||||
isShuttingDown = true;
|
||||
console.log("\nExiting...");
|
||||
cleanup();
|
||||
setTimeout(() => process.exit(0), 100);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error:", err.message);
|
||||
cleanup();
|
||||
process.exit(1);
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
function attachServerEvents() {
|
||||
server.on("error", (err) => {
|
||||
console.error("Failed to start server:", err.message);
|
||||
if (!isShuttingDown) tryRestart();
|
||||
else { cleanup(); process.exit(1); }
|
||||
});
|
||||
|
||||
server.on("close", (code) => {
|
||||
if (isShuttingDown || code === 0) {
|
||||
process.exit(code || 0);
|
||||
return;
|
||||
}
|
||||
tryRestart(code);
|
||||
});
|
||||
}
|
||||
|
||||
function tryRestart(code) {
|
||||
const aliveMs = Date.now() - serverStartTime;
|
||||
// Reset counter if last run was stable
|
||||
if (aliveMs >= RESTART_RESET_MS) restartCount = 0;
|
||||
|
||||
if (restartCount >= MAX_RESTARTS) {
|
||||
console.error(`\n⚠️ Server crashed ${MAX_RESTARTS} times. Disabling MITM and restarting...`);
|
||||
try {
|
||||
const dbPath = path.join(os.homedir(), process.platform === "win32" ? path.join("AppData", "Roaming", "9router", "db.json") : path.join(".9router", "db.json"));
|
||||
if (fs.existsSync(dbPath)) {
|
||||
const db = JSON.parse(fs.readFileSync(dbPath, "utf-8"));
|
||||
if (db.settings) db.settings.mitmEnabled = false;
|
||||
fs.writeFileSync(dbPath, JSON.stringify(db, null, 2));
|
||||
}
|
||||
} catch { /* best effort */ }
|
||||
restartCount = 0;
|
||||
server = spawnServer();
|
||||
attachServerEvents();
|
||||
return;
|
||||
}
|
||||
|
||||
restartCount++;
|
||||
const delay = Math.min(1000 * restartCount, 10000);
|
||||
console.error(`\n⚠️ Server exited (code=${code ?? "unknown"}). Restarting in ${delay / 1000}s... (${restartCount}/${MAX_RESTARTS})`);
|
||||
if (crashLog.length) {
|
||||
console.error("\n--- Server crash log ---");
|
||||
crashLog.forEach(l => console.error(l));
|
||||
console.error("--- End crash log ---\n");
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
server = spawnServer();
|
||||
attachServerEvents();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
attachServerEvents();
|
||||
}
|
||||
22
cli/hooks/postinstall.js
Normal file
22
cli/hooks/postinstall.js
Normal file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Postinstall: warm-up SQLite deps into ~/.9router/runtime so the first
|
||||
// `9router` start doesn't need network. Failure here is non-fatal —
|
||||
// cli.js will retry at runtime if anything is missing.
|
||||
const { ensureSqliteRuntime } = require("./sqliteRuntime");
|
||||
const { ensureTrayRuntime } = require("./trayRuntime");
|
||||
|
||||
try {
|
||||
ensureSqliteRuntime({ silent: false });
|
||||
console.log("[9router] runtime SQLite deps ready");
|
||||
} catch (e) {
|
||||
console.warn(`[9router] runtime warm-up skipped: ${e.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
ensureTrayRuntime({ silent: false });
|
||||
} catch (e) {
|
||||
console.warn(`[9router] tray runtime skipped: ${e.message}`);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
114
cli/hooks/sqliteRuntime.js
Normal file
114
cli/hooks/sqliteRuntime.js
Normal file
@@ -0,0 +1,114 @@
|
||||
// Ensure better-sqlite3 is installed in USER_DATA_DIR/runtime/node_modules
|
||||
// (user-writable, avoids Windows EBUSY locks during npm i -g updates).
|
||||
// sql.js is bundled in bin/app already; node:sqlite / bun:sqlite are built-in.
|
||||
const { execSync, spawnSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const BETTER_SQLITE3_VERSION = "12.6.2";
|
||||
|
||||
function getDataDir() {
|
||||
if (process.env.DATA_DIR) return process.env.DATA_DIR;
|
||||
return process.platform === "win32"
|
||||
? path.join(process.env.APPDATA || os.homedir(), "9router")
|
||||
: path.join(os.homedir(), ".9router");
|
||||
}
|
||||
|
||||
function getRuntimeDir() {
|
||||
return path.join(getDataDir(), "runtime");
|
||||
}
|
||||
|
||||
function getRuntimeNodeModules() {
|
||||
return path.join(getRuntimeDir(), "node_modules");
|
||||
}
|
||||
|
||||
function ensureRuntimeDir() {
|
||||
const dir = getRuntimeDir();
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
// Minimal package.json so npm treats it as a project root
|
||||
const pkgPath = path.join(dir, "package.json");
|
||||
if (!fs.existsSync(pkgPath)) {
|
||||
fs.writeFileSync(pkgPath, JSON.stringify({
|
||||
name: "9router-runtime",
|
||||
version: "1.0.0",
|
||||
private: true,
|
||||
description: "User-writable runtime deps for 9router (better-sqlite3 native binary)",
|
||||
}, null, 2));
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
function hasModule(name) {
|
||||
return fs.existsSync(path.join(getRuntimeNodeModules(), name, "package.json"));
|
||||
}
|
||||
|
||||
function isBetterSqliteBinaryValid() {
|
||||
const binary = path.join(getRuntimeNodeModules(), "better-sqlite3", "build", "Release", "better_sqlite3.node");
|
||||
if (!fs.existsSync(binary)) return false;
|
||||
try {
|
||||
const fd = fs.openSync(binary, "r");
|
||||
const buf = Buffer.alloc(4);
|
||||
fs.readSync(fd, buf, 0, 4, 0);
|
||||
fs.closeSync(fd);
|
||||
const magic = buf.toString("hex");
|
||||
if (process.platform === "linux") return magic.startsWith("7f454c46");
|
||||
if (process.platform === "darwin") return magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe");
|
||||
if (process.platform === "win32") return magic.startsWith("4d5a");
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
function npmInstall(pkgs, opts = {}) {
|
||||
const cwd = ensureRuntimeDir();
|
||||
const args = ["install", ...pkgs, "--no-audit", "--no-fund", "--prefer-online"];
|
||||
if (opts.optional) args.push("--no-save");
|
||||
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
console.log(`[9router][runtime] ${npmCmd} ${args.join(" ")} (cwd: ${cwd})`);
|
||||
const res = spawnSync(npmCmd, args, {
|
||||
cwd,
|
||||
stdio: opts.silent ? "ignore" : "inherit",
|
||||
timeout: opts.timeout || 180000,
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
return res.status === 0;
|
||||
}
|
||||
|
||||
// Public: ensure better-sqlite3 native module is installed in user-writable
|
||||
// runtime dir. sql.js is bundled in bin/app already; node:sqlite is built-in.
|
||||
// This is purely a *speed optimization* — app works without it via fallbacks.
|
||||
function ensureSqliteRuntime({ silent = false } = {}) {
|
||||
ensureRuntimeDir();
|
||||
|
||||
const needBetterSqlite = !hasModule("better-sqlite3") || !isBetterSqliteBinaryValid();
|
||||
if (!needBetterSqlite) {
|
||||
if (!silent) console.log("[9router][runtime] better-sqlite3 OK");
|
||||
return { betterSqlite: true };
|
||||
}
|
||||
|
||||
const ok = npmInstall([`better-sqlite3@${BETTER_SQLITE3_VERSION}`], { optional: true, silent });
|
||||
if (!ok && !silent) {
|
||||
console.warn("[9router][runtime] better-sqlite3 install failed (will use node:sqlite or sql.js fallback)");
|
||||
}
|
||||
return {
|
||||
betterSqlite: ok && hasModule("better-sqlite3") && isBetterSqliteBinaryValid(),
|
||||
};
|
||||
}
|
||||
|
||||
// Inject runtime + bundled node_modules into NODE_PATH so child Node processes
|
||||
// resolve sql.js (bundled in bin/app/node_modules) and better-sqlite3 (runtime).
|
||||
function buildEnvWithRuntime(baseEnv = process.env) {
|
||||
const runtimeNm = getRuntimeNodeModules();
|
||||
const bundledNm = path.join(__dirname, "..", "app", "node_modules");
|
||||
const existing = baseEnv.NODE_PATH || "";
|
||||
const NODE_PATH = [runtimeNm, bundledNm, existing].filter(Boolean).join(path.delimiter);
|
||||
return { ...baseEnv, NODE_PATH };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ensureSqliteRuntime,
|
||||
buildEnvWithRuntime,
|
||||
getRuntimeDir,
|
||||
getRuntimeNodeModules,
|
||||
};
|
||||
84
cli/hooks/trayRuntime.js
Normal file
84
cli/hooks/trayRuntime.js
Normal file
@@ -0,0 +1,84 @@
|
||||
// Lazy install systray for macOS/Linux into USER_DATA_DIR/runtime/node_modules.
|
||||
// Windows uses PowerShell NotifyIcon (no binary) → no systray needed.
|
||||
// This keeps the published npm tarball free of unsigned Go binaries that
|
||||
// trigger antivirus false positives (e.g. Kaspersky flagging tray_windows.exe).
|
||||
const { spawnSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { getRuntimeDir, getRuntimeNodeModules } = require("./sqliteRuntime");
|
||||
|
||||
const SYSTRAY_VERSION = "1.0.5";
|
||||
|
||||
function hasSystray() {
|
||||
return fs.existsSync(path.join(getRuntimeNodeModules(), "systray", "package.json"));
|
||||
}
|
||||
|
||||
// Remove legacy systray from all known locations on Windows (AV false positive cleanup)
|
||||
function cleanupWindowsSystray({ silent = false } = {}) {
|
||||
if (process.platform !== "win32") return;
|
||||
// 1) Runtime dir: %APPDATA%\9router\runtime\node_modules\systray
|
||||
// 2) npm global nested: <npm_prefix>\node_modules\9router\node_modules\systray
|
||||
// __dirname here = <npm_prefix>\node_modules\9router\hooks → up 1 = pkg root
|
||||
const targets = [
|
||||
path.join(getRuntimeNodeModules(), "systray"),
|
||||
path.join(__dirname, "..", "node_modules", "systray")
|
||||
];
|
||||
for (const dir of targets) {
|
||||
if (fs.existsSync(dir)) {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
if (!silent) console.log(`[9router][runtime] removed legacy systray: ${dir}`);
|
||||
} catch (e) {
|
||||
if (!silent) console.warn(`[9router][runtime] failed to remove ${dir}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureRuntimeDir() {
|
||||
const dir = getRuntimeDir();
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const pkgPath = path.join(dir, "package.json");
|
||||
if (!fs.existsSync(pkgPath)) {
|
||||
fs.writeFileSync(pkgPath, JSON.stringify({
|
||||
name: "9router-runtime",
|
||||
version: "1.0.0",
|
||||
private: true
|
||||
}, null, 2));
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
function npmInstall(pkgs, { silent = false } = {}) {
|
||||
const cwd = ensureRuntimeDir();
|
||||
const args = ["install", ...pkgs, "--no-audit", "--no-fund", "--no-save", "--prefer-online"];
|
||||
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
if (!silent) console.log(`[9router][runtime] ${npmCmd} ${args.join(" ")} (cwd: ${cwd})`);
|
||||
const res = spawnSync(npmCmd, args, {
|
||||
cwd,
|
||||
stdio: silent ? "ignore" : "inherit",
|
||||
timeout: 120000,
|
||||
shell: process.platform === "win32"
|
||||
});
|
||||
return res.status === 0;
|
||||
}
|
||||
|
||||
// Public: ensure systray is installed on macOS/Linux only.
|
||||
// Windows skips entirely (uses PowerShell tray).
|
||||
function ensureTrayRuntime({ silent = false } = {}) {
|
||||
if (process.platform === "win32") {
|
||||
cleanupWindowsSystray({ silent });
|
||||
return { systray: false, skipped: true };
|
||||
}
|
||||
if (hasSystray()) {
|
||||
if (!silent) console.log("[9router][runtime] systray OK");
|
||||
return { systray: true };
|
||||
}
|
||||
const ok = npmInstall([`systray@${SYSTRAY_VERSION}`], { silent });
|
||||
if (!ok && !silent) {
|
||||
console.warn("[9router][runtime] systray install failed (tray will be disabled)");
|
||||
}
|
||||
return { systray: ok && hasSystray() };
|
||||
}
|
||||
|
||||
module.exports = { ensureTrayRuntime };
|
||||
39
cli/package.json
Normal file
39
cli/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "9router",
|
||||
"version": "0.4.33",
|
||||
"description": "9Router CLI - Start and manage 9Router server",
|
||||
"bin": {
|
||||
"9router": "./cli.js"
|
||||
},
|
||||
"files": [
|
||||
"cli.js",
|
||||
"src",
|
||||
"hooks",
|
||||
"app",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"postinstall": "node hooks/postinstall.js",
|
||||
"prepublishOnly": "cd .. && npm run build:cli"
|
||||
},
|
||||
"dependencies": {
|
||||
"node-forge": "^1.3.3",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1"
|
||||
},
|
||||
"comment_sqlite": "sql.js + better-sqlite3 are NOT bundled here. They are installed into ~/.9router/runtime/node_modules by hooks/postinstall.js (and re-checked at runtime by cli.js). This avoids Windows EBUSY errors when updating the global CLI, since native .node files no longer live under the locked install dir.",
|
||||
"comment_systray": "systray is NOT bundled here. It is lazy-installed into ~/.9router/runtime/node_modules by hooks/postinstall.js on macOS/Linux only. Windows uses PowerShell NotifyIcon (zero binary). This avoids shipping the unsigned Go binary tray_windows.exe that triggers antivirus false positives (Kaspersky).",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
"9router",
|
||||
"cli",
|
||||
"proxy",
|
||||
"ai",
|
||||
"api"
|
||||
],
|
||||
"license": "MIT"
|
||||
}
|
||||
84
cli/scripts/buildMitm.js
Normal file
84
cli/scripts/buildMitm.js
Normal file
@@ -0,0 +1,84 @@
|
||||
const esbuild = require("esbuild");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// ── Build config ─────────────────────────────────────────
|
||||
const BUILD_CONFIG = {
|
||||
bundle: true,
|
||||
minify: true,
|
||||
obfuscate: false,
|
||||
cleanPlainFiles: true,
|
||||
};
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
const binDir = path.resolve(__dirname, "..");
|
||||
const appDir = path.resolve(binDir, "..", "app");
|
||||
const binMitmDir = path.join(binDir, "app", "src", "mitm");
|
||||
// Bundle everything — no externals. This keeps MITM runtime self-contained so
|
||||
// it can be copied to DATA_DIR/runtime/ and spawned from there (escapes
|
||||
// node_modules file locks that block `npm i -g 9router@latest` on Windows).
|
||||
const EXTERNALS = [];
|
||||
const ENTRIES = ["server.js"];
|
||||
|
||||
async function buildEntry(entry) {
|
||||
const mitmSrc = path.join(appDir, "src", "mitm");
|
||||
const output = path.join(binMitmDir, entry);
|
||||
|
||||
const buildPlugin = {
|
||||
name: "build-plugin",
|
||||
setup(build) {
|
||||
// Stub .git file scanned by esbuild
|
||||
build.onResolve({ filter: /\.git/ }, args => ({ path: args.path, namespace: "git-stub" }));
|
||||
build.onLoad({ filter: /.*/, namespace: "git-stub" }, () => ({ contents: "module.exports={}", loader: "js" }));
|
||||
},
|
||||
};
|
||||
|
||||
const steps = [];
|
||||
|
||||
if (BUILD_CONFIG.bundle) {
|
||||
const useTemp = BUILD_CONFIG.obfuscate;
|
||||
const outfile = useTemp ? output.replace(".js", ".bundled.js") : output;
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [path.join(mitmSrc, entry)],
|
||||
bundle: true,
|
||||
minify: BUILD_CONFIG.minify,
|
||||
platform: "node",
|
||||
target: "node18",
|
||||
external: EXTERNALS,
|
||||
plugins: [buildPlugin],
|
||||
outfile,
|
||||
});
|
||||
steps.push("bundled");
|
||||
if (BUILD_CONFIG.minify) steps.push("minified");
|
||||
|
||||
if (BUILD_CONFIG.obfuscate) {
|
||||
const { execSync } = require("child_process");
|
||||
execSync(
|
||||
`npx javascript-obfuscator "${outfile}" --output "${output}" --compact true --string-array true --string-array-encoding base64`,
|
||||
{ stdio: "inherit", cwd: appDir }
|
||||
);
|
||||
fs.unlinkSync(outfile);
|
||||
steps.push("obfuscated");
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ ${steps.join(" + ")} → ${output}`);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const flags = Object.entries(BUILD_CONFIG).filter(([, v]) => v).map(([k]) => k).join(", ");
|
||||
console.log(`⚙️ Config: ${flags}`);
|
||||
|
||||
for (const entry of ENTRIES) await buildEntry(entry);
|
||||
|
||||
if (BUILD_CONFIG.cleanPlainFiles) {
|
||||
const keep = new Set(ENTRIES);
|
||||
for (const name of fs.readdirSync(binMitmDir)) {
|
||||
if (!keep.has(name)) fs.rmSync(path.join(binMitmDir, name), { recursive: true, force: true });
|
||||
}
|
||||
console.log("✅ Removed plain MITM files from bin");
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((e) => { console.error(e); process.exit(1); });
|
||||
509
cli/src/cli/api/client.js
Normal file
509
cli/src/cli/api/client.js
Normal file
@@ -0,0 +1,509 @@
|
||||
const http = require("http");
|
||||
const https = require("https");
|
||||
const crypto = require("crypto");
|
||||
const { machineIdSync } = require("node-machine-id");
|
||||
|
||||
// Default configuration
|
||||
const DEFAULT_CONFIG = {
|
||||
host: "localhost",
|
||||
port: 20128,
|
||||
protocol: "http:",
|
||||
};
|
||||
|
||||
const CLI_TOKEN_HEADER = "x-9r-cli-token";
|
||||
const CLI_TOKEN_SALT = "9r-cli-auth";
|
||||
|
||||
let config = { ...DEFAULT_CONFIG };
|
||||
let cachedCliToken = null;
|
||||
|
||||
function getCliToken() {
|
||||
if (cachedCliToken !== null) return cachedCliToken;
|
||||
try {
|
||||
const mid = machineIdSync();
|
||||
cachedCliToken = crypto.createHash("sha256").update(mid + CLI_TOKEN_SALT).digest("hex").substring(0, 16);
|
||||
} catch {
|
||||
cachedCliToken = "";
|
||||
}
|
||||
return cachedCliToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure API client
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {string} options.host - API host
|
||||
* @param {number} options.port - API port
|
||||
* @param {string} options.protocol - Protocol (http: or https:)
|
||||
*/
|
||||
function configure(options = {}) {
|
||||
config = { ...config, ...options };
|
||||
}
|
||||
|
||||
/**
|
||||
* Make HTTP request to API
|
||||
* @param {string} method - HTTP method
|
||||
* @param {string} path - API path
|
||||
* @param {Object} body - Request body (optional)
|
||||
* @returns {Promise<Object>} Response with { success, data/error }
|
||||
*/
|
||||
function makeRequest(method, path, body = null) {
|
||||
return new Promise((resolve) => {
|
||||
const httpModule = config.protocol === "https:" ? https : http;
|
||||
|
||||
const options = {
|
||||
hostname: config.host,
|
||||
port: config.port,
|
||||
path: path,
|
||||
method: method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
[CLI_TOKEN_HEADER]: getCliToken(),
|
||||
},
|
||||
};
|
||||
|
||||
// Add Content-Length for POST/PUT requests
|
||||
if (body && (method === "POST" || method === "PUT" || method === "PATCH")) {
|
||||
const bodyString = JSON.stringify(body);
|
||||
options.headers["Content-Length"] = Buffer.byteLength(bodyString);
|
||||
}
|
||||
|
||||
const req = httpModule.request(options, (res) => {
|
||||
let data = "";
|
||||
|
||||
res.on("data", (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on("end", () => {
|
||||
try {
|
||||
const parsed = data ? JSON.parse(data) : {};
|
||||
|
||||
// Check if response indicates error
|
||||
if (res.statusCode >= 400 || parsed.error) {
|
||||
resolve({
|
||||
success: false,
|
||||
error: parsed.error || `HTTP ${res.statusCode}`,
|
||||
statusCode: res.statusCode,
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
success: true,
|
||||
data: parsed,
|
||||
statusCode: res.statusCode,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
resolve({
|
||||
success: false,
|
||||
error: `Failed to parse response: ${err.message}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on("error", (err) => {
|
||||
resolve({
|
||||
success: false,
|
||||
error: `Network error: ${err.message}`,
|
||||
});
|
||||
});
|
||||
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
resolve({
|
||||
success: false,
|
||||
error: "Request timeout",
|
||||
});
|
||||
});
|
||||
|
||||
// Set timeout (30 seconds)
|
||||
req.setTimeout(30000);
|
||||
|
||||
// Write body if present
|
||||
if (body && (method === "POST" || method === "PUT" || method === "PATCH")) {
|
||||
req.write(JSON.stringify(body));
|
||||
}
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PROVIDERS API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all providers
|
||||
* @returns {Promise<Object>} { success, data: { connections } }
|
||||
*/
|
||||
async function getProviders() {
|
||||
return makeRequest("GET", "/api/providers");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider by ID
|
||||
* @param {string} id - Provider ID
|
||||
* @returns {Promise<Object>} { success, data: { connection } }
|
||||
*/
|
||||
async function getProviderById(id) {
|
||||
return makeRequest("GET", `/api/providers/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test provider connection
|
||||
* @param {string} id - Provider ID
|
||||
* @returns {Promise<Object>} { success, data: { valid, error } }
|
||||
*/
|
||||
async function testProvider(id) {
|
||||
return makeRequest("POST", `/api/providers/${id}/test`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete provider
|
||||
* @param {string} id - Provider ID
|
||||
* @returns {Promise<Object>} { success, data: { message } }
|
||||
*/
|
||||
async function deleteProvider(id) {
|
||||
return makeRequest("DELETE", `/api/providers/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider models
|
||||
* @param {string} id - Provider ID
|
||||
* @returns {Promise<Object>} { success, data: { provider, connectionId, models } }
|
||||
*/
|
||||
async function getProviderModels(id) {
|
||||
return makeRequest("GET", `/api/providers/${id}/models`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OAUTH API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get OAuth authorization URL
|
||||
* @param {string} provider - Provider ID
|
||||
* @returns {Promise<Object>} { success, data: { authUrl, codeVerifier, state, redirectUri } }
|
||||
*/
|
||||
async function getOAuthAuthUrl(provider) {
|
||||
// Codex requires fixed port 1455 and path /auth/callback
|
||||
const redirectUri = provider === "codex"
|
||||
? "http://localhost:1455/auth/callback"
|
||||
: "http://localhost:20128/callback";
|
||||
return makeRequest("GET", `/api/oauth/${provider}/authorize?redirect_uri=${encodeURIComponent(redirectUri)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange OAuth authorization code for token
|
||||
* @param {string} provider - Provider ID
|
||||
* @param {Object} data - { code, redirectUri, codeVerifier, state }
|
||||
* @returns {Promise<Object>} { success, data }
|
||||
*/
|
||||
async function exchangeOAuthCode(provider, data) {
|
||||
return makeRequest("POST", `/api/oauth/${provider}/exchange`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OAuth device code
|
||||
* @param {string} provider - Provider ID
|
||||
* @returns {Promise<Object>} { success, data: { device_code, user_code, verification_uri, verification_uri_complete, codeVerifier, extraData } }
|
||||
*/
|
||||
async function getOAuthDeviceCode(provider) {
|
||||
return makeRequest("GET", `/api/oauth/${provider}/device-code`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll OAuth token using device code
|
||||
* @param {string} provider - Provider ID
|
||||
* @param {Object} data - { deviceCode, codeVerifier, extraData }
|
||||
* @returns {Promise<Object>} { success, data: { pending } }
|
||||
*/
|
||||
async function pollOAuthToken(provider, data) {
|
||||
return makeRequest("POST", `/api/oauth/${provider}/poll`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create API key provider connection
|
||||
* @param {Object} data - { provider, name, apiKey }
|
||||
* @returns {Promise<Object>} { success, data }
|
||||
*/
|
||||
async function createApiKeyProvider(data) {
|
||||
return makeRequest("POST", "/api/providers", data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update provider connection
|
||||
* @param {string} id - Connection ID
|
||||
* @param {Object} data - { name, priority, defaultModel, isActive }
|
||||
* @returns {Promise<Object>} { success, data: { connection } }
|
||||
*/
|
||||
async function updateConnection(id, data) {
|
||||
return makeRequest("PUT", `/api/providers/${id}`, data);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API KEYS API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all API keys
|
||||
* @returns {Promise<Object>} { success, data: { keys } }
|
||||
*/
|
||||
async function getApiKeys() {
|
||||
return makeRequest("GET", "/api/keys");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new API key
|
||||
* @param {string} name - Key name
|
||||
* @returns {Promise<Object>} { success, data: { key, name, id, machineId } }
|
||||
*/
|
||||
async function createApiKey(name) {
|
||||
return makeRequest("POST", "/api/keys", { name });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete API key
|
||||
* @param {string} id - Key ID
|
||||
* @returns {Promise<Object>} { success, data: { success } }
|
||||
*/
|
||||
async function deleteApiKey(id) {
|
||||
return makeRequest("DELETE", `/api/keys/${id}`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// COMBOS API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all combos
|
||||
* @returns {Promise<Object>} { success, data: { combos } }
|
||||
*/
|
||||
async function getCombos() {
|
||||
return makeRequest("GET", "/api/combos");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get combo by ID
|
||||
* @param {string} id - Combo ID
|
||||
* @returns {Promise<Object>} { success, data: combo }
|
||||
*/
|
||||
async function getComboById(id) {
|
||||
return makeRequest("GET", `/api/combos/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new combo
|
||||
* @param {Object} data - Combo data { name, models }
|
||||
* @returns {Promise<Object>} { success, data: combo }
|
||||
*/
|
||||
async function createCombo(data) {
|
||||
return makeRequest("POST", "/api/combos", data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update combo
|
||||
* @param {string} id - Combo ID
|
||||
* @param {Object} data - Update data { name?, models? }
|
||||
* @returns {Promise<Object>} { success, data: combo }
|
||||
*/
|
||||
async function updateCombo(id, data) {
|
||||
return makeRequest("PUT", `/api/combos/${id}`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete combo
|
||||
* @param {string} id - Combo ID
|
||||
* @returns {Promise<Object>} { success, data: { success } }
|
||||
*/
|
||||
async function deleteCombo(id) {
|
||||
return makeRequest("DELETE", `/api/combos/${id}`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CLI TOOLS API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get CLI tool settings
|
||||
* @param {string} tool - Tool name: claude | codex | droid | openclaw
|
||||
* @returns {Promise<Object>} { success, data: { installed, has9Router, ... } }
|
||||
*/
|
||||
async function getCliToolSettings(tool) {
|
||||
return makeRequest("GET", `/api/cli-tools/${tool}-settings`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply CLI tool settings (POST)
|
||||
* @param {string} tool - Tool name: claude | codex | droid | openclaw
|
||||
* @param {Object} body - Payload depends on tool
|
||||
* @returns {Promise<Object>} { success, data }
|
||||
*/
|
||||
async function applyCliToolSettings(tool, body) {
|
||||
return makeRequest("POST", `/api/cli-tools/${tool}-settings`, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset CLI tool settings (DELETE)
|
||||
* @param {string} tool - Tool name: claude | codex | droid | openclaw
|
||||
* @returns {Promise<Object>} { success, data }
|
||||
*/
|
||||
async function resetCliToolSettings(tool) {
|
||||
return makeRequest("DELETE", `/api/cli-tools/${tool}-settings`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SETTINGS API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get settings
|
||||
* @returns {Promise<Object>} { success, data: settings }
|
||||
*/
|
||||
async function getSettings() {
|
||||
return makeRequest("GET", "/api/settings");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update settings
|
||||
* @param {Object} data - Settings data
|
||||
* @returns {Promise<Object>} { success, data: settings }
|
||||
*/
|
||||
async function updateSettings(data) {
|
||||
return makeRequest("PATCH", "/api/settings", data);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MODELS API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all models (internal API)
|
||||
* @returns {Promise<Object>} { success, data: { models } }
|
||||
*/
|
||||
async function getModels() {
|
||||
return makeRequest("GET", "/api/models");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available models from active providers + combos (OpenAI compatible)
|
||||
* @returns {Promise<Object>} { success, data: { object, data: [...models] } }
|
||||
*/
|
||||
async function getAvailableModels() {
|
||||
return makeRequest("GET", "/v1/models");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PROVIDER NODES API (custom providers)
|
||||
// ============================================================================
|
||||
|
||||
async function getProviderNodes() {
|
||||
return makeRequest("GET", "/api/provider-nodes");
|
||||
}
|
||||
|
||||
async function createProviderNode(data) {
|
||||
return makeRequest("POST", "/api/provider-nodes", data);
|
||||
}
|
||||
|
||||
async function updateProviderNode(id, data) {
|
||||
return makeRequest("PUT", `/api/provider-nodes/${id}`, data);
|
||||
}
|
||||
|
||||
async function deleteProviderNode(id) {
|
||||
return makeRequest("DELETE", `/api/provider-nodes/${id}`);
|
||||
}
|
||||
|
||||
async function validateProviderNode(data) {
|
||||
return makeRequest("POST", "/api/provider-nodes/validate", data);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TUNNEL API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get tunnel status
|
||||
* @returns {Promise<Object>} { success, data: { enabled, tunnelUrl, shortId, running } }
|
||||
*/
|
||||
async function getTunnelStatus() {
|
||||
return makeRequest("GET", "/api/tunnel/status");
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable tunnel
|
||||
* @returns {Promise<Object>} { success, data: { tunnelUrl, shortId } }
|
||||
*/
|
||||
async function enableTunnel() {
|
||||
return makeRequest("POST", "/api/tunnel/enable");
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable tunnel
|
||||
* @returns {Promise<Object>} { success, data: { success } }
|
||||
*/
|
||||
async function disableTunnel() {
|
||||
return makeRequest("POST", "/api/tunnel/disable");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EXPORTS
|
||||
// ============================================================================
|
||||
|
||||
module.exports = {
|
||||
configure,
|
||||
|
||||
// Providers
|
||||
getProviders,
|
||||
getProviderById,
|
||||
testProvider,
|
||||
deleteProvider,
|
||||
getProviderModels,
|
||||
|
||||
// Connection aliases
|
||||
testConnection: testProvider,
|
||||
deleteConnection: deleteProvider,
|
||||
updateConnection,
|
||||
|
||||
// OAuth
|
||||
getOAuthAuthUrl,
|
||||
exchangeOAuthCode,
|
||||
getOAuthDeviceCode,
|
||||
pollOAuthToken,
|
||||
createApiKeyProvider,
|
||||
|
||||
// API Keys
|
||||
getApiKeys,
|
||||
createApiKey,
|
||||
deleteApiKey,
|
||||
|
||||
// Combos
|
||||
getCombos,
|
||||
getComboById,
|
||||
createCombo,
|
||||
updateCombo,
|
||||
deleteCombo,
|
||||
|
||||
// CLI Tools
|
||||
getCliToolSettings,
|
||||
applyCliToolSettings,
|
||||
resetCliToolSettings,
|
||||
|
||||
// Settings
|
||||
getSettings,
|
||||
updateSettings,
|
||||
|
||||
// Tunnel
|
||||
getTunnelStatus,
|
||||
enableTunnel,
|
||||
disableTunnel,
|
||||
|
||||
// Models
|
||||
getModels,
|
||||
getAvailableModels,
|
||||
|
||||
// Provider Nodes (custom providers)
|
||||
getProviderNodes,
|
||||
createProviderNode,
|
||||
updateProviderNode,
|
||||
deleteProviderNode,
|
||||
validateProviderNode,
|
||||
};
|
||||
233
cli/src/cli/menus/apiKeys.js
Normal file
233
cli/src/cli/menus/apiKeys.js
Normal file
@@ -0,0 +1,233 @@
|
||||
const api = require("../api/client");
|
||||
const { prompt, confirm, pause } = require("../utils/input");
|
||||
const { clearScreen, showStatus, showHeader } = require("../utils/display");
|
||||
const { maskKey, formatDate, getRelativeTime } = require("../utils/format");
|
||||
const { showMenuWithBack } = require("../utils/menuHelper");
|
||||
const { copyToClipboard } = require("../utils/clipboard");
|
||||
const { getEndpoint } = require("../utils/endpoint");
|
||||
|
||||
/**
|
||||
* Display API keys list with formatted output
|
||||
* @param {Array} keys - Array of API key objects
|
||||
* @param {number} port - Server port
|
||||
*/
|
||||
function displayApiKeys(keys, port) {
|
||||
console.log("┌─────────────────────────────────────────────────────────┐");
|
||||
console.log("│ 🔑 API Keys Management │");
|
||||
console.log("├─────────────────────────────────────────────────────────┤");
|
||||
// Note: This function is legacy, endpoint shown in menu header instead
|
||||
console.log("│ │");
|
||||
|
||||
if (keys.length === 0) {
|
||||
console.log("│ No API keys found. │");
|
||||
} else {
|
||||
console.log(`│ Your API Keys (${keys.length}):${" ".repeat(42 - String(keys.length).length)}│`);
|
||||
|
||||
keys.forEach((key, index) => {
|
||||
console.log("│ │");
|
||||
console.log(`│ ${index + 1}. ${key.name}${" ".repeat(52 - String(index + 1).length - key.name.length)}│`);
|
||||
|
||||
const maskedKey = maskKey(key.key);
|
||||
console.log(`│ Key: ${maskedKey}${" ".repeat(47 - maskedKey.length)}│`);
|
||||
|
||||
const created = formatDate(key.createdAt);
|
||||
console.log(`│ Created: ${created}${" ".repeat(43 - created.length)}│`);
|
||||
|
||||
if (key.lastUsedAt) {
|
||||
const lastUsed = getRelativeTime(key.lastUsedAt);
|
||||
console.log(`│ Last used: ${lastUsed}${" ".repeat(41 - lastUsed.length)}│`);
|
||||
} else {
|
||||
console.log("│ Last used: Never │");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log("│ │");
|
||||
console.log("│ Actions: │");
|
||||
console.log("│ 1. Create New API Key │");
|
||||
console.log("│ 2. View Full Key (by number) │");
|
||||
console.log("│ 3. Copy Key to Clipboard (by number) │");
|
||||
console.log("│ 4. Delete Key (by number) │");
|
||||
console.log("│ 0. ← Back to Main Menu │");
|
||||
console.log("└─────────────────────────────────────────────────────────┘");
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle creating new API key
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
async function handleCreateKey() {
|
||||
console.log("\n📝 Create New API Key");
|
||||
console.log("─".repeat(30));
|
||||
|
||||
const name = await prompt("Enter key name: ");
|
||||
|
||||
if (!name) {
|
||||
showStatus("Key name cannot be empty", "error");
|
||||
await pause();
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await api.createApiKey(name);
|
||||
|
||||
if (!result.success) {
|
||||
showStatus(`Failed to create key: ${result.error}`, "error");
|
||||
await pause();
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log("\n✅ API Key created successfully!");
|
||||
console.log("\n⚠️ IMPORTANT: Save this key now. You won't be able to see it again!");
|
||||
console.log(`\nKey: ${result.data.key}`);
|
||||
console.log(`Name: ${result.data.name}`);
|
||||
console.log(`ID: ${result.data.id}`);
|
||||
|
||||
const shouldCopy = await confirm("\nCopy key to clipboard?");
|
||||
if (shouldCopy) {
|
||||
if (copyToClipboard(result.data.key)) {
|
||||
showStatus("Key copied to clipboard!", "success");
|
||||
} else {
|
||||
showStatus("Failed to copy to clipboard", "error");
|
||||
}
|
||||
}
|
||||
|
||||
await pause();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle viewing full API key
|
||||
* @param {Object} key - API key object
|
||||
*/
|
||||
async function handleViewFullKey(key) {
|
||||
console.log("\n🔍 Full API Key");
|
||||
console.log("─".repeat(30));
|
||||
console.log(`Name: ${key.name}`);
|
||||
console.log(`Key: ${key.key}`);
|
||||
console.log(`ID: ${key.id}`);
|
||||
console.log(`Created: ${formatDate(key.createdAt)}`);
|
||||
|
||||
if (key.lastUsedAt) {
|
||||
console.log(`Last used: ${getRelativeTime(key.lastUsedAt)}`);
|
||||
} else {
|
||||
console.log("Last used: Never");
|
||||
}
|
||||
|
||||
await pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle copying API key to clipboard
|
||||
* @param {Object} key - API key object
|
||||
*/
|
||||
async function handleCopyKey(key) {
|
||||
if (copyToClipboard(key.key)) {
|
||||
showStatus(`Key "${key.name}" copied to clipboard!`, "success");
|
||||
} else {
|
||||
showStatus("Failed to copy to clipboard", "error");
|
||||
}
|
||||
await pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle deleting API key
|
||||
* @param {Object} key - API key object
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
async function handleDeleteKey(key) {
|
||||
console.log(`\n⚠️ Delete API Key: ${key.name}`);
|
||||
console.log("─".repeat(30));
|
||||
console.log(`Key: ${maskKey(key.key)}`);
|
||||
console.log(`Created: ${formatDate(key.createdAt)}`);
|
||||
|
||||
const confirmed = await confirm("\nAre you sure you want to delete this key?");
|
||||
|
||||
if (!confirmed) {
|
||||
showStatus("Deletion cancelled", "info");
|
||||
await pause();
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await api.deleteApiKey(key.id);
|
||||
|
||||
if (!result.success) {
|
||||
showStatus(`Failed to delete key: ${result.error}`, "error");
|
||||
await pause();
|
||||
return false;
|
||||
}
|
||||
|
||||
showStatus("API key deleted successfully", "success");
|
||||
await pause();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show actions for a specific key
|
||||
* @param {Object} key - API key object
|
||||
* @param {number} port - Server port
|
||||
* @param {Array<string>} breadcrumb - Breadcrumb path
|
||||
*/
|
||||
async function showKeyActions(key, port, breadcrumb = []) {
|
||||
const { endpoint } = await getEndpoint(port);
|
||||
await showMenuWithBack({
|
||||
title: `🔑 ${key.name}`,
|
||||
breadcrumb: [...breadcrumb, key.name],
|
||||
headerContent: `Name: ${key.name}\nKey: ${key.key}\nEndpoint: ${endpoint}`,
|
||||
items: [
|
||||
{
|
||||
label: "Copy to Clipboard",
|
||||
action: async () => {
|
||||
await handleCopyKey(key);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "Delete Key",
|
||||
action: async () => {
|
||||
await handleDeleteKey(key);
|
||||
return false; // Exit after delete
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Main API Keys menu
|
||||
* @param {number} port - Server port number
|
||||
* @param {Array<string>} breadcrumb - Breadcrumb path
|
||||
*/
|
||||
async function showApiKeysMenu(port, breadcrumb = []) {
|
||||
const { showListMenu } = require("../utils/menuHelper");
|
||||
|
||||
const { endpoint } = await getEndpoint(port);
|
||||
await showListMenu({
|
||||
title: "🔑 API Keys Management",
|
||||
breadcrumb,
|
||||
headerContent: `Endpoint: ${endpoint}`,
|
||||
fetchItems: async () => {
|
||||
const result = await api.getApiKeys();
|
||||
if (!result.success) {
|
||||
clearScreen();
|
||||
showStatus(`Failed to fetch API keys: ${result.error}`, "error");
|
||||
await pause();
|
||||
return null;
|
||||
}
|
||||
return { items: result.data.keys || [] };
|
||||
},
|
||||
formatItem: (key) => `${key.name} (${maskKey(key.key)})`,
|
||||
onSelect: async (key) => {
|
||||
await showKeyActions(key, port, breadcrumb);
|
||||
},
|
||||
createAction: {
|
||||
label: "Create New API Key",
|
||||
action: async () => {
|
||||
await handleCreateKey();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
showApiKeysMenu
|
||||
};
|
||||
618
cli/src/cli/menus/cliTools.js
Normal file
618
cli/src/cli/menus/cliTools.js
Normal file
@@ -0,0 +1,618 @@
|
||||
const api = require("../api/client");
|
||||
const { pause, confirm } = require("../utils/input");
|
||||
const { showStatus } = require("../utils/display");
|
||||
const { selectModelFromList } = require("../utils/modelSelector");
|
||||
const { showMenuWithBack } = require("../utils/menuHelper");
|
||||
const { getEndpoint } = require("../utils/endpoint");
|
||||
|
||||
const COLORS = {
|
||||
reset: "\x1b[0m",
|
||||
green: "\x1b[32m",
|
||||
red: "\x1b[31m",
|
||||
dim: "\x1b[2m",
|
||||
cyan: "\x1b[36m"
|
||||
};
|
||||
|
||||
// Claude model types with defaults (matching Web UI)
|
||||
const CLAUDE_MODEL_TYPES = [
|
||||
{ id: "sonnet", name: "Sonnet", envKey: "ANTHROPIC_DEFAULT_SONNET_MODEL", defaultValue: "cc/claude-sonnet-4-5-20250929" },
|
||||
{ id: "opus", name: "Opus", envKey: "ANTHROPIC_DEFAULT_OPUS_MODEL", defaultValue: "cc/claude-opus-4-5-20251101" },
|
||||
{ id: "haiku", name: "Haiku", envKey: "ANTHROPIC_DEFAULT_HAIKU_MODEL", defaultValue: "cc/claude-haiku-4-5-20251001" },
|
||||
];
|
||||
|
||||
// ─── Shared helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get first available API key from server
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
async function getFirstApiKey() {
|
||||
const result = await api.getApiKeys();
|
||||
const keys = result.success ? (result.data.keys || []) : [];
|
||||
return keys.length > 0 ? keys[0].key : null;
|
||||
}
|
||||
|
||||
// ─── Claude Code ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build header showing current Claude config status
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function buildClaudeHeader() {
|
||||
const result = await api.getCliToolSettings("claude");
|
||||
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
|
||||
|
||||
const settings = result.data.settings;
|
||||
const currentUrl = settings?.env?.ANTHROPIC_BASE_URL;
|
||||
const currentKey = settings?.env?.ANTHROPIC_AUTH_TOKEN;
|
||||
const lines = [];
|
||||
|
||||
if (currentUrl) {
|
||||
lines.push(`Status: ${COLORS.green}✓ Configured${COLORS.reset}`);
|
||||
lines.push(`Endpoint: ${COLORS.cyan}${currentUrl}${COLORS.reset}`);
|
||||
if (currentKey) {
|
||||
lines.push(`API Key: ${COLORS.dim}${currentKey.substring(0, 10)}...${COLORS.reset}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`);
|
||||
lines.push(`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current Claude model from settings
|
||||
* @param {string} envKey
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function getClaudeModel(envKey) {
|
||||
const result = await api.getCliToolSettings("claude");
|
||||
return result.success ? (result.data.settings?.env?.[envKey] || "Not set") : "Not set";
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick setup for Claude Code — sets endpoint, key, and all default models
|
||||
* @param {number} port
|
||||
*/
|
||||
async function claudeQuickSetup(port) {
|
||||
const { endpoint } = await getEndpoint(port);
|
||||
const apiKey = await getFirstApiKey();
|
||||
|
||||
if (!apiKey) {
|
||||
showStatus("No API keys found. Create one in API Keys menu first.", "error");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
const env = { ANTHROPIC_BASE_URL: endpoint, ANTHROPIC_AUTH_TOKEN: apiKey, API_TIMEOUT_MS: "600000" };
|
||||
CLAUDE_MODEL_TYPES.forEach(t => { env[t.envKey] = t.defaultValue; });
|
||||
|
||||
const result = await api.applyCliToolSettings("claude", { env });
|
||||
showStatus(result.success ? "Quick Setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
||||
await pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Select and save a specific Claude model type
|
||||
* @param {Object} modelType
|
||||
* @param {number} port
|
||||
*/
|
||||
async function claudeSelectModel(modelType, port) {
|
||||
const current = await getClaudeModel(modelType.envKey);
|
||||
const selected = await selectModelFromList(`Select ${modelType.name} Model`, current, { excludeCombos: true });
|
||||
if (!selected) return;
|
||||
|
||||
const env = { [modelType.envKey]: selected };
|
||||
|
||||
// Also set base URL if not configured yet
|
||||
const settingsResult = await api.getCliToolSettings("claude");
|
||||
if (!settingsResult.data?.settings?.env?.ANTHROPIC_BASE_URL) {
|
||||
const { endpoint } = await getEndpoint(port);
|
||||
const apiKey = await getFirstApiKey();
|
||||
env.ANTHROPIC_BASE_URL = endpoint;
|
||||
env.API_TIMEOUT_MS = "600000";
|
||||
if (apiKey) env.ANTHROPIC_AUTH_TOKEN = apiKey;
|
||||
}
|
||||
|
||||
const result = await api.applyCliToolSettings("claude", { env });
|
||||
showStatus(result.success ? `${modelType.name} → ${selected} saved!` : `Failed: ${result.error}`, result.success ? "success" : "error");
|
||||
await pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset Claude Code settings
|
||||
*/
|
||||
async function claudeReset() {
|
||||
const result = await api.resetCliToolSettings("claude");
|
||||
showStatus(result.success ? "Settings reset successfully!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
||||
await pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code submenu
|
||||
* @param {number} port
|
||||
* @param {Array<string>} breadcrumb
|
||||
*/
|
||||
async function showClaudeCodeMenu(port, breadcrumb = []) {
|
||||
await showMenuWithBack({
|
||||
title: "🔧 Claude Code Settings",
|
||||
breadcrumb,
|
||||
headerContent: buildClaudeHeader,
|
||||
refresh: async () => ({
|
||||
sonnet: await getClaudeModel("ANTHROPIC_DEFAULT_SONNET_MODEL"),
|
||||
opus: await getClaudeModel("ANTHROPIC_DEFAULT_OPUS_MODEL"),
|
||||
haiku: await getClaudeModel("ANTHROPIC_DEFAULT_HAIKU_MODEL"),
|
||||
}),
|
||||
items: [
|
||||
{
|
||||
label: "⚡ Quick Setup (recommended)",
|
||||
action: async () => { await claudeQuickSetup(port); return true; }
|
||||
},
|
||||
{
|
||||
label: (d) => `Sonnet → ${d.sonnet}`,
|
||||
action: async () => { await claudeSelectModel(CLAUDE_MODEL_TYPES[0], port); return true; }
|
||||
},
|
||||
{
|
||||
label: (d) => `Opus → ${d.opus}`,
|
||||
action: async () => { await claudeSelectModel(CLAUDE_MODEL_TYPES[1], port); return true; }
|
||||
},
|
||||
{
|
||||
label: (d) => `Haiku → ${d.haiku}`,
|
||||
action: async () => { await claudeSelectModel(CLAUDE_MODEL_TYPES[2], port); return true; }
|
||||
},
|
||||
{
|
||||
label: "Reset to Default",
|
||||
action: async () => { await claudeReset(); return true; }
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Codex CLI ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build header showing current Codex config status
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function buildCodexHeader() {
|
||||
const result = await api.getCliToolSettings("codex");
|
||||
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
|
||||
|
||||
const { installed, has9Router, config } = result.data;
|
||||
if (!installed) return `Status: ${COLORS.red}✗ Codex CLI not installed${COLORS.reset}`;
|
||||
|
||||
if (!has9Router) {
|
||||
return [
|
||||
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
|
||||
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
// Parse base_url and model from raw TOML string
|
||||
const baseUrlMatch = config && config.match(/base_url\s*=\s*"([^"]+)"/);
|
||||
const modelMatch = config && config.match(/^model\s*=\s*"([^"]+)"/m);
|
||||
const baseUrl = baseUrlMatch ? baseUrlMatch[1] : "";
|
||||
const model = modelMatch ? modelMatch[1] : "";
|
||||
|
||||
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
|
||||
if (baseUrl) lines.push(`Endpoint: ${COLORS.cyan}${baseUrl}${COLORS.reset}`);
|
||||
if (model) lines.push(`Model: ${COLORS.dim}${model}${COLORS.reset}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick setup for Codex CLI
|
||||
* @param {number} port
|
||||
*/
|
||||
async function codexQuickSetup(port) {
|
||||
const { endpoint } = await getEndpoint(port);
|
||||
const apiKey = await getFirstApiKey();
|
||||
|
||||
if (!apiKey) {
|
||||
showStatus("No API keys found. Create one in API Keys menu first.", "error");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get model selection
|
||||
const model = await selectModelFromList("Select Codex Model", "cx/claude-sonnet-4-5-20250929", { excludeCombos: true });
|
||||
if (!model) return;
|
||||
|
||||
const result = await api.applyCliToolSettings("codex", { baseUrl: endpoint, apiKey, model });
|
||||
showStatus(result.success ? "Codex setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
||||
await pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset Codex CLI settings
|
||||
*/
|
||||
async function codexReset() {
|
||||
const result = await api.resetCliToolSettings("codex");
|
||||
showStatus(result.success ? "Codex settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
||||
await pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex CLI submenu
|
||||
* @param {number} port
|
||||
* @param {Array<string>} breadcrumb
|
||||
*/
|
||||
async function showCodexMenu(port, breadcrumb = []) {
|
||||
await showMenuWithBack({
|
||||
title: "🤖 Codex CLI Settings",
|
||||
breadcrumb,
|
||||
headerContent: buildCodexHeader,
|
||||
refresh: async () => ({}),
|
||||
items: [
|
||||
{
|
||||
label: "⚡ Quick Setup",
|
||||
action: async () => { await codexQuickSetup(port); return true; }
|
||||
},
|
||||
{
|
||||
label: "Reset to Default",
|
||||
action: async () => { await codexReset(); return true; }
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Factory Droid ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build header showing current Droid config status
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function buildDroidHeader() {
|
||||
const result = await api.getCliToolSettings("droid");
|
||||
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
|
||||
|
||||
const { installed, has9Router, settings } = result.data;
|
||||
if (!installed) return `Status: ${COLORS.red}✗ Factory Droid not installed${COLORS.reset}`;
|
||||
|
||||
if (!has9Router) {
|
||||
return [
|
||||
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
|
||||
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
// Extract 9Router custom model config
|
||||
const custom = settings?.customModels?.find(m => m.id === "custom:9Router-0");
|
||||
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
|
||||
if (custom?.baseUrl) lines.push(`Endpoint: ${COLORS.cyan}${custom.baseUrl}${COLORS.reset}`);
|
||||
if (custom?.model) lines.push(`Model: ${COLORS.dim}${custom.model}${COLORS.reset}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick setup for Factory Droid
|
||||
* @param {number} port
|
||||
*/
|
||||
async function droidQuickSetup(port) {
|
||||
const { endpoint } = await getEndpoint(port);
|
||||
const apiKey = await getFirstApiKey();
|
||||
|
||||
if (!apiKey) {
|
||||
showStatus("No API keys found. Create one in API Keys menu first.", "error");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
const model = await selectModelFromList("Select Droid Model", "cc/claude-sonnet-4-5-20250929", { excludeCombos: true });
|
||||
if (!model) return;
|
||||
|
||||
const result = await api.applyCliToolSettings("droid", { baseUrl: endpoint, apiKey, model });
|
||||
showStatus(result.success ? "Factory Droid setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
||||
await pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset Factory Droid settings
|
||||
*/
|
||||
async function droidReset() {
|
||||
const result = await api.resetCliToolSettings("droid");
|
||||
showStatus(result.success ? "Factory Droid settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
||||
await pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory Droid submenu
|
||||
* @param {number} port
|
||||
* @param {Array<string>} breadcrumb
|
||||
*/
|
||||
async function showDroidMenu(port, breadcrumb = []) {
|
||||
await showMenuWithBack({
|
||||
title: "🤖 Factory Droid Settings",
|
||||
breadcrumb,
|
||||
headerContent: buildDroidHeader,
|
||||
refresh: async () => ({}),
|
||||
items: [
|
||||
{
|
||||
label: "⚡ Quick Setup",
|
||||
action: async () => { await droidQuickSetup(port); return true; }
|
||||
},
|
||||
{
|
||||
label: "Reset to Default",
|
||||
action: async () => { await droidReset(); return true; }
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Open Claw ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build header showing current OpenClaw config status
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function buildOpenClawHeader() {
|
||||
const result = await api.getCliToolSettings("openclaw");
|
||||
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
|
||||
|
||||
const { installed, has9Router, settings } = result.data;
|
||||
if (!installed) return `Status: ${COLORS.red}✗ Open Claw not installed${COLORS.reset}`;
|
||||
|
||||
if (!has9Router) {
|
||||
return [
|
||||
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
|
||||
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
// Extract 9Router provider config
|
||||
const provider = settings?.models?.providers?.["9router"];
|
||||
const primary = settings?.agents?.defaults?.model?.primary || "";
|
||||
const model = primary.startsWith("9router/") ? primary.replace("9router/", "") : (provider?.models?.[0]?.id || "");
|
||||
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
|
||||
if (provider?.baseUrl) lines.push(`Endpoint: ${COLORS.cyan}${provider.baseUrl}${COLORS.reset}`);
|
||||
if (model) lines.push(`Model: ${COLORS.dim}${model}${COLORS.reset}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick setup for Open Claw
|
||||
* @param {number} port
|
||||
*/
|
||||
async function openClawQuickSetup(port) {
|
||||
const { endpoint } = await getEndpoint(port);
|
||||
const apiKey = await getFirstApiKey();
|
||||
|
||||
if (!apiKey) {
|
||||
showStatus("No API keys found. Create one in API Keys menu first.", "error");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
const model = await selectModelFromList("Select OpenClaw Model", "cc/claude-sonnet-4-5-20250929", { excludeCombos: true });
|
||||
if (!model) return;
|
||||
|
||||
const result = await api.applyCliToolSettings("openclaw", { baseUrl: endpoint, apiKey, model });
|
||||
showStatus(result.success ? "Open Claw setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
||||
await pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset Open Claw settings
|
||||
*/
|
||||
async function openClawReset() {
|
||||
const result = await api.resetCliToolSettings("openclaw");
|
||||
showStatus(result.success ? "Open Claw settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
||||
await pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open Claw submenu
|
||||
* @param {number} port
|
||||
* @param {Array<string>} breadcrumb
|
||||
*/
|
||||
async function showOpenClawMenu(port, breadcrumb = []) {
|
||||
await showMenuWithBack({
|
||||
title: "🦞 Open Claw Settings",
|
||||
breadcrumb,
|
||||
headerContent: buildOpenClawHeader,
|
||||
refresh: async () => ({}),
|
||||
items: [
|
||||
{
|
||||
label: "⚡ Quick Setup",
|
||||
action: async () => { await openClawQuickSetup(port); return true; }
|
||||
},
|
||||
{
|
||||
label: "Reset to Default",
|
||||
action: async () => { await openClawReset(); return true; }
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// ─── OpenCode CLI ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function buildOpenCodeHeader() {
|
||||
const result = await api.getCliToolSettings("opencode");
|
||||
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
|
||||
|
||||
const { installed, has9Router, opencode } = result.data;
|
||||
if (!installed) return `Status: ${COLORS.red}✗ OpenCode CLI not installed${COLORS.reset}`;
|
||||
|
||||
if (!has9Router) {
|
||||
return [
|
||||
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
|
||||
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
|
||||
if (opencode?.baseURL) lines.push(`Endpoint: ${COLORS.cyan}${opencode.baseURL}${COLORS.reset}`);
|
||||
if (opencode?.activeModel) lines.push(`Active: ${COLORS.dim}${opencode.activeModel}${COLORS.reset}`);
|
||||
if (Array.isArray(opencode?.models) && opencode.models.length > 0) {
|
||||
lines.push(`Models: ${COLORS.dim}${opencode.models.join(", ")}${COLORS.reset}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function openCodeQuickSetup(port) {
|
||||
const { endpoint } = await getEndpoint(port);
|
||||
const apiKey = await getFirstApiKey();
|
||||
|
||||
if (!apiKey) {
|
||||
showStatus("No API keys found. Create one in API Keys menu first.", "error");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
// Pick first model (also becomes active model by default)
|
||||
const firstModel = await selectModelFromList("Select Active Model (OpenCode)", "", { excludeCombos: true });
|
||||
if (!firstModel) return;
|
||||
|
||||
const models = [firstModel];
|
||||
|
||||
// Optionally add more models
|
||||
while (true) {
|
||||
const more = await confirm(`Add another model? (current: ${models.length})`);
|
||||
if (!more) break;
|
||||
const next = await selectModelFromList(`Add Model #${models.length + 1}`, models.join(", "), { excludeCombos: true });
|
||||
if (!next) break;
|
||||
if (!models.includes(next)) models.push(next);
|
||||
}
|
||||
|
||||
// Optional subagent model
|
||||
let subagentModel = firstModel;
|
||||
const wantSubagent = await confirm(`Set a different subagent model? (default: ${firstModel})`);
|
||||
if (wantSubagent) {
|
||||
const picked = await selectModelFromList("Select Subagent Model", firstModel, { excludeCombos: true });
|
||||
if (picked) subagentModel = picked;
|
||||
}
|
||||
|
||||
const result = await api.applyCliToolSettings("opencode", {
|
||||
baseUrl: endpoint,
|
||||
apiKey,
|
||||
models,
|
||||
activeModel: firstModel,
|
||||
subagentModel,
|
||||
});
|
||||
showStatus(result.success ? "OpenCode setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
||||
await pause();
|
||||
}
|
||||
|
||||
async function openCodeReset() {
|
||||
const result = await api.resetCliToolSettings("opencode");
|
||||
showStatus(result.success ? "OpenCode settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
||||
await pause();
|
||||
}
|
||||
|
||||
async function showOpenCodeMenu(port, breadcrumb = []) {
|
||||
await showMenuWithBack({
|
||||
title: "💻 OpenCode CLI Settings",
|
||||
breadcrumb,
|
||||
headerContent: buildOpenCodeHeader,
|
||||
refresh: async () => ({}),
|
||||
items: [
|
||||
{ label: "⚡ Quick Setup", action: async () => { await openCodeQuickSetup(port); return true; } },
|
||||
{ label: "Reset to Default", action: async () => { await openCodeReset(); return true; } }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Hermes Agent ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function buildHermesHeader() {
|
||||
const result = await api.getCliToolSettings("hermes");
|
||||
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
|
||||
|
||||
const { installed, has9Router, settings } = result.data;
|
||||
if (!installed) return `Status: ${COLORS.red}✗ Hermes Agent not installed${COLORS.reset}`;
|
||||
|
||||
if (!has9Router) {
|
||||
return [
|
||||
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
|
||||
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const model = settings?.model || {};
|
||||
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
|
||||
if (model.base_url) lines.push(`Endpoint: ${COLORS.cyan}${model.base_url}${COLORS.reset}`);
|
||||
if (model.default) lines.push(`Model: ${COLORS.dim}${model.default}${COLORS.reset}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function hermesQuickSetup(port) {
|
||||
const { endpoint } = await getEndpoint(port);
|
||||
const apiKey = await getFirstApiKey();
|
||||
|
||||
if (!apiKey) {
|
||||
showStatus("No API keys found. Create one in API Keys menu first.", "error");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
const model = await selectModelFromList("Select Hermes Model", "", { excludeCombos: true });
|
||||
if (!model) return;
|
||||
|
||||
const result = await api.applyCliToolSettings("hermes", { baseUrl: endpoint, apiKey, model });
|
||||
showStatus(result.success ? "Hermes setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
||||
await pause();
|
||||
}
|
||||
|
||||
async function hermesReset() {
|
||||
const result = await api.resetCliToolSettings("hermes");
|
||||
showStatus(result.success ? "Hermes settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
|
||||
await pause();
|
||||
}
|
||||
|
||||
async function showHermesMenu(port, breadcrumb = []) {
|
||||
await showMenuWithBack({
|
||||
title: "⚡ Hermes Agent Settings",
|
||||
breadcrumb,
|
||||
headerContent: buildHermesHeader,
|
||||
refresh: async () => ({}),
|
||||
items: [
|
||||
{ label: "⚡ Quick Setup", action: async () => { await hermesQuickSetup(port); return true; } },
|
||||
{ label: "Reset to Default", action: async () => { await hermesReset(); return true; } }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Main CLI Tools Menu ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Main CLI Tools menu
|
||||
* @param {number} port
|
||||
* @param {Array<string>} breadcrumb
|
||||
*/
|
||||
async function showCliToolsMenu(port, breadcrumb = []) {
|
||||
const { endpoint } = await getEndpoint(port);
|
||||
await showMenuWithBack({
|
||||
title: "🔧 CLI Tools",
|
||||
breadcrumb,
|
||||
headerContent: `Configure CLI tools to use 9Router\nEndpoint: ${endpoint}`,
|
||||
items: [
|
||||
{
|
||||
label: "Claude Code",
|
||||
action: async () => { await showClaudeCodeMenu(port, [...breadcrumb, "Claude Code"]); return true; }
|
||||
},
|
||||
{
|
||||
label: "Codex CLI",
|
||||
action: async () => { await showCodexMenu(port, [...breadcrumb, "Codex CLI"]); return true; }
|
||||
},
|
||||
{
|
||||
label: "Factory Droid",
|
||||
action: async () => { await showDroidMenu(port, [...breadcrumb, "Factory Droid"]); return true; }
|
||||
},
|
||||
{
|
||||
label: "Open Claw",
|
||||
action: async () => { await showOpenClawMenu(port, [...breadcrumb, "Open Claw"]); return true; }
|
||||
},
|
||||
{
|
||||
label: "OpenCode",
|
||||
action: async () => { await showOpenCodeMenu(port, [...breadcrumb, "OpenCode"]); return true; }
|
||||
},
|
||||
{
|
||||
label: "Hermes",
|
||||
action: async () => { await showHermesMenu(port, [...breadcrumb, "Hermes"]); return true; }
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { showCliToolsMenu };
|
||||
477
cli/src/cli/menus/combos.js
Normal file
477
cli/src/cli/menus/combos.js
Normal file
@@ -0,0 +1,477 @@
|
||||
const api = require("../api/client");
|
||||
const { prompt, confirm, pause } = require("../utils/input");
|
||||
const { clearScreen, showStatus, showHeader } = require("../utils/display");
|
||||
const { formatDate } = require("../utils/format");
|
||||
const { selectModelFromList } = require("../utils/modelSelector");
|
||||
const { showMenuWithBack } = require("../utils/menuHelper");
|
||||
|
||||
/**
|
||||
* Format model to string (handle both string and object)
|
||||
*/
|
||||
function formatModel(model) {
|
||||
if (typeof model === "string") return model;
|
||||
if (model && typeof model === "object") {
|
||||
return model.id || model.name || `${model.provider}/${model.model}` || JSON.stringify(model);
|
||||
}
|
||||
return String(model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show actions for a specific combo
|
||||
* @param {Object} combo - Combo object
|
||||
* @param {Array<string>} breadcrumb - Breadcrumb path
|
||||
*/
|
||||
async function showComboActions(combo, breadcrumb = []) {
|
||||
const modelsChain = Array.isArray(combo.models)
|
||||
? combo.models.map(formatModel).join(" → ")
|
||||
: "";
|
||||
|
||||
await showMenuWithBack({
|
||||
title: `🔀 ${combo.name}`,
|
||||
breadcrumb: [...breadcrumb, combo.name],
|
||||
headerContent: `Name: ${combo.name}\nModels: ${modelsChain}`,
|
||||
items: [
|
||||
{
|
||||
label: "Edit Combo",
|
||||
action: async () => {
|
||||
await handleEditSingleCombo(combo);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "Delete Combo",
|
||||
action: async () => {
|
||||
await handleDeleteSingleCombo(combo);
|
||||
return false; // Exit after delete
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle editing a single combo
|
||||
* @param {Object} combo - Combo to edit
|
||||
*/
|
||||
async function handleEditSingleCombo(combo) {
|
||||
clearScreen();
|
||||
console.log(`\n✏️ Edit Combo: ${combo.name}\n`);
|
||||
|
||||
const newName = await prompt(`New name (Enter to keep "${combo.name}"): `);
|
||||
const name = newName || combo.name;
|
||||
|
||||
console.log("\nCurrent models: " + (Array.isArray(combo.models) ? combo.models.map(formatModel).join(" → ") : ""));
|
||||
console.log("\nSelect models for this combo (add one by one):");
|
||||
|
||||
const models = [];
|
||||
let addMore = true;
|
||||
|
||||
while (addMore) {
|
||||
const currentChain = models.length > 0 ? models.join(" → ") : "None";
|
||||
const model = await selectModelFromList(`Add Model #${models.length + 1}`, `Chain: ${currentChain}`);
|
||||
|
||||
if (model) {
|
||||
models.push(model);
|
||||
console.log(`\n✓ Added: ${model}`);
|
||||
console.log(`Current chain: ${models.join(" → ")}\n`);
|
||||
|
||||
const continueAdding = await confirm("Add another model?");
|
||||
addMore = continueAdding;
|
||||
} else {
|
||||
addMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Use new models if any were added, otherwise keep current
|
||||
const finalModels = models.length > 0 ? models : combo.models;
|
||||
|
||||
const result = await api.updateCombo(combo.id, { name, models: finalModels });
|
||||
|
||||
if (result.success) {
|
||||
showStatus("Combo updated!", "success");
|
||||
} else {
|
||||
showStatus(`Update failed: ${result.error}`, "error");
|
||||
}
|
||||
await pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle deleting a single combo
|
||||
* @param {Object} combo - Combo to delete
|
||||
*/
|
||||
async function handleDeleteSingleCombo(combo) {
|
||||
const confirmed = await confirm(`Delete combo "${combo.name}"?`);
|
||||
if (confirmed) {
|
||||
const result = await api.deleteCombo(combo.id);
|
||||
if (result.success) {
|
||||
showStatus("Combo deleted!", "success");
|
||||
} else {
|
||||
showStatus(`Delete failed: ${result.error}`, "error");
|
||||
}
|
||||
await pause();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main combos menu - list all combos and actions
|
||||
* @param {Array<string>} breadcrumb - Breadcrumb path
|
||||
*/
|
||||
async function showCombosMenu(breadcrumb = []) {
|
||||
const { showListMenu } = require("../utils/menuHelper");
|
||||
|
||||
await showListMenu({
|
||||
title: "🔀 Combos Management",
|
||||
breadcrumb,
|
||||
fetchItems: async () => {
|
||||
const result = await api.getCombos();
|
||||
if (!result.success) {
|
||||
clearScreen();
|
||||
showStatus(`Failed to load combos: ${result.error}`, "error");
|
||||
await pause();
|
||||
return null;
|
||||
}
|
||||
return { items: result.data.combos || [] };
|
||||
},
|
||||
formatItem: (combo) => {
|
||||
const modelsChain = Array.isArray(combo.models) ? combo.models.map(formatModel).join(" → ") : "";
|
||||
const maxLen = 35;
|
||||
const displayModels = modelsChain.length > maxLen
|
||||
? modelsChain.substring(0, maxLen - 3) + "..."
|
||||
: modelsChain;
|
||||
return `${combo.name}: ${displayModels}`;
|
||||
},
|
||||
onSelect: async (combo) => {
|
||||
await showComboActions(combo, breadcrumb);
|
||||
},
|
||||
createAction: {
|
||||
label: "Create New Combo",
|
||||
action: async () => {
|
||||
await handleCreateCombo();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show combo detail with stats
|
||||
*/
|
||||
async function showComboDetail(comboId) {
|
||||
clearScreen();
|
||||
|
||||
const result = await api.getComboById(comboId);
|
||||
|
||||
if (!result.success) {
|
||||
showStatus(`Failed to load combo: ${result.error}`, "error");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
const combo = result.data;
|
||||
|
||||
console.log("┌─────────────────────────────────────────────────────────┐");
|
||||
console.log(`│ 🔀 Combo: ${combo.name.padEnd(46)} │`);
|
||||
console.log("├─────────────────────────────────────────────────────────┤");
|
||||
console.log("│ │");
|
||||
console.log(`│ ID: ${combo.id.padEnd(51)} │`);
|
||||
console.log(`│ Created: ${formatDate(combo.createdAt).padEnd(46)} │`);
|
||||
console.log(`│ Updated: ${formatDate(combo.updatedAt).padEnd(46)} │`);
|
||||
console.log("│ │");
|
||||
console.log("│ Model Chain: │");
|
||||
|
||||
// Models is array of strings like ["ag/claude-sonnet-4-5", "kr/claude-sonnet-4.5"]
|
||||
const models = Array.isArray(combo.models) ? combo.models : [];
|
||||
models.forEach((modelStr, index) => {
|
||||
const arrow = index < models.length - 1 ? " →" : " ";
|
||||
const displayText = `${index + 1}. ${modelStr}${arrow}`;
|
||||
const padding = Math.max(0, 54 - displayText.length);
|
||||
console.log(`│ ${displayText}${" ".repeat(padding)} │`);
|
||||
});
|
||||
|
||||
console.log("│ │");
|
||||
console.log("└─────────────────────────────────────────────────────────┘");
|
||||
|
||||
await pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format combo for menu display
|
||||
*/
|
||||
function formatComboLabel(combo) {
|
||||
const modelsChain = Array.isArray(combo.models) ? combo.models.map(formatModel).join(" → ") : "";
|
||||
const maxLen = 40;
|
||||
const displayModels = modelsChain.length > maxLen
|
||||
? modelsChain.substring(0, maxLen - 3) + "..."
|
||||
: modelsChain;
|
||||
return `${combo.name}: ${displayModels}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new combo
|
||||
*/
|
||||
async function handleCreateCombo() {
|
||||
clearScreen();
|
||||
|
||||
showStatus("Create New Combo", "info");
|
||||
console.log();
|
||||
|
||||
// Get combo name
|
||||
const name = await prompt("Combo name: ");
|
||||
if (!name) {
|
||||
showStatus("Combo name is required", "error");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch available models
|
||||
showStatus("Loading available models...", "info");
|
||||
const modelsResult = await api.getModels();
|
||||
|
||||
if (!modelsResult.success) {
|
||||
showStatus(`Failed to load models: ${modelsResult.error}`, "error");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
const availableModels = modelsResult.data.models || [];
|
||||
|
||||
if (availableModels.length === 0) {
|
||||
showStatus("No models available. Please add providers first.", "warning");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
// Select models for chain
|
||||
const selectedModels = [];
|
||||
|
||||
console.log();
|
||||
showStatus("Select models for the chain (minimum 2)", "info");
|
||||
|
||||
while (true) {
|
||||
clearScreen();
|
||||
console.log(`Creating combo: ${name}`);
|
||||
console.log(`Selected models (${selectedModels.length}):`);
|
||||
|
||||
if (selectedModels.length > 0) {
|
||||
selectedModels.forEach((m, i) => {
|
||||
console.log(` ${i + 1}. ${m.provider}/${m.model}`);
|
||||
});
|
||||
} else {
|
||||
console.log(" (none)");
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log("Available models:");
|
||||
availableModels.forEach((m, i) => {
|
||||
console.log(` ${i + 1}. ${m.provider}/${m.model}`);
|
||||
});
|
||||
|
||||
console.log();
|
||||
console.log("Actions:");
|
||||
console.log(" - Enter number to add model");
|
||||
console.log(" - Type 'done' to finish (min 2 models)");
|
||||
console.log(" - Type 'cancel' to abort");
|
||||
|
||||
const input = await prompt("\nAction: ");
|
||||
|
||||
if (input.toLowerCase() === "cancel") {
|
||||
showStatus("Cancelled", "warning");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.toLowerCase() === "done") {
|
||||
if (selectedModels.length < 2) {
|
||||
showStatus("Please select at least 2 models", "error");
|
||||
await pause();
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const num = parseInt(input, 10);
|
||||
if (isNaN(num) || num < 1 || num > availableModels.length) {
|
||||
showStatus("Invalid model number", "error");
|
||||
await pause();
|
||||
continue;
|
||||
}
|
||||
|
||||
selectedModels.push(availableModels[num - 1]);
|
||||
}
|
||||
|
||||
// Create combo
|
||||
showStatus("Creating combo...", "info");
|
||||
|
||||
const createResult = await api.createCombo({
|
||||
name,
|
||||
models: selectedModels
|
||||
});
|
||||
|
||||
if (!createResult.success) {
|
||||
showStatus(`Failed to create combo: ${createResult.error}`, "error");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
showStatus(`Combo "${name}" created successfully!`, "success");
|
||||
await pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit combo - select which combo to edit
|
||||
*/
|
||||
async function handleEditCombo(combos) {
|
||||
if (combos.length === 0) {
|
||||
showStatus("No combos available", "warning");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
let selectedCombo = null;
|
||||
|
||||
await showMenuWithBack({
|
||||
title: "✏️ Select Combo to Edit",
|
||||
items: combos.map(combo => ({
|
||||
label: formatComboLabel(combo),
|
||||
action: async () => {
|
||||
selectedCombo = combo;
|
||||
return false;
|
||||
}
|
||||
}))
|
||||
});
|
||||
|
||||
if (!selectedCombo) return;
|
||||
await editSingleCombo(selectedCombo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit a single combo
|
||||
*/
|
||||
async function editSingleCombo(combo) {
|
||||
clearScreen();
|
||||
showStatus(`Editing combo: ${combo.name}`, "info");
|
||||
console.log();
|
||||
|
||||
const newName = await prompt(`New name (current: ${combo.name}, press Enter to keep): `);
|
||||
const editModels = await confirm("Edit model chain?");
|
||||
|
||||
let newModels = combo.models;
|
||||
|
||||
if (editModels) {
|
||||
newModels = [];
|
||||
|
||||
while (true) {
|
||||
clearScreen();
|
||||
console.log(`Editing combo: ${combo.name}`);
|
||||
console.log(`Selected models (${newModels.length}):`);
|
||||
|
||||
if (newModels.length > 0) {
|
||||
newModels.forEach((m, i) => console.log(` ${i + 1}. ${m}`));
|
||||
} else {
|
||||
console.log(" (none)");
|
||||
}
|
||||
|
||||
console.log("\nType 'done' to finish (min 2 models) or 'cancel' to abort\n");
|
||||
|
||||
const model = await selectModelFromList("Add Model", "");
|
||||
|
||||
if (model === null) {
|
||||
showStatus("Cancelled", "warning");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
if (model === "done") {
|
||||
if (newModels.length < 2) {
|
||||
showStatus("Please select at least 2 models", "error");
|
||||
await pause();
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
newModels.push(model);
|
||||
showStatus(`Added: ${model}`, "success");
|
||||
await pause();
|
||||
}
|
||||
}
|
||||
|
||||
const updateData = {};
|
||||
if (newName) updateData.name = newName;
|
||||
if (editModels) updateData.models = newModels;
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
showStatus("No changes made", "warning");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
showStatus("Updating combo...", "info");
|
||||
|
||||
const updateResult = await api.updateCombo(combo.id, updateData);
|
||||
|
||||
if (!updateResult.success) {
|
||||
showStatus(`Failed to update combo: ${updateResult.error}`, "error");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
showStatus("Combo updated successfully!", "success");
|
||||
await pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete combo - select which combo to delete
|
||||
*/
|
||||
async function handleDeleteCombo(combos) {
|
||||
if (combos.length === 0) {
|
||||
showStatus("No combos available", "warning");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
let selectedCombo = null;
|
||||
|
||||
await showMenuWithBack({
|
||||
title: "🗑️ Select Combo to Delete",
|
||||
items: combos.map(combo => ({
|
||||
label: formatComboLabel(combo),
|
||||
action: async () => {
|
||||
selectedCombo = combo;
|
||||
return false;
|
||||
}
|
||||
}))
|
||||
});
|
||||
|
||||
if (!selectedCombo) return;
|
||||
|
||||
clearScreen();
|
||||
showStatus(`Combo: ${selectedCombo.name}`, "warning");
|
||||
const modelsDisplay = Array.isArray(selectedCombo.models)
|
||||
? selectedCombo.models.map(formatModel).join(" → ")
|
||||
: "";
|
||||
console.log(`Models: ${modelsDisplay}`);
|
||||
console.log();
|
||||
|
||||
const confirmed = await confirm("Are you sure you want to delete this combo?");
|
||||
|
||||
if (!confirmed) {
|
||||
showStatus("Cancelled", "info");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
showStatus("Deleting combo...", "info");
|
||||
|
||||
const deleteResult = await api.deleteCombo(selectedCombo.id);
|
||||
|
||||
if (!deleteResult.success) {
|
||||
showStatus(`Failed to delete combo: ${deleteResult.error}`, "error");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
showStatus("Combo deleted successfully!", "success");
|
||||
await pause();
|
||||
}
|
||||
|
||||
module.exports = { showCombosMenu };
|
||||
844
cli/src/cli/menus/providers.js
Normal file
844
cli/src/cli/menus/providers.js
Normal file
@@ -0,0 +1,844 @@
|
||||
const api = require("../api/client");
|
||||
const { prompt, confirm, pause } = require("../utils/input");
|
||||
const { clearScreen, showStatus, showHeader } = require("../utils/display");
|
||||
const { formatDate, getRelativeTime } = require("../utils/format");
|
||||
const { showMenuWithBack } = require("../utils/menuHelper");
|
||||
const { copyToClipboard } = require("../utils/clipboard");
|
||||
|
||||
// ANSI colors for styling
|
||||
const COLORS = {
|
||||
reset: "\x1b[0m",
|
||||
bold: "\x1b[1m",
|
||||
cyan: "\x1b[36m",
|
||||
dim: "\x1b[2m"
|
||||
};
|
||||
|
||||
// Provider models - static config (synced from open-sse/config/providerModels.js)
|
||||
const PROVIDER_MODELS = {
|
||||
cc: [
|
||||
{ id: "claude-opus-4-5-20251101" },
|
||||
{ id: "claude-sonnet-4-5-20250929" },
|
||||
{ id: "claude-haiku-4-5-20251001" },
|
||||
],
|
||||
cx: [
|
||||
{ id: "gpt-5.2-codex" },
|
||||
{ id: "gpt-5.2" },
|
||||
{ id: "gpt-5.1-codex-max" },
|
||||
{ id: "gpt-5.1-codex" },
|
||||
{ id: "gpt-5.1-codex-mini" },
|
||||
{ id: "gpt-5.1" },
|
||||
{ id: "gpt-5-codex" },
|
||||
{ id: "gpt-5-codex-mini" },
|
||||
],
|
||||
gc: [
|
||||
{ id: "gemini-3-flash-preview" },
|
||||
{ id: "gemini-3-pro-preview" },
|
||||
{ id: "gemini-2.5-pro" },
|
||||
{ id: "gemini-2.5-flash" },
|
||||
{ id: "gemini-2.5-flash-lite" },
|
||||
],
|
||||
qw: [
|
||||
{ id: "qwen3-coder-plus" },
|
||||
{ id: "qwen3-coder-flash" },
|
||||
{ id: "vision-model" },
|
||||
],
|
||||
if: [
|
||||
{ id: "qwen3-coder-plus" },
|
||||
{ id: "kimi-k2" },
|
||||
{ id: "kimi-k2-thinking" },
|
||||
{ id: "deepseek-r1" },
|
||||
{ id: "deepseek-v3.2-chat" },
|
||||
{ id: "deepseek-v3.2-reasoner" },
|
||||
{ id: "minimax-m2" },
|
||||
{ id: "glm-4.7" },
|
||||
],
|
||||
ag: [
|
||||
{ id: "gemini-3-pro-low" },
|
||||
{ id: "gemini-3-pro-high" },
|
||||
{ id: "gemini-3-flash" },
|
||||
{ id: "gemini-2.5-flash" },
|
||||
{ id: "claude-sonnet-4-5" },
|
||||
{ id: "claude-sonnet-4-5-thinking" },
|
||||
{ id: "claude-opus-4-5-thinking" },
|
||||
],
|
||||
gh: [
|
||||
{ id: "gpt-5" },
|
||||
{ id: "gpt-5-mini" },
|
||||
{ id: "gpt-5.1-codex" },
|
||||
{ id: "gpt-5.1-codex-max" },
|
||||
{ id: "gpt-4.1" },
|
||||
{ id: "claude-4.5-sonnet" },
|
||||
{ id: "claude-4.5-opus" },
|
||||
{ id: "claude-4.5-haiku" },
|
||||
{ id: "gemini-3-pro" },
|
||||
{ id: "gemini-3-flash" },
|
||||
{ id: "gemini-2.5-pro" },
|
||||
{ id: "grok-code-fast-1" },
|
||||
],
|
||||
kr: [
|
||||
{ id: "claude-sonnet-4.5" },
|
||||
{ id: "claude-haiku-4.5" },
|
||||
],
|
||||
openai: [
|
||||
{ id: "gpt-4o" },
|
||||
{ id: "gpt-4o-mini" },
|
||||
{ id: "gpt-4-turbo" },
|
||||
{ id: "o1" },
|
||||
{ id: "o1-mini" },
|
||||
],
|
||||
anthropic: [
|
||||
{ id: "claude-sonnet-4-20250514" },
|
||||
{ id: "claude-opus-4-20250514" },
|
||||
{ id: "claude-3-5-sonnet-20241022" },
|
||||
],
|
||||
gemini: [
|
||||
{ id: "gemini-3-pro-preview" },
|
||||
{ id: "gemini-2.5-pro" },
|
||||
{ id: "gemini-2.5-flash" },
|
||||
{ id: "gemini-2.5-flash-lite" },
|
||||
],
|
||||
openrouter: [
|
||||
{ id: "auto" },
|
||||
],
|
||||
glm: [
|
||||
{ id: "glm-4.7" },
|
||||
{ id: "glm-4.6v" },
|
||||
],
|
||||
kimi: [
|
||||
{ id: "kimi-latest" },
|
||||
],
|
||||
minimax: [
|
||||
{ id: "MiniMax-M2.1" },
|
||||
],
|
||||
};
|
||||
|
||||
// Provider definitions
|
||||
const OAUTH_PROVIDERS = {
|
||||
claude: { id: "claude", alias: "cc", name: "Claude Code" },
|
||||
codex: { id: "codex", alias: "cx", name: "OpenAI Codex" },
|
||||
"gemini-cli": { id: "gemini-cli", alias: "gc", name: "Gemini CLI" },
|
||||
github: { id: "github", alias: "gh", name: "GitHub Copilot" },
|
||||
antigravity: { id: "antigravity", alias: "ag", name: "Antigravity" },
|
||||
iflow: { id: "iflow", alias: "if", name: "iFlow AI" },
|
||||
qwen: { id: "qwen", alias: "qw", name: "Qwen Code" },
|
||||
kiro: { id: "kiro", alias: "kr", name: "Kiro AI" },
|
||||
};
|
||||
|
||||
const APIKEY_PROVIDERS = {
|
||||
openrouter: { id: "openrouter", name: "OpenRouter" },
|
||||
glm: { id: "glm", name: "GLM Coding" },
|
||||
minimax: { id: "minimax", name: "Minimax Coding" },
|
||||
kimi: { id: "kimi", name: "Kimi Coding" },
|
||||
openai: { id: "openai", name: "OpenAI" },
|
||||
anthropic: { id: "anthropic", name: "Anthropic" },
|
||||
gemini: { id: "gemini", name: "Gemini" },
|
||||
};
|
||||
|
||||
const ALL_PROVIDERS = { ...OAUTH_PROVIDERS, ...APIKEY_PROVIDERS };
|
||||
|
||||
/**
|
||||
* Get auth type for provider
|
||||
* @param {string} providerId - Provider ID
|
||||
* @returns {string} "oauth" or "apikey"
|
||||
*/
|
||||
function getAuthType(providerId) {
|
||||
return OAUTH_PROVIDERS[providerId] ? "oauth" : "apikey";
|
||||
}
|
||||
|
||||
/**
|
||||
* Count connections by provider
|
||||
* @param {Array} connections - Array of connection objects
|
||||
* @returns {Object} Map of providerId -> count
|
||||
*/
|
||||
function countConnectionsByProvider(connections) {
|
||||
const counts = {};
|
||||
connections.forEach(conn => {
|
||||
const providerId = conn.provider || conn.providerId;
|
||||
counts[providerId] = (counts[providerId] || 0) + 1;
|
||||
});
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show main providers menu
|
||||
* @param {Array<string>} breadcrumb - Breadcrumb path
|
||||
*/
|
||||
async function showProvidersMenu(breadcrumb = []) {
|
||||
// Build provider items list
|
||||
const providerItems = [];
|
||||
|
||||
Object.values(OAUTH_PROVIDERS).forEach(provider => {
|
||||
providerItems.push({
|
||||
provider,
|
||||
authType: "oauth",
|
||||
label: (data) => {
|
||||
const count = data.counts[provider.id] || 0;
|
||||
return `${provider.name} (OAuth) - ${count} Connected`;
|
||||
},
|
||||
action: async (data) => {
|
||||
await showProviderDetail(provider.id, "oauth", data.connections, [...breadcrumb, provider.name]);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Object.values(APIKEY_PROVIDERS).forEach(provider => {
|
||||
providerItems.push({
|
||||
provider,
|
||||
authType: "apikey",
|
||||
label: (data) => {
|
||||
const count = data.counts[provider.id] || 0;
|
||||
return `${provider.name} (API) - ${count} Connected`;
|
||||
},
|
||||
action: async (data) => {
|
||||
await showProviderDetail(provider.id, "apikey", data.connections, [...breadcrumb, provider.name]);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Custom provider nodes section
|
||||
providerItems.push({
|
||||
label: () => `${COLORS.dim}── Custom Providers ──${COLORS.reset}`,
|
||||
action: async () => true, // separator, no-op
|
||||
isSeparator: true,
|
||||
});
|
||||
providerItems.push({
|
||||
label: (data) => {
|
||||
const count = data.nodeCount || 0;
|
||||
return `Custom Providers - ${count} Configured`;
|
||||
},
|
||||
action: async () => {
|
||||
await showCustomProvidersMenu([...breadcrumb, "Custom Providers"]);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
await showMenuWithBack({
|
||||
title: "🔌 Providers Management",
|
||||
breadcrumb,
|
||||
refresh: async () => {
|
||||
const [provRes, nodeRes] = await Promise.all([api.getProviders(), api.getProviderNodes()]);
|
||||
if (!provRes.success) {
|
||||
showStatus(`Failed to fetch providers: ${provRes.error}`, "error");
|
||||
await pause();
|
||||
return null;
|
||||
}
|
||||
const connections = provRes.data.connections || [];
|
||||
const nodes = nodeRes.success ? (nodeRes.data.nodes || nodeRes.data || []) : [];
|
||||
return {
|
||||
connections,
|
||||
counts: countConnectionsByProvider(connections),
|
||||
nodeCount: nodes.length,
|
||||
};
|
||||
},
|
||||
items: providerItems
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build provider header with alias and models
|
||||
* @param {string} providerId - Provider ID
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildProviderHeader(providerId) {
|
||||
const provider = ALL_PROVIDERS[providerId];
|
||||
const alias = provider.alias || providerId;
|
||||
|
||||
const lines = [];
|
||||
lines.push(`Alias: ${COLORS.cyan}${alias}${COLORS.reset}`);
|
||||
|
||||
// Get models from static config
|
||||
const models = PROVIDER_MODELS[alias] || [];
|
||||
if (models.length > 0) {
|
||||
const modelList = models
|
||||
.slice(0, 5)
|
||||
.map(m => `${alias}/${m.id}`)
|
||||
.join(", ");
|
||||
const more = models.length > 5 ? ` (+${models.length - 5} more)` : "";
|
||||
lines.push(`Models: ${COLORS.dim}${modelList}${more}${COLORS.reset}`);
|
||||
} else {
|
||||
lines.push(`Models: ${COLORS.dim}No models configured${COLORS.reset}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Show provider detail with connections and actions
|
||||
* @param {string} providerId - Provider ID
|
||||
* @param {string} authType - "oauth" or "apikey"
|
||||
* @param {Array} allConnections - All connections
|
||||
* @param {Array<string>} breadcrumb - Breadcrumb path
|
||||
*/
|
||||
async function showProviderDetail(providerId, authType, allConnections, breadcrumb = []) {
|
||||
const provider = ALL_PROVIDERS[providerId];
|
||||
const { showListMenu } = require("../utils/menuHelper");
|
||||
|
||||
await showListMenu({
|
||||
title: `🔌 ${provider.name} (${authType.toUpperCase()})`,
|
||||
breadcrumb,
|
||||
backLabel: "← Back to Providers",
|
||||
headerContent: buildProviderHeader(providerId),
|
||||
fetchItems: async () => {
|
||||
const response = await api.getProviders();
|
||||
if (response.success) {
|
||||
allConnections.length = 0;
|
||||
allConnections.push(...(response.data.connections || []));
|
||||
}
|
||||
const providerConns = allConnections.filter(conn =>
|
||||
(conn.provider || conn.providerId) === providerId
|
||||
);
|
||||
return { items: providerConns };
|
||||
},
|
||||
formatItem: (conn) => {
|
||||
const status = conn.testStatus === "active" ? "✓" : conn.testStatus === "error" ? "✗" : "?";
|
||||
const name = conn.name || conn.email || conn.displayName || "Unnamed";
|
||||
return `${name} (${status})`;
|
||||
},
|
||||
onSelect: async (conn) => {
|
||||
await showConnectionActions(conn, providerId, breadcrumb);
|
||||
},
|
||||
createAction: {
|
||||
label: "Add New Connection",
|
||||
action: async () => {
|
||||
await handleAddConnection(providerId, authType);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show actions for a specific connection
|
||||
* @param {Object} connection - Connection object
|
||||
* @param {string} providerId - Provider ID
|
||||
* @param {Array<string>} breadcrumb - Breadcrumb path
|
||||
*/
|
||||
async function showConnectionActions(connection, providerId, breadcrumb = []) {
|
||||
const name = connection.name || connection.email || connection.displayName || "Unnamed";
|
||||
const status = connection.testStatus === "active" ? "✓ Active" :
|
||||
connection.testStatus === "error" ? "✗ Error" : "? Unknown";
|
||||
|
||||
await showMenuWithBack({
|
||||
title: `🔌 ${name}`,
|
||||
breadcrumb: [...breadcrumb, name],
|
||||
headerContent: `Connection: ${name}\nStatus: ${status}`,
|
||||
items: [
|
||||
{
|
||||
label: "Rename Connection",
|
||||
action: async () => {
|
||||
const newName = await prompt(`New name (current: ${name}): `);
|
||||
if (newName && newName.trim()) {
|
||||
showStatus("Renaming connection...", "info");
|
||||
const result = await api.updateConnection(connection.id, { name: newName.trim() });
|
||||
if (result.success) {
|
||||
showStatus("Connection renamed!", "success");
|
||||
connection.name = newName.trim();
|
||||
} else {
|
||||
showStatus(`Rename failed: ${result.error}`, "error");
|
||||
}
|
||||
await pause();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "Test Connection",
|
||||
action: async () => {
|
||||
showStatus("Testing connection...", "info");
|
||||
const result = await api.testConnection(connection.id);
|
||||
if (result.success) {
|
||||
showStatus("Connection is working!", "success");
|
||||
} else {
|
||||
showStatus(`Test failed: ${result.error}`, "error");
|
||||
}
|
||||
await pause();
|
||||
return true;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "Delete Connection",
|
||||
action: async () => {
|
||||
const confirmed = await confirm(`Delete connection "${name}"?`);
|
||||
if (confirmed) {
|
||||
const result = await api.deleteConnection(connection.id);
|
||||
if (result.success) {
|
||||
showStatus("Connection deleted!", "success");
|
||||
} else {
|
||||
showStatus(`Delete failed: ${result.error}`, "error");
|
||||
}
|
||||
await pause();
|
||||
return false; // Exit menu after delete
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle adding new connection
|
||||
* @param {string} providerId - Provider ID
|
||||
* @param {string} authType - "oauth" or "apikey"
|
||||
*/
|
||||
// Providers that use Device Code Flow (terminal-based polling)
|
||||
const DEVICE_CODE_PROVIDERS = ["github", "qwen", "kiro"];
|
||||
|
||||
/**
|
||||
* Handle adding new connection - auto-detect flow type
|
||||
* @param {string} providerId - Provider ID
|
||||
* @param {string} authType - "oauth" or "apikey"
|
||||
*/
|
||||
async function handleAddConnection(providerId, authType) {
|
||||
if (authType === "apikey") {
|
||||
await handleAddApiKeyConnection(providerId);
|
||||
} else {
|
||||
// OAuth: auto-detect flow type based on provider
|
||||
if (DEVICE_CODE_PROVIDERS.includes(providerId)) {
|
||||
// Device Code Flow for GitHub, Qwen, Kiro
|
||||
await handleAddDeviceCodeConnection(providerId);
|
||||
} else {
|
||||
// Authorization Code Flow for Claude, Codex, Gemini, etc.
|
||||
await handleAddOAuthConnection(providerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle adding API Key connection
|
||||
* @param {string} providerId - Provider ID
|
||||
*/
|
||||
async function handleAddApiKeyConnection(providerId) {
|
||||
clearScreen();
|
||||
const provider = ALL_PROVIDERS[providerId];
|
||||
console.log(`\n➕ Add ${provider.name} API Key Connection\n`);
|
||||
|
||||
const name = await prompt("Connection Name: ");
|
||||
if (!name) {
|
||||
showStatus("Cancelled", "warning");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey = await prompt("API Key: ");
|
||||
if (!apiKey) {
|
||||
showStatus("Cancelled", "warning");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
showStatus("Creating connection...", "info");
|
||||
|
||||
const result = await api.createApiKeyProvider({
|
||||
provider: providerId,
|
||||
name,
|
||||
apiKey
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
showStatus("✓ Connection created successfully!", "success");
|
||||
} else {
|
||||
showStatus(`✗ Failed: ${result.error}`, "error");
|
||||
}
|
||||
|
||||
await pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle adding OAuth Authorization Code connection
|
||||
* User opens URL manually and pastes callback URL
|
||||
* @param {string} providerId - Provider ID
|
||||
*/
|
||||
async function handleAddOAuthConnection(providerId) {
|
||||
clearScreen();
|
||||
const provider = ALL_PROVIDERS[providerId];
|
||||
|
||||
// Step 1: Get auth URL
|
||||
showStatus("Requesting authorization URL...", "info");
|
||||
const authResult = await api.getOAuthAuthUrl(providerId);
|
||||
|
||||
if (!authResult.success) {
|
||||
showStatus(`Failed: ${authResult.error}`, "error");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
const authData = authResult.data || authResult;
|
||||
const authUrl = authData.authUrl;
|
||||
const codeVerifier = authData.codeVerifier;
|
||||
const state = authData.state;
|
||||
const redirectUri = authData.redirectUri;
|
||||
|
||||
if (!authUrl) {
|
||||
showStatus("Failed: No auth URL received", "error");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Show URL and instructions
|
||||
clearScreen();
|
||||
showHeader("🔐 OAuth Login", `Providers > ${provider.name} > Add Connection`);
|
||||
|
||||
console.log(` ${COLORS.bold}${COLORS.cyan}1.${COLORS.reset} Open this URL in your browser:`);
|
||||
console.log(` ${COLORS.dim}${authUrl}${COLORS.reset}`);
|
||||
if (copyToClipboard(authUrl)) {
|
||||
console.log(` \x1b[32m✓ Link copied to clipboard!\x1b[0m`);
|
||||
}
|
||||
console.log();
|
||||
console.log(` ${COLORS.bold}${COLORS.cyan}2.${COLORS.reset} Complete authorization in browser`);
|
||||
console.log();
|
||||
console.log(` ${COLORS.bold}${COLORS.cyan}3.${COLORS.reset} Copy the callback URL from address bar`);
|
||||
console.log(` ${COLORS.dim}(looks like: http://localhost:20128/callback?code=...)${COLORS.reset}`);
|
||||
console.log();
|
||||
|
||||
const callbackUrl = await prompt(" Paste callback URL: ");
|
||||
if (!callbackUrl) {
|
||||
showStatus("Cancelled", "warning");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 3: Parse callback URL and extract code
|
||||
let code, urlState, error;
|
||||
try {
|
||||
const url = new URL(callbackUrl.trim());
|
||||
code = url.searchParams.get("code");
|
||||
urlState = url.searchParams.get("state");
|
||||
error = url.searchParams.get("error");
|
||||
|
||||
if (error) {
|
||||
const errorDesc = url.searchParams.get("error_description") || error;
|
||||
showStatus(`Authorization failed: ${errorDesc}`, "error");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
showStatus("No authorization code found in URL", "error");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
showStatus("Invalid URL format", "error");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 4: Exchange code for tokens
|
||||
console.log();
|
||||
showStatus("Exchanging code for tokens...", "info");
|
||||
const exchangeResult = await api.exchangeOAuthCode(providerId, {
|
||||
code,
|
||||
redirectUri,
|
||||
codeVerifier,
|
||||
state: urlState || state
|
||||
});
|
||||
|
||||
if (exchangeResult.success) {
|
||||
showStatus("Connection created successfully!", "success");
|
||||
} else {
|
||||
showStatus(`Failed: ${exchangeResult.error}`, "error");
|
||||
}
|
||||
|
||||
await pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle adding OAuth Device Code connection
|
||||
* @param {string} providerId - Provider ID
|
||||
*/
|
||||
async function handleAddDeviceCodeConnection(providerId) {
|
||||
clearScreen();
|
||||
const provider = ALL_PROVIDERS[providerId];
|
||||
|
||||
// Step 1: Request device code
|
||||
showStatus("Requesting device code...", "info");
|
||||
const deviceResult = await api.getOAuthDeviceCode(providerId);
|
||||
|
||||
if (!deviceResult.success) {
|
||||
showStatus(`Failed: ${deviceResult.error}`, "error");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceData = deviceResult.data || deviceResult;
|
||||
const device_code = deviceData.device_code;
|
||||
const user_code = deviceData.user_code;
|
||||
const verification_uri = deviceData.verification_uri;
|
||||
const verification_uri_complete = deviceData.verification_uri_complete;
|
||||
const codeVerifier = deviceData.codeVerifier;
|
||||
const extraData = deviceData.extraData || deviceData;
|
||||
|
||||
if (!device_code) {
|
||||
showStatus("Failed: No device code received", "error");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Show instructions
|
||||
clearScreen();
|
||||
const deviceUrl = verification_uri_complete || verification_uri;
|
||||
showHeader("📱 Device Login", `Providers > ${provider.name} > Add Connection`);
|
||||
|
||||
console.log(` ${COLORS.bold}${COLORS.cyan}1.${COLORS.reset} Open: ${COLORS.dim}${deviceUrl}${COLORS.reset}`);
|
||||
if (copyToClipboard(deviceUrl)) {
|
||||
console.log(` \x1b[32m✓ Link copied to clipboard!\x1b[0m`);
|
||||
}
|
||||
console.log();
|
||||
if (!verification_uri_complete && user_code) {
|
||||
console.log(` ${COLORS.bold}${COLORS.cyan}2.${COLORS.reset} Enter code: ${COLORS.bold}${user_code}${COLORS.reset}`);
|
||||
console.log();
|
||||
}
|
||||
console.log(` ${COLORS.dim}Waiting for authorization...${COLORS.reset}`);
|
||||
console.log();
|
||||
|
||||
// Step 3: Poll for token
|
||||
const maxAttempts = 60; // 5 minutes (5s interval)
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
|
||||
const pollResult = await api.pollOAuthToken(providerId, {
|
||||
deviceCode: device_code,
|
||||
codeVerifier,
|
||||
extraData
|
||||
});
|
||||
|
||||
if (pollResult.success) {
|
||||
showStatus("\nConnection created successfully!", "success");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if still pending (pending flag is at root level, not in data)
|
||||
const isPending = pollResult.pending || pollResult.error === "authorization_pending" || pollResult.error === "slow_down";
|
||||
if (!isPending) {
|
||||
showStatus(`\nFailed: ${pollResult.error || "Unknown error"}`, "error");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(".");
|
||||
}
|
||||
|
||||
showStatus("\nTimeout waiting for authorization", "error");
|
||||
await pause();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CUSTOM PROVIDERS (provider nodes)
|
||||
// ============================================================================
|
||||
|
||||
const CUSTOM_NODE_TYPES = ["openai-compatible", "anthropic-compatible"];
|
||||
const OPENAI_API_TYPES = ["chat", "responses"];
|
||||
|
||||
/**
|
||||
* Show custom providers section in main providers menu
|
||||
* @param {Array} nodes - List of provider nodes
|
||||
* @param {Array} connections - All connections
|
||||
* @param {Array<string>} breadcrumb
|
||||
*/
|
||||
async function showCustomProvidersMenu(breadcrumb = []) {
|
||||
const { showListMenu } = require("../utils/menuHelper");
|
||||
|
||||
await showListMenu({
|
||||
title: "🔧 Custom Providers",
|
||||
breadcrumb,
|
||||
backLabel: "← Back to Providers",
|
||||
fetchItems: async () => {
|
||||
const res = await api.getProviderNodes();
|
||||
if (!res.success) return { items: [] };
|
||||
return { items: res.data.nodes || res.data || [] };
|
||||
},
|
||||
formatItem: (node) => `[${node.prefix}] ${node.name} (${node.type})`,
|
||||
onSelect: async (node) => {
|
||||
await showCustomNodeDetail(node, [...breadcrumb, node.name]);
|
||||
},
|
||||
createAction: {
|
||||
label: "➕ Add Custom Provider",
|
||||
action: async () => {
|
||||
await handleAddCustomNode();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show detail menu for a custom provider node
|
||||
*/
|
||||
async function showCustomNodeDetail(node, breadcrumb = []) {
|
||||
await showMenuWithBack({
|
||||
title: `🔧 ${node.name}`,
|
||||
breadcrumb,
|
||||
headerContent: [
|
||||
`Type: ${node.type}`,
|
||||
`Prefix: ${COLORS.cyan}${node.prefix}${COLORS.reset}`,
|
||||
`Base URL: ${COLORS.dim}${node.baseUrl}${COLORS.reset}`,
|
||||
].join("\n"),
|
||||
items: [
|
||||
{
|
||||
label: "Connections",
|
||||
action: async () => {
|
||||
await showCustomNodeConnections(node, breadcrumb);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "Edit Node",
|
||||
action: async () => {
|
||||
await handleEditCustomNode(node);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "Delete Node",
|
||||
action: async () => {
|
||||
const confirmed = await confirm(`Delete "${node.name}" and all its connections?`);
|
||||
if (confirmed) {
|
||||
const res = await api.deleteProviderNode(node.id);
|
||||
if (res.success) {
|
||||
showStatus("Node deleted!", "success");
|
||||
} else {
|
||||
showStatus(`Delete failed: ${res.error}`, "error");
|
||||
}
|
||||
await pause();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show connections for a custom provider node
|
||||
*/
|
||||
async function showCustomNodeConnections(node, breadcrumb = []) {
|
||||
const { showListMenu } = require("../utils/menuHelper");
|
||||
|
||||
await showListMenu({
|
||||
title: `🔌 ${node.name} – Connections`,
|
||||
breadcrumb,
|
||||
backLabel: "← Back",
|
||||
fetchItems: async () => {
|
||||
const res = await api.getProviders();
|
||||
if (!res.success) return { items: [] };
|
||||
const all = res.data.connections || [];
|
||||
const items = all.filter(c => c.provider === node.id);
|
||||
return { items };
|
||||
},
|
||||
formatItem: (conn) => {
|
||||
const status = conn.testStatus === "active" ? "✓" : conn.testStatus === "error" ? "✗" : "?";
|
||||
return `${conn.name || "Unnamed"} (${status})`;
|
||||
},
|
||||
onSelect: async (conn) => {
|
||||
await showConnectionActions(conn, node.id, breadcrumb);
|
||||
},
|
||||
createAction: {
|
||||
label: "Add API Key Connection",
|
||||
action: async () => {
|
||||
await handleAddCustomNodeConnection(node);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add API key connection to a custom provider node
|
||||
*/
|
||||
async function handleAddCustomNodeConnection(node) {
|
||||
clearScreen();
|
||||
console.log(`\n➕ Add Connection to ${node.name}\n`);
|
||||
|
||||
const name = await prompt("Connection Name: ");
|
||||
if (!name) { showStatus("Cancelled", "warning"); await pause(); return; }
|
||||
|
||||
const apiKey = await prompt("API Key: ");
|
||||
if (!apiKey) { showStatus("Cancelled", "warning"); await pause(); return; }
|
||||
|
||||
showStatus("Creating connection...", "info");
|
||||
const res = await api.createApiKeyProvider({ provider: node.id, name, apiKey });
|
||||
|
||||
showStatus(res.success ? "✓ Connection created!" : `✗ Failed: ${res.error}`, res.success ? "success" : "error");
|
||||
await pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle adding a new custom provider node
|
||||
*/
|
||||
async function handleAddCustomNode() {
|
||||
clearScreen();
|
||||
console.log("\n➕ Add Custom Provider\n");
|
||||
|
||||
// Step 1: Select type
|
||||
const typeChoices = CUSTOM_NODE_TYPES.map((t, i) => ` ${i + 1}. ${t}`).join("\n");
|
||||
console.log(`Select type:\n${typeChoices}\n`);
|
||||
const typeInput = await prompt("Type (1/2): ");
|
||||
const typeIdx = parseInt(typeInput) - 1;
|
||||
if (isNaN(typeIdx) || !CUSTOM_NODE_TYPES[typeIdx]) {
|
||||
showStatus("Cancelled", "warning"); await pause(); return;
|
||||
}
|
||||
const type = CUSTOM_NODE_TYPES[typeIdx];
|
||||
|
||||
// Step 2: Inputs
|
||||
const name = await prompt("Name: ");
|
||||
if (!name) { showStatus("Cancelled", "warning"); await pause(); return; }
|
||||
|
||||
const prefix = await prompt("Prefix (used in model IDs, e.g. myapi): ");
|
||||
if (!prefix) { showStatus("Cancelled", "warning"); await pause(); return; }
|
||||
|
||||
const baseUrl = await prompt("Base URL (e.g. https://api.example.com/v1): ");
|
||||
if (!baseUrl) { showStatus("Cancelled", "warning"); await pause(); return; }
|
||||
|
||||
// Step 3: API type (OpenAI only)
|
||||
let apiType;
|
||||
if (type === "openai-compatible") {
|
||||
const apiTypeChoices = OPENAI_API_TYPES.map((t, i) => ` ${i + 1}. ${t}`).join("\n");
|
||||
console.log(`\nAPI Type:\n${apiTypeChoices}\n`);
|
||||
const apiTypeInput = await prompt("API Type (1/2, default 1): ");
|
||||
const apiTypeIdx = parseInt(apiTypeInput) - 1;
|
||||
apiType = OPENAI_API_TYPES[apiTypeIdx] || "chat";
|
||||
}
|
||||
|
||||
showStatus("Creating provider node...", "info");
|
||||
const body = { name, prefix, baseUrl, type, ...(apiType && { apiType }) };
|
||||
const res = await api.createProviderNode(body);
|
||||
|
||||
showStatus(res.success ? "✓ Provider created!" : `✗ Failed: ${res.error}`, res.success ? "success" : "error");
|
||||
await pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle editing a custom provider node
|
||||
*/
|
||||
async function handleEditCustomNode(node) {
|
||||
clearScreen();
|
||||
console.log(`\n✏️ Edit ${node.name}\n`);
|
||||
console.log(`${COLORS.dim}Leave blank to keep current value${COLORS.reset}\n`);
|
||||
|
||||
const name = await prompt(`Name (${node.name}): `);
|
||||
const baseUrl = await prompt(`Base URL (${node.baseUrl}): `);
|
||||
const prefix = await prompt(`Prefix (${node.prefix}): `);
|
||||
|
||||
const updates = {};
|
||||
if (name && name.trim()) updates.name = name.trim();
|
||||
if (baseUrl && baseUrl.trim()) updates.baseUrl = baseUrl.trim();
|
||||
if (prefix && prefix.trim()) updates.prefix = prefix.trim();
|
||||
|
||||
if (!Object.keys(updates).length) {
|
||||
showStatus("No changes", "warning"); await pause(); return;
|
||||
}
|
||||
|
||||
showStatus("Updating...", "info");
|
||||
const res = await api.updateProviderNode(node.id, updates);
|
||||
if (res.success) {
|
||||
Object.assign(node, updates);
|
||||
showStatus("✓ Updated!", "success");
|
||||
} else {
|
||||
showStatus(`✗ Failed: ${res.error}`, "error");
|
||||
}
|
||||
await pause();
|
||||
}
|
||||
|
||||
module.exports = { showProvidersMenu };
|
||||
207
cli/src/cli/menus/settings.js
Normal file
207
cli/src/cli/menus/settings.js
Normal file
@@ -0,0 +1,207 @@
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const api = require("../api/client");
|
||||
const { confirm, pause } = require("../utils/input");
|
||||
const { showStatus } = require("../utils/display");
|
||||
const { showMenuWithBack } = require("../utils/menuHelper");
|
||||
|
||||
// ANSI colors
|
||||
const COLORS = {
|
||||
reset: "\x1b[0m",
|
||||
green: "\x1b[32m",
|
||||
red: "\x1b[31m",
|
||||
yellow: "\x1b[33m",
|
||||
dim: "\x1b[2m",
|
||||
cyan: "\x1b[36m"
|
||||
};
|
||||
|
||||
const DEFAULT_PASSWORD = "123456";
|
||||
|
||||
// Resolve db.json path (matches app/src/lib/dataDir.js convention)
|
||||
function getDbPath() {
|
||||
return process.platform === "win32"
|
||||
? path.join(process.env.APPDATA || "", "9router", "db.json")
|
||||
: path.join(os.homedir(), ".9router", "db.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Show settings menu (tunnel + RTK + reset password)
|
||||
* @param {Array<string>} breadcrumb - Breadcrumb path
|
||||
*/
|
||||
async function showSettingsMenu(breadcrumb = []) {
|
||||
await showMenuWithBack({
|
||||
title: "⚙️ Settings",
|
||||
breadcrumb,
|
||||
headerContent: async (data) => {
|
||||
const lines = [];
|
||||
|
||||
// Tunnel section
|
||||
const tunnel = data?.tunnel || {};
|
||||
if (tunnel.enabled && tunnel.publicUrl) {
|
||||
lines.push(` Endpoint: ${COLORS.green}${tunnel.publicUrl}/v1${COLORS.reset}`);
|
||||
lines.push(` Tunnel: ${COLORS.green}ON${COLORS.reset} ${COLORS.dim}(${tunnel.shortId})${COLORS.reset}`);
|
||||
} else {
|
||||
lines.push(` Endpoint: http://localhost:20128/v1`);
|
||||
lines.push(` Tunnel: ${COLORS.red}OFF${COLORS.reset} ${COLORS.dim}(local only)${COLORS.reset}`);
|
||||
}
|
||||
|
||||
// RTK section
|
||||
const rtkOn = data?.settings?.rtkEnabled !== false;
|
||||
lines.push(` RTK: ${rtkOn ? `${COLORS.green}ON${COLORS.reset}` : `${COLORS.red}OFF${COLORS.reset}`} ${COLORS.dim}(Token Saver)${COLORS.reset}`);
|
||||
|
||||
// Auth mode section
|
||||
const authMode = data?.settings?.authMode || "password";
|
||||
const authColor = authMode === "password" ? COLORS.green : COLORS.yellow;
|
||||
lines.push(` Auth: ${authColor}${authMode.toUpperCase()}${COLORS.reset} ${COLORS.dim}(login mode)${COLORS.reset}`);
|
||||
|
||||
return lines.join("\n");
|
||||
},
|
||||
refresh: async () => {
|
||||
const [tunnelRes, settingsRes] = await Promise.all([
|
||||
api.getTunnelStatus(),
|
||||
api.getSettings()
|
||||
]);
|
||||
return {
|
||||
tunnel: tunnelRes.success ? (tunnelRes.data || {}) : {},
|
||||
settings: settingsRes.success ? (settingsRes.data || {}) : {}
|
||||
};
|
||||
},
|
||||
items: [
|
||||
{
|
||||
label: "Tunnel ON",
|
||||
action: async () => { await enableTunnel(); return true; }
|
||||
},
|
||||
{
|
||||
label: "Tunnel OFF",
|
||||
action: async () => { await disableTunnel(); return true; }
|
||||
},
|
||||
{
|
||||
label: (d) => {
|
||||
const on = d?.settings?.rtkEnabled !== false;
|
||||
return `Token Saver (RTK): ${on ? "ON" : "OFF"} → toggle`;
|
||||
},
|
||||
action: async (d) => { await toggleRtk(d?.settings?.rtkEnabled !== false); return true; }
|
||||
},
|
||||
{
|
||||
label: "🔑 Reset Password to Default",
|
||||
action: async () => { await resetPassword(); return true; }
|
||||
},
|
||||
{
|
||||
label: (d) => {
|
||||
const mode = d?.settings?.authMode || "password";
|
||||
return mode === "password" ? "🔓 Reset Auth Mode (already password)" : `🔓 Reset Auth Mode to Password (current: ${mode})`;
|
||||
},
|
||||
action: async () => { await resetAuthMode(); return true; }
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset authMode to "password" via API. Used when OIDC is misconfigured
|
||||
* and user is locked out of dashboard. CLI bypasses auth via x-9r-cli-token.
|
||||
*/
|
||||
async function resetAuthMode() {
|
||||
const ok = await confirm("Reset auth mode to PASSWORD (disable OIDC)?");
|
||||
if (!ok) {
|
||||
showStatus("Cancelled", "info");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.updateSettings({ authMode: "password" });
|
||||
if (result.success) {
|
||||
showStatus("Auth mode reset to password. OIDC disabled.", "success");
|
||||
} else {
|
||||
showStatus(`Failed: ${result.error}`, "error");
|
||||
}
|
||||
await pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable tunnel via API
|
||||
*/
|
||||
async function enableTunnel() {
|
||||
showStatus("Creating tunnel...", "info");
|
||||
const result = await api.enableTunnel();
|
||||
|
||||
if (result.success) {
|
||||
const { publicUrl, shortId, alreadyRunning } = result.data || {};
|
||||
if (alreadyRunning) {
|
||||
showStatus(`Tunnel already running: ${publicUrl}`, "success");
|
||||
} else {
|
||||
showStatus(`Tunnel enabled: ${publicUrl} (${shortId})`, "success");
|
||||
}
|
||||
} else {
|
||||
showStatus(`Failed: ${result.error}`, "error");
|
||||
}
|
||||
|
||||
await pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable tunnel via API
|
||||
*/
|
||||
async function disableTunnel() {
|
||||
const result = await api.disableTunnel();
|
||||
|
||||
if (result.success) {
|
||||
showStatus("Tunnel disabled", "success");
|
||||
} else {
|
||||
showStatus(`Failed: ${result.error}`, "error");
|
||||
}
|
||||
|
||||
await pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle RTK (Token Saver) via API
|
||||
* @param {boolean} currentlyOn
|
||||
*/
|
||||
async function toggleRtk(currentlyOn) {
|
||||
const next = !currentlyOn;
|
||||
const result = await api.updateSettings({ rtkEnabled: next });
|
||||
if (result.success) {
|
||||
showStatus(`Token Saver ${next ? "enabled" : "disabled"}`, "success");
|
||||
} else {
|
||||
showStatus(`Failed: ${result.error}`, "error");
|
||||
}
|
||||
await pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset dashboard password by clearing the hash in db.json (Phase B).
|
||||
* After reset, user can log in with the default password "123456".
|
||||
*/
|
||||
async function resetPassword() {
|
||||
const dbPath = getDbPath();
|
||||
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
showStatus(`db.json not found at ${dbPath}`, "error");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
const ok = await confirm(`Reset dashboard password to default "${DEFAULT_PASSWORD}"?`);
|
||||
if (!ok) {
|
||||
showStatus("Cancelled", "info");
|
||||
await pause();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(dbPath, "utf-8");
|
||||
const db = JSON.parse(raw);
|
||||
if (db.settings && Object.prototype.hasOwnProperty.call(db.settings, "password")) {
|
||||
delete db.settings.password;
|
||||
}
|
||||
fs.writeFileSync(dbPath, JSON.stringify(db, null, 2));
|
||||
showStatus(`Password reset. Default: ${DEFAULT_PASSWORD}`, "success");
|
||||
} catch (err) {
|
||||
showStatus(`Failed to reset password: ${err.message}`, "error");
|
||||
}
|
||||
await pause();
|
||||
}
|
||||
|
||||
module.exports = { showSettingsMenu };
|
||||
109
cli/src/cli/terminalUI.js
Normal file
109
cli/src/cli/terminalUI.js
Normal file
@@ -0,0 +1,109 @@
|
||||
const api = require("./api/client");
|
||||
const { showMenuWithBack } = require("./utils/menuHelper");
|
||||
const { showProvidersMenu } = require("./menus/providers");
|
||||
const { showApiKeysMenu } = require("./menus/apiKeys");
|
||||
const { showCombosMenu } = require("./menus/combos");
|
||||
const { showSettingsMenu } = require("./menus/settings");
|
||||
const { showCliToolsMenu } = require("./menus/cliTools");
|
||||
|
||||
const COLORS = {
|
||||
reset: "\x1b[0m",
|
||||
green: "\x1b[32m",
|
||||
red: "\x1b[31m",
|
||||
dim: "\x1b[2m",
|
||||
cyan: "\x1b[36m"
|
||||
};
|
||||
|
||||
/**
|
||||
* Build header content with endpoint and API keys
|
||||
* @param {number} port - Server port
|
||||
* @returns {Promise<string>} Header content string
|
||||
*/
|
||||
async function buildHeaderContent(port) {
|
||||
const [keysResult, tunnelResult] = await Promise.all([
|
||||
api.getApiKeys(),
|
||||
api.getTunnelStatus()
|
||||
]);
|
||||
|
||||
const keys = keysResult.success ? (keysResult.data.keys || []) : [];
|
||||
const tunnel = tunnelResult.success ? (tunnelResult.data || {}) : {};
|
||||
const tunnelEnabled = tunnel.enabled === true;
|
||||
|
||||
const lines = [];
|
||||
|
||||
if (tunnelEnabled && tunnel.publicUrl) {
|
||||
lines.push(`Endpoint: ${COLORS.green}${tunnel.publicUrl}/v1${COLORS.reset}`);
|
||||
lines.push(`Tunnel: ${COLORS.green}ON${COLORS.reset} ${COLORS.dim}(${tunnel.shortId})${COLORS.reset}`);
|
||||
} else {
|
||||
lines.push(`Endpoint: http://localhost:${port}/v1`);
|
||||
lines.push(`Tunnel: ${COLORS.red}OFF${COLORS.reset} ${COLORS.dim}(local only)${COLORS.reset}`);
|
||||
}
|
||||
|
||||
if (keys.length === 0) {
|
||||
lines.push(`Key: ${COLORS.dim}No API keys yet${COLORS.reset}`);
|
||||
} else {
|
||||
lines.push(`Key: ${COLORS.cyan}${keys[0].key}${COLORS.reset}`);
|
||||
keys.slice(1).forEach(k => lines.push(` ${COLORS.cyan}${k.key}${COLORS.reset}`));
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Start Terminal UI
|
||||
* @param {number} port - Server port number
|
||||
*/
|
||||
async function startTerminalUI(port) {
|
||||
// Configure API client
|
||||
api.configure({ port });
|
||||
|
||||
const basePath = ["9Router"];
|
||||
|
||||
// Main menu
|
||||
await showMenuWithBack({
|
||||
title: "📡 9Router Terminal UI",
|
||||
breadcrumb: basePath,
|
||||
headerContent: async () => await buildHeaderContent(port),
|
||||
refresh: async () => ({}), // Refresh header on each loop
|
||||
items: [
|
||||
{
|
||||
label: "Providers",
|
||||
action: async () => {
|
||||
await showProvidersMenu([...basePath, "Providers"]);
|
||||
return true; // Continue
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "API Keys",
|
||||
action: async () => {
|
||||
await showApiKeysMenu(port, [...basePath, "API Keys"]);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "Combos",
|
||||
action: async () => {
|
||||
await showCombosMenu([...basePath, "Combos"]);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "CLI Tools",
|
||||
action: async () => {
|
||||
await showCliToolsMenu(port, [...basePath, "CLI Tools"]);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "Settings",
|
||||
action: async () => {
|
||||
await showSettingsMenu([...basePath, "Settings"]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
],
|
||||
backLabel: "← Back to Interface Menu"
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { startTerminalUI };
|
||||
321
cli/src/cli/tray/autostart.js
Normal file
321
cli/src/cli/tray/autostart.js
Normal file
@@ -0,0 +1,321 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
const { execSync } = require("child_process");
|
||||
|
||||
const APP_NAME = "9router";
|
||||
const APP_LABEL = "com.9router.autostart";
|
||||
|
||||
/**
|
||||
* Get the command to run 9router in tray mode
|
||||
*/
|
||||
function getStartCommand() {
|
||||
// Find the global npm bin path for 9router
|
||||
try {
|
||||
const npmBin = execSync("npm bin -g", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
||||
const routerPath = path.join(npmBin, "9router");
|
||||
if (fs.existsSync(routerPath)) {
|
||||
return `"${routerPath}" --tray --skip-update`;
|
||||
}
|
||||
} catch (e) {
|
||||
// npm not available or failed
|
||||
}
|
||||
|
||||
// Fallback: use npx
|
||||
return "npx 9router --tray --skip-update";
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable auto startup on OS boot
|
||||
* @param {string} cliPath - Optional path to cli.js (defaults to auto-detect)
|
||||
* @returns {boolean} success
|
||||
*/
|
||||
function enableAutoStart(cliPath) {
|
||||
const platform = process.platform;
|
||||
|
||||
// Skip on unsupported platforms
|
||||
if (!["darwin", "win32", "linux"].includes(platform)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip on Linux without GUI
|
||||
if (platform === "linux" && !process.env.DISPLAY) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (platform === "darwin") {
|
||||
return enableMacOS(cliPath);
|
||||
} else if (platform === "win32") {
|
||||
return enableWindows(cliPath);
|
||||
} else if (platform === "linux") {
|
||||
return enableLinux(cliPath);
|
||||
}
|
||||
} catch (err) {
|
||||
// Silent fail - autostart is optional
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable auto startup
|
||||
* @returns {boolean} success
|
||||
*/
|
||||
function disableAutoStart() {
|
||||
const platform = process.platform;
|
||||
|
||||
try {
|
||||
if (platform === "darwin") {
|
||||
return disableMacOS();
|
||||
} else if (platform === "win32") {
|
||||
return disableWindows();
|
||||
} else if (platform === "linux") {
|
||||
return disableLinux();
|
||||
}
|
||||
} catch (err) {
|
||||
// Silent fail
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if autostart is enabled
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isAutoStartEnabled() {
|
||||
const platform = process.platform;
|
||||
|
||||
try {
|
||||
if (platform === "darwin") {
|
||||
const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", `${APP_LABEL}.plist`);
|
||||
return fs.existsSync(plistPath);
|
||||
} else if (platform === "win32") {
|
||||
const startupPath = path.join(process.env.APPDATA, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", `${APP_NAME}.vbs`);
|
||||
return fs.existsSync(startupPath);
|
||||
} else if (platform === "linux") {
|
||||
const desktopPath = path.join(os.homedir(), ".config", "autostart", `${APP_NAME}.desktop`);
|
||||
return fs.existsSync(desktopPath);
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ============ macOS ============
|
||||
|
||||
function enableMacOS(cliPath) {
|
||||
const launchAgentsDir = path.join(os.homedir(), "Library", "LaunchAgents");
|
||||
const plistPath = path.join(launchAgentsDir, `${APP_LABEL}.plist`);
|
||||
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(launchAgentsDir)) {
|
||||
fs.mkdirSync(launchAgentsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Get absolute paths for node and 9router script
|
||||
const nodePath = process.execPath;
|
||||
let routerScript;
|
||||
|
||||
if (cliPath) {
|
||||
// Use provided path (from running cli.js)
|
||||
routerScript = path.resolve(cliPath);
|
||||
} else {
|
||||
// Fallback: try to resolve from npm bin
|
||||
try {
|
||||
const npmBin = execSync("npm bin -g", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
||||
const routerLink = path.join(npmBin, "9router");
|
||||
routerScript = fs.realpathSync(routerLink);
|
||||
} catch (e) {
|
||||
// Last resort fallback
|
||||
routerScript = "/usr/local/lib/node_modules/9router/cli.js";
|
||||
}
|
||||
}
|
||||
|
||||
// Determine user shell
|
||||
const userShell = process.env.SHELL || '/bin/zsh';
|
||||
|
||||
const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>${APP_LABEL}</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>${userShell}</string>
|
||||
<string>-l</string>
|
||||
<string>-c</string>
|
||||
<string>${nodePath} ${routerScript} --tray --skip-update</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<false/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/9router.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/9router.error.log</string>
|
||||
</dict>
|
||||
</plist>`;
|
||||
|
||||
fs.writeFileSync(plistPath, plistContent);
|
||||
|
||||
// Load the launch agent
|
||||
try {
|
||||
execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { stdio: "ignore" });
|
||||
} catch (e) {}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function disableMacOS() {
|
||||
const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", `${APP_LABEL}.plist`);
|
||||
|
||||
try {
|
||||
execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { stdio: "ignore" });
|
||||
} catch (e) {}
|
||||
|
||||
if (fs.existsSync(plistPath)) {
|
||||
fs.unlinkSync(plistPath);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============ Windows ============
|
||||
|
||||
function enableWindows(cliPath) {
|
||||
const startupDir = path.join(process.env.APPDATA, "Microsoft", "Windows", "Start Menu", "Programs", "Startup");
|
||||
const vbsPath = path.join(startupDir, `${APP_NAME}.vbs`);
|
||||
|
||||
// Ensure startup directory exists
|
||||
if (!fs.existsSync(startupDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get absolute paths
|
||||
const nodePath = process.execPath;
|
||||
let routerScript;
|
||||
|
||||
if (cliPath) {
|
||||
// Use provided path (from running cli.js)
|
||||
routerScript = path.resolve(cliPath);
|
||||
} else {
|
||||
// Fallback: try to resolve from npm bin
|
||||
try {
|
||||
const npmBin = execSync("npm bin -g", { encoding: "utf8", shell: true, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
||||
const routerLink = path.join(npmBin, "9router.cmd");
|
||||
if (fs.existsSync(routerLink)) {
|
||||
routerScript = routerLink;
|
||||
} else {
|
||||
// Try to resolve actual script
|
||||
const routerJs = path.join(npmBin, "../lib/node_modules/9router/cli.js");
|
||||
if (fs.existsSync(routerJs)) {
|
||||
routerScript = routerJs;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Create VBS script to run hidden (no console window)
|
||||
let vbsContent;
|
||||
if (routerScript && routerScript.endsWith(".js")) {
|
||||
// Run node directly with script
|
||||
vbsContent = `Set WshShell = CreateObject("WScript.Shell")
|
||||
WshShell.Run """${nodePath}"" ""${routerScript}"" --tray --skip-update", 0, False
|
||||
`;
|
||||
} else if (routerScript) {
|
||||
// Run .cmd file
|
||||
vbsContent = `Set WshShell = CreateObject("WScript.Shell")
|
||||
WshShell.Run """${routerScript}"" --tray --skip-update", 0, False
|
||||
`;
|
||||
} else {
|
||||
// Fallback to npx
|
||||
vbsContent = `Set WshShell = CreateObject("WScript.Shell")
|
||||
WshShell.Run "npx 9router --tray --skip-update", 0, False
|
||||
`;
|
||||
}
|
||||
|
||||
fs.writeFileSync(vbsPath, vbsContent);
|
||||
return true;
|
||||
}
|
||||
|
||||
function disableWindows() {
|
||||
const vbsPath = path.join(process.env.APPDATA, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", `${APP_NAME}.vbs`);
|
||||
|
||||
if (fs.existsSync(vbsPath)) {
|
||||
fs.unlinkSync(vbsPath);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============ Linux ============
|
||||
|
||||
function enableLinux(cliPath) {
|
||||
const autostartDir = path.join(os.homedir(), ".config", "autostart");
|
||||
const desktopPath = path.join(autostartDir, `${APP_NAME}.desktop`);
|
||||
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(autostartDir)) {
|
||||
try {
|
||||
fs.mkdirSync(autostartDir, { recursive: true });
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Get absolute paths
|
||||
const nodePath = process.execPath;
|
||||
let routerScript;
|
||||
|
||||
if (cliPath) {
|
||||
// Use provided path (from running cli.js)
|
||||
routerScript = path.resolve(cliPath);
|
||||
} else {
|
||||
// Fallback: try to resolve from npm bin
|
||||
try {
|
||||
const npmBin = execSync("npm bin -g", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
||||
const routerLink = path.join(npmBin, "9router");
|
||||
if (fs.existsSync(routerLink)) {
|
||||
routerScript = fs.realpathSync(routerLink);
|
||||
}
|
||||
} catch (e) {
|
||||
// Last resort fallback
|
||||
routerScript = "/usr/local/lib/node_modules/9router/cli.js";
|
||||
}
|
||||
}
|
||||
|
||||
const desktopContent = `[Desktop Entry]
|
||||
Type=Application
|
||||
Name=9Router
|
||||
Comment=9Router API Proxy
|
||||
Exec=${nodePath} ${routerScript} --tray --skip-update
|
||||
Hidden=false
|
||||
NoDisplay=false
|
||||
X-GNOME-Autostart-enabled=true
|
||||
`;
|
||||
|
||||
fs.writeFileSync(desktopPath, desktopContent);
|
||||
return true;
|
||||
}
|
||||
|
||||
function disableLinux() {
|
||||
const desktopPath = path.join(os.homedir(), ".config", "autostart", `${APP_NAME}.desktop`);
|
||||
|
||||
if (fs.existsSync(desktopPath)) {
|
||||
fs.unlinkSync(desktopPath);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
enableAutoStart,
|
||||
disableAutoStart,
|
||||
isAutoStartEnabled
|
||||
};
|
||||
BIN
cli/src/cli/tray/icon.ico
Normal file
BIN
cli/src/cli/tray/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 969 B |
BIN
cli/src/cli/tray/icon.png
Normal file
BIN
cli/src/cli/tray/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 947 B |
236
cli/src/cli/tray/tray.js
Normal file
236
cli/src/cli/tray/tray.js
Normal file
@@ -0,0 +1,236 @@
|
||||
const { exec } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
let trayInstance = null;
|
||||
let isWinTray = false;
|
||||
|
||||
/**
|
||||
* Get icon base64 from file — used for systray (mac/linux)
|
||||
*/
|
||||
function getIconBase64() {
|
||||
const isWin = process.platform === "win32";
|
||||
const iconFile = isWin ? "icon.ico" : "icon.png";
|
||||
try {
|
||||
const iconPath = path.join(__dirname, iconFile);
|
||||
if (fs.existsSync(iconPath)) {
|
||||
return fs.readFileSync(iconPath).toString("base64");
|
||||
}
|
||||
} catch (e) {}
|
||||
// Fallback: minimal green dot icon (PNG)
|
||||
return "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGPC/xhBQAAAAlwSFlzAAALEwAACxMBAJqcGAAAAHpJREFUOE9jYBgFgwEwMjIy/Gdg+P8fyP4PxP8ZGBgEcBnGyMjIsICBgSEAhyH/gfgBUNN8XJoZsdkCVL8Ah+b/QPwbqvkBMvk/AwMDAzYX/GdgYAhAN+A/SICRWAMYGfFEJSMjzriEiwDR/xmIa2RkZCSqnZERb3QCAAo3KxzxbKe1AAAAAElFTkSuQmCC";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if system tray is supported on current OS
|
||||
* Supported: macOS, Windows, Linux (with GUI)
|
||||
*/
|
||||
function isTraySupported() {
|
||||
const platform = process.platform;
|
||||
if (!["darwin", "win32", "linux"].includes(platform)) {
|
||||
return false;
|
||||
}
|
||||
if (platform === "linux" && !process.env.DISPLAY) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize system tray with menu
|
||||
* @param {Object} options - { port, onQuit, onOpenDashboard }
|
||||
* @returns {Object|null} tray instance or null if not supported/failed
|
||||
*/
|
||||
function initTray(options) {
|
||||
if (!isTraySupported()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Windows uses PowerShell NotifyIcon (AV-safe), others use systray
|
||||
if (process.platform === "win32") {
|
||||
return initWindowsTray(options);
|
||||
}
|
||||
return initUnixTray(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build menu items array shared between platforms
|
||||
*/
|
||||
function buildMenuItems(port, autostartEnabled) {
|
||||
return [
|
||||
{ title: `9Router (Port ${port})`, tooltip: "Server is running", enabled: false },
|
||||
{ title: "Open Dashboard", tooltip: "Open in browser", enabled: true },
|
||||
{
|
||||
title: autostartEnabled ? "✓ Auto-start Enabled" : "Enable Auto-start",
|
||||
tooltip: "Run on OS startup",
|
||||
enabled: true
|
||||
},
|
||||
{ title: "Quit", tooltip: "Stop server and exit", enabled: true }
|
||||
];
|
||||
}
|
||||
|
||||
// Menu item indexes
|
||||
const MENU_INDEX = { STATUS: 0, DASHBOARD: 1, AUTOSTART: 2, QUIT: 3 };
|
||||
|
||||
/**
|
||||
* Get current autostart state
|
||||
*/
|
||||
function getAutostartEnabled() {
|
||||
try {
|
||||
const { isAutoStartEnabled } = require("./autostart");
|
||||
return isAutoStartEnabled();
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle menu item click (shared logic)
|
||||
*/
|
||||
function handleClick(index, options, onAutostartToggle) {
|
||||
const { onQuit, onOpenDashboard, port } = options;
|
||||
if (index === MENU_INDEX.DASHBOARD) {
|
||||
if (onOpenDashboard) onOpenDashboard();
|
||||
else openBrowser(`http://localhost:${port}/dashboard`);
|
||||
} else if (index === MENU_INDEX.AUTOSTART) {
|
||||
const enabled = getAutostartEnabled();
|
||||
try {
|
||||
const { enableAutoStart, disableAutoStart } = require("./autostart");
|
||||
if (enabled) disableAutoStart();
|
||||
else enableAutoStart();
|
||||
onAutostartToggle(!enabled);
|
||||
} catch (e) {}
|
||||
} else if (index === MENU_INDEX.QUIT) {
|
||||
console.log("\n👋 Shutting down...");
|
||||
if (onQuit) onQuit();
|
||||
killTray();
|
||||
setTimeout(() => process.exit(0), 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows tray via PowerShell NotifyIcon
|
||||
*/
|
||||
function initWindowsTray(options) {
|
||||
const { port } = options;
|
||||
try {
|
||||
const { initWinTray } = require("./trayWin");
|
||||
const iconPath = path.join(__dirname, "icon.ico");
|
||||
const autostartEnabled = getAutostartEnabled();
|
||||
const items = buildMenuItems(port, autostartEnabled);
|
||||
|
||||
trayInstance = initWinTray({
|
||||
iconPath,
|
||||
tooltip: `9Router - Port ${port}`,
|
||||
items,
|
||||
onClick: (index) => {
|
||||
handleClick(index, options, (newEnabled) => {
|
||||
const newTitle = newEnabled ? "✓ Auto-start Enabled" : "Enable Auto-start";
|
||||
trayInstance.updateItem(MENU_INDEX.AUTOSTART, newTitle, true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
isWinTray = true;
|
||||
return trayInstance;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* macOS/Linux tray via systray binary
|
||||
*/
|
||||
function resolveSystray() {
|
||||
// Try local first (dev), then runtime dir (production lazy install)
|
||||
try {
|
||||
return require("systray").default;
|
||||
} catch (e) {}
|
||||
try {
|
||||
const { getRuntimeNodeModules } = require("../../../hooks/sqliteRuntime");
|
||||
const systrayPath = path.join(getRuntimeNodeModules(), "systray");
|
||||
return require(systrayPath).default;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function initUnixTray(options) {
|
||||
const { port } = options;
|
||||
try {
|
||||
const SysTray = resolveSystray();
|
||||
if (!SysTray) return null;
|
||||
const autostartEnabled = getAutostartEnabled();
|
||||
const items = buildMenuItems(port, autostartEnabled);
|
||||
|
||||
const menu = {
|
||||
icon: getIconBase64(),
|
||||
title: "",
|
||||
tooltip: `9Router - Port ${port}`,
|
||||
items
|
||||
};
|
||||
|
||||
trayInstance = new SysTray({ menu, debug: false, copyDir: true });
|
||||
isWinTray = false;
|
||||
|
||||
trayInstance.onClick((action) => {
|
||||
handleClick(action.seq_id, options, (newEnabled) => {
|
||||
trayInstance.sendAction({
|
||||
type: "update-item",
|
||||
item: {
|
||||
title: newEnabled ? "✓ Auto-start Enabled" : "Enable Auto-start",
|
||||
tooltip: "Run on OS startup",
|
||||
enabled: true
|
||||
},
|
||||
seq_id: MENU_INDEX.AUTOSTART
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
trayInstance.onReady(() => {});
|
||||
trayInstance.onError(() => {});
|
||||
|
||||
return trayInstance;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill/close system tray gracefully
|
||||
*/
|
||||
function killTray() {
|
||||
const instance = trayInstance;
|
||||
const wasWin = isWinTray;
|
||||
trayInstance = null;
|
||||
|
||||
if (instance) {
|
||||
try {
|
||||
if (wasWin) instance.kill();
|
||||
else instance.kill(true);
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open browser
|
||||
*/
|
||||
function openBrowser(url) {
|
||||
const platform = process.platform;
|
||||
let cmd;
|
||||
|
||||
if (platform === "darwin") {
|
||||
cmd = `open "${url}"`;
|
||||
} else if (platform === "win32") {
|
||||
cmd = `start "" "${url}"`;
|
||||
} else {
|
||||
cmd = `xdg-open "${url}"`;
|
||||
}
|
||||
|
||||
exec(cmd);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initTray,
|
||||
killTray
|
||||
};
|
||||
79
cli/src/cli/tray/tray.ps1
Normal file
79
cli/src/cli/tray/tray.ps1
Normal file
@@ -0,0 +1,79 @@
|
||||
# 9Router tray icon for Windows using NotifyIcon
|
||||
# IPC: stdin JSON commands, stdout JSON events
|
||||
param([string]$IconPath, [string]$Tooltip)
|
||||
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
$script:notifyIcon = New-Object System.Windows.Forms.NotifyIcon
|
||||
$script:notifyIcon.Icon = New-Object System.Drawing.Icon($IconPath)
|
||||
$script:notifyIcon.Text = $Tooltip
|
||||
$script:notifyIcon.Visible = $true
|
||||
|
||||
$script:menu = New-Object System.Windows.Forms.ContextMenuStrip
|
||||
$script:notifyIcon.ContextMenuStrip = $script:menu
|
||||
$script:items = @()
|
||||
|
||||
function Write-Event($obj) {
|
||||
$json = $obj | ConvertTo-Json -Compress
|
||||
[Console]::Out.WriteLine($json)
|
||||
[Console]::Out.Flush()
|
||||
}
|
||||
|
||||
function Add-MenuItem($index, $title, $enabled) {
|
||||
$item = New-Object System.Windows.Forms.ToolStripMenuItem
|
||||
$item.Text = $title
|
||||
$item.Enabled = $enabled
|
||||
$idx = $index
|
||||
$item.Add_Click({ Write-Event @{ type = "click"; index = $idx } }.GetNewClosure())
|
||||
$script:menu.Items.Add($item) | Out-Null
|
||||
$script:items += $item
|
||||
}
|
||||
|
||||
function Update-MenuItem($index, $title, $enabled) {
|
||||
if ($index -lt $script:items.Count) {
|
||||
$script:items[$index].Text = $title
|
||||
$script:items[$index].Enabled = $enabled
|
||||
}
|
||||
}
|
||||
|
||||
function Set-Tooltip($text) {
|
||||
# NotifyIcon.Text max 63 chars
|
||||
if ($text.Length -gt 63) { $text = $text.Substring(0, 63) }
|
||||
$script:notifyIcon.Text = $text
|
||||
}
|
||||
|
||||
# Background reader thread polls stdin via timer on UI thread
|
||||
$script:timer = New-Object System.Windows.Forms.Timer
|
||||
$script:timer.Interval = 100
|
||||
$script:timer.Add_Tick({
|
||||
try {
|
||||
while ([Console]::In.Peek() -ne -1) {
|
||||
$line = [Console]::In.ReadLine()
|
||||
if ([string]::IsNullOrWhiteSpace($line)) { continue }
|
||||
$cmd = $line | ConvertFrom-Json
|
||||
switch ($cmd.action) {
|
||||
"add-item" { Add-MenuItem $cmd.index $cmd.title $cmd.enabled }
|
||||
"update-item" { Update-MenuItem $cmd.index $cmd.title $cmd.enabled }
|
||||
"set-tooltip" { Set-Tooltip $cmd.text }
|
||||
"ready" { Write-Event @{ type = "ready" } }
|
||||
"kill" {
|
||||
$script:notifyIcon.Visible = $false
|
||||
$script:notifyIcon.Dispose()
|
||||
[System.Windows.Forms.Application]::Exit()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-Event @{ type = "error"; message = $_.Exception.Message }
|
||||
}
|
||||
})
|
||||
$script:timer.Start()
|
||||
|
||||
Write-Event @{ type = "started" }
|
||||
[System.Windows.Forms.Application]::Run()
|
||||
89
cli/src/cli/tray/trayWin.js
Normal file
89
cli/src/cli/tray/trayWin.js
Normal file
@@ -0,0 +1,89 @@
|
||||
const { spawn } = require("child_process");
|
||||
const path = require("path");
|
||||
const readline = require("readline");
|
||||
|
||||
// PowerShell-based tray for Windows (AV-safe, zero binary deps)
|
||||
|
||||
let psProcess = null;
|
||||
let clickHandler = null;
|
||||
|
||||
/**
|
||||
* Send JSON command to PowerShell tray process via stdin
|
||||
*/
|
||||
function sendCommand(cmd) {
|
||||
if (psProcess && psProcess.stdin.writable) {
|
||||
psProcess.stdin.write(`${JSON.stringify(cmd)}\n`, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Windows tray using PowerShell NotifyIcon
|
||||
* @param {Object} options - { iconPath, tooltip, items, onClick }
|
||||
* items: [{ title, enabled }]
|
||||
* @returns {Object|null} controller with sendAction/kill
|
||||
*/
|
||||
function initWinTray(options) {
|
||||
const { iconPath, tooltip, items, onClick } = options;
|
||||
clickHandler = onClick;
|
||||
|
||||
const scriptPath = path.join(__dirname, "tray.ps1");
|
||||
|
||||
try {
|
||||
psProcess = spawn(
|
||||
"powershell.exe",
|
||||
[
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy", "Bypass",
|
||||
"-WindowStyle", "Hidden",
|
||||
"-InputFormat", "Text",
|
||||
"-OutputFormat", "Text",
|
||||
"-File", scriptPath,
|
||||
"-IconPath", iconPath,
|
||||
"-Tooltip", tooltip
|
||||
],
|
||||
{ windowsHide: true, stdio: ["pipe", "pipe", "pipe"] }
|
||||
);
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({ input: psProcess.stdout });
|
||||
rl.on("line", (line) => {
|
||||
try {
|
||||
const evt = JSON.parse(line);
|
||||
if (evt.type === "click" && clickHandler) {
|
||||
clickHandler(evt.index);
|
||||
}
|
||||
} catch (e) {}
|
||||
});
|
||||
|
||||
psProcess.on("error", () => {});
|
||||
psProcess.stderr.on("data", () => {});
|
||||
|
||||
// Send initial menu items
|
||||
items.forEach((item, index) => {
|
||||
sendCommand({ action: "add-item", index, title: item.title, enabled: item.enabled });
|
||||
});
|
||||
|
||||
return {
|
||||
updateItem(index, title, enabled) {
|
||||
sendCommand({ action: "update-item", index, title, enabled });
|
||||
},
|
||||
setTooltip(text) {
|
||||
sendCommand({ action: "set-tooltip", text });
|
||||
},
|
||||
kill() {
|
||||
try {
|
||||
sendCommand({ action: "kill" });
|
||||
} catch (e) {}
|
||||
setTimeout(() => {
|
||||
if (psProcess && !psProcess.killed) {
|
||||
try { psProcess.kill(); } catch (e) {}
|
||||
}
|
||||
psProcess = null;
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { initWinTray };
|
||||
30
cli/src/cli/utils/clipboard.js
Normal file
30
cli/src/cli/utils/clipboard.js
Normal file
@@ -0,0 +1,30 @@
|
||||
const { execSync } = require("child_process");
|
||||
|
||||
/**
|
||||
* Copy text to clipboard based on OS
|
||||
* @param {string} text - Text to copy
|
||||
* @returns {boolean} Success status
|
||||
*/
|
||||
function copyToClipboard(text) {
|
||||
try {
|
||||
const platform = process.platform;
|
||||
|
||||
if (platform === "darwin") {
|
||||
execSync("pbcopy", { input: text });
|
||||
} else if (platform === "win32") {
|
||||
execSync("clip", { input: text });
|
||||
} else {
|
||||
// Linux - try xclip first, then xsel
|
||||
try {
|
||||
execSync("xclip -selection clipboard", { input: text });
|
||||
} catch {
|
||||
execSync("xsel --clipboard --input", { input: text });
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { copyToClipboard };
|
||||
156
cli/src/cli/utils/display.js
Normal file
156
cli/src/cli/utils/display.js
Normal file
@@ -0,0 +1,156 @@
|
||||
const { formatNumber } = require("./format");
|
||||
|
||||
// ANSI color codes
|
||||
const COLORS = {
|
||||
reset: "\x1b[0m",
|
||||
success: "\x1b[32m",
|
||||
error: "\x1b[31m",
|
||||
warning: "\x1b[33m",
|
||||
info: "\x1b[36m",
|
||||
dim: "\x1b[2m",
|
||||
bold: "\x1b[1m",
|
||||
bright: "\x1b[1m",
|
||||
cyan: "\x1b[36m"
|
||||
};
|
||||
|
||||
// Box drawing characters
|
||||
const BOX_CHARS = {
|
||||
topLeft: "┌",
|
||||
topRight: "┐",
|
||||
bottomLeft: "└",
|
||||
bottomRight: "┘",
|
||||
horizontal: "─",
|
||||
vertical: "│"
|
||||
};
|
||||
|
||||
/**
|
||||
* Draw a box with border around content
|
||||
* @param {string} title - Box title
|
||||
* @param {string} content - Content to display inside box
|
||||
* @param {number} [width=60] - Box width
|
||||
*/
|
||||
function showBox(title, content, width = 60) {
|
||||
const innerWidth = width - 4;
|
||||
const lines = content.split("\n");
|
||||
|
||||
// Top border with title
|
||||
const topBorder = BOX_CHARS.topLeft + BOX_CHARS.horizontal.repeat(2) +
|
||||
` ${title} ` +
|
||||
BOX_CHARS.horizontal.repeat(Math.max(0, innerWidth - title.length - 3)) +
|
||||
BOX_CHARS.topRight;
|
||||
|
||||
console.log(topBorder);
|
||||
|
||||
// Content lines
|
||||
lines.forEach(line => {
|
||||
const paddedLine = line.padEnd(innerWidth);
|
||||
console.log(`${BOX_CHARS.vertical} ${paddedLine} ${BOX_CHARS.vertical}`);
|
||||
});
|
||||
|
||||
// Bottom border
|
||||
const bottomBorder = BOX_CHARS.bottomLeft +
|
||||
BOX_CHARS.horizontal.repeat(innerWidth + 2) +
|
||||
BOX_CHARS.bottomRight;
|
||||
|
||||
console.log(bottomBorder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a menu with numbered items
|
||||
* @param {string} title - Menu title
|
||||
* @param {string[]} items - Array of menu items
|
||||
* @param {string} [footer] - Optional footer text
|
||||
*/
|
||||
function showMenu(title, items, footer) {
|
||||
console.log(`\n${COLORS.bold}${title}${COLORS.reset}`);
|
||||
console.log(COLORS.dim + "─".repeat(title.length) + COLORS.reset);
|
||||
|
||||
items.forEach((item, index) => {
|
||||
console.log(` ${COLORS.info}${index + 1}.${COLORS.reset} ${item}`);
|
||||
});
|
||||
|
||||
if (footer) {
|
||||
console.log(`\n${COLORS.dim}${footer}${COLORS.reset}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
/**
|
||||
* Display data in table format
|
||||
* @param {string[]} headers - Array of column headers
|
||||
* @param {Array<Array<string|number>>} rows - Array of row data
|
||||
*/
|
||||
function showTable(headers, rows) {
|
||||
if (!headers.length || !rows.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate column widths
|
||||
const colWidths = headers.map((header, i) => {
|
||||
const maxDataWidth = Math.max(...rows.map(row => String(row[i] || "").length));
|
||||
return Math.max(header.length, maxDataWidth);
|
||||
});
|
||||
|
||||
// Print header
|
||||
const headerRow = headers.map((h, i) => h.padEnd(colWidths[i])).join(" │ ");
|
||||
console.log(COLORS.bold + headerRow + COLORS.reset);
|
||||
|
||||
// Print separator
|
||||
const separator = colWidths.map(w => "─".repeat(w)).join("─┼─");
|
||||
console.log(COLORS.dim + separator + COLORS.reset);
|
||||
|
||||
// Print rows
|
||||
rows.forEach(row => {
|
||||
const rowStr = row.map((cell, i) => String(cell || "").padEnd(colWidths[i])).join(" │ ");
|
||||
console.log(rowStr);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show colored status message
|
||||
* @param {string} message - Message to display
|
||||
* @param {string} [type="info"] - Status type: success, error, warning, info
|
||||
*/
|
||||
function showStatus(message, type = "info") {
|
||||
const symbols = {
|
||||
success: "✓",
|
||||
error: "✗",
|
||||
warning: "⚠",
|
||||
info: "ℹ"
|
||||
};
|
||||
|
||||
const color = COLORS[type] || COLORS.info;
|
||||
const symbol = symbols[type] || symbols.info;
|
||||
|
||||
console.log(`${color}${symbol} ${message}${COLORS.reset}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the terminal screen
|
||||
*/
|
||||
function clearScreen() {
|
||||
console.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show menu header with title and subtitle
|
||||
* @param {string} title - Main title
|
||||
* @param {string} subtitle - Optional subtitle
|
||||
*/
|
||||
function showHeader(title, subtitle) {
|
||||
console.log(`\n${"=".repeat(60)}`);
|
||||
console.log(` ${COLORS.bright}${COLORS.cyan}${title}${COLORS.reset}`);
|
||||
if (subtitle) {
|
||||
console.log(` ${COLORS.dim}${subtitle}${COLORS.reset}`);
|
||||
}
|
||||
console.log(`${"=".repeat(60)}\n`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
showBox,
|
||||
showMenu,
|
||||
showTable,
|
||||
showStatus,
|
||||
clearScreen,
|
||||
showHeader
|
||||
};
|
||||
32
cli/src/cli/utils/endpoint.js
Normal file
32
cli/src/cli/utils/endpoint.js
Normal file
@@ -0,0 +1,32 @@
|
||||
const api = require("../api/client");
|
||||
|
||||
const COLORS = {
|
||||
reset: "\x1b[0m",
|
||||
green: "\x1b[32m"
|
||||
};
|
||||
|
||||
/**
|
||||
* Get endpoint URL based on tunnel status
|
||||
* @param {number} port - Local server port
|
||||
* @returns {Promise<{endpoint: string, tunnelEnabled: boolean}>}
|
||||
*/
|
||||
async function getEndpoint(port) {
|
||||
const result = await api.getTunnelStatus();
|
||||
const tunnelEnabled = result.success && result.data?.enabled === true;
|
||||
const publicUrl = result.success ? result.data?.publicUrl : "";
|
||||
|
||||
const endpoint = tunnelEnabled && publicUrl ? `${publicUrl}/v1` : `http://localhost:${port}/v1`;
|
||||
return { endpoint, tunnelEnabled };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get endpoint with color formatting
|
||||
* @param {number} port - Local server port
|
||||
* @returns {Promise<string>} Colored endpoint string
|
||||
*/
|
||||
async function getEndpointColored(port) {
|
||||
const { endpoint, tunnelEnabled } = await getEndpoint(port);
|
||||
return tunnelEnabled ? `${COLORS.green}${endpoint}${COLORS.reset}` : endpoint;
|
||||
}
|
||||
|
||||
module.exports = { getEndpoint, getEndpointColored };
|
||||
125
cli/src/cli/utils/format.js
Normal file
125
cli/src/cli/utils/format.js
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Truncate text with ellipsis
|
||||
* @param {string} text - Text to truncate
|
||||
* @param {number} maxLength - Maximum length
|
||||
* @returns {string} Truncated text
|
||||
*/
|
||||
function truncate(text, maxLength) {
|
||||
if (!text || text.length <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
return text.substring(0, maxLength - 3) + "...";
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask API key showing only first and last characters
|
||||
* @param {string} key - API key to mask
|
||||
* @returns {string} Masked key
|
||||
*/
|
||||
function maskKey(key) {
|
||||
if (!key || key.length < 8) {
|
||||
return "***";
|
||||
}
|
||||
const firstChars = key.substring(0, 4);
|
||||
const lastChars = key.substring(key.length - 4);
|
||||
return `${firstChars}${"*".repeat(key.length - 8)}${lastChars}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date to readable string
|
||||
* @param {Date|string|number} date - Date to format
|
||||
* @returns {string} Formatted date string
|
||||
*/
|
||||
function formatDate(date) {
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) {
|
||||
return "Invalid Date";
|
||||
}
|
||||
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
const hours = String(d.getHours()).padStart(2, "0");
|
||||
const minutes = String(d.getMinutes()).padStart(2, "0");
|
||||
const seconds = String(d.getSeconds()).padStart(2, "0");
|
||||
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format number with commas
|
||||
* @param {number} num - Number to format
|
||||
* @returns {string} Formatted number
|
||||
*/
|
||||
function formatNumber(num) {
|
||||
if (typeof num !== "number" || isNaN(num)) {
|
||||
return "0";
|
||||
}
|
||||
return num.toLocaleString("en-US");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human readable size
|
||||
* @param {number} bytes - Bytes to format
|
||||
* @returns {string} Formatted size string
|
||||
*/
|
||||
function formatBytes(bytes) {
|
||||
if (typeof bytes !== "number" || isNaN(bytes) || bytes < 0) {
|
||||
return "0 B";
|
||||
}
|
||||
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(2)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get relative time string
|
||||
* @param {Date|string|number} date - Date to compare
|
||||
* @returns {string} Relative time string
|
||||
*/
|
||||
function getRelativeTime(date) {
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) {
|
||||
return "Invalid Date";
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const diffMs = now - d;
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
const diffMonth = Math.floor(diffDay / 30);
|
||||
const diffYear = Math.floor(diffDay / 365);
|
||||
|
||||
if (diffSec < 60) {
|
||||
return "just now";
|
||||
} else if (diffMin < 60) {
|
||||
return `${diffMin} minute${diffMin > 1 ? "s" : ""} ago`;
|
||||
} else if (diffHour < 24) {
|
||||
return `${diffHour} hour${diffHour > 1 ? "s" : ""} ago`;
|
||||
} else if (diffDay < 30) {
|
||||
return `${diffDay} day${diffDay > 1 ? "s" : ""} ago`;
|
||||
} else if (diffMonth < 12) {
|
||||
return `${diffMonth} month${diffMonth > 1 ? "s" : ""} ago`;
|
||||
} else {
|
||||
return `${diffYear} year${diffYear > 1 ? "s" : ""} ago`;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
truncate,
|
||||
maskKey,
|
||||
formatDate,
|
||||
formatNumber,
|
||||
formatBytes,
|
||||
getRelativeTime
|
||||
};
|
||||
229
cli/src/cli/utils/input.js
Normal file
229
cli/src/cli/utils/input.js
Normal file
@@ -0,0 +1,229 @@
|
||||
const readline = require("readline");
|
||||
|
||||
const COLORS = {
|
||||
reset: "\x1b[0m",
|
||||
bright: "\x1b[1m",
|
||||
dim: "\x1b[2m",
|
||||
underline: "\x1b[4m",
|
||||
reverse: "\x1b[7m",
|
||||
cyan: "\x1b[36m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
blue: "\x1b[34m",
|
||||
white: "\x1b[37m",
|
||||
bgGreen: "\x1b[42m",
|
||||
bgBlue: "\x1b[44m",
|
||||
black: "\x1b[30m",
|
||||
// Terracotta/Earth orange - using RGB escape code
|
||||
terracotta: "\x1b[38;2;217;119;87m", // #D97757
|
||||
bgTerracotta: "\x1b[48;2;217;119;87m"
|
||||
};
|
||||
|
||||
/**
|
||||
* Ask a question and return the user's answer
|
||||
* @param {string} question - The question to ask
|
||||
* @returns {Promise<string>} The user's answer
|
||||
*/
|
||||
async function prompt(question) {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a numbered menu and return the selected option number
|
||||
* @param {string} question - The question to ask
|
||||
* @param {string[]} options - Array of options to display
|
||||
* @returns {Promise<number>} The selected option index (0-based)
|
||||
*/
|
||||
async function select(question, options) {
|
||||
console.log(question);
|
||||
options.forEach((option, index) => {
|
||||
console.log(` ${index + 1}. ${option}`);
|
||||
});
|
||||
|
||||
while (true) {
|
||||
const answer = await prompt("\nSelect option (number): ");
|
||||
const num = parseInt(answer, 10);
|
||||
|
||||
if (!isNaN(num) && num >= 1 && num <= options.length) {
|
||||
return num - 1;
|
||||
}
|
||||
|
||||
console.log(`Invalid selection. Please enter a number between 1 and ${options.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask a yes/no question and return boolean
|
||||
* @param {string} question - The question to ask
|
||||
* @returns {Promise<boolean>} True for yes, false for no
|
||||
*/
|
||||
async function confirm(question) {
|
||||
while (true) {
|
||||
const answer = await prompt(`${question} (y/n): `);
|
||||
const lower = answer.toLowerCase();
|
||||
|
||||
if (lower === "y" || lower === "yes") {
|
||||
return true;
|
||||
}
|
||||
if (lower === "n" || lower === "no") {
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log("Please answer 'y' or 'n'");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause execution until user presses Enter
|
||||
* @param {string} [message="Press Enter to continue..."] - Message to display
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function pause(message = "Press Enter to continue...") {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
rl.question(message, () => {
|
||||
rl.close();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show interactive menu with arrow key navigation
|
||||
* @param {string} title - Menu title
|
||||
* @param {Array<{label: string, icon?: string}>} items - Menu items
|
||||
* @param {number} defaultIndex - Default selected index
|
||||
* @param {string} headerContent - Optional content to show above menu
|
||||
* @param {Array<string>} breadcrumb - Optional breadcrumb path
|
||||
* @returns {Promise<number>} Selected index, or -1 if ESC pressed
|
||||
*/
|
||||
async function selectMenu(title, items, defaultIndex = 0, subtitle = "", headerContent = "", breadcrumb = []) {
|
||||
return new Promise((resolve) => {
|
||||
let selectedIndex = defaultIndex;
|
||||
let isActive = true;
|
||||
|
||||
// Remove any existing keypress listeners first
|
||||
process.stdin.removeAllListeners("keypress");
|
||||
|
||||
readline.emitKeypressEvents(process.stdin);
|
||||
if (process.stdin.isTTY) {
|
||||
try {
|
||||
process.stdin.setRawMode(true);
|
||||
} catch (err) {
|
||||
// TTY disconnected or EIO error - exit gracefully
|
||||
resolve(-1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const renderMenu = () => {
|
||||
if (!isActive) return;
|
||||
|
||||
// Clear previous menu
|
||||
process.stdout.write("\x1b[2J\x1b[H");
|
||||
|
||||
// Show title with terracotta color
|
||||
const width = Math.min(process.stdout.columns || 40, 40);
|
||||
console.log(`\n${COLORS.terracotta}${"=".repeat(width)}${COLORS.reset}`);
|
||||
console.log(` ${COLORS.bright}${COLORS.terracotta}${title}${COLORS.reset}`);
|
||||
|
||||
// Show subtitle inside the frame
|
||||
if (subtitle) {
|
||||
console.log(` ${COLORS.dim}${subtitle}${COLORS.reset}`);
|
||||
}
|
||||
|
||||
console.log(`${COLORS.terracotta}${"=".repeat(width)}${COLORS.reset}`);
|
||||
|
||||
// Show breadcrumb if provided
|
||||
if (breadcrumb.length > 0) {
|
||||
console.log(` ${COLORS.dim}${breadcrumb.join(" > ")}${COLORS.reset}`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
// Show header content if provided
|
||||
if (headerContent) {
|
||||
console.log(headerContent);
|
||||
console.log();
|
||||
}
|
||||
|
||||
// Show menu items with proper alignment
|
||||
items.forEach((item, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
|
||||
// Fallback to ASCII on Windows (cmd/powershell can't render unicode stars)
|
||||
const isWin = process.platform === "win32";
|
||||
const icon = isSelected ? (isWin ? ">" : "★") : (isWin ? " " : "☆");
|
||||
|
||||
if (isSelected) {
|
||||
// Selected: reverse + bright for high visibility on any terminal
|
||||
console.log(` ${COLORS.reverse}${COLORS.bright}${icon} ${item.label}${COLORS.reset}`);
|
||||
} else {
|
||||
// Not selected: plain text with empty star
|
||||
console.log(` ${icon} ${item.label}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (!isActive) return;
|
||||
isActive = false;
|
||||
|
||||
if (process.stdin.isTTY) {
|
||||
try {
|
||||
process.stdin.setRawMode(false);
|
||||
} catch (err) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
process.stdin.removeListener("keypress", onKeypress);
|
||||
process.stdin.pause();
|
||||
};
|
||||
|
||||
const onKeypress = (str, key) => {
|
||||
if (!isActive || !key) return;
|
||||
|
||||
if (key.name === "up") {
|
||||
selectedIndex = (selectedIndex - 1 + items.length) % items.length;
|
||||
renderMenu();
|
||||
} else if (key.name === "down") {
|
||||
selectedIndex = (selectedIndex + 1) % items.length;
|
||||
renderMenu();
|
||||
} else if (key.name === "return") {
|
||||
cleanup();
|
||||
resolve(selectedIndex);
|
||||
} else if (key.name === "escape") {
|
||||
cleanup();
|
||||
resolve(-1);
|
||||
} else if (key.ctrl && key.name === "c") {
|
||||
cleanup();
|
||||
process.exit(0);
|
||||
}
|
||||
};
|
||||
|
||||
process.stdin.on("keypress", onKeypress);
|
||||
process.stdin.resume();
|
||||
renderMenu();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
prompt,
|
||||
select,
|
||||
confirm,
|
||||
pause,
|
||||
selectMenu
|
||||
};
|
||||
156
cli/src/cli/utils/menuHelper.js
Normal file
156
cli/src/cli/utils/menuHelper.js
Normal file
@@ -0,0 +1,156 @@
|
||||
const { selectMenu } = require("./input");
|
||||
|
||||
/**
|
||||
* Show a menu with back button at top and handle selection
|
||||
* @param {Object} config - Menu configuration
|
||||
* @param {string} config.title - Menu title
|
||||
* @param {string} config.headerContent - Optional header content
|
||||
* @param {Array<{label: string, action: Function}>} config.items - Menu items with actions
|
||||
* @param {string} config.backLabel - Back button label (default: "← Back")
|
||||
* @param {number} config.defaultIndex - Default selected index (default: 0)
|
||||
* @param {Function} config.refresh - Optional refresh function to call after each action
|
||||
* @param {Array<string>} config.breadcrumb - Optional breadcrumb path
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function showMenuWithBack(config) {
|
||||
const {
|
||||
title,
|
||||
headerContent = "",
|
||||
items,
|
||||
backLabel = "← Back",
|
||||
defaultIndex = 0,
|
||||
refresh = null,
|
||||
breadcrumb = []
|
||||
} = config;
|
||||
|
||||
while (true) {
|
||||
// Call refresh if provided
|
||||
let refreshedData = null;
|
||||
if (refresh) {
|
||||
refreshedData = await refresh();
|
||||
if (refreshedData === null) {
|
||||
// Refresh failed, exit menu
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Build menu items with back at top
|
||||
const menuItems = [
|
||||
{ label: backLabel, icon: "☆" },
|
||||
...items.map(item => ({
|
||||
label: typeof item.label === "function" ? item.label(refreshedData) : item.label,
|
||||
icon: "☆"
|
||||
}))
|
||||
];
|
||||
|
||||
// Resolve headerContent if it's a function
|
||||
const resolvedHeader = typeof headerContent === "function"
|
||||
? await headerContent(refreshedData)
|
||||
: headerContent;
|
||||
|
||||
const selected = await selectMenu(
|
||||
title,
|
||||
menuItems,
|
||||
defaultIndex,
|
||||
"",
|
||||
resolvedHeader,
|
||||
breadcrumb
|
||||
);
|
||||
|
||||
// Back or ESC
|
||||
if (selected === -1 || selected === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute action for selected item
|
||||
const actionIndex = selected - 1;
|
||||
const item = items[actionIndex];
|
||||
|
||||
if (item && item.action) {
|
||||
const shouldContinue = await item.action(refreshedData);
|
||||
// If action returns false, exit menu
|
||||
if (shouldContinue === false) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a list menu where items are fetched dynamically
|
||||
* @param {Object} config - Menu configuration
|
||||
* @param {string} config.title - Menu title
|
||||
* @param {string} config.headerContent - Optional header content
|
||||
* @param {Function} config.fetchItems - Async function to fetch items array
|
||||
* @param {Function} config.formatItem - Function to format each item to {label, data}
|
||||
* @param {Function} config.onSelect - Action when item is selected
|
||||
* @param {Object} config.createAction - Optional create action {label, action}
|
||||
* @param {string} config.backLabel - Back button label
|
||||
* @param {Array<string>} config.breadcrumb - Optional breadcrumb path
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function showListMenu(config) {
|
||||
const {
|
||||
title,
|
||||
headerContent = "",
|
||||
fetchItems,
|
||||
formatItem,
|
||||
onSelect,
|
||||
createAction = null,
|
||||
backLabel = "← Back",
|
||||
breadcrumb = []
|
||||
} = config;
|
||||
|
||||
while (true) {
|
||||
// Fetch items
|
||||
const result = await fetchItems();
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = result.items || [];
|
||||
const metadata = result.metadata || {};
|
||||
|
||||
// Build menu items
|
||||
const menuItems = [{ label: backLabel, icon: "☆" }];
|
||||
|
||||
if (createAction) {
|
||||
menuItems.push({ label: createAction.label, icon: "☆" });
|
||||
}
|
||||
|
||||
items.forEach(item => {
|
||||
const formatted = formatItem(item);
|
||||
menuItems.push({ label: formatted, icon: "☆" });
|
||||
});
|
||||
|
||||
const header = typeof headerContent === "function"
|
||||
? await headerContent(metadata)
|
||||
: headerContent;
|
||||
|
||||
const selected = await selectMenu(title, menuItems, 0, "", header, breadcrumb);
|
||||
|
||||
// Back or ESC
|
||||
if (selected === -1 || selected === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create action
|
||||
if (createAction && selected === 1) {
|
||||
await createAction.action();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Select item
|
||||
const offset = createAction ? 2 : 1;
|
||||
const itemIndex = selected - offset;
|
||||
|
||||
if (itemIndex >= 0 && itemIndex < items.length) {
|
||||
await onSelect(items[itemIndex]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
showMenuWithBack,
|
||||
showListMenu
|
||||
};
|
||||
136
cli/src/cli/utils/modelSelector.js
Normal file
136
cli/src/cli/utils/modelSelector.js
Normal file
@@ -0,0 +1,136 @@
|
||||
const api = require("../api/client");
|
||||
const { prompt } = require("./input");
|
||||
const { clearScreen } = require("./display");
|
||||
|
||||
// Provider alias order: OAuth first, then API Key (matches ModelSelectModal)
|
||||
const PROVIDER_ALIAS_ORDER = [
|
||||
"cc", "ag", "cx", "if", "qw", "gc", "gh", "kr",
|
||||
"openrouter", "glm", "kimi", "minimax", "openai", "anthropic", "gemini"
|
||||
];
|
||||
|
||||
// Alias to display name mapping
|
||||
const PROVIDER_ALIAS_NAMES = {
|
||||
cc: "Claude Code",
|
||||
ag: "Antigravity",
|
||||
cx: "OpenAI Codex",
|
||||
if: "iFlow AI",
|
||||
qw: "Qwen Code",
|
||||
gc: "Gemini CLI",
|
||||
gh: "GitHub Copilot",
|
||||
kr: "Kiro AI",
|
||||
openrouter: "OpenRouter",
|
||||
glm: "GLM Coding",
|
||||
kimi: "Kimi Coding",
|
||||
minimax: "Minimax Coding",
|
||||
openai: "OpenAI",
|
||||
anthropic: "Anthropic",
|
||||
gemini: "Gemini"
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all available models grouped by provider + combos
|
||||
* @returns {Promise<{combos: Array, groups: Object}>}
|
||||
*/
|
||||
async function getAvailableModelsGrouped() {
|
||||
const result = await api.getAvailableModels();
|
||||
if (!result.success) return { combos: [], groups: {} };
|
||||
|
||||
const models = result.data?.data || [];
|
||||
const combos = [];
|
||||
const groups = {};
|
||||
|
||||
models.forEach(m => {
|
||||
if (m.owned_by === "combo") {
|
||||
combos.push(m.id);
|
||||
} else {
|
||||
const provider = m.owned_by;
|
||||
if (!groups[provider]) {
|
||||
groups[provider] = [];
|
||||
}
|
||||
groups[provider].push(m.id);
|
||||
}
|
||||
});
|
||||
|
||||
return { combos, groups };
|
||||
}
|
||||
|
||||
/**
|
||||
* Display model list and prompt for selection
|
||||
* @param {string} title - Title to display
|
||||
* @param {string} currentValue - Current selected value (optional)
|
||||
* @param {Object} options - { excludeCombos?: boolean }
|
||||
* @returns {Promise<string|null>} Selected model ID or null if cancelled
|
||||
*/
|
||||
async function selectModelFromList(title, currentValue = "", options = {}) {
|
||||
const { excludeCombos = false } = options;
|
||||
const { combos: rawCombos, groups } = await getAvailableModelsGrouped();
|
||||
const combos = excludeCombos ? [] : rawCombos;
|
||||
|
||||
const totalModels = combos.length + Object.values(groups).flat().length;
|
||||
if (totalModels === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build flat list for selection
|
||||
const allModels = [];
|
||||
|
||||
// Display
|
||||
clearScreen();
|
||||
console.log(`\n🎯 ${title}`);
|
||||
console.log("=".repeat(50));
|
||||
if (currentValue) {
|
||||
console.log(`Current: ${currentValue}\n`);
|
||||
} else {
|
||||
console.log();
|
||||
}
|
||||
|
||||
let idx = 1;
|
||||
|
||||
// Combos first (skipped when excludeCombos is true)
|
||||
if (combos.length > 0) {
|
||||
console.log("[Combos]");
|
||||
combos.forEach(combo => {
|
||||
console.log(` ${idx}. ${combo}`);
|
||||
allModels.push(combo);
|
||||
idx++;
|
||||
});
|
||||
console.log();
|
||||
}
|
||||
|
||||
// Provider groups in order (by alias)
|
||||
const sortedProviders = Object.keys(groups).sort((a, b) => {
|
||||
const idxA = PROVIDER_ALIAS_ORDER.indexOf(a);
|
||||
const idxB = PROVIDER_ALIAS_ORDER.indexOf(b);
|
||||
return (idxA === -1 ? 999 : idxA) - (idxB === -1 ? 999 : idxB);
|
||||
});
|
||||
|
||||
sortedProviders.forEach(provider => {
|
||||
const providerName = PROVIDER_ALIAS_NAMES[provider] || provider;
|
||||
console.log(`[${providerName}]`);
|
||||
groups[provider].forEach(model => {
|
||||
console.log(` ${idx}. ${model}`);
|
||||
allModels.push(model);
|
||||
idx++;
|
||||
});
|
||||
console.log();
|
||||
});
|
||||
|
||||
console.log(" 0. Cancel\n");
|
||||
|
||||
// Prompt for number input
|
||||
const input = await prompt("Enter number: ");
|
||||
const num = parseInt(input, 10);
|
||||
|
||||
if (isNaN(num) || num === 0 || num < 0 || num > allModels.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return allModels[num - 1];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
selectModelFromList,
|
||||
getAvailableModelsGrouped,
|
||||
PROVIDER_ALIAS_ORDER,
|
||||
PROVIDER_ALIAS_NAMES
|
||||
};
|
||||
3
cloud/.gitignore
vendored
3
cloud/.gitignore
vendored
@@ -1,3 +0,0 @@
|
||||
.wrangler/*
|
||||
node_modules/*
|
||||
**node_modules/*
|
||||
@@ -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**.
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"open-sse": ["../open-sse"],
|
||||
"open-sse/*": ["../open-sse/*"]
|
||||
},
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"target": "ESNext"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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" }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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": "*"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* Landing Page Service
|
||||
* Simple health check page for self-hosted worker
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create landing page response
|
||||
* @returns {Response} HTML response
|
||||
*/
|
||||
export function createLandingPageResponse() {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><head><title>9Router Worker</title></head>
|
||||
<body style="font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#0a0a0a;color:#fff">
|
||||
<div style="text-align:center">
|
||||
<h1>9Router Worker</h1>
|
||||
<p style="color:#888">Worker is running. Configure this URL in your 9Router dashboard.</p>
|
||||
</div>
|
||||
</body></html>`;
|
||||
|
||||
return new Response(html, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Cache-Control": "public, max-age=3600"
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import * as log from "../utils/logger.js";
|
||||
|
||||
// Request-scoped cache for getMachineData (avoids multiple D1 queries per request)
|
||||
const requestCache = new Map();
|
||||
const CACHE_TTL_MS = 5000;
|
||||
|
||||
/**
|
||||
* Get machine data from D1 (with request-scope caching)
|
||||
* @param {string} machineId
|
||||
* @param {Object} env
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
export async function getMachineData(machineId, env) {
|
||||
const cached = requestCache.get(machineId);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
const row = await env.DB.prepare("SELECT data FROM machines WHERE machineId = ?")
|
||||
.bind(machineId)
|
||||
.first();
|
||||
|
||||
if (!row) {
|
||||
log.debug("STORAGE", `Not found: ${machineId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = JSON.parse(row.data);
|
||||
requestCache.set(machineId, { data, timestamp: Date.now() });
|
||||
log.debug("STORAGE", `Retrieved: ${machineId}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save machine data to D1
|
||||
* @param {string} machineId
|
||||
* @param {Object} data
|
||||
* @param {Object} env
|
||||
*/
|
||||
export async function saveMachineData(machineId, data, env) {
|
||||
const now = new Date().toISOString();
|
||||
data.updatedAt = now;
|
||||
|
||||
// Upsert to D1
|
||||
await env.DB.prepare(`
|
||||
INSERT INTO machines (machineId, data, updatedAt)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(machineId) DO UPDATE SET data = ?, updatedAt = ?
|
||||
`)
|
||||
.bind(machineId, JSON.stringify(data), now, JSON.stringify(data), now)
|
||||
.run();
|
||||
|
||||
// Update cache after save
|
||||
requestCache.set(machineId, { data, timestamp: Date.now() });
|
||||
log.debug("STORAGE", `Saved: ${machineId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete machine data from D1
|
||||
* @param {string} machineId
|
||||
* @param {Object} env
|
||||
*/
|
||||
export async function deleteMachineData(machineId, env) {
|
||||
await env.DB.prepare("DELETE FROM machines WHERE machineId = ?")
|
||||
.bind(machineId)
|
||||
.run();
|
||||
|
||||
// Clear cache after delete
|
||||
requestCache.delete(machineId);
|
||||
log.debug("STORAGE", `Deleted: ${machineId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update specific fields in machine data (for token refresh, rate limit, etc.)
|
||||
* @param {string} machineId
|
||||
* @param {string} connectionId
|
||||
* @param {Object} updates
|
||||
* @param {Object} env
|
||||
*/
|
||||
export async function updateMachineProvider(machineId, connectionId, updates, env) {
|
||||
const data = await getMachineData(machineId, env);
|
||||
if (!data?.providers?.[connectionId]) return;
|
||||
|
||||
Object.assign(data.providers[connectionId], updates);
|
||||
data.providers[connectionId].updatedAt = new Date().toISOString();
|
||||
|
||||
await saveMachineData(machineId, data, env);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 []; }
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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"
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "9router-app",
|
||||
"version": "0.4.31",
|
||||
"version": "0.4.33",
|
||||
"description": "9Router web dashboard",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
Submodule src/mitm/dev deleted from ea459d42d0
@@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user