chore: import upstream snapshot with attribution
Deploy GitBook to 9router.github.io / build-deploy (push) Failing after 2s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:21:01 +08:00
commit 05fcd08057
1298 changed files with 194683 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
app/*
node_modules/*
+9
View File
@@ -0,0 +1,9 @@
# Ignore everything except what's in package.json "files"
*
!cli.js
!hooks/
!app/
!package.json
!README.md
!LICENSE
+42
View File
@@ -0,0 +1,42 @@
MIT License
Copyright (c) 2026 9Router Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+125
View File
@@ -0,0 +1,125 @@
# 9Router - FREE AI Router & Token Saver
**Never stop coding. Save 20-40% tokens with RTK + auto-fallback to FREE & cheap AI models.**
**Connect All AI Code Tools (Claude Code, Cursor, Antigravity, Copilot, Codex, Gemini, OpenCode, Cline, OpenClaw...) to 40+ AI Providers & 100+ Models.**
[![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router)
[![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router)
[![Docker Pulls](https://img.shields.io/docker/pulls/decolua/9router.svg?logo=docker&label=Docker%20pulls)](https://hub.docker.com/r/decolua/9router)
[![GHCR](https://img.shields.io/badge/GHCR-decolua%2F9router-blue?logo=github)](https://github.com/decolua/9router/pkgs/container/9router)
[![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE)
<a href="https://trendshift.io/repositories/22628" target="_blank"><img src="https://trendshift.io/api/badge/repositories/22628" alt="decolua%2F9router | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[🌐 Website](https://9router.com) • [📖 Full Docs](https://github.com/decolua/9router)
---
## 🤔 Why 9Router?
**Stop wasting money, tokens and hitting limits:**
- ❌ Subscription quota expires unused every month
- ❌ Rate limits stop you mid-coding
- ❌ Tool outputs (git diff, grep, ls...) burn tokens fast
- ❌ Expensive APIs ($20-50/month per provider)
**9Router solves this:**
-**RTK Token Saver** - Auto-compress tool_result, save 20-40% tokens
-**Maximize subscriptions** - Track quota, use every bit before reset
-**Auto fallback** - Subscription → Cheap → Free, zero downtime
-**Multi-account** - Round-robin between accounts per provider
-**Universal** - Works with any OpenAI/Claude-compatible CLI
---
## ⚡ Quick Start
**Option 1 — npm (recommended for desktop):**
```bash
npm install -g 9router
9router
# Or run directly with npx
npx 9router
```
**Option 2 — Docker (server/VPS):**
```bash
docker run -d --name 9router -p 20128:20128 \
-v "$HOME/.9router:/app/data" -e DATA_DIR=/app/data \
decolua/9router:latest
```
Published images: [Docker Hub](https://hub.docker.com/r/decolua/9router) • [GHCR](https://github.com/decolua/9router/pkgs/container/9router) (multi-platform amd64/arm64).
🎉 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/data.sqlite`
- **Windows**: `%APPDATA%/9router/db/data.sqlite`
- **Docker**: `/app/data/db/data.sqlite` (mount `$HOME/.9router` to persist)
---
## 📚 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.
Executable
+852
View File
@@ -0,0 +1,852 @@
#!/usr/bin/env node
const { spawn, exec, execSync } = require("child_process");
const path = require("path");
const fs = require("fs");
const https = require("https");
const net = require("net");
const os = require("os");
// Poll until the server accepts TCP connections on port, or timeout — avoids blind fixed waits.
function waitServerReady(port, { timeoutMs = 15000, intervalMs = 150 } = {}) {
const deadline = Date.now() + timeoutMs;
return new Promise((resolve) => {
const tryConnect = () => {
const socket = net.connect({ host: "127.0.0.1", port }, () => {
socket.destroy();
resolve(true);
});
socket.on("error", () => {
socket.destroy();
if (Date.now() >= deadline) return resolve(false);
setTimeout(tryConnect, intervalMs);
});
};
tryConnect();
});
}
// 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";
// First non-internal IPv4 — the address remote peers actually reach when bound to 0.0.0.0.
function getLanIp() {
for (const ifaces of Object.values(os.networkInterfaces())) {
for (const i of ifaces || []) {
if (i.family === "IPv4" && !i.internal) return i.address;
}
}
return null;
}
// Local URL stays "localhost"; warn separately when bound to all interfaces (network-exposed).
function getDisplayHost() {
return host === DEFAULT_HOST ? "localhost" : host;
}
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 {
// Background: MITM + tunnel/cloudflared run on separate ports/processes —
// killing them doesn't free the app port, so don't block the critical path.
// Server-side MITM manager has stale-lock recovery and starts deferred (~3s).
setImmediate(() => {
try { killProxyByPidFile(); } catch {}
try { killTunnelByPidFile(); } catch {}
try { killCloudflaredByAppPort(appPort); } catch {}
});
const platform = process.platform;
let pids = [];
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 => {
// Whitelist: real node process running 9router/cli.js, or next-server.
// Avoids killing editors/grep/strace/cursor that just have "9router" in cmdline.
const cmd = line.toLowerCase();
const isAppProcess =
(cmd.includes("node") && cmd.includes("9router") && (cmd.includes("cli.js") || cmd.includes("\\9router") || cmd.includes("/9router")))
|| cmd.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 => {
// Whitelist: real node process running 9router/cli.js, or next-server.
// Avoids killing grep/strace/editors/cursor that incidentally match "9router".
const cmd = line.toLowerCase();
const isAppProcess =
(cmd.includes("node") && cmd.includes("9router") && (cmd.includes("cli.js") || cmd.includes("/9router")))
|| cmd.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 MIT server by PID file (runs privileged, needs special handling)
// Sends SIGTERM first so MIT can clean up host entries before dying.
function killProxyByPidFile() {
try {
const pidFile = path.join(getAppDataDir(), "mitm", ".mitm.pid");
if (!fs.existsSync(pidFile)) return;
const pid = parseInt(fs.readFileSync(pidFile, "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(pidFile); } 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).
// Prefer custom-server.js (injects real socket IP) when present.
const standaloneDir = path.join(__dirname, "app");
const customServerPath = path.join(standaloneDir, "custom-server.js");
const serverPath = fs.existsSync(customServerPath)
? customServerPath
: 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);
}
// Start server immediately; run update check in parallel (not on the critical path).
const updatePromise = checkForUpdate();
killAllAppProcesses(port)
.then(() => killProcessOnPort(port))
.then(() => startServer(updatePromise));
// 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 = getDisplayHost();
// 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(updatePromise) {
// Accept either a Promise (parallel update check) or a resolved value.
const latestVersionPromise = Promise.resolve(updatePromise);
const displayHost = getDisplayHost();
const url = `http://${displayHost}:${port}/dashboard`;
// Surface real network exposure when bound to all interfaces (default 0.0.0.0).
if (host === DEFAULT_HOST) {
const lanIp = getLanIp();
if (lanIp) console.log(`\x1b[33m⚠ Network-exposed: reachable at http://${lanIp}:${port} (bound 0.0.0.0). Use --host 127.0.0.1 for local-only.\x1b[0m`);
}
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 MIT server (privileged process) via PID file
killProxyByPidFile();
// 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) {
// Ignore SIGHUP so macOS terminal close doesn't kill the background tray process
process.removeAllListeners("SIGHUP");
process.on("SIGHUP", () => {});
console.log(`\n🚀 ${pkg.name} v${pkg.version}`);
console.log(`Server: http://${displayHost}:${port}`);
waitServerReady(port).then(() => {
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");
});
return;
}
// Wait for server to be ready, then show interface menu loop + tray
waitServerReady(port).then(async () => {
// Resolve parallel update check (already running); don't block server start on it.
const latestVersion = await latestVersionPromise;
// 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") {
const { clearScreen } = require("./src/cli/utils/display");
clearScreen();
// Enable auto startup on OS boot
try {
const { enableAutoStart } = require("./src/cli/tray/autostart");
enableAutoStart(__filename);
} catch (e) { }
if (process.platform === "darwin") {
// macOS: keep current process alive — spawning a detached child puts
// it outside the login session so NSStatusItem silently fails.
process.removeAllListeners("SIGHUP");
process.on("SIGHUP", () => {});
console.log(`\n⏳ Switching to tray mode... (icon already visible in menu bar)`);
console.log(`🔔 9Router is running in tray (PID: ${process.pid})`);
console.log(` Server: http://${displayHost}:${port}`);
console.log(`\n💡 You can close this terminal. Right-click tray icon to quit.\n`);
// Tray already init'd at startup — just keep event loop alive.
return;
}
// Windows/Linux: spawn detached bgProcess (systray works fine in child)
console.log(`\n⏳ Starting background process... (tray icon will appear in ~3s)`);
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(`🔔 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 quit.\n`);
// cleanup() kills server so bgProcess can claim the port fresh
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);
}
});
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 MIT 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
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env node
// Postinstall: warm-up SQLite deps into ~/.9router/runtime so the first
// `9router` start doesn't need network. Failure here is non-fatal —
// cli.js will retry at runtime if anything is missing.
const { ensureSqliteRuntime } = require("./sqliteRuntime");
const { ensureTrayRuntime } = require("./trayRuntime");
try {
ensureSqliteRuntime({ silent: false });
console.log("[9router] runtime SQLite deps ready");
} catch (e) {
console.warn(`[9router] runtime warm-up skipped: ${e.message}`);
}
try {
ensureTrayRuntime({ silent: false });
} catch (e) {
console.warn(`[9router] tray runtime skipped: ${e.message}`);
}
process.exit(0);
+156
View File
@@ -0,0 +1,156 @@
// 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";
const SQL_JS_VERSION = "1.14.1";
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; }
}
// Extract a short, user-friendly reason from npm stderr.
function summarizeNpmError(stderr = "") {
const text = String(stderr);
if (/ENOTFOUND|ETIMEDOUT|EAI_AGAIN|network|getaddrinfo/i.test(text)) return "No internet connection or registry unreachable";
if (/EACCES|EPERM|permission denied/i.test(text)) return "Permission denied (check folder permissions)";
if (/ENOSPC|no space/i.test(text)) return "Not enough disk space";
if (/node-gyp|gyp ERR|python|MSBuild|Visual Studio|Xcode/i.test(text)) return "Missing build tools (Xcode CLT / Python / VS Build Tools)";
if (/ETARGET|version.*not found/i.test(text)) return "Package version not found on registry";
const m = text.match(/npm ERR! (.+)/);
if (m) return m[1].slice(0, 200);
const lastLine = text.trim().split(/\r?\n/).filter(Boolean).pop();
return lastLine ? lastLine.slice(0, 200) : "Unknown error";
}
function runNpmInstall({ cwd, pkgs, extraArgs = [], timeout = 180000 }) {
const args = ["install", ...pkgs, "--no-audit", "--no-fund", "--prefer-online", ...extraArgs];
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
const res = spawnSync(npmCmd, args, {
cwd,
stdio: ["ignore", "pipe", "pipe"],
timeout,
shell: process.platform === "win32",
encoding: "utf8",
});
return { ok: res.status === 0, code: res.status, stderr: res.stderr || "", stdout: res.stdout || "" };
}
function npmInstall(pkgs, opts = {}) {
const cwd = ensureRuntimeDir();
const extra = opts.optional ? ["--no-save"] : [];
if (!opts.silent) console.log("⏳ Installing SQLite engine (first run)...");
const res = runNpmInstall({ cwd, pkgs, extraArgs: extra, timeout: opts.timeout || 180000 });
if (!res.ok && !opts.silent) {
const reason = summarizeNpmError(res.stderr);
console.warn("⚠️ SQLite engine install failed — using fallback");
console.warn(` Reason: ${reason}`);
console.warn(` Retry: cd "${cwd}" && npm install ${pkgs.join(" ")}`);
}
return res.ok;
}
// Public: ensure better-sqlite3 native module is installed in user-writable
// runtime dir. sql.js may be bundled in bin/app, but npm publish strips .wasm
// from nested node_modules — verify and reinstall if missing. node:sqlite is
// built-in. This is purely a *speed optimization* — app works without
// better-sqlite3 via fallbacks.
function isSqlJsWasmValid() {
const bundledWasm = path.join(__dirname, "..", "app", "node_modules", "sql.js", "dist", "sql-wasm.wasm");
if (fs.existsSync(bundledWasm)) return true;
const runtimeWasm = path.join(getRuntimeNodeModules(), "sql.js", "dist", "sql-wasm.wasm");
return fs.existsSync(runtimeWasm);
}
function ensureSqliteRuntime({ silent = false } = {}) {
ensureRuntimeDir();
let sqlJsOk = isSqlJsWasmValid();
if (!sqlJsOk) {
sqlJsOk = npmInstall([`sql.js@${SQL_JS_VERSION}`], { silent });
if (sqlJsOk) sqlJsOk = isSqlJsWasmValid();
}
const needBetterSqlite = !hasModule("better-sqlite3") || !isBetterSqliteBinaryValid();
if (!needBetterSqlite) {
if (!silent) console.log("✅ SQLite engine ready");
return { betterSqlite: true, sqlJs: sqlJsOk };
}
const ok = npmInstall([`better-sqlite3@${BETTER_SQLITE3_VERSION}`], { optional: true, silent });
return {
betterSqlite: ok && hasModule("better-sqlite3") && isBetterSqliteBinaryValid(),
sqlJs: sqlJsOk,
};
}
// 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,
runNpmInstall,
summarizeNpmError,
};
+107
View File
@@ -0,0 +1,107 @@
// Lazy install systray2 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).
//
// We use the maintained `systray2` fork. The original `systray@1.0.5` package
// bundles a 2017 x86_64 Go binary whose Mach-O headers are rejected by modern
// dyld (macOS 14+), so the tray silently fails to register on Apple Silicon.
const { spawnSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const { getRuntimeDir, getRuntimeNodeModules, runNpmInstall, summarizeNpmError } = require("./sqliteRuntime");
const SYSTRAY_PKG = "systray2";
const SYSTRAY_VERSION = "2.1.4";
const LEGACY_SYSTRAY_PKG = "systray";
function hasSystray() {
return fs.existsSync(path.join(getRuntimeNodeModules(), SYSTRAY_PKG, "package.json"));
}
// Remove the legacy `systray` package from all known locations.
// On Windows it was an AV false-positive risk; on macOS/Linux its bundled
// binary is broken on modern OS versions.
function cleanupLegacySystray({ silent = false } = {}) {
// 1) Runtime dir: ~/.9router/runtime/node_modules/systray (or %APPDATA% on Win)
// 2) npm global nested: <npm_prefix>/node_modules/9router/node_modules/systray
// __dirname here = <pkg root>/hooks → up 1 = pkg root
const targets = [
path.join(getRuntimeNodeModules(), LEGACY_SYSTRAY_PKG),
path.join(__dirname, "..", "node_modules", LEGACY_SYSTRAY_PKG)
];
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}`);
}
}
}
}
// systray2's npm tarball sometimes ships the bundled Go binary without the
// executable bit set on macOS, causing spawn() to fail with EACCES. Set +x
// best-effort so the tray actually starts.
function chmodSystrayBin({ silent = false } = {}) {
if (process.platform === "win32") return;
const binName = process.platform === "darwin" ? "tray_darwin_release" : "tray_linux_release";
const binPath = path.join(getRuntimeNodeModules(), SYSTRAY_PKG, "traybin", binName);
if (!fs.existsSync(binPath)) return;
try {
fs.chmodSync(binPath, 0o755);
} catch (e) {
if (!silent) console.warn(`[9router][runtime] chmod tray bin failed: ${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();
if (!silent) console.log("⏳ Installing system tray (first run)...");
const res = runNpmInstall({ cwd, pkgs, extraArgs: ["--no-save"], timeout: 120000 });
if (!res.ok && !silent) {
const reason = summarizeNpmError(res.stderr);
console.warn("⚠️ System tray install failed — tray disabled");
console.warn(` Reason: ${reason}`);
console.warn(` Retry: cd "${cwd}" && npm install ${pkgs.join(" ")}`);
}
return res.ok;
}
// Public: ensure systray2 is installed on macOS/Linux only.
// Windows skips entirely (uses PowerShell tray).
function ensureTrayRuntime({ silent = false } = {}) {
// Always evict the legacy `systray` package — its binary is broken on
// modern macOS and an AV false-positive on Windows.
cleanupLegacySystray({ silent });
if (process.platform === "win32") {
return { systray: false, skipped: true };
}
if (hasSystray()) {
chmodSystrayBin({ silent });
if (!silent) console.log("✅ System tray ready");
return { systray: true };
}
const ok = npmInstall([`${SYSTRAY_PKG}@${SYSTRAY_VERSION}`], { silent });
if (ok) chmodSystrayBin({ silent });
return { systray: ok && hasSystray() };
}
module.exports = { ensureTrayRuntime };
+48
View File
@@ -0,0 +1,48 @@
{
"name": "9router",
"version": "0.5.30",
"description": "9Router CLI - Start and manage 9Router server",
"bin": {
"9router": "./cli.js"
},
"files": [
"cli.js",
"src",
"hooks",
"app",
"README.md",
"LICENSE"
],
"scripts": {
"dev": "nodemon -I --watch cli.js --watch src --watch hooks --ext js,json cli.js",
"build": "node scripts/build-cli.js",
"pack:cli": "npm run build && npm pack --pack-destination ../..",
"publish:cli": "npm run build && npm publish",
"postinstall": "node hooks/postinstall.js",
"prepublishOnly": "npm run build"
},
"dependencies": {
"enquirer": "^2.4.1",
"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": "systray2 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 unsigned Go binaries that trigger antivirus false positives (Kaspersky). We use the systray2 fork because the legacy systray@1.0.5 ships a 2017 x86_64 binary that fails on modern macOS dyld.",
"engines": {
"node": ">=18.0.0"
},
"keywords": [
"9router",
"cli",
"proxy",
"ai",
"api"
],
"license": "MIT",
"devDependencies": {
"esbuild": "^0.25.12",
"nodemon": "^3.1.14"
}
}
+281
View File
@@ -0,0 +1,281 @@
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const { execSync } = require("child_process");
const cliDir = path.resolve(__dirname, "..");
const appDir = path.resolve(cliDir, "..");
const rootDir = path.resolve(appDir, "..");
const cliAppDir = process.env.NINEROUTER_CLI_APP_DIR || path.join(cliDir, "app");
const buildHomeDir = path.join(cliDir, ".build-home");
const buildDistDirName = ".next-cli-build";
const buildDistDir = path.join(appDir, buildDistDirName);
// Exclude patterns for files/folders we don't want to copy
const EXCLUDE_PATTERNS = [
"@img", // Sharp image processing (not needed with unoptimized images)
"sharp", // Sharp core lib (not needed with unoptimized images)
"detect-libc", // Sharp dependency
".env", // Environment files
".env.local",
".env.*.local",
"*.log", // Log files
"tmp", // Temp files
".DS_Store", // macOS files
];
function shouldExclude(name) {
return EXCLUDE_PATTERNS.some(pattern => {
if (pattern.includes("*")) {
const regex = new RegExp("^" + pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$");
return regex.test(name);
}
return name === pattern;
});
}
function copyRecursive(src, dest) {
if (!fs.existsSync(src)) {
console.warn(`Warning: Source ${src} does not exist`);
return;
}
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true });
}
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
if (shouldExclude(entry.name)) {
continue;
}
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
// Skip broken symlinks (common in workspace setups)
try {
fs.accessSync(srcPath);
} catch {
continue;
}
if (entry.isDirectory()) {
copyRecursive(srcPath, destPath);
} else if (entry.isSymbolicLink()) {
// Resolve and copy target (avoid linking outside bundle)
try {
const real = fs.realpathSync(srcPath);
if (fs.statSync(real).isDirectory()) {
copyRecursive(real, destPath);
} else {
fs.copyFileSync(real, destPath);
}
} catch {}
} else {
try {
fs.copyFileSync(srcPath, destPath);
} catch {}
}
}
}
console.log("📦 Building 9Router CLI package with Next.js...\n");
fs.mkdirSync(buildHomeDir, { recursive: true });
fs.mkdirSync(path.join(buildHomeDir, "AppData", "Roaming"), { recursive: true });
fs.mkdirSync(path.join(buildHomeDir, "AppData", "Local"), { recursive: true });
// Step 0: Sync version from app/cli/package.json to app/package.json
console.log("0️⃣ Syncing version to app/package.json...");
const cliPkg = JSON.parse(fs.readFileSync(path.join(cliDir, "package.json"), "utf8"));
const appPkgPath = path.join(appDir, "package.json");
const appPkg = JSON.parse(fs.readFileSync(appPkgPath, "utf8"));
if (appPkg.version !== cliPkg.version) {
appPkg.version = cliPkg.version;
fs.writeFileSync(appPkgPath, JSON.stringify(appPkg, null, 2) + "\n");
console.log(`✅ Version synced: ${cliPkg.version}\n`);
} else {
console.log(`✅ Version already synced: ${cliPkg.version}\n`);
}
// Step 1: Build app with Next.js (workspace tracing root → traced node_modules in standalone).
console.log("1️⃣ Building Next.js app...");
try {
execSync("npm run build", {
stdio: "inherit",
cwd: appDir,
env: {
...process.env,
HOME: buildHomeDir,
USERPROFILE: buildHomeDir,
APPDATA: path.join(buildHomeDir, "AppData", "Roaming"),
LOCALAPPDATA: path.join(buildHomeDir, "AppData", "Local"),
NEXT_DIST_DIR: buildDistDirName,
NEXT_TRACING_ROOT_MODE: "workspace",
}
});
console.log("✅ Next.js build completed\n");
} catch (error) {
console.error("❌ Next.js build failed");
process.exit(1);
}
// Step 2: Clean old app/cli/app if exists
console.log("2️⃣ Cleaning old app/cli/app...");
if (fs.existsSync(cliAppDir)) {
fs.rmSync(cliAppDir, { recursive: true, force: true });
}
console.log("✅ Cleaned\n");
// Step 3: Copy Next.js standalone build to app/cli/app.
// Newer Next.js standalone output writes server.js/package.json plus .next/, src/, and
// node_modules/ directly under .next/standalone. Older builds may still use a nested app/.
console.log("3️⃣ Copying Next.js standalone build to app/cli/app...");
const standaloneRoot = path.join(appDir, ".next", "standalone");
const standaloneRootResolved = path.join(buildDistDir, "standalone");
let standaloneRootToUse = fs.existsSync(standaloneRootResolved) ? standaloneRootResolved : standaloneRoot;
// Next.js 16 nests standalone output under the project name when NEXT_TRACING_ROOT_MODE=workspace
// e.g. .next-cli-build/standalone/9router/server.js
const pkgName = path.basename(appDir);
const nestedRoot = path.join(standaloneRootToUse, pkgName);
if (fs.existsSync(path.join(nestedRoot, "server.js")) && !fs.existsSync(path.join(standaloneRootToUse, "server.js"))) {
console.log(`️ Detected nested standalone output: ${pkgName}/`);
standaloneRootToUse = nestedRoot;
}
const standaloneApp = fs.existsSync(path.join(standaloneRootToUse, "server.js"))
? standaloneRootToUse
: path.join(standaloneRootToUse, "app");
if (!fs.existsSync(standaloneApp)) {
console.error("❌ Next.js standalone build not found under .next/standalone");
console.error("Expected either .next/standalone/server.js or .next/standalone/app/");
process.exit(1);
}
copyRecursive(standaloneApp, cliAppDir);
// Older nested-app layout stores traced node_modules at standalone root.
const standaloneNodeModules = path.join(standaloneRootToUse, "node_modules");
if (standaloneApp !== standaloneRootToUse && fs.existsSync(standaloneNodeModules)) {
copyRecursive(standaloneNodeModules, path.join(cliAppDir, "node_modules"));
}
console.log("✅ Copied standalone build\n");
// Step 3a: Copy custom server (injects real socket IP, strips spoofable XFF).
const customServerSrc = path.join(appDir, "custom-server.js");
if (fs.existsSync(customServerSrc)) {
fs.copyFileSync(customServerSrc, path.join(cliAppDir, "custom-server.js"));
console.log("✅ Copied custom-server.js\n");
} else {
console.warn("⚠️ custom-server.js not found — server will run without real-IP injection\n");
}
// Step 3b: Ensure sql.js (pure JS fallback) bundled in app/cli/app/node_modules.
// Strip better-sqlite3 (native) — it lives in ~/.9router/runtime to avoid
// Windows EBUSY during global CLI updates. node:sqlite (Node ≥22.5) is also
// available as a no-install middle tier.
console.log("3️⃣ b Configuring SQLite drivers...");
function ensureModuleInBundle(pkg) {
const dest = path.join(cliAppDir, "node_modules", pkg);
if (fs.existsSync(dest)) {
console.log(`${pkg} already bundled`);
return;
}
const candidates = [
path.join(appDir, "node_modules", pkg),
path.join(rootDir, "node_modules", pkg),
];
const src = candidates.find((p) => fs.existsSync(p));
if (!src) {
console.warn(`⚠️ ${pkg} not found locally — bundle will rely on node:sqlite or runtime install`);
return;
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
copyRecursive(src, dest);
console.log(`✅ Bundled ${pkg}`);
}
ensureModuleInBundle("sql.js");
const betterDir = path.join(cliAppDir, "node_modules", "better-sqlite3");
if (fs.existsSync(betterDir)) {
fs.rmSync(betterDir, { recursive: true, force: true });
console.log("✅ Stripped better-sqlite3 (lives in ~/.9router/runtime)");
}
console.log("");
// Step 4: Copy static files
console.log("4️⃣ Copying static files...");
const staticSrc = path.join(appDir, ".next", "static");
const staticSrcResolved = path.join(buildDistDir, "static");
const staticDest = path.join(cliAppDir, buildDistDirName, "static");
if (fs.existsSync(staticSrcResolved) || fs.existsSync(staticSrc)) {
copyRecursive(fs.existsSync(staticSrcResolved) ? staticSrcResolved : staticSrc, staticDest);
console.log("✅ Copied static files\n");
} else {
console.log("⏭️ No static files found\n");
}
// Step 5: Copy public folder if exists
console.log("5️⃣ Copying public folder...");
const publicSrc = path.join(appDir, "public");
const publicDest = path.join(cliAppDir, "public");
if (fs.existsSync(publicSrc)) {
copyRecursive(publicSrc, publicDest);
console.log("✅ Copied public folder\n");
} else {
console.log("⏭️ No public folder found\n");
}
// Step 6: Copy vendor-chunks (required for production)
console.log("6️⃣ Copying vendor-chunks...");
const vendorChunksSrc = path.join(appDir, ".next", "server", "vendor-chunks");
const vendorChunksSrcResolved = path.join(buildDistDir, "server", "vendor-chunks");
const vendorChunksDest = path.join(cliAppDir, buildDistDirName, "server", "vendor-chunks");
if (fs.existsSync(vendorChunksSrcResolved) || fs.existsSync(vendorChunksSrc)) {
copyRecursive(fs.existsSync(vendorChunksSrcResolved) ? vendorChunksSrcResolved : vendorChunksSrc, vendorChunksDest);
console.log("✅ Copied vendor-chunks\n");
} else {
console.log("⏭️ No vendor-chunks found\n");
}
// Step 7: Copy MITM server files (not bundled by Next.js standalone)
console.log("7️⃣ Copying MITM server files...");
const mitmSrc = path.join(appDir, "src", "mitm");
const mitmDest = path.join(cliAppDir, "src", "mitm");
if (fs.existsSync(mitmSrc)) {
copyRecursive(mitmSrc, mitmDest);
console.log("✅ Copied MITM files\n");
} else {
console.log("⏭️ No MITM files found\n");
}
// Step 7b: Copy standalone updater (headless Node process for install progress)
console.log("7️⃣ b Copying updater files...");
const updaterSrc = path.join(appDir, "src", "lib", "updater");
const updaterDest = path.join(cliAppDir, "src", "lib", "updater");
if (fs.existsSync(updaterSrc)) {
copyRecursive(updaterSrc, updaterDest);
console.log("✅ Copied updater files\n");
} else {
console.log("⏭️ No updater files found\n");
}
// Step 8: Build MITM server (config driven - see app/cli/scripts/buildMitm.js)
console.log("8️⃣ Building MITM server...");
try {
execSync("node scripts/buildMitm.js", { stdio: "inherit", cwd: cliDir });
console.log("✅ MITM server build completed\n");
} catch (error) {
console.error("❌ MITM build failed");
process.exit(1);
}
console.log("✨ CLI package build completed!");
console.log(`📁 Output: ${cliAppDir}`);
try {
const { execSync: exec } = require("child_process");
const size = exec(`du -sh "${cliAppDir}"`, { encoding: "utf8" }).trim();
console.log(`📊 Package size: ${size.split("\t")[0]}`);
} catch (e) {
// Silent fail on size check
}
+71
View File
@@ -0,0 +1,71 @@
const esbuild = require("esbuild");
const fs = require("fs");
const path = require("path");
// ── Build config ─────────────────────────────────────────
const BUILD_CONFIG = {
bundle: true,
minify: true,
cleanPlainFiles: true,
};
// ─────────────────────────────────────────────────────────
const cliDir = path.resolve(__dirname, "..");
const appDir = path.resolve(cliDir, "..");
const cliAppDir = process.env.NINEROUTER_CLI_APP_DIR || path.join(cliDir, "app");
const cliMitmDir = path.join(cliAppDir, "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(cliMitmDir, 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) {
await esbuild.build({
entryPoints: [path.join(mitmSrc, entry)],
bundle: true,
minify: BUILD_CONFIG.minify,
platform: "node",
target: "node18",
external: EXTERNALS,
plugins: [buildPlugin],
outfile: output,
});
steps.push("bundled");
if (BUILD_CONFIG.minify) steps.push("minified");
}
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(cliMitmDir)) {
if (!keep.has(name)) fs.rmSync(path.join(cliMitmDir, name), { recursive: true, force: true });
}
console.log("✅ Removed plain MITM files from CLI bundle");
}
}
run().catch((e) => { console.error(e); process.exit(1); });
+556
View File
@@ -0,0 +1,556 @@
const http = require("http");
const https = require("https");
const crypto = require("crypto");
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
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";
const APP_NAME = "9router";
function getDataDir() {
if (process.env.DATA_DIR) return process.env.DATA_DIR;
if (process.platform === "win32") {
return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), APP_NAME);
}
return path.join(os.homedir(), `.${APP_NAME}`);
}
const MACHINE_ID_FILE = path.join(getDataDir(), "machine-id");
const AUTH_DIR = path.join(getDataDir(), "auth");
const CLI_SECRET_FILE = path.join(AUTH_DIR, "cli-secret");
let config = { ...DEFAULT_CONFIG };
let cachedCliToken = null;
let cachedCliSecret = null;
// Read raw machineId from shared file (written by server) → guarantees token match
function loadRawMachineId() {
try {
const raw = fs.readFileSync(MACHINE_ID_FILE, "utf8").trim();
if (raw) return raw;
} catch {}
try { return machineIdSync(); } catch { return ""; }
}
// Random secret shared with server via file → token unpredictable from machineId alone.
function loadCliSecret() {
if (cachedCliSecret) return cachedCliSecret;
try {
cachedCliSecret = fs.readFileSync(CLI_SECRET_FILE, "utf8").trim();
if (cachedCliSecret) return cachedCliSecret;
} catch {}
cachedCliSecret = crypto.randomBytes(32).toString("hex");
try {
fs.mkdirSync(AUTH_DIR, { recursive: true });
fs.writeFileSync(CLI_SECRET_FILE, cachedCliSecret, { mode: 0o600 });
} catch {}
return cachedCliSecret;
}
function getCliToken() {
if (cachedCliToken !== null) return cachedCliToken;
const raw = loadRawMachineId();
const secret = loadCliSecret();
cachedCliToken = raw ? crypto.createHash("sha256").update(raw + CLI_TOKEN_SALT + secret).digest("hex").substring(0, 16) : "";
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);
}
/**
* Reset dashboard password to default (clears stored hash server-side)
* @returns {Promise<Object>} { success }
*/
async function resetPassword() {
return makeRequest("POST", "/api/auth/reset-password");
}
// ============================================================================
// 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,
resetPassword,
// Tunnel
getTunnelStatus,
enableTunnel,
disableTunnel,
// Models
getModels,
getAvailableModels,
// Provider Nodes (custom providers)
getProviderNodes,
createProviderNode,
updateProviderNode,
deleteProviderNode,
validateProviderNode,
};
+233
View File
@@ -0,0 +1,233 @@
const api = require("../api/client");
const { prompt, confirm, pause } = require("../utils/input");
const { clearScreen, showStatus, showHeader } = require("../utils/display");
const { maskKey, formatDate, getRelativeTime } = require("../utils/format");
const { showMenuWithBack } = require("../utils/menuHelper");
const { copyToClipboard } = require("../utils/clipboard");
const { getEndpoint } = require("../utils/endpoint");
/**
* Display API keys list with formatted output
* @param {Array} keys - Array of API key objects
* @param {number} port - Server port
*/
function displayApiKeys(keys, port) {
console.log("┌─────────────────────────────────────────────────────────┐");
console.log("│ 🔑 API Keys Management │");
console.log("├─────────────────────────────────────────────────────────┤");
// Note: This function is legacy, endpoint shown in menu header instead
console.log("│ │");
if (keys.length === 0) {
console.log("│ No API keys found. │");
} else {
console.log(`│ Your API Keys (${keys.length}):${" ".repeat(42 - String(keys.length).length)}`);
keys.forEach((key, index) => {
console.log("│ │");
console.log(`${index + 1}. ${key.name}${" ".repeat(52 - String(index + 1).length - key.name.length)}`);
const maskedKey = maskKey(key.key);
console.log(`│ Key: ${maskedKey}${" ".repeat(47 - maskedKey.length)}`);
const created = formatDate(key.createdAt);
console.log(`│ Created: ${created}${" ".repeat(43 - created.length)}`);
if (key.lastUsedAt) {
const lastUsed = getRelativeTime(key.lastUsedAt);
console.log(`│ Last used: ${lastUsed}${" ".repeat(41 - lastUsed.length)}`);
} else {
console.log("│ Last used: Never │");
}
});
}
console.log("│ │");
console.log("│ Actions: │");
console.log("│ 1. Create New API Key │");
console.log("│ 2. View Full Key (by number) │");
console.log("│ 3. Copy Key to Clipboard (by number) │");
console.log("│ 4. Delete Key (by number) │");
console.log("│ 0. ← Back to Main Menu │");
console.log("└─────────────────────────────────────────────────────────┘");
}
/**
* Handle creating new API key
* @returns {Promise<boolean>} Success status
*/
async function handleCreateKey() {
console.log("\n📝 Create New API Key");
console.log("─".repeat(30));
const name = await prompt("Enter key name: ");
if (!name) {
showStatus("Key name cannot be empty", "error");
await pause();
return false;
}
const result = await api.createApiKey(name);
if (!result.success) {
showStatus(`Failed to create key: ${result.error}`, "error");
await pause();
return false;
}
console.log("\n✅ API Key created successfully!");
console.log("\n⚠️ IMPORTANT: Save this key now. You won't be able to see it again!");
console.log(`\nKey: ${result.data.key}`);
console.log(`Name: ${result.data.name}`);
console.log(`ID: ${result.data.id}`);
const shouldCopy = await confirm("\nCopy key to clipboard?");
if (shouldCopy) {
if (copyToClipboard(result.data.key)) {
showStatus("Key copied to clipboard!", "success");
} else {
showStatus("Failed to copy to clipboard", "error");
}
}
await pause();
return true;
}
/**
* Handle viewing full API key
* @param {Object} key - API key object
*/
async function handleViewFullKey(key) {
console.log("\n🔍 Full API Key");
console.log("─".repeat(30));
console.log(`Name: ${key.name}`);
console.log(`Key: ${key.key}`);
console.log(`ID: ${key.id}`);
console.log(`Created: ${formatDate(key.createdAt)}`);
if (key.lastUsedAt) {
console.log(`Last used: ${getRelativeTime(key.lastUsedAt)}`);
} else {
console.log("Last used: Never");
}
await pause();
}
/**
* Handle copying API key to clipboard
* @param {Object} key - API key object
*/
async function handleCopyKey(key) {
if (copyToClipboard(key.key)) {
showStatus(`Key "${key.name}" copied to clipboard!`, "success");
} else {
showStatus("Failed to copy to clipboard", "error");
}
await pause();
}
/**
* Handle deleting API key
* @param {Object} key - API key object
* @returns {Promise<boolean>} Success status
*/
async function handleDeleteKey(key) {
console.log(`\n⚠️ Delete API Key: ${key.name}`);
console.log("─".repeat(30));
console.log(`Key: ${maskKey(key.key)}`);
console.log(`Created: ${formatDate(key.createdAt)}`);
const confirmed = await confirm("\nAre you sure you want to delete this key?");
if (!confirmed) {
showStatus("Deletion cancelled", "info");
await pause();
return false;
}
const result = await api.deleteApiKey(key.id);
if (!result.success) {
showStatus(`Failed to delete key: ${result.error}`, "error");
await pause();
return false;
}
showStatus("API key deleted successfully", "success");
await pause();
return true;
}
/**
* Show actions for a specific key
* @param {Object} key - API key object
* @param {number} port - Server port
* @param {Array<string>} breadcrumb - Breadcrumb path
*/
async function showKeyActions(key, port, breadcrumb = []) {
const { endpoint } = await getEndpoint(port);
await showMenuWithBack({
title: `🔑 ${key.name}`,
breadcrumb: [...breadcrumb, key.name],
headerContent: `Name: ${key.name}\nKey: ${key.key}\nEndpoint: ${endpoint}`,
items: [
{
label: "Copy to Clipboard",
action: async () => {
await handleCopyKey(key);
return true;
}
},
{
label: "Delete Key",
action: async () => {
await handleDeleteKey(key);
return false; // Exit after delete
}
}
]
});
}
/**
* Main API Keys menu
* @param {number} port - Server port number
* @param {Array<string>} breadcrumb - Breadcrumb path
*/
async function showApiKeysMenu(port, breadcrumb = []) {
const { showListMenu } = require("../utils/menuHelper");
const { endpoint } = await getEndpoint(port);
await showListMenu({
title: "🔑 API Keys Management",
breadcrumb,
headerContent: `Endpoint: ${endpoint}`,
fetchItems: async () => {
const result = await api.getApiKeys();
if (!result.success) {
clearScreen();
showStatus(`Failed to fetch API keys: ${result.error}`, "error");
await pause();
return null;
}
return { items: result.data.keys || [] };
},
formatItem: (key) => `${key.name} (${maskKey(key.key)})`,
onSelect: async (key) => {
await showKeyActions(key, port, breadcrumb);
},
createAction: {
label: "Create New API Key",
action: async () => {
await handleCreateKey();
}
}
});
}
module.exports = {
showApiKeysMenu
};
+618
View File
@@ -0,0 +1,618 @@
const api = require("../api/client");
const { pause, confirm } = require("../utils/input");
const { showStatus } = require("../utils/display");
const { selectModelFromList } = require("../utils/modelSelector");
const { showMenuWithBack } = require("../utils/menuHelper");
const { getEndpoint } = require("../utils/endpoint");
const COLORS = {
reset: "\x1b[0m",
green: "\x1b[32m",
red: "\x1b[31m",
dim: "\x1b[2m",
cyan: "\x1b[36m"
};
// Claude model types with defaults (matching Web UI)
const CLAUDE_MODEL_TYPES = [
{ id: "sonnet", name: "Sonnet", envKey: "ANTHROPIC_DEFAULT_SONNET_MODEL", defaultValue: "cc/claude-sonnet-4-5-20250929" },
{ id: "opus", name: "Opus", envKey: "ANTHROPIC_DEFAULT_OPUS_MODEL", defaultValue: "cc/claude-opus-4-5-20251101" },
{ id: "haiku", name: "Haiku", envKey: "ANTHROPIC_DEFAULT_HAIKU_MODEL", defaultValue: "cc/claude-haiku-4-5-20251001" },
];
// ─── Shared helpers ───────────────────────────────────────────────────────────
/**
* Get first available API key from server
* @returns {Promise<string|null>}
*/
async function getFirstApiKey() {
const result = await api.getApiKeys();
const keys = result.success ? (result.data.keys || []) : [];
return keys.length > 0 ? keys[0].key : null;
}
// ─── Claude Code ──────────────────────────────────────────────────────────────
/**
* Build header showing current Claude config status
* @returns {Promise<string>}
*/
async function buildClaudeHeader() {
const result = await api.getCliToolSettings("claude");
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
const settings = result.data.settings;
const currentUrl = settings?.env?.ANTHROPIC_BASE_URL;
const currentKey = settings?.env?.ANTHROPIC_AUTH_TOKEN;
const lines = [];
if (currentUrl) {
lines.push(`Status: ${COLORS.green}✓ Configured${COLORS.reset}`);
lines.push(`Endpoint: ${COLORS.cyan}${currentUrl}${COLORS.reset}`);
if (currentKey) {
lines.push(`API Key: ${COLORS.dim}${currentKey.substring(0, 10)}...${COLORS.reset}`);
}
} else {
lines.push(`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`);
lines.push(`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`);
}
return lines.join("\n");
}
/**
* Get current Claude model from settings
* @param {string} envKey
* @returns {Promise<string>}
*/
async function getClaudeModel(envKey) {
const result = await api.getCliToolSettings("claude");
return result.success ? (result.data.settings?.env?.[envKey] || "Not set") : "Not set";
}
/**
* Quick setup for Claude Code — sets endpoint, key, and all default models
* @param {number} port
*/
async function claudeQuickSetup(port) {
const { endpoint } = await getEndpoint(port);
const apiKey = await getFirstApiKey();
if (!apiKey) {
showStatus("No API keys found. Create one in API Keys menu first.", "error");
await pause();
return;
}
const env = { ANTHROPIC_BASE_URL: endpoint, ANTHROPIC_AUTH_TOKEN: apiKey, API_TIMEOUT_MS: "600000" };
CLAUDE_MODEL_TYPES.forEach(t => { env[t.envKey] = t.defaultValue; });
const result = await api.applyCliToolSettings("claude", { env });
showStatus(result.success ? "Quick Setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Select and save a specific Claude model type
* @param {Object} modelType
* @param {number} port
*/
async function claudeSelectModel(modelType, port) {
const current = await getClaudeModel(modelType.envKey);
const selected = await selectModelFromList(`Select ${modelType.name} Model`, current, { excludeCombos: true });
if (!selected) return;
const env = { [modelType.envKey]: selected };
// Also set base URL if not configured yet
const settingsResult = await api.getCliToolSettings("claude");
if (!settingsResult.data?.settings?.env?.ANTHROPIC_BASE_URL) {
const { endpoint } = await getEndpoint(port);
const apiKey = await getFirstApiKey();
env.ANTHROPIC_BASE_URL = endpoint;
env.API_TIMEOUT_MS = "600000";
if (apiKey) env.ANTHROPIC_AUTH_TOKEN = apiKey;
}
const result = await api.applyCliToolSettings("claude", { env });
showStatus(result.success ? `${modelType.name}${selected} saved!` : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Reset Claude Code settings
*/
async function claudeReset() {
const result = await api.resetCliToolSettings("claude");
showStatus(result.success ? "Settings reset successfully!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Claude Code submenu
* @param {number} port
* @param {Array<string>} breadcrumb
*/
async function showClaudeCodeMenu(port, breadcrumb = []) {
await showMenuWithBack({
title: "🔧 Claude Code Settings",
breadcrumb,
headerContent: buildClaudeHeader,
refresh: async () => ({
sonnet: await getClaudeModel("ANTHROPIC_DEFAULT_SONNET_MODEL"),
opus: await getClaudeModel("ANTHROPIC_DEFAULT_OPUS_MODEL"),
haiku: await getClaudeModel("ANTHROPIC_DEFAULT_HAIKU_MODEL"),
}),
items: [
{
label: "⚡ Quick Setup (recommended)",
action: async () => { await claudeQuickSetup(port); return true; }
},
{
label: (d) => `Sonnet → ${d.sonnet}`,
action: async () => { await claudeSelectModel(CLAUDE_MODEL_TYPES[0], port); return true; }
},
{
label: (d) => `Opus → ${d.opus}`,
action: async () => { await claudeSelectModel(CLAUDE_MODEL_TYPES[1], port); return true; }
},
{
label: (d) => `Haiku → ${d.haiku}`,
action: async () => { await claudeSelectModel(CLAUDE_MODEL_TYPES[2], port); return true; }
},
{
label: "Reset to Default",
action: async () => { await claudeReset(); return true; }
}
]
});
}
// ─── Codex CLI ────────────────────────────────────────────────────────────────
/**
* Build header showing current Codex config status
* @returns {Promise<string>}
*/
async function buildCodexHeader() {
const result = await api.getCliToolSettings("codex");
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
const { installed, has9Router, config } = result.data;
if (!installed) return `Status: ${COLORS.red}✗ Codex CLI not installed${COLORS.reset}`;
if (!has9Router) {
return [
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
].join("\n");
}
// Parse base_url and model from raw TOML string
const baseUrlMatch = config && config.match(/base_url\s*=\s*"([^"]+)"/);
const modelMatch = config && config.match(/^model\s*=\s*"([^"]+)"/m);
const baseUrl = baseUrlMatch ? baseUrlMatch[1] : "";
const model = modelMatch ? modelMatch[1] : "";
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
if (baseUrl) lines.push(`Endpoint: ${COLORS.cyan}${baseUrl}${COLORS.reset}`);
if (model) lines.push(`Model: ${COLORS.dim}${model}${COLORS.reset}`);
return lines.join("\n");
}
/**
* Quick setup for Codex CLI
* @param {number} port
*/
async function codexQuickSetup(port) {
const { endpoint } = await getEndpoint(port);
const apiKey = await getFirstApiKey();
if (!apiKey) {
showStatus("No API keys found. Create one in API Keys menu first.", "error");
await pause();
return;
}
// Get model selection
const model = await selectModelFromList("Select Codex Model", "cx/claude-sonnet-4-5-20250929", { excludeCombos: true });
if (!model) return;
const result = await api.applyCliToolSettings("codex", { baseUrl: endpoint, apiKey, model });
showStatus(result.success ? "Codex setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Reset Codex CLI settings
*/
async function codexReset() {
const result = await api.resetCliToolSettings("codex");
showStatus(result.success ? "Codex settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Codex CLI submenu
* @param {number} port
* @param {Array<string>} breadcrumb
*/
async function showCodexMenu(port, breadcrumb = []) {
await showMenuWithBack({
title: "🤖 Codex CLI Settings",
breadcrumb,
headerContent: buildCodexHeader,
refresh: async () => ({}),
items: [
{
label: "⚡ Quick Setup",
action: async () => { await codexQuickSetup(port); return true; }
},
{
label: "Reset to Default",
action: async () => { await codexReset(); return true; }
}
]
});
}
// ─── Factory Droid ────────────────────────────────────────────────────────────
/**
* Build header showing current Droid config status
* @returns {Promise<string>}
*/
async function buildDroidHeader() {
const result = await api.getCliToolSettings("droid");
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
const { installed, has9Router, settings } = result.data;
if (!installed) return `Status: ${COLORS.red}✗ Factory Droid not installed${COLORS.reset}`;
if (!has9Router) {
return [
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
].join("\n");
}
// Extract 9Router custom model config
const custom = settings?.customModels?.find(m => m.id === "custom:9Router-0");
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
if (custom?.baseUrl) lines.push(`Endpoint: ${COLORS.cyan}${custom.baseUrl}${COLORS.reset}`);
if (custom?.model) lines.push(`Model: ${COLORS.dim}${custom.model}${COLORS.reset}`);
return lines.join("\n");
}
/**
* Quick setup for Factory Droid
* @param {number} port
*/
async function droidQuickSetup(port) {
const { endpoint } = await getEndpoint(port);
const apiKey = await getFirstApiKey();
if (!apiKey) {
showStatus("No API keys found. Create one in API Keys menu first.", "error");
await pause();
return;
}
const model = await selectModelFromList("Select Droid Model", "cc/claude-sonnet-4-5-20250929", { excludeCombos: true });
if (!model) return;
const result = await api.applyCliToolSettings("droid", { baseUrl: endpoint, apiKey, model });
showStatus(result.success ? "Factory Droid setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Reset Factory Droid settings
*/
async function droidReset() {
const result = await api.resetCliToolSettings("droid");
showStatus(result.success ? "Factory Droid settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Factory Droid submenu
* @param {number} port
* @param {Array<string>} breadcrumb
*/
async function showDroidMenu(port, breadcrumb = []) {
await showMenuWithBack({
title: "🤖 Factory Droid Settings",
breadcrumb,
headerContent: buildDroidHeader,
refresh: async () => ({}),
items: [
{
label: "⚡ Quick Setup",
action: async () => { await droidQuickSetup(port); return true; }
},
{
label: "Reset to Default",
action: async () => { await droidReset(); return true; }
}
]
});
}
// ─── Open Claw ────────────────────────────────────────────────────────────────
/**
* Build header showing current OpenClaw config status
* @returns {Promise<string>}
*/
async function buildOpenClawHeader() {
const result = await api.getCliToolSettings("openclaw");
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
const { installed, has9Router, settings } = result.data;
if (!installed) return `Status: ${COLORS.red}✗ Open Claw not installed${COLORS.reset}`;
if (!has9Router) {
return [
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
].join("\n");
}
// Extract 9Router provider config
const provider = settings?.models?.providers?.["9router"];
const primary = settings?.agents?.defaults?.model?.primary || "";
const model = primary.startsWith("9router/") ? primary.replace("9router/", "") : (provider?.models?.[0]?.id || "");
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
if (provider?.baseUrl) lines.push(`Endpoint: ${COLORS.cyan}${provider.baseUrl}${COLORS.reset}`);
if (model) lines.push(`Model: ${COLORS.dim}${model}${COLORS.reset}`);
return lines.join("\n");
}
/**
* Quick setup for Open Claw
* @param {number} port
*/
async function openClawQuickSetup(port) {
const { endpoint } = await getEndpoint(port);
const apiKey = await getFirstApiKey();
if (!apiKey) {
showStatus("No API keys found. Create one in API Keys menu first.", "error");
await pause();
return;
}
const model = await selectModelFromList("Select OpenClaw Model", "cc/claude-sonnet-4-5-20250929", { excludeCombos: true });
if (!model) return;
const result = await api.applyCliToolSettings("openclaw", { baseUrl: endpoint, apiKey, model });
showStatus(result.success ? "Open Claw setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Reset Open Claw settings
*/
async function openClawReset() {
const result = await api.resetCliToolSettings("openclaw");
showStatus(result.success ? "Open Claw settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
/**
* Open Claw submenu
* @param {number} port
* @param {Array<string>} breadcrumb
*/
async function showOpenClawMenu(port, breadcrumb = []) {
await showMenuWithBack({
title: "🦞 Open Claw Settings",
breadcrumb,
headerContent: buildOpenClawHeader,
refresh: async () => ({}),
items: [
{
label: "⚡ Quick Setup",
action: async () => { await openClawQuickSetup(port); return true; }
},
{
label: "Reset to Default",
action: async () => { await openClawReset(); return true; }
}
]
});
}
// ─── OpenCode CLI ─────────────────────────────────────────────────────────────
async function buildOpenCodeHeader() {
const result = await api.getCliToolSettings("opencode");
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
const { installed, has9Router, opencode } = result.data;
if (!installed) return `Status: ${COLORS.red}✗ OpenCode CLI not installed${COLORS.reset}`;
if (!has9Router) {
return [
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
].join("\n");
}
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
if (opencode?.baseURL) lines.push(`Endpoint: ${COLORS.cyan}${opencode.baseURL}${COLORS.reset}`);
if (opencode?.activeModel) lines.push(`Active: ${COLORS.dim}${opencode.activeModel}${COLORS.reset}`);
if (Array.isArray(opencode?.models) && opencode.models.length > 0) {
lines.push(`Models: ${COLORS.dim}${opencode.models.join(", ")}${COLORS.reset}`);
}
return lines.join("\n");
}
async function openCodeQuickSetup(port) {
const { endpoint } = await getEndpoint(port);
const apiKey = await getFirstApiKey();
if (!apiKey) {
showStatus("No API keys found. Create one in API Keys menu first.", "error");
await pause();
return;
}
// Pick first model (also becomes active model by default)
const firstModel = await selectModelFromList("Select Active Model (OpenCode)", "", { excludeCombos: true });
if (!firstModel) return;
const models = [firstModel];
// Optionally add more models
while (true) {
const more = await confirm(`Add another model? (current: ${models.length})`);
if (!more) break;
const next = await selectModelFromList(`Add Model #${models.length + 1}`, models.join(", "), { excludeCombos: true });
if (!next) break;
if (!models.includes(next)) models.push(next);
}
// Optional subagent model
let subagentModel = firstModel;
const wantSubagent = await confirm(`Set a different subagent model? (default: ${firstModel})`);
if (wantSubagent) {
const picked = await selectModelFromList("Select Subagent Model", firstModel, { excludeCombos: true });
if (picked) subagentModel = picked;
}
const result = await api.applyCliToolSettings("opencode", {
baseUrl: endpoint,
apiKey,
models,
activeModel: firstModel,
subagentModel,
});
showStatus(result.success ? "OpenCode setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
async function openCodeReset() {
const result = await api.resetCliToolSettings("opencode");
showStatus(result.success ? "OpenCode settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
async function showOpenCodeMenu(port, breadcrumb = []) {
await showMenuWithBack({
title: "💻 OpenCode CLI Settings",
breadcrumb,
headerContent: buildOpenCodeHeader,
refresh: async () => ({}),
items: [
{ label: "⚡ Quick Setup", action: async () => { await openCodeQuickSetup(port); return true; } },
{ label: "Reset to Default", action: async () => { await openCodeReset(); return true; } }
]
});
}
// ─── Hermes Agent ─────────────────────────────────────────────────────────────
async function buildHermesHeader() {
const result = await api.getCliToolSettings("hermes");
if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`;
const { installed, has9Router, settings } = result.data;
if (!installed) return `Status: ${COLORS.red}✗ Hermes Agent not installed${COLORS.reset}`;
if (!has9Router) {
return [
`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`,
`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`
].join("\n");
}
const model = settings?.model || {};
const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`];
if (model.base_url) lines.push(`Endpoint: ${COLORS.cyan}${model.base_url}${COLORS.reset}`);
if (model.default) lines.push(`Model: ${COLORS.dim}${model.default}${COLORS.reset}`);
return lines.join("\n");
}
async function hermesQuickSetup(port) {
const { endpoint } = await getEndpoint(port);
const apiKey = await getFirstApiKey();
if (!apiKey) {
showStatus("No API keys found. Create one in API Keys menu first.", "error");
await pause();
return;
}
const model = await selectModelFromList("Select Hermes Model", "", { excludeCombos: true });
if (!model) return;
const result = await api.applyCliToolSettings("hermes", { baseUrl: endpoint, apiKey, model });
showStatus(result.success ? "Hermes setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
async function hermesReset() {
const result = await api.resetCliToolSettings("hermes");
showStatus(result.success ? "Hermes settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error");
await pause();
}
async function showHermesMenu(port, breadcrumb = []) {
await showMenuWithBack({
title: "⚡ Hermes Agent Settings",
breadcrumb,
headerContent: buildHermesHeader,
refresh: async () => ({}),
items: [
{ label: "⚡ Quick Setup", action: async () => { await hermesQuickSetup(port); return true; } },
{ label: "Reset to Default", action: async () => { await hermesReset(); return true; } }
]
});
}
// ─── Main CLI Tools Menu ──────────────────────────────────────────────────────
/**
* Main CLI Tools menu
* @param {number} port
* @param {Array<string>} breadcrumb
*/
async function showCliToolsMenu(port, breadcrumb = []) {
const { endpoint } = await getEndpoint(port);
await showMenuWithBack({
title: "🔧 CLI Tools",
breadcrumb,
headerContent: `Configure CLI tools to use 9Router\nEndpoint: ${endpoint}`,
items: [
{
label: "Claude Code",
action: async () => { await showClaudeCodeMenu(port, [...breadcrumb, "Claude Code"]); return true; }
},
{
label: "Codex CLI",
action: async () => { await showCodexMenu(port, [...breadcrumb, "Codex CLI"]); return true; }
},
{
label: "Factory Droid",
action: async () => { await showDroidMenu(port, [...breadcrumb, "Factory Droid"]); return true; }
},
{
label: "Open Claw",
action: async () => { await showOpenClawMenu(port, [...breadcrumb, "Open Claw"]); return true; }
},
{
label: "OpenCode",
action: async () => { await showOpenCodeMenu(port, [...breadcrumb, "OpenCode"]); return true; }
},
{
label: "Hermes",
action: async () => { await showHermesMenu(port, [...breadcrumb, "Hermes"]); return true; }
}
]
});
}
module.exports = { showCliToolsMenu };
+477
View File
@@ -0,0 +1,477 @@
const api = require("../api/client");
const { prompt, confirm, pause } = require("../utils/input");
const { clearScreen, showStatus, showHeader } = require("../utils/display");
const { formatDate } = require("../utils/format");
const { selectModelFromList } = require("../utils/modelSelector");
const { showMenuWithBack } = require("../utils/menuHelper");
/**
* Format model to string (handle both string and object)
*/
function formatModel(model) {
if (typeof model === "string") return model;
if (model && typeof model === "object") {
return model.id || model.name || `${model.provider}/${model.model}` || JSON.stringify(model);
}
return String(model);
}
/**
* Show actions for a specific combo
* @param {Object} combo - Combo object
* @param {Array<string>} breadcrumb - Breadcrumb path
*/
async function showComboActions(combo, breadcrumb = []) {
const modelsChain = Array.isArray(combo.models)
? combo.models.map(formatModel).join(" → ")
: "";
await showMenuWithBack({
title: `🔀 ${combo.name}`,
breadcrumb: [...breadcrumb, combo.name],
headerContent: `Name: ${combo.name}\nModels: ${modelsChain}`,
items: [
{
label: "Edit Combo",
action: async () => {
await handleEditSingleCombo(combo);
return true;
}
},
{
label: "Delete Combo",
action: async () => {
await handleDeleteSingleCombo(combo);
return false; // Exit after delete
}
}
]
});
}
/**
* Handle editing a single combo
* @param {Object} combo - Combo to edit
*/
async function handleEditSingleCombo(combo) {
clearScreen();
console.log(`\n✏️ Edit Combo: ${combo.name}\n`);
const newName = await prompt(`New name (Enter to keep "${combo.name}"): `);
const name = newName || combo.name;
console.log("\nCurrent models: " + (Array.isArray(combo.models) ? combo.models.map(formatModel).join(" → ") : ""));
console.log("\nSelect models for this combo (add one by one):");
const models = [];
let addMore = true;
while (addMore) {
const currentChain = models.length > 0 ? models.join(" → ") : "None";
const model = await selectModelFromList(`Add Model #${models.length + 1}`, `Chain: ${currentChain}`);
if (model) {
models.push(model);
console.log(`\n✓ Added: ${model}`);
console.log(`Current chain: ${models.join(" → ")}\n`);
const continueAdding = await confirm("Add another model?");
addMore = continueAdding;
} else {
addMore = false;
}
}
// Use new models if any were added, otherwise keep current
const finalModels = models.length > 0 ? models : combo.models;
const result = await api.updateCombo(combo.id, { name, models: finalModels });
if (result.success) {
showStatus("Combo updated!", "success");
} else {
showStatus(`Update failed: ${result.error}`, "error");
}
await pause();
}
/**
* Handle deleting a single combo
* @param {Object} combo - Combo to delete
*/
async function handleDeleteSingleCombo(combo) {
const confirmed = await confirm(`Delete combo "${combo.name}"?`);
if (confirmed) {
const result = await api.deleteCombo(combo.id);
if (result.success) {
showStatus("Combo deleted!", "success");
} else {
showStatus(`Delete failed: ${result.error}`, "error");
}
await pause();
}
}
/**
* Main combos menu - list all combos and actions
* @param {Array<string>} breadcrumb - Breadcrumb path
*/
async function showCombosMenu(breadcrumb = []) {
const { showListMenu } = require("../utils/menuHelper");
await showListMenu({
title: "🔀 Combos Management",
breadcrumb,
fetchItems: async () => {
const result = await api.getCombos();
if (!result.success) {
clearScreen();
showStatus(`Failed to load combos: ${result.error}`, "error");
await pause();
return null;
}
return { items: result.data.combos || [] };
},
formatItem: (combo) => {
const modelsChain = Array.isArray(combo.models) ? combo.models.map(formatModel).join(" → ") : "";
const maxLen = 35;
const displayModels = modelsChain.length > maxLen
? modelsChain.substring(0, maxLen - 3) + "..."
: modelsChain;
return `${combo.name}: ${displayModels}`;
},
onSelect: async (combo) => {
await showComboActions(combo, breadcrumb);
},
createAction: {
label: "Create New Combo",
action: async () => {
await handleCreateCombo();
}
}
});
}
/**
* Show combo detail with stats
*/
async function showComboDetail(comboId) {
clearScreen();
const result = await api.getComboById(comboId);
if (!result.success) {
showStatus(`Failed to load combo: ${result.error}`, "error");
await pause();
return;
}
const combo = result.data;
console.log("┌─────────────────────────────────────────────────────────┐");
console.log(`│ 🔀 Combo: ${combo.name.padEnd(46)}`);
console.log("├─────────────────────────────────────────────────────────┤");
console.log("│ │");
console.log(`│ ID: ${combo.id.padEnd(51)}`);
console.log(`│ Created: ${formatDate(combo.createdAt).padEnd(46)}`);
console.log(`│ Updated: ${formatDate(combo.updatedAt).padEnd(46)}`);
console.log("│ │");
console.log("│ Model Chain: │");
// Models is array of strings like ["ag/claude-sonnet-4-5", "kr/claude-sonnet-4.5"]
const models = Array.isArray(combo.models) ? combo.models : [];
models.forEach((modelStr, index) => {
const arrow = index < models.length - 1 ? " →" : " ";
const displayText = `${index + 1}. ${modelStr}${arrow}`;
const padding = Math.max(0, 54 - displayText.length);
console.log(`${displayText}${" ".repeat(padding)}`);
});
console.log("│ │");
console.log("└─────────────────────────────────────────────────────────┘");
await pause();
}
/**
* Format combo for menu display
*/
function formatComboLabel(combo) {
const modelsChain = Array.isArray(combo.models) ? combo.models.map(formatModel).join(" → ") : "";
const maxLen = 40;
const displayModels = modelsChain.length > maxLen
? modelsChain.substring(0, maxLen - 3) + "..."
: modelsChain;
return `${combo.name}: ${displayModels}`;
}
/**
* Create new combo
*/
async function handleCreateCombo() {
clearScreen();
showStatus("Create New Combo", "info");
console.log();
// Get combo name
const name = await prompt("Combo name: ");
if (!name) {
showStatus("Combo name is required", "error");
await pause();
return;
}
// Fetch available models
showStatus("Loading available models...", "info");
const modelsResult = await api.getModels();
if (!modelsResult.success) {
showStatus(`Failed to load models: ${modelsResult.error}`, "error");
await pause();
return;
}
const availableModels = modelsResult.data.models || [];
if (availableModels.length === 0) {
showStatus("No models available. Please add providers first.", "warning");
await pause();
return;
}
// Select models for chain
const selectedModels = [];
console.log();
showStatus("Select models for the chain (minimum 2)", "info");
while (true) {
clearScreen();
console.log(`Creating combo: ${name}`);
console.log(`Selected models (${selectedModels.length}):`);
if (selectedModels.length > 0) {
selectedModels.forEach((m, i) => {
console.log(` ${i + 1}. ${m.provider}/${m.model}`);
});
} else {
console.log(" (none)");
}
console.log();
console.log("Available models:");
availableModels.forEach((m, i) => {
console.log(` ${i + 1}. ${m.provider}/${m.model}`);
});
console.log();
console.log("Actions:");
console.log(" - Enter number to add model");
console.log(" - Type 'done' to finish (min 2 models)");
console.log(" - Type 'cancel' to abort");
const input = await prompt("\nAction: ");
if (input.toLowerCase() === "cancel") {
showStatus("Cancelled", "warning");
await pause();
return;
}
if (input.toLowerCase() === "done") {
if (selectedModels.length < 2) {
showStatus("Please select at least 2 models", "error");
await pause();
continue;
}
break;
}
const num = parseInt(input, 10);
if (isNaN(num) || num < 1 || num > availableModels.length) {
showStatus("Invalid model number", "error");
await pause();
continue;
}
selectedModels.push(availableModels[num - 1]);
}
// Create combo
showStatus("Creating combo...", "info");
const createResult = await api.createCombo({
name,
models: selectedModels
});
if (!createResult.success) {
showStatus(`Failed to create combo: ${createResult.error}`, "error");
await pause();
return;
}
showStatus(`Combo "${name}" created successfully!`, "success");
await pause();
}
/**
* Edit combo - select which combo to edit
*/
async function handleEditCombo(combos) {
if (combos.length === 0) {
showStatus("No combos available", "warning");
await pause();
return;
}
let selectedCombo = null;
await showMenuWithBack({
title: "✏️ Select Combo to Edit",
items: combos.map(combo => ({
label: formatComboLabel(combo),
action: async () => {
selectedCombo = combo;
return false;
}
}))
});
if (!selectedCombo) return;
await editSingleCombo(selectedCombo);
}
/**
* Edit a single combo
*/
async function editSingleCombo(combo) {
clearScreen();
showStatus(`Editing combo: ${combo.name}`, "info");
console.log();
const newName = await prompt(`New name (current: ${combo.name}, press Enter to keep): `);
const editModels = await confirm("Edit model chain?");
let newModels = combo.models;
if (editModels) {
newModels = [];
while (true) {
clearScreen();
console.log(`Editing combo: ${combo.name}`);
console.log(`Selected models (${newModels.length}):`);
if (newModels.length > 0) {
newModels.forEach((m, i) => console.log(` ${i + 1}. ${m}`));
} else {
console.log(" (none)");
}
console.log("\nType 'done' to finish (min 2 models) or 'cancel' to abort\n");
const model = await selectModelFromList("Add Model", "");
if (model === null) {
showStatus("Cancelled", "warning");
await pause();
return;
}
if (model === "done") {
if (newModels.length < 2) {
showStatus("Please select at least 2 models", "error");
await pause();
continue;
}
break;
}
newModels.push(model);
showStatus(`Added: ${model}`, "success");
await pause();
}
}
const updateData = {};
if (newName) updateData.name = newName;
if (editModels) updateData.models = newModels;
if (Object.keys(updateData).length === 0) {
showStatus("No changes made", "warning");
await pause();
return;
}
showStatus("Updating combo...", "info");
const updateResult = await api.updateCombo(combo.id, updateData);
if (!updateResult.success) {
showStatus(`Failed to update combo: ${updateResult.error}`, "error");
await pause();
return;
}
showStatus("Combo updated successfully!", "success");
await pause();
}
/**
* Delete combo - select which combo to delete
*/
async function handleDeleteCombo(combos) {
if (combos.length === 0) {
showStatus("No combos available", "warning");
await pause();
return;
}
let selectedCombo = null;
await showMenuWithBack({
title: "🗑️ Select Combo to Delete",
items: combos.map(combo => ({
label: formatComboLabel(combo),
action: async () => {
selectedCombo = combo;
return false;
}
}))
});
if (!selectedCombo) return;
clearScreen();
showStatus(`Combo: ${selectedCombo.name}`, "warning");
const modelsDisplay = Array.isArray(selectedCombo.models)
? selectedCombo.models.map(formatModel).join(" → ")
: "";
console.log(`Models: ${modelsDisplay}`);
console.log();
const confirmed = await confirm("Are you sure you want to delete this combo?");
if (!confirmed) {
showStatus("Cancelled", "info");
await pause();
return;
}
showStatus("Deleting combo...", "info");
const deleteResult = await api.deleteCombo(selectedCombo.id);
if (!deleteResult.success) {
showStatus(`Failed to delete combo: ${deleteResult.error}`, "error");
await pause();
return;
}
showStatus("Combo deleted successfully!", "success");
await pause();
}
module.exports = { showCombosMenu };
+847
View File
@@ -0,0 +1,847 @@
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-flash-agent" },
{ id: "gemini-3.5-flash-low" },
{ id: "gemini-3.5-flash-extra-low" },
{ id: "gemini-pro-agent" },
{ id: "gemini-3.1-pro-low" },
{ id: "claude-sonnet-4-6" },
{ id: "claude-opus-4-6-thinking" },
{ id: "gpt-oss-120b-medium" },
{ id: "gemini-3-flash" },
],
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-5" },
{ 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 };
+204
View File
@@ -0,0 +1,204 @@
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";
/**
* 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}`);
const headroomOn = data?.settings?.headroomEnabled === true;
lines.push(` Headroom: ${headroomOn ? `${COLORS.green}ON${COLORS.reset}` : `${COLORS.red}OFF${COLORS.reset}`} ${COLORS.dim}(${data?.settings?.headroomUrl || "http://localhost:8787"})${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: (d) => {
const on = d?.settings?.headroomEnabled === true;
return `Token Saver (Headroom): ${on ? "ON" : "OFF"} → toggle`;
},
action: async (d) => { await toggleHeadroom(d?.settings?.headroomEnabled === true); 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();
}
async function toggleHeadroom(currentlyOn) {
const next = !currentlyOn;
const result = await api.updateSettings({ headroomEnabled: next });
if (result.success) {
showStatus(`Headroom ${next ? "enabled" : "disabled"}`, "success");
} else {
showStatus(`Failed: ${result.error}`, "error");
}
await pause();
}
/**
* Reset dashboard password to default via server API (writes the live SQLite DB).
* After reset, user can log in with the default password "123456".
*/
async function resetPassword() {
const ok = await confirm(`Reset dashboard password to default "${DEFAULT_PASSWORD}"?`);
if (!ok) {
showStatus("Cancelled", "info");
await pause();
return;
}
const result = await api.resetPassword();
if (result.success) {
showStatus(`Password reset. Default: ${DEFAULT_PASSWORD}`, "success");
} else {
showStatus(`Failed to reset password: ${result.error}`, "error");
}
await pause();
}
module.exports = { showSettingsMenu };
+121
View File
@@ -0,0 +1,121 @@
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"
};
// Cached header (SWR): show last value instantly, refresh in background.
let cachedHeader = "";
let fetchingHeader = false;
function renderHeader(port, keys, tunnel) {
const tunnelEnabled = tunnel && 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 || 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");
}
async function refreshHeaderBg(port) {
if (fetchingHeader) return;
fetchingHeader = true;
try {
const [keysResult, tunnelResult] = await Promise.all([
api.getApiKeys(),
api.getTunnelStatus()
]);
const keys = keysResult.success ? (keysResult.data.keys || []) : [];
const tunnel = tunnelResult.success ? (tunnelResult.data || {}) : {};
cachedHeader = renderHeader(port, keys, tunnel);
} finally {
fetchingHeader = false;
}
}
function getHeader(port) {
// Kick off background refresh; return cache (or placeholder on first call).
refreshHeaderBg(port);
return cachedHeader || `Endpoint: http://localhost:${port}/v1\nTunnel: ${COLORS.dim}...${COLORS.reset}\nKey: ${COLORS.dim}...${COLORS.reset}`;
}
/**
* Start Terminal UI
* @param {number} port - Server port number
*/
async function startTerminalUI(port) {
// Configure API client
api.configure({ port });
const basePath = ["9Router"];
// Prime header cache before first render
await refreshHeaderBg(port);
// Main menu
await showMenuWithBack({
title: "📡 9Router Terminal UI",
breadcrumb: basePath,
headerContent: () => getHeader(port),
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 };
+306
View File
@@ -0,0 +1,306 @@
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";
/**
* Resolve the absolute path to this package's cli.js.
*
* Order of preference:
* 1. Explicit `cliPath` argument — cleanest, used when called from running
* cli.js with `__filename`.
* 2. `process.argv[1]` if it's our cli.js — true when 9router is currently
* running and the tray menu fires this code path.
* 3. Compute relative to this file's own location. autostart.js lives at
* `<pkg>/src/cli/tray/autostart.js`, so cli.js is three levels up.
* This works for any global install layout (nvm, Volta, asdf, Homebrew,
* /usr/local, etc.) without depending on `npm bin -g` (removed in npm 9)
* or a hardcoded `/usr/local/...` path.
*
* Returns null if no candidate exists — callers should not write an autostart
* entry pointing at a non-existent script.
*/
function getCliJsPath(cliPath) {
if (cliPath) {
const resolved = path.resolve(cliPath);
if (fs.existsSync(resolved)) return resolved;
}
if (process.argv[1]) {
const resolved = path.resolve(process.argv[1]);
if (path.basename(resolved) === "cli.js" && fs.existsSync(resolved)) {
return resolved;
}
}
const computed = path.resolve(__dirname, "..", "..", "..", "cli.js");
if (fs.existsSync(computed)) return computed;
return null;
}
/**
* 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;
if (!["darwin", "win32", "linux"].includes(platform)) return false;
if (platform === "linux" && !process.env.DISPLAY) return false;
try {
if (platform === "darwin") return enableMacOS(cliPath);
if (platform === "win32") return enableWindows(cliPath);
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();
if (platform === "win32") return disableWindows();
if (platform === "linux") return disableLinux();
} catch (err) {}
return false;
}
/**
* Check if autostart is enabled.
*
* On macOS, both the plist file and the launchd registration must be present —
* otherwise the tray menu would lie about the state (showing "✓ Enabled" even
* when launchd has the agent in a failed state or hasn't loaded it).
*/
function isAutoStartEnabled() {
const platform = process.platform;
try {
if (platform === "darwin") {
const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", `${APP_LABEL}.plist`);
if (!fs.existsSync(plistPath)) return false;
try {
execSync(`launchctl list ${APP_LABEL}`, {
stdio: ["ignore", "ignore", "ignore"],
timeout: 3000
});
return true;
} catch (e) {
return false;
}
} 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 ============
/**
* Returns true when the current Node process IS the running instance that
* launchd is managing under our agent label.
*
* `launchctl unload <plist>` (and `load`) for an Aqua user-domain agent sends
* SIGTERM to the running process. When the running 9router cli.js was itself
* spawned by the autostart launchd agent (i.e. user enabled autostart at
* some point, then rebooted, then clicked the tray icon's "Disable
* Auto-start" menu item), an unload would kill the very process executing
* the click handler — and the tray icon would disappear instead of the menu
* label flipping back to "Enable Auto-start". This helper lets the enable
* and disable paths sidestep that by skipping launchctl when we'd otherwise
* be killing ourselves.
*/
function isAgentSelfMacOS() {
try {
const output = execSync(`launchctl list ${APP_LABEL}`, {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 3000
});
const match = output.match(/"PID"\s*=\s*(\d+)/);
return !!(match && parseInt(match[1], 10) === process.pid);
} catch (e) {
return false;
}
}
function enableMacOS(cliPath) {
const launchAgentsDir = path.join(os.homedir(), "Library", "LaunchAgents");
const plistPath = path.join(launchAgentsDir, `${APP_LABEL}.plist`);
if (!fs.existsSync(launchAgentsDir)) {
fs.mkdirSync(launchAgentsDir, { recursive: true });
}
const nodePath = process.execPath;
const routerScript = getCliJsPath(cliPath);
// Don't write a broken plist that references a non-existent script.
if (!routerScript) return false;
// Invoke node + cli.js directly with absolute paths — no shell wrapper.
// The previous design ran `zsh -l -c "..."` so a login shell would source
// nvm/.zshrc and set PATH; that's fragile (nvm.sh sourcing varies by user,
// some setups don't put node on PATH from a non-interactive login shell).
// EnvironmentVariables.PATH explicitly includes node's bin dir so child
// processes spawned by cli.js (npm install at runtime, etc.) resolve.
const launchPath = `${path.dirname(nodePath)}:/usr/local/bin:/usr/bin:/bin`;
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>${nodePath}</string>
<string>${routerScript}</string>
<string>--tray</string>
<string>--skip-update</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>${launchPath}</string>
</dict>
<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);
// If we're the running agent already, launchctl unload/load would send
// ourselves SIGTERM. Skip it — the plist file is updated on disk and
// launchd will pick it up at next login. isAutoStartEnabled() will still
// return true because launchctl already has the agent loaded.
if (isAgentSelfMacOS()) {
return true;
}
// Register with launchd in the current session. Without this, the agent
// only takes effect on the next user login and the user has no signal that
// anything actually happened. `unload` first defends against re-enable
// replacing an existing plist.
try {
execSync(`launchctl unload "${plistPath}"`, { stdio: "ignore" });
} catch (e) {}
try {
execSync(`launchctl load -w "${plistPath}"`, { stdio: "ignore" });
} catch (e) {
// Even if load fails, the plist is on disk and will be picked up at next
// login; report success based on the file write.
}
return true;
}
function disableMacOS() {
const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", `${APP_LABEL}.plist`);
// Don't kill ourselves: when the current process is the running agent,
// `launchctl unload` would send SIGTERM and the user clicking
// "Disable Auto-start" from the tray menu would lose their tray icon
// instead of just flipping the menu label. Skip the unload — removing the
// plist file is enough to prevent the agent from starting on next login.
if (!isAgentSelfMacOS()) {
try {
execSync(`launchctl unload "${plistPath}"`, { 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`);
if (!fs.existsSync(startupDir)) return false;
const nodePath = process.execPath;
const routerScript = getCliJsPath(cliPath);
if (!routerScript) return false;
// Run node + cli.js directly, hidden window. Avoids the fragile
// `9router.cmd` lookup that depended on the npm prefix path.
const vbsContent = `Set WshShell = CreateObject("WScript.Shell")
WshShell.Run """${nodePath}"" ""${routerScript}"" --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`);
if (!fs.existsSync(autostartDir)) {
try { fs.mkdirSync(autostartDir, { recursive: true }); }
catch (e) { return false; }
}
const nodePath = process.execPath;
const routerScript = getCliJsPath(cliPath);
if (!routerScript) return false;
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
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 969 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 947 B

+322
View File
@@ -0,0 +1,322 @@
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
*
* Prefers `systray2` (active fork of `systray`, ships newer
* getlantern/systray-portable binaries that work on macOS 14+ and Apple
* Silicon under Rosetta). Falls back to legacy `systray@1.0.5` if systray2
* is not available, though that binary's Mach-O headers are rejected by
* modern dyld and the icon will not appear.
*/
function resolveSystray() {
let runtimeDir = null;
try {
const { getRuntimeNodeModules } = require("../../../hooks/sqliteRuntime");
runtimeDir = getRuntimeNodeModules();
} catch (e) {}
// 1) systray2 in runtime dir (where ensureTrayRuntime installs it)
if (runtimeDir) {
try { return { mod: require(path.join(runtimeDir, "systray2")).default, isV2: true }; } catch (e) {}
}
// 2) systray2 resolvable from the package's own node_modules / NODE_PATH
try { return { mod: require("systray2").default, isV2: true }; } catch (e) {}
// 3) Legacy systray fallback (unlikely to render on modern macOS)
try { return { mod: require("systray").default, isV2: false }; } catch (e) {}
if (runtimeDir) {
try { return { mod: require(path.join(runtimeDir, "systray")).default, isV2: false }; } catch (e) {}
}
return null;
}
function chmodTrayBin(pkgName) {
// systray2's npm tarball occasionally lands without +x on the bundled Go
// binary (observed on macOS). spawn() then fails with EACCES. Best-effort
// chmod on every init avoids a hard-to-diagnose silent tray failure.
try {
const { getRuntimeNodeModules } = require("../../../hooks/sqliteRuntime");
const binName = process.platform === "darwin" ? "tray_darwin_release" : "tray_linux_release";
const candidates = [
path.join(getRuntimeNodeModules(), pkgName, "traybin", binName),
path.join(__dirname, "..", "..", "..", "node_modules", pkgName, "traybin", binName)
];
for (const p of candidates) {
if (fs.existsSync(p)) fs.chmodSync(p, 0o755);
}
} catch (e) {}
}
function initUnixTray(options) {
const { port } = options;
try {
const resolved = resolveSystray();
if (!resolved) return null;
const { mod: SysTray, isV2 } = resolved;
chmodTrayBin(isV2 ? "systray2" : "systray");
const autostartEnabled = getAutostartEnabled();
const items = buildMenuItems(port, autostartEnabled);
const menu = {
icon: getIconBase64(),
// The bundled icon.png is a full-color RGBA logo. Don't mark it as a
// template icon: macOS would then render it as a solid white square
// because template mode only uses the alpha channel.
isTemplateIcon: false,
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
});
});
});
if (isV2) {
// systray2 exposes a ready() promise instead of onReady/onError. Surface
// failures (binary crash, EACCES, etc.) so users can see why the icon
// didn't appear instead of getting a misleading "running in tray" log.
trayInstance.ready().catch((err) => {
process.stderr.write(`[9router] tray failed to start: ${err && err.message ? err.message : err}\n`);
});
} else {
trayInstance.onReady(() => {});
trayInstance.onError(() => {});
}
return trayInstance;
} catch (err) {
process.stderr.write(`[9router] tray init error: ${err.message}\n`);
return null;
}
}
/**
* Kill tray, wait Go binary fully exit (returns Promise).
* Critical for hide-to-tray: macOS must release NSStatusItem before bgProcess
* spawns a new tray, otherwise the new icon silently fails to register.
*/
function killTray() {
const instance = trayInstance;
const wasWin = isWinTray;
trayInstance = null;
if (!instance) return Promise.resolve();
if (wasWin) {
try { instance.kill(); } catch (e) {}
return Promise.resolve();
}
// Unix: get the Go tray child process handle.
let proc = null;
try {
proc = instance._process || (typeof instance.process === "function" ? instance.process() : null);
} catch (e) {}
// Graceful shutdown: send {type:"exit"} via IPC so the Go binary can call
// systray.Quit() and release NSStatusItem. SIGKILL leaves a ghost icon on
// the macOS menubar until logout, causing duplicate icons after re-spawn.
const gracefulQuit = () => { try { instance.kill(true); } catch (e) {} };
const closeIpc = () => { try { instance.kill(false); } catch (e) {} };
if (!proc || !proc.pid) {
gracefulQuit();
closeIpc();
return Promise.resolve();
}
return new Promise((resolve) => {
let done = false;
const finish = () => { if (done) return; done = true; closeIpc(); resolve(); };
proc.once("exit", finish);
gracefulQuit();
// Escalate: SIGTERM after 800ms, SIGKILL after 1600ms if still alive.
setTimeout(() => { try { process.kill(proc.pid, 0); proc.kill("SIGTERM"); } catch (e) {} }, 800);
setTimeout(() => { try { process.kill(proc.pid, 0); proc.kill("SIGKILL"); } catch (e) {} }, 1600);
// Fallback poll in case "exit" never fires (detached child, pipe closed)
const deadline = Date.now() + 3000;
const poll = setInterval(() => {
try { process.kill(proc.pid, 0); } catch { clearInterval(poll); finish(); return; }
if (Date.now() > deadline) { clearInterval(poll); finish(); }
}, 50);
});
}
/**
* 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
};
+130
View File
@@ -0,0 +1,130 @@
# 9Router tray icon for Windows using NotifyIcon
# IPC: stdin JSON commands, stdout JSON events
param([string]$IconPath, [string]$Tooltip)
$ErrorActionPreference = "Stop"
Add-Type @"
using System;
using System.Runtime.InteropServices;
public static class WinDpiAwareness {
public static IntPtr PerMonitorAwareV2 { get { return new IntPtr(-4); } }
public static IntPtr PerMonitorAware { get { return new IntPtr(-3); } }
[DllImport("user32.dll")]
public static extern bool SetProcessDpiAwarenessContext(IntPtr value);
[DllImport("user32.dll")]
public static extern IntPtr SetThreadDpiAwarenessContext(IntPtr value);
[DllImport("shcore.dll")]
public static extern int SetProcessDpiAwareness(int value);
[DllImport("user32.dll")]
public static extern bool SetProcessDPIAware();
}
"@
function Enable-HighDpiAwareness {
$contexts = @(
[WinDpiAwareness]::PerMonitorAwareV2,
[WinDpiAwareness]::PerMonitorAware
)
foreach ($context in $contexts) {
try {
if ([WinDpiAwareness]::SetProcessDpiAwarenessContext($context)) { break }
} catch {}
}
try { [WinDpiAwareness]::SetProcessDpiAwareness(2) | Out-Null } catch {}
try { [WinDpiAwareness]::SetProcessDPIAware() | Out-Null } catch {}
foreach ($context in $contexts) {
try {
$previous = [WinDpiAwareness]::SetThreadDpiAwarenessContext($context)
if ($previous -ne [IntPtr]::Zero) { break }
} catch {}
}
}
Enable-HighDpiAwareness
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
$OutputEncoding = [System.Text.Encoding]::UTF8
[System.Windows.Forms.Application]::EnableVisualStyles()
[System.Windows.Forms.Application]::SetCompatibleTextRenderingDefault($false)
$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
View File
@@ -0,0 +1,89 @@
const { spawn } = require("child_process");
const path = require("path");
const readline = require("readline");
// PowerShell-based tray for Windows (AV-safe, zero binary deps)
let psProcess = null;
let clickHandler = null;
/**
* Send JSON command to PowerShell tray process via stdin
*/
function sendCommand(cmd) {
if (psProcess && psProcess.stdin.writable) {
psProcess.stdin.write(`${JSON.stringify(cmd)}\n`, "utf8");
}
}
/**
* Initialize Windows tray using PowerShell NotifyIcon
* @param {Object} options - { iconPath, tooltip, items, onClick }
* items: [{ title, enabled }]
* @returns {Object|null} controller with sendAction/kill
*/
function initWinTray(options) {
const { iconPath, tooltip, items, onClick } = options;
clickHandler = onClick;
const scriptPath = path.join(__dirname, "tray.ps1");
try {
psProcess = spawn(
"powershell.exe",
[
"-NoProfile",
"-ExecutionPolicy", "Bypass",
"-WindowStyle", "Hidden",
"-InputFormat", "Text",
"-OutputFormat", "Text",
"-File", scriptPath,
"-IconPath", iconPath,
"-Tooltip", tooltip
],
{ windowsHide: true, stdio: ["pipe", "pipe", "pipe"] }
);
} catch (err) {
return null;
}
const rl = readline.createInterface({ input: psProcess.stdout });
rl.on("line", (line) => {
try {
const evt = JSON.parse(line);
if (evt.type === "click" && clickHandler) {
clickHandler(evt.index);
}
} catch (e) {}
});
psProcess.on("error", () => {});
psProcess.stderr.on("data", () => {});
// Send initial menu items
items.forEach((item, index) => {
sendCommand({ action: "add-item", index, title: item.title, enabled: item.enabled });
});
return {
updateItem(index, title, enabled) {
sendCommand({ action: "update-item", index, title, enabled });
},
setTooltip(text) {
sendCommand({ action: "set-tooltip", text });
},
kill() {
try {
sendCommand({ action: "kill" });
} catch (e) {}
setTimeout(() => {
if (psProcess && !psProcess.killed) {
try { psProcess.kill(); } catch (e) {}
}
psProcess = null;
}, 300);
}
};
}
module.exports = { initWinTray };
+30
View File
@@ -0,0 +1,30 @@
const { execSync } = require("child_process");
/**
* Copy text to clipboard based on OS
* @param {string} text - Text to copy
* @returns {boolean} Success status
*/
function copyToClipboard(text) {
try {
const platform = process.platform;
if (platform === "darwin") {
execSync("pbcopy", { input: text });
} else if (platform === "win32") {
execSync("clip", { input: text });
} else {
// Linux - try xclip first, then xsel
try {
execSync("xclip -selection clipboard", { input: text });
} catch {
execSync("xsel --clipboard --input", { input: text });
}
}
return true;
} catch (error) {
return false;
}
}
module.exports = { copyToClipboard };
+156
View File
@@ -0,0 +1,156 @@
const { formatNumber } = require("./format");
// ANSI color codes
const COLORS = {
reset: "\x1b[0m",
success: "\x1b[32m",
error: "\x1b[31m",
warning: "\x1b[33m",
info: "\x1b[36m",
dim: "\x1b[2m",
bold: "\x1b[1m",
bright: "\x1b[1m",
cyan: "\x1b[36m"
};
// Box drawing characters
const BOX_CHARS = {
topLeft: "┌",
topRight: "┐",
bottomLeft: "└",
bottomRight: "┘",
horizontal: "─",
vertical: "│"
};
/**
* Draw a box with border around content
* @param {string} title - Box title
* @param {string} content - Content to display inside box
* @param {number} [width=60] - Box width
*/
function showBox(title, content, width = 60) {
const innerWidth = width - 4;
const lines = content.split("\n");
// Top border with title
const topBorder = BOX_CHARS.topLeft + BOX_CHARS.horizontal.repeat(2) +
` ${title} ` +
BOX_CHARS.horizontal.repeat(Math.max(0, innerWidth - title.length - 3)) +
BOX_CHARS.topRight;
console.log(topBorder);
// Content lines
lines.forEach(line => {
const paddedLine = line.padEnd(innerWidth);
console.log(`${BOX_CHARS.vertical} ${paddedLine} ${BOX_CHARS.vertical}`);
});
// Bottom border
const bottomBorder = BOX_CHARS.bottomLeft +
BOX_CHARS.horizontal.repeat(innerWidth + 2) +
BOX_CHARS.bottomRight;
console.log(bottomBorder);
}
/**
* Display a menu with numbered items
* @param {string} title - Menu title
* @param {string[]} items - Array of menu items
* @param {string} [footer] - Optional footer text
*/
function showMenu(title, items, footer) {
console.log(`\n${COLORS.bold}${title}${COLORS.reset}`);
console.log(COLORS.dim + "─".repeat(title.length) + COLORS.reset);
items.forEach((item, index) => {
console.log(` ${COLORS.info}${index + 1}.${COLORS.reset} ${item}`);
});
if (footer) {
console.log(`\n${COLORS.dim}${footer}${COLORS.reset}`);
}
console.log();
}
/**
* Display data in table format
* @param {string[]} headers - Array of column headers
* @param {Array<Array<string|number>>} rows - Array of row data
*/
function showTable(headers, rows) {
if (!headers.length || !rows.length) {
return;
}
// Calculate column widths
const colWidths = headers.map((header, i) => {
const maxDataWidth = Math.max(...rows.map(row => String(row[i] || "").length));
return Math.max(header.length, maxDataWidth);
});
// Print header
const headerRow = headers.map((h, i) => h.padEnd(colWidths[i])).join(" │ ");
console.log(COLORS.bold + headerRow + COLORS.reset);
// Print separator
const separator = colWidths.map(w => "─".repeat(w)).join("─┼─");
console.log(COLORS.dim + separator + COLORS.reset);
// Print rows
rows.forEach(row => {
const rowStr = row.map((cell, i) => String(cell || "").padEnd(colWidths[i])).join(" │ ");
console.log(rowStr);
});
}
/**
* Show colored status message
* @param {string} message - Message to display
* @param {string} [type="info"] - Status type: success, error, warning, info
*/
function showStatus(message, type = "info") {
const symbols = {
success: "✓",
error: "✗",
warning: "⚠",
info: ""
};
const color = COLORS[type] || COLORS.info;
const symbol = symbols[type] || symbols.info;
console.log(`${color}${symbol} ${message}${COLORS.reset}`);
}
/**
* Clear the terminal screen
*/
function clearScreen() {
console.clear();
}
/**
* Show menu header with title and subtitle
* @param {string} title - Main title
* @param {string} subtitle - Optional subtitle
*/
function showHeader(title, subtitle) {
console.log(`\n${"=".repeat(60)}`);
console.log(` ${COLORS.bright}${COLORS.cyan}${title}${COLORS.reset}`);
if (subtitle) {
console.log(` ${COLORS.dim}${subtitle}${COLORS.reset}`);
}
console.log(`${"=".repeat(60)}\n`);
}
module.exports = {
showBox,
showMenu,
showTable,
showStatus,
clearScreen,
showHeader
};
+32
View File
@@ -0,0 +1,32 @@
const api = require("../api/client");
const COLORS = {
reset: "\x1b[0m",
green: "\x1b[32m"
};
/**
* Get endpoint URL based on tunnel status
* @param {number} port - Local server port
* @returns {Promise<{endpoint: string, tunnelEnabled: boolean}>}
*/
async function getEndpoint(port) {
const result = await api.getTunnelStatus();
const tunnelEnabled = result.success && result.data?.enabled === true;
const publicUrl = result.success ? result.data?.publicUrl : "";
const endpoint = tunnelEnabled && publicUrl ? `${publicUrl}/v1` : `http://localhost:${port}/v1`;
return { endpoint, tunnelEnabled };
}
/**
* Get endpoint with color formatting
* @param {number} port - Local server port
* @returns {Promise<string>} Colored endpoint string
*/
async function getEndpointColored(port) {
const { endpoint, tunnelEnabled } = await getEndpoint(port);
return tunnelEnabled ? `${COLORS.green}${endpoint}${COLORS.reset}` : endpoint;
}
module.exports = { getEndpoint, getEndpointColored };
+125
View File
@@ -0,0 +1,125 @@
/**
* Truncate text with ellipsis
* @param {string} text - Text to truncate
* @param {number} maxLength - Maximum length
* @returns {string} Truncated text
*/
function truncate(text, maxLength) {
if (!text || text.length <= maxLength) {
return text;
}
return text.substring(0, maxLength - 3) + "...";
}
/**
* Mask API key showing only first and last characters
* @param {string} key - API key to mask
* @returns {string} Masked key
*/
function maskKey(key) {
if (!key || key.length < 8) {
return "***";
}
const firstChars = key.substring(0, 4);
const lastChars = key.substring(key.length - 4);
return `${firstChars}${"*".repeat(key.length - 8)}${lastChars}`;
}
/**
* Format date to readable string
* @param {Date|string|number} date - Date to format
* @returns {string} Formatted date string
*/
function formatDate(date) {
const d = new Date(date);
if (isNaN(d.getTime())) {
return "Invalid Date";
}
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
const hours = String(d.getHours()).padStart(2, "0");
const minutes = String(d.getMinutes()).padStart(2, "0");
const seconds = String(d.getSeconds()).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
/**
* Format number with commas
* @param {number} num - Number to format
* @returns {string} Formatted number
*/
function formatNumber(num) {
if (typeof num !== "number" || isNaN(num)) {
return "0";
}
return num.toLocaleString("en-US");
}
/**
* Format bytes to human readable size
* @param {number} bytes - Bytes to format
* @returns {string} Formatted size string
*/
function formatBytes(bytes) {
if (typeof bytes !== "number" || isNaN(bytes) || bytes < 0) {
return "0 B";
}
const units = ["B", "KB", "MB", "GB", "TB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
}
/**
* Get relative time string
* @param {Date|string|number} date - Date to compare
* @returns {string} Relative time string
*/
function getRelativeTime(date) {
const d = new Date(date);
if (isNaN(d.getTime())) {
return "Invalid Date";
}
const now = new Date();
const diffMs = now - d;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
const diffMonth = Math.floor(diffDay / 30);
const diffYear = Math.floor(diffDay / 365);
if (diffSec < 60) {
return "just now";
} else if (diffMin < 60) {
return `${diffMin} minute${diffMin > 1 ? "s" : ""} ago`;
} else if (diffHour < 24) {
return `${diffHour} hour${diffHour > 1 ? "s" : ""} ago`;
} else if (diffDay < 30) {
return `${diffDay} day${diffDay > 1 ? "s" : ""} ago`;
} else if (diffMonth < 12) {
return `${diffMonth} month${diffMonth > 1 ? "s" : ""} ago`;
} else {
return `${diffYear} year${diffYear > 1 ? "s" : ""} ago`;
}
}
module.exports = {
truncate,
maskKey,
formatDate,
formatNumber,
formatBytes,
getRelativeTime
};
+156
View File
@@ -0,0 +1,156 @@
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: "\x1b[38;2;217;119;87m",
bgTerracotta: "\x1b[48;2;217;119;87m"
};
// Prime stdin once globally. Toggling raw mode between menus adds latency on
// macOS, so we keep raw mode on for the whole TUI session.
let rawPrimed = false;
function primeRawOnce() {
if (rawPrimed || !process.stdin.isTTY) return;
try {
readline.emitKeypressEvents(process.stdin);
process.stdin.setRawMode(true);
process.stdin.setEncoding("utf8");
process.stdin.resume();
rawPrimed = true;
} catch {}
}
function suspendRawFor(fn) {
// Temporarily drop raw mode so readline.question can buffer line input.
const wasPrimed = rawPrimed;
if (wasPrimed && process.stdin.isTTY) {
try { process.stdin.setRawMode(false); } catch {}
}
return fn().finally(() => {
if (wasPrimed && process.stdin.isTTY) {
try { process.stdin.setRawMode(true); } catch {}
process.stdin.resume();
}
});
}
async function prompt(question) {
return suspendRawFor(() => new Promise((resolve) => {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
rl.question(question, (answer) => {
rl.close();
resolve((answer || "").trim());
});
}));
}
async function select(question, options) {
console.log(question);
options.forEach((opt, i) => console.log(` ${i + 1}. ${opt}`));
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}`);
}
}
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'");
}
}
async function pause(message = "Press Enter to continue...") {
return suspendRawFor(() => new Promise((resolve) => {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
rl.question(message, () => { rl.close(); resolve(); });
}));
}
/**
* Interactive arrow-key menu. Renders ★/☆ icons; selected line uses reverse+bright
* (no underline). Uses readline keypress + raw 'data' fallback to prevent
* arrow-key escape sequence leaks on macOS.
*/
async function selectMenu(title, items, defaultIndex = 0, subtitle = "", headerContent = "", breadcrumb = []) {
return new Promise((resolve) => {
let selectedIndex = defaultIndex;
let isActive = true;
primeRawOnce();
if (!process.stdin.isTTY) { resolve(-1); return; }
const renderMenu = () => {
if (!isActive) return;
process.stdout.write("\x1b[2J\x1b[H");
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}`);
if (subtitle) console.log(` ${COLORS.dim}${subtitle}${COLORS.reset}`);
console.log(`${COLORS.terracotta}${"=".repeat(width)}${COLORS.reset}`);
if (breadcrumb.length > 0) console.log(` ${COLORS.dim}${breadcrumb.join(" > ")}${COLORS.reset}`);
console.log();
if (headerContent) { console.log(headerContent); console.log(); }
const isWin = process.platform === "win32";
items.forEach((item, index) => {
const isSelected = index === selectedIndex;
const icon = isSelected ? (isWin ? ">" : "★") : (isWin ? " " : "☆");
if (isSelected) {
console.log(` ${COLORS.reverse}${COLORS.bright}${icon} ${item.label}${COLORS.reset}`);
} else {
console.log(` ${icon} ${item.label}`);
}
});
};
const cleanup = () => {
if (!isActive) return;
isActive = false;
process.stdin.removeListener("keypress", onKeypress);
};
const move = (delta) => {
selectedIndex = (selectedIndex + delta + items.length) % items.length;
renderMenu();
};
const onKeypress = (_str, key) => {
if (!isActive || !key) return;
if (key.name === "up") return move(-1);
if (key.name === "down") return move(1);
if (key.name === "return") { cleanup(); resolve(selectedIndex); return; }
if (key.name === "escape") { cleanup(); resolve(-1); return; }
if (key.ctrl && key.name === "c") { cleanup(); process.exit(0); }
};
process.stdin.on("keypress", onKeypress);
renderMenu();
});
}
module.exports = {
prompt,
select,
confirm,
pause,
selectMenu,
COLORS
};
+156
View File
@@ -0,0 +1,156 @@
const { selectMenu } = require("./input");
/**
* Show a menu with back button at top and handle selection
* @param {Object} config - Menu configuration
* @param {string} config.title - Menu title
* @param {string} config.headerContent - Optional header content
* @param {Array<{label: string, action: Function}>} config.items - Menu items with actions
* @param {string} config.backLabel - Back button label (default: "← Back")
* @param {number} config.defaultIndex - Default selected index (default: 0)
* @param {Function} config.refresh - Optional refresh function to call after each action
* @param {Array<string>} config.breadcrumb - Optional breadcrumb path
* @returns {Promise<void>}
*/
async function showMenuWithBack(config) {
const {
title,
headerContent = "",
items,
backLabel = "← Back",
defaultIndex = 0,
refresh = null,
breadcrumb = []
} = config;
while (true) {
// Call refresh if provided
let refreshedData = null;
if (refresh) {
refreshedData = await refresh();
if (refreshedData === null) {
// Refresh failed, exit menu
return;
}
}
// Build menu items with back at top
const menuItems = [
{ label: backLabel, icon: "☆" },
...items.map(item => ({
label: typeof item.label === "function" ? item.label(refreshedData) : item.label,
icon: "☆"
}))
];
// Resolve headerContent if it's a function
const resolvedHeader = typeof headerContent === "function"
? await headerContent(refreshedData)
: headerContent;
const selected = await selectMenu(
title,
menuItems,
defaultIndex,
"",
resolvedHeader,
breadcrumb
);
// Back or ESC
if (selected === -1 || selected === 0) {
return;
}
// Execute action for selected item
const actionIndex = selected - 1;
const item = items[actionIndex];
if (item && item.action) {
const shouldContinue = await item.action(refreshedData);
// If action returns false, exit menu
if (shouldContinue === false) {
return;
}
}
}
}
/**
* Show a list menu where items are fetched dynamically
* @param {Object} config - Menu configuration
* @param {string} config.title - Menu title
* @param {string} config.headerContent - Optional header content
* @param {Function} config.fetchItems - Async function to fetch items array
* @param {Function} config.formatItem - Function to format each item to {label, data}
* @param {Function} config.onSelect - Action when item is selected
* @param {Object} config.createAction - Optional create action {label, action}
* @param {string} config.backLabel - Back button label
* @param {Array<string>} config.breadcrumb - Optional breadcrumb path
* @returns {Promise<void>}
*/
async function showListMenu(config) {
const {
title,
headerContent = "",
fetchItems,
formatItem,
onSelect,
createAction = null,
backLabel = "← Back",
breadcrumb = []
} = config;
while (true) {
// Fetch items
const result = await fetchItems();
if (!result) {
return;
}
const items = result.items || [];
const metadata = result.metadata || {};
// Build menu items
const menuItems = [{ label: backLabel, icon: "☆" }];
if (createAction) {
menuItems.push({ label: createAction.label, icon: "☆" });
}
items.forEach(item => {
const formatted = formatItem(item);
menuItems.push({ label: formatted, icon: "☆" });
});
const header = typeof headerContent === "function"
? await headerContent(metadata)
: headerContent;
const selected = await selectMenu(title, menuItems, 0, "", header, breadcrumb);
// Back or ESC
if (selected === -1 || selected === 0) {
return;
}
// Create action
if (createAction && selected === 1) {
await createAction.action();
continue;
}
// Select item
const offset = createAction ? 2 : 1;
const itemIndex = selected - offset;
if (itemIndex >= 0 && itemIndex < items.length) {
await onSelect(items[itemIndex]);
}
}
}
module.exports = {
showMenuWithBack,
showListMenu
};
+136
View File
@@ -0,0 +1,136 @@
const api = require("../api/client");
const { prompt } = require("./input");
const { clearScreen } = require("./display");
// Provider alias order: OAuth first, then API Key (matches ModelSelectModal)
const PROVIDER_ALIAS_ORDER = [
"cc", "ag", "cx", "if", "qw", "gc", "gh", "kr",
"openrouter", "glm", "kimi", "minimax", "openai", "anthropic", "gemini"
];
// Alias to display name mapping
const PROVIDER_ALIAS_NAMES = {
cc: "Claude Code",
ag: "Antigravity",
cx: "OpenAI Codex",
if: "iFlow AI",
qw: "Qwen Code",
gc: "Gemini CLI",
gh: "GitHub Copilot",
kr: "Kiro AI",
openrouter: "OpenRouter",
glm: "GLM Coding",
kimi: "Kimi Coding",
minimax: "Minimax Coding",
openai: "OpenAI",
anthropic: "Anthropic",
gemini: "Gemini"
};
/**
* Get all available models grouped by provider + combos
* @returns {Promise<{combos: Array, groups: Object}>}
*/
async function getAvailableModelsGrouped() {
const result = await api.getAvailableModels();
if (!result.success) return { combos: [], groups: {} };
const models = result.data?.data || [];
const combos = [];
const groups = {};
models.forEach(m => {
if (m.owned_by === "combo") {
combos.push(m.id);
} else {
const provider = m.owned_by;
if (!groups[provider]) {
groups[provider] = [];
}
groups[provider].push(m.id);
}
});
return { combos, groups };
}
/**
* Display model list and prompt for selection
* @param {string} title - Title to display
* @param {string} currentValue - Current selected value (optional)
* @param {Object} options - { excludeCombos?: boolean }
* @returns {Promise<string|null>} Selected model ID or null if cancelled
*/
async function selectModelFromList(title, currentValue = "", options = {}) {
const { excludeCombos = false } = options;
const { combos: rawCombos, groups } = await getAvailableModelsGrouped();
const combos = excludeCombos ? [] : rawCombos;
const totalModels = combos.length + Object.values(groups).flat().length;
if (totalModels === 0) {
return null;
}
// Build flat list for selection
const allModels = [];
// Display
clearScreen();
console.log(`\n🎯 ${title}`);
console.log("=".repeat(50));
if (currentValue) {
console.log(`Current: ${currentValue}\n`);
} else {
console.log();
}
let idx = 1;
// Combos first (skipped when excludeCombos is true)
if (combos.length > 0) {
console.log("[Combos]");
combos.forEach(combo => {
console.log(` ${idx}. ${combo}`);
allModels.push(combo);
idx++;
});
console.log();
}
// Provider groups in order (by alias)
const sortedProviders = Object.keys(groups).sort((a, b) => {
const idxA = PROVIDER_ALIAS_ORDER.indexOf(a);
const idxB = PROVIDER_ALIAS_ORDER.indexOf(b);
return (idxA === -1 ? 999 : idxA) - (idxB === -1 ? 999 : idxB);
});
sortedProviders.forEach(provider => {
const providerName = PROVIDER_ALIAS_NAMES[provider] || provider;
console.log(`[${providerName}]`);
groups[provider].forEach(model => {
console.log(` ${idx}. ${model}`);
allModels.push(model);
idx++;
});
console.log();
});
console.log(" 0. Cancel\n");
// Prompt for number input
const input = await prompt("Enter number: ");
const num = parseInt(input, 10);
if (isNaN(num) || num === 0 || num < 0 || num > allModels.length) {
return null;
}
return allModels[num - 1];
}
module.exports = {
selectModelFromList,
getAvailableModelsGrouped,
PROVIDER_ALIAS_ORDER,
PROVIDER_ALIAS_NAMES
};