Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
"croner": "^10.0.1",
"eventsource": "^4.1.0",
"hono": "^4.11.7",
"jsonc-parser": "^3.3.1",
"web-push": "^3.6.7",
"zod": "^4.1.12"
},
Expand Down
54 changes: 8 additions & 46 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ import { sweepStaleUploadSessions } from './routes/internal/repo-mirror-helpers'
import { createOpenCodeProxyRoutes } from './routes/opencode-proxy'
import { sseAggregator } from './services/sse-aggregator'
import { ensureDirectoryExists, writeFileContent, fileExists, readFileContent } from './services/file-operations'
import { SettingsService } from './services/settings'
import { SettingsService, DEFAULT_SEED_OPENCODE_CONFIG } from './services/settings'
import { sweepStaleOpenCodeConfigDirectoryDirs } from './services/opencode-config-directory'
import { ensureDefaultAgentsMdExists } from './services/agents-md'
import { opencodeServerManager } from './services/opencode-single-server'
import { createOpenCodeClient } from './services/opencode/client'
import { NotificationService } from './services/notification'
Expand All @@ -54,8 +56,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 {
Expand All @@ -68,7 +68,6 @@ import {
getReposPath,
getConfigPath,
getOpenCodeConfigFilePath,
getAgentsMdPath,
getDatabasePath,
ENV
} from '@opencode-manager/shared/config/env'
Expand Down Expand Up @@ -106,8 +105,6 @@ const auth = createAuth(db)
const requireAuth = createAuthMiddleware(auth)
const openCodeClient = createOpenCodeClient(() => new SettingsService(db).getOpenCodeServerPassword())

import { DEFAULT_AGENTS_MD } from './constants'

let ipcServer: IPCServer | undefined
const gitAuthService = new GitAuthService()
let openCodeSupervisor: OpenCodeSupervisor | undefined
Expand All @@ -119,29 +116,8 @@ async function ensureDefaultConfigExists(): Promise<void> {
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)
}
Expand Down Expand Up @@ -173,13 +149,8 @@ async function ensureDefaultConfigExists(): Promise<void> {
}

logger.info('No existing config found, creating minimal seed config')
const seedConfig = JSON.stringify({ $schema: 'https://opencode.ai/config.json' }, null, 2)
settingsService.createOpenCodeConfig({
name: 'default',
content: seedConfig,
isDefault: true,
})
await writeFileContent(workspaceConfigPath, seedConfig)
settingsService.upsertDefaultOpenCodeConfig(DEFAULT_SEED_OPENCODE_CONFIG)
await writeFileContent(workspaceConfigPath, DEFAULT_SEED_OPENCODE_CONFIG)
logger.info('Created minimal seed config')
}

Expand Down Expand Up @@ -239,16 +210,6 @@ async function ensureHomeStateImported(): Promise<void> {
}
}

async function ensureDefaultAgentsMdExists(): Promise<void> {
const agentsMdPath = getAgentsMdPath()
const exists = await fileExists(agentsMdPath)

if (!exists) {
await writeFileContent(agentsMdPath, DEFAULT_AGENTS_MD)
logger.info(`Created default AGENTS.md at: ${agentsMdPath}`)
}
}

try {
if (ENV.SERVER.NODE_ENV === 'production' && !ENV.AUTH.SECRET) {
logger.error('AUTH_SECRET is required in production mode')
Expand All @@ -264,6 +225,7 @@ try {

await cleanupExpiredCache()
await sweepStaleUploadSessions()
await sweepStaleOpenCodeConfigDirectoryDirs()

await ensureDefaultConfigExists()
await backfillOpenCodeModelStateFromFile()
Expand Down
106 changes: 84 additions & 22 deletions backend/src/routes/settings.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Hono, type Context } from 'hono'
import { bodyLimit } from 'hono/body-limit'
import { z } from 'zod'
import { execSync, spawnSync } from 'child_process'
import { randomUUID } from 'crypto'
Expand Down Expand Up @@ -39,7 +40,8 @@ import { encryptSecret } from '../utils/crypto'
import { compareVersions, isValidVersion } from '../utils/version-utils'
import { getImportedSessionDirectories, getOpenCodeImportStatus, OpenCodeImportProtectionError, syncOpenCodeImport } from '../services/opencode-import'
import { relinkReposFromSessionDirectories } from '../services/repo'
import { ENV } from '@opencode-manager/shared/config/env'
import { ENV, FILE_LIMITS } from '@opencode-manager/shared/config/env'
import { MAX_OPENCODE_CONFIG_DIRECTORY_FILES, OPENCODE_CONFIG_UPLOAD_ERRORS } from '@opencode-manager/shared/utils'
import {
listManagedSkills,
getSkill,
Expand All @@ -49,14 +51,16 @@ import {
installSkillFromGithubTree,
installSkillFromUploadedFiles,
} from '../services/skills'
import { replaceOpenCodeConfigDirectory } from '../services/opencode-config-directory'
import { ensureDefaultAgentsMdExists } from '../services/agents-md'
import {
installOpenCodeDirectoryFiles,
listOpenCodeDirectoryFiles,
getOpenCodeDirectoryFile,
updateOpenCodeDirectoryFile,
deleteOpenCodeDirectoryFile,
} from '../services/opencode-directory-files'
import { parseUploadManifest, readUploadedManifestFiles, UploadValidationError } from './upload-utils'
import { parseUploadManifest, parseUploadPreamble, readUploadedManifestFiles, resolveUploadedManifestFiles, UploadValidationError, UPLOAD_PATH_ERROR_STATUS } from './upload-utils'
import { getRepoById } from '../db/queries'
import { githubFetch } from '../utils/github'

Expand Down Expand Up @@ -146,12 +150,16 @@ async function reloadAfterSkillInstall(

const OPENCODE_DIRECTORY_UPLOAD_ERROR_STATUS: ReadonlyArray<readonly [string, 400]> = [
['No markdown', 400],
['Path must be relative', 400],
['Path must not contain', 400],
['Path must reference', 400],
['escapes', 400],
['Missing upload file', 400],
['not a valid file', 400],
...UPLOAD_PATH_ERROR_STATUS,
]

const OPENCODE_CONFIG_DIRECTORY_REPLACE_ERROR_STATUS: ReadonlyArray<readonly [string, 400 | 413]> = [
['No files were provided', 400],
['must contain opencode.json', 400],
['too many files', 400],
['exceed maximum upload size', 413],
...UPLOAD_PATH_ERROR_STATUS,
]

function matchErrorStatus<T extends number>(
Expand Down Expand Up @@ -190,14 +198,10 @@ const SKILL_INSTALL_ERROR_STATUS: ReadonlyArray<readonly [string, 400 | 404 | 40
['Invalid skill name', 400],
['Only one skill', 400],
['Skill source must contain', 400],
['Path must be relative', 400],
['Path must not contain', 400],
['escapes', 400],
['no downloadable files', 400],
['repoId is required', 400],
['Missing upload file', 400],
['Invalid repoId', 400],
['not a valid file', 400],
...UPLOAD_PATH_ERROR_STATUS,
]

function didConfigFieldChange(
Expand Down Expand Up @@ -1266,15 +1270,9 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic

app.post('/opencode-directory-files/install', 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 { formData, manifest } = await parseUploadPreamble(c)
const kind = z.enum(['agents', 'commands']).parse(formData['kind'])

const manifest = parseUploadManifest(formData['fileManifest'])
const markdownManifest = getMarkdownUploadManifest(manifest)
if (markdownManifest.length === 0) {
return c.json({ error: `No markdown ${kind} files found` }, 400)
Expand Down Expand Up @@ -1308,6 +1306,72 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic
}
})

app.use('/opencode-config-directory/replace', bodyLimit({
maxSize: FILE_LIMITS.MAX_UPLOAD_SIZE_BYTES,
onError: (c) => c.json({ error: OPENCODE_CONFIG_UPLOAD_ERRORS.EXCEEDS_MAX_UPLOAD_SIZE }, 413),
}))

app.post('/opencode-config-directory/replace', async (c) => {
try {
const userId = c.req.query('userId') || 'default'

let preamble: Awaited<ReturnType<typeof parseUploadPreamble>>
try {
preamble = await parseUploadPreamble(c)
} catch (error) {
if (error instanceof z.ZodError) {
return c.json({ error: 'Invalid upload manifest', details: error.issues }, 400)
}
throw error
}
const { formData, manifest } = preamble

if (manifest.length === 0) {
return c.json({ error: 'fileManifest must contain at least one entry' }, 400)
}
if (manifest.length > MAX_OPENCODE_CONFIG_DIRECTORY_FILES) {
return c.json({ error: OPENCODE_CONFIG_UPLOAD_ERRORS.TOO_MANY_FILES }, 400)
}

const files = resolveUploadedManifestFiles(formData, manifest)

settingsService.saveLastKnownGoodConfig(userId)

const result = await replaceOpenCodeConfigDirectory(db, files, userId)

try {
await ensureDefaultAgentsMdExists()
} catch (error) {
logger.warn('Failed to ensure AGENTS.md after OpenCode config directory replace', error)
}

opencodeServerManager.markRestartPending()
opencodeServerManager.clearStartupError()
await restartOpenCodeSafe(openCodeSupervisor, 'OpenCode config directory replace')

return c.json(result)
} 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'))
Expand Down Expand Up @@ -1387,14 +1451,12 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic
}

if (contentType.includes('multipart/form-data')) {
const formData = await c.req.parseBody({ all: true })
const { formData, manifest } = await parseUploadPreamble(c)

const scope = formData['scope']
const repoIdValue = formData['repoId']
const overwriteValue = formData['overwrite']

const manifest = parseUploadManifest(formData['fileManifest'])

const repoId = parseOptionalRepoId(repoIdValue as string | undefined)
const overwrite = parseBooleanFormValue(overwriteValue)

Expand Down
51 changes: 41 additions & 10 deletions backend/src/routes/upload-utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { InstallSkillUploadManifestEntrySchema, type InstallSkillUploadManifestEntry } from '@opencode-manager/shared'
import type { Context } from 'hono'

export class UploadValidationError extends Error {}

Expand All @@ -19,23 +20,53 @@ export function parseUploadManifest(fileManifestRaw: unknown): InstallSkillUploa
return InstallSkillUploadManifestEntrySchema.array().parse(manifestEntries)
}

export async function readUploadedManifestFiles(
export async function parseUploadPreamble(c: Context): Promise<{ formData: ParsedFormData; manifest: InstallSkillUploadManifestEntry[] }> {
const contentType = c.req.header('content-type') || ''
if (!contentType.includes('multipart/form-data')) {
throw new UploadValidationError('Unsupported content type. Use multipart/form-data')
}

const formData = await c.req.parseBody({ all: true })
const manifest = parseUploadManifest(formData['fileManifest'])

return { formData, manifest }
}

export const UPLOAD_PATH_ERROR_STATUS: ReadonlyArray<readonly [string, 400]> = [
['Path must be relative', 400],
['Path must not be empty', 400],
['Path must not contain', 400],
['escapes', 400],
['not a valid file', 400],
]

export function resolveUploadedManifestFiles(
formData: ParsedFormData,
manifest: InstallSkillUploadManifestEntry[],
): Promise<{ relativePath: string; content: Buffer }[]> {
): { relativePath: string; file: File }[] {
const missingFields = manifest.filter((entry) => !formData[entry.fieldName])
if (missingFields.length > 0) {
throw new UploadValidationError(`Missing upload file(s): ${missingFields.map((e) => e.fieldName).join(', ')}`)
}

return manifest.map((entry) => {
const file = formData[entry.fieldName]
if (!file || !(file instanceof File)) {
throw new Error(`Field "${entry.fieldName}" is not a valid file`)
}
return { relativePath: entry.relativePath, file }
})
}

export async function readUploadedManifestFiles(
formData: ParsedFormData,
manifest: InstallSkillUploadManifestEntry[],
): Promise<{ relativePath: string; content: Buffer }[]> {
const files = resolveUploadedManifestFiles(formData, manifest)
return Promise.all(
manifest.map(async (entry) => {
const file = formData[entry.fieldName]
if (!file || !(file instanceof File)) {
throw new Error(`Field "${entry.fieldName}" is not a valid file`)
}
const content = Buffer.from(await file.arrayBuffer())
return { relativePath: entry.relativePath, content }
}),
files.map(async ({ relativePath, file }) => ({
relativePath,
content: Buffer.from(await file.arrayBuffer()),
})),
)
}
14 changes: 14 additions & 0 deletions backend/src/services/agents-md.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { getAgentsMdPath } from '@opencode-manager/shared/config/env'
import { writeFileContent, fileExists } from './file-operations'
import { DEFAULT_AGENTS_MD } from '../constants'
import { logger } from '../utils/logger'

export async function ensureDefaultAgentsMdExists(): Promise<void> {
const agentsMdPath = getAgentsMdPath()
const exists = await fileExists(agentsMdPath)

if (!exists) {
await writeFileContent(agentsMdPath, DEFAULT_AGENTS_MD)
logger.info(`Created default AGENTS.md at: ${agentsMdPath}`)
}
}
Loading