From e86f95ddc7bd6193dfc30a9d7b7e951a3752ba6c Mon Sep 17 00:00:00 2001 From: isaacclad Date: Fri, 26 Jun 2026 23:00:02 +0000 Subject: [PATCH] Commit the control-panel terminal frontend (xterm) so clean builds keep it The terminal Alpine component + xterm/xterm-addon-fit only ever existed in an out-of-band build; committed source never had them, so clean CI rebuilds shipped a bundle WITHOUT the terminal (blank panel). Add resources/js/hosting-terminal.js (window.hostingInteractiveTerminal, recovered from the last working build), import it from app.js, and add xterm deps to package.json/lock. Verified: a clean 'npm ci && vite build' now includes the component + xterm. Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 23 +- package.json | 4 +- resources/js/app.js | 4 + resources/js/hosting-terminal.js | 556 +++++++++++++++++++++++++++++++ 4 files changed, 584 insertions(+), 3 deletions(-) create mode 100644 resources/js/hosting-terminal.js diff --git a/package-lock.json b/package-lock.json index 52a40bb..612100c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "ladill-email", + "name": "ladill-hosting", "lockfileVersion": 3, "requires": true, "packages": { @@ -7,7 +7,9 @@ "dependencies": { "@alpinejs/collapse": "^3.15.12", "@tailwindcss/forms": "^0.5.11", - "alpinejs": "^3.15.12" + "alpinejs": "^3.15.12", + "xterm": "^5.3.0", + "xterm-addon-fit": "^0.8.0" }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", @@ -2489,6 +2491,23 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/xterm": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz", + "integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==", + "deprecated": "This package is now deprecated. Move to @xterm/xterm instead.", + "license": "MIT" + }, + "node_modules/xterm-addon-fit": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz", + "integrity": "sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==", + "deprecated": "This package is now deprecated. Move to @xterm/addon-fit instead.", + "license": "MIT", + "peerDependencies": { + "xterm": "^5.0.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index fbc81f2..380bfce 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "dependencies": { "@alpinejs/collapse": "^3.15.12", "@tailwindcss/forms": "^0.5.11", - "alpinejs": "^3.15.12" + "alpinejs": "^3.15.12", + "xterm": "^5.3.0", + "xterm-addon-fit": "^0.8.0" } } diff --git a/resources/js/app.js b/resources/js/app.js index eefe37e..7086c8f 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,5 +1,9 @@ import './bootstrap'; +// Registers window.hostingInteractiveTerminal (xterm.js) used by the control-panel +// terminal blade. Must stay imported so clean builds include the terminal. +import './hosting-terminal'; + import { registerLadillConfirmStore, registerLadillModalHelpers } from './ladill-modals'; import { registerLadillSearchShortcut } from './ladill-search-shortcut'; import { registerLadillDomainPurchase } from './ladill-domain-purchase'; diff --git a/resources/js/hosting-terminal.js b/resources/js/hosting-terminal.js new file mode 100644 index 0000000..8ecf6b5 --- /dev/null +++ b/resources/js/hosting-terminal.js @@ -0,0 +1,556 @@ +// Browser terminal (xterm.js) Alpine factory for the hosting control panel. +// Exposed as a global so the panel blade can use x-data="hostingInteractiveTerminal({...})". +// NOTE: this frontend previously only existed in an out-of-band build and was +// lost on clean CI rebuilds — keep it committed. +import { Terminal } from 'xterm'; +import { FitAddon } from 'xterm-addon-fit'; +import 'xterm/css/xterm.css'; + +window.hostingInteractiveTerminal = (config = {}) => { + console.log('[Terminal] hostingInteractiveTerminal called with config:', config); + return { + sessionId: null, + offset: 0, + term: null, + fitAddon: null, + pollTimer: null, + resizeTimer: null, + inputTimer: null, + pendingInput: '', + commandLine: '', + overlayMessage: 'Connecting to shell...', + canReconnect: false, + statusLabel: 'Connecting', + terminalActive: false, + termHasOutput: false, + currentPath: config.initialPath ?? '/public_html', + currentPathLabel: `Starting in ${config.initialPath === '/' ? `/home/${config.username ?? ''}` : `/home/${config.username ?? ''}${config.initialPath ?? '/public_html'}`}`, + promptPath() { + if (this.currentPath === '/' || !this.currentPath) { + return '~'; + } + + return `~${this.currentPath}`; + }, + writePrompt(command = '') { + const prompt = `\x1b[36m${config.username ?? 'user'}@ladill:${this.promptPath()}$ \x1b[0m`; + const suffix = command ? `${command}\r\n` : ''; + this.term?.write(`${prompt}${suffix}`); + }, + + init() { + this.term = new Terminal({ + cursorBlink: true, + cursorStyle: 'bar', + convertEol: true, + allowTransparency: true, + disableStdin: false, + fontFamily: '"JetBrains Mono", "SF Mono", "Fira Code", "Monaco", "Consolas", monospace', + fontSize: 13, + lineHeight: 1.5, + letterSpacing: 0, + theme: { + background: '#0a0a0a', + foreground: '#e4e4e7', + cursor: '#a1a1aa', + cursorAccent: '#0a0a0a', + selectionBackground: 'rgba(161, 161, 170, 0.2)', + selectionForeground: '#fafafa', + black: '#18181b', + red: '#f87171', + green: '#4ade80', + yellow: '#fbbf24', + blue: '#60a5fa', + magenta: '#c084fc', + cyan: '#22d3ee', + white: '#e4e4e7', + brightBlack: '#52525b', + brightRed: '#fca5a5', + brightGreen: '#86efac', + brightYellow: '#fde047', + brightBlue: '#93c5fd', + brightMagenta: '#d8b4fe', + brightCyan: '#67e8f9', + brightWhite: '#fafafa', + }, + }); + + console.log('[Terminal] Initializing terminal...'); + this.fitAddon = new FitAddon(); + this.term.loadAddon(this.fitAddon); + this.term.open(this.$refs.terminal); + this.term.onData((data) => { + if (this.sessionId) { + this.queueInput(data); + } + }); + + // Delay initial fit to ensure container has dimensions + requestAnimationFrame(() => { + this.fitAddon.fit(); + this.term.focus(); + }); + + // Use ResizeObserver for more reliable resize detection + if (typeof ResizeObserver !== 'undefined') { + this.resizeObserver = new ResizeObserver(() => this.queueResize()); + this.resizeObserver.observe(this.$refs.terminal); + } + + window.addEventListener('resize', () => this.queueResize()); + window.addEventListener('beforeunload', () => this.shutdown(true)); + + this.start(); + }, + + async start() { + this.statusLabel = 'Connecting'; + this.overlayMessage = 'Connecting to shell...'; + this.canReconnect = false; + + try { + const response = await fetch(config.startUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-CSRF-TOKEN': config.csrfToken, + }, + body: JSON.stringify({ + path: config.initialPath ?? '/public_html', + cols: this.term.cols, + rows: this.term.rows, + }), + }); + + const payload = await response.json(); + + if (!response.ok || !payload.session_id) { + throw new Error(payload.message || 'Unable to start the browser terminal session.'); + } + + if (payload.status === 'failed') { + throw new Error(payload.error || 'Unable to start the browser terminal session.'); + } + + this.sessionId = payload.session_id; + this.offset = 0; + this.statusLabel = payload.status === 'starting' ? 'Connecting' : 'Live'; + this.overlayMessage = payload.status === 'starting' ? 'Connecting to shell...' : ''; + this.canReconnect = false; + this.queueResize(); + this.poll(); + // Delay focus to ensure terminal is fully rendered + setTimeout(() => this.focusTerminal(), 100); + } catch (error) { + this.statusLabel = 'Unavailable'; + this.overlayMessage = error.message || 'Unable to connect to the browser terminal.'; + this.canReconnect = true; + } + }, + + buildUrl(template) { + return String(template || '').replace('__SESSION__', this.sessionId ?? ''); + }, + + bindTerminalInputCapture() { + console.log('[Terminal] bindTerminalInputCapture called'); + const terminalElement = this.$refs.terminal; + + if (!terminalElement || terminalElement.dataset.keyboardCaptureBound === 'true') { + console.log('[Terminal] Already bound or no element'); + return; + } + + this.boundTerminalKeydownHandler = (event) => this.handleTerminalKeydown(event); + this.boundTerminalPasteHandler = (event) => this.handleTerminalPaste(event); + this.boundTerminalPointerHandler = (event) => { + this.terminalActive = this.$refs.terminal?.contains(event.target) ?? false; + }; + + window.addEventListener('keydown', this.boundTerminalKeydownHandler, true); + window.addEventListener('paste', this.boundTerminalPasteHandler, true); + window.addEventListener('pointerdown', this.boundTerminalPointerHandler, true); + terminalElement.dataset.keyboardCaptureBound = 'true'; + console.log('[Terminal] Input capture bound successfully'); + }, + + queueInput(data) { + if (!this.sessionId || !data) { + return; + } + + this.pendingInput += data; + + if (this.inputTimer) { + return; + } + + this.inputTimer = window.setTimeout(() => this.flushInput(), 16); + }, + + async flushInput() { + const payload = this.pendingInput; + this.pendingInput = ''; + this.inputTimer = null; + + if (!this.sessionId || payload === '') { + return; + } + + console.log('[Terminal] Sending input:', JSON.stringify(payload)); + + try { + const response = await fetch(this.buildUrl(config.inputUrlTemplate), { + method: 'POST', + headers: { + 'Content-Type': 'text/plain;charset=UTF-8', + Accept: 'application/json', + 'X-CSRF-TOKEN': config.csrfToken, + }, + body: payload, + }); + console.log('[Terminal] Input response:', response.status); + } catch (e) { + console.error('[Terminal] Input error:', e); + this.term?.write('\r\n[Input delivery failed]\r\n'); + } + }, + + handleTerminalKeydown(event) { + console.log('[Terminal] keydown:', event.key, 'terminalActive:', this.terminalActive, 'sessionId:', !!this.sessionId); + + if (!this.shouldCaptureTerminalInput(event)) { + console.log('[Terminal] Not capturing input'); + return; + } + + const sequence = this.keyEventSequence(event); + console.log('[Terminal] sequence:', sequence ? JSON.stringify(sequence) : 'null'); + + if (sequence === null) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation?.(); + this.queueInput(sequence); + }, + + handleTerminalPaste(event) { + if (!this.shouldCaptureTerminalInput(event)) { + return; + } + + const text = event.clipboardData?.getData('text/plain') ?? ''; + + if (text === '') { + return; + } + + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation?.(); + this.queueInput(text.replace(/\r?\n/g, '\r')); + }, + + shouldCaptureTerminalInput(event) { + if (!this.sessionId || !this.terminalActive) { + return false; + } + + const target = event.target; + const terminalElement = this.$refs.terminal; + + if (terminalElement && target instanceof Node && terminalElement.contains(target)) { + return true; + } + + if (!(target instanceof HTMLElement)) { + return false; + } + + return !target.closest('input, textarea, select, button, [contenteditable="true"], a[href]'); + }, + + keyEventSequence(event) { + if (!this.sessionId || event.metaKey) { + return null; + } + + if (event.ctrlKey && !event.altKey) { + const key = String(event.key || '').toLowerCase(); + + if (key >= 'a' && key <= 'z') { + return String.fromCharCode(key.charCodeAt(0) - 96); + } + + switch (key) { + case ' ': + case '@': + return '\x00'; + case '[': + return '\x1b'; + case '\\': + return '\x1c'; + case ']': + return '\x1d'; + case '^': + return '\x1e'; + case '_': + return '\x1f'; + default: + return null; + } + } + + if (event.altKey && !event.ctrlKey && String(event.key || '').length === 1) { + return `\x1b${event.key}`; + } + + switch (event.key) { + case 'Enter': + return '\r'; + case 'Backspace': + return '\x7f'; + case 'Tab': + return event.shiftKey ? '\x1b[Z' : '\t'; + case 'Escape': + return '\x1b'; + case 'ArrowUp': + return '\x1b[A'; + case 'ArrowDown': + return '\x1b[B'; + case 'ArrowRight': + return '\x1b[C'; + case 'ArrowLeft': + return '\x1b[D'; + case 'Home': + return '\x1b[H'; + case 'End': + return '\x1b[F'; + case 'Insert': + return '\x1b[2~'; + case 'Delete': + return '\x1b[3~'; + case 'PageUp': + return '\x1b[5~'; + case 'PageDown': + return '\x1b[6~'; + default: + return event.ctrlKey || event.altKey || String(event.key || '').length !== 1 + ? null + : event.key; + } + }, + + poll() { + if (!this.sessionId) { + return; + } + + const url = new URL(this.buildUrl(config.readUrlTemplate), window.location.origin); + url.searchParams.set('offset', String(this.offset)); + + fetch(url.toString(), { + headers: { + Accept: 'application/json', + 'X-CSRF-TOKEN': config.csrfToken, + }, + }) + .then(async (response) => { + const payload = await response.json().catch(() => ({})); + console.log('[Terminal] Poll response:', response.status, 'output length:', payload.output?.length ?? 0, 'status:', payload.status); + + if (!response.ok) { + throw new Error(payload.message || 'Unable to read terminal output.'); + } + + if (typeof payload.output === 'string' && payload.output.length > 0) { + console.log('[Terminal] Writing output:', JSON.stringify(payload.output.substring(0, 100))); + this.term?.write(payload.output); + this.termHasOutput = true; + this.overlayMessage = ''; + this.statusLabel = 'Live'; + } + + if (typeof payload.offset === 'number') { + this.offset = payload.offset; + } + + if (payload.status === 'starting') { + if (!this.termHasOutput) { + this.statusLabel = 'Connecting'; + this.overlayMessage = 'Connecting to shell...'; + } + this.pollTimer = window.setTimeout(() => this.poll(), 120); + + return; + } + + if (payload.status === 'closed' || payload.status === 'failed') { + this.statusLabel = payload.status === 'failed' ? 'Failed' : 'Closed'; + this.overlayMessage = payload.error || 'Shell session ended.'; + this.canReconnect = true; + this.sessionId = null; + return; + } + + this.statusLabel = 'Live'; + this.pollTimer = window.setTimeout(() => this.poll(), 120); + }) + .catch((error) => { + this.statusLabel = 'Disconnected'; + this.overlayMessage = error.message || 'Terminal connection lost.'; + this.canReconnect = true; + this.sessionId = null; + }); + }, + + queueResize() { + if (!this.term || !this.fitAddon) { + return; + } + + window.clearTimeout(this.resizeTimer); + this.resizeTimer = window.setTimeout(() => { + this.fitAddon.fit(); + + if (!this.sessionId) { + return; + } + + fetch(this.buildUrl(config.resizeUrlTemplate), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-CSRF-TOKEN': config.csrfToken, + }, + body: JSON.stringify({ + cols: this.term.cols, + rows: this.term.rows, + }), + }).catch(() => {}); + }, 120); + }, + + async sendCommand(command) { + const value = String(command || '').trim(); + if (!value) { + return; + } + + this.commandLine = ''; + this.focusCommandInput(); + + this.term?.write('\r\n'); + this.writePrompt(value); + + try { + const response = await fetch(config.runUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-CSRF-TOKEN': config.csrfToken, + }, + body: JSON.stringify({ + command: value, + path: this.currentPath, + }), + }); + + const payload = await response.json().catch(() => ({})); + const output = typeof payload.output === 'string' + ? payload.output + : (typeof payload.error === 'string' ? payload.error : ''); + + if (typeof payload.cwd === 'string' && payload.cwd.length > 0) { + this.currentPath = payload.cwd; + this.currentPathLabel = this.currentPath === '/' + ? `/home/${config.username ?? ''}` + : `/home/${config.username ?? ''}${this.currentPath}`; + } + + if (payload.clear) { + this.term?.reset(); + } + + if (output) { + this.term?.write(`${output.replace(/\n/g, '\r\n')}\r\n`); + } + + if (!response.ok && !output) { + this.term?.write('\x1b[31mCommand failed.\x1b[0m\r\n'); + } + + this.writePrompt(); + } catch (error) { + this.term?.write(`\x1b[31m${error.message || 'Command failed.'}\x1b[0m\r\n`); + this.writePrompt(); + } + }, + + submitCommand() { + const command = String(this.commandLine || '').trim(); + if (!command) { + this.term?.focus(); + return; + } + + this.sendCommand(command); + }, + + async restart() { + this.shutdown(); + this.term?.reset(); + this.pendingInput = ''; + this.commandLine = ''; + this.offset = 0; + this.terminalActive = false; + this.termHasOutput = false; + await this.start(); + }, + + focusTerminal() { + console.log('[Terminal] focusTerminal called'); + if (this.term) { + this.terminalActive = true; + this.$refs.terminal?.focus(); + this.term.focus(); + console.log('[Terminal] terminalActive set to true'); + } + }, + + focusCommandInput() { + this.$nextTick(() => this.$refs.commandInput?.focus()); + }, + + shutdown(keepalive = false) { + if (this.pollTimer) { + window.clearTimeout(this.pollTimer); + this.pollTimer = null; + } + + if (!this.sessionId) { + return; + } + + const sessionId = this.sessionId; + this.sessionId = null; + this.terminalActive = false; + + fetch(String(config.closeUrlTemplate || '').replace('__SESSION__', sessionId), { + method: 'DELETE', + headers: { + Accept: 'application/json', + 'X-CSRF-TOKEN': config.csrfToken, + }, + keepalive, + }).catch(() => {}); + }, +}; +}; +