Skip to content
Open
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
129 changes: 14 additions & 115 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@ import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { serveStatic } from '@hono/node-server/serve-static'
import os from 'os'
import path from 'path'
import { cp, readdir, readFile, rm } from 'fs/promises'
import { Database as SQLiteDatabase } from 'bun:sqlite'
import { readFile } from 'fs/promises'
import { initializeDatabase } from './db/schema'
import { createRepoRoutes } from './routes/repos'
import { createIPCServer, type IPCServer } from './ipc/ipcServer'
Expand Down Expand Up @@ -47,6 +44,7 @@ import { proxyRequest, proxyMcpAuthStart, proxyMcpAuthAuthenticate } from './ser
import { NotificationService } from './services/notification'
import { ScheduleRunner, ScheduleService } from './services/schedules'
import { migrateGlobalSkills } from './services/skills'
import { getOpenCodeImportStatus, syncOpenCodeImport } from './services/opencode-import'

import { logger } from './utils/logger'
import {
Expand All @@ -58,8 +56,7 @@ import {
getDatabasePath,
ENV
} from '@opencode-manager/shared/config/env'
import { OpenCodeConfigSchema } from '@opencode-manager/shared/schemas'
import { parse as parseJsonc } from 'jsonc-parser'


const { PORT, HOST } = ENV.SERVER
const DB_PATH = getDatabasePath()
Expand All @@ -86,72 +83,6 @@ import { DEFAULT_AGENTS_MD } from './constants'

let ipcServer: IPCServer | undefined
const gitAuthService = new GitAuthService()
const OPENCODE_STATE_DB_FILENAMES = new Set(['opencode.db', 'opencode.db-shm', 'opencode.db-wal'])

function getImportPathCandidates(envKey: string, fallbackPath: string): string[] {
const candidates = [process.env[envKey], fallbackPath]
.filter((value): value is string => Boolean(value))
.map((value) => path.resolve(value))

return Array.from(new Set(candidates))
}

async function getFirstExistingPath(paths: string[]): Promise<string | undefined> {
for (const candidate of paths) {
if (await fileExists(candidate)) {
return candidate
}
}

return undefined
}

function escapeSqliteValue(value: string): string {
return value.replace(/'/g, "''")
}

async function copyOpenCodeStateFiles(sourcePath: string, targetPath: string): Promise<void> {
const entries = await readdir(sourcePath, { withFileTypes: true })

for (const entry of entries) {
if (OPENCODE_STATE_DB_FILENAMES.has(entry.name)) {
continue
}

await cp(path.join(sourcePath, entry.name), path.join(targetPath, entry.name), {
recursive: true,
force: false,
errorOnExist: false,
})
}
}

async function snapshotOpenCodeDatabase(sourcePath: string, targetPath: string): Promise<void> {
await rm(targetPath, { force: true })

const database = new SQLiteDatabase(sourcePath)

try {
database.exec(`VACUUM INTO '${escapeSqliteValue(targetPath)}'`)
} finally {
database.close()
}
}

async function importOpenCodeStateDirectory(sourcePath: string, targetPath: string): Promise<void> {
await ensureDirectoryExists(targetPath)
await copyOpenCodeStateFiles(sourcePath, targetPath)

const sourceDbPath = path.join(sourcePath, 'opencode.db')
if (!await fileExists(sourceDbPath)) {
return
}

await rm(path.join(targetPath, 'opencode.db-shm'), { force: true })
await rm(path.join(targetPath, 'opencode.db-wal'), { force: true })
await snapshotOpenCodeDatabase(sourceDbPath, path.join(targetPath, 'opencode.db'))
}

async function ensureDefaultConfigExists(): Promise<void> {
const settingsService = new SettingsService(db)
const workspaceConfigPath = getOpenCodeConfigFilePath()
Expand Down Expand Up @@ -188,36 +119,13 @@ async function ensureDefaultConfigExists(): Promise<void> {
}
}

const importConfigPath = await getFirstExistingPath(
getImportPathCandidates(
'OPENCODE_IMPORT_CONFIG_PATH',
path.join(os.homedir(), '.config/opencode/opencode.json')
)
)
const { configSourcePath: importConfigPath } = await getOpenCodeImportStatus()

if (importConfigPath) {
logger.info(`Found importable OpenCode config at ${importConfigPath}, importing...`)
try {
const rawContent = await readFileContent(importConfigPath)
const parsed = parseJsonc(rawContent)
const validation = OpenCodeConfigSchema.safeParse(parsed)

if (validation.success) {
const existingDefault = settingsService.getOpenCodeConfigByName('default')
if (existingDefault) {
settingsService.updateOpenCodeConfig('default', {
content: rawContent,
isDefault: true,
})
} else {
settingsService.createOpenCodeConfig({
name: 'default',
content: rawContent,
isDefault: true,
})
}

await writeFileContent(workspaceConfigPath, rawContent)
const result = await syncOpenCodeImport({ db, overwriteState: false })
if (result.configImported) {
logger.info(`Imported OpenCode config from ${importConfigPath} to workspace`)
return
}
Expand Down Expand Up @@ -249,28 +157,19 @@ async function ensureDefaultConfigExists(): Promise<void> {

async function ensureHomeStateImported(): Promise<void> {
try {
const workspaceStateRoot = path.join(getWorkspacePath(), '.opencode', 'state')
const workspaceStatePath = path.join(workspaceStateRoot, 'opencode')
const workspaceStateDbPath = path.join(workspaceStatePath, 'opencode.db')

if (await fileExists(workspaceStateDbPath)) {
const status = await getOpenCodeImportStatus()
if (status.workspaceStateExists) {
return
}

const importStatePath = await getFirstExistingPath(
getImportPathCandidates(
'OPENCODE_IMPORT_STATE_PATH',
path.join(os.homedir(), '.local', 'share', 'opencode')
)
)

if (!importStatePath) {
if (!status.stateSourcePath) {
return
}

await ensureDirectoryExists(workspaceStateRoot)
await importOpenCodeStateDirectory(importStatePath, workspaceStatePath)
logger.info(`Imported OpenCode state from ${importStatePath}`)
const result = await syncOpenCodeImport({ db, overwriteState: false })
if (result.stateImported) {
logger.info(`Imported OpenCode state from ${status.stateSourcePath}`)
}
} catch (error) {
logger.warn('Failed to import OpenCode state, continuing without imported state', error)
}
Expand Down Expand Up @@ -359,7 +258,7 @@ const protectedApi = new Hono()
protectedApi.use('/*', requireAuth)

protectedApi.route('/repos', createRepoRoutes(db, gitAuthService, scheduleService))
protectedApi.route('/settings', createSettingsRoutes(db))
protectedApi.route('/settings', createSettingsRoutes(db, gitAuthService))
protectedApi.route('/files', createFileRoutes())
protectedApi.route('/providers', createProvidersRoutes())
protectedApi.route('/oauth', createOAuthRoutes())
Expand Down
64 changes: 63 additions & 1 deletion backend/src/routes/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@ import {
} from '@opencode-manager/shared'
import { logger } from '../utils/logger'
import { opencodeServerManager } from '../services/opencode-single-server'
import type { GitAuthService } from '../services/git-auth'
import { DEFAULT_AGENTS_MD } from '../constants'
import { validateSSHPrivateKey } from '../utils/ssh-validation'
import { encryptSecret } from '../utils/crypto'
import { compareVersions, isValidVersion } from '../utils/version-utils'
import { getImportedSessionDirectories, getOpenCodeImportStatus, syncOpenCodeImport } from '../services/opencode-import'
import { relinkReposFromSessionDirectories } from '../services/repo'
import {
listManagedSkills,
getSkill,
Expand Down Expand Up @@ -153,6 +156,10 @@ const TestSSHConnectionSchema = z.object({
passphrase: z.string().optional(),
})

const SyncOpenCodeImportSchema = z.object({
overwriteState: z.boolean().optional(),
})


async function extractOpenCodeError(response: Response, defaultError: string): Promise<string> {
const errorObj = await response.json().catch(() => null)
Expand All @@ -161,7 +168,7 @@ async function extractOpenCodeError(response: Response, defaultError: string): P
: defaultError
}

export function createSettingsRoutes(db: Database) {
export function createSettingsRoutes(db: Database, gitAuthService: GitAuthService) {
const app = new Hono()
const settingsService = new SettingsService(db)

Expand Down Expand Up @@ -432,6 +439,61 @@ export function createSettingsRoutes(db: Database) {
}
})

app.get('/opencode-import/status', async (c) => {
try {
return c.json(await getOpenCodeImportStatus())
} catch (error) {
logger.error('Failed to get OpenCode import status:', error)
return c.json({
error: 'Failed to get OpenCode import status',
details: error instanceof Error ? error.message : 'Unknown error'
}, 500)
}
})

app.post('/opencode-import', async (c) => {
try {
const userId = c.req.query('userId') || 'default'
const rawBody = c.req.header('content-type')?.includes('application/json') ? await c.req.json() : {}
const body = SyncOpenCodeImportSchema.parse(rawBody)
const result = await syncOpenCodeImport({
db,
userId,
overwriteState: body.overwriteState ?? true,
})

if (!result.configImported && !result.stateImported) {
return c.json({
error: 'No importable OpenCode host data found',
...result,
}, 404)
}

const importedSessions = await getImportedSessionDirectories(result.workspaceStatePath)
const relinkedRepos = await relinkReposFromSessionDirectories(db, gitAuthService, importedSessions.directories)

opencodeServerManager.clearStartupError()
await opencodeServerManager.restart()

return c.json({
success: true,
message: 'Imported existing OpenCode host data and restarted the server',
serverRestarted: true,
relinkedRepos,
...result,
})
} catch (error) {
logger.error('Failed to import existing OpenCode host data:', error)
if (error instanceof z.ZodError) {
return c.json({ error: 'Invalid OpenCode import request', details: error.issues }, 400)
}
return c.json({
error: 'Failed to import existing OpenCode host data',
details: error instanceof Error ? error.message : 'Unknown error'
}, 500)
}
})

app.post('/opencode-reload', async (c) => {
try {
logger.info('OpenCode configuration reload requested')
Expand Down
Loading