# 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:
parent
692d1fdf57
commit
7ccf8c5e84
14 changed files with 616 additions and 314 deletions
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");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue