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:
2026-10-16 15:33:56 +08:00
parent 8c1ddf415f
commit 4701bf0ad0
49 changed files with 354 additions and 2403 deletions
-126
View File
@@ -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)
})
})
-45
View File
@@ -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)
})
})
-33
View File
@@ -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)
})
})
+11 -11
View File
@@ -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')
})
})