Import the client, server, skills, tests, and build scripts as the starting point for the rebuild, then trim the surfaces this project does not ship: Electron desktop packaging, the marketing website, and the ESP32 firmware tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows. Rename the package and CLI entry points to hermes-ui, drop the prepare hook so npm ci no longer triggers a build, and drop the removed tsconfig and nodemon references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs so the documented package map and hard rules match this repository. reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
271 lines
10 KiB
TypeScript
271 lines
10 KiB
TypeScript
import { join } from 'node:path'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const updateConfigYamlForProfileMock = vi.fn()
|
|
const listProfileNamesFromDiskMock = vi.fn()
|
|
const configMock = vi.hoisted(() => ({
|
|
port: 8648,
|
|
appHome: '/Users/test/.hermes-web-ui',
|
|
}))
|
|
|
|
vi.mock('../../packages/server/src/services/config-helpers', () => ({
|
|
updateConfigYamlForProfile: updateConfigYamlForProfileMock,
|
|
}))
|
|
|
|
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
|
|
listProfileNamesFromDisk: listProfileNamesFromDiskMock,
|
|
}))
|
|
|
|
vi.mock('../../packages/server/src/config', () => ({
|
|
config: configMock,
|
|
}))
|
|
|
|
vi.mock('../../packages/server/src/services/logger', () => ({
|
|
logger: {
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
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
|
|
delete process.env.HERMES_WEB_UI_ALLOW_TRANSIENT_MCP_AUTOINJECT
|
|
configMock.port = 8648
|
|
configMock.appHome = '/Users/test/.hermes-web-ui'
|
|
listProfileNamesFromDiskMock.mockReturnValue(['default', 'work'])
|
|
updateConfigYamlForProfileMock.mockImplementation(async (_profile: string, updater: any) => {
|
|
const updated = await updater({})
|
|
return updated.result
|
|
})
|
|
})
|
|
|
|
it('injects bundled MCP server into every profile without relying on a global PATH shim', async () => {
|
|
const { injectBundledMcpServer } = await import('../../packages/server/src/services/hermes/studio-mcp-autoinject')
|
|
|
|
const result = await injectBundledMcpServer()
|
|
|
|
expect(result.targets.map(target => target.profile)).toEqual(['default', 'work'])
|
|
expect(updateConfigYamlForProfileMock).toHaveBeenCalledTimes(2)
|
|
const injectedDefault = await updateConfigYamlForProfileMock.mock.calls[0][1]({})
|
|
expect(injectedDefault.data.mcp_servers['hermes-studio-api']).toEqual({
|
|
command: process.execPath,
|
|
args: [join(process.cwd(), 'bin/hermes-studio-mcp.mjs'), 'api'],
|
|
env: {
|
|
HERMES_WEB_UI_URL: 'http://127.0.0.1:8648',
|
|
HERMES_WEB_UI_HOME: '/Users/test/.hermes-web-ui',
|
|
HERMES_WEBUI_STATE_DIR: '/Users/test/.hermes-web-ui',
|
|
HERMES_WEB_UI_PROFILE: 'default',
|
|
HERMES_MCP_SERVER_NAME: 'hermes-studio-api',
|
|
HERMES_MCP_TOOLSET: 'api',
|
|
HERMES_WEB_UI_MANAGED_MCP: '1',
|
|
},
|
|
enabled: true,
|
|
})
|
|
expect(injectedDefault.data.mcp_servers['hermes-studio-devices']).toMatchObject({
|
|
command: process.execPath,
|
|
args: [join(process.cwd(), 'bin/hermes-studio-mcp.mjs'), 'devices'],
|
|
env: {
|
|
HERMES_MCP_SERVER_NAME: 'hermes-studio-devices',
|
|
HERMES_MCP_TOOLSET: 'devices',
|
|
},
|
|
enabled: true,
|
|
})
|
|
expect(injectedDefault.data.mcp_servers['hermes-studio-use']).toMatchObject({
|
|
command: process.execPath,
|
|
args: [join(process.cwd(), 'bin/hermes-studio-mcp.mjs'), 'use'],
|
|
env: {
|
|
HERMES_MCP_SERVER_NAME: 'hermes-studio-use',
|
|
HERMES_MCP_TOOLSET: 'use',
|
|
},
|
|
enabled: true,
|
|
})
|
|
const injectedWork = await updateConfigYamlForProfileMock.mock.calls[1][1]({})
|
|
expect(injectedWork.data.mcp_servers['hermes-studio-api'].env.HERMES_WEB_UI_PROFILE).toBe('work')
|
|
expect(result.serverNames).toEqual(['hermes-studio-api', 'hermes-studio-devices', 'hermes-studio-use'])
|
|
expect(result.command).toBe(process.execPath)
|
|
})
|
|
|
|
it('skips autoinject for transient preview homes by default', async () => {
|
|
configMock.appHome = '/private/tmp/wui-preview-home'
|
|
const { injectBundledMcpServer } = await import('../../packages/server/src/services/hermes/studio-mcp-autoinject')
|
|
|
|
const result = await injectBundledMcpServer()
|
|
|
|
expect(result.targets).toEqual([])
|
|
expect(updateConfigYamlForProfileMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('allows transient preview autoinject when explicitly requested', async () => {
|
|
configMock.appHome = '/private/tmp/wui-preview-home'
|
|
process.env.HERMES_WEB_UI_ALLOW_TRANSIENT_MCP_AUTOINJECT = '1'
|
|
const { injectBundledMcpServer } = await import('../../packages/server/src/services/hermes/studio-mcp-autoinject')
|
|
|
|
await injectBundledMcpServer()
|
|
|
|
expect(updateConfigYamlForProfileMock).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('respects a user-disabled managed MCP server entry', async () => {
|
|
const { injectBundledMcpServer } = await import('../../packages/server/src/services/hermes/studio-mcp-autoinject')
|
|
|
|
await injectBundledMcpServer()
|
|
|
|
const updated = await updateConfigYamlForProfileMock.mock.calls[0][1]({
|
|
mcp_servers: {
|
|
'hermes-studio-api': {
|
|
command: process.execPath,
|
|
args: [join(process.cwd(), 'bin/hermes-studio-mcp.mjs'), 'api'],
|
|
env: {
|
|
HERMES_WEB_UI_URL: 'http://127.0.0.1:8648',
|
|
HERMES_WEB_UI_HOME: '/Users/test/.hermes-web-ui',
|
|
HERMES_WEBUI_STATE_DIR: '/Users/test/.hermes-web-ui',
|
|
HERMES_WEB_UI_PROFILE: 'default',
|
|
HERMES_MCP_SERVER_NAME: 'hermes-studio-api',
|
|
HERMES_MCP_TOOLSET: 'api',
|
|
HERMES_WEB_UI_MANAGED_MCP: '1',
|
|
},
|
|
enabled: false,
|
|
},
|
|
},
|
|
})
|
|
|
|
expect(updated.write).toBe(false)
|
|
expect(updated.result).toMatchObject({
|
|
status: 'skipped',
|
|
reason: 'existing hermes-studio-api MCP server is disabled by user',
|
|
})
|
|
expect(updated.data.mcp_servers['hermes-studio-api'].enabled).toBe(false)
|
|
})
|
|
|
|
it('cleans a disabled legacy managed MCP server entry before injecting split servers', async () => {
|
|
const { injectBundledMcpServer } = await import('../../packages/server/src/services/hermes/studio-mcp-autoinject')
|
|
|
|
await injectBundledMcpServer()
|
|
|
|
const updated = await updateConfigYamlForProfileMock.mock.calls[0][1]({
|
|
mcp_servers: {
|
|
'hermes-studio': {
|
|
command: 'hermes-web-ui-mcp',
|
|
env: {
|
|
HERMES_WEB_UI_MANAGED_MCP: '1',
|
|
},
|
|
enabled: false,
|
|
},
|
|
},
|
|
})
|
|
|
|
expect(updated.write).not.toBe(false)
|
|
expect(updated.result).toMatchObject({
|
|
status: 'updated',
|
|
})
|
|
expect(updated.data.mcp_servers['hermes-studio']).toBeUndefined()
|
|
expect(updated.data.mcp_servers['hermes-studio-api']).toBeDefined()
|
|
expect(updated.data.mcp_servers['hermes-studio-devices']).toBeDefined()
|
|
expect(updated.data.mcp_servers['hermes-studio-use']).toBeDefined()
|
|
})
|
|
|
|
it('updates old managed PATH-only MCP entries to the bundled node script', async () => {
|
|
const { injectBundledMcpServer } = await import('../../packages/server/src/services/hermes/studio-mcp-autoinject')
|
|
|
|
await injectBundledMcpServer()
|
|
|
|
const updated = await updateConfigYamlForProfileMock.mock.calls[0][1]({
|
|
mcp_servers: {
|
|
'hermes-studio': {
|
|
command: 'hermes-web-ui-mcp',
|
|
env: {
|
|
HERMES_WEB_UI_URL: 'http://127.0.0.1:8648',
|
|
HERMES_WEB_UI_HOME: '/tmp/hermes-web-ui-home',
|
|
HERMES_WEBUI_STATE_DIR: '/tmp/hermes-web-ui-home',
|
|
HERMES_WEB_UI_PROFILE: 'default',
|
|
HERMES_WEB_UI_MANAGED_MCP: '1',
|
|
},
|
|
enabled: true,
|
|
},
|
|
'hermes-web-ui-mcp': {
|
|
command: 'hermes-web-ui-mcp',
|
|
env: {
|
|
HERMES_WEB_UI_MANAGED_MCP: '1',
|
|
},
|
|
enabled: true,
|
|
},
|
|
},
|
|
})
|
|
expect(updated.result.status).toBe('updated')
|
|
expect(updated.data.mcp_servers['hermes-studio']).toBeUndefined()
|
|
expect(updated.data.mcp_servers['hermes-web-ui-mcp']).toBeUndefined()
|
|
expect(updated.data.mcp_servers['hermes-studio-api'].command).toBe(process.execPath)
|
|
expect(updated.data.mcp_servers['hermes-studio-api'].args).toEqual([join(process.cwd(), 'bin/hermes-studio-mcp.mjs'), 'api'])
|
|
expect(updated.data.mcp_servers['hermes-studio-devices'].args).toEqual([join(process.cwd(), 'bin/hermes-studio-mcp.mjs'), 'devices'])
|
|
expect(updated.data.mcp_servers['hermes-studio-use'].args).toEqual([join(process.cwd(), 'bin/hermes-studio-mcp.mjs'), 'use'])
|
|
})
|
|
|
|
it('uses the desktop runtime node for bundled MCP servers when available', async () => {
|
|
process.env.HERMES_DESKTOP = 'true'
|
|
process.env.HERMES_AGENT_NODE = '/runtime/node'
|
|
const { injectBundledMcpServer } = await import('../../packages/server/src/services/hermes/studio-mcp-autoinject')
|
|
|
|
await injectBundledMcpServer()
|
|
|
|
const injected = await updateConfigYamlForProfileMock.mock.calls[0][1]({})
|
|
expect(injected.data.mcp_servers['hermes-studio-api'].command).toBe('/runtime/node')
|
|
expect(injected.data.mcp_servers['hermes-studio-api'].args).toEqual([
|
|
join(process.cwd(), 'bin/hermes-studio-mcp.mjs'), 'api',
|
|
])
|
|
expect(injected.data.mcp_servers['hermes-studio-devices'].args).toEqual([
|
|
join(process.cwd(), 'bin/hermes-studio-mcp.mjs'), 'devices',
|
|
])
|
|
expect(injected.data.mcp_servers['hermes-studio-use'].args).toEqual([
|
|
join(process.cwd(), 'bin/hermes-studio-mcp.mjs'), 'use',
|
|
])
|
|
})
|
|
|
|
it('removes stale injected tokens from managed server config', async () => {
|
|
const { injectBundledMcpServer } = await import('../../packages/server/src/services/hermes/studio-mcp-autoinject')
|
|
|
|
await injectBundledMcpServer()
|
|
|
|
const updated = await updateConfigYamlForProfileMock.mock.calls[0][1]({
|
|
mcp_servers: {
|
|
'hermes-studio-api': {
|
|
command: 'hermes-web-ui-mcp',
|
|
args: ['api'],
|
|
env: {
|
|
HERMES_WEB_UI_URL: 'http://127.0.0.1:8648',
|
|
HERMES_WEB_UI_HOME: '/tmp/hermes-web-ui-home',
|
|
HERMES_WEBUI_STATE_DIR: '/tmp/hermes-web-ui-home',
|
|
HERMES_WEB_UI_PROFILE: 'default',
|
|
HERMES_MCP_SERVER_NAME: 'hermes-studio-api',
|
|
HERMES_MCP_TOOLSET: 'api',
|
|
HERMES_WEB_UI_MANAGED_MCP: '1',
|
|
HERMES_WEB_UI_TOKEN: 'old-token',
|
|
},
|
|
enabled: true,
|
|
},
|
|
},
|
|
})
|
|
expect(updated.result.status).toBe('updated')
|
|
expect(updated.data.mcp_servers['hermes-studio-api'].env.HERMES_WEB_UI_TOKEN).toBeUndefined()
|
|
})
|
|
|
|
it('skips an unmanaged existing server entry', async () => {
|
|
const { injectBundledMcpServer } = await import('../../packages/server/src/services/hermes/studio-mcp-autoinject')
|
|
|
|
await injectBundledMcpServer()
|
|
|
|
const updated = await updateConfigYamlForProfileMock.mock.calls[0][1]({
|
|
mcp_servers: {
|
|
'hermes-studio': { command: 'custom-command' },
|
|
},
|
|
})
|
|
expect(updated.write).toBe(false)
|
|
expect(updated.result.status).toBe('skipped')
|
|
})
|
|
})
|