diff --git a/backend/src/index.ts b/backend/src/index.ts index 64c393af..f2de1190 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -54,8 +54,6 @@ import { getOpenCodeImportStatus, syncOpenCodeImport } from './services/opencode 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 { @@ -119,29 +117,8 @@ async function ensureDefaultConfigExists(): Promise { 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 - } + settingsService.upsertDefaultOpenCodeConfig(rawContent) + return } catch (error) { logger.warn('Failed to read workspace config', error) } diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index d512fa4d..81a73ac2 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -49,6 +49,7 @@ import { installSkillFromGithubTree, installSkillFromUploadedFiles, } from '../services/skills' +import { replaceOpenCodeConfigDirectory } from '../services/opencode-config-directory' import { installOpenCodeDirectoryFiles, listOpenCodeDirectoryFiles, @@ -154,6 +155,19 @@ const OPENCODE_DIRECTORY_UPLOAD_ERROR_STATUS: ReadonlyArray = [ + ['No files were provided', 400], + ['must contain opencode.json', 400], + ['too many files', 400], + ['contains too many files', 400], + ['exceed maximum upload size', 413], + ['Path must be relative', 400], + ['Path must not contain', 400], + ['escapes', 400], + ['Missing upload file', 400], + ['not a valid file', 400], +] + function matchErrorStatus( table: ReadonlyArray, error: Error, @@ -1308,6 +1322,62 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic } }) + app.post('/opencode-config-directory/replace', async (c) => { + try { + const contentType = c.req.header('content-type') || '' + if (!contentType.includes('multipart/form-data')) { + return c.json({ error: 'Unsupported content type. Use multipart/form-data' }, 400) + } + + const formData = await c.req.parseBody({ all: true }) + const userId = c.req.query('userId') || 'default' + + let manifest: ReturnType + try { + manifest = parseUploadManifest(formData['fileManifest']) + } catch (error) { + if (error instanceof z.ZodError) { + return c.json({ error: 'Invalid upload manifest', details: error.issues }, 400) + } + throw error + } + if (manifest.length === 0) { + return c.json({ error: 'fileManifest must contain at least one entry' }, 400) + } + + const files = await readUploadedManifestFiles(formData, manifest) + + settingsService.saveLastKnownGoodConfig(userId) + + const result = await replaceOpenCodeConfigDirectory(db, files, userId) + + opencodeServerManager.markRestartPending() + opencodeServerManager.clearStartupError() + await restartOpenCodeSafe(openCodeSupervisor, 'OpenCode config directory replace') + + return c.json({ ...result, restartRequired: true }) + } catch (error) { + logger.error('Failed to replace OpenCode config directory:', error) + + if (error instanceof UploadValidationError) { + return c.json({ error: error.message }, 400) + } + + if (error instanceof z.ZodError) { + return c.json({ error: 'Uploaded OpenCode config is invalid', details: error.issues }, 400) + } + + if (error instanceof Error) { + const status = matchErrorStatus(OPENCODE_CONFIG_DIRECTORY_REPLACE_ERROR_STATUS, error) + if (status) { + return c.json({ error: error.message }, status) + } + } + + return c.json({ error: 'Failed to replace OpenCode config directory' }, 500) + } + }) + app.get('/opencode-directory-files', async (c) => { try { const kind = z.enum(['agents', 'commands']).parse(c.req.query('kind')) diff --git a/backend/src/services/opencode-config-directory.ts b/backend/src/services/opencode-config-directory.ts new file mode 100644 index 00000000..25a682d0 --- /dev/null +++ b/backend/src/services/opencode-config-directory.ts @@ -0,0 +1,168 @@ +import path from 'path' +import { promises as fs } from 'fs' +import { randomUUID } from 'crypto' +import type { Database } from 'bun:sqlite' +import { FILE_LIMITS, getConfigPath } from '@opencode-manager/shared/config/env' +import { + OPENCODE_CANONICAL_CONFIG_FILENAME, + getCommonUploadRootDirectory, + isExcludedOpenCodeConfigUploadPath, + isOpenCodeConfigUploadPath, +} from '@opencode-manager/shared/utils' +import { fileExists, normalizeUploadRelativePath, resolveWithinDirectory } from './file-operations' +import { mkdirSafe } from '../utils/fs-safe' +import { SettingsService } from './settings' +import { logger } from '../utils/logger' + +export interface UploadedConfigDirectoryFile { + relativePath: string + content: Buffer +} + +export interface ReplaceOpenCodeConfigDirectoryResult { + configDirectory: string + configSourceFilename: string + filesInstalled: string[] + skippedPaths: string[] + preservedEntries: string[] + executablesRestored: string[] +} + +const MAX_CONFIG_DIRECTORY_FILES = 5000 +const PRESERVED_ENTRIES = ['node_modules'] +const STAGING_PREFIX = '.opencode-config-staging-' +const BACKUP_PREFIX = '.opencode-config-backup-' +const SHEBANG_PREFIX = '#!' + +export async function replaceOpenCodeConfigDirectory( + db: Database, + files: UploadedConfigDirectoryFile[], + userId = 'default', +): Promise { + const normalizedFiles = files.map((file) => ({ + relativePath: normalizeUploadRelativePath(file.relativePath, { collapseEmptySegments: true }), + content: file.content, + })) + + const commonRoot = getCommonUploadRootDirectory(normalizedFiles.map((file) => file.relativePath)) + const strippedFiles = normalizedFiles + .map((file) => ({ + relativePath: commonRoot ? file.relativePath.slice(commonRoot.length + 1) : file.relativePath, + content: file.content, + })) + .filter((file) => file.relativePath !== '') + + const kept: UploadedConfigDirectoryFile[] = [] + const skippedPaths: string[] = [] + for (const file of strippedFiles) { + if (isExcludedOpenCodeConfigUploadPath(file.relativePath)) { + skippedPaths.push(file.relativePath) + } else { + kept.push(file) + } + } + + if (kept.length === 0) { + throw new Error('No files were provided for the OpenCode config directory replace') + } + if (kept.length > MAX_CONFIG_DIRECTORY_FILES) { + throw new Error('Uploaded config directory contains too many files (max 5000)') + } + const totalBytes = kept.reduce((sum, file) => sum + file.content.length, 0) + if (totalBytes > FILE_LIMITS.MAX_UPLOAD_SIZE_BYTES) { + throw new Error('Uploaded config directory files exceed maximum upload size') + } + + const configCandidates = kept + .map((file, index) => ({ file, index })) + .filter(({ file }) => isOpenCodeConfigUploadPath(file.relativePath)) + const jsonCandidate = configCandidates.find(({ file }) => file.relativePath === 'opencode.json') + const jsoncCandidate = configCandidates.find(({ file }) => file.relativePath === 'opencode.jsonc') + const chosenConfig = jsonCandidate ?? jsoncCandidate + if (!chosenConfig) { + throw new Error('Uploaded directory must contain opencode.json or opencode.jsonc at its root') + } + + const configSourceFilename = chosenConfig.file.relativePath + const droppedJsoncFile = jsonCandidate && jsoncCandidate ? jsoncCandidate.file : undefined + if (droppedJsoncFile) { + skippedPaths.push(droppedJsoncFile.relativePath) + } + + new SettingsService(db).upsertDefaultOpenCodeConfig(chosenConfig.file.content.toString('utf8'), userId) + + const filesToWrite = kept + .filter((file) => file !== droppedJsoncFile) + .map((file) => file.relativePath === configSourceFilename + ? { relativePath: OPENCODE_CANONICAL_CONFIG_FILENAME, content: file.content } + : file) + + const configDirectory = getConfigPath() + const parent = path.dirname(configDirectory) + + const executablesRestored: string[] = [] + const preservedEntries: string[] = [] + let staged: string | null = null + let backupPath: string | null = null + + try { + await mkdirSafe(parent) + staged = await fs.mkdtemp(path.join(parent, STAGING_PREFIX)) + + for (const file of filesToWrite) { + const target = resolveWithinDirectory(staged, file.relativePath, 'config directory') + await mkdirSafe(path.dirname(target)) + await fs.writeFile(target, file.content) + if (file.content.subarray(0, SHEBANG_PREFIX.length).toString() === SHEBANG_PREFIX) { + await fs.chmod(target, 0o755) + executablesRestored.push(file.relativePath) + } + } + + if (await fileExists(configDirectory)) { + backupPath = path.join(parent, BACKUP_PREFIX + randomUUID()) + await fs.rename(configDirectory, backupPath) + await fs.rename(staged, configDirectory) + staged = null + + for (const name of PRESERVED_ENTRIES) { + const backupEntryPath = path.join(backupPath, name) + const targetEntryPath = path.join(configDirectory, name) + if (await fileExists(backupEntryPath) && !(await fileExists(targetEntryPath))) { + await fs.rename(backupEntryPath, targetEntryPath) + preservedEntries.push(name) + } + } + } else { + await fs.rename(staged, configDirectory) + staged = null + } + + if (backupPath) { + await fs.rm(backupPath, { recursive: true, force: true }) + } + + logger.info(`Replaced OpenCode config directory at ${configDirectory}`) + + return { + configDirectory, + configSourceFilename, + filesInstalled: filesToWrite.map((file) => file.relativePath), + skippedPaths, + preservedEntries, + executablesRestored, + } + } catch (error) { + if (staged) { + await fs.rm(staged, { recursive: true, force: true }) + } + if (backupPath) { + if (await fileExists(configDirectory)) { + await fs.rm(backupPath, { recursive: true, force: true }) + } else { + await fs.rename(backupPath, configDirectory) + } + } + throw error + } +} diff --git a/backend/src/services/opencode-directory-files.ts b/backend/src/services/opencode-directory-files.ts index eb6f8e51..52279e12 100644 --- a/backend/src/services/opencode-directory-files.ts +++ b/backend/src/services/opencode-directory-files.ts @@ -1,6 +1,6 @@ import path from 'path' import { promises as fs } from 'fs' -import { getWorkspacePath } from '@opencode-manager/shared/config/env' +import { getConfigPath } from '@opencode-manager/shared/config/env' import { normalizeUploadRelativePath, resolveWithinDirectory } from './file-operations' import { mkdirSafe } from '../utils/fs-safe' @@ -23,7 +23,7 @@ export interface OpenCodeDirectoryFileInfo { } function getOpenCodeDirectoryRoot(kind: OpenCodeDirectoryFileKind): string { - return path.join(getWorkspacePath(), '.config', 'opencode', kind) + return path.join(getConfigPath(), kind) } function getNameFromRelativePath(relativePath: string): string { diff --git a/backend/src/services/opencode-import.ts b/backend/src/services/opencode-import.ts index a224a97e..6bb90c90 100644 --- a/backend/src/services/opencode-import.ts +++ b/backend/src/services/opencode-import.ts @@ -2,9 +2,7 @@ import os from 'os' import path from 'path' 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 { getOpenCodeConfigFilePath, getWorkspacePath } from '@opencode-manager/shared/config/env' -import { parse as parseJsonc } from 'jsonc-parser' +import { getConfigPath, getOpenCodeConfigFilePath, getWorkspacePath } from '@opencode-manager/shared/config/env' import { SettingsService } from './settings' import { ensureDirectoryExists, fileExists, readFileContent, writeFileContent } from './file-operations' @@ -14,6 +12,7 @@ export interface OpenCodeImportStatus { configSourcePath: string | null stateSourcePath: string | null workspaceConfigPath: string + workspaceConfigDirectory: string workspaceStatePath: string workspaceStateExists: boolean } @@ -137,6 +136,7 @@ export async function importOpenCodeStateDirectory(sourcePath: string, targetPat export async function getOpenCodeImportStatus(): Promise { const workspaceConfigPath = getOpenCodeConfigFilePath() + const workspaceConfigDirectory = getConfigPath() const workspaceStatePath = path.join(getWorkspacePath(), '.opencode', 'state', 'opencode') const workspaceStateExists = await fileExists(path.join(workspaceStatePath, 'opencode.db')) @@ -151,6 +151,7 @@ export async function getOpenCodeImportStatus(): Promise { configSourcePath, stateSourcePath, workspaceConfigPath, + workspaceConfigDirectory, workspaceStatePath, workspaceStateExists, } @@ -158,28 +159,7 @@ export async function getOpenCodeImportStatus(): Promise { async function importOpenCodeConfigFromSource(db: Database, userId: string, sourcePath: string, workspaceConfigPath: string): Promise { const rawContent = await readFileContent(sourcePath) - const parsed = parseJsonc(rawContent) - const validation = OpenCodeConfigSchema.safeParse(parsed) - - if (!validation.success) { - throw new Error('Importable OpenCode config is invalid') - } - - const settingsService = new SettingsService(db) - const existingDefault = settingsService.getOpenCodeConfigByName('default', userId) - - if (existingDefault) { - settingsService.updateOpenCodeConfig('default', { - content: rawContent, - isDefault: true, - }, userId) - } else { - settingsService.createOpenCodeConfig({ - name: 'default', - content: rawContent, - isDefault: true, - }, userId) - } + new SettingsService(db).upsertDefaultOpenCodeConfig(rawContent, userId) await writeFileContent(workspaceConfigPath, rawContent) return true diff --git a/backend/src/services/settings.ts b/backend/src/services/settings.ts index f9c9c43c..e66278ea 100644 --- a/backend/src/services/settings.ts +++ b/backend/src/services/settings.ts @@ -353,6 +353,16 @@ export class SettingsService { return config } + upsertDefaultOpenCodeConfig(rawContent: string, userId: string = 'default'): OpenCodeConfigWithRaw { + const existing = this.getOpenCodeConfigByName('default', userId) + + if (existing) { + return this.updateOpenCodeConfig('default', { content: rawContent, isDefault: true }, userId)! + } + + return this.createOpenCodeConfig({ name: 'default', content: rawContent, isDefault: true }, userId) + } + deleteOpenCodeConfig(configName: string, userId: string = 'default'): boolean { const result = this.db .query('DELETE FROM opencode_configs WHERE user_id = ? AND config_name = ?') diff --git a/backend/src/services/skills.ts b/backend/src/services/skills.ts index e0257683..a86232cc 100644 --- a/backend/src/services/skills.ts +++ b/backend/src/services/skills.ts @@ -4,7 +4,7 @@ import { promises as fs } from 'fs' import type { Database } from 'bun:sqlite' import type { SkillFileInfo, SkillScope, CreateSkillRequest, UpdateSkillRequest, InstallSkillUploadRequest, InstallSkillFromGithubRequest, InstallSkillResponse } from '@opencode-manager/shared' import { SKILL_NAME_REGEX, SkillFrontmatterSchema } from '@opencode-manager/shared' -import { getWorkspacePath, FILE_LIMITS } from '@opencode-manager/shared/config/env' +import { getWorkspacePath, getConfigPath, FILE_LIMITS } from '@opencode-manager/shared/config/env' import { getRepoById, getRepoName, listRepos } from '../db/queries' import type { Repo } from '@opencode-manager/shared/types' import { ensureDirectoryExists, fileExists, readFileContent, writeFileContent, deletePath, listDirectory, normalizeUploadRelativePath, resolveWithinDirectory } from './file-operations' @@ -303,7 +303,7 @@ export async function installSkillFromGithubTree( } function getGlobalSkillsPath(): string { - return path.join(getWorkspacePath(), '.config', 'opencode', 'skills') + return path.join(getConfigPath(), 'skills') } function getOldGlobalSkillsPath(): string { diff --git a/backend/test/routes/settings-config-directory-replace.test.ts b/backend/test/routes/settings-config-directory-replace.test.ts new file mode 100644 index 00000000..d82f4a0f --- /dev/null +++ b/backend/test/routes/settings-config-directory-replace.test.ts @@ -0,0 +1,343 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { z } from 'zod' +import { createStubOpenCodeClient } from '../helpers/stub-opencode-client' + +vi.mock('fs', () => ({ + existsSync: vi.fn(() => false), + promises: { + mkdir: vi.fn(), + access: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), + stat: vi.fn(), + chmod: vi.fn(), + unlink: vi.fn(), + rm: vi.fn(), + readdir: vi.fn(), + }, +})) + +vi.mock('child_process', () => ({ + execSync: vi.fn(), + spawnSync: vi.fn(), + spawn: vi.fn(), +})) + +vi.mock('../../src/utils/logger', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }, +})) + +vi.mock('../../src/constants', () => ({ + DEFAULT_AGENTS_MD: '# Test Agents MD', +})) + +vi.mock('../../src/services/settings', () => ({ + SettingsService: vi.fn().mockImplementation(() => ({ + getSettings: vi.fn(), + updateSettings: vi.fn(), + saveLastKnownGoodConfig: vi.fn(), + createOpenCodeConfig: vi.fn(), + updateOpenCodeConfig: vi.fn(), + deleteOpenCodeConfig: vi.fn(), + getOpenCodeConfigByName: vi.fn(), + setDefaultOpenCodeConfig: vi.fn(), + })), +})) + +vi.mock('../../src/services/file-operations', () => ({ + writeFileContent: vi.fn(), + readFileContent: vi.fn(), + fileExists: vi.fn(), +})) + +vi.mock('../../src/services/opencode-single-server', () => { + class MockConfigReloadError extends Error { + validationIssues: Array<{ path: string; message: string }> = [] + removedFields: string[] = [] + constructor(message: string) { + super(message) + this.name = 'ConfigReloadError' + } + } + + return { + opencodeServerManager: { + getVersion: vi.fn(), + fetchVersion: vi.fn(), + reloadConfig: vi.fn(), + restart: vi.fn(), + clearStartupError: vi.fn(), + getLastStartupError: vi.fn(), + markRestartPending: vi.fn(), + isRestartPending: vi.fn(), + setDatabase: vi.fn(), + reinitializeBinDirectory: vi.fn(), + }, + ConfigReloadError: MockConfigReloadError, + } +}) + +vi.mock('../../src/services/opencode-restart', () => ({ + restartOpenCode: vi.fn().mockResolvedValue({ resumedSessionIDs: [] }), + reloadOpenCodeConfig: vi.fn(), + getOpenCodeRestartCoordinator: vi.fn(() => null), + setOpenCodeRestartCoordinator: vi.fn(), +})) + +vi.mock('../../src/services/skills', () => ({ + listManagedSkills: vi.fn(), + getSkill: vi.fn(), + createSkill: vi.fn(), + updateSkill: vi.fn(), + deleteSkill: vi.fn(), + installSkillFromGithubTree: vi.fn(), + installSkillFromUploadedFiles: vi.fn(), +})) + +vi.mock('../../src/services/opencode-config-directory', () => ({ + replaceOpenCodeConfigDirectory: vi.fn(), +})) + +vi.mock('@opencode-manager/shared/config/env', () => ({ + getWorkspacePath: vi.fn(() => '/tmp/test-workspace'), + getReposPath: vi.fn(() => '/tmp/test-repos'), + getOpenCodeConfigFilePath: vi.fn(() => '/tmp/test-workspace/.config/opencode.json'), + getAgentsMdPath: vi.fn(() => '/tmp/test-workspace/AGENTS.md'), + getDatabasePath: vi.fn(() => ':memory:'), + getConfigPath: vi.fn(() => '/tmp/test-workspace/config'), + ENV: { + SERVER: { PORT: 5003, HOST: '0.0.0.0', NODE_ENV: 'test' }, + AUTH: { TRUSTED_ORIGINS: 'http://localhost:5173', SECRET: 'test-secret-for-encryption-key-32c' }, + WORKSPACE: { BASE_PATH: '/tmp/test-workspace', REPOS_DIR: 'repos', CONFIG_DIR: 'config', AUTH_FILE: 'auth.json' }, + OPENCODE: { PORT: 5551, HOST: '127.0.0.1' }, + DATABASE: { PATH: ':memory:' }, + FILE_LIMITS: { + MAX_SIZE_BYTES: 1024 * 1024, + MAX_UPLOAD_SIZE_BYTES: 10 * 1024 * 1024, + }, + }, + FILE_LIMITS: { + MAX_SIZE_BYTES: 1024 * 1024, + MAX_UPLOAD_SIZE_BYTES: 10 * 1024 * 1024, + }, +})) + +import { createSettingsRoutes } from '../../src/routes/settings' +import { SettingsService } from '../../src/services/settings' +import { opencodeServerManager } from '../../src/services/opencode-single-server' +import { restartOpenCode } from '../../src/services/opencode-restart' +import { replaceOpenCodeConfigDirectory } from '../../src/services/opencode-config-directory' + +const mockReplace = replaceOpenCodeConfigDirectory as ReturnType +const mockRestartOpenCode = restartOpenCode as ReturnType +const mockMarkRestartPending = opencodeServerManager.markRestartPending as ReturnType +const mockClearStartupError = opencodeServerManager.clearStartupError as ReturnType + +const mockReplaceResult = { + configDirectory: '/tmp/test-workspace/config', + configSourceFilename: 'opencode.json', + filesInstalled: ['opencode.json', 'agents/team/lead.md'], + skippedPaths: ['node_modules/package/dist/index.js'], + preservedEntries: ['node_modules'], + executablesRestored: ['scripts/deploy.sh'], +} + +function createZodError(): z.ZodError { + try { + z.object({ name: z.string() }).parse({}) + } catch (error) { + return error as z.ZodError + } + throw new Error('unreachable') +} + +describe('Settings Routes - OpenCode Config Directory Replace', () => { + let settingsApp: ReturnType + let testDb: any + let mockSaveLastKnownGoodConfig: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + mockReplace.mockResolvedValue(mockReplaceResult) + mockRestartOpenCode.mockResolvedValue({ resumedSessionIDs: [] }) + + testDb = {} as any + settingsApp = createSettingsRoutes(testDb, { getGitEnvironment: vi.fn().mockReturnValue({}) } as any, createStubOpenCodeClient()) + const settingsInstance = (SettingsService as unknown as { mock: { results: Array<{ value: any }> } }).mock.results[0]!.value + mockSaveLastKnownGoodConfig = settingsInstance.saveLastKnownGoodConfig + }) + + function buildFormData(): FormData { + const formData = new FormData() + formData.append('fileManifest', JSON.stringify([ + { fieldName: 'file0', relativePath: 'opencode.json' }, + { fieldName: 'file1', relativePath: 'agents/team/lead.md' }, + ])) + formData.append('file0', new File(['{"name":"test"}'], 'opencode.json', { type: 'application/json' })) + formData.append('file1', new File(['# Lead'], 'lead.md', { type: 'text/markdown' })) + return formData + } + + describe('POST /opencode-config-directory/replace', () => { + it('replaces the config directory and marks a restart required', async () => { + const res = await settingsApp.request('/opencode-config-directory/replace?userId=custom', { + method: 'POST', + body: buildFormData(), + }) + + expect(res.status).toBe(200) + const body = await res.json() as Record + expect(body).toEqual({ ...mockReplaceResult, restartRequired: true }) + + expect(mockReplace).toHaveBeenCalledTimes(1) + expect(mockReplace).toHaveBeenCalledWith( + testDb, + [ + expect.objectContaining({ relativePath: 'opencode.json', content: expect.any(Buffer) }), + expect.objectContaining({ relativePath: 'agents/team/lead.md', content: expect.any(Buffer) }), + ], + 'custom', + ) + expect(mockSaveLastKnownGoodConfig).toHaveBeenCalledWith('custom') + expect(mockMarkRestartPending).toHaveBeenCalledTimes(1) + expect(mockClearStartupError).toHaveBeenCalledTimes(1) + expect(mockRestartOpenCode).toHaveBeenCalledTimes(1) + expect(mockRestartOpenCode).toHaveBeenCalledWith(undefined) + expect(mockSaveLastKnownGoodConfig.mock.invocationCallOrder[0]).toBeLessThan(mockMarkRestartPending.mock.invocationCallOrder[0]!) + expect(mockMarkRestartPending.mock.invocationCallOrder[0]).toBeLessThan(mockRestartOpenCode.mock.invocationCallOrder[0]!) + }) + + it('rejects non-multipart requests with 400', async () => { + const res = await settingsApp.request('/opencode-config-directory/replace', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + + expect(res.status).toBe(400) + const body = await res.json() as { error: string } + expect(body.error).toBe('Unsupported content type. Use multipart/form-data') + expect(mockReplace).not.toHaveBeenCalled() + }) + + it('returns 400 with the service message when the root config file is missing', async () => { + mockReplace.mockRejectedValue(new Error('Uploaded directory must contain opencode.json or opencode.jsonc at its root')) + + const res = await settingsApp.request('/opencode-config-directory/replace', { + method: 'POST', + body: buildFormData(), + }) + + expect(res.status).toBe(400) + const body = await res.json() as { error: string } + expect(body.error).toBe('Uploaded directory must contain opencode.json or opencode.jsonc at its root') + }) + + it('maps oversize uploads to 413', async () => { + mockReplace.mockRejectedValue(new Error('Uploaded config directory files exceed maximum upload size')) + + const res = await settingsApp.request('/opencode-config-directory/replace', { + method: 'POST', + body: buildFormData(), + }) + + expect(res.status).toBe(413) + const body = await res.json() as { error: string } + expect(body.error).toBe('Uploaded config directory files exceed maximum upload size') + }) + + it('maps too-many-files errors to 400', async () => { + mockReplace.mockRejectedValue(new Error('Uploaded config directory contains too many files (max 5000)')) + + const res = await settingsApp.request('/opencode-config-directory/replace', { + method: 'POST', + body: buildFormData(), + }) + + expect(res.status).toBe(400) + const body = await res.json() as { error: string } + expect(body.error).toBe('Uploaded config directory contains too many files (max 5000)') + }) + + it('reports invalid config content as a ZodError 400 with issue details', async () => { + mockReplace.mockRejectedValue(createZodError()) + + const res = await settingsApp.request('/opencode-config-directory/replace', { + method: 'POST', + body: buildFormData(), + }) + + expect(res.status).toBe(400) + const body = await res.json() as { error: string; details: unknown[] } + expect(body.error).toBe('Uploaded OpenCode config is invalid') + expect(body.details).toHaveLength(1) + }) + + it('rejects a malformed fileManifest as invalid upload data, not config content', async () => { + const formData = new FormData() + formData.append('fileManifest', JSON.stringify([{ fieldName: 'file0' }])) + formData.append('file0', new File(['{"name":"test"}'], 'opencode.json', { type: 'application/json' })) + + const res = await settingsApp.request('/opencode-config-directory/replace', { + method: 'POST', + body: formData, + }) + + expect(res.status).toBe(400) + const body = await res.json() as { error: string } + expect(body.error).toBe('Invalid upload manifest') + expect(mockReplace).not.toHaveBeenCalled() + }) + + it('rejects an empty manifest with 400', async () => { + const formData = new FormData() + formData.append('fileManifest', JSON.stringify([])) + + const res = await settingsApp.request('/opencode-config-directory/replace', { + method: 'POST', + body: formData, + }) + + expect(res.status).toBe(400) + const body = await res.json() as { error: string } + expect(body.error).toBe('fileManifest must contain at least one entry') + expect(mockReplace).not.toHaveBeenCalled() + }) + + it('rejects missing manifest fields with the upload validation message', async () => { + const formData = new FormData() + formData.append('fileManifest', JSON.stringify([ + { fieldName: 'file0', relativePath: 'opencode.json' }, + ])) + + const res = await settingsApp.request('/opencode-config-directory/replace', { + method: 'POST', + body: formData, + }) + + expect(res.status).toBe(400) + const body = await res.json() as { error: string } + expect(body.error).toBe('Missing upload file(s): file0') + expect(mockReplace).not.toHaveBeenCalled() + }) + + it('leaves restartRequired false when the replace itself fails', async () => { + mockReplace.mockRejectedValue(new Error('unexpected failure')) + + const res = await settingsApp.request('/opencode-config-directory/replace', { + method: 'POST', + body: buildFormData(), + }) + + expect(res.status).toBe(500) + const body = await res.json() as { error: string } + expect(body.error).toBe('Failed to replace OpenCode config directory') + expect(mockMarkRestartPending).not.toHaveBeenCalled() + expect(mockRestartOpenCode).not.toHaveBeenCalled() + }) + }) +}) diff --git a/backend/test/services/opencode-config-directory.test.ts b/backend/test/services/opencode-config-directory.test.ts new file mode 100644 index 00000000..0691be30 --- /dev/null +++ b/backend/test/services/opencode-config-directory.test.ts @@ -0,0 +1,268 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import { join } from 'path' +import { tmpdir } from 'os' +import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'fs/promises' +import type { Database } from 'bun:sqlite' +import { z } from 'zod' + +vi.mock('@opencode-manager/shared/config/env', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + FILE_LIMITS: { + MAX_SIZE_BYTES: 1024 * 1024, + MAX_UPLOAD_SIZE_BYTES: 500, + }, + } +}) + +const mockUpsertDefaultOpenCodeConfig = vi.fn() + +vi.mock('../../src/services/settings', () => ({ + SettingsService: vi.fn(() => ({ + upsertDefaultOpenCodeConfig: mockUpsertDefaultOpenCodeConfig, + })), +})) + +import { SettingsService } from '../../src/services/settings' + +interface TestUploadFile { + relativePath: string + content: Buffer +} + +function payload(files: Array<[string, string]>): TestUploadFile[] { + return files.map(([relativePath, content]) => ({ + relativePath, + content: Buffer.from(content), + })) +} + +async function listParentEntries(parent: string): Promise { + try { + return await readdir(parent) + } catch { + return [] + } +} + +function createConfigZodError(): z.ZodError { + try { + z.object({ theme: z.string() }).parse({ theme: 42 }) + } catch (error) { + return error as z.ZodError + } + throw new Error('expected schema parse to fail') +} + +describe('replaceOpenCodeConfigDirectory', () => { + let tempDir: string + let configDir: string + let mockDb: Database + + beforeEach(async () => { + vi.clearAllMocks() + tempDir = await mkdtemp(join(tmpdir(), 'oc-config-dir-test-')) + configDir = join(tempDir, '.config', 'opencode') + vi.spyOn(await import('@opencode-manager/shared/config/env'), 'getConfigPath').mockReturnValue(configDir) + mockDb = { query: vi.fn() } as unknown as Database + mockUpsertDefaultOpenCodeConfig.mockReturnValue({ name: 'default', isDefault: true }) + }) + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }) + vi.restoreAllMocks() + }) + + it('replaces the directory with the uploaded tree, normalizing the config and restoring executables', async () => { + const { replaceOpenCodeConfigDirectory } = await import('../../src/services/opencode-config-directory') + await mkdir(join(configDir, 'agents'), { recursive: true }) + await writeFile(join(configDir, 'agents', 'old.md'), 'old') + + const jsonc = `{ + // comment + "$schema": "https://opencode.ai/config.json", + "theme": "dark" +}` + const result = await replaceOpenCodeConfigDirectory(mockDb, payload([ + ['my-config/opencode.jsonc', jsonc], + ['my-config/AGENTS.md', '# Project'], + ['my-config/agents/team/planner.md', '# Planner'], + ['my-config/commands/deploy.md', '# Deploy'], + ['my-config/skills/quo-api/SKILL.md', '---\nname: quo-api\n---\nBody'], + ['my-config/skills/quo-api/scripts/quo-spec.sh', '#!/usr/bin/env bash\necho hi'], + ['my-config/plugin/opencode-forge/dist/index.js', 'console.log(1)'], + ['my-config/vendor/x.js', 'var x = 1'], + ['my-config/postgres-mcp-manager.sh', '#!/bin/sh\npsql'], + ])) + + expect(SettingsService).toHaveBeenCalledWith(mockDb) + expect(mockUpsertDefaultOpenCodeConfig).toHaveBeenCalledWith(jsonc, 'default') + expect(result.configDirectory).toBe(configDir) + expect(result.configSourceFilename).toBe('opencode.jsonc') + expect(result.filesInstalled).toEqual([ + 'opencode.json', + 'AGENTS.md', + 'agents/team/planner.md', + 'commands/deploy.md', + 'skills/quo-api/SKILL.md', + 'skills/quo-api/scripts/quo-spec.sh', + 'plugin/opencode-forge/dist/index.js', + 'vendor/x.js', + 'postgres-mcp-manager.sh', + ]) + expect(result.executablesRestored).toEqual(['skills/quo-api/scripts/quo-spec.sh', 'postgres-mcp-manager.sh']) + expect(result.skippedPaths).toEqual([]) + expect(result.preservedEntries).toEqual([]) + + expect(await readFile(join(configDir, 'opencode.json'), 'utf8')).toBe(jsonc) + await expect(readFile(join(configDir, 'opencode.jsonc'), 'utf8')).rejects.toThrow() + expect(await readFile(join(configDir, 'skills/quo-api/scripts/quo-spec.sh'), 'utf8')).toBe('#!/usr/bin/env bash\necho hi') + expect(await readFile(join(configDir, 'postgres-mcp-manager.sh'), 'utf8')).toBe('#!/bin/sh\npsql') + expect(await readFile(join(configDir, 'plugin/opencode-forge/dist/index.js'), 'utf8')).toBe('console.log(1)') + + expect((await stat(join(configDir, 'skills/quo-api/scripts/quo-spec.sh'))).mode & 0o111).toBe(0o111) + expect((await stat(join(configDir, 'postgres-mcp-manager.sh'))).mode & 0o111).toBe(0o111) + expect((await stat(join(configDir, 'AGENTS.md'))).mode & 0o111).toBe(0) + + await expect(readFile(join(configDir, 'agents', 'old.md'), 'utf8')).rejects.toThrow() + + const parentEntries = await listParentEntries(join(tempDir, '.config')) + expect(parentEntries.filter((name) => name.startsWith('.opencode-config-staging-') || name.startsWith('.opencode-config-backup-'))).toEqual([]) + }) + + it('prefers opencode.json and reports the uploaded opencode.jsonc as skipped', async () => { + const { replaceOpenCodeConfigDirectory } = await import('../../src/services/opencode-config-directory') + const result = await replaceOpenCodeConfigDirectory(mockDb, payload([ + ['opencode.json', '{"theme":"dark"}'], + ['opencode.jsonc', '{ // c\n"theme":"light"\n}'], + ])) + + expect(result.configSourceFilename).toBe('opencode.json') + expect(result.skippedPaths).toEqual(['opencode.jsonc']) + expect(mockUpsertDefaultOpenCodeConfig).toHaveBeenCalledWith('{"theme":"dark"}', 'default') + expect(await readFile(join(configDir, 'opencode.json'), 'utf8')).toBe('{"theme":"dark"}') + await expect(readFile(join(configDir, 'opencode.jsonc'), 'utf8')).rejects.toThrow() + }) + + it('preserves an existing node_modules directory while skipping uploaded excluded entries', async () => { + const { replaceOpenCodeConfigDirectory } = await import('../../src/services/opencode-config-directory') + await mkdir(join(configDir, 'node_modules', 'dep'), { recursive: true }) + await writeFile(join(configDir, 'node_modules', 'dep', 'index.js'), 'module.exports = 1') + + const result = await replaceOpenCodeConfigDirectory(mockDb, payload([ + ['opencode.json', '{"theme":"dark"}'], + ['node_modules/installed.js', 'x'], + ['plugin/pkg/node_modules/inner.js', 'y'], + ['.git/config', '[core]'], + ['.DS_Store', 'junk'], + ['agents/team.md', '# Team'], + ])) + + expect(result.preservedEntries).toEqual(['node_modules']) + expect(result.skippedPaths).toEqual([ + 'node_modules/installed.js', + 'plugin/pkg/node_modules/inner.js', + '.git/config', + '.DS_Store', + ]) + expect(await readFile(join(configDir, 'node_modules', 'dep', 'index.js'), 'utf8')).toBe('module.exports = 1') + await expect(readFile(join(configDir, 'node_modules', 'installed.js'), 'utf8')).rejects.toThrow() + await expect(readFile(join(configDir, '.git', 'config'), 'utf8')).rejects.toThrow() + await expect(readFile(join(configDir, '.DS_Store'), 'utf8')).rejects.toThrow() + }) + + it('rejects a payload with no root config file, leaving the old directory untouched', async () => { + const { replaceOpenCodeConfigDirectory } = await import('../../src/services/opencode-config-directory') + await mkdir(join(configDir, 'agents'), { recursive: true }) + await writeFile(join(configDir, 'agents', 'existing.md'), 'keep me') + await writeFile(join(configDir, 'opencode.json'), '{"theme":"old"}') + + await expect(replaceOpenCodeConfigDirectory(mockDb, payload([ + ['AGENTS.md', '# Project'], + ['agents/planner.md', '# Planner'], + ]))).rejects.toThrow('Uploaded directory must contain opencode.json or opencode.jsonc at its root') + + expect(mockUpsertDefaultOpenCodeConfig).not.toHaveBeenCalled() + expect(await readFile(join(configDir, 'opencode.json'), 'utf8')).toBe('{"theme":"old"}') + expect(await readFile(join(configDir, 'agents', 'existing.md'), 'utf8')).toBe('keep me') + const parentEntries = await listParentEntries(join(tempDir, '.config')) + expect(parentEntries.filter((name) => name.startsWith('.opencode-config-staging-') || name.startsWith('.opencode-config-backup-'))).toEqual([]) + }) + + it('rejects when the config fails validation before touching disk', async () => { + const { replaceOpenCodeConfigDirectory } = await import('../../src/services/opencode-config-directory') + mockUpsertDefaultOpenCodeConfig.mockImplementationOnce(() => { + throw createConfigZodError() + }) + await mkdir(join(configDir, 'agents'), { recursive: true }) + await writeFile(join(configDir, 'agents', 'existing.md'), 'keep me') + await writeFile(join(configDir, 'opencode.json'), '{"theme":"old"}') + + const error = await replaceOpenCodeConfigDirectory(mockDb, payload([ + ['opencode.json', '{"theme": 42}'], + ['agents/planner.md', '# Planner'], + ])).catch((caught) => caught) + + expect(error).toBeInstanceOf(z.ZodError) + expect(mockUpsertDefaultOpenCodeConfig).toHaveBeenCalledWith('{"theme": 42}', 'default') + expect(await readFile(join(configDir, 'opencode.json'), 'utf8')).toBe('{"theme":"old"}') + expect(await readFile(join(configDir, 'agents', 'existing.md'), 'utf8')).toBe('keep me') + const parentEntries = await listParentEntries(join(tempDir, '.config')) + expect(parentEntries.filter((name) => name.startsWith('.opencode-config-staging-') || name.startsWith('.opencode-config-backup-'))).toEqual([]) + }) + + it('rejects a path containing .. before any disk mutation', async () => { + const { replaceOpenCodeConfigDirectory } = await import('../../src/services/opencode-config-directory') + await mkdir(configDir, { recursive: true }) + await writeFile(join(configDir, 'opencode.json'), '{"theme":"old"}') + + await expect(replaceOpenCodeConfigDirectory(mockDb, payload([ + ['opencode.json', '{"theme":"new"}'], + ['../escape.md', 'x'], + ]))).rejects.toThrow('Path must not contain ".."') + + expect(await readFile(join(configDir, 'opencode.json'), 'utf8')).toBe('{"theme":"old"}') + expect(mockUpsertDefaultOpenCodeConfig).not.toHaveBeenCalled() + }) + + it('rejects payloads exceeding the upload size limit', async () => { + const { replaceOpenCodeConfigDirectory } = await import('../../src/services/opencode-config-directory') + await mkdir(configDir, { recursive: true }) + await writeFile(join(configDir, 'opencode.json'), '{"theme":"old"}') + + const big = 'x'.repeat(600) + await expect(replaceOpenCodeConfigDirectory(mockDb, payload([ + ['opencode.json', '{"theme":"new"}'], + ['plugin/opencode-forge/dist/big.js', big], + ]))).rejects.toThrow('Uploaded config directory files exceed maximum upload size') + + expect(await readFile(join(configDir, 'opencode.json'), 'utf8')).toBe('{"theme":"old"}') + expect(mockUpsertDefaultOpenCodeConfig).not.toHaveBeenCalled() + }) + + it('rejects payloads with more files than the configured maximum', async () => { + const { replaceOpenCodeConfigDirectory } = await import('../../src/services/opencode-config-directory') + await mkdir(configDir, { recursive: true }) + await writeFile(join(configDir, 'opencode.json'), '{"theme":"old"}') + + const tooMany = Array.from({ length: 5001 }, (_, index) => [`file${index}.md`, '# x'] as [string, string]) + await expect(replaceOpenCodeConfigDirectory(mockDb, payload(tooMany))) + .rejects.toThrow('Uploaded config directory contains too many files (max 5000)') + + expect(await readFile(join(configDir, 'opencode.json'), 'utf8')).toBe('{"theme":"old"}') + expect(mockUpsertDefaultOpenCodeConfig).not.toHaveBeenCalled() + }) + + it('rejects when every uploaded entry is excluded', async () => { + const { replaceOpenCodeConfigDirectory } = await import('../../src/services/opencode-config-directory') + await expect(replaceOpenCodeConfigDirectory(mockDb, payload([ + ['.DS_Store', 'junk'], + ['node_modules/x.js', 'x'], + ]))).rejects.toThrow('No files were provided for the OpenCode config directory replace') + + expect(mockUpsertDefaultOpenCodeConfig).not.toHaveBeenCalled() + const parentEntries = await listParentEntries(join(tempDir, '.config')) + expect(parentEntries.filter((name) => name.startsWith('.opencode-config-staging-') || name.startsWith('.opencode-config-backup-'))).toEqual([]) + }) +}) diff --git a/backend/test/services/opencode-import.test.ts b/backend/test/services/opencode-import.test.ts index 47952e9a..b0fef221 100644 --- a/backend/test/services/opencode-import.test.ts +++ b/backend/test/services/opencode-import.test.ts @@ -28,6 +28,7 @@ vi.mock('../../src/services/settings', () => ({ })) vi.mock('@opencode-manager/shared/config/env', () => ({ + getConfigPath: vi.fn(() => '/tmp/workspace/.config/opencode'), getOpenCodeConfigFilePath: vi.fn(() => '/tmp/workspace/.config/opencode/opencode.json'), getWorkspacePath: vi.fn(() => '/tmp/workspace'), })) @@ -52,9 +53,7 @@ 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(), + upsertDefaultOpenCodeConfig: vi.fn(), } beforeEach(() => { @@ -92,6 +91,7 @@ describe('opencode-import service', () => { configSourcePath: '/import/opencode-config/opencode.json', stateSourcePath: '/import/opencode-state', workspaceConfigPath: '/tmp/workspace/.config/opencode/opencode.json', + workspaceConfigDirectory: '/tmp/workspace/.config/opencode', workspaceStatePath: '/tmp/workspace/.opencode/state/opencode', workspaceStateExists: true, }) @@ -108,8 +108,6 @@ 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', @@ -119,10 +117,7 @@ describe('opencode-import service', () => { expect(result.configImported).toBe(true) expect(result.stateImported).toBe(true) expect(result.workspaceStateExists).toBe(true) - expect(settingsService.updateOpenCodeConfig).toHaveBeenCalledWith('default', { - content: '{"$schema":"https://opencode.ai/config.json"}', - isDefault: true, - }, 'default') + expect(settingsService.upsertDefaultOpenCodeConfig).toHaveBeenCalledWith('{"$schema":"https://opencode.ai/config.json"}', 'default') expect(mockWriteFileContent).toHaveBeenCalledWith( '/tmp/workspace/.config/opencode/opencode.json', '{"$schema":"https://opencode.ai/config.json"}' @@ -243,7 +238,7 @@ describe('opencode-import service', () => { protectExistingState: true, })).rejects.toThrow('OpenCode host import was blocked to protect existing workspace state') - expect(settingsService.updateOpenCodeConfig).not.toHaveBeenCalled() + expect(settingsService.upsertDefaultOpenCodeConfig).not.toHaveBeenCalled() expect(mockEnsureDirectoryExists).not.toHaveBeenCalled() }) diff --git a/backend/test/services/settings-default-config.test.ts b/backend/test/services/settings-default-config.test.ts new file mode 100644 index 00000000..38ba5f55 --- /dev/null +++ b/backend/test/services/settings-default-config.test.ts @@ -0,0 +1,57 @@ +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 - upsertDefaultOpenCodeConfig', () => { + let settingsService: SettingsService + let mockGetOpenCodeConfigByName: ReturnType + let mockUpdateOpenCodeConfig: ReturnType + let mockCreateOpenCodeConfig: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + settingsService = new SettingsService({ query: vi.fn() } as unknown as Database) + mockGetOpenCodeConfigByName = vi.fn() + mockUpdateOpenCodeConfig = vi.fn() + mockCreateOpenCodeConfig = vi.fn() + vi.spyOn(settingsService, 'getOpenCodeConfigByName').mockImplementation(mockGetOpenCodeConfigByName) + vi.spyOn(settingsService, 'updateOpenCodeConfig').mockImplementation(mockUpdateOpenCodeConfig) + vi.spyOn(settingsService, 'createOpenCodeConfig').mockImplementation(mockCreateOpenCodeConfig) + }) + + it('updates the default config when a default row already exists', () => { + mockGetOpenCodeConfigByName.mockReturnValue({ name: 'default' }) + mockUpdateOpenCodeConfig.mockReturnValue({ name: 'default', isDefault: true }) + + const result = settingsService.upsertDefaultOpenCodeConfig('{"$schema":"https://opencode.ai/config.json"}') + + expect(mockUpdateOpenCodeConfig).toHaveBeenCalledWith('default', { + content: '{"$schema":"https://opencode.ai/config.json"}', + isDefault: true, + }, 'default') + expect(mockCreateOpenCodeConfig).not.toHaveBeenCalled() + expect(result.isDefault).toBe(true) + }) + + it('creates the default config when no default row exists', () => { + mockGetOpenCodeConfigByName.mockReturnValue(null) + mockCreateOpenCodeConfig.mockReturnValue({ name: 'default', isDefault: true }) + + const result = settingsService.upsertDefaultOpenCodeConfig('{"$schema":"https://opencode.ai/config.json"}') + + expect(mockCreateOpenCodeConfig).toHaveBeenCalledWith({ + name: 'default', + content: '{"$schema":"https://opencode.ai/config.json"}', + isDefault: true, + }, 'default') + expect(mockUpdateOpenCodeConfig).not.toHaveBeenCalled() + expect(result.isDefault).toBe(true) + }) +}) diff --git a/backend/test/services/skills.test.ts b/backend/test/services/skills.test.ts index 9011a3ad..90c540ad 100644 --- a/backend/test/services/skills.test.ts +++ b/backend/test/services/skills.test.ts @@ -53,6 +53,7 @@ describe('SkillService', () => { beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), 'skills-test-')) vi.spyOn(await import('@opencode-manager/shared/config/env'), 'getWorkspacePath').mockReturnValue(tempDir) + vi.spyOn(await import('@opencode-manager/shared/config/env'), 'getConfigPath').mockReturnValue(join(tempDir, '.config', 'opencode')) }) afterEach(async () => { diff --git a/backend/test/services/upload-paths.test.ts b/backend/test/services/upload-paths.test.ts new file mode 100644 index 00000000..9dbae7ee --- /dev/null +++ b/backend/test/services/upload-paths.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { getCommonUploadRootDirectory, isExcludedOpenCodeConfigUploadPath, isOpenCodeConfigUploadPath } from '@opencode-manager/shared/utils' + +describe('getCommonUploadRootDirectory', () => { + it('returns the shared first segment when every path has the same root and at least one is nested', () => { + expect(getCommonUploadRootDirectory(['opencode/opencode.jsonc', 'opencode/agents/a.md'])).toBe('opencode') + }) + + it('returns null for a single loose file', () => { + expect(getCommonUploadRootDirectory(['opencode.json'])).toBeNull() + }) + + it('returns null when paths have mixed roots', () => { + expect(getCommonUploadRootDirectory(['a/x.md', 'b/y.md'])).toBeNull() + }) + + it('returns null for empty input', () => { + expect(getCommonUploadRootDirectory([])).toBeNull() + }) + + it('returns null when no path is nested', () => { + expect(getCommonUploadRootDirectory(['opencode', 'opencode'])).toBeNull() + }) +}) + +describe('isOpenCodeConfigUploadPath', () => { + it('accepts both config filenames at the root', () => { + expect(isOpenCodeConfigUploadPath('opencode.json')).toBe(true) + expect(isOpenCodeConfigUploadPath('opencode.jsonc')).toBe(true) + }) + + it('rejects nested config files', () => { + expect(isOpenCodeConfigUploadPath('opencode/opencode.json')).toBe(false) + expect(isOpenCodeConfigUploadPath('forge/opencode.json')).toBe(false) + }) + + it('rejects renamed or non-config files', () => { + expect(isOpenCodeConfigUploadPath('opencode.json.bak')).toBe(false) + expect(isOpenCodeConfigUploadPath('opencode.jsonc.bak')).toBe(false) + }) +}) + +describe('isExcludedOpenCodeConfigUploadPath', () => { + it('excludes node_modules at any depth', () => { + expect(isExcludedOpenCodeConfigUploadPath('node_modules/x/package.json')).toBe(true) + expect(isExcludedOpenCodeConfigUploadPath('plugin/opencode-forge/node_modules/y.js')).toBe(true) + }) + + it('excludes .git segments', () => { + expect(isExcludedOpenCodeConfigUploadPath('opencode/.git/config')).toBe(true) + }) + + it('excludes .DS_Store files', () => { + expect(isExcludedOpenCodeConfigUploadPath('plugin/.DS_Store')).toBe(true) + }) + + it('keeps normal config directory files', () => { + expect(isExcludedOpenCodeConfigUploadPath('skills/quo-api/scripts/quo-spec.sh')).toBe(false) + expect(isExcludedOpenCodeConfigUploadPath('agents/planner.md')).toBe(false) + expect(isExcludedOpenCodeConfigUploadPath('opencode.jsonc')).toBe(false) + }) +}) diff --git a/docs/features/ai-config.md b/docs/features/ai-config.md index e953049a..7e44f5bc 100644 --- a/docs/features/ai-config.md +++ b/docs/features/ai-config.md @@ -149,3 +149,25 @@ When context is running low: ### Context Limits Different models have different context limits. Check your provider's documentation for exact limits per model. + +## Replace the Global OpenCode Config Directory + +OpenCode's configuration lives in a directory — `workspace/.config/opencode` — that holds the config file, `AGENTS.md`, `agents/`, `commands/`, `skills/`, `plugin/` and any other files you keep there. In **Settings > OpenCode**, you can replace the entire directory at once by dragging a folder onto the drop zone (or choosing a folder), instead of importing only a config file. + +### What Happens + +- The whole `workspace/.config/opencode` tree is replaced — the config file, `AGENTS.md`, `agents/`, `commands/`, `skills/`, `plugin/` and any other files the directory holds. The exact destination path is shown in the confirmation dialog before you confirm. +- The folder must contain `opencode.json` or `opencode.jsonc` at its root. An uploaded `.jsonc` file is installed as `opencode.json` with its comments preserved; when both are uploaded, the `.jsonc` is skipped. +- The uploaded config becomes the **default** config shown in the config manager. +- `node_modules`, `.git` and `.DS_Store` entries are skipped. An existing `node_modules` directory at the root of the config directory is carried over unchanged across the replace, so plugin dependencies do not have to be re-uploaded; OpenCode installs the plugins declared in the config's `plugin` array on start. +- Files whose contents start with a `#!` shebang are restored as executable. +- At most 5000 files can be uploaded, with a combined upload size limited by `MAX_UPLOAD_SIZE_MB` (50 MB by default). +- The OpenCode server restarts afterwards. + +### Restart Required + +Changes that need a restart surface as an amber notice pinned at the top of the **OpenCode** tab in Settings while a restart is pending. The notice appears when a change has been saved but not yet applied to the running server — a directory replace restarts the server for you, so it only shows up after one if that restart never came up healthy. The first time a pending restart is seen, a confirmation dialog opens automatically; you can dismiss it with **Later**. The **Restart Now** button restarts the server immediately when no sessions are working, and asks for confirmation first when sessions are working, reporting how many would be interrupted. + +### If the Replacement Breaks Startup + +If an uploaded `plugin/**` prevents OpenCode from starting, upload a corrected directory again. Automatic recovery restores only `opencode.json`, so a bad plugin can only be fixed by re-uploading a working directory. diff --git a/frontend/src/api/settings.test.ts b/frontend/src/api/settings.test.ts index ffeb6879..0a4916fd 100644 --- a/frontend/src/api/settings.test.ts +++ b/frontend/src/api/settings.test.ts @@ -69,9 +69,12 @@ describe('settingsApi', () => { ) const file = new File(['# Teach Skill'], 'SKILL.md', { type: 'text/markdown' }) - Object.defineProperty(file, 'webkitRelativePath', { value: 'teach/SKILL.md' }) + Object.defineProperty(file, 'webkitRelativePath', { value: 'decoy/fallback.md' }) - const result = await settingsApi.installSkillFromUpload({ files: [file], scope: 'global' }) + const result = await settingsApi.installSkillFromUpload({ + items: [{ file, relativePath: 'teach/SKILL.md' }], + scope: 'global', + }) expect(result.sourceType).toBe('upload') diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index 6781ed04..34d7ead7 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -14,20 +14,21 @@ import type { InstallSkillFromGithubRequest, InstallSkillResponse, OpenCodeDirectoryFileInfo, + ReplaceOpenCodeConfigDirectoryResponse, } from './types/settings' import { API_BASE_URL } from '@/config' import { fetchWrapper, FetchError } from './fetchWrapper' +import type { DirectoryUploadItem } from '@/lib/directoryUpload' const DEFAULT_USER_ID = 'default' -function appendFilesWithManifest(formData: FormData, files: File[]): void { +function appendUploadItemsWithManifest(formData: FormData, items: DirectoryUploadItem[]): void { const fileManifest: Array<{ fieldName: string; relativePath: string }> = [] - files.forEach((file, index) => { + items.forEach((item, index) => { const fieldName = `file${index}` - const relativePath = file.webkitRelativePath || file.name - fileManifest.push({ fieldName, relativePath }) - formData.append(fieldName, file) + fileManifest.push({ fieldName, relativePath: item.relativePath }) + formData.append(fieldName, item.file) }) formData.append('fileManifest', JSON.stringify(fileManifest)) @@ -311,7 +312,7 @@ export const settingsApi = { }, installSkillFromUpload: async (data: { - files: File[] + items: DirectoryUploadItem[] scope: SkillScope repoId?: number overwrite?: boolean @@ -322,7 +323,7 @@ export const settingsApi = { if (data.repoId !== undefined) formData.append('repoId', String(data.repoId)) if (data.overwrite !== undefined) formData.append('overwrite', String(data.overwrite)) - appendFilesWithManifest(formData, data.files) + appendUploadItemsWithManifest(formData, data.items) return fetchWrapper(`${API_BASE_URL}/api/settings/skills/install`, { method: 'POST', @@ -332,12 +333,12 @@ export const settingsApi = { installOpenCodeDirectoryFiles: async (data: { kind: 'agents' | 'commands' - files: File[] + items: DirectoryUploadItem[] }): Promise<{ kind: 'agents' | 'commands'; filesInstalled: string[] }> => { const formData = new FormData() formData.append('kind', data.kind) - appendFilesWithManifest(formData, data.files) + appendUploadItemsWithManifest(formData, data.items) return fetchWrapper(`${API_BASE_URL}/api/settings/opencode-directory-files/install`, { method: 'POST', @@ -381,6 +382,18 @@ export const settingsApi = { params: { kind, relativePath }, }) }, + + replaceOpenCodeConfigDirectory: async ( + items: DirectoryUploadItem[], + ): Promise => { + const formData = new FormData() + appendUploadItemsWithManifest(formData, items) + return fetchWrapper(`${API_BASE_URL}/api/settings/opencode-config-directory/replace`, { + method: 'POST', + body: formData, + timeout: 300000, + }) + }, } export interface VersionInfo { diff --git a/frontend/src/api/types/settings.ts b/frontend/src/api/types/settings.ts index cabc73b2..644809c2 100644 --- a/frontend/src/api/types/settings.ts +++ b/frontend/src/api/types/settings.ts @@ -121,6 +121,7 @@ export interface OpenCodeImportStatus { configSourcePath: string | null stateSourcePath: string | null workspaceConfigPath: string + workspaceConfigDirectory: string workspaceStatePath: string workspaceStateExists: boolean } @@ -146,3 +147,13 @@ export interface OpenCodeDirectoryFileInfo { name: string relativePath: string } + +export interface ReplaceOpenCodeConfigDirectoryResponse { + configDirectory: string + configSourceFilename: string + filesInstalled: string[] + skippedPaths: string[] + preservedEntries: string[] + executablesRestored: string[] + restartRequired: boolean +} diff --git a/frontend/src/components/file-browser/FileBrowser.tsx b/frontend/src/components/file-browser/FileBrowser.tsx index c6df1a35..2d13f159 100644 --- a/frontend/src/components/file-browser/FileBrowser.tsx +++ b/frontend/src/components/file-browser/FileBrowser.tsx @@ -12,6 +12,7 @@ import { FolderOpen, Upload, RefreshCw, X } from 'lucide-react' import type { FileInfo } from '@/types/files' import { useMobile } from '@/hooks/useMobile' import { getFileApiUrl, useFile } from '@/api/files' +import { getUploadItemsFromDataTransfer, getUploadItemsFromFileList, type DirectoryUploadItem } from '@/lib/directoryUpload' export interface FileBrowserHandle { goBack: () => void @@ -29,11 +30,6 @@ interface FileBrowserProps { allowNavigateAboveBase?: boolean } -interface UploadItem { - file: File - relativePath: string -} - interface UploadProgress { current: number total: number @@ -51,86 +47,6 @@ const encodeBase64 = (content: string) => { return btoa(binary) } -async function readFileEntry(entry: FileSystemFileEntry): Promise { - return new Promise((resolve, reject) => { - entry.file(resolve, reject) - }) -} - -async function readDirectoryEntries(dirReader: FileSystemDirectoryReader): Promise { - return new Promise((resolve, reject) => { - dirReader.readEntries(resolve, reject) - }) -} - -async function traverseFileSystemEntry( - entry: FileSystemEntry, - basePath: string = '' -): Promise { - const items: UploadItem[] = [] - const relativePath = basePath ? `${basePath}/${entry.name}` : entry.name - - if (entry.isFile) { - const fileEntry = entry as FileSystemFileEntry - const file = await readFileEntry(fileEntry) - items.push({ file, relativePath }) - } else if (entry.isDirectory) { - const dirEntry = entry as FileSystemDirectoryEntry - const dirReader = dirEntry.createReader() - let entries: FileSystemEntry[] = [] - let batch: FileSystemEntry[] - - do { - batch = await readDirectoryEntries(dirReader) - entries = entries.concat(batch) - } while (batch.length > 0) - - for (const childEntry of entries) { - const childItems = await traverseFileSystemEntry(childEntry, relativePath) - items.push(...childItems) - } - } - - return items -} - -async function getUploadItemsFromDataTransfer(dataTransfer: DataTransfer): Promise { - const items: UploadItem[] = [] - const entries: FileSystemEntry[] = [] - - for (let i = 0; i < dataTransfer.items.length; i++) { - const item = dataTransfer.items[i] - const entry = item.webkitGetAsEntry?.() - if (entry) { - entries.push(entry) - } - } - - if (entries.length > 0) { - for (const entry of entries) { - const entryItems = await traverseFileSystemEntry(entry) - items.push(...entryItems) - } - } else { - for (let i = 0; i < dataTransfer.files.length; i++) { - const file = dataTransfer.files[i] - items.push({ file, relativePath: file.name }) - } - } - - return items -} - -function getUploadItemsFromFileList(fileList: FileList): UploadItem[] { - const items: UploadItem[] = [] - for (let i = 0; i < fileList.length; i++) { - const file = fileList[i] - const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name - items.push({ file, relativePath }) - } - return items -} - export const FileBrowser = forwardRef(function FileBrowser({ basePath = '', onFileSelect, embedded = false, initialSelectedFile, onDirectoryLoad, onPreviewStateChange, allowNavigateAboveBase = false }, ref) { const [currentPath, setCurrentPath] = useState(basePath) const [files, setFiles] = useState(null) @@ -287,7 +203,7 @@ useEffect(() => { loadFiles(currentPath) } - const uploadSingleFile = useCallback(async (item: UploadItem): Promise => { + const uploadSingleFile = useCallback(async (item: DirectoryUploadItem): Promise => { const formData = new FormData() formData.append('file', item.file) formData.append('relativePath', item.relativePath) @@ -309,7 +225,7 @@ useEffect(() => { } }, [currentPath]) - const handleUploadItems = useCallback(async (items: UploadItem[]) => { + const handleUploadItems = useCallback(async (items: DirectoryUploadItem[]) => { if (items.length === 0) return uploadCancelledRef.current = false diff --git a/frontend/src/components/file-browser/FileOperations.tsx b/frontend/src/components/file-browser/FileOperations.tsx index 577aacab..4038f171 100644 --- a/frontend/src/components/file-browser/FileOperations.tsx +++ b/frontend/src/components/file-browser/FileOperations.tsx @@ -6,6 +6,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Upload, Plus, FolderPlus, FilePlus, File, Folder } from 'lucide-react' import { useMobile } from '@/hooks/useMobile' +import { DIRECTORY_INPUT_PROPS } from '@/lib/directoryUpload' interface FileOperationsProps { onUpload: (files: FileList) => void @@ -51,7 +52,7 @@ export const FileOperations = memo(function FileOperations({ onUpload, onCreate type="file" className="hidden" onChange={handleFileSelect} - {...{ webkitdirectory: '', directory: '' } as React.InputHTMLAttributes} + {...DIRECTORY_INPUT_PROPS} /> {isMobile ? ( diff --git a/frontend/src/components/settings/CommandsEditor.test.tsx b/frontend/src/components/settings/CommandsEditor.test.tsx index 2d069456..722c15df 100644 --- a/frontend/src/components/settings/CommandsEditor.test.tsx +++ b/frontend/src/components/settings/CommandsEditor.test.tsx @@ -151,7 +151,10 @@ describe('CommandsEditor', () => { fireEvent.change(input, { target: { files: [markdownFile, systemFile] } }) await waitFor(() => { - expect(mocks.installOpenCodeDirectoryFiles).toHaveBeenCalledWith({ kind: 'commands', files: [markdownFile] }) + expect(mocks.installOpenCodeDirectoryFiles).toHaveBeenCalledWith({ + kind: 'commands', + items: [{ file: markdownFile, relativePath: 'commands/git/commit.md' }], + }) }) expect(mocks.toastSuccess).toHaveBeenCalledWith('Uploaded 1 command file') }) @@ -169,4 +172,35 @@ describe('CommandsEditor', () => { expect(mocks.installOpenCodeDirectoryFiles).not.toHaveBeenCalled() expect(mocks.toastError).toHaveBeenCalledWith('No markdown commands files found') }) + + it('materializes the FileList before the input reset, matching Blink/WebKit in-place clearing', async () => { + const onChange = vi.fn() + const markdownFile = new File(['commit body'], 'commit.md', { type: 'text/markdown' }) + Object.defineProperty(markdownFile, 'webkitRelativePath', { value: 'commands/git/commit.md' }) + + const { container } = render(, { wrapper: createWrapper() }) + const input = container.querySelector('input[type="file"]') as HTMLInputElement + + const liveFiles: File[] = [markdownFile] + Object.defineProperty(input, 'files', { + configurable: true, + get: () => liveFiles, + }) + Object.defineProperty(input, 'value', { + configurable: true, + get: () => (liveFiles.length > 0 ? 'C:\\fakepath\\commit.md' : ''), + set: () => { + liveFiles.length = 0 + }, + }) + + fireEvent.change(input) + + await waitFor(() => { + expect(mocks.installOpenCodeDirectoryFiles).toHaveBeenCalledWith({ + kind: 'commands', + items: [{ file: markdownFile, relativePath: 'commands/git/commit.md' }], + }) + }) + }) }) diff --git a/frontend/src/components/settings/OpenCodeConfigDirectoryUpload.test.tsx b/frontend/src/components/settings/OpenCodeConfigDirectoryUpload.test.tsx new file mode 100644 index 00000000..1b107092 --- /dev/null +++ b/frontend/src/components/settings/OpenCodeConfigDirectoryUpload.test.tsx @@ -0,0 +1,247 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { FILE_LIMITS } from '@/config' +import { OpenCodeConfigDirectoryUpload } from './OpenCodeConfigDirectoryUpload' +import type { DirectoryUploadItem } from '@/lib/directoryUpload' +import type { ReplaceOpenCodeConfigDirectoryResponse } from '@/api/types/settings' + +const { + mockReplaceOpenCodeConfigDirectory, + mockGetOpenCodeImportStatus, + mockGetUploadItemsFromDataTransfer, + mockGetUploadItemsFromFileList, + mockShowToast, +} = vi.hoisted(() => ({ + mockReplaceOpenCodeConfigDirectory: vi.fn(), + mockGetOpenCodeImportStatus: vi.fn(), + mockGetUploadItemsFromDataTransfer: vi.fn(), + mockGetUploadItemsFromFileList: vi.fn(), + mockShowToast: { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + loading: vi.fn(), + warning: vi.fn(), + dismiss: vi.fn(), + }, +})) + +vi.mock('@/api/settings', () => ({ + settingsApi: { + replaceOpenCodeConfigDirectory: mockReplaceOpenCodeConfigDirectory, + getOpenCodeImportStatus: mockGetOpenCodeImportStatus, + }, +})) + +vi.mock('@/lib/toast', () => ({ + showToast: mockShowToast, +})) + +vi.mock('@/lib/directoryUpload', () => ({ + DIRECTORY_INPUT_PROPS: { webkitdirectory: '', directory: '' }, + getUploadItemsFromDataTransfer: mockGetUploadItemsFromDataTransfer, + getUploadItemsFromFileList: mockGetUploadItemsFromFileList, +})) + +const IMPORT_STATUS = { workspaceConfigDirectory: '/workspace/.config/opencode' } + +const RESULT: ReplaceOpenCodeConfigDirectoryResponse = { + configDirectory: '/workspace/.config/opencode', + configSourceFilename: 'opencode.jsonc', + filesInstalled: ['opencode.json', 'skills/x/SKILL.md', 'plugin/p.js'], + skippedPaths: ['node_modules/pkg/index.js'], + preservedEntries: ['node_modules'], + executablesRestored: ['scripts/run.sh'], + restartRequired: true, +} + +function makeItem(relativePath: string, size = 10): DirectoryUploadItem { + const name = relativePath.split('/').pop() ?? 'file' + return { file: new File([new Uint8Array(size)], name), relativePath } +} + +function renderComponent() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + + , + ) +} + +async function dropItems(items: DirectoryUploadItem[]) { + mockGetUploadItemsFromDataTransfer.mockResolvedValue(items) + fireEvent.drop(screen.getByTestId('config-directory-drop-zone'), { dataTransfer: { items: [] } }) + await screen.findByText('Replace OpenCode Config Directory?') +} + +describe('OpenCodeConfigDirectoryUpload', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetOpenCodeImportStatus.mockResolvedValue(IMPORT_STATUS) + mockReplaceOpenCodeConfigDirectory.mockResolvedValue(RESULT) + }) + + it('stages a dropped folder and replaces only after destructive confirmation', async () => { + const user = userEvent.setup() + const items = [ + makeItem('opencode/opencode.jsonc'), + makeItem('opencode/skills/x/SKILL.md'), + makeItem('opencode/plugin/p.js'), + ] + renderComponent() + + await dropItems(items) + + expect(screen.getByText('/workspace/.config/opencode')).toBeInTheDocument() + expect(screen.getByText('3 files (30 B)')).toBeInTheDocument() + expect(screen.getByText('opencode.jsonc')).toBeInTheDocument() + expect(screen.getByText('plugin')).toBeInTheDocument() + expect(screen.getByText('skills')).toBeInTheDocument() + expect( + screen.getByText('Every file currently in the destination directory except node_modules will be deleted.'), + ).toBeInTheDocument() + + expect(mockReplaceOpenCodeConfigDirectory).not.toHaveBeenCalled() + + await user.click(screen.getByRole('button', { name: 'Replace and Restart' })) + + await waitFor(() => expect(mockReplaceOpenCodeConfigDirectory).toHaveBeenCalledTimes(1)) + expect(mockReplaceOpenCodeConfigDirectory).toHaveBeenCalledWith(items) + }) + + it('cancelling the dialog performs no request and discards the staged items', async () => { + const user = userEvent.setup() + renderComponent() + + await dropItems([makeItem('opencode/opencode.jsonc'), makeItem('opencode/skills/x/SKILL.md')]) + + await user.click(screen.getByRole('button', { name: 'Cancel' })) + + expect(mockReplaceOpenCodeConfigDirectory).not.toHaveBeenCalled() + expect(screen.queryByText('Replace OpenCode Config Directory?')).not.toBeInTheDocument() + }) + + it('rejects a drop without a root config file before any request', async () => { + renderComponent() + + mockGetUploadItemsFromDataTransfer.mockResolvedValue([ + makeItem('folder/AGENTS.md'), + makeItem('folder/skills/x/SKILL.md'), + ]) + fireEvent.drop(screen.getByTestId('config-directory-drop-zone'), { dataTransfer: { items: [] } }) + + await waitFor(() => + expect(mockShowToast.error).toHaveBeenCalledWith( + 'Uploaded directory must contain opencode.json or opencode.jsonc at its root', + ), + ) + expect(screen.queryByText('Replace OpenCode Config Directory?')).not.toBeInTheDocument() + expect(mockReplaceOpenCodeConfigDirectory).not.toHaveBeenCalled() + }) + + it('rejects an empty drop with an error toast', async () => { + renderComponent() + + mockGetUploadItemsFromDataTransfer.mockResolvedValue([]) + fireEvent.drop(screen.getByTestId('config-directory-drop-zone'), { dataTransfer: { items: [] } }) + + await waitFor(() => expect(mockShowToast.error).toHaveBeenCalled()) + expect(screen.queryByText('Replace OpenCode Config Directory?')).not.toBeInTheDocument() + }) + + it('rejects a staged set exceeding the file-count ceiling before any request', async () => { + const items = [makeItem('f/opencode.jsonc')] + for (let i = 0; i < 5000; i++) { + items.push(makeItem(`f/file${i}.md`)) + } + renderComponent() + + mockGetUploadItemsFromDataTransfer.mockResolvedValue(items) + fireEvent.drop(screen.getByTestId('config-directory-drop-zone'), { dataTransfer: { items: [] } }) + + await waitFor(() => + expect(mockShowToast.error).toHaveBeenCalledWith( + 'Uploaded config directory contains too many files (max 5000)', + ), + ) + expect(screen.queryByText('Replace OpenCode Config Directory?')).not.toBeInTheDocument() + expect(mockReplaceOpenCodeConfigDirectory).not.toHaveBeenCalled() + }) + + it('rejects a staged set exceeding the upload-size ceiling before any request', async () => { + const oversized = new File([], 'big.bin') + Object.defineProperty(oversized, 'size', { value: FILE_LIMITS.MAX_UPLOAD_SIZE_BYTES + 1 }) + renderComponent() + + mockGetUploadItemsFromDataTransfer.mockResolvedValue([ + makeItem('f/opencode.json', 0), + { file: oversized, relativePath: 'f/big.bin' }, + ]) + fireEvent.drop(screen.getByTestId('config-directory-drop-zone'), { dataTransfer: { items: [] } }) + + await waitFor(() => + expect(mockShowToast.error).toHaveBeenCalledWith('Uploaded config directory files exceed maximum upload size'), + ) + expect(screen.queryByText('Replace OpenCode Config Directory?')).not.toBeInTheDocument() + expect(mockReplaceOpenCodeConfigDirectory).not.toHaveBeenCalled() + }) + + it('collects through the folder picker, applies the exclusion filter, and opens the confirmation', async () => { + const user = userEvent.setup() + const { container } = renderComponent() + + mockGetUploadItemsFromFileList.mockReturnValue([ + makeItem('opencode/opencode.jsonc'), + makeItem('opencode/node_modules/pkg/index.js'), + makeItem('opencode/skills/x/SKILL.md'), + ]) + + await user.click(screen.getByRole('button', { name: 'Choose Folder' })) + const input = container.querySelector('input[type="file"]') as HTMLInputElement + fireEvent.change(input, { target: { files: [] } }) + + await screen.findByText('Replace OpenCode Config Directory?') + expect(screen.getByText('2 files (20 B)')).toBeInTheDocument() + expect(screen.getByText('opencode.jsonc')).toBeInTheDocument() + expect(screen.getByText('skills')).toBeInTheDocument() + expect(screen.queryByText('node_modules')).not.toBeInTheDocument() + expect(mockReplaceOpenCodeConfigDirectory).not.toHaveBeenCalled() + }) + + it('shows the result panel after a successful replace', async () => { + const user = userEvent.setup() + mockReplaceOpenCodeConfigDirectory.mockResolvedValue(RESULT) + renderComponent() + + await dropItems([makeItem('opencode/opencode.jsonc')]) + + await user.click(screen.getByRole('button', { name: 'Replace and Restart' })) + + expect(await screen.findByText('Replace complete')).toBeInTheDocument() + expect(screen.getByText('3 files installed, 1 skipped')).toBeInTheDocument() + expect(screen.getByText('Preserved: node_modules')).toBeInTheDocument() + expect(screen.getByText('Executables restored: 1')).toBeInTheDocument() + expect( + screen.getByText('The uploaded opencode.jsonc was installed as opencode.json.'), + ).toBeInTheDocument() + await waitFor(() => expect(mockShowToast.success).toHaveBeenCalled()) + expect(screen.queryByText('Replace OpenCode Config Directory?')).not.toBeInTheDocument() + }) + + it('surfaces a failed replace through an error toast', async () => { + const user = userEvent.setup() + mockReplaceOpenCodeConfigDirectory.mockRejectedValue(new Error('boom')) + renderComponent() + + await dropItems([makeItem('opencode/opencode.jsonc')]) + + await user.click(screen.getByRole('button', { name: 'Replace and Restart' })) + + await waitFor(() => + expect(mockShowToast.error).toHaveBeenCalledWith('Failed to replace the OpenCode config directory'), + ) + }) +}) diff --git a/frontend/src/components/settings/OpenCodeConfigDirectoryUpload.tsx b/frontend/src/components/settings/OpenCodeConfigDirectoryUpload.tsx new file mode 100644 index 00000000..fb3239f2 --- /dev/null +++ b/frontend/src/components/settings/OpenCodeConfigDirectoryUpload.tsx @@ -0,0 +1,275 @@ +import { useRef, useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { FolderUp, UploadCloud, Loader2 } from 'lucide-react' +import { + getCommonUploadRootDirectory, + isExcludedOpenCodeConfigUploadPath, + isOpenCodeConfigUploadPath, +} from '@opencode-manager/shared/utils' +import { FILE_LIMITS } from '@/config' +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { ConfirmDestructiveDialog } from '@/components/ui/confirm-destructive-dialog' +import { settingsApi } from '@/api/settings' +import { showToast } from '@/lib/toast' +import { invalidateConfigCaches } from '@/lib/queryInvalidation' +import { getOpenCodeApiErrorMessage } from '@/lib/opencode-errors' +import { + DIRECTORY_INPUT_PROPS, + getUploadItemsFromDataTransfer, + getUploadItemsFromFileList, + type DirectoryUploadItem, +} from '@/lib/directoryUpload' +import type { OpenCodeImportStatus, ReplaceOpenCodeConfigDirectoryResponse } from '@/api/types/settings' + +const MAX_CONFIG_DIRECTORY_FILES = 5000 +const MAX_CONFIG_UPLOAD_BYTES = FILE_LIMITS.MAX_UPLOAD_SIZE_BYTES + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +function stripCommonRoot(relativePath: string, commonRoot: string | null): string { + return commonRoot ? relativePath.slice(commonRoot.length + 1) : relativePath +} + +export function OpenCodeConfigDirectoryUpload() { + const queryClient = useQueryClient() + const folderInputRef = useRef(null) + const dropZoneRef = useRef(null) + const [isDragging, setIsDragging] = useState(false) + const [stagedItems, setStagedItems] = useState(null) + const [isConfirmOpen, setIsConfirmOpen] = useState(false) + const [lastResult, setLastResult] = useState(null) + + const { data: importStatus } = useQuery({ + queryKey: ['opencode-import-status'], + queryFn: () => settingsApi.getOpenCodeImportStatus(), + staleTime: 30 * 1000, + }) + + const replaceMutation = useMutation({ + mutationFn: (items: DirectoryUploadItem[]) => settingsApi.replaceOpenCodeConfigDirectory(items), + onSuccess: (result) => { + invalidateConfigCaches(queryClient) + queryClient.invalidateQueries({ queryKey: ['opencode-import-status'] }) + setLastResult(result) + setStagedItems(null) + setIsConfirmOpen(false) + showToast.success( + `Replaced the OpenCode config directory: ${result.filesInstalled.length} files installed, ${result.skippedPaths.length} skipped`, + ) + }, + onError: (error) => { + showToast.error(getOpenCodeApiErrorMessage(error, 'Failed to replace the OpenCode config directory')) + }, + }) + + const stageItems = (items: DirectoryUploadItem[]) => { + if (items.length === 0) { + showToast.error('No files were provided. Drop a folder containing opencode.json or opencode.jsonc at its root.') + return + } + + const commonRoot = getCommonUploadRootDirectory(items.map((item) => item.relativePath)) + const hasRootConfig = items.some((item) => + isOpenCodeConfigUploadPath(stripCommonRoot(item.relativePath, commonRoot)), + ) + if (!hasRootConfig) { + showToast.error('Uploaded directory must contain opencode.json or opencode.jsonc at its root') + return + } + + if (items.length > MAX_CONFIG_DIRECTORY_FILES) { + showToast.error(`Uploaded config directory contains too many files (max ${MAX_CONFIG_DIRECTORY_FILES})`) + return + } + + const totalBytes = items.reduce((sum, item) => sum + item.file.size, 0) + if (totalBytes > MAX_CONFIG_UPLOAD_BYTES) { + showToast.error('Uploaded config directory files exceed maximum upload size') + return + } + + setLastResult(null) + setStagedItems(items) + setIsConfirmOpen(true) + } + + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(true) + } + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + if (e.currentTarget === dropZoneRef.current) { + setIsDragging(false) + } + } + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + } + + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(false) + + const items = await getUploadItemsFromDataTransfer(e.dataTransfer, { + shouldSkip: (relativePath) => isExcludedOpenCodeConfigUploadPath(relativePath), + }) + stageItems(items) + } + + const openFolderPicker = () => { + requestAnimationFrame(() => folderInputRef.current?.click()) + } + + const handleFolderChange = (event: React.ChangeEvent) => { + const fileList = event.target.files + const items = fileList + ? getUploadItemsFromFileList(fileList).filter((item) => !isExcludedOpenCodeConfigUploadPath(item.relativePath)) + : [] + event.target.value = '' + stageItems(items) + } + + const stagedRoot = stagedItems ? getCommonUploadRootDirectory(stagedItems.map((item) => item.relativePath)) : null + const topLevelEntries = stagedItems + ? Array.from(new Set( + stagedItems.map((item) => stripCommonRoot(item.relativePath, stagedRoot).split('/')[0]), + )).sort() + : [] + const stagedTotalBytes = stagedItems?.reduce((sum, item) => sum + item.file.size, 0) ?? 0 + + return ( + + + Replace OpenCode Config Directory +

+ Replace the whole global config directory — the config file, AGENTS.md, agents, commands, skills, plugins and + anything else it contains — with the contents of a folder. Not just the config file. If the replacement breaks + startup, upload a working folder again. +

+
+ +
+ +

Drag and drop your OpenCode config folder here

+

+ The folder must contain opencode.json or opencode.jsonc at its root. node_modules, .git and .DS_Store + entries are excluded automatically. +

+ + +
+ + {lastResult && ( +
+

Replace complete

+

+ {lastResult.filesInstalled.length} file{lastResult.filesInstalled.length === 1 ? '' : 's'} installed + {lastResult.skippedPaths.length > 0 ? `, ${lastResult.skippedPaths.length} skipped` : ''} +

+ {lastResult.preservedEntries.length > 0 && ( +

+ Preserved: {lastResult.preservedEntries.join(', ')} +

+ )} + {lastResult.executablesRestored.length > 0 && ( +

+ Executables restored: {lastResult.executablesRestored.length} +

+ )} + {lastResult.configSourceFilename !== 'opencode.json' && ( +

+ The uploaded {lastResult.configSourceFilename} was installed as opencode.json. +

+ )} +
+ )} + + { + setIsConfirmOpen(open) + if (!open) setStagedItems(null) + }} + onConfirm={() => { + if (stagedItems) replaceMutation.mutate(stagedItems) + }} + onCancel={() => { + setIsConfirmOpen(false) + setStagedItems(null) + }} + title="Replace OpenCode Config Directory?" + description={ +
+

Replace the OpenCode config directory at:

+

+ {importStatus?.workspaceConfigDirectory ?? 'Unavailable'} +

+

+ {stagedItems?.length ?? 0} files ({formatBytes(stagedTotalBytes)}) +

+
+

Top-level entries:

+
    + {topLevelEntries.map((entry) => ( +
  • + {entry} +
  • + ))} +
+
+
+ } + warning="Every file currently in the destination directory except node_modules will be deleted." + confirmLabel="Replace and Restart" + pendingLabel="Replacing..." + isPending={replaceMutation.isPending} + /> +
+
+ ) +} diff --git a/frontend/src/components/settings/OpenCodeConfigManager.test.tsx b/frontend/src/components/settings/OpenCodeConfigManager.test.tsx index 21672cd6..c557bfca 100644 --- a/frontend/src/components/settings/OpenCodeConfigManager.test.tsx +++ b/frontend/src/components/settings/OpenCodeConfigManager.test.tsx @@ -8,25 +8,15 @@ import type { OpenCodeConfig } from '@/api/types/settings' const { mockGetOpenCodeConfigs, mockUpdateOpenCodeConfig, - mockRestartOpenCodeServer, - mockGetActiveOpenCodeSessions, mockGetOpenCodeImportStatus, mockListManagedSkills, mockListOpenCodeDirectoryFiles, - healthState, } = vi.hoisted(() => ({ mockGetOpenCodeConfigs: vi.fn(), mockUpdateOpenCodeConfig: vi.fn(), - mockRestartOpenCodeServer: vi.fn(), - mockGetActiveOpenCodeSessions: vi.fn(), mockGetOpenCodeImportStatus: vi.fn(), mockListManagedSkills: vi.fn(), mockListOpenCodeDirectoryFiles: vi.fn(), - healthState: { data: { opencode: 'healthy', opencodeRestartPending: false } as Record }, -})) - -vi.mock('@/hooks/useServerHealth', () => ({ - useServerHealth: () => healthState, })) vi.mock('@/lib/toast', () => ({ @@ -37,13 +27,10 @@ vi.mock('@/api/settings', () => ({ settingsApi: { getOpenCodeConfigs: mockGetOpenCodeConfigs, updateOpenCodeConfig: mockUpdateOpenCodeConfig, - restartOpenCodeServer: mockRestartOpenCodeServer, - getActiveOpenCodeSessions: mockGetActiveOpenCodeSessions, getOpenCodeImportStatus: mockGetOpenCodeImportStatus, listManagedSkills: mockListManagedSkills, listOpenCodeDirectoryFiles: mockListOpenCodeDirectoryFiles, syncOpenCodeImport: vi.fn(), - upgradeOpenCode: vi.fn(), }, })) @@ -78,7 +65,6 @@ describe('OpenCodeConfigManager', () => { beforeEach(() => { vi.clearAllMocks() - healthState.data = { opencode: 'healthy', opencodeRestartPending: false } mockGetOpenCodeConfigs.mockResolvedValue({ configs: [defaultConfig] }) mockGetOpenCodeImportStatus.mockResolvedValue({}) mockListManagedSkills.mockResolvedValue([]) @@ -87,8 +73,6 @@ describe('OpenCodeConfigManager', () => { return Promise.resolve([]) }) mockUpdateOpenCodeConfig.mockResolvedValue(defaultConfig) - mockRestartOpenCodeServer.mockResolvedValue({ success: true, message: 'ok' }) - mockGetActiveOpenCodeSessions.mockResolvedValue({ count: 2, sessions: [] }) }) it('shows uploaded command and agent directory files in settings', async () => { @@ -98,7 +82,7 @@ describe('OpenCodeConfigManager', () => { }) const user = userEvent.setup() - renderWithQuery() + renderWithQuery() await screen.findByText('Commands') await vi.waitFor(() => { @@ -120,7 +104,7 @@ describe('OpenCodeConfigManager', () => { mockUpdateOpenCodeConfig.mockResolvedValueOnce({ ...defaultConfig, restartRequired: true }) const user = userEvent.setup() - renderWithQuery() + renderWithQuery() await screen.findByText('GPT-4o') @@ -133,30 +117,13 @@ describe('OpenCodeConfigManager', () => { const [configName, payload] = mockUpdateOpenCodeConfig.mock.calls[0] expect(configName).toBe('default') expect(payload.content.provider.openai.models).not.toHaveProperty('gpt-4o') - - expect(screen.queryByText('Restart OpenCode Server?')).not.toBeInTheDocument() - }) - - it('deferred restart banner triggers server restart', async () => { - healthState.data = { opencode: 'healthy', opencodeRestartPending: true } - - const user = userEvent.setup() - renderWithQuery() - - const restartNowButton = await screen.findByRole('button', { name: /restart now/i }) - await user.click(restartNowButton) - - await screen.findByText('Restart OpenCode Server?') - await user.click(screen.getByRole('button', { name: /restart now/i })) - - expect(mockRestartOpenCodeServer).toHaveBeenCalledTimes(1) }) it('rollback on failure', async () => { mockUpdateOpenCodeConfig.mockRejectedValueOnce(new Error('boom')) const user = userEvent.setup() - renderWithQuery() + renderWithQuery() await screen.findByText('GPT-4o') @@ -173,12 +140,10 @@ describe('OpenCodeConfigManager', () => { }) expect(screen.getByText('GPT-4o')).toBeInTheDocument() - - expect(screen.queryByText('Restart OpenCode Server?')).not.toBeInTheDocument() }) it('anchors the AGENTS.md card to the settings dialog scrollport', async () => { - renderWithQuery() + renderWithQuery() const header = await screen.findByRole('button', { name: /Global Agent Instructions/i }) const card = header.parentElement expect(card?.className).toContain('overflow-clip') @@ -200,7 +165,7 @@ describe('OpenCodeConfigManager', () => { mockUpdateOpenCodeConfig.mockResolvedValue(configWithRaw) const user = userEvent.setup() - const { container } = renderWithQuery() + const { container } = renderWithQuery() await screen.findByText('GPT-4o') const editIcon = container.querySelector('.lucide-square-pen') as SVGElement diff --git a/frontend/src/components/settings/OpenCodeConfigManager.tsx b/frontend/src/components/settings/OpenCodeConfigManager.tsx index 979cf14d..2deac5d0 100644 --- a/frontend/src/components/settings/OpenCodeConfigManager.tsx +++ b/frontend/src/components/settings/OpenCodeConfigManager.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react' import { cn } from '@/lib/utils' -import { Loader2, Plus, Trash2, Edit, Download, RotateCcw, FileText, ArrowUpCircle, History, ChevronDown, AlertTriangle } from 'lucide-react' +import { Loader2, Plus, Trash2, Edit, Download, FileText, ChevronDown } from 'lucide-react' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Label } from '@/components/ui/label' @@ -8,7 +8,6 @@ import { Badge } from '@/components/ui/badge' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { DeleteDialog } from '@/components/ui/delete-dialog' -import { RestartServerDialog } from './RestartServerDialog' import { CreateConfigDialog } from './CreateConfigDialog' import { OpenCodeConfigEditor } from './OpenCodeConfigEditor' import { CommandsEditor } from './CommandsEditor' @@ -16,12 +15,10 @@ import { AgentsEditor } from './AgentsEditor' import { AgentsMdEditor } from './AgentsMdEditor' import { McpManager } from './McpManager' import { SkillsEditor } from './SkillsEditor' +import { OpenCodeConfigDirectoryUpload } from './OpenCodeConfigDirectoryUpload' import { OpenCodeModelsEditor, type ConfigProvider } from './OpenCodeModelsEditor' -import { VersionSelectDialog } from './VersionSelectDialog' import { settingsApi } from '@/api/settings' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { useServerHealth } from '@/hooks/useServerHealth' -import { useOpenCodeServerActions } from '@/hooks/useOpenCodeServerActions' import { parseJsonc, hasJsoncComments } from '@/lib/jsonc' import { showToast } from '@/lib/toast' import { invalidateConfigCaches } from '@/lib/queryInvalidation' @@ -56,15 +53,10 @@ interface Agent { [key: string]: unknown } -interface OpenCodeConfigManagerProps { - hideHealthStatus?: boolean -} - const EXPANDED_SECTION_CONTENT_CLASS = 'p-2 sm:p-4' -export function OpenCodeConfigManager({ hideHealthStatus = false }: OpenCodeConfigManagerProps) { +export function OpenCodeConfigManager() { const queryClient = useQueryClient() - const { data: health } = useServerHealth() const [configs, setConfigs] = useState([]) const [isLoading, setIsLoading] = useState(true) const [isUpdating, setIsUpdating] = useState(false) @@ -81,19 +73,8 @@ export function OpenCodeConfigManager({ hideHealthStatus = false }: OpenCodeConf }) const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false) const [isEditDialogOpen, setIsEditDialogOpen] = useState(false) - const [isVersionDialogOpen, setIsVersionDialogOpen] = useState(false) const [deleteConfirmConfig, setDeleteConfirmConfig] = useState(null) - const { - restartServerMutation, - upgradeOpenCodeMutation, - confirmOpen: isRestartPromptOpen, - setConfirmOpen: setIsRestartPromptOpen, - activeSessionCount, - requestRestart, - confirmRestart, - performUpgrade, - } = useOpenCodeServerActions() - + const agentsMdRef = useRef(null) const commandsRef = useRef(null) const agentsRef = useRef(null) @@ -335,101 +316,11 @@ export function OpenCodeConfigManager({ hideHealthStatus = false }: OpenCodeConf ) } - const isUnhealthy = health?.opencode !== 'healthy' const canImportFromHost = Boolean(importStatus?.configSourcePath || importStatus?.stateSourcePath) const activeConfig = configs.find((c) => c.name === activeConfigName) ?? null return (
- {!hideHealthStatus && health && ( - - -
-
-
-

- Server Status: {isUnhealthy ? 'Unhealthy' : 'Healthy'} -

- {health.error && ( -

- {health.error} -

- )} - {health.opencodeVersion && ( -

- OpenCode v{health.opencodeVersion} -

- )} - {health.opencodeManagerVersion && ( -

- Manager v{health.opencodeManagerVersion} -

- )} -
-
- - - -
-
- - - )} - - {health?.opencodeRestartPending && ( -
-
- -

- Configuration changes are saved but require a server restart to take effect. -

-
- -
- )} @@ -527,7 +418,8 @@ export function OpenCodeConfigManager({ hideHealthStatus = false }: OpenCodeConf - + + - - {configs.length === 0 ? ( @@ -973,15 +860,6 @@ export function OpenCodeConfigManager({ hideHealthStatus = false }: OpenCodeConf itemName={deleteConfirmConfig?.name} isDeleting={isUpdating} /> - - setIsRestartPromptOpen(false)} - onConfirm={confirmRestart} - />
) } diff --git a/frontend/src/components/settings/OpenCodeRestartPendingNotice.test.tsx b/frontend/src/components/settings/OpenCodeRestartPendingNotice.test.tsx new file mode 100644 index 00000000..ecbc3294 --- /dev/null +++ b/frontend/src/components/settings/OpenCodeRestartPendingNotice.test.tsx @@ -0,0 +1,124 @@ +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 { OpenCodeRestartPendingNotice } from './OpenCodeRestartPendingNotice' + +const { + mockRestartOpenCodeServer, + mockGetActiveOpenCodeSessions, + healthState, +} = vi.hoisted(() => ({ + mockRestartOpenCodeServer: vi.fn(), + mockGetActiveOpenCodeSessions: vi.fn(), + healthState: { data: { opencode: 'healthy', opencodeRestartPending: false } as Record }, +})) + +vi.mock('@/hooks/useServerHealth', () => ({ + useServerHealth: () => healthState, +})) + +vi.mock('@/lib/toast', () => ({ + showToast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), loading: vi.fn(), warning: vi.fn(), dismiss: vi.fn() }, +})) + +vi.mock('@/api/settings', () => ({ + settingsApi: { + restartOpenCodeServer: mockRestartOpenCodeServer, + getActiveOpenCodeSessions: mockGetActiveOpenCodeSessions, + }, +})) + +const NOTICE_TEXT = 'Configuration changes are saved but require a server restart to take effect.' + +function renderWithQuery(ui: React.ReactElement) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render(ui, { + wrapper: ({ children }) => {children}, + }) +} + +describe('OpenCodeRestartPendingNotice', () => { + beforeEach(() => { + vi.clearAllMocks() + healthState.data = { opencode: 'healthy', opencodeRestartPending: false } + mockRestartOpenCodeServer.mockResolvedValue({ success: true, message: 'ok' }) + mockGetActiveOpenCodeSessions.mockResolvedValue({ count: 2, sessions: [] }) + }) + + it('renders nothing when no restart is pending', () => { + renderWithQuery() + + expect(screen.queryByText(NOTICE_TEXT)).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /restart now/i })).not.toBeInTheDocument() + expect(screen.queryByText('Restart OpenCode Server?')).not.toBeInTheDocument() + }) + + it('auto-opens the restart dialog when the pending flag flips to true', async () => { + const { rerender } = renderWithQuery() + expect(screen.queryByText('Restart OpenCode Server?')).not.toBeInTheDocument() + + healthState.data = { opencode: 'healthy', opencodeRestartPending: true } + rerender() + + expect(await screen.findByText('Restart OpenCode Server?')).toBeInTheDocument() + expect(screen.getByText(/2 sessions are currently working/i)).toBeInTheDocument() + expect(screen.getByText(NOTICE_TEXT)).toBeInTheDocument() + expect(mockGetActiveOpenCodeSessions).toHaveBeenCalledTimes(1) + }) + + it('closes the dialog with "Later" without issuing a restart', async () => { + healthState.data = { opencode: 'healthy', opencodeRestartPending: true } + const user = userEvent.setup() + renderWithQuery() + + await screen.findByText('Restart OpenCode Server?') + await user.click(screen.getByRole('button', { name: /later/i })) + + await waitFor(() => { + expect(screen.queryByText('Restart OpenCode Server?')).not.toBeInTheDocument() + }) + expect(screen.getByText(NOTICE_TEXT)).toBeInTheDocument() + expect(mockRestartOpenCodeServer).not.toHaveBeenCalled() + }) + + it('restarts exactly once via the notice button plus dialog confirmation', async () => { + healthState.data = { opencode: 'healthy', opencodeRestartPending: true } + const user = userEvent.setup() + renderWithQuery() + + await screen.findByText('Restart OpenCode Server?') + await user.click(screen.getByRole('button', { name: /later/i })) + await waitFor(() => { + expect(screen.queryByText('Restart OpenCode Server?')).not.toBeInTheDocument() + }) + + await user.click(screen.getByRole('button', { name: /restart now/i })) + expect(await screen.findByText('Restart OpenCode Server?')).toBeInTheDocument() + expect(mockGetActiveOpenCodeSessions).toHaveBeenCalledTimes(2) + + await user.click(screen.getByRole('button', { name: /restart now/i })) + await waitFor(() => { + expect(mockRestartOpenCodeServer).toHaveBeenCalledTimes(1) + }) + }) + + it('does not re-open a dismissed dialog on re-render while the flag stays true', async () => { + healthState.data = { opencode: 'healthy', opencodeRestartPending: true } + const user = userEvent.setup() + const { rerender } = renderWithQuery() + + await screen.findByText('Restart OpenCode Server?') + await user.click(screen.getByRole('button', { name: /later/i })) + await waitFor(() => { + expect(screen.queryByText('Restart OpenCode Server?')).not.toBeInTheDocument() + }) + + rerender() + + expect(screen.queryByText('Restart OpenCode Server?')).not.toBeInTheDocument() + expect(screen.getByText(NOTICE_TEXT)).toBeInTheDocument() + expect(mockGetActiveOpenCodeSessions).toHaveBeenCalledTimes(1) + expect(mockRestartOpenCodeServer).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/settings/OpenCodeRestartPendingNotice.tsx b/frontend/src/components/settings/OpenCodeRestartPendingNotice.tsx new file mode 100644 index 00000000..b52e9132 --- /dev/null +++ b/frontend/src/components/settings/OpenCodeRestartPendingNotice.tsx @@ -0,0 +1,69 @@ +import { useEffect, useRef } from 'react' +import { AlertTriangle, Loader2, RotateCcw } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { useServerHealth } from '@/hooks/useServerHealth' +import { useOpenCodeServerActions } from '@/hooks/useOpenCodeServerActions' +import { RestartServerDialog } from './RestartServerDialog' + +export function OpenCodeRestartPendingNotice() { + const { data: health } = useServerHealth() + const { + restartServerMutation, + confirmOpen, + setConfirmOpen, + activeSessionCount, + requestRestart, + confirmRestart, + openRestartPrompt, + } = useOpenCodeServerActions() + const hasPromptedRef = useRef(false) + + useEffect(() => { + if (health?.opencodeRestartPending) { + if (!hasPromptedRef.current) { + hasPromptedRef.current = true + void openRestartPrompt() + } + } else { + hasPromptedRef.current = false + } + }, [health?.opencodeRestartPending, openRestartPrompt]) + + if (!health?.opencodeRestartPending) { + return null + } + + return ( +
+
+
+ +

+ Configuration changes are saved but require a server restart to take effect. +

+
+ +
+ setConfirmOpen(false)} + onConfirm={confirmRestart} + /> +
+ ) +} diff --git a/frontend/src/components/settings/SettingsDialog.test.tsx b/frontend/src/components/settings/SettingsDialog.test.tsx index 973681cb..a977bddc 100644 --- a/frontend/src/components/settings/SettingsDialog.test.tsx +++ b/frontend/src/components/settings/SettingsDialog.test.tsx @@ -1,9 +1,34 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent } from '@testing-library/react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { SettingsDialog } from './SettingsDialog' import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' +const { + mockRestartOpenCodeServer, + mockGetActiveOpenCodeSessions, +} = vi.hoisted(() => ({ + mockRestartOpenCodeServer: vi.fn(), + mockGetActiveOpenCodeSessions: vi.fn(), +})) + +vi.mock('@/hooks/useServerHealth', () => ({ + useServerHealth: () => ({ data: { opencode: 'healthy', opencodeRestartPending: true } }), +})) + +vi.mock('@/api/settings', () => ({ + settingsApi: { + restartOpenCodeServer: mockRestartOpenCodeServer, + getActiveOpenCodeSessions: mockGetActiveOpenCodeSessions, + }, +})) + +vi.mock('@/lib/toast', () => ({ + showToast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), loading: vi.fn(), warning: vi.fn(), dismiss: vi.fn() }, +})) + vi.mock('@/components/settings/GeneralSettings', () => ({ GeneralSettings: () =>
General Settings Content
, })) @@ -20,6 +45,22 @@ vi.mock('@/components/settings/OpenCodeConfigManager', () => ({ OpenCodeConfigManager: () =>
OpenCode Config Content
, })) +vi.mock('@/components/settings/ServerHealthStatus', () => ({ + ServerHealthStatus: () =>
Server Health Status
, +})) + +vi.mock('@/components/settings/OpenCodeServerAuthSettings', () => ({ + OpenCodeServerAuthSettings: () =>
OpenCode Auth Settings
, +})) + +vi.mock('@/components/settings/ManagerTokenSettings', () => ({ + ManagerTokenSettings: () =>
Manager Token Settings
, +})) + +vi.mock('@/components/settings/ServerEnvVarsSettings', () => ({ + ServerEnvVarsSettings: () =>
Server Env Vars Settings
, +})) + vi.mock('@/components/settings/ProviderSettings', () => ({ ProviderSettings: () =>
Provider Settings Content
, })) @@ -51,6 +92,8 @@ vi.mock('@/hooks/useMobile', () => ({ describe('SettingsDialog', () => { beforeEach(() => { vi.clearAllMocks() + mockRestartOpenCodeServer.mockResolvedValue({ success: true, message: 'ok' }) + mockGetActiveOpenCodeSessions.mockResolvedValue({ count: 2, sessions: [] }) }) it('resets to menu state when dialog closes and reopens', () => { @@ -153,4 +196,45 @@ describe('SettingsDialog', () => { expect(screen.getByTestId('settings-open')).toBeInTheDocument() }) + + it('mounts a single restart-pending notice and dialog on the OpenCode tab', async () => { + function TestWrapper() { + const location = useLocation() + const navigate = useNavigate() + + const isOpen = new URLSearchParams(location.search).get('settings') === 'open' + + return ( + <> + + {isOpen && Dialog Open} + + + ) + } + + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + + + + + ) + + const user = userEvent.setup() + await user.click(screen.getByText('Open Settings')) + await user.click(screen.getByRole('tab', { name: 'OpenCode' })) + + await screen.findByText('Restart OpenCode Server?') + expect(screen.getAllByText('Restart OpenCode Server?')).toHaveLength(1) + expect(screen.getAllByText('Configuration changes are saved but require a server restart to take effect.')).toHaveLength(1) + + await user.click(screen.getByRole('button', { name: /later/i })) + await waitFor(() => { + expect(screen.queryAllByText('Restart OpenCode Server?')).toHaveLength(0) + }) + expect(screen.getByText('Configuration changes are saved but require a server restart to take effect.')).toBeInTheDocument() + expect(mockRestartOpenCodeServer).not.toHaveBeenCalled() + }) }) diff --git a/frontend/src/components/settings/SettingsDialog.tsx b/frontend/src/components/settings/SettingsDialog.tsx index 8559f5a5..e3e5638a 100644 --- a/frontend/src/components/settings/SettingsDialog.tsx +++ b/frontend/src/components/settings/SettingsDialog.tsx @@ -3,6 +3,7 @@ import { GeneralSettings } from '@/components/settings/GeneralSettings' import { GitSettings } from '@/components/settings/GitSettings' import { KeyboardShortcuts } from '@/components/settings/KeyboardShortcuts' import { OpenCodeConfigManager } from '@/components/settings/OpenCodeConfigManager' +import { OpenCodeRestartPendingNotice } from '@/components/settings/OpenCodeRestartPendingNotice' import { OpenCodeServerAuthSettings } from '@/components/settings/OpenCodeServerAuthSettings' import { ManagerTokenSettings } from '@/components/settings/ManagerTokenSettings' import { ServerEnvVarsSettings } from '@/components/settings/ServerEnvVarsSettings' @@ -118,6 +119,7 @@ export function SettingsDialog() { data-settings-dialog > Settings + {activeTab === 'opencode' && }

@@ -178,7 +180,7 @@ export function SettingsDialog() {

- +
@@ -249,7 +251,7 @@ export function SettingsDialog() { - +
)} {mobileView === 'providers' &&
} diff --git a/frontend/src/components/settings/SkillInstallDialog.tsx b/frontend/src/components/settings/SkillInstallDialog.tsx index c9c97686..53d32878 100644 --- a/frontend/src/components/settings/SkillInstallDialog.tsx +++ b/frontend/src/components/settings/SkillInstallDialog.tsx @@ -14,6 +14,7 @@ import { FetchError } from '@/api/fetchWrapper' import { toast } from 'sonner' import type { SkillScope } from '@opencode-manager/shared' import type { Repo } from '@/api/types' +import { DIRECTORY_INPUT_PROPS, getUploadItemsFromFileList, type DirectoryUploadItem } from '@/lib/directoryUpload' interface SkillInstallDialogProps { open: boolean @@ -26,18 +27,18 @@ export function SkillInstallDialog({ open, onOpenChange, onInstalled }: SkillIns const [url, setUrl] = useState('') const [scope, setScope] = useState('global') const [selectedRepoId, setSelectedRepoId] = useState(undefined) - const [files, setFiles] = useState([]) + const [items, setItems] = useState([]) const [overwrite, setOverwrite] = useState(false) useEffect(() => { setOverwrite(false) - }, [url, files, sourceType, scope, selectedRepoId]) + }, [url, items, sourceType, scope, selectedRepoId]) const resetForm = () => { setUrl('') setScope('global') setSelectedRepoId(undefined) - setFiles([]) + setItems([]) setOverwrite(false) } @@ -60,7 +61,7 @@ export function SkillInstallDialog({ open, onOpenChange, onInstalled }: SkillIns }) } return settingsApi.installSkillFromUpload({ - files, + items, scope, repoId: scope === 'project' ? selectedRepoId : undefined, overwrite: overwrite || undefined, @@ -86,7 +87,7 @@ export function SkillInstallDialog({ open, onOpenChange, onInstalled }: SkillIns toast.error('Please enter a GitHub URL') return } - if (sourceType === 'upload' && files.length === 0) { + if (sourceType === 'upload' && items.length === 0) { toast.error('Please select at least one file') return } @@ -105,18 +106,17 @@ export function SkillInstallDialog({ open, onOpenChange, onInstalled }: SkillIns } const handleFileChange = (e: React.ChangeEvent) => { - const selectedFiles = e.target.files - if (selectedFiles) { - setFiles(Array.from(selectedFiles)) + if (e.target.files) { + setItems(getUploadItemsFromFileList(e.target.files)) } } const selectedFileSummary = () => { - if (files.length === 0) return null - const relPath = files[0].webkitRelativePath || files[0].name + if (items.length === 0) return null + const relPath = items[0].relativePath return (

- {files.length} file{files.length > 1 ? 's' : ''} selected (first: {relPath}) + {items.length} file{items.length > 1 ? 's' : ''} selected (first: {relPath})

) } @@ -161,7 +161,7 @@ export function SkillInstallDialog({ open, onOpenChange, onInstalled }: SkillIns } + {...DIRECTORY_INPUT_PROPS} onChange={handleFileChange} /> diff --git a/frontend/src/components/settings/SkillsEditor.test.tsx b/frontend/src/components/settings/SkillsEditor.test.tsx index 79d5b07c..4217353a 100644 --- a/frontend/src/components/settings/SkillsEditor.test.tsx +++ b/frontend/src/components/settings/SkillsEditor.test.tsx @@ -120,7 +120,7 @@ describe('SkillsEditor', () => { await waitFor(() => { expect(mocks.installSkillFromUpload).toHaveBeenCalledWith({ - files: expect.arrayContaining([expect.any(File)]), + items: expect.arrayContaining([expect.objectContaining({ file: expect.any(File), relativePath: 'SKILL.md' })]), scope: 'global', }) }) diff --git a/frontend/src/components/settings/UploadFolderButton.tsx b/frontend/src/components/settings/UploadFolderButton.tsx index ea79f6ed..98d82e55 100644 --- a/frontend/src/components/settings/UploadFolderButton.tsx +++ b/frontend/src/components/settings/UploadFolderButton.tsx @@ -11,22 +11,13 @@ import { } from '@/components/ui/dropdown-menu' import { settingsApi } from '@/api/settings' import { invalidateConfigCaches } from '@/lib/queryInvalidation' +import { DIRECTORY_INPUT_PROPS, getUploadItemsFromFileList } from '@/lib/directoryUpload' const KIND_NOUN: Record<'agents' | 'commands', string> = { agents: 'agent', commands: 'command', } -const DIRECTORY_INPUT_PROPS = { - webkitdirectory: '', - directory: '', - mozdirectory: '', -} as React.InputHTMLAttributes - -function isMarkdownFile(file: File): boolean { - return (file.webkitRelativePath || file.name).toLowerCase().endsWith('.md') -} - interface UploadFolderButtonProps { kind: 'agents' | 'commands' } @@ -43,21 +34,22 @@ export function UploadFolderButton({ kind }: UploadFolderButtonProps) { } const handleChange = async (event: React.ChangeEvent) => { - const selectedFiles = Array.from(event.target.files ?? []) + const fileList = event.target.files + const items = fileList ? getUploadItemsFromFileList(fileList) : [] event.target.value = '' - if (selectedFiles.length === 0) { + if (items.length === 0) { return } - const files = selectedFiles.filter(isMarkdownFile) - if (files.length === 0) { + const markdownItems = items.filter((item) => item.relativePath.toLowerCase().endsWith('.md')) + if (markdownItems.length === 0) { toast.error(`No markdown ${kind} files found`) return } try { setIsUploading(true) - const result = await settingsApi.installOpenCodeDirectoryFiles({ kind, files }) + const result = await settingsApi.installOpenCodeDirectoryFiles({ kind, items: markdownItems }) invalidateConfigCaches(queryClient) toast.success(`Uploaded ${result.filesInstalled.length} ${noun} file${result.filesInstalled.length === 1 ? '' : 's'}`) } catch (error) { diff --git a/frontend/src/components/ui/confirm-destructive-dialog.tsx b/frontend/src/components/ui/confirm-destructive-dialog.tsx index 3ba98499..242452c1 100644 --- a/frontend/src/components/ui/confirm-destructive-dialog.tsx +++ b/frontend/src/components/ui/confirm-destructive-dialog.tsx @@ -36,7 +36,9 @@ export function ConfirmDestructiveDialog({ {title} - {description} + +
{description}
+
{warning && ( diff --git a/frontend/src/hooks/useOpenCodeServerActions.ts b/frontend/src/hooks/useOpenCodeServerActions.ts index b76f5f3c..cbcef893 100644 --- a/frontend/src/hooks/useOpenCodeServerActions.ts +++ b/frontend/src/hooks/useOpenCodeServerActions.ts @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useCallback, useState } from 'react' import { useMutation, useQueryClient } from '@tanstack/react-query' import { settingsApi } from '@/api/settings' import { showToast } from '@/lib/toast' @@ -69,16 +69,27 @@ export function useOpenCodeServerActions() { } } - const requestRestart = async () => { + const probeActiveSessionCount = useCallback(async (): Promise => { try { const { count } = await settingsApi.getActiveOpenCodeSessions() - if (count > 0) { - setActiveSessionCount(count) - setConfirmOpen(true) - return - } + return count } catch { - // Fall through to an immediate restart when the active-session probe fails. + return 0 + } + }, []) + + const openRestartPrompt = useCallback(async () => { + const count = await probeActiveSessionCount() + setActiveSessionCount(count) + setConfirmOpen(true) + }, [probeActiveSessionCount, setActiveSessionCount, setConfirmOpen]) + + const requestRestart = async () => { + const count = await probeActiveSessionCount() + if (count > 0) { + setActiveSessionCount(count) + setConfirmOpen(true) + return } await performRestart() } @@ -104,6 +115,7 @@ export function useOpenCodeServerActions() { setConfirmOpen, activeSessionCount, requestRestart, + openRestartPrompt, confirmRestart, performUpgrade, } diff --git a/frontend/src/lib/directoryUpload.test.ts b/frontend/src/lib/directoryUpload.test.ts new file mode 100644 index 00000000..89ce015a --- /dev/null +++ b/frontend/src/lib/directoryUpload.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest' +import { getUploadItemsFromDataTransfer, getUploadItemsFromFileList } from './directoryUpload' + +interface StubFileEntry { + name: string + isFile: true + isDirectory: false + file: (successCallback: (file: File) => void) => void +} + +interface StubDirectoryEntry { + name: string + isFile: false + isDirectory: true + createReader: () => FileSystemDirectoryReader +} + +function createFileEntry(name: string, content = 'content'): { entry: FileSystemFileEntry; readCount: () => number } { + const file = new File([content], name) + let readCount = 0 + const entry = { + name, + isFile: true, + isDirectory: false, + file: (successCallback: (file: File) => void) => { + readCount++ + successCallback(file) + }, + } as StubFileEntry as unknown as FileSystemFileEntry + return { entry, readCount: () => readCount } +} + +function createDirReader(children: FileSystemEntry[]): FileSystemDirectoryReader { + let called = false + return { + readEntries: (successCallback: (entries: FileSystemEntry[]) => void) => { + if (called) { + successCallback([]) + return + } + called = true + successCallback(children) + }, + } as unknown as FileSystemDirectoryReader +} + +function createDirectoryEntry(name: string, children: FileSystemEntry[]): { entry: FileSystemDirectoryEntry; readerCreated: () => number } { + let readerCreated = 0 + const entry = { + name, + isFile: false, + isDirectory: true, + createReader: () => { + readerCreated++ + return createDirReader(children) + }, + } as StubDirectoryEntry as unknown as FileSystemDirectoryEntry + return { entry, readerCreated: () => readerCreated } +} + +function createDataTransfer(entries: FileSystemEntry[]): DataTransfer { + return { + items: entries.map((entry) => ({ webkitGetAsEntry: () => entry })), + files: [], + } as unknown as DataTransfer +} + +describe('getUploadItemsFromDataTransfer', () => { + it('preserves nested relative paths from a dropped directory tree', async () => { + const a = createFileEntry('a.md') + const b = createFileEntry('b.md') + const sub = createDirectoryEntry('sub', [b.entry]) + const root = createDirectoryEntry('root', [a.entry, sub.entry]) + + const items = await getUploadItemsFromDataTransfer(createDataTransfer([root.entry])) + + expect(items.map((item) => item.relativePath)).toEqual(['root/a.md', 'root/sub/b.md']) + expect(items.map((item) => item.file.name)).toEqual(['a.md', 'b.md']) + }) + + it('prunes a skipped subtree without reading its files', async () => { + const keep = createFileEntry('keep.md') + const junk = createFileEntry('junk.js') + const nodeModules = createDirectoryEntry('node_modules', [junk.entry]) + const root = createDirectoryEntry('root', [keep.entry, nodeModules.entry]) + + const items = await getUploadItemsFromDataTransfer(createDataTransfer([root.entry]), { + shouldSkip: (relativePath) => relativePath.split('/').includes('node_modules'), + }) + + expect(items.map((item) => item.relativePath)).toEqual(['root/keep.md']) + expect(junk.readCount()).toBe(0) + expect(nodeModules.readerCreated()).toBe(0) + }) +}) + +describe('getUploadItemsFromFileList', () => { + it('uses webkitRelativePath when present', () => { + const nested = new File(['a'], 'a.md') + Object.defineProperty(nested, 'webkitRelativePath', { value: 'folder/a.md' }) + const plain = new File(['b'], 'b.md') + + const items = getUploadItemsFromFileList([nested, plain] as unknown as FileList) + + expect(items.map((item) => item.relativePath)).toEqual(['folder/a.md', 'b.md']) + expect(items.map((item) => item.file)).toEqual([nested, plain]) + }) +}) diff --git a/frontend/src/lib/directoryUpload.ts b/frontend/src/lib/directoryUpload.ts new file mode 100644 index 00000000..524f8c20 --- /dev/null +++ b/frontend/src/lib/directoryUpload.ts @@ -0,0 +1,99 @@ +import type { InputHTMLAttributes } from 'react' + +export interface DirectoryUploadItem { + file: File + relativePath: string +} + +export const DIRECTORY_INPUT_PROPS = { + webkitdirectory: '', + directory: '', + mozdirectory: '', +} as InputHTMLAttributes + +async function readFileEntry(entry: FileSystemFileEntry): Promise { + return new Promise((resolve, reject) => { + entry.file(resolve, reject) + }) +} + +async function readDirectoryEntries(dirReader: FileSystemDirectoryReader): Promise { + return new Promise((resolve, reject) => { + dirReader.readEntries(resolve, reject) + }) +} + +export async function traverseFileSystemEntry( + entry: FileSystemEntry, + basePath: string = '', + shouldSkip?: (relativePath: string, isDirectory: boolean) => boolean, +): Promise { + const items: DirectoryUploadItem[] = [] + const relativePath = basePath ? `${basePath}/${entry.name}` : entry.name + + if (entry.isFile) { + if (shouldSkip?.(relativePath, false)) return items + const fileEntry = entry as FileSystemFileEntry + const file = await readFileEntry(fileEntry) + items.push({ file, relativePath }) + } else if (entry.isDirectory) { + if (shouldSkip?.(relativePath, true)) return items + const dirEntry = entry as FileSystemDirectoryEntry + const dirReader = dirEntry.createReader() + let entries: FileSystemEntry[] = [] + let batch: FileSystemEntry[] + + do { + batch = await readDirectoryEntries(dirReader) + entries = entries.concat(batch) + } while (batch.length > 0) + + for (const childEntry of entries) { + const childItems = await traverseFileSystemEntry(childEntry, relativePath, shouldSkip) + items.push(...childItems) + } + } + + return items +} + +export async function getUploadItemsFromDataTransfer( + dataTransfer: DataTransfer, + options?: { shouldSkip?: (relativePath: string, isDirectory: boolean) => boolean }, +): Promise { + const items: DirectoryUploadItem[] = [] + const entries: FileSystemEntry[] = [] + const shouldSkip = options?.shouldSkip + + for (let i = 0; i < dataTransfer.items.length; i++) { + const item = dataTransfer.items[i] + const entry = item.webkitGetAsEntry?.() + if (entry) { + entries.push(entry) + } + } + + if (entries.length > 0) { + for (const entry of entries) { + const entryItems = await traverseFileSystemEntry(entry, '', shouldSkip) + items.push(...entryItems) + } + } else { + for (let i = 0; i < dataTransfer.files.length; i++) { + const file = dataTransfer.files[i] + items.push({ file, relativePath: file.name }) + } + } + + return items +} + +export function getUploadItemsFromFileList(fileList: FileList): DirectoryUploadItem[] { + const items: DirectoryUploadItem[] = [] + for (let i = 0; i < fileList.length; i++) { + const file = fileList[i] + const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name + items.push({ file, relativePath }) + } + return items +} diff --git a/shared/src/utils/index.ts b/shared/src/utils/index.ts index adcbed0e..77f7e7f6 100644 --- a/shared/src/utils/index.ts +++ b/shared/src/utils/index.ts @@ -1,2 +1,3 @@ export * from './jsonc' +export * from './opencode-config-upload' export * from './repo' diff --git a/shared/src/utils/opencode-config-upload.ts b/shared/src/utils/opencode-config-upload.ts new file mode 100644 index 00000000..3ee60814 --- /dev/null +++ b/shared/src/utils/opencode-config-upload.ts @@ -0,0 +1,30 @@ +export const OPENCODE_CONFIG_FILENAMES = ['opencode.json', 'opencode.jsonc'] as const + +export const OPENCODE_CANONICAL_CONFIG_FILENAME = 'opencode.json' + +export const EXCLUDED_OPENCODE_CONFIG_UPLOAD_SEGMENTS = ['node_modules', '.git'] as const + +export const EXCLUDED_OPENCODE_CONFIG_UPLOAD_FILENAMES = ['.DS_Store'] as const + +export function isOpenCodeConfigUploadPath(relativePath: string): boolean { + if (relativePath.includes('/')) return false + return OPENCODE_CONFIG_FILENAMES.some((filename) => filename === relativePath) +} + +export function isExcludedOpenCodeConfigUploadPath(relativePath: string): boolean { + const segments = relativePath.split('/') + const excludedSegments = EXCLUDED_OPENCODE_CONFIG_UPLOAD_SEGMENTS as readonly string[] + if (segments.some((segment) => excludedSegments.includes(segment))) return true + const lastSegment = segments[segments.length - 1] + return lastSegment !== undefined && (EXCLUDED_OPENCODE_CONFIG_UPLOAD_FILENAMES as readonly string[]).includes(lastSegment) +} + +export function getCommonUploadRootDirectory(relativePaths: string[]): string | null { + if (relativePaths.length === 0) return null + const firstSegments = relativePaths.map((relativePath) => relativePath.split('/')[0]) + const root = firstSegments[0] + if (root === undefined) return null + if (!firstSegments.every((segment) => segment === root)) return null + if (!relativePaths.some((relativePath) => relativePath.includes('/'))) return null + return root +}