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 00000000..d108d0c3 Binary files /dev/null and b/public/providers/hermes.png differ diff --git a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js index baafc6fd..f81658a3 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js +++ b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js @@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from "react"; import { Card, CardSkeleton } from "@/shared/components"; import { CLI_TOOLS } from "@/shared/constants/cliTools"; import { getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models"; -import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, DefaultToolCard, OpenCodeToolCard, MitmLinkCard } from "./components"; +import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, HermesToolCard, DefaultToolCard, OpenCodeToolCard, MitmLinkCard } from "./components"; import { MITM_TOOLS } from "@/shared/constants/cliTools"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; @@ -16,6 +16,7 @@ const STATUS_ENDPOINTS = { opencode: "/api/cli-tools/opencode-settings", droid: "/api/cli-tools/droid-settings", openclaw: "/api/cli-tools/openclaw-settings", + hermes: "/api/cli-tools/hermes-settings", }; export default function CLIToolsPageClient({ machineId }) { @@ -179,6 +180,8 @@ export default function CLIToolsPageClient({ machineId }) { return ; 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",