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:
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
### State & UI
|
||||
|
||||

|
||||

|
||||
|
||||
### Backend & Desktop
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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}"
|
||||
Reference in New Issue
Block a user