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 @@ [![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) decolua%2F9router | Trendshift @@ -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 @@ [![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) decolua%2F9router | Trendshift @@ -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("/", "/")}`);