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).
|
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
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

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

|

|
||||||

|

|
||||||
|
|
||||||
### Backend & Desktop
|
### 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;
|
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);
|
||||||
|
|||||||
@@ -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