# 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:
22
.github/workflows/docker-publish.yml
vendored
22
.github/workflows/docker-publish.yml
vendored
@@ -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
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -50,7 +50,6 @@ source/*
|
||||
docs/*
|
||||
!docs/ARCHITECTURE.md
|
||||
test/*
|
||||
bin/*
|
||||
open-sse/test/*
|
||||
RM.vn.md
|
||||
RM.md
|
||||
|
||||
16
CHANGELOG.md
16
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
|
||||
|
||||
138
DOCKER.md
138
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`
|
||||
|
||||
64
README.md
64
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)
|
||||
|
||||
<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>
|
||||
@@ -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: `<repo>/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: `<repo>/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.
|
||||
|
||||
</details>
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
<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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
cli/scripts/build-cli.js
Normal file
239
cli/scripts/build-cli.js
Normal 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
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const readline = require("readline");
|
||||
const { Input, Confirm, Select } = require("enquirer");
|
||||
|
||||
const COLORS = {
|
||||
reset: "\x1b[0m",
|
||||
@@ -14,216 +14,134 @@ 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
|
||||
*/
|
||||
async function prompt(question) {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
// Hex color used by enquirer styles
|
||||
const TERRACOTTA_HEX = "#D97757";
|
||||
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
function handleCancel(err) {
|
||||
// Enquirer throws empty string on ESC/Ctrl+C — treat as cancel
|
||||
if (err === "" || err === undefined) return null;
|
||||
throw 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}`);
|
||||
});
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
if (lower === "y" || lower === "yes") {
|
||||
return true;
|
||||
}
|
||||
if (lower === "n" || lower === "no") {
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log("Please answer 'y' or 'n'");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
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) {
|
||||
// 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 p = new Input({ name: "value", message: question.replace(/:\s*$/, "") });
|
||||
try {
|
||||
const answer = await runPrompt(p);
|
||||
return (answer || "").trim();
|
||||
} catch (err) {
|
||||
// TTY disconnected or EIO error - exit gracefully
|
||||
resolve(-1);
|
||||
return;
|
||||
return handleCancel(err) ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
const renderMenu = () => {
|
||||
if (!isActive) return;
|
||||
async function select(question, options) {
|
||||
const p = new Select({
|
||||
name: "value",
|
||||
message: question,
|
||||
choices: options.map((label, i) => ({ name: String(i), message: label })),
|
||||
});
|
||||
try {
|
||||
const answer = await runPrompt(p);
|
||||
return parseInt(answer, 10);
|
||||
} catch (err) {
|
||||
handleCancel(err);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear previous menu
|
||||
async function confirm(question) {
|
||||
const p = new Confirm({ name: "value", message: question });
|
||||
try {
|
||||
return await runPrompt(p);
|
||||
} catch (err) {
|
||||
handleCancel(err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pause(message = "Press Enter to continue...") {
|
||||
const p = new Input({ name: "value", message });
|
||||
try {
|
||||
await runPrompt(p);
|
||||
} catch (err) {
|
||||
handleCancel(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = []) {
|
||||
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 p = new Select({
|
||||
name: "menu",
|
||||
message: "Select",
|
||||
initial: defaultIndex,
|
||||
choices: items.map((item, i) => ({ name: String(i), message: item.label })),
|
||||
});
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (!isActive) return;
|
||||
isActive = false;
|
||||
|
||||
if (process.stdin.isTTY) {
|
||||
try {
|
||||
process.stdin.setRawMode(false);
|
||||
const answer = await runPrompt(p);
|
||||
return parseInt(answer, 10);
|
||||
} catch (err) {
|
||||
// Ignore cleanup errors
|
||||
handleCancel(err);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
prompt,
|
||||
select,
|
||||
confirm,
|
||||
pause,
|
||||
selectMenu
|
||||
selectMenu,
|
||||
COLORS
|
||||
};
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "9router-app",
|
||||
"version": "0.4.33",
|
||||
"version": "0.4.36",
|
||||
"description": "9Router web dashboard",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
28
scripts/dockerBuild.js
Normal file
28
scripts/dockerBuild.js
Normal file
@@ -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);
|
||||
}
|
||||
58
scripts/dockerPublish.js
Normal file
58
scripts/dockerPublish.js
Normal file
@@ -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("/", "/")}`);
|
||||
Reference in New Issue
Block a user