diff --git a/README.md b/README.md index 831361fb3e..4bfe950094 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,15 @@ Docker must be installed and running on your machine. ### Self-hosted: Docker Compose +For local Docker builds, use the local compose file: + +```bash +git clone https://github.com/simstudioai/sim.git && cd sim +docker compose -f docker-compose.local.yml up -d --build +``` + +For a cloud or production-style deployment, use the published images: + ```bash git clone https://github.com/simstudioai/sim.git && cd sim docker compose -f docker-compose.prod.yml up -d @@ -74,7 +83,181 @@ docker compose -f docker-compose.prod.yml up -d Open [http://localhost:3000](http://localhost:3000) -Sim also supports local models via [Ollama](https://ollama.ai) and [vLLM](https://docs.vllm.ai/) — see the [Docker self-hosting docs](https://docs.sim.ai/self-hosting/docker) for setup details. +#### OpenCode Setup + +OpenCode is opt-in. By default the `OpenCode` block stays hidden so the base Sim UX and deployment path remain unchanged. + +Quick deploy paths: + +##### Sim only + +- Do not set `NEXT_PUBLIC_OPENCODE_ENABLED`. +- Do not set any `OPENCODE_*` variables. +- Do not use `docker-compose.opencode.yml` or `docker-compose.opencode.local.yml`. +- Start Sim with the normal upstream flow. + +##### Sim + OpenCode local overlay + +- Set `NEXT_PUBLIC_OPENCODE_ENABLED=true`. +- Set `OPENCODE_SERVER_USERNAME` and `OPENCODE_SERVER_PASSWORD`. +- Set `OPENCODE_REPOSITORY_ROOT=/app/repos` unless you intentionally changed the runtime root. +- Set `OPENCODE_REPOS` to one or more HTTPS repository URLs. +- Set at least one provider key such as `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, or `GOOGLE_GENERATIVE_AI_API_KEY`. +- Set `GIT_USERNAME` and `GIT_TOKEN` or `GITHUB_TOKEN` if any repository is private. +- For host-side `next dev`, also set `OPENCODE_BASE_URL=http://127.0.0.1:4096`. +- Start with `docker compose -f docker-compose.local.yml -f docker-compose.opencode.local.yml up -d --build`. + +##### Sim + external OpenCode runtime + +- Set `NEXT_PUBLIC_OPENCODE_ENABLED=true`. +- Set `OPENCODE_BASE_URL` to the external OpenCode server. +- Set `OPENCODE_SERVER_USERNAME` and `OPENCODE_SERVER_PASSWORD` to the credentials expected by that server. +- Set `OPENCODE_REPOSITORY_ROOT` to the same worktree root used by the external OpenCode deployment. +- Set `OPENCODE_REPOS` to the repository catalog you expect the runtime to clone or expose. +- Ensure the external runtime already has at least one provider key configured. +- Ensure the external runtime can clone private repositories with the right git credentials if needed. +- Verify `/global/health` and one real prompt before exposing the block to users. + +Minimum setup: + +```bash +cp apps/sim/.env.example apps/sim/.env +``` + +Then add these values to `apps/sim/.env`: + +```env +NEXT_PUBLIC_OPENCODE_ENABLED=true +OPENCODE_REPOSITORY_ROOT=/app/repos +OPENCODE_SERVER_USERNAME=opencode +OPENCODE_SERVER_PASSWORD=change-me +OPENCODE_REPOS=https://github.com/octocat/Hello-World.git + +# Pick at least one provider key that OpenCode can use +GEMINI_API_KEY=your-gemini-key +# or OPENAI_API_KEY=... +# or ANTHROPIC_API_KEY=... +``` + +If you want private repositories: + +```env +# Generic HTTPS or Azure Repos +GIT_USERNAME=your-user-or-email +GIT_TOKEN=your-token-or-pat + +# Optional GitHub-only fallback +GITHUB_TOKEN=your-github-token +``` + +Important: + +- The `OpenCode` block remains hidden unless `NEXT_PUBLIC_OPENCODE_ENABLED=true` is set on the Sim app. +- `docker compose` reads environment from the shell, not from `apps/sim/.env` automatically. +- If you want the app and the OpenCode runtime to use the same credentials, load that file before starting compose: + +```bash +set -a +source apps/sim/.env +set +a +docker compose -f docker-compose.local.yml -f docker-compose.opencode.local.yml up -d --build +``` + +Local vs production behavior: + +- `docker-compose.local.yml` + - remains unchanged +- `docker-compose.opencode.local.yml` + - adds OpenCode locally without changing the base local compose file + - publishes `OPENCODE_PORT` to the host so `next dev` on the host can talk to OpenCode + - defaults `OPENCODE_REPOSITORY_ROOT=/app/repos` + - defaults `OPENCODE_SERVER_USERNAME=opencode` + - defaults `OPENCODE_SERVER_PASSWORD=dev-opencode-password` if you do not set one explicitly +- `docker-compose.prod.yml` + - contains the upstream-style base deployment only +- `docker-compose.opencode.yml` + - adds the `opencode` service as a production overlay + - builds the OpenCode runtime locally from this repository instead of requiring an official Sim-hosted image + - injects the required `NEXT_PUBLIC_OPENCODE_ENABLED` and `OPENCODE_*` variables into `simstudio` + - keeps OpenCode internal to the Docker network with `expose`, not a published host port + - defaults `OPENCODE_REPOSITORY_ROOT=/app/repos` + - requires `OPENCODE_SERVER_PASSWORD` to be set explicitly before `docker compose` starts + +Production deploy command: + +```bash +docker compose -f docker-compose.prod.yml -f docker-compose.opencode.yml up -d --build +``` + +For local hot reload with `next dev` on the host, also set this in `apps/sim/.env`: + +```env +OPENCODE_BASE_URL=http://127.0.0.1:4096 +``` + +Then start only the optional OpenCode runtime: + +```bash +docker compose -f docker-compose.local.yml -f docker-compose.opencode.local.yml up -d --build opencode +``` + +Without that override, host-side Next.js cannot reliably reach the Docker service alias. + +Notes: + +- If `OPENCODE_REPOS` is empty, `opencode` still starts but no repositories are cloned. +- Repositories are cloned into `${OPENCODE_REPOSITORY_ROOT:-/app/repos}/`. +- Private Azure Repos must use `https` plus `GIT_USERNAME` and `GIT_TOKEN`; the container will not prompt interactively for passwords. +- `GOOGLE_GENERATIVE_AI_API_KEY` is optional; the optional overlays map it automatically from `GEMINI_API_KEY` if not set. +- If you prefer to run OpenCode in separate infrastructure, skip the overlays and point Sim at that deployment with `OPENCODE_BASE_URL`, `OPENCODE_SERVER_USERNAME`, `OPENCODE_SERVER_PASSWORD`, and the matching `OPENCODE_REPOSITORY_ROOT`. + +Basic verification after startup: + +```bash +curl -u "opencode:change-me" http://127.0.0.1:4096/global/health +``` + +If you changed the username, password, or port, use those values instead. + +See [`docker/opencode/README.md`](docker/opencode/README.md) for service-specific verification steps and runtime behavior. + +#### Using Local Models with Ollama + +Run Sim with local AI models using [Ollama](https://ollama.ai) - no external APIs required: + +```bash +# Start with GPU support (automatically downloads gemma3:4b model) +docker compose -f docker-compose.ollama.yml --profile setup up -d + +# For CPU-only systems: +docker compose -f docker-compose.ollama.yml --profile cpu --profile setup up -d +``` + +Wait for the model to download, then visit [http://localhost:3000](http://localhost:3000). Add more models with: +```bash +docker compose -f docker-compose.ollama.yml exec ollama ollama pull llama3.1:8b +``` + +#### Using an External Ollama Instance + +If Ollama is running on your host machine, use `host.docker.internal` instead of `localhost`: + +```bash +OLLAMA_URL=http://host.docker.internal:11434 docker compose -f docker-compose.prod.yml up -d +``` + +On Linux, use your host's IP address or add `extra_hosts: ["host.docker.internal:host-gateway"]` to the compose file. + +#### Using vLLM + +Sim supports [vLLM](https://docs.vllm.ai/) for self-hosted models. Set `VLLM_BASE_URL` and optionally `VLLM_API_KEY` in your environment. + +### Self-hosted: Dev Containers + +1. Open VS Code with the [Remote - Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) +2. Open the project and click "Reopen in Container" when prompted +3. Run `bun run dev:full` in the terminal or use the `sim-start` alias + - This starts both the main application and the realtime socket server ### Self-hosted: Manual Setup @@ -104,6 +287,26 @@ cp packages/db/.env.example packages/db/.env # Edit both .env files to set DATABASE_URL="postgresql://postgres:your_password@localhost:5432/simstudio" ``` +If you want to use the OpenCode workflow block while running `next dev` on the host, also set these in `apps/sim/.env`: + +```env +NEXT_PUBLIC_OPENCODE_ENABLED=true +OPENCODE_BASE_URL=http://127.0.0.1:4096 +OPENCODE_SERVER_USERNAME=opencode +OPENCODE_SERVER_PASSWORD=change-me +OPENCODE_REPOS=https://github.com/octocat/Hello-World.git +GEMINI_API_KEY=your-gemini-key +``` + +Then export the same environment before starting the OpenCode container so the app and Docker use identical credentials: + +```bash +set -a +source apps/sim/.env +set +a +docker compose -f docker-compose.local.yml -f docker-compose.opencode.local.yml up -d --build opencode +``` + 4. Run migrations: ```bash @@ -114,9 +317,10 @@ cd packages/db && bunx drizzle-kit migrate --config=./drizzle.config.ts ```bash bun run dev:full # Starts both Next.js app and realtime socket server +bun run dev:full:webpack # Same, but using Webpack instead of Turbopack ``` -Or run separately: `bun run dev` (Next.js) and `cd apps/sim && bun run dev:sockets` (realtime). +Or run separately: `bun run dev` (Next.js/Turbopack), `cd apps/sim && bun run dev:webpack` (Next.js/Webpack), and `cd apps/sim && bun run dev:sockets` (realtime). ## Copilot API Keys diff --git a/apps/sim/.env.example b/apps/sim/.env.example index 6c22b09eef..5887d15469 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -29,6 +29,26 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener # VLLM_BASE_URL=http://localhost:8000 # Base URL for your self-hosted vLLM (OpenAI-compatible) # VLLM_API_KEY= # Optional bearer token if your vLLM instance requires auth +# Internal OpenCode Service (Optional, opt-in) +# NEXT_PUBLIC_OPENCODE_ENABLED=true # Required to show the OpenCode block in the UI +# # Leave unset to keep the block hidden and preserve the default Sim UX +# OPENCODE_BASE_URL=http://127.0.0.1:4096 # Use this when SIM runs on the host (for example, `bun run dev`) and OpenCode runs in Docker +# OPENCODE_BASE_URL=http://opencode:4096 # Use this when SIM and OpenCode both run in Docker Compose +# # Or point this to any separate OpenCode deployment that implements the same auth contract +# OPENCODE_PORT=4096 +# OPENCODE_REPOSITORY_ROOT=/app/repos # Must match the repository root used by the OpenCode runtime, including external deployments +# OPENCODE_SERVER_USERNAME=opencode +# OPENCODE_SERVER_PASSWORD=change-me # Required for the internal OpenCode service +# OPENCODE_REPOS=https://github.com/org/ui-components,https://github.com/org/design-tokens +# OPENCODE_REPOS=https://dev.azure.com/org/project/_git/repo # Azure Repos over HTTPS also works +# GIT_USERNAME= # Optional HTTPS git username for private repos, including Azure Repos +# GIT_TOKEN= # Optional HTTPS git token/PAT for private repos, including Azure Repos +# GITHUB_TOKEN= # Optional GitHub token fallback for private GitHub repos +# OPENAI_API_KEY= # OpenCode can use any supported provider key from the environment +# ANTHROPIC_API_KEY= # Optional if you prefer Anthropic for OpenCode +# GEMINI_API_KEY= # Optional if you prefer Gemini for OpenCode +# GOOGLE_GENERATIVE_AI_API_KEY= # Optional explicit alias for OpenCode's Google provider; defaults from GEMINI_API_KEY in the optional compose overlays + # Admin API (Optional - for self-hosted GitOps) # ADMIN_API_KEY= # Use `openssl rand -hex 32` to generate. Enables admin API for workflow export/import. # Usage: curl -H "x-admin-key: your_key" https://your-instance/api/v1/admin/workspaces diff --git a/apps/sim/app/api/opencode/agents/route.ts b/apps/sim/app/api/opencode/agents/route.ts new file mode 100644 index 0000000000..1d0a818052 --- /dev/null +++ b/apps/sim/app/api/opencode/agents/route.ts @@ -0,0 +1,50 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { getOpenCodeRouteError } from '@/lib/opencode/errors' +import { listOpenCodeAgents } from '@/lib/opencode/service' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('OpenCodeAgentsAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +export async function GET(request: NextRequest) { + const requestId = generateRequestId() + + try { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized OpenCode agents access attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const workspaceId = request.nextUrl.searchParams.get('workspaceId') + if (!workspaceId) { + return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }) + } + + const access = await checkWorkspaceAccess(workspaceId, authResult.userId) + if (!access.exists) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } + + if (!access.hasAccess) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + + const repository = request.nextUrl.searchParams.get('repository') || undefined + const agents = await listOpenCodeAgents(repository) + return NextResponse.json({ data: agents }) + } catch (error) { + const routeError = getOpenCodeRouteError(error, 'agents') + logger.error(`[${requestId}] Failed to fetch OpenCode agents`, { + error, + status: routeError.status, + responseMessage: routeError.message, + }) + return NextResponse.json({ error: routeError.message }, { status: routeError.status }) + } +} diff --git a/apps/sim/app/api/opencode/models/route.ts b/apps/sim/app/api/opencode/models/route.ts new file mode 100644 index 0000000000..42deb015b8 --- /dev/null +++ b/apps/sim/app/api/opencode/models/route.ts @@ -0,0 +1,55 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { getOpenCodeRouteError } from '@/lib/opencode/errors' +import { listOpenCodeModels } from '@/lib/opencode/service' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('OpenCodeModelsAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +export async function GET(request: NextRequest) { + const requestId = generateRequestId() + + try { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized OpenCode models access attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const workspaceId = request.nextUrl.searchParams.get('workspaceId') + if (!workspaceId) { + return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }) + } + + const providerId = request.nextUrl.searchParams.get('providerId') + if (!providerId) { + return NextResponse.json({ error: 'providerId is required' }, { status: 400 }) + } + + const access = await checkWorkspaceAccess(workspaceId, authResult.userId) + if (!access.exists) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } + + if (!access.hasAccess) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + + const repository = request.nextUrl.searchParams.get('repository') || undefined + const models = await listOpenCodeModels(providerId, repository) + return NextResponse.json({ data: models }) + } catch (error) { + const routeError = getOpenCodeRouteError(error, 'models') + logger.error(`[${requestId}] Failed to fetch OpenCode models`, { + error, + status: routeError.status, + responseMessage: routeError.message, + }) + return NextResponse.json({ error: routeError.message }, { status: routeError.status }) + } +} diff --git a/apps/sim/app/api/opencode/providers/route.ts b/apps/sim/app/api/opencode/providers/route.ts new file mode 100644 index 0000000000..3d7296557f --- /dev/null +++ b/apps/sim/app/api/opencode/providers/route.ts @@ -0,0 +1,50 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { getOpenCodeRouteError } from '@/lib/opencode/errors' +import { listOpenCodeProviders } from '@/lib/opencode/service' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('OpenCodeProvidersAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +export async function GET(request: NextRequest) { + const requestId = generateRequestId() + + try { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized OpenCode providers access attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const workspaceId = request.nextUrl.searchParams.get('workspaceId') + if (!workspaceId) { + return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }) + } + + const access = await checkWorkspaceAccess(workspaceId, authResult.userId) + if (!access.exists) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } + + if (!access.hasAccess) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + + const repository = request.nextUrl.searchParams.get('repository') || undefined + const providers = await listOpenCodeProviders(repository) + return NextResponse.json({ data: providers }) + } catch (error) { + const routeError = getOpenCodeRouteError(error, 'providers') + logger.error(`[${requestId}] Failed to fetch OpenCode providers`, { + error, + status: routeError.status, + responseMessage: routeError.message, + }) + return NextResponse.json({ error: routeError.message }, { status: routeError.status }) + } +} diff --git a/apps/sim/app/api/opencode/repos/route.test.ts b/apps/sim/app/api/opencode/repos/route.test.ts new file mode 100644 index 0000000000..95ff2a1115 --- /dev/null +++ b/apps/sim/app/api/opencode/repos/route.test.ts @@ -0,0 +1,128 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckSessionOrInternalAuth, mockCheckWorkspaceAccess, mockListOpenCodeRepositories } = + vi.hoisted(() => ({ + mockCheckSessionOrInternalAuth: vi.fn(), + mockCheckWorkspaceAccess: vi.fn(), + mockListOpenCodeRepositories: vi.fn(), + })) + +vi.mock('@/lib/auth/hybrid', () => ({ + AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' }, + checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth, +})) + +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('test-request-id'), +})) + +vi.mock('@/lib/opencode/service', () => ({ + listOpenCodeRepositories: mockListOpenCodeRepositories, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mockCheckWorkspaceAccess, +})) + +import { GET } from '@/app/api/opencode/repos/route' + +describe('GET /api/opencode/repos', () => { + function createRequest(query = ''): NextRequest { + return new NextRequest(new URL(`http://localhost:3000/api/opencode/repos${query}`)) + } + + beforeEach(() => { + vi.clearAllMocks() + + mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-123', + }) + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + }) + mockListOpenCodeRepositories.mockResolvedValue([ + { + id: 'repo-a', + label: 'repo-a', + directory: '/app/repos/repo-a', + projectId: 'project-1', + }, + ]) + }) + + it('returns 401 when unauthenticated', async () => { + mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: false, + userId: null, + }) + + const response = await GET(createRequest('?workspaceId=ws-1')) + const data = await response.json() + + expect(response.status).toBe(401) + expect(data).toEqual({ error: 'Unauthorized' }) + expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled() + expect(mockListOpenCodeRepositories).not.toHaveBeenCalled() + }) + + it('returns 400 when workspaceId is missing', async () => { + const response = await GET(createRequest()) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data).toEqual({ error: 'workspaceId is required' }) + expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled() + }) + + it('returns 404 when workspace does not exist', async () => { + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: false, + hasAccess: false, + }) + + const response = await GET(createRequest('?workspaceId=ws-404')) + const data = await response.json() + + expect(response.status).toBe(404) + expect(data).toEqual({ error: 'Workspace not found' }) + expect(mockListOpenCodeRepositories).not.toHaveBeenCalled() + }) + + it('returns 403 when the user does not have access to the workspace', async () => { + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: false, + }) + + const response = await GET(createRequest('?workspaceId=ws-1')) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data).toEqual({ error: 'Access denied' }) + expect(mockListOpenCodeRepositories).not.toHaveBeenCalled() + }) + + it('returns repository options when the request is authorized', async () => { + const response = await GET(createRequest('?workspaceId=ws-1')) + const data = await response.json() + + expect(response.status).toBe(200) + expect(mockCheckWorkspaceAccess).toHaveBeenCalledWith('ws-1', 'user-123') + expect(data).toEqual({ + data: [ + { + id: 'repo-a', + label: 'repo-a', + directory: '/app/repos/repo-a', + projectId: 'project-1', + }, + ], + }) + }) +}) diff --git a/apps/sim/app/api/opencode/repos/route.ts b/apps/sim/app/api/opencode/repos/route.ts new file mode 100644 index 0000000000..d9f239081a --- /dev/null +++ b/apps/sim/app/api/opencode/repos/route.ts @@ -0,0 +1,49 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { getOpenCodeRouteError } from '@/lib/opencode/errors' +import { listOpenCodeRepositories } from '@/lib/opencode/service' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('OpenCodeReposAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +export async function GET(request: NextRequest) { + const requestId = generateRequestId() + + try { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized OpenCode repos access attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const workspaceId = request.nextUrl.searchParams.get('workspaceId') + if (!workspaceId) { + return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }) + } + + const access = await checkWorkspaceAccess(workspaceId, authResult.userId) + if (!access.exists) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } + + if (!access.hasAccess) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + + const repositories = await listOpenCodeRepositories() + return NextResponse.json({ data: repositories }) + } catch (error) { + const routeError = getOpenCodeRouteError(error, 'repositories') + logger.error(`[${requestId}] Failed to fetch OpenCode repositories`, { + error, + status: routeError.status, + responseMessage: routeError.message, + }) + return NextResponse.json({ error: routeError.message }, { status: routeError.status }) + } +} diff --git a/apps/sim/app/api/tools/opencode/messages/route.ts b/apps/sim/app/api/tools/opencode/messages/route.ts new file mode 100644 index 0000000000..cfb454836f --- /dev/null +++ b/apps/sim/app/api/tools/opencode/messages/route.ts @@ -0,0 +1,46 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { getOpenCodeMessages } from '@/lib/opencode/service' + +const logger = createLogger('OpenCodeMessagesToolAPI') + +const OpenCodeMessagesSchema = z.object({ + repository: z.string().min(1, 'repository is required'), + threadId: z.string().min(1, 'threadId is required'), +}) + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +export async function POST(request: NextRequest) { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success) { + logger.warn(`[${requestId}] Unauthorized OpenCode messages request`) + return NextResponse.json({ error: authResult.error || 'Unauthorized' }, { status: 401 }) + } + + const body = OpenCodeMessagesSchema.parse(await request.json()) + const messages = await getOpenCodeMessages(body.repository, body.threadId) + + return NextResponse.json({ + success: true, + output: { + threadId: body.threadId, + messages, + count: messages.length, + }, + }) + } catch (error) { + logger.error(`[${requestId}] Failed to fetch OpenCode messages`, { error }) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to fetch OpenCode messages' }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/opencode/prompt/route.test.ts b/apps/sim/app/api/tools/opencode/prompt/route.test.ts new file mode 100644 index 0000000000..48832e4737 --- /dev/null +++ b/apps/sim/app/api/tools/opencode/prompt/route.test.ts @@ -0,0 +1,384 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckInternalAuth, + mockBuildOpenCodeSessionMemoryKey, + mockBuildOpenCodeSessionTitle, + mockCreateOpenCodeSession, + mockGetStoredOpenCodeSession, + mockLogOpenCodeFailure, + mockPromptOpenCodeSession, + mockResolveOpenCodeRepositoryOption, + mockShouldRetryWithFreshOpenCodeSession, + mockStoreOpenCodeSession, +} = vi.hoisted(() => ({ + mockCheckInternalAuth: vi.fn(), + mockBuildOpenCodeSessionMemoryKey: vi.fn(), + mockBuildOpenCodeSessionTitle: vi.fn(), + mockCreateOpenCodeSession: vi.fn(), + mockGetStoredOpenCodeSession: vi.fn(), + mockLogOpenCodeFailure: vi.fn(), + mockPromptOpenCodeSession: vi.fn(), + mockResolveOpenCodeRepositoryOption: vi.fn(), + mockShouldRetryWithFreshOpenCodeSession: vi.fn(), + mockStoreOpenCodeSession: vi.fn(), +})) + +vi.mock('@/lib/auth/hybrid', () => ({ + AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' }, + checkInternalAuth: mockCheckInternalAuth, +})) + +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('test-request-id'), +})) + +vi.mock('@/lib/opencode/service', () => ({ + buildOpenCodeSessionMemoryKey: mockBuildOpenCodeSessionMemoryKey, + buildOpenCodeSessionTitle: mockBuildOpenCodeSessionTitle, + createOpenCodeSession: mockCreateOpenCodeSession, + getStoredOpenCodeSession: mockGetStoredOpenCodeSession, + logOpenCodeFailure: mockLogOpenCodeFailure, + promptOpenCodeSession: mockPromptOpenCodeSession, + resolveOpenCodeRepositoryOption: mockResolveOpenCodeRepositoryOption, + shouldRetryWithFreshOpenCodeSession: mockShouldRetryWithFreshOpenCodeSession, + storeOpenCodeSession: mockStoreOpenCodeSession, +})) + +import { POST } from '@/app/api/tools/opencode/prompt/route' + +describe('POST /api/tools/opencode/prompt', () => { + beforeEach(() => { + vi.clearAllMocks() + + mockCheckInternalAuth.mockResolvedValue({ + success: true, + userId: 'internal-user', + }) + mockResolveOpenCodeRepositoryOption.mockResolvedValue({ + id: 'repo-a', + label: 'repo-a', + directory: '/app/repos/repo-a', + projectId: 'project-1', + }) + mockBuildOpenCodeSessionMemoryKey.mockReturnValue('memory-key') + mockBuildOpenCodeSessionTitle.mockReturnValue('session-title') + mockGetStoredOpenCodeSession.mockResolvedValue(null) + mockCreateOpenCodeSession.mockResolvedValue({ id: 'session-1' }) + mockPromptOpenCodeSession.mockResolvedValue({ + content: 'OpenCode result', + threadId: 'session-1', + cost: 1.25, + }) + mockStoreOpenCodeSession.mockResolvedValue(undefined) + mockShouldRetryWithFreshOpenCodeSession.mockReturnValue(false) + mockLogOpenCodeFailure.mockResolvedValue(undefined) + }) + + it('returns 401 when internal auth fails', async () => { + mockCheckInternalAuth.mockResolvedValue({ + success: false, + error: 'Unauthorized', + }) + + const request = createMockRequest('POST', { + repository: 'repo-a', + providerId: 'provider-a', + modelId: 'model-a', + prompt: 'hello', + }) + + const response = await POST(request as never) + const data = await response.json() + + expect(response.status).toBe(401) + expect(data).toEqual({ error: 'Unauthorized' }) + }) + + it('returns 400 when workflow execution context is incomplete', async () => { + const request = createMockRequest('POST', { + repository: 'repo-a', + providerId: 'provider-a', + modelId: 'model-a', + prompt: 'hello', + _context: { + workspaceId: 'ws-1', + }, + }) + + const response = await POST(request as never) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data).toEqual({ + error: 'workspaceId and workflowId are required in execution context', + }) + }) + + it('creates a new OpenCode session when no stored session exists', async () => { + const request = createMockRequest('POST', { + repository: ' repo-a ', + providerId: ' provider-a ', + modelId: ' model-a ', + systemPrompt: ' system prompt ', + agent: ' planner ', + prompt: ' explain the change ', + _context: { + workspaceId: 'ws-1', + workflowId: 'wf-1', + userId: 'user-123', + }, + }) + + const response = await POST(request as never) + const data = await response.json() + + expect(response.status).toBe(200) + expect(mockResolveOpenCodeRepositoryOption).toHaveBeenCalledWith('repo-a') + expect(mockBuildOpenCodeSessionMemoryKey).toHaveBeenCalledWith('wf-1', 'user:user-123') + expect(mockGetStoredOpenCodeSession).toHaveBeenCalledWith('ws-1', 'memory-key') + expect(mockBuildOpenCodeSessionTitle).toHaveBeenCalledWith('repo-a', 'user:user-123') + expect(mockCreateOpenCodeSession).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'repo-a', + directory: '/app/repos/repo-a', + }), + 'session-title' + ) + expect(mockPromptOpenCodeSession).toHaveBeenCalledWith({ + repository: 'repo-a', + repositoryOption: expect.objectContaining({ + id: 'repo-a', + directory: '/app/repos/repo-a', + }), + sessionId: 'session-1', + prompt: 'explain the change', + systemPrompt: 'system prompt', + providerId: 'provider-a', + modelId: 'model-a', + agent: 'planner', + }) + expect(mockStoreOpenCodeSession).toHaveBeenCalledWith( + 'ws-1', + 'memory-key', + expect.objectContaining({ + sessionId: 'session-1', + repository: 'repo-a', + updatedAt: expect.any(String), + }) + ) + expect(data).toEqual({ + success: true, + output: { + content: 'OpenCode result', + threadId: 'session-1', + cost: 1.25, + }, + }) + }) + + it('reuses an existing stored session for the same repository', async () => { + mockGetStoredOpenCodeSession.mockResolvedValue({ + sessionId: 'stored-session', + repository: 'repo-a', + updatedAt: '2026-03-25T00:00:00.000Z', + }) + mockPromptOpenCodeSession.mockResolvedValue({ + content: 'Reused session result', + threadId: 'stored-session', + }) + + const request = createMockRequest('POST', { + repository: 'repo-a', + providerId: 'provider-a', + modelId: 'model-a', + prompt: 'continue', + _context: { + workspaceId: 'ws-1', + workflowId: 'wf-1', + executionId: 'exec-1', + }, + }) + + const response = await POST(request as never) + const data = await response.json() + + expect(response.status).toBe(200) + expect(mockBuildOpenCodeSessionMemoryKey).toHaveBeenCalledWith('wf-1', 'execution:exec-1') + expect(mockCreateOpenCodeSession).not.toHaveBeenCalled() + expect(mockPromptOpenCodeSession).toHaveBeenCalledWith({ + repository: 'repo-a', + repositoryOption: expect.objectContaining({ + id: 'repo-a', + directory: '/app/repos/repo-a', + }), + sessionId: 'stored-session', + prompt: 'continue', + systemPrompt: undefined, + providerId: 'provider-a', + modelId: 'model-a', + agent: undefined, + }) + expect(data).toEqual({ + success: true, + output: { + content: 'Reused session result', + threadId: 'stored-session', + }, + }) + }) + + it('retries with a fresh session when the stored session is stale', async () => { + mockGetStoredOpenCodeSession.mockResolvedValue({ + sessionId: 'stale-session', + repository: 'repo-a', + updatedAt: '2026-03-25T00:00:00.000Z', + }) + mockPromptOpenCodeSession + .mockRejectedValueOnce(new Error('session not found')) + .mockResolvedValueOnce({ + content: 'Recovered result', + threadId: 'fresh-session', + cost: 2.5, + }) + mockShouldRetryWithFreshOpenCodeSession.mockReturnValue(true) + mockCreateOpenCodeSession.mockResolvedValue({ id: 'fresh-session' }) + + const request = createMockRequest('POST', { + repository: 'repo-a', + providerId: 'provider-a', + modelId: 'model-a', + prompt: 'retry please', + _context: { + workspaceId: 'ws-1', + workflowId: 'wf-1', + userId: 'user-123', + }, + }) + + const response = await POST(request as never) + const data = await response.json() + + expect(response.status).toBe(200) + expect(mockPromptOpenCodeSession).toHaveBeenCalledTimes(2) + expect(mockShouldRetryWithFreshOpenCodeSession).toHaveBeenCalledWith( + expect.objectContaining({ message: 'session not found' }) + ) + expect(mockCreateOpenCodeSession).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'repo-a', + directory: '/app/repos/repo-a', + }), + 'session-title' + ) + expect(mockStoreOpenCodeSession).toHaveBeenCalledWith( + 'ws-1', + 'memory-key', + expect.objectContaining({ + sessionId: 'fresh-session', + repository: 'repo-a', + }) + ) + expect(mockLogOpenCodeFailure).not.toHaveBeenCalled() + expect(data).toEqual({ + success: true, + output: { + content: 'Recovered result', + threadId: 'fresh-session', + cost: 2.5, + }, + }) + }) + + it('does not retry when a newly created session fails immediately', async () => { + mockPromptOpenCodeSession.mockRejectedValueOnce(new Error('session not found')) + mockShouldRetryWithFreshOpenCodeSession.mockReturnValue(true) + mockCreateOpenCodeSession.mockResolvedValue({ id: 'fresh-session' }) + + const request = createMockRequest('POST', { + repository: 'repo-a', + providerId: 'provider-a', + modelId: 'model-a', + prompt: 'retry please', + _context: { + workspaceId: 'ws-1', + workflowId: 'wf-1', + userId: 'user-123', + }, + }) + + const response = await POST(request as never) + const data = await response.json() + + expect(response.status).toBe(200) + expect(mockCreateOpenCodeSession).toHaveBeenCalledTimes(1) + expect(mockPromptOpenCodeSession).toHaveBeenCalledTimes(1) + expect(mockLogOpenCodeFailure).toHaveBeenCalledWith( + 'Failed to execute OpenCode prompt', + expect.objectContaining({ message: 'session not found' }) + ) + expect(data).toEqual({ + success: true, + output: { + content: '', + threadId: 'fresh-session', + error: 'session not found', + }, + }) + }) + + it('stores and returns the fresh session id when the retry prompt fails', async () => { + mockGetStoredOpenCodeSession.mockResolvedValue({ + sessionId: 'stale-session', + repository: 'repo-a', + updatedAt: '2026-03-25T00:00:00.000Z', + }) + mockPromptOpenCodeSession + .mockRejectedValueOnce(new Error('session not found')) + .mockRejectedValueOnce(new Error('provider unavailable')) + mockShouldRetryWithFreshOpenCodeSession.mockReturnValue(true) + mockCreateOpenCodeSession.mockResolvedValue({ id: 'fresh-session' }) + + const request = createMockRequest('POST', { + repository: 'repo-a', + providerId: 'provider-a', + modelId: 'model-a', + prompt: 'retry please', + _context: { + workspaceId: 'ws-1', + workflowId: 'wf-1', + userId: 'user-123', + }, + }) + + const response = await POST(request as never) + const data = await response.json() + + expect(response.status).toBe(200) + expect(mockPromptOpenCodeSession).toHaveBeenCalledTimes(2) + expect(mockStoreOpenCodeSession).toHaveBeenCalledWith( + 'ws-1', + 'memory-key', + expect.objectContaining({ + sessionId: 'fresh-session', + repository: 'repo-a', + }) + ) + expect(mockLogOpenCodeFailure).toHaveBeenCalledWith( + 'Failed to retry OpenCode prompt with a fresh session', + expect.objectContaining({ message: 'provider unavailable' }) + ) + expect(data).toEqual({ + success: true, + output: { + content: '', + threadId: 'fresh-session', + error: 'provider unavailable', + }, + }) + }) +}) diff --git a/apps/sim/app/api/tools/opencode/prompt/route.ts b/apps/sim/app/api/tools/opencode/prompt/route.ts new file mode 100644 index 0000000000..4dba240605 --- /dev/null +++ b/apps/sim/app/api/tools/opencode/prompt/route.ts @@ -0,0 +1,244 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { + buildOpenCodeSessionMemoryKey, + buildOpenCodeSessionTitle, + createOpenCodeSession, + getStoredOpenCodeSession, + logOpenCodeFailure, + promptOpenCodeSession, + resolveOpenCodeRepositoryOption, + shouldRetryWithFreshOpenCodeSession, + storeOpenCodeSession, +} from '@/lib/opencode/service' +import { coerceOpenCodeBoolean } from '@/lib/opencode/utils' + +const logger = createLogger('OpenCodePromptToolAPI') + +const optionalNullableStringSchema = z.preprocess( + (value) => (value === null ? undefined : value), + z.string().optional() +) + +const OpenCodePromptSchema = z.object({ + repository: z.string().min(1, 'repository is required'), + systemPrompt: optionalNullableStringSchema, + providerId: z.string().min(1, 'providerId is required'), + modelId: z.string().min(1, 'modelId is required'), + agent: optionalNullableStringSchema, + prompt: z.string().min(1, 'prompt is required'), + newThread: z.union([z.boolean(), z.string()]).optional(), + _context: z + .object({ + workspaceId: z.string().optional(), + workflowId: z.string().optional(), + userId: z.string().optional(), + executionId: z.string().optional(), + }) + .passthrough() + .optional(), +}) + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +function getSessionOwnerKey(params: z.infer): string { + if (params._context?.userId) { + return `user:${params._context.userId}` + } + + if (params._context?.executionId) { + return `execution:${params._context.executionId}` + } + + return 'anonymous' +} + +function buildSuccessResponse(threadId: string, content: string, cost?: number): NextResponse { + return NextResponse.json({ + success: true, + output: { + content, + threadId, + ...(typeof cost === 'number' ? { cost } : {}), + }, + }) +} + +function buildErrorResponse( + threadId: string, + content: string, + cost: number | undefined, + error: string +): NextResponse { + return NextResponse.json({ + success: true, + output: { + content, + threadId, + ...(typeof cost === 'number' ? { cost } : {}), + error, + }, + }) +} + +async function executePrompt( + params: z.infer, + repository: string, + repositoryOption: Awaited>, + threadId: string, + prompt: string, + providerId: string, + modelId: string +) { + return promptOpenCodeSession({ + repository, + repositoryOption, + sessionId: threadId, + prompt, + systemPrompt: params.systemPrompt?.trim() || undefined, + providerId, + modelId, + agent: params.agent?.trim() || undefined, + }) +} + +export async function POST(request: NextRequest) { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success) { + logger.warn(`[${requestId}] Unauthorized OpenCode prompt request`) + return NextResponse.json({ error: authResult.error || 'Unauthorized' }, { status: 401 }) + } + + const body = OpenCodePromptSchema.parse(await request.json()) + const workspaceId = body._context?.workspaceId + const workflowId = body._context?.workflowId + + if (!workspaceId || !workflowId) { + return NextResponse.json( + { error: 'workspaceId and workflowId are required in execution context' }, + { status: 400 } + ) + } + + const repositoryOption = await resolveOpenCodeRepositoryOption(body.repository.trim()) + const repositoryId = repositoryOption.id + const prompt = body.prompt.trim() + const providerId = body.providerId.trim() + const modelId = body.modelId.trim() + const sessionOwnerKey = getSessionOwnerKey(body) + const memoryKey = buildOpenCodeSessionMemoryKey(workflowId, sessionOwnerKey) + const newThread = coerceOpenCodeBoolean(body.newThread) + const storedThread = newThread ? null : await getStoredOpenCodeSession(workspaceId, memoryKey) + const reusableStoredThread = + storedThread && storedThread.repository === repositoryId ? storedThread : null + let threadId = reusableStoredThread?.sessionId + + if (!threadId) { + const session = await createOpenCodeSession( + repositoryOption, + buildOpenCodeSessionTitle(repositoryId, sessionOwnerKey) + ) + threadId = session.id + } + + try { + const result = await executePrompt( + body, + repositoryId, + repositoryOption, + threadId, + prompt, + providerId, + modelId + ) + + await storeOpenCodeSession(workspaceId, memoryKey, { + sessionId: result.threadId, + repository: repositoryId, + updatedAt: new Date().toISOString(), + }) + + if (result.assistantError) { + return buildErrorResponse( + result.threadId, + result.content, + result.cost, + result.assistantError + ) + } + + return buildSuccessResponse(result.threadId, result.content, result.cost) + } catch (error) { + if (reusableStoredThread && threadId && shouldRetryWithFreshOpenCodeSession(error)) { + let freshSessionId = threadId + + try { + const freshSession = await createOpenCodeSession( + repositoryOption, + buildOpenCodeSessionTitle(repositoryId, sessionOwnerKey) + ) + freshSessionId = freshSession.id + + await storeOpenCodeSession(workspaceId, memoryKey, { + sessionId: freshSessionId, + repository: repositoryId, + updatedAt: new Date().toISOString(), + }) + + const result = await executePrompt( + body, + repositoryId, + repositoryOption, + freshSession.id, + prompt, + providerId, + modelId + ) + + await storeOpenCodeSession(workspaceId, memoryKey, { + sessionId: result.threadId, + repository: repositoryId, + updatedAt: new Date().toISOString(), + }) + + if (result.assistantError) { + return buildErrorResponse( + result.threadId, + result.content, + result.cost, + result.assistantError + ) + } + + return buildSuccessResponse(result.threadId, result.content, result.cost) + } catch (retryError) { + await logOpenCodeFailure( + 'Failed to retry OpenCode prompt with a fresh session', + retryError + ) + + const errorMessage = + retryError instanceof Error ? retryError.message : 'OpenCode prompt retry failed' + return buildErrorResponse(freshSessionId, '', undefined, errorMessage) + } + } + + await logOpenCodeFailure('Failed to execute OpenCode prompt', error) + const errorMessage = error instanceof Error ? error.message : 'OpenCode prompt failed' + return buildErrorResponse(threadId || '', '', undefined, errorMessage) + } + } catch (error) { + logger.error(`[${requestId}] Failed to execute OpenCode prompt tool`, { error }) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to execute OpenCode prompt' }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/opencode/repos/route.ts b/apps/sim/app/api/tools/opencode/repos/route.ts new file mode 100644 index 0000000000..1b438592c8 --- /dev/null +++ b/apps/sim/app/api/tools/opencode/repos/route.ts @@ -0,0 +1,39 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { listOpenCodeRepositories } from '@/lib/opencode/service' + +const logger = createLogger('OpenCodeReposToolAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +export async function POST(request: NextRequest) { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success) { + logger.warn(`[${requestId}] Unauthorized OpenCode repos request`) + return NextResponse.json({ error: authResult.error || 'Unauthorized' }, { status: 401 }) + } + + await request.text() + const repositories = await listOpenCodeRepositories() + + return NextResponse.json({ + success: true, + output: { + repositories, + count: repositories.length, + }, + }) + } catch (error) { + logger.error(`[${requestId}] Failed to fetch OpenCode repositories`, { error }) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to fetch repositories' }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx index c0ef093326..29c424cce6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx @@ -39,7 +39,7 @@ type ComboBoxOption = */ interface ComboBoxProps { /** Available options for selection - can be static array or function that returns options */ - options: ComboBoxOption[] | (() => ComboBoxOption[]) + options?: ComboBoxOption[] | (() => ComboBoxOption[]) /** Default value to use when no value is set */ defaultValue?: string /** ID of the parent block */ @@ -123,28 +123,68 @@ export const ComboBox = memo(function ComboBox({ const [fetchedOptions, setFetchedOptions] = useState>([]) const [isLoadingOptions, setIsLoadingOptions] = useState(false) const [fetchError, setFetchError] = useState(null) + const [hasAttemptedOptionsFetch, setHasAttemptedOptionsFetch] = useState(false) const [hydratedOption, setHydratedOption] = useState<{ label: string; id: string } | null>(null) const previousDependencyValuesRef = useRef('') + const optionsFetchVersionRef = useRef(0) + const isOptionsFetchInFlightRef = useRef(false) + const hasAttemptedOptionsFetchRef = useRef(false) + const fetchOptionsIfNeededRef = useRef<((force?: boolean) => Promise) | null>(null) /** * Fetches options from the async fetchOptions function if provided */ - const fetchOptionsIfNeeded = useCallback(async () => { - if (!fetchOptions || isPreview || disabled) return - - setIsLoadingOptions(true) - setFetchError(null) - try { - const options = await fetchOptions(blockId, subBlockId) - setFetchedOptions(options) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Failed to fetch options' - setFetchError(errorMessage) - setFetchedOptions([]) - } finally { - setIsLoadingOptions(false) - } - }, [fetchOptions, blockId, subBlockId, isPreview, disabled]) + const fetchOptionsIfNeeded = useCallback( + async (force = false) => { + if ( + !fetchOptions || + isPreview || + disabled || + (!force && hasAttemptedOptionsFetchRef.current) || + isOptionsFetchInFlightRef.current + ) { + return + } + + const fetchVersion = optionsFetchVersionRef.current + let shouldTriggerReplacementFetch = false + isOptionsFetchInFlightRef.current = true + hasAttemptedOptionsFetchRef.current = true + setHasAttemptedOptionsFetch(true) + setIsLoadingOptions(true) + setFetchError(null) + try { + const options = await fetchOptions(blockId, subBlockId) + if (fetchVersion !== optionsFetchVersionRef.current) { + shouldTriggerReplacementFetch = true + return + } + setFetchedOptions(options) + } catch (error) { + if (fetchVersion !== optionsFetchVersionRef.current) { + shouldTriggerReplacementFetch = true + return + } + const errorMessage = error instanceof Error ? error.message : 'Failed to fetch options' + setFetchError(errorMessage) + setFetchedOptions([]) + } finally { + isOptionsFetchInFlightRef.current = false + const replacementFetch = fetchOptionsIfNeededRef.current + + if (shouldTriggerReplacementFetch && replacementFetch) { + void replacementFetch(true) + } else { + setIsLoadingOptions(false) + } + } + }, + [fetchOptions, blockId, subBlockId, isPreview, disabled] + ) + + useEffect(() => { + fetchOptionsIfNeededRef.current = fetchOptionsIfNeeded + }, [fetchOptionsIfNeeded]) // Determine the active value based on mode (preview vs. controlled vs. store) const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue @@ -154,7 +194,8 @@ export const ComboBox = memo(function ComboBox({ // Evaluate static options if provided as a function const staticOptions = useMemo(() => { - const opts = typeof options === 'function' ? options() : options + const resolvedOptions = typeof options === 'function' ? options() : options + const opts = Array.isArray(resolvedOptions) ? resolvedOptions : [] if (subBlockId === 'model') { return opts.filter((opt) => { @@ -306,7 +347,11 @@ export const ComboBox = memo(function ComboBox({ previousDependencyValuesStr && currentDependencyValuesStr !== previousDependencyValuesStr ) { + optionsFetchVersionRef.current += 1 setFetchedOptions([]) + setFetchError(null) + hasAttemptedOptionsFetchRef.current = false + setHasAttemptedOptionsFetch(false) setHydratedOption(null) } @@ -322,7 +367,8 @@ export const ComboBox = memo(function ComboBox({ !disabled && fetchedOptions.length === 0 && !isLoadingOptions && - !fetchError + !fetchError && + !hasAttemptedOptionsFetch ) { fetchOptionsIfNeeded() } @@ -334,6 +380,7 @@ export const ComboBox = memo(function ComboBox({ fetchedOptions.length, isLoadingOptions, fetchError, + hasAttemptedOptionsFetch, dependencyValues, ]) @@ -429,7 +476,7 @@ export const ComboBox = memo(function ComboBox({ const handleOpenChange = useCallback( (open: boolean) => { if (open) { - void fetchOptionsIfNeeded() + void fetchOptionsIfNeeded(true) } }, [fetchOptionsIfNeeded] diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index 3a56fcfabf..56bf35245e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -32,7 +32,7 @@ type DropdownOption = */ interface DropdownProps { /** Static options array or function that returns options */ - options: DropdownOption[] | (() => DropdownOption[]) + options?: DropdownOption[] | (() => DropdownOption[]) /** Default value to select when no value is set */ defaultValue?: string /** Unique identifier for the block */ @@ -127,10 +127,15 @@ export const Dropdown = memo(function Dropdown({ const [fetchedOptions, setFetchedOptions] = useState>([]) const [isLoadingOptions, setIsLoadingOptions] = useState(false) const [fetchError, setFetchError] = useState(null) + const [hasAttemptedOptionsFetch, setHasAttemptedOptionsFetch] = useState(false) const [hydratedOption, setHydratedOption] = useState<{ label: string; id: string } | null>(null) const previousModeRef = useRef(null) const previousDependencyValuesRef = useRef('') + const optionsFetchVersionRef = useRef(0) + const isOptionsFetchInFlightRef = useRef(false) + const hasAttemptedOptionsFetchRef = useRef(false) + const fetchOptionsIfNeededRef = useRef<((force?: boolean) => Promise) | null>(null) const [builderData, setBuilderData] = useSubBlockValue(blockId, 'builderData') const [data, setData] = useSubBlockValue(blockId, 'data') @@ -154,22 +159,57 @@ export const Dropdown = memo(function Dropdown({ : [] : null - const fetchOptionsIfNeeded = useCallback(async () => { - if (!fetchOptions || isPreview || disabled) return + const fetchOptionsIfNeeded = useCallback( + async (force = false) => { + if ( + !fetchOptions || + isPreview || + disabled || + (!force && hasAttemptedOptionsFetchRef.current) || + isOptionsFetchInFlightRef.current + ) { + return + } - setIsLoadingOptions(true) - setFetchError(null) - try { - const options = await fetchOptions(blockId, subBlockId) - setFetchedOptions(options) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Failed to fetch options' - setFetchError(errorMessage) - setFetchedOptions([]) - } finally { - setIsLoadingOptions(false) - } - }, [fetchOptions, blockId, subBlockId, isPreview, disabled]) + const fetchVersion = optionsFetchVersionRef.current + let shouldTriggerReplacementFetch = false + isOptionsFetchInFlightRef.current = true + hasAttemptedOptionsFetchRef.current = true + setHasAttemptedOptionsFetch(true) + setIsLoadingOptions(true) + setFetchError(null) + try { + const options = await fetchOptions(blockId, subBlockId) + if (fetchVersion !== optionsFetchVersionRef.current) { + shouldTriggerReplacementFetch = true + return + } + setFetchedOptions(options) + } catch (error) { + if (fetchVersion !== optionsFetchVersionRef.current) { + shouldTriggerReplacementFetch = true + return + } + const errorMessage = error instanceof Error ? error.message : 'Failed to fetch options' + setFetchError(errorMessage) + setFetchedOptions([]) + } finally { + isOptionsFetchInFlightRef.current = false + const replacementFetch = fetchOptionsIfNeededRef.current + + if (shouldTriggerReplacementFetch && replacementFetch) { + void replacementFetch(true) + } else { + setIsLoadingOptions(false) + } + } + }, + [fetchOptions, blockId, subBlockId, isPreview, disabled] + ) + + useEffect(() => { + fetchOptionsIfNeededRef.current = fetchOptionsIfNeeded + }, [fetchOptionsIfNeeded]) /** * Handles combobox open state changes to trigger option fetching @@ -177,14 +217,15 @@ export const Dropdown = memo(function Dropdown({ const handleOpenChange = useCallback( (open: boolean) => { if (open) { - void fetchOptionsIfNeeded() + void fetchOptionsIfNeeded(true) } }, [fetchOptionsIfNeeded] ) const evaluatedOptions = useMemo(() => { - return typeof options === 'function' ? options() : options + const resolvedOptions = typeof options === 'function' ? options() : options + return Array.isArray(resolvedOptions) ? resolvedOptions : [] }, [options]) const normalizedFetchedOptions = useMemo(() => { @@ -369,7 +410,11 @@ export const Dropdown = memo(function Dropdown({ previousDependencyValuesStr && currentDependencyValuesStr !== previousDependencyValuesStr ) { + optionsFetchVersionRef.current += 1 setFetchedOptions([]) + setFetchError(null) + hasAttemptedOptionsFetchRef.current = false + setHasAttemptedOptionsFetch(false) setHydratedOption(null) } @@ -387,7 +432,8 @@ export const Dropdown = memo(function Dropdown({ !disabled && fetchedOptions.length === 0 && !isLoadingOptions && - !fetchError + !fetchError && + !hasAttemptedOptionsFetch ) { fetchOptionsIfNeeded() } @@ -399,6 +445,7 @@ export const Dropdown = memo(function Dropdown({ fetchedOptions.length, isLoadingOptions, fetchError, + hasAttemptedOptionsFetch, dependencyValues, ]) diff --git a/apps/sim/blocks/blocks/opencode.ts b/apps/sim/blocks/blocks/opencode.ts new file mode 100644 index 0000000000..68b9a1a00b --- /dev/null +++ b/apps/sim/blocks/blocks/opencode.ts @@ -0,0 +1,251 @@ +import { OpenCodeIcon } from '@/components/icons' +import { getEnv, isTruthy } from '@/lib/core/config/env' +import { coerceOpenCodeBoolean } from '@/lib/opencode/utils' +import type { BlockConfig } from '@/blocks/types' +import type { OpenCodePromptResponse } from '@/tools/opencode/types' + +const isOpenCodeEnabled = isTruthy(getEnv('NEXT_PUBLIC_OPENCODE_ENABLED')) + +function getOptionalString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined + } + + const trimmedValue = value.trim() + return trimmedValue ? trimmedValue : undefined +} + +async function getOpenCodeBlockValues(blockId: string): Promise> { + const { useSubBlockStore } = await import('@/stores/workflows/subblock/store') + const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store') + + const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId + if (!activeWorkflowId) { + return {} + } + + return useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[blockId] || {} +} + +async function getOpenCodeWorkspaceId(): Promise { + const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store') + return useWorkflowRegistry.getState().hydration.workspaceId +} + +async function fetchOpenCodeOptions( + route: string, + query: Record +): Promise> { + const workspaceId = await getOpenCodeWorkspaceId() + if (!workspaceId) { + return [] + } + + const searchParams = new URLSearchParams({ workspaceId }) + for (const [key, value] of Object.entries(query)) { + if (value) { + searchParams.set(key, value) + } + } + + const response = await fetch(`${route}?${searchParams.toString()}`) + const result = (await response.json().catch(() => null)) as { + data?: Array<{ id: string; label: string }> + error?: string + } | null + + if (!response.ok) { + throw new Error(result?.error || `Request failed with status ${response.status}`) + } + + return Array.isArray(result?.data) ? result.data.map(({ id, label }) => ({ id, label })) : [] +} + +async function fetchOpenCodeOptionById( + route: string, + optionId: string, + query: Record +): Promise<{ label: string; id: string } | null> { + if (!optionId) { + return null + } + + const options = await fetchOpenCodeOptions(route, query) + return options.find((option) => option.id === optionId) || null +} + +export const OpenCodeBlock: BlockConfig = { + type: 'opencode', + name: 'OpenCode', + description: 'Run a fixed-repository OpenCode expert inside a workflow.', + longDescription: + 'Use the internal OpenCode server from a workflow with a fixed repository, system prompt, provider, model, and optional agent preset. The workflow can then be deployed as MCP or A2A using the normal Workflow Deployment flow.', + docsLink: 'https://docs.sim.ai/tools/opencode', + category: 'tools', + bgColor: '#111827', + icon: OpenCodeIcon, + hideFromToolbar: !isOpenCodeEnabled, + subBlocks: [ + { + id: 'repository', + title: 'Repository', + type: 'dropdown', + options: [], + placeholder: 'Select a repository', + required: true, + fetchOptions: async () => fetchOpenCodeOptions('/api/opencode/repos', {}), + fetchOptionById: async (blockId, _subBlockId, optionId) => + fetchOpenCodeOptionById('/api/opencode/repos', optionId, {}), + }, + { + id: 'systemPrompt', + title: 'System Prompt', + type: 'long-input', + placeholder: 'Define the role, rules, and behaviour for this OpenCode agent', + rows: 8, + }, + { + id: 'providerId', + title: 'Model Provider', + type: 'dropdown', + options: [], + placeholder: 'Select a provider', + required: true, + dependsOn: ['repository'], + fetchOptions: async (blockId: string) => { + const values = await getOpenCodeBlockValues(blockId) + const repository = typeof values.repository === 'string' ? values.repository : undefined + return fetchOpenCodeOptions('/api/opencode/providers', { repository }) + }, + fetchOptionById: async (blockId, _subBlockId, optionId) => { + const values = await getOpenCodeBlockValues(blockId) + const repository = typeof values.repository === 'string' ? values.repository : undefined + return fetchOpenCodeOptionById('/api/opencode/providers', optionId, { + repository, + }) + }, + }, + { + id: 'modelId', + title: 'Model ID', + type: 'combobox', + options: [], + placeholder: 'Select a model', + required: true, + searchable: true, + dependsOn: ['repository', 'providerId'], + fetchOptions: async (blockId: string) => { + const values = await getOpenCodeBlockValues(blockId) + const repository = typeof values.repository === 'string' ? values.repository : undefined + const providerId = typeof values.providerId === 'string' ? values.providerId : undefined + + if (!providerId) { + return [] + } + + return fetchOpenCodeOptions('/api/opencode/models', { repository, providerId }) + }, + fetchOptionById: async (blockId, _subBlockId, optionId) => { + const values = await getOpenCodeBlockValues(blockId) + const repository = typeof values.repository === 'string' ? values.repository : undefined + const providerId = typeof values.providerId === 'string' ? values.providerId : undefined + + if (!providerId) { + return null + } + + return fetchOpenCodeOptionById('/api/opencode/models', optionId, { + repository, + providerId, + }) + }, + }, + { + id: 'agent', + title: 'Agent', + type: 'dropdown', + options: [], + placeholder: 'Optional OpenCode agent preset', + dependsOn: ['repository'], + fetchOptions: async (blockId: string) => { + const values = await getOpenCodeBlockValues(blockId) + const repository = typeof values.repository === 'string' ? values.repository : undefined + const agents = await fetchOpenCodeOptions('/api/opencode/agents', { repository }) + return [{ label: 'None', id: '' }, ...agents] + }, + fetchOptionById: async (blockId, _subBlockId, optionId) => { + if (!optionId) { + return { label: 'None', id: '' } + } + + const values = await getOpenCodeBlockValues(blockId) + const repository = typeof values.repository === 'string' ? values.repository : undefined + return fetchOpenCodeOptionById('/api/opencode/agents', optionId, { + repository, + }) + }, + }, + { + id: 'prompt', + title: 'Prompt', + type: 'long-input', + placeholder: 'Map this to the runtime input, e.g. ', + required: true, + rows: 5, + }, + { + id: 'newThreadToggle', + title: 'New Thread', + type: 'switch', + canonicalParamId: 'newThread', + mode: 'basic', + defaultValue: false, + description: 'Start a fresh OpenCode thread instead of reusing the caller thread.', + }, + { + id: 'newThreadExpression', + title: 'New Thread', + type: 'short-input', + canonicalParamId: 'newThread', + mode: 'advanced', + placeholder: 'false or ', + description: 'Boolean expression used at runtime to force a new thread.', + }, + ], + tools: { + access: ['opencode_get_messages', 'opencode_list_repos', 'opencode_prompt'], + config: { + tool: () => 'opencode_prompt', + params: (params) => ({ + repository: params.repository, + systemPrompt: getOptionalString(params.systemPrompt), + providerId: params.providerId, + modelId: params.modelId, + ...(getOptionalString(params.agent) ? { agent: getOptionalString(params.agent) } : {}), + prompt: params.prompt, + newThread: coerceOpenCodeBoolean(params.newThread), + }), + }, + }, + inputs: { + repository: { type: 'string', description: 'Repository selected for the workflow' }, + systemPrompt: { type: 'string', description: 'System prompt applied to the OpenCode agent' }, + providerId: { type: 'string', description: 'OpenCode provider identifier' }, + modelId: { type: 'string', description: 'OpenCode model identifier' }, + agent: { type: 'string', description: 'Optional OpenCode agent preset name' }, + prompt: { type: 'string', description: 'Runtime prompt sent by the caller' }, + newThread: { + type: 'boolean', + description: 'Whether to force creation of a new OpenCode thread for the caller', + }, + }, + outputs: { + content: { type: 'string', description: 'Assistant text returned by OpenCode' }, + threadId: { type: 'string', description: 'OpenCode thread identifier used for the call' }, + cost: { + type: 'number', + description: 'Estimated OpenCode cost for the assistant response', + }, + error: { type: 'string', description: 'Error message if the OpenCode call fails' }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 15363a8ad5..267c381ed1 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -126,6 +126,7 @@ import { OktaBlock } from '@/blocks/blocks/okta' import { OneDriveBlock } from '@/blocks/blocks/onedrive' import { OnePasswordBlock } from '@/blocks/blocks/onepassword' import { OpenAIBlock } from '@/blocks/blocks/openai' +import { OpenCodeBlock } from '@/blocks/blocks/opencode' import { OutlookBlock } from '@/blocks/blocks/outlook' import { PagerDutyBlock } from '@/blocks/blocks/pagerduty' import { ParallelBlock } from '@/blocks/blocks/parallel' @@ -347,6 +348,7 @@ export const registry: Record = { onepassword: OnePasswordBlock, onedrive: OneDriveBlock, openai: OpenAIBlock, + opencode: OpenCodeBlock, outlook: OutlookBlock, pagerduty: PagerDutyBlock, parallel_ai: ParallelBlock, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 0cf5773ca4..ba97767f27 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -56,6 +56,29 @@ export function AgentIcon(props: SVGProps) { ) } +export function OpenCodeIcon(props: SVGProps) { + return ( + + + + + + + + ) +} + export function ApiIcon(props: SVGProps) { return ( ({ + mockCreateOpencodeClient: vi.fn(), +})) + +vi.mock('@opencode-ai/sdk', () => ({ + createOpencodeClient: mockCreateOpencodeClient, +})) + +import { createOpenCodeClient, resetOpenCodeClientForTesting } from '@/lib/opencode/client' + +describe('createOpenCodeClient', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubEnv('OPENCODE_BASE_URL', 'http://localhost:4096') + vi.stubEnv('OPENCODE_SERVER_USERNAME', 'opencode') + vi.stubEnv('OPENCODE_SERVER_PASSWORD', 'password') + mockCreateOpencodeClient.mockReturnValue({ session: {} }) + resetOpenCodeClientForTesting() + }) + + afterEach(() => { + vi.unstubAllEnvs() + resetOpenCodeClientForTesting() + }) + + it('reuses the same client instance across calls', () => { + const firstClient = createOpenCodeClient() + const secondClient = createOpenCodeClient() + + expect(firstClient).toBe(secondClient) + expect(mockCreateOpencodeClient).toHaveBeenCalledTimes(1) + }) + + it('recreates the client after resetting the cache', () => { + const firstClient = { session: { id: 'first' } } + const secondClient = { session: { id: 'second' } } + mockCreateOpencodeClient.mockReturnValueOnce(firstClient).mockReturnValueOnce(secondClient) + + const initialClient = createOpenCodeClient() + resetOpenCodeClientForTesting() + const recreatedClient = createOpenCodeClient() + + expect(initialClient).toBe(firstClient) + expect(recreatedClient).toBe(secondClient) + expect(mockCreateOpencodeClient).toHaveBeenCalledTimes(2) + }) + + it('recreates the client when credentials change at runtime', () => { + const firstClient = { session: { id: 'first' } } + const secondClient = { session: { id: 'second' } } + mockCreateOpencodeClient.mockReturnValueOnce(firstClient).mockReturnValueOnce(secondClient) + + const initialClient = createOpenCodeClient() + vi.stubEnv('OPENCODE_SERVER_PASSWORD', 'rotated-password') + const refreshedClient = createOpenCodeClient() + + expect(initialClient).toBe(firstClient) + expect(refreshedClient).toBe(secondClient) + expect(mockCreateOpencodeClient).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/sim/lib/opencode/client.ts b/apps/sim/lib/opencode/client.ts new file mode 100644 index 0000000000..5e53b834cb --- /dev/null +++ b/apps/sim/lib/opencode/client.ts @@ -0,0 +1,59 @@ +import { existsSync } from 'node:fs' +import { createOpencodeClient } from '@opencode-ai/sdk' + +const OPEN_CODE_HOST = 'opencode' +const OPEN_CODE_LOCALHOST = '127.0.0.1' +const OPEN_CODE_DEFAULT_PORT = '4096' +const IS_DOCKER_RUNTIME = existsSync('/.dockerenv') +let cachedOpenCodeClient: ReturnType | null = null +let cachedOpenCodeClientKey: string | null = null + +function getOpenCodeBasicAuthHeader(): string { + const username = process.env.OPENCODE_SERVER_USERNAME + const password = process.env.OPENCODE_SERVER_PASSWORD + + if (!username || !password) { + throw new Error('OpenCode server credentials are not configured') + } + + return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}` +} + +function getOpenCodeBaseUrl(): string { + const explicitBaseUrl = process.env.OPENCODE_BASE_URL?.trim() + if (explicitBaseUrl) { + return explicitBaseUrl + } + + const port = process.env.OPENCODE_PORT || OPEN_CODE_DEFAULT_PORT + const host = IS_DOCKER_RUNTIME ? OPEN_CODE_HOST : OPEN_CODE_LOCALHOST + + return `http://${host}:${port}` +} + +function getOpenCodeClientKey(baseUrl: string, authorization: string): string { + return JSON.stringify({ baseUrl, authorization }) +} + +export function createOpenCodeClient() { + const baseUrl = getOpenCodeBaseUrl() + const authorization = getOpenCodeBasicAuthHeader() + const clientKey = getOpenCodeClientKey(baseUrl, authorization) + + if (!cachedOpenCodeClient || cachedOpenCodeClientKey !== clientKey) { + cachedOpenCodeClient = createOpencodeClient({ + baseUrl, + headers: { + Authorization: authorization, + }, + }) + cachedOpenCodeClientKey = clientKey + } + + return cachedOpenCodeClient +} + +export function resetOpenCodeClientForTesting(): void { + cachedOpenCodeClient = null + cachedOpenCodeClientKey = null +} diff --git a/apps/sim/lib/opencode/errors.test.ts b/apps/sim/lib/opencode/errors.test.ts new file mode 100644 index 0000000000..a7483b3a3d --- /dev/null +++ b/apps/sim/lib/opencode/errors.test.ts @@ -0,0 +1,35 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { getOpenCodeRouteError } from '@/lib/opencode/errors' + +describe('getOpenCodeRouteError', () => { + it('does not leak the internal OpenCode base URL in connectivity errors', () => { + const error = getOpenCodeRouteError( + new Error('fetch failed for http://opencode:4096/session'), + 'repositories' + ) + + expect(error).toEqual({ + status: 503, + message: + 'OpenCode server is unreachable. Check OPENCODE_BASE_URL and the runtime network configuration.', + }) + expect(error.message).not.toContain('http://opencode:4096') + }) + + it('prioritizes connectivity errors over auth substring matches', () => { + const error = getOpenCodeRouteError( + new Error('fetch failed for http://127.0.0.1:4013/session'), + 'repositories' + ) + + expect(error).toEqual({ + status: 503, + message: + 'OpenCode server is unreachable. Check OPENCODE_BASE_URL and the runtime network configuration.', + }) + }) +}) diff --git a/apps/sim/lib/opencode/errors.ts b/apps/sim/lib/opencode/errors.ts new file mode 100644 index 0000000000..88933078e6 --- /dev/null +++ b/apps/sim/lib/opencode/errors.ts @@ -0,0 +1,82 @@ +export interface OpenCodeRouteError { + status: number + message: string +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim()) { + return error.message + } + + if (typeof error === 'string' && error.trim()) { + return error + } + + if (error && typeof error === 'object' && 'message' in error) { + const message = error.message + if (typeof message === 'string' && message.trim()) { + return message + } + } + + return 'Unknown OpenCode error' +} + +function includesAny(haystack: string, needles: string[]): boolean { + return needles.some((needle) => haystack.includes(needle)) +} + +export function getOpenCodeRouteError(error: unknown, resourceName: string): OpenCodeRouteError { + const message = getErrorMessage(error) + const normalized = message.toLowerCase() + + if ( + includesAny(normalized, [ + 'repository is required', + 'unknown opencode repository', + 'providerid is required', + ]) + ) { + return { + status: 400, + message, + } + } + + if (normalized.includes('credentials are not configured')) { + return { + status: 500, + message: 'OpenCode credentials are not configured in the app environment.', + } + } + + if ( + includesAny(normalized, [ + 'econnrefused', + 'enotfound', + 'fetch failed', + 'socket hang up', + 'timed out', + 'timeout', + ]) + ) { + return { + status: 503, + message: + 'OpenCode server is unreachable. Check OPENCODE_BASE_URL and the runtime network configuration.', + } + } + + if (includesAny(normalized, ['401', '403', 'unauthorized', 'forbidden'])) { + return { + status: 502, + message: + 'OpenCode authentication failed. Align OPENCODE_SERVER_USERNAME and OPENCODE_SERVER_PASSWORD with the running OpenCode server.', + } + } + + return { + status: 500, + message: `Failed to fetch OpenCode ${resourceName}.`, + } +} diff --git a/apps/sim/lib/opencode/service.test.ts b/apps/sim/lib/opencode/service.test.ts new file mode 100644 index 0000000000..ea91b88fc2 --- /dev/null +++ b/apps/sim/lib/opencode/service.test.ts @@ -0,0 +1,167 @@ +/** + * @vitest-environment node + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCreateOpenCodeClient } = vi.hoisted(() => ({ + mockCreateOpenCodeClient: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: {}, +})) + +vi.mock('@sim/db/schema', () => ({ + memory: {}, +})) + +vi.mock('@sim/logger', () => ({ + createLogger: vi.fn().mockReturnValue({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + child: vi.fn(), + }), +})) + +vi.mock('drizzle-orm', () => ({ + and: vi.fn(), + eq: vi.fn(), + isNull: vi.fn(), +})) + +vi.mock('@/lib/opencode/client', () => ({ + createOpenCodeClient: mockCreateOpenCodeClient, +})) + +import { + listOpenCodeRepositories, + promptOpenCodeSession, + shouldRetryWithFreshOpenCodeSession, +} from '@/lib/opencode/service' + +beforeEach(() => { + vi.clearAllMocks() +}) + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe('shouldRetryWithFreshOpenCodeSession', () => { + it('returns true for stale-session errors', () => { + expect(shouldRetryWithFreshOpenCodeSession(new Error('404 session not found'))).toBe(true) + expect(shouldRetryWithFreshOpenCodeSession('session does not exist')).toBe(true) + expect(shouldRetryWithFreshOpenCodeSession('unknown session')).toBe(true) + }) + + it('returns false for unrelated session errors', () => { + expect(shouldRetryWithFreshOpenCodeSession(new Error('session limit exceeded'))).toBe(false) + expect(shouldRetryWithFreshOpenCodeSession('invalid session format')).toBe(false) + expect(shouldRetryWithFreshOpenCodeSession('model not found')).toBe(false) + expect(shouldRetryWithFreshOpenCodeSession('provider does not exist')).toBe(false) + }) + + it('does not crash for undefined, symbol, or function errors', () => { + expect(() => shouldRetryWithFreshOpenCodeSession(undefined)).not.toThrow() + expect(() => shouldRetryWithFreshOpenCodeSession(Symbol('session'))).not.toThrow() + expect(() => shouldRetryWithFreshOpenCodeSession(() => 'session')).not.toThrow() + expect(shouldRetryWithFreshOpenCodeSession(undefined)).toBe(false) + }) +}) + +describe('listOpenCodeRepositories', () => { + it('handles OPENCODE_REPOSITORY_ROOT set to / without double slashes', async () => { + vi.stubEnv('OPENCODE_REPOSITORY_ROOT', '/') + + mockCreateOpenCodeClient.mockReturnValue({ + project: { + list: vi.fn().mockResolvedValue({ + data: [{ id: 'project-1', worktree: '/repo-a' }], + }), + }, + }) + + await expect(listOpenCodeRepositories()).resolves.toEqual([ + { + id: 'repo-a', + label: 'repo-a', + directory: '/repo-a', + projectId: 'project-1', + }, + ]) + }) +}) + +describe('promptOpenCodeSession', () => { + it('reuses the provided repository option without resolving repositories again', async () => { + const mockSessionCreate = vi.fn().mockResolvedValue({ + data: { id: 'session-1' }, + }) + const mockSessionPrompt = vi.fn().mockResolvedValue({ + data: { + info: { + sessionID: 'session-1', + cost: 0.75, + providerID: 'provider-a', + modelID: 'model-a', + }, + parts: [{ type: 'text', text: 'OpenCode result' }], + }, + }) + + mockCreateOpenCodeClient.mockReturnValue({ + project: { + list: vi.fn(), + }, + session: { + create: mockSessionCreate, + prompt: mockSessionPrompt, + }, + }) + + const result = await promptOpenCodeSession({ + repository: 'repo-a', + repositoryOption: { + id: 'repo-a', + label: 'repo-a', + directory: '/app/repos/repo-a', + projectId: 'project-1', + }, + prompt: 'Explain the change', + providerId: 'provider-a', + modelId: 'model-a', + title: 'session-title', + }) + + expect(mockSessionCreate).toHaveBeenCalledWith({ + query: { directory: '/app/repos/repo-a' }, + body: { title: 'session-title' }, + throwOnError: true, + }) + expect(mockSessionPrompt).toHaveBeenCalledWith({ + path: { id: 'session-1' }, + query: { directory: '/app/repos/repo-a' }, + body: { + parts: [{ type: 'text', text: 'Explain the change' }], + model: { + providerID: 'provider-a', + modelID: 'model-a', + }, + }, + throwOnError: true, + }) + expect(result).toEqual({ + content: 'OpenCode result', + threadId: 'session-1', + cost: 0.75, + providerId: 'provider-a', + modelId: 'model-a', + assistantError: undefined, + }) + }) +}) diff --git a/apps/sim/lib/opencode/service.ts b/apps/sim/lib/opencode/service.ts new file mode 100644 index 0000000000..5d86977cdf --- /dev/null +++ b/apps/sim/lib/opencode/service.ts @@ -0,0 +1,551 @@ +import { randomUUID } from 'node:crypto' +import type { + Agent, + AssistantMessage, + Model, + Part, + Project, + Provider, + SessionPromptResponse, +} from '@opencode-ai/sdk' +import { db } from '@sim/db' +import { memory } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, isNull } from 'drizzle-orm' +import { createOpenCodeClient } from '@/lib/opencode/client' + +const logger = createLogger('OpenCodeService') +const DEFAULT_OPEN_CODE_REPOSITORY_ROOT = '/app/repos' + +export interface OpenCodeRepositoryOption { + id: string + label: string + directory: string + projectId: string +} + +export interface OpenCodeProviderOption { + id: string + label: string +} + +export interface OpenCodeModelOption { + id: string + label: string + providerId: string +} + +export interface OpenCodeAgentOption { + id: string + label: string + description?: string +} + +export interface OpenCodeStoredSession { + sessionId: string + repository: string + updatedAt: string +} + +export interface OpenCodePromptRequest { + repository: string + prompt: string + providerId: string + modelId: string + repositoryOption?: OpenCodeRepositoryOption + systemPrompt?: string + agent?: string + sessionId?: string + title?: string +} + +export interface OpenCodePromptResult { + content: string + threadId: string + cost?: number + providerId?: string + modelId?: string + assistantError?: string +} + +export interface OpenCodeMessageItem { + messageId: string + role: 'user' | 'assistant' + content: string + cost?: number + providerId?: string + modelId?: string + createdAt: number +} + +function getOpenCodeRepositoryRoot(): string { + const configuredRoot = process.env.OPENCODE_REPOSITORY_ROOT?.trim() + if (!configuredRoot) { + return DEFAULT_OPEN_CODE_REPOSITORY_ROOT + } + + if (configuredRoot === '/') { + return '' + } + + return configuredRoot.replace(/\/+$/, '') +} + +function stripGitSuffix(value: string): string { + return value.endsWith('.git') ? value.slice(0, -4) : value +} + +function parseConfiguredRepositoryName(repositoryUrl: string): string | null { + const trimmedUrl = repositoryUrl.trim() + if (!trimmedUrl) { + return null + } + + try { + const url = new URL(trimmedUrl) + const segments = url.pathname + .split('/') + .map((segment) => segment.trim()) + .filter(Boolean) + + if (segments.length === 0) { + return null + } + + return stripGitSuffix(segments[segments.length - 1]) + } catch (error) { + logger.warn('Failed to parse OpenCode repository URL from OPENCODE_REPOS', { + repositoryUrl: trimmedUrl, + error, + }) + return null + } +} + +function listConfiguredOpenCodeRepositoryNames(): string[] { + const configuredRepositories = process.env.OPENCODE_REPOS?.split(',') + .map((item) => item.trim()) + .filter(Boolean) + + if (!configuredRepositories || configuredRepositories.length === 0) { + return [] + } + + const uniqueRepositories = new Map() + + for (const repositoryUrl of configuredRepositories) { + const repositoryName = parseConfiguredRepositoryName(repositoryUrl) + if (!repositoryName) { + continue + } + + if (uniqueRepositories.has(repositoryName)) { + logger.warn('Duplicate OpenCode repository name in OPENCODE_REPOS', { + repositoryName, + repositoryUrl, + }) + continue + } + + uniqueRepositories.set(repositoryName, repositoryUrl) + } + + return Array.from(uniqueRepositories.keys()).sort((left, right) => left.localeCompare(right)) +} + +function getRepositoryName(repository: string): string { + const repositoryRoot = getOpenCodeRepositoryRoot() + + if (repository.startsWith(`${repositoryRoot}/`)) { + return repository.slice(repositoryRoot.length + 1) + } + + return repository +} + +function buildOpenCodeRepositoryDirectory(repository: string): string { + return `${getOpenCodeRepositoryRoot()}/${repository}` +} + +function isProjectInsideRepositoryRoot(project: Project): boolean { + return project.worktree.startsWith(`${getOpenCodeRepositoryRoot()}/`) +} + +function mapProjectToRepositoryOption(project: Project): OpenCodeRepositoryOption { + const repository = getRepositoryName(project.worktree) + + return { + id: repository, + label: repository, + directory: buildOpenCodeRepositoryDirectory(repository), + projectId: project.id, + } +} + +function mapProviderToOption(provider: Provider): OpenCodeProviderOption { + return { + id: provider.id, + label: provider.name, + } +} + +function mapModelToOption(providerId: string, model: Model): OpenCodeModelOption { + return { + id: model.id, + label: model.name || model.id, + providerId, + } +} + +function mapAgentToOption(agent: Agent): OpenCodeAgentOption { + return { + id: agent.name, + label: agent.name, + description: agent.description, + } +} + +function getAssistantErrorMessage(error: AssistantMessage['error']): string | undefined { + if (!error) { + return undefined + } + + if ('data' in error && error.data && typeof error.data === 'object' && 'message' in error.data) { + const message = error.data.message + if (typeof message === 'string' && message.trim()) { + return message + } + } + + return error.name +} + +function extractOpenCodeText(parts: Part[]): string { + return parts + .filter((part): part is Extract => part.type === 'text') + .map((part) => part.text.trim()) + .filter(Boolean) + .join('\n\n') +} + +function mapPromptResponseToResult(response: SessionPromptResponse): OpenCodePromptResult { + return { + content: extractOpenCodeText(response.parts), + threadId: response.info.sessionID, + cost: response.info.cost, + providerId: response.info.providerID, + modelId: response.info.modelID, + assistantError: getAssistantErrorMessage(response.info.error), + } +} + +export async function listOpenCodeRepositories(): Promise { + const client = createOpenCodeClient() + const projectResult = await client.project.list({ throwOnError: true }) + const projects = projectResult.data + const configuredRepositories = listConfiguredOpenCodeRepositoryNames() + + if (configuredRepositories.length > 0) { + const projectsByDirectory = new Map( + projects + .filter(isProjectInsideRepositoryRoot) + .map((project) => [project.worktree, project] as const) + ) + + return configuredRepositories.map((repository) => { + const directory = buildOpenCodeRepositoryDirectory(repository) + const project = projectsByDirectory.get(directory) + + return { + id: repository, + label: repository, + directory, + projectId: project?.id || `configured:${repository}`, + } + }) + } + + const repositories = projects + .filter(isProjectInsideRepositoryRoot) + .map(mapProjectToRepositoryOption) + .sort((left, right) => left.label.localeCompare(right.label)) + + const uniqueRepositories = new Map() + for (const repository of repositories) { + uniqueRepositories.set(repository.id, repository) + } + + return Array.from(uniqueRepositories.values()) +} + +export async function resolveOpenCodeRepositoryOption( + repository: string +): Promise { + const normalizedRepository = repository.trim() + if (!normalizedRepository) { + throw new Error('repository is required') + } + + const repositories = await listOpenCodeRepositories() + const repositoryOption = repositories.find((item) => item.id === normalizedRepository) + + if (!repositoryOption) { + throw new Error(`Unknown OpenCode repository: ${normalizedRepository}`) + } + + return repositoryOption +} + +async function getOpenCodeRepositoryOption( + repository: string | OpenCodeRepositoryOption +): Promise { + if (typeof repository === 'string') { + return resolveOpenCodeRepositoryOption(repository) + } + + return repository +} + +export async function listOpenCodeProviders( + repository?: string +): Promise { + const client = createOpenCodeClient() + const directory = repository + ? (await resolveOpenCodeRepositoryOption(repository)).directory + : undefined + const configResult = await client.config.providers({ + query: directory ? { directory } : undefined, + throwOnError: true, + }) + const providers = configResult.data.providers + + return providers + .map((provider) => mapProviderToOption(provider)) + .sort((left, right) => left.label.localeCompare(right.label)) +} + +export async function listOpenCodeModels( + providerId: string, + repository?: string +): Promise { + const client = createOpenCodeClient() + const directory = repository + ? (await resolveOpenCodeRepositoryOption(repository)).directory + : undefined + const configResult = await client.config.providers({ + query: directory ? { directory } : undefined, + throwOnError: true, + }) + const providers = configResult.data.providers + + const provider = providers.find((item) => item.id === providerId) + if (!provider) { + return [] + } + + return Object.values(provider.models) + .map((model) => mapModelToOption(providerId, model)) + .sort((left, right) => left.label.localeCompare(right.label)) +} + +export async function listOpenCodeAgents(repository?: string): Promise { + const client = createOpenCodeClient() + const directory = repository + ? (await resolveOpenCodeRepositoryOption(repository)).directory + : undefined + const agentResult = await client.app.agents({ + query: directory ? { directory } : undefined, + throwOnError: true, + }) + const agents = agentResult.data + + return agents.map(mapAgentToOption).sort((left, right) => left.label.localeCompare(right.label)) +} + +export async function createOpenCodeSession( + repository: string | OpenCodeRepositoryOption, + title?: string +): Promise<{ id: string }> { + const client = createOpenCodeClient() + const repositoryOption = await getOpenCodeRepositoryOption(repository) + const sessionResult = await client.session.create({ + query: { directory: repositoryOption.directory }, + body: title ? { title } : undefined, + throwOnError: true, + }) + + return { id: sessionResult.data.id } +} + +export async function promptOpenCodeSession( + request: OpenCodePromptRequest +): Promise { + const client = createOpenCodeClient() + const repositoryOption = + request.repositoryOption || (await resolveOpenCodeRepositoryOption(request.repository)) + const directory = repositoryOption.directory + const sessionId = + request.sessionId || (await createOpenCodeSession(repositoryOption, request.title)).id + + const response = await client.session.prompt({ + path: { id: sessionId }, + query: { directory }, + body: { + parts: [{ type: 'text', text: request.prompt }], + ...(request.systemPrompt ? { system: request.systemPrompt } : {}), + ...(request.agent ? { agent: request.agent } : {}), + model: { + providerID: request.providerId, + modelID: request.modelId, + }, + }, + throwOnError: true, + }) + + return mapPromptResponseToResult(response.data) +} + +export async function getOpenCodeMessages( + repository: string, + sessionId: string +): Promise { + const client = createOpenCodeClient() + const repositoryOption = await resolveOpenCodeRepositoryOption(repository) + const response = await client.session.messages({ + path: { id: sessionId }, + query: { directory: repositoryOption.directory }, + throwOnError: true, + }) + + return response.data.map((message) => { + const baseItem = { + messageId: message.info.id, + role: message.info.role, + content: extractOpenCodeText(message.parts), + createdAt: message.info.time.created, + } + + if (message.info.role === 'assistant') { + return { + ...baseItem, + cost: message.info.cost, + providerId: message.info.providerID, + modelId: message.info.modelID, + } + } + + return baseItem + }) +} + +export async function getStoredOpenCodeSession( + workspaceId: string, + key: string +): Promise { + const result = await db + .select({ data: memory.data }) + .from(memory) + .where(and(eq(memory.workspaceId, workspaceId), eq(memory.key, key), isNull(memory.deletedAt))) + .limit(1) + + if (result.length === 0) { + return null + } + + const data = result[0].data + if (!data || typeof data !== 'object' || Array.isArray(data)) { + return null + } + + const sessionId = 'sessionId' in data ? data.sessionId : undefined + const repository = 'repository' in data ? data.repository : undefined + const updatedAt = 'updatedAt' in data ? data.updatedAt : undefined + + if ( + typeof sessionId !== 'string' || + typeof repository !== 'string' || + typeof updatedAt !== 'string' + ) { + return null + } + + return { sessionId, repository, updatedAt } +} + +export async function storeOpenCodeSession( + workspaceId: string, + key: string, + value: OpenCodeStoredSession +): Promise { + const now = new Date() + + await db + .insert(memory) + .values({ + id: randomUUID(), + workspaceId, + key, + data: value, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [memory.workspaceId, memory.key], + set: { + data: value, + updatedAt: now, + deletedAt: null, + }, + }) +} + +export function buildOpenCodeSessionMemoryKey(workflowId: string, userKey: string): string { + return `opencode:session:${workflowId}:${userKey}` +} + +export function buildOpenCodeSessionTitle(repository: string, userKey: string): string { + return `SIMAI ${getRepositoryName(repository)} ${userKey}` +} + +export function shouldRetryWithFreshOpenCodeSession(error: unknown): boolean { + const message = getOpenCodeRetryErrorMessage(error) + + const normalized = message.toLowerCase() + const staleSessionPatterns = [ + 'session not found', + 'session does not exist', + 'session was not found', + 'session no longer exists', + 'unknown session', + 'invalid session id', + ] + + if (staleSessionPatterns.some((pattern) => normalized.includes(pattern))) { + return true + } + + return normalized.includes('404') && normalized.includes('session') +} + +function getOpenCodeRetryErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message + } + + if (typeof error === 'string') { + return error + } + + try { + const serialized = JSON.stringify(error) + if (typeof serialized === 'string') { + return serialized + } + } catch {} + + return String(error ?? '') +} + +export async function logOpenCodeFailure(message: string, error: unknown): Promise { + logger.error(message, { error }) +} diff --git a/apps/sim/lib/opencode/utils.test.ts b/apps/sim/lib/opencode/utils.test.ts new file mode 100644 index 0000000000..e76fd26b0d --- /dev/null +++ b/apps/sim/lib/opencode/utils.test.ts @@ -0,0 +1,18 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { coerceOpenCodeBoolean } from '@/lib/opencode/utils' + +describe('coerceOpenCodeBoolean', () => { + it('coerces booleans and string booleans consistently', () => { + expect(coerceOpenCodeBoolean(true)).toBe(true) + expect(coerceOpenCodeBoolean(false)).toBe(false) + expect(coerceOpenCodeBoolean('true')).toBe(true) + expect(coerceOpenCodeBoolean('TRUE')).toBe(true) + expect(coerceOpenCodeBoolean('false')).toBe(false) + expect(coerceOpenCodeBoolean(undefined)).toBe(false) + expect(coerceOpenCodeBoolean(null)).toBe(false) + }) +}) diff --git a/apps/sim/lib/opencode/utils.ts b/apps/sim/lib/opencode/utils.ts new file mode 100644 index 0000000000..46cb520719 --- /dev/null +++ b/apps/sim/lib/opencode/utils.ts @@ -0,0 +1,11 @@ +export function coerceOpenCodeBoolean(value: unknown): boolean { + if (typeof value === 'boolean') { + return value + } + + if (typeof value === 'string') { + return value.toLowerCase() === 'true' + } + + return false +} diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 139dd97977..28db8ee737 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -1,3 +1,4 @@ +import path from 'path' import type { NextConfig } from 'next' import { env, getEnv, isTruthy } from './lib/core/config/env' import { isDev } from './lib/core/config/feature-flags' @@ -8,7 +9,19 @@ import { getWorkflowExecutionCSPPolicy, } from './lib/core/security/csp' +const OPEN_CODE_SDK_DIST_ABSOLUTE = path.resolve( + __dirname, + '../../node_modules/@opencode-ai/sdk/dist/index.js' +) +const OPEN_CODE_SDK_DIST_PROJECT_RELATIVE = '../../node_modules/@opencode-ai/sdk/dist/index.js' + const nextConfig: NextConfig = { + webpack: (config) => { + config.resolve = config.resolve || {} + config.resolve.alias = config.resolve.alias || {} + config.resolve.alias['@opencode-ai/sdk'] = OPEN_CODE_SDK_DIST_ABSOLUTE + return config + }, devIndicators: false, images: { remotePatterns: [ @@ -76,6 +89,9 @@ const nextConfig: NextConfig = { output: isTruthy(env.DOCKER_BUILD) ? 'standalone' : undefined, turbopack: { resolveExtensions: ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.json'], + resolveAlias: { + '@opencode-ai/sdk': OPEN_CODE_SDK_DIST_PROJECT_RELATIVE, + }, }, serverExternalPackages: [ '@1password/sdk', diff --git a/apps/sim/package.json b/apps/sim/package.json index c588585b3d..98dc028ca3 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -12,6 +12,7 @@ "dev:webpack": "next dev --webpack", "dev:sockets": "bun run socket/index.ts", "dev:full": "bunx concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"bun run dev\" \"bun run dev:sockets\"", + "dev:full:webpack": "bunx concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"bun run dev:webpack\" \"bun run dev:sockets\"", "build": "bun run build:pptx-worker && next build", "build:pptx-worker": "bun build ./lib/execution/pptx-worker.cjs --target=node --format=cjs --outfile ./dist/pptx-worker.cjs", "start": "next start", @@ -50,6 +51,7 @@ "@linear/sdk": "40.0.0", "@marsidev/react-turnstile": "1.4.2", "@modelcontextprotocol/sdk": "1.20.2", + "@opencode-ai/sdk": "0.8.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-jaeger": "2.1.0", "@opentelemetry/exporter-trace-otlp-http": "^0.200.0", diff --git a/apps/sim/tools/opencode/get_messages.ts b/apps/sim/tools/opencode/get_messages.ts new file mode 100644 index 0000000000..7e67907851 --- /dev/null +++ b/apps/sim/tools/opencode/get_messages.ts @@ -0,0 +1,60 @@ +import type { OpenCodeGetMessagesParams, OpenCodeGetMessagesResponse } from '@/tools/opencode/types' +import type { ToolConfig } from '@/tools/types' + +export const openCodeGetMessagesTool: ToolConfig< + OpenCodeGetMessagesParams, + OpenCodeGetMessagesResponse +> = { + id: 'opencode_get_messages', + name: 'OpenCode Get Messages', + description: 'Retrieve the current message history for an OpenCode thread.', + version: '1.0.0', + + params: { + repository: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Repository configured for the OpenCode session.', + }, + threadId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'OpenCode thread ID to inspect.', + }, + }, + + request: { + url: '/api/tools/opencode/messages', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + repository: params.repository, + threadId: params.threadId, + }), + }, + + outputs: { + threadId: { type: 'string', description: 'OpenCode thread identifier' }, + messages: { + type: 'array', + description: 'Messages currently stored in the OpenCode thread.', + items: { + type: 'object', + properties: { + messageId: { type: 'string', description: 'Message identifier' }, + role: { type: 'string', description: 'Message role' }, + content: { type: 'string', description: 'Extracted text content' }, + cost: { type: 'number', description: 'Estimated cost for assistant messages' }, + providerId: { type: 'string', description: 'Provider used for assistant messages' }, + modelId: { type: 'string', description: 'Model used for assistant messages' }, + createdAt: { type: 'number', description: 'Unix timestamp in milliseconds' }, + }, + }, + }, + count: { type: 'number', description: 'Number of messages returned' }, + }, +} diff --git a/apps/sim/tools/opencode/index.ts b/apps/sim/tools/opencode/index.ts new file mode 100644 index 0000000000..eefa1c166b --- /dev/null +++ b/apps/sim/tools/opencode/index.ts @@ -0,0 +1,5 @@ +import { openCodeGetMessagesTool } from '@/tools/opencode/get_messages' +import { openCodeListReposTool } from '@/tools/opencode/list_repos' +import { openCodePromptTool } from '@/tools/opencode/prompt' + +export { openCodeGetMessagesTool, openCodeListReposTool, openCodePromptTool } diff --git a/apps/sim/tools/opencode/list_repos.ts b/apps/sim/tools/opencode/list_repos.ts new file mode 100644 index 0000000000..bc75652aed --- /dev/null +++ b/apps/sim/tools/opencode/list_repos.ts @@ -0,0 +1,41 @@ +import type { OpenCodeListReposResponse } from '@/tools/opencode/types' +import type { ToolConfig } from '@/tools/types' + +export const openCodeListReposTool: ToolConfig, OpenCodeListReposResponse> = { + id: 'opencode_list_repos', + name: 'OpenCode List Repositories', + description: 'List the repositories currently available in the internal OpenCode server.', + version: '1.0.0', + + params: {}, + + request: { + url: '/api/tools/opencode/repos', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: () => ({}), + }, + + outputs: { + repositories: { + type: 'array', + description: 'Repositories available in OpenCode.', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Repository identifier' }, + label: { type: 'string', description: 'Repository label' }, + directory: { type: 'string', description: 'Absolute directory mounted in OpenCode' }, + projectId: { + type: 'string', + description: + 'OpenCode project identifier when registered, otherwise a configured fallback', + }, + }, + }, + }, + count: { type: 'number', description: 'Number of repositories returned' }, + }, +} diff --git a/apps/sim/tools/opencode/prompt.ts b/apps/sim/tools/opencode/prompt.ts new file mode 100644 index 0000000000..c5614403cd --- /dev/null +++ b/apps/sim/tools/opencode/prompt.ts @@ -0,0 +1,89 @@ +import type { OpenCodePromptParams, OpenCodePromptResponse } from '@/tools/opencode/types' +import type { ToolConfig } from '@/tools/types' + +export const openCodePromptTool: ToolConfig = { + id: 'opencode_prompt', + name: 'OpenCode Prompt', + description: + 'Create or continue an OpenCode thread for the authenticated workflow caller and send a prompt.', + version: '1.0.0', + + params: { + repository: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Repository configured for this workflow.', + }, + systemPrompt: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'System prompt used for the OpenCode assistant.', + }, + providerId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'LLM provider identifier configured in OpenCode.', + }, + modelId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Model identifier configured in OpenCode.', + }, + agent: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Optional OpenCode agent preset name.', + }, + prompt: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Prompt to send to the configured OpenCode assistant.', + }, + newThread: { + type: 'boolean', + required: false, + default: false, + visibility: 'user-or-llm', + description: 'Create a new thread instead of reusing the current caller thread.', + }, + }, + + request: { + url: '/api/tools/opencode/prompt', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + repository: params.repository, + systemPrompt: params.systemPrompt, + providerId: params.providerId, + modelId: params.modelId, + agent: params.agent, + prompt: params.prompt, + newThread: params.newThread, + _context: params._context, + }), + }, + + outputs: { + content: { type: 'string', description: 'Assistant text returned by OpenCode' }, + threadId: { type: 'string', description: 'OpenCode thread identifier used for this response' }, + cost: { + type: 'number', + description: 'Estimated cost returned by OpenCode for the assistant message', + optional: true, + }, + error: { + type: 'string', + description: 'Error message if the OpenCode request failed', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/opencode/types.ts b/apps/sim/tools/opencode/types.ts new file mode 100644 index 0000000000..20193ce1dd --- /dev/null +++ b/apps/sim/tools/opencode/types.ts @@ -0,0 +1,58 @@ +import type { ToolResponse, WorkflowToolExecutionContext } from '@/tools/types' + +export interface OpenCodePromptParams { + repository: string + prompt: string + providerId: string + modelId: string + systemPrompt?: string + agent?: string + newThread?: boolean | string + _context?: WorkflowToolExecutionContext +} + +export interface OpenCodeRepositoryItem { + id: string + label: string + directory: string + projectId: string +} + +export interface OpenCodeListReposResponse extends ToolResponse { + output: { + repositories: OpenCodeRepositoryItem[] + count: number + } +} + +export interface OpenCodePromptResponse extends ToolResponse { + output: { + content: string + threadId: string + cost?: number + error?: string + } +} + +export interface OpenCodeGetMessagesParams { + repository: string + threadId: string +} + +export interface OpenCodeMessage { + messageId: string + role: 'user' | 'assistant' + content: string + cost?: number + providerId?: string + modelId?: string + createdAt: number +} + +export interface OpenCodeGetMessagesResponse extends ToolResponse { + output: { + threadId: string + messages: OpenCodeMessage[] + count: number + } +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 99efb6dcbb..f9fcafb540 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1640,6 +1640,11 @@ import { onepasswordUpdateItemTool, } from '@/tools/onepassword' import { openAIEmbeddingsTool, openAIImageTool } from '@/tools/openai' +import { + openCodeGetMessagesTool, + openCodeListReposTool, + openCodePromptTool, +} from '@/tools/opencode' import { outlookCopyTool, outlookDeleteTool, @@ -4061,6 +4066,9 @@ export const tools: Record = { microsoft_ad_list_group_members: microsoftAdListGroupMembersTool, microsoft_ad_add_group_member: microsoftAdAddGroupMemberTool, microsoft_ad_remove_group_member: microsoftAdRemoveGroupMemberTool, + opencode_get_messages: openCodeGetMessagesTool, + opencode_list_repos: openCodeListReposTool, + opencode_prompt: openCodePromptTool, microsoft_teams_read_chat: microsoftTeamsReadChatTool, microsoft_teams_write_chat: microsoftTeamsWriteChatTool, microsoft_teams_read_channel: microsoftTeamsReadChannelTool, diff --git a/apps/sim/vitest.config.ts b/apps/sim/vitest.config.ts index f5aec399e8..8395f5c982 100644 --- a/apps/sim/vitest.config.ts +++ b/apps/sim/vitest.config.ts @@ -47,6 +47,10 @@ export default defineConfig({ find: '@sim/logger', replacement: path.resolve(__dirname, '../../packages/logger/src'), }, + { + find: '@opencode-ai/sdk', + replacement: path.resolve(__dirname, '../../node_modules/@opencode-ai/sdk/dist/index.js'), + }, { find: '@/stores/console/store', replacement: path.resolve(__dirname, 'stores/console/store.ts'), diff --git a/bun.lock b/bun.lock index ed31b1d954..6976e77847 100644 --- a/bun.lock +++ b/bun.lock @@ -75,6 +75,7 @@ "@linear/sdk": "40.0.0", "@marsidev/react-turnstile": "1.4.2", "@modelcontextprotocol/sdk": "1.20.2", + "@opencode-ai/sdk": "0.8.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-jaeger": "2.1.0", "@opentelemetry/exporter-trace-otlp-http": "^0.200.0", @@ -730,6 +731,10 @@ "@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="], + "@hey-api/json-schema-ref-parser": ["@hey-api/json-schema-ref-parser@1.0.6", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.0", "lodash": "^4.17.21" } }, "sha512-yktiFZoWPtEW8QKS65eqKwA5MTKp88CyiL8q72WynrBs/73SAaxlSWlA2zW/DZlywZ5hX1OYzrCC0wFdvO9c2w=="], + + "@hey-api/openapi-ts": ["@hey-api/openapi-ts@0.81.0", "", { "dependencies": { "@hey-api/json-schema-ref-parser": "1.0.6", "ansi-colors": "4.1.3", "c12": "2.0.1", "color-support": "1.1.3", "commander": "13.0.0", "handlebars": "4.7.8", "js-yaml": "4.1.0", "open": "10.1.2", "semver": "7.7.2" }, "peerDependencies": { "typescript": "^5.5.3" }, "bin": { "openapi-ts": "bin/index.cjs" } }, "sha512-PoJukNBkUfHOoMDpN33bBETX49TUhy7Hu8Sa0jslOvFndvZ5VjQr4Nl/Dzjb9LG1Lp5HjybyTJMA6a1zYk/q6A=="], + "@hookform/resolvers": ["@hookform/resolvers@4.1.3", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.0.0" } }, "sha512-Jsv6UOWYTrEFJ/01ZrnwVXs7KDvP8XIo115i++5PWvNkNvkrsTfGiLS6w+eJ57CYtUtDQalUWovCZDHFJ8u1VQ=="], "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], @@ -804,6 +809,8 @@ "@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="], + "@jsdevtools/ono": ["@jsdevtools/ono@7.1.3", "", {}, "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="], + "@jsonhero/path": ["@jsonhero/path@1.0.21", "", {}, "sha512-gVUDj/92acpVoJwsVJ/RuWOaHyG4oFzn898WNGQItLCTQ+hOaVlEaImhwE1WqOTf+l3dGOUkbSiVKlb3q1hd1Q=="], "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], @@ -904,6 +911,8 @@ "@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@0.8.0", "", { "dependencies": { "@hey-api/openapi-ts": "0.81.0" } }, "sha512-MekFgqYcsdNyX2mNq12faigEq41hVn5jDhq9YL5QlatUzhd8g7iQY192bHi9Kml0e0qdGWfbDx6qjLlYsy4EfQ=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.200.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IKJBQxh91qJ+3ssRly5hYEJ8NDHu9oY/B1PXVSCWf7zytmYO9RNLB0Ox9XQ/fJ8m6gY6Q6NtBWlmXfaXt5Uc4Q=="], @@ -1696,6 +1705,8 @@ "ansi-color": ["ansi-color@0.2.2", "", {}, "sha512-qPx7iZZDHITYrrfzaUFXQpIcF2xYifcQHQflP1pFz8yY3lfU6GgCHb0+hJD7nimYKO7f2iaYYwBpZ+GaNcAhcA=="], + "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], + "ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -1828,6 +1839,8 @@ "buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="], + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], "c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="], @@ -1922,6 +1935,8 @@ "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], + "color-support": ["color-support@1.1.3", "", { "bin": { "color-support": "bin.js" } }, "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg=="], + "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], @@ -2048,8 +2063,14 @@ "deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="], + "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], + + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], + "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="], + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], "degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="], @@ -2316,6 +2337,8 @@ "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + "fs-minipass": ["fs-minipass@2.1.0", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "fumadocs-core": ["fumadocs-core@16.6.7", "", { "dependencies": { "@formatjs/intl-localematcher": "^0.8.1", "@orama/orama": "^3.1.18", "@shikijs/rehype": "^3.23.0", "@shikijs/transformers": "^3.23.0", "estree-util-value-to-estree": "^3.5.0", "github-slugger": "^2.0.0", "hast-util-to-estree": "^3.1.3", "hast-util-to-jsx-runtime": "^2.3.6", "image-size": "^2.0.2", "mdast-util-mdx": "^3.0.0", "mdast-util-to-markdown": "^2.1.2", "negotiator": "^1.0.0", "npm-to-yarn": "^3.0.1", "path-to-regexp": "^8.3.0", "remark": "^15.0.1", "remark-gfm": "^4.0.1", "remark-rehype": "^11.1.2", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^3.23.0", "tinyglobby": "^0.2.15", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3" }, "peerDependencies": { "@mdx-js/mdx": "*", "@mixedbread/sdk": "^0.46.0", "@orama/core": "1.x.x", "@oramacloud/client": "2.x.x", "@tanstack/react-router": "1.x.x", "@types/estree-jsx": "*", "@types/hast": "*", "@types/mdast": "*", "@types/react": "*", "algoliasearch": "5.x.x", "lucide-react": "*", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router": "7.x.x", "waku": "^0.26.0 || ^0.27.0 || ^1.0.0", "zod": "4.x.x" }, "optionalPeers": ["@mdx-js/mdx", "@mixedbread/sdk", "@orama/core", "@oramacloud/client", "@tanstack/react-router", "@types/estree-jsx", "@types/hast", "@types/mdast", "@types/react", "algoliasearch", "lucide-react", "next", "react", "react-dom", "react-router", "waku", "zod"] }, "sha512-Re68KbSJkjrZP3zhWqdWfd8Oo1/3H5ql3FOa+lCJkjJSDsrPbeCqvQ7zVaWrnZ4j5BnIvRtKDrDEPXfDqSNOqA=="], @@ -2380,6 +2403,8 @@ "gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="], + "handlebars": ["handlebars@4.7.8", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], @@ -2506,6 +2531,8 @@ "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], "is-lite": ["is-lite@1.2.1", "", {}, "sha512-pgF+L5bxC+10hLBgf6R2P4ZZUBOQIIacbdo8YvuCP8/JvsWxG7aZ9p10DYuLtifFci4l3VITphhMlMV4Y+urPw=="], @@ -2862,6 +2889,8 @@ "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], @@ -2904,6 +2933,8 @@ "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], + "neo4j-driver": ["neo4j-driver@6.0.1", "", { "dependencies": { "neo4j-driver-bolt-connection": "6.0.1", "neo4j-driver-core": "6.0.1", "rxjs": "^7.8.2" } }, "sha512-8DDF2MwEJNz7y7cp97x4u8fmVIP4CWS8qNBxdwxTG0fWtsS+2NdeC+7uXwmmuFOpHvkfXqv63uWY73bfDtOH8Q=="], "neo4j-driver-bolt-connection": ["neo4j-driver-bolt-connection@6.0.1", "", { "dependencies": { "buffer": "^6.0.3", "neo4j-driver-core": "6.0.1", "string_decoder": "^1.3.0" } }, "sha512-1KyG73TO+CwnYJisdHD0sjUw9yR+P5q3JFcmVPzsHT4/whzCjuXSMpmY4jZcHH2PdY2cBUq4l/6WcDiPMxW2UA=="], @@ -2988,6 +3019,8 @@ "oniguruma-to-es": ["oniguruma-to-es@4.3.5", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ=="], + "open": ["open@10.1.2", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "is-wsl": "^3.1.0" } }, "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw=="], + "openai": ["openai@4.104.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA=="], "openapi-fetch": ["openapi-fetch@0.14.1", "", { "dependencies": { "openapi-typescript-helpers": "^0.0.15" } }, "sha512-l7RarRHxlEZYjMLd/PR0slfMVse2/vvIAGm75/F7J6MlQ8/b9uUQmUF2kCPrQhJqMXSxmYWObVgeYXbFYzZR+A=="], @@ -3306,6 +3339,8 @@ "rss-parser": ["rss-parser@3.13.0", "", { "dependencies": { "entities": "^2.0.3", "xml2js": "^0.5.0" } }, "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w=="], + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], + "run-async": ["run-async@2.4.1", "", {}, "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ=="], "run-exclusive": ["run-exclusive@2.2.19", "", { "dependencies": { "minimal-polyfills": "^2.2.3" } }, "sha512-K3mdoAi7tjJ/qT7Flj90L7QyPozwUaAG+CVhkdDje4HLKXUYC3N/Jzkau3flHVDLQVhiHBtcimVodMjN9egYbA=="], @@ -3612,6 +3647,8 @@ "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], + "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], + "uid2": ["uid2@1.0.0", "", {}, "sha512-+I6aJUv63YAcY9n4mQreLUt0d4lvwkkopDNmpomkAUz0fAkEMV9pRWxN0EjhW1YfRhcuyHg2v3mwddCDW1+LFQ=="], "ulid": ["ulid@2.4.0", "", { "bin": { "ulid": "bin/cli.js" } }, "sha512-fIRiVTJNcSRmXKPZtGzFQv9WRrZ3M9eoptl/teFJvjOzmpU+/K/JH6HZ8deBfb5vMEpicJcLn7JmvdknlMq7Zg=="], @@ -3724,6 +3761,8 @@ "word": ["word@0.3.0", "", {}, "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA=="], + "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], + "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -3920,6 +3959,14 @@ "@fumari/json-schema-to-typescript/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "@hey-api/json-schema-ref-parser/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "@hey-api/openapi-ts/c12": ["c12@2.0.1", "", { "dependencies": { "chokidar": "^4.0.1", "confbox": "^0.1.7", "defu": "^6.1.4", "dotenv": "^16.4.5", "giget": "^1.2.3", "jiti": "^2.3.0", "mlly": "^1.7.1", "ohash": "^1.1.4", "pathe": "^1.1.2", "perfect-debounce": "^1.0.0", "pkg-types": "^1.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-Z4JgsKXHG37C6PYUtIxCfLJZvo6FyhHJoClwwb9ftUkLpPSkuYqn6Tr+vnaN8hymm0kIbcg6Ey3kv/Q71k5w/A=="], + + "@hey-api/openapi-ts/commander": ["commander@13.0.0", "", {}, "sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ=="], + + "@hey-api/openapi-ts/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "@inquirer/external-editor/iconv-lite": ["iconv-lite@0.7.1", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw=="], "@langchain/core/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], @@ -4216,6 +4263,8 @@ "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "fumadocs-core/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], "fumadocs-mdx/esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="], @@ -4244,6 +4293,8 @@ "groq-sdk/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "handlebars/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "hexer/process": ["process@0.10.1", "", {}, "sha512-dyIett8dgGIZ/TXKUzeYExt7WA6ldDzys9vTDU/cCA9L17Ypme+KzS+NjQCjpn9xsvi/shbMC+yP/BcFMBz0NA=="], "htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], @@ -4260,6 +4311,8 @@ "inquirer/ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="], + "is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + "isomorphic-unfetch/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], "istanbul-lib-report/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -4312,6 +4365,8 @@ "ollama-ai-provider-v2/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "open/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + "openai/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], "openai/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], @@ -4566,6 +4621,16 @@ "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + "@hey-api/openapi-ts/c12/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "@hey-api/openapi-ts/c12/giget": ["giget@1.2.5", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.5.4", "pathe": "^2.0.3", "tar": "^6.2.1" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug=="], + + "@hey-api/openapi-ts/c12/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "@hey-api/openapi-ts/c12/ohash": ["ohash@1.1.6", "", {}, "sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg=="], + + "@hey-api/openapi-ts/c12/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], + "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], @@ -4672,6 +4737,8 @@ "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "fs-minipass/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "fumadocs-core/shiki/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], "fumadocs-core/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="], @@ -5060,6 +5127,14 @@ "@cerebras/cerebras_cloud_sdk/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "@hey-api/openapi-ts/c12/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "@hey-api/openapi-ts/c12/giget/nypm": ["nypm@0.5.4", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "tinyexec": "^0.3.2", "ufo": "^1.5.4" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA=="], + + "@hey-api/openapi-ts/c12/giget/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "@hey-api/openapi-ts/c12/giget/tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="], + "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.0.1", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g=="], "@trigger.dev/core/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.0.1", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g=="], @@ -5150,6 +5225,14 @@ "@browserbasehq/stagehand/@anthropic-ai/sdk/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "@hey-api/openapi-ts/c12/giget/tar/chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="], + + "@hey-api/openapi-ts/c12/giget/tar/minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], + + "@hey-api/openapi-ts/c12/giget/tar/minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="], + + "@hey-api/openapi-ts/c12/giget/tar/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "@trigger.dev/core/socket.io/engine.io/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], "lint-staged/listr2/cli-truncate/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -5178,6 +5261,8 @@ "test-exclude/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "@hey-api/openapi-ts/c12/giget/tar/minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "lint-staged/listr2/cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "lint-staged/listr2/log-update/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], diff --git a/docker-compose.opencode.local.yml b/docker-compose.opencode.local.yml new file mode 100644 index 0000000000..4a9ab5ef34 --- /dev/null +++ b/docker-compose.opencode.local.yml @@ -0,0 +1,49 @@ +services: + opencode: + build: + context: . + dockerfile: docker/opencode.Dockerfile + restart: unless-stopped + ports: + - '${OPENCODE_PORT:-4096}:${OPENCODE_PORT:-4096}' + expose: + - '4096' + environment: + - OPENCODE_PORT=${OPENCODE_PORT:-4096} + - OPENCODE_REPOSITORY_ROOT=${OPENCODE_REPOSITORY_ROOT:-/app/repos} + - OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME:-opencode} + - OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD:-dev-opencode-password} + - OPENCODE_REPOS=${OPENCODE_REPOS:-} + - GIT_USERNAME=${GIT_USERNAME:-} + - GIT_TOKEN=${GIT_TOKEN:-} + - GITHUB_TOKEN=${GITHUB_TOKEN:-} + - OPENAI_API_KEY=${OPENAI_API_KEY:-${OPENAI_API_KEY_1:-}} + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-${ANTHROPIC_API_KEY_1:-}} + - GEMINI_API_KEY=${GEMINI_API_KEY:-${GEMINI_API_KEY_1:-}} + - GOOGLE_GENERATIVE_AI_API_KEY=${GOOGLE_GENERATIVE_AI_API_KEY:-${GEMINI_API_KEY:-${GEMINI_API_KEY_1:-}}} + volumes: + - opencode_repos:${OPENCODE_REPOSITORY_ROOT:-/app/repos} + - opencode_data:/home/opencode/.local/share/opencode + healthcheck: + test: ['CMD', '/usr/local/bin/opencode-healthcheck.sh'] + interval: 90s + timeout: 5s + retries: 3 + start_period: 15s + + simstudio: + environment: + - NEXT_PUBLIC_OPENCODE_ENABLED=${NEXT_PUBLIC_OPENCODE_ENABLED:-true} + - OPENCODE_BASE_URL=${OPENCODE_BASE_URL:-http://opencode:${OPENCODE_PORT:-4096}} + - OPENCODE_PORT=${OPENCODE_PORT:-4096} + - OPENCODE_REPOSITORY_ROOT=${OPENCODE_REPOSITORY_ROOT:-/app/repos} + - OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME:-opencode} + - OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD:-dev-opencode-password} + - OPENCODE_REPOS=${OPENCODE_REPOS:-} + depends_on: + opencode: + condition: service_healthy + +volumes: + opencode_data: + opencode_repos: diff --git a/docker-compose.opencode.yml b/docker-compose.opencode.yml new file mode 100644 index 0000000000..669e621801 --- /dev/null +++ b/docker-compose.opencode.yml @@ -0,0 +1,47 @@ +services: + opencode: + build: + context: . + dockerfile: docker/opencode.Dockerfile + restart: unless-stopped + expose: + - '4096' + environment: + - OPENCODE_PORT=${OPENCODE_PORT:-4096} + - OPENCODE_REPOSITORY_ROOT=${OPENCODE_REPOSITORY_ROOT:-/app/repos} + - OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME:-opencode} + - OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD:?OPENCODE_SERVER_PASSWORD is required for docker-compose.opencode.yml} + - OPENCODE_REPOS=${OPENCODE_REPOS:-} + - GIT_USERNAME=${GIT_USERNAME:-} + - GIT_TOKEN=${GIT_TOKEN:-} + - GITHUB_TOKEN=${GITHUB_TOKEN:-} + - OPENAI_API_KEY=${OPENAI_API_KEY:-${OPENAI_API_KEY_1:-}} + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-${ANTHROPIC_API_KEY_1:-}} + - GEMINI_API_KEY=${GEMINI_API_KEY:-${GEMINI_API_KEY_1:-}} + - GOOGLE_GENERATIVE_AI_API_KEY=${GOOGLE_GENERATIVE_AI_API_KEY:-${GEMINI_API_KEY:-${GEMINI_API_KEY_1:-}}} + volumes: + - opencode_repos:${OPENCODE_REPOSITORY_ROOT:-/app/repos} + - opencode_data:/home/opencode/.local/share/opencode + healthcheck: + test: ['CMD', '/usr/local/bin/opencode-healthcheck.sh'] + interval: 90s + timeout: 5s + retries: 3 + start_period: 15s + + simstudio: + environment: + - NEXT_PUBLIC_OPENCODE_ENABLED=${NEXT_PUBLIC_OPENCODE_ENABLED:-true} + - OPENCODE_BASE_URL=${OPENCODE_BASE_URL:-http://opencode:${OPENCODE_PORT:-4096}} + - OPENCODE_PORT=${OPENCODE_PORT:-4096} + - OPENCODE_REPOSITORY_ROOT=${OPENCODE_REPOSITORY_ROOT:-/app/repos} + - OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME:-opencode} + - OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD:?OPENCODE_SERVER_PASSWORD is required for docker-compose.opencode.yml} + - OPENCODE_REPOS=${OPENCODE_REPOS:-} + depends_on: + opencode: + condition: service_healthy + +volumes: + opencode_data: + opencode_repos: diff --git a/docker/opencode.Dockerfile b/docker/opencode.Dockerfile new file mode 100644 index 0000000000..2c9fd316b5 --- /dev/null +++ b/docker/opencode.Dockerfile @@ -0,0 +1,45 @@ +FROM node:22-bookworm-slim + +ARG OPENCODE_AI_VERSION=0.8.0 + +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + cron \ + curl \ + git \ + gosu \ + && update-ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +RUN npm install -g "opencode-ai@${OPENCODE_AI_VERSION}" + +RUN groupadd -g 1001 opencode && \ + useradd -m -u 1001 -g opencode -s /bin/bash opencode + +WORKDIR /app + +RUN mkdir -p \ + /app/repos \ + /home/opencode/.config/opencode \ + /home/opencode/.local/state \ + /home/opencode/.local/share/opencode + +COPY docker/opencode/entrypoint.sh /usr/local/bin/opencode-entrypoint.sh +COPY docker/opencode/git-askpass.sh /usr/local/bin/git-askpass.sh +COPY docker/opencode/healthcheck.sh /usr/local/bin/opencode-healthcheck.sh +COPY docker/opencode/sync-repos.sh /usr/local/bin/sync-repos.sh +RUN chmod 755 \ + /usr/local/bin/opencode-entrypoint.sh \ + /usr/local/bin/git-askpass.sh \ + /usr/local/bin/opencode-healthcheck.sh \ + /usr/local/bin/sync-repos.sh + +ENV HOME=/home/opencode +ENV OPENCODE_PORT=4096 + +EXPOSE 4096 + +ENTRYPOINT ["/usr/local/bin/opencode-entrypoint.sh"] diff --git a/docker/opencode/README.md b/docker/opencode/README.md new file mode 100644 index 0000000000..147ac88a45 --- /dev/null +++ b/docker/opencode/README.md @@ -0,0 +1,172 @@ +# OpenCode Service + +This service runs `opencode serve` for Sim. It backs the optional `OpenCode` workflow block and can also be queried by internal tooling against one or more cloned repositories. + +## What it provides + +- HTTP service on `http://opencode:4096` inside Docker, with an optional published host port for local development +- HTTP basic auth via `OPENCODE_SERVER_USERNAME` and `OPENCODE_SERVER_PASSWORD` +- Persistent OpenCode storage in `~/.local/share/opencode` +- Optional multi-repo sync into `/app/repos` +- Global read-only OpenCode permissions + +## Required configuration + +At minimum, set: + +```env +OPENCODE_REPOSITORY_ROOT=/app/repos +OPENCODE_SERVER_USERNAME=opencode +OPENCODE_SERVER_PASSWORD=change-me +OPENCODE_REPOS=https://github.com/octocat/Hello-World.git +GEMINI_API_KEY=your-gemini-key +``` + +Notes: + +- The UI block is intentionally hidden until `NEXT_PUBLIC_OPENCODE_ENABLED=true` is set on the Sim app. +- `OPENCODE_REPOSITORY_ROOT` defaults to `/app/repos` and must match the path Sim uses when it resolves repository directories. +- `OPENCODE_SERVER_USERNAME` defaults to `opencode` in the optional compose overlays if omitted. +- `docker-compose.opencode.local.yml` defaults `OPENCODE_SERVER_PASSWORD` to `dev-opencode-password`, but setting it explicitly is safer and avoids app/container credential drift. +- `docker-compose.opencode.yml` requires `OPENCODE_SERVER_PASSWORD` to be provided from the environment before `docker compose` starts. +- OpenCode needs at least one provider key to answer prompts: + - `OPENAI_API_KEY` + - `ANTHROPIC_API_KEY` + - `GEMINI_API_KEY` + - `GOOGLE_GENERATIVE_AI_API_KEY` +- In the optional compose overlays, `GOOGLE_GENERATIVE_AI_API_KEY` is automatically derived from `GEMINI_API_KEY` if not set explicitly. + +## Configure repositories + +Set `OPENCODE_REPOS` to a comma-separated list of HTTPS repository URLs. + +```bash +OPENCODE_REPOS=https://github.com/org/ui-components,https://github.com/org/design-tokens +``` + +Azure Repos over HTTPS is also supported. Example: + +```bash +OPENCODE_REPOS=https://dev.azure.com/org/project/_git/repo +``` + +Each repository is cloned into `${OPENCODE_REPOSITORY_ROOT:-/app/repos}/`. On restart, existing clones are updated with `git pull --ff-only`. A background cron sync retries every 15 minutes. + +For private repositories, provide HTTPS credentials with one of these options: + +- `GIT_USERNAME` and `GIT_TOKEN` +- `GITHUB_TOKEN` for GitHub HTTPS access + +For Azure Repos, use `GIT_USERNAME` plus an Azure DevOps PAT in `GIT_TOKEN`. The container uses non-interactive `GIT_ASKPASS`, so it will not stop to ask for a password in the terminal during clone or pull. + +If a clone or pull fails, the service logs the error and continues syncing the remaining repositories. + +## Local development on the host + +If you run `next dev` on the host instead of inside Docker, the app must reach OpenCode through the published host port. + +Add this to `apps/sim/.env`: + +```env +NEXT_PUBLIC_OPENCODE_ENABLED=true +OPENCODE_BASE_URL=http://127.0.0.1:4096 +OPENCODE_REPOSITORY_ROOT=/app/repos +OPENCODE_SERVER_USERNAME=opencode +OPENCODE_SERVER_PASSWORD=change-me +``` + +Then load the same environment into your shell before starting the OpenCode container: + +```bash +set -a +source apps/sim/.env +set +a +docker compose -f docker-compose.local.yml -f docker-compose.opencode.local.yml up -d --build opencode +``` + +This matters because `apps/sim/.env` configures the host-side Next.js app, but `docker compose` only sees variables present in the shell environment. + +If Sim itself also runs in Docker, use the same local overlay without targeting just `opencode`: + +```bash +docker compose -f docker-compose.local.yml -f docker-compose.opencode.local.yml up -d --build +``` + +## Verify the service + +Verification differs slightly between local and production-style compose. + +### Local compose + +`docker-compose.opencode.local.yml` publishes `OPENCODE_PORT` to the host, so this should work from the host: + +```bash +curl -u "$OPENCODE_SERVER_USERNAME:$OPENCODE_SERVER_PASSWORD" \ + http://127.0.0.1:${OPENCODE_PORT:-4096}/global/health +``` + +Create a session from the host: + +```bash +curl -u "$OPENCODE_SERVER_USERNAME:$OPENCODE_SERVER_PASSWORD" \ + -H "Content-Type: application/json" \ + -d '{"title":"test"}' \ + http://127.0.0.1:${OPENCODE_PORT:-4096}/session +``` + +### Production-style compose + +Production should use the base compose plus the OpenCode overlay: + +```bash +docker compose -f docker-compose.prod.yml -f docker-compose.opencode.yml up -d --build +``` + +The overlay injects `NEXT_PUBLIC_OPENCODE_ENABLED`, `OPENCODE_BASE_URL`, `OPENCODE_PORT`, `OPENCODE_REPOSITORY_ROOT`, `OPENCODE_SERVER_USERNAME`, and `OPENCODE_SERVER_PASSWORD` into `simstudio`, so the app can authenticate against the internal OpenCode server without changing `docker-compose.prod.yml`. + +If you prefer to run OpenCode in separate infrastructure, skip the overlay and set the same app variables directly on the Sim deployment. The external OpenCode runtime must expose project worktrees under the same `OPENCODE_REPOSITORY_ROOT` that Sim is configured to use. + +OpenCode stays internal to the Docker network, so verify from another container: + +```bash +docker compose -f docker-compose.prod.yml -f docker-compose.opencode.yml exec simstudio \ + curl -u "$OPENCODE_SERVER_USERNAME:$OPENCODE_SERVER_PASSWORD" \ + http://opencode:${OPENCODE_PORT:-4096}/global/health +``` + +Create a session: + +```bash +docker compose -f docker-compose.prod.yml -f docker-compose.opencode.yml exec simstudio \ + curl -u "$OPENCODE_SERVER_USERNAME:$OPENCODE_SERVER_PASSWORD" \ + -H "Content-Type: application/json" \ + -d '{"title":"test"}' \ + http://opencode:${OPENCODE_PORT:-4096}/session +``` + +Useful runtime checks: + +```bash +docker logs --tail 100 sim-opencode-1 +docker exec sim-opencode-1 env | grep OPENCODE +docker exec sim-opencode-1 env | grep -E 'OPENAI|ANTHROPIC|GEMINI|GOOGLE_GENERATIVE' +``` + +Expected signals: + +- `opencode server listening on http://0.0.0.0:4096` +- `[opencode-sync] Updated ` or clone logs +- the same username/password and provider env vars you expect the app to use + +Before accepting a deployment, validate the read-only permission config with a real prompt against a cloned repository. The check should confirm that OpenCode can still read files while `edit`, `bash`, and web-capable tools remain blocked. If the wildcard rule prevents normal reads, remove `permission."*": "deny"` and keep the explicit tool denies as the fallback. + +## Repo-specific behavior + +Each cloned repository can keep its own `AGENTS.md` and `opencode.json` at the repo root. OpenCode will use those when a future client targets that repository directory. + +The SDK also supports injecting extra per-session context without triggering a reply by calling `session.prompt` with `noReply: true`. The current Sim block can evolve to use this for dynamic runtime instructions on top of repository-local configuration. + +## Notes + +- Session retention is not managed yet. OpenCode data persists until the `opencode_data` volume is pruned. +- The compose overlays are convenience wrappers. The app can also target any compatible external OpenCode deployment through `OPENCODE_BASE_URL`, the same server credentials, and the same `OPENCODE_REPOSITORY_ROOT`. diff --git a/docker/opencode/entrypoint.sh b/docker/opencode/entrypoint.sh new file mode 100755 index 0000000000..5810e9c292 --- /dev/null +++ b/docker/opencode/entrypoint.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +set -euo pipefail + +log() { + printf '[opencode-entrypoint] %s\n' "$*" +} + +validate_port() { + local port="$1" + + if [[ ! "$port" =~ ^[0-9]+$ ]]; then + log "OPENCODE_PORT must be a numeric TCP port" + exit 1 + fi +} + +write_runtime_env() { + local env_file="/home/opencode/.config/opencode/runtime-env.sh" + local vars=( + HOME + PATH + GIT_ASKPASS + OPENCODE_REPOS + OPENCODE_PORT + OPENCODE_REPOSITORY_ROOT + OPENCODE_SERVER_PASSWORD + OPENCODE_SERVER_USERNAME + GIT_USERNAME + GIT_TOKEN + GITHUB_TOKEN + OPENAI_API_KEY + ANTHROPIC_API_KEY + GEMINI_API_KEY + GOOGLE_GENERATIVE_AI_API_KEY + ) + + umask 077 + : >"$env_file" + + for name in "${vars[@]}"; do + if [[ -v "$name" ]]; then + printf 'export %s=%q\n' "$name" "${!name}" >>"$env_file" + fi + done + + chown opencode:opencode "$env_file" +} + +write_global_config() { + cat >/home/opencode/.config/opencode/opencode.json </etc/cron.d/opencode-sync <<'EOF' +SHELL=/bin/bash +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +*/15 * * * * opencode /usr/local/bin/sync-repos.sh >> /proc/1/fd/1 2>> /proc/1/fd/2 +EOF + chmod 0644 /etc/cron.d/opencode-sync +} + +main() { + : "${OPENCODE_PORT:=4096}" + : "${OPENCODE_SERVER_USERNAME:=opencode}" + : "${OPENCODE_REPOSITORY_ROOT:=/app/repos}" + export GIT_ASKPASS="${GIT_ASKPASS:-/usr/local/bin/git-askpass.sh}" + + if [[ -z "${OPENCODE_SERVER_PASSWORD:-}" ]]; then + log "OPENCODE_SERVER_PASSWORD is required" + exit 1 + fi + + if [[ -z "${GOOGLE_GENERATIVE_AI_API_KEY:-}" && -n "${GEMINI_API_KEY:-}" ]]; then + export GOOGLE_GENERATIVE_AI_API_KEY="${GEMINI_API_KEY}" + fi + + validate_port "${OPENCODE_PORT}" + + mkdir -p "${OPENCODE_REPOSITORY_ROOT}" /home/opencode/.config/opencode /home/opencode/.local/share/opencode /home/opencode/.local/state + chown -R opencode:opencode "${OPENCODE_REPOSITORY_ROOT}" /home/opencode/.config /home/opencode/.local/share /home/opencode/.local/state + + write_runtime_env + write_global_config + install_cron + + if [[ -z "${OPENAI_API_KEY:-}" && -z "${ANTHROPIC_API_KEY:-}" && -z "${GEMINI_API_KEY:-}" && -z "${GOOGLE_GENERATIVE_AI_API_KEY:-}" ]]; then + log "No provider API key detected in environment; server will start but prompts may fail" + fi + + if ! gosu opencode /usr/local/bin/sync-repos.sh; then + log "Repository sync completed with errors" + fi + cron + + cd /app + exec gosu opencode opencode serve --hostname 0.0.0.0 --port "${OPENCODE_PORT}" +} + +main "$@" diff --git a/docker/opencode/git-askpass.sh b/docker/opencode/git-askpass.sh new file mode 100755 index 0000000000..d3f823da3e --- /dev/null +++ b/docker/opencode/git-askpass.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +prompt="${1:-}" + +if [[ "$prompt" == Username* ]]; then + if [[ "$prompt" == *github.com* && -n "${GITHUB_TOKEN:-}" && -z "${GIT_TOKEN:-}" ]]; then + printf '%s\n' "${GITHUB_USERNAME:-x-access-token}" + exit 0 + fi + + printf '%s\n' "${GIT_USERNAME:-git}" + exit 0 +fi + +if [[ "$prompt" == Password* ]]; then + if [[ "$prompt" == *github.com* && -n "${GITHUB_TOKEN:-}" && -z "${GIT_TOKEN:-}" ]]; then + printf '%s\n' "${GITHUB_TOKEN}" + exit 0 + fi + + printf '%s\n' "${GIT_TOKEN:-}" + exit 0 +fi + +printf '\n' diff --git a/docker/opencode/healthcheck.sh b/docker/opencode/healthcheck.sh new file mode 100755 index 0000000000..073faaa06e --- /dev/null +++ b/docker/opencode/healthcheck.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +port="${OPENCODE_PORT:-4096}" +user="${OPENCODE_SERVER_USERNAME:-opencode}" +password="${OPENCODE_SERVER_PASSWORD:-}" + +if [[ -z "$password" ]]; then + exit 1 +fi + +curl --silent --show-error --fail \ + -u "${user}:${password}" \ + "http://127.0.0.1:${port}/global/health" >/dev/null diff --git a/docker/opencode/sync-repos.sh b/docker/opencode/sync-repos.sh new file mode 100755 index 0000000000..b95f525c23 --- /dev/null +++ b/docker/opencode/sync-repos.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +set -uo pipefail + +if [[ -f /home/opencode/.config/opencode/runtime-env.sh ]]; then + source /home/opencode/.config/opencode/runtime-env.sh +fi + +export HOME="${HOME:-/home/opencode}" +export GIT_TERMINAL_PROMPT=0 +export GIT_ASKPASS=/usr/local/bin/git-askpass.sh +export OPENCODE_REPOSITORY_ROOT="${OPENCODE_REPOSITORY_ROOT:-/app/repos}" + +log() { + printf '[opencode-sync] %s\n' "$*" +} + +trim() { + local value="$1" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + printf '%s' "$value" +} + +sync_repo() { + local repo_url="$1" + local repo_name="$2" + local repo_dir="${OPENCODE_REPOSITORY_ROOT}/${repo_name}" + + if [[ -d "$repo_dir/.git" ]]; then + if git -C "$repo_dir" pull --ff-only; then + log "Updated ${repo_name}" + return 0 + fi + + log "Failed to update ${repo_name} from ${repo_url}" + return 1 + fi + + if [[ -e "$repo_dir" ]]; then + log "Skipping ${repo_url}; target path ${repo_dir} exists and is not a git repository" + return 1 + fi + + if git clone "$repo_url" "$repo_dir"; then + log "Cloned ${repo_name}" + return 0 + fi + + rm -rf "$repo_dir" + log "Failed to clone ${repo_url}" + return 1 +} + +main() { + local repos_raw="${OPENCODE_REPOS:-}" + + mkdir -p "${OPENCODE_REPOSITORY_ROOT}" + + if [[ -z "$repos_raw" ]]; then + log "No repositories configured" + exit 0 + fi + + local -A seen_names=() + local repo_url + local repo_name + local sync_failed=0 + + IFS=',' read -r -a repo_items <<<"$repos_raw" + for repo_item in "${repo_items[@]}"; do + repo_url="$(trim "$repo_item")" + if [[ -z "$repo_url" ]]; then + continue + fi + + repo_name="${repo_url##*/}" + repo_name="${repo_name%.git}" + + if [[ -z "$repo_name" ]]; then + log "Skipping invalid repository URL: ${repo_url}" + sync_failed=1 + continue + fi + + if [[ -n "${seen_names[$repo_name]:-}" ]]; then + log "Skipping ${repo_url}; repository name ${repo_name} collides with ${seen_names[$repo_name]}" + sync_failed=1 + continue + fi + + seen_names["$repo_name"]="$repo_url" + + if ! sync_repo "$repo_url" "$repo_name"; then + sync_failed=1 + fi + done + + exit "$sync_failed" +} + +main "$@"