Skip to content

Commit ccd9a6d

Browse files
committed
feat: enhance OpenCode adapter with new server shape handling, validation, and legacy support
1 parent 4f1a7f5 commit ccd9a6d

6 files changed

Lines changed: 534 additions & 46 deletions

File tree

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2+
import { mkdirSync, writeFileSync, rmSync } from 'fs'
3+
import { join } from 'path'
4+
import { tmpdir } from 'os'
5+
import { createTestDb } from '@main/db/__tests__/helpers'
6+
import type Database from 'better-sqlite3'
7+
import { ServersRepo } from '@main/db/servers.repo'
8+
import { opencodeAdapter } from '../opencode.adapter'
9+
import { importExternalConfigChanges, previewExternalConfigImport } from '../config-import.service'
10+
11+
const makeTmpDir = (): string => {
12+
const dir = join(
13+
tmpdir(),
14+
`aidrelay-config-import-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
15+
)
16+
mkdirSync(dir, { recursive: true })
17+
return dir
18+
}
19+
20+
describe('config-import.service (OpenCode canonicalization)', () => {
21+
let db: Database.Database
22+
let serversRepo: ServersRepo
23+
let tmpDir: string
24+
25+
beforeEach(() => {
26+
db = createTestDb()
27+
serversRepo = new ServersRepo(db)
28+
tmpDir = makeTmpDir()
29+
})
30+
31+
afterEach(() => {
32+
db.close()
33+
rmSync(tmpDir, { recursive: true, force: true })
34+
})
35+
36+
it('imports new OpenCode local/remote entries in canonical shape', async () => {
37+
const configPath = join(tmpDir, 'opencode.json')
38+
writeFileSync(
39+
configPath,
40+
JSON.stringify({
41+
mcp: {
42+
localServer: {
43+
type: 'local',
44+
command: ['npx', '-y', 'local-mcp'],
45+
environment: { TOKEN: 'abc' },
46+
},
47+
remoteServer: {
48+
type: 'remote',
49+
url: 'https://example.test/sse',
50+
transport: 'sse',
51+
headers: { Authorization: 'Bearer token' },
52+
},
53+
},
54+
}),
55+
)
56+
57+
const payload = {
58+
clientId: 'opencode' as const,
59+
configPath,
60+
added: ['localServer', 'remoteServer'],
61+
removed: [],
62+
modified: [],
63+
}
64+
65+
const result = await importExternalConfigChanges(opencodeAdapter, payload, serversRepo)
66+
expect(result.created).toBe(2)
67+
expect(result.updated).toBe(0)
68+
expect(result.errors).toEqual([])
69+
70+
const servers = serversRepo.findAll()
71+
const local = servers.find((server) => server.name === 'localServer')
72+
const remote = servers.find((server) => server.name === 'remoteServer')
73+
74+
expect(local).toMatchObject({
75+
name: 'localServer',
76+
type: 'stdio',
77+
command: 'npx',
78+
args: ['-y', 'local-mcp'],
79+
env: { TOKEN: 'abc' },
80+
})
81+
82+
expect(remote).toMatchObject({
83+
name: 'remoteServer',
84+
type: 'sse',
85+
command: 'fetch',
86+
url: 'https://example.test/sse',
87+
headers: { Authorization: 'Bearer token' },
88+
})
89+
})
90+
91+
it('marks canonical-equivalent OpenCode entries as no-op in preview', async () => {
92+
serversRepo.create({
93+
name: 'localServer',
94+
type: 'stdio',
95+
command: 'npx',
96+
args: ['-y', 'local-mcp'],
97+
env: { TOKEN: 'abc' },
98+
})
99+
100+
const configPath = join(tmpDir, 'opencode.json')
101+
writeFileSync(
102+
configPath,
103+
JSON.stringify({
104+
mcp: {
105+
localServer: {
106+
type: 'local',
107+
command: ['npx', '-y', 'local-mcp'],
108+
environment: { TOKEN: 'abc' },
109+
},
110+
},
111+
}),
112+
)
113+
114+
const payload = {
115+
clientId: 'opencode' as const,
116+
configPath,
117+
added: [],
118+
removed: [],
119+
modified: ['localServer'],
120+
}
121+
122+
const preview = await previewExternalConfigImport(opencodeAdapter, payload, serversRepo)
123+
expect(preview.items).toHaveLength(1)
124+
expect(preview.items[0]).toMatchObject({
125+
name: 'localServer',
126+
action: 'no-op',
127+
source: 'modified',
128+
})
129+
})
130+
})

src/main/clients/__tests__/opencode.adapter.test.ts

Lines changed: 165 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,16 +93,177 @@ describe('opencodeAdapter', () => {
9393
expect(result.configPaths).toEqual([])
9494
})
9595

96-
it('reads and writes mcp section', async () => {
96+
it('reads legacy mcp server shape', async () => {
97+
const configPath = join(tmpDir, 'opencode.json')
98+
writeFileSync(
99+
configPath,
100+
JSON.stringify({
101+
mcp: {
102+
legacy: {
103+
command: 'npx',
104+
args: ['-y', 'legacy-mcp'],
105+
env: { TOKEN: 'abc' },
106+
},
107+
},
108+
}),
109+
)
110+
111+
const read = await opencodeAdapter.read(configPath)
112+
expect(read).toEqual({
113+
legacy: {
114+
command: 'npx',
115+
args: ['-y', 'legacy-mcp'],
116+
env: { TOKEN: 'abc' },
117+
},
118+
})
119+
})
120+
121+
it('reads new OpenCode local/remote shapes into canonical format', async () => {
122+
const configPath = join(tmpDir, 'opencode.json')
123+
writeFileSync(
124+
configPath,
125+
JSON.stringify({
126+
mcp: {
127+
localServer: {
128+
type: 'local',
129+
command: ['npx', '-y', 'local-mcp'],
130+
environment: { TOKEN: 'abc' },
131+
},
132+
sseRemote: {
133+
type: 'remote',
134+
url: 'https://example.test/sse',
135+
transport: 'sse',
136+
headers: { Authorization: 'Bearer token' },
137+
},
138+
httpRemote: {
139+
type: 'remote',
140+
url: 'https://example.test/http',
141+
},
142+
},
143+
}),
144+
)
145+
146+
const read = await opencodeAdapter.read(configPath)
147+
expect(read).toEqual({
148+
localServer: {
149+
command: 'npx',
150+
args: ['-y', 'local-mcp'],
151+
env: { TOKEN: 'abc' },
152+
},
153+
sseRemote: {
154+
command: 'fetch',
155+
type: 'sse',
156+
url: 'https://example.test/sse',
157+
headers: { Authorization: 'Bearer token' },
158+
},
159+
httpRemote: {
160+
command: 'fetch',
161+
type: 'http',
162+
url: 'https://example.test/http',
163+
},
164+
})
165+
})
166+
167+
it('writes canonical stdio server shape to OpenCode local format', async () => {
97168
const configPath = join(tmpDir, 'opencode.json')
98169
writeFileSync(configPath, JSON.stringify({ other: true }))
99170

100-
await opencodeAdapter.write(configPath, { myServer: { command: 'npx' } })
171+
await opencodeAdapter.write(configPath, {
172+
myServer: {
173+
command: 'npx',
174+
args: ['-y', 'my-mcp'],
175+
env: { TOKEN: 'abc' },
176+
},
177+
})
101178
const written = JSON.parse(readFileSync(configPath, 'utf-8')) as Record<string, unknown>
102-
expect(written['mcp']).toEqual({ myServer: { command: 'npx' } })
179+
const mcp = written['mcp'] as Record<string, unknown>
180+
expect(mcp['myServer']).toEqual({
181+
type: 'local',
182+
command: ['npx', '-y', 'my-mcp'],
183+
environment: { TOKEN: 'abc' },
184+
enabled: true,
185+
})
103186
expect(written['other']).toBe(true)
187+
})
188+
189+
it('writes canonical remote server shape to OpenCode remote format', async () => {
190+
const configPath = join(tmpDir, 'opencode.json')
191+
writeFileSync(configPath, JSON.stringify({}))
192+
193+
await opencodeAdapter.write(configPath, {
194+
sseRemote: {
195+
command: 'fetch',
196+
type: 'sse',
197+
url: 'https://example.test/sse',
198+
headers: { Authorization: 'Bearer sse' },
199+
},
200+
httpRemote: {
201+
command: 'fetch',
202+
type: 'http',
203+
url: 'https://example.test/http',
204+
},
205+
})
104206

207+
const written = JSON.parse(readFileSync(configPath, 'utf-8')) as Record<string, unknown>
208+
const mcp = written['mcp'] as Record<string, unknown>
209+
expect(mcp['sseRemote']).toEqual({
210+
type: 'remote',
211+
url: 'https://example.test/sse',
212+
headers: { Authorization: 'Bearer sse' },
213+
transport: 'sse',
214+
enabled: true,
215+
})
216+
expect(mcp['httpRemote']).toEqual({
217+
type: 'remote',
218+
url: 'https://example.test/http',
219+
transport: 'streamable-http',
220+
enabled: true,
221+
})
222+
})
223+
224+
it('round-trips canonical config through write/read', async () => {
225+
const configPath = join(tmpDir, 'opencode.json')
226+
writeFileSync(configPath, JSON.stringify({}))
227+
228+
await opencodeAdapter.write(configPath, {
229+
myServer: { command: 'npx', args: ['-y', 'pkg'] },
230+
})
105231
const read = await opencodeAdapter.read(configPath)
106-
expect(read).toEqual({ myServer: { command: 'npx' } })
232+
expect(read).toEqual({ myServer: { command: 'npx', args: ['-y', 'pkg'] } })
233+
})
234+
235+
it('validates new and legacy mcp entry formats', async () => {
236+
const configPath = join(tmpDir, 'opencode.json')
237+
writeFileSync(
238+
configPath,
239+
JSON.stringify({
240+
mcp: {
241+
legacy: { command: 'npx', args: ['-y', 'legacy'] },
242+
local: { type: 'local', command: ['npx', '-y', 'local'] },
243+
remote: { type: 'remote', url: 'https://example.test', transport: 'sse' },
244+
},
245+
}),
246+
)
247+
248+
const validation = await opencodeAdapter.validate(configPath)
249+
expect(validation.valid).toBe(true)
250+
expect(validation.errors).toEqual([])
251+
})
252+
253+
it('rejects malformed OpenCode entries', async () => {
254+
const configPath = join(tmpDir, 'opencode.json')
255+
writeFileSync(
256+
configPath,
257+
JSON.stringify({
258+
mcp: {
259+
brokenLocal: { type: 'local', command: [] },
260+
brokenRemote: { type: 'remote', url: '' },
261+
},
262+
}),
263+
)
264+
265+
const validation = await opencodeAdapter.validate(configPath)
266+
expect(validation.valid).toBe(false)
267+
expect(validation.errors.length).toBeGreaterThan(0)
107268
})
108269
})

0 commit comments

Comments
 (0)