feat(container): add Docker deployment and terminal shell fallback (#520)

* feat(container): add Docker deployment and terminal shell fallback

Add Docker runtime assets (Dockerfile, compose file, and entrypoint) to run OpenChamber in containers with persistent config/data mounts.

Improve terminal PTY startup reliability by probing multiple shell candidates and falling back automatically across Unix and Windows environments.

* fix: add OMO install env

* docs: add Docker Compose instructions to README
This commit is contained in:
nzlov
2026-02-26 20:13:23 +02:00
committed by GitHub
parent 0e9b231fed
commit 6ca2b0da05
6 changed files with 279 additions and 26 deletions
+20
View File
@@ -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.*
+50
View File
@@ -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"]
+23
View File
@@ -120,6 +120,26 @@ openchamber update # Update to latest version
Download from [Releases](https://github.com/btriapitsyn/openchamber/releases). 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 ## Prerequisites
- [OpenCode CLI](https://opencode.ai) installed - [OpenCode CLI](https://opencode.ai) installed
@@ -131,16 +151,19 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines.
## Tech Stack ## Tech Stack
### Frontend ### Frontend
![React](https://img.shields.io/badge/React-19-61DAFB?style=flat&logo=react&logoColor=white) ![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) ![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) ![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) ![Tailwind CSS](https://img.shields.io/badge/Tailwind_CSS-v4-06B6D4?style=flat&logo=tailwindcss&logoColor=white)
### State & UI ### State & UI
![Zustand](https://img.shields.io/badge/Zustand-State_Management-FF6B6B?style=flat) ![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) ![Radix UI](https://img.shields.io/badge/Radix_UI-Components-8B5CF6?style=flat&logo=radixui&logoColor=white)
### Backend & Desktop ### Backend & Desktop
![Express](https://img.shields.io/badge/Express.js-Server-000000?style=flat&logo=express&logoColor=white) ![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) ![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) ![OpenCode SDK](https://img.shields.io/badge/OpenCode-SDK-4F46E5?style=flat)
+20
View File
@@ -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
+100 -26
View File
@@ -10952,6 +10952,94 @@ async function main(options = {}) {
return ptyProviderPromise; 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 terminalSessions = new Map();
const MAX_TERMINAL_SESSIONS = 20; const MAX_TERMINAL_SESSIONS = 20;
const TERMINAL_IDLE_TIMEOUT = 30 * 60 * 1000; const TERMINAL_IDLE_TIMEOUT = 30 * 60 * 1000;
@@ -11174,8 +11262,6 @@ async function main(options = {}) {
return res.status(400).json({ error: 'Invalid working directory' }); 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) + const sessionId = Math.random().toString(36).substring(2, 15) +
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 resolvedEnv = { ...process.env, PATH: envPath };
const pty = await getPtyProvider(); const pty = await getPtyProvider();
const ptyProcess = pty.spawn(shell, [], { const { ptyProcess, shell } = spawnTerminalPtyWithFallback(pty, {
name: 'xterm-256color', cols,
cols: cols || 80, rows,
rows: rows || 24, cwd,
cwd: cwd, env: resolvedEnv,
env: {
...resolvedEnv,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
},
}); });
const session = { const session = {
@@ -11210,7 +11291,7 @@ async function main(options = {}) {
terminalSessions.delete(sessionId); 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 }); res.json({ sessionId, cols: cols || 80, rows: rows || 24, capabilities: terminalInputCapabilities });
} catch (error) { } catch (error) {
console.error('Failed to create terminal session:', 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' }); 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) + const newSessionId = Math.random().toString(36).substring(2, 15) +
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 resolvedEnv = { ...process.env, PATH: envPath };
const pty = await getPtyProvider(); const pty = await getPtyProvider();
const ptyProcess = pty.spawn(shell, [], { const { ptyProcess, shell } = spawnTerminalPtyWithFallback(pty, {
name: 'xterm-256color', cols,
cols: cols || 80, rows,
rows: rows || 24, cwd,
cwd: cwd, env: resolvedEnv,
env: {
...resolvedEnv,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
},
}); });
const session = { const session = {
@@ -11431,7 +11505,7 @@ async function main(options = {}) {
terminalSessions.delete(newSessionId); 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 }); res.json({ sessionId: newSessionId, cols: cols || 80, rows: rows || 24, capabilities: terminalInputCapabilities });
} catch (error) { } catch (error) {
console.error('Failed to restart terminal session:', error); console.error('Failed to restart terminal session:', error);
+66
View File
@@ -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}"