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
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=5003
ENV OPENCODE_SERVER_PORT=5551
ENV DEV_PREVIEW_PORT=3056
ENV DATABASE_PATH=/app/data/opencode.db
ENV WORKSPACE_PATH=/workspace
ENV XDG_CACHE_HOME=/home/node/.cache
Expand All @@ -112,7 +113,7 @@ RUN chmod +x /docker-entrypoint.sh
RUN mkdir -p /workspace /app/data /home/node/.cache /home/node/.opencode && \
chown -R node:node /workspace /app/data /home/node

EXPOSE 5003 5100 5101 5102 5103
EXPOSE 5003 3055 3056

HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD curl -f http://localhost:5003/api/health || exit 1
Expand Down
1 change: 1 addition & 0 deletions backend/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export default defineConfig([
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': 'error',
'no-useless-escape': 'warn',
'no-empty': ['error', { allowEmptyCatch: true }],
},
},
{
Expand Down
57 changes: 2 additions & 55 deletions backend/src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,3 @@
export const DEFAULT_AGENTS_MD = `# OpenCode Manager - Global Agent Instructions
import defaultAgentsMd from './default-agents.md' with { type: 'text' }

## Critical System Constraints

- **DO NOT** use ports 5003 or 5551 - these are reserved for OpenCode Manager
- **DO NOT** kill or stop processes on ports 5003 or 5551
- **DO NOT** modify files in the \`.config/opencode\` directory unless explicitly requested

## Dev Server Ports

When starting dev servers, use the pre-allocated ports 5100-5103:
- Port 5100: Primary dev server (frontend)
- Port 5101: Secondary dev server (API/backend)
- Port 5102: Additional service
- Port 5103: Additional service

Always bind to \`0.0.0.0\` to allow external access from the Docker host.

## Package Management

### Node.js Packages
Prefer **pnpm** or **bun** over npm for installing dependencies to save disk space:
- Use \`pnpm install\` instead of \`npm install\`
- Use \`bun install\` as an alternative
- Both are pre-installed in the container

### Python Packages
Always create a virtual environment in the repository directory before installing packages:

1. Create virtual environment in repo:
\`cd \`<repo_path>\`
\`uv venv .venv\`

2. Activate the virtual environment:
\`source .venv/bin/activate\` # or \`uv pip sync\` for project-based workflows

3. Install packages into activated environment:
\`uv pip install \`<package>\`
\`uv pip install -r requirements.txt\`

4. Run Python commands:
\`python script.py\` # Uses activated .venv

Alternative: Use \`uv run python script.py\` to skip explicit activation

**Important:**
- Always create .venv in the repository directory (not workspace root)
- Activate the environment before running pip operations
- uv is pre-installed in the container and provides faster package installation
- .venv directories created in repos will persist but can be removed safely

## General Guidelines

- This file is merged with any AGENTS.md files in individual repositories
- Repository-specific instructions take precedence for their respective codebases
`
export const DEFAULT_AGENTS_MD = defaultAgentsMd
56 changes: 56 additions & 0 deletions backend/src/default-agents.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# OpenCode Manager - Global Agent Instructions

## Critical System Constraints

- **DO NOT** use ports 5003 or 5551 - these are reserved for OpenCode Manager
- **DO NOT** kill or stop processes on ports 5003 or 5551
- **DO NOT** modify files in the `.config/opencode` directory unless explicitly requested

## Dev Server Port

The preview feature checks the port provided in the `OCM_DEV_SERVER_PORT` environment variable. Always run the dev server on that port so it can be previewed.

- Start the dev server on the port in `$OCM_DEV_SERVER_PORT` (e.g. `PORT=$OCM_DEV_SERVER_PORT <dev command>` or pass `--port $OCM_DEV_SERVER_PORT`).
- Bind to `0.0.0.0` to allow external access from the Docker host.

## Package Management

### Node.js Packages

Prefer **pnpm** or **bun** over npm for installing dependencies to save disk space:

- Use `pnpm install` instead of `npm install`
- Use `bun install` as an alternative
- Both are pre-installed in the container

### Python Packages

Always create a virtual environment in the repository directory before installing packages:

1. Create virtual environment in repo:
`cd <repo_path>`
`uv venv .venv`

2. Activate the virtual environment:
`source .venv/bin/activate` # or `uv pip sync` for project-based workflows

3. Install packages into activated environment:
`uv pip install <package>`
`uv pip install -r requirements.txt`

4. Run Python commands:
`python script.py` # Uses activated .venv

Alternative: Use `uv run python script.py` to skip explicit activation

**Important:**

- Always create .venv in the repository directory (not workspace root)
- Activate the environment before running pip operations
- uv is pre-installed in the container and provides faster package installation
- .venv directories created in repos will persist but can be removed safely

## General Guidelines

- This file is merged with any AGENTS.md files in individual repositories
- Repository-specific instructions take precedence for their respective codebases
6 changes: 6 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import { createPromptTemplateRoutes } from './routes/prompt-templates'
import { createInternalRoutes } from './routes/internal'
import { sweepStaleUploadSessions } from './routes/internal/repo-mirror-helpers'
import { createOpenCodeProxyRoutes } from './routes/opencode-proxy'
import { createDevServerRoutes } from './routes/dev-server'
import { startPreviewServer } from './services/dev-server/preview-server'
import { sseAggregator } from './services/sse-aggregator'
import { ensureDirectoryExists, writeFileContent, fileExists, readFileContent } from './services/file-operations'
import { SettingsService } from './services/settings'
Expand Down Expand Up @@ -342,6 +344,7 @@ protectedApi.route('/ssh', createSSHRoutes(gitAuthService))
protectedApi.route('/notifications', createNotificationRoutes(notificationService))
protectedApi.route('/prompt-templates', createPromptTemplateRoutes(db))
protectedApi.route('/schedules', createScheduleRoutes(scheduleService))
protectedApi.route('/dev-server', createDevServerRoutes(db))

app.route('/api', protectedApi)

Expand Down Expand Up @@ -467,4 +470,7 @@ serve({
hostname: HOST,
})

startPreviewServer(auth, db)

logger.info(`🚀 OpenCode WebUI API running on http://${HOST}:${PORT}`)
logger.info(`🔍 Dev preview proxy running on http://${HOST}:${ENV.DEV_PREVIEW.PORT}`)
28 changes: 28 additions & 0 deletions backend/src/routes/dev-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Hono } from 'hono'
import type { Database } from 'bun:sqlite'
import { getRepoById } from '../db/queries'
import { getDevServerState } from '../services/dev-server/manager'
import { resolveDevPreviewUrl } from '../services/dev-server/proxy-utils'

export function createDevServerRoutes(db: Database): Hono {
const app = new Hono()

app.get('/:repoId/status', async (c) => {
const repoId = parseInt(c.req.param('repoId'), 10)
if (isNaN(repoId)) {
return c.json({ error: 'Invalid repoId' }, 400)
}

const repo = getRepoById(db, repoId)
if (!repo) {
return c.json({ error: 'Repository not found' }, 404)
}

const previewUrl = resolveDevPreviewUrl(c.req.header('host'), c.req.header('x-forwarded-proto'))
const state = await getDevServerState(db, repo.id, previewUrl)

return c.json(state)
})

return app
}
127 changes: 127 additions & 0 deletions backend/src/routes/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,139 @@ function getSpecialRoutePathFromRequest(c: Context, routeName: string): string |
return match?.[1] ? decodeFilePath(match[1]) : undefined
}

function getPreviewPathFromRequest(c: Context): string | undefined {
const path = c.req.path
const prefix = '/api/files/preview'

const queryPath = c.req.query('path')
if (queryPath !== undefined) {
return queryPath
}

if (path.startsWith(prefix + '/')) {
const previewPath = path.slice(prefix.length + 1)
return decodeURIComponent(previewPath)
}

return undefined
}

const PREVIEWABLE_MIME_TYPES = new Set([
'text/html',
'text/css',
'text/javascript',
'application/javascript',
'application/json',
'application/xml',
'image/png',
'image/jpeg',
'image/gif',
'image/svg+xml',
])

function isPreviewableMimeType(mimeType?: string): boolean {
return mimeType !== undefined && PREVIEWABLE_MIME_TYPES.has(mimeType)
}

function buildPreviewValidators(stat: { size: number; lastModified: Date }): { etag: string; lastModified: string } {
return {
etag: `"${stat.size.toString(16)}-${stat.lastModified.getTime().toString(16)}"`,
lastModified: stat.lastModified.toUTCString(),
}
}

function isPreviewFresh(c: Context, validators: { etag: string; lastModified: string }): boolean {
const ifNoneMatch = c.req.header('if-none-match')
if (ifNoneMatch !== undefined) {
return ifNoneMatch.split(',').some((tag: string) => tag.trim() === validators.etag)
}

const ifModifiedSince = c.req.header('if-modified-since')
if (ifModifiedSince !== undefined) {
const since = Date.parse(ifModifiedSince)
return !Number.isNaN(since) && Date.parse(validators.lastModified) <= since
}

return false
}

function getPreviewHeaders(result: {
name: string
size: number
mimeType?: string
lastModified: Date
}): Record<string, string> {
const isHtmlSvg = result.mimeType === 'text/html' || result.mimeType === 'image/svg+xml'
const validators = buildPreviewValidators(result)
const headers: Record<string, string> = {
'Content-Type': result.mimeType || 'application/octet-stream',
'Content-Length': result.size.toString(),
'Content-Disposition': `inline; filename="${result.name}"`,
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'no-referrer',
'Cache-Control': 'private, must-revalidate, max-age=0',
'ETag': validators.etag,
'Last-Modified': validators.lastModified,
}

if (isHtmlSvg) {
headers['Content-Security-Policy'] = [
'sandbox allow-scripts allow-same-origin;',
"default-src 'self' http: https: data: blob: 'unsafe-inline' 'unsafe-eval';",
"script-src 'self' http: https: 'unsafe-inline' 'unsafe-eval';",
"style-src 'self' http: https: 'unsafe-inline';",
"img-src 'self' http: https: data: blob:;",
"font-src 'self' http: https: data:;",
"connect-src 'self' http: https:;",
"media-src 'self' http: https: data: blob:;",
"object-src 'none';",
"base-uri 'none';",
"frame-ancestors 'self'",
].join(' ')
}

return headers
}

export function createFileRoutes() {
const app = new Hono()

app.get('*', async (c) => {
const path = c.req.path

if (path === '/api/files/preview' || path.startsWith('/api/files/preview/')) {
const userPath = getPreviewPathFromRequest(c)

if (!userPath) {
return c.json({ error: 'No path provided' }, 400)
}

try {
const stat = await fileService.getFilePreviewStat(userPath)

if (stat.isDirectory) {
return c.json({ error: 'Cannot preview directories' }, 400)
}

if (!isPreviewableMimeType(stat.mimeType)) {
return c.json({ error: 'File type cannot be previewed' }, 415)
}

const headers = getPreviewHeaders(stat)

if (isPreviewFresh(c, buildPreviewValidators(stat))) {
return new Response(null, { status: 304, headers })
}

const content = await fileService.getRawFileContent(userPath)

return new Response(content, { headers })
} catch (error: unknown) {
logger.error('Failed to preview file:', error)
return c.json({ error: getErrorMessage(error) || 'Failed to preview file' }, getStatusCode(error) as ContentfulStatusCode)
}
}

if (path.endsWith('/download-zip')) {
const userPath = getSpecialRoutePathFromRequest(c, 'download-zip')

Expand Down
14 changes: 0 additions & 14 deletions backend/src/routes/internal/git-credentials.ts

This file was deleted.

4 changes: 2 additions & 2 deletions backend/src/routes/internal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { createInternalRepoSyncRoutes } from './repo-sync'
import { createInternalRepoMirrorRoutes as mirrorRoutes } from './repo-mirror'
import { createInternalOpenCodeWorkspacesRoutes } from './opencode-workspaces'
import { createInternalAssistantRoutes } from './assistant'
import { createInternalGitCredentialsRoutes } from './git-credentials'
import { createInternalShellEnvRoutes } from './shell-env'

export function createInternalRoutes(
db: Database,
Expand All @@ -35,6 +35,6 @@ export function createInternalRoutes(
app.route('/repos', repos)
app.route('/opencode-workspaces', createInternalOpenCodeWorkspacesRoutes(db))
app.route('/assistant', createInternalAssistantRoutes(openCodeClient))
app.route('/git-credentials', createInternalGitCredentialsRoutes(db))
app.route('/shell-env', createInternalShellEnvRoutes(db))
return app
}
18 changes: 18 additions & 0 deletions backend/src/routes/internal/shell-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Hono } from 'hono'
import type { Database } from 'bun:sqlite'
import { CredentialProvider } from '../../services/credential-provider'
import { getDevServerPort } from '../../services/dev-server/manager'

export function createInternalShellEnvRoutes(db: Database) {
const app = new Hono()

app.get('/', (c) => {
const provider = new CredentialProvider(db)
return c.json({
...provider.getGhCliEnv({ cwd: c.req.query('cwd') }),
OCM_DEV_SERVER_PORT: String(getDevServerPort(db)),
})
})

return app
}
Loading