diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..4b25f4d0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +.git +.gitignore +node_modules +**/node_modules +dist +**/dist +build +**/build +.DS_Store +.idea +.vscode +coverage +tmp +logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.env +.env.* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..cac1e341 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,50 @@ +FROM oven/bun:1.3.9 AS base +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && rm -rf /var/lib/apt/lists/* + +FROM base AS deps +COPY . . +RUN bun install --frozen-lockfile --ignore-scripts + +FROM deps AS builder +RUN bun run build:web + +FROM oven/bun:1.3.9 AS runtime +WORKDIR /app + +ENV NODE_ENV=production +ENV OPENCHAMBER_PORT=3000 +ENV BUN_INSTALL=/home/bun/.bun +ENV PATH=${BUN_INSTALL}/bin:${PATH} + +USER root + +RUN apt-get update && apt-get install -y --no-install-recommends git npm openssh-client && rm -rf /var/lib/apt/lists/* + +# 配置 npm 全局安装到用户可写目录 +RUN npm config set prefix /home/bun/.npm-global && mkdir -p /home/bun/.npm-global + +ENV NPM_CONFIG_PREFIX=/home/bun/.npm-global +ENV PATH=${NPM_CONFIG_PREFIX}/bin:${PATH} + +# 确保 bun 用户对全局 npm 目录有写权限 +RUN chown -R bun:bun /home/bun/.npm-global + +USER bun + +RUN npm install -g opencode-ai + +RUN mkdir -p /home/bun/.local /home/bun/.config /home/bun/.ssh + +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/packages/web/node_modules ./packages/web/node_modules +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/packages/web/package.json ./packages/web/package.json +COPY --from=builder /app/packages/web/bin ./packages/web/bin +COPY --from=builder /app/packages/web/server ./packages/web/server +COPY --from=builder /app/packages/web/dist ./packages/web/dist +COPY --chmod=755 scripts/docker-entrypoint.sh /app/openchamber-entrypoint.sh + +EXPOSE 3000 + +ENTRYPOINT ["/app/openchamber-entrypoint.sh"] diff --git a/README.md b/README.md index 7a1dda76..2b07831c 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,26 @@ openchamber update # Update to latest version Download from [Releases](https://github.com/btriapitsyn/openchamber/releases). +### Docker Compose + +```bash +docker compose up -d +``` + +The service will be available at `http://localhost:3000`. + +**Data Directory Permission Note:** The `data/` directory is mounted into the container for persistent storage (config, sessions, SSH keys, workspaces). Before running, ensure the directory exists and has proper permissions: + +```bash +# Create data directories with correct ownership +mkdir -p data/openchamber data/opencode/share data/opencode/config data/ssh data/workspaces + +# Fix permissions (replace $USER with your username) +chown -R 1000:1000 data/ +``` + +Without proper permissions, the container may fail to start or encounter permission denied errors when writing to these directories. + ## Prerequisites - [OpenCode CLI](https://opencode.ai) installed @@ -131,16 +151,19 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines. ## Tech Stack ### Frontend + ![React](https://img.shields.io/badge/React-19-61DAFB?style=flat&logo=react&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript&logoColor=white) ![Vite](https://img.shields.io/badge/Vite-7-646CFF?style=flat&logo=vite&logoColor=white) ![Tailwind CSS](https://img.shields.io/badge/Tailwind_CSS-v4-06B6D4?style=flat&logo=tailwindcss&logoColor=white) ### State & UI + ![Zustand](https://img.shields.io/badge/Zustand-State_Management-FF6B6B?style=flat) ![Radix UI](https://img.shields.io/badge/Radix_UI-Components-8B5CF6?style=flat&logo=radixui&logoColor=white) ### Backend & Desktop + ![Express](https://img.shields.io/badge/Express.js-Server-000000?style=flat&logo=express&logoColor=white) ![Tauri](https://img.shields.io/badge/Tauri-Desktop-FFC131?style=flat&logo=tauri&logoColor=white) ![OpenCode SDK](https://img.shields.io/badge/OpenCode-SDK-4F46E5?style=flat) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..9a7eef03 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +services: + openchamber: + build: + context: . + dockerfile: Dockerfile + image: openchamber:local + container_name: openchamber + ports: + - "3000:3000" + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - ./data/openchamber:/home/bun/.config/openchamber + - ./data/opencode/share:/home/bun/.local/share/opencode + - ./data/opencode/config:/home/bun/.config/opencode + - ./data/ssh:/home/bun/.ssh + - ./workspaces:/home/bun/workspaces + # environment: + # OH_MY_OPENCODE: true # enable oh-my-opencode + restart: unless-stopped diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 52e45d60..f3ddf315 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -10952,6 +10952,94 @@ async function main(options = {}) { return ptyProviderPromise; }; + const getTerminalShellCandidates = () => { + if (process.platform === 'win32') { + const windowsCandidates = [ + process.env.OPENCHAMBER_TERMINAL_SHELL, + process.env.SHELL, + process.env.ComSpec, + path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), + 'pwsh.exe', + 'powershell.exe', + 'cmd.exe', + ].filter(Boolean); + + const resolved = []; + const seen = new Set(); + for (const candidateRaw of windowsCandidates) { + const candidate = String(candidateRaw).trim(); + if (!candidate) continue; + + const lookedUp = candidate.includes('\\') || candidate.includes('/') + ? candidate + : searchPathFor(candidate); + const executable = lookedUp && isExecutable(lookedUp) ? lookedUp : (isExecutable(candidate) ? candidate : null); + if (!executable || seen.has(executable)) continue; + seen.add(executable); + resolved.push(executable); + } + return resolved; + } + + const unixCandidates = [ + process.env.OPENCHAMBER_TERMINAL_SHELL, + process.env.SHELL, + '/bin/zsh', + '/bin/bash', + '/bin/sh', + 'zsh', + 'bash', + 'sh', + ].filter(Boolean); + + const resolved = []; + const seen = new Set(); + for (const candidateRaw of unixCandidates) { + const candidate = String(candidateRaw).trim(); + if (!candidate) continue; + + const lookedUp = candidate.includes('/') ? candidate : searchPathFor(candidate); + const executable = lookedUp && isExecutable(lookedUp) ? lookedUp : (isExecutable(candidate) ? candidate : null); + if (!executable || seen.has(executable)) continue; + seen.add(executable); + resolved.push(executable); + } + + return resolved; + }; + + const spawnTerminalPtyWithFallback = (pty, { cols, rows, cwd, env }) => { + const shellCandidates = getTerminalShellCandidates(); + if (shellCandidates.length === 0) { + throw new Error('No executable shell found for terminal session'); + } + + let lastError = null; + for (const shell of shellCandidates) { + try { + const ptyProcess = pty.spawn(shell, [], { + name: 'xterm-256color', + cols: cols || 80, + rows: rows || 24, + cwd, + env: { + ...env, + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + }, + }); + + return { ptyProcess, shell }; + } catch (error) { + lastError = error; + console.warn(`Failed to spawn PTY using shell ${shell}:`, error && error.message ? error.message : error); + } + } + + const baseMessage = lastError && lastError.message ? lastError.message : 'PTY spawn failed'; + throw new Error(`Failed to spawn terminal PTY with available shells (${shellCandidates.join(', ')}): ${baseMessage}`); + }; + const terminalSessions = new Map(); const MAX_TERMINAL_SESSIONS = 20; const TERMINAL_IDLE_TIMEOUT = 30 * 60 * 1000; @@ -11174,8 +11262,6 @@ async function main(options = {}) { return res.status(400).json({ error: 'Invalid working directory' }); } - const shell = process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : '/bin/zsh'); - const sessionId = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); @@ -11183,16 +11269,11 @@ async function main(options = {}) { const resolvedEnv = { ...process.env, PATH: envPath }; const pty = await getPtyProvider(); - const ptyProcess = pty.spawn(shell, [], { - name: 'xterm-256color', - cols: cols || 80, - rows: rows || 24, - cwd: cwd, - env: { - ...resolvedEnv, - TERM: 'xterm-256color', - COLORTERM: 'truecolor', - }, + const { ptyProcess, shell } = spawnTerminalPtyWithFallback(pty, { + cols, + rows, + cwd, + env: resolvedEnv, }); const session = { @@ -11210,7 +11291,7 @@ async function main(options = {}) { terminalSessions.delete(sessionId); }); - console.log(`Created terminal session: ${sessionId} in ${cwd}`); + console.log(`Created terminal session: ${sessionId} in ${cwd} using shell ${shell}`); res.json({ sessionId, cols: cols || 80, rows: rows || 24, capabilities: terminalInputCapabilities }); } catch (error) { console.error('Failed to create terminal session:', error); @@ -11395,8 +11476,6 @@ async function main(options = {}) { return res.status(400).json({ error: 'Invalid working directory: not accessible' }); } - const shell = process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : '/bin/zsh'); - const newSessionId = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); @@ -11404,16 +11483,11 @@ async function main(options = {}) { const resolvedEnv = { ...process.env, PATH: envPath }; const pty = await getPtyProvider(); - const ptyProcess = pty.spawn(shell, [], { - name: 'xterm-256color', - cols: cols || 80, - rows: rows || 24, - cwd: cwd, - env: { - ...resolvedEnv, - TERM: 'xterm-256color', - COLORTERM: 'truecolor', - }, + const { ptyProcess, shell } = spawnTerminalPtyWithFallback(pty, { + cols, + rows, + cwd, + env: resolvedEnv, }); const session = { @@ -11431,7 +11505,7 @@ async function main(options = {}) { terminalSessions.delete(newSessionId); }); - console.log(`Restarted terminal session: ${sessionId} -> ${newSessionId} in ${cwd}`); + console.log(`Restarted terminal session: ${sessionId} -> ${newSessionId} in ${cwd} using shell ${shell}`); res.json({ sessionId: newSessionId, cols: cols || 80, rows: rows || 24, capabilities: terminalInputCapabilities }); } catch (error) { console.error('Failed to restart terminal session:', error); diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh new file mode 100644 index 00000000..e7521526 --- /dev/null +++ b/scripts/docker-entrypoint.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env sh +set -eu + +if [ -z "${HOME:-}" ]; then + HOME="$(getent passwd "$(id -u)" | cut -d: -f6 2>/dev/null || true)" +fi + +if [ -z "${HOME:-}" ]; then + HOME="/home/bun" +fi + +OPENCODE_CONFIG_DIR="${OPENCODE_CONFIG_DIR:-${HOME}/.config/opencode}" +export OPENCODE_CONFIG_DIR + +SSH_DIR="${HOME}/.ssh" +SSH_PRIVATE_KEY_PATH="${SSH_DIR}/id_ed25519" +SSH_PUBLIC_KEY_PATH="${SSH_PRIVATE_KEY_PATH}.pub" + +mkdir -p "${SSH_DIR}" +if ! chmod 700 "${SSH_DIR}" 2>/dev/null; then + echo "[entrypoint] warning: cannot chmod ${SSH_DIR}, continuing with existing permissions" +fi + +if [ ! -f "${SSH_PRIVATE_KEY_PATH}" ] || [ ! -f "${SSH_PUBLIC_KEY_PATH}" ]; then + if [ ! -w "${SSH_DIR}" ]; then + echo "[entrypoint] error: ssh key missing and ${SSH_DIR} is not writable" >&2 + exit 1 + fi + + echo "[entrypoint] generating SSH key..." + ssh-keygen -t ed25519 -N "" -f "${SSH_PRIVATE_KEY_PATH}" >/dev/null +fi + +if ! chmod 600 "${SSH_PRIVATE_KEY_PATH}" 2>/dev/null; then + echo "[entrypoint] warning: cannot chmod ${SSH_PRIVATE_KEY_PATH}, continuing" +fi + +if ! chmod 644 "${SSH_PUBLIC_KEY_PATH}" 2>/dev/null; then + echo "[entrypoint] warning: cannot chmod ${SSH_PUBLIC_KEY_PATH}, continuing" +fi + +echo "[entrypoint] SSH public key:" +cat "${SSH_PUBLIC_KEY_PATH}" + +OMO_INSTALL_ARGS="--no-tui --claude=no --openai=no --gemini=no --copilot=no --opencode-zen=no --zai-coding-plan=no --kimi-for-coding=no --skip-auth" + +if [ "${OH_MY_OPENCODE:-false}" = "true" ]; then + + echo "[entrypoint] npm installing oh-my-opencode..." + npm install -g oh-my-opencode + + OMO_CONFIG_FILE="${OPENCODE_CONFIG_DIR}/oh-my-opencode.json" + + if [ ! -f "${OMO_CONFIG_FILE}" ]; then + echo "[entrypoint] oh-my-opencode installing..." + oh-my-opencode install ${OMO_INSTALL_ARGS} + fi +fi + +echo "[entrypoint] starting..." + +if [ "$#" -gt 0 ]; then + exec "$@" +fi + +exec bun packages/web/server/index.js --port "${OPENCHAMBER_PORT:-3000}"