From 9c3b957946601607ed0f879d19cdeb93dc1eb329 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:42:51 -0400 Subject: [PATCH 1/4] refactor(config): make on-disk opencode.json the single source of truth Make /.config/opencode/opencode.json the only store for the active OpenCode configuration. One owner (opencode-config-file.ts) reads, validates, writes, archives, and seeds the file; one owner (opencode-config-apply.ts) decides how an edit is applied: restart pending, or a live patch with recovery. Settings routes, the internal assistant API, the health-watch supervisor, boot, and host import all go through them, so an edit that lands on the file without going through the database is visible immediately instead of at the next Manager boot. Remove the named-config profiles. Migration 019 archives every opencode_configs row to .config/opencode-configs-archive/.json, restores the default row to opencode.json when no file exists, then drops the table, its indexes, and repos.opencode_config_name. The /opencode-configs* and config/switch routes and the CreateConfigDialog/SwitchConfigDialog dialogs are gone, and the ocm tool gains GET/PUT /opencode-config backed by the same apply owner. Harden the write path: serialize and atomically write config updates while preserving the file mode, keep withFileLock usable after a rejection, route reloadConfig's cleaned-config write through the file owner, stop boot from rewriting an existing valid config when importing state, prune the health-watch directory to its newest 20 entries, resolve archive paths with realpath, and share one frontend config query across dialogs. --- backend/package.json | 2 +- .../migrations/019-drop-opencode-configs.ts | 87 + backend/src/db/migrations/index.ts | 2 + backend/src/db/queries.ts | 10 - backend/src/index.ts | 89 +- backend/src/routes/internal/index.ts | 2 + .../src/routes/internal/opencode-config.ts | 60 + backend/src/routes/providers.test.ts | 4 +- backend/src/routes/repos.ts | 64 +- backend/src/routes/settings.ts | 357 +--- backend/src/services/archive.ts | 17 +- backend/src/services/assistant-mode.ts | 72 +- backend/src/services/opencode-config-apply.ts | 102 + backend/src/services/opencode-config-file.ts | 171 ++ backend/src/services/opencode-import.ts | 62 +- .../services/opencode-manager-tool-plugin.ts | 6 +- .../services/opencode-plugin-quarantine.ts | 10 +- .../src/services/opencode-single-server.ts | 19 +- backend/src/services/opencode-supervisor.ts | 45 +- backend/src/services/settings.ts | 504 +---- backend/src/utils/atomic-json.test.ts | 57 + backend/src/utils/atomic-json.ts | 2 +- backend/src/utils/fs-safe.ts | 8 + backend/test/auth/index.test.ts | 123 ++ backend/test/auth/middleware.test.ts | 73 + .../test/db/opencode-config-migration.test.ts | 127 ++ backend/test/db/prompt-templates.test.ts | 103 + backend/test/db/queries.test.ts | 18 - backend/test/db/schema.test.ts | 41 + backend/test/helpers/assistant-workspace.ts | 14 +- backend/test/index.test.ts | 225 +++ backend/test/ipc/ipcServer.test.ts | 141 ++ backend/test/mocks/bun-sqlite.ts | 1 + backend/test/mocks/bun-test.ts | 1 + backend/test/routes/auth.test.ts | 369 ++++ .../routes/internal-opencode-config.test.ts | 113 ++ backend/test/routes/mcp-oauth-proxy.test.ts | 486 +++++ backend/test/routes/notifications.test.ts | 226 +++ backend/test/routes/repos.test.ts | 983 +++++++++- .../routes/settings-skills-install.test.ts | 5 - backend/test/routes/settings.test.ts | 1710 +++++++++++------ backend/test/routes/sse.test.ts | 288 +++ backend/test/routes/tts.test.ts | 297 ++- backend/test/scripts/askpass-main.test.ts | 357 ++++ backend/test/services/archive.test.ts | 247 +++ backend/test/services/assistant-mode.test.ts | 16 + backend/test/services/files.test.ts | 419 ++++ backend/test/services/mcp-oauth-state.test.ts | 106 + .../services/opencode-config-apply.test.ts | 234 +++ .../services/opencode-config-file.test.ts | 168 ++ backend/test/services/opencode-import.test.ts | 82 +- .../opencode-manager-tool-plugin.test.ts | 21 +- .../services/opencode-single-server.test.ts | 126 ++ .../test/services/opencode-supervisor.test.ts | 107 +- .../test/services/prompt-templates.test.ts | 89 + backend/test/services/repo-git.test.ts | 1063 ++++++++++ .../test/services/settings-archive.test.ts | 68 - backend/test/services/skills.test.ts | 1 - backend/vitest.config.ts | 17 +- docs/configuration/docker.md | 2 +- docs/features/assistant-internal-api.md | 49 +- docs/features/assistant-mode.md | 4 +- docs/features/server-health.md | 13 + frontend/src/api/providers.test.ts | 66 + frontend/src/api/providers.ts | 13 +- frontend/src/api/repos.ts | 13 +- frontend/src/api/settings.ts | 80 +- frontend/src/api/types.ts | 1 - frontend/src/api/types/settings.ts | 37 +- .../components/model/ModelSelectDialog.tsx | 6 +- .../components/repo/SwitchConfigDialog.tsx | 153 -- .../components/repo/repo-list-state.test.ts | 1 - .../ScheduleJobDialog.providers.test.tsx | 86 + .../schedules/ScheduleJobDialog.tsx | 6 +- .../settings/AddMcpServerDialog.test.tsx | 80 + .../settings/AddMcpServerDialog.tsx | 21 +- .../src/components/settings/AgentDialog.tsx | 6 +- .../settings/CreateConfigDialog.tsx | 180 -- .../src/components/settings/McpManager.tsx | 8 +- .../settings/OpenCodeConfigEditor.test.tsx | 9 +- .../settings/OpenCodeConfigEditor.tsx | 29 +- .../settings/OpenCodeConfigManager.test.tsx | 180 +- .../settings/OpenCodeConfigManager.tsx | 846 +++----- frontend/src/hooks/useOpenCodeConfigFile.ts | 13 + frontend/src/pages/AssistantRedirect.tsx | 19 +- frontend/src/pages/RepoDetail.tsx | 22 +- shared/src/config/env.ts | 1 + shared/src/schemas/repo.ts | 2 - shared/src/schemas/settings.ts | 29 +- shared/src/types/index.ts | 10 +- 90 files changed, 9150 insertions(+), 3052 deletions(-) create mode 100644 backend/src/db/migrations/019-drop-opencode-configs.ts create mode 100644 backend/src/routes/internal/opencode-config.ts create mode 100644 backend/src/services/opencode-config-apply.ts create mode 100644 backend/src/services/opencode-config-file.ts create mode 100644 backend/test/auth/index.test.ts create mode 100644 backend/test/auth/middleware.test.ts create mode 100644 backend/test/db/opencode-config-migration.test.ts create mode 100644 backend/test/db/prompt-templates.test.ts create mode 100644 backend/test/db/schema.test.ts create mode 100644 backend/test/index.test.ts create mode 100644 backend/test/ipc/ipcServer.test.ts create mode 100644 backend/test/mocks/bun-test.ts create mode 100644 backend/test/routes/auth.test.ts create mode 100644 backend/test/routes/internal-opencode-config.test.ts create mode 100644 backend/test/routes/mcp-oauth-proxy.test.ts create mode 100644 backend/test/routes/notifications.test.ts create mode 100644 backend/test/routes/sse.test.ts create mode 100644 backend/test/scripts/askpass-main.test.ts create mode 100644 backend/test/services/archive.test.ts create mode 100644 backend/test/services/files.test.ts create mode 100644 backend/test/services/mcp-oauth-state.test.ts create mode 100644 backend/test/services/opencode-config-apply.test.ts create mode 100644 backend/test/services/opencode-config-file.test.ts create mode 100644 backend/test/services/prompt-templates.test.ts create mode 100644 backend/test/services/repo-git.test.ts delete mode 100644 backend/test/services/settings-archive.test.ts create mode 100644 frontend/src/api/providers.test.ts delete mode 100644 frontend/src/components/repo/SwitchConfigDialog.tsx create mode 100644 frontend/src/components/schedules/ScheduleJobDialog.providers.test.tsx create mode 100644 frontend/src/components/settings/AddMcpServerDialog.test.tsx delete mode 100644 frontend/src/components/settings/CreateConfigDialog.tsx create mode 100644 frontend/src/hooks/useOpenCodeConfigFile.ts diff --git a/backend/package.json b/backend/package.json index 830ee2755..b28f72a55 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,7 +9,7 @@ "build": "bun build src/index.ts --outdir=dist --target=bun", "typecheck": "tsc --noEmit", "test": "pnpm run test:bun && pnpm run test:vitest", - "test:bun": "bun test test/services/assistant-mode.test.ts test/services/internal-token.test.ts test/auth/internal-token-middleware.test.ts test/routes/internal-schedules.test.ts test/routes/internal-notifications.test.ts test/routes/internal-settings.test.ts test/routes/internal-repos.test.ts test/routes/internal-assistant.test.ts test/routes/internal-sandbox.test.ts src/db/model-state.test.ts src/routes/providers.test.ts src/routes/repos.test.ts src/routes/session-pins.test.ts", + "test:bun": "bun test test/services/assistant-mode.test.ts test/services/internal-token.test.ts test/auth/internal-token-middleware.test.ts test/routes/internal-schedules.test.ts test/routes/internal-notifications.test.ts test/routes/internal-settings.test.ts test/routes/internal-opencode-config.test.ts test/routes/internal-repos.test.ts test/routes/internal-assistant.test.ts test/routes/internal-sandbox.test.ts src/db/model-state.test.ts src/routes/providers.test.ts src/routes/repos.test.ts src/routes/session-pins.test.ts", "test:vitest": "vitest run", "test:ui": "vitest --ui", "test:watch": "vitest --watch", diff --git a/backend/src/db/migrations/019-drop-opencode-configs.ts b/backend/src/db/migrations/019-drop-opencode-configs.ts new file mode 100644 index 000000000..252fd9514 --- /dev/null +++ b/backend/src/db/migrations/019-drop-opencode-configs.ts @@ -0,0 +1,87 @@ +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { randomBytes } from 'node:crypto' +import path from 'node:path' +import { getOpenCodeConfigFilePath, getOpenCodeConfigHome } from '@opencode-manager/shared/config/env' +import type { Migration } from '../migration-runner' +import { logger } from '../../utils/logger' +import { getFirstExistingConfigSourcePath } from '../../services/opencode-import' + +function writeArchivedConfig(archiveDir: string, configName: string, content: string): void { + const base = configName.replace(/[^A-Za-z0-9._-]/g, '_') + try { + writeFileSync(path.join(archiveDir, `${base}.json`), content, { flag: 'wx' }) + return + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { + throw error + } + } + + const suffix = `${Date.now()}-${randomBytes(4).toString('hex')}` + writeFileSync(path.join(archiveDir, `${base}-${suffix}.json`), content, { flag: 'wx' }) +} + +function archiveConfigs(rows: Array<{ config_name: string; config_content: string }>): void { + const archiveDir = path.join(getOpenCodeConfigHome(), 'opencode-configs-archive') + mkdirSync(archiveDir, { recursive: true }) + for (const row of rows) { + try { + writeArchivedConfig(archiveDir, row.config_name, row.config_content) + } catch (error) { + logger.warn('Failed to archive an opencode config before dropping the table', error) + } + } +} + +const migration: Migration = { + version: 19, + name: 'drop-opencode-configs', + + up(db) { + const rows = db.prepare('SELECT config_name, config_content, is_default FROM opencode_configs').all() as Array<{ config_name: string; config_content: string; is_default: number | null }> + + try { + archiveConfigs(rows) + } catch (error) { + logger.warn('Failed to archive opencode configs before dropping the table', error) + } + + const defaultRow = rows.find(row => row.is_default) + if (defaultRow) { + try { + const configFilePath = getOpenCodeConfigFilePath() + if (!existsSync(configFilePath) && !getFirstExistingConfigSourcePath()) { + mkdirSync(path.dirname(configFilePath), { recursive: true }) + writeFileSync(configFilePath, defaultRow.config_content) + } + } catch (error) { + logger.warn('Failed to restore the default opencode config file', error) + } + } + + db.run('DROP INDEX IF EXISTS idx_opencode_default') + db.run('DROP INDEX IF EXISTS idx_opencode_user_id') + db.run('DROP TABLE IF EXISTS opencode_configs') + db.run('ALTER TABLE repos DROP COLUMN opencode_config_name') + }, + + down(db) { + db.run(` + CREATE TABLE IF NOT EXISTS opencode_configs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL DEFAULT 'default', + config_name TEXT NOT NULL, + config_content TEXT NOT NULL, + is_default BOOLEAN DEFAULT FALSE, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(user_id, config_name) + ) + `) + db.run('CREATE INDEX IF NOT EXISTS idx_opencode_user_id ON opencode_configs(user_id)') + db.run('CREATE INDEX IF NOT EXISTS idx_opencode_default ON opencode_configs(user_id, is_default)') + db.run('ALTER TABLE repos ADD COLUMN opencode_config_name TEXT') + }, +} + +export default migration diff --git a/backend/src/db/migrations/index.ts b/backend/src/db/migrations/index.ts index 514d74ca7..6e537a74f 100644 --- a/backend/src/db/migrations/index.ts +++ b/backend/src/db/migrations/index.ts @@ -17,6 +17,7 @@ import migration015 from './015-schedule-worktree-isolation' import migration016 from './016-schedule-permission-config' import migration017 from './017-schedule-run-workspace-id' import migration018 from './018-session-pins' +import migration019 from './019-drop-opencode-configs' export const allMigrations: Migration[] = [ migration001, @@ -37,4 +38,5 @@ export const allMigrations: Migration[] = [ migration016, migration017, migration018, + migration019, ] diff --git a/backend/src/db/queries.ts b/backend/src/db/queries.ts index 573228720..c02e57ac8 100644 --- a/backend/src/db/queries.ts +++ b/backend/src/db/queries.ts @@ -17,7 +17,6 @@ interface RepoRow { cloned_at: number last_pulled?: number last_accessed_at?: number - opencode_config_name?: string is_worktree?: number is_local?: number } @@ -42,7 +41,6 @@ function rowToRepo(row: RepoRow): Repo { clonedAt: row.cloned_at, lastPulled: row.last_pulled, lastAccessedAt: row.last_accessed_at, - openCodeConfigName: row.opencode_config_name, isWorktree: row.is_worktree ? Boolean(row.is_worktree) : undefined, isLocal: row.is_local ? Boolean(row.is_local) : undefined, } @@ -311,14 +309,6 @@ export function updateRepoStatus(db: Database, id: number, cloneStatus: Repo['cl } } -export function updateRepoConfigName(db: Database, id: number, configName: string): void { - const stmt = db.prepare('UPDATE repos SET opencode_config_name = ? WHERE id = ?') - const result = stmt.run(configName, id) - if (result.changes === 0) { - throw new Error(`Repository with id ${id} not found`) - } -} - export function updateLastPulled(db: Database, id: number): void { const stmt = db.prepare('UPDATE repos SET last_pulled = ? WHERE id = ?') const result = stmt.run(Date.now(), id) diff --git a/backend/src/index.ts b/backend/src/index.ts index fd9f76e1e..103ba0d36 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -42,7 +42,7 @@ import { sweepStaleUploadSessions } from './routes/internal/repo-mirror-helpers' import { createOpenCodeProxyRoutes } from './routes/opencode-proxy' import { createAuthenticatedOpenCodeProxyRoutes } from './routes/opencode-auth-proxy' import { sseAggregator } from './services/sse-aggregator' -import { ensureDirectoryExists, writeFileContent, fileExists, readFileContent } from './services/file-operations' +import { ensureDirectoryExists, writeFileContent, fileExists } from './services/file-operations' import { SettingsService } from './services/settings' import { opencodeServerManager } from './services/opencode-single-server' import { createOpenCodeClient } from './services/opencode/client' @@ -55,11 +55,10 @@ import { installAssistantWorkspace } from './services/assistant-mode' import { detectSandboxCapability } from './services/sandbox/capability' import { SandboxRuntimeService, stopWorkspaceSandboxOnShutdown } from './services/sandbox/runtime' import { getOpenCodeImportStatus, syncOpenCodeImport } from './services/opencode-import' +import { readOpenCodeConfigFile, writeOpenCodeConfigFile, OPENCODE_CONFIG_SEED } from './services/opencode-config-file' import { OpenCodeSupervisor } from './services/opencode-supervisor' import { OpenCodeRestartCoordinator } from './services/opencode-restart-coordinator' import { setOpenCodeRestartCoordinator } from './services/opencode-restart' -import { OpenCodeConfigSchema } from '@opencode-manager/shared/schemas' -import { parse as parseJsonc } from 'jsonc-parser' import { getModelStatePath, ModelStateSchema } from './routes/providers' import { readJsonSafe } from './utils/atomic-json' import { @@ -71,7 +70,6 @@ import { getWorkspacePath, getReposPath, getConfigPath, - getOpenCodeConfigFilePath, getAgentsMdPath, getDatabasePath, ENV @@ -118,75 +116,30 @@ import { DEFAULT_AGENTS_MD } from './constants' let ipcServer: IPCServer | undefined const gitAuthService = new GitAuthService() let openCodeSupervisor: OpenCodeSupervisor | undefined -async function ensureDefaultConfigExists(): Promise { - const settingsService = new SettingsService(db) - const workspaceConfigPath = getOpenCodeConfigFilePath() - - if (await fileExists(workspaceConfigPath)) { - logger.info(`Found workspace config at ${workspaceConfigPath}, syncing to database...`) - try { - const rawContent = await readFileContent(workspaceConfigPath) - const parsed = parseJsonc(rawContent) - const validation = OpenCodeConfigSchema.safeParse(parsed) - - if (!validation.success) { - logger.warn('Workspace config has invalid structure', validation.error) - } else { - const existingDefault = settingsService.getOpenCodeConfigByName('default') - if (existingDefault) { - settingsService.updateOpenCodeConfig('default', { - content: rawContent, - isDefault: true, - }) - logger.info('Updated database config from workspace file') - } else { - settingsService.createOpenCodeConfig({ - name: 'default', - content: rawContent, - isDefault: true, - }) - logger.info('Created database config from workspace file') - } - return - } - } catch (error) { - logger.warn('Failed to read workspace config', error) +async function ensureOpenCodeConfigFileExists(): Promise { + const existing = await readOpenCodeConfigFile() + if (existing) { + if (!existing.isValid) { + logger.warn('OpenCode config file has validation issues', existing.validationIssues) } + return } - - const { configSourcePath: importConfigPath } = await getOpenCodeImportStatus() - if (importConfigPath) { - logger.info(`Found importable OpenCode config at ${importConfigPath}, importing...`) + const status = await getOpenCodeImportStatus() + if (status.configSourcePath) { + logger.info(`Found importable OpenCode config at ${status.configSourcePath}, importing...`) try { - const result = await syncOpenCodeImport({ db, overwriteState: false }) + const result = await syncOpenCodeImport({ overwriteState: false, status }) if (result.configImported) { - logger.info(`Imported OpenCode config from ${importConfigPath} to workspace`) + logger.info(`Imported OpenCode config from ${status.configSourcePath} to workspace`) return } } catch (error) { - logger.warn(`Failed to import OpenCode config from ${importConfigPath}`, error) + logger.warn(`Failed to import OpenCode config from ${status.configSourcePath}`, error) } } - - const existingDbConfigs = settingsService.getOpenCodeConfigs() - if (existingDbConfigs.configs.length > 0) { - const defaultConfig = settingsService.getDefaultOpenCodeConfig() - if (defaultConfig) { - await writeFileContent(workspaceConfigPath, defaultConfig.rawContent) - logger.info('Wrote existing database config to workspace file') - } - return - } - - logger.info('No existing config found, creating minimal seed config') - const seedConfig = JSON.stringify({ $schema: 'https://opencode.ai/config.json' }, null, 2) - settingsService.createOpenCodeConfig({ - name: 'default', - content: seedConfig, - isDefault: true, - }) - await writeFileContent(workspaceConfigPath, seedConfig) + + await writeOpenCodeConfigFile(OPENCODE_CONFIG_SEED) logger.info('Created minimal seed config') } @@ -237,7 +190,7 @@ async function ensureHomeStateImported(): Promise { return } - const result = await syncOpenCodeImport({ db, overwriteState: false }) + const result = await syncOpenCodeImport({ overwriteState: false, importConfig: false, status }) if (result.stateImported) { logger.info(`Imported OpenCode state from ${status.stateSourcePath}`) } @@ -272,7 +225,7 @@ try { await cleanupExpiredCache() await sweepStaleUploadSessions() - await ensureDefaultConfigExists() + await ensureOpenCodeConfigFileExists() await backfillOpenCodeModelStateFromFile() await ensureHomeStateImported() await ensureDefaultAgentsMdExists() @@ -280,9 +233,7 @@ try { const settingsService = new SettingsService(db) settingsService.initializeLastKnownGoodConfig() - openCodeSupervisor = new OpenCodeSupervisor(opencodeServerManager, settingsService, { - userId: 'default' - }) + openCodeSupervisor = new OpenCodeSupervisor(opencodeServerManager, settingsService) await migrateGlobalSkills() @@ -364,7 +315,7 @@ app.route('/api/opencode-proxy', createOpenCodeProxyRoutes(db, settingsService)) const protectedApi = new Hono() protectedApi.use('/*', requireAuth) -protectedApi.route('/repos', createRepoRoutes(db, gitAuthService, scheduleService, openCodeClient, openCodeSupervisor)) +protectedApi.route('/repos', createRepoRoutes(db, gitAuthService, scheduleService, openCodeClient)) protectedApi.route('/settings', createSettingsRoutes(db, gitAuthService, openCodeClient, openCodeSupervisor)) protectedApi.route('/files', createFileRoutes()) protectedApi.route('/filesystem', createFilesystemRoutes()) diff --git a/backend/src/routes/internal/index.ts b/backend/src/routes/internal/index.ts index 0a3282507..1281a8e00 100644 --- a/backend/src/routes/internal/index.ts +++ b/backend/src/routes/internal/index.ts @@ -8,6 +8,7 @@ import { createScheduleRoutes } from '../schedules' import { createInternalTokenMiddleware } from '../../auth/internal-token-middleware' import { createInternalNotificationRoutes } from './notifications' import { createInternalSettingsRoutes } from './settings' +import { createInternalOpenCodeConfigRoutes } from './opencode-config' import { createInternalRepoRoutes } from './repos' import { createInternalRepoSyncRoutes } from './repo-sync' import { createInternalRepoMirrorRoutes as mirrorRoutes } from './repo-mirror' @@ -28,6 +29,7 @@ export function createInternalRoutes( app.route('/schedules', createScheduleRoutes(scheduleService)) app.route('/notifications', createInternalNotificationRoutes(notificationService)) app.route('/settings', createInternalSettingsRoutes(settingsService)) + app.route('/opencode-config', createInternalOpenCodeConfigRoutes(settingsService, openCodeClient)) const repos = new Hono() repos.route('/', createInternalRepoRoutes(db, settingsService)) repos.route('/:id/schedules', createScheduleRoutes(scheduleService)) diff --git a/backend/src/routes/internal/opencode-config.ts b/backend/src/routes/internal/opencode-config.ts new file mode 100644 index 000000000..d1dfb6ccb --- /dev/null +++ b/backend/src/routes/internal/opencode-config.ts @@ -0,0 +1,60 @@ +import { Hono } from 'hono' +import { z } from 'zod' +import { UpdateOpenCodeConfigRequestSchema } from '@opencode-manager/shared/schemas' +import type { SettingsService } from '../../services/settings' +import type { OpenCodeClient } from '../../services/opencode/client' +import { readOpenCodeConfigFile } from '../../services/opencode-config-file' +import { applyOpenCodeConfigUpdate, toOpenCodeConfigApplyResponse } from '../../services/opencode-config-apply' +import { logger } from '../../utils/logger' + +export function createInternalOpenCodeConfigRoutes(settingsService: SettingsService, openCodeClient: OpenCodeClient) { + const app = new Hono() + + app.get('/', async (c) => { + try { + const config = await readOpenCodeConfigFile() + if (!config) { + return c.json({ error: 'No OpenCode config file found' }, 404) + } + return c.json(config) + } catch (error) { + logger.error('Failed to get OpenCode config:', error) + return c.json({ error: 'Failed to get OpenCode config' }, 500) + } + }) + + app.put('/', async (c) => { + const userId = c.req.query('userId') ?? 'default' + + let body: unknown + try { + body = await c.req.json() + } catch { + return c.json({ error: 'Invalid JSON' }, 400) + } + + const parsed = UpdateOpenCodeConfigRequestSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid config data', details: parsed.error.issues }, 400) + } + + try { + const result = await applyOpenCodeConfigUpdate({ + content: parsed.data.content, + openCodeClient, + settingsService, + userId, + }) + const { status, body: responseBody } = toOpenCodeConfigApplyResponse(result) + return c.json(responseBody, status) + } catch (error) { + logger.error('Failed to update OpenCode config:', error) + if (error instanceof z.ZodError) { + return c.json({ error: 'Invalid config data', details: error.issues }, 400) + } + return c.json({ error: 'Failed to update OpenCode config' }, 500) + } + }) + + return app +} diff --git a/backend/src/routes/providers.test.ts b/backend/src/routes/providers.test.ts index 21a50147c..a31369a77 100644 --- a/backend/src/routes/providers.test.ts +++ b/backend/src/routes/providers.test.ts @@ -5,7 +5,7 @@ import { migrate } from '../db/migration-runner' import { allMigrations } from '../db/migrations' import { createProvidersRoutes } from './providers' import { join, dirname } from 'node:path' -import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises' +import { mkdtemp, rm, writeFile, mkdir, readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { createStubOpenCodeClient } from '../../test/helpers/stub-opencode-client' @@ -119,7 +119,7 @@ describe('providers routes', () => { const data = (await res.json()) as { recent: Array<{ providerID: string; modelID: string }> } expect(data.recent).toHaveLength(1) - const fileContent = await Bun.file(modelStatePath).text() + const fileContent = await readFile(modelStatePath, 'utf8') const parsed = JSON.parse(fileContent) as { recent: unknown[] } expect(parsed.recent).toHaveLength(1) }) diff --git a/backend/src/routes/repos.ts b/backend/src/routes/repos.ts index 7b215a9d2..5bc9808ee 100644 --- a/backend/src/routes/repos.ts +++ b/backend/src/routes/repos.ts @@ -3,17 +3,13 @@ import type { ContentfulStatusCode } from 'hono/utils/http-status' import type { Database } from 'bun:sqlite' import type { Repo } from '@opencode-manager/shared/types' import { DiscoverReposRequestSchema, AssistantModeInitRequestSchema, UpdateRepoRequestSchema } from '@opencode-manager/shared/schemas' -import { listRepos, getRepoById, updateLastAccessed, updateRepoConfigName, getRepoGitCredentialId, setRepoGitCredentialId, updateRepoName } from '../db/queries' +import { listRepos, getRepoById, updateLastAccessed, getRepoGitCredentialId, setRepoGitCredentialId, updateRepoName } from '../db/queries' import * as repoService from '../services/repo' import * as archiveService from '../services/archive' import { SettingsService } from '../services/settings' -import { writeFileContent } from '../services/file-operations' -import { restartOpenCodeAfterCommit } from '../services/opencode-restart' -import type { OpenCodeSupervisor } from '../services/opencode-supervisor' import type { OpenCodeClient } from '../services/opencode/client' import { logger } from '../utils/logger' import { getErrorMessage, getStatusCode } from '../utils/error-utils' -import { getOpenCodeConfigFilePath } from '@opencode-manager/shared/config/env' import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' import { createRepoGitRoutes } from './repo-git' import { createScheduleRoutes } from './schedules' @@ -38,7 +34,6 @@ export function createRepoRoutes( gitAuthService: GitAuthService, scheduleService: ScheduleService, openCodeClient: OpenCodeClient, - openCodeSupervisor?: OpenCodeSupervisor, ) { const app = new Hono() @@ -48,7 +43,7 @@ export function createRepoRoutes( app.post('/', async (c) => { try { const body = await c.req.json() - const { repoUrl, localPath, branch, directoryName, openCodeConfigName, useWorktree, skipSSHVerification, provider, baseBranch } = body + const { repoUrl, localPath, branch, directoryName, useWorktree, skipSSHVerification, provider, baseBranch } = body if (!repoUrl && !localPath) { return c.json({ error: 'Either repoUrl or localPath is required' }, 400) @@ -73,18 +68,6 @@ export function createRepoRoutes( ) } - if (openCodeConfigName) { - const settingsService = new SettingsService(database) - const configContent = settingsService.getOpenCodeConfigContent(openCodeConfigName) - - if (configContent) { - const openCodeConfigPath = getOpenCodeConfigFilePath() - await writeFileContent(openCodeConfigPath, configContent) - updateRepoConfigName(database, repo.id, openCodeConfigName) - logger.info(`Applied config '${openCodeConfigName}' to: ${openCodeConfigPath}`) - } - } - return c.json(repo) } catch (error: unknown) { logger.error('Failed to create repo:', error) @@ -366,49 +349,6 @@ app.get('/', async (c) => { } }) - app.post('/:id/config/switch', async (c) => { - try { - const id = parseInt(c.req.param('id')) - const repo = getRepoById(database, id) - - if (!repo) { - return c.json({ error: 'Repo not found' }, 404) - } - - const body = await c.req.json() - const { configName } = body - - if (!configName) { - return c.json({ error: 'configName is required' }, 400) - } - - const settingsService = new SettingsService(database) - const configContent = settingsService.getOpenCodeConfigContent(configName) - - if (!configContent) { - return c.json({ error: `Config '${configName}' not found` }, 404) - } - - const openCodeConfigPath = getOpenCodeConfigFilePath() - - await writeFileContent(openCodeConfigPath, configContent) - - updateRepoConfigName(database, id, configName) - - logger.info(`Switched config for repo ${id} to '${configName}'`) - logger.info(`Updated OpenCode config: ${openCodeConfigPath}`) - - logger.info('Restarting OpenCode server due to workspace config change') - const { restartFailed, restartError } = await restartOpenCodeAfterCommit(openCodeSupervisor) - - const updatedRepo = getRepoById(database, id) - return c.json(restartFailed ? { ...updatedRepo, restartFailed, restartError } : updatedRepo) - } catch (error: unknown) { - logger.error('Failed to switch repo config:', error) - return c.json({ error: getErrorMessage(error) }, 500) - } - }) - app.post('/:id/branch/switch', async (c) => { try { const id = parseInt(c.req.param('id')) diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index bae0fc1da..216daa2d1 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -7,12 +7,13 @@ import { resolve, dirname } from 'path' import type { Database } from 'bun:sqlite' import { SettingsService } from '../services/settings' import { writeFileContent, readFileContent, fileExists } from '../services/file-operations' -import { patchConfigWithRecovery } from '../services/opencode/config-recovery' +import { readOpenCodeConfigFile, writeOpenCodeConfigFile, deleteOpenCodeConfigFile } from '../services/opencode-config-file' +import { applyOpenCodeConfigUpdate, toOpenCodeConfigApplyResponse } from '../services/opencode-config-apply' import type { OpenCodeClient } from '../services/opencode/client' -import { getOpenCodeConfigFilePath, getAgentsMdPath } from '@opencode-manager/shared/config/env' +import { getAgentsMdPath } from '@opencode-manager/shared/config/env' +import { UpdateOpenCodeConfigRequestSchema } from '@opencode-manager/shared/schemas' import { UserPreferencesSchema, - OpenCodeConfigSchema, type SandboxPreferences, } from '../types/settings' import type { GitCredential } from '@opencode-manager/shared' @@ -34,7 +35,7 @@ import { sseAggregator } from '../services/sse-aggregator' import type { OpenCodeSupervisor } from '../services/opencode-supervisor' import { detectSandboxCapability } from '../services/sandbox/capability' import { getProcessIdentityAttestationError } from '../services/opencode/process-identity' -import { restartOpenCode, restartOpenCodeAfterCommit, reloadOpenCodeConfig, getOpenCodeRestartCoordinator } from '../services/opencode-restart' +import { restartOpenCode, reloadOpenCodeConfig, getOpenCodeRestartCoordinator } from '../services/opencode-restart' import type { GitAuthService } from '../services/git-auth' import { DEFAULT_AGENTS_MD } from '../constants' import { validateSSHPrivateKey } from '../utils/ssh-validation' @@ -84,19 +85,6 @@ function getOpenCodeInstallMethod(): string { return 'curl' } -function getOpenCodeConfigContentToWrite( - rawContent: string, - sourceConfig: Record, - appliedConfig?: Record, - removedFields?: string[], -): string { - if (removedFields && removedFields.length > 0) { - return JSON.stringify(appliedConfig ?? sourceConfig, null, 2) - } - - return rawContent -} - async function restartOpenCodeSafe(openCodeSupervisor: OpenCodeSupervisor | undefined, context: string): Promise { try { await restartOpenCode(openCodeSupervisor) @@ -204,21 +192,6 @@ const SKILL_INSTALL_ERROR_STATUS: ReadonlyArray | undefined, - next: Record | undefined, - field: string -): boolean { - return JSON.stringify(previous?.[field]) !== JSON.stringify(next?.[field]) -} - -function needsOpenCodeRestart( - previous: Record | undefined, - next: Record | undefined -): boolean { - return ['agent', 'plugin', 'skills', 'provider'].some((field) => didConfigFieldChange(previous, next, field)) -} - function sandboxEnforcementChanged( previous: SandboxPreferences | undefined, next: SandboxPreferences | undefined, @@ -244,10 +217,6 @@ function getMarkdownUploadManifest(manifest: ReturnType entry.relativePath.toLowerCase().endsWith('.md')) } -function hasConfiguredPlugins(config: Record | undefined): boolean { - return Array.isArray(config?.plugin) && config.plugin.length > 0 -} - function execWithTimeout( args: [executable: string, ...commandArgs: string[]], timeoutMs: number, @@ -296,19 +265,6 @@ const UpdateSettingsSchema = z.object({ preferences: UserPreferencesSchema.partial(), }) -const CreateOpenCodeConfigSchema = z.object({ - name: z.string().min(1).max(255), - content: z.union([OpenCodeConfigSchema, z.string()]), - isDefault: z.boolean().optional(), -}) - -const UpdateOpenCodeConfigSchema = z.object({ - content: z.union([OpenCodeConfigSchema, z.string()]), - isDefault: z.boolean().optional(), -}) - - - const CreateCustomCommandSchema = z.object({ name: z.string().min(1).max(255), description: z.string().min(1).max(1000), @@ -463,166 +419,34 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic }) // OpenCode Config routes - app.get('/opencode-configs', async (c) => { - try { - const userId = c.req.query('userId') || 'default' - const configs = settingsService.getOpenCodeConfigs(userId) - return c.json(configs) - } catch (error) { - logger.error('Failed to get OpenCode configs:', error) - return c.json({ error: 'Failed to get OpenCode configs' }, 500) - } - }) - - app.post('/opencode-configs', async (c) => { + app.get('/opencode-config', async (c) => { try { - const userId = c.req.query('userId') || 'default' - const body = await c.req.json() - const validated = CreateOpenCodeConfigSchema.parse(body) - - if (validated.isDefault) { - settingsService.saveLastKnownGoodConfig(userId) - - const provisionalConfig = settingsService.createOpenCodeConfig( - { ...validated, isDefault: false }, - userId, - { suppressAutoDefault: true } - ) - - if (hasConfiguredPlugins(provisionalConfig.content)) { - const contentToWrite = provisionalConfig.rawContent - const config = settingsService.updateOpenCodeConfig(provisionalConfig.name, { - content: contentToWrite, - isDefault: true, - }, userId) - - if (!config) { - return c.json({ error: 'Failed to finalize OpenCode config creation' }, 500) - } - - const configPath = getOpenCodeConfigFilePath() - await writeFileContent(configPath, contentToWrite) - logger.info(`Wrote default config to: ${configPath}`) - opencodeServerManager.clearStartupError() - const { restartFailed, restartError } = await restartOpenCodeAfterCommit(openCodeSupervisor) - - return c.json(restartFailed ? { ...config, restartFailed, restartError } : config) - } - - const patchResult = await patchConfigWithRecovery(openCodeClient, provisionalConfig.content) - if (!patchResult.success) { - settingsService.deleteOpenCodeConfig(provisionalConfig.name, userId) - return c.json({ - error: 'Config validation failed', - details: patchResult.error, - validationIssues: patchResult.details, - removedFields: patchResult.removedFields - }, 400) - } - - const contentToWrite = getOpenCodeConfigContentToWrite( - provisionalConfig.rawContent, - provisionalConfig.content, - patchResult.appliedConfig, - patchResult.removedFields, - ) - const config = settingsService.updateOpenCodeConfig(provisionalConfig.name, { - content: contentToWrite, - isDefault: true, - }, userId) - - if (!config) { - return c.json({ error: 'Failed to finalize OpenCode config creation' }, 500) - } - - const configPath = getOpenCodeConfigFilePath() - await writeFileContent(configPath, contentToWrite) - logger.info(`Wrote default config to: ${configPath}`) - - if (patchResult.removedFields && patchResult.removedFields.length > 0) { - logger.info(`Config applied with auto-removed fields: ${patchResult.removedFields.join(', ')}`) - return c.json({ ...config, removedFields: patchResult.removedFields }) - } - - return c.json(config) + const config = await readOpenCodeConfigFile() + if (!config) { + return c.json({ error: 'No OpenCode config file found' }, 404) } - - const config = settingsService.createOpenCodeConfig(validated, userId) return c.json(config) } catch (error) { - logger.error('Failed to create OpenCode config:', error) - if (error instanceof z.ZodError) { - return c.json({ error: 'Invalid config data', details: error.issues }, 400) - } - if (error instanceof Error && error.message.includes('already exists')) { - return c.json({ error: error.message }, 409) - } - return c.json({ error: 'Failed to create OpenCode config' }, 500) + logger.error('Failed to get OpenCode config:', error) + return c.json({ error: 'Failed to get OpenCode config' }, 500) } }) - app.put('/opencode-configs/:name', async (c) => { + app.put('/opencode-config', async (c) => { + const userId = c.req.query('userId') || 'default' + + let body: unknown try { - const userId = c.req.query('userId') || 'default' - const configName = c.req.param('name') - const body = await c.req.json() - const validated = UpdateOpenCodeConfigSchema.parse(body) - - const existingConfig = settingsService.getOpenCodeConfigByName(configName, userId) - const previousContent = existingConfig?.content - - const config = settingsService.updateOpenCodeConfig(configName, validated, userId) - if (!config) { - return c.json({ error: 'Config not found' }, 404) - } - - if (config.isDefault) { - const restartRequired = needsOpenCodeRestart(previousContent, config.content) - const configPath = getOpenCodeConfigFilePath() - - if (restartRequired) { - const contentToWrite = config.rawContent - await writeFileContent(configPath, contentToWrite) - logger.info(`Wrote default config to: ${configPath}`) - logger.info('OpenCode configuration change requires a server restart; deferring until requested') - opencodeServerManager.markRestartPending() - return c.json({ ...config, restartRequired: true }) - } else { - const patchResult = await patchConfigWithRecovery(openCodeClient, config.content) - if (!patchResult.success) { - return c.json({ - error: 'Config saved but failed to apply', - details: patchResult.error, - validationIssues: patchResult.details, - removedFields: patchResult.removedFields - }, 500) - } - - const removedFields = patchResult.removedFields ?? [] - const contentToWrite = getOpenCodeConfigContentToWrite( - config.rawContent, - config.content, - patchResult.appliedConfig, - removedFields, - ) - - await writeFileContent(configPath, contentToWrite) - logger.info(`Wrote default config to: ${configPath}`) - - if (removedFields.length > 0) { - logger.info(`Config applied with auto-removed fields: ${removedFields.join(', ')}`) - const persisted = settingsService.updateOpenCodeConfig(configName, { content: contentToWrite }, userId) - if (!persisted) { - return c.json({ - error: 'OpenCode config was removed while applying recovered fields', - }, 409) - } - return c.json({ ...persisted, removedFields }) - } - } - } + body = await c.req.json() + } catch { + return c.json({ error: 'Invalid config data' }, 400) + } - return c.json(config) + try { + const { content } = UpdateOpenCodeConfigRequestSchema.parse(body) + const result = await applyOpenCodeConfigUpdate({ content, openCodeClient, settingsService, userId }) + const { status, body: responseBody } = toOpenCodeConfigApplyResponse(result) + return c.json(responseBody, status) } catch (error) { logger.error('Failed to update OpenCode config:', error) if (error instanceof z.ZodError) { @@ -632,112 +456,6 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic } }) - app.delete('/opencode-configs/:name', async (c) => { - try { - const userId = c.req.query('userId') || 'default' - const configName = c.req.param('name') - - const deleted = settingsService.deleteOpenCodeConfig(configName, userId) - if (!deleted) { - return c.json({ error: 'Config not found' }, 404) - } - - return c.json({ success: true }) - } catch (error) { - logger.error('Failed to delete OpenCode config:', error) - return c.json({ error: 'Failed to delete OpenCode config' }, 500) - } - }) - - app.post('/opencode-configs/:name/set-default', async (c) => { - try { - const userId = c.req.query('userId') || 'default' - const configName = c.req.param('name') - - settingsService.saveLastKnownGoodConfig(userId) - - const existingConfig = settingsService.getOpenCodeConfigByName(configName, userId) - if (!existingConfig) { - return c.json({ error: 'Config not found' }, 404) - } - - if (hasConfiguredPlugins(existingConfig.content)) { - const contentToWrite = existingConfig.rawContent - const config = settingsService.setDefaultOpenCodeConfig(configName, userId) - if (!config) { - return c.json({ error: 'Config not found' }, 404) - } - - const configPath = getOpenCodeConfigFilePath() - await writeFileContent(configPath, contentToWrite) - logger.info(`Wrote default config '${configName}' to: ${configPath}`) - opencodeServerManager.clearStartupError() - const { restartFailed, restartError } = await restartOpenCodeAfterCommit(openCodeSupervisor) - - return c.json(restartFailed ? { ...config, restartFailed, restartError } : config) - } - - const patchResult = await patchConfigWithRecovery(openCodeClient, existingConfig.content) - if (!patchResult.success) { - return c.json({ - error: 'Config validation failed', - details: patchResult.error, - validationIssues: patchResult.details, - removedFields: patchResult.removedFields - }, 400) - } - - const contentToWrite = getOpenCodeConfigContentToWrite( - existingConfig.rawContent, - existingConfig.content, - patchResult.appliedConfig, - patchResult.removedFields, - ) - const updatedConfig = settingsService.updateOpenCodeConfig(configName, { - content: contentToWrite, - }, userId) - - if (!updatedConfig) { - return c.json({ error: 'Failed to update OpenCode config' }, 500) - } - - const config = settingsService.setDefaultOpenCodeConfig(configName, userId) - if (!config) { - return c.json({ error: 'Config not found' }, 404) - } - - const configPath = getOpenCodeConfigFilePath() - await writeFileContent(configPath, contentToWrite) - logger.info(`Wrote default config '${configName}' to: ${configPath}`) - - if (patchResult.removedFields && patchResult.removedFields.length > 0) { - logger.info(`Config applied with auto-removed fields: ${patchResult.removedFields.join(', ')}`) - return c.json({ ...config, removedFields: patchResult.removedFields }) - } - - return c.json(config) - } catch (error) { - logger.error('Failed to set default OpenCode config:', error) - return c.json({ error: 'Failed to set default OpenCode config' }, 500) - } - }) - - app.get('/opencode-configs/default', async (c) => { - try { - const userId = c.req.query('userId') || 'default' - const config = settingsService.getDefaultOpenCodeConfig(userId) - - if (!config) { - return c.json({ error: 'No default config found' }, 404) - } - - return c.json(config) - } catch (error) { - logger.error('Failed to get default OpenCode config:', error) - return c.json({ error: 'Failed to get default OpenCode config' }, 500) - } - }) - app.post('/opencode-restart', async (c) => { try { logger.info('Manual OpenCode server restart requested') @@ -772,12 +490,9 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic 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 ?? false, protectExistingState: true, }) @@ -860,23 +575,15 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic app.post('/opencode-rollback', async (c) => { try { - const userId = c.req.query('userId') || 'default' logger.info('OpenCode config rollback requested') - const rollbackConfig = settingsService.rollbackToLastKnownGoodHealth(userId) - if (!rollbackConfig) { + const lastGood = settingsService.getLastKnownGoodConfig() + if (!lastGood) { return c.json({ error: 'No previous working config available for rollback' }, 404) } - const configPath = getOpenCodeConfigFilePath() - const config = settingsService.getDefaultOpenCodeConfig(userId) - if (!config) { - return c.json({ error: 'Failed to get default config after rollback' }, 500) - } - - const contentToWrite = config.rawContent - await writeFileContent(configPath, contentToWrite) - logger.info(`Rolled back to config '${rollbackConfig}'`) + await writeOpenCodeConfigFile(lastGood) + logger.info('Rolled back to the previous working config') opencodeServerManager.clearStartupError() try { @@ -884,7 +591,7 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic } catch (reloadError) { logger.error('Rollback config reload failed, attempting restart:', reloadError) - const deleted = settingsService.deleteFilesystemConfig() + const deleted = await deleteOpenCodeConfigFile() if (deleted) { logger.info('Deleted filesystem config, attempting restart with fallback') await new Promise(r => setTimeout(r, 1000)) @@ -894,9 +601,8 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic return c.json({ success: true, - message: `Server restarted after deleting problematic config. DB config '${rollbackConfig}' preserved for manual recovery.`, + message: 'Server restarted after deleting the broken config file. The previous working config remains available for rollback.', fallback: true, - configName: rollbackConfig }) } @@ -908,8 +614,7 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic return c.json({ success: true, - message: `Server reloaded with previous working config: ${rollbackConfig}`, - configName: rollbackConfig + message: 'Server reloaded with the previous working config', }) } catch (error) { logger.error('Failed to rollback OpenCode config:', error) diff --git a/backend/src/services/archive.ts b/backend/src/services/archive.ts index 86573c394..ed0491972 100644 --- a/backend/src/services/archive.ts +++ b/backend/src/services/archive.ts @@ -1,13 +1,18 @@ import archiver from 'archiver' import { createWriteStream, createReadStream } from 'fs' -import { readdir, stat, unlink } from 'fs/promises' +import { readdir, stat, unlink, realpath } from 'fs/promises' import path from 'path' import os from 'os' import { logger } from '../utils/logger' import { getReposPath } from '@opencode-manager/shared/config/env' -function resolvePath(userPath: string): string { - return path.isAbsolute(userPath) ? userPath : path.join(getReposPath(), userPath) +async function resolvePath(userPath: string): Promise { + const absolutePath = path.isAbsolute(userPath) ? userPath : path.join(getReposPath(), userPath) + try { + return await realpath(absolutePath) + } catch { + return absolutePath + } } export interface ArchiveOptions { @@ -205,7 +210,7 @@ async function filterIgnoredPaths(targetPath: string, allPaths: string[], option } export async function createRepoArchive(repoPath: string, options?: ArchiveOptions): Promise { - repoPath = resolvePath(repoPath) + repoPath = await resolvePath(repoPath) const repoName = path.basename(repoPath) const tempFile = path.join(os.tmpdir(), `${repoName}-${Date.now()}.zip`) @@ -243,7 +248,7 @@ export async function createRepoArchive(repoPath: string, options?: ArchiveOptio } export async function createDirectoryArchive(directoryPath: string, archiveName?: string, options?: ArchiveOptions): Promise { - directoryPath = resolvePath(directoryPath) + directoryPath = await resolvePath(directoryPath) const dirName = archiveName || path.basename(directoryPath) const tempFile = path.join(os.tmpdir(), `${dirName}-${Date.now()}.zip`) @@ -300,7 +305,7 @@ export async function getArchiveSize(filePath: string): Promise { } export async function getIgnoredPathsList(directoryPath: string): Promise { - directoryPath = resolvePath(directoryPath) + directoryPath = await resolvePath(directoryPath) logger.debug('[getIgnoredPathsList] Starting for:', directoryPath) const gitRoot = await findGitRoot(directoryPath) logger.debug('[getIgnoredPathsList] Git root:', gitRoot) diff --git a/backend/src/services/assistant-mode.ts b/backend/src/services/assistant-mode.ts index 4944b1034..c8116c59c 100644 --- a/backend/src/services/assistant-mode.ts +++ b/backend/src/services/assistant-mode.ts @@ -335,7 +335,7 @@ Use the \`${MANAGER_TOOL_NAME}\` tool with the \`request\` action. The tool runs { action: 'request', params: { - method: 'GET' | 'POST' | 'PATCH' | 'DELETE', + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', path: string // relative internal API path; query strings allowed body?: object // JSON body for POST and PATCH routes } @@ -575,7 +575,7 @@ Sending is rate limited to **10 notifications per minute**. Beyond that the tool export function buildSettingsSkill(): string { return `--- name: manager-settings -description: Read and modify safe user preferences with the ${MANAGER_TOOL_NAME} tool +description: Read and modify safe user preferences and the OpenCode configuration file with the ${MANAGER_TOOL_NAME} tool --- ## When to Load @@ -591,7 +591,7 @@ Use the \`${MANAGER_TOOL_NAME}\` tool with the \`request\` action. The tool runs { action: 'request', params: { - method: 'GET' | 'POST' | 'PATCH' | 'DELETE', + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', path: string // relative internal API path; query strings allowed body?: object // JSON body for POST and PATCH routes } @@ -713,6 +713,69 @@ Reload the assistant workspace by disposing the current OpenCode instance. Use t { "success": true } \`\`\` +## OpenCode Configuration + +The OpenCode configuration file on disk is the source of truth, and this endpoint is the only supported way to change it. Never edit \`opencode.json\` directly. + +### GET /opencode-config + +Read the current configuration file. Returns \`404\` when no config file exists yet. + +**Response (\`OpenCodeConfigFile\`):** +\`\`\`ts +{ + path: string // Absolute path of the configuration file + content: object // Parsed configuration + rawContent: string // Raw file content, including comments + isValid: boolean // Whether the file passes schema validation + validationIssues?: Array<{ path: string, message: string }> + updatedAt: number // Unix timestamp of the last write +} +\`\`\` + +**Example:** +\`\`\`json +{ + "action": "request", + "params": { + "method": "GET", + "path": "/opencode-config" + } +} +\`\`\` + +### PUT /opencode-config + +Persist a complete configuration. Read the file first, change only the keys the user asked for, and send the complete object back. + +**Request Body:** +\`\`\`ts +{ content: object } // The complete configuration to persist +\`\`\` + +**Example:** +\`\`\`json +{ + "action": "request", + "params": { + "method": "PUT", + "path": "/opencode-config", + "body": { + "content": { + "theme": "dark" + } + } + } +} +\`\`\` + +**Response:** +Returns the written \`OpenCodeConfigFile\`. Adds \`restartRequired: true\` when the change needs an OpenCode server restart, and \`removedFields\` when OpenCode dropped fields it does not accept. + +Returns \`400\` with \`validationIssues\` when OpenCode rejects the configuration. + +When the response contains \`restartRequired: true\`, tell the user to restart the OpenCode server from Settings. Never attempt the restart yourself: it would terminate your own session. + ## Safety - This API intentionally rejects any attempt to modify credentials, API keys, or other sensitive settings @@ -740,7 +803,7 @@ Use the \`${MANAGER_TOOL_NAME}\` tool with the \`request\` action. The tool runs { action: 'request', params: { - method: 'GET' | 'POST' | 'PATCH' | 'DELETE', + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', path: string // relative internal API path; query strings allowed body?: object // JSON body for POST and PATCH routes } @@ -779,7 +842,6 @@ List all repos available to OpenCode Manager. The repos are returned in the orde clonedAt: number // Unix timestamp lastPulled?: number lastAccessedAt?: number - openCodeConfigName?: string isWorktree?: boolean isLocal?: boolean }> diff --git a/backend/src/services/opencode-config-apply.ts b/backend/src/services/opencode-config-apply.ts new file mode 100644 index 000000000..934e69513 --- /dev/null +++ b/backend/src/services/opencode-config-apply.ts @@ -0,0 +1,102 @@ +import { OpenCodeConfigSchema } from '@opencode-manager/shared/schemas' +import { parseJsonc } from '@opencode-manager/shared/utils' +import type { OpenCodeConfigFile, OpenCodeConfigInput } from '../types/settings' +import { normalizeOpenCodeConfigContent, readOpenCodeConfigFile, withOpenCodeConfigLock, writeOpenCodeConfigFile } from './opencode-config-file' +import { patchConfigWithRecovery, type PatchConfigValidationIssue } from './opencode/config-recovery' +import type { OpenCodeClient } from './opencode/client' +import { opencodeServerManager } from './opencode-single-server' +import type { SettingsService } from './settings' + +export type ApplyOpenCodeConfigResult = + | { status: 'restart_pending'; config: OpenCodeConfigFile } + | { status: 'applied'; config: OpenCodeConfigFile; removedFields: string[] } + | { status: 'rejected'; error: string; validationIssues: PatchConfigValidationIssue[]; removedFields: string[] } + +export interface ApplyOpenCodeConfigInput { + content: OpenCodeConfigInput | string + openCodeClient: OpenCodeClient + settingsService: SettingsService + userId?: string +} + +function didConfigFieldChange( + previous: Record | undefined, + next: Record | undefined, + field: string, +): boolean { + return JSON.stringify(previous?.[field]) !== JSON.stringify(next?.[field]) +} + +export function needsOpenCodeRestart( + previous: Record | undefined, + next: Record | undefined, +): boolean { + return ['agent', 'plugin', 'skills', 'provider'].some((field) => didConfigFieldChange(previous, next, field)) +} + +export function toOpenCodeConfigApplyResponse( + result: ApplyOpenCodeConfigResult, +): { status: 200 | 400; body: Record } { + if (result.status === 'restart_pending') { + return { status: 200, body: { ...result.config, restartRequired: true } } + } + + if (result.status === 'applied') { + return { + status: 200, + body: result.removedFields.length > 0 + ? { ...result.config, removedFields: result.removedFields } + : { ...result.config }, + } + } + + return { + status: 400, + body: { + error: 'Config validation failed', + details: result.error, + validationIssues: result.validationIssues, + removedFields: result.removedFields, + }, + } +} + +export async function applyOpenCodeConfigUpdate( + input: ApplyOpenCodeConfigInput, +): Promise { + return withOpenCodeConfigLock(async () => { + const { content, openCodeClient, settingsService, userId } = input + + const rawContent = normalizeOpenCodeConfigContent(content) + const nextContent = OpenCodeConfigSchema.parse(parseJsonc(rawContent)) + + const previous = await readOpenCodeConfigFile() + if (previous?.isValid) { + settingsService.saveLastKnownGoodConfig(previous.rawContent, userId) + } + + if (needsOpenCodeRestart(previous?.content, nextContent)) { + const config = await writeOpenCodeConfigFile(rawContent) + opencodeServerManager.markRestartPending() + return { status: 'restart_pending', config } + } + + const patchResult = await patchConfigWithRecovery(openCodeClient, nextContent) + if (!patchResult.success) { + return { + status: 'rejected', + error: patchResult.error ?? 'Config validation failed', + validationIssues: patchResult.details ?? [], + removedFields: patchResult.removedFields ?? [], + } + } + + const removedFields = patchResult.removedFields ?? [] + const contentToWrite = removedFields.length > 0 + ? JSON.stringify(patchResult.appliedConfig ?? nextContent, null, 2) + : rawContent + const config = await writeOpenCodeConfigFile(contentToWrite) + + return { status: 'applied', config, removedFields } + }) +} diff --git a/backend/src/services/opencode-config-file.ts b/backend/src/services/opencode-config-file.ts new file mode 100644 index 000000000..7528c6384 --- /dev/null +++ b/backend/src/services/opencode-config-file.ts @@ -0,0 +1,171 @@ +import { copyFile, readdir, rm, stat } from 'fs/promises' +import path from 'path' +import { getOpenCodeConfigFilePath, getOpenCodeHealthWatchPath } from '@opencode-manager/shared/config/env' +import { OpenCodeConfigSchema } from '@opencode-manager/shared/schemas' +import { parseJsonc } from '@opencode-manager/shared/utils' +import type { OpenCodeConfigFile, OpenCodeConfigInput, OpenCodeConfigValidationIssue } from '../types/settings' +import { logger } from '../utils/logger' +import { withFileLock } from '../utils/atomic-json' +import { existingFileMode, writeFileAtomic } from '../utils/fs-safe' +import { ensureDirectoryExists, fileExists, readFileContent } from './file-operations' + +export const OPENCODE_CONFIG_SEED = JSON.stringify({ $schema: 'https://opencode.ai/config.json' }, null, 2) + +export const HEALTH_WATCH_MAX_ENTRIES = 20 + +export function withOpenCodeConfigLock(fn: () => Promise): Promise { + return withFileLock(getOpenCodeConfigFilePath(), fn) +} + +interface OpenCodeConfigParseResult { + content: Record + isValid: boolean + validationIssues?: OpenCodeConfigValidationIssue[] +} + +export function normalizeOpenCodeConfigContent(content: OpenCodeConfigInput | string): string { + return typeof content === 'string' ? content : JSON.stringify(content, null, 2) +} + +export function parseOpenCodeConfigContent(rawContent: string): OpenCodeConfigParseResult { + let parsed: unknown + try { + parsed = parseJsonc(rawContent) + } catch (error) { + const message = error instanceof Error ? error.message : 'Invalid JSONC' + logger.error(`Failed to parse OpenCode config: ${message}`) + return { + content: {}, + isValid: false, + validationIssues: [{ path: 'root', message }], + } + } + + const content = parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record + : {} + + const validated = OpenCodeConfigSchema.safeParse(parsed) + if (validated.success) { + return { + content: validated.data as Record, + isValid: true, + } + } + + const validationIssues = validated.error.issues.map((issue) => ({ + path: issue.path.length > 0 ? issue.path.join('.') : 'root', + message: issue.message, + })) + logger.error(`Failed to validate OpenCode config: ${validationIssues.map((issue) => `${issue.path}: ${issue.message}`).join('; ')}`) + + return { + content, + isValid: false, + validationIssues, + } +} + +export async function readOpenCodeConfigFile(): Promise { + const configPath = getOpenCodeConfigFilePath() + + let updatedAt: number + try { + const stats = await stat(configPath) + updatedAt = stats.mtimeMs + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null + } + throw error + } + + const rawContent = await readFileContent(configPath) + + return { + path: configPath, + rawContent, + ...parseOpenCodeConfigContent(rawContent), + updatedAt, + } +} + +export async function writeOpenCodeConfigFile(rawContent: string): Promise { + const parsed = OpenCodeConfigSchema.parse(parseJsonc(rawContent)) + + const configPath = getOpenCodeConfigFilePath() + await writeFileAtomic(configPath, rawContent, { mode: await existingFileMode(configPath) }) + + const stats = await stat(configPath) + + return { + path: configPath, + rawContent, + content: parsed as Record, + isValid: true, + updatedAt: stats.mtimeMs, + } +} + +export async function pruneHealthWatchDirectory(dirPath: string): Promise { + try { + const entries = await readdir(dirPath, { withFileTypes: true }) + const files = await Promise.all( + entries + .filter((entry) => entry.isFile()) + .map(async (entry) => { + const filePath = path.join(dirPath, entry.name) + const fileStats = await stat(filePath) + return { filePath, mtimeMs: fileStats.mtimeMs } + }), + ) + + if (files.length <= HEALTH_WATCH_MAX_ENTRIES) return + + files.sort((left, right) => right.mtimeMs - left.mtimeMs) + await Promise.all( + files.slice(HEALTH_WATCH_MAX_ENTRIES).map((file) => rm(file.filePath, { force: true })), + ) + } catch (error) { + logger.warn('Failed to prune OpenCode health-watch directory:', error) + } +} + +export async function archiveBrokenOpenCodeConfigFile(): Promise { + const configPath = getOpenCodeConfigFilePath() + if (!(await fileExists(configPath))) { + return null + } + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-') + const archivePath = path.join(getOpenCodeHealthWatchPath(), `opencode-config-broken-${timestamp}.json`) + + try { + await ensureDirectoryExists(getOpenCodeHealthWatchPath()) + await copyFile(configPath, archivePath) + await pruneHealthWatchDirectory(getOpenCodeHealthWatchPath()) + logger.warn(`Archived broken OpenCode config to ${archivePath}`) + return archivePath + } catch (error) { + logger.error('Failed to archive broken OpenCode config:', error) + return null + } +} + +export async function deleteOpenCodeConfigFile(): Promise { + const configPath = getOpenCodeConfigFilePath() + + if (!(await fileExists(configPath))) { + logger.warn('Config file does not exist:', configPath) + return false + } + + try { + await rm(configPath, { force: true }) + logger.info('Deleted filesystem config to allow server startup:', configPath) + return true + } catch (error) { + logger.error('Failed to delete config file:', error) + return false + } +} diff --git a/backend/src/services/opencode-import.ts b/backend/src/services/opencode-import.ts index a224a97ec..03875d794 100644 --- a/backend/src/services/opencode-import.ts +++ b/backend/src/services/opencode-import.ts @@ -1,12 +1,11 @@ import os from 'os' import path from 'path' +import { existsSync } from 'node:fs' import { cp, mkdtemp, readdir, rename, rm } from 'fs/promises' -import { Database as SQLiteDatabase, type Database } from 'bun:sqlite' -import { OpenCodeConfigSchema } from '@opencode-manager/shared/schemas' +import { Database as SQLiteDatabase } from 'bun:sqlite' 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' +import { parseOpenCodeConfigContent, writeOpenCodeConfigFile } from './opencode-config-file' +import { ensureDirectoryExists, fileExists, readFileContent } from './file-operations' const OPENCODE_STATE_DB_FILENAMES = new Set(['opencode.db', 'opencode.db-shm', 'opencode.db-wal']) @@ -19,10 +18,10 @@ export interface OpenCodeImportStatus { } export interface SyncOpenCodeImportOptions { - db: Database - userId?: string overwriteState?: boolean protectExistingState?: boolean + importConfig?: boolean + status?: OpenCodeImportStatus } export interface SyncOpenCodeImportResult extends OpenCodeImportStatus { @@ -53,14 +52,11 @@ export function getImportPathCandidates(envKey: string, fallbackPath: string): s 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 +export function getFirstExistingConfigSourcePath(): string | null { + return getImportPathCandidates( + 'OPENCODE_IMPORT_CONFIG_PATH', + path.join(os.homedir(), '.config', 'opencode', 'opencode.json') + ).find(candidate => existsSync(candidate)) ?? null } async function getFirstExistingPathWithDatabase(paths: string[]): Promise { @@ -140,9 +136,7 @@ export async function getOpenCodeImportStatus(): Promise { 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 configSourcePath = getFirstExistingConfigSourcePath() const stateSourcePath = await getFirstExistingPathWithDatabase( getImportPathCandidates('OPENCODE_IMPORT_STATE_PATH', path.join(os.homedir(), '.local', 'share', 'opencode')) ) @@ -156,38 +150,20 @@ export async function getOpenCodeImportStatus(): Promise { } } -async function importOpenCodeConfigFromSource(db: Database, userId: string, sourcePath: string, workspaceConfigPath: string): Promise { +async function importOpenCodeConfigFromSource(sourcePath: string): Promise { const rawContent = await readFileContent(sourcePath) - const parsed = parseJsonc(rawContent) - const validation = OpenCodeConfigSchema.safeParse(parsed) + const { isValid } = parseOpenCodeConfigContent(rawContent) - if (!validation.success) { + if (!isValid) { 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) + await writeOpenCodeConfigFile(rawContent) return true } export async function syncOpenCodeImport(options: SyncOpenCodeImportOptions): Promise { - const initialStatus = await getOpenCodeImportStatus() - const userId = options.userId || 'default' + const initialStatus = options.status ?? await getOpenCodeImportStatus() const overwriteState = options.overwriteState === true let configImported = false let stateImported = false @@ -198,8 +174,8 @@ export async function syncOpenCodeImport(options: SyncOpenCodeImportOptions): Pr ) } - if (initialStatus.configSourcePath) { - configImported = await importOpenCodeConfigFromSource(options.db, userId, initialStatus.configSourcePath, initialStatus.workspaceConfigPath) + if (options.importConfig !== false && initialStatus.configSourcePath) { + configImported = await importOpenCodeConfigFromSource(initialStatus.configSourcePath) } if (initialStatus.stateSourcePath && (overwriteState || !initialStatus.workspaceStateExists)) { diff --git a/backend/src/services/opencode-manager-tool-plugin.ts b/backend/src/services/opencode-manager-tool-plugin.ts index bcd6209a1..d62d169cc 100644 --- a/backend/src/services/opencode-manager-tool-plugin.ts +++ b/backend/src/services/opencode-manager-tool-plugin.ts @@ -7,6 +7,8 @@ export const MANAGER_TOOL_REQUEST_TIMEOUT_MS = 15000 export const MANAGER_TOOL_ALLOWED_ROUTES = [ 'GET /settings', 'PATCH /settings', + 'GET /opencode-config', + 'PUT /opencode-config', 'POST /assistant/reload', 'GET /repos', 'GET /repos/*/git-info', @@ -26,7 +28,7 @@ export const MANAGER_TOOL_ALLOWED_ROUTES = [ 'POST /repos/*/schedules/*/runs/*/cancel', ] as const -export const MANAGER_TOOL_ALLOWED_METHODS = ['GET', 'POST', 'PATCH', 'DELETE'] as const +export const MANAGER_TOOL_ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const export function parseAllowedRoute(route: string): { method: string; path: string } { const separator = route.indexOf(' ') @@ -159,7 +161,7 @@ export default async function () { 'The action runs inside OpenCode Manager itself, so it needs no token and no network access from the agent shell, and it works in sandboxed sessions and scheduled runs.', 'Actions:', '- send_notification: send a push notification to every device the user has registered.', - '- request: call an allow-listed internal API route to read and manage settings, repos, OpenCode workspaces, and schedules.', + '- request: call an allow-listed internal API route to read and manage settings, the OpenCode configuration file, repos, OpenCode workspaces, and schedules.', 'Allowed request routes:', ].concat(ALLOWED_ROUTES.map(function (route) { return '- ' + route })).join('\\n'), args: { diff --git a/backend/src/services/opencode-plugin-quarantine.ts b/backend/src/services/opencode-plugin-quarantine.ts index 0c22171da..f8a757d18 100644 --- a/backend/src/services/opencode-plugin-quarantine.ts +++ b/backend/src/services/opencode-plugin-quarantine.ts @@ -3,7 +3,7 @@ import { lstat, realpath } from 'fs/promises' import path from 'path' import { parseJsonc } from '@opencode-manager/shared/utils' import { logger } from '../utils/logger' -import { mkdirSafe, writeFileAtomic } from '../utils/fs-safe' +import { existingFileMode, mkdirSafe, writeFileAtomic } from '../utils/fs-safe' import { getOpenCodePluginDir } from './opencode/plugin-registry' import { isRecord, @@ -242,14 +242,6 @@ async function restorePluginEntries(dir: string): Promise { } } -async function existingFileMode(filePath: string): Promise { - try { - return (await fs.stat(filePath)).mode & 0o777 - } catch { - return undefined - } -} - async function restoreEnforcementConfigSections(configPath: string): Promise { const backupPath = `${configPath}${PLUGIN_CONFIG_BACKUP_SUFFIX}` if (!(await pathExists(backupPath))) return diff --git a/backend/src/services/opencode-single-server.ts b/backend/src/services/opencode-single-server.ts index 0f1d2abf4..96576bc72 100644 --- a/backend/src/services/opencode-single-server.ts +++ b/backend/src/services/opencode-single-server.ts @@ -27,11 +27,12 @@ import { ENV, } from '@opencode-manager/shared/config/env' import { parseJsonc } from '@opencode-manager/shared/utils' +import { ZodError } from 'zod' import type { Database } from 'bun:sqlite' import { compareVersions } from '../utils/version-utils' import { patchConfigWithRecovery } from './opencode/config-recovery' import type { OpenCodeClient } from './opencode/client' -import { writeFileContent } from './file-operations' +import { withOpenCodeConfigLock, writeOpenCodeConfigFile } from './opencode-config-file' import { getOrCreateInternalToken } from './internal-token' import { installManagedPlugins } from './opencode/plugin-registry' import { getOpenCodePluginDiscoveryHome, restoreQuarantinedOpenCodePlugins } from './opencode-plugin-quarantine' @@ -1065,7 +1066,21 @@ class OpenCodeServerManager { } if (patchResult.removedFields && patchResult.removedFields.length > 0 && patchResult.appliedConfig) { - await writeFileContent(configPath, JSON.stringify(patchResult.appliedConfig, null, 2)) + const cleanedConfigContent = JSON.stringify(patchResult.appliedConfig, null, 2) + try { + await withOpenCodeConfigLock(() => writeOpenCodeConfigFile(cleanedConfigContent)) + } catch (error) { + if (error instanceof ZodError) { + const validationIssues = error.issues.map((issue) => ({ + path: issue.path.length > 0 ? issue.path.join('.') : 'root', + message: issue.message, + })) + const issueSummary = validationIssues.map((d) => `${d.path}: ${d.message}`).join('; ') + logger.error(`Config reload validation errors: ${issueSummary}`) + throw new ConfigReloadError('Cleaned config failed validation', validationIssues, patchResult.removedFields) + } + throw error + } logger.info(`Persisted cleaned config to ${configPath} after removing fields: ${patchResult.removedFields.join(', ')}`) } diff --git a/backend/src/services/opencode-supervisor.ts b/backend/src/services/opencode-supervisor.ts index a301eb362..1fb48be16 100644 --- a/backend/src/services/opencode-supervisor.ts +++ b/backend/src/services/opencode-supervisor.ts @@ -2,7 +2,8 @@ import path from 'path' import type { SettingsService } from './settings' import { logger } from '../utils/logger' import { ensureDirectoryExists, writeFileContent } from './file-operations' -import { getOpenCodeConfigFilePath, getWorkspacePath, ENV } from '@opencode-manager/shared/config/env' +import { getOpenCodeHealthWatchPath, ENV } from '@opencode-manager/shared/config/env' +import { archiveBrokenOpenCodeConfigFile, pruneHealthWatchDirectory, writeOpenCodeConfigFile, OPENCODE_CONFIG_SEED } from './opencode-config-file' import type { OpenCodeServerManager } from './opencode-single-server' export const OPENCODE_LIFECYCLE_STATES = [ @@ -55,7 +56,6 @@ export interface OpenCodeLifecycleStatus { interface OpenCodeSupervisorOptions { pollIntervalMs?: number failureThreshold?: number - userId?: string watchEnabled?: boolean } @@ -320,7 +320,7 @@ export class OpenCodeSupervisor { private async captureDebugSnapshot(): Promise { const timestamp = new Date().toISOString().replace(/[:.]/g, '-') - const debugPath = path.join(getWorkspacePath(), '.opencode', 'state', 'health-watch', `opencode-health-${timestamp}.json`) + const debugPath = path.join(getOpenCodeHealthWatchPath(), `opencode-health-${timestamp}.json`) const payload = JSON.stringify({ capturedAt: timestamp, startupError: this.openCodeServerManager.getLastStartupError(), @@ -330,52 +330,27 @@ export class OpenCodeSupervisor { await ensureDirectoryExists(path.dirname(debugPath)) await writeFileContent(debugPath, payload) + await pruneHealthWatchDirectory(getOpenCodeHealthWatchPath()) } private async rollbackToLastKnownGood(): Promise { - this.settingsService.archiveBrokenConfig(this.userId) - const lastGood = this.settingsService.restoreToLastKnownGoodConfig(this.userId) + await archiveBrokenOpenCodeConfigFile() + const lastGood = this.settingsService.getLastKnownGoodConfig() if (!lastGood) { throw new Error('No last known good config available') } - const config = this.settingsService.updateOpenCodeConfig(lastGood.configName, { content: lastGood.content }, this.userId) - if (!config) { - throw new Error(`Failed to restore OpenCode config '${lastGood.configName}'`) - } - - await this.writeConfig(lastGood.content) + await writeOpenCodeConfigFile(lastGood) this.openCodeServerManager.clearStartupError() await this.openCodeServerManager.restart() } private async seedDefaultConfig(): Promise { - const seedConfig = JSON.stringify({ $schema: 'https://opencode.ai/config.json' }, null, 2) - const defaultConfig = this.settingsService.getDefaultOpenCodeConfig(this.userId) - - if (defaultConfig) { - this.settingsService.updateOpenCodeConfig(defaultConfig.name, { content: seedConfig }, this.userId) - } else { - this.settingsService.createOpenCodeConfig( - { - name: 'default', - content: seedConfig, - isDefault: true, - }, - this.userId, - ) - } - - await this.writeConfig(seedConfig) + await writeOpenCodeConfigFile(OPENCODE_CONFIG_SEED) this.openCodeServerManager.clearStartupError() await this.openCodeServerManager.restart() } - private async writeConfig(content: string): Promise { - const configPath = getOpenCodeConfigFilePath() - await writeFileContent(configPath, content) - } - private startWatching(): void { if (!this.isWatchEnabled()) { logger.info('OpenCode supervisor health polling disabled') @@ -429,8 +404,4 @@ export class OpenCodeSupervisor { private getNextRecoveryAction(): OpenCodeRecoveryAction | null { return OPENCODE_RECOVERY_ACTIONS.find((action) => !this.attemptedRecoveryActions.includes(action)) ?? null } - - private get userId(): string { - return this.options.userId ?? 'default' - } } diff --git a/backend/src/services/settings.ts b/backend/src/services/settings.ts index f9c9c43cf..b32e357de 100644 --- a/backend/src/services/settings.ts +++ b/backend/src/services/settings.ts @@ -1,49 +1,17 @@ import { Database } from 'bun:sqlite' -import { unlinkSync, existsSync } from 'fs' -import { getOpenCodeConfigFilePath } from '@opencode-manager/shared/config/env' import { logger } from '../utils/logger' import { parseJsonc } from '@opencode-manager/shared/utils' -import { z } from 'zod' import { encryptSecret, decryptSecret } from '../utils/crypto' import { ENV } from '@opencode-manager/shared/config/env' -import type { - UserPreferences, - SettingsResponse, - CreateOpenCodeConfigRequest, - UpdateOpenCodeConfigRequest +import type { + UserPreferences, + SettingsResponse, } from '../types/settings' import { UserPreferencesSchema, - OpenCodeConfigSchema, DEFAULT_USER_PREFERENCES, } from '../types/settings' -interface OpenCodeConfigValidationIssue { - path: string - message: string -} - -interface OpenCodeConfigWithRaw { - id: number - name: string - content: Record - rawContent: string - validationIssues?: OpenCodeConfigValidationIssue[] - isValid: boolean - isDefault: boolean - createdAt: number - updatedAt: number -} - -interface OpenCodeConfigResponseWithRaw { - configs: OpenCodeConfigWithRaw[] - defaultConfig: OpenCodeConfigWithRaw | null -} - -interface CreateOpenCodeConfigOptions { - suppressAutoDefault?: boolean -} - interface OpenCodeServerPasswordState { value: string createdAt: number @@ -56,37 +24,6 @@ export class SettingsService { constructor(private db: Database) {} - private getValidationIssues(error: z.ZodError): OpenCodeConfigValidationIssue[] { - return error.issues.map((issue) => ({ - path: issue.path.length > 0 ? issue.path.join('.') : 'root', - message: issue.message, - })) - } - - private parseStoredConfig(rawContent: string, configName: string): { content: Record; validationIssues?: OpenCodeConfigValidationIssue[]; isValid: boolean } { - const parsed = parseJsonc(rawContent) - const content = parsed && typeof parsed === 'object' && !Array.isArray(parsed) - ? parsed as Record - : {} - - const validated = OpenCodeConfigSchema.safeParse(parsed) - if (validated.success) { - return { - content: validated.data as Record, - isValid: true, - } - } - - const validationIssues = this.getValidationIssues(validated.error) - logger.error(`Failed to validate config ${configName}: ${validationIssues.map((issue) => `${issue.path}: ${issue.message}`).join('; ')}`) - - return { - content, - validationIssues, - isValid: false, - } - } - initializeLastKnownGoodConfig(userId: string = 'default'): void { const settings = this.getSettings(userId) if (settings.preferences.lastKnownGoodConfig) { @@ -102,6 +39,10 @@ export class SettingsService { } } + getLastKnownGoodConfig(): string | null { + return SettingsService.lastKnownGoodConfigContent + } + getSettings(userId: string = 'default'): SettingsResponse { const row = this.db .query('SELECT preferences, updated_at FROM user_preferences WHERE user_id = ?') @@ -177,433 +118,10 @@ export class SettingsService { } } - getOpenCodeConfigs(userId: string = 'default'): OpenCodeConfigResponseWithRaw { - const rows = this.db - .query('SELECT * FROM opencode_configs WHERE user_id = ? ORDER BY created_at DESC') - .all(userId) as Array<{ - id: number - user_id: string - config_name: string - config_content: string - is_default: boolean - created_at: number - updated_at: number - }> - - const configs: OpenCodeConfigWithRaw[] = [] - let defaultConfig: OpenCodeConfigWithRaw | null = null - - for (const row of rows) { - try { - const rawContent = row.config_content - const parsedConfig = this.parseStoredConfig(rawContent, row.config_name) - - const config: OpenCodeConfigWithRaw = { - id: row.id, - name: row.config_name, - content: parsedConfig.content, - rawContent: rawContent, - validationIssues: parsedConfig.validationIssues, - isValid: parsedConfig.isValid, - isDefault: Boolean(row.is_default), - createdAt: row.created_at, - updatedAt: row.updated_at, - } - - configs.push(config) - - if (config.isDefault) { - defaultConfig = config - } - } catch (error) { - logger.error(`Failed to parse config ${row.config_name}:`, error) - } - } - - return { - configs, - defaultConfig, - } - } - - createOpenCodeConfig( - request: CreateOpenCodeConfigRequest, - userId: string = 'default', - options: CreateOpenCodeConfigOptions = {} - ): OpenCodeConfigWithRaw { - // Check for existing config with the same name - const existing = this.getOpenCodeConfigByName(request.name, userId) - if (existing) { - throw new Error(`Config with name '${request.name}' already exists`) - } - - const rawContent = typeof request.content === 'string' - ? request.content - : JSON.stringify(request.content, null, 2) - - const parsedContent = typeof request.content === 'string' - ? parseJsonc(request.content) - : request.content - - const contentValidated = OpenCodeConfigSchema.parse(parsedContent) - const now = Date.now() - - const existingCount = this.db - .query('SELECT COUNT(*) as count FROM opencode_configs WHERE user_id = ?') - .get(userId) as { count: number } - - const shouldBeDefault = request.isDefault || (!options.suppressAutoDefault && existingCount.count === 0) - - if (shouldBeDefault) { - this.db - .query('UPDATE opencode_configs SET is_default = FALSE WHERE user_id = ?') - .run(userId) - } - - const result = this.db - .query( - `INSERT INTO opencode_configs (user_id, config_name, config_content, is_default, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?)` - ) - .run( - userId, - request.name, - rawContent, - shouldBeDefault, - now, - now - ) - - const config: OpenCodeConfigWithRaw = { - id: result.lastInsertRowid as number, - name: request.name, - content: contentValidated as Record, - rawContent: rawContent, - isValid: true, - isDefault: shouldBeDefault, - createdAt: now, - updatedAt: now, - } - - logger.info(`Created OpenCode config '${config.name}' for user: ${userId}`) - return config - } - - updateOpenCodeConfig( - configName: string, - request: UpdateOpenCodeConfigRequest, - userId: string = 'default' - ): OpenCodeConfigWithRaw | null { - const existing = this.db - .query('SELECT * FROM opencode_configs WHERE user_id = ? AND config_name = ?') - .get(userId, configName) as { - id: number - config_content: string - is_default: boolean - created_at: number - } | undefined - - if (!existing) { - return null - } - - const rawContent = typeof request.content === 'string' - ? request.content - : JSON.stringify(request.content, null, 2) - - const parsedContent = typeof request.content === 'string' - ? parseJsonc(request.content) - : request.content - - const contentValidated = OpenCodeConfigSchema.parse(parsedContent) - const now = Date.now() - - if (request.isDefault) { - this.db - .query('UPDATE opencode_configs SET is_default = FALSE WHERE user_id = ?') - .run(userId) - } - - this.db - .query( - `UPDATE opencode_configs - SET config_content = ?, is_default = ?, updated_at = ? - WHERE user_id = ? AND config_name = ?` - ) - .run( - rawContent, - request.isDefault !== undefined ? request.isDefault : existing.is_default, - now, - userId, - configName - ) - - const config: OpenCodeConfigWithRaw = { - id: existing.id, - name: configName, - content: contentValidated as Record, - rawContent: rawContent, - isValid: true, - isDefault: request.isDefault !== undefined ? request.isDefault : existing.is_default, - createdAt: existing.created_at, - updatedAt: now, - } - - logger.info(`Updated OpenCode config '${configName}' for user: ${userId}`) - return config - } - - deleteOpenCodeConfig(configName: string, userId: string = 'default'): boolean { - const result = this.db - .query('DELETE FROM opencode_configs WHERE user_id = ? AND config_name = ?') - .run(userId, configName) - - const deleted = result.changes > 0 - if (deleted) { - logger.info(`Deleted OpenCode config '${configName}' for user: ${userId}`) - this.ensureSingleConfigIsDefault(userId) - } - - return deleted - } - - setDefaultOpenCodeConfig(configName: string, userId: string = 'default'): OpenCodeConfigWithRaw | null { - const existing = this.db - .query('SELECT * FROM opencode_configs WHERE user_id = ? AND config_name = ?') - .get(userId, configName) as { - id: number - config_content: string - created_at: number - } | undefined - - if (!existing) { - return null - } - - this.db - .query('UPDATE opencode_configs SET is_default = FALSE WHERE user_id = ?') - .run(userId) - - const now = Date.now() - this.db - .query( - `UPDATE opencode_configs - SET is_default = TRUE, updated_at = ? - WHERE user_id = ? AND config_name = ?` - ) - .run(now, userId, configName) - - try { - const rawContent = existing.config_content - const parsedConfig = this.parseStoredConfig(rawContent, configName) - - const config: OpenCodeConfigWithRaw = { - id: existing.id, - name: configName, - content: parsedConfig.content, - rawContent: rawContent, - validationIssues: parsedConfig.validationIssues, - isValid: parsedConfig.isValid, - isDefault: true, - createdAt: existing.created_at, - updatedAt: now, - } - - logger.info(`Set '${configName}' as default OpenCode config for user: ${userId}`) - return config - } catch (error) { - logger.error(`Failed to parse config ${configName}:`, error) - return null - } - } - - getDefaultOpenCodeConfig(userId: string = 'default'): OpenCodeConfigWithRaw | null { - const row = this.db - .query('SELECT * FROM opencode_configs WHERE user_id = ? AND is_default = TRUE') - .get(userId) as { - id: number - config_name: string - config_content: string - created_at: number - updated_at: number - } | undefined - - if (!row) { - return null - } - - try { - const rawContent = row.config_content - const parsedConfig = this.parseStoredConfig(rawContent, row.config_name) - - return { - id: row.id, - name: row.config_name, - content: parsedConfig.content, - rawContent: rawContent, - validationIssues: parsedConfig.validationIssues, - isValid: parsedConfig.isValid, - isDefault: true, - createdAt: row.created_at, - updatedAt: row.updated_at, - } - } catch (error) { - logger.error(`Failed to parse default config:`, error) - return null - } - } - - getOpenCodeConfigByName(configName: string, userId: string = 'default'): OpenCodeConfigWithRaw | null { - const row = this.db - .query('SELECT * FROM opencode_configs WHERE user_id = ? AND config_name = ?') - .get(userId, configName) as { - id: number - config_name: string - config_content: string - is_default: boolean - created_at: number - updated_at: number - } | undefined - - if (!row) { - return null - } - - try { - const rawContent = row.config_content - const parsedConfig = this.parseStoredConfig(rawContent, configName) - - return { - id: row.id, - name: row.config_name, - content: parsedConfig.content, - rawContent: rawContent, - validationIssues: parsedConfig.validationIssues, - isValid: parsedConfig.isValid, - isDefault: Boolean(row.is_default), - createdAt: row.created_at, - updatedAt: row.updated_at, - } - } catch (error) { - logger.error(`Failed to parse config ${configName}:`, error) - return null - } - } - - getOpenCodeConfigContent(configName: string, userId: string = 'default'): string | null { - const row = this.db - .query('SELECT config_content FROM opencode_configs WHERE user_id = ? AND config_name = ?') - .get(userId, configName) as { config_content: string } | undefined - - if (!row) { - logger.error(`Config '${configName}' not found for user ${userId}`) - return null - } - - return row.config_content - } - - ensureSingleConfigIsDefault(userId: string = 'default'): void { - const hasDefault = this.db - .query('SELECT COUNT(*) as count FROM opencode_configs WHERE user_id = ? AND is_default = TRUE') - .get(userId) as { count: number } - - if (hasDefault.count === 0) { - const firstConfig = this.db - .query('SELECT config_name FROM opencode_configs WHERE user_id = ? ORDER BY created_at ASC LIMIT 1') - .get(userId) as { config_name: string } | undefined - - if (firstConfig) { - this.db - .query('UPDATE opencode_configs SET is_default = TRUE WHERE user_id = ? AND config_name = ?') - .run(userId, firstConfig.config_name) - logger.info(`Auto-set '${firstConfig.config_name}' as default (only config)`) - } - } - } - - saveLastKnownGoodConfig(userId: string = 'default'): void { - const config = this.getDefaultOpenCodeConfig(userId) - if (config) { - SettingsService.lastKnownGoodConfigContent = config.rawContent - this.persistLastKnownGoodConfig(userId) - logger.info(`Saved last known good config: ${config.name}`) - } - } - - archiveBrokenConfig(userId: string = 'default'): string | null { - const current = this.getDefaultOpenCodeConfig(userId) - if (!current) { - return null - } - - const ts = new Date().toISOString().replace(/[:.]/g, '-') - const backupName = `${current.name}-broken-${ts}` - try { - this.createOpenCodeConfig( - { - name: backupName, - content: current.rawContent, - isDefault: false, - }, - userId, - { suppressAutoDefault: true }, - ) - logger.warn(`Archived broken OpenCode config as '${backupName}'`) - return backupName - } catch (error) { - logger.error('Failed to archive broken config:', error) - return null - } - } - - restoreToLastKnownGoodConfig(userId: string = 'default'): { configName: string; content: string } | null { - if (!SettingsService.lastKnownGoodConfigContent) { - logger.warn('No last known good config available for rollback') - return null - } - - const configs = this.getOpenCodeConfigs(userId) - const defaultConfig = configs.defaultConfig - - if (!defaultConfig) { - logger.error('Cannot rollback: no default config found') - return null - } - - logger.info(`Restoring to last known good config for: ${defaultConfig.name}`) - return { - configName: defaultConfig.name, - content: SettingsService.lastKnownGoodConfigContent - } - } - - rollbackToLastKnownGoodHealth(userId: string = 'default'): string | null { - const lastGood = this.restoreToLastKnownGoodConfig(userId) - if (!lastGood) { - return null - } - - this.updateOpenCodeConfig(lastGood.configName, { content: lastGood.content }, userId) - return lastGood.configName - } - - deleteFilesystemConfig(): boolean { - const configPath = getOpenCodeConfigFilePath() - - if (!existsSync(configPath)) { - logger.warn('Config file does not exist:', configPath) - return false - } - - try { - unlinkSync(configPath) - logger.info('Deleted filesystem config to allow server startup:', configPath) - return true - } catch (error) { - logger.error('Failed to delete config file:', error) - return false - } + saveLastKnownGoodConfig(rawContent: string, userId: string = 'default'): void { + SettingsService.lastKnownGoodConfigContent = rawContent + this.persistLastKnownGoodConfig(userId) + logger.info('Saved last known good config') } getOpenCodeServerPassword(): string { diff --git a/backend/src/utils/atomic-json.test.ts b/backend/src/utils/atomic-json.test.ts index 5803224b4..a37a55c50 100644 --- a/backend/src/utils/atomic-json.test.ts +++ b/backend/src/utils/atomic-json.test.ts @@ -98,6 +98,63 @@ describe('atomic-json', () => { expect(executionOrder).toEqual([1, 2, 3, 4]) }) + it('runs the next queued call when the previous call rejects', async () => { + const filePath = join(tmpDir, 'reject-next.json') + const firstError = new Error('first fails') + let secondRan = false + + const first = withFileLock(filePath, async () => { + throw firstError + }) + + const second = withFileLock(filePath, async () => { + secondRan = true + return 'second' + }) + + await expect(first).rejects.toThrow('first fails') + await expect(second).resolves.toBe('second') + expect(secondRan).toBe(true) + }) + + it('does not poison the lock for later calls after a rejection settles', async () => { + const filePath = join(tmpDir, 'reject-recovery.json') + const firstError = new Error('first fails') + + await expect( + withFileLock(filePath, async () => { + throw firstError + }), + ).rejects.toThrow('first fails') + + await expect(withFileLock(filePath, async () => 'recovered')).resolves.toBe('recovered') + }) + + it('serializes calls when an operation rejects', async () => { + const filePath = join(tmpDir, 'reject-order.json') + const executionOrder: number[] = [] + + const first = withFileLock(filePath, async () => { + executionOrder.push(1) + await new Promise((resolve) => setTimeout(resolve, 50)) + executionOrder.push(2) + throw new Error('first fails') + }) + + const second = withFileLock(filePath, async () => { + executionOrder.push(3) + await new Promise((resolve) => setTimeout(resolve, 50)) + executionOrder.push(4) + return 'second' + }) + + const [firstResult, secondResult] = await Promise.allSettled([first, second]) + + expect(firstResult.status).toBe('rejected') + expect(secondResult).toEqual({ status: 'fulfilled', value: 'second' }) + expect(executionOrder).toEqual([1, 2, 3, 4]) + }) + it('concurrent stress test: 50 writes then reads', async () => { const filePath = join(tmpDir, 'stress.json') const numOps = 50 diff --git a/backend/src/utils/atomic-json.ts b/backend/src/utils/atomic-json.ts index 96c1c7fa2..ad2ea1ce5 100644 --- a/backend/src/utils/atomic-json.ts +++ b/backend/src/utils/atomic-json.ts @@ -44,7 +44,7 @@ export async function withFileLock(filePath: string, fn: () => Promise): P } } - const newLock = previousLock ? previousLock.then(executeWithLock) : executeWithLock() + const newLock = previousLock ? previousLock.then(executeWithLock, executeWithLock) : executeWithLock() fileLockPromises.set(absolutePath, newLock) return newLock as Promise diff --git a/backend/src/utils/fs-safe.ts b/backend/src/utils/fs-safe.ts index 1aea0e530..83b0633ab 100644 --- a/backend/src/utils/fs-safe.ts +++ b/backend/src/utils/fs-safe.ts @@ -23,6 +23,14 @@ export async function writeFileAtomic(filePath: string, content: string, options } } +export async function existingFileMode(filePath: string): Promise { + try { + return (await fs.stat(filePath)).mode & 0o777 + } catch { + return undefined + } +} + export async function mkdirSafe(dirPath: string, options: MkdirSafeOptions = {}): Promise { try { await fs.mkdir(dirPath, { ...options, recursive: true }) diff --git a/backend/test/auth/index.test.ts b/backend/test/auth/index.test.ts new file mode 100644 index 000000000..47bb47944 --- /dev/null +++ b/backend/test/auth/index.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createTestDb } from '../helpers/assistant-workspace' +import { createAuth } from '../../src/auth' + +const { ENV } = vi.hoisted(() => ({ + ENV: { + SERVER: { PORT: 5003, HOST: '0.0.0.0', NODE_ENV: 'test' }, + AUTH: { + SECRET: 'test-secret-for-encryption-key-32c', + TRUSTED_ORIGINS: 'http://localhost:5173,http://localhost:5003', + SECURE_COOKIES: false, + ADMIN_EMAIL: undefined as string | undefined, + ADMIN_PASSWORD: undefined as string | undefined, + ADMIN_PASSWORD_RESET: false, + GITHUB_CLIENT_ID: undefined as string | undefined, + GITHUB_CLIENT_SECRET: undefined as string | undefined, + GOOGLE_CLIENT_ID: undefined as string | undefined, + GOOGLE_CLIENT_SECRET: undefined as string | undefined, + DISCORD_CLIENT_ID: undefined as string | undefined, + DISCORD_CLIENT_SECRET: undefined as string | undefined, + PASSKEY_RP_ID: 'localhost', + PASSKEY_RP_NAME: 'OpenCode Manager', + PASSKEY_ORIGIN: 'http://localhost:5003', + }, + LOGGING: { DEBUG: false, LOG_LEVEL: 'info' }, + }, +})) + +vi.mock('@opencode-manager/shared/config/env', () => ({ ENV })) + +vi.mock('../../src/utils/logger', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})) + +function resetEnv(): void { + ENV.SERVER.PORT = 5003 + ENV.AUTH.TRUSTED_ORIGINS = 'http://localhost:5173,http://localhost:5003' + ENV.AUTH.SECURE_COOKIES = false + ENV.AUTH.GITHUB_CLIENT_ID = undefined + ENV.AUTH.GITHUB_CLIENT_SECRET = undefined + ENV.AUTH.GOOGLE_CLIENT_ID = undefined + ENV.AUTH.GOOGLE_CLIENT_SECRET = undefined + ENV.AUTH.DISCORD_CLIENT_ID = undefined + ENV.AUTH.DISCORD_CLIENT_SECRET = undefined +} + +describe('createAuth', () => { + beforeEach(() => { + resetEnv() + }) + + it('creates an auth instance without social providers and responds to requests', async () => { + const db = createTestDb() + const auth = createAuth(db) + + expect(typeof auth.api.getSession).toBe('function') + expect(auth.handler).toBeTypeOf('function') + + const res = await auth.handler(new Request('http://localhost/api/auth/ok')) + expect(res).toBeInstanceOf(Response) + + db.close() + }) + + it('creates an auth instance when all social providers are configured', async () => { + ENV.AUTH.GITHUB_CLIENT_ID = 'github-id' + ENV.AUTH.GITHUB_CLIENT_SECRET = 'github-secret' + ENV.AUTH.GOOGLE_CLIENT_ID = 'google-id' + ENV.AUTH.GOOGLE_CLIENT_SECRET = 'google-secret' + ENV.AUTH.DISCORD_CLIENT_ID = 'discord-id' + ENV.AUTH.DISCORD_CLIENT_SECRET = 'discord-secret' + + const db = createTestDb() + const auth = createAuth(db) + + expect(typeof auth.api.getSession).toBe('function') + + const res = await auth.handler(new Request('http://localhost/api/auth/ok')) + expect(res).toBeInstanceOf(Response) + + db.close() + }) + + it('ignores social providers with only one credential half configured', async () => { + ENV.AUTH.GITHUB_CLIENT_ID = 'github-id' + ENV.AUTH.GOOGLE_CLIENT_SECRET = 'google-secret' + ENV.AUTH.DISCORD_CLIENT_ID = 'discord-id' + + const db = createTestDb() + const auth = createAuth(db) + + const res = await auth.handler(new Request('http://localhost/api/auth/ok')) + expect(res).toBeInstanceOf(Response) + + db.close() + }) + + it('falls back to the localhost port when trusted origins are empty', async () => { + ENV.AUTH.TRUSTED_ORIGINS = '' + ENV.SERVER.PORT = 4321 + ENV.AUTH.SECURE_COOKIES = true + + const db = createTestDb() + const auth = createAuth(db) + + const res = await auth.handler(new Request('http://localhost/api/auth/ok')) + expect(res).toBeInstanceOf(Response) + + db.close() + }) + + it('uses the first trusted origin as the base URL', async () => { + ENV.AUTH.TRUSTED_ORIGINS = ' http://example.test ,http://localhost:5003' + + const db = createTestDb() + const auth = createAuth(db) + + const res = await auth.handler(new Request('http://example.test/api/auth/ok')) + expect(res).toBeInstanceOf(Response) + + db.close() + }) +}) diff --git a/backend/test/auth/middleware.test.ts b/backend/test/auth/middleware.test.ts new file mode 100644 index 000000000..6c299feaa --- /dev/null +++ b/backend/test/auth/middleware.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { Hono } from 'hono' +import { createAuthMiddleware } from '../../src/auth/middleware' +import type { AuthInstance, Session } from '../../src/auth' + +function createSession(): Session { + return { + session: { + id: 'session-1', + userId: 'user-1', + token: 'token-1', + expiresAt: new Date(Date.now() + 1000), + createdAt: new Date(), + updatedAt: new Date(), + }, + user: { + id: 'user-1', + name: 'Test User', + email: 'test@example.com', + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + role: 'user', + }, + } +} + +function createApp(getSession: (input: { headers: Headers }) => Promise) { + const app = new Hono<{ Variables: { session: Session['session']; user: Session['user'] } }>() + app.use('/*', createAuthMiddleware({ api: { getSession } } as unknown as AuthInstance)) + app.get('/', (c) => c.json({ userId: c.get('user').id })) + return app +} + +describe('createAuthMiddleware', () => { + let getSession: ReturnType + + beforeEach(() => { + getSession = vi.fn() + }) + + it('returns 401 when no session is found', async () => { + getSession.mockResolvedValue(null) + const app = createApp(getSession) + + const res = await app.request('/', { headers: { cookie: 'opencode.session=stale' } }) + + expect(res.status).toBe(401) + expect(await res.json()).toEqual({ error: 'Unauthorized' }) + }) + + it('attaches the session and user and continues to the route', async () => { + const session = createSession() + getSession.mockResolvedValue(session) + const app = createApp(getSession) + + const res = await app.request('/', { headers: { cookie: 'opencode.session=valid' } }) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ userId: 'user-1' }) + expect(getSession).toHaveBeenCalledTimes(1) + }) + + it('returns 500 when the session lookup throws', async () => { + getSession.mockRejectedValue(new Error('database unavailable')) + const app = createApp(getSession) + + const res = await app.request('/') + + expect(res.status).toBe(500) + expect(await res.json()).toEqual({ error: 'Internal Server Error' }) + }) +}) diff --git a/backend/test/db/opencode-config-migration.test.ts b/backend/test/db/opencode-config-migration.test.ts new file mode 100644 index 000000000..1a3263b34 --- /dev/null +++ b/backend/test/db/opencode-config-migration.test.ts @@ -0,0 +1,127 @@ +import { Database } from 'bun:sqlite' +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' + +const paths = vi.hoisted(() => ({ + workDir: '', + configHome: '', + configFile: '', +})) + +vi.mock('@opencode-manager/shared/config/env', async (importOriginal) => ({ + ...(await importOriginal()), + getOpenCodeConfigHome: () => paths.configHome, + getOpenCodeConfigFilePath: () => paths.configFile, +})) + +vi.mock('../../src/services/opencode-import', () => ({ + getFirstExistingConfigSourcePath: vi.fn(), +})) + +import { getFirstExistingConfigSourcePath } from '../../src/services/opencode-import' + +const mockGetFirstExistingConfigSourcePath = getFirstExistingConfigSourcePath as ReturnType + +function migrateToV18(db: Database): void { + migrate(db, allMigrations.filter(migration => migration.version < 19)) +} + +function insertConfig(db: Database, name: string, content: string, isDefault: boolean): void { + db.prepare('INSERT INTO opencode_configs (user_id, config_name, config_content, is_default, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)') + .run('default', name, content, isDefault ? 1 : 0, Date.now(), Date.now()) +} + +describe('019-drop-opencode-configs', () => { + beforeEach(async () => { + paths.workDir = await mkdtemp(path.join(tmpdir(), 'opencode-config-migration-')) + paths.configHome = path.join(paths.workDir, '.config') + paths.configFile = path.join(paths.configHome, 'opencode', 'opencode.json') + mockGetFirstExistingConfigSourcePath.mockReturnValue(null) + }) + + afterEach(async () => { + await rm(paths.workDir, { recursive: true, force: true }) + }) + + it('archives rows, restores the default file, and drops the table and column', async () => { + const db = new Database(':memory:') + migrateToV18(db) + + const defaultContent = JSON.stringify({ $schema: 'https://opencode.ai/config.json', model: 'default' }) + const namedContent = JSON.stringify({ model: 'team' }) + insertConfig(db, 'default', defaultContent, true) + insertConfig(db, 'team/main config', namedContent, false) + + migrate(db, allMigrations) + + expect(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'opencode_configs'").get()).toBeUndefined() + const columns = (db.prepare('PRAGMA table_info(repos)').all() as Array<{ name: string }>).map(column => column.name) + expect(columns).not.toContain('opencode_config_name') + expect(await readFile(path.join(paths.configHome, 'opencode-configs-archive', 'default.json'), 'utf8')).toBe(defaultContent) + expect(await readFile(path.join(paths.configHome, 'opencode-configs-archive', 'team_main_config.json'), 'utf8')).toBe(namedContent) + expect(await readFile(paths.configFile, 'utf8')).toBe(defaultContent) + }) + + it('leaves an existing opencode.json byte-identical', async () => { + const db = new Database(':memory:') + migrateToV18(db) + + const defaultContent = JSON.stringify({ model: 'default' }) + insertConfig(db, 'default', defaultContent, true) + + const existingContent = '{\n // keep me\n "model": "existing"\n}\n' + await mkdir(path.dirname(paths.configFile), { recursive: true }) + await writeFile(paths.configFile, existingContent) + + migrate(db, allMigrations) + + expect(await readFile(paths.configFile, 'utf8')).toBe(existingContent) + }) + + it('does not create the config file when an import source exists', async () => { + const db = new Database(':memory:') + migrateToV18(db) + + const defaultContent = JSON.stringify({ model: 'default' }) + insertConfig(db, 'default', defaultContent, true) + + mockGetFirstExistingConfigSourcePath.mockReturnValue('/import/opencode.json') + + migrate(db, allMigrations) + + await expect(readFile(paths.configFile, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('keeps every profile when sanitized names collide or the archive destination already exists', async () => { + const db = new Database(':memory:') + migrateToV18(db) + + const firstContent = JSON.stringify({ model: 'first' }) + const secondContent = JSON.stringify({ model: 'second' }) + const suffixedContent = JSON.stringify({ model: 'suffixed' }) + insertConfig(db, 'team/main config', firstContent, false) + insertConfig(db, 'team_main_config', secondContent, false) + insertConfig(db, 'team_main_config-1', suffixedContent, false) + + const archiveDir = path.join(paths.configHome, 'opencode-configs-archive') + await mkdir(archiveDir, { recursive: true }) + const preExistingContent = JSON.stringify({ model: 'pre-existing' }) + await writeFile(path.join(archiveDir, 'team_main_config.json'), preExistingContent) + + migrate(db, allMigrations) + + expect(await readFile(path.join(archiveDir, 'team_main_config.json'), 'utf8')).toBe(preExistingContent) + const archivedContents = await Promise.all( + (await readdir(archiveDir)).map(file => readFile(path.join(archiveDir, file), 'utf8')) + ) + expect(archivedContents).toHaveLength(4) + expect(new Set(archivedContents).size).toBe(4) + expect(archivedContents).toContain(firstContent) + expect(archivedContents).toContain(secondContent) + expect(archivedContents).toContain(suffixedContent) + }) +}) diff --git a/backend/test/db/prompt-templates.test.ts b/backend/test/db/prompt-templates.test.ts new file mode 100644 index 000000000..7d3548e87 --- /dev/null +++ b/backend/test/db/prompt-templates.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { Database } from 'bun:sqlite' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import { + listPromptTemplates, + getPromptTemplateById, + createPromptTemplate, + updatePromptTemplate, + deletePromptTemplate, +} from '../../src/db/prompt-templates' + +function createTemplateInput(overrides: Record = {}) { + return { + title: 'Daily standup', + category: 'standup', + cadenceHint: 'daily', + suggestedName: 'daily-standup', + suggestedDescription: 'Summarize yesterday', + description: 'A standup prompt', + prompt: 'Summarize my work', + ...overrides, + } +} + +describe('prompt template repository', () => { + let db: Database + + beforeEach(() => { + db = new Database(':memory:') + migrate(db, allMigrations) + }) + + afterEach(() => { + db.close() + }) + + it('lists the seeded templates in ascending id order', () => { + const templates = listPromptTemplates(db) + + expect(templates.length).toBeGreaterThan(0) + expect(templates.map(template => template.id)).toEqual( + [...templates].map(template => template.id).sort((a, b) => a - b), + ) + }) + + it('creates a template and reads it back by id', () => { + const created = createPromptTemplate(db, createTemplateInput()) + + expect(created.id).toBeGreaterThan(0) + expect(created.title).toBe('Daily standup') + expect(created.prompt).toBe('Summarize my work') + expect(created.createdAt).toBeGreaterThan(0) + expect(getPromptTemplateById(db, created.id)).toEqual(created) + }) + + it('includes newly created templates in the list ordered by id', () => { + const first = createPromptTemplate(db, createTemplateInput({ title: 'First' })) + const second = createPromptTemplate(db, createTemplateInput({ title: 'Second' })) + + const ids = listPromptTemplates(db).map(template => template.id) + expect(ids).toContain(first.id) + expect(ids).toContain(second.id) + expect(ids.indexOf(first.id)).toBeLessThan(ids.indexOf(second.id)) + expect(ids).toEqual([...ids].sort((a, b) => a - b)) + }) + + it('returns null for an unknown template id', () => { + expect(getPromptTemplateById(db, 999)).toBeNull() + }) + + it('updates provided fields and preserves the rest', () => { + const created = createPromptTemplate(db, createTemplateInput()) + + const updated = updatePromptTemplate(db, created.id, { + title: 'Renamed', + prompt: 'Updated prompt', + }) + + expect(updated?.title).toBe('Renamed') + expect(updated?.prompt).toBe('Updated prompt') + expect(updated?.category).toBe(created.category) + expect(updated?.suggestedName).toBe(created.suggestedName) + expect(updated?.updatedAt).toBeGreaterThanOrEqual(created.updatedAt) + expect(getPromptTemplateById(db, created.id)).toEqual(updated) + }) + + it('returns null when updating an unknown template', () => { + expect(updatePromptTemplate(db, 999, { title: 'Missing' })).toBeNull() + }) + + it('deletes an existing template and reports success', () => { + const created = createPromptTemplate(db, createTemplateInput()) + + expect(deletePromptTemplate(db, created.id)).toBe(true) + expect(getPromptTemplateById(db, created.id)).toBeNull() + expect(listPromptTemplates(db).map(template => template.id)).not.toContain(created.id) + }) + + it('returns false when deleting an unknown template', () => { + expect(deletePromptTemplate(db, 999)).toBe(false) + }) +}) diff --git a/backend/test/db/queries.test.ts b/backend/test/db/queries.test.ts index dd429b63b..9e4546f0d 100644 --- a/backend/test/db/queries.test.ts +++ b/backend/test/db/queries.test.ts @@ -100,7 +100,6 @@ describe('Database Queries', () => { cloned_at: clonedAt, last_pulled: null, last_accessed_at: lastAccessedAt, - opencode_config_name: null, is_worktree: 0, is_local: 0 } @@ -124,7 +123,6 @@ describe('Database Queries', () => { clonedAt: clonedAt, lastPulled: null, lastAccessedAt: lastAccessedAt, - openCodeConfigName: null, isWorktree: undefined, isLocal: undefined }) @@ -199,22 +197,6 @@ describe('Database Queries', () => { }) }) - describe('updateRepoConfigName', () => { - it('should update repo OpenCode config name', () => { - const stmt = { - run: vi.fn().mockReturnValue({ changes: 1 }) - } - mockDb.prepare.mockReturnValue(stmt) - - db.updateRepoConfigName(mockDb, 1, 'my-config') - - expect(mockDb.prepare).toHaveBeenCalledWith( - 'UPDATE repos SET opencode_config_name = ? WHERE id = ?' - ) - expect(stmt.run).toHaveBeenCalledWith('my-config', 1) - }) - }) - describe('updateLastPulled', () => { it('should update repo last pulled timestamp', () => { const stmt = { diff --git a/backend/test/db/schema.test.ts b/backend/test/db/schema.test.ts new file mode 100644 index 000000000..c0b5aa55f --- /dev/null +++ b/backend/test/db/schema.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, rm, readFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { initializeDatabase } from '../../src/db/schema' +import { getRepoById } from '../../src/db/queries' + +describe('initializeDatabase', () => { + let tempDir: string + let dbPath: string + + beforeEach(async () => { + tempDir = await mkdtemp(path.join(tmpdir(), 'ocm-db-')) + dbPath = path.join(tempDir, 'nested', 'opencode.db') + }) + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }) + }) + + it('creates the database file, applies migrations, and seeds defaults', async () => { + const db = initializeDatabase(dbPath) + + const migrations = db.prepare('SELECT name FROM schema_migrations ORDER BY version ASC').all() as Array<{ name: string }> + expect(migrations.map(migration => migration.name)).toContain('base-schema') + expect(migrations.map(migration => migration.name)).toContain('drop-opencode-configs') + + const preferences = db.prepare('SELECT preferences FROM user_preferences WHERE user_id = ?').get('default') as { preferences: string } + expect(preferences.preferences).toBe('{}') + + expect(getRepoById(db, 0)?.localPath).toBe('assistant') + + const modelStateTable = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'opencode_model_state'").get() + expect(modelStateTable).toBeDefined() + + db.close() + + const fileContents = await readFile(dbPath, 'utf-8') + expect(fileContents.length).toBeGreaterThan(0) + }) +}) diff --git a/backend/test/helpers/assistant-workspace.ts b/backend/test/helpers/assistant-workspace.ts index 30eba229c..a2d8d3376 100644 --- a/backend/test/helpers/assistant-workspace.ts +++ b/backend/test/helpers/assistant-workspace.ts @@ -8,6 +8,7 @@ import type { Repo } from '@opencode-manager/shared/types' export async function createTempAssistantWorkspace() { const workspacePath = await mkdtemp(path.join(tmpdir(), 'oc-assistant-')) + const previousWorkspacePath = process.env.WORKSPACE_PATH process.env.WORKSPACE_PATH = workspacePath const reposPath = path.join(workspacePath, 'repos') const assistantDir = path.join(reposPath, 'assistant') @@ -15,7 +16,17 @@ export async function createTempAssistantWorkspace() { workspacePath, reposPath, assistantDir, - cleanup: () => rm(workspacePath, { recursive: true, force: true }), + cleanup: async () => { + try { + await rm(workspacePath, { recursive: true, force: true }) + } finally { + if (previousWorkspacePath === undefined) { + delete process.env.WORKSPACE_PATH + } else { + process.env.WORKSPACE_PATH = previousWorkspacePath + } + } + }, } } @@ -37,7 +48,6 @@ export const mockRepo: Repo = { clonedAt: Date.now(), lastPulled: Date.now(), lastAccessedAt: Date.now(), - openCodeConfigName: 'default', isWorktree: false, isLocal: false, } diff --git a/backend/test/index.test.ts b/backend/test/index.test.ts new file mode 100644 index 000000000..27f51570c --- /dev/null +++ b/backend/test/index.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const serveMock = vi.fn() + +vi.mock('@hono/node-server', () => ({ + serve: serveMock, +})) + +const supervisorMock = vi.hoisted(() => ({ + start: vi.fn().mockResolvedValue({ healthy: true, port: 5551, state: 'running', resumedSessionIDs: [] }), + stop: vi.fn().mockResolvedValue(undefined), + restart: vi.fn().mockResolvedValue({ healthy: true, resumedSessionIDs: [] }), + reloadConfig: vi.fn().mockResolvedValue({ healthy: true }), + getLastStartupError: vi.fn().mockReturnValue(null), +})) + +vi.mock('../src/services/opencode-supervisor', () => ({ + OpenCodeSupervisor: vi.fn().mockImplementation(() => supervisorMock), +})) + +const scheduleRunnerMock = vi.hoisted(() => ({ + start: vi.fn(), + stop: vi.fn(), +})) + +vi.mock('../src/services/schedules', () => ({ + ScheduleService: vi.fn().mockImplementation(() => ({ + getActiveRunSessions: vi.fn().mockResolvedValue([]), + })), + ScheduleRunner: vi.fn().mockImplementation(() => scheduleRunnerMock), +})) + +vi.mock('../src/services/sandbox/runtime', () => ({ + SandboxRuntimeService: vi.fn().mockImplementation(() => ({ + prepareWorkspaceSandboxOnBoot: vi.fn().mockResolvedValue(undefined), + })), + stopWorkspaceSandboxOnShutdown: vi.fn().mockResolvedValue(undefined), +})) + +const ipcServerMock = vi.hoisted(() => ({ + ipcHandlePath: '/tmp/opencode-test-ipc.sock', + dispose: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('../src/ipc/ipcServer', () => ({ + createIPCServer: vi.fn().mockResolvedValue(ipcServerMock), +})) + +vi.mock('../src/services/opencode-import', () => ({ + getFirstExistingConfigSourcePath: vi.fn().mockReturnValue(null), + getOpenCodeImportStatus: vi.fn().mockResolvedValue({ + configSourcePath: null, + stateSourcePath: null, + workspaceConfigPath: '/tmp/test-workspace/.config/opencode/opencode.json', + workspaceStatePath: '/tmp/test-workspace/.opencode/state/opencode', + workspaceStateExists: true, + }), + syncOpenCodeImport: vi.fn().mockResolvedValue({ configImported: false, stateImported: false }), +})) + +vi.mock('../src/services/assistant-mode', () => ({ + installAssistantWorkspace: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('../src/services/skills', () => ({ + migrateGlobalSkills: vi.fn().mockResolvedValue(undefined), +})) + +const sseAggregatorMock = vi.hoisted(() => ({ + onEvent: vi.fn(), + setPendingActionsFetcher: vi.fn(), + setPasswordResolver: vi.fn(), + setScheduledSessionsResolver: vi.fn(), + start: vi.fn(), + shutdown: vi.fn(), + reconnect: vi.fn(), +})) + +vi.mock('../src/services/sse-aggregator', () => ({ + sseAggregator: sseAggregatorMock, +})) + +const serverManagerMock = vi.hoisted(() => ({ + getVersion: vi.fn().mockReturnValue('1.2.27'), + fetchVersion: vi.fn().mockResolvedValue('1.2.27'), + setDatabase: vi.fn(), + start: vi.fn().mockResolvedValue({ healthy: true, port: 5551, state: 'running', resumedSessionIDs: [] }), + stop: vi.fn().mockResolvedValue(undefined), + getEffectiveServerHost: vi.fn().mockReturnValue('127.0.0.1'), + getLastStartupError: vi.fn().mockReturnValue(null), + clearStartupError: vi.fn(), + markRestartPending: vi.fn(), + isRestartPending: vi.fn().mockReturnValue(false), + reloadConfig: vi.fn().mockResolvedValue(undefined), + restart: vi.fn().mockResolvedValue(undefined), + checkHealth: vi.fn().mockResolvedValue(true), +})) + +vi.mock('../src/services/opencode-single-server', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + opencodeServerManager: serverManagerMock, + } +}) + +const restartCoordinatorMock = vi.hoisted(() => ({ + runWithResume: vi.fn(), + captureResumableSessions: vi.fn().mockReturnValue([]), +})) + +vi.mock('../src/services/opencode-restart-coordinator', () => ({ + OpenCodeRestartCoordinator: vi.fn().mockImplementation(() => restartCoordinatorMock), +})) + +vi.mock('../src/services/git-auth', () => ({ + GitAuthService: vi.fn().mockImplementation(() => ({ + initialize: vi.fn().mockResolvedValue(undefined), + getGitEnvironment: vi.fn().mockReturnValue({}), + })), +})) + +vi.mock('../src/services/schedule-worktree', () => ({ + ScheduleWorktreeManager: vi.fn().mockImplementation(() => ({})), +})) + +vi.mock('../src/services/credential-provider', () => ({ + CredentialProvider: vi.fn().mockImplementation(() => ({})), +})) + +describe('backend entrypoint', () => { + let tempWorkspace: string + let previousWorkspace: string | undefined + + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + tempWorkspace = await mkdtemp(join(tmpdir(), 'ocm-entrypoint-')) + previousWorkspace = process.env.WORKSPACE_PATH + process.env.WORKSPACE_PATH = tempWorkspace + }) + + afterEach(async () => { + const { sseAggregator } = await import('../src/services/sse-aggregator') + sseAggregator.shutdown() + if (previousWorkspace === undefined) { + delete process.env.WORKSPACE_PATH + } else { + process.env.WORKSPACE_PATH = previousWorkspace + } + await rm(tempWorkspace, { recursive: true, force: true }) + }) + + it('initializes the workspace, registers every route group, and serves the app', async () => { + await import('../src/index') + + expect(serveMock).toHaveBeenCalledTimes(1) + const options = serveMock.mock.calls[0]![0] as { fetch: unknown; port: number; hostname: string } + expect(typeof options.fetch).toBe('function') + expect(options.port).toBe(3001) + expect(options.hostname).toBeDefined() + + expect(serverManagerMock.setDatabase).toHaveBeenCalledTimes(1) + expect(supervisorMock.start).toHaveBeenCalledTimes(1) + expect(scheduleRunnerMock.start).toHaveBeenCalledTimes(1) + expect(sseAggregatorMock.start).toHaveBeenCalledTimes(1) + expect(ipcServerMock.dispose).not.toHaveBeenCalled() + }) + + it('imports home state without rewriting an existing valid config', async () => { + const configDir = join(tempWorkspace, '.config', 'opencode') + await mkdir(configDir, { recursive: true }) + await writeFile(join(configDir, 'opencode.json'), '{"$schema":"https://opencode.ai/config.json"}') + + const { getOpenCodeImportStatus, syncOpenCodeImport } = await import('../src/services/opencode-import') + vi.mocked(getOpenCodeImportStatus).mockResolvedValueOnce({ + configSourcePath: '/import/opencode.json', + stateSourcePath: '/import/state', + workspaceConfigPath: join(configDir, 'opencode.json'), + workspaceStatePath: join(tempWorkspace, '.opencode', 'state', 'opencode'), + workspaceStateExists: false, + }) + vi.mocked(syncOpenCodeImport).mockResolvedValueOnce({ + configSourcePath: '/import/opencode.json', + stateSourcePath: '/import/state', + workspaceConfigPath: join(configDir, 'opencode.json'), + workspaceStatePath: join(tempWorkspace, '.opencode', 'state', 'opencode'), + workspaceStateExists: false, + configImported: false, + stateImported: true, + }) + + await import('../src/index') + + expect(syncOpenCodeImport).toHaveBeenCalledWith(expect.objectContaining({ + overwriteState: false, + importConfig: false, + })) + }) + + it('answers the root route with service metadata outside production', async () => { + await import('../src/index') + + const options = serveMock.mock.calls[0]![0] as { fetch: (request: Request) => Promise } + const response = await options.fetch(new Request('http://localhost/')) + const body = await response.json() as { name: string; status: string; endpoints: Record } + + expect(response.status).toBe(200) + expect(body.name).toBe('OpenCode WebUI') + expect(body.status).toBe('running') + expect(body.endpoints.repos).toBe('/api/repos') + }) + + it('ignores unknown API routes through the not-found handler', async () => { + await import('../src/index') + + const options = serveMock.mock.calls[0]![0] as { fetch: (request: Request) => Promise } + const response = await options.fetch(new Request('http://localhost/unknown-route')) + + expect(response.status).toBe(404) + }) +}) diff --git a/backend/test/ipc/ipcServer.test.ts b/backend/test/ipc/ipcServer.test.ts new file mode 100644 index 000000000..974635026 --- /dev/null +++ b/backend/test/ipc/ipcServer.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import * as http from 'http' +import * as fs from 'fs/promises' +import { randomUUID } from 'crypto' +import { createIPCServer } from '../../src/ipc/ipcServer' +import type { IPCServer } from '../../src/ipc/ipcServer' + +interface IPCResponse { + status: number + body: string +} + +function sendRequest(socketPath: string, path: string, body?: string): Promise { + return new Promise((resolve, reject) => { + const req = http.request({ socketPath, path, method: 'POST' }, (res) => { + const chunks: Buffer[] = [] + res.on('data', (chunk: Buffer) => chunks.push(chunk)) + res.on('end', () => { + resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }) + }) + }) + req.on('error', reject) + if (body !== undefined) { + req.write(body) + } + req.end() + }) +} + +describe('IPCServer', () => { + const originalXdgRuntimeDir = process.env['XDG_RUNTIME_DIR'] + const servers: IPCServer[] = [] + + const startServer = async (context: string = randomUUID()): Promise => { + const server = await createIPCServer(context) + servers.push(server) + return server + } + + beforeEach(() => { + delete process.env['XDG_RUNTIME_DIR'] + }) + + afterEach(async () => { + for (const server of servers.splice(0)) { + await server.dispose() + await fs.unlink(server.ipcHandlePath).catch(() => {}) + } + if (originalXdgRuntimeDir === undefined) { + delete process.env['XDG_RUNTIME_DIR'] + } else { + process.env['XDG_RUNTIME_DIR'] = originalXdgRuntimeDir + } + }) + + it('registers and retrieves handlers by name', async () => { + const server = await startServer() + const handler = { handle: vi.fn(async () => 'ok') } + + server.registerHandler('askpass', handler) + + expect(server.getHandler('askpass')).toBe(handler) + expect(server.getHandler('missing')).toBeUndefined() + }) + + it('exposes the socket path through getEnv', async () => { + const server = await startServer() + + expect(server.getEnv()).toEqual({ VSCODE_GIT_IPC_HANDLE: server.ipcHandlePath }) + }) + + it('creates a random socket path when no context is given', async () => { + const server = await startServer(randomUUID()) + const randomServer = await createIPCServer() + servers.push(randomServer) + + expect(randomServer.ipcHandlePath).toContain('opencode-git-') + expect(randomServer.ipcHandlePath).not.toBe(server.ipcHandlePath) + }) + + it('returns the handler result as JSON with status 200', async () => { + const server = await startServer() + server.registerHandler('askpass', { handle: async (request) => ({ request, value: 7 }) }) + + const response = await sendRequest(server.ipcHandlePath, '/askpass', JSON.stringify({ user: 'alice' })) + + expect(response.status).toBe(200) + expect(JSON.parse(response.body)).toEqual({ request: { user: 'alice' }, value: 7 }) + }) + + it('returns 404 with an error body for an unknown path', async () => { + const server = await startServer() + + const response = await sendRequest(server.ipcHandlePath, '/unknown', '{}') + + expect(response.status).toBe(404) + expect(JSON.parse(response.body)).toEqual({ error: 'Handler not found' }) + }) + + it('returns 400 when the request body is empty', async () => { + const server = await startServer() + server.registerHandler('askpass', { handle: async () => 'ok' }) + + const response = await sendRequest(server.ipcHandlePath, '/askpass') + + expect(response.status).toBe(400) + expect(JSON.parse(response.body)).toEqual({ error: 'Empty request body' }) + }) + + it('returns 500 when the handler throws', async () => { + const server = await startServer() + server.registerHandler('askpass', { + handle: async () => { + throw new Error('handler exploded') + }, + }) + + const response = await sendRequest(server.ipcHandlePath, '/askpass', '{}') + + expect(response.status).toBe(500) + expect(JSON.parse(response.body)).toEqual({ error: 'Internal server error' }) + }) + + it('returns 500 when the request body is not valid JSON', async () => { + const server = await startServer() + server.registerHandler('askpass', { handle: async () => 'ok' }) + + const response = await sendRequest(server.ipcHandlePath, '/askpass', 'not-json') + + expect(response.status).toBe(500) + expect(JSON.parse(response.body)).toEqual({ error: 'Internal server error' }) + }) + + it('closes the server on dispose', async () => { + const server = await startServer() + + await server.dispose() + + await expect(sendRequest(server.ipcHandlePath, '/askpass', '{}')).rejects.toBeDefined() + }) +}) diff --git a/backend/test/mocks/bun-sqlite.ts b/backend/test/mocks/bun-sqlite.ts index 517868ffe..724b3db87 100644 --- a/backend/test/mocks/bun-sqlite.ts +++ b/backend/test/mocks/bun-sqlite.ts @@ -6,6 +6,7 @@ export class Database { constructor(path: string) { this.db = new DatabaseSync(path) + this.db.exec('PRAGMA foreign_keys = OFF') } prepare(sql: string) { diff --git a/backend/test/mocks/bun-test.ts b/backend/test/mocks/bun-test.ts new file mode 100644 index 000000000..8e11d0198 --- /dev/null +++ b/backend/test/mocks/bun-test.ts @@ -0,0 +1 @@ +export { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest' diff --git a/backend/test/routes/auth.test.ts b/backend/test/routes/auth.test.ts new file mode 100644 index 000000000..e9d5eee73 --- /dev/null +++ b/backend/test/routes/auth.test.ts @@ -0,0 +1,369 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { createTestDb } from '../helpers/assistant-workspace' +import { createAuthRoutes, createAuthInfoRoutes, syncAdminFromEnv } from '../../src/routes/auth' +import type { AuthInstance, Session } from '../../src/auth' + +const { ENV } = vi.hoisted(() => ({ + ENV: { + SERVER: { PORT: 5003, HOST: '0.0.0.0', NODE_ENV: 'test' }, + AUTH: { + SECRET: 'test-secret-for-encryption-key-32c', + TRUSTED_ORIGINS: 'http://localhost:5173,http://localhost:5003', + SECURE_COOKIES: false, + ADMIN_EMAIL: undefined as string | undefined, + ADMIN_PASSWORD: undefined as string | undefined, + ADMIN_PASSWORD_RESET: false, + GITHUB_CLIENT_ID: undefined as string | undefined, + GITHUB_CLIENT_SECRET: undefined as string | undefined, + GOOGLE_CLIENT_ID: undefined as string | undefined, + GOOGLE_CLIENT_SECRET: undefined as string | undefined, + DISCORD_CLIENT_ID: undefined as string | undefined, + DISCORD_CLIENT_SECRET: undefined as string | undefined, + PASSKEY_RP_ID: 'localhost', + PASSKEY_RP_NAME: 'OpenCode Manager', + PASSKEY_ORIGIN: 'http://localhost:5003', + }, + LOGGING: { DEBUG: false, LOG_LEVEL: 'info' }, + }, +})) + +vi.mock('@opencode-manager/shared/config/env', () => ({ ENV })) + +vi.mock('../../src/utils/logger', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})) + +const { hashPasswordMock } = vi.hoisted(() => ({ + hashPasswordMock: vi.fn(), +})) + +vi.mock('better-auth/crypto', () => ({ hashPassword: hashPasswordMock })) + +import { logger } from '../../src/utils/logger' + +const mockLoggerInfo = vi.mocked(logger.info) +const mockLoggerWarn = vi.mocked(logger.warn) +const mockLoggerError = vi.mocked(logger.error) +const mockLoggerDebug = vi.mocked(logger.debug) + +function resetEnv(): void { + ENV.AUTH.TRUSTED_ORIGINS = 'http://localhost:5173,http://localhost:5003' + ENV.AUTH.SECURE_COOKIES = false + ENV.AUTH.ADMIN_EMAIL = undefined + ENV.AUTH.ADMIN_PASSWORD = undefined + ENV.AUTH.ADMIN_PASSWORD_RESET = false + ENV.AUTH.GITHUB_CLIENT_ID = undefined + ENV.AUTH.GITHUB_CLIENT_SECRET = undefined + ENV.AUTH.GOOGLE_CLIENT_ID = undefined + ENV.AUTH.GOOGLE_CLIENT_SECRET = undefined + ENV.AUTH.DISCORD_CLIENT_ID = undefined + ENV.AUTH.DISCORD_CLIENT_SECRET = undefined +} + +function insertUser(db: ReturnType, id: string, email: string): void { + db.prepare( + 'INSERT INTO "user" (id, name, email, emailVerified, createdAt, updatedAt, role) VALUES (?, ?, ?, ?, ?, ?, ?)' + ).run(id, 'Test User', email, 0, Date.now(), Date.now(), 'user') +} + +function insertCredentialAccount(db: ReturnType, id: string, userId: string, password: string): void { + db.prepare( + 'INSERT INTO "account" (id, accountId, providerId, userId, password, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)' + ).run(id, userId, 'credential', userId, password, Date.now(), Date.now()) +} + +function createSession(): Session { + return { + session: { + id: 'session-1', + userId: 'user-1', + token: 'token-1', + expiresAt: new Date('2030-01-01T00:00:00.000Z'), + createdAt: new Date('2029-01-01T00:00:00.000Z'), + updatedAt: new Date('2029-01-01T00:00:00.000Z'), + }, + user: { + id: 'user-1', + name: 'Test User', + email: 'user@example.com', + emailVerified: true, + createdAt: new Date('2029-01-01T00:00:00.000Z'), + updatedAt: new Date('2029-01-01T00:00:00.000Z'), + role: 'user', + }, + } +} + +describe('createAuthRoutes', () => { + beforeEach(() => { + resetEnv() + vi.clearAllMocks() + }) + + it('proxies GET requests and preserves the status, body and headers', async () => { + const handler = vi.fn(async () => new Response('proxied', { + status: 200, + headers: { 'set-cookie': 'opencode.session=1' }, + })) + const app = createAuthRoutes({ handler } as unknown as AuthInstance) + const request = new Request('http://localhost/api/auth/session') + + const res = await app.fetch(request) + + expect(handler).toHaveBeenCalledWith(request) + expect(res.status).toBe(200) + expect(await res.text()).toBe('proxied') + expect(res.headers.get('set-cookie')).toBe('opencode.session=1') + }) + + it('proxies POST requests and preserves the status', async () => { + const handler = vi.fn(async () => new Response('created', { status: 201 })) + const app = createAuthRoutes({ handler } as unknown as AuthInstance) + + const res = await app.fetch(new Request('http://localhost/api/auth/sign-up/email', { method: 'POST', body: '{}' })) + + expect(handler).toHaveBeenCalledTimes(1) + expect(res.status).toBe(201) + expect(await res.text()).toBe('created') + }) + + it('logs sign-in responses when a set-cookie header is present', async () => { + const handler = vi.fn(async () => new Response('ok', { + status: 200, + headers: { 'set-cookie': 'opencode.session=abc; Path=/; HttpOnly' }, + })) + const app = createAuthRoutes({ handler } as unknown as AuthInstance) + + await app.fetch(new Request('http://localhost/api/auth/sign-in/email', { method: 'POST', body: '{}' })) + + expect(mockLoggerInfo).toHaveBeenCalledWith(expect.stringContaining('Sign-in response')) + expect(mockLoggerInfo).toHaveBeenCalledWith(expect.stringContaining('present')) + expect(mockLoggerDebug).toHaveBeenCalledWith(expect.stringContaining('Set-Cookie header')) + }) + + it('logs sign-in responses when no set-cookie header is present', async () => { + const handler = vi.fn(async () => new Response('unauthorized', { status: 401 })) + const app = createAuthRoutes({ handler } as unknown as AuthInstance) + + await app.fetch(new Request('http://localhost/api/auth/sign-in/email', { method: 'POST', body: '{}' })) + + expect(mockLoggerInfo).toHaveBeenCalledWith(expect.stringContaining('missing')) + expect(mockLoggerDebug).not.toHaveBeenCalled() + }) + + it('does not log for non sign-in paths', async () => { + const handler = vi.fn(async () => new Response('ok', { + status: 200, + headers: { 'set-cookie': 'opencode.session=abc' }, + })) + const app = createAuthRoutes({ handler } as unknown as AuthInstance) + + await app.fetch(new Request('http://localhost/api/auth/get-session')) + + expect(mockLoggerInfo).not.toHaveBeenCalled() + expect(mockLoggerDebug).not.toHaveBeenCalled() + }) +}) + +describe('syncAdminFromEnv', () => { + let db: ReturnType + let signUpEmail: ReturnType + + beforeEach(() => { + resetEnv() + hashPasswordMock.mockImplementation(async (password: string) => `hashed:${password}`) + db = createTestDb() + vi.clearAllMocks() + signUpEmail = vi.fn(async () => ({})) + }) + + afterEach(() => { + db.close() + }) + + it('does nothing when the admin credentials are not configured', async () => { + const auth = { api: { signUpEmail } } as unknown as AuthInstance + + await syncAdminFromEnv(auth, db) + + expect(signUpEmail).not.toHaveBeenCalled() + expect(mockLoggerInfo).not.toHaveBeenCalled() + }) + + it('does nothing when only one admin credential is configured', async () => { + ENV.AUTH.ADMIN_EMAIL = 'admin@example.com' + const auth = { api: { signUpEmail } } as unknown as AuthInstance + + await syncAdminFromEnv(auth, db) + + expect(signUpEmail).not.toHaveBeenCalled() + }) + + it('returns without resetting when an existing user has no password reset flag', async () => { + ENV.AUTH.ADMIN_EMAIL = 'admin@example.com' + ENV.AUTH.ADMIN_PASSWORD = 'admin-password' + insertUser(db, 'user-1', 'admin@example.com') + insertCredentialAccount(db, 'account-1', 'user-1', 'old-hash') + const auth = { api: { signUpEmail } } as unknown as AuthInstance + + await syncAdminFromEnv(auth, db) + + expect(signUpEmail).not.toHaveBeenCalled() + expect(hashPasswordMock).not.toHaveBeenCalled() + const account = db.prepare('SELECT password FROM "account" WHERE "userId" = ?').get('user-1') as { password: string } + expect(account.password).toBe('old-hash') + }) + + it('resets the credential account password for an existing user', async () => { + ENV.AUTH.ADMIN_EMAIL = 'admin@example.com' + ENV.AUTH.ADMIN_PASSWORD = 'new-password' + ENV.AUTH.ADMIN_PASSWORD_RESET = true + insertUser(db, 'user-1', 'admin@example.com') + insertCredentialAccount(db, 'account-1', 'user-1', 'old-hash') + const auth = { api: { signUpEmail } } as unknown as AuthInstance + + await syncAdminFromEnv(auth, db) + + expect(hashPasswordMock).toHaveBeenCalledWith('new-password') + expect(signUpEmail).not.toHaveBeenCalled() + const account = db.prepare('SELECT password FROM "account" WHERE "userId" = ?').get('user-1') as { password: string } + expect(account.password).toBe('hashed:new-password') + expect(mockLoggerInfo).toHaveBeenCalledWith(expect.stringContaining('Admin password reset')) + expect(mockLoggerWarn).toHaveBeenCalledWith(expect.stringContaining('ADMIN_PASSWORD_RESET')) + }) + + it('creates a new admin user when none exists', async () => { + ENV.AUTH.ADMIN_EMAIL = 'admin@example.com' + ENV.AUTH.ADMIN_PASSWORD = 'admin-password' + const auth = { api: { signUpEmail } } as unknown as AuthInstance + + await syncAdminFromEnv(auth, db) + + expect(signUpEmail).toHaveBeenCalledWith({ + body: { email: 'admin@example.com', password: 'admin-password', name: 'Admin' }, + }) + expect(mockLoggerInfo).toHaveBeenCalledWith(expect.stringContaining('Admin user created')) + }) + + it('logs and swallows sign-up failures', async () => { + ENV.AUTH.ADMIN_EMAIL = 'admin@example.com' + ENV.AUTH.ADMIN_PASSWORD = 'admin-password' + const failure = new Error('sign up failed') + signUpEmail.mockRejectedValueOnce(failure) + const auth = { api: { signUpEmail } } as unknown as AuthInstance + + await expect(syncAdminFromEnv(auth, db)).resolves.toBeUndefined() + + expect(mockLoggerError).toHaveBeenCalledWith('Failed to create admin user from environment:', failure) + }) +}) + +describe('createAuthInfoRoutes', () => { + let db: ReturnType + let getSession: ReturnType + let app: ReturnType + + beforeEach(() => { + resetEnv() + vi.clearAllMocks() + db = createTestDb() + getSession = vi.fn() + app = createAuthInfoRoutes({ api: { getSession } } as unknown as AuthInstance, db) + }) + + afterEach(() => { + db.close() + }) + + it('returns the default config for an empty database with no providers', async () => { + const res = await app.fetch(new Request('http://localhost/config')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + enabledProviders: ['credentials', 'passkey'], + registrationEnabled: true, + isFirstUser: true, + adminConfigured: false, + }) + }) + + it('lists every configured social provider', async () => { + ENV.AUTH.GITHUB_CLIENT_ID = 'github-id' + ENV.AUTH.GITHUB_CLIENT_SECRET = 'github-secret' + ENV.AUTH.GOOGLE_CLIENT_ID = 'google-id' + ENV.AUTH.GOOGLE_CLIENT_SECRET = 'google-secret' + ENV.AUTH.DISCORD_CLIENT_ID = 'discord-id' + ENV.AUTH.DISCORD_CLIENT_SECRET = 'discord-secret' + + const res = await app.fetch(new Request('http://localhost/config')) + const json = await res.json() as { enabledProviders: string[] } + + expect(json.enabledProviders).toEqual(['credentials', 'github', 'google', 'discord', 'passkey']) + }) + + it('omits social providers with only one credential half configured', async () => { + ENV.AUTH.GITHUB_CLIENT_ID = 'github-id' + ENV.AUTH.GOOGLE_CLIENT_SECRET = 'google-secret' + ENV.AUTH.DISCORD_CLIENT_ID = 'discord-id' + + const res = await app.fetch(new Request('http://localhost/config')) + const json = await res.json() as { enabledProviders: string[] } + + expect(json.enabledProviders).toEqual(['credentials', 'passkey']) + }) + + it('disables registration when admin credentials are configured', async () => { + ENV.AUTH.ADMIN_EMAIL = 'admin@example.com' + ENV.AUTH.ADMIN_PASSWORD = 'admin-password' + + const res = await app.fetch(new Request('http://localhost/config')) + const json = await res.json() as { registrationEnabled: boolean; adminConfigured: boolean } + + expect(json.registrationEnabled).toBe(false) + expect(json.adminConfigured).toBe(true) + }) + + it('reports that it is not the first user once a user exists', async () => { + insertUser(db, 'user-1', 'user@example.com') + + const res = await app.fetch(new Request('http://localhost/config')) + const json = await res.json() as { isFirstUser: boolean } + + expect(json.isFirstUser).toBe(false) + }) + + it('returns the session and user from GET /me', async () => { + const session = createSession() + getSession.mockResolvedValueOnce(session) + + const res = await app.fetch(new Request('http://localhost/me')) + const json = await res.json() as { user: unknown; session: { id: string; expiresAt: string } } + + expect(getSession).toHaveBeenCalledWith({ headers: expect.any(Headers) }) + expect(json.user).toEqual({ + ...session.user, + createdAt: session.user.createdAt.toISOString(), + updatedAt: session.user.updatedAt.toISOString(), + }) + expect(json.session).toEqual({ + id: 'session-1', + expiresAt: session.session.expiresAt.toISOString(), + }) + }) + + it('returns nulls from GET /me when no session is found', async () => { + getSession.mockResolvedValueOnce(null) + + const res = await app.fetch(new Request('http://localhost/me')) + + expect(await res.json()).toEqual({ user: null, session: null }) + }) + + it('returns nulls from GET /me when the session lookup throws', async () => { + getSession.mockRejectedValueOnce(new Error('database unavailable')) + + const res = await app.fetch(new Request('http://localhost/me')) + + expect(await res.json()).toEqual({ user: null, session: null }) + }) +}) diff --git a/backend/test/routes/internal-opencode-config.test.ts b/backend/test/routes/internal-opencode-config.test.ts new file mode 100644 index 000000000..65df8ff3a --- /dev/null +++ b/backend/test/routes/internal-opencode-config.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { Hono } from 'hono' +import { Database } from 'bun:sqlite' +import { readFile } from 'fs/promises' +import path from 'path' +import { createInternalRoutes } from '../../src/routes/internal' +import { ScheduleService } from '../../src/services/schedules' +import { NotificationService } from '../../src/services/notification' +import { SettingsService } from '../../src/services/settings' +import { createOpenCodeClient } from '../../src/services/opencode/client' +import { allMigrations } from '../../src/db/migrations' +import { getOrCreateInternalToken } from '../../src/services/internal-token' +import { migrate } from '../../src/db/migration-runner' +import { OPENCODE_CONFIG_SEED, writeOpenCodeConfigFile } from '../../src/services/opencode-config-file' +import { createTempAssistantWorkspace } from '../helpers/assistant-workspace' +import type { ScheduleWorktreeManager } from '../../src/services/schedule-worktree' + +describe('internal/opencode-config routes', () => { + let db: Database + let app: Hono + let token: string + let ws: Awaited> + + beforeEach(async () => { + ws = await createTempAssistantWorkspace() + db = new Database(':memory:') + migrate(db, allMigrations) + const openCodeClient = createOpenCodeClient() + const stubWorktreeManager = { prepare: () => Promise.resolve(null), finalize: () => Promise.resolve({ commitHash: null }) } as unknown as ScheduleWorktreeManager + const scheduleService = new ScheduleService(db, openCodeClient, stubWorktreeManager) + const notificationService = new NotificationService(db) + const settingsService = new SettingsService(db) + app = new Hono() + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient)) + token = getOrCreateInternalToken(db) + }) + + afterEach(async () => { + await ws.cleanup() + }) + + it('GET /api/internal/opencode-config returns 401 without bearer token', async () => { + const res = await app.request('/api/internal/opencode-config') + expect(res.status).toBe(401) + }) + + it('GET /api/internal/opencode-config returns 404 when no config file exists', async () => { + const res = await app.request('/api/internal/opencode-config', { + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.status).toBe(404) + const body = await res.json() as { error: string } + expect(body.error).toBe('No OpenCode config file found') + }) + + it('GET /api/internal/opencode-config returns the on-disk config state', async () => { + await writeOpenCodeConfigFile(OPENCODE_CONFIG_SEED) + + const res = await app.request('/api/internal/opencode-config', { + headers: { authorization: `Bearer ${token}` }, + }) + + expect(res.status).toBe(200) + const body = await res.json() as { + path: string + content: Record + rawContent: string + isValid: boolean + updatedAt: number + } + expect(body.path).toBe(path.join(ws.workspacePath, '.config/opencode/opencode.json')) + expect(body.rawContent).toBe(OPENCODE_CONFIG_SEED) + expect(body.content).toEqual({ $schema: 'https://opencode.ai/config.json' }) + expect(body.isValid).toBe(true) + expect(body.updatedAt).toBeGreaterThan(0) + }) + + it('PUT /api/internal/opencode-config writes the file and reports restartRequired for a plugin change', async () => { + await writeOpenCodeConfigFile(OPENCODE_CONFIG_SEED) + + const res = await app.request('/api/internal/opencode-config', { + method: 'PUT', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ content: { $schema: 'https://opencode.ai/config.json', plugin: ['x'] } }), + }) + + expect(res.status).toBe(200) + const body = await res.json() as { restartRequired?: boolean; content: Record } + expect(body.restartRequired).toBe(true) + expect(body.content).toEqual({ $schema: 'https://opencode.ai/config.json', plugin: ['x'] }) + + const onDisk = await readFile(path.join(ws.workspacePath, '.config/opencode/opencode.json'), 'utf8') + expect(JSON.parse(onDisk).plugin).toEqual(['x']) + }) + + it('PUT /api/internal/opencode-config returns 400 for an invalid JSON body', async () => { + const res = await app.request('/api/internal/opencode-config', { + method: 'PUT', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + body: '{', + }) + + expect(res.status).toBe(400) + const body = await res.json() as { error: string } + expect(body.error).toBe('Invalid JSON') + }) +}) diff --git a/backend/test/routes/mcp-oauth-proxy.test.ts b/backend/test/routes/mcp-oauth-proxy.test.ts new file mode 100644 index 000000000..fb7a0335c --- /dev/null +++ b/backend/test/routes/mcp-oauth-proxy.test.ts @@ -0,0 +1,486 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { readFile, writeFile, mkdir, rm } from 'fs/promises' +import { dirname } from 'path' +import type { MiddlewareHandler } from 'hono' +import { createMcpOauthProxyRoutes } from '../../src/routes/mcp-oauth-proxy' +import { createStubOpenCodeClient } from '../helpers/stub-opencode-client' +import type { OpenCodeClient } from '../../src/services/opencode/client' + +vi.mock('../../src/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})) + +const WORKSPACE_PATH = '/tmp/test-workspace' +const MCP_AUTH_PATH = `${WORKSPACE_PATH}/.opencode/state/opencode/mcp-auth.json` +const SERVER_URL = 'https://mcp.example.com' +const AUTHORIZATION_ENDPOINT = `${SERVER_URL}/authorize` +const TOKEN_ENDPOINT = `${SERVER_URL}/token` +const REGISTRATION_ENDPOINT = `${SERVER_URL}/register` + +const fetchMock = vi.fn() + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +function textResponse(body: string, status = 200): Response { + return new Response(body, { status }) +} + +function discoveryMetadata(overrides: Record = {}) { + return { + authorization_endpoint: AUTHORIZATION_ENDPOINT, + token_endpoint: TOKEN_ENDPOINT, + ...overrides, + } +} + +function createApp( + client: OpenCodeClient = createStubOpenCodeClient(), + requireAuth?: MiddlewareHandler, +) { + return createMcpOauthProxyRoutes(client, requireAuth) +} + +async function startFlow( + app: ReturnType, + body: Record = {}, + headers: Record = {}, +): Promise { + return app.request('/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify({ serverName: 'my-server', serverUrl: SERVER_URL, ...body }), + }) +} + +async function readAuthFile(): Promise + clientInfo: Record +}>> { + return JSON.parse(await readFile(MCP_AUTH_PATH, 'utf-8')) as Record + clientInfo: Record + }> +} + +describe('mcp oauth proxy routes', () => { + beforeEach(async () => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + await rm(MCP_AUTH_PATH, { force: true }) + }) + + afterEach(async () => { + vi.unstubAllGlobals() + await rm(MCP_AUTH_PATH, { force: true }) + }) + + describe('POST /start', () => { + it('returns 400 when OAuth metadata discovery responds with an error', async () => { + fetchMock.mockResolvedValueOnce(textResponse('not found', 404)) + + const res = await startFlow(createApp()) + const json = await res.json() as { error: string } + + expect(res.status).toBe(400) + expect(json.error).toBe('OAuth metadata discovery failed for this MCP server') + expect(fetchMock).toHaveBeenCalledWith( + `${SERVER_URL}/.well-known/oauth-authorization-server`, + expect.objectContaining({ headers: { 'MCP-Protocol-Version': '2025-03-26' } }), + ) + }) + + it('returns 400 when OAuth metadata discovery throws', async () => { + fetchMock.mockRejectedValueOnce(new Error('network down')) + + const res = await startFlow(createApp()) + const json = await res.json() as { error: string } + + expect(res.status).toBe(400) + expect(json.error).toBe('OAuth metadata discovery failed for this MCP server') + }) + + it('returns 400 when the server has no registration endpoint and no clientId is provided', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(discoveryMetadata())) + + const res = await startFlow(createApp()) + const json = await res.json() as { error: string } + + expect(res.status).toBe(400) + expect(json.error).toBe('Server does not support dynamic client registration and no clientId provided') + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('builds the authorization URL with a provided clientId and forwarded proto', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(discoveryMetadata())) + + const res = await startFlow( + createApp(), + { clientId: 'client-123', scope: 'read write' }, + { 'x-forwarded-proto': 'https', host: 'manager.example.com' }, + ) + const json = await res.json() as { authorizationUrl: string; flowId: string } + const authUrl = new URL(json.authorizationUrl) + + expect(res.status).toBe(200) + expect(`${authUrl.origin}${authUrl.pathname}`).toBe(AUTHORIZATION_ENDPOINT) + expect(authUrl.searchParams.get('response_type')).toBe('code') + expect(authUrl.searchParams.get('client_id')).toBe('client-123') + expect(authUrl.searchParams.get('redirect_uri')).toBe('https://manager.example.com/api/mcp-oauth-proxy/callback') + expect(authUrl.searchParams.get('state')).toBe(json.flowId) + expect(authUrl.searchParams.get('code_challenge')).toMatch(/^[A-Za-z0-9_-]{43}$/) + expect(authUrl.searchParams.get('code_challenge_method')).toBe('S256') + expect(authUrl.searchParams.get('scope')).toBe('read write') + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('uses the origin header for the callback URL when no forwarded proto is present', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(discoveryMetadata())) + + const res = await startFlow( + createApp(), + { clientId: 'client-123' }, + { origin: 'http://localhost:5173' }, + ) + const json = await res.json() as { authorizationUrl: string } + const authUrl = new URL(json.authorizationUrl) + + expect(res.status).toBe(200) + expect(authUrl.searchParams.get('redirect_uri')).toBe('http://localhost:5173/api/mcp-oauth-proxy/callback') + }) + + it('falls back to the request host for the callback URL', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(discoveryMetadata())) + + const res = await startFlow(createApp(), { clientId: 'client-123' }, { host: 'manager.example.com' }) + const json = await res.json() as { authorizationUrl: string } + const authUrl = new URL(json.authorizationUrl) + + expect(res.status).toBe(200) + expect(authUrl.searchParams.get('redirect_uri')).toBe('http://manager.example.com/api/mcp-oauth-proxy/callback') + }) + + it('falls back to localhost:5003 for the callback URL when no host headers are present', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(discoveryMetadata())) + + const res = await startFlow(createApp(), { clientId: 'client-123' }) + const json = await res.json() as { authorizationUrl: string } + const authUrl = new URL(json.authorizationUrl) + + expect(res.status).toBe(200) + expect(authUrl.searchParams.get('redirect_uri')).toBe('http://localhost:5003/api/mcp-oauth-proxy/callback') + }) + + it('registers a client dynamically when no clientId is provided', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse(discoveryMetadata({ registration_endpoint: REGISTRATION_ENDPOINT }))) + .mockResolvedValueOnce(jsonResponse({ client_id: 'dynamic-client' })) + + const res = await startFlow(createApp()) + const json = await res.json() as { authorizationUrl: string } + const authUrl = new URL(json.authorizationUrl) + const registrationCall = fetchMock.mock.calls[1]! + const registrationInit = registrationCall[1] as RequestInit + const registrationBody = JSON.parse(registrationInit.body as string) as Record + + expect(res.status).toBe(200) + expect(authUrl.searchParams.get('client_id')).toBe('dynamic-client') + expect(registrationCall[0]).toBe(REGISTRATION_ENDPOINT) + expect(registrationInit.method).toBe('POST') + expect(registrationBody.redirect_uris).toEqual(['http://localhost:5003/api/mcp-oauth-proxy/callback']) + expect(registrationBody.client_name).toBe('OpenCode Manager') + expect(registrationBody.token_endpoint_auth_method).toBe('none') + }) + + it('registers with client_secret_post when a clientSecret is provided', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse(discoveryMetadata({ registration_endpoint: REGISTRATION_ENDPOINT }))) + .mockResolvedValueOnce(jsonResponse({ client_id: 'dynamic-client', client_secret: 'dynamic-secret' })) + + const res = await startFlow(createApp(), { clientSecret: 'seed-secret' }) + const registrationCall = fetchMock.mock.calls[1]! + const registrationInit = registrationCall[1] as RequestInit + const registrationBody = JSON.parse(registrationInit.body as string) as Record + + expect(res.status).toBe(200) + expect(registrationBody.token_endpoint_auth_method).toBe('client_secret_post') + }) + + it('returns 500 when dynamic client registration responds with an error', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse(discoveryMetadata({ registration_endpoint: REGISTRATION_ENDPOINT }))) + .mockResolvedValueOnce(textResponse('bad request', 400)) + + const res = await startFlow(createApp()) + const json = await res.json() as { error: string } + + expect(res.status).toBe(500) + expect(json.error).toContain('Dynamic client registration failed') + }) + + it('returns 500 when the registration request throws', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse(discoveryMetadata({ registration_endpoint: REGISTRATION_ENDPOINT }))) + .mockRejectedValueOnce(new Error('connection refused')) + + const res = await startFlow(createApp()) + const json = await res.json() as { error: string } + + expect(res.status).toBe(500) + expect(json.error).toBe('connection refused') + }) + + it('returns 500 when the request body fails schema validation', async () => { + const res = await createApp().request('/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ serverName: 'my-server', serverUrl: 'not-a-url' }), + }) + + expect(res.status).toBe(500) + }) + + it('returns 500 when the request body is not valid JSON', async () => { + const res = await createApp().request('/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{', + }) + + expect(res.status).toBe(500) + }) + }) + + describe('GET /status/:flowId', () => { + it('returns unknown for an unknown flow', async () => { + const res = await createApp().request('/status/does-not-exist') + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ status: 'unknown' }) + }) + + it('returns pending for a started flow', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(discoveryMetadata())) + const app = createApp() + const started = await startFlow(app, { clientId: 'client-123' }) + const { flowId } = await started.json() as { flowId: string } + + const res = await app.request(`/status/${flowId}`) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ status: 'pending' }) + }) + }) + + describe('GET /callback', () => { + it('returns 400 when the state parameter is missing', async () => { + const res = await createApp().request('/callback?code=abc') + + expect(res.status).toBe(400) + expect(await res.text()).toContain('Missing State') + }) + + it('returns 400 and marks the flow failed when the provider reports an error', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(discoveryMetadata())) + const app = createApp() + const started = await startFlow(app, { clientId: 'client-123' }) + const { flowId } = await started.json() as { flowId: string } + + const res = await app.request(`/callback?state=${flowId}&error=access_denied&error_description=User%20denied`) + const statusRes = await app.request(`/status/${flowId}`) + + expect(res.status).toBe(400) + expect(await res.text()).toContain('Authorization Failed') + expect(await statusRes.json()).toEqual({ status: 'failed', error: 'User denied' }) + }) + + it('returns 400 when the authorization code is missing', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(discoveryMetadata())) + const app = createApp() + const started = await startFlow(app, { clientId: 'client-123' }) + const { flowId } = await started.json() as { flowId: string } + + const res = await app.request(`/callback?state=${flowId}`) + const statusRes = await app.request(`/status/${flowId}`) + + expect(res.status).toBe(400) + expect(await res.text()).toContain('Missing Code') + expect(await statusRes.json()).toEqual({ status: 'failed', error: 'No authorization code received' }) + }) + + it('returns 400 for an unknown or expired state', async () => { + const res = await createApp().request('/callback?code=abc&state=unknown-state') + + expect(res.status).toBe(400) + expect(await res.text()).toContain('Session Expired') + }) + + it('returns 500 when the token exchange responds with an error', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(discoveryMetadata())) + const app = createApp() + const started = await startFlow(app, { clientId: 'client-123' }) + const { flowId } = await started.json() as { flowId: string } + fetchMock.mockResolvedValueOnce(textResponse('invalid_grant', 400)) + + const res = await app.request(`/callback?code=auth-code&state=${flowId}`) + const statusRes = await app.request(`/status/${flowId}`) + + expect(res.status).toBe(500) + expect(await res.text()).toContain('Token Exchange Failed') + expect(await statusRes.json()).toEqual({ status: 'failed', error: 'Token exchange failed' }) + }) + + it('returns 500 when the token exchange request throws', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(discoveryMetadata())) + const app = createApp() + const started = await startFlow(app, { clientId: 'client-123' }) + const { flowId } = await started.json() as { flowId: string } + fetchMock.mockRejectedValueOnce(new Error('token endpoint down')) + + const res = await app.request(`/callback?code=auth-code&state=${flowId}`) + const statusRes = await app.request(`/status/${flowId}`) + + expect(res.status).toBe(500) + expect(await res.text()).toContain('Unexpected Error') + expect(await statusRes.json()).toEqual({ status: 'failed', error: 'Unexpected error during token exchange' }) + }) + + it('writes tokens to mcp-auth.json and reconnects with the directory', async () => { + const forward = vi.fn(async () => new Response('{}', { status: 200 })) + const app = createApp(createStubOpenCodeClient({ forward })) + fetchMock.mockResolvedValueOnce(jsonResponse(discoveryMetadata())) + const started = await startFlow(app, { clientId: 'client-123', directory: '/tmp/project' }) + const { flowId } = await started.json() as { flowId: string } + const before = Math.floor(Date.now() / 1000) + fetchMock.mockResolvedValueOnce(jsonResponse({ + access_token: 'access-token', + refresh_token: 'refresh-token', + expires_in: 3600, + scope: 'read write', + token_type: 'Bearer', + })) + + const res = await app.request(`/callback?code=auth-code&state=${flowId}`) + const auth = await readAuthFile() + const statusRes = await app.request(`/status/${flowId}`) + + expect(res.status).toBe(200) + expect(await res.text()).toContain('Authentication Successful') + expect(auth['my-server']!.serverUrl).toBe(SERVER_URL) + expect(auth['my-server']!.tokens.accessToken).toBe('access-token') + expect(auth['my-server']!.tokens.refreshToken).toBe('refresh-token') + expect(auth['my-server']!.tokens.scope).toBe('read write') + expect(auth['my-server']!.tokens.expiresAt).toBeGreaterThanOrEqual(before + 3600) + expect(auth['my-server']!.clientInfo).toEqual({ clientId: 'client-123' }) + expect(forward).toHaveBeenCalledTimes(2) + expect(forward).toHaveBeenNthCalledWith(1, { + method: 'POST', + path: '/mcp/my-server/connect', + directory: '/tmp/project', + }) + expect(forward).toHaveBeenNthCalledWith(2, { + method: 'POST', + path: '/mcp/my-server/connect', + }) + expect(await statusRes.json()).toEqual({ status: 'completed', serverName: 'my-server' }) + }) + + it('merges tokens into an existing mcp-auth.json without dropping other servers', async () => { + await mkdir(dirname(MCP_AUTH_PATH), { recursive: true }) + await writeFile(MCP_AUTH_PATH, JSON.stringify({ existing: { serverUrl: 'https://old.example.com' } })) + const app = createApp() + fetchMock.mockResolvedValueOnce(jsonResponse(discoveryMetadata())) + const started = await startFlow(app, { clientId: 'client-123' }) + const { flowId } = await started.json() as { flowId: string } + fetchMock.mockResolvedValueOnce(jsonResponse({ access_token: 'access-token' })) + + const res = await app.request(`/callback?code=auth-code&state=${flowId}`) + const auth = await readAuthFile() + + expect(res.status).toBe(200) + expect(auth.existing).toEqual({ serverUrl: 'https://old.example.com' }) + expect(auth['my-server']!.tokens.accessToken).toBe('access-token') + expect(auth['my-server']!.tokens.expiresAt).toBeUndefined() + }) + + it('still reports success when the reconnect trigger fails', async () => { + const forward = vi.fn(async () => { + throw new Error('reconnect failed') + }) + const app = createApp(createStubOpenCodeClient({ forward })) + fetchMock.mockResolvedValueOnce(jsonResponse(discoveryMetadata())) + const started = await startFlow(app, { clientId: 'client-123' }) + const { flowId } = await started.json() as { flowId: string } + fetchMock.mockResolvedValueOnce(jsonResponse({ access_token: 'access-token' })) + + const res = await app.request(`/callback?code=auth-code&state=${flowId}`) + + expect(res.status).toBe(200) + expect(await res.text()).toContain('Authentication Successful') + expect(forward).toHaveBeenCalledTimes(1) + }) + + it('sends the client secret during token exchange when one was registered', async () => { + const app = createApp() + fetchMock + .mockResolvedValueOnce(jsonResponse(discoveryMetadata({ registration_endpoint: REGISTRATION_ENDPOINT }))) + .mockResolvedValueOnce(jsonResponse({ client_id: 'dynamic-client', client_secret: 'dynamic-secret' })) + const started = await startFlow(app) + const { flowId } = await started.json() as { flowId: string } + fetchMock.mockResolvedValueOnce(jsonResponse({ access_token: 'access-token' })) + + const res = await app.request(`/callback?code=auth-code&state=${flowId}`) + const tokenCall = fetchMock.mock.calls[2]! + const tokenInit = tokenCall[1] as RequestInit + const params = tokenInit.body as URLSearchParams + + expect(res.status).toBe(200) + expect(tokenCall[0]).toBe(TOKEN_ENDPOINT) + expect(params.get('grant_type')).toBe('authorization_code') + expect(params.get('client_id')).toBe('dynamic-client') + expect(params.get('client_secret')).toBe('dynamic-secret') + expect(params.get('code')).toBe('auth-code') + expect(params.get('code_verifier')).toBeTruthy() + expect(params.get('redirect_uri')).toBe('http://localhost:5003/api/mcp-oauth-proxy/callback') + }) + }) + + describe('requireAuth middleware', () => { + const denyAuth: MiddlewareHandler = async (c) => c.json({ error: 'unauthorized' }, 401) + const allowAuth: MiddlewareHandler = async (c, next) => { + await next() + } + + it('protects /start and /status but not /callback when the middleware rejects', async () => { + const app = createApp(createStubOpenCodeClient(), denyAuth) + + const startRes = await startFlow(app) + const statusRes = await app.request('/status/anything') + const callbackRes = await app.request('/callback') + + expect(startRes.status).toBe(401) + expect(statusRes.status).toBe(401) + expect(callbackRes.status).toBe(400) + }) + + it('allows requests through when the middleware calls next', async () => { + const app = createApp(createStubOpenCodeClient(), allowAuth) + fetchMock.mockResolvedValueOnce(jsonResponse(discoveryMetadata())) + + const res = await startFlow(app, { clientId: 'client-123' }) + + expect(res.status).toBe(200) + }) + }) +}) diff --git a/backend/test/routes/notifications.test.ts b/backend/test/routes/notifications.test.ts new file mode 100644 index 000000000..69b0ed653 --- /dev/null +++ b/backend/test/routes/notifications.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createNotificationRoutes } from '../../src/routes/notifications' +import type { NotificationService } from '../../src/services/notification' + +interface NotificationServiceStub { + getVapidPublicKey: ReturnType + saveSubscription: ReturnType + removeSubscription: ReturnType + getSubscriptions: ReturnType + removeSubscriptionById: ReturnType + isConfigured: ReturnType + sendTestNotification: ReturnType +} + +describe('Notification Routes', () => { + let service: NotificationServiceStub + let app: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + service = { + getVapidPublicKey: vi.fn(), + saveSubscription: vi.fn(), + removeSubscription: vi.fn(), + getSubscriptions: vi.fn(), + removeSubscriptionById: vi.fn(), + isConfigured: vi.fn(), + sendTestNotification: vi.fn(), + } + app = createNotificationRoutes(service as unknown as NotificationService) + }) + + describe('GET /vapid-public-key', () => { + it('returns the public key when configured', async () => { + service.getVapidPublicKey.mockReturnValue('public-key') + + const res = await app.fetch(new Request('http://localhost/vapid-public-key')) + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ publicKey: 'public-key' }) + }) + + it('returns 503 when push notifications are not configured', async () => { + service.getVapidPublicKey.mockReturnValue(null) + + const res = await app.fetch(new Request('http://localhost/vapid-public-key')) + + expect(res.status).toBe(503) + await expect(res.json()).resolves.toEqual({ error: 'Push notifications are not configured' }) + }) + }) + + describe('POST /subscribe', () => { + it('saves a subscription with the default userId', async () => { + const subscription = { + id: 1, + userId: 'default', + endpoint: 'https://push.example.com/abc', + p256dh: 'p256dh-key', + auth: 'auth-key', + deviceName: 'Phone', + createdAt: 1, + lastUsedAt: null, + } + service.saveSubscription.mockReturnValue(subscription) + + const res = await app.fetch(new Request('http://localhost/subscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + endpoint: 'https://push.example.com/abc', + keys: { p256dh: 'p256dh-key', auth: 'auth-key' }, + deviceName: 'Phone', + }), + })) + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ subscription }) + expect(service.saveSubscription).toHaveBeenCalledWith( + 'default', + 'https://push.example.com/abc', + 'p256dh-key', + 'auth-key', + 'Phone', + ) + }) + + it('saves a subscription with an overridden userId', async () => { + const subscription = { id: 2 } + service.saveSubscription.mockReturnValue(subscription) + + const res = await app.fetch(new Request('http://localhost/subscribe?userId=user-9', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + endpoint: 'https://push.example.com/abc', + keys: { p256dh: 'p256dh-key', auth: 'auth-key' }, + }), + })) + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ subscription }) + expect(service.saveSubscription).toHaveBeenCalledWith( + 'user-9', + 'https://push.example.com/abc', + 'p256dh-key', + 'auth-key', + undefined, + ) + }) + + it('returns 400 for invalid subscription data', async () => { + const res = await app.fetch(new Request('http://localhost/subscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ endpoint: 'not-a-url', keys: {} }), + })) + + expect(res.status).toBe(400) + const json = await res.json() as { error: string } + expect(json.error).toBe('Invalid subscription data') + expect(service.saveSubscription).not.toHaveBeenCalled() + }) + }) + + describe('DELETE /subscribe', () => { + it('removes a subscription with the default userId', async () => { + service.removeSubscription.mockReturnValue(true) + + const res = await app.fetch(new Request('http://localhost/subscribe', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ endpoint: 'https://push.example.com/abc' }), + })) + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ success: true }) + expect(service.removeSubscription).toHaveBeenCalledWith('https://push.example.com/abc', 'default') + }) + + it('returns 400 for an invalid endpoint', async () => { + const res = await app.fetch(new Request('http://localhost/subscribe', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ endpoint: 'not-a-url' }), + })) + + expect(res.status).toBe(400) + const json = await res.json() as { error: string } + expect(json.error).toBe('Valid endpoint URL is required') + expect(service.removeSubscription).not.toHaveBeenCalled() + }) + }) + + describe('GET /subscriptions', () => { + it('lists subscriptions for the requested user', async () => { + const subscriptions = [{ id: 1 }, { id: 2 }] + service.getSubscriptions.mockReturnValue(subscriptions) + + const res = await app.fetch(new Request('http://localhost/subscriptions?userId=user-9')) + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ subscriptions }) + expect(service.getSubscriptions).toHaveBeenCalledWith('user-9') + }) + }) + + describe('DELETE /subscriptions/:id', () => { + it('removes a subscription by numeric id', async () => { + service.removeSubscriptionById.mockReturnValue(true) + + const res = await app.fetch(new Request('http://localhost/subscriptions/12?userId=user-9', { + method: 'DELETE', + })) + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ success: true }) + expect(service.removeSubscriptionById).toHaveBeenCalledWith(12, 'user-9') + }) + + it('returns 400 for a non-numeric id', async () => { + const res = await app.fetch(new Request('http://localhost/subscriptions/abc', { + method: 'DELETE', + })) + + expect(res.status).toBe(400) + await expect(res.json()).resolves.toEqual({ error: 'Invalid subscription ID' }) + expect(service.removeSubscriptionById).not.toHaveBeenCalled() + }) + }) + + describe('POST /test', () => { + it('returns 503 when push notifications are not configured', async () => { + service.isConfigured.mockReturnValue(false) + + const res = await app.fetch(new Request('http://localhost/test', { method: 'POST' })) + + expect(res.status).toBe(503) + await expect(res.json()).resolves.toEqual({ error: 'Push notifications are not configured' }) + expect(service.getSubscriptions).not.toHaveBeenCalled() + }) + + it('returns 404 when the user has no subscriptions', async () => { + service.isConfigured.mockReturnValue(true) + service.getSubscriptions.mockReturnValue([]) + + const res = await app.fetch(new Request('http://localhost/test', { method: 'POST' })) + + expect(res.status).toBe(404) + await expect(res.json()).resolves.toEqual({ error: 'No push subscriptions registered' }) + expect(service.sendTestNotification).not.toHaveBeenCalled() + }) + + it('sends a test notification and reports the device count', async () => { + service.isConfigured.mockReturnValue(true) + service.getSubscriptions.mockReturnValue([{ id: 1 }, { id: 2 }]) + service.sendTestNotification.mockResolvedValue(undefined) + + const res = await app.fetch(new Request('http://localhost/test?userId=user-9', { method: 'POST' })) + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ success: true, devicesNotified: 2 }) + expect(service.sendTestNotification).toHaveBeenCalledWith('user-9') + }) + }) +}) diff --git a/backend/test/routes/repos.test.ts b/backend/test/routes/repos.test.ts index 8383b0bb2..361649040 100644 --- a/backend/test/routes/repos.test.ts +++ b/backend/test/routes/repos.test.ts @@ -15,11 +15,23 @@ vi.mock('bun:sqlite', () => ({ vi.mock('../../src/db/queries', () => ({ getRepoById: vi.fn(), - updateLastAccessed: vi.fn() + updateLastAccessed: vi.fn(), + listRepos: vi.fn(), + getRepoGitCredentialId: vi.fn(), + setRepoGitCredentialId: vi.fn(), + updateRepoName: vi.fn(), })) vi.mock('../../src/services/repo', () => ({ - getCurrentBranch: vi.fn() + getCurrentBranch: vi.fn(), + initLocalRepo: vi.fn(), + cloneRepo: vi.fn(), + discoverLocalRepos: vi.fn(), + pullRepo: vi.fn(), + switchBranch: vi.fn(), + createBranch: vi.fn(), + deleteRepoFiles: vi.fn(), + getSiblingRepos: vi.fn(), })) vi.mock('../../src/services/assistant-mode', () => ({ @@ -27,6 +39,24 @@ vi.mock('../../src/services/assistant-mode', () => ({ ensureAssistantMode: vi.fn(), getAssistantModeDirectory: vi.fn(), buildAssistantOpenCodeConfig: vi.fn(), + buildAssistantRepo: vi.fn(), +})) + +vi.mock('../../src/services/archive', () => ({ + createRepoArchive: vi.fn(), + getArchiveSize: vi.fn(), + getArchiveStream: vi.fn(), + deleteArchive: vi.fn(), +})) + +const mockGetSettings = vi.fn() +const mockUpdateSettings = vi.fn() + +vi.mock('../../src/services/settings', () => ({ + SettingsService: vi.fn().mockImplementation(() => ({ + getSettings: mockGetSettings, + updateSettings: mockUpdateSettings, + })), })) vi.mock('../../src/services/opencode-single-server', () => ({ @@ -37,24 +67,55 @@ vi.mock('../../src/services/opencode-single-server', () => ({ }, })) +import { Readable } from 'stream' import * as db from '../../src/db/queries' +import * as repoService from '../../src/services/repo' +import * as archiveService from '../../src/services/archive' import { createRepoRoutes } from '../../src/routes/repos' import { opencodeServerManager } from '../../src/services/opencode-single-server' import type { GitAuthService } from '../../src/services/git-auth' import type { ScheduleService } from '../../src/services/schedules' -import type { AssistantModeStatus } from '@opencode-manager/shared/types' -import { getAssistantModeStatus, ensureAssistantMode } from '../../src/services/assistant-mode' +import type { AssistantModeStatus, Repo } from '@opencode-manager/shared/types' +import { getAssistantModeStatus, ensureAssistantMode, buildAssistantRepo } from '../../src/services/assistant-mode' const mockGitAuthService = { getGitEnvironment: vi.fn().mockReturnValue({}) } as unknown as GitAuthService -const mockScheduleService = {} as ScheduleService +const mockPrepareRepoDelete = vi.fn() +const mockScheduleService = { + prepareRepoDelete: mockPrepareRepoDelete, +} as unknown as ScheduleService + +function createMockRepo(overrides: Partial = {}): Repo { + return { + id: 1, + repoUrl: 'https://github.com/test/repo', + localPath: 'repos/test-repo', + fullPath: '/tmp/repos/test-repo', + sourcePath: '/tmp/repos/test-repo/.git', + branch: 'main', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + lastAccessedAt: Date.now(), + ...overrides, + } +} describe('Repo Routes', () => { beforeEach(() => { vi.clearAllMocks() vi.mocked(opencodeServerManager.isSandboxEnforced).mockReturnValue(false) + mockGetSettings.mockReturnValue({ + preferences: { repoOrder: [], gitCredentials: [] }, + updatedAt: Date.now(), + }) + mockUpdateSettings.mockReturnValue({ + preferences: { repoOrder: [] }, + updatedAt: Date.now(), + }) + vi.mocked(db.getRepoGitCredentialId).mockReturnValue(null) }) describe('POST /:id/access', () => { @@ -387,4 +448,916 @@ describe('Repo Routes', () => { expect(forward).not.toHaveBeenCalledWith(expect.objectContaining({ method: 'DELETE' })) }) }) + + describe('POST /', () => { + it('should return 400 when neither repoUrl nor localPath is provided', async () => { + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + + expect(res.status).toBe(400) + const body = await res.json() as { error: string } + expect(body.error).toBe('Either repoUrl or localPath is required') + }) + + it('should create a local repo through initLocalRepo', async () => { + const repo = createMockRepo() + vi.mocked(repoService.initLocalRepo).mockResolvedValue(repo) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ localPath: 'repos/test-repo', branch: 'main' }), + }) + + expect(res.status).toBe(200) + const body = await res.json() as Repo + expect(body.id).toBe(1) + expect(repoService.initLocalRepo).toHaveBeenCalledWith(mockDb, mockGitAuthService, 'repos/test-repo', 'main') + }) + + it('should clone a remote repo through cloneRepo', async () => { + const repo = createMockRepo({ repoUrl: 'https://github.com/test/remote' }) + vi.mocked(repoService.cloneRepo).mockResolvedValue(repo) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ repoUrl: 'https://github.com/test/remote', directoryName: 'remote' }), + }) + + expect(res.status).toBe(200) + const body = await res.json() as Repo + expect(body.repoUrl).toBe('https://github.com/test/remote') + expect(repoService.cloneRepo).toHaveBeenCalledWith(mockDb, mockGitAuthService, 'https://github.com/test/remote', { + branch: undefined, + directoryName: 'remote', + useWorktree: undefined, + skipSSHVerification: undefined, + baseBranch: undefined, + }) + }) + + it('should return 500 when repo creation throws', async () => { + vi.mocked(repoService.initLocalRepo).mockRejectedValue(new Error('init failed')) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ localPath: 'repos/test-repo' }), + }) + + expect(res.status).toBe(500) + const body = await res.json() as { error: string } + expect(body.error).toBe('init failed') + }) + }) + + describe('POST /discover', () => { + it('should return 400 for an invalid body', async () => { + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/discover', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ rootPath: '' }), + }) + + expect(res.status).toBe(400) + expect(repoService.discoverLocalRepos).not.toHaveBeenCalled() + }) + + it('should return the discovered repos', async () => { + const discovery = { repos: [createMockRepo()], discoveredCount: 1, existingCount: 0, errors: [] } + vi.mocked(repoService.discoverLocalRepos).mockResolvedValue(discovery) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/discover', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ rootPath: '/tmp/repos', maxDepth: 2 }), + }) + + expect(res.status).toBe(200) + const body = await res.json() as typeof discovery + expect(body.discoveredCount).toBe(1) + expect(repoService.discoverLocalRepos).toHaveBeenCalledWith(mockDb, mockGitAuthService, '/tmp/repos', 2) + }) + + it('should return 500 when discovery throws', async () => { + vi.mocked(repoService.discoverLocalRepos).mockRejectedValue(new Error('discover failed')) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/discover', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ rootPath: '/tmp/repos' }), + }) + + expect(res.status).toBe(500) + const body = await res.json() as { error: string } + expect(body.error).toBe('discover failed') + }) + }) + + describe('GET /', () => { + it('should return repos with current branch and git credential id, skipping the assistant repo', async () => { + const repo = createMockRepo({ id: 1 }) + const assistantRepo = createMockRepo({ id: 0, localPath: 'assistant', fullPath: '/tmp/repos/assistant' }) + vi.mocked(db.listRepos).mockReturnValue([repo, assistantRepo]) + vi.mocked(repoService.getCurrentBranch).mockResolvedValue('main') + vi.mocked(db.getRepoGitCredentialId).mockReturnValue('cred-1') + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/', { method: 'GET' }) + + expect(res.status).toBe(200) + const body = await res.json() as Array + expect(body).toHaveLength(2) + expect(body[0]?.currentBranch).toBe('main') + expect(body[0]?.gitCredentialId).toBe('cred-1') + expect(body[1]?.currentBranch).toBeUndefined() + expect(repoService.getCurrentBranch).toHaveBeenCalledTimes(1) + expect(repoService.getCurrentBranch).toHaveBeenCalledWith(expect.objectContaining({ id: 1 }), {}) + }) + + it('should return 500 when listing repos throws', async () => { + vi.mocked(db.listRepos).mockImplementation(() => { + throw new Error('list failed') + }) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/', { method: 'GET' }) + + expect(res.status).toBe(500) + const body = await res.json() as { error: string } + expect(body.error).toBe('list failed') + }) + }) + + describe('PUT /order', () => { + it('should return 400 when order is not an array of numbers', async () => { + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/order', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ order: ['a', 'b'] }), + }) + + expect(res.status).toBe(400) + expect(mockUpdateSettings).not.toHaveBeenCalled() + }) + + it('should update the repo order', async () => { + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/order', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ order: [2, 1] }), + }) + + expect(res.status).toBe(200) + const body = await res.json() as { success: boolean } + expect(body.success).toBe(true) + expect(mockUpdateSettings).toHaveBeenCalledWith({ repoOrder: [2, 1] }) + }) + + it('should return 500 when the body is not valid JSON', async () => { + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/order', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: 'not-json', + }) + + expect(res.status).toBe(500) + }) + }) + + describe('GET /:id', () => { + it('should return the repo with its current branch', async () => { + vi.mocked(db.getRepoById).mockReturnValue(createMockRepo({ id: 5 })) + vi.mocked(repoService.getCurrentBranch).mockResolvedValue('develop') + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/5', { method: 'GET' }) + + expect(res.status).toBe(200) + const body = await res.json() as Repo & { currentBranch?: string } + expect(body.id).toBe(5) + expect(body.currentBranch).toBe('develop') + }) + + it('should return 404 when the repo does not exist', async () => { + vi.mocked(db.getRepoById).mockReturnValue(null) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/5', { method: 'GET' }) + + expect(res.status).toBe(404) + }) + + it('should return 500 when reading the current branch throws', async () => { + vi.mocked(db.getRepoById).mockReturnValue(createMockRepo({ id: 5 })) + vi.mocked(repoService.getCurrentBranch).mockRejectedValue(new Error('branch failed')) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/5', { method: 'GET' }) + + expect(res.status).toBe(500) + const body = await res.json() as { error: string } + expect(body.error).toBe('branch failed') + }) + }) + + describe('GET /:id/siblings', () => { + it('should return 400 for a non-numeric repo id', async () => { + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/abc/siblings', { method: 'GET' }) + + expect(res.status).toBe(400) + expect(repoService.getSiblingRepos).not.toHaveBeenCalled() + }) + + it('should return the sibling repos', async () => { + const siblings = [{ ...createMockRepo({ id: 2 }), currentBranch: 'main' }] + vi.mocked(repoService.getSiblingRepos).mockResolvedValue(siblings) + const client = createStubOpenCodeClient() + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, client) + const res = await app.request('/1/siblings', { method: 'GET' }) + + expect(res.status).toBe(200) + const body = await res.json() as Repo[] + expect(body).toHaveLength(1) + expect(repoService.getSiblingRepos).toHaveBeenCalledWith(mockDb, 1, {}, client) + }) + + it('should return 500 when listing siblings throws', async () => { + vi.mocked(repoService.getSiblingRepos).mockRejectedValue(new Error('siblings failed')) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/1/siblings', { method: 'GET' }) + + expect(res.status).toBe(500) + const body = await res.json() as { error: string } + expect(body.error).toBe('siblings failed') + }) + }) + + describe('PATCH /:id/git-credential', () => { + it('should return 404 when the repo does not exist', async () => { + vi.mocked(db.getRepoById).mockReturnValue(null) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/1/git-credential', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credentialId: 'cred-1' }), + }) + + expect(res.status).toBe(404) + }) + + it('should return 400 when the credential is not in settings', async () => { + vi.mocked(db.getRepoById).mockReturnValue(createMockRepo({ id: 1 })) + mockGetSettings.mockReturnValue({ preferences: { repoOrder: [], gitCredentials: [] }, updatedAt: Date.now() }) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/1/git-credential', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credentialId: 'cred-1' }), + }) + + expect(res.status).toBe(400) + expect(db.setRepoGitCredentialId).not.toHaveBeenCalled() + }) + + it('should set the credential and return the updated repo', async () => { + vi.mocked(db.getRepoById).mockReturnValue(createMockRepo({ id: 1 })) + mockGetSettings.mockReturnValue({ + preferences: { repoOrder: [], gitCredentials: [{ id: 'cred-1', name: 'test', host: 'github.com' }] }, + updatedAt: Date.now(), + }) + vi.mocked(db.getRepoGitCredentialId).mockReturnValue('cred-1') + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/1/git-credential', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credentialId: 'cred-1' }), + }) + + expect(res.status).toBe(200) + const body = await res.json() as Repo + expect(body.gitCredentialId).toBe('cred-1') + expect(db.setRepoGitCredentialId).toHaveBeenCalledWith(mockDb, 1, 'cred-1') + }) + + it('should clear the credential when none is provided', async () => { + vi.mocked(db.getRepoById).mockReturnValue(createMockRepo({ id: 1 })) + vi.mocked(db.getRepoGitCredentialId).mockReturnValue(null) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/1/git-credential', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credentialId: '' }), + }) + + expect(res.status).toBe(200) + expect(db.setRepoGitCredentialId).toHaveBeenCalledWith(mockDb, 1, null) + }) + + it('should return 500 when reading the repo throws', async () => { + vi.mocked(db.getRepoById).mockImplementation(() => { + throw new Error('repo failed') + }) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/1/git-credential', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credentialId: 'cred-1' }), + }) + + expect(res.status).toBe(500) + }) + }) + + describe('PATCH /:id', () => { + it('should return 400 for a non-numeric repo id', async () => { + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/abc', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'new-name' }), + }) + + expect(res.status).toBe(400) + }) + + it('should return 400 for the assistant repo id', async () => { + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/0', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'new-name' }), + }) + + expect(res.status).toBe(400) + expect(db.getRepoById).not.toHaveBeenCalled() + }) + + it('should return 404 when the repo does not exist', async () => { + vi.mocked(db.getRepoById).mockReturnValue(null) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/1', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'new-name' }), + }) + + expect(res.status).toBe(404) + }) + + it('should return 400 for an invalid body', async () => { + vi.mocked(db.getRepoById).mockReturnValue(createMockRepo({ id: 1 })) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/1', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + + expect(res.status).toBe(400) + }) + + it('should rename the repo and return the updated repo', async () => { + vi.mocked(db.getRepoById) + .mockReturnValueOnce(createMockRepo({ id: 1 })) + .mockReturnValueOnce(createMockRepo({ id: 1, name: 'renamed' })) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/1', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'renamed' }), + }) + + expect(res.status).toBe(200) + const body = await res.json() as Repo + expect(body.name).toBe('renamed') + expect(db.updateRepoName).toHaveBeenCalledWith(mockDb, 1, 'renamed') + }) + + it('should clear the name when the trimmed value is empty', async () => { + vi.mocked(db.getRepoById).mockReturnValue(createMockRepo({ id: 1 })) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/1', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: ' ' }), + }) + + expect(res.status).toBe(200) + expect(db.updateRepoName).toHaveBeenCalledWith(mockDb, 1, null) + }) + + it('should return 500 when reading the repo throws', async () => { + vi.mocked(db.getRepoById).mockImplementation(() => { + throw new Error('repo failed') + }) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/1', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'new-name' }), + }) + + expect(res.status).toBe(500) + }) + }) + + describe('DELETE /:id/workspaces/:workspaceId', () => { + it('should return 400 for a non-numeric repo id', async () => { + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/abc/workspaces/wrk_1', { method: 'DELETE' }) + + expect(res.status).toBe(400) + }) + + it('should return 404 when the repo is missing or not ready', async () => { + vi.mocked(db.getRepoById).mockReturnValue(null) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const missingRes = await app.request('/1/workspaces/wrk_1', { method: 'DELETE' }) + expect(missingRes.status).toBe(404) + + vi.mocked(db.getRepoById).mockReturnValue(createMockRepo({ id: 1, cloneStatus: 'cloning' })) + const notReadyRes = await app.request('/1/workspaces/wrk_1', { method: 'DELETE' }) + expect(notReadyRes.status).toBe(404) + }) + + it('should return 400 for an invalid workspace id', async () => { + vi.mocked(db.getRepoById).mockReturnValue(createMockRepo({ id: 1 })) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/1/workspaces/bad-id', { method: 'DELETE' }) + + expect(res.status).toBe(400) + }) + + it('should forward an upstream error status', async () => { + vi.mocked(db.getRepoById).mockReturnValue(createMockRepo({ id: 1 })) + const forward = vi.fn(async () => new Response('bad request', { status: 400 })) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient({ forward })) + const res = await app.request('/1/workspaces/wrk_1', { method: 'DELETE' }) + + expect(res.status).toBe(400) + const body = await res.json() as { error: string } + expect(body.error).toBe('bad request') + expect(forward).toHaveBeenCalledWith({ + method: 'DELETE', + path: '/experimental/workspace/wrk_1', + directory: '/tmp/repos/test-repo', + }) + }) + + it('should delete the workspace and return success', async () => { + vi.mocked(db.getRepoById).mockReturnValue(createMockRepo({ id: 1 })) + const forward = vi.fn(async () => new Response('', { status: 200 })) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient({ forward })) + const res = await app.request('/1/workspaces/wrk_1', { method: 'DELETE' }) + + expect(res.status).toBe(200) + const body = await res.json() as { success: boolean } + expect(body.success).toBe(true) + }) + + it('should return 500 when reading the repo throws', async () => { + vi.mocked(db.getRepoById).mockImplementation(() => { + throw new Error('repo failed') + }) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/1/workspaces/wrk_1', { method: 'DELETE' }) + + expect(res.status).toBe(500) + }) + }) + + describe('POST /:id/workspaces additional branches', () => { + it('should return 400 for a non-numeric repo id', async () => { + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/abc/workspaces', { method: 'POST' }) + + expect(res.status).toBe(400) + }) + + it('should return 404 when the repo is missing or not ready', async () => { + vi.mocked(db.getRepoById).mockReturnValue(null) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const missingRes = await app.request('/1/workspaces', { method: 'POST' }) + expect(missingRes.status).toBe(404) + + vi.mocked(db.getRepoById).mockReturnValue(createMockRepo({ id: 1, cloneStatus: 'cloning' })) + const notReadyRes = await app.request('/1/workspaces', { method: 'POST' }) + expect(notReadyRes.status).toBe(404) + }) + + it('should forward an upstream error status', async () => { + vi.mocked(db.getRepoById).mockReturnValue(createMockRepo({ id: 1 })) + const forward = vi.fn(async () => new Response('boom', { status: 502 })) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient({ forward })) + const res = await app.request('/1/workspaces', { method: 'POST' }) + + expect(res.status).toBe(502) + const body = await res.json() as { error: string } + expect(body.error).toBe('boom') + }) + + it('should return 500 when the upstream body is not JSON', async () => { + vi.mocked(db.getRepoById).mockReturnValue(createMockRepo({ id: 1 })) + const forward = vi.fn(async () => new Response('not-json', { status: 200 })) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient({ forward })) + const res = await app.request('/1/workspaces', { method: 'POST' }) + + expect(res.status).toBe(500) + const body = await res.json() as { error: string } + expect(body.error).toBe('Failed to create workspace') + }) + + it('should return the parsed workspace', async () => { + vi.mocked(db.getRepoById).mockReturnValue(createMockRepo({ id: 1 })) + const forward = vi.fn(async () => + new Response(JSON.stringify({ id: 'wrk_new', directory: '/tmp/wrk_new', branch: 'main' }), { status: 200 }), + ) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient({ forward })) + const res = await app.request('/1/workspaces', { method: 'POST' }) + + expect(res.status).toBe(200) + const body = await res.json() as { id: string } + expect(body.id).toBe('wrk_new') + }) + }) + + describe('DELETE /:id', () => { + it('should return 403 for the assistant repo id', async () => { + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/0', { method: 'DELETE' }) + + expect(res.status).toBe(403) + }) + + it('should return 404 when the repo does not exist', async () => { + vi.mocked(db.getRepoById).mockReturnValue(null) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/1', { method: 'DELETE' }) + + expect(res.status).toBe(404) + }) + + it('should prepare the delete and remove the repo files', async () => { + vi.mocked(db.getRepoById).mockReturnValue(createMockRepo({ id: 1 })) + vi.mocked(repoService.deleteRepoFiles).mockResolvedValue(undefined) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/1', { method: 'DELETE' }) + + expect(res.status).toBe(200) + const body = await res.json() as { success: boolean } + expect(body.success).toBe(true) + expect(mockPrepareRepoDelete).toHaveBeenCalledWith(1) + expect(repoService.deleteRepoFiles).toHaveBeenCalledWith(mockDb, 1) + }) + + it('should return 500 when removing the repo files throws', async () => { + vi.mocked(db.getRepoById).mockReturnValue(createMockRepo({ id: 1 })) + vi.mocked(repoService.deleteRepoFiles).mockRejectedValue(new Error('delete failed')) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/1', { method: 'DELETE' }) + + expect(res.status).toBe(500) + const body = await res.json() as { error: string } + expect(body.error).toBe('delete failed') + }) + }) + + describe('POST /:id/pull', () => { + it('should pull the repo and return the refreshed repo', async () => { + const repo = createMockRepo({ id: 3 }) + vi.mocked(repoService.pullRepo).mockResolvedValue(undefined) + vi.mocked(db.getRepoById).mockReturnValue(repo) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/3/pull', { method: 'POST' }) + + expect(res.status).toBe(200) + const body = await res.json() as Repo + expect(body.id).toBe(3) + expect(repoService.pullRepo).toHaveBeenCalledWith(mockDb, mockGitAuthService, 3) + }) + + it('should return 500 when pulling throws', async () => { + vi.mocked(repoService.pullRepo).mockRejectedValue(new Error('pull failed')) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/3/pull', { method: 'POST' }) + + expect(res.status).toBe(500) + const body = await res.json() as { error: string } + expect(body.error).toBe('pull failed') + }) + }) + + describe('POST /:id/branch/switch', () => { + it('should return 404 when the repo does not exist', async () => { + vi.mocked(db.getRepoById).mockReturnValue(null) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/1/branch/switch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ branch: 'feature' }), + }) + + expect(res.status).toBe(404) + }) + + it('should return 400 when the branch is missing', async () => { + vi.mocked(db.getRepoById).mockReturnValue(createMockRepo({ id: 1 })) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/1/branch/switch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + + expect(res.status).toBe(400) + }) + + it('should switch the branch and return the current branch', async () => { + const repo = createMockRepo({ id: 1 }) + vi.mocked(db.getRepoById).mockReturnValue(repo) + vi.mocked(repoService.switchBranch).mockResolvedValue(undefined) + vi.mocked(repoService.getCurrentBranch).mockResolvedValue('feature') + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/1/branch/switch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ branch: 'feature' }), + }) + + expect(res.status).toBe(200) + const body = await res.json() as Repo & { currentBranch?: string } + expect(body.currentBranch).toBe('feature') + expect(repoService.switchBranch).toHaveBeenCalledWith(mockDb, mockGitAuthService, 1, 'feature') + }) + + it('should return 500 when switching the branch throws', async () => { + vi.mocked(db.getRepoById).mockReturnValue(createMockRepo({ id: 1 })) + vi.mocked(repoService.switchBranch).mockRejectedValue(new Error('switch failed')) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/1/branch/switch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ branch: 'feature' }), + }) + + expect(res.status).toBe(500) + const body = await res.json() as { error: string } + expect(body.error).toBe('switch failed') + }) + }) + + describe('POST /:id/branch/create', () => { + it('should return 404 when the repo does not exist', async () => { + vi.mocked(db.getRepoById).mockReturnValue(null) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/1/branch/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ branch: 'feature' }), + }) + + expect(res.status).toBe(404) + }) + + it('should return 400 when the branch is missing', async () => { + vi.mocked(db.getRepoById).mockReturnValue(createMockRepo({ id: 1 })) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/1/branch/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + + expect(res.status).toBe(400) + }) + + it('should create the branch and return the current branch', async () => { + const repo = createMockRepo({ id: 1 }) + vi.mocked(db.getRepoById).mockReturnValue(repo) + vi.mocked(repoService.createBranch).mockResolvedValue(undefined) + vi.mocked(repoService.getCurrentBranch).mockResolvedValue('feature') + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/1/branch/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ branch: 'feature' }), + }) + + expect(res.status).toBe(200) + const body = await res.json() as Repo & { currentBranch?: string } + expect(body.currentBranch).toBe('feature') + expect(repoService.createBranch).toHaveBeenCalledWith(mockDb, mockGitAuthService, 1, 'feature') + }) + + it('should return 500 when creating the branch throws', async () => { + vi.mocked(db.getRepoById).mockReturnValue(createMockRepo({ id: 1 })) + vi.mocked(repoService.createBranch).mockRejectedValue(new Error('create failed')) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/1/branch/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ branch: 'feature' }), + }) + + expect(res.status).toBe(500) + const body = await res.json() as { error: string } + expect(body.error).toBe('create failed') + }) + }) + + describe('GET /:id/download', () => { + it('should return 404 when the repo does not exist', async () => { + vi.mocked(db.getRepoById).mockReturnValue(null) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/1/download', { method: 'GET' }) + + expect(res.status).toBe(404) + }) + + it('should stream the archive with the requested options', async () => { + vi.mocked(db.getRepoById).mockReturnValue(createMockRepo({ id: 1, fullPath: '/tmp/repos/test-repo' })) + vi.mocked(archiveService.createRepoArchive).mockResolvedValue('/tmp/repo-archive.zip') + vi.mocked(archiveService.getArchiveSize).mockResolvedValue(3) + vi.mocked(archiveService.getArchiveStream).mockReturnValue( + Readable.from(['zip']) as unknown as ReturnType, + ) + vi.mocked(archiveService.deleteArchive).mockResolvedValue(undefined) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/1/download?includeGit=true&includePaths=src,docs', { method: 'GET' }) + + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('application/zip') + expect(res.headers.get('Content-Disposition')).toBe('attachment; filename="test-repo.zip"') + expect(res.headers.get('Content-Length')).toBe('3') + expect(archiveService.createRepoArchive).toHaveBeenCalledWith('/tmp/repos/test-repo', { + includeGit: true, + includePaths: ['src', 'docs'], + }) + + const body = await res.text() + expect(body).toBe('zip') + await vi.waitFor(() => { + expect(archiveService.deleteArchive).toHaveBeenCalledWith('/tmp/repo-archive.zip') + }) + }) + + it('should return 500 when archive creation throws', async () => { + vi.mocked(db.getRepoById).mockReturnValue(createMockRepo({ id: 1 })) + vi.mocked(archiveService.createRepoArchive).mockRejectedValue(new Error('archive failed')) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/1/download', { method: 'GET' }) + + expect(res.status).toBe(500) + const body = await res.json() as { error: string } + expect(body.error).toBe('archive failed') + }) + }) + + describe('POST /:id/reset-permissions upstream failure', () => { + it('should return 500 when the upstream dispose fails', async () => { + vi.mocked(db.getRepoById).mockReturnValue(createMockRepo({ id: 1 })) + const forward = vi.fn(async () => new Response('nope', { status: 500 })) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient({ forward })) + const res = await app.request('/1/reset-permissions', { method: 'POST' }) + + expect(res.status).toBe(500) + const body = await res.json() as { error: string } + expect(body.error).toBe('Failed to reset permissions') + }) + + it('should return 500 when forwarding throws', async () => { + vi.mocked(db.getRepoById).mockReturnValue(createMockRepo({ id: 1 })) + const forward = vi.fn(async () => { + throw new Error('forward failed') + }) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient({ forward })) + const res = await app.request('/1/reset-permissions', { method: 'POST' }) + + expect(res.status).toBe(500) + const body = await res.json() as { error: string } + expect(body.error).toBe('forward failed') + }) + }) + + describe('assistant repo id 0', () => { + const assistantRepo = createMockRepo({ id: 0, localPath: 'assistant', fullPath: '/tmp/repos/assistant' }) + + it('should return assistant mode status for the assistant repo on GET', async () => { + vi.mocked(db.getRepoById).mockReturnValue(null) + vi.mocked(buildAssistantRepo).mockReturnValue(assistantRepo) + const status: AssistantModeStatus = { + repoId: 0, + directory: '/tmp/repos/assistant', + relativePath: 'repos/assistant', + files: { + agentsMd: { path: '/tmp/repos/assistant/AGENTS.md', exists: true, created: false }, + opencodeJson: { path: '/tmp/repos/assistant/opencode.json', exists: true, created: false }, + }, + } + vi.mocked(getAssistantModeStatus).mockResolvedValue(status) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/0/assistant-mode', { method: 'GET' }) + + expect(res.status).toBe(200) + const body = await res.json() as AssistantModeStatus + expect(body.repoId).toBe(0) + expect(getAssistantModeStatus).toHaveBeenCalledWith(assistantRepo) + }) + + it('should initialize assistant mode for the assistant repo on POST', async () => { + vi.mocked(db.getRepoById).mockReturnValue(null) + vi.mocked(buildAssistantRepo).mockReturnValue(assistantRepo) + const status: AssistantModeStatus = { + repoId: 0, + directory: '/tmp/repos/assistant', + relativePath: 'repos/assistant', + files: { + agentsMd: { path: '/tmp/repos/assistant/AGENTS.md', exists: true, created: true }, + opencodeJson: { path: '/tmp/repos/assistant/opencode.json', exists: true, created: true }, + }, + } + vi.mocked(ensureAssistantMode).mockResolvedValue(status) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/0/assistant-mode', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ overwriteAgentsMd: true }), + }) + + expect(res.status).toBe(200) + const body = await res.json() as AssistantModeStatus + expect(body.repoId).toBe(0) + expect(ensureAssistantMode).toHaveBeenCalledWith(assistantRepo, { overwriteAgentsMd: true }) + }) + + it('should return 500 when the assistant mode status throws', async () => { + vi.mocked(db.getRepoById).mockReturnValue(null) + vi.mocked(buildAssistantRepo).mockReturnValue(assistantRepo) + vi.mocked(getAssistantModeStatus).mockRejectedValue(new Error('assistant failed')) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient()) + const res = await app.request('/0/assistant-mode', { method: 'GET' }) + + expect(res.status).toBe(500) + const body = await res.json() as { error: string } + expect(body.error).toBe('assistant failed') + }) + }) }) diff --git a/backend/test/routes/settings-skills-install.test.ts b/backend/test/routes/settings-skills-install.test.ts index b7b76bd90..4ef5ba30a 100644 --- a/backend/test/routes/settings-skills-install.test.ts +++ b/backend/test/routes/settings-skills-install.test.ts @@ -39,11 +39,6 @@ vi.mock('../../src/services/settings', () => ({ getSettings: vi.fn(), updateSettings: vi.fn(), saveLastKnownGoodConfig: vi.fn(), - createOpenCodeConfig: vi.fn(), - updateOpenCodeConfig: vi.fn(), - deleteOpenCodeConfig: vi.fn(), - getOpenCodeConfigByName: vi.fn(), - setDefaultOpenCodeConfig: vi.fn(), })), })) diff --git a/backend/test/routes/settings.test.ts b/backend/test/routes/settings.test.ts index c5ac86a73..9ca2b023f 100644 --- a/backend/test/routes/settings.test.ts +++ b/backend/test/routes/settings.test.ts @@ -9,12 +9,18 @@ import { getOrCreateInternalToken } from '../../src/services/internal-token' const mockGetSettings = vi.fn() const mockUpdateSettings = vi.fn() const mockResetSettings = vi.fn() -const mockSaveLastKnownGoodConfig = vi.fn() -const mockCreateOpenCodeConfig = vi.fn() -const mockUpdateOpenCodeConfig = vi.fn() -const mockDeleteOpenCodeConfig = vi.fn() -const mockGetOpenCodeConfigByName = vi.fn() -const mockSetDefaultOpenCodeConfig = vi.fn() +const mockGetLastKnownGoodConfig = vi.fn() +const { + mockReadOpenCodeConfigFile, + mockWriteOpenCodeConfigFile, + mockDeleteOpenCodeConfigFile, + mockApplyOpenCodeConfigUpdate, +} = vi.hoisted(() => ({ + mockReadOpenCodeConfigFile: vi.fn(), + mockWriteOpenCodeConfigFile: vi.fn(), + mockDeleteOpenCodeConfigFile: vi.fn(), + mockApplyOpenCodeConfigUpdate: vi.fn(), +})) vi.mock('fs', () => ({ existsSync: vi.fn(() => false), @@ -48,20 +54,100 @@ vi.mock('../../src/constants', () => ({ DEFAULT_AGENTS_MD: '# Test Agents MD', })) +const { + mockHasStoredOpenCodeServerPassword, + mockGetStoredOpenCodeServerPasswordState, + mockClearOpenCodeServerPassword, + mockSetOpenCodeServerPassword, + mockRestoreOpenCodeServerPasswordState, +} = vi.hoisted(() => ({ + mockHasStoredOpenCodeServerPassword: vi.fn(), + mockGetStoredOpenCodeServerPasswordState: vi.fn(), + mockClearOpenCodeServerPassword: vi.fn(), + mockSetOpenCodeServerPassword: vi.fn(), + mockRestoreOpenCodeServerPasswordState: vi.fn(), +})) + vi.mock('../../src/services/settings', () => ({ SettingsService: vi.fn().mockImplementation(() => ({ getSettings: mockGetSettings, updateSettings: mockUpdateSettings, resetSettings: mockResetSettings, - saveLastKnownGoodConfig: mockSaveLastKnownGoodConfig, - createOpenCodeConfig: mockCreateOpenCodeConfig, - updateOpenCodeConfig: mockUpdateOpenCodeConfig, - deleteOpenCodeConfig: mockDeleteOpenCodeConfig, - getOpenCodeConfigByName: mockGetOpenCodeConfigByName, - setDefaultOpenCodeConfig: mockSetDefaultOpenCodeConfig, + getLastKnownGoodConfig: mockGetLastKnownGoodConfig, + hasStoredOpenCodeServerPassword: mockHasStoredOpenCodeServerPassword, + getStoredOpenCodeServerPasswordState: mockGetStoredOpenCodeServerPasswordState, + clearOpenCodeServerPassword: mockClearOpenCodeServerPassword, + setOpenCodeServerPassword: mockSetOpenCodeServerPassword, + restoreOpenCodeServerPasswordState: mockRestoreOpenCodeServerPasswordState, })), })) +const { + mockListManagedSkills, + mockGetSkill, + mockCreateSkill, + mockUpdateSkill, + mockDeleteSkill, + mockInstallSkillFromGithubTree, + mockInstallSkillFromUploadedFiles, +} = vi.hoisted(() => ({ + mockListManagedSkills: vi.fn(), + mockGetSkill: vi.fn(), + mockCreateSkill: vi.fn(), + mockUpdateSkill: vi.fn(), + mockDeleteSkill: vi.fn(), + mockInstallSkillFromGithubTree: vi.fn(), + mockInstallSkillFromUploadedFiles: vi.fn(), +})) + +vi.mock('../../src/services/skills', () => ({ + listManagedSkills: mockListManagedSkills, + getSkill: mockGetSkill, + createSkill: mockCreateSkill, + updateSkill: mockUpdateSkill, + deleteSkill: mockDeleteSkill, + installSkillFromGithubTree: mockInstallSkillFromGithubTree, + installSkillFromUploadedFiles: mockInstallSkillFromUploadedFiles, +})) + +const { + mockInstallOpenCodeDirectoryFiles, + mockListOpenCodeDirectoryFiles, + mockGetOpenCodeDirectoryFile, + mockUpdateOpenCodeDirectoryFile, + mockDeleteOpenCodeDirectoryFile, +} = vi.hoisted(() => ({ + mockInstallOpenCodeDirectoryFiles: vi.fn(), + mockListOpenCodeDirectoryFiles: vi.fn(), + mockGetOpenCodeDirectoryFile: vi.fn(), + mockUpdateOpenCodeDirectoryFile: vi.fn(), + mockDeleteOpenCodeDirectoryFile: vi.fn(), +})) + +vi.mock('../../src/services/opencode-directory-files', () => ({ + installOpenCodeDirectoryFiles: mockInstallOpenCodeDirectoryFiles, + listOpenCodeDirectoryFiles: mockListOpenCodeDirectoryFiles, + getOpenCodeDirectoryFile: mockGetOpenCodeDirectoryFile, + updateOpenCodeDirectoryFile: mockUpdateOpenCodeDirectoryFile, + deleteOpenCodeDirectoryFile: mockDeleteOpenCodeDirectoryFile, +})) + +const { mockDiscoverModelsCached } = vi.hoisted(() => ({ + mockDiscoverModelsCached: vi.fn(), +})) + +vi.mock('../../src/utils/discovery-cache', () => ({ + discoverModelsCached: mockDiscoverModelsCached, +})) + +const { mockValidateSSHPrivateKey } = vi.hoisted(() => ({ + mockValidateSSHPrivateKey: vi.fn(), +})) + +vi.mock('../../src/utils/ssh-validation', () => ({ + validateSSHPrivateKey: mockValidateSSHPrivateKey, +})) + vi.mock('../../src/services/file-operations', () => ({ writeFileContent: vi.fn(), readFileContent: vi.fn(), @@ -72,6 +158,20 @@ vi.mock('../../src/services/opencode/config-recovery', () => ({ patchConfigWithRecovery: vi.fn(), })) +vi.mock('../../src/services/opencode-config-file', () => ({ + readOpenCodeConfigFile: mockReadOpenCodeConfigFile, + writeOpenCodeConfigFile: mockWriteOpenCodeConfigFile, + deleteOpenCodeConfigFile: mockDeleteOpenCodeConfigFile, +})) + +vi.mock('../../src/services/opencode-config-apply', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + applyOpenCodeConfigUpdate: mockApplyOpenCodeConfigUpdate, + } +}) + vi.mock('../../src/services/opencode/client', () => ({ createOpenCodeClient: () => ({ forward: vi.fn(), @@ -131,6 +231,7 @@ vi.mock('../../src/services/opencode-import', () => ({ getOpenCodeImportStatus: vi.fn(), syncOpenCodeImport: vi.fn(), getImportedSessionDirectories: vi.fn(), + getFirstExistingConfigSourcePath: vi.fn().mockReturnValue(null), })) vi.mock('../../src/services/repo', () => ({ @@ -180,13 +281,13 @@ vi.mock('@opencode-manager/shared/config/env', () => ({ })) import { createSettingsRoutes } from '../../src/routes/settings' -import { writeFileContent } from '../../src/services/file-operations' import { getImportedSessionDirectories, getOpenCodeImportStatus, OpenCodeImportProtectionError, syncOpenCodeImport } from '../../src/services/opencode-import' import { relinkReposFromSessionDirectories } from '../../src/services/repo' import { opencodeServerManager, ConfigReloadError } from '../../src/services/opencode-single-server' -import { patchConfigWithRecovery } from '../../src/services/opencode/config-recovery' import { detectSandboxCapability } from '../../src/services/sandbox/capability' import { forceProcessAttestation } from '../../src/services/opencode/process-identity' +import { setOpenCodeRestartCoordinator } from '../../src/services/opencode-restart' +import { createRepo } from '../../src/db/queries' const mockSpawnSync = spawnSync as ReturnType const mockGetVersion = opencodeServerManager.getVersion as ReturnType @@ -200,8 +301,6 @@ const mockGetOpenCodeImportStatus = getOpenCodeImportStatus as ReturnType const mockGetImportedSessionDirectories = getImportedSessionDirectories as ReturnType const mockRelinkReposFromSessionDirectories = relinkReposFromSessionDirectories as ReturnType -const mockWriteFileContent = writeFileContent as ReturnType -const mockPatchConfigWithRecovery = patchConfigWithRecovery as ReturnType const mockDetectSandboxCapability = detectSandboxCapability as ReturnType describe('Settings Routes - OpenCode Upgrade', () => { @@ -219,18 +318,15 @@ describe('Settings Routes - OpenCode Upgrade', () => { mockGetSettings.mockReset() mockUpdateSettings.mockReset() mockResetSettings.mockReset() - mockSaveLastKnownGoodConfig.mockReset() - mockCreateOpenCodeConfig.mockReset() - mockUpdateOpenCodeConfig.mockReset() - mockDeleteOpenCodeConfig.mockReset() - mockGetOpenCodeConfigByName.mockReset() - mockSetDefaultOpenCodeConfig.mockReset() + mockGetLastKnownGoodConfig.mockReset() mockGetOpenCodeImportStatus.mockReset() mockSyncOpenCodeImport.mockReset() mockGetImportedSessionDirectories.mockReset() mockRelinkReposFromSessionDirectories.mockReset() - mockWriteFileContent.mockReset() - mockPatchConfigWithRecovery.mockReset() + mockReadOpenCodeConfigFile.mockReset() + mockWriteOpenCodeConfigFile.mockReset() + mockDeleteOpenCodeConfigFile.mockReset() + mockApplyOpenCodeConfigUpdate.mockReset() mockDetectSandboxCapability.mockReset() mockDetectSandboxCapability.mockReturnValue({ available: true, msbVersion: 'msb 1.0.0' }) forceProcessAttestation(true) @@ -245,8 +341,6 @@ describe('Settings Routes - OpenCode Upgrade', () => { mockReloadConfig.mockResolvedValue(undefined) mockRestart.mockResolvedValue(undefined) mockClearStartupError.mockReturnValue(undefined) - mockPatchConfigWithRecovery.mockResolvedValue({ success: true, appliedConfig: { $schema: 'https://opencode.ai/config.json' } } as any) - mockWriteFileContent.mockResolvedValue(undefined) mockGetOpenCodeImportStatus.mockResolvedValue({ configSourcePath: null, stateSourcePath: null, @@ -268,676 +362,231 @@ describe('Settings Routes - OpenCode Upgrade', () => { }) describe('OpenCode config routes', () => { - it('should reject create-as-default when runtime validation fails', async () => { - mockCreateOpenCodeConfig.mockReturnValue({ - id: 1, - name: 'broken', - content: { command: { review: true } }, - rawContent: '{"command":{"review":true}}', - isValid: true, - isDefault: false, - createdAt: 1, - updatedAt: 1, - }) - mockPatchConfigWithRecovery.mockResolvedValueOnce({ - success: false, - error: 'command.review: Invalid field', - details: [{ path: 'command.review', message: 'Invalid field' }], - }) - - const req = new Request('http://localhost/opencode-configs', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name: 'broken', - content: '{"command":{"review":true}}', - isDefault: true, - }), - }) - const res = await settingsApp.fetch(req) - const json = await res.json() as Record - - expect(res.status).toBe(400) - expect(json.error).toBe('Config validation failed') - expect(mockSaveLastKnownGoodConfig).toHaveBeenCalledWith('default') - expect(mockCreateOpenCodeConfig).toHaveBeenCalledWith( - { - name: 'broken', - content: '{"command":{"review":true}}', - isDefault: false, - }, - 'default', - { suppressAutoDefault: true } - ) - expect(mockDeleteOpenCodeConfig).toHaveBeenCalledWith('broken', 'default') - expect(mockSetDefaultOpenCodeConfig).not.toHaveBeenCalled() - expect(mockWriteFileContent).not.toHaveBeenCalled() - }) - - it('should persist recovery-cleaned content before marking a new config as default', async () => { - mockCreateOpenCodeConfig.mockReturnValue({ - id: 1, - name: 'cleaned', - content: { command: { review: true }, theme: 'dark' }, - rawContent: '{"command":{"review":true},"theme":"dark"}', - isValid: true, - isDefault: false, - createdAt: 1, - updatedAt: 1, - }) - mockPatchConfigWithRecovery.mockResolvedValueOnce({ - success: true, - appliedConfig: { theme: 'dark' }, - removedFields: ['command.review'], - details: [{ path: 'command.review', message: 'Invalid field' }], - }) - mockUpdateOpenCodeConfig.mockReturnValue({ - id: 1, - name: 'cleaned', + it('returns the on-disk config state from GET /opencode-config', async () => { + const fileState = { + path: '/tmp/test-workspace/.config/opencode.json', content: { theme: 'dark' }, - rawContent: '{\n "theme": "dark"\n}', + rawContent: '{"theme":"dark"}', isValid: true, - isDefault: true, - createdAt: 1, - updatedAt: 2, - }) + updatedAt: 1, + } + mockReadOpenCodeConfigFile.mockResolvedValueOnce(fileState) - const req = new Request('http://localhost/opencode-configs', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name: 'cleaned', - content: '{"command":{"review":true},"theme":"dark"}', - isDefault: true, - }), - }) - const res = await settingsApp.fetch(req) + const res = await settingsApp.fetch(new Request('http://localhost/opencode-config')) const json = await res.json() as Record expect(res.status).toBe(200) - expect(mockUpdateOpenCodeConfig).toHaveBeenCalledWith( - 'cleaned', - { - content: '{\n "theme": "dark"\n}', - isDefault: true, - }, - 'default' - ) - expect(mockWriteFileContent).toHaveBeenCalledWith( - '/tmp/test-workspace/.config/opencode.json', - '{\n "theme": "dark"\n}' - ) - expect(json.removedFields).toEqual(['command.review']) + expect(json).toEqual(fileState) }) - it('should reject set-default when runtime validation fails', async () => { - mockGetOpenCodeConfigByName.mockReturnValue({ - id: 2, - name: 'broken', - content: { command: { review: true } }, - rawContent: '{"command":{"review":true}}', - isValid: true, - isDefault: false, - createdAt: 1, - updatedAt: 1, - }) - mockPatchConfigWithRecovery.mockResolvedValueOnce({ - success: false, - error: 'command.review: Invalid field', - details: [{ path: 'command.review', message: 'Invalid field' }], - }) + it('returns 404 from GET /opencode-config when no config file exists', async () => { + mockReadOpenCodeConfigFile.mockResolvedValueOnce(null) - const req = new Request('http://localhost/opencode-configs/broken/set-default', { - method: 'POST', - }) - const res = await settingsApp.fetch(req) + const res = await settingsApp.fetch(new Request('http://localhost/opencode-config')) const json = await res.json() as Record - expect(res.status).toBe(400) - expect(json.error).toBe('Config validation failed') - expect(mockSetDefaultOpenCodeConfig).not.toHaveBeenCalled() - expect(mockWriteFileContent).not.toHaveBeenCalled() + expect(res.status).toBe(404) + expect(json.error).toBe('No OpenCode config file found') }) - it('should persist recovery-cleaned content before switching the default flag', async () => { - mockGetOpenCodeConfigByName.mockReturnValue({ - id: 2, - name: 'cleaned', - content: { command: { review: true }, theme: 'dark' }, - rawContent: '{"command":{"review":true},"theme":"dark"}', - isValid: true, - isDefault: false, - createdAt: 1, - updatedAt: 1, - }) - mockPatchConfigWithRecovery.mockResolvedValueOnce({ - success: true, - appliedConfig: { theme: 'dark' }, - removedFields: ['command.review'], - details: [{ path: 'command.review', message: 'Invalid field' }], - }) - mockUpdateOpenCodeConfig.mockReturnValue({ - id: 2, - name: 'cleaned', - content: { theme: 'dark' }, - rawContent: '{\n "theme": "dark"\n}', + it('maps a restart-pending apply result to 200 with restartRequired', async () => { + const config = { + path: '/tmp/test-workspace/.config/opencode.json', + content: { plugin: ['evil-plugin'] }, + rawContent: '{"plugin":["evil-plugin"]}', isValid: true, - isDefault: false, - createdAt: 1, updatedAt: 2, - }) - mockSetDefaultOpenCodeConfig.mockReturnValue({ - id: 2, - name: 'cleaned', - content: { theme: 'dark' }, - rawContent: '{\n "theme": "dark"\n}', - isValid: true, - isDefault: true, - createdAt: 1, - updatedAt: 3, - }) + } + mockApplyOpenCodeConfigUpdate.mockResolvedValueOnce({ status: 'restart_pending', config }) - const req = new Request('http://localhost/opencode-configs/cleaned/set-default', { - method: 'POST', - }) - const res = await settingsApp.fetch(req) + const res = await settingsApp.fetch(new Request('http://localhost/opencode-config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: '{"plugin":["evil-plugin"]}' }), + })) const json = await res.json() as Record - const updateCallOrder = mockUpdateOpenCodeConfig.mock.invocationCallOrder[0] - const setDefaultCallOrder = mockSetDefaultOpenCodeConfig.mock.invocationCallOrder[0] expect(res.status).toBe(200) - expect(mockUpdateOpenCodeConfig).toHaveBeenCalledWith( - 'cleaned', - { content: '{\n "theme": "dark"\n}' }, - 'default' - ) - expect(updateCallOrder).toBeDefined() - expect(setDefaultCallOrder).toBeDefined() - expect(updateCallOrder ?? 0).toBeLessThan(setDefaultCallOrder ?? 0) - expect(mockWriteFileContent).toHaveBeenCalledWith( - '/tmp/test-workspace/.config/opencode.json', - '{\n "theme": "dark"\n}' - ) - expect(json.removedFields).toEqual(['command.review']) + expect(json).toEqual({ ...config, restartRequired: true }) + expect(mockApplyOpenCodeConfigUpdate).toHaveBeenCalledWith({ + content: '{"plugin":["evil-plugin"]}', + openCodeClient: expect.anything(), + settingsService: expect.anything(), + userId: 'default', + }) }) - it('persists recovery-cleaned content back to the DB after a default-config PUT with removedFields (audit regression)', async () => { - mockGetOpenCodeConfigByName.mockReturnValue({ - id: 2, - name: 'cleaned', - content: {}, - rawContent: '{}', - isValid: true, - isDefault: true, - createdAt: 1, - updatedAt: 1, - }) - const firstConfig = { - id: 2, - name: 'cleaned', - content: { command: { review: true }, theme: 'dark' }, - rawContent: '{"command":{"review":true},"theme":"dark"}', + it('maps an applied result to 200 without removedFields when none were removed', async () => { + const config = { + path: '/tmp/test-workspace/.config/opencode.json', + content: { theme: 'light' }, + rawContent: '{"theme":"light"}', isValid: true, - isDefault: true, - createdAt: 1, - updatedAt: 2, - } - const persistedConfig = { - ...firstConfig, - content: { theme: 'dark' }, - rawContent: '{\n "theme": "dark"\n}', updatedAt: 3, } - mockUpdateOpenCodeConfig - .mockReturnValueOnce(firstConfig) - .mockReturnValueOnce(persistedConfig) - mockPatchConfigWithRecovery.mockResolvedValueOnce({ - success: true, - appliedConfig: { theme: 'dark' }, - removedFields: ['command.review'], - }) + mockApplyOpenCodeConfigUpdate.mockResolvedValueOnce({ status: 'applied', config, removedFields: [] }) - const req = new Request('http://localhost/opencode-configs/cleaned', { + const res = await settingsApp.fetch(new Request('http://localhost/opencode-config', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - content: '{"command":{"review":true},"theme":"dark"}', - isDefault: true, - }), - }) - const res = await settingsApp.fetch(req) + body: JSON.stringify({ content: { theme: 'light' } }), + })) const json = await res.json() as Record expect(res.status).toBe(200) - expect(json.removedFields).toEqual(['command.review']) - expect(mockUpdateOpenCodeConfig).toHaveBeenCalledTimes(2) - expect(mockUpdateOpenCodeConfig).toHaveBeenNthCalledWith( - 2, - 'cleaned', - { content: '{\n "theme": "dark"\n}' }, - 'default', - ) - expect(mockWriteFileContent).toHaveBeenCalledWith( - '/tmp/test-workspace/.config/opencode.json', - '{\n "theme": "dark"\n}', - ) + expect(json).toEqual(config) + expect(json.removedFields).toBeUndefined() + expect(mockApplyOpenCodeConfigUpdate).toHaveBeenCalledWith({ + content: { theme: 'light' }, + openCodeClient: expect.anything(), + settingsService: expect.anything(), + userId: 'default', + }) }) - it('returns 409 instead of 200 when the recovery persistence write reports the config row was removed concurrently (audit regression)', async () => { - mockGetOpenCodeConfigByName.mockReturnValue({ - id: 2, - name: 'cleaned', - content: {}, - rawContent: '{}', - isValid: true, - isDefault: true, - createdAt: 1, - updatedAt: 1, - }) - const firstConfig = { - id: 2, - name: 'cleaned', - content: { command: { review: true }, theme: 'dark' }, - rawContent: '{"command":{"review":true},"theme":"dark"}', + it('maps an applied result with removedFields to 200', async () => { + const config = { + path: '/tmp/test-workspace/.config/opencode.json', + content: { theme: 'light' }, + rawContent: '{"theme":"light"}', isValid: true, - isDefault: true, - createdAt: 1, - updatedAt: 2, + updatedAt: 3, } - mockUpdateOpenCodeConfig - .mockReturnValueOnce(firstConfig) - .mockReturnValueOnce(null) - mockPatchConfigWithRecovery.mockResolvedValueOnce({ - success: true, - appliedConfig: { theme: 'dark' }, + mockApplyOpenCodeConfigUpdate.mockResolvedValueOnce({ + status: 'applied', + config, removedFields: ['command.review'], }) - const req = new Request('http://localhost/opencode-configs/cleaned', { + const res = await settingsApp.fetch(new Request('http://localhost/opencode-config', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - content: '{"command":{"review":true},"theme":"dark"}', - isDefault: true, - }), - }) - const res = await settingsApp.fetch(req) + body: JSON.stringify({ content: '{"command":{"review":true},"theme":"light"}' }), + })) const json = await res.json() as Record - expect(res.status).toBe(409) - expect(json.error).toBe( - 'OpenCode config was removed while applying recovered fields', - ) - expect(mockUpdateOpenCodeConfig).toHaveBeenCalledTimes(2) - expect(mockUpdateOpenCodeConfig).toHaveBeenNthCalledWith( - 2, - 'cleaned', - { content: '{\n "theme": "dark"\n}' }, - 'default', - ) + expect(res.status).toBe(200) + expect(json).toEqual({ ...config, removedFields: ['command.review'] }) }) - it('keeps configured plugins in a live default-config patch while sandbox enforcement is active', async () => { - mockGetOpenCodeConfigByName.mockReturnValue({ - id: 2, - name: 'enforced', - content: { plugin: ['evil-plugin'], theme: 'dark' }, - rawContent: '{"plugin":["evil-plugin"],"theme":"dark"}', - isValid: true, - isDefault: true, - createdAt: 1, - updatedAt: 1, - }) - mockUpdateOpenCodeConfig.mockReturnValue({ - id: 2, - name: 'enforced', - content: { plugin: ['evil-plugin'], theme: 'light' }, - rawContent: '{"plugin":["evil-plugin"],"theme":"light"}', - isValid: true, - isDefault: true, - createdAt: 1, - updatedAt: 2, - }) - mockPatchConfigWithRecovery.mockResolvedValue({ - success: true, - appliedConfig: { plugin: ['evil-plugin'], theme: 'light' }, + it('maps a rejected apply result to 400 with validation issues', async () => { + const validationIssues = [{ path: 'command.review', message: 'Invalid field' }] + mockApplyOpenCodeConfigUpdate.mockResolvedValueOnce({ + status: 'rejected', + error: 'command.review: Invalid field', + validationIssues, + removedFields: ['command.review'], }) - mockIsSandboxEnforced.mockReturnValue(true) - const req = new Request('http://localhost/opencode-configs/enforced', { + const res = await settingsApp.fetch(new Request('http://localhost/opencode-config', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - content: '{"plugin":["evil-plugin"],"theme":"light"}', - isDefault: true, - }), - }) - const res = await settingsApp.fetch(req) + body: JSON.stringify({ content: '{"command":{"review":true}}' }), + })) const json = await res.json() as Record - expect(res.status).toBe(200) - expect(mockPatchConfigWithRecovery).toHaveBeenCalledWith(expect.anything(), { plugin: ['evil-plugin'], theme: 'light' }) - expect(mockWriteFileContent).toHaveBeenCalledWith( - '/tmp/test-workspace/.config/opencode.json', - '{"plugin":["evil-plugin"],"theme":"light"}', - ) - expect((json.content as Record).theme).toBe('light') - }) - - it('keeps local MCP servers and the formatter in a live default-config patch while sandbox enforcement is active', async () => { - const submittedContent = { - mcp: { local: { type: 'local', command: ['npx', 'evil-server'] }, remote: { type: 'remote', url: 'https://example.com/mcp' } }, - formatter: { typescript: { command: ['prettier'] } }, - theme: 'light', - } - mockGetOpenCodeConfigByName.mockReturnValue({ - id: 2, - name: 'enforced', - content: { ...submittedContent, theme: 'dark' }, - rawContent: JSON.stringify({ ...submittedContent, theme: 'dark' }), - isValid: true, - isDefault: true, - createdAt: 1, - updatedAt: 1, - }) - mockUpdateOpenCodeConfig.mockReturnValue({ - id: 2, - name: 'enforced', - content: submittedContent, - rawContent: JSON.stringify(submittedContent), - isValid: true, - isDefault: true, - createdAt: 1, - updatedAt: 2, - }) - mockPatchConfigWithRecovery.mockResolvedValue({ - success: true, - appliedConfig: submittedContent, + expect(res.status).toBe(400) + expect(json).toEqual({ + error: 'Config validation failed', + details: 'command.review: Invalid field', + validationIssues, + removedFields: ['command.review'], }) - mockIsSandboxEnforced.mockReturnValue(true) + }) - const req = new Request('http://localhost/opencode-configs/enforced', { + it('returns 400 when the PUT body fails schema validation', async () => { + const res = await settingsApp.fetch(new Request('http://localhost/opencode-config', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - content: JSON.stringify(submittedContent), - isDefault: true, - }), - }) - const res = await settingsApp.fetch(req) + body: JSON.stringify({ content: 123 }), + })) const json = await res.json() as Record - expect(res.status).toBe(200) - expect(mockPatchConfigWithRecovery).toHaveBeenCalledWith(expect.anything(), submittedContent) - expect(mockWriteFileContent).toHaveBeenCalledWith( - '/tmp/test-workspace/.config/opencode.json', - JSON.stringify(submittedContent), - ) - expect(JSON.stringify(json.content)).toContain('evil-server') - expect(JSON.stringify(json.content)).toContain('prettier') + expect(res.status).toBe(400) + expect(json.error).toBe('Invalid config data') + expect(mockApplyOpenCodeConfigUpdate).not.toHaveBeenCalled() }) - it('writes and persists the exact submitted config with local MCP and formatter when recovery removes no fields', async () => { - const submittedContent = { - mcp: { local: { type: 'local', command: ['npx', 'evil-server'] }, remote: { type: 'remote', url: 'https://example.com/mcp' } }, - formatter: { typescript: { command: ['prettier'] } }, - theme: 'light', - } - mockGetOpenCodeConfigByName.mockReturnValue({ - id: 2, - name: 'enforced', - content: { ...submittedContent, theme: 'dark' }, - rawContent: JSON.stringify({ ...submittedContent, theme: 'dark' }), - isValid: true, - isDefault: true, - createdAt: 1, - updatedAt: 1, - }) - mockUpdateOpenCodeConfig.mockReturnValue({ - id: 2, - name: 'enforced', - content: submittedContent, - rawContent: JSON.stringify(submittedContent), - isValid: true, - isDefault: true, - createdAt: 1, - updatedAt: 2, - }) - mockPatchConfigWithRecovery.mockResolvedValue({ - success: true, - appliedConfig: submittedContent, - }) - mockIsSandboxEnforced.mockReturnValue(true) - - const req = new Request('http://localhost/opencode-configs/enforced', { + it('returns 400 when the PUT body is malformed JSON', async () => { + const res = await settingsApp.fetch(new Request('http://localhost/opencode-config', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content: JSON.stringify(submittedContent), isDefault: true }), - }) - const res = await settingsApp.fetch(req) + body: '{', + })) const json = await res.json() as Record - expect(res.status).toBe(200) - expect(mockPatchConfigWithRecovery).toHaveBeenCalledWith(expect.anything(), submittedContent) - expect(mockWriteFileContent).toHaveBeenCalledWith('/tmp/test-workspace/.config/opencode.json', JSON.stringify(submittedContent)) - expect(mockUpdateOpenCodeConfig).toHaveBeenCalledTimes(1) - expect(JSON.stringify(json.content)).toContain('evil-server') - expect(JSON.stringify(json.content)).toContain('prettier') - }) - - it('writes and persists the exact submitted config with shell, LSP, hooks, and custom providers when recovery removes no fields', async () => { - const submittedContent = { - shell: { command: '/repo/.bin/evil-shell', args: [] }, - lsp: true, - experimental: { hook: { file_edited: [{ command: ['chmod', '+x', 'script.sh'] }] }, chatMaxRetries: 4 }, - provider: { builtin: { options: { apiKey: 'k' } }, evil: { npm: 'file:///repo/evil-provider.js' } }, - theme: 'light', - } - mockGetOpenCodeConfigByName.mockReturnValue({ - id: 2, - name: 'enforced', - content: { ...submittedContent, theme: 'dark' }, - rawContent: JSON.stringify({ ...submittedContent, theme: 'dark' }), - isValid: true, - isDefault: true, - createdAt: 1, - updatedAt: 1, - }) - mockUpdateOpenCodeConfig.mockReturnValue({ - id: 2, - name: 'enforced', - content: submittedContent, - rawContent: JSON.stringify(submittedContent), - isValid: true, - isDefault: true, - createdAt: 1, - updatedAt: 2, - }) - mockPatchConfigWithRecovery.mockResolvedValue({ - success: true, - appliedConfig: submittedContent, - }) - mockIsSandboxEnforced.mockReturnValue(true) + expect(res.status).toBe(400) + expect(json.error).toBe('Invalid config data') + expect(mockApplyOpenCodeConfigUpdate).not.toHaveBeenCalled() + }) - const req = new Request('http://localhost/opencode-configs/enforced', { + it('returns 400 when the PUT body is empty', async () => { + const res = await settingsApp.fetch(new Request('http://localhost/opencode-config', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content: JSON.stringify(submittedContent), isDefault: true }), - }) - const res = await settingsApp.fetch(req) + })) const json = await res.json() as Record - expect(res.status).toBe(200) - expect(mockPatchConfigWithRecovery).toHaveBeenCalledWith(expect.anything(), submittedContent) - expect(mockWriteFileContent).toHaveBeenCalledWith('/tmp/test-workspace/.config/opencode.json', JSON.stringify(submittedContent)) - expect(mockUpdateOpenCodeConfig).toHaveBeenCalledTimes(1) - expect(JSON.stringify(json.content)).toContain('evil-shell') - expect(JSON.stringify(json.content)).toContain('chmod') - expect(JSON.stringify(json.content)).toContain('evil-provider') - }) - - it('keeps the original raw content when recovery removes nothing', async () => { - mockGetOpenCodeConfigByName.mockReturnValue({ - id: 2, - name: 'enforced', - content: { mcp: { local: { type: 'local', command: ['npx', 'evil-server'] } }, theme: 'dark' }, - rawContent: '{"mcp":{"local":{"type":"local","command":["npx","evil-server"]}},"theme":"dark"}', - isValid: true, - isDefault: true, - createdAt: 1, - updatedAt: 1, - }) - mockUpdateOpenCodeConfig.mockReturnValue({ - id: 2, - name: 'enforced', - content: { mcp: { local: { type: 'local', command: ['npx', 'evil-server'] } }, theme: 'light' }, - rawContent: '{"mcp":{"local":{"type":"local","command":["npx","evil-server"]}},"theme":"light"}', - isValid: true, - isDefault: true, - createdAt: 1, - updatedAt: 2, - }) - mockPatchConfigWithRecovery.mockResolvedValue({ - success: true, - appliedConfig: { mcp: { local: { type: 'local', command: ['npx', 'evil-server'] } }, theme: 'light' }, - }) - mockIsSandboxEnforced.mockReturnValue(false) + expect(res.status).toBe(400) + expect(json.error).toBe('Invalid config data') + expect(mockApplyOpenCodeConfigUpdate).not.toHaveBeenCalled() + }) - const req = new Request('http://localhost/opencode-configs/enforced', { + it('returns 500 when applying the config fails', async () => { + mockApplyOpenCodeConfigUpdate.mockRejectedValueOnce(new Error('write failed')) + + const res = await settingsApp.fetch(new Request('http://localhost/opencode-config', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - content: '{"mcp":{"local":{"type":"local","command":["npx","evil-server"]}},"theme":"light"}', - isDefault: true, - }), - }) - const res = await settingsApp.fetch(req) + body: JSON.stringify({ content: '{"theme":"light"}' }), + })) + const json = await res.json() as Record - expect(res.status).toBe(200) - expect(mockWriteFileContent).toHaveBeenCalledWith( - '/tmp/test-workspace/.config/opencode.json', - '{"mcp":{"local":{"type":"local","command":["npx","evil-server"]}},"theme":"light"}', - ) - expect(mockUpdateOpenCodeConfig).toHaveBeenCalledTimes(1) + expect(res.status).toBe(500) + expect(json.error).toBe('Failed to update OpenCode config') }) - it('writes the exact submitted config on a restart-required PUT regardless of sandbox state', async () => { - mockGetOpenCodeConfigByName.mockReturnValue({ - id: 2, - name: 'enforced', - content: { theme: 'dark' }, - rawContent: '{"theme":"dark"}', - isValid: true, - isDefault: true, - createdAt: 1, - updatedAt: 1, - }) - mockUpdateOpenCodeConfig.mockReturnValue({ - id: 2, - name: 'enforced', - content: { plugin: ['evil-plugin'], theme: 'light' }, - rawContent: '{"plugin":["evil-plugin"],"theme":"light"}', - isValid: true, - isDefault: true, - createdAt: 1, - updatedAt: 2, - }) - mockIsSandboxEnforced.mockReturnValue(true) + it('writes the last known good config and reloads on rollback', async () => { + mockGetLastKnownGoodConfig.mockReturnValueOnce('{"theme":"dark"}') - const req = new Request('http://localhost/opencode-configs/enforced', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content: '{"plugin":["evil-plugin"],"theme":"light"}', isDefault: true }), - }) - const res = await settingsApp.fetch(req) + const res = await settingsApp.fetch(new Request('http://localhost/opencode-rollback', { method: 'POST' })) const json = await res.json() as Record expect(res.status).toBe(200) - expect(json.restartRequired).toBe(true) - expect(mockWriteFileContent).toHaveBeenCalledWith( - '/tmp/test-workspace/.config/opencode.json', - '{"plugin":["evil-plugin"],"theme":"light"}', - ) - expect(mockUpdateOpenCodeConfig).toHaveBeenCalledTimes(1) - expect(opencodeServerManager.markRestartPending).toHaveBeenCalled() + expect(json).toEqual({ success: true, message: 'Server reloaded with the previous working config' }) + expect(mockWriteOpenCodeConfigFile).toHaveBeenCalledWith('{"theme":"dark"}') + expect(mockClearStartupError).toHaveBeenCalled() + expect(mockReloadConfig).toHaveBeenCalled() }) - it('writes the exact submitted config when creating a plugin-bearing default config regardless of sandbox state', async () => { - mockCreateOpenCodeConfig.mockReturnValue({ - id: 1, - name: 'enforced', - content: { plugin: ['evil-plugin'], theme: 'dark' }, - rawContent: '{"plugin":["evil-plugin"],"theme":"dark"}', - isValid: true, - isDefault: false, - createdAt: 1, - updatedAt: 1, - }) - mockUpdateOpenCodeConfig.mockReturnValue({ - id: 1, - name: 'enforced', - content: { plugin: ['evil-plugin'], theme: 'dark' }, - rawContent: '{"plugin":["evil-plugin"],"theme":"dark"}', - isValid: true, - isDefault: true, - createdAt: 1, - updatedAt: 2, - }) - mockIsSandboxEnforced.mockReturnValue(true) + it('returns 404 from rollback when no last known good config exists', async () => { + mockGetLastKnownGoodConfig.mockReturnValueOnce(null) - const req = new Request('http://localhost/opencode-configs', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'enforced', content: '{"plugin":["evil-plugin"],"theme":"dark"}', isDefault: true }), - }) - const res = await settingsApp.fetch(req) + const res = await settingsApp.fetch(new Request('http://localhost/opencode-rollback', { method: 'POST' })) + const json = await res.json() as Record - expect(res.status).toBe(200) - expect(mockUpdateOpenCodeConfig).toHaveBeenCalledWith( - 'enforced', - { content: '{"plugin":["evil-plugin"],"theme":"dark"}', isDefault: true }, - 'default', - ) - expect(mockWriteFileContent).toHaveBeenCalledWith( - '/tmp/test-workspace/.config/opencode.json', - '{"plugin":["evil-plugin"],"theme":"dark"}', - ) + expect(res.status).toBe(404) + expect(json.error).toBe('No previous working config available for rollback') + expect(mockWriteOpenCodeConfigFile).not.toHaveBeenCalled() }) - it('writes the exact submitted config when setting a plugin-bearing config as default regardless of sandbox state', async () => { - mockGetOpenCodeConfigByName.mockReturnValue({ - id: 2, - name: 'enforced', - content: { plugin: ['evil-plugin'], theme: 'dark' }, - rawContent: '{"plugin":["evil-plugin"],"theme":"dark"}', - isValid: true, - isDefault: false, - createdAt: 1, - updatedAt: 1, - }) - mockSetDefaultOpenCodeConfig.mockReturnValue({ - id: 2, - name: 'enforced', - content: { plugin: ['evil-plugin'], theme: 'dark' }, - rawContent: '{"plugin":["evil-plugin"],"theme":"dark"}', - isValid: true, - isDefault: true, - createdAt: 1, - updatedAt: 2, - }) - mockIsSandboxEnforced.mockReturnValue(true) + it('deletes the config file and restarts when the rollback reload fails', async () => { + mockGetLastKnownGoodConfig.mockReturnValueOnce('{"theme":"dark"}') + mockReloadConfig.mockRejectedValueOnce(new Error('reload failed')) + mockDeleteOpenCodeConfigFile.mockResolvedValueOnce(true) - const req = new Request('http://localhost/opencode-configs/enforced/set-default', { - method: 'POST', - }) - const res = await settingsApp.fetch(req) + const res = await settingsApp.fetch(new Request('http://localhost/opencode-rollback', { method: 'POST' })) + const json = await res.json() as Record expect(res.status).toBe(200) - expect(mockSetDefaultOpenCodeConfig).toHaveBeenCalledWith('enforced', 'default') - expect(mockWriteFileContent).toHaveBeenCalledWith( - '/tmp/test-workspace/.config/opencode.json', - '{"plugin":["evil-plugin"],"theme":"dark"}', - ) + expect(json).toEqual({ + success: true, + message: 'Server restarted after deleting the broken config file. The previous working config remains available for rollback.', + fallback: true, + }) + expect(mockWriteOpenCodeConfigFile).toHaveBeenCalledWith('{"theme":"dark"}') + expect(mockDeleteOpenCodeConfigFile).toHaveBeenCalled() + expect(mockRestart).toHaveBeenCalled() }) }) @@ -984,8 +633,6 @@ describe('Settings Routes - OpenCode Upgrade', () => { expect(json.success).toBe(true) expect(json.serverRestarted).toBe(true) expect(mockSyncOpenCodeImport).toHaveBeenCalledWith({ - db: testDb, - userId: 'default', overwriteState: true, protectExistingState: true, }) @@ -2051,3 +1698,840 @@ describe('Settings Routes - OpenCode Upgrade', () => { }) }) }) + +describe('Settings Routes - versions, directory files, skills, MCP and maintenance', () => { + let app: ReturnType + let db: Database + let supervisor: { restart: ReturnType; reloadConfig: ReturnType } + let fetchMock: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + mockSpawnSync.mockReset() + mockGetVersion.mockReset() + mockFetchVersion.mockReset() + mockRestart.mockReset() + mockClearStartupError.mockReset() + mockHasStoredOpenCodeServerPassword.mockReset() + mockGetStoredOpenCodeServerPasswordState.mockReset() + mockClearOpenCodeServerPassword.mockReset() + mockSetOpenCodeServerPassword.mockReset() + mockRestoreOpenCodeServerPasswordState.mockReset() + mockListManagedSkills.mockReset() + mockGetSkill.mockReset() + mockCreateSkill.mockReset() + mockUpdateSkill.mockReset() + mockDeleteSkill.mockReset() + mockInstallSkillFromGithubTree.mockReset() + mockInstallSkillFromUploadedFiles.mockReset() + mockInstallOpenCodeDirectoryFiles.mockReset() + mockListOpenCodeDirectoryFiles.mockReset() + mockGetOpenCodeDirectoryFile.mockReset() + mockUpdateOpenCodeDirectoryFile.mockReset() + mockDeleteOpenCodeDirectoryFile.mockReset() + mockDiscoverModelsCached.mockReset() + mockValidateSSHPrivateKey.mockReset() + + db = new Database(':memory:') + migrate(db, allMigrations) + supervisor = { + restart: vi.fn().mockResolvedValue({ healthy: true, resumedSessionIDs: [] }), + reloadConfig: vi.fn().mockResolvedValue({ healthy: true }), + } + app = createSettingsRoutes( + db, + { getGitEnvironment: vi.fn().mockReturnValue({}) } as any, + createStubOpenCodeClient(), + supervisor as any, + ) + fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + mockGetVersion.mockReturnValue('1.2.27') + mockFetchVersion.mockResolvedValue('1.2.27') + mockClearStartupError.mockReturnValue(undefined) + mockSpawnSync.mockReturnValue({ status: 0, stdout: '', stderr: '' }) + mockValidateSSHPrivateKey.mockResolvedValue({ valid: true, hasPassphrase: false }) + mockHasStoredOpenCodeServerPassword.mockReturnValue(false) + mockGetStoredOpenCodeServerPasswordState.mockReturnValue({ source: 'none' }) + }) + + afterEach(() => { + setOpenCodeRestartCoordinator(null) + db.close() + vi.unstubAllGlobals() + }) + + describe('GET /opencode-versions', () => { + it('returns non-prerelease GitHub releases with the current version', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify([ + { tag_name: 'v1.2.28', name: '1.2.28', published_at: '2026-01-01T00:00:00Z', prerelease: false }, + { tag_name: 'v1.3.0-beta.1', name: '1.3.0-beta.1', published_at: '2026-01-02T00:00:00Z', prerelease: true }, + ]), { status: 200 })) + + const res = await app.request(new Request('http://localhost/opencode-versions')) + const json = await res.json() as { versions: Array<{ version: string; tag: string }>; currentVersion: string } + + expect(res.status).toBe(200) + expect(json.currentVersion).toBe('1.2.27') + expect(json.versions).toEqual([ + { version: '1.2.28', tag: 'v1.2.28', name: '1.2.28', publishedAt: '2026-01-01T00:00:00Z' }, + ]) + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.github.com/repos/sst/opencode/releases?per_page=20', + { headers: expect.objectContaining({ Accept: 'application/vnd.github.v3+json' }) }, + ) + }) + + it('returns 500 when the GitHub releases request fails', async () => { + fetchMock.mockResolvedValue(new Response('rate limited', { status: 403 })) + + const res = await app.request(new Request('http://localhost/opencode-versions')) + const json = await res.json() as { error: string; details: string } + + expect(res.status).toBe(500) + expect(json.error).toBe('Failed to fetch versions') + expect(json.details).toContain('403') + }) + }) + + describe('POST /opencode-install-version', () => { + it('installs the requested version and restarts the server', async () => { + mockFetchVersion.mockResolvedValue('1.2.28') + + const res = await app.request(new Request('http://localhost/opencode-install-version', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ version: '1.2.28' }), + })) + const json = await res.json() as { success: boolean; message: string; oldVersion: string; newVersion: string } + + expect(res.status).toBe(200) + expect(json.success).toBe(true) + expect(json.oldVersion).toBe('1.2.27') + expect(json.newVersion).toBe('1.2.28') + expect(json.message).toContain('changed from v1.2.27 to v1.2.28') + expect(supervisor.restart).toHaveBeenCalledTimes(1) + expect(mockSpawnSync).toHaveBeenCalledWith( + expect.any(String), + expect.arrayContaining(['upgrade', 'v1.2.28']), + expect.objectContaining({ timeout: 90000 }), + ) + }) + + it('returns 500 without recovery when the version install fails and the server cannot be recovered', async () => { + mockFetchVersion.mockResolvedValue('1.2.29') + supervisor.restart.mockResolvedValue({ healthy: false, resumedSessionIDs: [] }) + + const res = await app.request(new Request('http://localhost/opencode-install-version', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ version: '1.2.28' }), + })) + const json = await res.json() as { recovered: boolean; newVersion: string } + + expect(res.status).toBe(500) + expect(json.recovered).toBe(false) + expect(json.newVersion).toBe('1.2.27') + }) + }) + + describe('OpenCode directory file routes', () => { + it('lists directory files by kind', async () => { + mockListOpenCodeDirectoryFiles.mockResolvedValue([ + { kind: 'agents', name: 'assistant', relativePath: 'assistant.md' }, + ]) + + const res = await app.request(new Request('http://localhost/opencode-directory-files?kind=agents')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual([ + { kind: 'agents', name: 'assistant', relativePath: 'assistant.md' }, + ]) + expect(mockListOpenCodeDirectoryFiles).toHaveBeenCalledWith('agents') + }) + + it('returns 400 when the directory file kind is invalid', async () => { + const res = await app.request(new Request('http://localhost/opencode-directory-files?kind=unknown')) + + expect(res.status).toBe(400) + }) + + it('returns a directory file and its content', async () => { + mockGetOpenCodeDirectoryFile.mockResolvedValue({ + kind: 'commands', + name: 'review', + relativePath: 'review.md', + content: 'Review the changes', + }) + + const res = await app.request(new Request('http://localhost/opencode-directory-files/content?kind=commands&relativePath=review.md')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + kind: 'commands', + name: 'review', + relativePath: 'review.md', + content: 'Review the changes', + }) + }) + + it('returns 404 when the directory file does not exist', async () => { + mockGetOpenCodeDirectoryFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) + + const res = await app.request(new Request('http://localhost/opencode-directory-files/content?kind=agents&relativePath=missing.md')) + + expect(res.status).toBe(404) + expect(await res.json()).toEqual({ error: 'File not found' }) + }) + + it('returns 400 when the content query is invalid', async () => { + const res = await app.request(new Request('http://localhost/opencode-directory-files/content?kind=agents')) + + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ error: 'Invalid request', details: expect.any(Array) }) + }) + + it('updates a directory file and marks a restart pending', async () => { + mockUpdateOpenCodeDirectoryFile.mockResolvedValue({ + kind: 'commands', + name: 'review', + relativePath: 'review.md', + }) + + const res = await app.request(new Request('http://localhost/opencode-directory-files', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ kind: 'commands', relativePath: 'review.md', content: 'Updated' }), + })) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + kind: 'commands', + name: 'review', + relativePath: 'review.md', + restartRequired: true, + }) + expect(mockUpdateOpenCodeDirectoryFile).toHaveBeenCalledWith('commands', 'review.md', 'Updated') + expect(opencodeServerManager.markRestartPending).toHaveBeenCalledTimes(1) + }) + + it('returns 400 when updating a directory file with an invalid body', async () => { + const res = await app.request(new Request('http://localhost/opencode-directory-files', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ kind: 'commands' }), + })) + + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ error: 'Invalid request', details: expect.any(Array) }) + expect(mockUpdateOpenCodeDirectoryFile).not.toHaveBeenCalled() + }) + + it('deletes a directory file and marks a restart pending', async () => { + mockDeleteOpenCodeDirectoryFile.mockResolvedValue(undefined) + + const res = await app.request(new Request('http://localhost/opencode-directory-files?kind=agents&relativePath=assistant.md', { + method: 'DELETE', + })) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ kind: 'agents', relativePath: 'assistant.md', restartRequired: true }) + expect(mockDeleteOpenCodeDirectoryFile).toHaveBeenCalledWith('agents', 'assistant.md') + expect(opencodeServerManager.markRestartPending).toHaveBeenCalledTimes(1) + }) + + it('returns 400 when installing directory files without multipart form data', async () => { + const res = await app.request(new Request('http://localhost/opencode-directory-files/install', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ kind: 'agents' }), + })) + + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ error: 'Unsupported content type. Use multipart/form-data' }) + }) + + it('installs uploaded directory files and marks a restart pending', async () => { + mockInstallOpenCodeDirectoryFiles.mockResolvedValue({ kind: 'agents', filesInstalled: ['assistant.md'] }) + + const formData = new FormData() + formData.set('kind', 'agents') + formData.set('fileManifest', JSON.stringify([{ fieldName: 'file0', relativePath: 'assistant.md' }])) + formData.set('file0', new File(['# Assistant'], 'assistant.md', { type: 'text/markdown' })) + + const res = await app.request(new Request('http://localhost/opencode-directory-files/install', { + method: 'POST', + body: formData, + })) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + kind: 'agents', + filesInstalled: ['assistant.md'], + restartRequired: true, + }) + expect(mockInstallOpenCodeDirectoryFiles).toHaveBeenCalledWith('agents', [ + { relativePath: 'assistant.md', content: expect.any(Buffer) }, + ]) + expect(opencodeServerManager.markRestartPending).toHaveBeenCalledTimes(1) + }) + }) + + describe('Skill routes', () => { + it('lists managed skills', async () => { + mockListManagedSkills.mockResolvedValue([ + { name: 'review', description: 'Review changes', body: 'body', scope: 'global', location: '/tmp/review' }, + ]) + + const res = await app.request(new Request('http://localhost/skills')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual([ + { name: 'review', description: 'Review changes', body: 'body', scope: 'global', location: '/tmp/review' }, + ]) + expect(mockListManagedSkills).toHaveBeenCalledWith(db, expect.anything(), undefined, undefined) + }) + + it('returns a skill by name and scope', async () => { + mockGetSkill.mockResolvedValue({ + name: 'review', + description: 'Review changes', + body: 'body', + scope: 'global', + location: '/tmp/review', + }) + + const res = await app.request(new Request('http://localhost/skills/review?scope=global')) + + expect(res.status).toBe(200) + expect(mockGetSkill).toHaveBeenCalledWith(db, expect.anything(), 'review', 'global', undefined) + }) + + it('returns 400 for an invalid skill scope', async () => { + const res = await app.request(new Request('http://localhost/skills/review?scope=invalid')) + + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ error: 'Invalid scope parameter. Must be "global" or "project"' }) + }) + + it('returns 400 when a project skill is requested without a repoId', async () => { + const res = await app.request(new Request('http://localhost/skills/review?scope=project')) + + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ error: 'repoId is required for project scope' }) + }) + + it('returns 404 when the skill does not exist', async () => { + mockGetSkill.mockRejectedValue(new Error('Skill not found')) + + const res = await app.request(new Request('http://localhost/skills/review?scope=global')) + + expect(res.status).toBe(404) + expect(await res.json()).toEqual({ error: 'Skill not found' }) + }) + + it('creates a skill and marks a restart pending', async () => { + mockCreateSkill.mockResolvedValue({ + name: 'review', + description: 'Review changes', + body: 'body', + scope: 'global', + location: '/tmp/review', + }) + + const res = await app.request(new Request('http://localhost/skills', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'review', description: 'Review changes', body: 'body', scope: 'global' }), + })) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + name: 'review', + description: 'Review changes', + body: 'body', + scope: 'global', + location: '/tmp/review', + restartRequired: true, + }) + expect(opencodeServerManager.markRestartPending).toHaveBeenCalledTimes(1) + }) + + it('returns 409 when creating a skill that already exists', async () => { + mockCreateSkill.mockRejectedValue(new Error('Skill already exists')) + + const res = await app.request(new Request('http://localhost/skills', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'review', description: 'Review changes', body: 'body', scope: 'global' }), + })) + + expect(res.status).toBe(409) + expect(await res.json()).toEqual({ error: 'Skill already exists' }) + }) + + it('updates a skill and marks a restart pending', async () => { + mockUpdateSkill.mockResolvedValue({ + name: 'review', + description: 'Updated', + body: 'body', + scope: 'global', + location: '/tmp/review', + }) + + const res = await app.request(new Request('http://localhost/skills/review?scope=global', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ description: 'Updated' }), + })) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + name: 'review', + description: 'Updated', + body: 'body', + scope: 'global', + location: '/tmp/review', + restartRequired: true, + }) + expect(opencodeServerManager.markRestartPending).toHaveBeenCalledTimes(1) + }) + + it('deletes a skill and marks a restart pending', async () => { + mockDeleteSkill.mockResolvedValue(undefined) + + const res = await app.request(new Request('http://localhost/skills/review?scope=global', { method: 'DELETE' })) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ success: true, restartRequired: true }) + expect(mockDeleteSkill).toHaveBeenCalledWith(db, 'review', 'global', undefined) + expect(opencodeServerManager.markRestartPending).toHaveBeenCalledTimes(1) + }) + + it('returns 400 when installing a project skill without a repoId', async () => { + const res = await app.request(new Request('http://localhost/skills/install', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sourceType: 'github', url: 'https://github.com/example/skill', scope: 'project' }), + })) + + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ error: 'repoId is required for project scope' }) + }) + + it('installs a global skill from a GitHub tree and marks a restart pending', async () => { + mockInstallSkillFromGithubTree.mockResolvedValue({ + skill: { name: 'review', description: 'Review', body: 'body', scope: 'global', location: '/tmp/review' }, + overwritten: false, + sourceType: 'github', + filesInstalled: ['SKILL.md'], + }) + + const res = await app.request(new Request('http://localhost/skills/install', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sourceType: 'github', url: 'https://github.com/example/skill', scope: 'global' }), + })) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + skill: { name: 'review', description: 'Review', body: 'body', scope: 'global', location: '/tmp/review' }, + overwritten: false, + sourceType: 'github', + filesInstalled: ['SKILL.md'], + restartRequired: true, + }) + expect(opencodeServerManager.markRestartPending).toHaveBeenCalledTimes(1) + }) + + it('reloads a project skill without restarting the whole server', async () => { + const repo = createRepo(db, { localPath: 'project-repo', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true }) + mockInstallSkillFromGithubTree.mockResolvedValue({ + skill: { name: 'review', description: 'Review', body: 'body', scope: 'project', location: '/tmp/review', repoId: repo.id }, + overwritten: false, + sourceType: 'github', + filesInstalled: ['SKILL.md'], + }) + + const res = await app.request(new Request('http://localhost/skills/install', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sourceType: 'github', url: 'https://github.com/example/skill', scope: 'project', repoId: repo.id }), + })) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + skill: { name: 'review', description: 'Review', body: 'body', scope: 'project', location: '/tmp/review', repoId: repo.id }, + overwritten: false, + sourceType: 'github', + filesInstalled: ['SKILL.md'], + restartRequired: false, + }) + expect(opencodeServerManager.markRestartPending).not.toHaveBeenCalled() + expect(supervisor.restart).toHaveBeenCalledTimes(1) + expect(mockInstallSkillFromGithubTree).toHaveBeenCalledWith(db, expect.objectContaining({ scope: 'project', repoId: repo.id })) + }) + + it('installs an uploaded skill from multipart form data', async () => { + mockInstallSkillFromUploadedFiles.mockResolvedValue({ + skill: { name: 'review', description: 'Review', body: 'body', scope: 'global', location: '/tmp/review' }, + overwritten: false, + sourceType: 'upload', + filesInstalled: ['SKILL.md'], + }) + + const formData = new FormData() + formData.set('scope', 'global') + formData.set('fileManifest', JSON.stringify([{ fieldName: 'file0', relativePath: 'review/SKILL.md' }])) + formData.set('file0', new File(['---\nname: review\ndescription: Review\n---\nbody'], 'SKILL.md', { type: 'text/markdown' })) + + const res = await app.request(new Request('http://localhost/skills/install', { + method: 'POST', + body: formData, + })) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + skill: { name: 'review', description: 'Review', body: 'body', scope: 'global', location: '/tmp/review' }, + overwritten: false, + sourceType: 'upload', + filesInstalled: ['SKILL.md'], + restartRequired: true, + }) + expect(mockInstallSkillFromUploadedFiles).toHaveBeenCalledWith( + db, + expect.objectContaining({ sourceType: 'upload', scope: 'global' }), + [{ relativePath: 'review/SKILL.md', content: expect.any(Buffer) }], + ) + }) + + it('returns 400 when installing a skill with an unsupported content type', async () => { + const res = await app.request(new Request('http://localhost/skills/install', { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: 'not a skill', + })) + + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ error: 'Unsupported content type. Use application/json or multipart/form-data' }) + }) + }) + + describe('POST /test-ssh', () => { + it('returns 400 when the SSH key is invalid', async () => { + mockValidateSSHPrivateKey.mockResolvedValue({ valid: false, hasPassphrase: false, error: 'Invalid SSH key' }) + + const res = await app.request(new Request('http://localhost/test-ssh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ host: 'example.com', sshPrivateKey: 'not-a-key' }), + })) + + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ success: false, message: 'Invalid SSH key' }) + }) + + it('reports a successful SSH authentication', async () => { + mockSpawnSync.mockReturnValue({ status: 255, stdout: '', stderr: "debug1: Authentication succeeded\nYou've successfully authenticated" }) + + const res = await app.request(new Request('http://localhost/test-ssh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ host: 'git@example.com:2222', sshPrivateKey: '-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----' }), + })) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ success: true, message: 'Successfully connected to git@example.com:2222' }) + expect(mockSpawnSync).toHaveBeenCalledWith( + 'ssh', + expect.arrayContaining(['-p', '2222', 'git@example.com']), + expect.objectContaining({ timeout: 30000 }), + ) + }) + + it('reports a timeout when the SSH command is killed', async () => { + mockSpawnSync.mockReturnValue({ status: null, signal: 'SIGKILL', stdout: '', stderr: '' }) + + const res = await app.request(new Request('http://localhost/test-ssh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ host: 'example.com', sshPrivateKey: '-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----' }), + })) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + success: false, + message: 'Connection timed out. This may indicate a network issue or an incorrect host.', + }) + }) + + it('reports permission denied, unknown host, and refused connection details', async () => { + const cases = [ + { output: 'Permission denied (publickey)', message: 'Permission denied. The SSH key may not be authorized on this host, or the passphrase is incorrect.' }, + { output: 'Could not resolve hostname example.com', message: 'Could not resolve hostname. Please check that the host is correct and accessible.' }, + { output: 'ssh: connect to host example.com port 22: Connection refused', message: 'Connection refused or timed out. The host may be down or not accepting SSH connections.' }, + ] + + for (const testCase of cases) { + mockSpawnSync.mockReturnValue({ status: 255, stdout: '', stderr: testCase.output }) + + const res = await app.request(new Request('http://localhost/test-ssh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ host: 'example.com', sshPrivateKey: '-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----' }), + })) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ success: false, message: testCase.message }) + } + }) + + it('reports ambiguous SSH output and uses sshpass when a passphrase is provided', async () => { + mockSpawnSync.mockReturnValue({ status: 255, stdout: '', stderr: 'debug1: no matching host key' }) + + const res = await app.request(new Request('http://localhost/test-ssh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ host: 'example.com', sshPrivateKey: '-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----', passphrase: 'secret' }), + })) + + expect(res.status).toBe(200) + const json = await res.json() as { success: boolean; message: string } + expect(json.success).toBe(false) + expect(json.message).toContain('Authentication failed') + expect(mockSpawnSync).toHaveBeenCalledWith( + 'sshpass', + expect.arrayContaining(['-e', 'ssh']), + expect.objectContaining({ env: expect.objectContaining({ SSHPASS: 'secret' }) }), + ) + }) + + it('returns 400 for an invalid test-ssh body', async () => { + const res = await app.request(new Request('http://localhost/test-ssh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ host: 'example.com' }), + })) + + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ error: 'Invalid request data', details: expect.any(Array) }) + }) + }) + + describe('MCP directory routes', () => { + function createMcpApp(forward: ReturnType) { + return createSettingsRoutes( + db, + { getGitEnvironment: vi.fn().mockReturnValue({}) } as any, + createStubOpenCodeClient({ forward: forward as any }), + supervisor as any, + ) + } + + it('connects an MCP server for a directory', async () => { + const forward = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })) + const mcpApp = createMcpApp(forward) + + const res = await mcpApp.request(new Request('http://localhost/mcp/context7/connectdirectory', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ directory: '/tmp/project' }), + })) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ success: true }) + expect(forward).toHaveBeenCalledWith({ + method: 'POST', + path: '/mcp/context7/connect', + directory: '/tmp/project', + }) + }) + + it('returns 400 with the OpenCode error when connecting fails', async () => { + const forward = vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: 'MCP server unreachable' }), { status: 502 })) + const mcpApp = createMcpApp(forward) + + const res = await mcpApp.request(new Request('http://localhost/mcp/context7/connectdirectory', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ directory: '/tmp/project' }), + })) + + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ error: 'MCP server unreachable' }) + }) + + it('disconnects an MCP server and removes MCP auth', async () => { + const forward = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })) + const mcpApp = createMcpApp(forward) + + const disconnectRes = await mcpApp.request(new Request('http://localhost/mcp/context7/disconnectdirectory', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ directory: '/tmp/project' }), + })) + expect(disconnectRes.status).toBe(200) + + const authRes = await mcpApp.request(new Request('http://localhost/mcp/context7/authdirectedir', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ directory: '/tmp/project' }), + })) + expect(authRes.status).toBe(200) + expect(await authRes.json()).toEqual({}) + + const deleteRes = await mcpApp.request(new Request('http://localhost/mcp/context7/authdir', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ directory: '/tmp/project' }), + })) + expect(deleteRes.status).toBe(200) + expect(await deleteRes.json()).toEqual({ success: true }) + + expect(forward).toHaveBeenCalledTimes(3) + }) + + it('returns 400 for invalid MCP directory bodies', async () => { + const forward = vi.fn() + const mcpApp = createMcpApp(forward) + + const res = await mcpApp.request(new Request('http://localhost/mcp/context7/connectdirectory', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ directory: '' }), + })) + + expect(res.status).toBe(400) + expect(forward).not.toHaveBeenCalled() + }) + }) + + describe('OpenCode server auth routes', () => { + it('reports the configured source for the server password', async () => { + mockHasStoredOpenCodeServerPassword.mockReturnValue(false) + + const res = await app.request(new Request('http://localhost/opencode-server-auth')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ isSet: false, source: 'none' }) + }) + + it('clears the server password and reconnects the SSE aggregator', async () => { + mockGetStoredOpenCodeServerPasswordState.mockReturnValue({ source: 'db' }) + + const res = await app.request(new Request('http://localhost/opencode-server-auth', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: null }), + })) + + expect(res.status).toBe(200) + expect(mockClearOpenCodeServerPassword).toHaveBeenCalledTimes(1) + expect(mockSetOpenCodeServerPassword).not.toHaveBeenCalled() + expect(await res.json()).toEqual({ isSet: false, source: 'none' }) + }) + + it('restores the previous password state when the restart fails', async () => { + mockGetStoredOpenCodeServerPasswordState.mockReturnValue({ source: 'db' }) + supervisor.restart.mockResolvedValue({ healthy: false, resumedSessionIDs: [] }) + + const res = await app.request(new Request('http://localhost/opencode-server-auth', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: 'new-password' }), + })) + + expect(res.status).toBe(500) + expect(mockSetOpenCodeServerPassword).toHaveBeenCalledWith('new-password') + expect(mockRestoreOpenCodeServerPasswordState).toHaveBeenCalledWith({ source: 'db' }) + expect(await res.json()).toEqual({ error: 'Failed to update OpenCode server auth' }) + }) + }) + + describe('Maintenance routes', () => { + it('returns the manager token', async () => { + const res = await app.request(new Request('http://localhost/manager-token')) + + expect(res.status).toBe(200) + const json = await res.json() as { token: string } + expect(json.token).toBe(getOrCreateInternalToken(db)) + }) + + it('returns 500 when the manager token cannot be read', async () => { + const brokenDb = { + prepare: vi.fn(() => { + throw new Error('database is unavailable') + }), + } as any + const brokenApp = createSettingsRoutes( + brokenDb, + { getGitEnvironment: vi.fn().mockReturnValue({}) } as any, + createStubOpenCodeClient(), + supervisor as any, + ) + + const res = await brokenApp.request(new Request('http://localhost/manager-token')) + + expect(res.status).toBe(500) + expect(await res.json()).toEqual({ error: 'Failed to get manager token' }) + }) + + it('returns resumable sessions from the restart coordinator', async () => { + setOpenCodeRestartCoordinator({ + captureResumableSessions: vi.fn().mockReturnValue([{ id: 'session-1' }]), + } as any) + + const res = await app.request(new Request('http://localhost/opencode-active-sessions')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ count: 1, sessions: [{ id: 'session-1' }] }) + }) + + it('returns an empty session list without a restart coordinator', async () => { + const res = await app.request(new Request('http://localhost/opencode-active-sessions')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ count: 0, sessions: [] }) + }) + + it('validates the model discovery baseUrl', async () => { + const missing = await app.request(new Request('http://localhost/opencode-discover-models')) + expect(missing.status).toBe(400) + expect(await missing.json()).toEqual({ error: 'baseUrl is required' }) + + const invalid = await app.request(new Request('http://localhost/opencode-discover-models?baseUrl=not-a-url')) + expect(invalid.status).toBe(400) + expect(await invalid.json()).toEqual({ error: 'Invalid baseUrl' }) + + const nonHttp = await app.request(new Request('http://localhost/opencode-discover-models?baseUrl=ftp://example.com')) + expect(nonHttp.status).toBe(400) + expect(await nonHttp.json()).toEqual({ error: 'baseUrl must be an http or https URL' }) + }) + + it('discovers and returns models with the cache flag', async () => { + mockDiscoverModelsCached.mockResolvedValue({ models: [{ id: 'gpt-4' }], cached: false }) + + const res = await app.request(new Request('http://localhost/opencode-discover-models?baseUrl=https://api.example.com&apiKey=secret&refresh=true')) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ models: [{ id: 'gpt-4' }], cached: false }) + expect(mockDiscoverModelsCached).toHaveBeenCalledWith({ + baseUrl: 'https://api.example.com', + apiKey: 'secret', + type: 'opencode-models', + filterPattern: /.*/, + defaultModels: [], + forceRefresh: true, + }) + }) + + it('returns 500 when model discovery fails', async () => { + mockDiscoverModelsCached.mockRejectedValue(new Error('discovery failed')) + + const res = await app.request(new Request('http://localhost/opencode-discover-models?baseUrl=https://api.example.com')) + + expect(res.status).toBe(500) + expect(await res.json()).toEqual({ error: 'Failed to discover models' }) + }) + }) +}) diff --git a/backend/test/routes/sse.test.ts b/backend/test/routes/sse.test.ts new file mode 100644 index 000000000..b6ad20704 --- /dev/null +++ b/backend/test/routes/sse.test.ts @@ -0,0 +1,288 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const sseMocks = vi.hoisted(() => ({ + addClient: vi.fn(), + addDirectories: vi.fn(), + removeDirectories: vi.fn(), + setClientVisibility: vi.fn(), + getConnectionStatus: vi.fn(), + getClientCount: vi.fn(), + getActiveDirectories: vi.fn(), + getActiveSessions: vi.fn(), +})) + +vi.mock('../../src/services/sse-aggregator', () => ({ + sseAggregator: sseMocks, +})) + +vi.mock('../../src/utils/logger', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }, +})) + +import { createSSERoutes } from '../../src/routes/sse' +import { encodeSSEFrame } from '../../src/utils/sse-frame' + +const decoder = new TextDecoder() + +async function readChunk(reader: { read(): Promise<{ value?: Uint8Array }> }): Promise { + const { value } = await reader.read() + return decoder.decode(value) +} + +describe('SSE Routes', () => { + let app: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + sseMocks.addClient.mockReturnValue(() => {}) + sseMocks.addDirectories.mockReturnValue(true) + sseMocks.removeDirectories.mockReturnValue(true) + sseMocks.setClientVisibility.mockReturnValue(true) + sseMocks.getConnectionStatus.mockReturnValue({ connected: 1, total: 1 }) + sseMocks.getClientCount.mockReturnValue(0) + sseMocks.getActiveDirectories.mockReturnValue([]) + sseMocks.getActiveSessions.mockReturnValue({}) + + app = createSSERoutes() + }) + + describe('GET /stream', () => { + it('streams the connected frame and registers the client with parsed directories', async () => { + const cleanup = vi.fn() + sseMocks.addClient.mockReturnValue(cleanup) + sseMocks.getConnectionStatus.mockReturnValue({ connected: 1, total: 2 }) + + const res = await app.fetch(new Request('http://localhost/stream?directories=/one,/two,,/three')) + + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toBe('text/event-stream') + expect(res.headers.get('cache-control')).toBe('no-cache, no-store, no-transform') + expect(res.headers.get('connection')).toBe('keep-alive') + expect(res.headers.get('x-accel-buffering')).toBe('no') + + const reader = res.body!.getReader() + try { + const first = await readChunk(reader) + expect(first).toContain('event: connected') + expect(first).toContain('"directories":["/one","/two","/three"]') + expect(first).toContain('"connected":1') + expect(first).toContain('"total":2') + + const clientId = first.match(/"clientId":"([^"]+)"/)?.[1] + expect(clientId).toMatch(/^client_\d+_[a-z0-9]+$/) + + expect(sseMocks.addClient).toHaveBeenCalledTimes(1) + const [idArg, callbackArg, frameWriterArg, directoriesArg] = sseMocks.addClient.mock.calls[0] as [ + string, + (event: string, data: string) => void, + (frame: Uint8Array) => void, + string[], + ] + expect(idArg).toBe(clientId) + expect(directoriesArg).toEqual(['/one', '/two', '/three']) + + callbackArg('message', '{"from":"callback"}') + const second = await readChunk(reader) + expect(second).toBe('event: message\ndata: {"from":"callback"}\n\n') + + frameWriterArg(encodeSSEFrame('message', '{"from":"frame"}')) + const third = await readChunk(reader) + expect(third).toBe('event: message\ndata: {"from":"frame"}\n\n') + } finally { + await reader.cancel() + } + + expect(cleanup).toHaveBeenCalledTimes(1) + }) + + it('defaults directories to an empty array when the query is absent', async () => { + const cleanup = vi.fn() + sseMocks.addClient.mockReturnValue(cleanup) + + const res = await app.fetch(new Request('http://localhost/stream')) + const reader = res.body!.getReader() + try { + const first = await readChunk(reader) + expect(first).toContain('event: connected') + expect(first).toContain('"directories":[]') + } finally { + await reader.cancel() + } + + expect(sseMocks.addClient).toHaveBeenCalledWith( + expect.stringMatching(/^client_/), + expect.any(Function), + expect.any(Function), + [], + ) + expect(cleanup).toHaveBeenCalledTimes(1) + }) + }) + + describe('POST /subscribe', () => { + it('subscribes directories for an existing client', async () => { + sseMocks.addDirectories.mockReturnValue(true) + + const res = await app.fetch(new Request('http://localhost/subscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clientId: 'client-1', directories: ['/a', '/b'] }), + })) + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ success: true }) + expect(sseMocks.addDirectories).toHaveBeenCalledWith('client-1', ['/a', '/b']) + }) + + it('returns 400 for an invalid subscribe body', async () => { + const res = await app.fetch(new Request('http://localhost/subscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clientId: '', directories: 'not-an-array' }), + })) + + expect(res.status).toBe(400) + const json = await res.json() as { success: boolean; error: string } + expect(json.success).toBe(false) + expect(json.error).toBe('Invalid request') + expect(sseMocks.addDirectories).not.toHaveBeenCalled() + }) + + it('returns 404 when the client is not found', async () => { + sseMocks.addDirectories.mockReturnValue(false) + + const res = await app.fetch(new Request('http://localhost/subscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clientId: 'missing', directories: ['/a'] }), + })) + + expect(res.status).toBe(404) + await expect(res.json()).resolves.toEqual({ success: false, error: 'Client not found' }) + }) + }) + + describe('POST /unsubscribe', () => { + it('unsubscribes directories for an existing client', async () => { + sseMocks.removeDirectories.mockReturnValue(true) + + const res = await app.fetch(new Request('http://localhost/unsubscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clientId: 'client-1', directories: ['/a'] }), + })) + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ success: true }) + expect(sseMocks.removeDirectories).toHaveBeenCalledWith('client-1', ['/a']) + }) + + it('returns 400 for an invalid unsubscribe body', async () => { + const res = await app.fetch(new Request('http://localhost/unsubscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ directories: [] }), + })) + + expect(res.status).toBe(400) + const json = await res.json() as { success: boolean; error: string } + expect(json.success).toBe(false) + expect(json.error).toBe('Invalid request') + expect(sseMocks.removeDirectories).not.toHaveBeenCalled() + }) + + it('returns 404 when the client is not found', async () => { + sseMocks.removeDirectories.mockReturnValue(false) + + const res = await app.fetch(new Request('http://localhost/unsubscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clientId: 'missing', directories: ['/a'] }), + })) + + expect(res.status).toBe(404) + await expect(res.json()).resolves.toEqual({ success: false, error: 'Client not found' }) + }) + }) + + describe('POST /visibility', () => { + it('sets visibility with an explicit active session id', async () => { + sseMocks.setClientVisibility.mockReturnValue(true) + + const res = await app.fetch(new Request('http://localhost/visibility', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clientId: 'client-1', visible: true, activeSessionId: 'session-9' }), + })) + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ success: true }) + expect(sseMocks.setClientVisibility).toHaveBeenCalledWith('client-1', true, 'session-9') + }) + + it('defaults the active session id to null when omitted', async () => { + sseMocks.setClientVisibility.mockReturnValue(true) + + const res = await app.fetch(new Request('http://localhost/visibility', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clientId: 'client-1', visible: false }), + })) + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ success: true }) + expect(sseMocks.setClientVisibility).toHaveBeenCalledWith('client-1', false, null) + }) + + it('returns 400 for an invalid visibility body', async () => { + const res = await app.fetch(new Request('http://localhost/visibility', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clientId: 'client-1', visible: 'yes' }), + })) + + expect(res.status).toBe(400) + const json = await res.json() as { success: boolean; error: string } + expect(json.success).toBe(false) + expect(json.error).toBe('Invalid request') + expect(sseMocks.setClientVisibility).not.toHaveBeenCalled() + }) + + it('returns 404 when the client is not found', async () => { + sseMocks.setClientVisibility.mockReturnValue(false) + + const res = await app.fetch(new Request('http://localhost/visibility', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clientId: 'missing', visible: true }), + })) + + expect(res.status).toBe(404) + await expect(res.json()).resolves.toEqual({ success: false, error: 'Client not found' }) + }) + }) + + describe('GET /status', () => { + it('returns the merged connection status and counts', async () => { + sseMocks.getConnectionStatus.mockReturnValue({ connected: 1, total: 2 }) + sseMocks.getClientCount.mockReturnValue(4) + sseMocks.getActiveDirectories.mockReturnValue(['/a', '/b']) + sseMocks.getActiveSessions.mockReturnValue({ '/a': ['session-1'] }) + + const res = await app.fetch(new Request('http://localhost/status')) + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ + connected: 1, + total: 2, + clients: 4, + directories: ['/a', '/b'], + activeSessions: { '/a': ['session-1'] }, + }) + }) + }) +}) diff --git a/backend/test/routes/tts.test.ts b/backend/test/routes/tts.test.ts index 90e9e14ad..4faf8afd3 100644 --- a/backend/test/routes/tts.test.ts +++ b/backend/test/routes/tts.test.ts @@ -17,8 +17,29 @@ vi.mock('../../src/utils/fs-safe', () => ({ vi.mock('bun:sqlite', () => ({ Database: vi.fn(), })) + +const { mockGetSettings, mockUpdateSettings } = vi.hoisted(() => ({ + mockGetSettings: vi.fn(), + mockUpdateSettings: vi.fn(), +})) + vi.mock('../../src/services/settings', () => ({ - SettingsService: vi.fn(), + SettingsService: vi.fn().mockImplementation(() => ({ + getSettings: mockGetSettings, + updateSettings: mockUpdateSettings, + })), +})) + +const { mockNormalizeToBaseUrl, mockDiscoverModelsCached, mockDiscoverCached } = vi.hoisted(() => ({ + mockNormalizeToBaseUrl: vi.fn((url: string) => url.replace(/\/+$/, '')), + mockDiscoverModelsCached: vi.fn(), + mockDiscoverCached: vi.fn(), +})) + +vi.mock('../../src/utils/discovery-cache', () => ({ + normalizeToBaseUrl: mockNormalizeToBaseUrl, + discoverModelsCached: mockDiscoverModelsCached, + discoverCached: mockDiscoverCached, })) vi.mock('../../src/utils/logger', () => ({ logger: { @@ -33,7 +54,22 @@ const mockReaddir = fs.readdir as any const mockStat = fs.stat as any const mockUnlink = fs.unlink as any -import { createTTSRoutes, cleanupExpiredCache, getCacheStats, generateCacheKey, ensureCacheDir, getCachedAudio, getCacheSize, cleanupOldestFiles } from '../../src/routes/tts' +import { createTTSRoutes, cleanupExpiredCache, getCacheStats, generateCacheKey, ensureCacheDir, getCachedAudio, getCacheSize, cleanupOldestFiles, cacheAudio } from '../../src/routes/tts' + +const mockWriteFile = fs.writeFile as any + +function createTtsConfig(overrides: Record = {}) { + return { + enabled: true, + apiKey: 'test-api-key', + endpoint: 'https://tts.example.com', + voice: 'alloy', + model: 'tts-1', + speed: 1, + availableVoices: [], + ...overrides, + } +} describe('TTS Routes', () => { let mockDb: any @@ -43,6 +79,13 @@ describe('TTS Routes', () => { mockDb = {} as any createTTSRoutes(mockDb) + mockGetSettings.mockReturnValue({ preferences: { tts: createTtsConfig() } }) + mockUpdateSettings.mockReturnValue(undefined) + mockDiscoverModelsCached.mockResolvedValue({ models: ['tts-1'], cached: false }) + mockDiscoverCached.mockImplementation(async (options: { fetcher: () => Promise }) => ({ + value: await options.fetcher(), + cached: false, + })) }) describe('generateCacheKey', () => { @@ -195,4 +238,252 @@ describe('getCachedAudio', () => { ) }) }) -}) \ No newline at end of file +}) +describe('TTS route handlers', () => { + let app: ReturnType + let mockDb: any + + beforeEach(() => { + vi.clearAllMocks() + mockDb = {} as any + app = createTTSRoutes(mockDb) + mockGetSettings.mockReturnValue({ preferences: { tts: createTtsConfig() } }) + mockUpdateSettings.mockReturnValue(undefined) + mockDiscoverModelsCached.mockResolvedValue({ models: ['tts-1'], cached: false }) + mockDiscoverCached.mockImplementation(async (options: { fetcher: () => Promise }) => ({ + value: await options.fetcher(), + cached: false, + })) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('returns 400 when TTS is not enabled', async () => { + mockGetSettings.mockReturnValue({ preferences: { tts: createTtsConfig({ enabled: false }) } }) + + const res = await app.request('/synthesize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: 'Hello' }), + }) + + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ error: 'TTS is not enabled' }) + }) + + it('returns 400 when the TTS API key is missing', async () => { + mockGetSettings.mockReturnValue({ preferences: { tts: createTtsConfig({ apiKey: '' }) } }) + + const res = await app.request('/synthesize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: 'Hello' }), + }) + + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ error: 'TTS API key is not configured' }) + }) + + it('returns 400 for an invalid synthesize body', async () => { + const res = await app.request('/synthesize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: '' }), + }) + + expect(res.status).toBe(400) + const body = await res.json() as { error: string; details: unknown[] } + expect(body.error).toBe('Invalid request') + expect(body.details.length).toBeGreaterThan(0) + }) + + it('serves cached audio with a cache hit header', async () => { + mockStat.mockResolvedValue({ mtimeMs: Date.now(), size: 5 } as any) + mockReadFile.mockResolvedValue(Buffer.from('cached')) + + const res = await app.request('/synthesize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: 'Hello' }), + }) + + expect(res.status).toBe(200) + expect(res.headers.get('X-Cache')).toBe('HIT') + expect(Buffer.from(await res.arrayBuffer()).toString()).toBe('cached') + }) + + it('synthesizes and caches audio on a cache miss', async () => { + mockStat.mockRejectedValue(new Error('not found')) + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + arrayBuffer: async () => Buffer.from('fresh-audio'), + }) + vi.stubGlobal('fetch', fetchMock) + + const res = await app.request('/synthesize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: 'Hello' }), + }) + + expect(res.status).toBe(200) + expect(res.headers.get('X-Cache')).toBe('MISS') + expect(Buffer.from(await res.arrayBuffer()).toString()).toBe('fresh-audio') + expect(fetchMock).toHaveBeenCalledWith( + 'https://tts.example.com/v1/audio/speech', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ Authorization: 'Bearer test-api-key' }), + }), + ) + expect(mockWriteFile).toHaveBeenCalledWith( + expect.stringContaining('.mp3'), + expect.any(Buffer), + ) + }) + + it('returns the upstream error details when synthesis fails', async () => { + mockStat.mockRejectedValue(new Error('not found')) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 400, + text: async () => JSON.stringify({ detail: { error: { message: 'Voice not supported' } } }), + })) + + const res = await app.request('/synthesize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: 'Hello' }), + }) + + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ + error: 'TTS API request failed', + details: 'Voice not supported', + voice: 'alloy', + availableVoices: [], + }) + }) + + it('lists models and stores them when not cached', async () => { + mockDiscoverModelsCached.mockResolvedValue({ models: ['tts-1', 'tts-1-hd'], cached: false }) + + const res = await app.request('/models') + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ models: ['tts-1', 'tts-1-hd'], cached: false }) + expect(mockUpdateSettings).toHaveBeenCalledWith( + expect.objectContaining({ tts: expect.objectContaining({ availableModels: ['tts-1', 'tts-1-hd'] }) }), + 'default', + ) + }) + + it('returns 400 when models are requested without configuration', async () => { + mockGetSettings.mockReturnValue({ preferences: { tts: createTtsConfig({ endpoint: '' }) } }) + + const res = await app.request('/models') + + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ error: 'TTS not configured' }) + }) + + it('lists voices from the OpenAI data response format', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [{ id: 'alloy' }, { name: 'echo' }] }), + })) + + const res = await app.request('/voices') + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ voices: ['alloy', 'echo'], cached: false }) + expect(mockUpdateSettings).toHaveBeenCalledWith( + expect.objectContaining({ tts: expect.objectContaining({ availableVoices: ['alloy', 'echo'] }) }), + 'default', + ) + }) + + it('lists voices from the simple array response format', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ['alloy', { voice: 'echo' }], + })) + + const res = await app.request('/voices') + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ voices: ['alloy', 'echo'], cached: false }) + }) + + it('falls back to the default voices when every endpoint fails', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down'))) + + const res = await app.request('/voices') + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + voices: ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'], + cached: false, + }) + }) + + it('returns 400 when voices are requested without configuration', async () => { + mockGetSettings.mockReturnValue({ preferences: { tts: createTtsConfig({ endpoint: '' }) } }) + + const res = await app.request('/voices') + + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ error: 'TTS not configured' }) + }) + + it('returns the cache status', async () => { + mockReaddir.mockResolvedValue(['cached.mp3'] as any) + mockStat.mockResolvedValue({ size: 1024, mtimeMs: Date.now() } as any) + + const res = await app.request('/status') + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + enabled: true, + configured: true, + cache: { count: 1, sizeBytes: 1024, sizeMB: 0, maxSizeMB: 200, ttlHours: 24 }, + }) + }) +}) + +describe('cacheAudio', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('writes audio and frees space when the cache limit would be exceeded', async () => { + mockReaddir.mockResolvedValue(['big.mp3'] as any) + mockStat.mockResolvedValue({ size: 200 * 1024 * 1024, mtimeMs: 1000 } as any) + mockUnlink.mockResolvedValue(undefined) + mockWriteFile.mockResolvedValue(undefined) + + await cacheAudio('cache-key', Buffer.from('audio')) + + expect(mockUnlink).toHaveBeenCalledWith(expect.stringContaining('big.mp3')) + expect(mockWriteFile).toHaveBeenCalledWith( + expect.stringContaining('cache-key.mp3'), + expect.any(Buffer), + ) + }) + + it('writes audio without cleanup when the cache has room', async () => { + mockReaddir.mockResolvedValue(['small.mp3'] as any) + mockStat.mockResolvedValue({ size: 1024, mtimeMs: 1000 } as any) + mockWriteFile.mockResolvedValue(undefined) + + await cacheAudio('cache-key', Buffer.from('audio')) + + expect(mockUnlink).not.toHaveBeenCalled() + expect(mockWriteFile).toHaveBeenCalledWith( + expect.stringContaining('cache-key.mp3'), + expect.any(Buffer), + ) + }) +}) diff --git a/backend/test/scripts/askpass-main.test.ts b/backend/test/scripts/askpass-main.test.ts new file mode 100644 index 000000000..98389ab3c --- /dev/null +++ b/backend/test/scripts/askpass-main.test.ts @@ -0,0 +1,357 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { spawn } from 'child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import * as http from 'http' +import { randomUUID } from 'crypto' +import { createIPCServer } from '../../src/ipc/ipcServer' +import type { IPCServer } from '../../src/ipc/ipcServer' +import { repoRoot } from '../helpers/repo-root' + +interface CapturedRequest { + askpassType: string + argv: string[] + cwd?: string + repoId?: number +} + +const scriptPath = join(repoRoot, 'backend/scripts/askpass-main.ts') + +let dir: string +let outputPath: string + +const servers: IPCServer[] = [] +const rawServers: http.Server[] = [] + +function buildEnv(overrides: Record): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env } + delete env['VSCODE_GIT_ASKPASS_PIPE'] + delete env['VSCODE_GIT_ASKPASS_TYPE'] + delete env['VSCODE_GIT_IPC_HANDLE'] + delete env['OCM_GIT_REPO_ID'] + delete env['OCM_GIT_REPO_CWD'] + for (const [key, value] of Object.entries(overrides)) { + if (value === undefined) { + delete env[key] + } else { + env[key] = value + } + } + return env +} + +interface AskpassRun { + status: number | null + stdout: string + stderr: string +} + +function runAskpass(env: NodeJS.ProcessEnv): Promise { + return new Promise((resolve, reject) => { + const child = spawn('bun', [scriptPath], { env }) + const stdout: Buffer[] = [] + const stderr: Buffer[] = [] + child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk)) + child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)) + child.on('error', reject) + child.on('close', (status) => { + resolve({ + status, + stdout: Buffer.concat(stdout).toString('utf8'), + stderr: Buffer.concat(stderr).toString('utf8'), + }) + }) + }) +} + +function readOutput(): string { + return readFileSync(outputPath, 'utf-8') +} + +async function startIPCServer(): Promise { + const server = await createIPCServer(randomUUID()) + servers.push(server) + return server +} + +async function startRawServer(socketPath: string, body: string): Promise { + const server = http.createServer((_req, res) => { + res.writeHead(200) + res.end(body) + }) + rawServers.push(server) + await new Promise((resolve) => server.listen(socketPath, () => resolve())) +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'ocm-askpass-')) + outputPath = join(dir, 'askpass.out') +}) + +afterEach(async () => { + for (const server of servers.splice(0)) { + await server.dispose() + } + for (const server of rawServers.splice(0)) { + await new Promise((resolve) => server.close(() => resolve())) + } + rmSync(dir, { recursive: true, force: true }) +}) + +describe('askpass-main', () => { + it('exits 1 when the askpass pipe is missing', async () => { + const result = await runAskpass(buildEnv({})) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('Missing pipe') + }) + + it('exits 1 when the askpass type is missing', async () => { + const result = await runAskpass(buildEnv({ VSCODE_GIT_ASKPASS_PIPE: outputPath })) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('Missing type') + }) + + it('exits 1 for an invalid askpass type', async () => { + const result = await runAskpass(buildEnv({ VSCODE_GIT_ASKPASS_PIPE: outputPath, VSCODE_GIT_ASKPASS_TYPE: 'ftp' })) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('Invalid type: ftp') + }) + + it('writes a newline and exits 0 when no IPC handle is configured', async () => { + const result = await runAskpass(buildEnv({ VSCODE_GIT_ASKPASS_PIPE: outputPath, VSCODE_GIT_ASKPASS_TYPE: 'https' })) + + expect(result.status).toBe(0) + expect(readOutput()).toBe('\n') + }) + + it('writes the handler result and exits 0 on IPC success', async () => { + const server = await startIPCServer() + let received: CapturedRequest | undefined + server.registerHandler('askpass', { + handle: async (request) => { + received = request as CapturedRequest + return 'secret-token' + }, + }) + + const result = await runAskpass(buildEnv({ + VSCODE_GIT_ASKPASS_PIPE: outputPath, + VSCODE_GIT_ASKPASS_TYPE: 'ssh', + VSCODE_GIT_IPC_HANDLE: server.ipcHandlePath, + OCM_GIT_REPO_ID: '42', + OCM_GIT_REPO_CWD: '/tmp/repo', + })) + + expect(result.status).toBe(0) + expect(readOutput()).toBe('secret-token\n') + expect(received?.askpassType).toBe('ssh') + expect(received?.cwd).toBe('/tmp/repo') + expect(received?.repoId).toBe(42) + expect(received?.argv[1]).toBe(scriptPath) + }) + + it('writes a newline and exits 0 when the handler returns an empty body', async () => { + const server = await startIPCServer() + server.registerHandler('askpass', { handle: async () => undefined }) + + const result = await runAskpass(buildEnv({ + VSCODE_GIT_ASKPASS_PIPE: outputPath, + VSCODE_GIT_ASKPASS_TYPE: 'https', + VSCODE_GIT_IPC_HANDLE: server.ipcHandlePath, + })) + + expect(result.status).toBe(0) + expect(readOutput()).toBe('\n') + }) + + it('writes a newline and exits 1 when the IPC server returns a non-200 status', async () => { + const server = await startIPCServer() + + const result = await runAskpass(buildEnv({ + VSCODE_GIT_ASKPASS_PIPE: outputPath, + VSCODE_GIT_ASKPASS_TYPE: 'https', + VSCODE_GIT_IPC_HANDLE: server.ipcHandlePath, + })) + + expect(result.status).toBe(1) + expect(readOutput()).toBe('\n') + expect(result.stderr).toContain('IPC error response') + }) + + it('writes a newline and exits 1 when the response body is not JSON', async () => { + const socketPath = join(dir, 'raw.sock') + await startRawServer(socketPath, 'not-json') + + const result = await runAskpass(buildEnv({ + VSCODE_GIT_ASKPASS_PIPE: outputPath, + VSCODE_GIT_ASKPASS_TYPE: 'https', + VSCODE_GIT_IPC_HANDLE: socketPath, + })) + + expect(result.status).toBe(1) + expect(readOutput()).toBe('\n') + expect(result.stderr).toContain('JSON parse error') + }) + + it('writes a newline and exits 1 when the IPC connection fails', async () => { + const result = await runAskpass(buildEnv({ + VSCODE_GIT_ASKPASS_PIPE: outputPath, + VSCODE_GIT_ASKPASS_TYPE: 'https', + VSCODE_GIT_IPC_HANDLE: join(dir, 'missing.sock'), + })) + + expect(result.status).toBe(1) + expect(readOutput()).toBe('\n') + expect(result.stderr).toContain('IPC request error') + }) +}) + +const askpassEnvKeys = [ + 'VSCODE_GIT_ASKPASS_PIPE', + 'VSCODE_GIT_ASKPASS_TYPE', + 'VSCODE_GIT_IPC_HANDLE', + 'OCM_GIT_REPO_ID', + 'OCM_GIT_REPO_CWD', +] as const + +async function runAskpassInProcess(overrides: Record): Promise<{ code: number | undefined; output: string | undefined }> { + const nextEnv = buildEnv(overrides) + const previousEnv: Record = {} + for (const key of askpassEnvKeys) { + previousEnv[key] = process.env[key] + const next = nextEnv[key] + if (next === undefined) { + delete process.env[key] + } else { + process.env[key] = next + } + } + + let resolveExit: (code: number | undefined) => void = () => {} + const exitPromise = new Promise((resolve) => { + resolveExit = resolve + }) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + resolveExit(code) + return undefined as never + }) as typeof process.exit) + + vi.resetModules() + try { + await import('../../scripts/askpass-main') + const code = await exitPromise + return { code, output: existsSync(outputPath) ? readFileSync(outputPath, 'utf-8') : undefined } + } finally { + exitSpy.mockRestore() + errorSpy.mockRestore() + for (const key of askpassEnvKeys) { + const previous = previousEnv[key] + if (previous === undefined) { + delete process.env[key] + } else { + process.env[key] = previous + } + } + } +} + +describe('askpass-main in-process', () => { + it('exits 1 when the askpass pipe is missing', async () => { + const result = await runAskpassInProcess({}) + + expect(result.code).toBe(1) + expect(result.output).toBeUndefined() + }) + + it('exits 1 when the askpass type is missing', async () => { + const result = await runAskpassInProcess({ VSCODE_GIT_ASKPASS_PIPE: outputPath }) + + expect(result.code).toBe(1) + expect(result.output).toBeUndefined() + }) + + it('exits 1 for an invalid askpass type', async () => { + const result = await runAskpassInProcess({ VSCODE_GIT_ASKPASS_PIPE: outputPath, VSCODE_GIT_ASKPASS_TYPE: 'ftp' }) + + expect(result.code).toBe(1) + expect(result.output).toBeUndefined() + }) + + it('writes a newline and exits 0 when no IPC handle is configured', async () => { + const result = await runAskpassInProcess({ VSCODE_GIT_ASKPASS_PIPE: outputPath, VSCODE_GIT_ASKPASS_TYPE: 'https' }) + + expect(result.code).toBe(0) + expect(result.output).toBe('\n') + }) + + it('writes the handler result and exits 0 on IPC success', async () => { + const server = await startIPCServer() + server.registerHandler('askpass', { handle: async () => 'in-process-token' }) + + const result = await runAskpassInProcess({ + VSCODE_GIT_ASKPASS_PIPE: outputPath, + VSCODE_GIT_ASKPASS_TYPE: 'https', + VSCODE_GIT_IPC_HANDLE: server.ipcHandlePath, + }) + + expect(result.code).toBe(0) + expect(result.output).toBe('in-process-token\n') + }) + + it('writes a newline and exits 0 when the handler returns an empty body', async () => { + const server = await startIPCServer() + server.registerHandler('askpass', { handle: async () => undefined }) + + const result = await runAskpassInProcess({ + VSCODE_GIT_ASKPASS_PIPE: outputPath, + VSCODE_GIT_ASKPASS_TYPE: 'https', + VSCODE_GIT_IPC_HANDLE: server.ipcHandlePath, + }) + + expect(result.code).toBe(0) + expect(result.output).toBe('\n') + }) + + it('writes a newline and exits 1 when the IPC server returns a non-200 status', async () => { + const server = await startIPCServer() + + const result = await runAskpassInProcess({ + VSCODE_GIT_ASKPASS_PIPE: outputPath, + VSCODE_GIT_ASKPASS_TYPE: 'https', + VSCODE_GIT_IPC_HANDLE: server.ipcHandlePath, + }) + + expect(result.code).toBe(1) + expect(result.output).toBe('\n') + }) + + it('writes a newline and exits 1 when the response body is not JSON', async () => { + const socketPath = join(dir, 'raw-in-process.sock') + await startRawServer(socketPath, 'not-json') + + const result = await runAskpassInProcess({ + VSCODE_GIT_ASKPASS_PIPE: outputPath, + VSCODE_GIT_ASKPASS_TYPE: 'https', + VSCODE_GIT_IPC_HANDLE: socketPath, + }) + + expect(result.code).toBe(1) + expect(result.output).toBe('\n') + }) + + it('writes a newline and exits 1 when the IPC connection fails', async () => { + const result = await runAskpassInProcess({ + VSCODE_GIT_ASKPASS_PIPE: outputPath, + VSCODE_GIT_ASKPASS_TYPE: 'https', + VSCODE_GIT_IPC_HANDLE: join(dir, 'missing-in-process.sock'), + }) + + expect(result.code).toBe(1) + expect(result.output).toBe('\n') + }) +}) diff --git a/backend/test/services/archive.test.ts b/backend/test/services/archive.test.ts new file mode 100644 index 000000000..1c3519be7 --- /dev/null +++ b/backend/test/services/archive.test.ts @@ -0,0 +1,247 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest' +import { execFileSync } from 'child_process' +import { mkdtempSync, mkdirSync, writeFileSync, existsSync } from 'fs' +import { rm } from 'fs/promises' +import { tmpdir } from 'os' +import path from 'path' +import type { ReadStream } from 'fs' +import { + createRepoArchive, + createDirectoryArchive, + deleteArchive, + getArchiveStream, + getArchiveSize, + getIgnoredPathsList, +} from '../../src/services/archive' +import { getReposPath } from '@opencode-manager/shared/config/env' + +vi.mock('../../src/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})) + +function runGit(cwd: string, args: string[]): void { + execFileSync('git', args, { cwd, stdio: 'ignore' }) +} + +function listZipEntries(zipPath: string): string[] { + return execFileSync('unzip', ['-Z1', zipPath], { encoding: 'utf-8' }) + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) +} + +async function readStreamToBuffer(stream: ReadStream): Promise { + const chunks: Buffer[] = [] + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string)) + } + return Buffer.concat(chunks) +} + +describe('archive service', () => { + let tmpRoot: string + let gitRepoPath: string + let plainDirPath: string + let fakeGitDirPath: string + const zipPaths: string[] = [] + const createdDirs: string[] = [] + + beforeAll(() => { + tmpRoot = mkdtempSync(path.join(tmpdir(), 'archive-service-test-')) + + gitRepoPath = path.join(tmpRoot, 'git-repo') + mkdirSync(path.join(gitRepoPath, 'src'), { recursive: true }) + mkdirSync(path.join(gitRepoPath, 'ignored-dir'), { recursive: true }) + writeFileSync(path.join(gitRepoPath, '.gitignore'), 'ignored.txt\nignored-dir/\n') + writeFileSync(path.join(gitRepoPath, 'tracked.txt'), 'tracked') + writeFileSync(path.join(gitRepoPath, 'src', 'index.ts'), 'export const value = 1\n') + writeFileSync(path.join(gitRepoPath, 'ignored.txt'), 'ignored') + writeFileSync(path.join(gitRepoPath, 'ignored-dir', 'nested.txt'), 'nested') + runGit(gitRepoPath, ['init']) + runGit(gitRepoPath, ['config', 'user.email', 'test@example.com']) + runGit(gitRepoPath, ['config', 'user.name', 'Test']) + runGit(gitRepoPath, ['add', '.']) + runGit(gitRepoPath, ['-c', 'commit.gpgsign=false', 'commit', '-m', 'initial']) + + plainDirPath = path.join(tmpRoot, 'plain-dir') + mkdirSync(plainDirPath, { recursive: true }) + writeFileSync(path.join(plainDirPath, 'file.txt'), 'plain') + + fakeGitDirPath = path.join(tmpRoot, 'fake-git-dir') + mkdirSync(path.join(fakeGitDirPath, '.git'), { recursive: true }) + writeFileSync(path.join(fakeGitDirPath, '.git', 'HEAD'), 'ref: refs/heads/main\n') + writeFileSync(path.join(fakeGitDirPath, 'file.txt'), 'fake') + }) + + afterEach(async () => { + while (zipPaths.length > 0) { + const zipPath = zipPaths.pop() + if (zipPath) await rm(zipPath, { force: true }) + } + while (createdDirs.length > 0) { + const dirPath = createdDirs.pop() + if (dirPath) await rm(dirPath, { recursive: true, force: true }) + } + }) + + afterAll(async () => { + await rm(tmpRoot, { recursive: true, force: true }) + }) + + describe('createRepoArchive', () => { + it('creates a zip excluding .git and gitignored paths by default', async () => { + const zipPath = await createRepoArchive(gitRepoPath) + zipPaths.push(zipPath) + + expect(existsSync(zipPath)).toBe(true) + expect(path.dirname(zipPath)).toBe(tmpdir()) + expect(path.basename(zipPath)).toMatch(/^git-repo-\d+\.zip$/) + + const entries = listZipEntries(zipPath) + expect(entries).toContain('git-repo/tracked.txt') + expect(entries).toContain('git-repo/src/index.ts') + expect(entries).toContain('git-repo/.gitignore') + expect(entries.some((entry) => entry.startsWith('git-repo/.git/'))).toBe(false) + expect(entries).not.toContain('git-repo/ignored.txt') + expect(entries).not.toContain('git-repo/ignored-dir/nested.txt') + }) + + it('includes .git contents when includeGit is true', async () => { + const zipPath = await createRepoArchive(gitRepoPath, { includeGit: true }) + zipPaths.push(zipPath) + + const entries = listZipEntries(zipPath) + expect(entries).toContain('git-repo/.git/HEAD') + expect(entries.some((entry) => entry.startsWith('git-repo/.git/'))).toBe(true) + }) + + it('includes an ignored file requested through includePaths', async () => { + const zipPath = await createRepoArchive(gitRepoPath, { includePaths: ['ignored.txt'] }) + zipPaths.push(zipPath) + + const entries = listZipEntries(zipPath) + expect(entries).toContain('git-repo/ignored.txt') + expect(entries).not.toContain('git-repo/ignored-dir/nested.txt') + }) + + it('includes a nested ignored file requested through includePaths', async () => { + const zipPath = await createRepoArchive(gitRepoPath, { + includePaths: ['ignored-dir/nested.txt'], + }) + zipPaths.push(zipPath) + + const entries = listZipEntries(zipPath) + expect(entries).toContain('git-repo/ignored-dir/nested.txt') + expect(entries).not.toContain('git-repo/ignored.txt') + }) + }) + + describe('createDirectoryArchive', () => { + it('uses the directory basename as the archive name', async () => { + const zipPath = await createDirectoryArchive(plainDirPath) + zipPaths.push(zipPath) + + expect(path.dirname(zipPath)).toBe(tmpdir()) + expect(path.basename(zipPath)).toMatch(/^plain-dir-\d+\.zip$/) + expect(listZipEntries(zipPath)).toContain('plain-dir/file.txt') + }) + + it('uses a custom archive name when provided', async () => { + const zipPath = await createDirectoryArchive(plainDirPath, 'custom-name') + zipPaths.push(zipPath) + + expect(path.basename(zipPath)).toMatch(/^custom-name-\d+\.zip$/) + expect(listZipEntries(zipPath)).toContain('custom-name/file.txt') + }) + + it('resolves a relative directory path against the repos directory', async () => { + const relativeName = `archive-relative-${Date.now()}` + const dirPath = path.join(getReposPath(), relativeName) + mkdirSync(dirPath, { recursive: true }) + writeFileSync(path.join(dirPath, 'relative.txt'), 'relative') + createdDirs.push(dirPath) + + const zipPath = await createDirectoryArchive(relativeName) + zipPaths.push(zipPath) + + expect(path.basename(zipPath)).toMatch(new RegExp(`^${relativeName}-\\d+\\.zip$`)) + expect(listZipEntries(zipPath)).toContain(`${relativeName}/relative.txt`) + }) + }) + + describe('deleteArchive', () => { + it('removes an existing archive', async () => { + const zipPath = await createDirectoryArchive(plainDirPath, 'delete-me') + expect(existsSync(zipPath)).toBe(true) + + await deleteArchive(zipPath) + + expect(existsSync(zipPath)).toBe(false) + }) + + it('resolves when the archive does not exist', async () => { + const missingPath = path.join(tmpdir(), `missing-archive-${Date.now()}.zip`) + + await expect(deleteArchive(missingPath)).resolves.toBeUndefined() + }) + }) + + describe('getArchiveStream and getArchiveSize', () => { + it('streams the archive to completion and reports its size', async () => { + const zipPath = await createDirectoryArchive(plainDirPath, 'stream-test') + zipPaths.push(zipPath) + + const size = await getArchiveSize(zipPath) + const buffer = await readStreamToBuffer(getArchiveStream(zipPath)) + + expect(size).toBeGreaterThan(0) + expect(buffer.length).toBe(size) + expect(buffer.subarray(0, 2).toString('utf-8')).toBe('PK') + }) + + it('rejects getArchiveSize for a missing file', async () => { + const missingPath = path.join(tmpdir(), `missing-size-${Date.now()}.zip`) + + await expect(getArchiveSize(missingPath)).rejects.toThrow() + }) + }) + + describe('getIgnoredPathsList', () => { + it('returns ignored directories and .git for a git repository', async () => { + const result = await getIgnoredPathsList(gitRepoPath) + + expect(result).toEqual(['.git/', 'ignored-dir/', 'ignored.txt/']) + }) + + it('returns [] for a non-git directory containing a .git folder', async () => { + expect(existsSync(path.join(fakeGitDirPath, '.git'))).toBe(true) + + const result = await getIgnoredPathsList(fakeGitDirPath) + + expect(result).toEqual([]) + }) + + it('returns [] for a plain directory without git metadata', async () => { + const result = await getIgnoredPathsList(plainDirPath) + + expect(result).toEqual([]) + }) + + it('resolves a relative directory path against the repos directory', async () => { + const relativeName = `archive-ignored-${Date.now()}` + const dirPath = path.join(getReposPath(), relativeName) + mkdirSync(dirPath, { recursive: true }) + writeFileSync(path.join(dirPath, 'file.txt'), 'relative') + createdDirs.push(dirPath) + + const result = await getIgnoredPathsList(relativeName) + + expect(result).toEqual([]) + }) + }) +}) diff --git a/backend/test/services/assistant-mode.test.ts b/backend/test/services/assistant-mode.test.ts index c44b487cc..1a0adc824 100644 --- a/backend/test/services/assistant-mode.test.ts +++ b/backend/test/services/assistant-mode.test.ts @@ -53,6 +53,11 @@ describe('buildReposSkill', () => { const skill = buildReposSkill() expect(skill).not.toContain('localhost') }) + + it('does not document the removed openCodeConfigName field', () => { + const skill = buildReposSkill() + expect(skill).not.toContain('openCodeConfigName') + }) }) describe('buildSettingsSkill', () => { @@ -83,6 +88,15 @@ describe('buildSettingsSkill', () => { expect(skill).toContain('5 requests per minute') }) + it('documents the OpenCode configuration endpoints', () => { + const skill = buildSettingsSkill() + expect(skill).toContain('## OpenCode Configuration') + expect(skill).toContain('GET /opencode-config') + expect(skill).toContain('PUT /opencode-config') + expect(skill).toContain('restartRequired') + expect(skill).toContain('Never attempt the restart yourself') + }) + it('still lists apiKey and endpoint as forbidden', () => { const skill = buildSettingsSkill() expect(skill).toContain('tts.apiKey') @@ -225,6 +239,8 @@ describe('ensureAssistantMode', () => { const settingsSkillContent = await readFile(settingsSkillPath, 'utf8') expect(settingsSkillContent).toContain('name: manager-settings') expect(settingsSkillContent).toContain('Read and modify') + expect(settingsSkillContent).toContain('/opencode-config') + expect(settingsSkillContent).toContain('restartRequired') const reposSkillContent = await readFile(reposSkillPath, 'utf8') expect(reposSkillContent).toContain('name: repo-management') diff --git a/backend/test/services/files.test.ts b/backend/test/services/files.test.ts new file mode 100644 index 000000000..1df66f6ad --- /dev/null +++ b/backend/test/services/files.test.ts @@ -0,0 +1,419 @@ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest' +import { mkdir, mkdtemp, readFile, rm, truncate, writeFile } from 'fs/promises' +import path from 'path' +import { FILE_LIMITS, getReposPath } from '@opencode-manager/shared/config/env' +import { + applyFilePatches, + createFileOrFolder, + deleteFileOrFolder, + getFile, + getFileRange, + getRawFileContent, + renameOrMoveFile, + uploadFile, +} from '../../src/services/files' + +const reposPath = getReposPath() + +let tempRoot: string +let relativeRoot: string + +function resolveTempPath(relativePath: string): string { + return path.join(reposPath, relativePath) +} + +async function writeLines(relativePath: string, content: string): Promise { + const absolutePath = resolveTempPath(relativePath) + await mkdir(path.dirname(absolutePath), { recursive: true }) + await writeFile(absolutePath, content, 'utf8') + return absolutePath +} + +async function readLines(relativePath: string): Promise { + return readFile(resolveTempPath(relativePath), 'utf8') +} + +beforeAll(async () => { + await mkdir(reposPath, { recursive: true }) +}) + +beforeEach(async () => { + tempRoot = await mkdtemp(path.join(reposPath, 'files-test-')) + relativeRoot = path.basename(tempRoot) +}) + +afterEach(async () => { + await rm(tempRoot, { recursive: true, force: true }) +}) + +describe('files service', () => { + describe('getRawFileContent', () => { + it('returns the raw buffer for an existing file', async () => { + await writeLines(`${relativeRoot}/raw.txt`, 'raw content') + + const buffer = await getRawFileContent(`${relativeRoot}/raw.txt`) + + expect(Buffer.isBuffer(buffer)).toBe(true) + expect(buffer.toString('utf8')).toBe('raw content') + }) + + it('rejects with 404 when the file is missing', async () => { + await expect(getRawFileContent(`${relativeRoot}/missing.txt`)).rejects.toEqual({ + message: 'File not found or cannot be read', + statusCode: 404, + }) + }) + + it('rejects with 404 when the path is a directory', async () => { + await mkdir(resolveTempPath(`${relativeRoot}/dir`)) + + await expect(getRawFileContent(`${relativeRoot}/dir`)).rejects.toEqual({ + message: 'File not found or cannot be read', + statusCode: 404, + }) + }) + }) + + describe('getFile', () => { + it('returns base64 content for a text file', async () => { + await writeLines(`${relativeRoot}/note.txt`, 'hello world') + + const result = await getFile(`${relativeRoot}/note.txt`) + + expect(result.isDirectory).toBe(false) + expect(result.name).toBe('note.txt') + expect(result.path).toBe(`${relativeRoot}/note.txt`) + expect(result.mimeType).toBe('text/plain') + expect(result.size).toBe(11) + expect(Buffer.from(result.content ?? '', 'base64').toString('utf8')).toBe('hello world') + }) + + it('returns base64 content for an image file', async () => { + const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]) + await writeFile(resolveTempPath(`${relativeRoot}/image.png`), bytes) + + const result = await getFile(`${relativeRoot}/image.png`) + + expect(result.mimeType).toBe('image/png') + expect(result.content).toBe(bytes.toString('base64')) + }) + + it('returns base64 content for an unknown text extension', async () => { + await writeLines(`${relativeRoot}/data.unknownext`, 'mystery') + + const result = await getFile(`${relativeRoot}/data.unknownext`) + + expect(result.mimeType).toBe('text/plain') + expect(Buffer.from(result.content ?? '', 'base64').toString('utf8')).toBe('mystery') + }) + + it('omits content for files at the size limit', async () => { + const filePath = await writeLines(`${relativeRoot}/large.txt`, '') + await truncate(filePath, FILE_LIMITS.MAX_SIZE_BYTES) + + const result = await getFile(`${relativeRoot}/large.txt`) + + expect(result.size).toBe(FILE_LIMITS.MAX_SIZE_BYTES) + expect(result.content).toBe('') + }) + + it('lists directories first then files sorted by name', async () => { + await mkdir(resolveTempPath(`${relativeRoot}/zdir`)) + await mkdir(resolveTempPath(`${relativeRoot}/adir`)) + await writeLines(`${relativeRoot}/b.txt`, 'b') + await writeLines(`${relativeRoot}/a.txt`, 'a') + + const result = await getFile(relativeRoot) + + expect(result.isDirectory).toBe(true) + expect(result.path).toBe(relativeRoot) + expect(result.workspaceRoot).toBe(reposPath) + expect(result.children?.map((child) => child.name)).toEqual(['adir', 'zdir', 'a.txt', 'b.txt']) + expect(result.children?.map((child) => child.isDirectory)).toEqual([true, true, false, false]) + }) + + it('rejects with 404 when the path is missing', async () => { + await expect(getFile(`${relativeRoot}/missing.txt`)).rejects.toEqual({ + message: 'File or directory not found', + statusCode: 404, + }) + }) + }) + + describe('uploadFile', () => { + it('writes a file and returns its metadata', async () => { + const file = new File(['hello'], 'hello.txt', { type: 'text/plain' }) + + const result = await uploadFile(relativeRoot, file) + + expect(result).toEqual({ + name: 'hello.txt', + path: path.join(relativeRoot, 'hello.txt'), + size: 5, + mimeType: 'text/plain', + }) + expect(await readLines(`${relativeRoot}/hello.txt`)).toBe('hello') + }) + + it('derives the mime type from the file name when type is empty', async () => { + const file = new File(['markdown'], 'notes.md') + + const result = await uploadFile(relativeRoot, file) + + expect(result.mimeType).toBe('text/markdown') + expect(await readLines(`${relativeRoot}/notes.md`)).toBe('markdown') + }) + + it('writes to a nested relative path', async () => { + const file = new File(['nested'], 'deep.txt', { type: 'text/plain' }) + + const result = await uploadFile(relativeRoot, file, 'nested/deep.txt') + + expect(result.path).toBe(path.join(relativeRoot, 'nested/deep.txt')) + expect(await readLines(`${relativeRoot}/nested/deep.txt`)).toBe('nested') + }) + + it('rejects files larger than the upload limit', async () => { + const file = { + name: 'oversized.txt', + type: 'text/plain', + size: FILE_LIMITS.MAX_UPLOAD_SIZE_BYTES + 1, + } as unknown as File + + await expect(uploadFile(relativeRoot, file)).rejects.toThrow('File too large') + }) + + it('rejects disallowed mime types', async () => { + const file = new File(['binary'], 'payload.exe', { type: 'application/octet-stream' }) + + await expect(uploadFile(relativeRoot, file)).rejects.toThrow('File type not allowed') + }) + + it('rejects a relative path that resolves elsewhere', async () => { + const file = new File(['escape'], 'escape.txt', { type: 'text/plain' }) + + await expect(uploadFile(`${relativeRoot} `, file)).rejects.toEqual({ + message: 'Invalid relative path', + statusCode: 400, + }) + }) + }) + + describe('createFileOrFolder', () => { + it('creates a folder', async () => { + const result = await createFileOrFolder(`${relativeRoot}/new-folder`, { type: 'folder' }) + + expect(result.isDirectory).toBe(true) + expect(result.name).toBe('new-folder') + expect(result.path).toBe(`${relativeRoot}/new-folder`) + expect((await getFile(`${relativeRoot}/new-folder`)).isDirectory).toBe(true) + }) + + it('creates a file with content', async () => { + const result = await createFileOrFolder(`${relativeRoot}/created.txt`, { + type: 'file', + content: 'created', + }) + + expect(result.isDirectory).toBe(false) + expect(result.size).toBe(7) + expect(await readLines(`${relativeRoot}/created.txt`)).toBe('created') + }) + + it('creates an empty file when no content is given', async () => { + const result = await createFileOrFolder(`${relativeRoot}/empty.txt`, { type: 'file' }) + + expect(result.size).toBe(0) + expect(await readLines(`${relativeRoot}/empty.txt`)).toBe('') + }) + }) + + describe('deleteFileOrFolder', () => { + it('deletes a file', async () => { + await writeLines(`${relativeRoot}/remove.txt`, 'remove me') + + await deleteFileOrFolder(`${relativeRoot}/remove.txt`) + + await expect(getFile(`${relativeRoot}/remove.txt`)).rejects.toEqual({ + message: 'File or directory not found', + statusCode: 404, + }) + }) + + it('deletes a directory recursively', async () => { + await writeLines(`${relativeRoot}/nested/remove.txt`, 'remove me') + + await deleteFileOrFolder(`${relativeRoot}/nested`) + + await expect(getFile(`${relativeRoot}/nested`)).rejects.toEqual({ + message: 'File or directory not found', + statusCode: 404, + }) + }) + }) + + describe('renameOrMoveFile', () => { + it('moves a file into a new parent directory', async () => { + await writeLines(`${relativeRoot}/old.txt`, 'moved') + + const result = await renameOrMoveFile(`${relativeRoot}/old.txt`, { + newPath: `${relativeRoot}/new-parent/new.txt`, + }) + + expect(result.name).toBe('new.txt') + expect(result.path).toBe(`${relativeRoot}/new-parent/new.txt`) + expect(result.isDirectory).toBe(false) + expect(await readLines(`${relativeRoot}/new-parent/new.txt`)).toBe('moved') + }) + }) + + describe('getFileRange', () => { + beforeEach(async () => { + await writeLines(`${relativeRoot}/lines.txt`, 'l1\nl2\nl3\nl4\nl5') + }) + + it('returns a middle range with hasMore', async () => { + const result = await getFileRange(`${relativeRoot}/lines.txt`, 1, 3) + + expect(result.lines).toEqual(['l2', 'l3']) + expect(result.totalLines).toBe(5) + expect(result.startLine).toBe(1) + expect(result.endLine).toBe(3) + expect(result.hasMore).toBe(true) + expect(result.isDirectory).toBe(false) + }) + + it('clamps the end line and reports no more lines', async () => { + const result = await getFileRange(`${relativeRoot}/lines.txt`, 2, 100) + + expect(result.lines).toEqual(['l3', 'l4', 'l5']) + expect(result.endLine).toBe(5) + expect(result.hasMore).toBe(false) + }) + + it('rejects with 404 when the file is missing', async () => { + await expect(getFileRange(`${relativeRoot}/missing.txt`, 0, 5)).rejects.toEqual({ + message: 'File does not exist', + statusCode: 404, + }) + }) + + it('rejects with 400 when the path is a directory', async () => { + await expect(getFileRange(relativeRoot, 0, 5)).rejects.toEqual({ + message: 'Path is a directory', + statusCode: 400, + }) + }) + }) + + describe('applyFilePatches', () => { + it('replaces a line range', async () => { + await writeLines(`${relativeRoot}/replace.txt`, 'a\nb\nc\nd') + + const result = await applyFilePatches(`${relativeRoot}/replace.txt`, [ + { type: 'replace', startLine: 1, endLine: 3, content: 'B' }, + ]) + + expect(result).toEqual({ success: true, totalLines: 3 }) + expect(await readLines(`${relativeRoot}/replace.txt`)).toBe('a\nB\nd') + }) + + it('replaces a single line when endLine is omitted', async () => { + await writeLines(`${relativeRoot}/replace-one.txt`, 'a\nb\nc') + + const result = await applyFilePatches(`${relativeRoot}/replace-one.txt`, [ + { type: 'replace', startLine: 1, content: 'X' }, + ]) + + expect(result).toEqual({ success: true, totalLines: 3 }) + expect(await readLines(`${relativeRoot}/replace-one.txt`)).toBe('a\nX\nc') + }) + + it('inserts content at a line', async () => { + await writeLines(`${relativeRoot}/insert.txt`, 'a\nb') + + const result = await applyFilePatches(`${relativeRoot}/insert.txt`, [ + { type: 'insert', startLine: 1, content: 'X\nY' }, + ]) + + expect(result).toEqual({ success: true, totalLines: 4 }) + expect(await readLines(`${relativeRoot}/insert.txt`)).toBe('a\nX\nY\nb') + }) + + it('inserts nothing when content is omitted', async () => { + await writeLines(`${relativeRoot}/insert-empty.txt`, 'a\nb') + + const result = await applyFilePatches(`${relativeRoot}/insert-empty.txt`, [ + { type: 'insert', startLine: 0 }, + ]) + + expect(result).toEqual({ success: true, totalLines: 2 }) + expect(await readLines(`${relativeRoot}/insert-empty.txt`)).toBe('a\nb') + }) + + it('deletes a line range', async () => { + await writeLines(`${relativeRoot}/delete.txt`, 'a\nb\nc\nd') + + const result = await applyFilePatches(`${relativeRoot}/delete.txt`, [ + { type: 'delete', startLine: 1, endLine: 3 }, + ]) + + expect(result).toEqual({ success: true, totalLines: 2 }) + expect(await readLines(`${relativeRoot}/delete.txt`)).toBe('a\nd') + }) + + it('deletes a single line when endLine is omitted', async () => { + await writeLines(`${relativeRoot}/delete-one.txt`, 'a\nb\nc') + + const result = await applyFilePatches(`${relativeRoot}/delete-one.txt`, [ + { type: 'delete', startLine: 1 }, + ]) + + expect(result).toEqual({ success: true, totalLines: 2 }) + expect(await readLines(`${relativeRoot}/delete-one.txt`)).toBe('a\nc') + }) + + it('rejects with 404 when the file is missing', async () => { + await expect( + applyFilePatches(`${relativeRoot}/missing.txt`, [ + { type: 'replace', startLine: 0, content: 'x' }, + ]), + ).rejects.toEqual({ + message: 'File does not exist', + statusCode: 404, + }) + }) + }) + + describe('path traversal protection', () => { + it('rejects traversal in getFile', async () => { + await expect(getFile('../../etc/passwd')).rejects.toEqual({ + message: 'Path traversal detected', + statusCode: 403, + }) + }) + + it('rejects traversal in getRawFileContent', async () => { + await expect(getRawFileContent('../../etc/passwd')).rejects.toEqual({ + message: 'Path traversal detected', + statusCode: 403, + }) + }) + + it('rejects traversal in createFileOrFolder', async () => { + await expect(createFileOrFolder('../../escape', { type: 'folder' })).rejects.toEqual({ + message: 'Path traversal detected', + statusCode: 403, + }) + }) + + it('rejects traversal in applyFilePatches', async () => { + await expect( + applyFilePatches('../../escape.txt', [{ type: 'replace', startLine: 0, content: 'x' }]), + ).rejects.toEqual({ + message: 'Path traversal detected', + statusCode: 403, + }) + }) + }) +}) diff --git a/backend/test/services/mcp-oauth-state.test.ts b/backend/test/services/mcp-oauth-state.test.ts new file mode 100644 index 000000000..09cca6a15 --- /dev/null +++ b/backend/test/services/mcp-oauth-state.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type { McpOAuthFlowState } from '../../src/services/mcp-oauth-state' + +function createFlow(overrides: Partial> = {}): Omit { + return { + serverName: 'test-server', + serverUrl: 'https://mcp.example.com', + codeVerifier: 'verifier-1', + clientId: 'client-1', + callbackUrl: 'http://localhost:5003/api/mcp-oauth-proxy/callback', + tokenEndpoint: 'https://mcp.example.com/token', + directory: '/tmp/project', + ...overrides, + } +} + +describe('mcp-oauth-state', () => { + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('stores a flow with a timestamp and reports it as pending', async () => { + const state = await import('../../src/services/mcp-oauth-state') + const before = Date.now() + + state.storeMcpOAuthFlow('state-1', createFlow()) + + const result = state.getMcpOAuthFlowResult('state-1') + expect(result).toEqual({ status: 'pending' }) + + const flow = state.consumeMcpOAuthFlow('state-1') + expect(flow).toMatchObject({ serverName: 'test-server', clientId: 'client-1'.replace('client-1', 'client-1') }) + expect(flow?.timestamp).toBeGreaterThanOrEqual(before) + expect(flow?.timestamp).toBeLessThanOrEqual(Date.now()) + }) + + it('consumes a flow only once', async () => { + const state = await import('../../src/services/mcp-oauth-state') + state.storeMcpOAuthFlow('state-2', createFlow()) + + expect(state.consumeMcpOAuthFlow('state-2')).toBeDefined() + expect(state.consumeMcpOAuthFlow('state-2')).toBeUndefined() + }) + + it('returns undefined for unknown flows and results', async () => { + const state = await import('../../src/services/mcp-oauth-state') + + expect(state.consumeMcpOAuthFlow('missing')).toBeUndefined() + expect(state.getMcpOAuthFlowResult('missing')).toBeUndefined() + }) + + it('marks a flow completed and returns the server name', async () => { + const state = await import('../../src/services/mcp-oauth-state') + state.storeMcpOAuthFlow('state-3', createFlow({ serverName: 'server-three' })) + + state.markMcpOAuthFlowCompleted('state-3', 'server-three') + + expect(state.getMcpOAuthFlowResult('state-3')).toEqual({ status: 'completed', serverName: 'server-three' }) + expect(state.consumeMcpOAuthFlow('state-3')).toBeDefined() + }) + + it('marks a flow failed and returns the error', async () => { + const state = await import('../../src/services/mcp-oauth-state') + state.storeMcpOAuthFlow('state-4', createFlow()) + + state.markMcpOAuthFlowFailed('state-4', 'token exchange failed') + + expect(state.getMcpOAuthFlowResult('state-4')).toEqual({ status: 'failed', error: 'token exchange failed' }) + }) + + it('deletes a stored flow without touching its result', async () => { + const state = await import('../../src/services/mcp-oauth-state') + state.storeMcpOAuthFlow('state-5', createFlow()) + + state.deleteMcpOAuthFlow('state-5') + + expect(state.consumeMcpOAuthFlow('state-5')).toBeUndefined() + expect(state.getMcpOAuthFlowResult('state-5')).toEqual({ status: 'pending' }) + }) + + it('expires stored flows after the state TTL', async () => { + vi.useFakeTimers() + const state = await import('../../src/services/mcp-oauth-state') + state.storeMcpOAuthFlow('state-6', createFlow()) + + vi.advanceTimersByTime(11 * 60 * 1000) + + expect(state.consumeMcpOAuthFlow('state-6')).toBeUndefined() + expect(state.getMcpOAuthFlowResult('state-6')).toBeUndefined() + }) + + it('expires flow results after the result TTL', async () => { + vi.useFakeTimers() + const state = await import('../../src/services/mcp-oauth-state') + state.storeMcpOAuthFlow('state-7', createFlow()) + state.markMcpOAuthFlowCompleted('state-7', 'server-seven') + + vi.advanceTimersByTime(6 * 60 * 1000) + + expect(state.getMcpOAuthFlowResult('state-7')).toBeUndefined() + }) +}) diff --git a/backend/test/services/opencode-config-apply.test.ts b/backend/test/services/opencode-config-apply.test.ts new file mode 100644 index 000000000..f15fc5487 --- /dev/null +++ b/backend/test/services/opencode-config-apply.test.ts @@ -0,0 +1,234 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtemp, readFile, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import path from 'path' +import { Database } from 'bun:sqlite' +import { ZodError } from 'zod' + +const paths = vi.hoisted(() => ({ config: '' })) + +vi.mock('@opencode-manager/shared/config/env', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getOpenCodeConfigFilePath: () => paths.config, + } +}) + +vi.mock('../../src/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})) + +const patchConfigWithRecoveryMock = vi.hoisted(() => vi.fn()) +vi.mock('../../src/services/opencode/config-recovery', () => ({ + patchConfigWithRecovery: patchConfigWithRecoveryMock, +})) + +const markRestartPendingMock = vi.hoisted(() => vi.fn()) +vi.mock('../../src/services/opencode-single-server', () => ({ + opencodeServerManager: { + markRestartPending: markRestartPendingMock, + }, +})) + +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import { SettingsService } from '../../src/services/settings' +import { applyOpenCodeConfigUpdate, type ApplyOpenCodeConfigResult } from '../../src/services/opencode-config-apply' +import type { OpenCodeClient } from '../../src/services/opencode/client' + +function expectStatus( + result: ApplyOpenCodeConfigResult, + status: T, +): Extract { + expect(result.status).toBe(status) + return result as Extract +} + +describe('opencode-config-apply', () => { + let workDir: string + let db: Database + let settingsService: SettingsService + let openCodeClient: OpenCodeClient + + beforeEach(async () => { + vi.clearAllMocks() + workDir = await mkdtemp(path.join(tmpdir(), 'opencode-config-apply-')) + paths.config = path.join(workDir, 'opencode.json') + db = new Database(':memory:') + migrate(db, allMigrations) + settingsService = new SettingsService(db) + openCodeClient = { forward: vi.fn() } as unknown as OpenCodeClient + }) + + afterEach(async () => { + db.close() + await rm(workDir, { recursive: true, force: true }) + }) + + it('writes the file and marks a restart pending for a plugin change without patching the live server', async () => { + await writeFile(paths.config, '{"theme":"dark"}', 'utf8') + + const result = expectStatus(await applyOpenCodeConfigUpdate({ + content: '{"theme":"dark","plugin":["x"]}', + openCodeClient, + settingsService, + }), 'restart_pending') + + expect(result.config.rawContent).toBe('{"theme":"dark","plugin":["x"]}') + await expect(readFile(paths.config, 'utf8')).resolves.toBe('{"theme":"dark","plugin":["x"]}') + expect(markRestartPendingMock).toHaveBeenCalledTimes(1) + expect(patchConfigWithRecoveryMock).not.toHaveBeenCalled() + }) + + it('patches a live-applied change and writes the submitted raw text verbatim', async () => { + await writeFile(paths.config, '{"theme":"dark"}', 'utf8') + patchConfigWithRecoveryMock.mockResolvedValue({ + success: true, + appliedConfig: { theme: 'dark', mcp: { local: { type: 'local' } } }, + }) + + const submitted = '{\n // keep this comment\n "theme": "dark",\n "mcp": { "local": { "type": "local" } }\n}\n' + const result = expectStatus(await applyOpenCodeConfigUpdate({ + content: submitted, + openCodeClient, + settingsService, + }), 'applied') + + expect(result.removedFields).toEqual([]) + await expect(readFile(paths.config, 'utf8')).resolves.toBe(submitted) + expect(markRestartPendingMock).not.toHaveBeenCalled() + }) + + it('writes the cleaned applied config and reports removed fields when recovery drops them', async () => { + await writeFile(paths.config, '{"theme":"dark"}', 'utf8') + const appliedConfig = { mcp: { local: { type: 'local' } } } + patchConfigWithRecoveryMock.mockResolvedValue({ + success: true, + appliedConfig, + removedFields: ['theme'], + }) + + const result = expectStatus(await applyOpenCodeConfigUpdate({ + content: '{"theme":"dark","mcp":{"local":{"type":"local"}}}', + openCodeClient, + settingsService, + }), 'applied') + + expect(result.removedFields).toEqual(['theme']) + await expect(readFile(paths.config, 'utf8')).resolves.toBe(JSON.stringify(appliedConfig, null, 2)) + }) + + it('leaves the previous file byte-identical and returns rejected when the live patch fails', async () => { + const previous = '{\n // previous\n "theme": "dark"\n}\n' + await writeFile(paths.config, previous, 'utf8') + patchConfigWithRecoveryMock.mockResolvedValue({ + success: false, + error: 'model: must be string', + details: [{ path: 'model', message: 'must be string' }], + removedFields: ['model'], + }) + + const result = expectStatus(await applyOpenCodeConfigUpdate({ + content: { model: 'x' }, + openCodeClient, + settingsService, + }), 'rejected') + + expect(result.error).toBe('model: must be string') + expect(result.validationIssues).toEqual([{ path: 'model', message: 'must be string' }]) + expect(result.removedFields).toEqual(['model']) + await expect(readFile(paths.config, 'utf8')).resolves.toBe(previous) + expect(markRestartPendingMock).not.toHaveBeenCalled() + }) + + it('saves a valid previous file as last known good before writing', async () => { + const previous = '{"theme":"dark"}' + await writeFile(paths.config, previous, 'utf8') + patchConfigWithRecoveryMock.mockResolvedValue({ success: true }) + + await applyOpenCodeConfigUpdate({ + content: { mcp: {} }, + openCodeClient, + settingsService, + }) + + expect(settingsService.getLastKnownGoodConfig()).toBe(previous) + expect(settingsService.getSettings().preferences.lastKnownGoodConfig).toBe(previous) + }) + + it('does not capture an invalid previous file as last known good', async () => { + await writeFile(paths.config, '{"model": 5}', 'utf8') + settingsService.saveLastKnownGoodConfig('sentinel') + patchConfigWithRecoveryMock.mockResolvedValue({ success: true }) + + const result = expectStatus(await applyOpenCodeConfigUpdate({ + content: { theme: 'light' }, + openCodeClient, + settingsService, + }), 'applied') + + expect(result.config.rawContent).toBe('{\n "theme": "light"\n}') + expect(settingsService.getLastKnownGoodConfig()).toBe('sentinel') + }) + + it('throws ZodError and writes nothing when the submitted content is invalid', async () => { + const previous = '{"theme":"dark"}' + await writeFile(paths.config, previous, 'utf8') + + await expect(applyOpenCodeConfigUpdate({ + content: '{"model": 5}', + openCodeClient, + settingsService, + })).rejects.toBeInstanceOf(ZodError) + + await expect(readFile(paths.config, 'utf8')).resolves.toBe(previous) + expect(patchConfigWithRecoveryMock).not.toHaveBeenCalled() + expect(markRestartPendingMock).not.toHaveBeenCalled() + }) + + it('serializes concurrent applies so their live patches do not interleave', async () => { + await writeFile(paths.config, '{"theme":"dark"}', 'utf8') + + const events: string[] = [] + let releaseFirstPatch!: () => void + patchConfigWithRecoveryMock + .mockImplementationOnce(() => { + events.push('first:start') + return new Promise((resolve) => { + releaseFirstPatch = () => { + events.push('first:end') + resolve({ success: true }) + } + }) + }) + .mockImplementationOnce(() => { + events.push('second:start') + return Promise.resolve({ success: true }) + }) + + const first = applyOpenCodeConfigUpdate({ + content: { mcp: { first: { type: 'local' } } }, + openCodeClient, + settingsService, + }) + await vi.waitFor(() => expect(events).toContain('first:start')) + + const second = applyOpenCodeConfigUpdate({ + content: { mcp: { second: { type: 'local' } } }, + openCodeClient, + settingsService, + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(events).toEqual(['first:start']) + + releaseFirstPatch() + await Promise.all([first, second]) + + expect(events).toEqual(['first:start', 'first:end', 'second:start']) + }) +}) diff --git a/backend/test/services/opencode-config-file.test.ts b/backend/test/services/opencode-config-file.test.ts new file mode 100644 index 000000000..74f2c5d3e --- /dev/null +++ b/backend/test/services/opencode-config-file.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { chmod, mkdir, mkdtemp, readFile, readdir, rm, stat, utimes, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import path from 'path' +import { ZodError } from 'zod' + +const paths = vi.hoisted(() => ({ config: '', healthWatch: '' })) + +vi.mock('@opencode-manager/shared/config/env', () => ({ + getOpenCodeConfigFilePath: () => paths.config, + getOpenCodeHealthWatchPath: () => paths.healthWatch, +})) + +vi.mock('../../src/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})) + +import { + HEALTH_WATCH_MAX_ENTRIES, + OPENCODE_CONFIG_SEED, + archiveBrokenOpenCodeConfigFile, + deleteOpenCodeConfigFile, + pruneHealthWatchDirectory, + readOpenCodeConfigFile, + writeOpenCodeConfigFile, +} from '../../src/services/opencode-config-file' + +describe('opencode-config-file', () => { + let workDir: string + + beforeEach(async () => { + vi.clearAllMocks() + workDir = await mkdtemp(path.join(tmpdir(), 'opencode-config-file-')) + paths.config = path.join(workDir, 'opencode.json') + paths.healthWatch = path.join(workDir, 'health-watch') + }) + + afterEach(async () => { + await rm(workDir, { recursive: true, force: true }) + }) + + it('returns null when the config file does not exist', async () => { + await expect(readOpenCodeConfigFile()).resolves.toBeNull() + }) + + it('writes a valid config and reads it back', async () => { + const written = await writeOpenCodeConfigFile(OPENCODE_CONFIG_SEED) + + expect(written.path).toBe(paths.config) + expect(written.rawContent).toBe(OPENCODE_CONFIG_SEED) + expect(written.content).toEqual({ $schema: 'https://opencode.ai/config.json' }) + expect(written.isValid).toBe(true) + expect(written.updatedAt).toBeGreaterThan(0) + }) + + it('preserves an existing non-default file mode when rewriting atomically', async () => { + const previousUmask = process.umask(0) + try { + await writeFile(paths.config, OPENCODE_CONFIG_SEED, 'utf8') + await chmod(paths.config, 0o640) + + await writeOpenCodeConfigFile('{"theme":"dark"}') + + const stats = await stat(paths.config) + expect(stats.mode & 0o777).toBe(0o640) + } finally { + process.umask(previousUmask) + } + }) + + it('rejects invalid content with a ZodError and leaves the previous file untouched', async () => { + await writeOpenCodeConfigFile(OPENCODE_CONFIG_SEED) + + await expect(writeOpenCodeConfigFile('{"model": 5}')).rejects.toBeInstanceOf(ZodError) + await expect(readFile(paths.config, 'utf8')).resolves.toBe(OPENCODE_CONFIG_SEED) + }) + + it('parses JSONC comments and preserves the raw content', async () => { + const rawContent = '{\n // user comment\n "theme": "dark"\n}\n' + await writeFile(paths.config, rawContent, 'utf8') + + const file = await readOpenCodeConfigFile() + + expect(file?.content).toEqual({ theme: 'dark' }) + expect(file?.rawContent).toBe(rawContent) + expect(file?.isValid).toBe(true) + }) + + it('reports validation issues for schema-invalid content', async () => { + await writeFile(paths.config, '{"model": 5}', 'utf8') + + const file = await readOpenCodeConfigFile() + + expect(file?.isValid).toBe(false) + expect(file?.validationIssues?.[0]?.path).toBe('model') + }) + + it('reports a root validation issue when the file is not valid JSONC', async () => { + await writeFile(paths.config, '{ not json', 'utf8') + + const file = await readOpenCodeConfigFile() + + expect(file?.isValid).toBe(false) + expect(file?.validationIssues?.[0]?.path).toBe('root') + }) + + it('archives the config file under the health-watch directory', async () => { + await writeOpenCodeConfigFile(OPENCODE_CONFIG_SEED) + + const archivePath = await archiveBrokenOpenCodeConfigFile() + + expect(archivePath).toBeTruthy() + expect(path.dirname(archivePath as string)).toBe(paths.healthWatch) + await expect(readFile(archivePath as string, 'utf8')).resolves.toBe(OPENCODE_CONFIG_SEED) + }) + + it('returns null when archiving with no config file present', async () => { + await expect(archiveBrokenOpenCodeConfigFile()).resolves.toBeNull() + }) + + it('prunes the health-watch directory to the newest entries', async () => { + await mkdir(paths.healthWatch, { recursive: true }) + const baseTime = Date.now() - 100_000 + for (let index = 0; index < HEALTH_WATCH_MAX_ENTRIES + 5; index += 1) { + const filePath = path.join(paths.healthWatch, `entry-${index}.json`) + await writeFile(filePath, '{}', 'utf8') + const time = new Date(baseTime + index * 1000) + await utimes(filePath, time, time) + } + + await pruneHealthWatchDirectory(paths.healthWatch) + + const remaining = await readdir(paths.healthWatch) + expect(remaining).toHaveLength(HEALTH_WATCH_MAX_ENTRIES) + expect(remaining).toContain(`entry-${HEALTH_WATCH_MAX_ENTRIES + 4}.json`) + expect(remaining).not.toContain('entry-0.json') + expect(remaining).not.toContain('entry-4.json') + }) + + it('caps the health-watch directory when archiving a broken config', async () => { + await writeOpenCodeConfigFile(OPENCODE_CONFIG_SEED) + await mkdir(paths.healthWatch, { recursive: true }) + const baseTime = Date.now() - 100_000 + for (let index = 0; index < HEALTH_WATCH_MAX_ENTRIES + 5; index += 1) { + const filePath = path.join(paths.healthWatch, `old-${index}.json`) + await writeFile(filePath, '{}', 'utf8') + const time = new Date(baseTime + index * 1000) + await utimes(filePath, time, time) + } + + const archivePath = await archiveBrokenOpenCodeConfigFile() + + const remaining = await readdir(paths.healthWatch) + expect(remaining).toHaveLength(HEALTH_WATCH_MAX_ENTRIES) + expect(remaining).toContain(path.basename(archivePath as string)) + }) + + it('deletes the config file and reports whether it existed', async () => { + await writeOpenCodeConfigFile(OPENCODE_CONFIG_SEED) + + await expect(deleteOpenCodeConfigFile()).resolves.toBe(true) + await expect(deleteOpenCodeConfigFile()).resolves.toBe(false) + }) +}) diff --git a/backend/test/services/opencode-import.test.ts b/backend/test/services/opencode-import.test.ts index 47952e9a9..e996e7f25 100644 --- a/backend/test/services/opencode-import.test.ts +++ b/backend/test/services/opencode-import.test.ts @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { Database } from 'bun:sqlite' + +vi.mock('node:fs', async (importOriginal) => ({ + ...(await importOriginal()), + existsSync: vi.fn(), +})) vi.mock('fs/promises', () => ({ cp: vi.fn(), @@ -20,11 +24,11 @@ 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('../../src/services/opencode-config-file', async (importOriginal) => ({ + ...(await importOriginal()), + writeOpenCodeConfigFile: vi.fn(), })) vi.mock('@opencode-manager/shared/config/env', () => ({ @@ -33,37 +37,31 @@ vi.mock('@opencode-manager/shared/config/env', () => ({ })) import path from 'path' +import { existsSync } from 'node:fs' import { readdir, rm, cp, mkdtemp, rename } 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' +import { ensureDirectoryExists, fileExists, readFileContent } from '../../src/services/file-operations' +import { writeOpenCodeConfigFile } from '../../src/services/opencode-config-file' +import { getFirstExistingConfigSourcePath, getOpenCodeImportStatus, syncOpenCodeImport } from '../../src/services/opencode-import' const mockReaddir = readdir as unknown as ReturnType +const mockExistsSync = existsSync 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 mockWriteOpenCodeConfigFile = writeOpenCodeConfigFile as ReturnType const MockSQLiteDatabase = SQLiteDatabase as unknown as ReturnType const mockMkdtemp = mkdtemp as unknown as ReturnType const mockRename = rename as unknown as ReturnType describe('opencode-import service', () => { - const mockDb = {} as unknown as Database - 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([]) mockMkdtemp.mockResolvedValue('/tmp/workspace/.opencode/state/opencode-import-123') mockRename.mockResolvedValue(undefined) + mockExistsSync.mockImplementation((candidate: string) => candidate === process.env.OPENCODE_IMPORT_CONFIG_PATH) }) it('detects importable host config and state paths with opencode.db', async () => { @@ -108,23 +106,14 @@ describe('opencode-import service', () => { || candidate === '/tmp/workspace/.opencode/state/opencode/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(result.workspaceStateExists).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', + expect(mockWriteOpenCodeConfigFile).toHaveBeenCalledWith( '{"$schema":"https://opencode.ai/config.json"}' ) expect(mockEnsureDirectoryExists).toHaveBeenCalledWith('/tmp/workspace/.opencode/state') @@ -145,8 +134,6 @@ describe('opencode-import service', () => { }) const result = await syncOpenCodeImport({ - db: mockDb, - userId: 'default', overwriteState: true, }) @@ -155,6 +142,39 @@ describe('opencode-import service', () => { expect(mockEnsureDirectoryExists).not.toHaveBeenCalled() }) + it('resolves the first existing import config candidate synchronously', () => { + process.env.OPENCODE_IMPORT_CONFIG_PATH = process.execPath + + expect(getFirstExistingConfigSourcePath()).toBe(process.execPath) + }) + + it('rejects invalid importable config content with the existing error', async () => { + process.env.OPENCODE_IMPORT_CONFIG_PATH = '/import/opencode-config/opencode.json' + + mockFileExists.mockImplementation(async (candidate: string) => candidate === '/import/opencode-config/opencode.json') + mockReadFileContent.mockResolvedValue('{"model": 123}') + + await expect(syncOpenCodeImport({ overwriteState: true })).rejects.toThrow('Importable OpenCode config is invalid') + expect(mockWriteOpenCodeConfigFile).not.toHaveBeenCalled() + }) + + it('imports state without rewriting the config when importConfig is false', 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' + }) + + const result = await syncOpenCodeImport({ overwriteState: false, importConfig: false }) + + expect(result.configImported).toBe(false) + expect(result.stateImported).toBe(true) + expect(mockWriteOpenCodeConfigFile).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') @@ -237,13 +257,11 @@ describe('opencode-import service', () => { }) await expect(syncOpenCodeImport({ - db: mockDb, - userId: 'default', overwriteState: false, protectExistingState: true, })).rejects.toThrow('OpenCode host import was blocked to protect existing workspace state') - expect(settingsService.updateOpenCodeConfig).not.toHaveBeenCalled() + expect(mockWriteOpenCodeConfigFile).not.toHaveBeenCalled() expect(mockEnsureDirectoryExists).not.toHaveBeenCalled() }) diff --git a/backend/test/services/opencode-manager-tool-plugin.test.ts b/backend/test/services/opencode-manager-tool-plugin.test.ts index e8756fa91..8f8639414 100644 --- a/backend/test/services/opencode-manager-tool-plugin.test.ts +++ b/backend/test/services/opencode-manager-tool-plugin.test.ts @@ -171,6 +171,25 @@ describe('ocm-manager plugin', () => { expect(JSON.parse(init.body)).toEqual({ theme: 'dark' }) }) + it('sends a PUT request with a JSON body', async () => { + const fetchMock = jsonResponse({}) + vi.stubGlobal('fetch', fetchMock) + const tool = await loadTool(configHome) + + await tool.execute({ + action: 'request', + params: { method: 'PUT', path: '/opencode-config', body: { content: { theme: 'dark' } } }, + }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] ?? [] + expect(url).toBe('http://localhost:5003/api/internal/opencode-config') + expect(init.method).toBe('PUT') + expect(init.headers.Authorization).toBe('Bearer secret-token') + expect(init.headers['content-type']).toBe('application/json') + expect(JSON.parse(init.body)).toEqual({ content: { theme: 'dark' } }) + }) + it('allows every route in the exported allow list', async () => { const tool = await loadTool(configHome) @@ -464,7 +483,7 @@ describe.skipIf(SHIPPED_OPENCODE_BIN === null)('ocm-manager plugin against the s type: 'object', required: ['method', 'path'], properties: { - method: { type: 'string', enum: ['GET', 'POST', 'PATCH', 'DELETE'] }, + method: { type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] }, path: { type: 'string', minLength: 1, maxLength: 500 }, body: { type: 'object' }, }, diff --git a/backend/test/services/opencode-single-server.test.ts b/backend/test/services/opencode-single-server.test.ts index 179311dc0..f316298a8 100644 --- a/backend/test/services/opencode-single-server.test.ts +++ b/backend/test/services/opencode-single-server.test.ts @@ -26,6 +26,7 @@ vi.mock('bun:sqlite', () => ({ vi.mock('@opencode-manager/shared/config/env', () => ({ getWorkspacePath: vi.fn(() => '/test/workspace'), getOpenCodeConfigFilePath: vi.fn(() => '/test/workspace/.config/opencode.json'), + getOpenCodeHealthWatchPath: vi.fn(() => '/test/workspace/health-watch'), getOpenCodeStateHome: vi.fn(() => '/test/workspace/.opencode/state'), getOpenCodeConfigHome: vi.fn(() => '/test/workspace/.config'), getOpenCodeTmpHome: vi.fn(() => '/test/workspace/.opencode/tmp'), @@ -86,6 +87,16 @@ vi.mock('../../src/services/opencode/config-recovery', () => ({ patchConfigWithRecovery: vi.fn(), })) +const writeOpenCodeConfigFileMock = vi.hoisted(() => vi.fn()) + +vi.mock('../../src/services/opencode-config-file', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + writeOpenCodeConfigFile: writeOpenCodeConfigFileMock, + } +}) + vi.mock('../../src/services/opencode/client', () => ({ createOpenCodeClient: createOpenCodeClientMock, })) @@ -125,6 +136,7 @@ import { promises as fs, accessSync, readdirSync } from 'fs' import { execSync, spawnSync } from 'child_process' import path from 'path' import os from 'os' +import { ZodError } from 'zod' import { ConfigReloadError, resolveOpenCodeExecutable } from '../../src/services/opencode-single-server' import { forceProcessAttestation, resetProcessIdentityProvider } from '../../src/services/opencode/process-identity' import { encryptSecret } from '../../src/utils/crypto' @@ -3050,6 +3062,7 @@ describe('ConfigReloadError', () => { describe('OpenCodeServerManager - reloadConfig', () => { beforeEach(() => { vi.clearAllMocks() + writeOpenCodeConfigFileMock.mockReset() }) it('should read config from file before patching', async () => { @@ -3100,6 +3113,119 @@ describe('OpenCodeServerManager - reloadConfig', () => { const patchTarget = vi.mocked(patchConfigWithRecovery).mock.calls[0]![1] expect(patchTarget).toEqual({ model: 'x' }) }) + + it('persists the cleaned config through the config-file owner when fields are removed', async () => { + const { opencodeServerManager } = await import('../../src/services/opencode-single-server') + const { patchConfigWithRecovery } = await import('../../src/services/opencode/config-recovery') + const cleanedConfig = { model: 'x' } + vi.mocked(patchConfigWithRecovery).mockResolvedValue({ + success: true, + removedFields: ['mcp.bad'], + appliedConfig: cleanedConfig, + } as any) + const { createStubOpenCodeClient } = await import('../helpers/stub-opencode-client') + opencodeServerManager.setOpenCodeClient(createStubOpenCodeClient()) + fs.readFile = vi.fn().mockResolvedValue(JSON.stringify({ model: 'x', mcp: { bad: true } })) + + await opencodeServerManager.reloadConfig() + + expect(writeOpenCodeConfigFileMock).toHaveBeenCalledWith(JSON.stringify(cleanedConfig, null, 2)) + }) + + it('does not write the config file when the live patch removes nothing', async () => { + const { opencodeServerManager } = await import('../../src/services/opencode-single-server') + const { patchConfigWithRecovery } = await import('../../src/services/opencode/config-recovery') + vi.mocked(patchConfigWithRecovery).mockResolvedValue({ success: true } as any) + const { createStubOpenCodeClient } = await import('../helpers/stub-opencode-client') + opencodeServerManager.setOpenCodeClient(createStubOpenCodeClient()) + fs.readFile = vi.fn().mockResolvedValue(JSON.stringify({ model: 'x' })) + + await opencodeServerManager.reloadConfig() + + expect(writeOpenCodeConfigFileMock).not.toHaveBeenCalled() + }) + + it('reports a cleaned config validation failure as a ConfigReloadError with the removed fields', async () => { + const { opencodeServerManager } = await import('../../src/services/opencode-single-server') + const { patchConfigWithRecovery } = await import('../../src/services/opencode/config-recovery') + vi.mocked(patchConfigWithRecovery).mockResolvedValue({ + success: true, + removedFields: ['mcp.bad'], + appliedConfig: { model: 'x' }, + } as any) + writeOpenCodeConfigFileMock.mockRejectedValue(new ZodError([ + { code: 'custom', path: ['model'], message: 'Invalid model' }, + ])) + const { createStubOpenCodeClient } = await import('../helpers/stub-opencode-client') + opencodeServerManager.setOpenCodeClient(createStubOpenCodeClient()) + fs.readFile = vi.fn().mockResolvedValue(JSON.stringify({ model: 'x', mcp: { bad: true } })) + + const error = await opencodeServerManager.reloadConfig().then( + () => null, + (caught: unknown) => caught, + ) + + expect(error).toBeInstanceOf(ConfigReloadError) + const reloadError = error as ConfigReloadError + expect(reloadError.validationIssues).toEqual([{ path: 'model', message: 'Invalid model' }]) + expect(reloadError.removedFields).toEqual(['mcp.bad']) + }) + + it('serializes the cleaned-config write against a concurrent apply so neither write interleaves', async () => { + const { opencodeServerManager } = await import('../../src/services/opencode-single-server') + const { patchConfigWithRecovery } = await import('../../src/services/opencode/config-recovery') + const { applyOpenCodeConfigUpdate } = await import('../../src/services/opencode-config-apply') + const { createStubOpenCodeClient } = await import('../helpers/stub-opencode-client') + + const openCodeClient = createStubOpenCodeClient() + opencodeServerManager.setOpenCodeClient(openCodeClient) + fs.readFile = vi.fn().mockResolvedValue(JSON.stringify({ model: 'x', mcp: { bad: true } })) + + const events: string[] = [] + let releaseReloadWrite!: () => void + const writtenConfig = { + path: '/test/workspace/.config/opencode.json', + rawContent: '{}', + content: {}, + isValid: true, + updatedAt: 0, + } + + vi.mocked(patchConfigWithRecovery) + .mockResolvedValueOnce({ success: true, removedFields: ['mcp.bad'], appliedConfig: { model: 'x' } } as any) + .mockResolvedValueOnce({ success: true } as any) + + writeOpenCodeConfigFileMock + .mockImplementationOnce(() => { + events.push('reload:write:start') + return new Promise((resolve) => { + releaseReloadWrite = () => { + events.push('reload:write:end') + resolve(writtenConfig) + } + }) + }) + .mockImplementationOnce(() => { + events.push('apply:write:start') + return Promise.resolve(writtenConfig) + }) + + const reload = opencodeServerManager.reloadConfig() + await vi.waitFor(() => expect(events).toContain('reload:write:start')) + + const apply = applyOpenCodeConfigUpdate({ + content: { theme: 'light' }, + openCodeClient, + settingsService: {} as unknown as Parameters[0]['settingsService'], + }) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(events).toEqual(['reload:write:start']) + + releaseReloadWrite() + await Promise.all([reload, apply]) + + expect(events).toEqual(['reload:write:start', 'reload:write:end', 'apply:write:start']) + }, 5000) }) describe('OpenCodeServerManager - checkHealth', () => { diff --git a/backend/test/services/opencode-supervisor.test.ts b/backend/test/services/opencode-supervisor.test.ts index 95463aae2..f1587361e 100644 --- a/backend/test/services/opencode-supervisor.test.ts +++ b/backend/test/services/opencode-supervisor.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { ensureDirectoryExists, writeFileContent } from '../../src/services/file-operations' +import { archiveBrokenOpenCodeConfigFile, pruneHealthWatchDirectory, writeOpenCodeConfigFile, OPENCODE_CONFIG_SEED } from '../../src/services/opencode-config-file' import { OpenCodeSupervisor } from '../../src/services/opencode-supervisor' vi.mock('../../src/utils/logger', () => ({ @@ -15,9 +16,15 @@ vi.mock('../../src/services/file-operations', () => ({ ensureDirectoryExists: vi.fn(), })) +vi.mock('../../src/services/opencode-config-file', () => ({ + archiveBrokenOpenCodeConfigFile: vi.fn(), + pruneHealthWatchDirectory: vi.fn(), + writeOpenCodeConfigFile: vi.fn(), + OPENCODE_CONFIG_SEED: '{"$schema":"https://opencode.ai/config.json"}', +})) + vi.mock('@opencode-manager/shared/config/env', () => ({ - getWorkspacePath: vi.fn(() => '/tmp/opencode-workspace'), - getOpenCodeConfigFilePath: vi.fn(() => '/tmp/opencode-workspace/.config/opencode.json'), + getOpenCodeHealthWatchPath: vi.fn(() => '/tmp/opencode-workspace/.opencode/state/health-watch'), ENV: { OPENCODE: { HEALTH_POLL_MS: 200, @@ -45,11 +52,7 @@ interface FakeManager { } interface FakeSettingsService { - archiveBrokenConfig: ReturnType - restoreToLastKnownGoodConfig: ReturnType - getDefaultOpenCodeConfig: ReturnType - updateOpenCodeConfig: ReturnType - createOpenCodeConfig: ReturnType + getLastKnownGoodConfig: ReturnType } describe('OpenCodeSupervisor', () => { @@ -75,24 +78,7 @@ describe('OpenCodeSupervisor', () => { }) const createSettings = (): FakeSettingsService => ({ - archiveBrokenConfig: vi.fn(() => 'default-broken-2026-01-01'), - restoreToLastKnownGoodConfig: vi.fn(() => ({ - configName: 'default', - content: '{"$schema":"https://opencode.ai/config.json"}', - })), - getDefaultOpenCodeConfig: vi.fn(() => ({ - name: 'default', - content: { $schema: 'https://opencode.ai/config.json' }, - rawContent: '{"$schema":"https://opencode.ai/config.json"}', - isDefault: true, - })), - updateOpenCodeConfig: vi.fn(() => ({ - name: 'default', - content: { $schema: 'https://opencode.ai/config.json' }, - rawContent: '{"$schema":"https://opencode.ai/config.json"}', - isDefault: true, - })), - createOpenCodeConfig: vi.fn(), + getLastKnownGoodConfig: vi.fn(() => '{"$schema":"https://opencode.ai/config.json"}'), }) it('recovers a startup failure through rollback and keeps watching', async () => { @@ -100,7 +86,6 @@ describe('OpenCodeSupervisor', () => { const settings = createSettings() const supervisor = new OpenCodeSupervisor(manager as unknown as never, settings as unknown as never, { failureThreshold: 1, - userId: 'default', }) manager.start.mockRejectedValueOnce(new Error('startup failed')) @@ -114,22 +99,34 @@ describe('OpenCodeSupervisor', () => { expect(status.healthy).toBe(true) expect(status.state).toBe('healthy') expect(manager.restart).toHaveBeenCalledTimes(3) - expect(settings.archiveBrokenConfig).toHaveBeenCalledWith('default') - expect(settings.restoreToLastKnownGoodConfig).toHaveBeenCalledWith('default') - expect(settings.updateOpenCodeConfig).toHaveBeenCalledWith( - 'default', - { content: '{"$schema":"https://opencode.ai/config.json"}' }, - 'default', - ) - expect(writeFileContent).toHaveBeenCalledWith( - '/tmp/opencode-workspace/.config/opencode.json', - '{"$schema":"https://opencode.ai/config.json"}', - ) + expect(settings.getLastKnownGoodConfig).toHaveBeenCalled() + expect(archiveBrokenOpenCodeConfigFile).toHaveBeenCalled() + expect(writeOpenCodeConfigFile).toHaveBeenCalledWith('{"$schema":"https://opencode.ai/config.json"}') expect(status.watching).toBe(true) await supervisor.stop() }) + it('seeds the default config when the recovery ladder reaches the seed action', async () => { + const manager = createManager() + const settings = createSettings() + const supervisor = new OpenCodeSupervisor(manager as unknown as never, settings as unknown as never, { + failureThreshold: 1, + watchEnabled: false, + }) + + manager.start.mockRejectedValueOnce(new Error('startup failed')) + manager.checkHealth.mockResolvedValue(false) + + const status = await supervisor.start() + + expect(status.state).toBe('failed') + expect(archiveBrokenOpenCodeConfigFile).toHaveBeenCalled() + expect(writeOpenCodeConfigFile).toHaveBeenCalledWith(OPENCODE_CONFIG_SEED) + + await supervisor.stop() + }) + it('opens the proxy lifecycle gate when the managed child is attested healthy', async () => { const manager = createManager() const settings = createSettings() @@ -149,7 +146,6 @@ describe('OpenCodeSupervisor', () => { const supervisor = new OpenCodeSupervisor(manager as unknown as never, settings as unknown as never, { failureThreshold: 1, watchEnabled: false, - userId: 'default', }) manager.start.mockRejectedValueOnce(new Error('OpenCode version 1.18.15 does not support sandboxed bash tool rewriting')) @@ -167,7 +163,6 @@ describe('OpenCodeSupervisor', () => { const supervisor = new OpenCodeSupervisor(manager as unknown as never, settings as unknown as never, { failureThreshold: 1, watchEnabled: false, - userId: 'default', }) manager.start.mockRejectedValueOnce(new Error('startup failed')) @@ -253,6 +248,7 @@ describe('OpenCodeSupervisor', () => { expect(status.healthy).toBe(true) expect(ensureDirectoryExists).toHaveBeenCalled() expect(writeFileContent).toHaveBeenCalled() + expect(pruneHealthWatchDirectory).toHaveBeenCalledWith('/tmp/opencode-workspace/.opencode/state/health-watch') expect(manager.restart).toHaveBeenCalledTimes(2) }) @@ -273,7 +269,6 @@ describe('OpenCodeSupervisor', () => { const settings = createSettings() const supervisor = new OpenCodeSupervisor(manager as unknown as never, settings as unknown as never, { failureThreshold: 1, - userId: 'default', }) manager.start.mockRejectedValueOnce(new Error('OpenCode version 1.18.15 does not support sandboxed bash tool rewriting')) @@ -285,10 +280,9 @@ describe('OpenCodeSupervisor', () => { expect(status.healthy).toBe(false) expect(status.lastError).toContain('does not support sandboxed bash tool rewriting') expect(manager.restart).not.toHaveBeenCalled() - expect(settings.archiveBrokenConfig).not.toHaveBeenCalled() - expect(settings.restoreToLastKnownGoodConfig).not.toHaveBeenCalled() - expect(settings.updateOpenCodeConfig).not.toHaveBeenCalled() - expect(settings.createOpenCodeConfig).not.toHaveBeenCalled() + expect(archiveBrokenOpenCodeConfigFile).not.toHaveBeenCalled() + expect(settings.getLastKnownGoodConfig).not.toHaveBeenCalled() + expect(writeOpenCodeConfigFile).not.toHaveBeenCalled() expect(writeFileContent).not.toHaveBeenCalled() await supervisor.stop() @@ -299,7 +293,6 @@ describe('OpenCodeSupervisor', () => { const settings = createSettings() const supervisor = new OpenCodeSupervisor(manager as unknown as never, settings as unknown as never, { failureThreshold: 1, - userId: 'default', }) manager.restart.mockRejectedValueOnce(new Error('Failed to install a generated OpenCode plugin; refusing to start an enforced server')) @@ -308,10 +301,9 @@ describe('OpenCodeSupervisor', () => { const status = await supervisor.restart('settings_restart') expect(status.state).toBe('failed') - expect(settings.archiveBrokenConfig).not.toHaveBeenCalled() - expect(settings.restoreToLastKnownGoodConfig).not.toHaveBeenCalled() - expect(settings.updateOpenCodeConfig).not.toHaveBeenCalled() - expect(settings.createOpenCodeConfig).not.toHaveBeenCalled() + expect(archiveBrokenOpenCodeConfigFile).not.toHaveBeenCalled() + expect(settings.getLastKnownGoodConfig).not.toHaveBeenCalled() + expect(writeOpenCodeConfigFile).not.toHaveBeenCalled() expect(writeFileContent).not.toHaveBeenCalled() await supervisor.stop() @@ -322,7 +314,6 @@ describe('OpenCodeSupervisor', () => { const settings = createSettings() const supervisor = new OpenCodeSupervisor(manager as unknown as never, settings as unknown as never, { failureThreshold: 1, - userId: 'default', }) manager.start.mockRejectedValueOnce(new Error('startup failed')) @@ -337,10 +328,9 @@ describe('OpenCodeSupervisor', () => { expect(status.state).toBe('failed') expect(status.lastError).toContain('Failed to install a generated OpenCode plugin') expect(manager.restart).toHaveBeenCalledTimes(1) - expect(settings.archiveBrokenConfig).not.toHaveBeenCalled() - expect(settings.restoreToLastKnownGoodConfig).not.toHaveBeenCalled() - expect(settings.updateOpenCodeConfig).not.toHaveBeenCalled() - expect(settings.createOpenCodeConfig).not.toHaveBeenCalled() + expect(archiveBrokenOpenCodeConfigFile).not.toHaveBeenCalled() + expect(settings.getLastKnownGoodConfig).not.toHaveBeenCalled() + expect(writeOpenCodeConfigFile).not.toHaveBeenCalled() expect(writeFileContent).not.toHaveBeenCalled() await supervisor.stop() @@ -351,7 +341,6 @@ describe('OpenCodeSupervisor', () => { const settings = createSettings() const supervisor = new OpenCodeSupervisor(manager as unknown as never, settings as unknown as never, { failureThreshold: 1, - userId: 'default', }) manager.start.mockRejectedValueOnce(new Error('OpenCode config validation failed: command.review: Invalid')) @@ -363,10 +352,9 @@ describe('OpenCodeSupervisor', () => { const status = await supervisor.start() expect(status.state).toBe('healthy') - expect(settings.archiveBrokenConfig).toHaveBeenCalledWith('default') - expect(settings.restoreToLastKnownGoodConfig).toHaveBeenCalledWith('default') - expect(settings.updateOpenCodeConfig).toHaveBeenCalled() - expect(writeFileContent).toHaveBeenCalled() + expect(archiveBrokenOpenCodeConfigFile).toHaveBeenCalled() + expect(settings.getLastKnownGoodConfig).toHaveBeenCalled() + expect(writeOpenCodeConfigFile).toHaveBeenCalledWith('{"$schema":"https://opencode.ai/config.json"}') await supervisor.stop() }) @@ -519,7 +507,6 @@ describe('OpenCodeSupervisor', () => { const supervisor = new OpenCodeSupervisor(manager as unknown as never, settings as unknown as never, { failureThreshold: 1, watchEnabled: false, - userId: 'default', }) await supervisor.start() diff --git a/backend/test/services/prompt-templates.test.ts b/backend/test/services/prompt-templates.test.ts new file mode 100644 index 000000000..192d89687 --- /dev/null +++ b/backend/test/services/prompt-templates.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { Database } from 'bun:sqlite' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import { PromptTemplateService, PromptTemplateServiceError } from '../../src/services/prompt-templates' + +function createTemplateInput(overrides: Record = {}) { + return { + title: 'Daily standup', + category: 'standup', + cadenceHint: 'daily', + suggestedName: 'daily-standup', + suggestedDescription: 'Summarize yesterday', + description: 'A standup prompt', + prompt: 'Summarize my work', + ...overrides, + } +} + +describe('PromptTemplateService', () => { + let db: Database + let service: PromptTemplateService + + beforeEach(() => { + db = new Database(':memory:') + migrate(db, allMigrations) + service = new PromptTemplateService(db) + }) + + afterEach(() => { + db.close() + }) + + it('creates, lists, and gets a template', () => { + const created = service.create(createTemplateInput()) + + expect(service.list().map(template => template.id)).toContain(created.id) + expect(service.getById(created.id)).toEqual(created) + }) + + it('throws a 404 service error when a template is missing', () => { + expect(() => service.getById(999)).toThrow(PromptTemplateServiceError) + + try { + service.getById(999) + } catch (error) { + expect(error).toBeInstanceOf(PromptTemplateServiceError) + expect((error as PromptTemplateServiceError).statusCode).toBe(404) + expect((error as Error).message).toBe('Template not found') + } + }) + + it('updates an existing template', () => { + const created = service.create(createTemplateInput()) + + const updated = service.update(created.id, { title: 'Renamed' }) + + expect(updated.title).toBe('Renamed') + expect(service.getById(created.id).title).toBe('Renamed') + }) + + it('throws a 404 service error when updating a missing template', () => { + try { + service.update(999, { title: 'Missing' }) + throw new Error('expected update to throw') + } catch (error) { + expect(error).toBeInstanceOf(PromptTemplateServiceError) + expect((error as PromptTemplateServiceError).statusCode).toBe(404) + } + }) + + it('deletes an existing template', () => { + const created = service.create(createTemplateInput()) + + service.delete(created.id) + + expect(service.list().map(template => template.id)).not.toContain(created.id) + }) + + it('throws a 404 service error when deleting a missing template', () => { + try { + service.delete(999) + throw new Error('expected delete to throw') + } catch (error) { + expect(error).toBeInstanceOf(PromptTemplateServiceError) + expect((error as PromptTemplateServiceError).statusCode).toBe(404) + } + }) +}) diff --git a/backend/test/services/repo-git.test.ts b/backend/test/services/repo-git.test.ts new file mode 100644 index 000000000..98f2fa1ff --- /dev/null +++ b/backend/test/services/repo-git.test.ts @@ -0,0 +1,1063 @@ +import { execFileSync } from 'node:child_process' +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:https' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { Database } from 'bun:sqlite' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import { createRepo, getRepoById, getRepoByLocalPath } from '../../src/db/queries' +import { resolveOpenCodeProjectId } from '@opencode-manager/shared/project-id' +import { getReposPath, getScheduleWorktreesPath } from '@opencode-manager/shared/config/env' +import type { GitAuthService } from '../../src/services/git-auth' +import type { OpenCodeClient } from '../../src/services/opencode/client' +import type { Repo } from '../../src/types/repo' + +type SiblingRepo = Repo & { currentBranch: string | undefined; workspaceId?: string } + +const workspaceRoot = mkdtempSync(path.join(tmpdir(), 'repo-git-')) +process.env.WORKSPACE_PATH = workspaceRoot + +const reposPath = path.join(workspaceRoot, 'repos') +const projectsRoot = path.join(workspaceRoot, 'projects') +const certDir = path.join(workspaceRoot, 'certs') +const keyPath = path.join(certDir, 'key.pem') +const certPath = path.join(certDir, 'cert.pem') + +let certKey: Buffer +let certPem: Buffer + +function git(args: string[], cwd?: string): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf-8', + env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_NOSYSTEM: '1' }, + }).trim() +} + +function createCommittedRepo(repoPath: string, branch = 'main'): void { + mkdirSync(repoPath, { recursive: true }) + git(['init', '-b', branch], repoPath) + git(['config', 'user.email', 'test@test.com'], repoPath) + git(['config', 'user.name', 'Test'], repoPath) + git(['commit', '--allow-empty', '-m', 'init'], repoPath) +} + +function createOrigin(originPath: string, workPath: string, extraBranches: string[] = []): void { + mkdirSync(originPath, { recursive: true }) + git(['init', '--bare', originPath]) + mkdirSync(workPath, { recursive: true }) + git(['init', '-b', 'main'], workPath) + git(['config', 'user.email', 'test@test.com'], workPath) + git(['config', 'user.name', 'Test'], workPath) + git(['commit', '--allow-empty', '-m', 'init'], workPath) + git(['remote', 'add', 'origin', originPath], workPath) + git(['push', 'origin', 'main'], workPath) + git(['symbolic-ref', 'HEAD', 'refs/heads/main'], originPath) + + for (const branch of extraBranches) { + git(['checkout', '-b', branch], workPath) + git(['commit', '--allow-empty', '-m', branch], workPath) + git(['push', 'origin', branch], workPath) + git(['checkout', 'main'], workPath) + } +} + +function cloneOrigin(originPath: string, clonePath: string): void { + git(['clone', originPath, clonePath]) + git(['config', 'user.email', 'test@test.com'], clonePath) + git(['config', 'user.name', 'Test'], clonePath) +} + +let seq = 0 + +function uniqueName(prefix: string): string { + seq += 1 + return `${prefix}-${seq}` +} + +function createGitAuthService(env: Record = {}): GitAuthService { + return { + getGitEnvironment: () => env, + getSSHEnvironment: () => ({}), + setupSSHForRepoUrl: async () => false, + cleanupSSHKey: async () => {}, + } as unknown as GitAuthService +} + +async function withTlsServer(status: number, fn: (port: number) => Promise): Promise { + const server = createServer({ key: certKey, cert: certPem }, (_req, res) => { + res.writeHead(status, { 'WWW-Authenticate': 'Basic realm="test"' }) + res.end('error') + }) + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + const port = typeof address === 'object' && address ? address.port : 0 + + try { + return await fn(port) + } finally { + await new Promise((resolve) => server.close(() => resolve())) + } +} + +describe('repo service real git', () => { + let db: Database + let gitAuth: GitAuthService + + beforeAll(() => { + mkdirSync(certDir, { recursive: true }) + execFileSync('openssl', [ + 'req', '-x509', '-newkey', 'rsa:2048', + '-keyout', keyPath, '-out', certPath, + '-days', '1', '-nodes', '-subj', '/CN=127.0.0.1', + ], { stdio: 'ignore' }) + certKey = readFileSync(keyPath) + certPem = readFileSync(certPath) + }) + + beforeEach(() => { + db = new Database(':memory:') + migrate(db, allMigrations) + gitAuth = createGitAuthService() + rmSync(reposPath, { recursive: true, force: true }) + rmSync(projectsRoot, { recursive: true, force: true }) + }) + + afterEach(() => { + db.close() + }) + + afterAll(() => { + rmSync(workspaceRoot, { recursive: true, force: true }) + }) + + function registerLocalRepo(localPath: string, branch = 'main'): Repo { + return createRepo(db, { + isLocal: true, + localPath, + branch, + defaultBranch: branch, + cloneStatus: 'ready', + clonedAt: Date.now(), + }) + } + + describe('initLocalRepo', () => { + it('initializes a relative repo and marks it ready', async () => { + const { initLocalRepo } = await import('../../src/services/repo') + const name = uniqueName('relative') + const repo = await initLocalRepo(db, gitAuth, name) + + expect(repo.cloneStatus).toBe('ready') + expect(repo.localPath).toBe(name) + expect(existsSync(path.join(reposPath, name, '.git'))).toBe(true) + expect(getRepoById(db, repo.id)?.cloneStatus).toBe('ready') + }) + + it('initializes a relative repo on a custom branch', async () => { + const { initLocalRepo } = await import('../../src/services/repo') + const name = uniqueName('relative-branch') + const repo = await initLocalRepo(db, gitAuth, name, 'feature') + + expect(repo.branch).toBe('feature') + expect(git(['symbolic-ref', '--short', 'HEAD'], path.join(reposPath, name))).toBe('feature') + }) + + it('returns the existing row for a repeated relative repo', async () => { + const { initLocalRepo } = await import('../../src/services/repo') + const name = uniqueName('relative-repeat') + const first = await initLocalRepo(db, gitAuth, name) + const second = await initLocalRepo(db, gitAuth, name) + + expect(second.id).toBe(first.id) + }) + + it('rolls back when the relative target path is a file', async () => { + const { initLocalRepo } = await import('../../src/services/repo') + const name = uniqueName('relative-blocked') + mkdirSync(reposPath, { recursive: true }) + writeFileSync(path.join(reposPath, name), 'blocked') + + await expect(initLocalRepo(db, gitAuth, name)).rejects.toThrow(/Failed to initialize local repository/) + expect(getRepoByLocalPath(db, name)).toBeNull() + }) + + it('registers an absolute repo with a workspace symlink', async () => { + const { initLocalRepo } = await import('../../src/services/repo') + const sourcePath = path.join(projectsRoot, uniqueName('absolute')) + createCommittedRepo(sourcePath) + + const repo = await initLocalRepo(db, gitAuth, sourcePath) + const aliasPath = path.join(reposPath, repo.localPath) + + expect(repo.sourcePath).toBe(sourcePath) + expect(repo.isLocal).toBe(true) + expect(lstatSync(aliasPath).isSymbolicLink()).toBe(true) + }) + + it('returns the existing row for a repeated absolute source path', async () => { + const { initLocalRepo } = await import('../../src/services/repo') + const sourcePath = path.join(projectsRoot, uniqueName('absolute-repeat')) + createCommittedRepo(sourcePath) + + const first = await initLocalRepo(db, gitAuth, sourcePath) + const second = await initLocalRepo(db, gitAuth, sourcePath) + + expect(second.id).toBe(first.id) + }) + + it('checks out a requested local branch while registering an absolute repo', async () => { + const { initLocalRepo } = await import('../../src/services/repo') + const sourcePath = path.join(projectsRoot, uniqueName('absolute-branch')) + createCommittedRepo(sourcePath) + git(['branch', 'feature'], sourcePath) + + const repo = await initLocalRepo(db, gitAuth, sourcePath, 'feature') + + expect(repo.branch).toBe('feature') + expect(git(['rev-parse', '--abbrev-ref', 'HEAD'], sourcePath)).toBe('feature') + }) + + it('registers an absolute repo located inside the repos directory', async () => { + const { initLocalRepo } = await import('../../src/services/repo') + const sourcePath = path.join(reposPath, uniqueName('inside-repo')) + createCommittedRepo(sourcePath) + + const repo = await initLocalRepo(db, gitAuth, sourcePath) + + expect(repo.localPath).toBe(path.basename(sourcePath)) + expect(repo.sourcePath).toBeNull() + }) + + it('marks a linked worktree as a worktree when registering an absolute path', async () => { + const { initLocalRepo } = await import('../../src/services/repo') + const basePath = path.join(projectsRoot, uniqueName('linked-base')) + createCommittedRepo(basePath) + const worktreePath = path.join(projectsRoot, `${path.basename(basePath)}-linked`) + git(['worktree', 'add', '-b', 'linked', worktreePath], basePath) + + const repo = await initLocalRepo(db, gitAuth, worktreePath) + + expect(repo.isWorktree).toBe(true) + }) + + it('rejects an absolute path that is not a git repository', async () => { + const { initLocalRepo } = await import('../../src/services/repo') + const sourcePath = path.join(projectsRoot, uniqueName('absolute-not-git')) + mkdirSync(sourcePath, { recursive: true }) + + await expect(initLocalRepo(db, gitAuth, sourcePath)).rejects.toThrow(/not a valid Git repository/) + }) + + it('rejects a missing absolute path', async () => { + const { initLocalRepo } = await import('../../src/services/repo') + + await expect(initLocalRepo(db, gitAuth, path.join(projectsRoot, 'missing-absolute'))).rejects.toThrow(/No such file or directory/) + }) + + it('rejects the repos root itself as a source', async () => { + const { initLocalRepo } = await import('../../src/services/repo') + mkdirSync(reposPath, { recursive: true }) + + await expect(initLocalRepo(db, gitAuth, reposPath)).rejects.toThrow(/not a valid Git repository/) + }) + + it('picks a suffixed alias when the preferred alias directory exists', async () => { + const { initLocalRepo } = await import('../../src/services/repo') + const name = uniqueName('alias-conflict') + const sourcePath = path.join(projectsRoot, name) + createCommittedRepo(sourcePath) + mkdirSync(path.join(reposPath, name), { recursive: true }) + + const repo = await initLocalRepo(db, gitAuth, sourcePath) + + expect(repo.localPath).toBe(`${name}-2`) + expect(lstatSync(path.join(reposPath, `${name}-2`)).isSymbolicLink()).toBe(true) + }) + }) + + describe('discoverLocalRepos', () => { + it('discovers nested real git repositories', async () => { + const { discoverLocalRepos } = await import('../../src/services/repo') + const root = path.join(projectsRoot, uniqueName('discovery')) + createCommittedRepo(path.join(root, 'app-one')) + createCommittedRepo(path.join(root, 'nested', 'app-two')) + + const result = await discoverLocalRepos(db, gitAuth, root) + + expect(result.discoveredCount).toBe(2) + expect(result.existingCount).toBe(0) + expect(result.errors).toEqual([]) + expect(result.repos).toHaveLength(2) + }) + + it('keeps existing registrations on a second discovery pass', async () => { + const { discoverLocalRepos } = await import('../../src/services/repo') + const root = path.join(projectsRoot, uniqueName('discovery-repeat')) + createCommittedRepo(path.join(root, 'app-one')) + + const first = await discoverLocalRepos(db, gitAuth, root) + const second = await discoverLocalRepos(db, gitAuth, root) + + expect(first.discoveredCount).toBe(1) + expect(second.discoveredCount).toBe(0) + expect(second.existingCount).toBe(1) + }) + + it('rejects a missing discovery root', async () => { + const { discoverLocalRepos } = await import('../../src/services/repo') + + await expect(discoverLocalRepos(db, gitAuth, path.join(projectsRoot, 'missing-root'))).rejects.toThrow(/Failed to access/) + }) + + it('rejects a discovery root that is a file', async () => { + const { discoverLocalRepos } = await import('../../src/services/repo') + mkdirSync(projectsRoot, { recursive: true }) + const filePath = path.join(projectsRoot, uniqueName('root-file')) + writeFileSync(filePath, 'file') + + await expect(discoverLocalRepos(db, gitAuth, filePath)).rejects.toThrow(/not a directory/) + }) + }) + + describe('relinkReposFromSessionDirectories', () => { + it('relinks session directories to real git repo roots', async () => { + const { relinkReposFromSessionDirectories } = await import('../../src/services/repo') + const repoRoot = path.join(projectsRoot, uniqueName('relink')) + createCommittedRepo(repoRoot) + mkdirSync(path.join(repoRoot, 'apps', 'web'), { recursive: true }) + mkdirSync(path.join(repoRoot, 'packages', 'api'), { recursive: true }) + const notRepo = path.join(projectsRoot, uniqueName('relink-not-repo')) + mkdirSync(notRepo, { recursive: true }) + + const result = await relinkReposFromSessionDirectories(db, gitAuth, [ + path.join(repoRoot, 'apps', 'web'), + path.join(repoRoot, 'packages', 'api'), + notRepo, + '', + ]) + + expect(result.relinkedCount).toBe(1) + expect(result.existingCount).toBe(0) + expect(result.duplicatePathCount).toBe(1) + expect(result.nonRepoPathCount).toBe(2) + expect(result.errors).toEqual([]) + expect(result.repos).toHaveLength(1) + }) + + it('reports existing registrations on a second relink pass', async () => { + const { relinkReposFromSessionDirectories } = await import('../../src/services/repo') + const repoRoot = path.join(projectsRoot, uniqueName('relink-repeat')) + createCommittedRepo(repoRoot) + + const first = await relinkReposFromSessionDirectories(db, gitAuth, [repoRoot]) + const second = await relinkReposFromSessionDirectories(db, gitAuth, [repoRoot]) + + expect(first.relinkedCount).toBe(1) + expect(second.relinkedCount).toBe(0) + expect(second.existingCount).toBe(1) + }) + }) + + describe('getCurrentBranch', () => { + it('reads the branch of a repo with commits', async () => { + const { getCurrentBranch } = await import('../../src/services/repo') + const repoPath = path.join(reposPath, uniqueName('branch-commits')) + createCommittedRepo(repoPath) + const repo = registerLocalRepo(path.basename(repoPath)) + + expect(await getCurrentBranch(repo, {})).toBe('main') + }) + + it('reads the symbolic branch of a repo without commits', async () => { + const { getCurrentBranch } = await import('../../src/services/repo') + const repoPath = path.join(reposPath, uniqueName('branch-empty')) + mkdirSync(repoPath, { recursive: true }) + git(['init', '-b', 'main'], repoPath) + const repo = registerLocalRepo(path.basename(repoPath)) + + expect(await getCurrentBranch(repo, {})).toBe('main') + }) + + it('falls back to the stored branch for a missing path', async () => { + const { getCurrentBranch } = await import('../../src/services/repo') + const repo = registerLocalRepo(uniqueName('branch-missing')) + + expect(await getCurrentBranch(repo, {})).toBe('main') + }) + }) + + describe('switchBranch', () => { + it('switches to an existing local branch and updates the row', async () => { + const { switchBranch } = await import('../../src/services/repo') + const repoPath = path.join(reposPath, uniqueName('switch-local')) + createCommittedRepo(repoPath) + git(['branch', 'feature'], repoPath) + const repo = registerLocalRepo(path.basename(repoPath)) + + await switchBranch(db, gitAuth, repo.id, 'refs/heads/feature') + + expect(getRepoById(db, repo.id)?.branch).toBe('feature') + expect(git(['rev-parse', '--abbrev-ref', 'HEAD'], repoPath)).toBe('feature') + }) + + it('checks out a branch that only exists on the remote', async () => { + const { switchBranch } = await import('../../src/services/repo') + const origin = path.join(workspaceRoot, uniqueName('switch-origin.git')) + const work = path.join(workspaceRoot, uniqueName('switch-work')) + createOrigin(origin, work, ['remote-only']) + const repoPath = path.join(reposPath, uniqueName('switch-remote')) + cloneOrigin(origin, repoPath) + const repo = registerLocalRepo(path.basename(repoPath)) + + await switchBranch(db, gitAuth, repo.id, 'origin/remote-only') + + expect(git(['rev-parse', '--abbrev-ref', 'HEAD'], repoPath)).toBe('remote-only') + }) + + it('creates a branch that exists nowhere when switching', async () => { + const { switchBranch } = await import('../../src/services/repo') + const repoPath = path.join(reposPath, uniqueName('switch-new')) + createCommittedRepo(repoPath) + const repo = registerLocalRepo(path.basename(repoPath)) + + await switchBranch(db, gitAuth, repo.id, 'brand-new') + + expect(git(['rev-parse', '--abbrev-ref', 'HEAD'], repoPath)).toBe('brand-new') + }) + + it('rejects switching an unknown repo', async () => { + const { switchBranch } = await import('../../src/services/repo') + + await expect(switchBranch(db, gitAuth, 9999, 'main')).rejects.toThrow(/Repo not found/) + }) + }) + + describe('createBranch', () => { + it('creates and switches to a new branch', async () => { + const { createBranch } = await import('../../src/services/repo') + const repoPath = path.join(reposPath, uniqueName('create-branch')) + createCommittedRepo(repoPath) + const repo = registerLocalRepo(path.basename(repoPath)) + + await createBranch(db, gitAuth, repo.id, 'refs/heads/new-branch') + + expect(getRepoById(db, repo.id)?.branch).toBe('new-branch') + expect(git(['rev-parse', '--abbrev-ref', 'HEAD'], repoPath)).toBe('new-branch') + }) + + it('rejects creating a branch for an unknown repo', async () => { + const { createBranch } = await import('../../src/services/repo') + + await expect(createBranch(db, gitAuth, 9999, 'main')).rejects.toThrow(/Repo not found/) + }) + }) + + describe('pullRepo', () => { + it('skips pulling local repos', async () => { + const { pullRepo } = await import('../../src/services/repo') + const repoPath = path.join(reposPath, uniqueName('pull-local')) + createCommittedRepo(repoPath) + const repo = registerLocalRepo(path.basename(repoPath)) + + await expect(pullRepo(db, gitAuth, repo.id)).resolves.toBeUndefined() + expect(getRepoById(db, repo.id)?.lastPulled).toBeNull() + }) + + it('pulls a remote-backed repo and records the pull time', async () => { + const { pullRepo } = await import('../../src/services/repo') + const origin = path.join(workspaceRoot, uniqueName('pull-origin.git')) + const work = path.join(workspaceRoot, uniqueName('pull-work')) + createOrigin(origin, work) + const repoPath = path.join(reposPath, uniqueName('pull-remote')) + cloneOrigin(origin, repoPath) + const repo = createRepo(db, { + repoUrl: origin, + localPath: path.basename(repoPath), + branch: 'main', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + }) + + git(['commit', '--allow-empty', '-m', 'second'], work) + git(['push', 'origin', 'main'], work) + await pullRepo(db, gitAuth, repo.id) + + expect(getRepoById(db, repo.id)?.lastPulled).toBeGreaterThan(0) + expect(git(['rev-parse', 'HEAD'], repoPath)).toBe(git(['rev-parse', 'HEAD'], work)) + }) + + it('throws when a remote-backed repo has no upstream', async () => { + const { pullRepo } = await import('../../src/services/repo') + const repoPath = path.join(reposPath, uniqueName('pull-fail')) + createCommittedRepo(repoPath) + const repo = createRepo(db, { + repoUrl: `https://example.com/${uniqueName('pull-fail')}.git`, + localPath: path.basename(repoPath), + branch: 'main', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + }) + + await expect(pullRepo(db, gitAuth, repo.id)).rejects.toThrow() + }) + + it('rejects pulling an unknown repo', async () => { + const { pullRepo } = await import('../../src/services/repo') + + await expect(pullRepo(db, gitAuth, 9999)).rejects.toThrow(/Repo not found/) + }) + }) + + describe('deleteRepoFiles', () => { + it('deletes a local repo directory and its row', async () => { + const { deleteRepoFiles } = await import('../../src/services/repo') + const repoPath = path.join(reposPath, uniqueName('delete-local')) + createCommittedRepo(repoPath) + const repo = registerLocalRepo(path.basename(repoPath)) + + await deleteRepoFiles(db, repo.id) + + expect(existsSync(repoPath)).toBe(false) + expect(getRepoById(db, repo.id)).toBeNull() + }) + + it('removes a worktree repo and its row', async () => { + const { deleteRepoFiles } = await import('../../src/services/repo') + const baseName = uniqueName('delete-base') + const basePath = path.join(reposPath, baseName) + createCommittedRepo(basePath) + const worktreePath = path.join(reposPath, `${baseName}-feature`) + git(['worktree', 'add', '-b', 'feature', worktreePath], basePath) + const repo = createRepo(db, { + repoUrl: `https://github.com/example/${baseName}.git`, + localPath: `${baseName}-feature`, + branch: 'feature', + defaultBranch: 'feature', + cloneStatus: 'ready', + clonedAt: Date.now(), + isWorktree: true, + }) + + await deleteRepoFiles(db, repo.id) + + expect(existsSync(worktreePath)).toBe(false) + expect(getRepoById(db, repo.id)).toBeNull() + }) + + it('rejects deleting an unknown repo', async () => { + const { deleteRepoFiles } = await import('../../src/services/repo') + + await expect(deleteRepoFiles(db, 9999)).rejects.toThrow(/Repo not found/) + }) + }) + + describe('cloneRepo', () => { + it('clones a repo from a local bare origin', async () => { + const { cloneRepo } = await import('../../src/services/repo') + const origin = path.join(workspaceRoot, uniqueName('clone-origin.git')) + const work = path.join(workspaceRoot, uniqueName('clone-work')) + createOrigin(origin, work) + const name = uniqueName('clone-target') + + const repo = await cloneRepo(db, gitAuth, origin, { directoryName: name }) + + expect(repo.cloneStatus).toBe('ready') + expect(existsSync(path.join(reposPath, name, '.git'))).toBe(true) + expect(getRepoById(db, repo.id)?.cloneStatus).toBe('ready') + }) + + it('returns the existing repo for the same url and branch', async () => { + const { cloneRepo } = await import('../../src/services/repo') + const origin = path.join(workspaceRoot, uniqueName('clone-dup-origin.git')) + const work = path.join(workspaceRoot, uniqueName('clone-dup-work')) + createOrigin(origin, work) + const first = await cloneRepo(db, gitAuth, origin, { directoryName: uniqueName('clone-dup-first'), branch: 'main' }) + + const second = await cloneRepo(db, gitAuth, origin, { directoryName: uniqueName('clone-dup-second'), branch: 'main' }) + + expect(second.id).toBe(first.id) + }) + + it('clones a branch that exists on the origin', async () => { + const { cloneRepo } = await import('../../src/services/repo') + const origin = path.join(workspaceRoot, uniqueName('clone-branch-origin.git')) + const work = path.join(workspaceRoot, uniqueName('clone-branch-work')) + createOrigin(origin, work, ['feature']) + const name = uniqueName('clone-branch') + + await cloneRepo(db, gitAuth, origin, { directoryName: name, branch: 'feature' }) + + expect(git(['rev-parse', '--abbrev-ref', 'HEAD'], path.join(reposPath, name))).toBe('feature') + }) + + it('clones the default branch and creates a missing branch locally', async () => { + const { cloneRepo } = await import('../../src/services/repo') + const origin = path.join(workspaceRoot, uniqueName('clone-missing-origin.git')) + const work = path.join(workspaceRoot, uniqueName('clone-missing-work')) + createOrigin(origin, work) + const name = uniqueName('clone-missing-branch') + + await cloneRepo(db, gitAuth, origin, { directoryName: name, branch: 'ghost' }) + + expect(git(['rev-parse', '--abbrev-ref', 'HEAD'], path.join(reposPath, name))).toBe('ghost') + }) + + it('creates a worktree clone when the base repo already exists', async () => { + const { cloneRepo } = await import('../../src/services/repo') + const origin = path.join(workspaceRoot, uniqueName('clone-wt-origin.git')) + const work = path.join(workspaceRoot, uniqueName('clone-wt-work')) + createOrigin(origin, work) + const baseName = uniqueName('clone-wt-base') + await cloneRepo(db, gitAuth, origin, { directoryName: baseName }) + const branchName = uniqueName('wt-branch') + + const repo = await cloneRepo(db, gitAuth, origin, { directoryName: baseName, branch: branchName, useWorktree: true }) + + expect(repo.isWorktree).toBe(true) + expect(existsSync(path.join(reposPath, `${baseName}-${branchName}`))).toBe(true) + expect(git(['rev-parse', '--abbrev-ref', 'HEAD'], path.join(reposPath, `${baseName}-${branchName}`))).toBe(branchName) + }) + + it('reuses a valid existing base repo directory and checks out a local branch', async () => { + const { cloneRepo } = await import('../../src/services/repo') + const origin = path.join(workspaceRoot, uniqueName('clone-reuse-origin.git')) + const work = path.join(workspaceRoot, uniqueName('clone-reuse-work')) + createOrigin(origin, work) + const baseName = uniqueName('clone-reuse-base') + cloneOrigin(origin, path.join(reposPath, baseName)) + + await cloneRepo(db, gitAuth, origin, { directoryName: baseName, branch: 'main' }) + + expect(git(['rev-parse', '--abbrev-ref', 'HEAD'], path.join(reposPath, baseName))).toBe('main') + }) + + it('checks out a remote-only branch in an existing base repo', async () => { + const { cloneRepo } = await import('../../src/services/repo') + const origin = path.join(workspaceRoot, uniqueName('clone-remote-only-origin.git')) + const work = path.join(workspaceRoot, uniqueName('clone-remote-only-work')) + createOrigin(origin, work, ['remote-only']) + const baseName = uniqueName('clone-remote-only-base') + cloneOrigin(origin, path.join(reposPath, baseName)) + + await cloneRepo(db, gitAuth, origin, { directoryName: baseName, branch: 'remote-only' }) + + expect(git(['rev-parse', '--abbrev-ref', 'HEAD'], path.join(reposPath, baseName))).toBe('remote-only') + }) + + it('creates a new local branch in an existing base repo', async () => { + const { cloneRepo } = await import('../../src/services/repo') + const origin = path.join(workspaceRoot, uniqueName('clone-new-branch-origin.git')) + const work = path.join(workspaceRoot, uniqueName('clone-new-branch-work')) + createOrigin(origin, work) + const baseName = uniqueName('clone-new-branch-base') + cloneOrigin(origin, path.join(reposPath, baseName)) + + await cloneRepo(db, gitAuth, origin, { directoryName: baseName, branch: 'fresh' }) + + expect(git(['rev-parse', '--abbrev-ref', 'HEAD'], path.join(reposPath, baseName))).toBe('fresh') + }) + + it('rejects a base directory holding a different repository', async () => { + const { cloneRepo } = await import('../../src/services/repo') + const origin = path.join(workspaceRoot, uniqueName('clone-collide-origin.git')) + const originWork = path.join(workspaceRoot, uniqueName('clone-collide-origin-work')) + createOrigin(origin, originWork) + const otherOrigin = path.join(workspaceRoot, uniqueName('clone-collide-other.git')) + const otherWork = path.join(workspaceRoot, uniqueName('clone-collide-other-work')) + createOrigin(otherOrigin, otherWork) + const baseName = uniqueName('clone-collide') + cloneOrigin(otherOrigin, path.join(reposPath, baseName)) + + await expect(cloneRepo(db, gitAuth, origin, { directoryName: baseName })).rejects.toMatchObject({ statusCode: 409 }) + }) + + it('removes an invalid base directory and reclones', async () => { + const { cloneRepo } = await import('../../src/services/repo') + const origin = path.join(workspaceRoot, uniqueName('clone-invalid-origin.git')) + const work = path.join(workspaceRoot, uniqueName('clone-invalid-work')) + createOrigin(origin, work) + const baseName = uniqueName('clone-invalid') + mkdirSync(path.join(reposPath, baseName), { recursive: true }) + writeFileSync(path.join(reposPath, baseName, 'stray.txt'), 'stray') + + const repo = await cloneRepo(db, gitAuth, origin, { directoryName: baseName }) + + expect(repo.cloneStatus).toBe('ready') + expect(existsSync(path.join(reposPath, baseName, '.git'))).toBe(true) + }) + + it('enhances a missing local clone error and rolls back the row', async () => { + const { cloneRepo } = await import('../../src/services/repo') + const name = uniqueName('clone-missing-source') + + await expect(cloneRepo(db, gitAuth, path.join(workspaceRoot, 'no-such-origin'), { directoryName: name })).rejects.toThrow(/does not exist/) + expect(getRepoByLocalPath(db, name)).toBeNull() + }) + + it('maps a repository-not-found clone failure', async () => { + const { cloneRepo } = await import('../../src/services/repo') + const name = uniqueName('clone-not-found') + + await withTlsServer(404, async (port) => { + const url = `https://127.0.0.1:${port}/404/repo-missing.git` + process.env.GIT_SSL_NO_VERIFY = 'true' + try { + await expect(cloneRepo(db, gitAuth, url, { directoryName: name })).rejects.toThrow(/Repository not found/) + } finally { + delete process.env.GIT_SSL_NO_VERIFY + } + }) + + expect(getRepoByLocalPath(db, name)).toBeNull() + }) + + it('maps an authentication clone failure', async () => { + const { cloneRepo } = await import('../../src/services/repo') + const name = uniqueName('clone-auth') + + await withTlsServer(401, async (port) => { + const url = `https://user:pass@127.0.0.1:${port}/repo-auth.git` + process.env.GIT_SSL_NO_VERIFY = 'true' + try { + await expect(cloneRepo(db, gitAuth, url, { directoryName: name })).rejects.toThrow(/Authentication failed/) + } finally { + delete process.env.GIT_SSL_NO_VERIFY + } + }) + + expect(getRepoByLocalPath(db, name)).toBeNull() + }) + + it('maps an ssh permission-denied clone failure', async () => { + const { cloneRepo } = await import('../../src/services/repo') + const sshScript = path.join(workspaceRoot, uniqueName('fake-ssh.sh')) + writeFileSync(sshScript, '#!/bin/sh\necho "Permission denied (publickey)." >&2\nexit 255\n') + chmodSync(sshScript, 0o755) + const sshAuth = createGitAuthService({ GIT_SSH_COMMAND: sshScript, GIT_TERMINAL_PROMPT: '0' }) + const name = uniqueName('clone-ssh') + + await expect(cloneRepo(db, sshAuth, 'ssh://git@example.invalid/owner/repo.git', { directoryName: name })).rejects.toThrow(/Access denied/) + expect(getRepoByLocalPath(db, name)).toBeNull() + }) + + it('maps a scp-style ssh permission-denied clone failure', async () => { + const { cloneRepo } = await import('../../src/services/repo') + const sshScript = path.join(workspaceRoot, uniqueName('fake-scp-ssh.sh')) + writeFileSync(sshScript, '#!/bin/sh\necho "Permission denied (publickey)." >&2\nexit 255\n') + chmodSync(sshScript, 0o755) + const sshAuth = createGitAuthService({ GIT_SSH_COMMAND: sshScript, GIT_TERMINAL_PROMPT: '0' }) + const name = uniqueName('clone-scp') + + await expect(cloneRepo(db, sshAuth, 'git@example.invalid:owner/repo.git', { directoryName: name })).rejects.toThrow(/Access denied/) + expect(getRepoByLocalPath(db, name)).toBeNull() + }) + }) + + describe('worktree helpers', () => { + it('creates a worktree for a new branch and removes it', async () => { + const { createWorktreeSafely, removeWorktree } = await import('../../src/services/repo') + const basePath = path.join(reposPath, uniqueName('wt-base')) + createCommittedRepo(basePath) + const worktreePath = path.join(reposPath, `${path.basename(basePath)}-feature`) + + await createWorktreeSafely(basePath, worktreePath, 'feature', {}) + expect(existsSync(worktreePath)).toBe(true) + + await removeWorktree(basePath, worktreePath) + expect(existsSync(worktreePath)).toBe(false) + }) + + it('creates a worktree for an existing branch', async () => { + const { createWorktreeSafely } = await import('../../src/services/repo') + const basePath = path.join(reposPath, uniqueName('wt-existing-base')) + createCommittedRepo(basePath) + git(['branch', 'feature'], basePath) + const worktreePath = path.join(reposPath, `${path.basename(basePath)}-existing`) + + await createWorktreeSafely(basePath, worktreePath, 'feature', {}) + + expect(existsSync(worktreePath)).toBe(true) + expect(git(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath)).toBe('feature') + }) + + it('switches off the requested branch before creating its worktree', async () => { + const { createWorktreeSafely } = await import('../../src/services/repo') + const basePath = path.join(reposPath, uniqueName('wt-switch-base')) + createCommittedRepo(basePath) + git(['checkout', '-b', 'feature'], basePath) + const worktreePath = path.join(reposPath, `${path.basename(basePath)}-switch`) + + await createWorktreeSafely(basePath, worktreePath, 'feature', {}) + + expect(existsSync(worktreePath)).toBe(true) + expect(git(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath)).toBe('feature') + }) + + it('resolves the default branch from origin HEAD and falls back to main', async () => { + const { resolveDefaultBranch } = await import('../../src/services/repo') + const origin = path.join(workspaceRoot, uniqueName('default-branch-origin.git')) + const work = path.join(workspaceRoot, uniqueName('default-branch-work')) + createOrigin(origin, work) + const clonePath = path.join(workspaceRoot, uniqueName('default-branch-clone')) + cloneOrigin(origin, clonePath) + git(['remote', 'set-head', 'origin', '--auto'], clonePath) + + expect(await resolveDefaultBranch(clonePath, {})).toBe('main') + expect(await resolveDefaultBranch(path.join(workspaceRoot, 'no-such-path'), {})).toBe('main') + }) + }) + + describe('mirror helpers', () => { + it('plans and ensures a mirror worktree', async () => { + const { ensureMirrorTarget, planMirrorTarget } = await import('../../src/services/repo') + const baseName = uniqueName('mirror-base') + const basePath = path.join(reposPath, baseName) + createCommittedRepo(basePath) + const base = registerLocalRepo(baseName) + + const inPlace = await planMirrorTarget(db, base, 'main') + expect(inPlace.kind).toBe('in-place') + + const planned = await planMirrorTarget(db, base, 'feature/x') + expect(planned.kind).toBe('new') + + const created = await ensureMirrorTarget(db, base, 'feature/x') + expect(created.created).toBe(true) + expect(existsSync(created.repo.fullPath)).toBe(true) + + const existing = await ensureMirrorTarget(db, base, 'feature/x') + expect(existing.created).toBe(false) + expect(existing.repo.id).toBe(created.repo.id) + }) + + it('resolves the base directory name from a worktree repo row', async () => { + const { ensureMirrorTarget, planMirrorTarget } = await import('../../src/services/repo') + const baseName = uniqueName('mirror-name-base') + const basePath = path.join(reposPath, baseName) + createCommittedRepo(basePath) + const base = registerLocalRepo(baseName) + const created = await ensureMirrorTarget(db, base, 'feature/x') + + const planned = await planMirrorTarget(db, created.repo, 'other') + + expect(planned).toMatchObject({ kind: 'new', localPath: `${baseName}-other` }) + }) + + it('creates a unique mirror target path', async () => { + const { ensureMirrorTargetPath } = await import('../../src/services/repo') + const name = uniqueName('Mirror Name') + const slug = name.toLowerCase().replace(/[^a-z0-9._-]+/g, '-') + const first = ensureMirrorTargetPath(name) + expect(first.localPath).toBe(slug) + + mkdirSync(first.fullPath, { recursive: true }) + const second = ensureMirrorTargetPath(name) + expect(second.localPath).toBe(`${slug}-2`) + }) + + it('creates and reuses repo rows', async () => { + const { createRepoRow } = await import('../../src/services/repo') + const localPath = uniqueName('row-local') + const first = createRepoRow(db, { name: 'local', localPath, fullPath: path.join(reposPath, localPath) }) + expect(first.created).toBe(true) + + const again = createRepoRow(db, { name: 'local', localPath, fullPath: path.join(reposPath, localPath) }) + expect(again.created).toBe(false) + + const originUrl = `https://example.com/${uniqueName('row-remote')}.git` + const remote = createRepoRow(db, { name: 'remote', originUrl, localPath: uniqueName('row-remote-path'), fullPath: '/tmp/remote', branch: 'main' }) + expect(remote.created).toBe(true) + + const remoteAgain = createRepoRow(db, { name: 'remote', originUrl, localPath: uniqueName('row-remote-path-2'), fullPath: '/tmp/remote-2', branch: 'main' }) + expect(remoteAgain.created).toBe(false) + }) + + it('reports repos as not in use', async () => { + const { isRepoInUse } = await import('../../src/services/repo') + const repoPath = path.join(reposPath, uniqueName('in-use')) + createCommittedRepo(repoPath) + const repo = registerLocalRepo(path.basename(repoPath)) + + expect(isRepoInUse(db, repo.id)).toBe(false) + expect(isRepoInUse(db, 9999)).toBe(false) + }) + }) + + describe('getSiblingRepos', () => { + it('returns an empty list for an unknown repo', async () => { + const { getSiblingRepos } = await import('../../src/services/repo') + + expect(await getSiblingRepos(db, 9999, {})).toEqual([]) + }) + + it('returns an empty list for a repo that is not ready', async () => { + const { getSiblingRepos } = await import('../../src/services/repo') + const repoPath = path.join(reposPath, uniqueName('sibling-cloning')) + createCommittedRepo(repoPath) + const repo = createRepo(db, { + isLocal: true, + localPath: path.basename(repoPath), + branch: 'main', + defaultBranch: 'main', + cloneStatus: 'cloning', + clonedAt: Date.now(), + }) + + expect(await getSiblingRepos(db, repo.id, {})).toEqual([]) + }) + + it('returns an empty list when the project id cannot be resolved', async () => { + const { getSiblingRepos } = await import('../../src/services/repo') + const repoPath = path.join(reposPath, uniqueName('sibling-not-git')) + mkdirSync(repoPath, { recursive: true }) + const repo = createRepo(db, { + isLocal: true, + localPath: path.basename(repoPath), + branch: 'main', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + }) + + expect(await getSiblingRepos(db, repo.id, {})).toEqual([]) + }) + + it('returns repos sharing the same origin project', async () => { + const { getSiblingRepos } = await import('../../src/services/repo') + const origin = path.join(workspaceRoot, uniqueName('sibling-origin.git')) + const work = path.join(workspaceRoot, uniqueName('sibling-work')) + createOrigin(origin, work) + const repoA = path.join(reposPath, uniqueName('sibling-a')) + const repoB = path.join(reposPath, uniqueName('sibling-b')) + cloneOrigin(origin, repoA) + cloneOrigin(origin, repoB) + const a = createRepo(db, { + isLocal: true, + localPath: path.basename(repoA), + sourcePath: repoA, + branch: 'main', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + }) + const b = createRepo(db, { + isLocal: true, + localPath: path.basename(repoB), + sourcePath: repoB, + branch: 'main', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + }) + + const siblings = await getSiblingRepos(db, a.id, {}) + + expect(siblings.map((repo) => repo.id).sort()).toEqual([a.id, b.id].sort()) + expect(siblings.every((repo) => repo.currentBranch === 'main')).toBe(true) + }) + + it('adds filtered workspace siblings from the OpenCode client', async () => { + const { getSiblingRepos } = await import('../../src/services/repo') + const origin = path.join(workspaceRoot, uniqueName('sibling-ws-origin.git')) + const work = path.join(workspaceRoot, uniqueName('sibling-ws-work')) + createOrigin(origin, work) + const repoA = path.join(reposPath, uniqueName('sibling-ws-a')) + const repoB = path.join(reposPath, uniqueName('sibling-ws-b')) + cloneOrigin(origin, repoA) + cloneOrigin(origin, repoB) + const a = createRepo(db, { + isLocal: true, + localPath: path.basename(repoA), + sourcePath: repoA, + branch: 'main', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + }) + createRepo(db, { + isLocal: true, + localPath: path.basename(repoB), + sourcePath: repoB, + branch: 'main', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + }) + const projectId = (await resolveOpenCodeProjectId(repoA))! + const extraDir = path.join(workspaceRoot, uniqueName('sibling-ws-extra')) + const duplicateDir = path.join(workspaceRoot, uniqueName('sibling-ws-duplicate')) + const activeWorktree = path.join(workspaceRoot, uniqueName('sibling-ws-active')) + const activeWorkspace = path.join(workspaceRoot, uniqueName('sibling-ws-active-dir')) + mkdirSync(extraDir, { recursive: true }) + mkdirSync(duplicateDir, { recursive: true }) + mkdirSync(activeWorktree, { recursive: true }) + mkdirSync(activeWorkspace, { recursive: true }) + const scheduleDir = path.join(getScheduleWorktreesPath(), uniqueName('sibling-ws-schedule')) + db.prepare('INSERT INTO schedule_runs (job_id, repo_id, trigger_source, status, started_at, created_at, worktree_path, workspace_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)').run(1, a.id, 'manual', 'running', Date.now(), Date.now(), activeWorktree, null) + db.prepare('INSERT INTO schedule_runs (job_id, repo_id, trigger_source, status, started_at, created_at, worktree_path, workspace_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)').run(1, a.id, 'manual', 'running', Date.now(), Date.now(), null, 'ws-active') + const client = { + getJson: async () => [ + { id: 'ws-extra', type: 'worktree', name: 'extra', branch: 'feature', directory: extraDir, projectID: projectId }, + { id: 'ws-dup', type: 'worktree', name: 'dup', branch: 'main', directory: duplicateDir, projectID: projectId }, + { id: 'ws-dup-2', type: 'worktree', name: 'dup-2', branch: 'main', directory: duplicateDir, projectID: projectId }, + { id: 'ws-target', type: 'worktree', name: 'target', branch: 'main', directory: repoA, projectID: projectId }, + { id: 'ws-repos-root', type: 'worktree', name: 'root', branch: 'main', directory: getReposPath(), projectID: projectId }, + { id: 'ws-schedule', type: 'worktree', name: 'sched', branch: 'main', directory: scheduleDir, projectID: projectId }, + { id: 'ws-active-worktree', type: 'worktree', name: 'aw', branch: 'main', directory: activeWorktree, projectID: projectId }, + { id: 'ws-active', type: 'worktree', name: 'aws', branch: 'main', directory: activeWorkspace, projectID: projectId }, + { id: 'ws-null-dir', type: 'worktree', name: 'null', branch: null, directory: null, projectID: projectId }, + { id: 'ws-other', type: 'worktree', name: 'other', branch: null, directory: extraDir, projectID: 'other' }, + ], + } as unknown as OpenCodeClient + + const siblings = await getSiblingRepos(db, a.id, {}, client) as SiblingRepo[] + const workspaceSiblings = siblings.filter((repo) => repo.workspaceId) + + expect(workspaceSiblings.map((repo) => repo.workspaceId)).toEqual(['ws-extra', 'ws-dup']) + }) + + it('returns repo siblings when the OpenCode client fails', async () => { + const { getSiblingRepos } = await import('../../src/services/repo') + const origin = path.join(workspaceRoot, uniqueName('sibling-err-origin.git')) + const work = path.join(workspaceRoot, uniqueName('sibling-err-work')) + createOrigin(origin, work) + const repoA = path.join(reposPath, uniqueName('sibling-err-a')) + cloneOrigin(origin, repoA) + const a = createRepo(db, { + isLocal: true, + localPath: path.basename(repoA), + sourcePath: repoA, + branch: 'main', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + }) + const client = { + getJson: async () => { + throw new Error('upstream unavailable') + }, + } as unknown as OpenCodeClient + + const siblings = await getSiblingRepos(db, a.id, {}, client) as SiblingRepo[] + + expect(siblings.some((repo) => repo.id === a.id)).toBe(true) + expect(siblings.some((repo) => repo.workspaceId)).toBe(false) + }) + + it('resolves the project id with the shared resolver', async () => { + const origin = path.join(workspaceRoot, uniqueName('sibling-resolver-origin.git')) + const work = path.join(workspaceRoot, uniqueName('sibling-resolver-work')) + createOrigin(origin, work) + const repoPath = path.join(reposPath, uniqueName('sibling-resolver')) + cloneOrigin(origin, repoPath) + + expect(await resolveOpenCodeProjectId(repoPath)).toMatch(/^[0-9a-f]{40}$/) + }) + }) +}) diff --git a/backend/test/services/settings-archive.test.ts b/backend/test/services/settings-archive.test.ts deleted file mode 100644 index 134695422..000000000 --- a/backend/test/services/settings-archive.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { Database } from 'bun:sqlite' - -vi.mock('bun:sqlite', () => ({ - Database: vi.fn().mockImplementation(() => ({ - query: vi.fn(), - })), -})) - -import { SettingsService } from '../../src/services/settings' - -describe('SettingsService - archiveBrokenConfig', () => { - let settingsService: SettingsService - let mockGetDefaultConfig: ReturnType - let mockCreateOpenCodeConfig: ReturnType - - beforeEach(() => { - vi.clearAllMocks() - settingsService = new SettingsService({ query: vi.fn() } as unknown as Database) - mockGetDefaultConfig = vi.fn() - mockCreateOpenCodeConfig = vi.fn() - vi.spyOn(settingsService, 'getDefaultOpenCodeConfig').mockImplementation(mockGetDefaultConfig) - vi.spyOn(settingsService, 'createOpenCodeConfig').mockImplementation(mockCreateOpenCodeConfig) - }) - - it('creates a broken config backup with default-broken prefix', () => { - const defaultConfig = { - id: 1, - name: 'default', - rawContent: '{"$schema": "https://opencode.ai/config.json"}', - isValid: true, - content: { '$schema': 'https://opencode.ai/config.json' }, - isDefault: true, - createdAt: Date.now(), - updatedAt: Date.now(), - } - - mockGetDefaultConfig.mockReturnValue(defaultConfig) - mockCreateOpenCodeConfig.mockReturnValue({ - ...defaultConfig, - id: 2, - name: 'default-broken-2026-04-25T00-00-00-000Z', - isDefault: false, - }) - - const backupName = settingsService.archiveBrokenConfig() - - expect(backupName).toMatch(/^default-broken-/) - expect(mockCreateOpenCodeConfig).toHaveBeenCalledWith( - expect.objectContaining({ - name: expect.stringMatching(/^default-broken-/), - content: defaultConfig.rawContent, - isDefault: false, - }), - 'default', - { suppressAutoDefault: true }, - ) - }) - - it('returns null when no default config exists', () => { - mockGetDefaultConfig.mockReturnValue(null) - - const result = settingsService.archiveBrokenConfig() - - expect(result).toBeNull() - expect(mockCreateOpenCodeConfig).not.toHaveBeenCalled() - }) -}) diff --git a/backend/test/services/skills.test.ts b/backend/test/services/skills.test.ts index 6af09fc2b..9d0b8b869 100644 --- a/backend/test/services/skills.test.ts +++ b/backend/test/services/skills.test.ts @@ -26,7 +26,6 @@ vi.mock('../../src/db/queries', async () => { getRepoBySourcePath: vi.fn(), createRepo: vi.fn(), updateRepoStatus: vi.fn(), - updateRepoConfigName: vi.fn(), updateLastPulled: vi.fn(), updateRepoBranch: vi.fn(), deleteRepo: vi.fn(), diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index 783bfce62..3edf72158 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -10,22 +10,18 @@ export default defineConfig({ exclude: [ '**/node_modules/**', '**/dist/**', - 'test/services/assistant-mode.test.ts', - 'test/services/internal-token.test.ts', - 'test/auth/internal-token-middleware.test.ts', - 'test/routes/internal-schedules.test.ts', - 'test/routes/internal-notifications.test.ts', - 'test/routes/internal-settings.test.ts', - 'test/routes/internal-repos.test.ts', 'test/routes/internal-sandbox.test.ts', - 'src/db/model-state.test.ts', - 'src/routes/providers.test.ts', 'src/routes/repos.test.ts', - 'src/routes/session-pins.test.ts', ], coverage: { provider: 'v8', reporter: ['text', 'html'], + thresholds: { + statements: 80, + branches: 80, + functions: 80, + lines: 80, + }, }, env: { NODE_ENV: 'test', @@ -38,6 +34,7 @@ export default defineConfig({ resolve: { alias: { 'bun:sqlite': path.resolve(__dirname, './test/mocks/bun-sqlite.ts'), + 'bun:test': path.resolve(__dirname, './test/mocks/bun-test.ts'), }, }, }) diff --git a/docs/configuration/docker.md b/docs/configuration/docker.md index 50f550805..5c2ba7ec7 100644 --- a/docs/configuration/docker.md +++ b/docs/configuration/docker.md @@ -330,7 +330,7 @@ services: cap_add: - NET_ADMIN environment: - - SANDBOX_IMAGE=${SANDBOX_IMAGE:-docker.io/cstechdev/ocm-sandbox@sha256:30d4c0e7cfe087d23e7387e1d0633bbbe3bb64f222263a24f3d9ca7559bea8aa} + - SANDBOX_IMAGE=${SANDBOX_IMAGE:-docker.io/cstechdev/ocm-sandbox@sha256:7435dce147503b846bcd89ad5e9f7192f37b332c8e49087a7e619034352c47f4} - SANDBOX_MEMORY=${SANDBOX_MEMORY:-4G} - SANDBOX_CPUS=${SANDBOX_CPUS:-2} - SANDBOX_EXEC_USER=${SANDBOX_EXEC_USER:-${PUID:-1000}} diff --git a/docs/features/assistant-internal-api.md b/docs/features/assistant-internal-api.md index 52cef6ab6..7a3eb4201 100644 --- a/docs/features/assistant-internal-api.md +++ b/docs/features/assistant-internal-api.md @@ -58,6 +58,8 @@ The `path` is relative to the internal API base (for example `/settings` or `/re ``` GET /settings PATCH /settings +GET /opencode-config +PUT /opencode-config POST /assistant/reload GET /repos GET /repos/*/git-info @@ -200,6 +202,50 @@ Returns the updated settings object. - `400`: Invalid request body or disallowed key - `401`: Missing or invalid bearer token +### OpenCode Configuration + +The OpenCode configuration file at `getOpenCodeConfigFilePath()` is the source of truth, and this endpoint is the only supported way to change it. The endpoint applies the same restart and live-patch rules as the Settings UI: changes to `agent`, `plugin`, `skills`, or `provider` mark an OpenCode server restart as required, and any other change is live-patched into the running OpenCode server. + +**GET `/api/internal/opencode-config`** + +Read the current configuration file state. + +**Response (`OpenCodeConfigFile`):** +```ts +{ + path: string // Absolute path of the configuration file + content: object // Parsed configuration + rawContent: string // Raw file content, including comments + isValid: boolean // Whether the file passes schema validation + validationIssues?: Array<{ path: string, message: string }> + updatedAt: number // Unix timestamp of the last write +} +``` + +**Status Codes:** +- `200`: Configuration file state returned +- `401`: Missing or invalid bearer token +- `404`: No config file found +- `500`: Server error + +**PUT `/api/internal/opencode-config`** + +Persist a complete configuration. Read the file first, change only the keys the user asked for, and send the complete object back. + +**Request Body:** +```ts +{ content: object } // The complete configuration to persist +``` + +**Response:** +Returns the written `OpenCodeConfigFile`. Adds `restartRequired: true` when the change needs an OpenCode server restart, and `removedFields` when OpenCode dropped fields it does not accept. + +**Status Codes:** +- `200`: Configuration written (live-patched or restart pending) +- `400`: Invalid request body, or configuration rejected with `validationIssues` +- `401`: Missing or invalid bearer token +- `500`: Server error + ### Assistant **POST `/api/internal/assistant/reload`** @@ -251,7 +297,6 @@ Retrieve a list of all managed repositories, ordered by the user's repo preferen clonedAt: number // Timestamp when repo was cloned lastPulled?: number // Timestamp of last pull lastAccessedAt?: number // Timestamp of last access - openCodeConfigName?: string // Associated OpenCode config name isWorktree?: boolean // Whether repo is a worktree isLocal?: boolean // Whether repo is local-only }> @@ -269,7 +314,7 @@ The assistant workspace includes four skills that document these capabilities: 1. **Schedule Management** (`.opencode/skills/schedule-management/SKILL.md`) — manage schedule jobs and runs through the `ocm` `request` action. 2. **Notifications** (`.opencode/skills/notifications/SKILL.md`) — send push notifications through the `ocm` `send_notification` action. -3. **Manager Settings** (`.opencode/skills/manager-settings/SKILL.md`) — read and patch user preferences and reload the assistant workspace through the `ocm` `request` action. +3. **Manager Settings** (`.opencode/skills/manager-settings/SKILL.md`) — read and patch user preferences, read and update the OpenCode configuration file, and reload the assistant workspace through the `ocm` `request` action. 4. **Repo Management** (`.opencode/skills/repo-management/SKILL.md`) — list managed repositories through the `ocm` `request` action. These skills are automatically provisioned when assistant mode is initialized and contain detailed examples and usage patterns. diff --git a/docs/features/assistant-mode.md b/docs/features/assistant-mode.md index d0a8117c2..fb44bd7dc 100644 --- a/docs/features/assistant-mode.md +++ b/docs/features/assistant-mode.md @@ -21,9 +21,11 @@ Four skills are provisioned automatically when assistant mode is initialized: |-------|----------------| | `schedule-management` | Create, list, update, delete, and run scheduled jobs through the `ocm` `request` action | | `notifications` | Send push notifications to registered user devices with the `ocm` `send_notification` action | -| `manager-settings` | Read and patch user preferences, and reload the assistant workspace, through the `ocm` `request` action | +| `manager-settings` | Read and patch user preferences, read and update the OpenCode configuration file, and reload the assistant workspace, through the `ocm` `request` action | | `repo-management` | List all managed repositories through the `ocm` `request` action | +The assistant manages the Manager's global OpenCode configuration file through the `ocm` tool (`/opencode-config`), and the file on disk is the source of truth. + See [Assistant Internal API](assistant-internal-api.md) for the full API reference these skills expose. ## Assistant Persona diff --git a/docs/features/server-health.md b/docs/features/server-health.md index 730f92bba..67b0f23e7 100644 --- a/docs/features/server-health.md +++ b/docs/features/server-health.md @@ -40,6 +40,19 @@ Health monitoring is configured through environment variables: | `OPENCODE_HEALTH_POLL_MS` | `30000` | Poll interval in milliseconds | | `OPENCODE_HEALTH_FAILURE_THRESHOLD` | `2` | Failed checks before recovery starts | +## Configuration Recovery + +The on-disk `opencode.json` is the source of truth. When the file exists at boot but fails validation, the Manager logs a warning and starts with the file unchanged — an invalid config file is never automatically replaced or rolled back during boot. + +The health-watch ladder is the only automatic repair path. When the supervised OpenCode server fails repeated health checks, recovery runs these actions in order until the server is healthy: + +1. **Restart** — restart the server process +2. **Debug capture** — capture a diagnostic snapshot, then restart +3. **Rollback to last known good** — archive the broken config and restore the last known good config +4. **Seed default config** — write the minimal seed config and restart + +Because the ladder only runs after repeated failed health checks, a config file that fails validation but does not make the server unhealthy is left in place. Setting `OPENCODE_HEALTH_WATCH_ENABLED=false` disables the ladder entirely, leaving no automatic repair path. + ## Restart with Session Resume When you restart the OpenCode server (manually or through an upgrade), active sessions are handled gracefully: diff --git a/frontend/src/api/providers.test.ts b/frontend/src/api/providers.test.ts new file mode 100644 index 000000000..54d807d21 --- /dev/null +++ b/frontend/src/api/providers.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { getProvidersWithModels } from './providers' +import type { OpenCodeConfigFile } from './types/settings' + +const { mockGetOpenCodeConfig, mockFetchWrapper } = vi.hoisted(() => ({ + mockGetOpenCodeConfig: vi.fn(), + mockFetchWrapper: vi.fn(), +})) + +vi.mock('./settings', () => ({ + settingsApi: { + getOpenCodeConfig: mockGetOpenCodeConfig, + }, +})) + +vi.mock('./fetchWrapper', () => ({ + fetchWrapper: mockFetchWrapper, +})) + +const config: OpenCodeConfigFile = { + path: '/workspace/.opencode/opencode.json', + isValid: true, + updatedAt: 1, + rawContent: '{}', + content: { + provider: { + openai: { + name: 'OpenAI', + models: { + 'gpt-4o': { name: 'GPT-4o' }, + }, + }, + }, + }, +} + +describe('getProvidersWithModels', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFetchWrapper.mockResolvedValue({ all: [], connected: [], default: {} }) + }) + + it('uses the supplied config instead of re-reading it', async () => { + const providers = await getProvidersWithModels(undefined, config) + + expect(mockGetOpenCodeConfig).not.toHaveBeenCalled() + expect(providers.map((provider) => provider.id)).toEqual(['openai']) + }) + + it('reads the config when none is supplied', async () => { + mockGetOpenCodeConfig.mockResolvedValue(config) + + const providers = await getProvidersWithModels(undefined) + + expect(mockGetOpenCodeConfig).toHaveBeenCalledTimes(1) + expect(providers.map((provider) => provider.id)).toEqual(['openai']) + }) + + it('degrades to no configured providers when the config read fails', async () => { + mockGetOpenCodeConfig.mockRejectedValue(new Error('no config file')) + + const providers = await getProvidersWithModels(undefined) + + expect(providers).toEqual([]) + }) +}) diff --git a/frontend/src/api/providers.ts b/frontend/src/api/providers.ts index 4e3bd6ca0..2b593c934 100644 --- a/frontend/src/api/providers.ts +++ b/frontend/src/api/providers.ts @@ -1,6 +1,7 @@ import { API_BASE_URL } from "@/config"; import { settingsApi } from "./settings"; import { fetchWrapper } from "./fetchWrapper"; +import type { OpenCodeConfigFile } from "./types/settings"; export type ProviderSource = "configured" | "local" | "builtin"; @@ -265,12 +266,12 @@ export async function toggleOpenCodeFavoriteModel(model: ModelSelection): Promis }); } -async function getConfiguredProviders(connectedIds: Set): Promise { +async function getConfiguredProviders(connectedIds: Set, config?: OpenCodeConfigFile): Promise { try { - const config = await settingsApi.getDefaultOpenCodeConfig(); - if (!config?.content?.provider) return []; + const resolvedConfig = config ?? await settingsApi.getOpenCodeConfig(); + if (!resolvedConfig.content.provider) return []; - const configProviders = config.content.provider as Record; + const configProviders = resolvedConfig.content.provider as Record; const result: ProviderWithModels[] = []; for (const [providerId, providerConfig] of Object.entries(configProviders)) { @@ -314,11 +315,11 @@ async function getConfiguredProviders(connectedIds: Set): Promise { +export async function getProvidersWithModels(directory?: string, config?: OpenCodeConfigFile): Promise { const { providers: builtinProviders, connected } = await getProviders(directory); const connectedIds = new Set(connected); - const configuredProviders = await getConfiguredProviders(connectedIds); + const configuredProviders = await getConfiguredProviders(connectedIds, config); const configuredIds = new Set(configuredProviders.map((p) => p.id)); const builtinResult: ProviderWithModels[] = builtinProviders diff --git a/frontend/src/api/repos.ts b/frontend/src/api/repos.ts index 651a6d1c9..7bc495fc3 100644 --- a/frontend/src/api/repos.ts +++ b/frontend/src/api/repos.ts @@ -8,7 +8,6 @@ export interface CreateRepoOptions { localPath?: string branch?: string directoryName?: string - openCodeConfigName?: string useWorktree?: boolean skipSSHVerification?: boolean baseBranch?: string @@ -86,11 +85,9 @@ export async function deleteRepo(id: number): Promise { }) } -export async function startServer(id: number, openCodeConfigName?: string): Promise { +export async function startServer(id: number): Promise { return fetchWrapper(`${API_BASE_URL}/api/repos/${id}/server/start`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ openCodeConfigName }), }) } @@ -106,14 +103,6 @@ export async function pullRepo(id: number): Promise { }) } -export async function switchRepoConfig(id: number, configName: string): Promise { - return fetchWrapper(`${API_BASE_URL}/api/repos/${id}/config/switch`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ configName }), - }) -} - export async function updateRepoGitCredential(id: number, credentialId?: string): Promise { return fetchWrapper(`${API_BASE_URL}/api/repos/${id}/git-credential`, { method: 'PATCH', diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index 6781ed046..60ddd8f51 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -1,9 +1,8 @@ import type { SettingsResponse, UpdateSettingsRequest, - OpenCodeConfig, - OpenCodeConfigResponse, - CreateOpenCodeConfigRequest, + OpenCodeConfigFile, + OpenCodeConfigSaveResponse, UpdateOpenCodeConfigRequest, OpenCodeImportStatus, SyncOpenCodeImportResponse, @@ -59,79 +58,20 @@ export const settingsApi = { }) }, - getOpenCodeConfigs: async (userId = DEFAULT_USER_ID): Promise => { - return fetchWrapper(`${API_BASE_URL}/api/settings/opencode-configs`, { - params: { userId }, - }) + getOpenCodeConfig: async (): Promise => { + return fetchWrapper(`${API_BASE_URL}/api/settings/opencode-config`) }, - createOpenCodeConfig: async ( - request: CreateOpenCodeConfigRequest, - userId = DEFAULT_USER_ID - ): Promise => { - return fetchWrapper(`${API_BASE_URL}/api/settings/opencode-configs`, { - method: 'POST', - params: { userId }, + updateOpenCodeConfig: async ( + request: UpdateOpenCodeConfigRequest + ): Promise => { + return fetchWrapper(`${API_BASE_URL}/api/settings/opencode-config`, { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request), }) }, - updateOpenCodeConfig: async ( - configName: string, - request: UpdateOpenCodeConfigRequest, - userId = DEFAULT_USER_ID - ): Promise => { - return fetchWrapper( - `${API_BASE_URL}/api/settings/opencode-configs/${encodeURIComponent(configName)}`, - { - method: 'PUT', - params: { userId }, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(request), - } - ) - }, - - deleteOpenCodeConfig: async ( - configName: string, - userId = DEFAULT_USER_ID - ): Promise => { - await fetchWrapper( - `${API_BASE_URL}/api/settings/opencode-configs/${encodeURIComponent(configName)}`, - { - method: 'DELETE', - params: { userId }, - } - ) - return true - }, - - setDefaultOpenCodeConfig: async ( - configName: string, - userId = DEFAULT_USER_ID - ): Promise => { - return fetchWrapper( - `${API_BASE_URL}/api/settings/opencode-configs/${encodeURIComponent(configName)}/set-default`, - { - method: 'POST', - params: { userId }, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - } - ) - }, - - getDefaultOpenCodeConfig: async (userId = DEFAULT_USER_ID): Promise => { - try { - return fetchWrapper(`${API_BASE_URL}/api/settings/opencode-configs/default`, { - params: { userId }, - }) - } catch { - return null - } - }, - discoverOpenCodeModels: async ( baseUrl: string, apiKey?: string, @@ -173,7 +113,7 @@ export const settingsApi = { } }, - rollbackOpenCodeConfig: async (): Promise<{ success: boolean; message: string; configName?: string }> => { + rollbackOpenCodeConfig: async (): Promise<{ success: boolean; message: string; fallback?: boolean }> => { return fetchWrapper(`${API_BASE_URL}/api/settings/opencode-rollback`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index d6043d049..222ce3d80 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -12,7 +12,6 @@ export interface Repo { clonedAt: number lastPulled?: number lastAccessedAt?: number - openCodeConfigName?: string gitCredentialId?: string isWorktree?: boolean isLocal?: boolean diff --git a/frontend/src/api/types/settings.ts b/frontend/src/api/types/settings.ts index fe68ca4da..3a2f49341 100644 --- a/frontend/src/api/types/settings.ts +++ b/frontend/src/api/types/settings.ts @@ -8,7 +8,8 @@ import { DEFAULT_SERVER_ENV_VARS, type TTSConfig, type STTConfig, - type OpenCodeConfigContent, + type OpenCodeConfigFile, + type UpdateOpenCodeConfigRequest, type ModelConfig, type ProviderConfig, type SandboxPreferences, @@ -21,7 +22,7 @@ import { } from '@opencode-manager/shared' import type { NotificationPreferences } from '@opencode-manager/shared/types' -export type { TTSConfig, STTConfig, OpenCodeConfigContent, ModelConfig, ProviderConfig, SandboxPreferences, NotificationPreferences, SkillFileInfo, CreateSkillRequest, UpdateSkillRequest, SkillScope, InstallSkillFromGithubRequest, InstallSkillResponse } +export type { TTSConfig, STTConfig, OpenCodeConfigFile, UpdateOpenCodeConfigRequest, ModelConfig, ProviderConfig, SandboxPreferences, NotificationPreferences, SkillFileInfo, CreateSkillRequest, UpdateSkillRequest, SkillScope, InstallSkillFromGithubRequest, InstallSkillResponse } export { DEFAULT_TTS_CONFIG, DEFAULT_STT_CONFIG, DEFAULT_KEYBOARD_SHORTCUTS, DEFAULT_USER_PREFERENCES, DEFAULT_LEADER_KEY, BLOCKED_SERVER_ENV_KEYS, DEFAULT_SERVER_ENV_VARS } export interface CustomCommand { @@ -85,37 +86,9 @@ export interface UpdateSettingsRequest { preferences: Partial } -export interface OpenCodeConfig { - id: number - name: string - content: Record - rawContent?: string - validationIssues?: Array<{ - path: string - message: string - }> - removedFields?: string[] +export interface OpenCodeConfigSaveResponse extends OpenCodeConfigFile { restartRequired?: boolean - isValid: boolean - isDefault: boolean - createdAt: number - updatedAt: number -} - -export interface CreateOpenCodeConfigRequest { - name: string - content: OpenCodeConfigContent | string - isDefault?: boolean -} - -export interface UpdateOpenCodeConfigRequest { - content: OpenCodeConfigContent | string - isDefault?: boolean -} - -export interface OpenCodeConfigResponse { - configs: OpenCodeConfig[] - defaultConfig: OpenCodeConfig | null + removedFields?: string[] } export interface OpenCodeImportStatus { diff --git a/frontend/src/components/model/ModelSelectDialog.tsx b/frontend/src/components/model/ModelSelectDialog.tsx index 966115c70..2142140e4 100644 --- a/frontend/src/components/model/ModelSelectDialog.tsx +++ b/frontend/src/components/model/ModelSelectDialog.tsx @@ -15,6 +15,7 @@ import { formatModelName, formatProviderName, } from "@/api/providers"; +import { useOpenCodeConfigFile } from "@/hooks/useOpenCodeConfigFile"; import { useModelSelection } from "@/hooks/useModelSelection"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import type { Model, ProviderWithModels } from "@/api/providers"; @@ -349,10 +350,11 @@ export function ModelSelectDialog({ const { modelString, setModel, toggleFavorite, recentModels, favoriteModels } = useModelSelection(opcodeUrl, directory); const currentModel = modelString || ""; + const { data: config, isLoading: isConfigLoading } = useOpenCodeConfigFile(open); const { data: allProviders = [], isLoading: loading } = useQuery({ queryKey: ["providers-with-models", opcodeUrl, directory], - queryFn: () => getProvidersWithModels(directory), - enabled: open, + queryFn: () => getProvidersWithModels(directory, config), + enabled: open && !isConfigLoading, staleTime: 5 * 60 * 1000, gcTime: 10 * 60 * 1000, placeholderData: keepPreviousData, diff --git a/frontend/src/components/repo/SwitchConfigDialog.tsx b/frontend/src/components/repo/SwitchConfigDialog.tsx deleted file mode 100644 index 744c90f83..000000000 --- a/frontend/src/components/repo/SwitchConfigDialog.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { useState, useEffect } from 'react' -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { AlertCircle, Loader2 } from 'lucide-react' -import { settingsApi } from '@/api/settings' -import * as reposApi from '@/api/repos' -import type { OpenCodeConfig } from '@/api/types/settings' - -interface SwitchConfigDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - repoId: number - currentConfigName?: string - onConfigSwitched: (configName: string) => void -} - -export function SwitchConfigDialog({ - open, - onOpenChange, - repoId, - currentConfigName, - onConfigSwitched, -}: SwitchConfigDialogProps) { - const [configs, setConfigs] = useState([]) - const [selectedConfig, setSelectedConfig] = useState('') - const [loading, setLoading] = useState(true) - const [switching, setSwitching] = useState(false) - const [error, setError] = useState(null) - - useEffect(() => { - if (!open) return - - const fetchConfigs = async () => { - try { - setLoading(true) - setError(null) - const response = await settingsApi.getOpenCodeConfigs() - setConfigs(response.configs || []) - setSelectedConfig(currentConfigName || '') - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load configs') - } finally { - setLoading(false) - } - } - - fetchConfigs() - }, [open, currentConfigName]) - - const handleSwitch = async () => { - if (!selectedConfig) { - setError('Please select a config') - return - } - - if (selectedConfig === currentConfigName) { - onOpenChange(false) - return - } - - try { - setSwitching(true) - setError(null) - await reposApi.switchRepoConfig(repoId, selectedConfig) - onConfigSwitched(selectedConfig) - onOpenChange(false) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to switch config') - } finally { - setSwitching(false) - } - } - - return ( - - - - Switch Config - - Select a different OpenCode configuration for this repository - - - -
- {currentConfigName && ( -
- Current config: {currentConfigName} -
- )} - - {loading ? ( -
- - Loading configs... -
- ) : configs.length === 0 ? ( -
No configs available
- ) : ( - - )} - - {error && ( -
- -

{error}

-
- )} - -
- - -
-
-
-
- ) -} diff --git a/frontend/src/components/repo/repo-list-state.test.ts b/frontend/src/components/repo/repo-list-state.test.ts index 9f38dc7cc..d4535a5c5 100644 --- a/frontend/src/components/repo/repo-list-state.test.ts +++ b/frontend/src/components/repo/repo-list-state.test.ts @@ -25,7 +25,6 @@ const createMockRepo = (overrides: Partial = {}): Repo => ({ clonedAt: Date.now() - 100000, lastPulled: undefined, lastAccessedAt: undefined, - openCodeConfigName: undefined, isWorktree: false, isLocal: false, ...overrides, diff --git a/frontend/src/components/schedules/ScheduleJobDialog.providers.test.tsx b/frontend/src/components/schedules/ScheduleJobDialog.providers.test.tsx new file mode 100644 index 000000000..a4fa509e7 --- /dev/null +++ b/frontend/src/components/schedules/ScheduleJobDialog.providers.test.tsx @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { ScheduleJobDialog } from './ScheduleJobDialog' +import { OPEN_CODE_CONFIG_QUERY_KEY } from '@/hooks/useOpenCodeConfigFile' +import type { OpenCodeConfigFile } from '@/api/types/settings' + +Element.prototype.scrollIntoView = vi.fn() + +const { mockGetProvidersWithModels, mockGetOpenCodeConfig } = vi.hoisted(() => ({ + mockGetProvidersWithModels: vi.fn(), + mockGetOpenCodeConfig: vi.fn(), +})) + +vi.mock('@/hooks/usePromptTemplates', () => ({ + usePromptTemplates: () => ({ data: [], isLoading: false }), + useCreatePromptTemplate: () => ({ mutate: vi.fn(), isPending: false }), + useUpdatePromptTemplate: () => ({ mutate: vi.fn(), isPending: false }), + useDeletePromptTemplate: () => ({ mutate: vi.fn(), isPending: false }), +})) + +vi.mock('@/api/providers', () => ({ + getProvidersWithModels: mockGetProvidersWithModels, +})) + +vi.mock('@/api/opencode', () => ({ + createOpenCodeClient: () => ({ + listAgents: () => Promise.resolve([]), + getConfig: () => Promise.resolve(null), + }), +})) + +vi.mock('@/api/settings', () => ({ + settingsApi: { + getOpenCodeConfig: mockGetOpenCodeConfig, + listManagedSkills: () => Promise.resolve([]), + }, +})) + +vi.mock('@/api/repos', () => ({ + listRepos: () => Promise.resolve([]), + listBranches: () => Promise.resolve({ branches: [], status: { ahead: 0, behind: 0 } }), +})) + +const config: OpenCodeConfigFile = { + path: '/workspace/.opencode/opencode.json', + isValid: true, + updatedAt: 1, + rawContent: '{}', + content: {}, +} + +describe('ScheduleJobDialog — shared config query', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetProvidersWithModels.mockResolvedValue([]) + mockGetOpenCodeConfig.mockResolvedValue(config) + }) + + it('feeds the cached shared config into the provider listing', async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + queryClient.setQueryDefaults(OPEN_CODE_CONFIG_QUERY_KEY, { staleTime: Infinity }) + queryClient.setQueryData(OPEN_CODE_CONFIG_QUERY_KEY, config) + + render( + , + { + wrapper: ({ children }) => ( + {children} + ), + }, + ) + + await waitFor(() => expect(mockGetProvidersWithModels).toHaveBeenCalled()) + expect(mockGetProvidersWithModels).toHaveBeenCalledWith(undefined, config) + expect(mockGetOpenCodeConfig).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/schedules/ScheduleJobDialog.tsx b/frontend/src/components/schedules/ScheduleJobDialog.tsx index 3fb962ced..ccef6f141 100644 --- a/frontend/src/components/schedules/ScheduleJobDialog.tsx +++ b/frontend/src/components/schedules/ScheduleJobDialog.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useQuery } from '@tanstack/react-query' import type { CreateScheduleJobRequest, PromptTemplate, ScheduleJob } from '@opencode-manager/shared/types' import { getProvidersWithModels } from '@/api/providers' +import { useOpenCodeConfigFile } from '@/hooks/useOpenCodeConfigFile' import { createOpenCodeClient } from '@/api/opencode' import { settingsApi } from '@/api/settings' import { listRepos, listBranches } from '@/api/repos' @@ -74,10 +75,11 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit, const { data: templates = EMPTY_TEMPLATES } = usePromptTemplates() const deleteTemplateMutation = useDeletePromptTemplate() + const { data: config, isLoading: isConfigLoading } = useOpenCodeConfigFile(open) const { data: providerModels = [] } = useQuery({ queryKey: ['providers-with-models', 'schedule-dialog'], - queryFn: () => getProvidersWithModels(), - enabled: open, + queryFn: () => getProvidersWithModels(undefined, config), + enabled: open && !isConfigLoading, staleTime: 5 * 60 * 1000, }) diff --git a/frontend/src/components/settings/AddMcpServerDialog.test.tsx b/frontend/src/components/settings/AddMcpServerDialog.test.tsx new file mode 100644 index 000000000..d17c3b0e9 --- /dev/null +++ b/frontend/src/components/settings/AddMcpServerDialog.test.tsx @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { AddMcpServerDialog } from './AddMcpServerDialog' +import type { OpenCodeConfigFile } from '@/api/types/settings' + +const { + mockGetOpenCodeConfig, + mockUpdateOpenCodeConfig, + mockAddServerAsync, +} = vi.hoisted(() => ({ + mockGetOpenCodeConfig: vi.fn(), + mockUpdateOpenCodeConfig: vi.fn(), + mockAddServerAsync: vi.fn(), +})) + +vi.mock('@/api/settings', () => ({ + settingsApi: { + getOpenCodeConfig: mockGetOpenCodeConfig, + updateOpenCodeConfig: mockUpdateOpenCodeConfig, + }, +})) + +vi.mock('@/hooks/useMcpServers', () => ({ + useMcpServers: () => ({ addServerAsync: mockAddServerAsync, isAddingServer: false }), +})) + +vi.mock('@/lib/toast', () => ({ + showToast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), loading: vi.fn(), warning: vi.fn(), dismiss: vi.fn() }, +})) + +const config: OpenCodeConfigFile = { + path: '/workspace/.opencode/opencode.json', + isValid: true, + updatedAt: 1, + rawContent: '{}', + content: {}, +} + +function renderDialog(onUpdate: (content: Record) => Promise) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + + , + ) +} + +describe('AddMcpServerDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetOpenCodeConfig.mockResolvedValue(config) + mockUpdateOpenCodeConfig.mockResolvedValue(config) + mockAddServerAsync.mockResolvedValue(undefined) + }) + + it('issues exactly one config update through the owner callback and never writes directly', async () => { + const onUpdate = vi.fn<(content: Record) => Promise>().mockResolvedValue(undefined) + const user = userEvent.setup() + renderDialog(onUpdate) + + await user.type(screen.getByLabelText('Server ID'), 'filesystem') + await user.type(screen.getByLabelText('Command'), 'npx server-filesystem /tmp') + await user.click(screen.getByRole('button', { name: 'Add MCP Server' })) + + await waitFor(() => expect(onUpdate).toHaveBeenCalledTimes(1)) + expect(mockUpdateOpenCodeConfig).not.toHaveBeenCalled() + expect(onUpdate).toHaveBeenCalledWith({ + mcp: { + filesystem: { + type: 'local', + enabled: true, + command: ['npx', 'server-filesystem', '/tmp'], + }, + }, + }) + expect(mockAddServerAsync).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/components/settings/AddMcpServerDialog.tsx b/frontend/src/components/settings/AddMcpServerDialog.tsx index 2f7afbe2a..fabfa6b30 100644 --- a/frontend/src/components/settings/AddMcpServerDialog.tsx +++ b/frontend/src/components/settings/AddMcpServerDialog.tsx @@ -13,8 +13,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' interface AddMcpServerDialogProps { open: boolean onOpenChange: (open: boolean) => void - configName?: string - onUpdate?: (configName: string, content: Record) => Promise + onUpdate: (content: Record) => Promise } interface EnvironmentVariable { @@ -40,10 +39,8 @@ export function AddMcpServerDialog({ open, onOpenChange, onUpdate }: AddMcpServe const addMcpServerMutation = useMutation({ mutationFn: async () => { - const config = await settingsApi.getDefaultOpenCodeConfig() - if (!config) throw new Error('No default config found') - - const currentMcp = (config.content?.mcp as Record) || {} + const config = await settingsApi.getOpenCodeConfig() + const currentMcp = (config.content.mcp as Record) || {} const mcpConfig: Record = { type: serverType, @@ -93,7 +90,7 @@ export function AddMcpServerDialog({ open, onOpenChange, onUpdate }: AddMcpServe }, } - await settingsApi.updateOpenCodeConfig(config.name, { content: updatedConfig }) + await onUpdate(updatedConfig) if (enabled) { const buildOauthField = () => { @@ -126,15 +123,7 @@ export function AddMcpServerDialog({ open, onOpenChange, onUpdate }: AddMcpServe }) } }, - onSuccess: async () => { - if (onUpdate) { - const config = await settingsApi.getDefaultOpenCodeConfig() - if (config) { - await onUpdate(config.name, config.content) - } - } else { - queryClient.invalidateQueries({ queryKey: ['opencode-config'] }) - } + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['mcp-status'] }) handleClose() }, diff --git a/frontend/src/components/settings/AgentDialog.tsx b/frontend/src/components/settings/AgentDialog.tsx index 20dc7d824..ca157abe9 100644 --- a/frontend/src/components/settings/AgentDialog.tsx +++ b/frontend/src/components/settings/AgentDialog.tsx @@ -12,6 +12,7 @@ import { Switch } from '@/components/ui/switch' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Combobox, type ComboboxOption } from '@/components/ui/combobox' import { getProvidersWithModels } from '@/api/providers' +import { useOpenCodeConfigFile } from '@/hooks/useOpenCodeConfigFile' const agentFormSchema = z.object({ name: z.string().min(1, 'Agent name is required').regex(/^[a-z0-9-]+$/, 'Must be lowercase letters, numbers, and hyphens only'), @@ -66,10 +67,11 @@ interface AgentDialogProps { } export function AgentDialog({ open, onOpenChange, onSubmit, editingAgent }: AgentDialogProps) { + const { data: config, isLoading: isConfigLoading } = useOpenCodeConfigFile(open) const { data: providers = [] } = useQuery({ queryKey: ['providers-with-models'], - queryFn: () => getProvidersWithModels(), - enabled: open, + queryFn: () => getProvidersWithModels(undefined, config), + enabled: open && !isConfigLoading, staleTime: 5 * 60 * 1000, }) diff --git a/frontend/src/components/settings/CreateConfigDialog.tsx b/frontend/src/components/settings/CreateConfigDialog.tsx deleted file mode 100644 index 193f3f89f..000000000 --- a/frontend/src/components/settings/CreateConfigDialog.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { useState, useRef } from 'react' -import { Loader2 } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Textarea } from '@/components/ui/textarea' -import { Switch } from '@/components/ui/switch' -import { parseJsonc } from '@/lib/jsonc' - -interface CreateConfigDialogProps { - isOpen: boolean - onOpenChange: (open: boolean) => void - onCreate: (name: string, content: string, isDefault: boolean) => Promise - isUpdating: boolean -} - -export function CreateConfigDialog({ isOpen, onOpenChange, onCreate, isUpdating }: CreateConfigDialogProps) { - const [name, setName] = useState('') - const [content, setContent] = useState('') - const [isDefault, setIsDefault] = useState(false) - const [error, setError] = useState('') - const [errorLine, setErrorLine] = useState(null) - const textareaRef = useRef(null) - - const handleSubmit = async (event?: React.MouseEvent) => { - event?.preventDefault() - event?.stopPropagation() - - if (!name.trim() || !content.trim()) return - - try { - await onCreate(name.trim(), content.trim(), isDefault) - setName('') - setContent('') - setIsDefault(false) - setError('') - setErrorLine(null) - } catch (error: unknown) { - if (error instanceof SyntaxError) { - const match = error.message.match(/line (\d+)/i) - const line = match ? parseInt(match[1]) : null - setErrorLine(line) - setError(`JSON Error: ${error.message}`) - if (line && textareaRef.current) { - highlightErrorLine(textareaRef.current, line) - } - } else if (error instanceof Error) { - setError(error.message) - setErrorLine(null) - } else { - setError('Failed to create configuration') - setErrorLine(null) - } - } - } - - const handleFileUpload = (event: React.ChangeEvent) => { - const file = event.target.files?.[0] - if (!file) return - - const reader = new FileReader() - reader.onload = (e) => { - const fileContent = e.target?.result as string - try { - parseJsonc(fileContent) - setContent(fileContent) - setName(file.name.replace('.json', '').replace('.jsonc', '')) - setError('') - setErrorLine(null) - } catch (err) { - if (err instanceof SyntaxError) { - const match = err.message.match(/line (\d+)/i) - const line = match ? parseInt(match[1]) : null - setErrorLine(line) - setError(`Invalid JSON/JSONC file: ${err.message}`) - } else { - setError('Invalid JSON/JSONC file') - setErrorLine(null) - } - } - } - reader.readAsText(file) - } - - const highlightErrorLine = (textarea: HTMLTextAreaElement, line: number) => { - const lines = textarea.value.split('\n') - if (line > lines.length) return - - let charIndex = 0 - for (let i = 0; i < line - 1; i++) { - charIndex += lines[i].length + 1 - } - - textarea.focus() - textarea.setSelectionRange(charIndex, charIndex + lines[line - 1].length) - } - - const handleContentChange = (value: string) => { - setContent(value) - setError('') - setErrorLine(null) - } - - return ( - - - - Create OpenCode Config - -
-
- - setName(e.target.value)} - placeholder="my-config" - /> -
- -
- - -
- -
- -