From 368f4c3e7fad00e64caf57843c9230ae53af226e Mon Sep 17 00:00:00 2001 From: decolua Date: Thu, 23 Apr 2026 16:39:31 +0700 Subject: [PATCH] - Added Hermes tool to CLI tools and updated related components. --- open-sse/config/runtimeConfig.js | 19 +- open-sse/executors/base.js | 8 +- open-sse/executors/kiro.js | 8 +- package.json | 2 +- public/providers/hermes.png | Bin 0 -> 23273 bytes .../dashboard/cli-tools/CLIToolsPageClient.js | 5 +- .../cli-tools/components/HermesToolCard.js | 321 ++++++++++++++++++ .../dashboard/cli-tools/components/index.js | 1 + .../api/cli-tools/hermes-settings/route.js | 175 ++++++++++ src/app/api/version/update/route.js | 21 ++ src/lib/appUpdater.js | 168 +++++++++ src/mitm/server.js | 20 +- src/shared/components/Sidebar.js | 96 ++++-- src/shared/constants/cliTools.js | 8 + src/shared/constants/config.js | 7 + 15 files changed, 821 insertions(+), 38 deletions(-) create mode 100644 public/providers/hermes.png create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js create mode 100644 src/app/api/cli-tools/hermes-settings/route.js create mode 100644 src/app/api/version/update/route.js create mode 100644 src/lib/appUpdater.js diff --git a/open-sse/config/runtimeConfig.js b/open-sse/config/runtimeConfig.js index 4ab70928..11108183 100644 --- a/open-sse/config/runtimeConfig.js +++ b/open-sse/config/runtimeConfig.js @@ -41,13 +41,24 @@ export const RETRY_CONFIG = { delayMs: 2000 }; -// Default retry config by status code (number of retry attempts) +// Default retry config by status code: { attempts, delayMs } +// Backward compat: if value is a number, treated as attempts with RETRY_CONFIG.delayMs export const DEFAULT_RETRY_CONFIG = { - 429: 0, // Rate limit - no retry, use account fallback instead - 503: 1, // Service unavailable - retry 1 time (transient) - 502: 1 // Bad gateway - retry 1 time (transient) + 429: { attempts: 0, delayMs: 0 }, + 502: { attempts: 1, delayMs: 3000 }, + 503: { attempts: 1, delayMs: 2000 } }; +// Normalize a retry entry to { attempts, delayMs } +export function resolveRetryEntry(entry) { + if (entry == null) return { attempts: 0, delayMs: RETRY_CONFIG.delayMs }; + if (typeof entry === "number") return { attempts: entry, delayMs: RETRY_CONFIG.delayMs }; + return { + attempts: entry.attempts || 0, + delayMs: entry.delayMs != null ? entry.delayMs : RETRY_CONFIG.delayMs + }; +} + // Requests containing these texts will bypass provider export const SKIP_PATTERNS = [ "Please write a 5-10 word title for the following conversation:" diff --git a/open-sse/executors/base.js b/open-sse/executors/base.js index eaea0792..7dcbc77e 100644 --- a/open-sse/executors/base.js +++ b/open-sse/executors/base.js @@ -1,4 +1,4 @@ -import { HTTP_STATUS, RETRY_CONFIG, DEFAULT_RETRY_CONFIG } from "../config/runtimeConfig.js"; +import { HTTP_STATUS, RETRY_CONFIG, DEFAULT_RETRY_CONFIG, resolveRetryEntry } from "../config/runtimeConfig.js"; import { resolveOllamaLocalHost } from "../config/providers.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; @@ -124,11 +124,11 @@ export class BaseExecutor { }, proxyOptions); // Retry based on status code config - const maxRetries = retryConfig[response.status] || 0; + const { attempts: maxRetries, delayMs } = resolveRetryEntry(retryConfig[response.status]); if (maxRetries > 0 && retryAttemptsByUrl[urlIndex] < maxRetries) { retryAttemptsByUrl[urlIndex]++; - log?.debug?.("RETRY", `${response.status} retry ${retryAttemptsByUrl[urlIndex]}/${maxRetries} after ${RETRY_CONFIG.delayMs / 1000}s`); - await new Promise(resolve => setTimeout(resolve, RETRY_CONFIG.delayMs)); + log?.debug?.("RETRY", `${response.status} retry ${retryAttemptsByUrl[urlIndex]}/${maxRetries} after ${delayMs / 1000}s`); + await new Promise(resolve => setTimeout(resolve, delayMs)); urlIndex--; continue; } diff --git a/open-sse/executors/kiro.js b/open-sse/executors/kiro.js index aeb612eb..bcb28f6e 100644 --- a/open-sse/executors/kiro.js +++ b/open-sse/executors/kiro.js @@ -3,7 +3,7 @@ import { PROVIDERS } from "../config/providers.js"; import { v4 as uuidv4 } from "uuid"; import { refreshKiroToken } from "../services/tokenRefresh.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; -import { HTTP_STATUS, RETRY_CONFIG, DEFAULT_RETRY_CONFIG } from "../config/runtimeConfig.js"; +import { HTTP_STATUS, RETRY_CONFIG, DEFAULT_RETRY_CONFIG, resolveRetryEntry } from "../config/runtimeConfig.js"; /** * KiroExecutor - Executor for Kiro AI (AWS CodeWhisperer) @@ -54,11 +54,11 @@ export class KiroExecutor extends BaseExecutor { }, proxyOptions); // Check if should retry based on status code - const maxRetries = retryConfig[response.status] || 0; + const { attempts: maxRetries, delayMs } = resolveRetryEntry(retryConfig[response.status]); if (!response.ok && maxRetries > 0 && retryAttempts < maxRetries) { retryAttempts++; - log?.debug?.("RETRY", `${response.status} retry ${retryAttempts}/${maxRetries} after ${RETRY_CONFIG.delayMs / 1000}s`); - await new Promise(resolve => setTimeout(resolve, RETRY_CONFIG.delayMs)); + log?.debug?.("RETRY", `${response.status} retry ${retryAttempts}/${maxRetries} after ${delayMs / 1000}s`); + await new Promise(resolve => setTimeout(resolve, delayMs)); continue; } diff --git a/package.json b/package.json index f903431f..4f06d94f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.3.98", + "version": "0.3.99", "description": "9Router web dashboard", "private": true, "scripts": { diff --git a/public/providers/hermes.png b/public/providers/hermes.png new file mode 100644 index 0000000000000000000000000000000000000000..d108d0c38d5b197feb906e0a0c6b8bdd377f106c GIT binary patch literal 23273 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 case "openclaw": return ; + case "hermes": + return ; default: return ; } diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js new file mode 100644 index 00000000..aaafb609 --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js @@ -0,0 +1,321 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; +import Image from "next/image"; + +const ENDPOINT = "/api/cli-tools/hermes-settings"; + +export default function HermesToolCard({ + tool, + isExpanded, + onToggle, + baseUrl, + hasActiveProviders, + apiKeys, + activeProviders, + cloudEnabled, + initialStatus, +}) { + const [hermesStatus, setHermesStatus] = useState(initialStatus || null); + const [checking, setChecking] = useState(false); + const [applying, setApplying] = useState(false); + const [restoring, setRestoring] = useState(false); + const [message, setMessage] = useState(null); + const [selectedApiKey, setSelectedApiKey] = useState(""); + const [selectedModel, setSelectedModel] = useState(""); + const [modalOpen, setModalOpen] = useState(false); + const [modelAliases, setModelAliases] = useState({}); + const [showManualConfigModal, setShowManualConfigModal] = useState(false); + const [customBaseUrl, setCustomBaseUrl] = useState(""); + const hasInitializedModel = useRef(false); + + const getConfigStatus = () => { + if (!hermesStatus?.installed) return null; + const cfg = hermesStatus.settings?.model; + if (!cfg?.base_url) return "not_configured"; + const localMatch = /localhost|127\.0\.0\.1|0\.0\.0\.0/.test(cfg.base_url); + const tunnelMatch = baseUrl && cfg.base_url.startsWith(baseUrl); + if (localMatch || tunnelMatch) return "configured"; + return "other"; + }; + + const configStatus = getConfigStatus(); + + useEffect(() => { + if (apiKeys?.length > 0 && !selectedApiKey) { + setSelectedApiKey(apiKeys[0].key); + } + }, [apiKeys, selectedApiKey]); + + useEffect(() => { + if (initialStatus) setHermesStatus(initialStatus); + }, [initialStatus]); + + useEffect(() => { + if (isExpanded && !hermesStatus) { + checkStatus(); + fetchModelAliases(); + } + if (isExpanded) fetchModelAliases(); + }, [isExpanded]); + + const fetchModelAliases = async () => { + try { + const res = await fetch("/api/models/alias"); + const data = await res.json(); + if (res.ok) setModelAliases(data.aliases || {}); + } catch (error) { + console.log("Error fetching model aliases:", error); + } + }; + + useEffect(() => { + if (hermesStatus?.installed && !hasInitializedModel.current) { + hasInitializedModel.current = true; + const cfg = hermesStatus.settings?.model; + if (cfg?.default) setSelectedModel(cfg.default); + } + }, [hermesStatus]); + + const checkStatus = async () => { + setChecking(true); + try { + const res = await fetch(ENDPOINT); + const data = await res.json(); + setHermesStatus(data); + } catch (error) { + setHermesStatus({ installed: false, error: error.message }); + } finally { + setChecking(false); + } + }; + + const normalizeLocalhost = (url) => url.replace("://localhost", "://127.0.0.1"); + + const getLocalBaseUrl = () => { + if (typeof window !== "undefined") { + return normalizeLocalhost(window.location.origin); + } + return "http://127.0.0.1:20128"; + }; + + const getEffectiveBaseUrl = () => { + const url = customBaseUrl || getLocalBaseUrl(); + return url.endsWith("/v1") ? url : `${url}/v1`; + }; + + const handleApply = async () => { + setApplying(true); + setMessage(null); + try { + const keyToUse = selectedApiKey?.trim() + || (apiKeys?.length > 0 ? apiKeys[0].key : null) + || (!cloudEnabled ? "sk_9router" : null); + + const res = await fetch(ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + baseUrl: getEffectiveBaseUrl(), + apiKey: keyToUse, + model: selectedModel, + }), + }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Settings applied successfully!" }); + checkStatus(); + } else { + setMessage({ type: "error", text: data.error || "Failed to apply settings" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setApplying(false); + } + }; + + const handleReset = async () => { + setRestoring(true); + setMessage(null); + try { + const res = await fetch(ENDPOINT, { method: "DELETE" }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Settings reset successfully!" }); + setSelectedModel(""); + checkStatus(); + } else { + setMessage({ type: "error", text: data.error || "Failed to reset settings" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setRestoring(false); + } + }; + + const handleModelSelect = (model) => { + setSelectedModel(model.value); + setModalOpen(false); + }; + + const getManualConfigs = () => { + const keyToUse = (selectedApiKey && selectedApiKey.trim()) + ? selectedApiKey + : (!cloudEnabled ? "sk_9router" : ""); + + const yamlContent = `model:\n default: "${selectedModel || "provider/model-id"}"\n provider: "custom"\n base_url: "${getEffectiveBaseUrl()}"\n`; + const envContent = `OPENAI_API_KEY=${keyToUse}\n`; + + return [ + { filename: "~/.hermes/config.yaml", content: yamlContent }, + { filename: "~/.hermes/.env", content: envContent }, + ]; + }; + + return ( + +
+
+
+ {tool.name} { e.target.style.display = "none"; }} /> +
+
+
+

{tool.name}

+ {configStatus === "configured" && Connected} + {configStatus === "not_configured" && Not configured} + {configStatus === "other" && Other} +
+

{tool.description}

+
+
+ expand_more +
+ + {isExpanded && ( +
+ {checking && ( +
+ progress_activity + Checking Hermes Agent... +
+ )} + + {!checking && hermesStatus && !hermesStatus.installed && ( +
+
+
+ warning +
+

Hermes Agent not detected locally

+

Install: curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash

+
+
+
+ +
+
+
+ )} + + {!checking && hermesStatus?.installed && ( + <> +
+ {hermesStatus?.settings?.model?.base_url && ( +
+ Current + arrow_forward + + {hermesStatus.settings.model.base_url} + +
+ )} + +
+ Base URL + arrow_forward + setCustomBaseUrl(e.target.value)} + placeholder="https://.../v1" + className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" + /> + {customBaseUrl && customBaseUrl !== baseUrl && ( + + )} +
+ +
+ API Key + arrow_forward + {apiKeys.length > 0 ? ( + + ) : ( + + {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"} + + )} +
+ +
+ Default Model + arrow_forward + setSelectedModel(e.target.value)} placeholder="provider/model-id" className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" /> + + {selectedModel && } +
+
+ + {message && ( +
+ {message.type === "success" ? "check_circle" : "error"} + {message.text} +
+ )} + +
+ + + +
+ + )} +
+ )} + + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for Hermes Agent" + /> + + setShowManualConfigModal(false)} + title="Hermes Agent - Manual Configuration" + configs={getManualConfigs()} + /> +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/index.js b/src/app/(dashboard)/dashboard/cli-tools/components/index.js index c345cc39..782fcae8 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/index.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/index.js @@ -2,6 +2,7 @@ export { default as ClaudeToolCard } from "./ClaudeToolCard"; export { default as CodexToolCard } from "./CodexToolCard"; export { default as DroidToolCard } from "./DroidToolCard"; export { default as OpenClawToolCard } from "./OpenClawToolCard"; +export { default as HermesToolCard } from "./HermesToolCard"; export { default as DefaultToolCard } from "./DefaultToolCard"; export { default as AntigravityToolCard } from "./AntigravityToolCard"; export { default as OpenCodeToolCard } from "./OpenCodeToolCard"; diff --git a/src/app/api/cli-tools/hermes-settings/route.js b/src/app/api/cli-tools/hermes-settings/route.js new file mode 100644 index 00000000..104c4f19 --- /dev/null +++ b/src/app/api/cli-tools/hermes-settings/route.js @@ -0,0 +1,175 @@ +"use server"; + +import { NextResponse } from "next/server"; +import { exec } from "child_process"; +import { promisify } from "util"; +import fs from "fs/promises"; +import path from "path"; +import os from "os"; + +const execAsync = promisify(exec); + +const PROVIDER_NAME = "9router"; +const API_KEY_ENV = "OPENAI_API_KEY"; + +const getHermesDir = () => path.join(os.homedir(), ".hermes"); +const getHermesConfigPath = () => path.join(getHermesDir(), "config.yaml"); +const getHermesEnvPath = () => path.join(getHermesDir(), ".env"); + +// Match top-level "model:" block (until next non-indented, non-empty line) +const MODEL_BLOCK_RE = /^model:[ \t]*\r?\n((?:[ \t]+.*\r?\n?|[ \t]*\r?\n)*)/m; + +const buildModelBlock = (model, baseUrl) => + `model:\n default: "${model}"\n provider: "custom"\n base_url: "${baseUrl}"\n`; + +// Parse current model block back to fields (best-effort, simple key:value) +const parseModelBlock = (yaml) => { + const match = yaml.match(MODEL_BLOCK_RE); + if (!match) return null; + const body = match[1] || ""; + const get = (key) => { + const m = body.match(new RegExp(`^[ \\t]+${key}:[ \\t]*["']?([^"'\\r\\n]+)["']?`, "m")); + return m ? m[1].trim() : null; + }; + return { + default: get("default"), + provider: get("provider"), + base_url: get("base_url"), + }; +}; + +const upsertModelBlock = (yaml, newBlock) => { + if (MODEL_BLOCK_RE.test(yaml)) return yaml.replace(MODEL_BLOCK_RE, newBlock); + return yaml.length > 0 ? `${newBlock}\n${yaml}` : newBlock; +}; + +const removeModelBlock = (yaml) => yaml.replace(MODEL_BLOCK_RE, "").replace(/^\n+/, ""); + +// .env helpers — upsert/remove single KEY=VALUE line +const upsertEnvVar = (envText, key, value) => { + const re = new RegExp(`^${key}=.*$`, "m"); + const line = `${key}=${value}`; + if (re.test(envText)) return envText.replace(re, line); + return envText.length > 0 && !envText.endsWith("\n") ? `${envText}\n${line}\n` : `${envText}${line}\n`; +}; + +const removeEnvVar = (envText, key) => { + const re = new RegExp(`^${key}=.*\\r?\\n?`, "m"); + return envText.replace(re, ""); +}; + +const checkHermesInstalled = async () => { + try { + const isWindows = os.platform() === "win32"; + const command = isWindows ? "where hermes" : "which hermes"; + await execAsync(command, { windowsHide: true }); + return true; + } catch { + try { + await fs.access(getHermesConfigPath()); + return true; + } catch { + return false; + } + } +}; + +const readConfigYaml = async () => { + try { + return await fs.readFile(getHermesConfigPath(), "utf-8"); + } catch (error) { + if (error.code === "ENOENT") return ""; + throw error; + } +}; + +const readEnvFile = async () => { + try { + return await fs.readFile(getHermesEnvPath(), "utf-8"); + } catch (error) { + if (error.code === "ENOENT") return ""; + throw error; + } +}; + +// Detect 9router by base_url containing localhost/127.0.0.1 or matching tunnel URL +const has9RouterConfig = (modelCfg) => { + if (!modelCfg?.base_url) return false; + return modelCfg.provider === "custom" && /localhost|127\.0\.0\.1|0\.0\.0\.0/.test(modelCfg.base_url); +}; + +export async function GET() { + try { + const installed = await checkHermesInstalled(); + if (!installed) { + return NextResponse.json({ installed: false, settings: null, message: "Hermes Agent is not installed" }); + } + const yaml = await readConfigYaml(); + const model = parseModelBlock(yaml); + return NextResponse.json({ + installed: true, + settings: { model }, + has9Router: has9RouterConfig(model), + configPath: getHermesConfigPath(), + }); + } catch (error) { + console.log("Error checking hermes settings:", error); + return NextResponse.json({ error: "Failed to check hermes settings" }, { status: 500 }); + } +} + +export async function POST(request) { + try { + const { baseUrl, apiKey, model } = await request.json(); + if (!baseUrl || !model) { + return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 }); + } + + const dir = getHermesDir(); + await fs.mkdir(dir, { recursive: true }); + + const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; + + // Update config.yaml — replace/insert model: block, keep everything else + const existingYaml = await readConfigYaml(); + const newYaml = upsertModelBlock(existingYaml, buildModelBlock(model, normalizedBaseUrl)); + await fs.writeFile(getHermesConfigPath(), newYaml); + + // Update .env — upsert OPENAI_API_KEY only when caller provides one + if (apiKey) { + const existingEnv = await readEnvFile(); + const newEnv = upsertEnvVar(existingEnv, API_KEY_ENV, apiKey); + await fs.writeFile(getHermesEnvPath(), newEnv); + } + + return NextResponse.json({ + success: true, + message: "Hermes settings applied successfully!", + configPath: getHermesConfigPath(), + }); + } catch (error) { + console.log("Error updating hermes settings:", error); + return NextResponse.json({ error: "Failed to update hermes settings" }, { status: 500 }); + } +} + +export async function DELETE() { + try { + const configPath = getHermesConfigPath(); + let yaml = ""; + try { + yaml = await fs.readFile(configPath, "utf-8"); + } catch (error) { + if (error.code === "ENOENT") { + return NextResponse.json({ success: true, message: "No config file to reset" }); + } + throw error; + } + const newYaml = removeModelBlock(yaml); + await fs.writeFile(configPath, newYaml); + return NextResponse.json({ success: true, message: `${PROVIDER_NAME} model block removed` }); + } catch (error) { + console.log("Error resetting hermes settings:", error); + return NextResponse.json({ error: "Failed to reset hermes settings" }, { status: 500 }); + } +} diff --git a/src/app/api/version/update/route.js b/src/app/api/version/update/route.js new file mode 100644 index 00000000..e35de795 --- /dev/null +++ b/src/app/api/version/update/route.js @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { killAppProcesses, spawnUpdaterAndExit } from "@/lib/appUpdater"; + +export async function POST() { + if (process.env.NODE_ENV !== "production") { + return NextResponse.json( + { success: false, message: "Update is only available in production build (9router CLI)" }, + { status: 403 } + ); + } + + try { + // Kill sibling processes (cloudflared, MITM, stray next-server) to release file locks on Windows + await killAppProcesses(); + } catch { /* best effort */ } + + // Schedule detached updater then exit current server process + spawnUpdaterAndExit(); + + return NextResponse.json({ success: true, message: "Updater started. This app will exit shortly." }); +} diff --git a/src/lib/appUpdater.js b/src/lib/appUpdater.js new file mode 100644 index 00000000..959af8cc --- /dev/null +++ b/src/lib/appUpdater.js @@ -0,0 +1,168 @@ +import { spawn, execSync } from "child_process"; +import path from "path"; +import fs from "fs"; +import os from "os"; +import { UPDATER_CONFIG } from "@/shared/constants/config"; + +const KILL_TIMEOUT_MS = 5000; +const PROCESS_WAIT_MS = 1500; + +// Kill MITM server by PID file (MITM may run as admin/sudo) +function killMitmByPidFile() { + try { + const mitmPidFile = path.join( + process.platform === "win32" + ? path.join(process.env.APPDATA || "", "9router") + : path.join(os.homedir(), ".9router"), + "mitm", + ".mitm.pid" + ); + if (!fs.existsSync(mitmPidFile)) return; + const pid = parseInt(fs.readFileSync(mitmPidFile, "utf8").trim(), 10); + if (!pid) return; + + if (process.platform === "win32") { + execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 3000 }); + } else { + try { + execSync(`sudo -n kill -9 ${pid} 2>/dev/null`, { stdio: "ignore", timeout: 3000 }); + } catch { + try { process.kill(pid, "SIGKILL"); } catch { /* best effort */ } + } + } + try { fs.unlinkSync(mitmPidFile); } catch { /* best effort */ } + } catch { /* best effort */ } +} + +// Collect PIDs of all 9router-related processes (excluding current) +function collectAppPids() { + const pids = []; + const platform = process.platform; + + if (platform === "win32") { + try { + const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "Get-WmiObject Win32_Process -Filter 'Name=\\"node.exe\\"' | Select-Object ProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"`; + const output = execSync(psCmd, { encoding: "utf8", windowsHide: true, timeout: KILL_TIMEOUT_MS }); + const lines = output.split("\n").slice(1).filter(l => l.trim()); + lines.forEach(line => { + const isAppProcess = line.toLowerCase().includes("9router") || line.toLowerCase().includes("next-server"); + if (isAppProcess) { + const match = line.match(/^"(\d+)"/); + if (match && match[1] && match[1] !== process.pid.toString()) pids.push(match[1]); + } + }); + } catch { /* no processes */ } + + try { + const cfCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "Get-Process cloudflared -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id"`; + const cfOut = execSync(cfCmd, { encoding: "utf8", windowsHide: true, timeout: KILL_TIMEOUT_MS }); + cfOut.split("\n").forEach(l => { + const pid = l.trim(); + if (pid && !isNaN(pid)) pids.push(pid); + }); + } catch { /* no cloudflared */ } + } else { + try { + const output = execSync("ps aux 2>/dev/null", { encoding: "utf8", timeout: KILL_TIMEOUT_MS }); + output.split("\n").forEach(line => { + const isAppProcess = line.includes("9router") || line.includes("next-server") || line.includes("cloudflared"); + if (isAppProcess) { + const parts = line.trim().split(/\s+/); + const pid = parts[1]; + if (pid && !isNaN(pid) && pid !== process.pid.toString()) pids.push(pid); + } + }); + } catch { /* no processes */ } + } + + return pids; +} + +// Build the .bat content for Windows update flow +function buildWindowsScript(packageName) { + return `@echo off +timeout /t 3 /nobreak >nul +echo Installing new version... +npm install -g ${packageName}@latest --prefer-online +if %ERRORLEVEL% EQU 0 ( + echo. + echo Update completed. Run "${packageName}" to start. +) else ( + echo. + echo Update failed. Try manually: npm install -g ${packageName}@latest +) +pause +`; +} + +// Build the .sh content for macOS/Linux update flow +function buildUnixScript(packageName) { + return `#!/bin/bash +echo "Installing new version..." +sleep 2 + +npm cache clean --force 2>/dev/null +EXIT_CODE=1 +for i in 1 2 3; do + npm install -g ${packageName}@latest --prefer-online 2>&1 + EXIT_CODE=$? + [ $EXIT_CODE -eq 0 ] && break + echo "Retry $i/3..." + sleep 5 +done + +if [ $EXIT_CODE -eq 0 ]; then + echo "" + echo "Update completed. Run \\"${packageName}\\" to start." +else + echo "" + echo "Update failed (exit code: $EXIT_CODE)" + echo "Try manually: npm install -g ${packageName}@latest" +fi +`; +} + +// Kill all app-related processes to release file locks (esp. on Windows) +export async function killAppProcesses() { + killMitmByPidFile(); + const pids = collectAppPids(); + const platform = process.platform; + + pids.forEach(pid => { + try { + if (platform === "win32") { + execSync(`taskkill /F /PID ${pid} 2>nul`, { stdio: "ignore", shell: true, windowsHide: true, timeout: 3000 }); + } else { + execSync(`kill -9 ${pid} 2>/dev/null`, { stdio: "ignore", timeout: 3000 }); + } + } catch { /* already dead */ } + }); + + if (pids.length > 0) { + await new Promise(r => setTimeout(r, PROCESS_WAIT_MS)); + } +} + +// Spawn detached updater script and schedule current process to exit +export function spawnUpdaterAndExit(packageName = UPDATER_CONFIG.npmPackageName) { + const platform = process.platform; + + if (platform === "win32") { + const scriptPath = path.join(os.tmpdir(), `${packageName}-update.bat`); + fs.writeFileSync(scriptPath, buildWindowsScript(packageName)); + spawn("cmd", ["/c", "start", "", "cmd", "/c", scriptPath], { + detached: true, + stdio: "ignore", + windowsHide: false, + }).unref(); + } else { + const scriptPath = path.join(os.tmpdir(), `${packageName}-update.sh`); + fs.writeFileSync(scriptPath, buildUnixScript(packageName), { mode: 0o755 }); + spawn("sh", [scriptPath], { + detached: true, + stdio: "inherit", + }).unref(); + } + + setTimeout(() => process.exit(0), UPDATER_CONFIG.exitDelayMs); +} diff --git a/src/mitm/server.js b/src/mitm/server.js index 080aa3b2..c444dacf 100644 --- a/src/mitm/server.js +++ b/src/mitm/server.js @@ -11,6 +11,7 @@ const { getCertForDomain } = require("./cert/generate"); const DB_FILE = path.join(DATA_DIR, "db.json"); const LOCAL_PORT = 443; +const IS_WIN = process.platform === "win32"; const ENABLE_FILE_LOG = false; const LOG_DIR = path.join(DATA_DIR, "logs", "mitm"); const INTERNAL_REQUEST_HEADER = { name: "x-request-source", value: "local" }; @@ -222,16 +223,25 @@ const server = https.createServer(sslOptions, async (req, res) => { // Kill only processes LISTENING on LOCAL_PORT (not outbound connections) function killPort(port) { try { - const pids = execSync(`lsof -nP -iTCP:${port} -sTCP:LISTEN -t`, { encoding: "utf-8", windowsHide: true }).trim(); - if (!pids) return; - const pidList = pids.split("\n").filter(p => p && Number(p) !== process.pid); + let pidList = []; + if (IS_WIN) { + const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command ` + + `"Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess"`; + const out = execSync(psCmd, { encoding: "utf-8", windowsHide: true }).trim(); + if (!out) return; + pidList = out.split(/\r?\n/).map(s => s.trim()).filter(p => p && Number(p) !== process.pid && Number(p) > 4); + } else { + const out = execSync(`lsof -nP -iTCP:${port} -sTCP:LISTEN -t`, { encoding: "utf-8", windowsHide: true }).trim(); + if (!out) return; + pidList = out.split("\n").filter(p => p && Number(p) !== process.pid); + } if (pidList.length === 0) return; pidList.forEach(pid => { try { - process.kill(Number(pid), "SIGKILL"); + if (IS_WIN) execSync(`taskkill /F /PID ${pid}`, { windowsHide: true }); + else process.kill(Number(pid), "SIGKILL"); } catch (e) { err(`Failed to kill PID ${pid}: ${e.message}`); - throw e; } }); log(`Killed ${pidList.length} process(es) on port ${port}`); diff --git a/src/shared/components/Sidebar.js b/src/shared/components/Sidebar.js index eee07906..35cb481b 100644 --- a/src/shared/components/Sidebar.js +++ b/src/shared/components/Sidebar.js @@ -5,7 +5,7 @@ import PropTypes from "prop-types"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { cn } from "@/shared/utils/cn"; -import { APP_CONFIG } from "@/shared/constants/config"; +import { APP_CONFIG, UPDATER_CONFIG } from "@/shared/constants/config"; import { MEDIA_PROVIDER_KINDS } from "@/shared/constants/providers"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import Button from "./Button"; @@ -41,10 +41,12 @@ export default function Sidebar({ onClose }) { const [isShuttingDown, setIsShuttingDown] = useState(false); const [isDisconnected, setIsDisconnected] = useState(false); const [updateInfo, setUpdateInfo] = useState(null); + const [showUpdateModal, setShowUpdateModal] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); const [enableTranslator, setEnableTranslator] = useState(false); const { copied, copy } = useCopyToClipboard(2000); - const INSTALL_CMD = "npm install -g 9router@latest"; + const INSTALL_CMD = UPDATER_CONFIG.installCmd; useEffect(() => { fetch("/api/settings") @@ -68,6 +70,25 @@ export default function Sidebar({ onClose }) { return pathname.startsWith(href); }; + const handleUpdate = async () => { + setIsUpdating(true); + setShowUpdateModal(false); + try { + const res = await fetch("/api/version/update", { method: "POST" }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + alert(data.message || "Update failed. Please run the install command manually."); + setIsUpdating(false); + return; + } + // Server will exit shortly; show disconnected overlay + setIsDisconnected(true); + } catch (e) { + // Expected once the server exits; treat as disconnected + setIsDisconnected(true); + } + }; + const handleShutdown = async () => { setIsShuttingDown(true); try { @@ -104,18 +125,28 @@ export default function Sidebar({ onClose }) { {updateInfo && ( - +
+ + +
+ )} @@ -292,18 +323,45 @@ export default function Sidebar({ onClose }) { loading={isShuttingDown} /> + {/* Update Confirmation Modal */} + setShowUpdateModal(false)} + onConfirm={handleUpdate} + title="Update 9Router" + message={`This will close 9Router and install v${updateInfo?.latestVersion || ""} in a separate window. Continue?`} + confirmText="Update" + cancelText="Cancel" + variant="primary" + loading={isUpdating} + /> + {/* Disconnected Overlay */} {isDisconnected && (
-
- power_off -
-

Server Disconnected

-

The proxy server has been stopped.

- + {isUpdating ? ( + <> +
+ download +
+

Updating 9Router

+

+ A new terminal window is installing the update. Once finished, run 9router again. +

+ + ) : ( + <> +
+ power_off +
+

Server Disconnected

+

The proxy server has been stopped.

+ + + )}
)} diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js index 69f9deeb..43cb41c2 100644 --- a/src/shared/constants/cliTools.js +++ b/src/shared/constants/cliTools.js @@ -212,6 +212,14 @@ export const CLI_TOOLS = { }`, }, }, + hermes: { + id: "hermes", + name: "Hermes Agent", + image: "/providers/hermes.png", + color: "#8B5CF6", + description: "Nous Research self-improving AI agent", + configType: "custom", + }, // HIDDEN: gemini-cli // "gemini-cli": { // id: "gemini-cli", diff --git a/src/shared/constants/config.js b/src/shared/constants/config.js index da8db3e6..cf9003e2 100644 --- a/src/shared/constants/config.js +++ b/src/shared/constants/config.js @@ -12,6 +12,13 @@ export const GITHUB_CONFIG = { changelogUrl: "https://raw.githubusercontent.com/decolua/9router/refs/heads/master/CHANGELOG.md", }; +// Updater configuration +export const UPDATER_CONFIG = { + npmPackageName: "9router", + installCmd: "npm i -g 9router", + exitDelayMs: 500, +}; + // Theme configuration export const THEME_CONFIG = { storageKey: "theme",