Skip to content

Commit 30747cb

Browse files
Merge dev to main - Fetch API migration & install method detection
2 parents 6764c6b + 8955573 commit 30747cb

15 files changed

Lines changed: 462 additions & 410 deletions

File tree

backend/src/routes/settings.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { Hono } from 'hono'
22
import { z } from 'zod'
33
import { execSync } from 'child_process'
4+
import { existsSync } from 'fs'
5+
import { resolve, dirname } from 'path'
46
import type { Database } from 'bun:sqlite'
57
import { SettingsService } from '../services/settings'
68
import { writeFileContent, readFileContent, fileExists } from '../services/file-operations'
@@ -27,6 +29,27 @@ function compareVersions(v1: string, v2: string): number {
2729
return 0
2830
}
2931

32+
function getOpenCodeInstallMethod(): string {
33+
const homePath = process.env.HOME || ''
34+
const opencodePath = process.env.OPENCOD_PATH || resolve(homePath, '.opencode', 'bin', 'opencode')
35+
36+
if (!existsSync(opencodePath)) return 'curl'
37+
38+
try {
39+
const opencodeDir = dirname(opencodePath)
40+
if (opencodeDir.includes('.opencode')) return 'curl'
41+
42+
if (opencodePath.includes('/homebrew/') || opencodePath.includes('/HOMEBREW/')) return 'brew'
43+
if (opencodePath.includes('/.npm/') || opencodePath.includes('/node_modules/')) return 'npm'
44+
if (opencodePath.includes('/.pnpm/')) return 'pnpm'
45+
if (opencodePath.includes('/.bun/')) return 'bun'
46+
} catch {
47+
return 'curl'
48+
}
49+
50+
return 'curl'
51+
}
52+
3053
function execWithTimeout(command: string, timeoutMs: number): { output: string; timedOut: boolean } {
3154
try {
3255
const output = execSync(command, {
@@ -396,8 +419,9 @@ export function createSettingsRoutes(db: Database) {
396419
logger.info(`Current OpenCode version: ${oldVersion}`)
397420

398421
try {
399-
logger.info('Running opencode upgrade with 90s timeout...')
400-
const { output: upgradeOutput, timedOut } = execWithTimeout('opencode upgrade 2>&1', 90000)
422+
const installMethod = getOpenCodeInstallMethod()
423+
logger.info(`Running opencode upgrade --method ${installMethod} with 90s timeout...`)
424+
const { output: upgradeOutput, timedOut } = execWithTimeout(`opencode upgrade --method ${installMethod} 2>&1`, 90000)
401425
logger.info(`Upgrade output: ${upgradeOutput}`)
402426

403427
if (timedOut) {
@@ -546,9 +570,10 @@ export function createSettingsRoutes(db: Database) {
546570

547571
logger.info(`Installing OpenCode version: ${version}`)
548572
const versionArg = version.startsWith('v') ? version : `v${version}`
549-
logger.info(`Running opencode upgrade ${versionArg} with 90s timeout...`)
573+
const installMethod = getOpenCodeInstallMethod()
574+
logger.info(`Running opencode upgrade ${versionArg} --method ${installMethod} with 90s timeout...`)
550575

551-
const { output: upgradeOutput, timedOut } = execWithTimeout(`opencode upgrade ${versionArg} 2>&1`, 90000)
576+
const { output: upgradeOutput, timedOut } = execWithTimeout(`opencode upgrade ${versionArg} --method ${installMethod} 2>&1`, 90000)
552577
logger.info(`Upgrade output: ${upgradeOutput}`)
553578

554579
if (timedOut) {

backend/src/services/opencode-single-server.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,15 +185,25 @@ class OpenCodeServerManager {
185185
try {
186186
process.kill(this.serverPid, 'SIGTERM')
187187
} catch (error) {
188-
logger.warn(`Failed to send SIGTERM to ${this.serverPid}:`, error)
188+
const errorCode = error && typeof error === 'object' && 'code' in error ? (error as { code: string }).code : ''
189+
if (errorCode === 'ESRCH') {
190+
logger.debug(`Process ${this.serverPid} already stopped`)
191+
} else {
192+
logger.warn(`Failed to send SIGTERM to ${this.serverPid}:`, error)
193+
}
189194
}
190195

191196
await new Promise(r => setTimeout(r, 2000))
192197

193198
try {
194199
process.kill(this.serverPid, 'SIGKILL')
195200
} catch (error) {
196-
logger.warn(`Failed to send SIGKILL to ${this.serverPid}:`, error)
201+
const errorCode = error && typeof error === 'object' && 'code' in error ? (error as { code: string }).code : ''
202+
if (errorCode === 'ESRCH') {
203+
logger.debug(`Process ${this.serverPid} already stopped`)
204+
} else {
205+
logger.warn(`Failed to send SIGKILL to ${this.serverPid}:`, error)
206+
}
197207
}
198208

199209
this.serverPid = null

backend/test/routes/settings.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ describe('Settings Routes - OpenCode Upgrade', () => {
180180
const res = await settingsApp.fetch(req)
181181
const json = await res.json() as Record<string, unknown>
182182

183-
expect(mockExecSync).toHaveBeenCalledWith('opencode upgrade 2>&1', expect.objectContaining({
183+
expect(mockExecSync).toHaveBeenCalledWith('opencode upgrade --method curl 2>&1', expect.objectContaining({
184184
timeout: 90000,
185185
killSignal: 'SIGKILL'
186186
}))
@@ -304,7 +304,7 @@ describe('Settings Routes - OpenCode Upgrade', () => {
304304
await settingsApp.fetch(req)
305305

306306
expect(mockExecSync).toHaveBeenCalledWith(
307-
'opencode upgrade v1.0.5 2>&1',
307+
'opencode upgrade v1.0.5 --method curl 2>&1',
308308
expect.any(Object)
309309
)
310310
})
@@ -322,7 +322,7 @@ describe('Settings Routes - OpenCode Upgrade', () => {
322322
await settingsApp.fetch(req)
323323

324324
expect(mockExecSync).toHaveBeenCalledWith(
325-
'opencode upgrade v1.0.5 2>&1',
325+
'opencode upgrade v1.0.5 --method curl 2>&1',
326326
expect.any(Object)
327327
)
328328
})
@@ -346,8 +346,8 @@ describe('Settings Routes - OpenCode Upgrade', () => {
346346
const json = await res.json() as Record<string, unknown>
347347

348348
expect(mockExecSync).toHaveBeenCalledWith(
349-
'opencode upgrade v1.0.5 2>&1',
350-
expect.objectContaining({ timeout: 90000 })
349+
'opencode upgrade v1.0.5 --method curl 2>&1',
350+
expect.any(Object)
351351
)
352352
expect(mockRestart).toHaveBeenCalled()
353353
expect(res.status).toBe(400)

backend/test/routes/tts.test.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect, vi, beforeEach } from 'vitest'
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
22
import * as fs from 'fs/promises'
33

44
vi.mock('fs/promises', () => ({
@@ -78,9 +78,13 @@ describe('TTS Routes', () => {
7878
})
7979

8080
describe('getCachedAudio', () => {
81-
beforeEach(() => {
82-
vi.useFakeTimers()
83-
})
81+
beforeEach(() => {
82+
vi.useFakeTimers()
83+
})
84+
85+
afterEach(() => {
86+
vi.useRealTimers()
87+
})
8488

8589
it('should return cached audio when file exists and is not expired', async () => {
8690
const cacheKey = 'test-key'

frontend/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
"@radix-ui/react-tabs": "^1.1.13",
3030
"@tailwindcss/vite": "^4.1.14",
3131
"@tanstack/react-query": "^5.90.5",
32-
"axios": "^1.12.2",
3332
"better-auth": "^1.4.17",
3433
"class-variance-authority": "^0.7.1",
3534
"clsx": "^2.1.1",

frontend/src/api/fetchWrapper.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
export class FetchError extends Error {
2+
statusCode?: number
3+
code?: string
4+
5+
constructor(message: string, statusCode?: number, code?: string) {
6+
super(message)
7+
this.name = 'FetchError'
8+
this.statusCode = statusCode
9+
this.code = code
10+
}
11+
}
12+
13+
interface ApiError {
14+
error?: string
15+
code?: string
16+
message?: string
17+
}
18+
19+
interface FetchWrapperOptions extends RequestInit {
20+
timeout?: number
21+
params?: Record<string, string | number | boolean | undefined | unknown>
22+
}
23+
24+
async function handleResponse(response: Response): Promise<never> {
25+
const data: ApiError = await response.json().catch(() => ({ error: 'An error occurred' }))
26+
throw new FetchError(data.error || data.message || 'Request failed', response.status, data.code)
27+
}
28+
29+
async function fetchWrapper<T = unknown>(
30+
url: string,
31+
options: FetchWrapperOptions = {}
32+
): Promise<T> {
33+
const { timeout = 30000, params, ...fetchOptions } = options
34+
35+
const urlObj = new URL(url, window.location.origin)
36+
if (params) {
37+
Object.entries(params).forEach(([key, value]) => {
38+
if (value !== undefined) {
39+
urlObj.searchParams.append(key, String(value))
40+
}
41+
})
42+
}
43+
44+
const controller = new AbortController()
45+
const timeoutId = setTimeout(() => controller.abort(), timeout)
46+
47+
try {
48+
const response = await fetch(urlObj.toString(), {
49+
...fetchOptions,
50+
signal: controller.signal,
51+
})
52+
53+
clearTimeout(timeoutId)
54+
55+
if (!response.ok) {
56+
await handleResponse(response)
57+
}
58+
59+
return response.json()
60+
} catch (error) {
61+
clearTimeout(timeoutId)
62+
if (error instanceof Error && error.name === 'AbortError') {
63+
throw new FetchError('Request timeout', 408, 'TIMEOUT')
64+
}
65+
throw error
66+
}
67+
}
68+
69+
async function fetchWrapperBlob(
70+
url: string,
71+
options: FetchWrapperOptions = {}
72+
): Promise<Blob> {
73+
const { timeout = 30000, params, ...fetchOptions } = options
74+
75+
const urlObj = new URL(url, window.location.origin)
76+
if (params) {
77+
Object.entries(params).forEach(([key, value]) => {
78+
if (value !== undefined) {
79+
urlObj.searchParams.append(key, String(value))
80+
}
81+
})
82+
}
83+
84+
const controller = new AbortController()
85+
const timeoutId = setTimeout(() => controller.abort(), timeout)
86+
87+
try {
88+
const response = await fetch(urlObj.toString(), {
89+
...fetchOptions,
90+
signal: controller.signal,
91+
})
92+
93+
clearTimeout(timeoutId)
94+
95+
if (!response.ok) {
96+
await handleResponse(response)
97+
}
98+
99+
return response.blob()
100+
} catch (error) {
101+
clearTimeout(timeoutId)
102+
if (error instanceof Error && error.name === 'AbortError') {
103+
throw new FetchError('Request timeout', 408, 'TIMEOUT')
104+
}
105+
throw error
106+
}
107+
}
108+
109+
export { fetchWrapper, fetchWrapperBlob }

frontend/src/api/oauth.ts

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import axios from "axios"
21
import { API_BASE_URL } from "@/config"
2+
import { fetchWrapper, FetchError } from "./fetchWrapper"
33

44
export interface OAuthAuthorizeResponse {
55
url: string
@@ -22,38 +22,43 @@ export interface ProviderAuthMethods {
2222
}
2323

2424
function handleApiError(error: unknown, context: string): never {
25-
if (axios.isAxiosError(error)) {
26-
const message = error.response?.data?.error || error.message
27-
throw new Error(`${context}: ${message}`)
25+
if (error instanceof FetchError) {
26+
throw new Error(`${context}: ${error.message}`)
2827
}
2928
throw error
3029
}
3130

3231
export const oauthApi = {
3332
authorize: async (providerId: string, method: number): Promise<OAuthAuthorizeResponse> => {
3433
try {
35-
const { data } = await axios.post(`${API_BASE_URL}/api/oauth/${providerId}/oauth/authorize`, {
36-
method,
34+
return fetchWrapper(`${API_BASE_URL}/api/oauth/${providerId}/oauth/authorize`, {
35+
method: 'POST',
36+
headers: { 'Content-Type': 'application/json' },
37+
body: JSON.stringify({ method }),
3738
})
38-
return data
3939
} catch (error) {
4040
handleApiError(error, "OAuth authorization failed")
4141
}
4242
},
4343

4444
callback: async (providerId: string, request: OAuthCallbackRequest): Promise<boolean> => {
4545
try {
46-
const { data } = await axios.post(`${API_BASE_URL}/api/oauth/${providerId}/oauth/callback`, request)
47-
return data
46+
return fetchWrapper(`${API_BASE_URL}/api/oauth/${providerId}/oauth/callback`, {
47+
method: 'POST',
48+
headers: { 'Content-Type': 'application/json' },
49+
body: JSON.stringify(request),
50+
})
4851
} catch (error) {
4952
handleApiError(error, "OAuth callback failed")
5053
}
5154
},
5255

5356
getAuthMethods: async (): Promise<ProviderAuthMethods> => {
5457
try {
55-
const { data } = await axios.get(`${API_BASE_URL}/api/oauth/auth-methods`)
56-
return data.providers || data
58+
const { providers, ...rest } = await fetchWrapper<{ providers?: ProviderAuthMethods } & ProviderAuthMethods>(
59+
`${API_BASE_URL}/api/oauth/auth-methods`
60+
)
61+
return providers || rest
5762
} catch (error) {
5863
handleApiError(error, "Failed to get provider auth methods")
5964
}

0 commit comments

Comments
 (0)