feat(win): hermes wsl installer hooks
This commit is contained in:
@@ -2,9 +2,11 @@ import type { BrowserWindow } from 'electron';
|
|||||||
import type { GatewayManager } from '../gateway/manager';
|
import type { GatewayManager } from '../gateway/manager';
|
||||||
import type { ClawHubService } from '../gateway/clawhub';
|
import type { ClawHubService } from '../gateway/clawhub';
|
||||||
import type { HostEventBus } from './event-bus';
|
import type { HostEventBus } from './event-bus';
|
||||||
|
import type { HermesManager } from '../hermes/manager';
|
||||||
|
|
||||||
export interface HostApiContext {
|
export interface HostApiContext {
|
||||||
gatewayManager: GatewayManager;
|
gatewayManager: GatewayManager;
|
||||||
|
hermesManager: HermesManager;
|
||||||
clawHubService: ClawHubService;
|
clawHubService: ClawHubService;
|
||||||
eventBus: HostEventBus;
|
eventBus: HostEventBus;
|
||||||
mainWindow: BrowserWindow | null;
|
mainWindow: BrowserWindow | null;
|
||||||
|
|||||||
31
electron/api/routes/hermes.ts
Normal file
31
electron/api/routes/hermes.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'http';
|
||||||
|
import type { HostApiContext } from '../context';
|
||||||
|
import { sendJson } from '../route-utils';
|
||||||
|
|
||||||
|
export async function handleHermesRoutes(
|
||||||
|
req: IncomingMessage,
|
||||||
|
res: ServerResponse,
|
||||||
|
url: URL,
|
||||||
|
ctx: HostApiContext,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (url.pathname === '/api/hermes/status' && req.method === 'GET') {
|
||||||
|
const status = await ctx.hermesManager.refreshStatus();
|
||||||
|
sendJson(res, 200, { success: true, status });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/api/hermes/install' && req.method === 'POST') {
|
||||||
|
const result = await ctx.hermesManager.install();
|
||||||
|
sendJson(res, result.success ? 200 : 500, { ...result });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/api/hermes/start' && req.method === 'POST') {
|
||||||
|
const result = await ctx.hermesManager.startGateway();
|
||||||
|
sendJson(res, result.success ? 200 : 500, { ...result });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -6,6 +6,7 @@ import { extensionRegistry } from '../extensions/registry';
|
|||||||
import type { HostApiContext } from './context';
|
import type { HostApiContext } from './context';
|
||||||
import { handleAppRoutes } from './routes/app';
|
import { handleAppRoutes } from './routes/app';
|
||||||
import { handleGatewayRoutes } from './routes/gateway';
|
import { handleGatewayRoutes } from './routes/gateway';
|
||||||
|
import { handleHermesRoutes } from './routes/hermes';
|
||||||
import { handleSettingsRoutes } from './routes/settings';
|
import { handleSettingsRoutes } from './routes/settings';
|
||||||
import { handleProviderRoutes } from './routes/providers';
|
import { handleProviderRoutes } from './routes/providers';
|
||||||
import { handleAgentRoutes } from './routes/agents';
|
import { handleAgentRoutes } from './routes/agents';
|
||||||
@@ -29,6 +30,7 @@ type RouteHandler = (
|
|||||||
const coreRouteHandlers: RouteHandler[] = [
|
const coreRouteHandlers: RouteHandler[] = [
|
||||||
handleAppRoutes,
|
handleAppRoutes,
|
||||||
handleGatewayRoutes,
|
handleGatewayRoutes,
|
||||||
|
handleHermesRoutes,
|
||||||
handleSettingsRoutes,
|
handleSettingsRoutes,
|
||||||
handleProviderRoutes,
|
handleProviderRoutes,
|
||||||
handleAgentRoutes,
|
handleAgentRoutes,
|
||||||
|
|||||||
97
electron/hermes/manager.ts
Normal file
97
electron/hermes/manager.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { getResourcesDir } from '../utils/paths';
|
||||||
|
|
||||||
|
export type HermesInstallState = 'unknown' | 'not_installed' | 'installed' | 'wsl_missing' | 'error';
|
||||||
|
|
||||||
|
export type HermesRunState = 'stopped' | 'running' | 'error';
|
||||||
|
|
||||||
|
export interface HermesStatus {
|
||||||
|
installState: HermesInstallState;
|
||||||
|
runState: HermesRunState;
|
||||||
|
version?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runPowerShell(scriptPath: string): Promise<{ success: boolean; stdout: string; stderr: string; exitCode: number | null }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = spawn('powershell.exe', [
|
||||||
|
'-NoProfile',
|
||||||
|
'-ExecutionPolicy',
|
||||||
|
'Bypass',
|
||||||
|
'-File',
|
||||||
|
scriptPath,
|
||||||
|
], { windowsHide: true });
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
child.stdout.on('data', (d) => { stdout += String(d); });
|
||||||
|
child.stderr.on('data', (d) => { stderr += String(d); });
|
||||||
|
child.on('close', (code) => {
|
||||||
|
resolve({ success: code === 0, stdout, stderr, exitCode: code });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runWsl(args: string[]): Promise<{ success: boolean; stdout: string; stderr: string; exitCode: number | null }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = spawn('wsl.exe', args, { windowsHide: true });
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
child.stdout.on('data', (d) => { stdout += String(d); });
|
||||||
|
child.stderr.on('data', (d) => { stderr += String(d); });
|
||||||
|
child.on('close', (code) => resolve({ success: code === 0, stdout, stderr, exitCode: code }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HermesManager {
|
||||||
|
private status: HermesStatus = { installState: 'unknown', runState: 'stopped' };
|
||||||
|
|
||||||
|
getStatus(): HermesStatus {
|
||||||
|
return { ...this.status };
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshStatus(): Promise<HermesStatus> {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
this.status = { installState: 'error', runState: 'stopped', error: 'Hermes Windows integration requires WSL2' };
|
||||||
|
return this.getStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
const wslCheck = await runWsl(['-e', 'bash', '-lc', 'echo ok']);
|
||||||
|
if (!wslCheck.success) {
|
||||||
|
this.status = { installState: 'wsl_missing', runState: 'stopped', error: wslCheck.stderr.trim() || 'WSL2 not available' };
|
||||||
|
return this.getStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = await runWsl(['-e', 'bash', '-lc', 'command -v hermes >/dev/null 2>&1 && hermes --version || hermes version || true']);
|
||||||
|
const raw = (version.stdout || version.stderr).trim();
|
||||||
|
if (!raw) {
|
||||||
|
this.status = { installState: 'not_installed', runState: 'stopped' };
|
||||||
|
return this.getStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.status = { installState: 'installed', runState: this.status.runState, version: raw };
|
||||||
|
return this.getStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async install(): Promise<{ success: boolean; stdout: string; stderr: string; exitCode: number | null }> {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
return { success: false, stdout: '', stderr: 'Windows only', exitCode: 1 };
|
||||||
|
}
|
||||||
|
const scriptPath = join(getResourcesDir(), 'hermes', 'install-hermes.ps1');
|
||||||
|
const result = await runPowerShell(scriptPath);
|
||||||
|
await this.refreshStatus();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async startGateway(): Promise<{ success: boolean; stdout: string; stderr: string; exitCode: number | null }> {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
return { success: false, stdout: '', stderr: 'Windows only', exitCode: 1 };
|
||||||
|
}
|
||||||
|
const scriptPath = join(getResourcesDir(), 'hermes', 'start-hermes.ps1');
|
||||||
|
const result = await runPowerShell(scriptPath);
|
||||||
|
this.status = { ...this.status, runState: result.success ? 'running' : 'error', error: result.success ? undefined : result.stderr.trim() };
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -6,6 +6,7 @@ import { app, BrowserWindow, nativeImage, session, shell } from 'electron';
|
|||||||
import type { Server } from 'node:http';
|
import type { Server } from 'node:http';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { GatewayManager } from '../gateway/manager';
|
import { GatewayManager } from '../gateway/manager';
|
||||||
|
import { HermesManager } from '../hermes/manager';
|
||||||
import { registerIpcHandlers } from './ipc-handlers';
|
import { registerIpcHandlers } from './ipc-handlers';
|
||||||
import { createTray } from './tray';
|
import { createTray } from './tray';
|
||||||
import { createMenu } from './menu';
|
import { createMenu } from './menu';
|
||||||
@@ -121,6 +122,7 @@ const gotTheLock = gotElectronLock && gotFileLock;
|
|||||||
// Global references
|
// Global references
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
let gatewayManager!: GatewayManager;
|
let gatewayManager!: GatewayManager;
|
||||||
|
let hermesManager!: HermesManager;
|
||||||
let clawHubService!: ClawHubService;
|
let clawHubService!: ClawHubService;
|
||||||
let hostEventBus!: HostEventBus;
|
let hostEventBus!: HostEventBus;
|
||||||
let hostApiServer: Server | null = null;
|
let hostApiServer: Server | null = null;
|
||||||
@@ -339,6 +341,7 @@ async function initialize(): Promise<void> {
|
|||||||
|
|
||||||
hostApiServer = startHostApiServer({
|
hostApiServer = startHostApiServer({
|
||||||
gatewayManager,
|
gatewayManager,
|
||||||
|
hermesManager,
|
||||||
clawHubService,
|
clawHubService,
|
||||||
eventBus: hostEventBus,
|
eventBus: hostEventBus,
|
||||||
mainWindow: window,
|
mainWindow: window,
|
||||||
@@ -538,6 +541,7 @@ if (gotTheLock) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
gatewayManager = new GatewayManager();
|
gatewayManager = new GatewayManager();
|
||||||
|
hermesManager = new HermesManager();
|
||||||
clawHubService = new ClawHubService();
|
clawHubService = new ClawHubService();
|
||||||
hostEventBus = new HostEventBus();
|
hostEventBus = new HostEventBus();
|
||||||
|
|
||||||
|
|||||||
12
resources/hermes/install-hermes.ps1
Normal file
12
resources/hermes/install-hermes.ps1
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$wsl = Get-Command wsl.exe -ErrorAction SilentlyContinue
|
||||||
|
if (-not $wsl) {
|
||||||
|
Write-Output "WSL2 is required. Install it first: https://learn.microsoft.com/windows/wsl/install"
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
& wsl.exe -e bash -lc "command -v curl >/dev/null 2>&1 || (sudo apt-get update -y && sudo apt-get install -y curl)"
|
||||||
|
& wsl.exe -e bash -lc "curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash"
|
||||||
|
& wsl.exe -e bash -lc "command -v hermes && hermes --help >/dev/null 2>&1 && echo OK"
|
||||||
|
|
||||||
10
resources/hermes/start-hermes.ps1
Normal file
10
resources/hermes/start-hermes.ps1
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$wsl = Get-Command wsl.exe -ErrorAction SilentlyContinue
|
||||||
|
if (-not $wsl) {
|
||||||
|
Write-Output "WSL2 is required."
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
& wsl.exe -e bash -lc "command -v hermes >/dev/null 2>&1 || exit 3; nohup hermes gateway start >/tmp/hermes-gateway.log 2>&1 </dev/null & echo STARTED"
|
||||||
|
|
||||||
@@ -47,6 +47,13 @@ type ControlUiInfo = {
|
|||||||
port: number;
|
port: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type HermesStatus = {
|
||||||
|
installState: string;
|
||||||
|
runState: string;
|
||||||
|
version?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function Settings() {
|
export function Settings() {
|
||||||
const { t } = useTranslation('settings');
|
const { t } = useTranslation('settings');
|
||||||
const {
|
const {
|
||||||
@@ -98,6 +105,8 @@ export function Settings() {
|
|||||||
const [wsDiagnosticEnabled, setWsDiagnosticEnabled] = useState(false);
|
const [wsDiagnosticEnabled, setWsDiagnosticEnabled] = useState(false);
|
||||||
const [showTelemetryViewer, setShowTelemetryViewer] = useState(false);
|
const [showTelemetryViewer, setShowTelemetryViewer] = useState(false);
|
||||||
const [telemetryEntries, setTelemetryEntries] = useState<UiTelemetryEntry[]>([]);
|
const [telemetryEntries, setTelemetryEntries] = useState<UiTelemetryEntry[]>([]);
|
||||||
|
const [hermesStatus, setHermesStatus] = useState<HermesStatus | null>(null);
|
||||||
|
const [hermesBusy, setHermesBusy] = useState(false);
|
||||||
|
|
||||||
const isWindows = window.electron.platform === 'win32';
|
const isWindows = window.electron.platform === 'win32';
|
||||||
const showCliTools = true;
|
const showCliTools = true;
|
||||||
@@ -233,6 +242,51 @@ export function Settings() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const refreshHermesStatus = async () => {
|
||||||
|
try {
|
||||||
|
const result = await hostApiFetch<{ success: boolean; status?: HermesStatus }>('/api/hermes/status');
|
||||||
|
if (result.success && result.status) {
|
||||||
|
setHermesStatus(result.status);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setHermesStatus({ installState: 'error', runState: 'stopped', error: 'Failed to query Hermes status' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInstallHermes = async () => {
|
||||||
|
setHermesBusy(true);
|
||||||
|
try {
|
||||||
|
const result = await hostApiFetch<{ success: boolean; stderr?: string }>('/api/hermes/install', { method: 'POST' });
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('Hermes installed');
|
||||||
|
} else {
|
||||||
|
toast.error(result.stderr || 'Hermes install failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(toUserMessage(error) || 'Hermes install failed');
|
||||||
|
} finally {
|
||||||
|
setHermesBusy(false);
|
||||||
|
void refreshHermesStatus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartHermes = async () => {
|
||||||
|
setHermesBusy(true);
|
||||||
|
try {
|
||||||
|
const result = await hostApiFetch<{ success: boolean; stderr?: string }>('/api/hermes/start', { method: 'POST' });
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('Hermes started');
|
||||||
|
} else {
|
||||||
|
toast.error(result.stderr || 'Hermes start failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(toUserMessage(error) || 'Hermes start failed');
|
||||||
|
} finally {
|
||||||
|
setHermesBusy(false);
|
||||||
|
void refreshHermesStatus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showCliTools) return;
|
if (!showCliTools) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -262,6 +316,12 @@ export function Settings() {
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [devModeUnlocked, showCliTools]);
|
}, [devModeUnlocked, showCliTools]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isWindows) return;
|
||||||
|
if (gatewayBackend !== 'hermes') return;
|
||||||
|
void refreshHermesStatus();
|
||||||
|
}, [gatewayBackend, isWindows]);
|
||||||
|
|
||||||
const handleCopyCliCommand = async () => {
|
const handleCopyCliCommand = async () => {
|
||||||
if (!openclawCliCommand) return;
|
if (!openclawCliCommand) return;
|
||||||
try {
|
try {
|
||||||
@@ -648,6 +708,56 @@ export function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isWindows && gatewayBackend === 'hermes' && (
|
||||||
|
<div className="p-4 rounded-2xl bg-black/5 dark:bg-white/5 border border-black/5 dark:border-white/5 space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-[14px] font-medium text-foreground">Hermes status</Label>
|
||||||
|
<p className="text-[12px] text-muted-foreground mt-1">
|
||||||
|
{hermesStatus
|
||||||
|
? `${hermesStatus.installState} / ${hermesStatus.runState}${hermesStatus.version ? ` (${hermesStatus.version})` : ''}`
|
||||||
|
: 'unknown'}
|
||||||
|
</p>
|
||||||
|
{hermesStatus?.error ? (
|
||||||
|
<p className="text-[12px] text-red-600 dark:text-red-500 mt-1">
|
||||||
|
{hermesStatus.error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={refreshHermesStatus}
|
||||||
|
disabled={hermesBusy}
|
||||||
|
className="rounded-full h-8 px-4 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-3.5 w-3.5 mr-1.5${hermesBusy ? ' animate-spin' : ''}`} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleInstallHermes}
|
||||||
|
disabled={hermesBusy}
|
||||||
|
className="rounded-full h-8 px-4 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
|
>
|
||||||
|
Install Hermes
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleStartHermes}
|
||||||
|
disabled={hermesBusy}
|
||||||
|
className="rounded-full h-8 px-4 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
|
>
|
||||||
|
Start Hermes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user