Files
peardock/libs/dockerTerminal.js
T
snxraven 869ba6a932
CI / test (push) Successful in 10m6s
Expand Settings into categorized subtabs with full client prefs
Add Appearance, Behavior, Notifications, Tunnels, Templates,
Connections, Terminal, and About panels. Wire density, sidebar,
confirmations, refresh, badge mode, toasts, tunnel defaults,
template lists, peer reset, terminal themes, and read-only server
feature flags into peardock.settings.v1.
2026-07-10 23:45:11 -04:00

411 lines
11 KiB
JavaScript

/**
* Host Docker CLI terminal — line-buffered input, xterm FitAddon, binary-safe I/O.
*/
import { manager, Methods } from '../client/manager.js'
import {
getTerminalCtor,
getFitAddonCtor,
defaultXtermOptions,
decodePayload,
createFitController,
safeFit,
} from './xtermUtils.js'
const Terminal = getTerminalCtor()
const FitAddon = getFitAddonCtor()
let dockerTerminalSession = null
let dockerTerminalFontSize = 14
let dockerTerminalTheme = 'dark'
const dockerTerminalThemes = {
dark: defaultXtermOptions().theme,
light: {
background: '#ffffff',
foreground: '#0f172a',
cursor: '#0f172a',
selectionBackground: '#b3d4fc',
},
'solarized-dark': {
background: '#002b36',
foreground: '#839496',
cursor: '#93a1a1',
selectionBackground: '#073642',
},
'solarized-light': {
background: '#fdf6e3',
foreground: '#657b83',
cursor: '#586e75',
selectionBackground: '#eee8d5',
},
monokai: {
background: '#272822',
foreground: '#f8f8f2',
cursor: '#f8f8f0',
selectionBackground: '#49483e',
},
}
const ALLOWED_DOCKER_COMMANDS = new Set([
'ps',
'images',
'volumes',
'networks',
'info',
'version',
'logs',
'inspect',
'stats',
'top',
'diff',
'port',
'history',
'search',
'events',
'system',
'help',
'container',
'image',
'network',
'volume',
'context',
'compose',
])
const BLOCKED_COMMANDS = new Set([
'exec',
'run',
'create',
'start',
'stop',
'restart',
'kill',
'rm',
'rmi',
'prune',
'build',
'commit',
'push',
'pull',
'tag',
'load',
'save',
'import',
'export',
'cp',
'update',
'login',
'logout',
'swarm',
'plugin',
'secret',
'config',
'service',
'node',
'stack',
])
const BLOCKED_PATTERNS = [
/rm\s+-/,
/prune/,
/\$\(/,
/`/,
/&&/,
/\|\|/,
/;/,
/>/,
/</,
/2>&1/,
/\|\s*sh/,
/\|\s*bash/,
]
const MAX_COMMAND_LENGTH = 500
function validateDockerCommand(command) {
if (!command || command.length > MAX_COMMAND_LENGTH) return null
for (const pattern of BLOCKED_PATTERNS) {
if (pattern.test(command)) return null
}
const normalized = command.trim().toLowerCase()
let rest = normalized.startsWith('docker ') ? normalized.slice(7).trim() : normalized
const parts = rest.split(/\s+/).filter(Boolean)
const base = parts[0] || ''
if (BLOCKED_COMMANDS.has(base)) return null
// docker system df / docker container ls etc.
if (base === 'system' && parts[1] && ['df', 'info', 'events'].includes(parts[1])) {
return command.startsWith('docker ') ? command : `docker ${command}`
}
if (ALLOWED_DOCKER_COMMANDS.has(base) || base === 'help' || base === '--help' || base === '-h') {
return command.startsWith('docker ') ? command : `docker ${command}`
}
return null
}
/**
* @param {string} connectionId
* @param {import('../client/connection.js').PearDockConnection} [peer]
*/
function startDockerTerminal(connectionId, peer) {
const conn = peer || manager.active
if (!conn?.connected) {
console.error('[ERROR] No active peer for Docker CLI terminal.')
return
}
if (dockerTerminalSession) {
// Refocus existing
dockerTerminalSession.xterm?.focus()
dockerTerminalSession.fitController?.fitNow()
return
}
const dockerTerminalContainer = document.getElementById('docker-terminal-container')
const dockerTerminalTitle = document.getElementById('docker-terminal-title')
const dockerTerminalModal = document.getElementById('dockerTerminalModal')
const dockerKillTerminalBtn = document.getElementById('docker-kill-terminal-btn')
if (!dockerTerminalContainer || !dockerTerminalModal) {
console.error('[ERROR] Missing Docker CLI terminal DOM')
return
}
const xterm = new Terminal(
defaultXtermOptions({
fontSize: dockerTerminalFontSize,
theme: dockerTerminalThemes[dockerTerminalTheme] || dockerTerminalThemes.dark,
convertEol: true,
})
)
const fitAddon = new FitAddon()
xterm.loadAddon(fitAddon)
dockerTerminalContainer.innerHTML = ''
dockerTerminalContainer.style.height = dockerTerminalContainer.style.height || '500px'
xterm.open(dockerTerminalContainer)
const fitController = createFitController(fitAddon, xterm, () => {
// Docker CLI is line-mode; resize is cosmetic for local xterm only
})
fitController.observe(dockerTerminalContainer)
const peerDataHandler = (response) => {
try {
if (!response || response.type !== 'dockerOutput') return
if (response.connectionId && response.connectionId !== connectionId) return
let text = decodePayload(response.data, response.encoding || 'utf8')
// Ensure newlines render in convertEol mode
if (text && !text.endsWith('\n') && !text.endsWith('\r')) {
// keep as-is for partial chunks
}
xterm.write(text.replace(/\r?\n/g, '\r\n'))
} catch (error) {
console.error(`[ERROR] Failed to handle docker output: ${error.message}`)
}
}
const managerHandler = (msg, c) => {
if (c && conn && c !== conn && c.id !== conn.id) return
peerDataHandler(msg)
}
conn.on?.('message', peerDataHandler)
manager.on('message', managerHandler)
let inputBuffer = ''
const onDataDisposable = xterm.onData((input) => {
// Enter
if (input === '\r') {
const line = inputBuffer.trim()
inputBuffer = ''
xterm.write('\r\n')
if (!line) {
xterm.write('\x1b[32m$\x1b[0m ')
return
}
const fullCommand = validateDockerCommand(line)
if (!fullCommand) {
xterm.write('\x1b[31m[ERROR]\x1b[0m Invalid or blocked command.\r\n')
xterm.write('Allowed: ps, images, logs, inspect, stats, system df, …\r\n')
xterm.write('\x1b[32m$\x1b[0m ')
return
}
const api = conn.request ? conn : manager
api
.request(Methods.dockerCommand, { connectionId, data: fullCommand })
.then(() => {
xterm.write('\x1b[32m$\x1b[0m ')
})
.catch((err) => {
xterm.write(`\x1b[31m[ERROR]\x1b[0m ${err.message}\r\n`)
xterm.write('\x1b[32m$\x1b[0m ')
})
return
}
// Backspace
if (input === '\u007F' || input === '\b') {
if (inputBuffer.length > 0) {
inputBuffer = inputBuffer.slice(0, -1)
xterm.write('\b \b')
}
return
}
// Ctrl+C
if (input === '\u0003') {
inputBuffer = ''
xterm.write('^C\r\n\x1b[32m$\x1b[0m ')
return
}
// Ctrl+U clear line
if (input === '\u0015') {
while (inputBuffer.length > 0) {
inputBuffer = inputBuffer.slice(0, -1)
xterm.write('\b \b')
}
return
}
// Ignore other controls except printable / tab
if (input < ' ' && input !== '\t') return
inputBuffer += input
xterm.write(input)
})
dockerTerminalSession = {
xterm,
fitAddon,
fitController,
connectionId,
peer: conn,
peerDataHandler,
managerHandler,
onDataDisposable,
}
if (dockerTerminalTitle) {
dockerTerminalTitle.textContent = `Docker CLI Terminal: ${connectionId}`
}
const modalInstance = bootstrap.Modal.getOrCreateInstance(dockerTerminalModal)
dockerTerminalModal.addEventListener(
'shown.bs.modal',
() => {
fitController.fitNow()
xterm.focus()
xterm.write('\x1b[1mDocker CLI\x1b[0m (read-only allow-list)\r\n\x1b[32m$\x1b[0m ')
},
{ once: true }
)
dockerTerminalModal.addEventListener(
'hidden.bs.modal',
() => {
cleanUpDockerTerminal()
},
{ once: true }
)
modalInstance.show()
if (dockerKillTerminalBtn) {
dockerKillTerminalBtn.onclick = () => cleanUpDockerTerminal()
}
requestAnimationFrame(() => safeFit(fitAddon, xterm))
}
function cleanUpDockerTerminal() {
const dockerTerminalContainer = document.getElementById('docker-terminal-container')
const dockerTerminalModal = document.getElementById('dockerTerminalModal')
if (dockerTerminalSession) {
try {
dockerTerminalSession.onDataDisposable?.dispose()
dockerTerminalSession.fitController?.disconnect()
dockerTerminalSession.peer?.removeListener?.('message', dockerTerminalSession.peerDataHandler)
if (dockerTerminalSession.managerHandler) {
manager.removeListener('message', dockerTerminalSession.managerHandler)
}
dockerTerminalSession.xterm?.dispose()
} catch {
// ignore
}
dockerTerminalSession = null
}
if (dockerTerminalContainer) dockerTerminalContainer.innerHTML = ''
if (dockerTerminalModal) {
const modalInstance = bootstrap.Modal.getInstance(dockerTerminalModal)
modalInstance?.hide()
}
document.querySelector('.modal-backdrop')?.remove()
document.body.classList.remove('modal-open')
document.body.style.paddingRight = ''
}
function updateDockerTerminalFontSizeDisplay() {
const display = document.getElementById('docker-terminal-font-size-display')
if (display) display.textContent = String(dockerTerminalFontSize)
}
document.addEventListener('DOMContentLoaded', () => {
const applySize = (n) => {
dockerTerminalFontSize = n
if (dockerTerminalSession?.xterm) {
dockerTerminalSession.xterm.options.fontSize = n
dockerTerminalSession.fitController?.fitNow()
}
updateDockerTerminalFontSizeDisplay()
}
document.getElementById('docker-terminal-font-decrease')?.addEventListener('click', () => {
if (dockerTerminalFontSize > 8) applySize(dockerTerminalFontSize - 1)
})
document.getElementById('docker-terminal-font-increase')?.addEventListener('click', () => {
if (dockerTerminalFontSize < 28) applySize(dockerTerminalFontSize + 1)
})
document.getElementById('docker-terminal-font-reset')?.addEventListener('click', () => applySize(14))
document.getElementById('docker-terminal-copy-btn')?.addEventListener('click', () => {
const sel = dockerTerminalSession?.xterm?.getSelection?.()
if (sel) navigator.clipboard.writeText(sel).catch(() => {})
})
document.getElementById('docker-terminal-clear-btn')?.addEventListener('click', () => {
dockerTerminalSession?.xterm?.clear()
})
const themeSelect = document.getElementById('docker-terminal-theme-select')
try {
const pref = window.__peardockSettings?.dockerTerminalTheme
if (pref && dockerTerminalThemes[pref]) {
dockerTerminalTheme = pref
}
} catch {
// ignore
}
if (themeSelect) {
themeSelect.value = dockerTerminalTheme
themeSelect.addEventListener('change', (e) => {
dockerTerminalTheme = e.target.value
if (dockerTerminalSession?.xterm) {
dockerTerminalSession.xterm.options.theme =
dockerTerminalThemes[dockerTerminalTheme] || dockerTerminalThemes.dark
}
})
}
updateDockerTerminalFontSizeDisplay()
})
if (typeof window !== 'undefined') {
const prev = window.__peardockApplyTerminalThemes
window.__peardockApplyTerminalThemes = (termTheme, dockerTheme) => {
if (typeof prev === 'function') prev(termTheme, dockerTheme)
if (dockerTheme && dockerTerminalThemes[dockerTheme]) {
dockerTerminalTheme = dockerTheme
if (dockerTerminalSession?.xterm) {
dockerTerminalSession.xterm.options.theme =
dockerTerminalThemes[dockerTerminalTheme] || dockerTerminalThemes.dark
}
const themeSelect = document.getElementById('docker-terminal-theme-select')
if (themeSelect) themeSelect.value = dockerTheme
}
}
}
export { startDockerTerminal, cleanUpDockerTerminal, dockerTerminalSession }