diff --git a/backend/package.json b/backend/package.json index 830ee2755..cfa6147a9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,8 +9,8 @@ "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:vitest": "vitest run", + "test:bun": "bun test test/routes/internal-sandbox.test.ts src/routes/repos.test.ts", + "test:vitest": "vitest run --coverage", "test:ui": "vitest --ui", "test:watch": "vitest --watch", "lint": "eslint . --ext .ts", diff --git a/backend/src/db/migrations/012-opencode-model-state.ts b/backend/src/db/migrations/012-opencode-model-state.ts index 2d584e32f..4fd2ebe96 100644 --- a/backend/src/db/migrations/012-opencode-model-state.ts +++ b/backend/src/db/migrations/012-opencode-model-state.ts @@ -1,11 +1,21 @@ import type { Migration } from '../migration-runner' -import { ensureOpenCodeModelStateTable } from '../model-state' const migration: Migration = { version: 12, name: 'opencode-model-state', up(db) { - ensureOpenCodeModelStateTable(db) + db.run(` + CREATE TABLE IF NOT EXISTS opencode_model_state ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL DEFAULT 'default', + recent TEXT NOT NULL DEFAULT '[]', + favorite TEXT NOT NULL DEFAULT '[]', + variant TEXT NOT NULL DEFAULT '{}', + updated_at INTEGER NOT NULL, + UNIQUE(user_id) + ) + `) + db.run('CREATE INDEX IF NOT EXISTS idx_opencode_model_state_user ON opencode_model_state(user_id)') }, down(db) { db.run('DROP TABLE IF EXISTS opencode_model_state') 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..434eb7647 --- /dev/null +++ b/backend/src/db/migrations/019-drop-opencode-configs.ts @@ -0,0 +1,98 @@ +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { randomBytes } from 'node:crypto' +import os from 'node:os' +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' + +function firstExistingConfigSourcePath(): string | null { + const candidates = [ + process.env.OPENCODE_IMPORT_CONFIG_PATH, + path.join(os.homedir(), '.config', 'opencode', 'opencode.json'), + ] + .filter((value): value is string => Boolean(value)) + .map((value) => path.resolve(value)) + + return Array.from(new Set(candidates)).find((candidate) => existsSync(candidate)) ?? null +} + +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) && !firstExistingConfigSourcePath()) { + 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/020-drop-opencode-model-state.ts b/backend/src/db/migrations/020-drop-opencode-model-state.ts new file mode 100644 index 000000000..06239c333 --- /dev/null +++ b/backend/src/db/migrations/020-drop-opencode-model-state.ts @@ -0,0 +1,85 @@ +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { getOpenCodeModelStatePath } from '@opencode-manager/shared/config/env' +import type { Migration } from '../migration-runner' +import { logger } from '../../utils/logger' + +interface ModelStateRow { + recent: string + favorite: string + variant: string + updated_at: number +} + +function readDefaultRow(db: Parameters[0]): ModelStateRow | undefined { + try { + return db.prepare("SELECT recent, favorite, variant, updated_at FROM opencode_model_state WHERE user_id = 'default'").get() as ModelStateRow | undefined + } catch { + return undefined + } +} + +function parseJson(value: string, fallback: T): T { + try { + return JSON.parse(value) as T + } catch { + return fallback + } +} + +function restoreModelStateFile(row: ModelStateRow): void { + const filePath = getOpenCodeModelStatePath() + const fileExists = existsSync(filePath) + + if (fileExists && statSync(filePath).mtimeMs >= row.updated_at) { + return + } + + const existing = fileExists + ? parseJson>(readFileSync(filePath, 'utf8'), {}) + : {} + + mkdirSync(path.dirname(filePath), { recursive: true }) + writeFileSync(filePath, JSON.stringify({ + ...existing, + recent: parseJson(row.recent, []), + favorite: parseJson(row.favorite, []), + variant: parseJson>(row.variant, {}), + }, null, 2)) +} + +const migration: Migration = { + version: 20, + name: 'drop-opencode-model-state', + + up(db) { + const row = readDefaultRow(db) + if (row) { + try { + restoreModelStateFile(row) + } catch (error) { + logger.warn('Failed to restore the OpenCode model state file', error) + } + } + + db.run('DROP INDEX IF EXISTS idx_opencode_model_state_user') + db.run('DROP TABLE IF EXISTS opencode_model_state') + }, + + down(db) { + db.run(` + CREATE TABLE IF NOT EXISTS opencode_model_state ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL DEFAULT 'default', + recent TEXT NOT NULL DEFAULT '[]', + favorite TEXT NOT NULL DEFAULT '[]', + variant TEXT NOT NULL DEFAULT '{}', + updated_at INTEGER NOT NULL, + UNIQUE(user_id) + ) + `) + db.run('CREATE INDEX IF NOT EXISTS idx_opencode_model_state_user ON opencode_model_state(user_id)') + }, +} + +export default migration diff --git a/backend/src/db/migrations/index.ts b/backend/src/db/migrations/index.ts index 514d74ca7..bd1ab38dd 100644 --- a/backend/src/db/migrations/index.ts +++ b/backend/src/db/migrations/index.ts @@ -17,6 +17,8 @@ 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' +import migration020 from './020-drop-opencode-model-state' export const allMigrations: Migration[] = [ migration001, @@ -37,4 +39,6 @@ export const allMigrations: Migration[] = [ migration016, migration017, migration018, + migration019, + migration020, ] diff --git a/backend/src/db/model-state.test.ts b/backend/src/db/model-state.test.ts deleted file mode 100644 index b3a6a3024..000000000 --- a/backend/src/db/model-state.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest' -import { Database } from 'bun:sqlite' -import { migrate } from './migration-runner' -import { allMigrations } from './migrations' -import { - getOpenCodeModelState, - addRecentOpenCodeModel, - toggleFavoriteOpenCodeModel, - setOpenCodeVariant, - MAX_RECENT_MODELS, -} from './model-state' - -function createTestDb(): Database { - const db = new Database(':memory:') - migrate(db, allMigrations) - return db -} - -describe('model-state', () => { - let db: Database - - beforeEach(() => { - db = createTestDb() - }) - - describe('getOpenCodeModelState', () => { - it('returns empty defaults when no row exists', () => { - const state = getOpenCodeModelState(db) - expect(state).toEqual({ recent: [], favorite: [], variant: {} }) - }) - - it('creates the model state table when an existing database is missing it', () => { - db.run('DROP TABLE opencode_model_state') - - const state = getOpenCodeModelState(db) - const table = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'opencode_model_state'").get() - - expect(state).toEqual({ recent: [], favorite: [], variant: {} }) - expect(table).toBeTruthy() - }) - - it('returns defaults with explicit userId when no row exists', () => { - const state = getOpenCodeModelState(db, 'user123') - expect(state).toEqual({ recent: [], favorite: [], variant: {} }) - }) - }) - - describe('addRecentOpenCodeModel', () => { - it('inserts new state and returns the model in recent[0]', () => { - const model = { providerID: 'anthropic', modelID: 'claude-sonnet-4-20250514' } - const state = addRecentOpenCodeModel(db, model) - expect(state.recent).toHaveLength(1) - expect(state.recent[0]).toEqual(model) - }) - - it('deduplicates re-selections (same model added twice → length 1, model at index 0)', () => { - const model = { providerID: 'openai', modelID: 'gpt-4o' } - addRecentOpenCodeModel(db, model) - const state = addRecentOpenCodeModel(db, model) - expect(state.recent).toHaveLength(1) - expect(state.recent[0]).toEqual(model) - }) - - it('caps at MAX_RECENT_MODELS (insert 12 distinct, expect 10)', () => { - for (let i = 0; i < 12; i++) { - addRecentOpenCodeModel(db, { providerID: `provider-${i}`, modelID: `model-${i}` }) - } - const state = getOpenCodeModelState(db) - expect(state.recent).toHaveLength(MAX_RECENT_MODELS) - expect(state.recent[0]).toEqual({ providerID: 'provider-11', modelID: 'model-11' }) - }) - }) - - describe('toggleFavoriteOpenCodeModel', () => { - it('adds when missing', () => { - const model = { providerID: 'anthropic', modelID: 'claude' } - const state = toggleFavoriteOpenCodeModel(db, model) - expect(state.favorite).toHaveLength(1) - expect(state.favorite[0]).toEqual(model) - }) - - it('removes when present', () => { - const model = { providerID: 'openai', modelID: 'gpt-4' } - toggleFavoriteOpenCodeModel(db, model) - const state = toggleFavoriteOpenCodeModel(db, model) - expect(state.favorite).toHaveLength(0) - }) - }) - - describe('setOpenCodeVariant', () => { - it('adds variant entry', () => { - const state = setOpenCodeVariant(db, 'key1', 'variant1') - expect(state.variant.key1).toBe('variant1') - }) - - it('updates variant entry', () => { - setOpenCodeVariant(db, 'key1', 'variant1') - const state = setOpenCodeVariant(db, 'key1', 'variant2') - expect(state.variant.key1).toBe('variant2') - }) - - it('deletes variant when undefined', () => { - setOpenCodeVariant(db, 'key1', 'variant1') - const state = setOpenCodeVariant(db, 'key1', undefined) - expect(state.variant.key1).toBeUndefined() - }) - }) - - it('corrupt JSON in recent column → getOpenCodeModelState returns [] for recent, preserves valid favorite', () => { - const now = Date.now() - db.prepare(` - INSERT INTO opencode_model_state(user_id, recent, favorite, variant, updated_at) - VALUES(?,?,?,?,?) - ON CONFLICT(user_id) DO UPDATE SET recent=excluded.recent, favorite=excluded.favorite, variant=excluded.variant, updated_at=excluded.updated_at - `).run('default', '{ invalid json }', JSON.stringify([{ providerID: 'test', modelID: 'test' }]), '{}', now) - - const state = getOpenCodeModelState(db) - expect(state.recent).toEqual([]) - expect(state.favorite).toHaveLength(1) - expect(state.favorite[0]).toEqual({ providerID: 'test', modelID: 'test' }) - }) - - it('50 concurrent addRecentOpenCodeModel calls → final recent.length <= MAX_RECENT_MODELS, no exceptions, all entries unique', async () => { - const db = createTestDb() - const numOps = 50 - - const operations = Array.from({ length: numOps }, (_, i) => - addRecentOpenCodeModel(db, { providerID: `provider-${i}`, modelID: `model-${i}` }), - ) - - await Promise.all(operations) - const finalState = getOpenCodeModelState(db) - - expect(finalState.recent.length).toBeLessThanOrEqual(MAX_RECENT_MODELS) - expect(finalState.recent.length).toBeGreaterThan(0) - - const uniqueKeys = new Set(finalState.recent.map((m) => `${m.providerID}/${m.modelID}`)) - expect(uniqueKeys.size).toBe(finalState.recent.length) - }) -}) diff --git a/backend/src/db/model-state.ts b/backend/src/db/model-state.ts deleted file mode 100644 index 6d58a6cc5..000000000 --- a/backend/src/db/model-state.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { Database } from 'bun:sqlite' -import { logger } from '../utils/logger' - -export interface ModelSelectionRecord { - providerID: string - modelID: string -} - -export interface OpenCodeModelStateRecord { - recent: ModelSelectionRecord[] - favorite: ModelSelectionRecord[] - variant: Record -} - -export const MAX_RECENT_MODELS = 10 - -const EMPTY_STATE: OpenCodeModelStateRecord = { recent: [], favorite: [], variant: {} } - -export function ensureOpenCodeModelStateTable(db: Database): void { - db.run(` - CREATE TABLE IF NOT EXISTS opencode_model_state ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL DEFAULT 'default', - recent TEXT NOT NULL DEFAULT '[]', - favorite TEXT NOT NULL DEFAULT '[]', - variant TEXT NOT NULL DEFAULT '{}', - updated_at INTEGER NOT NULL, - UNIQUE(user_id) - ) - `) - db.run('CREATE INDEX IF NOT EXISTS idx_opencode_model_state_user ON opencode_model_state(user_id)') -} - -function parseJsonSafe(json: string, fallback: T): T { - try { - return JSON.parse(json) as T - } catch (error) { - logger.warn(`Failed to parse JSON: ${error instanceof Error ? error.message : String(error)}`) - return fallback - } -} - -export function getOpenCodeModelState(db: Database, userId = 'default'): OpenCodeModelStateRecord { - ensureOpenCodeModelStateTable(db) - - const row = db.prepare('SELECT recent, favorite, variant FROM opencode_model_state WHERE user_id = ?').get(userId) as - | { recent: string; favorite: string; variant: string } - | undefined - - if (!row) { - return EMPTY_STATE - } - - const recent = parseJsonSafe(row.recent, []) - const favorite = parseJsonSafe(row.favorite, []) - const variant = parseJsonSafe>(row.variant, {}) - - return { recent, favorite, variant } -} - -export function addRecentOpenCodeModel( - db: Database, - model: ModelSelectionRecord, - userId = 'default', -): OpenCodeModelStateRecord { - const insertMany = db.transaction(() => { - const current = getOpenCodeModelState(db, userId) - const deduped = [model, ...current.recent.filter(m => m.providerID !== model.providerID || m.modelID !== model.modelID)] - const sliced = deduped.slice(0, MAX_RECENT_MODELS) - const now = Date.now() - - db.prepare(` - INSERT INTO opencode_model_state(user_id, recent, favorite, variant, updated_at) - VALUES(?,?,?,?,?) - ON CONFLICT(user_id) DO UPDATE SET recent=excluded.recent, updated_at=excluded.updated_at - `).run(userId, JSON.stringify(sliced), JSON.stringify(current.favorite), JSON.stringify(current.variant), now) - - return { recent: sliced, favorite: current.favorite, variant: current.variant } - }) - - return insertMany() -} - -export function removeRecentOpenCodeModel( - db: Database, - model: ModelSelectionRecord, - userId = 'default', -): OpenCodeModelStateRecord { - const remove = db.transaction(() => { - const current = getOpenCodeModelState(db, userId) - const updated = current.recent.filter( - m => m.providerID !== model.providerID || m.modelID !== model.modelID, - ) - const now = Date.now() - - db.prepare(` - INSERT INTO opencode_model_state(user_id, recent, favorite, variant, updated_at) - VALUES(?,?,?,?,?) - ON CONFLICT(user_id) DO UPDATE SET recent=excluded.recent, updated_at=excluded.updated_at - `).run(userId, JSON.stringify(updated), JSON.stringify(current.favorite), JSON.stringify(current.variant), now) - - return { recent: updated, favorite: current.favorite, variant: current.variant } - }) - - return remove() -} - -export function toggleFavoriteOpenCodeModel( - db: Database, - model: ModelSelectionRecord, - userId = 'default', -): OpenCodeModelStateRecord { - const toggle = db.transaction(() => { - const current = getOpenCodeModelState(db, userId) - const exists = current.favorite.some( - m => m.providerID === model.providerID && m.modelID === model.modelID, - ) - - const updated = exists - ? current.favorite.filter(m => m.providerID !== model.providerID || m.modelID !== model.modelID) - : [...current.favorite, model] - - const now = Date.now() - - db.prepare(` - INSERT INTO opencode_model_state(user_id, recent, favorite, variant, updated_at) - VALUES(?,?,?,?,?) - ON CONFLICT(user_id) DO UPDATE SET favorite=excluded.favorite, updated_at=excluded.updated_at - `).run(userId, JSON.stringify(current.recent), JSON.stringify(updated), JSON.stringify(current.variant), now) - - return { recent: current.recent, favorite: updated, variant: current.variant } - }) - - return toggle() -} - -export function setOpenCodeVariant( - db: Database, - key: string, - variant: string | undefined, - userId = 'default', -): OpenCodeModelStateRecord { - const setVariant = db.transaction(() => { - const current = getOpenCodeModelState(db, userId) - const updated = { ...current.variant } - - if (variant === undefined) { - delete updated[key] - } else { - updated[key] = variant - } - - const now = Date.now() - - db.prepare(` - INSERT INTO opencode_model_state(user_id, recent, favorite, variant, updated_at) - VALUES(?,?,?,?,?) - ON CONFLICT(user_id) DO UPDATE SET variant=excluded.variant, updated_at=excluded.updated_at - `).run(userId, JSON.stringify(current.recent), JSON.stringify(current.favorite), JSON.stringify(updated), now) - - return { recent: current.recent, favorite: current.favorite, variant: updated } - }) - - return setVariant() -} 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/db/schema.ts b/backend/src/db/schema.ts index 9e53ef7b5..472dd7be5 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -3,7 +3,6 @@ import { logger } from '../utils/logger' import { dirname } from 'path' import { migrate } from './migration-runner' import { allMigrations } from './migrations' -import { ensureOpenCodeModelStateTable } from './model-state' import { ensureAssistantRepo } from './queries' import { mkdirSyncSafe } from '../utils/fs-safe' @@ -12,7 +11,6 @@ export function initializeDatabase(dbPath: string = './data/opencode.db'): Datab const db = new Database(dbPath) migrate(db, allMigrations) - ensureOpenCodeModelStateTable(db) db.prepare('INSERT OR IGNORE INTO user_preferences (user_id, preferences, updated_at) VALUES (?, ?, ?)') .run('default', '{}', Date.now()) diff --git a/backend/src/index.ts b/backend/src/index.ts index fd9f76e1e..817753fc8 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,23 +55,15 @@ 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 { - type OpenCodeModelStateRecord, -} from './db/model-state' - import { logger } from './utils/logger' import { getWorkspacePath, getReposPath, getConfigPath, - getOpenCodeConfigFilePath, getAgentsMdPath, getDatabasePath, ENV @@ -118,112 +110,31 @@ 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) - } - } - - 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') + logger.warn(`Failed to import OpenCode config from ${status.configSourcePath}`, error) } - 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) - logger.info('Created minimal seed config') -} - -async function backfillOpenCodeModelStateFromFile(): Promise { - try { - const modelStatePath = getModelStatePath() - const fileState = await readJsonSafe(modelStatePath, null) - - if (!fileState) { - return - } - - const existingRow = db.prepare('SELECT 1 FROM opencode_model_state WHERE user_id = ?').get('default') - if (existingRow) { - return - } - - const validated = ModelStateSchema.safeParse(fileState) - if (!validated.success) { - logger.warn('Model state file has invalid structure, skipping backfill', validated.error) - return - } - db.prepare( - 'INSERT INTO opencode_model_state(user_id, recent, favorite, variant, updated_at) VALUES(?,?,?,?,?)' - ).run( - 'default', - JSON.stringify(validated.data.recent), - JSON.stringify(validated.data.favorite), - JSON.stringify(validated.data.variant), - Date.now() - ) - - logger.info('Backfilled OpenCode model state from model.json to database') - } catch (error) { - logger.warn('Failed to backfill OpenCode model state from file:', error) - } + await writeOpenCodeConfigFile(OPENCODE_CONFIG_SEED) + logger.info('Created minimal seed config') } async function ensureHomeStateImported(): Promise { @@ -237,7 +148,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,17 +183,14 @@ try { await cleanupExpiredCache() await sweepStaleUploadSessions() - await ensureDefaultConfigExists() - await backfillOpenCodeModelStateFromFile() + await ensureOpenCodeConfigFileExists() await ensureHomeStateImported() await ensureDefaultAgentsMdExists() const settingsService = new SettingsService(db) settingsService.initializeLastKnownGoodConfig() - openCodeSupervisor = new OpenCodeSupervisor(opencodeServerManager, settingsService, { - userId: 'default' - }) + openCodeSupervisor = new OpenCodeSupervisor(opencodeServerManager, settingsService) await migrateGlobalSkills() @@ -364,11 +272,11 @@ 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()) -protectedApi.route('/providers', createProvidersRoutes(db, openCodeClient, openCodeSupervisor)) +protectedApi.route('/providers', createProvidersRoutes(openCodeClient, openCodeSupervisor)) protectedApi.route('/oauth', createOAuthRoutes(openCodeClient, openCodeSupervisor)) protectedApi.route('/tts', createTTSRoutes(db)) protectedApi.route('/stt', createSTTRoutes(db)) diff --git a/backend/src/routes/internal/index.ts b/backend/src/routes/internal/index.ts index 0a3282507..69fc943cc 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 { createOpenCodeConfigRoutes } 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', createOpenCodeConfigRoutes(settingsService, openCodeClient)) const repos = new Hono() repos.route('/', createInternalRepoRoutes(db, settingsService)) repos.route('/:id/schedules', createScheduleRoutes(scheduleService)) diff --git a/backend/src/routes/opencode-config.ts b/backend/src/routes/opencode-config.ts new file mode 100644 index 000000000..6b6bc0f7d --- /dev/null +++ b/backend/src/routes/opencode-config.ts @@ -0,0 +1,57 @@ +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 createOpenCodeConfigRoutes(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) => { + 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, + }) + 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..00e63012d 100644 --- a/backend/src/routes/providers.test.ts +++ b/backend/src/routes/providers.test.ts @@ -1,60 +1,53 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, beforeEach, vi } from 'vitest' import { Hono } from 'hono' -import { Database } from 'bun:sqlite' -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 { tmpdir } from 'node:os' import { createStubOpenCodeClient } from '../../test/helpers/stub-opencode-client' +import type { OpenCodeModelStateRecord } from '../services/opencode-model-state' + +const modelStateMock = vi.hoisted(() => ({ + readOpenCodeModelState: vi.fn(), + updateOpenCodeModelState: vi.fn(), +})) + +vi.mock('../services/opencode-model-state', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + readOpenCodeModelState: modelStateMock.readOpenCodeModelState, + updateOpenCodeModelState: modelStateMock.updateOpenCodeModelState, + } +}) -function createTestApp(db: Database): Hono { +function createTestApp(): Hono { const app = new Hono() - app.route('/providers', createProvidersRoutes(db, createStubOpenCodeClient())) + app.route('/providers', createProvidersRoutes(createStubOpenCodeClient())) return app } -function createTestDb(): Database { - const db = new Database(':memory:') - migrate(db, allMigrations) - return db -} - describe('providers routes', () => { - let db: Database let app: Hono - let tmpDir: string - let originalWorkspacePath: string | undefined - - beforeEach(async () => { - db = createTestDb() - app = createTestApp(db) - tmpDir = await mkdtemp(join(tmpdir(), 'providers-test-')) - originalWorkspacePath = process.env.WORKSPACE_PATH - process.env.WORKSPACE_PATH = tmpDir - - const { getModelStatePath } = await import('./providers') - const modelStatePath = getModelStatePath() - const modelStateDir = dirname(modelStatePath) - await mkdir(modelStateDir, { recursive: true }) - }) - - afterEach(async () => { - if (originalWorkspacePath) { - process.env.WORKSPACE_PATH = originalWorkspacePath - } else { - delete process.env.WORKSPACE_PATH - } - await rm(tmpDir, { recursive: true, force: true }) + let storedState: OpenCodeModelStateRecord + + beforeEach(() => { + vi.clearAllMocks() + storedState = { recent: [], favorite: [], variant: {} } + modelStateMock.readOpenCodeModelState.mockImplementation(async () => storedState) + modelStateMock.updateOpenCodeModelState.mockImplementation( + async (mutate: (state: OpenCodeModelStateRecord) => OpenCodeModelStateRecord) => { + storedState = mutate(storedState) + return storedState + }, + ) + app = createTestApp() }) describe('GET /model-state', () => { - it('on empty DB returns defaults', async () => { + it('returns the state owned by the service', async () => { const res = await app.request('/providers/model-state') expect(res.status).toBe(200) const data = (await res.json()) as { recent: unknown[]; favorite: unknown[]; variant: Record } expect(data).toEqual({ recent: [], favorite: [], variant: {} }) + expect(modelStateMock.readOpenCodeModelState).toHaveBeenCalledTimes(1) }) }) @@ -71,9 +64,27 @@ describe('providers routes', () => { expect(data.recent[0]).toEqual({ providerID: 'anthropic', modelID: 'claude' }) }) + it('with removeRecent removes the model', async () => { + storedState = { + recent: [{ providerID: 'openai', modelID: 'gpt-4' }], + favorite: [], + variant: {}, + } + + const res = await app.request('/providers/model-state', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ removeRecent: { providerID: 'openai', modelID: 'gpt-4' } }), + }) + + expect(res.status).toBe(200) + const data = (await res.json()) as { recent: Array<{ providerID: string; modelID: string }> } + expect(data.recent).toHaveLength(0) + }) + it('with favorite toggles favorite (add then remove)', async () => { const body = { favorite: { providerID: 'openai', modelID: 'gpt-4' } } - + const res1 = await app.request('/providers/model-state', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -102,12 +113,11 @@ describe('providers routes', () => { expect(res.status).toBe(400) const data = (await res.json()) as { error: string } expect(data.error).toBe('Invalid request data') + expect(modelStateMock.updateOpenCodeModelState).not.toHaveBeenCalled() }) - it('with corrupt model.json on disk still returns 200 and overwrites with valid JSON', async () => { - const { getModelStatePath } = await import('./providers') - const modelStatePath = getModelStatePath() - await writeFile(modelStatePath, '{ invalid json content }', 'utf8') + it('returns 500 when the service rejects', async () => { + modelStateMock.updateOpenCodeModelState.mockRejectedValueOnce(new Error('disk full')) const res = await app.request('/providers/model-state', { method: 'POST', @@ -115,38 +125,9 @@ describe('providers routes', () => { body: JSON.stringify({ recent: { providerID: 'test', modelID: 'test' } }), }) - expect(res.status).toBe(200) - 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 parsed = JSON.parse(fileContent) as { recent: unknown[] } - expect(parsed.recent).toHaveLength(1) - }) - - it('20 concurrent POST calls all return 200, final recent is valid and bounded', async () => { - const numOps = 20 - - const requests = Array.from({ length: numOps }, (_, i) => - app.request('/providers/model-state', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ recent: { providerID: `provider-${i}`, modelID: `model-${i}` } }), - }), - ) - - const responses = await Promise.all(requests) - responses.forEach((res) => { - expect(res.status).toBe(200) - }) - - const finalRes = await app.request('/providers/model-state') - const finalData = (await finalRes.json()) as { recent: Array<{ providerID: string; modelID: string }> } - expect(finalData.recent.length).toBeLessThanOrEqual(10) - expect(finalData.recent.length).toBeGreaterThan(0) - - const uniqueKeys = new Set(finalData.recent.map((m) => `${m.providerID}/${m.modelID}`)) - expect(uniqueKeys.size).toBe(finalData.recent.length) + expect(res.status).toBe(500) + const data = (await res.json()) as { error: string } + expect(data.error).toBe('Failed to update OpenCode model state') }) }) }) diff --git a/backend/src/routes/providers.ts b/backend/src/routes/providers.ts index d5607c810..eb5f14e98 100644 --- a/backend/src/routes/providers.ts +++ b/backend/src/routes/providers.ts @@ -1,33 +1,19 @@ import { Hono } from 'hono' import { z } from 'zod' -import path from 'path' import { AuthService } from '../services/auth' import { SetCredentialRequestSchema } from '../../../shared/src/schemas/auth' import { logger } from '../utils/logger' import type { OpenCodeClient } from '../services/opencode/client' import { reloadOpenCodeConfig } from '../services/opencode-restart' import type { OpenCodeSupervisor } from '../services/opencode-supervisor' -import type { Database } from 'bun:sqlite' -import { getWorkspacePath } from '@opencode-manager/shared/config/env' import { - addRecentOpenCodeModel, - getOpenCodeModelState as readModelStateFromDb, - removeRecentOpenCodeModel, - toggleFavoriteOpenCodeModel, - type OpenCodeModelStateRecord, -} from '../db/model-state' -import { writeJsonAtomic, withFileLock } from '../utils/atomic-json' - -export const ModelSelectionSchema = z.object({ - providerID: z.string().min(1), - modelID: z.string().min(1), -}) - -export const ModelStateSchema = z.object({ - recent: z.array(ModelSelectionSchema).default([]), - favorite: z.array(ModelSelectionSchema).default([]), - variant: z.record(z.string(), z.string().optional()).default({}), -}) + addRecentModel, + ModelSelectionSchema, + readOpenCodeModelState, + removeRecentModel, + toggleFavoriteModel, + updateOpenCodeModelState, +} from '../services/opencode-model-state' const UpdateModelStateSchema = z.object({ recent: ModelSelectionSchema.optional(), @@ -35,35 +21,16 @@ const UpdateModelStateSchema = z.object({ removeRecent: ModelSelectionSchema.optional(), }).strict() -export function getModelStatePath(): string { - return path.join(getWorkspacePath(), '.opencode', 'state', 'opencode', 'model.json') -} - -async function mirrorModelStateToFile(state: OpenCodeModelStateRecord): Promise { - const modelStatePath = getModelStatePath() - try { - await withFileLock(modelStatePath, async () => { - await writeJsonAtomic(modelStatePath, { - recent: state.recent, - favorite: state.favorite, - variant: state.variant, - }) - }) - } catch (error) { - logger.warn(`Failed to mirror model state to file ${modelStatePath}:`, error) - } -} - -export function createProvidersRoutes(db: Database, openCodeClient: OpenCodeClient, openCodeSupervisor?: OpenCodeSupervisor) { +export function createProvidersRoutes(openCodeClient: OpenCodeClient, openCodeSupervisor?: OpenCodeSupervisor) { const app = new Hono() const authService = new AuthService() app.get('/model-state', async (c) => { try { - const state = readModelStateFromDb(db) + const state = await readOpenCodeModelState() return c.json(state) } catch (error) { - logger.error('Failed to read OpenCode model state from DB:', error) + logger.error('Failed to read OpenCode model state:', error) return c.json({ recent: [], favorite: [], variant: {} }) } }) @@ -72,21 +39,20 @@ export function createProvidersRoutes(db: Database, openCodeClient: OpenCodeClie try { const body = await c.req.json() const validated = UpdateModelStateSchema.parse(body) - - let nextState: OpenCodeModelStateRecord - - if (validated.favorite) { - nextState = toggleFavoriteOpenCodeModel(db, validated.favorite) - } else if (validated.recent) { - nextState = addRecentOpenCodeModel(db, validated.recent) - } else if (validated.removeRecent) { - nextState = removeRecentOpenCodeModel(db, validated.removeRecent) - } else { - nextState = readModelStateFromDb(db) - } - - await mirrorModelStateToFile(nextState) - + + const nextState = await updateOpenCodeModelState((state) => { + if (validated.favorite) { + return toggleFavoriteModel(state, validated.favorite) + } + if (validated.recent) { + return addRecentModel(state, validated.recent) + } + if (validated.removeRecent) { + return removeRecentModel(state, validated.removeRecent) + } + return state + }) + return c.json(nextState) } catch (error) { logger.error('Failed to update OpenCode model state:', error) diff --git a/backend/src/routes/repos.test.ts b/backend/src/routes/repos.test.ts index 461607d68..ca533bc83 100644 --- a/backend/src/routes/repos.test.ts +++ b/backend/src/routes/repos.test.ts @@ -50,6 +50,17 @@ function createTestDb(): Database { return db } +function createThrowingDb(): Database { + return { + prepare: () => { + throw new Error('repo failed') + }, + query: () => { + throw new Error('repo failed') + }, + } as unknown as Database +} + describe('GET /api/repos/:id/siblings', () => { let db: Database let app: Hono @@ -268,6 +279,11 @@ describe('GET /api/repos/:id/siblings', () => { const data = await res.json() as { error: string } expect(data.error).toBe('Invalid repo id') }) + + it('returns 500 when listing siblings throws', async () => { + const res = await createTestApp(createThrowingDb()).request('/repos/1/siblings') + expect(res.status).toBe(500) + }) }) describe('DELETE /api/repos/:id/workspaces/:workspaceId', () => { @@ -299,6 +315,50 @@ describe('DELETE /api/repos/:id/workspaces/:workspaceId', () => { expect(captured?.path).toBe('/experimental/workspace/wrk_test') expect(captured?.directory?.endsWith('/repos/repo-a')).toBe(true) }) + + it('returns 400 for a non-numeric repo id', async () => { + const app = createTestApp(db) + const res = await app.request('/repos/abc/workspaces/wrk_test', { method: 'DELETE' }) + + expect(res.status).toBe(400) + }) + + it('returns 404 when the repo is missing or not ready', async () => { + const app = createTestApp(db) + + const missing = await app.request('/repos/1/workspaces/wrk_test', { method: 'DELETE' }) + expect(missing.status).toBe(404) + + createRepo(db, { localPath: 'repo-a', defaultBranch: 'main', cloneStatus: 'cloning', clonedAt: Date.now(), isLocal: true }) + const notReady = await app.request('/repos/1/workspaces/wrk_test', { method: 'DELETE' }) + expect(notReady.status).toBe(404) + }) + + it('returns 400 for an invalid workspace id', async () => { + createRepo(db, { localPath: 'repo-a', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true }) + const app = createTestApp(db) + const res = await app.request('/repos/1/workspaces/bad-id', { method: 'DELETE' }) + + expect(res.status).toBe(400) + }) + + it('forwards an upstream error status', async () => { + createRepo(db, { localPath: 'repo-a', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true }) + const app = createTestApp(db, createStubOpenCodeClient({ + forward: mock(async () => new Response('bad request', { status: 400 })) as any, + })) + const res = await app.request('/repos/1/workspaces/wrk_test', { method: 'DELETE' }) + + expect(res.status).toBe(400) + const data = await res.json() as { error: string } + expect(data.error).toBe('bad request') + }) + + it('returns 500 when reading the repo throws', async () => { + const res = await createTestApp(createThrowingDb()).request('/repos/1/workspaces/wrk_test', { method: 'DELETE' }) + + expect(res.status).toBe(500) + }) }) describe('POST /api/repos/:id/workspaces', () => { @@ -332,6 +392,59 @@ describe('POST /api/repos/:id/workspaces', () => { expect(JSON.parse(captured?.body ?? '{}')).toEqual({ type: 'worktree', branch: null }) expect(await res.json()).toMatchObject({ id: 'wrk_test', type: 'worktree' }) }) + + it('returns 400 for a non-numeric repo id', async () => { + const app = createTestApp(db) + const res = await app.request('/repos/abc/workspaces', { method: 'POST' }) + + expect(res.status).toBe(400) + }) + + it('returns 404 when the repo is missing or not ready', async () => { + const app = createTestApp(db) + + const missing = await app.request('/repos/1/workspaces', { method: 'POST' }) + expect(missing.status).toBe(404) + + createRepo(db, { localPath: 'repo-a', defaultBranch: 'main', cloneStatus: 'cloning', clonedAt: Date.now(), isLocal: true }) + const notReady = await app.request('/repos/1/workspaces', { method: 'POST' }) + expect(notReady.status).toBe(404) + }) + + it('forwards an upstream error status', async () => { + createRepo(db, { localPath: 'repo-a', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true }) + const app = createTestApp(db, createStubOpenCodeClient({ + forward: mock(async () => new Response('boom', { status: 502 })) as any, + })) + const res = await app.request('/repos/1/workspaces', { method: 'POST' }) + + expect(res.status).toBe(502) + const data = await res.json() as { error: string } + expect(data.error).toBe('boom') + }) + + it('returns 500 when the upstream body is not JSON', async () => { + createRepo(db, { localPath: 'repo-a', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true }) + const app = createTestApp(db, createStubOpenCodeClient({ + forward: mock(async () => new Response('not-json', { status: 200 })) as any, + })) + const res = await app.request('/repos/1/workspaces', { method: 'POST' }) + + expect(res.status).toBe(500) + const data = await res.json() as { error: string } + expect(data.error).toBe('Failed to create workspace') + }) + + it('returns success for an empty upstream response body', async () => { + createRepo(db, { localPath: 'repo-a', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true }) + const app = createTestApp(db, createStubOpenCodeClient({ + forward: mock(async () => new Response('', { status: 200 })) as any, + })) + const res = await app.request('/repos/1/workspaces', { method: 'POST' }) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ success: true }) + }) }) describe('PATCH /api/repos/:id', () => { @@ -402,4 +515,21 @@ describe('PATCH /api/repos/:id', () => { const res = await app.request('/repos/1', { method: 'PATCH', body: JSON.stringify({ name: longName }), headers: { 'Content-Type': 'application/json' } }) expect(res.status).toBe(400) }) + + it('returns 400 for a non-numeric repo id', async () => { + const res = await app.request('/repos/abc', { method: 'PATCH', body: JSON.stringify({ name: 'new-name' }), headers: { 'Content-Type': 'application/json' } }) + expect(res.status).toBe(400) + }) + + it('returns 400 for an invalid body', async () => { + createRepo(db, { localPath: 'repo-a', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true }) + + const res = await app.request('/repos/1', { method: 'PATCH', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json' } }) + expect(res.status).toBe(400) + }) + + it('returns 500 when reading the repo throws', async () => { + const res = await createTestApp(createThrowingDb()).request('/repos/1', { method: 'PATCH', body: JSON.stringify({ name: 'new-name' }), headers: { 'Content-Type': 'application/json' } }) + expect(res.status).toBe(500) + }) }) 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..a87504c82 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 { deleteOpenCodeConfigFile } from '../services/opencode-config-file' +import { restoreLastKnownGoodOpenCodeConfig } from '../services/opencode-config-apply' +import { createOpenCodeConfigRoutes } from './opencode-config' 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 { 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,280 +419,7 @@ 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) => { - 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 = 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) - } - }) - - app.put('/opencode-configs/:name', async (c) => { - 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 }) - } - } - } - - return c.json(config) - } 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) - } - }) - - 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.route('/opencode-config', createOpenCodeConfigRoutes(settingsService, openCodeClient)) app.post('/opencode-restart', async (c) => { try { @@ -772,14 +455,12 @@ 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, + settingsService, }) if (!result.configImported && !result.stateImported) { @@ -860,31 +541,21 @@ 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 restored = await restoreLastKnownGoodOpenCodeConfig(settingsService) + if (!restored) { 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}'`) + logger.info('Rolled back to the previous working config') - opencodeServerManager.clearStartupError() try { await reloadOpenCodeConfig(openCodeSupervisor) } 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 +565,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 +578,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..3ce4f2786 100644 --- a/backend/src/services/archive.ts +++ b/backend/src/services/archive.ts @@ -4,10 +4,12 @@ import { readdir, stat, unlink } from 'fs/promises' import path from 'path' import os from 'os' import { logger } from '../utils/logger' +import { canonicalPath } from '../utils/fs-safe' 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) + return canonicalPath(absolutePath) } export interface ArchiveOptions { @@ -205,7 +207,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 +245,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 +302,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..00dd894d5 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,10 +713,73 @@ 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 -- If you need to change credentials (Git, TTS, STT, etc.), guide the user to use the full UI +- The settings PATCH endpoint rejects any attempt to modify credentials, API keys, or other sensitive settings; guide the user to the full UI for Git, TTS, and STT credentials +- PUT /opencode-config writes the complete OpenCode configuration, including \`plugin\`, \`mcp\`, and \`provider\` entries; change only the keys the user explicitly asked for and never add plugins, MCP servers, or provider credentials the user did not request - The settings PATCH endpoint does NOT trigger OpenCode reload or restart ` } @@ -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..ccc9238e8 --- /dev/null +++ b/backend/src/services/opencode-config-apply.ts @@ -0,0 +1,121 @@ +import { OpenCodeConfigSchema } from '@opencode-manager/shared/schemas' +import { parseJsonc } from '@opencode-manager/shared/utils' +import type { OpenCodeConfigFile, OpenCodeConfigInput } from '../types/settings' +import { OPENCODE_CONFIG_SEED, 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 +} + +export async function captureLastKnownGoodOpenCodeConfig(settingsService: SettingsService): Promise { + const previous = await readOpenCodeConfigFile() + if (previous?.isValid) { + settingsService.saveLastKnownGoodConfig(previous.rawContent) + } + return previous +} + +export async function restoreLastKnownGoodOpenCodeConfig(settingsService: SettingsService): Promise { + const lastGood = settingsService.getLastKnownGoodConfig() + if (!lastGood) { + return null + } + + const config = await withOpenCodeConfigLock(() => writeOpenCodeConfigFile(lastGood)) + opencodeServerManager.clearStartupError() + return config +} + +export async function seedOpenCodeConfigFile(): Promise { + return withOpenCodeConfigLock(() => writeOpenCodeConfigFile(OPENCODE_CONFIG_SEED)) +} + +function didConfigFieldChange( + previous: Record | 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)) +} + +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 } = input + + const rawContent = normalizeOpenCodeConfigContent(content) + const nextContent = OpenCodeConfigSchema.parse(parseJsonc(rawContent)) + + const previous = await captureLastKnownGoodOpenCodeConfig(settingsService) + + 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..fdc45563a --- /dev/null +++ b/backend/src/services/opencode-config-file.ts @@ -0,0 +1,184 @@ +import { readdir, rm, stat } from 'fs/promises' +import path from 'path' +import type { ZodIssue } from 'zod' +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, writeFileContent } 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 toOpenCodeConfigValidationIssues(issues: ZodIssue[]): OpenCodeConfigValidationIssue[] { + return issues.map((issue) => ({ + path: issue.path.length > 0 ? issue.path.join('.') : 'root', + message: issue.message, + })) +} + +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 = toOpenCodeConfigValidationIssues(validated.error.issues) + 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 writeHealthWatchArtifact( + prefix: string, + buildContent: (timestamp: string) => string, +): Promise { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-') + const artifactPath = path.join(getOpenCodeHealthWatchPath(), `${prefix}-${timestamp}.json`) + await ensureDirectoryExists(getOpenCodeHealthWatchPath()) + await writeFileContent(artifactPath, buildContent(timestamp)) + await pruneHealthWatchDirectory(getOpenCodeHealthWatchPath()) + return artifactPath +} + +export async function archiveBrokenOpenCodeConfigFile(): Promise { + const configPath = getOpenCodeConfigFilePath() + if (!(await fileExists(configPath))) { + return null + } + + try { + const content = await readFileContent(configPath) + const archivePath = await writeHealthWatchArtifact('opencode-config-broken', () => content) + 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..de609899a 100644 --- a/backend/src/services/opencode-import.ts +++ b/backend/src/services/opencode-import.ts @@ -1,12 +1,13 @@ 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, withOpenCodeConfigLock, writeOpenCodeConfigFile } from './opencode-config-file' +import { captureLastKnownGoodOpenCodeConfig } from './opencode-config-apply' +import { ensureDirectoryExists, fileExists, readFileContent } from './file-operations' +import type { SettingsService } from './settings' const OPENCODE_STATE_DB_FILENAMES = new Set(['opencode.db', 'opencode.db-shm', 'opencode.db-wal']) @@ -19,10 +20,11 @@ export interface OpenCodeImportStatus { } export interface SyncOpenCodeImportOptions { - db: Database - userId?: string overwriteState?: boolean protectExistingState?: boolean + importConfig?: boolean + status?: OpenCodeImportStatus + settingsService?: SettingsService } export interface SyncOpenCodeImportResult extends OpenCodeImportStatus { @@ -53,14 +55,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 +139,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 +153,25 @@ export async function getOpenCodeImportStatus(): Promise { } } -async function importOpenCodeConfigFromSource(db: Database, userId: string, sourcePath: string, workspaceConfigPath: string): Promise { +async function importOpenCodeConfigFromSource(sourcePath: string, settingsService?: SettingsService): 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 withOpenCodeConfigLock(async () => { + if (settingsService) { + await captureLastKnownGoodOpenCodeConfig(settingsService) + } + 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 +182,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, options.settingsService) } 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-model-state.ts b/backend/src/services/opencode-model-state.ts new file mode 100644 index 000000000..5ef08c74b --- /dev/null +++ b/backend/src/services/opencode-model-state.ts @@ -0,0 +1,101 @@ +import { z } from 'zod' +import { getOpenCodeModelStatePath } from '@opencode-manager/shared/config/env' +import { readJsonSafe, withFileLock, writeJsonAtomic } from '../utils/atomic-json' +import { logger } from '../utils/logger' + +export const ModelSelectionSchema = z.object({ + providerID: z.string().min(1), + modelID: z.string().min(1), +}) + +export const ModelStateSchema = z.object({ + recent: z.array(ModelSelectionSchema).default([]), + favorite: z.array(ModelSelectionSchema).default([]), + variant: z.record(z.string(), z.string().optional()).default({}), +}).passthrough() + +export interface ModelSelectionRecord { + providerID: string + modelID: string +} + +export interface OpenCodeModelStateRecord { + recent: ModelSelectionRecord[] + favorite: ModelSelectionRecord[] + variant: Record +} + +export const MAX_RECENT_MODELS = 10 + +function createEmptyState(): OpenCodeModelStateRecord { + return { recent: [], favorite: [], variant: {} } +} + +function toRecord(data: z.infer): OpenCodeModelStateRecord { + return { recent: data.recent, favorite: data.favorite, variant: data.variant } +} + +export async function readOpenCodeModelState(): Promise { + const raw = await readJsonSafe(getOpenCodeModelStatePath(), null) + const parsed = ModelStateSchema.safeParse(raw) + + if (!parsed.success) { + if (raw !== null) { + logger.warn('OpenCode model state file has invalid structure', parsed.error) + } + return createEmptyState() + } + + return toRecord(parsed.data) +} + +export function addRecentModel(state: OpenCodeModelStateRecord, model: ModelSelectionRecord): OpenCodeModelStateRecord { + const deduped = [ + model, + ...state.recent.filter(m => m.providerID !== model.providerID || m.modelID !== model.modelID), + ] + return { ...state, recent: deduped.slice(0, MAX_RECENT_MODELS) } +} + +export function removeRecentModel(state: OpenCodeModelStateRecord, model: ModelSelectionRecord): OpenCodeModelStateRecord { + return { + ...state, + recent: state.recent.filter(m => m.providerID !== model.providerID || m.modelID !== model.modelID), + } +} + +export function toggleFavoriteModel(state: OpenCodeModelStateRecord, model: ModelSelectionRecord): OpenCodeModelStateRecord { + const exists = state.favorite.some( + m => m.providerID === model.providerID && m.modelID === model.modelID, + ) + const favorite = exists + ? state.favorite.filter(m => m.providerID !== model.providerID || m.modelID !== model.modelID) + : [...state.favorite, model] + + return { ...state, favorite } +} + +export async function updateOpenCodeModelState( + mutate: (state: OpenCodeModelStateRecord) => OpenCodeModelStateRecord, +): Promise { + const modelStatePath = getOpenCodeModelStatePath() + + return withFileLock(modelStatePath, async () => { + const raw = await readJsonSafe(modelStatePath, {}) + const parsed = ModelStateSchema.safeParse(raw) + const current = parsed.success ? toRecord(parsed.data) : createEmptyState() + const next = mutate(current) + const rawObject = raw && typeof raw === 'object' && !Array.isArray(raw) + ? raw as Record + : {} + + await writeJsonAtomic(modelStatePath, { + ...rawObject, + recent: next.recent, + favorite: next.favorite, + variant: next.variant, + }) + + return next + }) +} diff --git a/backend/src/services/opencode-plugin-quarantine.ts b/backend/src/services/opencode-plugin-quarantine.ts index 0c22171da..8b518c659 100644 --- a/backend/src/services/opencode-plugin-quarantine.ts +++ b/backend/src/services/opencode-plugin-quarantine.ts @@ -3,7 +3,8 @@ 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 { withFileLock } from '../utils/atomic-json' import { getOpenCodePluginDir } from './opencode/plugin-registry' import { isRecord, @@ -242,14 +243,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 @@ -293,7 +286,8 @@ async function restoreEnforcementConfigSections(configPath: string): Promise writeFileAtomic(configPath, restoredContent, { mode })) } await fs.rm(backupPath, { force: true }) } diff --git a/backend/src/services/opencode-restart.ts b/backend/src/services/opencode-restart.ts index 9dbcd7541..8216f0ce5 100644 --- a/backend/src/services/opencode-restart.ts +++ b/backend/src/services/opencode-restart.ts @@ -1,7 +1,6 @@ import { opencodeServerManager } from './opencode-single-server' import type { OpenCodeSupervisor } from './opencode-supervisor' import type { OpenCodeRestartCoordinator } from './opencode-restart-coordinator' -import { logger } from '../utils/logger' let restartCoordinator: OpenCodeRestartCoordinator | null = null @@ -68,19 +67,6 @@ export async function restartOpenCode(supervisor?: OpenCodeSupervisor): Promise< return { resumedSessionIDs: [] } } -export async function restartOpenCodeAfterCommit( - supervisor?: OpenCodeSupervisor, -): Promise<{ restartFailed: boolean; restartError?: string }> { - try { - await restartOpenCode(supervisor) - return { restartFailed: false } - } catch (error) { - const restartError = error instanceof Error ? error.message : String(error) - logger.error('OpenCode restart failed after the change was persisted', error) - return { restartFailed: true, restartError } - } -} - /** * Reloads OpenCode configuration via the non-disruptive API patch. This does * NOT drop the server process, so active sessions keep running and there is diff --git a/backend/src/services/opencode-single-server.ts b/backend/src/services/opencode-single-server.ts index 0f1d2abf4..7be0c297d 100644 --- a/backend/src/services/opencode-single-server.ts +++ b/backend/src/services/opencode-single-server.ts @@ -26,12 +26,17 @@ import { getOpenCodeTmpHome, 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 { + readOpenCodeConfigFile, + toOpenCodeConfigValidationIssues, + withOpenCodeConfigLock, + writeOpenCodeConfigFile, +} from './opencode-config-file' import { getOrCreateInternalToken } from './internal-token' import { installManagedPlugins } from './opencode/plugin-registry' import { getOpenCodePluginDiscoveryHome, restoreQuarantinedOpenCodePlugins } from './opencode-plugin-quarantine' @@ -615,7 +620,7 @@ class OpenCodeServerManager { } logger.warn('Failed to install a generated OpenCode plugin (sandboxing is disabled):', error) } - const configuredPlugins = await this.getConfiguredPlugins(openCodeConfigPath) + const configuredPlugins = await this.getConfiguredPlugins() await this.installConfiguredPlugins(configuredPlugins) const configuredPluginCount = configuredPlugins.length const openCodeExecutable = resolveOpenCodeExecutable() ?? 'opencode' @@ -957,13 +962,13 @@ class OpenCodeServerManager { return options !== null && typeof options === 'object' && !Array.isArray(options) } - private async getConfiguredPlugins(configPath: string): Promise { + private async getConfiguredPlugins(): Promise { try { - const content = await fs.readFile(configPath, 'utf-8') - const config = parseJsonc(content) as { plugin?: unknown } - if (!Array.isArray(config.plugin)) return [] - return config.plugin - .filter((plugin): plugin is OpenCodePluginSpec => this.isOpenCodePluginSpec(plugin)) + const file = await readOpenCodeConfigFile() + const plugin = file?.content.plugin + if (!Array.isArray(plugin)) return [] + return plugin + .filter((entry): entry is OpenCodePluginSpec => this.isOpenCodePluginSpec(entry)) } catch { return [] } @@ -1043,31 +1048,44 @@ class OpenCodeServerManager { try { logger.info('Reloading OpenCode configuration (via API)') try { - const configPath = getOpenCodeConfigFilePath() - const fileContent = await fs.readFile(configPath, 'utf-8') - const fileConfig = parseJsonc(fileContent) as Record - logger.info(`Read config from file for reload: ${configPath}`) - - const patchTarget = fileConfig - const patchResult = await patchConfigWithRecovery(this.requireClient(), patchTarget) - if (!patchResult.success) { - const errorMessage = patchResult.error || 'Failed to reload config' - const validationIssues = patchResult.details || [] - const removedFields = patchResult.removedFields || [] - if (validationIssues.length > 0) { - const issueSummary = validationIssues.map((d) => `${d.path}: ${d.message}`).join('; ') - logger.error(`Config reload validation errors: ${issueSummary}`) + await withOpenCodeConfigLock(async () => { + const file = await readOpenCodeConfigFile() + if (file === null) { + throw new Error(`OpenCode config file not found: ${getOpenCodeConfigFilePath()}`) } - if (removedFields.length > 0) { - logger.info(`Removed fields during config reload: ${removedFields.join(', ')}`) + logger.info(`Read config from file for reload: ${file.path}`) + + const patchResult = await patchConfigWithRecovery(this.requireClient(), file.content) + if (!patchResult.success) { + const errorMessage = patchResult.error || 'Failed to reload config' + const validationIssues = patchResult.details || [] + const removedFields = patchResult.removedFields || [] + if (validationIssues.length > 0) { + const issueSummary = validationIssues.map((d) => `${d.path}: ${d.message}`).join('; ') + logger.error(`Config reload validation errors: ${issueSummary}`) + } + if (removedFields.length > 0) { + logger.info(`Removed fields during config reload: ${removedFields.join(', ')}`) + } + throw new ConfigReloadError(errorMessage, validationIssues, removedFields) } - throw new ConfigReloadError(errorMessage, validationIssues, removedFields) - } - if (patchResult.removedFields && patchResult.removedFields.length > 0 && patchResult.appliedConfig) { - await writeFileContent(configPath, JSON.stringify(patchResult.appliedConfig, null, 2)) - logger.info(`Persisted cleaned config to ${configPath} after removing fields: ${patchResult.removedFields.join(', ')}`) - } + if (patchResult.removedFields && patchResult.removedFields.length > 0 && patchResult.appliedConfig) { + const cleanedConfigContent = JSON.stringify(patchResult.appliedConfig, null, 2) + try { + await writeOpenCodeConfigFile(cleanedConfigContent) + } catch (error) { + if (error instanceof ZodError) { + const validationIssues = toOpenCodeConfigValidationIssues(error.issues) + 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 ${file.path} after removing fields: ${patchResult.removedFields.join(', ')}`) + } + }) logger.info('OpenCode configuration reloaded successfully') await new Promise(r => setTimeout(r, 500)) diff --git a/backend/src/services/opencode-supervisor.ts b/backend/src/services/opencode-supervisor.ts index a301eb362..8265a5936 100644 --- a/backend/src/services/opencode-supervisor.ts +++ b/backend/src/services/opencode-supervisor.ts @@ -1,8 +1,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 { ENV } from '@opencode-manager/shared/config/env' +import { archiveBrokenOpenCodeConfigFile, writeHealthWatchArtifact } from './opencode-config-file' +import { restoreLastKnownGoodOpenCodeConfig, seedOpenCodeConfigFile } from './opencode-config-apply' import type { OpenCodeServerManager } from './opencode-single-server' export const OPENCODE_LIFECYCLE_STATES = [ @@ -55,7 +55,6 @@ export interface OpenCodeLifecycleStatus { interface OpenCodeSupervisorOptions { pollIntervalMs?: number failureThreshold?: number - userId?: string watchEnabled?: boolean } @@ -319,63 +318,30 @@ 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 payload = JSON.stringify({ + await writeHealthWatchArtifact('opencode-health', (timestamp) => JSON.stringify({ capturedAt: timestamp, startupError: this.openCodeServerManager.getLastStartupError(), lifecycleState: this.state, attemptedRecoveryActions: this.attemptedRecoveryActions, - }, null, 2) - - await ensureDirectoryExists(path.dirname(debugPath)) - await writeFileContent(debugPath, payload) + }, null, 2)) } private async rollbackToLastKnownGood(): Promise { - this.settingsService.archiveBrokenConfig(this.userId) - const lastGood = this.settingsService.restoreToLastKnownGoodConfig(this.userId) - if (!lastGood) { + await archiveBrokenOpenCodeConfigFile() + const restored = await restoreLastKnownGoodOpenCodeConfig(this.settingsService) + if (!restored) { 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) - 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 seedOpenCodeConfigFile() 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 +395,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/opencode/config-recovery.ts b/backend/src/services/opencode/config-recovery.ts index 2efe7a2fc..2715722bf 100644 --- a/backend/src/services/opencode/config-recovery.ts +++ b/backend/src/services/opencode/config-recovery.ts @@ -1,5 +1,6 @@ import type { OpenCodeClient } from './client' import { logger } from '../../utils/logger' +import { TIMEOUTS } from '@opencode-manager/shared/config/env' import { parseJsonc } from '@opencode-manager/shared/utils' export type PatchConfigValidationIssue = { @@ -150,17 +151,41 @@ function parseErrorResponse(responseText: string): { details: PatchConfigValidat return { details, errorMessage } } +const CONFIG_PATCH_TIMEOUT_ERROR = `Timed out waiting for OpenCode config patch after ${TIMEOUTS.CONFIG_PATCH_TIMEOUT_MS}ms` + +function isTimeoutError(error: unknown): boolean { + return typeof error === 'object' + && error !== null + && 'name' in error + && (error as { name?: unknown }).name === 'TimeoutError' +} + +async function forwardConfigPatch( + client: OpenCodeClient, + config: Record, +): Promise { + const signal = AbortSignal.timeout(TIMEOUTS.CONFIG_PATCH_TIMEOUT_MS) + const response = await client.forward({ + method: 'PATCH', + path: '/config', + body: JSON.stringify(config), + headers: { 'Content-Type': 'application/json' }, + signal, + }) + + if (!response.ok && signal.aborted) { + throw new DOMException(CONFIG_PATCH_TIMEOUT_ERROR, 'TimeoutError') + } + + return response +} + export async function patchConfigWithRecovery( client: OpenCodeClient, config: Record, ): Promise { try { - const response = await client.forward({ - method: 'PATCH', - path: '/config', - body: JSON.stringify(config), - headers: { 'Content-Type': 'application/json' }, - }) + const response = await forwardConfigPatch(client, config) if (response.ok) { logger.info('Patched OpenCode config via API') @@ -202,12 +227,7 @@ export async function patchConfigWithRecovery( } logger.info(`Retrying config patch after removing ${removedFields.length} problematic field(s): ${removedFields.join(', ')}`) - const retryResponse = await client.forward({ - method: 'PATCH', - path: '/config', - body: JSON.stringify(cleanedConfig), - headers: { 'Content-Type': 'application/json' }, - }) + const retryResponse = await forwardConfigPatch(client, cleanedConfig) if (retryResponse.ok) { logger.info('Patched OpenCode config via API after removing invalid fields') @@ -230,6 +250,11 @@ export async function patchConfigWithRecovery( removedFields } } catch (error) { + if (isTimeoutError(error)) { + logger.error(`Failed to patch OpenCode config: ${CONFIG_PATCH_TIMEOUT_ERROR}`) + return { success: false, error: CONFIG_PATCH_TIMEOUT_ERROR } + } + const errorMessage = error instanceof Error ? error.message : 'Unknown error' logger.error('Failed to patch OpenCode config:', error) return { success: false, error: errorMessage } diff --git a/backend/src/services/repo.ts b/backend/src/services/repo.ts index f17fd7954..283c3516c 100644 --- a/backend/src/services/repo.ts +++ b/backend/src/services/repo.ts @@ -1,5 +1,5 @@ import fs from 'fs/promises' -import { existsSync, rmSync, realpathSync } from 'node:fs' +import { existsSync, rmSync } from 'node:fs' import { executeCommand } from '../utils/process' import { ensureDirectoryExists } from './file-operations' import { createRepo, getRepoByLocalPath, getRepoBySourcePath, getRepoById, updateRepoStatus, updateRepoBranch, updateLastPulled, deleteRepo, getRepoByUrlAndBranch } from '../db/queries' @@ -19,20 +19,12 @@ import { listRepos } from '../db/queries' import { listActiveScheduleRunWorkspaces } from '../db/schedules' import { SettingsService } from './settings' import type { OpenCodeClient } from './opencode/client' -import { mkdirSafe } from '../utils/fs-safe' +import { canonicalPathSync, mkdirSafe } from '../utils/fs-safe' const GIT_CLONE_TIMEOUT = 300000 const DEFAULT_DISCOVERY_MAX_DEPTH = 4 const DISCOVERY_SKIP_DIRECTORIES = new Set(['.git', 'node_modules']) -function canonical(dir: string): string { - try { - return realpathSync(path.resolve(dir)) - } catch { - return path.resolve(dir) - } -} - function enhanceCloneError(error: unknown, repoUrl: string, originalMessage: string): Error { const message = originalMessage.toLowerCase() @@ -1224,10 +1216,10 @@ export async function getSiblingRepos( // Workspaces that are git main checkouts (not linked worktrees) are also // excluded so the project's origin/main repository can never be surfaced // as deletable. - const knownDirectories = new Set(repoSiblings.map((repo) => canonical(repo.fullPath))) - const targetDirectory = canonical(target.fullPath) - const reposRoot = canonical(getReposPath()) - const scheduleWorktreeRoot = canonical(getScheduleWorktreesPath()) + const knownDirectories = new Set(repoSiblings.map((repo) => canonicalPathSync(path.resolve(repo.fullPath)))) + const targetDirectory = canonicalPathSync(path.resolve(target.fullPath)) + const reposRoot = canonicalPathSync(path.resolve(getReposPath())) + const scheduleWorktreeRoot = canonicalPathSync(path.resolve(getScheduleWorktreesPath())) // Schedule runs may create their isolated worktree via the OpenCode workspace // API, which places it outside getScheduleWorktreesPath(). Exclude any live @@ -1236,14 +1228,14 @@ export async function getSiblingRepos( const activeRuns = listActiveScheduleRunWorkspaces(database) const activeRunWorkspaceIds = new Set(activeRuns.map((run) => run.workspaceId).filter((id): id is string => id !== null)) const activeRunDirectories = new Set( - activeRuns.map((run) => run.worktreePath).filter((p): p is string => p !== null).map((p) => canonical(p)), + activeRuns.map((run) => run.worktreePath).filter((p): p is string => p !== null).map((p) => canonicalPathSync(path.resolve(p))), ) const candidates = workspaces.filter((workspace) => { if (workspace.projectID !== targetProjectId) return false if (!workspace.directory) return false - const workspaceDirectory = canonical(workspace.directory) + const workspaceDirectory = canonicalPathSync(path.resolve(workspace.directory)) if (workspaceDirectory === targetDirectory) return false if (workspaceDirectory === reposRoot) return false if (workspaceDirectory.startsWith(`${scheduleWorktreeRoot}${path.sep}`)) return false @@ -1262,7 +1254,7 @@ export async function getSiblingRepos( candidates .filter((_, index) => !mainChecks[index]) .forEach((workspace) => { - const directory = canonical(workspace.directory!) + const directory = canonicalPathSync(path.resolve(workspace.directory!)) if (!uniqueWorkspaces.has(directory)) { uniqueWorkspaces.set(directory, workspace) } diff --git a/backend/src/services/settings.ts b/backend/src/services/settings.ts index f9c9c43cf..b54f35507 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): void { + SettingsService.lastKnownGoodConfigContent = rawContent + this.persistLastKnownGoodConfig() + 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..e2bd1c81c 100644 --- a/backend/src/utils/fs-safe.ts +++ b/backend/src/utils/fs-safe.ts @@ -1,5 +1,5 @@ import path from 'path' -import { promises as fs, mkdirSync, accessSync, constants } from 'node:fs' +import { promises as fs, mkdirSync, accessSync, constants, realpathSync } from 'node:fs' interface MkdirSafeOptions { mode?: number @@ -10,6 +10,22 @@ function isPermissionError(error: unknown): boolean { return code === 'EACCES' || code === 'EPERM' } +export async function canonicalPath(target: string): Promise { + try { + return await fs.realpath(target) + } catch { + return target + } +} + +export function canonicalPathSync(target: string): string { + try { + return realpathSync(target) + } catch { + return target + } +} + export async function writeFileAtomic(filePath: string, content: string, options: { mode?: number } = {}): Promise { const dir = path.dirname(filePath) await mkdirSafe(dir) @@ -23,6 +39,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..373c5e0a0 --- /dev/null +++ b/backend/test/db/opencode-config-migration.test.ts @@ -0,0 +1,135 @@ +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, +})) + +const originalHome = process.env.HOME +const originalImportConfigPath = process.env.OPENCODE_IMPORT_CONFIG_PATH + +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') + process.env.HOME = paths.workDir + delete process.env.OPENCODE_IMPORT_CONFIG_PATH + }) + + afterEach(async () => { + await rm(paths.workDir, { recursive: true, force: true }) + if (originalHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = originalHome + } + if (originalImportConfigPath === undefined) { + delete process.env.OPENCODE_IMPORT_CONFIG_PATH + } else { + process.env.OPENCODE_IMPORT_CONFIG_PATH = originalImportConfigPath + } + }) + + 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) + + const importSource = path.join(paths.workDir, 'import-source.json') + await writeFile(importSource, '{}') + process.env.OPENCODE_IMPORT_CONFIG_PATH = importSource + + 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/opencode-model-state-migration.test.ts b/backend/test/db/opencode-model-state-migration.test.ts new file mode 100644 index 000000000..2bcf24b2c --- /dev/null +++ b/backend/test/db/opencode-model-state-migration.test.ts @@ -0,0 +1,117 @@ +import { Database } from 'bun:sqlite' +import { mkdir, mkdtemp, readFile, rm, stat, 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: '', + modelStateFile: '', +})) + +vi.mock('@opencode-manager/shared/config/env', async (importOriginal) => ({ + ...(await importOriginal()), + getOpenCodeModelStatePath: () => paths.modelStateFile, +})) + +interface ModelStateRow { + recent: unknown[] + favorite: unknown[] + variant: Record +} + +function migrateToV19(db: Database): void { + migrate(db, allMigrations.filter(migration => migration.version < 20)) +} + +function insertRow(db: Database, state: ModelStateRow, updatedAt: number): void { + db.prepare( + 'INSERT INTO opencode_model_state (user_id, recent, favorite, variant, updated_at) VALUES (?, ?, ?, ?, ?)', + ).run('default', JSON.stringify(state.recent), JSON.stringify(state.favorite), JSON.stringify(state.variant), updatedAt) +} + +function tableExists(db: Database): boolean { + return db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'opencode_model_state'").get() !== undefined +} + +describe('020-drop-opencode-model-state', () => { + beforeEach(async () => { + paths.workDir = await mkdtemp(path.join(tmpdir(), 'opencode-model-state-migration-')) + paths.modelStateFile = path.join(paths.workDir, '.opencode', 'state', 'opencode', 'model.json') + }) + + afterEach(async () => { + await rm(paths.workDir, { recursive: true, force: true }) + }) + + it('writes the file from a newer row and drops the table', async () => { + const db = new Database(':memory:') + migrateToV19(db) + + const state: ModelStateRow = { + recent: [{ providerID: 'anthropic', modelID: 'claude' }], + favorite: [{ providerID: 'openai', modelID: 'gpt-4' }], + variant: { anthropic: 'thinking' }, + } + insertRow(db, state, Date.now() + 60_000) + + migrate(db, allMigrations) + + expect(tableExists(db)).toBe(false) + expect(JSON.parse(await readFile(paths.modelStateFile, 'utf8'))).toEqual(state) + }) + + it('leaves an existing newer file untouched and drops the table', async () => { + const db = new Database(':memory:') + migrateToV19(db) + + const existing = JSON.stringify({ + session: { current: 'abc' }, + recent: [{ providerID: 'existing', modelID: 'model' }], + favorite: [], + variant: {}, + }) + await mkdir(path.dirname(paths.modelStateFile), { recursive: true }) + await writeFile(paths.modelStateFile, existing) + + const fileStats = await stat(paths.modelStateFile) + insertRow(db, { recent: [], favorite: [], variant: {} }, Math.floor(fileStats.mtimeMs) - 60_000) + + migrate(db, allMigrations) + + expect(tableExists(db)).toBe(false) + expect(await readFile(paths.modelStateFile, 'utf8')).toBe(existing) + }) + + it('preserves unknown top-level keys when restoring over an older file', async () => { + const db = new Database(':memory:') + migrateToV19(db) + + await mkdir(path.dirname(paths.modelStateFile), { recursive: true }) + await writeFile(paths.modelStateFile, JSON.stringify({ session: { current: 'abc' }, recent: [] })) + + const state: ModelStateRow = { + recent: [{ providerID: 'anthropic', modelID: 'claude' }], + favorite: [], + variant: {}, + } + insertRow(db, state, Date.now() + 60_000) + + migrate(db, allMigrations) + + const file = JSON.parse(await readFile(paths.modelStateFile, 'utf8')) as { session: unknown } + expect(file.session).toEqual({ current: 'abc' }) + expect(file).toMatchObject(state) + }) + + it('does not throw and drops the table when the table is absent', async () => { + const db = new Database(':memory:') + migrateToV19(db) + db.run('DROP TABLE IF EXISTS opencode_model_state') + + expect(() => migrate(db, allMigrations)).not.toThrow() + expect(tableExists(db)).toBe(false) + }) +}) 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..9e471b942 --- /dev/null +++ b/backend/test/db/schema.test.ts @@ -0,0 +1,38 @@ +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') + + 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..501990818 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', () => { @@ -322,69 +383,653 @@ describe('Repo Routes', () => { }) }) - describe('POST /:id/workspaces', () => { - const mockRepo = { - id: 1, - repoUrl: 'https://github.com/test/repo', - localPath: 'repos/test-repo', - fullPath: '/tmp/test-repo', - sourcePath: '/tmp/test-repo/.git', - branch: 'main', - defaultBranch: 'main', - cloneStatus: 'ready' as const, - clonedAt: Date.now(), - } - - it('returns the workspace created outside the project roots even while sandboxing is enforced', async () => { - vi.mocked(db.getRepoById).mockReturnValue(mockRepo) - vi.mocked(opencodeServerManager.isSandboxEnforced).mockReturnValue(true) + 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({}), + }) - const forward = vi.fn(async () => - new Response( - JSON.stringify({ id: 'wrk_outside', directory: '/workspace/.opencode/state/workspaces/wrk_outside', branch: null }), - { status: 200 }, - ), - ) - const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient({ forward })) - const res = await app.request('/1/workspaces', { method: 'POST' }) + 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 { id: string } - expect(body.id).toBe('wrk_outside') - expect(forward).not.toHaveBeenCalledWith(expect.objectContaining({ method: 'DELETE' })) + const body = await res.json() as Repo + expect(body.id).toBe(1) + expect(repoService.initLocalRepo).toHaveBeenCalledWith(mockDb, mockGitAuthService, 'repos/test-repo', 'main') }) - it('returns a workspace with an empty response body when sandboxing is enforced', async () => { - vi.mocked(db.getRepoById).mockReturnValue(mockRepo) - vi.mocked(opencodeServerManager.isSandboxEnforced).mockReturnValue(true) + 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 forward = vi.fn(async () => new Response('', { status: 200 })) - const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient({ forward })) - const res = await app.request('/1/workspaces', { method: 'POST' }) + 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(forward).not.toHaveBeenCalledWith(expect.objectContaining({ method: 'DELETE' })) + expect(mockUpdateSettings).toHaveBeenCalledWith({ repoOrder: [2, 1] }) }) - it('allows workspace creation when sandboxing is not enforced', async () => { - vi.mocked(db.getRepoById).mockReturnValue(mockRepo) - vi.mocked(opencodeServerManager.isSandboxEnforced).mockReturnValue(false) + 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('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: '' }), + }) - const forward = vi.fn(async () => - new Response( - JSON.stringify({ id: 'wrk_ok', directory: '/workspace/.opencode/state/workspaces/wrk_ok', branch: null }), - { status: 200 }, - ), + 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('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/workspaces', { method: 'POST' }) + 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 { id: string } - expect(body.id).toBe('wrk_ok') - expect(forward).not.toHaveBeenCalledWith(expect.objectContaining({ method: 'DELETE' })) + 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..6d8ca79ad 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(), })), })) @@ -112,6 +107,7 @@ vi.mock('@opencode-manager/shared/config/env', () => ({ MAX_SIZE_BYTES: 1024 * 1024, MAX_UPLOAD_SIZE_BYTES: 10 * 1024 * 1024, }, + TIMEOUTS: { CONFIG_PATCH_TIMEOUT_MS: 15000 }, })) import { createSettingsRoutes } from '../../src/routes/settings' diff --git a/backend/test/routes/settings.test.ts b/backend/test/routes/settings.test.ts index c5ac86a73..ba84620df 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,21 @@ vi.mock('../../src/services/opencode/config-recovery', () => ({ patchConfigWithRecovery: vi.fn(), })) +vi.mock('../../src/services/opencode-config-file', () => ({ + readOpenCodeConfigFile: mockReadOpenCodeConfigFile, + writeOpenCodeConfigFile: mockWriteOpenCodeConfigFile, + deleteOpenCodeConfigFile: mockDeleteOpenCodeConfigFile, + withOpenCodeConfigLock: (fn: () => Promise) => fn(), +})) + +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 +232,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 +282,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 +302,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 +319,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 +342,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 +363,243 @@ 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(), + }) }) - 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: '{}', + 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: 1, - }) - const firstConfig = { - id: 2, - name: 'cleaned', - content: { command: { review: true }, theme: 'dark' }, - rawContent: '{"command":{"review":true},"theme":"dark"}', - 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(), + }) }) - 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 JSON') + 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 JSON') + 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' }, + it('writes the last known good config and reloads on rollback', async () => { + mockGetLastKnownGoodConfig.mockReturnValueOnce('{"theme":"dark"}') + mockWriteOpenCodeConfigFile.mockResolvedValueOnce({ + path: '/tmp/test-workspace/.config/opencode.json', rawContent: '{"theme":"dark"}', + content: { 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) - 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"}', + it('deletes the config file and restarts when the rollback reload fails', async () => { + mockGetLastKnownGoodConfig.mockReturnValueOnce('{"theme":"dark"}') + mockWriteOpenCodeConfigFile.mockResolvedValueOnce({ + path: '/tmp/test-workspace/.config/opencode.json', + rawContent: '{"theme":"dark"}', + content: { 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) + 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,10 +646,9 @@ 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, + settingsService: expect.anything(), }) expect(mockGetImportedSessionDirectories).toHaveBeenCalledWith('/tmp/test-workspace/.opencode/state/opencode') expect(mockRelinkReposFromSessionDirectories).toHaveBeenCalled() @@ -2051,3 +1712,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..1cc954087 --- /dev/null +++ b/backend/test/services/opencode-config-apply.test.ts @@ -0,0 +1,274 @@ +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()) +const clearStartupErrorMock = vi.hoisted(() => vi.fn()) +vi.mock('../../src/services/opencode-single-server', () => ({ + opencodeServerManager: { + markRestartPending: markRestartPendingMock, + clearStartupError: clearStartupErrorMock, + }, +})) + +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import { SettingsService } from '../../src/services/settings' +import { applyOpenCodeConfigUpdate, captureLastKnownGoodOpenCodeConfig, restoreLastKnownGoodOpenCodeConfig, 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('captures the on-disk config as last known good when it is valid', async () => { + const previous = '{"theme":"dark"}' + await writeFile(paths.config, previous, 'utf8') + settingsService.saveLastKnownGoodConfig('sentinel') + + const captured = await captureLastKnownGoodOpenCodeConfig(settingsService) + + expect(captured?.rawContent).toBe(previous) + expect(settingsService.getLastKnownGoodConfig()).toBe(previous) + }) + + it('does not overwrite last known good when the on-disk config is invalid', async () => { + await writeFile(paths.config, '{"model": 5}', 'utf8') + settingsService.saveLastKnownGoodConfig('sentinel') + + const captured = await captureLastKnownGoodOpenCodeConfig(settingsService) + + expect(captured?.isValid).toBe(false) + expect(settingsService.getLastKnownGoodConfig()).toBe('sentinel') + }) + + it('returns null from restore when no last known good config exists', async () => { + const service = { getLastKnownGoodConfig: () => null } as unknown as SettingsService + + expect(await restoreLastKnownGoodOpenCodeConfig(service)).toBeNull() + expect(clearStartupErrorMock).not.toHaveBeenCalled() + }) + + it('writes the last known good config and clears the startup error on restore', async () => { + const service = { getLastKnownGoodConfig: () => '{"theme":"dark"}' } as unknown as SettingsService + + const restored = await restoreLastKnownGoodOpenCodeConfig(service) + + expect(restored?.rawContent).toBe('{"theme":"dark"}') + await expect(readFile(paths.config, 'utf8')).resolves.toBe('{"theme":"dark"}') + expect(clearStartupErrorMock).toHaveBeenCalledTimes(1) + }) + + 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..4a0f2194c --- /dev/null +++ b/backend/test/services/opencode-config-file.test.ts @@ -0,0 +1,191 @@ +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, + toOpenCodeConfigValidationIssues, + writeHealthWatchArtifact, + 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('maps Zod issues to config validation issues with a root fallback', () => { + const issues = new ZodError([ + { code: 'custom', path: ['model'], message: 'Invalid model' }, + { code: 'custom', path: [], message: 'Invalid root' }, + ]).issues + + expect(toOpenCodeConfigValidationIssues(issues)).toEqual([ + { path: 'model', message: 'Invalid model' }, + { path: 'root', message: 'Invalid root' }, + ]) + }) + + it('writes a health-watch artifact with a shared timestamp and returns its path', async () => { + const artifactPath = await writeHealthWatchArtifact('opencode-health', (timestamp) => JSON.stringify({ capturedAt: timestamp })) + + expect(path.dirname(artifactPath)).toBe(paths.healthWatch) + expect(path.basename(artifactPath)).toMatch(/^opencode-health-.+\.json$/) + const content = JSON.parse(await readFile(artifactPath, 'utf8')) as { capturedAt: string } + expect(content.capturedAt).toBe(path.basename(artifactPath).replace(/^opencode-health-/, '').replace(/\.json$/, '')) + }) + + 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..605423105 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,22 @@ 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()), + readOpenCodeConfigFile: vi.fn(), + writeOpenCodeConfigFile: vi.fn(), +})) + +vi.mock('../../src/services/opencode-single-server', () => ({ + opencodeServerManager: { + clearStartupError: vi.fn(), + }, +})) + +vi.mock('../../src/services/opencode/config-recovery', () => ({ + patchConfigWithRecovery: vi.fn(), })) vi.mock('@opencode-manager/shared/config/env', () => ({ @@ -33,37 +48,33 @@ 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 { readOpenCodeConfigFile, writeOpenCodeConfigFile } from '../../src/services/opencode-config-file' +import type { SettingsService } from '../../src/services/settings' +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 mockReadOpenCodeConfigFile = readOpenCodeConfigFile 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 +119,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') @@ -135,6 +137,35 @@ describe('opencode-import service', () => { ) }) + it('captures the previous on-disk config as last known good before importing over it', async () => { + process.env.OPENCODE_IMPORT_CONFIG_PATH = '/import/opencode-config/opencode.json' + + mockFileExists.mockImplementation(async (candidate: string) => candidate === '/import/opencode-config/opencode.json') + mockReadOpenCodeConfigFile.mockResolvedValue({ + isValid: true, + rawContent: '{"theme":"previous"}', + }) + const settingsService = { saveLastKnownGoodConfig: vi.fn() } as unknown as SettingsService + + await syncOpenCodeImport({ overwriteState: true, settingsService }) + + expect(settingsService.saveLastKnownGoodConfig).toHaveBeenCalledWith('{"theme":"previous"}') + expect(mockWriteOpenCodeConfigFile).toHaveBeenCalledWith('{"$schema":"https://opencode.ai/config.json"}') + }) + + it('does not capture last known good when no previous config file exists', async () => { + process.env.OPENCODE_IMPORT_CONFIG_PATH = '/import/opencode-config/opencode.json' + + mockFileExists.mockImplementation(async (candidate: string) => candidate === '/import/opencode-config/opencode.json') + mockReadOpenCodeConfigFile.mockResolvedValue(null) + const settingsService = { saveLastKnownGoodConfig: vi.fn() } as unknown as SettingsService + + await syncOpenCodeImport({ overwriteState: true, settingsService }) + + expect(settingsService.saveLastKnownGoodConfig).not.toHaveBeenCalled() + expect(mockWriteOpenCodeConfigFile).toHaveBeenCalled() + }) + it('does not report state imported when source db is missing', async () => { process.env.OPENCODE_IMPORT_CONFIG_PATH = '/import/opencode-config/opencode.json' process.env.OPENCODE_IMPORT_STATE_PATH = '/import/opencode-state' @@ -145,8 +176,6 @@ describe('opencode-import service', () => { }) const result = await syncOpenCodeImport({ - db: mockDb, - userId: 'default', overwriteState: true, }) @@ -155,6 +184,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 +299,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-model-state.test.ts b/backend/test/services/opencode-model-state.test.ts new file mode 100644 index 000000000..049f02e4b --- /dev/null +++ b/backend/test/services/opencode-model-state.test.ts @@ -0,0 +1,184 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' + +const paths = vi.hoisted(() => ({ modelState: '' })) + +vi.mock('@opencode-manager/shared/config/env', () => ({ + getOpenCodeModelStatePath: () => paths.modelState, +})) + +vi.mock('../../src/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})) + +import { + MAX_RECENT_MODELS, + addRecentModel, + readOpenCodeModelState, + removeRecentModel, + toggleFavoriteModel, + updateOpenCodeModelState, + type OpenCodeModelStateRecord, +} from '../../src/services/opencode-model-state' +import { logger } from '../../src/utils/logger' + +const emptyState = (): OpenCodeModelStateRecord => ({ recent: [], favorite: [], variant: {} }) + +describe('opencode-model-state', () => { + let workDir: string + + beforeEach(async () => { + vi.clearAllMocks() + workDir = await mkdtemp(path.join(tmpdir(), 'opencode-model-state-')) + paths.modelState = path.join(workDir, 'model.json') + }) + + afterEach(async () => { + await rm(workDir, { recursive: true, force: true }) + }) + + describe('addRecentModel', () => { + it('adds a new model to the front', () => { + const state = addRecentModel(emptyState(), { providerID: 'anthropic', modelID: 'claude' }) + expect(state.recent).toEqual([{ providerID: 'anthropic', modelID: 'claude' }]) + }) + + it('deduplicates and moves a re-selected model to the front', () => { + const model = { providerID: 'openai', modelID: 'gpt-4o' } + const first = addRecentModel(emptyState(), model) + const second = addRecentModel(first, { providerID: 'anthropic', modelID: 'claude' }) + const third = addRecentModel(second, model) + + expect(third.recent).toEqual([ + model, + { providerID: 'anthropic', modelID: 'claude' }, + ]) + }) + + it('caps recent at MAX_RECENT_MODELS', () => { + let state = emptyState() + for (let i = 0; i < MAX_RECENT_MODELS + 2; i += 1) { + state = addRecentModel(state, { providerID: `provider-${i}`, modelID: `model-${i}` }) + } + + expect(state.recent).toHaveLength(MAX_RECENT_MODELS) + expect(state.recent[0]).toEqual({ providerID: 'provider-11', modelID: 'model-11' }) + }) + }) + + describe('removeRecentModel', () => { + it('removes the matching model and keeps the rest', () => { + const state = { + recent: [ + { providerID: 'anthropic', modelID: 'claude' }, + { providerID: 'openai', modelID: 'gpt-4o' }, + ], + favorite: [], + variant: {}, + } + + expect(removeRecentModel(state, { providerID: 'anthropic', modelID: 'claude' }).recent).toEqual([ + { providerID: 'openai', modelID: 'gpt-4o' }, + ]) + }) + }) + + describe('toggleFavoriteModel', () => { + it('adds when missing', () => { + const state = toggleFavoriteModel(emptyState(), { providerID: 'anthropic', modelID: 'claude' }) + expect(state.favorite).toEqual([{ providerID: 'anthropic', modelID: 'claude' }]) + }) + + it('removes when present', () => { + const model = { providerID: 'openai', modelID: 'gpt-4' } + const added = toggleFavoriteModel(emptyState(), model) + expect(toggleFavoriteModel(added, model).favorite).toEqual([]) + }) + }) + + describe('readOpenCodeModelState', () => { + it('returns empty defaults when the file is missing', async () => { + await expect(readOpenCodeModelState()).resolves.toEqual(emptyState()) + }) + + it('returns the parsed state when the file is valid', async () => { + const state = { + recent: [{ providerID: 'anthropic', modelID: 'claude' }], + favorite: [{ providerID: 'openai', modelID: 'gpt-4' }], + variant: { anthropic: 'thinking' }, + } + await writeFile(paths.modelState, JSON.stringify(state), 'utf8') + + await expect(readOpenCodeModelState()).resolves.toEqual(state) + }) + + it('warns and returns empty defaults when the file has invalid structure', async () => { + await writeFile(paths.modelState, JSON.stringify({ recent: 'not-an-array' }), 'utf8') + + await expect(readOpenCodeModelState()).resolves.toEqual(emptyState()) + expect(logger.warn).toHaveBeenCalled() + }) + }) + + describe('updateOpenCodeModelState', () => { + it('writes the mutated state and preserves unknown top-level keys', async () => { + await writeFile( + paths.modelState, + JSON.stringify({ session: { current: 'abc' }, recent: [{ providerID: 'anthropic', modelID: 'claude' }] }), + 'utf8', + ) + + const next = await updateOpenCodeModelState(state => + addRecentModel(state, { providerID: 'openai', modelID: 'gpt-4o' }), + ) + + expect(next.recent[0]).toEqual({ providerID: 'openai', modelID: 'gpt-4o' }) + + const file = JSON.parse(await readFile(paths.modelState, 'utf8')) as { + session: unknown + recent: unknown[] + favorite: unknown[] + variant: Record + } + expect(file.session).toEqual({ current: 'abc' }) + expect(file.recent[0]).toEqual({ providerID: 'openai', modelID: 'gpt-4o' }) + expect(file.favorite).toEqual([]) + expect(file.variant).toEqual({}) + }) + + it('recovers from corrupt JSON and writes valid state', async () => { + await writeFile(paths.modelState, '{ invalid json content }', 'utf8') + + const next = await updateOpenCodeModelState(state => + addRecentModel(state, { providerID: 'test', modelID: 'test' }), + ) + + expect(next.recent).toHaveLength(1) + const file = JSON.parse(await readFile(paths.modelState, 'utf8')) as { recent: unknown[] } + expect(file.recent).toHaveLength(1) + }) + + it('serializes concurrent updates without losing entries or exceeding the cap', async () => { + const numOps = 20 + + const operations = Array.from({ length: numOps }, (_, i) => + updateOpenCodeModelState(state => addRecentModel(state, { providerID: `provider-${i}`, modelID: `model-${i}` })), + ) + + await Promise.all(operations) + + const finalState = await readOpenCodeModelState() + expect(finalState.recent.length).toBeLessThanOrEqual(MAX_RECENT_MODELS) + expect(finalState.recent.length).toBeGreaterThan(0) + + const uniqueKeys = new Set(finalState.recent.map((m) => `${m.providerID}/${m.modelID}`)) + expect(uniqueKeys.size).toBe(finalState.recent.length) + }) + }) +}) diff --git a/backend/test/services/opencode-restart.test.ts b/backend/test/services/opencode-restart.test.ts index 1b4f55cce..31b48a848 100644 --- a/backend/test/services/opencode-restart.test.ts +++ b/backend/test/services/opencode-restart.test.ts @@ -4,7 +4,6 @@ const managerMock = vi.hoisted(() => ({ getLastStartupError: vi.fn<() => string | null>(() => null), clearStartupError: vi.fn<() => void>(), restart: vi.fn<() => Promise>(), - checkHealth: vi.fn<() => boolean>(() => true), })) vi.mock('../../src/services/opencode-single-server', () => ({ @@ -13,7 +12,6 @@ vi.mock('../../src/services/opencode-single-server', () => ({ import { restartOpenCode, - restartOpenCodeAfterCommit, setOpenCodeRestartCoordinator, } from '../../src/services/opencode-restart' import type { OpenCodeRestartCoordinator } from '../../src/services/opencode-restart-coordinator' @@ -92,29 +90,3 @@ describe('restartOpenCode', () => { expect(managerMock.clearStartupError).toHaveBeenCalled() }) }) - -describe('restartOpenCodeAfterCommit', () => { - beforeEach(() => { - vi.clearAllMocks() - setOpenCodeRestartCoordinator(null) - }) - - afterEach(() => { - setOpenCodeRestartCoordinator(null) - }) - - it('reports success without a restart error when the restart completes', async () => { - managerMock.checkHealth.mockReturnValue(true) - - await expect(restartOpenCodeAfterCommit(createSupervisor(true))).resolves.toEqual({ restartFailed: false }) - }) - - it('reports the failure instead of throwing so the caller can still return the persisted entity', async () => { - managerMock.getLastStartupError.mockReturnValue('OpenCode health check failed') - - const result = await restartOpenCodeAfterCommit(createSupervisor(false)) - - expect(result.restartFailed).toBe(true) - expect(result.restartError).toBe('OpenCode health check failed') - }) -}) diff --git a/backend/test/services/opencode-single-server.test.ts b/backend/test/services/opencode-single-server.test.ts index 179311dc0..ac35b3ec5 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,18 @@ vi.mock('../../src/services/opencode/config-recovery', () => ({ patchConfigWithRecovery: vi.fn(), })) +const writeOpenCodeConfigFileMock = vi.hoisted(() => vi.fn()) +const readOpenCodeConfigFileMock = vi.hoisted(() => vi.fn()) + +vi.mock('../../src/services/opencode-config-file', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + readOpenCodeConfigFile: readOpenCodeConfigFileMock, + writeOpenCodeConfigFile: writeOpenCodeConfigFileMock, + } +}) + vi.mock('../../src/services/opencode/client', () => ({ createOpenCodeClient: createOpenCodeClientMock, })) @@ -125,6 +138,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' @@ -3048,13 +3062,22 @@ describe('ConfigReloadError', () => { }) describe('OpenCodeServerManager - reloadConfig', () => { + const configFile = (content: Record) => ({ + path: '/test/workspace/.config/opencode.json', + rawContent: JSON.stringify(content), + content, + isValid: true, + updatedAt: 0, + }) + beforeEach(() => { vi.clearAllMocks() + writeOpenCodeConfigFileMock.mockReset() + readOpenCodeConfigFileMock.mockReset() }) it('should read config from file before patching', async () => { - const mockReadFile = vi.fn().mockResolvedValue(JSON.stringify({ command: { review: 'test' } })) - fs.readFile = mockReadFile + readOpenCodeConfigFileMock.mockResolvedValue(configFile({ command: { review: 'test' } })) const { patchConfigWithRecovery } = await import('../../src/services/opencode/config-recovery') const mockPatchResult = { success: true } @@ -3066,10 +3089,7 @@ describe('OpenCodeServerManager - reloadConfig', () => { await opencodeServerManager.reloadConfig() - expect(mockReadFile).toHaveBeenCalledWith( - expect.stringContaining('.config/opencode.json'), - 'utf-8' - ) + expect(readOpenCodeConfigFileMock).toHaveBeenCalled() expect(patchConfigWithRecovery).toHaveBeenCalled() }) @@ -3079,7 +3099,7 @@ describe('OpenCodeServerManager - reloadConfig', () => { 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({ plugin: ['evil-plugin'], model: 'x' })) + readOpenCodeConfigFileMock.mockResolvedValue(configFile({ plugin: ['evil-plugin'], model: 'x' })) await opencodeServerManager.reloadConfig() @@ -3093,13 +3113,126 @@ describe('OpenCodeServerManager - reloadConfig', () => { 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' })) + readOpenCodeConfigFileMock.mockResolvedValue(configFile({ model: 'x' })) await 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()) + readOpenCodeConfigFileMock.mockResolvedValue(configFile({ 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()) + readOpenCodeConfigFileMock.mockResolvedValue(configFile({ 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()) + readOpenCodeConfigFileMock.mockResolvedValue(configFile({ 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) + readOpenCodeConfigFileMock.mockResolvedValue(configFile({ 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: { saveLastKnownGoodConfig: vi.fn() } 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..61c18b96e 100644 --- a/backend/test/services/opencode-supervisor.test.ts +++ b/backend/test/services/opencode-supervisor.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ensureDirectoryExists, writeFileContent } from '../../src/services/file-operations' +import { archiveBrokenOpenCodeConfigFile, writeHealthWatchArtifact, writeOpenCodeConfigFile, OPENCODE_CONFIG_SEED } from '../../src/services/opencode-config-file' import { OpenCodeSupervisor } from '../../src/services/opencode-supervisor' vi.mock('../../src/utils/logger', () => ({ @@ -10,14 +10,24 @@ vi.mock('../../src/utils/logger', () => ({ }, })) -vi.mock('../../src/services/file-operations', () => ({ - writeFileContent: vi.fn(), - ensureDirectoryExists: vi.fn(), +vi.mock('../../src/services/opencode-config-file', () => ({ + archiveBrokenOpenCodeConfigFile: vi.fn(), + writeHealthWatchArtifact: vi.fn(), + writeOpenCodeConfigFile: vi.fn(async (rawContent: string) => ({ rawContent, isValid: true })), + withOpenCodeConfigLock: (fn: () => Promise) => fn(), + OPENCODE_CONFIG_SEED: '{"$schema":"https://opencode.ai/config.json"}', +})) + +vi.mock('../../src/services/opencode-single-server', () => ({ + opencodeServerManager: { + clearStartupError: vi.fn(), + }, })) vi.mock('@opencode-manager/shared/config/env', () => ({ - getWorkspacePath: vi.fn(() => '/tmp/opencode-workspace'), - getOpenCodeConfigFilePath: vi.fn(() => '/tmp/opencode-workspace/.config/opencode.json'), + TIMEOUTS: { + CONFIG_PATCH_TIMEOUT_MS: 30000, + }, ENV: { OPENCODE: { HEALTH_POLL_MS: 200, @@ -45,11 +55,7 @@ interface FakeManager { } interface FakeSettingsService { - archiveBrokenConfig: ReturnType - restoreToLastKnownGoodConfig: ReturnType - getDefaultOpenCodeConfig: ReturnType - updateOpenCodeConfig: ReturnType - createOpenCodeConfig: ReturnType + getLastKnownGoodConfig: ReturnType } describe('OpenCodeSupervisor', () => { @@ -75,24 +81,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 +89,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 +102,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 +149,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 +166,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')) @@ -251,8 +249,7 @@ describe('OpenCodeSupervisor', () => { const status = await supervisor.checkNow('manual') expect(status.healthy).toBe(true) - expect(ensureDirectoryExists).toHaveBeenCalled() - expect(writeFileContent).toHaveBeenCalled() + expect(writeHealthWatchArtifact).toHaveBeenCalled() expect(manager.restart).toHaveBeenCalledTimes(2) }) @@ -273,7 +270,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,11 +281,10 @@ 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(writeFileContent).not.toHaveBeenCalled() + expect(archiveBrokenOpenCodeConfigFile).not.toHaveBeenCalled() + expect(settings.getLastKnownGoodConfig).not.toHaveBeenCalled() + expect(writeOpenCodeConfigFile).not.toHaveBeenCalled() + expect(writeHealthWatchArtifact).not.toHaveBeenCalled() await supervisor.stop() }) @@ -299,7 +294,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,11 +302,10 @@ 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(writeFileContent).not.toHaveBeenCalled() + expect(archiveBrokenOpenCodeConfigFile).not.toHaveBeenCalled() + expect(settings.getLastKnownGoodConfig).not.toHaveBeenCalled() + expect(writeOpenCodeConfigFile).not.toHaveBeenCalled() + expect(writeHealthWatchArtifact).not.toHaveBeenCalled() await supervisor.stop() }) @@ -322,7 +315,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,11 +329,10 @@ 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(writeFileContent).not.toHaveBeenCalled() + expect(archiveBrokenOpenCodeConfigFile).not.toHaveBeenCalled() + expect(settings.getLastKnownGoodConfig).not.toHaveBeenCalled() + expect(writeOpenCodeConfigFile).not.toHaveBeenCalled() + expect(writeHealthWatchArtifact).not.toHaveBeenCalled() await supervisor.stop() }) @@ -351,7 +342,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 +353,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 +508,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/opencode/config-recovery.test.ts b/backend/test/services/opencode/config-recovery.test.ts index 65dadeeb4..9c6a20dac 100644 --- a/backend/test/services/opencode/config-recovery.test.ts +++ b/backend/test/services/opencode/config-recovery.test.ts @@ -23,6 +23,9 @@ vi.mock('@opencode-manager/shared/config/env', () => ({ MAX_SIZE_BYTES: 1024 * 1024, MAX_UPLOAD_SIZE_BYTES: 10 * 1024 * 1024, }, + TIMEOUTS: { + CONFIG_PATCH_TIMEOUT_MS: 15000, + }, })) vi.mock('../../../src/utils/logger', () => ({ @@ -272,6 +275,42 @@ describe('patchConfigWithRecovery', () => { expect(captured).toHaveLength(1) }) + it('should pass an AbortSignal to every forward call', async () => { + const errorResponse = { + success: false, + data: {}, + errors: [ + { path: ['command', 'review'], message: 'Invalid command review field' }, + ], + } + + const captured: ForwardRequest[] = [] + const client = createStubClient([ + { status: 400, text: JSON.stringify(errorResponse) }, + { status: 200, text: '{}' }, + ], captured) + + const result = await patchConfigWithRecovery(client, { command: { review: 'test' } }) + + expect(result.success).toBe(true) + expect(captured).toHaveLength(2) + expect(captured[0]!.signal).toBeInstanceOf(AbortSignal) + expect(captured[1]!.signal).toBeInstanceOf(AbortSignal) + expect(captured[0]!.signal).not.toBe(captured[1]!.signal) + }) + + it('should map a TimeoutError rejection to a readable timeout error result', async () => { + const client = createStubClient([]) + client.forward = vi.fn(async () => { + throw new DOMException('The operation was aborted due to timeout', 'TimeoutError') + }) + + const result = await patchConfigWithRecovery(client, {}) + + expect(result.success).toBe(false) + expect(result.error).toMatch(/timed out/i) + }) + it('should return error on 502 from client.forward', async () => { const error502Response = { error: 'Proxy request failed' } const captured: ForwardRequest[] = [] 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/test/utils/fs-safe.test.ts b/backend/test/utils/fs-safe.test.ts new file mode 100644 index 000000000..e517accff --- /dev/null +++ b/backend/test/utils/fs-safe.test.ts @@ -0,0 +1,31 @@ +import { mkdtemp, realpath, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { canonicalPath, canonicalPathSync } from '../../src/utils/fs-safe' + +describe('canonicalPath', () => { + let workDir = '' + + beforeEach(async () => { + workDir = await mkdtemp(path.join(tmpdir(), 'fs-safe-')) + }) + + afterEach(async () => { + await rm(workDir, { recursive: true, force: true }) + }) + + it('resolves an existing path to its realpath', async () => { + const expected = await realpath(workDir) + + await expect(canonicalPath(workDir)).resolves.toBe(expected) + expect(canonicalPathSync(workDir)).toBe(expected) + }) + + it('returns a missing path unchanged', async () => { + const missing = path.join(workDir, 'missing', 'target') + + await expect(canonicalPath(missing)).resolves.toBe(missing) + expect(canonicalPathSync(missing)).toBe(missing) + }) +}) diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index 783bfce62..6949703a6 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'], + reporter: ['text-summary', '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/configuration/environment.md b/docs/configuration/environment.md index 6018e5ab5..c82beb04c 100644 --- a/docs/configuration/environment.md +++ b/docs/configuration/environment.md @@ -116,7 +116,7 @@ Sandboxed agent commands run inside a microVM managed by `msb` (see [Agent Sandb |----------|-------------|---------| | `MSB_PATH` | Path to the `msb` executable | `msb` | | `MSB_LIBKRUNFW_PATH` | Path to the `libkrunfw` firmware library used by `msb` (set in the container image) | `/opt/microsandbox/lib/libkrunfw.so` | -| `SANDBOX_IMAGE` | OCI image the microVM boots from. Digest-pinned by default so a rebuilt guest image is actually adopted; see [Sandbox Guest Image](../features/sandboxing.md#sandbox-guest-image) for what the default ships and how to build your own | `docker.io/cstechdev/ocm-sandbox@sha256:30d4c0e7…` | +| `SANDBOX_IMAGE` | OCI image the microVM boots from. Digest-pinned by default so a rebuilt guest image is actually adopted; see [Sandbox Guest Image](../features/sandboxing.md#sandbox-guest-image) for what the default ships and how to build your own | `docker.io/cstechdev/ocm-sandbox@sha256:7435dce1…` | | `SANDBOX_MEMORY` | MicroVM memory (e.g. `4G`) | `4G` | | `SANDBOX_CPUS` | MicroVM CPU count | `2` | | `SANDBOX_EXEC_USER` | Guest identity sandboxed commands run as: a numeric `uid`, a numeric `uid:gid`, or a guest username. A numeric uid must match the Manager's effective uid (`PUID`); the compose overlay defaults it to `${PUID:-1000}`. A guest username is resolved to the Manager's effective `uid:gid` so writes to the mounted project roots always succeed. When a configured numeric identity cannot write the workspace, enforcement is reported unavailable | `${PUID:-1000}` via the overlay, otherwise `node` | @@ -131,6 +131,7 @@ Sandboxed agent commands run inside a microVM managed by `msb` (see [Agent Sandb | `PROCESS_START_WAIT_MS` | Wait time for OpenCode process to start | `2000` | | `PROCESS_VERIFY_WAIT_MS` | Wait time for process health verification | `1000` | | `HEALTH_CHECK_TIMEOUT_MS` | OpenCode liveness probe timeout | `30000` | +| `CONFIG_PATCH_TIMEOUT_MS` | Timeout for an OpenCode config patch request | `15000` | ## File Limits diff --git a/docs/features/ai-config.md b/docs/features/ai-config.md index e953049a2..1dbe3365b 100644 --- a/docs/features/ai-config.md +++ b/docs/features/ai-config.md @@ -16,17 +16,11 @@ A compact model switcher is embedded directly in the chat interface. Click the * | **Variants** | Some models offer tier options (e.g., fast or pro). Variant items are highlighted and show a checkmark on the active selection. | | **All Models…** | Opens the full model browser when you need a model not in recents or favorites. | -Model selections persist across page reloads. +Favorites, recents, and variant choices are stored in OpenCode's own state file (`.opencode/state/opencode/model.json` in the workspace), so they are shared with the OpenCode TUI and survive Manager restarts. The active model itself is not persisted: on page load it resolves the same way the OpenCode TUI does — the `model` set in `opencode.json`, otherwise your most recent valid model, otherwise the provider default. A model you pick during a session stays active until the page is reloaded. -### Per-Agent Model Selection +### Per-Agent and Per-Session Models -Each agent can use a different model independently: - -1. Select an agent in the chat session header -2. Open the quick model switcher -3. Choose a model — it is now stored for that agent - -When you switch agents, the model you last used with that agent is restored automatically. Your global model selection is unaffected. +Each agent can declare its own `model` in `opencode.json`, which becomes that agent's default. When you open an existing session, the model, agent, and variant used by its last message are restored from the session itself, so switching between sessions keeps each one's selection. ### Full Model Browser 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..a24a88b02 100644 --- a/docs/features/server-health.md +++ b/docs/features/server-health.md @@ -40,6 +40,23 @@ 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. + +The last known good config is captured from the current on-disk file before every write made through the Settings UI, the internal API, or a host config import, so any of those can be undone with `POST /api/settings/opencode-rollback` or by the ladder. Archived broken configs and debug snapshots are kept under `.opencode/state/health-watch/` in the workspace, pruned to the newest 20 files. + +Earlier releases stored named configuration profiles in the Manager database. On first start after upgrading, each profile is archived to `.config/opencode-configs-archive/.json` in the workspace, the default profile is restored to `opencode.json` if that file does not exist yet, and the database table is dropped. + ## Restart with Session Resume When you restart the OpenCode server (manually or through an upgrade), active sessions are handled gracefully: @@ -68,9 +85,8 @@ If the upgrade fails but the server recovers to a usable state, a recovery notic Besides the explicit **Restart** button, the server is automatically restarted when: -- **OpenCode configuration is saved** — Changes to models, agents, commands, or MCP servers that require a server restart - **Assistant workspace is reloaded** — Via the `POST /assistant/reload` internal API endpoint - **Config import completes** — Importing a standalone OpenCode config into the workspace - **Version upgrade** — After installing a new OpenCode version -Configuration changes that only affect non-process settings (e.g., environment variable passthrough, AGENTS.md) use a non-disruptive config reload instead, which does not interrupt active sessions. +Saving the OpenCode configuration never restarts the server on its own. Changes to `agent`, `plugin`, `skills`, or `provider` are written to disk and flagged as **restart required**; the server keeps running on the previous configuration until you restart it. Every other change is live-patched into the running server without interrupting active sessions, and is only written to disk once the server has accepted it. diff --git a/frontend/src/api/providers.test.ts b/frontend/src/api/providers.test.ts new file mode 100644 index 000000000..52aa698b3 --- /dev/null +++ b/frontend/src/api/providers.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { getProvidersWithModels } from './providers' +import { makeOpenCodeConfigFile } from '@/test/fixtures/opencode-config' + +const { mockGetOpenCodeConfig, mockFetchWrapper } = vi.hoisted(() => ({ + mockGetOpenCodeConfig: vi.fn(), + mockFetchWrapper: vi.fn(), +})) + +vi.mock('./settings', () => ({ + settingsApi: { + getOpenCodeConfig: mockGetOpenCodeConfig, + }, +})) + +vi.mock('./fetchWrapper', () => ({ + fetchWrapper: mockFetchWrapper, +})) + +const config = makeOpenCodeConfigFile({ + 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 cc70aa32e..478bb7aab 100644 --- a/frontend/src/api/repos.ts +++ b/frontend/src/api/repos.ts @@ -9,7 +9,6 @@ export interface CreateRepoOptions { localPath?: string branch?: string directoryName?: string - openCodeConfigName?: string useWorktree?: boolean skipSSHVerification?: boolean baseBranch?: string @@ -87,34 +86,6 @@ export async function deleteRepo(id: number): Promise { }) } -export async function startServer(id: number, openCodeConfigName?: string): Promise { - return fetchWrapper(`${API_BASE_URL}/api/repos/${id}/server/start`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ openCodeConfigName }), - }) -} - -export async function stopServer(id: number): Promise { - return fetchWrapper(`${API_BASE_URL}/api/repos/${id}/server/stop`, { - method: 'POST', - }) -} - -export async function pullRepo(id: number): Promise { - return fetchWrapper(`${API_BASE_URL}/api/repos/${id}/pull`, { - method: 'POST', - }) -} - -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..5a5a9ed11 100644 --- a/frontend/src/components/model/ModelSelectDialog.tsx +++ b/frontend/src/components/model/ModelSelectDialog.tsx @@ -11,12 +11,11 @@ import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Clock, Loader2, Search, Check, Star } from "lucide-react"; import { - getProvidersWithModels, formatModelName, formatProviderName, } from "@/api/providers"; +import { useProvidersWithModels } from "@/hooks/useProvidersWithModels"; import { useModelSelection } from "@/hooks/useModelSelection"; -import { keepPreviousData, useQuery } from "@tanstack/react-query"; import type { Model, ProviderWithModels } from "@/api/providers"; interface ModelSelectDialogProps { @@ -349,13 +348,10 @@ export function ModelSelectDialog({ const { modelString, setModel, toggleFavorite, recentModels, favoriteModels } = useModelSelection(opcodeUrl, directory); const currentModel = modelString || ""; - const { data: allProviders = [], isLoading: loading } = useQuery({ - queryKey: ["providers-with-models", opcodeUrl, directory], - queryFn: () => getProvidersWithModels(directory), + const { data: allProviders, isLoading: loading } = useProvidersWithModels({ enabled: open, - staleTime: 5 * 60 * 1000, - gcTime: 10 * 60 * 1000, - placeholderData: keepPreviousData, + directory, + keyParts: [opcodeUrl, directory], }); const connectedProviders = useMemo(() => { 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..24fb49f3a --- /dev/null +++ b/frontend/src/components/schedules/ScheduleJobDialog.providers.test.tsx @@ -0,0 +1,80 @@ +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 { makeOpenCodeConfigFile } from '@/test/fixtures/opencode-config' + +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 = makeOpenCodeConfigFile() + +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..746ad06fc 100644 --- a/frontend/src/components/schedules/ScheduleJobDialog.tsx +++ b/frontend/src/components/schedules/ScheduleJobDialog.tsx @@ -1,7 +1,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 { useProvidersWithModels } from '@/hooks/useProvidersWithModels' import { createOpenCodeClient } from '@/api/opencode' import { settingsApi } from '@/api/settings' import { listRepos, listBranches } from '@/api/repos' @@ -74,11 +74,9 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit, const { data: templates = EMPTY_TEMPLATES } = usePromptTemplates() const deleteTemplateMutation = useDeletePromptTemplate() - const { data: providerModels = [] } = useQuery({ - queryKey: ['providers-with-models', 'schedule-dialog'], - queryFn: () => getProvidersWithModels(), + const { data: providerModels } = useProvidersWithModels({ enabled: open, - staleTime: 5 * 60 * 1000, + keyParts: ['schedule-dialog'], }) const { data: agents = [] } = useQuery({ diff --git a/frontend/src/components/settings/AddMcpServerDialog.test.tsx b/frontend/src/components/settings/AddMcpServerDialog.test.tsx new file mode 100644 index 000000000..8a6cc9464 --- /dev/null +++ b/frontend/src/components/settings/AddMcpServerDialog.test.tsx @@ -0,0 +1,74 @@ +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 { makeOpenCodeConfigFile } from '@/test/fixtures/opencode-config' + +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 = makeOpenCodeConfigFile() + +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..a987ed13f 100644 --- a/frontend/src/components/settings/AgentDialog.tsx +++ b/frontend/src/components/settings/AgentDialog.tsx @@ -2,7 +2,6 @@ import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' import { useMemo, useEffect } from 'react' -import { useQuery } from '@tanstack/react-query' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' @@ -11,7 +10,7 @@ import { Textarea } from '@/components/ui/textarea' 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 { useProvidersWithModels } from '@/hooks/useProvidersWithModels' 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,12 +65,7 @@ interface AgentDialogProps { } export function AgentDialog({ open, onOpenChange, onSubmit, editingAgent }: AgentDialogProps) { - const { data: providers = [] } = useQuery({ - queryKey: ['providers-with-models'], - queryFn: () => getProvidersWithModels(), - enabled: open, - staleTime: 5 * 60 * 1000, - }) + const { data: providers } = useProvidersWithModels({ enabled: open }) const providerOptions: ComboboxOption[] = useMemo(() => { const sourceLabels: Record = { 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" - /> -
- -
- - -
- -
- -