diff --git a/backend/src/index.ts b/backend/src/index.ts index b524d9e8..e1a79dca 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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' @@ -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 { + 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' @@ -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()) @@ -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', diff --git a/backend/src/ipc/sshHostKeyHandler.ts b/backend/src/ipc/sshHostKeyHandler.ts index b2461cf2..3a0caa3b 100644 --- a/backend/src/ipc/sshHostKeyHandler.ts +++ b/backend/src/ipc/sshHostKeyHandler.ts @@ -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}`) } diff --git a/backend/src/routes/health.ts b/backend/src/routes/health.ts index 01f48d65..4750314a 100644 --- a/backend/src/routes/health.ts +++ b/backend/src/routes/health.ts @@ -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 { + 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 => { + 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() @@ -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) { @@ -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) } @@ -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 } diff --git a/backend/src/routes/repo-git.ts b/backend/src/routes/repo-git.ts index 94fba2aa..490f8ed2 100644 --- a/backend/src/routes/repo-git.ts +++ b/backend/src/routes/repo-git.ts @@ -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 = {} for (const entry of statuses) { @@ -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) @@ -371,4 +380,4 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS }) return app -} \ No newline at end of file +} diff --git a/backend/src/services/git-auth.ts b/backend/src/services/git-auth.ts index 2510a550..b642c222 100644 --- a/backend/src/services/git-auth.ts +++ b/backend/src/services/git-auth.ts @@ -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 } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b85936b6..e0ba81bb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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' @@ -82,6 +83,7 @@ function AppShell() { + => { + 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 } diff --git a/frontend/src/components/VersionNotifier.tsx b/frontend/src/components/VersionNotifier.tsx new file mode 100644 index 00000000..e8b98bdb --- /dev/null +++ b/frontend/src/components/VersionNotifier.tsx @@ -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 +} diff --git a/frontend/src/components/repo/AddRepoDialog.tsx b/frontend/src/components/repo/AddRepoDialog.tsx index a7bfb0c5..64b4cc63 100644 --- a/frontend/src/components/repo/AddRepoDialog.tsx +++ b/frontend/src/components/repo/AddRepoDialog.tsx @@ -35,6 +35,7 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['repos'] }) + queryClient.invalidateQueries({ queryKey: ['reposGitStatus'] }) setRepoUrl('') setLocalPath('') setBranch('') diff --git a/frontend/src/components/repo/RepoList.tsx b/frontend/src/components/repo/RepoList.tsx index a5a9105c..30a09de3 100644 --- a/frontend/src/components/repo/RepoList.tsx +++ b/frontend/src/components/repo/RepoList.tsx @@ -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) }, @@ -128,6 +131,7 @@ export function RepoList() { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["repos"] }) + queryClient.invalidateQueries({ queryKey: ["reposGitStatus"] }) setDeleteDialogOpen(false) setSelectedRepos(new Set()) }, @@ -155,6 +159,7 @@ export function RepoList() { }, onSettled: () => { queryClient.invalidateQueries({ queryKey: ["repos"] }) + queryClient.invalidateQueries({ queryKey: ["reposGitStatus"] }) }, }) diff --git a/frontend/src/components/settings/GeneralSettings.tsx b/frontend/src/components/settings/GeneralSettings.tsx index db35000e..b3ad0c78 100644 --- a/frontend/src/components/settings/GeneralSettings.tsx +++ b/frontend/src/components/settings/GeneralSettings.tsx @@ -1,4 +1,5 @@ import { useSettings } from '@/hooks/useSettings' +import { useVersionCheck } from '@/hooks/useVersionCheck' import { Loader2 } from 'lucide-react' import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' @@ -6,6 +7,7 @@ import { Switch } from '@/components/ui/switch' export function GeneralSettings() { const { preferences, isLoading, updateSettings, isUpdating } = useSettings() + const { data: versionInfo, isLoading: isVersionLoading } = useVersionCheck() if (isLoading) { return ( @@ -20,6 +22,31 @@ export function GeneralSettings() {

General Preferences

+
+ OpenCode Manager + {isVersionLoading ? ( + + ) : versionInfo?.currentVersion ? ( + <> + + {versionInfo.currentVersion} + + {versionInfo.updateAvailable && versionInfo.latestVersion && ( + + v{versionInfo.latestVersion} available + + )} + + ) : ( + unknown + )} +
+