chore: import upstream snapshot with attribution
Deploy GitBook to 9router.github.io / build-deploy (push) Failing after 2s
Deploy GitBook to 9router.github.io / build-deploy (push) Failing after 2s
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
app/*
|
||||
node_modules/*
|
||||
@@ -0,0 +1,9 @@
|
||||
# Ignore everything except what's in package.json "files"
|
||||
*
|
||||
!cli.js
|
||||
!hooks/
|
||||
!app/
|
||||
!package.json
|
||||
!README.md
|
||||
!LICENSE
|
||||
|
||||
+42
@@ -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
@@ -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.**
|
||||
|
||||
[](https://www.npmjs.com/package/9router)
|
||||
[](https://www.npmjs.com/package/9router)
|
||||
[](https://hub.docker.com/r/decolua/9router)
|
||||
[](https://github.com/decolua/9router/pkgs/container/9router)
|
||||
[](https://github.com/decolua/9router/blob/main/LICENSE)
|
||||
|
||||
<a href="https://trendshift.io/repositories/22628" target="_blank"><img src="https://trendshift.io/api/badge/repositories/22628" alt="decolua%2F9router | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
[🌐 Website](https://9router.com) • [📖 Full Docs](https://github.com/decolua/9router)
|
||||
|
||||
---
|
||||
|
||||
## 🤔 Why 9Router?
|
||||
|
||||
**Stop wasting money, tokens and hitting limits:**
|
||||
|
||||
- ❌ Subscription quota expires unused every month
|
||||
- ❌ Rate limits stop you mid-coding
|
||||
- ❌ Tool outputs (git diff, grep, ls...) burn tokens fast
|
||||
- ❌ Expensive APIs ($20-50/month per provider)
|
||||
|
||||
**9Router solves this:**
|
||||
|
||||
- ✅ **RTK Token Saver** - Auto-compress tool_result, save 20-40% tokens
|
||||
- ✅ **Maximize subscriptions** - Track quota, use every bit before reset
|
||||
- ✅ **Auto fallback** - Subscription → Cheap → Free, zero downtime
|
||||
- ✅ **Multi-account** - Round-robin between accounts per provider
|
||||
- ✅ **Universal** - Works with any OpenAI/Claude-compatible CLI
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Quick Start
|
||||
|
||||
**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
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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); });
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 |
@@ -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
|
||||
};
|
||||
@@ -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()
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user