Phase 004: remove the desktop shell, website tooling, and bundled hermes CLI
- Drop all desktop/Electron branches, scripts, and UI (title bar, pets, desktop bridge); LAN endpoint kind is now web/custom only - Replace the bundled hermes CLI with user-side installation: add GET /api/hermes/cli-status plus a HermesMissingView onboarding guide - Remove the version-management UI (snapshots handled by git) and prune dead website/mcu/claude scripts and lockfile entries
This commit is contained in:
@@ -1,126 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const routeMock = vi.hoisted(() => ({ name: 'hermes.chat' as string }))
|
||||
const appStoreMock = vi.hoisted(() => ({
|
||||
nodeVersion: '23.0.0',
|
||||
sidebarOpen: true,
|
||||
toggleSidebar: vi.fn(),
|
||||
closeSidebar: vi.fn(),
|
||||
loadModels: vi.fn(),
|
||||
startHealthPolling: vi.fn(),
|
||||
stopHealthPolling: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue-router')>()
|
||||
return {
|
||||
...actual,
|
||||
useRoute: () => routeMock,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) => params?.version ? `${key}:${params.version}` : key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useTheme', () => ({
|
||||
useTheme: () => ({ isDark: false, isComic: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useKeyboard', () => ({
|
||||
useKeyboard: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/hermes/app', () => ({
|
||||
useAppStore: () => appStoreMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/styles/theme', () => ({
|
||||
getThemeOverrides: () => ({}),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hermes/pets/WebPet.vue', () => ({
|
||||
default: { name: 'WebPet', template: '<div class="web-pet-test" />' },
|
||||
}))
|
||||
|
||||
vi.mock('@/components/auth/AuthEventListener.vue', () => ({
|
||||
default: { name: 'AuthEventListener', template: '<div />' },
|
||||
}))
|
||||
|
||||
vi.mock('@/components/auth/DefaultCredentialPrompt.vue', () => ({
|
||||
default: { name: 'DefaultCredentialPrompt', template: '<div />' },
|
||||
}))
|
||||
|
||||
vi.mock('@/components/hermes/chat/SessionSearchModal.vue', () => ({
|
||||
default: { name: 'SessionSearchModal', template: '<div />' },
|
||||
}))
|
||||
|
||||
vi.mock('@/components/layout/AppSidebar.vue', () => ({
|
||||
default: { name: 'AppSidebar', template: '<aside />' },
|
||||
}))
|
||||
|
||||
vi.mock('@/components/layout/DesktopTitleBar.vue', () => ({
|
||||
default: { name: 'DesktopTitleBar', template: '<div />' },
|
||||
}))
|
||||
|
||||
import App from '@/App.vue'
|
||||
|
||||
type WindowWithDesktop = typeof window & {
|
||||
hermesDesktop?: { isDesktop?: boolean; platform?: string }
|
||||
}
|
||||
|
||||
function mountApp() {
|
||||
return mount(App, {
|
||||
global: {
|
||||
stubs: {
|
||||
NConfigProvider: { template: '<div><slot /></div>' },
|
||||
NMessageProvider: { template: '<div><slot /></div>' },
|
||||
NDialogProvider: { template: '<div><slot /></div>' },
|
||||
NNotificationProvider: { template: '<div><slot /></div>' },
|
||||
AuthEventListener: true,
|
||||
AppSidebar: true,
|
||||
DesktopTitleBar: true,
|
||||
SessionSearchModal: true,
|
||||
DefaultCredentialPrompt: true,
|
||||
RouterView: { template: '<div class="router-view-test" />' },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('App web pet mounting', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
routeMock.name = 'hermes.chat'
|
||||
delete (window as WindowWithDesktop).hermesDesktop
|
||||
})
|
||||
|
||||
it('mounts the web pet in the browser web app', () => {
|
||||
const wrapper = mountApp()
|
||||
|
||||
expect(wrapper.findComponent({ name: 'WebPet' }).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not mount the web pet in the Electron desktop shell', () => {
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: { isDesktop: true, platform: 'darwin' },
|
||||
})
|
||||
|
||||
const wrapper = mountApp()
|
||||
|
||||
expect(wrapper.findComponent({ name: 'WebPet' }).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not duplicate the web pet on the dedicated desktop pet route', () => {
|
||||
routeMock.name = 'desktop.pet'
|
||||
|
||||
const wrapper = mountApp()
|
||||
|
||||
expect(wrapper.findComponent({ name: 'WebPet' }).exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,45 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import DesktopTitleBar from '@/components/layout/DesktopTitleBar.vue'
|
||||
|
||||
type DesktopBridge = {
|
||||
platform?: string
|
||||
getWindowState?: () => Promise<{ isMaximized: boolean }>
|
||||
windowControl?: (action: 'minimize' | 'toggle-maximize' | 'close') => Promise<{ isMaximized: boolean }>
|
||||
}
|
||||
|
||||
function setDesktopBridge(bridge: DesktopBridge) {
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: bridge,
|
||||
})
|
||||
}
|
||||
|
||||
describe('DesktopTitleBar', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
delete (window as typeof window & { hermesDesktop?: DesktopBridge }).hermesDesktop
|
||||
})
|
||||
|
||||
it('does not render a custom title bar on Linux so native window controls remain visible', () => {
|
||||
setDesktopBridge({ platform: 'linux' })
|
||||
|
||||
const wrapper = mount(DesktopTitleBar)
|
||||
|
||||
expect(wrapper.find('.desktop-titlebar').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders custom window controls on Windows frameless windows', () => {
|
||||
setDesktopBridge({
|
||||
platform: 'win32',
|
||||
getWindowState: vi.fn().mockResolvedValue({ isMaximized: false }),
|
||||
windowControl: vi.fn().mockResolvedValue({ isMaximized: false }),
|
||||
})
|
||||
|
||||
const wrapper = mount(DesktopTitleBar)
|
||||
|
||||
expect(wrapper.find('.desktop-titlebar').exists()).toBe(true)
|
||||
expect(wrapper.findAll('.desktop-window-btn')).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,6 @@ const mockLoginWithPassword = vi.hoisted(() => vi.fn())
|
||||
const mockSetApiKey = vi.hoisted(() => vi.fn())
|
||||
const mockClearApiKey = vi.hoisted(() => vi.fn())
|
||||
const mockHasApiKey = vi.hoisted(() => vi.fn())
|
||||
const mockIsDesktopShell = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({
|
||||
@@ -33,17 +32,12 @@ vi.mock('@/api/auth', () => ({
|
||||
loginWithPassword: mockLoginWithPassword,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/desktop-bridge', () => ({
|
||||
isDesktopShell: mockIsDesktopShell,
|
||||
}))
|
||||
|
||||
import LoginView from '@/views/LoginView.vue'
|
||||
|
||||
describe('LoginView password login', () => {
|
||||
beforeEach(() => {
|
||||
delete (window as any).__LOGIN_TOKEN__
|
||||
vi.clearAllMocks()
|
||||
mockIsDesktopShell.mockReturnValue(false)
|
||||
mockHasApiKey.mockReturnValue(false)
|
||||
mockFetchAuthStatus.mockResolvedValue({ hasPasswordLogin: true, username: 'admin' })
|
||||
})
|
||||
@@ -57,16 +51,6 @@ describe('LoginView password login', () => {
|
||||
expect(mockReplace).toHaveBeenCalledWith('/hermes/chat')
|
||||
})
|
||||
|
||||
it('clears stale tokens when the desktop login page is opened', () => {
|
||||
mockIsDesktopShell.mockReturnValue(true)
|
||||
mockHasApiKey.mockReturnValue(true)
|
||||
|
||||
mount(LoginView)
|
||||
|
||||
expect(mockClearApiKey).toHaveBeenCalledOnce()
|
||||
expect(mockReplace).not.toHaveBeenCalledWith('/hermes/chat')
|
||||
})
|
||||
|
||||
it('logs in with username and password', async () => {
|
||||
mockLoginWithPassword.mockResolvedValue('jwt-token')
|
||||
const wrapper = mount(LoginView)
|
||||
@@ -121,21 +105,4 @@ describe('LoginView password login', () => {
|
||||
'hermes-ui reset-default-login',
|
||||
])
|
||||
})
|
||||
|
||||
it('shows the tray reset hint for locked desktop logins', async () => {
|
||||
mockIsDesktopShell.mockReturnValue(true)
|
||||
const err: any = new Error('Too many login attempts')
|
||||
err.status = 429
|
||||
mockLoginWithPassword.mockRejectedValue(err)
|
||||
const wrapper = mount(LoginView)
|
||||
|
||||
const inputs = wrapper.findAll('input.login-input')
|
||||
await inputs[0].setValue('admin')
|
||||
await inputs[1].setValue('123456')
|
||||
await wrapper.find('form.login-form').trigger('submit')
|
||||
|
||||
expect(wrapper.find('.login-error').text()).toBe('login.tooManyAttempts')
|
||||
expect(wrapper.find('.login-lock-hint').text()).toContain('login.desktopLockResetHint')
|
||||
expect(wrapper.findAll('.login-lock-hint code')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockHasApiKey = vi.hoisted(() => vi.fn())
|
||||
const mockIsStoredSuperAdmin = vi.hoisted(() => vi.fn())
|
||||
const mockCheckHermesCliAvailable = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
hasApiKey: mockHasApiKey,
|
||||
isStoredSuperAdmin: mockIsStoredSuperAdmin,
|
||||
}))
|
||||
|
||||
vi.mock('@/api/hermes/system', () => ({
|
||||
checkHermesCliAvailable: mockCheckHermesCliAvailable,
|
||||
}))
|
||||
|
||||
async function loadRouter() {
|
||||
vi.resetModules()
|
||||
return (await import('@/router')).default
|
||||
@@ -18,17 +23,13 @@ describe('router login redirect', () => {
|
||||
beforeEach(() => {
|
||||
mockHasApiKey.mockReturnValue(false)
|
||||
mockIsStoredSuperAdmin.mockReturnValue(true)
|
||||
mockCheckHermesCliAvailable.mockResolvedValue(true)
|
||||
if (!document.queryCommandSupported) {
|
||||
document.queryCommandSupported = vi.fn(() => false)
|
||||
}
|
||||
delete (window as any).hermesDesktop
|
||||
window.location.hash = ''
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as any).hermesDesktop
|
||||
})
|
||||
|
||||
it('keeps the web login redirect when a token exists', async () => {
|
||||
mockHasApiKey.mockReturnValue(true)
|
||||
const router = await loadRouter()
|
||||
@@ -39,14 +40,13 @@ describe('router login redirect', () => {
|
||||
expect(router.currentRoute.value.name).toBe('hermes.chat')
|
||||
}, 60_000)
|
||||
|
||||
it('does not redirect desktop login when a stale token exists', async () => {
|
||||
mockHasApiKey.mockReturnValue(true)
|
||||
;(window as any).hermesDesktop = { isDesktop: true }
|
||||
it('redirects to the hermes CLI guide when the CLI is missing', async () => {
|
||||
mockCheckHermesCliAvailable.mockResolvedValue(false)
|
||||
const router = await loadRouter()
|
||||
|
||||
await router.push('/')
|
||||
await router.push('/hermes/chat')
|
||||
await router.isReady()
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(router.currentRoute.value.name).toBe('hermes.missing')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -113,6 +113,11 @@ export async function mockHermesApi(page: Page, options: MockHermesApiOptions =
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/api/hermes/cli-status') {
|
||||
await route.fulfill(jsonResponse({ available: true, bin: '/usr/bin/hermes' }))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/api/auth/status') {
|
||||
await route.fulfill(jsonResponse({ hasPasswordLogin: true, username: 'playwright' }))
|
||||
return
|
||||
|
||||
@@ -11,12 +11,10 @@ import {
|
||||
gatewayStateLooksRunningForProfile,
|
||||
parseGatewayStatusesFromProfileListOutput,
|
||||
prepareGatewayForProfileDelete,
|
||||
recoverWindowsDesktopGatewayOrphans,
|
||||
reconcileGatewayManagementTransition,
|
||||
resolveGatewayTargetProfile,
|
||||
selectGatewayProfilesForAutostart,
|
||||
selectProfilesForGatewayAutostart,
|
||||
shouldRecoverWindowsDesktopGatewayOrphans,
|
||||
shouldUseUnifiedGatewayManagement,
|
||||
shouldUseManagedGatewayRun,
|
||||
shouldUseManagedGatewayRunForAutostart,
|
||||
@@ -251,74 +249,6 @@ describe('gateway autostart status parsing', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('only recovers Windows desktop gateway orphans when enabled', () => {
|
||||
expect(shouldRecoverWindowsDesktopGatewayOrphans('win32', { HERMES_DESKTOP: 'true' })).toBe(true)
|
||||
expect(shouldRecoverWindowsDesktopGatewayOrphans('darwin', { HERMES_DESKTOP: 'true' })).toBe(false)
|
||||
expect(shouldRecoverWindowsDesktopGatewayOrphans('win32', {})).toBe(false)
|
||||
expect(shouldRecoverWindowsDesktopGatewayOrphans('win32', {
|
||||
HERMES_DESKTOP: 'true',
|
||||
HERMES_WEB_UI_DISABLE_GATEWAY_STARTUP_RECOVERY: '1',
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('kills Windows desktop gateway runtime PIDs and removes stale runtime files', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'hermes-gateway-recovery-'))
|
||||
const workHome = join(home, 'profiles', 'work')
|
||||
mkdirSync(workHome, { recursive: true })
|
||||
const killed: number[] = []
|
||||
const stopped: string[] = []
|
||||
|
||||
try {
|
||||
writeFileSync(join(home, 'gateway.pid'), JSON.stringify({ pid: 11111 }), 'utf-8')
|
||||
writeFileSync(join(home, 'gateway_state.json'), JSON.stringify({ pid: 99999, gateway_state: 'stopped' }), 'utf-8')
|
||||
writeFileSync(join(workHome, 'gateway.lock'), JSON.stringify({ pid: '22222' }), 'utf-8')
|
||||
writeFileSync(join(workHome, 'gateway_state.json'), JSON.stringify({ pid: 33333, gateway_state: 'running' }), 'utf-8')
|
||||
|
||||
const result = await recoverWindowsDesktopGatewayOrphans({
|
||||
platform: 'win32',
|
||||
env: { HERMES_DESKTOP: 'true' },
|
||||
hermesHome: home,
|
||||
isAlive: pid => pid !== 99999,
|
||||
stopGateway: async profileDir => { stopped.push(profileDir) },
|
||||
execTaskkill: async pid => { killed.push(pid) },
|
||||
})
|
||||
|
||||
expect(result.attempted).toBe(true)
|
||||
expect(stopped.sort()).toEqual([home, workHome].sort())
|
||||
expect(result.stoppedProfileDirs.sort()).toEqual([home, workHome].sort())
|
||||
expect(killed.sort((a, b) => a - b)).toEqual([11111, 22222, 33333])
|
||||
expect(result.killedPids.sort((a, b) => a - b)).toEqual([11111, 22222, 33333])
|
||||
expect(existsSync(join(home, 'gateway.pid'))).toBe(false)
|
||||
expect(existsSync(join(home, 'gateway_state.json'))).toBe(false)
|
||||
expect(existsSync(join(workHome, 'gateway.lock'))).toBe(false)
|
||||
expect(existsSync(join(workHome, 'gateway_state.json'))).toBe(false)
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('skips recovery outside Windows desktop mode without deleting runtime files', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'hermes-gateway-recovery-skip-'))
|
||||
try {
|
||||
const pidPath = join(home, 'gateway.pid')
|
||||
writeFileSync(pidPath, JSON.stringify({ pid: 11111 }), 'utf-8')
|
||||
|
||||
const result = await recoverWindowsDesktopGatewayOrphans({
|
||||
platform: 'linux',
|
||||
env: { HERMES_DESKTOP: 'true' },
|
||||
hermesHome: home,
|
||||
isAlive: () => {
|
||||
throw new Error('should not check liveness')
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.attempted).toBe(false)
|
||||
expect(existsSync(pidPath)).toBe(true)
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('detects managed gateway state files with a live pid', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hermes-gateway-state-'))
|
||||
try {
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('LAN discovery', () => {
|
||||
|
||||
it('classifies well-known LAN endpoints', () => {
|
||||
expect(getLanEndpointKind(8648)).toBe('web')
|
||||
expect(getLanEndpointKind(8748)).toBe('desktop')
|
||||
expect(getLanEndpointKind(8748)).toBe('custom')
|
||||
expect(getLanEndpointKind(19001)).toBe('custom')
|
||||
})
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ describe('shutdown bridge policy', () => {
|
||||
const originalValue = process.env.HERMES_AGENT_BRIDGE_STOP_ON_SHUTDOWN
|
||||
const originalGatewayValue = process.env.HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN
|
||||
const originalNodeEnv = process.env.NODE_ENV
|
||||
const originalDesktop = process.env.HERMES_DESKTOP
|
||||
const originalForceExitMs = process.env.HERMES_WEB_UI_SHUTDOWN_FORCE_EXIT_MS
|
||||
|
||||
afterEach(() => {
|
||||
@@ -19,8 +18,6 @@ describe('shutdown bridge policy', () => {
|
||||
else process.env.HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN = originalGatewayValue
|
||||
if (originalNodeEnv === undefined) delete process.env.NODE_ENV
|
||||
else process.env.NODE_ENV = originalNodeEnv
|
||||
if (originalDesktop === undefined) delete process.env.HERMES_DESKTOP
|
||||
else process.env.HERMES_DESKTOP = originalDesktop
|
||||
if (originalForceExitMs === undefined) delete process.env.HERMES_WEB_UI_SHUTDOWN_FORCE_EXIT_MS
|
||||
else process.env.HERMES_WEB_UI_SHUTDOWN_FORCE_EXIT_MS = originalForceExitMs
|
||||
})
|
||||
@@ -62,18 +59,13 @@ describe('shutdown bridge policy', () => {
|
||||
expect(shouldStopManagedGatewaysOnShutdown()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps desktop shutdown force-exit timing long enough for runtime cleanup by default', () => {
|
||||
it('keeps shutdown force-exit timing long enough for runtime cleanup by default', () => {
|
||||
delete process.env.HERMES_WEB_UI_SHUTDOWN_FORCE_EXIT_MS
|
||||
|
||||
delete process.env.HERMES_DESKTOP
|
||||
expect(getShutdownForceExitMs()).toBe(15_000)
|
||||
|
||||
process.env.HERMES_DESKTOP = 'true'
|
||||
expect(getShutdownForceExitMs()).toBe(15_000)
|
||||
})
|
||||
|
||||
it('allows operators to override shutdown force-exit timing', () => {
|
||||
process.env.HERMES_DESKTOP = 'true'
|
||||
process.env.HERMES_WEB_UI_SHUTDOWN_FORCE_EXIT_MS = '7000'
|
||||
|
||||
expect(getShutdownForceExitMs()).toBe(7_000)
|
||||
|
||||
@@ -31,7 +31,6 @@ describe('studio MCP autoinject', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
delete process.env.HERMES_DESKTOP
|
||||
delete process.env.HERMES_AGENT_NODE
|
||||
delete process.env.AUTH_TOKEN
|
||||
delete process.env.HERMES_WEB_UI_DISABLE_MCP_AUTOINJECT
|
||||
@@ -206,8 +205,7 @@ describe('studio MCP autoinject', () => {
|
||||
expect(updated.data.mcp_servers['hermes-studio-use'].args).toEqual([join(process.cwd(), 'bin/hermes-ui-mcp.mjs'), 'use'])
|
||||
})
|
||||
|
||||
it('uses the desktop runtime node for bundled MCP servers when available', async () => {
|
||||
process.env.HERMES_DESKTOP = 'true'
|
||||
it('uses the runtime node override for bundled MCP servers when available', async () => {
|
||||
process.env.HERMES_AGENT_NODE = '/runtime/node'
|
||||
const { injectBundledMcpServer } = await import('../../packages/server/src/services/hermes/studio-mcp-autoinject')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user