Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { cors } from 'hono/cors'
import { serveStatic } from '@hono/node-server/serve-static'
import os from 'os'
import path from 'path'
import { readFile } from 'fs/promises'
import { initializeDatabase } from './db/schema'
import { createRepoRoutes } from './routes/repos'
import { createIPCServer, type IPCServer } from './ipc/ipcServer'
Expand All @@ -13,6 +14,17 @@ import { createHealthRoutes } from './routes/health'
import { createTTSRoutes, cleanupExpiredCache } from './routes/tts';
import { createSTTRoutes } from './routes/stt'
import { createFileRoutes } from './routes/files'

async function getAppVersion(): Promise<string> {
try {
const packageUrl = new URL('../../package.json', import.meta.url)
const packageJsonRaw = await readFile(packageUrl, 'utf-8')
const packageJson = JSON.parse(packageJsonRaw) as { version?: string }
return packageJson.version ?? 'unknown'
} catch {
return 'unknown'
}
}
import { createProvidersRoutes } from './routes/providers'
import { createOAuthRoutes } from './routes/oauth'
import { createTitleRoutes } from './routes/title'
Expand Down Expand Up @@ -225,12 +237,12 @@ if (ENV.VAPID.PUBLIC_KEY && ENV.VAPID.PRIVATE_KEY) {
app.route('/api/auth', createAuthRoutes(auth))
app.route('/api/auth-info', createAuthInfoRoutes(auth, db))

app.route('/api/health', createHealthRoutes(db))
app.route('/api/mcp-oauth-proxy', createMcpOauthProxyRoutes(requireAuth))

const protectedApi = new Hono()
protectedApi.use('/*', requireAuth)

protectedApi.route('/health', createHealthRoutes(db))
protectedApi.route('/repos', createRepoRoutes(db, gitAuthService))
protectedApi.route('/settings', createSettingsRoutes(db))
protectedApi.route('/files', createFileRoutes())
Expand Down Expand Up @@ -287,10 +299,11 @@ if (isProduction) {
return c.html(html)
})
} else {
app.get('/', (c) => {
app.get('/', async (c) => {
const version = await getAppVersion()
return c.json({
name: 'OpenCode WebUI',
version: '2.0.0',
version,
status: 'running',
endpoints: {
health: '/api/health',
Expand Down
1 change: 0 additions & 1 deletion backend/src/ipc/sshHostKeyHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ export class SSHHostKeyHandler implements IPCHandler {
this.timeoutMs = timeoutMs
const configDir = path.join(getWorkspacePath(), 'config')
this.knownHostsPath = path.join(configDir, 'known_hosts')
this.ensureKnownHostsFile()
logger.info(`SSHHostKeyHandler initialized with timeout=${timeoutMs}ms, known_hosts=${this.knownHostsPath}`)
}

Expand Down
120 changes: 119 additions & 1 deletion backend/src/routes/health.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,91 @@
import { Hono } from 'hono'
import type { Database } from 'bun:sqlite'
import { readFile } from 'fs/promises'
import { opencodeServerManager } from '../services/opencode-single-server'

const GITHUB_REPO_OWNER = 'chriswritescode-dev'
const GITHUB_REPO_NAME = 'opencode-manager'

function compareVersions(a: string, b: string): number {
const cleanA = a.replace(/^v/, '')
const cleanB = b.replace(/^v/, '')
const partsA = cleanA.split('.').map(Number)
const partsB = cleanB.split('.').map(Number)

for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
const partA = partsA[i] ?? 0
const partB = partsB[i] ?? 0
if (partA > partB) return 1
if (partA < partB) return -1
}
return 0
}

interface CachedRelease {
tagName: string
htmlUrl: string
name: string
fetchedAt: number
}

let cachedRelease: CachedRelease | null = null
const CACHE_TTL_MS = 60 * 60 * 1000

async function fetchLatestRelease(): Promise<CachedRelease | null> {
if (cachedRelease && Date.now() - cachedRelease.fetchedAt < CACHE_TTL_MS) {
return cachedRelease
}

try {
const response = await fetch(
`https://api.github.com/repos/${GITHUB_REPO_OWNER}/${GITHUB_REPO_NAME}/releases/latest`,
{
headers: {
'Accept': 'application/vnd.github+json',
'User-Agent': 'OpenCode-Manager'
}
}
)

if (!response.ok) {
return cachedRelease
}

const data = await response.json() as { tag_name?: string; html_url?: string; name?: string }
const tagName = data.tag_name ?? '0.0.0'
const htmlUrl = data.html_url ?? ''
const name = data.name ?? tagName

cachedRelease = {
tagName,
htmlUrl,
name,
fetchedAt: Date.now()
}

return cachedRelease
} catch {
return cachedRelease
}
}

const opencodeManagerVersionPromise = (async (): Promise<string | null> => {
try {
const packageUrl = new URL('../../../package.json', import.meta.url)
const packageJsonRaw = await readFile(packageUrl, 'utf-8')
const packageJson = JSON.parse(packageJsonRaw) as { version?: unknown }
return typeof packageJson.version === 'string' ? packageJson.version : null
} catch {
return null
}
})()

export function createHealthRoutes(db: Database) {
const app = new Hono()

app.get('/', async (c) => {
try {
const opencodeManagerVersion = await opencodeManagerVersionPromise
const dbCheck = db.prepare('SELECT 1').get()
const opencodeHealthy = await opencodeServerManager.checkHealth()
const startupError = opencodeServerManager.getLastStartupError()
Expand All @@ -23,7 +102,8 @@ export function createHealthRoutes(db: Database) {
opencodePort: opencodeServerManager.getPort(),
opencodeVersion: opencodeServerManager.getVersion(),
opencodeMinVersion: opencodeServerManager.getMinVersion(),
opencodeVersionSupported: opencodeServerManager.isVersionSupported()
opencodeVersionSupported: opencodeServerManager.isVersionSupported(),
opencodeManagerVersion,
}

if (startupError && !opencodeHealthy) {
Expand All @@ -32,9 +112,11 @@ export function createHealthRoutes(db: Database) {

return c.json(response)
} catch (error) {
const opencodeManagerVersion = await opencodeManagerVersionPromise
return c.json({
status: 'unhealthy',
timestamp: new Date().toISOString(),
opencodeManagerVersion,
error: error instanceof Error ? error.message : 'Unknown error'
}, 503)
}
Expand All @@ -59,5 +141,41 @@ export function createHealthRoutes(db: Database) {
}
})

app.get('/version', async (c) => {
const currentVersion = await opencodeManagerVersionPromise
const latestRelease = await fetchLatestRelease()

if (!currentVersion) {
return c.json({
currentVersion: null,
latestVersion: null,
updateAvailable: false,
releaseUrl: null,
releaseName: null
})
}

if (!latestRelease) {
return c.json({
currentVersion,
latestVersion: null,
updateAvailable: false,
releaseUrl: null,
releaseName: null
})
}

const latestVersion = latestRelease.tagName.replace(/^v/, '')
const isUpdateAvailable = compareVersions(currentVersion, latestVersion) < 0

return c.json({
currentVersion,
latestVersion,
updateAvailable: isUpdateAvailable,
releaseUrl: latestRelease.htmlUrl,
releaseName: latestRelease.name
})
})

return app
}
33 changes: 21 additions & 12 deletions backend/src/routes/repo-git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,25 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS
return c.json({ error: 'repoIds must be an array of numbers' }, 400)
}

const statuses = await Promise.all(
repoIds.map(async (id) => {
try {
const status = await git.getStatus(id, database)
return [id, status]
} catch (error: unknown) {
logger.error(`Failed to get git status for repo ${id}:`, error)
return null
}
})
)
const BATCH_CONCURRENCY = 3
const results: Array<[number, GitStatusResponse] | null> = []
for (let i = 0; i < repoIds.length; i += BATCH_CONCURRENCY) {
const batch = repoIds.slice(i, i + BATCH_CONCURRENCY)
const batchResults = await Promise.all(
batch.map(async (id) => {
try {
const status = await git.getStatus(id, database)
return [id, status] as [number, GitStatusResponse]
} catch (error: unknown) {
logger.error(`Failed to get git status for repo ${id}:`, error)
return null
}
})
)
results.push(...batchResults)
}

const statuses = results

const resultMap: Record<number, GitStatusResponse> = {}
for (const entry of statuses) {
Expand All @@ -65,6 +73,7 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS
}
}


return c.json(resultMap)
} catch (error: unknown) {
logger.error('Failed to get batch git status:', error)
Expand Down Expand Up @@ -371,4 +380,4 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS
})

return app
}
}
8 changes: 8 additions & 0 deletions backend/src/services/git-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,14 @@ export class GitAuthService {
Object.assign(env, this.askpassHandler.getEnv())
}

if (this.sshHostKeyHandler) {
const knownHostsPath = this.sshHostKeyHandler.getKnownHostsPath()
if (knownHostsPath) {
env.GIT_SSH_COMMAND = buildSSHCommandWithKnownHosts(knownHostsPath)
Object.assign(env, this.sshHostKeyHandler.getEnv())
}
}

return env
}
}
2 changes: 2 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Login } from './pages/Login'
import { Register } from './pages/Register'
import { Setup } from './pages/Setup'
import { SettingsDialog } from './components/settings/SettingsDialog'
import { VersionNotifier } from './components/VersionNotifier'
import { useTheme } from './hooks/useTheme'
import { TTSProvider } from './contexts/TTSContext'
import { AuthProvider } from './contexts/AuthContext'
Expand Down Expand Up @@ -82,6 +83,7 @@ function AppShell() {
<PermissionDialogWrapper />
<SSHHostKeyDialogWrapper />
<SettingsDialog />
<VersionNotifier />
<Toaster
position="bottom-right"
expand={false}
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/api/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,4 +206,16 @@ export const settingsApi = {
body: JSON.stringify({ content }),
})
},

getVersionInfo: async (): Promise<VersionInfo> => {
return fetchWrapper(`${API_BASE_URL}/api/health/version`)
},
}

export interface VersionInfo {
currentVersion: string | null
latestVersion: string | null
updateAvailable: boolean
releaseUrl: string | null
releaseName: string | null
}
26 changes: 26 additions & 0 deletions frontend/src/components/VersionNotifier.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useEffect, useRef } from 'react'
import { showToast } from '@/lib/toast'
import { useVersionCheck } from '@/hooks/useVersionCheck'

export function VersionNotifier() {
const { data, isSuccess } = useVersionCheck()
const hasNotifiedRef = useRef(false)

useEffect(() => {
if (!isSuccess || !data || hasNotifiedRef.current) return

if (data.updateAvailable && data.latestVersion && data.releaseUrl) {
hasNotifiedRef.current = true
showToast.info(`OpenCode Manager v${data.latestVersion} is available`, {
description: 'A new version is ready to install.',
action: {
label: 'View Release',
onClick: () => window.open(data.releaseUrl ?? '', '_blank'),
},
duration: 10000,
})
}
}, [isSuccess, data])

return null
}
1 change: 1 addition & 0 deletions frontend/src/components/repo/AddRepoDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['repos'] })
queryClient.invalidateQueries({ queryKey: ['reposGitStatus'] })
setRepoUrl('')
setLocalPath('')
setBranch('')
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/components/repo/RepoList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,15 @@ export function RepoList() {
queryKey: ["reposGitStatus", repoIds],
queryFn: () => fetchReposGitStatus(repoIds),
enabled: repoIds.length > 0,
staleTime: 60 * 60 * 1000,
gcTime: 60 * 60 * 1000,
})

const deleteMutation = useMutation({
mutationFn: deleteRepo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["repos"] })
queryClient.invalidateQueries({ queryKey: ["reposGitStatus"] })
setDeleteDialogOpen(false)
setRepoToDelete(null)
},
Expand All @@ -128,6 +131,7 @@ export function RepoList() {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["repos"] })
queryClient.invalidateQueries({ queryKey: ["reposGitStatus"] })
setDeleteDialogOpen(false)
setSelectedRepos(new Set())
},
Expand Down Expand Up @@ -155,6 +159,7 @@ export function RepoList() {
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["repos"] })
queryClient.invalidateQueries({ queryKey: ["reposGitStatus"] })
},
})

Expand Down
Loading