From 5647b844b6d5330b736cadbb108a8715ba963ece Mon Sep 17 00:00:00 2001 From: itz4blitz Date: Wed, 1 Apr 2026 16:38:38 -0400 Subject: [PATCH 1/2] feat: add explicit OpenCode host import and repo relink --- backend/src/index.ts | 129 ++---------- backend/src/routes/settings.ts | 64 +++++- backend/src/services/opencode-import.ts | 194 ++++++++++++++++++ backend/src/services/repo.ts | 69 +++++++ backend/test/routes/settings.test.ts | 117 ++++++++++- backend/test/services/opencode-import.test.ts | 138 +++++++++++++ backend/test/services/repo.test.ts | 71 +++++++ frontend/src/api/settings.ts | 14 ++ frontend/src/api/types/settings.ts | 23 +++ .../settings/OpenCodeConfigManager.tsx | 105 +++++++++- 10 files changed, 805 insertions(+), 119 deletions(-) create mode 100644 backend/src/services/opencode-import.ts create mode 100644 backend/test/services/opencode-import.test.ts diff --git a/backend/src/index.ts b/backend/src/index.ts index 8cdd399d..7dbec446 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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' @@ -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 { @@ -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() @@ -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 { - 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 { - 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 { - 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 { - 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 { const settingsService = new SettingsService(db) const workspaceConfigPath = getOpenCodeConfigFilePath() @@ -188,36 +119,13 @@ async function ensureDefaultConfigExists(): Promise { } } - 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 } @@ -249,28 +157,19 @@ async function ensureDefaultConfigExists(): Promise { async function ensureHomeStateImported(): Promise { 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) } @@ -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()) diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index fffc4b4d..f5f2feb1 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -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, @@ -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 { const errorObj = await response.json().catch(() => null) @@ -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) @@ -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') diff --git a/backend/src/services/opencode-import.ts b/backend/src/services/opencode-import.ts new file mode 100644 index 00000000..55ac105b --- /dev/null +++ b/backend/src/services/opencode-import.ts @@ -0,0 +1,194 @@ +import os from 'os' +import path from 'path' +import { cp, readdir, rm } from 'fs/promises' +import { Database as SQLiteDatabase, type Database } from 'bun:sqlite' +import { OpenCodeConfigSchema } from '@opencode-manager/shared/schemas' +import { getOpenCodeConfigFilePath, getWorkspacePath } from '@opencode-manager/shared/config/env' +import { parse as parseJsonc } from 'jsonc-parser' +import { SettingsService } from './settings' +import { ensureDirectoryExists, fileExists, readFileContent, writeFileContent } from './file-operations' + +const OPENCODE_STATE_DB_FILENAMES = new Set(['opencode.db', 'opencode.db-shm', 'opencode.db-wal']) + +export interface OpenCodeImportStatus { + configSourcePath: string | null + stateSourcePath: string | null + workspaceConfigPath: string + workspaceStatePath: string + workspaceStateExists: boolean +} + +export interface SyncOpenCodeImportOptions { + db: Database + userId?: string + overwriteState?: boolean +} + +export interface SyncOpenCodeImportResult extends OpenCodeImportStatus { + configImported: boolean + stateImported: boolean +} + +export interface ImportedSessionDirectorySummary { + directories: string[] +} + +export 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)) +} + +export async function getFirstExistingPath(paths: string[]): Promise { + for (const candidate of paths) { + if (await fileExists(candidate)) { + return candidate + } + } + + return null +} + +function escapeSqliteValue(value: string): string { + return value.replace(/'/g, "''") +} + +async function copyOpenCodeStateFiles(sourcePath: string, targetPath: string): Promise { + 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: true, + errorOnExist: false, + }) + } +} + +function snapshotOpenCodeDatabase(sourcePath: string, targetPath: string): void { + const database = new SQLiteDatabase(sourcePath) + + try { + database.exec(`VACUUM INTO '${escapeSqliteValue(targetPath)}'`) + } finally { + database.close() + } +} + +export async function importOpenCodeStateDirectory(sourcePath: string, targetPath: string): Promise { + 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'), { force: true }) + await rm(path.join(targetPath, 'opencode.db-shm'), { force: true }) + await rm(path.join(targetPath, 'opencode.db-wal'), { force: true }) + snapshotOpenCodeDatabase(sourceDbPath, path.join(targetPath, 'opencode.db')) +} + +export async function getOpenCodeImportStatus(): Promise { + const workspaceConfigPath = getOpenCodeConfigFilePath() + const workspaceStatePath = path.join(getWorkspacePath(), '.opencode', 'state', 'opencode') + const workspaceStateExists = await fileExists(path.join(workspaceStatePath, 'opencode.db')) + + const configSourcePath = await getFirstExistingPath( + getImportPathCandidates('OPENCODE_IMPORT_CONFIG_PATH', path.join(os.homedir(), '.config', 'opencode', 'opencode.json')) + ) + const stateSourcePath = await getFirstExistingPath( + getImportPathCandidates('OPENCODE_IMPORT_STATE_PATH', path.join(os.homedir(), '.local', 'share', 'opencode')) + ) + + return { + configSourcePath, + stateSourcePath, + workspaceConfigPath, + workspaceStatePath, + workspaceStateExists, + } +} + +async function importOpenCodeConfigFromSource(db: Database, userId: string, sourcePath: string, workspaceConfigPath: string): Promise { + const rawContent = await readFileContent(sourcePath) + const parsed = parseJsonc(rawContent) + const validation = OpenCodeConfigSchema.safeParse(parsed) + + if (!validation.success) { + throw new Error('Importable OpenCode config is invalid') + } + + const settingsService = new SettingsService(db) + const existingDefault = settingsService.getOpenCodeConfigByName('default', userId) + + if (existingDefault) { + settingsService.updateOpenCodeConfig('default', { + content: rawContent, + isDefault: true, + }, userId) + } else { + settingsService.createOpenCodeConfig({ + name: 'default', + content: rawContent, + isDefault: true, + }, userId) + } + + await writeFileContent(workspaceConfigPath, rawContent) + return true +} + +export async function syncOpenCodeImport(options: SyncOpenCodeImportOptions): Promise { + const status = await getOpenCodeImportStatus() + const userId = options.userId || 'default' + let configImported = false + let stateImported = false + + if (status.configSourcePath) { + configImported = await importOpenCodeConfigFromSource(options.db, userId, status.configSourcePath, status.workspaceConfigPath) + } + + if (status.stateSourcePath && ((options.overwriteState ?? true) || !status.workspaceStateExists)) { + await importOpenCodeStateDirectory(status.stateSourcePath, status.workspaceStatePath) + stateImported = true + } + + return { + ...status, + configImported, + stateImported, + } +} + +export async function getImportedSessionDirectories(workspaceStatePath?: string): Promise { + const statePath = workspaceStatePath || path.join(getWorkspacePath(), '.opencode', 'state', 'opencode') + const stateDbPath = path.join(statePath, 'opencode.db') + + if (!await fileExists(stateDbPath)) { + return { directories: [] } + } + + const database = new SQLiteDatabase(stateDbPath, { readonly: true }) + + try { + const rows = database + .query("SELECT DISTINCT directory FROM session WHERE directory IS NOT NULL AND TRIM(directory) != '' ORDER BY directory") + .all() as Array<{ directory: string }> + + return { + directories: rows + .map((row) => row.directory.trim()) + .filter(Boolean), + } + } finally { + database.close() + } +} diff --git a/backend/src/services/repo.ts b/backend/src/services/repo.ts index 86343d4c..1e67e1eb 100644 --- a/backend/src/services/repo.ts +++ b/backend/src/services/repo.ts @@ -230,6 +230,16 @@ async function safeGetCurrentBranch(repoPath: string, env: Record): Promise { + try { + const resolvedPath = normalizeAbsolutePath(targetPath) + const repoRoot = await executeCommand(['git', '-C', resolvedPath, 'rev-parse', '--show-toplevel'], { env, silent: true }) + return normalizeAbsolutePath(repoRoot.trim()) + } catch { + return null + } +} + async function registerExistingLocalRepo( database: Database, gitAuthService: GitAuthService, @@ -375,6 +385,65 @@ export async function discoverLocalRepos( } } +export async function relinkReposFromSessionDirectories( + database: Database, + gitAuthService: GitAuthService, + directories: string[] +): Promise<{ + repos: Repo[] + relinkedCount: number + existingCount: number + skippedCount: number + errors: Array<{ path: string; error: string }> +}> { + const env = gitAuthService.getGitEnvironment() + const errors: Array<{ path: string; error: string }> = [] + const uniqueRepoRoots = new Set() + + for (const directory of directories) { + const normalizedDirectory = normalizeInputPath(directory) + if (!normalizedDirectory) { + continue + } + + const repoRoot = await findGitRepoRoot(normalizedDirectory, env) + if (!repoRoot) { + continue + } + + uniqueRepoRoots.add(repoRoot) + } + + const repos: Repo[] = [] + let relinkedCount = 0 + let existingCount = 0 + + for (const repoRoot of Array.from(uniqueRepoRoots).sort((left, right) => left.localeCompare(right))) { + try { + const result = await registerExistingLocalRepo(database, gitAuthService, repoRoot) + repos.push(result.repo) + if (result.existed) { + existingCount += 1 + } else { + relinkedCount += 1 + } + } catch (error: unknown) { + errors.push({ + path: repoRoot, + error: getErrorMessage(error), + }) + } + } + + return { + repos, + relinkedCount, + existingCount, + skippedCount: Math.max(0, directories.length - uniqueRepoRoots.size), + errors, + } +} + async function checkoutBranchSafely(repoPath: string, branch: string, env: Record): Promise { const sanitizedBranch = branch .replace(/^refs\/heads\//, '') diff --git a/backend/test/routes/settings.test.ts b/backend/test/routes/settings.test.ts index 4a0b70b2..4592183c 100644 --- a/backend/test/routes/settings.test.ts +++ b/backend/test/routes/settings.test.ts @@ -65,6 +65,16 @@ vi.mock('../../src/services/opencode-single-server', () => ({ }, })) +vi.mock('../../src/services/opencode-import', () => ({ + getOpenCodeImportStatus: vi.fn(), + syncOpenCodeImport: vi.fn(), + getImportedSessionDirectories: vi.fn(), +})) + +vi.mock('../../src/services/repo', () => ({ + relinkReposFromSessionDirectories: vi.fn(), +})) + vi.mock('@opencode-manager/shared/config/env', () => ({ getWorkspacePath: vi.fn(() => '/tmp/test-workspace'), getReposPath: vi.fn(() => '/tmp/test-repos'), @@ -90,6 +100,8 @@ vi.mock('@opencode-manager/shared/config/env', () => ({ })) import { createSettingsRoutes } from '../../src/routes/settings' +import { getImportedSessionDirectories, getOpenCodeImportStatus, syncOpenCodeImport } from '../../src/services/opencode-import' +import { relinkReposFromSessionDirectories } from '../../src/services/repo' import { opencodeServerManager } from '../../src/services/opencode-single-server' const mockExecSync = execSync as ReturnType @@ -99,6 +111,10 @@ const mockFetchVersion = opencodeServerManager.fetchVersion as ReturnType const mockRestart = opencodeServerManager.restart as ReturnType const mockClearStartupError = opencodeServerManager.clearStartupError as ReturnType +const mockGetOpenCodeImportStatus = getOpenCodeImportStatus as ReturnType +const mockSyncOpenCodeImport = syncOpenCodeImport as ReturnType +const mockGetImportedSessionDirectories = getImportedSessionDirectories as ReturnType +const mockRelinkReposFromSessionDirectories = relinkReposFromSessionDirectories as ReturnType describe('Settings Routes - OpenCode Upgrade', () => { let settingsApp: ReturnType @@ -112,13 +128,112 @@ describe('Settings Routes - OpenCode Upgrade', () => { mockReloadConfig.mockReset() mockRestart.mockReset() mockClearStartupError.mockReset() + mockGetOpenCodeImportStatus.mockReset() + mockSyncOpenCodeImport.mockReset() + mockGetImportedSessionDirectories.mockReset() + mockRelinkReposFromSessionDirectories.mockReset() testDb = {} as any - settingsApp = createSettingsRoutes(testDb) + settingsApp = createSettingsRoutes(testDb, { getGitEnvironment: vi.fn().mockReturnValue({}) } as any) mockReloadConfig.mockResolvedValue(undefined) mockRestart.mockResolvedValue(undefined) mockClearStartupError.mockReturnValue(undefined) + mockGetOpenCodeImportStatus.mockResolvedValue({ + configSourcePath: null, + stateSourcePath: null, + workspaceConfigPath: '/tmp/test-workspace/.config/opencode/opencode.json', + workspaceStatePath: '/tmp/test-workspace/.opencode/state/opencode', + workspaceStateExists: false, + }) + mockGetImportedSessionDirectories.mockResolvedValue({ + directories: ['/Users/test/project-a', '/Users/test/project-b/apps/web'], + }) + mockRelinkReposFromSessionDirectories.mockResolvedValue({ + repos: [], + relinkedCount: 0, + existingCount: 0, + skippedCount: 0, + errors: [], + }) + }) + + describe('OpenCode import routes', () => { + it('should return import status', async () => { + mockGetOpenCodeImportStatus.mockResolvedValueOnce({ + configSourcePath: '/import/opencode-config/opencode.json', + stateSourcePath: '/import/opencode-state', + workspaceConfigPath: '/tmp/test-workspace/.config/opencode/opencode.json', + workspaceStatePath: '/tmp/test-workspace/.opencode/state/opencode', + workspaceStateExists: true, + }) + + const req = new Request('http://localhost/opencode-import/status') + const res = await settingsApp.fetch(req) + const json = await res.json() as Record + + expect(res.status).toBe(200) + expect(json.configSourcePath).toBe('/import/opencode-config/opencode.json') + expect(json.stateSourcePath).toBe('/import/opencode-state') + expect(mockGetOpenCodeImportStatus).toHaveBeenCalled() + }) + + it('should import host OpenCode data and restart the server', async () => { + mockSyncOpenCodeImport.mockResolvedValueOnce({ + configSourcePath: '/import/opencode-config/opencode.json', + stateSourcePath: '/import/opencode-state', + workspaceConfigPath: '/tmp/test-workspace/.config/opencode/opencode.json', + workspaceStatePath: '/tmp/test-workspace/.opencode/state/opencode', + workspaceStateExists: true, + configImported: true, + stateImported: true, + }) + + const req = new Request('http://localhost/opencode-import?userId=default', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ overwriteState: true }), + }) + const res = await settingsApp.fetch(req) + const json = await res.json() as Record + + expect(res.status).toBe(200) + expect(json.success).toBe(true) + expect(json.serverRestarted).toBe(true) + expect(mockSyncOpenCodeImport).toHaveBeenCalledWith({ + db: testDb, + userId: 'default', + overwriteState: true, + }) + expect(mockGetImportedSessionDirectories).toHaveBeenCalledWith('/tmp/test-workspace/.opencode/state/opencode') + expect(mockRelinkReposFromSessionDirectories).toHaveBeenCalled() + expect(mockClearStartupError).toHaveBeenCalled() + expect(mockRestart).toHaveBeenCalled() + }) + + it('should return 404 when no importable host data exists', async () => { + mockSyncOpenCodeImport.mockResolvedValueOnce({ + configSourcePath: null, + stateSourcePath: null, + workspaceConfigPath: '/tmp/test-workspace/.config/opencode/opencode.json', + workspaceStatePath: '/tmp/test-workspace/.opencode/state/opencode', + workspaceStateExists: true, + configImported: false, + stateImported: false, + }) + + const req = new Request('http://localhost/opencode-import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ overwriteState: true }), + }) + const res = await settingsApp.fetch(req) + const json = await res.json() as Record + + expect(res.status).toBe(404) + expect(json.error).toBe('No importable OpenCode host data found') + expect(mockRestart).not.toHaveBeenCalled() + }) }) describe('POST /opencode-upgrade', () => { diff --git a/backend/test/services/opencode-import.test.ts b/backend/test/services/opencode-import.test.ts new file mode 100644 index 00000000..ddc474fa --- /dev/null +++ b/backend/test/services/opencode-import.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('fs/promises', () => ({ + cp: vi.fn(), + readdir: vi.fn(), + rm: vi.fn(), +})) + +vi.mock('bun:sqlite', () => ({ + Database: vi.fn().mockImplementation(() => ({ + exec: vi.fn(), + close: vi.fn(), + })), +})) + +vi.mock('../../src/services/file-operations', () => ({ + ensureDirectoryExists: vi.fn(), + fileExists: vi.fn(), + readFileContent: vi.fn(), + writeFileContent: vi.fn(), +})) + +vi.mock('../../src/services/settings', () => ({ + SettingsService: vi.fn(), +})) + +vi.mock('@opencode-manager/shared/config/env', () => ({ + getOpenCodeConfigFilePath: vi.fn(() => '/tmp/workspace/.config/opencode/opencode.json'), + getWorkspacePath: vi.fn(() => '/tmp/workspace'), +})) + +import { readdir } from 'fs/promises' +import { Database as SQLiteDatabase } from 'bun:sqlite' +import { ensureDirectoryExists, fileExists, readFileContent, writeFileContent } from '../../src/services/file-operations' +import { SettingsService } from '../../src/services/settings' +import { getOpenCodeImportStatus, syncOpenCodeImport } from '../../src/services/opencode-import' + +const mockReaddir = readdir as unknown as ReturnType +const mockFileExists = fileExists as ReturnType +const mockReadFileContent = readFileContent as ReturnType +const mockWriteFileContent = writeFileContent as ReturnType +const mockEnsureDirectoryExists = ensureDirectoryExists as ReturnType +const MockSettingsService = SettingsService as unknown as ReturnType +const MockSQLiteDatabase = SQLiteDatabase as unknown as ReturnType + +describe('opencode-import service', () => { + const mockDb = {} as any + const settingsService = { + getOpenCodeConfigByName: vi.fn(), + updateOpenCodeConfig: vi.fn(), + createOpenCodeConfig: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + MockSettingsService.mockImplementation(() => settingsService) + mockReadFileContent.mockResolvedValue('{"$schema":"https://opencode.ai/config.json"}') + mockReaddir.mockResolvedValue([]) + }) + + it('detects importable host config and state paths', async () => { + process.env.OPENCODE_IMPORT_CONFIG_PATH = '/import/opencode-config/opencode.json' + process.env.OPENCODE_IMPORT_STATE_PATH = '/import/opencode-state' + + mockFileExists.mockImplementation(async (candidate: string) => { + return candidate === '/import/opencode-config/opencode.json' + || candidate === '/import/opencode-state' + || candidate === '/tmp/workspace/.opencode/state/opencode/opencode.db' + }) + + const status = await getOpenCodeImportStatus() + + expect(status).toEqual({ + configSourcePath: '/import/opencode-config/opencode.json', + stateSourcePath: '/import/opencode-state', + workspaceConfigPath: '/tmp/workspace/.config/opencode/opencode.json', + workspaceStatePath: '/tmp/workspace/.opencode/state/opencode', + workspaceStateExists: true, + }) + }) + + it('imports host config and state into the workspace', async () => { + process.env.OPENCODE_IMPORT_CONFIG_PATH = '/import/opencode-config/opencode.json' + process.env.OPENCODE_IMPORT_STATE_PATH = '/import/opencode-state' + + mockFileExists.mockImplementation(async (candidate: string) => { + return candidate === '/import/opencode-config/opencode.json' + || candidate === '/import/opencode-state' + || candidate === '/import/opencode-state/opencode.db' + }) + + settingsService.getOpenCodeConfigByName.mockReturnValue({ name: 'default' }) + + const result = await syncOpenCodeImport({ + db: mockDb, + userId: 'default', + overwriteState: true, + }) + + expect(result.configImported).toBe(true) + expect(result.stateImported).toBe(true) + expect(settingsService.updateOpenCodeConfig).toHaveBeenCalledWith('default', { + content: '{"$schema":"https://opencode.ai/config.json"}', + isDefault: true, + }, 'default') + expect(mockWriteFileContent).toHaveBeenCalledWith( + '/tmp/workspace/.config/opencode/opencode.json', + '{"$schema":"https://opencode.ai/config.json"}' + ) + expect(mockEnsureDirectoryExists).toHaveBeenCalledWith('/tmp/workspace/.opencode/state/opencode') + expect(MockSQLiteDatabase).toHaveBeenCalledWith('/import/opencode-state/opencode.db') + }) + + it('reads distinct session directories from imported workspace state', async () => { + mockFileExists.mockImplementation(async (candidate: string) => candidate === '/tmp/workspace/.opencode/state/opencode/opencode.db') + + const readonlyDatabase = { + query: vi.fn().mockReturnValue({ + all: vi.fn().mockReturnValue([ + { directory: '/Users/test/project-a' }, + { directory: ' /Users/test/project-b/apps/web ' }, + ]), + }), + close: vi.fn(), + } + + MockSQLiteDatabase.mockImplementationOnce(() => readonlyDatabase) + + const { getImportedSessionDirectories } = await import('../../src/services/opencode-import') + const result = await getImportedSessionDirectories('/tmp/workspace/.opencode/state/opencode') + + expect(result.directories).toEqual([ + '/Users/test/project-a', + '/Users/test/project-b/apps/web', + ]) + expect(readonlyDatabase.close).toHaveBeenCalled() + }) +}) diff --git a/backend/test/services/repo.test.ts b/backend/test/services/repo.test.ts index 89a17177..897bba5f 100644 --- a/backend/test/services/repo.test.ts +++ b/backend/test/services/repo.test.ts @@ -347,4 +347,75 @@ describe('repo service', () => { }, ]) }) + + it('relinks imported session directories to nearest git repo roots', async () => { + const { relinkReposFromSessionDirectories } = await import('../../src/services/repo') + const database = {} as never + const repoRoot = '/Users/test/projects/app-one' + const aliasPath = path.join(getReposPath(), 'app-one') + + getRepoByLocalPath.mockReturnValue(null) + getRepoBySourcePath.mockReturnValue(null) + createRepo.mockImplementation((_, input) => ({ + id: 5, + localPath: input.localPath, + sourcePath: input.sourcePath, + fullPath: input.sourcePath ?? path.join(getReposPath(), input.localPath), + branch: input.branch, + defaultBranch: input.defaultBranch, + cloneStatus: input.cloneStatus, + clonedAt: input.clonedAt, + isLocal: true, + isWorktree: input.isWorktree, + })) + + lstat.mockImplementation(async (targetPath: string) => { + if (targetPath === repoRoot || targetPath === path.join(repoRoot, '.git')) { + return createDirectoryStat() + } + + if (targetPath === aliasPath) { + throw createEnoentError(targetPath) + } + + throw createEnoentError(targetPath) + }) + + executeCommand.mockImplementation(async (args: string[]) => { + if (args.includes('--show-toplevel')) { + if (args[2] === '/Users/test/projects/not-a-repo') { + throw new Error('not a git repository') + } + return `${repoRoot}\n` + } + + if (args.includes('--git-dir')) { + return '.git' + } + + if (args.includes('HEAD') && !args.includes('--abbrev-ref')) { + return 'abc123' + } + + if (args.includes('--abbrev-ref')) { + return 'main' + } + + return '' + }) + + const result = await relinkReposFromSessionDirectories(database, mockGitAuthService, [ + '/Users/test/projects/app-one/apps/web', + '/Users/test/projects/app-one/packages/api', + '/Users/test/projects/not-a-repo', + ]) + + expect(result.relinkedCount).toBe(1) + expect(result.existingCount).toBe(0) + expect(result.skippedCount).toBe(2) + expect(result.errors).toEqual([]) + expect(result.repos).toHaveLength(1) + expect(result.repos[0]?.fullPath).toBe(repoRoot) + expect(createRepo).toHaveBeenCalledTimes(1) + }) }) diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index bcda5bd3..864850b8 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -5,6 +5,8 @@ import type { OpenCodeConfigResponse, CreateOpenCodeConfigRequest, UpdateOpenCodeConfigRequest, + OpenCodeImportStatus, + SyncOpenCodeImportResponse, SkillFileInfo, CreateSkillRequest, UpdateSkillRequest, @@ -145,6 +147,18 @@ export const settingsApi = { }) }, + getOpenCodeImportStatus: async (): Promise => { + return fetchWrapper(`${API_BASE_URL}/api/settings/opencode-import/status`) + }, + + syncOpenCodeImport: async (overwriteState = true): Promise => { + return fetchWrapper(`${API_BASE_URL}/api/settings/opencode-import`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ overwriteState }), + }) + }, + getOpenCodeVersions: async (): Promise<{ versions: Array<{ version: string diff --git a/frontend/src/api/types/settings.ts b/frontend/src/api/types/settings.ts index 1e342325..c4ac729b 100644 --- a/frontend/src/api/types/settings.ts +++ b/frontend/src/api/types/settings.ts @@ -98,3 +98,26 @@ export interface OpenCodeConfigResponse { configs: OpenCodeConfig[] defaultConfig: OpenCodeConfig | null } + +export interface OpenCodeImportStatus { + configSourcePath: string | null + stateSourcePath: string | null + workspaceConfigPath: string + workspaceStatePath: string + workspaceStateExists: boolean +} + +export interface SyncOpenCodeImportResponse extends OpenCodeImportStatus { + success: boolean + message: string + serverRestarted: boolean + configImported: boolean + stateImported: boolean + relinkedRepos?: { + repos: Array> + relinkedCount: number + existingCount: number + skippedCount: number + errors: Array<{ path: string; error: string }> + } +} diff --git a/frontend/src/components/settings/OpenCodeConfigManager.tsx b/frontend/src/components/settings/OpenCodeConfigManager.tsx index f771cb74..d1370df9 100644 --- a/frontend/src/components/settings/OpenCodeConfigManager.tsx +++ b/frontend/src/components/settings/OpenCodeConfigManager.tsx @@ -21,8 +21,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useServerHealth } from '@/hooks/useServerHealth' import { parseJsonc, hasJsoncComments } from '@/lib/jsonc' import { showToast } from '@/lib/toast' -import { invalidateConfigCaches } from '@/lib/queryInvalidation' -import type { OpenCodeConfig } from '@/api/types/settings' +import { invalidateAllConfigRelatedCaches, invalidateConfigCaches } from '@/lib/queryInvalidation' +import type { OpenCodeConfig, OpenCodeImportStatus } from '@/api/types/settings' interface Command { template: string @@ -83,6 +83,12 @@ export function OpenCodeConfigManager() { staleTime: 5 * 60 * 1000, }) + const { data: importStatus, isLoading: isImportStatusLoading } = useQuery({ + queryKey: ['opencode-import-status'], + queryFn: () => settingsApi.getOpenCodeImportStatus(), + staleTime: 30 * 1000, + }) + const scrollToSection = (ref: React.RefObject) => { if (ref.current) { ref.current.scrollIntoView({ @@ -152,6 +158,15 @@ export function OpenCodeConfigManager() { }, }) + const syncOpenCodeImportMutation = useMutation({ + mutationFn: async () => settingsApi.syncOpenCodeImport(true), + onSuccess: async () => { + await fetchConfigs() + invalidateAllConfigRelatedCaches(queryClient) + queryClient.invalidateQueries({ queryKey: ['opencode-import-status'] }) + }, + }) + const getApiErrorMessage = (error: unknown, fallback: string): string => { if (error && typeof error === 'object' && 'response' in error) { const response = (error as { response?: { data?: { details?: string; error?: string } } }).response @@ -337,6 +352,7 @@ export function OpenCodeConfigManager() { } const isUnhealthy = health?.opencode !== 'healthy' + const canImportFromHost = Boolean(importStatus?.configSourcePath || importStatus?.stateSourcePath) return (
@@ -433,6 +449,91 @@ export function OpenCodeConfigManager() { )} + + +
+
+ Existing OpenCode Host Import +

+ Re-import your standalone OpenCode config and session state into this workspace, then restart the server so existing chats can reconnect. +

+
+ +
+
+ +
+
+

Config Source

+

+ {isImportStatusLoading ? 'Checking...' : importStatus?.configSourcePath || 'No importable OpenCode config found'} +

+
+
+

State Source

+

+ {isImportStatusLoading ? 'Checking...' : importStatus?.stateSourcePath || 'No importable OpenCode state found'} +

+
+
+
+

Workspace State

+

+ {importStatus?.workspaceStatePath || 'Unavailable'} +

+

+ {importStatus?.workspaceStateExists + ? 'A workspace session database already exists. Import will replace it with the detected host state.' + : 'No workspace session database exists yet. Import will seed it from the detected host state.'} +

+
+ {syncOpenCodeImportMutation.data?.relinkedRepos && ( +
+

Last Relink Result

+

+ Linked {syncOpenCodeImportMutation.data.relinkedRepos.relinkedCount} repos, matched {syncOpenCodeImportMutation.data.relinkedRepos.existingCount} existing repos, skipped {syncOpenCodeImportMutation.data.relinkedRepos.skippedCount} non-repo session paths. +

+ {syncOpenCodeImportMutation.data.relinkedRepos.errors.length > 0 && ( +

+ {syncOpenCodeImportMutation.data.relinkedRepos.errors.length} repo paths could not be linked. +

+ )} +
+ )} + {!canImportFromHost && !isImportStatusLoading && ( +

+ No host OpenCode config or state was detected. For Docker installs, bind your host OpenCode config and state into the container before using this action. +

+ )} +
+
+ c.isDefault)?.content?.plugin?.includes('@opencode-manager/memory') ?? false} onToggle={async (enabled) => { From f431f7be4617a16f4f63f277e1befced88f09de1 Mon Sep 17 00:00:00 2001 From: itz4blitz Date: Wed, 1 Apr 2026 20:55:21 -0400 Subject: [PATCH 2/2] fix: tighten OpenCode import and relink reporting --- backend/src/services/opencode-import.ts | 25 +-- backend/src/services/repo.ts | 15 +- backend/test/routes/settings.test.ts | 3 +- backend/test/services/opencode-import.test.ts | 25 ++- backend/test/services/repo.test.ts | 3 +- frontend/src/api/types/settings.ts | 3 +- .../settings/OpenCodeConfigManager.tsx | 142 +++++++++--------- 7 files changed, 127 insertions(+), 89 deletions(-) diff --git a/backend/src/services/opencode-import.ts b/backend/src/services/opencode-import.ts index 55ac105b..a5233ee0 100644 --- a/backend/src/services/opencode-import.ts +++ b/backend/src/services/opencode-import.ts @@ -81,19 +81,19 @@ function snapshotOpenCodeDatabase(sourcePath: string, targetPath: string): void } } -export async function importOpenCodeStateDirectory(sourcePath: string, targetPath: string): Promise { - await ensureDirectoryExists(targetPath) - await copyOpenCodeStateFiles(sourcePath, targetPath) - +export async function importOpenCodeStateDirectory(sourcePath: string, targetPath: string): Promise { const sourceDbPath = path.join(sourcePath, 'opencode.db') if (!await fileExists(sourceDbPath)) { - return + return false } + await ensureDirectoryExists(targetPath) + await copyOpenCodeStateFiles(sourcePath, targetPath) await rm(path.join(targetPath, 'opencode.db'), { force: true }) await rm(path.join(targetPath, 'opencode.db-shm'), { force: true }) await rm(path.join(targetPath, 'opencode.db-wal'), { force: true }) snapshotOpenCodeDatabase(sourceDbPath, path.join(targetPath, 'opencode.db')) + return true } export async function getOpenCodeImportStatus(): Promise { @@ -147,22 +147,23 @@ async function importOpenCodeConfigFromSource(db: Database, userId: string, sour } export async function syncOpenCodeImport(options: SyncOpenCodeImportOptions): Promise { - const status = await getOpenCodeImportStatus() + const initialStatus = await getOpenCodeImportStatus() const userId = options.userId || 'default' let configImported = false let stateImported = false - if (status.configSourcePath) { - configImported = await importOpenCodeConfigFromSource(options.db, userId, status.configSourcePath, status.workspaceConfigPath) + if (initialStatus.configSourcePath) { + configImported = await importOpenCodeConfigFromSource(options.db, userId, initialStatus.configSourcePath, initialStatus.workspaceConfigPath) } - if (status.stateSourcePath && ((options.overwriteState ?? true) || !status.workspaceStateExists)) { - await importOpenCodeStateDirectory(status.stateSourcePath, status.workspaceStatePath) - stateImported = true + if (initialStatus.stateSourcePath && ((options.overwriteState ?? true) || !initialStatus.workspaceStateExists)) { + stateImported = await importOpenCodeStateDirectory(initialStatus.stateSourcePath, initialStatus.workspaceStatePath) } + const finalStatus = await getOpenCodeImportStatus() + return { - ...status, + ...finalStatus, configImported, stateImported, } diff --git a/backend/src/services/repo.ts b/backend/src/services/repo.ts index 1e67e1eb..d9cddde3 100644 --- a/backend/src/services/repo.ts +++ b/backend/src/services/repo.ts @@ -393,21 +393,31 @@ export async function relinkReposFromSessionDirectories( repos: Repo[] relinkedCount: number existingCount: number - skippedCount: number + nonRepoPathCount: number + duplicatePathCount: number errors: Array<{ path: string; error: string }> }> { const env = gitAuthService.getGitEnvironment() const errors: Array<{ path: string; error: string }> = [] const uniqueRepoRoots = new Set() + let nonRepoPathCount = 0 + let duplicatePathCount = 0 for (const directory of directories) { const normalizedDirectory = normalizeInputPath(directory) if (!normalizedDirectory) { + nonRepoPathCount += 1 continue } const repoRoot = await findGitRepoRoot(normalizedDirectory, env) if (!repoRoot) { + nonRepoPathCount += 1 + continue + } + + if (uniqueRepoRoots.has(repoRoot)) { + duplicatePathCount += 1 continue } @@ -439,7 +449,8 @@ export async function relinkReposFromSessionDirectories( repos, relinkedCount, existingCount, - skippedCount: Math.max(0, directories.length - uniqueRepoRoots.size), + nonRepoPathCount, + duplicatePathCount, errors, } } diff --git a/backend/test/routes/settings.test.ts b/backend/test/routes/settings.test.ts index 4592183c..c77b3fc7 100644 --- a/backend/test/routes/settings.test.ts +++ b/backend/test/routes/settings.test.ts @@ -153,7 +153,8 @@ describe('Settings Routes - OpenCode Upgrade', () => { repos: [], relinkedCount: 0, existingCount: 0, - skippedCount: 0, + nonRepoPathCount: 0, + duplicatePathCount: 0, errors: [], }) }) diff --git a/backend/test/services/opencode-import.test.ts b/backend/test/services/opencode-import.test.ts index ddc474fa..0df46083 100644 --- a/backend/test/services/opencode-import.test.ts +++ b/backend/test/services/opencode-import.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Database } from 'bun:sqlite' vi.mock('fs/promises', () => ({ cp: vi.fn(), @@ -44,7 +45,7 @@ const MockSettingsService = SettingsService as unknown as ReturnType describe('opencode-import service', () => { - const mockDb = {} as any + const mockDb = {} as unknown as Database const settingsService = { getOpenCodeConfigByName: vi.fn(), updateOpenCodeConfig: vi.fn(), @@ -87,6 +88,7 @@ describe('opencode-import service', () => { return candidate === '/import/opencode-config/opencode.json' || candidate === '/import/opencode-state' || candidate === '/import/opencode-state/opencode.db' + || candidate === '/tmp/workspace/.opencode/state/opencode/opencode.db' }) settingsService.getOpenCodeConfigByName.mockReturnValue({ name: 'default' }) @@ -99,6 +101,7 @@ describe('opencode-import service', () => { expect(result.configImported).toBe(true) expect(result.stateImported).toBe(true) + expect(result.workspaceStateExists).toBe(true) expect(settingsService.updateOpenCodeConfig).toHaveBeenCalledWith('default', { content: '{"$schema":"https://opencode.ai/config.json"}', isDefault: true, @@ -111,6 +114,26 @@ describe('opencode-import service', () => { expect(MockSQLiteDatabase).toHaveBeenCalledWith('/import/opencode-state/opencode.db') }) + it('does not report state imported when source db is missing', async () => { + process.env.OPENCODE_IMPORT_CONFIG_PATH = '/import/opencode-config/opencode.json' + process.env.OPENCODE_IMPORT_STATE_PATH = '/import/opencode-state' + + mockFileExists.mockImplementation(async (candidate: string) => { + return candidate === '/import/opencode-config/opencode.json' + || candidate === '/import/opencode-state' + }) + + const result = await syncOpenCodeImport({ + db: mockDb, + userId: 'default', + overwriteState: true, + }) + + expect(result.configImported).toBe(true) + expect(result.stateImported).toBe(false) + expect(mockEnsureDirectoryExists).not.toHaveBeenCalled() + }) + it('reads distinct session directories from imported workspace state', async () => { mockFileExists.mockImplementation(async (candidate: string) => candidate === '/tmp/workspace/.opencode/state/opencode/opencode.db') diff --git a/backend/test/services/repo.test.ts b/backend/test/services/repo.test.ts index 897bba5f..8cde2b36 100644 --- a/backend/test/services/repo.test.ts +++ b/backend/test/services/repo.test.ts @@ -412,7 +412,8 @@ describe('repo service', () => { expect(result.relinkedCount).toBe(1) expect(result.existingCount).toBe(0) - expect(result.skippedCount).toBe(2) + expect(result.nonRepoPathCount).toBe(1) + expect(result.duplicatePathCount).toBe(1) expect(result.errors).toEqual([]) expect(result.repos).toHaveLength(1) expect(result.repos[0]?.fullPath).toBe(repoRoot) diff --git a/frontend/src/api/types/settings.ts b/frontend/src/api/types/settings.ts index c4ac729b..4c386de1 100644 --- a/frontend/src/api/types/settings.ts +++ b/frontend/src/api/types/settings.ts @@ -117,7 +117,8 @@ export interface SyncOpenCodeImportResponse extends OpenCodeImportStatus { repos: Array> relinkedCount: number existingCount: number - skippedCount: number + nonRepoPathCount: number + duplicatePathCount: number errors: Array<{ path: string; error: string }> } } diff --git a/frontend/src/components/settings/OpenCodeConfigManager.tsx b/frontend/src/components/settings/OpenCodeConfigManager.tsx index d1370df9..43a9f43c 100644 --- a/frontend/src/components/settings/OpenCodeConfigManager.tsx +++ b/frontend/src/components/settings/OpenCodeConfigManager.tsx @@ -462,77 +462,77 @@ export function OpenCodeConfigManager() { variant="outline" size="sm" disabled={!canImportFromHost || syncOpenCodeImportMutation.isPending || isImportStatusLoading} - onClick={async () => { - showToast.loading('Importing existing OpenCode host data...', { id: 'opencode-import' }) - try { - const result = await syncOpenCodeImportMutation.mutateAsync() - const importedParts = [result.configImported && 'config', result.stateImported && 'state'] - .filter(Boolean) - .join(' and ') - const relinkSummary = result.relinkedRepos - ? ` Linked ${result.relinkedRepos.relinkedCount} repos and matched ${result.relinkedRepos.existingCount} existing repos.` - : '' - showToast.success(`Imported existing OpenCode ${importedParts || 'data'} and restarted the server.${relinkSummary}`, { id: 'opencode-import' }) - } catch (error) { - showToast.error(getApiErrorMessage(error, 'Failed to import existing OpenCode host data'), { id: 'opencode-import' }) - } - }} - > - {syncOpenCodeImportMutation.isPending ? ( - - ) : ( - - )} - Import From Host - -
- - -
-
-

Config Source

-

- {isImportStatusLoading ? 'Checking...' : importStatus?.configSourcePath || 'No importable OpenCode config found'} -

-
-
-

State Source

-

- {isImportStatusLoading ? 'Checking...' : importStatus?.stateSourcePath || 'No importable OpenCode state found'} -

-
-
-
-

Workspace State

-

- {importStatus?.workspaceStatePath || 'Unavailable'} -

-

- {importStatus?.workspaceStateExists - ? 'A workspace session database already exists. Import will replace it with the detected host state.' - : 'No workspace session database exists yet. Import will seed it from the detected host state.'} -

-
- {syncOpenCodeImportMutation.data?.relinkedRepos && ( -
-

Last Relink Result

-

- Linked {syncOpenCodeImportMutation.data.relinkedRepos.relinkedCount} repos, matched {syncOpenCodeImportMutation.data.relinkedRepos.existingCount} existing repos, skipped {syncOpenCodeImportMutation.data.relinkedRepos.skippedCount} non-repo session paths. -

- {syncOpenCodeImportMutation.data.relinkedRepos.errors.length > 0 && ( -

- {syncOpenCodeImportMutation.data.relinkedRepos.errors.length} repo paths could not be linked. -

- )} -
- )} - {!canImportFromHost && !isImportStatusLoading && ( -

- No host OpenCode config or state was detected. For Docker installs, bind your host OpenCode config and state into the container before using this action. -

- )} -
- + onClick={async () => { + showToast.loading('Importing existing OpenCode host data...', { id: 'opencode-import' }) + try { + const result = await syncOpenCodeImportMutation.mutateAsync() + const importedParts = [result.configImported && 'config', result.stateImported && 'state'] + .filter(Boolean) + .join(' and ') + const relinkSummary = result.relinkedRepos + ? ` Linked ${result.relinkedRepos.relinkedCount} repos, matched ${result.relinkedRepos.existingCount} existing repos, skipped ${result.relinkedRepos.nonRepoPathCount} non-repo paths, and ignored ${result.relinkedRepos.duplicatePathCount} duplicate session paths.` + : '' + showToast.success(`Imported existing OpenCode ${importedParts || 'data'} and restarted the server.${relinkSummary}`, { id: 'opencode-import' }) + } catch (error) { + showToast.error(getApiErrorMessage(error, 'Failed to import existing OpenCode host data'), { id: 'opencode-import' }) + } + }} + > + {syncOpenCodeImportMutation.isPending ? ( + + ) : ( + + )} + Import From Host + + + + +
+
+

Config Source

+

+ {isImportStatusLoading ? 'Checking...' : importStatus?.configSourcePath || 'No importable OpenCode config found'} +

+
+
+

State Source

+

+ {isImportStatusLoading ? 'Checking...' : importStatus?.stateSourcePath || 'No importable OpenCode state found'} +

+
+
+
+

Workspace State

+

+ {importStatus?.workspaceStatePath || 'Unavailable'} +

+

+ {importStatus?.workspaceStateExists + ? 'A workspace session database already exists. Import will replace it with the detected host state.' + : 'No workspace session database exists yet. Import will seed it from the detected host state.'} +

+
+ {syncOpenCodeImportMutation.data?.relinkedRepos && ( +
+

Last Relink Result

+

+ Linked {syncOpenCodeImportMutation.data.relinkedRepos.relinkedCount} repos, matched {syncOpenCodeImportMutation.data.relinkedRepos.existingCount} existing repos, skipped {syncOpenCodeImportMutation.data.relinkedRepos.nonRepoPathCount} non-repo session paths, and ignored {syncOpenCodeImportMutation.data.relinkedRepos.duplicatePathCount} duplicate session paths. +

+ {syncOpenCodeImportMutation.data.relinkedRepos.errors.length > 0 && ( +

+ {syncOpenCodeImportMutation.data.relinkedRepos.errors.length} repo paths could not be linked. +

+ )} +
+ )} + {!canImportFromHost && !isImportStatusLoading && ( +

+ No host OpenCode config or state was detected. For Docker installs, bind your host OpenCode config and state into the container before using this action. +

+ )} +
+ c.isDefault)?.content?.plugin?.includes('@opencode-manager/memory') ?? false}