Skip to content

Commit bb8db34

Browse files
committed
feat: add manual configuration path handling and enhance IPC methods for client installation and registry interactions
1 parent 684c961 commit bb8db34

56 files changed

Lines changed: 4044 additions & 436 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"isomorphic-git": "^1.37.2",
5252
"keytar": "^7.9.0",
5353
"lucide-react": "^0.577.0",
54+
"monaco-editor": "^0.55.1",
5455
"radix-ui": "^1.4.3",
5556
"react-hook-form": "^7.71.2",
5657
"react-i18next": "^16.5.6",

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/main/__tests__/preload-bridge.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@ import { createApi } from '../../preload/api/bridge'
33

44
const EXPECTED_KEYS = [
55
'clientsDetectAll',
6+
'clientsInstall',
67
'clientsReadConfig',
78
'clientsSync',
89
'clientsSyncAll',
10+
'clientsPreviewConfigImport',
11+
'clientsImportConfigChanges',
12+
'clientsSetManualConfigPath',
13+
'clientsClearManualConfigPath',
914
'clientsValidateConfig',
1015
'serversList',
1116
'serversGet',
@@ -47,6 +52,7 @@ const EXPECTED_KEYS = [
4752
'gitSyncPush',
4853
'gitSyncPull',
4954
'registrySearch',
55+
'registryPrepareInstall',
5056
'registryInstall',
5157
'stacksExport',
5258
'stacksImport',
@@ -93,17 +99,61 @@ describe('preload bridge composition', () => {
9399
const api = createApi({ invoke, on, removeListener })
94100

95101
await api.clientsDetectAll()
102+
await api.clientsInstall('cursor')
103+
await api.clientsPreviewConfigImport({
104+
clientId: 'cursor',
105+
configPath: 'C:\\tmp\\mcp.json',
106+
added: ['alpha'],
107+
removed: [],
108+
modified: [],
109+
})
110+
await api.clientsImportConfigChanges({
111+
clientId: 'cursor',
112+
configPath: 'C:\\tmp\\mcp.json',
113+
added: [],
114+
removed: ['alpha'],
115+
modified: [],
116+
})
117+
await api.clientsSetManualConfigPath('cursor', 'C:\\tmp\\mcp.json')
118+
await api.clientsClearManualConfigPath('cursor')
96119
await api.serversList()
97120
await api.rulesSyncAll()
98121
await api.settingsSet('language', 'en')
99122
await api.backupsList('cursor')
100123
await api.filesReveal('C:\\tmp\\file.txt')
124+
await api.registryPrepareInstall('smithery', '@anthropic/github-mcp')
101125

102126
expect(invoke).toHaveBeenCalledWith('clients:detect-all')
127+
expect(invoke).toHaveBeenCalledWith('clients:install', 'cursor')
128+
expect(invoke).toHaveBeenCalledWith('clients:preview-config-import', {
129+
clientId: 'cursor',
130+
configPath: 'C:\\tmp\\mcp.json',
131+
added: ['alpha'],
132+
removed: [],
133+
modified: [],
134+
})
135+
expect(invoke).toHaveBeenCalledWith('clients:import-config-changes', {
136+
clientId: 'cursor',
137+
configPath: 'C:\\tmp\\mcp.json',
138+
added: [],
139+
removed: ['alpha'],
140+
modified: [],
141+
})
142+
expect(invoke).toHaveBeenCalledWith(
143+
'clients:set-manual-config-path',
144+
'cursor',
145+
'C:\\tmp\\mcp.json',
146+
)
147+
expect(invoke).toHaveBeenCalledWith('clients:clear-manual-config-path', 'cursor')
103148
expect(invoke).toHaveBeenCalledWith('servers:list')
104149
expect(invoke).toHaveBeenCalledWith('rules:sync-all')
105150
expect(invoke).toHaveBeenCalledWith('settings:set', 'language', 'en')
106151
expect(invoke).toHaveBeenCalledWith('backups:list', 'cursor')
107152
expect(invoke).toHaveBeenCalledWith('files:reveal', 'C:\\tmp\\file.txt')
153+
expect(invoke).toHaveBeenCalledWith(
154+
'registry:prepare-install',
155+
'smithery',
156+
'@anthropic/github-mcp',
157+
)
108158
})
109159
})
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/**
2+
* @file src/main/clients/__tests__/client-install.service.test.ts
3+
*
4+
* @description Unit tests for ClientInstallService fallback chains and
5+
* failure classifications.
6+
*/
7+
8+
import { EventEmitter } from 'events'
9+
import { beforeEach, describe, expect, it, vi } from 'vitest'
10+
11+
type Platform = typeof process.platform
12+
13+
const withMockPlatform = (platform: Platform): void => {
14+
Object.defineProperty(process, 'platform', {
15+
configurable: true,
16+
value: platform,
17+
})
18+
}
19+
20+
interface SpawnPlan {
21+
readonly command: string
22+
readonly exitCode?: number
23+
readonly stdout?: string
24+
readonly stderr?: string
25+
readonly error?: string
26+
}
27+
28+
const spawnPlanQueue = vi.hoisted(() => [] as SpawnPlan[])
29+
const managerAvailability = vi.hoisted(
30+
() => ({ winget: true, choco: true, npm: true }) as Record<string, boolean>,
31+
)
32+
33+
const spawnMock = vi.hoisted(() =>
34+
vi.fn((command: string) => {
35+
const plan = spawnPlanQueue.shift()
36+
const effectivePlan: SpawnPlan = plan ?? { command, exitCode: 0 }
37+
38+
const child = new EventEmitter() as EventEmitter & {
39+
stdout: EventEmitter
40+
stderr: EventEmitter
41+
}
42+
child.stdout = new EventEmitter()
43+
child.stderr = new EventEmitter()
44+
45+
queueMicrotask(() => {
46+
if (effectivePlan.stdout) child.stdout.emit('data', effectivePlan.stdout)
47+
if (effectivePlan.stderr) child.stderr.emit('data', effectivePlan.stderr)
48+
if (effectivePlan.error) {
49+
child.emit('error', new Error(effectivePlan.error))
50+
} else {
51+
child.emit('close', effectivePlan.exitCode ?? 0)
52+
}
53+
})
54+
55+
return child
56+
}),
57+
)
58+
59+
vi.mock('cross-spawn', () => ({
60+
default: spawnMock,
61+
}))
62+
63+
vi.mock('../windows-detection.util', () => ({
64+
hasWindowsCommandOnPath: (commandNames: readonly string[]) => {
65+
const key = commandNames[0]
66+
if (!key) return false
67+
return managerAvailability[key] ?? false
68+
},
69+
}))
70+
71+
import { ClientInstallService } from '../client-install.service'
72+
73+
describe('ClientInstallService', () => {
74+
beforeEach(() => {
75+
withMockPlatform('win32')
76+
spawnMock.mockClear()
77+
spawnPlanQueue.length = 0
78+
managerAvailability.winget = true
79+
managerAvailability.choco = true
80+
managerAvailability.npm = true
81+
})
82+
83+
it('uses fallback chain when winget fails and choco succeeds', async () => {
84+
spawnPlanQueue.push(
85+
{ command: 'winget', exitCode: 1, stderr: 'failed' },
86+
{ command: 'choco', exitCode: 0, stdout: 'ok' },
87+
)
88+
const service = new ClientInstallService()
89+
90+
const result = await service.install('cursor')
91+
92+
expect(result.success).toBe(true)
93+
expect(result.installedWith).toBe('choco')
94+
expect(result.attempts).toHaveLength(2)
95+
expect(result.attempts[0]?.manager).toBe('winget')
96+
expect(result.attempts[0]?.success).toBe(false)
97+
expect(result.attempts[1]?.manager).toBe('choco')
98+
expect(result.attempts[1]?.success).toBe(true)
99+
expect(spawnMock).toHaveBeenNthCalledWith(1, 'winget', expect.any(Array), expect.any(Object))
100+
expect(spawnMock).toHaveBeenNthCalledWith(2, 'choco', expect.any(Array), expect.any(Object))
101+
})
102+
103+
it('returns no_available_manager when none of the managers are present', async () => {
104+
managerAvailability.winget = false
105+
managerAvailability.choco = false
106+
managerAvailability.npm = false
107+
const service = new ClientInstallService()
108+
109+
const result = await service.install('codex-cli')
110+
111+
expect(result.success).toBe(false)
112+
expect(result.failureReason).toBe('no_available_manager')
113+
expect(result.attempts).toHaveLength(3)
114+
expect(result.attempts.every((attempt) => attempt.skipped === true)).toBe(true)
115+
expect(spawnMock).not.toHaveBeenCalled()
116+
})
117+
118+
it('returns requires_elevation without trying to elevate automatically', async () => {
119+
spawnPlanQueue.push({
120+
command: 'winget',
121+
exitCode: 1,
122+
stderr: 'This operation requires elevation. Run as administrator.',
123+
})
124+
managerAvailability.choco = false
125+
managerAvailability.npm = false
126+
const service = new ClientInstallService()
127+
128+
const result = await service.install('codex-gui')
129+
130+
expect(result.success).toBe(false)
131+
expect(result.failureReason).toBe('requires_elevation')
132+
expect(result.attempts).toHaveLength(1)
133+
expect(result.attempts[0]?.command).toBe('winget')
134+
expect(spawnMock).toHaveBeenCalledTimes(1)
135+
})
136+
137+
it('returns manual_install_required for manual-only clients', async () => {
138+
const service = new ClientInstallService()
139+
140+
const result = await service.install('jetbrains')
141+
142+
expect(result.success).toBe(false)
143+
expect(result.failureReason).toBe('manual_install_required')
144+
expect(result.docsUrl).toContain('jetbrains.com')
145+
expect(result.attempts).toHaveLength(0)
146+
expect(spawnMock).not.toHaveBeenCalled()
147+
})
148+
149+
it('returns unsupported_platform on non-Windows', async () => {
150+
withMockPlatform('linux')
151+
const service = new ClientInstallService()
152+
153+
const result = await service.install('cursor')
154+
155+
expect(result.success).toBe(false)
156+
expect(result.failureReason).toBe('unsupported_platform')
157+
expect(spawnMock).not.toHaveBeenCalled()
158+
})
159+
})

0 commit comments

Comments
 (0)