diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index 343db39..176d4be 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -7,8 +7,8 @@ on:
workflow_dispatch:
env:
- REGISTRY: ghcr.io
- IMAGE_NAME: ${{ github.repository }}
+ GHCR_IMAGE: ghcr.io/${{ github.repository }}
+ DOCKERHUB_IMAGE: decolua/9router
jobs:
build-and-push:
@@ -22,18 +22,26 @@ jobs:
- uses: docker/setup-buildx-action@v3
- - name: Log in to Container Registry
+ - name: Log in to GHCR
uses: docker/login-action@v3
with:
- registry: ${{ env.REGISTRY }}
+ registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
+ - name: Log in to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
- images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+ images: |
+ ${{ env.GHCR_IMAGE }}
+ ${{ env.DOCKERHUB_IMAGE }}
tags: |
type=sha,prefix=
type=semver,pattern={{version}}
@@ -47,8 +55,8 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
- cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
+ cache-from: type=registry,ref=${{ env.GHCR_IMAGE }}:buildcache
+ cache-to: type=registry,ref=${{ env.GHCR_IMAGE }}:buildcache,mode=max
platforms: linux/amd64,linux/arm64
provenance: false
sbom: false
diff --git a/.gitignore b/.gitignore
index 07e7f7d..9123977 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,7 +50,6 @@ source/*
docs/*
!docs/ARCHITECTURE.md
test/*
-bin/*
open-sse/test/*
RM.vn.md
RM.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c0d28cf..39c1b53 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,19 @@
+# v0.4.36 (2026-05-13)
+
+## Features
+- Add MiniMax TTS provider support (#1043)
+- Docker images now published on both Docker Hub (`decolua/9router`) and GHCR ā 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)
+
# v0.4.33 (2026-05-12)
## Improvements
diff --git a/DOCKER.md b/DOCKER.md
index 2ef89da..79013c1 100644
--- a/DOCKER.md
+++ b/DOCKER.md
@@ -1,55 +1,12 @@
# Docker
-This project ships with a `Dockerfile` for building and running 9Router in a container.
+Run 9Router in a container. Published image: [`decolua/9router`](https://hub.docker.com/r/decolua/9router) ā multi-platform `linux/amd64` + `linux/arm64`.
-## Build image
+---
-```bash
-docker build -t 9router .
-```
+# š¤ For Users
-## Start container
-
-```bash
-docker run --rm \
- -p 20128:20128 \
- -v "$HOME/.9router:/app/data" \
- -e DATA_DIR=/app/data \
- --name 9router \
- 9router
-```
-
-The app listens on port `20128` in the container.
-
-## What the volume does
-
-```bash
--v "$HOME/.9router:/app/data" \
--e DATA_DIR=/app/data
-```
-
-`9router` stores its database at `path.join(DATA_DIR, "db.json")`.
-Without `DATA_DIR`, the app falls back to the current user's home directory (for example `~/.9router/db.json` on macOS/Linux). In the container, set `DATA_DIR=/app/data` so the bind mount is actually used.
-
-With the example above, the database file is:
-
-```text
-/app/data/db.json
-```
-
-and it is persisted on the host at:
-
-```text
-$HOME/.9router/db.json
-```
-
-## Stop container
-
-```bash
-docker stop 9router
-```
-
-## Run in background
+## Quick start
```bash
docker run -d \
@@ -57,23 +14,46 @@ docker run -d \
-v "$HOME/.9router:/app/data" \
-e DATA_DIR=/app/data \
--name 9router \
- 9router
+ decolua/9router:latest
```
-## View logs
+App listens on port `20128`. Open: http://localhost:20128
+
+## Manage container
```bash
-docker logs -f 9router
+docker logs -f 9router # view logs
+docker stop 9router # stop
+docker start 9router # start again
+docker rm -f 9router # remove
```
-## Optional environment variables
-
-You can override runtime env vars with `-e`.
-
-Example:
+## Data persistence
```bash
-docker run --rm \
+-v "$HOME/.9router:/app/data" \
+-e DATA_DIR=/app/data
+```
+
+Without `DATA_DIR`, the app falls back to `~/.9router/` (macOS/Linux) or `%APPDATA%\9router\` (Windows). In the container, `DATA_DIR=/app/data` makes the bind mount work.
+
+Data layout under `$DATA_DIR/`:
+
+```text
+$DATA_DIR/
+āāā db/
+ā āāā data.sqlite # main SQLite database
+ā āāā backups/ # auto backups
+āāā ... # certs, logs, runtime configs
+```
+
+Host path: `$HOME/.9router/db/data.sqlite`
+Container path: `/app/data/db/data.sqlite`
+
+## Optional env vars
+
+```bash
+docker run -d \
-p 20128:20128 \
-v "$HOME/.9router:/app/data" \
-e DATA_DIR=/app/data \
@@ -81,13 +61,53 @@ docker run --rm \
-e HOSTNAME=0.0.0.0 \
-e DEBUG=true \
--name 9router \
+ decolua/9router:latest
+```
+
+## Update to latest
+
+```bash
+docker pull decolua/9router:latest
+docker rm -f 9router
+# re-run the quick start command
+```
+
+---
+
+# š For Developers
+
+## Build image locally
+
+```bash
+# from repo root
+npm run docker:build
+```
+
+Or directly:
+```bash
+cd app && docker build -t 9router .
+```
+
+Run local build:
+```bash
+docker run --rm -p 20128:20128 \
+ -v "$HOME/.9router:/app/data" \
+ -e DATA_DIR=/app/data \
9router
```
-## Rebuild after code changes
+## Publish to Docker Hub (multi-platform)
+
+Triggered automatically by `npm run cli:publish`. Manual:
```bash
-docker build -t 9router .
+# 1. Login once
+docker login
+
+# 2. Build amd64 + arm64 + push (tag from app/cli/package.json version)
+npm run docker:publish
```
-Then restart the container.
+Tags pushed:
+- `decolua/9router:v{version}`
+- `decolua/9router:latest`
diff --git a/README.md b/README.md
index e40a555..bcc4da6 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,8 @@
[](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)
@@ -1048,51 +1050,55 @@ pm2 startup
### Docker
+Published images (multi-platform `linux/amd64` + `linux/arm64`):
+- Docker Hub: [`decolua/9router`](https://hub.docker.com/r/decolua/9router)
+- GHCR: [`ghcr.io/decolua/9router`](https://github.com/decolua/9router/pkgs/container/9router)
+
+**Quick start (use published image):**
+
```bash
-# Build image (from repository root)
+docker run -d \
+ --name 9router \
+ -p 20128:20128 \
+ -v "$HOME/.9router:/app/data" \
+ -e DATA_DIR=/app/data \
+ decolua/9router:latest
+```
+
+ā Open http://localhost:20128
+
+**Build from source (dev):**
+
+```bash
+git clone https://github.com/decolua/9router.git
+cd 9router/app
docker build -t 9router .
-
-# Run container (command used in current setup)
-docker run -d \
- --name 9router \
- -p 20128:20128 \
- --env-file /root/dev/9router/.env \
- -v 9router-data:/app/data \
- -v 9router-usage:/root/.9router \
- 9router
+docker run -d --name 9router -p 20128:20128 \
+ -v "$HOME/.9router:/app/data" -e DATA_DIR=/app/data 9router
```
-Portable command (if you are already at repository root):
-
-```bash
-docker run -d \
- --name 9router \
- -p 20128:20128 \
- --env-file ./.env \
- -v 9router-data:/app/data \
- -v 9router-usage:/root/.9router \
- 9router
-```
-
-Container defaults:
+**Container defaults:**
- `PORT=20128`
- `HOSTNAME=0.0.0.0`
-Useful commands:
+**Useful commands:**
```bash
docker logs -f 9router
docker restart 9router
docker stop 9router && docker rm 9router
+docker pull decolua/9router:latest # update to latest
```
+**Data persistence:** `$HOME/.9router/db/data.sqlite` on host ā `/app/data/db/data.sqlite` in container.
+
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `JWT_SECRET` | `9router-default-secret-change-me` | JWT signing secret for dashboard auth cookie (**change in production**) |
| `INITIAL_PASSWORD` | `123456` | First login password when no saved hash exists |
-| `DATA_DIR` | `~/.9router` | Main app database location (`db.json`) |
+| `DATA_DIR` | `~/.9router` | Main app data location (SQLite at `$DATA_DIR/db/data.sqlite`) |
| `PORT` | framework default | Service port (`20128` in examples) |
| `HOSTNAME` | framework default | Bind host (Docker defaults to `0.0.0.0`) |
| `NODE_ENV` | runtime default | Set `production` for deploy |
@@ -1115,9 +1121,9 @@ Notes:
### Runtime Files and Storage
-- Main app state: `${DATA_DIR}/db.json` (providers, combos, aliases, keys, settings), managed by `src/lib/localDb.js`.
-- Usage history and logs: `${DATA_DIR}/usage.json` and `${DATA_DIR}/log.txt`, managed by `src/lib/usageDb.js`.
-- Optional request/translator logs: `/logs/...` when `ENABLE_REQUEST_LOGS=true`.
+- Main app state: `${DATA_DIR}/db/data.sqlite` (SQLite ā providers, combos, aliases, keys, settings, usage history)
+- Auto backups: `${DATA_DIR}/db/backups/`
+- Optional request/translator logs: `/logs/...` when `ENABLE_REQUEST_LOGS=true`
- Both `${DATA_DIR}` and `~/.9router` resolve to the same location in a Docker container ā the symlink `/root/.9router -> /app/data` is created at build time.
@@ -1228,7 +1234,7 @@ Notes:
- **Runtime**: Node.js 20+
- **Framework**: Next.js 16
- **UI**: React 19 + Tailwind CSS 4
-- **Database**: LowDB (JSON file-based)
+- **Database**: SQLite (better-sqlite3 / node:sqlite / sql.js fallback)
- **Streaming**: Server-Sent Events (SSE)
- **Auth**: OAuth 2.0 (PKCE) + JWT + API Keys
diff --git a/cli/README.md b/cli/README.md
index 8e4ee69..e77b927 100644
--- a/cli/README.md
+++ b/cli/README.md
@@ -6,6 +6,8 @@
[](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)
@@ -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)
---
diff --git a/cli/package.json b/cli/package.json
index 823e6ad..1c54755 100644
--- a/cli/package.json
+++ b/cli/package.json
@@ -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"
+ }
}
diff --git a/cli/scripts/build-cli.js b/cli/scripts/build-cli.js
new file mode 100644
index 0000000..980f1bf
--- /dev/null
+++ b/cli/scripts/build-cli.js
@@ -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
+}
diff --git a/cli/scripts/buildMitm.js b/cli/scripts/buildMitm.js
index 02fceab..45c1664 100644
--- a/cli/scripts/buildMitm.js
+++ b/cli/scripts/buildMitm.js
@@ -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");
}
}
diff --git a/cli/src/cli/utils/input.js b/cli/src/cli/utils/input.js
index b4482cb..fbccf3c 100644
--- a/cli/src/cli/utils/input.js
+++ b/cli/src/cli/utils/input.js
@@ -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} 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} 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} 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}
- */
-async function pause(message = "Press Enter to continue...") {
- const rl = readline.createInterface({
- input: process.stdin,
- output: process.stdout
- });
-
- return new Promise((resolve) => {
- rl.question(message, () => {
- rl.close();
- resolve();
- });
- });
-}
-
-/**
- * Show interactive menu with arrow key navigation
- * @param {string} title - Menu title
- * @param {Array<{label: string, icon?: string}>} items - Menu items
- * @param {number} defaultIndex - Default selected index
- * @param {string} headerContent - Optional content to show above menu
- * @param {Array} breadcrumb - Optional breadcrumb path
- * @returns {Promise} Selected index, or -1 if ESC pressed
+ * 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
};
diff --git a/next.config.mjs b/next.config.mjs
index de5874f..495f58f 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -1,16 +1,19 @@
import { fileURLToPath } from "node:url";
-import { dirname } from "node:path";
+import { dirname, join } from "node:path";
const projectRoot = dirname(fileURLToPath(import.meta.url));
+// Workspace root (one level up from app/) ā where npm hoists deps.
+// Next.js tracing must scan from here to find "next", "react", etc.
+const workspaceRoot = join(projectRoot, "..");
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
serverExternalPackages: ["better-sqlite3", "sql.js", "node:sqlite", "bun:sqlite"],
turbopack: {
- root: projectRoot
+ root: workspaceRoot
},
- outputFileTracingRoot: projectRoot,
+ outputFileTracingRoot: workspaceRoot,
outputFileTracingExcludes: {
"*": ["./gitbook/**/*"]
},
@@ -28,7 +31,7 @@ const nextConfig = {
};
}
// Exclude logs, .next, gitbook subapp from watcher
- config.watchOptions = { ...config.watchOptions, ignored: /[\\/](logs|\.next|gitbook)[\\/]/ };
+ config.watchOptions = { ...config.watchOptions, ignored: /[\\/](logs|\.next|gitbook|cli)[\\/]/ };
return config;
},
async rewrites() {
diff --git a/package.json b/package.json
index c59530b..82480ec 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "9router-app",
- "version": "0.4.33",
+ "version": "0.4.36",
"description": "9Router web dashboard",
"private": true,
"scripts": {
diff --git a/scripts/dockerBuild.js b/scripts/dockerBuild.js
new file mode 100644
index 0000000..0722626
--- /dev/null
+++ b/scripts/dockerBuild.js
@@ -0,0 +1,28 @@
+#!/usr/bin/env node
+
+// Build Docker image locally for current platform (test/dev).
+// Usage: node app/scripts/dockerBuild.js
+
+const { execSync } = require("child_process");
+const path = require("path");
+const fs = require("fs");
+
+const IMAGE = "decolua/9router";
+const appDir = path.resolve(__dirname, "..");
+const cliPkgPath = path.resolve(appDir, "cli", "package.json");
+const version = JSON.parse(fs.readFileSync(cliPkgPath, "utf8")).version;
+const tag = `v${version}`;
+
+console.log(`\nš³ Building ${IMAGE}:${tag} (local platform)...\n`);
+
+try {
+ execSync(
+ `docker build -t ${IMAGE}:${tag} -t ${IMAGE}:latest .`,
+ { stdio: "inherit", cwd: appDir }
+ );
+ console.log(`\nā
Built ${IMAGE}:${tag} + ${IMAGE}:latest`);
+ console.log(`ā¶ļø Run: docker run --rm -p 20128:20128 -v "$HOME/.9router:/app/data" -e DATA_DIR=/app/data ${IMAGE}:${tag}`);
+} catch (e) {
+ console.error("ā Docker build failed");
+ process.exit(1);
+}
diff --git a/scripts/dockerPublish.js b/scripts/dockerPublish.js
new file mode 100644
index 0000000..9ddce34
--- /dev/null
+++ b/scripts/dockerPublish.js
@@ -0,0 +1,58 @@
+#!/usr/bin/env node
+
+// Build & push multi-platform Docker image to Docker Hub.
+// Requires: docker login + buildx (Docker Desktop ships with it).
+// Usage: node app/scripts/dockerPublish.js
+
+const { execSync } = require("child_process");
+const path = require("path");
+const fs = require("fs");
+
+const IMAGE = "decolua/9router";
+const PLATFORMS = "linux/amd64,linux/arm64";
+const BUILDER = "9router-builder";
+
+const appDir = path.resolve(__dirname, "..");
+const cliPkgPath = path.resolve(appDir, "cli", "package.json");
+const version = JSON.parse(fs.readFileSync(cliPkgPath, "utf8")).version;
+const tag = `v${version}`;
+
+function run(cmd, opts = {}) {
+ console.log(`$ ${cmd}`);
+ execSync(cmd, { stdio: "inherit", cwd: appDir, ...opts });
+}
+
+console.log(`\nš³ Publishing ${IMAGE}:${tag} (${PLATFORMS}) to Docker Hub...\n`);
+
+// Verify docker login by checking config
+try {
+ const cfg = path.join(process.env.HOME || "", ".docker", "config.json");
+ if (fs.existsSync(cfg)) {
+ const json = JSON.parse(fs.readFileSync(cfg, "utf8"));
+ if (!json.auths || Object.keys(json.auths).length === 0) {
+ console.error("ā Not logged in to Docker Hub. Run: docker login");
+ process.exit(1);
+ }
+ }
+} catch {}
+
+// Ensure buildx builder exists with multi-arch support
+try {
+ execSync(`docker buildx inspect ${BUILDER}`, { stdio: "ignore", cwd: appDir });
+ console.log(`ā
Builder "${BUILDER}" exists`);
+} catch {
+ console.log(`š§ Creating buildx builder "${BUILDER}"...`);
+ run(`docker buildx create --name ${BUILDER} --driver docker-container --use`);
+ run(`docker buildx inspect --bootstrap`);
+}
+
+// Build + push multi-platform image
+run(
+ `docker buildx build --builder ${BUILDER} --platform ${PLATFORMS} ` +
+ `-t ${IMAGE}:${tag} -t ${IMAGE}:latest --push .`
+);
+
+console.log(`\nā
Published:`);
+console.log(` - ${IMAGE}:${tag}`);
+console.log(` - ${IMAGE}:latest`);
+console.log(`š https://hub.docker.com/r/${IMAGE.replace("/", "/")}`);