# v0.4.36 (2026-05-13)

## Features
- Add MiniMax TTS provider support (#1043)
- Docker images now published on both GHCR & Docker Hub (decolua/9router) — pull from your preferred registry

## Improvements
- Replace browser confirm dialogs with custom ConfirmModal (#1060)

## Fixes
- Fix Docker `Cannot find module 'next'` error in standalone build
- Restore /app/server.js in Docker standalone build (#1064, #1067)
- Fix CLI TUI menu arrow-key escape sequences leaking (^[[A^[[B)
- Switch macOS/Linux tray to systray2 fork (fixes Kaspersky AV false-positive) (#1080)
- Fix zoom controls contrast in topology view (#1066)
This commit is contained in:
decolua
2026-05-13 18:22:31 +07:00
parent 692d1fdf57
commit 7ccf8c5e84
14 changed files with 616 additions and 314 deletions
+16 -3
View File
@@ -6,6 +6,8 @@
[![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router)
[![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router)
[![Docker Hub](https://img.shields.io/docker/pulls/decolua/9router.svg?logo=docker&label=Docker%20Hub)](https://hub.docker.com/r/decolua/9router)
[![GHCR](https://img.shields.io/badge/GHCR-decolua%2F9router-blue?logo=github)](https://github.com/decolua/9router/pkgs/container/9router)
[![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE)
<a href="https://trendshift.io/repositories/22628" target="_blank"><img src="https://trendshift.io/api/badge/repositories/22628" alt="decolua%2F9router | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
@@ -35,7 +37,7 @@
## ⚡ Quick Start
**1. Install & run:**
**Option 1 — npm (recommended for desktop):**
```bash
npm install -g 9router
@@ -45,6 +47,16 @@ npm install -g 9router
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):**
@@ -88,8 +100,9 @@ Any tool supporting OpenAI/Claude-compatible API works.
## 💾 Data Location
- **macOS/Linux**: `~/.9router/db.json`
- **Windows**: `%APPDATA%/9router/db.json`
- **macOS/Linux**: `~/.9router/db/data.sqlite`
- **Windows**: `%APPDATA%/9router/db/data.sqlite`
- **Docker**: `/app/data/db/data.sqlite` (mount `$HOME/.9router` to persist)
---
+11 -3
View File
@@ -1,6 +1,6 @@
{
"name": "9router",
"version": "0.4.33",
"version": "0.4.36",
"description": "9Router CLI - Start and manage 9Router server",
"bin": {
"9router": "./cli.js"
@@ -14,10 +14,15 @@
"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 && node ../scripts/dockerPublish.js",
"postinstall": "node hooks/postinstall.js",
"prepublishOnly": "cd .. && npm run build:cli"
"prepublishOnly": "npm run build"
},
"dependencies": {
"enquirer": "^2.4.1",
"node-forge": "^1.3.3",
"node-machine-id": "^1.1.12",
"react": "19.2.1",
@@ -35,5 +40,8 @@
"ai",
"api"
],
"license": "MIT"
"license": "MIT",
"devDependencies": {
"nodemon": "^3.1.14"
}
}
+239
View File
@@ -0,0 +1,239 @@
#!/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 = path.join(cliDir, "app");
// 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
"logs", // Runtime logs
".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");
// 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"));
appPkg.version = cliPkg.version;
fs.writeFileSync(appPkgPath, JSON.stringify(appPkg, null, 2) + "\n");
console.log(`✅ Version synced: ${cliPkg.version}\n`);
// Step 1: Build app with Next.js
console.log("1️⃣ Building Next.js app...");
try {
execSync("npm run build", {
stdio: "inherit",
cwd: appDir
});
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.
// With outputFileTracingRoot = workspace root, Next places app files under
// .next/standalone/app/ and traced node_modules under .next/standalone/node_modules/.
console.log("3️⃣ Copying Next.js standalone build to app/cli/app...");
const standaloneRoot = path.join(appDir, ".next", "standalone");
const standaloneApp = path.join(standaloneRoot, "app");
if (!fs.existsSync(standaloneApp)) {
console.error("❌ Next.js standalone build not found at .next/standalone/app");
console.error("Make sure output: 'standalone' is set in next.config.js");
process.exit(1);
}
copyRecursive(standaloneApp, cliAppDir);
// Copy traced node_modules from standalone root into CLI bundle
const standaloneNodeModules = path.join(standaloneRoot, "node_modules");
if (fs.existsSync(standaloneNodeModules)) {
copyRecursive(standaloneNodeModules, path.join(cliAppDir, "node_modules"));
}
console.log("✅ Copied standalone build\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 staticDest = path.join(cliAppDir, ".next", "static");
if (fs.existsSync(staticSrc)) {
copyRecursive(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 vendorChunksDest = path.join(cliAppDir, ".next", "server", "vendor-chunks");
if (fs.existsSync(vendorChunksSrc)) {
copyRecursive(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
}
+8 -22
View File
@@ -6,14 +6,13 @@ const path = require("path");
const BUILD_CONFIG = {
bundle: true,
minify: true,
obfuscate: false,
cleanPlainFiles: true,
};
// ─────────────────────────────────────────────────────────
const binDir = path.resolve(__dirname, "..");
const appDir = path.resolve(binDir, "..", "app");
const binMitmDir = path.join(binDir, "app", "src", "mitm");
const cliDir = path.resolve(__dirname, "..");
const appDir = path.resolve(cliDir, "..");
const cliMitmDir = path.join(cliDir, "app", "src", "mitm");
// Bundle everything — no externals. This keeps MITM runtime self-contained so
// it can be copied to DATA_DIR/runtime/ and spawned from there (escapes
// node_modules file locks that block `npm i -g 9router@latest` on Windows).
@@ -22,7 +21,7 @@ const ENTRIES = ["server.js"];
async function buildEntry(entry) {
const mitmSrc = path.join(appDir, "src", "mitm");
const output = path.join(binMitmDir, entry);
const output = path.join(cliMitmDir, entry);
const buildPlugin = {
name: "build-plugin",
@@ -36,9 +35,6 @@ async function buildEntry(entry) {
const steps = [];
if (BUILD_CONFIG.bundle) {
const useTemp = BUILD_CONFIG.obfuscate;
const outfile = useTemp ? output.replace(".js", ".bundled.js") : output;
await esbuild.build({
entryPoints: [path.join(mitmSrc, entry)],
bundle: true,
@@ -47,20 +43,10 @@ async function buildEntry(entry) {
target: "node18",
external: EXTERNALS,
plugins: [buildPlugin],
outfile,
outfile: output,
});
steps.push("bundled");
if (BUILD_CONFIG.minify) steps.push("minified");
if (BUILD_CONFIG.obfuscate) {
const { execSync } = require("child_process");
execSync(
`npx javascript-obfuscator "${outfile}" --output "${output}" --compact true --string-array true --string-array-encoding base64`,
{ stdio: "inherit", cwd: appDir }
);
fs.unlinkSync(outfile);
steps.push("obfuscated");
}
}
console.log(`${steps.join(" + ")}${output}`);
@@ -74,10 +60,10 @@ async function run() {
if (BUILD_CONFIG.cleanPlainFiles) {
const keep = new Set(ENTRIES);
for (const name of fs.readdirSync(binMitmDir)) {
if (!keep.has(name)) fs.rmSync(path.join(binMitmDir, name), { recursive: true, force: true });
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 bin");
console.log("✅ Removed plain MITM files from CLI bundle");
}
}
+103 -185
View File
@@ -1,4 +1,4 @@
const readline = require("readline");
const { Input, Confirm, Select } = require("enquirer");
const COLORS = {
reset: "\x1b[0m",
@@ -14,210 +14,127 @@ const COLORS = {
bgGreen: "\x1b[42m",
bgBlue: "\x1b[44m",
black: "\x1b[30m",
// Terracotta/Earth orange - using RGB escape code
terracotta: "\x1b[38;2;217;119;87m", // #D97757
terracotta: "\x1b[38;2;217;119;87m",
bgTerracotta: "\x1b[48;2;217;119;87m"
};
/**
* Ask a question and return the user's answer
* @param {string} question - The question to ask
* @returns {Promise<string>} The user's answer
*/
// Hex color used by enquirer styles
const TERRACOTTA_HEX = "#D97757";
function handleCancel(err) {
// Enquirer throws empty string on ESC/Ctrl+C — treat as cancel
if (err === "" || err === undefined) return null;
throw err;
}
// Workaround enquirer raw-mode bug (PR #460): prime stdin into raw mode
// + utf8 encoding BEFORE each prompt so arrow keys don't leak as ^[[A/^[[B.
function primeStdin() {
if (!process.stdin.isTTY) return;
try {
process.stdin.setRawMode(true);
process.stdin.setEncoding("utf8");
process.stdin.resume();
} catch {}
}
function restoreStdin() {
if (!process.stdin.isTTY) return;
try {
process.stdin.setRawMode(false);
} catch {}
process.stdin.pause();
}
async function runPrompt(p) {
primeStdin();
try {
return await p.run();
} finally {
restoreStdin();
}
}
async function prompt(question) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim());
});
});
const p = new Input({ name: "value", message: question.replace(/:\s*$/, "") });
try {
const answer = await runPrompt(p);
return (answer || "").trim();
} catch (err) {
return handleCancel(err) ?? "";
}
}
/**
* Show a numbered menu and return the selected option number
* @param {string} question - The question to ask
* @param {string[]} options - Array of options to display
* @returns {Promise<number>} The selected option index (0-based)
*/
async function select(question, options) {
console.log(question);
options.forEach((option, index) => {
console.log(` ${index + 1}. ${option}`);
const p = new Select({
name: "value",
message: question,
choices: options.map((label, i) => ({ name: String(i), message: label })),
});
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}`);
try {
const answer = await runPrompt(p);
return parseInt(answer, 10);
} catch (err) {
handleCancel(err);
return -1;
}
}
/**
* Ask a yes/no question and return boolean
* @param {string} question - The question to ask
* @returns {Promise<boolean>} True for yes, false for no
*/
async function confirm(question) {
while (true) {
const answer = await prompt(`${question} (y/n): `);
const lower = answer.toLowerCase();
const p = new Confirm({ name: "value", message: question });
try {
return await runPrompt(p);
} catch (err) {
handleCancel(err);
return false;
}
}
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...") {
const p = new Input({ name: "value", message });
try {
await runPrompt(p);
} catch (err) {
handleCancel(err);
}
}
/**
* Pause execution until user presses Enter
* @param {string} [message="Press Enter to continue..."] - Message to display
* @returns {Promise<void>}
*/
async function pause(message = "Press Enter to continue...") {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
return new Promise((resolve) => {
rl.question(message, () => {
rl.close();
resolve();
});
});
}
/**
* Show interactive menu with arrow key navigation
* @param {string} title - Menu title
* @param {Array<{label: string, icon?: string}>} items - Menu items
* @param {number} defaultIndex - Default selected index
* @param {string} headerContent - Optional content to show above menu
* @param {Array<string>} breadcrumb - Optional breadcrumb path
* @returns {Promise<number>} Selected index, or -1 if ESC pressed
* Interactive arrow-key menu using enquirer Select.
* Header (title/subtitle/breadcrumb/headerContent) rendered before prompt.
*/
async function selectMenu(title, items, defaultIndex = 0, subtitle = "", headerContent = "", breadcrumb = []) {
return new Promise((resolve) => {
let selectedIndex = defaultIndex;
let isActive = true;
// Remove any existing keypress listeners first
process.stdin.removeAllListeners("keypress");
readline.emitKeypressEvents(process.stdin);
if (process.stdin.isTTY) {
try {
process.stdin.setRawMode(true);
} catch (err) {
// TTY disconnected or EIO error - exit gracefully
resolve(-1);
return;
}
}
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 renderMenu = () => {
if (!isActive) return;
// Clear previous menu
process.stdout.write("\x1b[2J\x1b[H");
// Show title with terracotta color
const width = Math.min(process.stdout.columns || 40, 40);
console.log(`\n${COLORS.terracotta}${"=".repeat(width)}${COLORS.reset}`);
console.log(` ${COLORS.bright}${COLORS.terracotta}${title}${COLORS.reset}`);
// Show subtitle inside the frame
if (subtitle) {
console.log(` ${COLORS.dim}${subtitle}${COLORS.reset}`);
}
console.log(`${COLORS.terracotta}${"=".repeat(width)}${COLORS.reset}`);
// Show breadcrumb if provided
if (breadcrumb.length > 0) {
console.log(` ${COLORS.dim}${breadcrumb.join(" > ")}${COLORS.reset}`);
}
console.log();
// Show header content if provided
if (headerContent) {
console.log(headerContent);
console.log();
}
// Show menu items with proper alignment
items.forEach((item, index) => {
const isSelected = index === selectedIndex;
// Fallback to ASCII on Windows (cmd/powershell can't render unicode stars)
const isWin = process.platform === "win32";
const icon = isSelected ? (isWin ? ">" : "★") : (isWin ? " " : "☆");
if (isSelected) {
// Selected: reverse + bright for high visibility on any terminal
console.log(` ${COLORS.reverse}${COLORS.bright}${icon} ${item.label}${COLORS.reset}`);
} else {
// Not selected: plain text with empty star
console.log(` ${icon} ${item.label}`);
}
});
};
const cleanup = () => {
if (!isActive) return;
isActive = false;
if (process.stdin.isTTY) {
try {
process.stdin.setRawMode(false);
} catch (err) {
// Ignore cleanup errors
}
}
process.stdin.removeListener("keypress", onKeypress);
process.stdin.pause();
};
const onKeypress = (str, key) => {
if (!isActive || !key) return;
if (key.name === "up") {
selectedIndex = (selectedIndex - 1 + items.length) % items.length;
renderMenu();
} else if (key.name === "down") {
selectedIndex = (selectedIndex + 1) % items.length;
renderMenu();
} else if (key.name === "return") {
cleanup();
resolve(selectedIndex);
} else if (key.name === "escape") {
cleanup();
resolve(-1);
} else if (key.ctrl && key.name === "c") {
cleanup();
process.exit(0);
}
};
process.stdin.on("keypress", onKeypress);
process.stdin.resume();
renderMenu();
const p = new Select({
name: "menu",
message: "Select",
initial: defaultIndex,
choices: items.map((item, i) => ({ name: String(i), message: item.label })),
});
try {
const answer = await runPrompt(p);
return parseInt(answer, 10);
} catch (err) {
handleCancel(err);
return -1;
}
}
module.exports = {
@@ -225,5 +142,6 @@ module.exports = {
select,
confirm,
pause,
selectMenu
selectMenu,
COLORS
};