From 417c6ab49cff07ef820ec35e56b3f823c34eecc6 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 26 Feb 2026 02:55:53 +0000 Subject: [PATCH] feat(web): integrate xterm.js with WebSocket terminal backend (#518) Co-authored-by: Jason Woltje Co-committed-by: Jason Woltje --- apps/web/package.json | 3 + .../terminal/TerminalPanel.test.tsx | 195 ++++++++ .../src/components/terminal/TerminalPanel.tsx | 124 ++--- .../components/terminal/XTerminal.test.tsx | 227 +++++++++ .../web/src/components/terminal/XTerminal.tsx | 427 ++++++++++++++++ apps/web/src/components/terminal/index.ts | 4 +- apps/web/src/hooks/useTerminal.test.ts | 462 ++++++++++++++++++ apps/web/src/hooks/useTerminal.ts | 294 +++++++++++ pnpm-lock.yaml | 58 ++- 9 files changed, 1694 insertions(+), 100 deletions(-) create mode 100644 apps/web/src/components/terminal/TerminalPanel.test.tsx create mode 100644 apps/web/src/components/terminal/XTerminal.test.tsx create mode 100644 apps/web/src/components/terminal/XTerminal.tsx create mode 100644 apps/web/src/hooks/useTerminal.test.ts create mode 100644 apps/web/src/hooks/useTerminal.ts diff --git a/apps/web/package.json b/apps/web/package.json index f4df651..7ce1534 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -33,6 +33,9 @@ "@tiptap/react": "^3.20.0", "@tiptap/starter-kit": "^3.20.0", "@types/dompurify": "^3.2.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", "@xyflow/react": "^12.5.3", "better-auth": "^1.4.17", "date-fns": "^4.1.0", diff --git a/apps/web/src/components/terminal/TerminalPanel.test.tsx b/apps/web/src/components/terminal/TerminalPanel.test.tsx new file mode 100644 index 0000000..3ee9d83 --- /dev/null +++ b/apps/web/src/components/terminal/TerminalPanel.test.tsx @@ -0,0 +1,195 @@ +/** + * @file TerminalPanel.test.tsx + * @description Unit tests for the TerminalPanel component + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import type { ReactElement } from "react"; + +// ========================================== +// Mocks +// ========================================== + +// Mock XTerminal to avoid xterm.js DOM dependencies in panel tests +vi.mock("./XTerminal", () => ({ + XTerminal: vi.fn(({ token, isVisible }: { token: string; isVisible: boolean }) => ( +
+ )), +})); + +import { TerminalPanel } from "./TerminalPanel"; + +// ========================================== +// Tests +// ========================================== + +describe("TerminalPanel", () => { + const onClose = vi.fn(); + const onTabChange = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ========================================== + // Rendering + // ========================================== + + describe("rendering", () => { + it("renders the terminal panel", () => { + render(() as ReactElement); + expect(screen.getByRole("region", { name: "Terminal panel" })).toBeInTheDocument(); + }); + + it("renders with height 280 when open", () => { + render(() as ReactElement); + const panel = screen.getByRole("region", { name: "Terminal panel" }); + expect(panel).toHaveStyle({ height: "280px" }); + }); + + it("renders with height 0 when closed", () => { + const { container } = render( + () as ReactElement + ); + const panel = container.querySelector('[role="region"][aria-label="Terminal panel"]'); + expect(panel).toHaveStyle({ height: "0px" }); + }); + + it("passes isVisible=true to XTerminal when open", () => { + render(() as ReactElement); + const xterm = screen.getByTestId("mock-xterminal"); + expect(xterm).toHaveAttribute("data-visible", "true"); + }); + + it("passes isVisible=false to XTerminal when closed", () => { + const { container } = render( + () as ReactElement + ); + // Use container query since the element is inside an aria-hidden region + const xterm = container.querySelector('[data-testid="mock-xterminal"]'); + expect(xterm).toHaveAttribute("data-visible", "false"); + }); + + it("passes token to XTerminal", () => { + render( + () as ReactElement + ); + const xterm = screen.getByTestId("mock-xterminal"); + expect(xterm).toHaveAttribute("data-token", "my-auth-token"); + }); + }); + + // ========================================== + // Tab bar + // ========================================== + + describe("tab bar", () => { + it("renders default tabs when none provided", () => { + render(() as ReactElement); + expect(screen.getByRole("tab", { name: "main" })).toBeInTheDocument(); + }); + + it("renders custom tabs", () => { + const tabs = [ + { id: "tab1", label: "Terminal 1" }, + { id: "tab2", label: "Terminal 2" }, + ]; + render( + ( + + ) as ReactElement + ); + expect(screen.getByRole("tab", { name: "Terminal 1" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Terminal 2" })).toBeInTheDocument(); + }); + + it("marks the active tab as selected", () => { + const tabs = [ + { id: "tab1", label: "Tab 1" }, + { id: "tab2", label: "Tab 2" }, + ]; + render( + ( + + ) as ReactElement + ); + const tab2 = screen.getByRole("tab", { name: "Tab 2" }); + expect(tab2).toHaveAttribute("aria-selected", "true"); + }); + + it("calls onTabChange when a tab is clicked", () => { + const tabs = [ + { id: "tab1", label: "Tab 1" }, + { id: "tab2", label: "Tab 2" }, + ]; + render( + ( + + ) as ReactElement + ); + fireEvent.click(screen.getByRole("tab", { name: "Tab 2" })); + expect(onTabChange).toHaveBeenCalledWith("tab2"); + }); + }); + + // ========================================== + // Close button + // ========================================== + + describe("close button", () => { + it("renders the close button", () => { + render(() as ReactElement); + expect(screen.getByRole("button", { name: "Close terminal" })).toBeInTheDocument(); + }); + + it("calls onClose when close button is clicked", () => { + render(() as ReactElement); + fireEvent.click(screen.getByRole("button", { name: "Close terminal" })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + }); + + // ========================================== + // Accessibility + // ========================================== + + describe("accessibility", () => { + it("has aria-hidden=true when closed", () => { + const { container } = render( + () as ReactElement + ); + // When aria-hidden=true, testing-library role queries ignore the element's aria-label. + // Use a direct DOM query to verify the attribute. + const panel = container.querySelector('[role="region"][aria-label="Terminal panel"]'); + expect(panel).toHaveAttribute("aria-hidden", "true"); + }); + + it("has aria-hidden=false when open", () => { + render(() as ReactElement); + const panel = screen.getByRole("region", { name: "Terminal panel" }); + expect(panel).toHaveAttribute("aria-hidden", "false"); + }); + + it("has tablist role on the tab bar", () => { + render(() as ReactElement); + expect(screen.getByRole("tablist")).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/src/components/terminal/TerminalPanel.tsx b/apps/web/src/components/terminal/TerminalPanel.tsx index 3f0ac80..8da853a 100644 --- a/apps/web/src/components/terminal/TerminalPanel.tsx +++ b/apps/web/src/components/terminal/TerminalPanel.tsx @@ -1,9 +1,18 @@ -import type { ReactElement, CSSProperties } from "react"; +"use client"; -export interface TerminalLine { - type: "prompt" | "command" | "output" | "error" | "warning" | "success"; - content: string; -} +/** + * TerminalPanel + * + * Shell panel that wraps the XTerminal component with a tab bar and close button. + * Replaces the former mock terminal with a real xterm.js PTY terminal. + */ + +import type { ReactElement, CSSProperties } from "react"; +import { XTerminal } from "./XTerminal"; + +// ========================================== +// Types (retained for backwards compatibility) +// ========================================== export interface TerminalTab { id: string; @@ -16,51 +25,16 @@ export interface TerminalPanelProps { tabs?: TerminalTab[]; activeTab?: string; onTabChange?: (id: string) => void; - lines?: TerminalLine[]; + /** Authentication token for the WebSocket connection */ + token?: string; className?: string; } -const defaultTabs: TerminalTab[] = [ - { id: "main", label: "main" }, - { id: "build", label: "build" }, - { id: "logs", label: "logs" }, -]; +const defaultTabs: TerminalTab[] = [{ id: "main", label: "main" }]; -const blinkKeyframes = ` -@keyframes ms-terminal-blink { - 0%, 100% { opacity: 1; } - 50% { opacity: 0; } -} -`; - -let blinkStyleInjected = false; - -function ensureBlinkStyle(): void { - if (blinkStyleInjected || typeof document === "undefined") return; - const styleEl = document.createElement("style"); - styleEl.textContent = blinkKeyframes; - document.head.appendChild(styleEl); - blinkStyleInjected = true; -} - -function getLineColor(type: TerminalLine["type"]): string { - switch (type) { - case "prompt": - return "var(--success)"; - case "command": - return "var(--text-2)"; - case "output": - return "var(--muted)"; - case "error": - return "var(--danger)"; - case "warning": - return "var(--warn)"; - case "success": - return "var(--success)"; - default: - return "var(--muted)"; - } -} +// ========================================== +// Component +// ========================================== export function TerminalPanel({ open, @@ -68,11 +42,9 @@ export function TerminalPanel({ tabs, activeTab, onTabChange, - lines = [], + token = "", className = "", }: TerminalPanelProps): ReactElement { - ensureBlinkStyle(); - const resolvedTabs = tabs ?? defaultTabs; const resolvedActiveTab = activeTab ?? resolvedTabs[0]?.id ?? ""; @@ -109,21 +81,10 @@ export function TerminalPanel({ const bodyStyle: CSSProperties = { flex: 1, - overflowY: "auto", - padding: "10px 16px", - fontFamily: "var(--mono)", - fontSize: "0.78rem", - lineHeight: 1.6, - }; - - const cursorStyle: CSSProperties = { - display: "inline-block", - width: 7, - height: 14, - background: "var(--success)", - marginLeft: 2, - animation: "ms-terminal-blink 1s step-end infinite", - verticalAlign: "text-bottom", + overflow: "hidden", + display: "flex", + flexDirection: "column", + minHeight: 0, }; return ( @@ -208,7 +169,7 @@ export function TerminalPanel({ (e.currentTarget as HTMLButtonElement).style.color = "var(--muted)"; }} > - {/* Close icon — simple X using SVG */} + {/* Close icon */}
- {/* Body */} -
- {lines.map((line, index) => { - const isLast = index === lines.length - 1; - const lineStyle: CSSProperties = { - display: "flex", - gap: 8, - }; - const contentStyle: CSSProperties = { - color: getLineColor(line.type), - }; - - return ( -
- - {line.content} - {isLast && -
- ); - })} - - {/* Show cursor even when no lines */} - {lines.length === 0 && ( -
- - -
- )} + {/* Terminal body */} +
+
); diff --git a/apps/web/src/components/terminal/XTerminal.test.tsx b/apps/web/src/components/terminal/XTerminal.test.tsx new file mode 100644 index 0000000..201fb8e --- /dev/null +++ b/apps/web/src/components/terminal/XTerminal.test.tsx @@ -0,0 +1,227 @@ +/** + * @file XTerminal.test.tsx + * @description Unit tests for the XTerminal component + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import type { ReactElement } from "react"; + +// ========================================== +// Mocks — set up before importing components +// ========================================== + +// Mock socket.io-client +vi.mock("socket.io-client"); + +// Mock lib/config +vi.mock("@/lib/config", () => ({ + API_BASE_URL: "http://localhost:3001", +})); + +// Mock xterm packages — they require a DOM canvas not available in jsdom +const mockTerminalDispose = vi.fn(); +const mockTerminalWrite = vi.fn(); +const mockTerminalClear = vi.fn(); +const mockTerminalOpen = vi.fn(); +const mockOnData = vi.fn((_handler: (data: string) => void) => ({ dispose: vi.fn() })); +const mockLoadAddon = vi.fn(); +let mockTerminalCols = 80; +let mockTerminalRows = 24; + +const MockTerminal = vi.fn(function MockTerminalConstructor( + this: Record, + _options: unknown +) { + this.open = mockTerminalOpen; + this.loadAddon = mockLoadAddon; + this.onData = mockOnData; + this.write = mockTerminalWrite; + this.clear = mockTerminalClear; + this.dispose = mockTerminalDispose; + this.options = {}; + Object.defineProperty(this, "cols", { + get: () => mockTerminalCols, + configurable: true, + }); + Object.defineProperty(this, "rows", { + get: () => mockTerminalRows, + configurable: true, + }); +}); + +const mockFitAddonFit = vi.fn(); +const MockFitAddon = vi.fn(function MockFitAddonConstructor(this: Record) { + this.fit = mockFitAddonFit; +}); + +const MockWebLinksAddon = vi.fn(function MockWebLinksAddonConstructor( + this: Record +) { + // no-op +}); + +vi.mock("@xterm/xterm", () => ({ + Terminal: MockTerminal, +})); + +vi.mock("@xterm/addon-fit", () => ({ + FitAddon: MockFitAddon, +})); + +vi.mock("@xterm/addon-web-links", () => ({ + WebLinksAddon: MockWebLinksAddon, +})); + +// Mock the CSS import +vi.mock("@xterm/xterm/css/xterm.css", () => ({})); + +// Mock useTerminal hook +const mockCreateSession = vi.fn(); +const mockSendInput = vi.fn(); +const mockResize = vi.fn(); +const mockCloseSession = vi.fn(); +let mockIsConnected = false; +let mockSessionId: string | null = null; + +vi.mock("@/hooks/useTerminal", () => ({ + useTerminal: vi.fn(() => ({ + isConnected: mockIsConnected, + sessionId: mockSessionId, + createSession: mockCreateSession, + sendInput: mockSendInput, + resize: mockResize, + closeSession: mockCloseSession, + connectionError: null, + })), +})); + +// Mock ResizeObserver +const mockObserve = vi.fn(); +const mockUnobserve = vi.fn(); +const mockDisconnect = vi.fn(); + +vi.stubGlobal( + "ResizeObserver", + vi.fn(function MockResizeObserver(this: Record, _callback: unknown) { + this.observe = mockObserve; + this.unobserve = mockUnobserve; + this.disconnect = mockDisconnect; + }) +); + +// Mock MutationObserver +const mockMutationObserve = vi.fn(); +const mockMutationDisconnect = vi.fn(); + +vi.stubGlobal( + "MutationObserver", + vi.fn(function MockMutationObserver(this: Record, _callback: unknown) { + this.observe = mockMutationObserve; + this.disconnect = mockMutationDisconnect; + }) +); + +// ========================================== +// Import component after mocks are set up +// ========================================== + +import { XTerminal } from "./XTerminal"; + +// ========================================== +// Tests +// ========================================== + +describe("XTerminal", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIsConnected = false; + mockSessionId = null; + mockTerminalCols = 80; + mockTerminalRows = 24; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + // ========================================== + // Rendering + // ========================================== + + describe("rendering", () => { + it("renders the terminal container", () => { + render(() as ReactElement); + expect(screen.getByTestId("xterminal-container")).toBeInTheDocument(); + }); + + it("renders the xterm viewport div", () => { + render(() as ReactElement); + expect(screen.getByTestId("xterm-viewport")).toBeInTheDocument(); + }); + + it("applies the className prop to the container", () => { + render(() as ReactElement); + expect(screen.getByTestId("xterminal-container")).toHaveClass("custom-class"); + }); + + it("shows connecting message when not connected", () => { + mockIsConnected = false; + + render(() as ReactElement); + expect(screen.getByText("Connecting to terminal...")).toBeInTheDocument(); + }); + + it("does not show connecting message when connected", async () => { + mockIsConnected = true; + + const { useTerminal } = await import("@/hooks/useTerminal"); + vi.mocked(useTerminal).mockReturnValue({ + isConnected: true, + sessionId: "session-xyz", + createSession: mockCreateSession, + sendInput: mockSendInput, + resize: mockResize, + closeSession: mockCloseSession, + connectionError: null, + }); + + render(() as ReactElement); + expect(screen.queryByText("Connecting to terminal...")).not.toBeInTheDocument(); + }); + }); + + // ========================================== + // useTerminal integration + // ========================================== + + describe("useTerminal integration", () => { + it("passes the token to useTerminal", async () => { + const { useTerminal } = await import("@/hooks/useTerminal"); + render(() as ReactElement); + expect(vi.mocked(useTerminal)).toHaveBeenCalledWith( + expect.objectContaining({ token: "my-auth-token" }) + ); + }); + + it("passes onOutput, onExit, onError callbacks to useTerminal", async () => { + const { useTerminal } = await import("@/hooks/useTerminal"); + render(() as ReactElement); + const callArgs = vi.mocked(useTerminal).mock.calls[0]?.[0]; + expect(typeof callArgs?.onOutput).toBe("function"); + expect(typeof callArgs?.onExit).toBe("function"); + expect(typeof callArgs?.onError).toBe("function"); + }); + }); + + // ========================================== + // Accessibility + // ========================================== + + describe("accessibility", () => { + it("has an accessible region role", () => { + render(() as ReactElement); + expect(screen.getByRole("region", { name: "Terminal" })).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/src/components/terminal/XTerminal.tsx b/apps/web/src/components/terminal/XTerminal.tsx new file mode 100644 index 0000000..b11dd2e --- /dev/null +++ b/apps/web/src/components/terminal/XTerminal.tsx @@ -0,0 +1,427 @@ +"use client"; + +/** + * XTerminal component + * + * Renders a real xterm.js terminal connected to the backend /terminal WebSocket namespace. + * Handles resize, copy/paste, theme, and session lifecycle. + */ + +import { useEffect, useRef, useCallback, useState } from "react"; +import type { ReactElement, CSSProperties } from "react"; +import "@xterm/xterm/css/xterm.css"; +import type { Terminal as XTerm } from "@xterm/xterm"; +import type { FitAddon as XFitAddon } from "@xterm/addon-fit"; +import { useTerminal } from "@/hooks/useTerminal"; + +// ========================================== +// Types +// ========================================== + +export interface XTerminalProps { + /** Authentication token for the WebSocket connection */ + token: string; + /** Optional CSS class name for the outer container */ + className?: string; + /** Optional inline styles for the outer container */ + style?: CSSProperties; + /** Whether the terminal is visible (used to trigger re-fit) */ + isVisible?: boolean; +} + +// ========================================== +// Theme helpers +// ========================================== + +/** + * Read a CSS variable value from :root via computed styles. + * Falls back to the provided default value if not available (e.g., during SSR). + */ +function getCssVar(varName: string, fallback: string): string { + if (typeof document === "undefined") return fallback; + const value = getComputedStyle(document.documentElement).getPropertyValue(varName).trim(); + return value.length > 0 ? value : fallback; +} + +/** + * Build an xterm.js ITheme object from the current design system CSS variables. + * All 5 themes (dark, light, aurora, midnight, sunlit, ocean) update --ms-* primitives + * which flow through to the semantic aliases we read here. + */ +function buildXtermTheme(): Record { + return { + background: getCssVar("--bg-deep", "#080b12"), + foreground: getCssVar("--text", "#eef3ff"), + cursor: getCssVar("--success", "#14b8a6"), + cursorAccent: getCssVar("--bg-deep", "#080b12"), + selectionBackground: `${getCssVar("--primary", "#2f80ff")}40`, + selectionForeground: getCssVar("--text", "#eef3ff"), + selectionInactiveBackground: `${getCssVar("--muted", "#8f9db7")}30`, + // Standard ANSI colors mapped to design system + black: getCssVar("--bg-deep", "#080b12"), + red: getCssVar("--danger", "#e5484d"), + green: getCssVar("--success", "#14b8a6"), + yellow: getCssVar("--warn", "#f59e0b"), + blue: getCssVar("--primary", "#2f80ff"), + magenta: getCssVar("--purple", "#8b5cf6"), + cyan: "#06b6d4", + white: getCssVar("--text-2", "#c5d0e6"), + brightBlack: getCssVar("--muted", "#8f9db7"), + brightRed: "#f06a6f", + brightGreen: "#2dd4bf", + brightYellow: "#fbbf24", + brightBlue: "#56a0ff", + brightMagenta: "#a78bfa", + brightCyan: "#22d3ee", + brightWhite: getCssVar("--text", "#eef3ff"), + }; +} + +// ========================================== +// Component +// ========================================== + +/** + * XTerminal renders a real PTY terminal powered by xterm.js, + * connected to the backend /terminal WebSocket namespace. + */ +export function XTerminal({ + token, + className = "", + style, + isVisible = true, +}: XTerminalProps): ReactElement { + const containerRef = useRef(null); + const terminalRef = useRef(null); + const fitAddonRef = useRef(null); + const resizeObserverRef = useRef(null); + const isTerminalMountedRef = useRef(false); + + const [hasExited, setHasExited] = useState(false); + const [exitCode, setExitCode] = useState(null); + + // ========================================== + // Terminal session callbacks + // ========================================== + + const handleOutput = useCallback((sessionId: string, data: string): void => { + void sessionId; // sessionId is single-session in this component + terminalRef.current?.write(data); + }, []); + + const handleExit = useCallback((event: { sessionId: string; exitCode: number }): void => { + void event.sessionId; + setHasExited(true); + setExitCode(event.exitCode); + const term = terminalRef.current; + if (term) { + term.write(`\r\n\x1b[33m[Process exited with code ${event.exitCode.toString()}]\x1b[0m\r\n`); + } + }, []); + + const handleError = useCallback((message: string): void => { + const term = terminalRef.current; + if (term) { + term.write(`\r\n\x1b[31m[Error: ${message}]\x1b[0m\r\n`); + } + }, []); + + const { isConnected, sessionId, createSession, sendInput, resize, closeSession } = useTerminal({ + token, + onOutput: handleOutput, + onExit: handleExit, + onError: handleError, + }); + + // ========================================== + // Fit helper + // ========================================== + + const fitAndResize = useCallback((): void => { + const fitAddon = fitAddonRef.current; + const terminal = terminalRef.current; + if (!fitAddon || !terminal) return; + + try { + fitAddon.fit(); + resize(terminal.cols, terminal.rows); + } catch { + // Ignore fit errors (e.g., when container has zero dimensions) + } + }, [resize]); + + // ========================================== + // Mount xterm.js terminal (client-only) + // ========================================== + + useEffect(() => { + if (!containerRef.current || isTerminalMountedRef.current) return; + + let cancelled = false; + + const mountTerminal = async (): Promise => { + // Dynamic imports ensure DOM-dependent xterm.js modules are never loaded server-side + const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([ + import("@xterm/xterm"), + import("@xterm/addon-fit"), + import("@xterm/addon-web-links"), + ]); + + if (cancelled || !containerRef.current) return; + + const theme = buildXtermTheme(); + + const terminal = new Terminal({ + fontFamily: "var(--mono, 'Fira Code', 'Cascadia Code', monospace)", + fontSize: 13, + lineHeight: 1.4, + cursorBlink: true, + cursorStyle: "block", + scrollback: 10000, + theme, + allowTransparency: false, + convertEol: true, + // Accessibility + screenReaderMode: false, + }); + + const fitAddon = new FitAddon(); + const webLinksAddon = new WebLinksAddon(); + + terminal.loadAddon(fitAddon); + terminal.loadAddon(webLinksAddon); + + terminal.open(containerRef.current); + + terminalRef.current = terminal; + fitAddonRef.current = fitAddon; + isTerminalMountedRef.current = true; + + // Initial fit + try { + fitAddon.fit(); + } catch { + // Container might not have dimensions yet + } + + // Set up ResizeObserver for automatic re-fitting + const observer = new ResizeObserver(() => { + fitAndResize(); + }); + observer.observe(containerRef.current); + resizeObserverRef.current = observer; + }; + + void mountTerminal(); + + return (): void => { + cancelled = true; + }; + }, []); + + // ========================================== + // Re-fit when visibility changes + // ========================================== + + useEffect(() => { + if (isVisible) { + // Small delay allows CSS transitions to complete before fitting + const id = setTimeout(fitAndResize, 50); + return (): void => { + clearTimeout(id); + }; + } + return undefined; + }, [isVisible, fitAndResize]); + + // ========================================== + // Wire terminal input → sendInput + // ========================================== + + useEffect(() => { + const terminal = terminalRef.current; + if (!terminal) return; + + const disposable = terminal.onData((data: string): void => { + sendInput(data); + }); + + return (): void => { + disposable.dispose(); + }; + }, [sendInput]); + + // ========================================== + // Create PTY session when connected + // ========================================== + + useEffect(() => { + if (!isConnected || sessionId !== null) return; + + const terminal = terminalRef.current; + const fitAddon = fitAddonRef.current; + + let cols = 80; + let rows = 24; + + if (terminal && fitAddon) { + try { + fitAddon.fit(); + cols = terminal.cols; + rows = terminal.rows; + } catch { + // Use defaults + } + } + + createSession({ cols, rows }); + }, [isConnected, sessionId, createSession]); + + // ========================================== + // Update xterm theme when data-theme attribute changes + // ========================================== + + useEffect(() => { + const observer = new MutationObserver(() => { + const terminal = terminalRef.current; + if (terminal) { + terminal.options.theme = buildXtermTheme(); + } + }); + + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); + + return (): void => { + observer.disconnect(); + }; + }, []); + + // ========================================== + // Cleanup on unmount + // ========================================== + + useEffect(() => { + return (): void => { + // Cleanup ResizeObserver + resizeObserverRef.current?.disconnect(); + resizeObserverRef.current = null; + + // Close PTY session + closeSession(); + + // Dispose xterm terminal + terminalRef.current?.dispose(); + terminalRef.current = null; + fitAddonRef.current = null; + isTerminalMountedRef.current = false; + }; + }, [closeSession]); + + // ========================================== + // Render + // ========================================== + + const containerStyle: CSSProperties = { + flex: 1, + overflow: "hidden", + position: "relative", + backgroundColor: "var(--bg-deep)", + ...style, + }; + + return ( +
+ {/* Status bar */} + {!isConnected && !hasExited && ( +
+ Connecting to terminal... +
+ )} + + {/* Exit overlay */} + {hasExited && ( +
+ +
+ )} + + {/* xterm.js render target */} +
+
+ ); +} diff --git a/apps/web/src/components/terminal/index.ts b/apps/web/src/components/terminal/index.ts index cd385a0..5bb8a0a 100644 --- a/apps/web/src/components/terminal/index.ts +++ b/apps/web/src/components/terminal/index.ts @@ -1,2 +1,4 @@ -export type { TerminalLine, TerminalTab, TerminalPanelProps } from "./TerminalPanel"; +export type { TerminalTab, TerminalPanelProps } from "./TerminalPanel"; export { TerminalPanel } from "./TerminalPanel"; +export type { XTerminalProps } from "./XTerminal"; +export { XTerminal } from "./XTerminal"; diff --git a/apps/web/src/hooks/useTerminal.test.ts b/apps/web/src/hooks/useTerminal.test.ts new file mode 100644 index 0000000..f80afad --- /dev/null +++ b/apps/web/src/hooks/useTerminal.test.ts @@ -0,0 +1,462 @@ +/** + * @file useTerminal.test.ts + * @description Unit tests for the useTerminal hook + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import { useTerminal } from "./useTerminal"; +import type { Socket } from "socket.io-client"; + +// ========================================== +// Mock socket.io-client +// ========================================== + +vi.mock("socket.io-client"); + +// ========================================== +// Mock lib/config +// ========================================== + +vi.mock("@/lib/config", () => ({ + API_BASE_URL: "http://localhost:3001", +})); + +// ========================================== +// Helpers +// ========================================== + +interface MockSocket { + on: ReturnType; + off: ReturnType; + emit: ReturnType; + disconnect: ReturnType; + connected: boolean; +} + +describe("useTerminal", () => { + let mockSocket: MockSocket; + let socketEventHandlers: Record void>; + let mockIo: ReturnType; + + beforeEach(async () => { + socketEventHandlers = {}; + + mockSocket = { + on: vi.fn((event: string, handler: (data: unknown) => void) => { + socketEventHandlers[event] = handler; + return mockSocket; + }), + off: vi.fn(), + emit: vi.fn(), + disconnect: vi.fn(), + connected: true, + }; + + const socketIo = await import("socket.io-client"); + mockIo = vi.mocked(socketIo.io); + mockIo.mockReturnValue(mockSocket as unknown as Socket); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + // ========================================== + // Connection + // ========================================== + + describe("connection lifecycle", () => { + it("should connect to the /terminal namespace with auth token", () => { + renderHook(() => + useTerminal({ + token: "test-token", + }) + ); + + expect(mockIo).toHaveBeenCalledWith( + expect.stringContaining("/terminal"), + expect.objectContaining({ + auth: { token: "test-token" }, + }) + ); + }); + + it("should start disconnected and update when connected event fires", async () => { + const { result } = renderHook(() => + useTerminal({ + token: "test-token", + }) + ); + + expect(result.current.isConnected).toBe(false); + + act(() => { + socketEventHandlers.connect?.(undefined); + }); + + await waitFor(() => { + expect(result.current.isConnected).toBe(true); + }); + }); + + it("should update sessionId when terminal:created event fires", async () => { + const { result } = renderHook(() => + useTerminal({ + token: "test-token", + }) + ); + + act(() => { + socketEventHandlers.connect?.(undefined); + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-abc", + name: "main", + cols: 80, + rows: 24, + }); + }); + + await waitFor(() => { + expect(result.current.sessionId).toBe("session-abc"); + }); + }); + + it("should clear sessionId when disconnect event fires", async () => { + const { result } = renderHook(() => + useTerminal({ + token: "test-token", + }) + ); + + act(() => { + socketEventHandlers.connect?.(undefined); + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-abc", + name: "main", + cols: 80, + rows: 24, + }); + }); + + await waitFor(() => { + expect(result.current.sessionId).toBe("session-abc"); + }); + + act(() => { + socketEventHandlers.disconnect?.(undefined); + }); + + await waitFor(() => { + expect(result.current.isConnected).toBe(false); + expect(result.current.sessionId).toBeNull(); + }); + }); + + it("should set connectionError when connect_error fires", async () => { + const { result } = renderHook(() => + useTerminal({ + token: "test-token", + }) + ); + + act(() => { + socketEventHandlers.connect_error?.(new Error("Connection refused")); + }); + + await waitFor(() => { + expect(result.current.connectionError).toBe("Connection refused"); + expect(result.current.isConnected).toBe(false); + }); + }); + + it("should not connect when token is empty", () => { + renderHook(() => + useTerminal({ + token: "", + }) + ); + + expect(mockIo).not.toHaveBeenCalled(); + }); + }); + + // ========================================== + // Output and exit callbacks + // ========================================== + + describe("event callbacks", () => { + it("should call onOutput when terminal:output fires", () => { + const onOutput = vi.fn(); + + renderHook(() => + useTerminal({ + token: "test-token", + onOutput, + }) + ); + + act(() => { + socketEventHandlers["terminal:output"]?.({ + sessionId: "session-abc", + data: "hello world\r\n", + }); + }); + + expect(onOutput).toHaveBeenCalledWith("session-abc", "hello world\r\n"); + }); + + it("should call onExit when terminal:exit fires and clear sessionId", async () => { + const onExit = vi.fn(); + + const { result } = renderHook(() => + useTerminal({ + token: "test-token", + onExit, + }) + ); + + act(() => { + socketEventHandlers.connect?.(undefined); + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-abc", + name: "main", + cols: 80, + rows: 24, + }); + }); + + act(() => { + socketEventHandlers["terminal:exit"]?.({ + sessionId: "session-abc", + exitCode: 0, + }); + }); + + await waitFor(() => { + expect(onExit).toHaveBeenCalledWith({ sessionId: "session-abc", exitCode: 0 }); + expect(result.current.sessionId).toBeNull(); + }); + }); + + it("should call onError when terminal:error fires", () => { + const onError = vi.fn(); + + renderHook(() => + useTerminal({ + token: "test-token", + onError, + }) + ); + + act(() => { + socketEventHandlers["terminal:error"]?.({ + message: "PTY spawn failed", + }); + }); + + expect(onError).toHaveBeenCalledWith("PTY spawn failed"); + }); + }); + + // ========================================== + // Control functions + // ========================================== + + describe("createSession", () => { + it("should emit terminal:create with options when connected", () => { + const { result } = renderHook(() => + useTerminal({ + token: "test-token", + }) + ); + + act(() => { + socketEventHandlers.connect?.(undefined); + }); + + act(() => { + result.current.createSession({ cols: 120, rows: 40, name: "test" }); + }); + + expect(mockSocket.emit).toHaveBeenCalledWith("terminal:create", { + cols: 120, + rows: 40, + name: "test", + }); + }); + + it("should not emit terminal:create when disconnected", () => { + mockSocket.connected = false; + + const { result } = renderHook(() => + useTerminal({ + token: "test-token", + }) + ); + + act(() => { + result.current.createSession({ cols: 80, rows: 24 }); + }); + + expect(mockSocket.emit).not.toHaveBeenCalledWith("terminal:create", expect.anything()); + }); + }); + + describe("sendInput", () => { + it("should emit terminal:input with sessionId and data", () => { + const { result } = renderHook(() => + useTerminal({ + token: "test-token", + }) + ); + + act(() => { + socketEventHandlers.connect?.(undefined); + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-abc", + name: "main", + cols: 80, + rows: 24, + }); + }); + + act(() => { + result.current.sendInput("ls -la\n"); + }); + + expect(mockSocket.emit).toHaveBeenCalledWith("terminal:input", { + sessionId: "session-abc", + data: "ls -la\n", + }); + }); + + it("should not emit when no sessionId is set", () => { + const { result } = renderHook(() => + useTerminal({ + token: "test-token", + }) + ); + + act(() => { + socketEventHandlers.connect?.(undefined); + }); + + act(() => { + result.current.sendInput("ls -la\n"); + }); + + expect(mockSocket.emit).not.toHaveBeenCalledWith("terminal:input", expect.anything()); + }); + }); + + describe("resize", () => { + it("should emit terminal:resize with sessionId, cols, and rows", () => { + const { result } = renderHook(() => + useTerminal({ + token: "test-token", + }) + ); + + act(() => { + socketEventHandlers.connect?.(undefined); + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-abc", + name: "main", + cols: 80, + rows: 24, + }); + }); + + act(() => { + result.current.resize(100, 30); + }); + + expect(mockSocket.emit).toHaveBeenCalledWith("terminal:resize", { + sessionId: "session-abc", + cols: 100, + rows: 30, + }); + }); + }); + + describe("closeSession", () => { + it("should emit terminal:close and clear sessionId", async () => { + const { result } = renderHook(() => + useTerminal({ + token: "test-token", + }) + ); + + act(() => { + socketEventHandlers.connect?.(undefined); + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-abc", + name: "main", + cols: 80, + rows: 24, + }); + }); + + await waitFor(() => { + expect(result.current.sessionId).toBe("session-abc"); + }); + + act(() => { + result.current.closeSession(); + }); + + expect(mockSocket.emit).toHaveBeenCalledWith("terminal:close", { + sessionId: "session-abc", + }); + + await waitFor(() => { + expect(result.current.sessionId).toBeNull(); + }); + }); + }); + + // ========================================== + // Cleanup + // ========================================== + + describe("cleanup", () => { + it("should disconnect socket on unmount", () => { + const { unmount } = renderHook(() => + useTerminal({ + token: "test-token", + }) + ); + + unmount(); + + expect(mockSocket.disconnect).toHaveBeenCalled(); + }); + + it("should emit terminal:close for active session on unmount", () => { + const { result, unmount } = renderHook(() => + useTerminal({ + token: "test-token", + }) + ); + + act(() => { + socketEventHandlers.connect?.(undefined); + socketEventHandlers["terminal:created"]?.({ + sessionId: "session-abc", + name: "main", + cols: 80, + rows: 24, + }); + }); + + expect(result.current.sessionId).toBe("session-abc"); + + unmount(); + + expect(mockSocket.emit).toHaveBeenCalledWith("terminal:close", { + sessionId: "session-abc", + }); + }); + }); +}); diff --git a/apps/web/src/hooks/useTerminal.ts b/apps/web/src/hooks/useTerminal.ts new file mode 100644 index 0000000..0703f2a --- /dev/null +++ b/apps/web/src/hooks/useTerminal.ts @@ -0,0 +1,294 @@ +/** + * useTerminal hook + * + * Manages a WebSocket connection to the /terminal namespace and a PTY terminal session. + * Follows the same patterns as useVoiceInput and useWebSocket. + * + * Protocol (from terminal.gateway.ts): + * 1. Connect with auth token in handshake + * 2. Emit terminal:create → receive terminal:created { sessionId, name, cols, rows } + * 3. Emit terminal:input { sessionId, data } to send keystrokes + * 4. Receive terminal:output { sessionId, data } for stdout/stderr + * 5. Emit terminal:resize { sessionId, cols, rows } on window resize + * 6. Emit terminal:close { sessionId } to terminate the PTY + * 7. Receive terminal:exit { sessionId, exitCode, signal } on PTY exit + * 8. Receive terminal:error { message } on errors + */ + +import { useEffect, useRef, useState, useCallback } from "react"; +import type { Socket } from "socket.io-client"; +import { io } from "socket.io-client"; +import { API_BASE_URL } from "@/lib/config"; + +// ========================================== +// Types +// ========================================== + +export interface CreateSessionOptions { + name?: string; + cols?: number; + rows?: number; + cwd?: string; +} + +export interface TerminalSession { + sessionId: string; + name: string; + cols: number; + rows: number; +} + +export interface TerminalExitEvent { + sessionId: string; + exitCode: number; + signal?: string; +} + +export interface UseTerminalOptions { + /** Authentication token for WebSocket handshake */ + token: string; + /** Callback fired when terminal output is received */ + onOutput?: (sessionId: string, data: string) => void; + /** Callback fired when a terminal session exits */ + onExit?: (event: TerminalExitEvent) => void; + /** Callback fired on terminal errors */ + onError?: (message: string) => void; +} + +export interface UseTerminalReturn { + /** Whether the WebSocket is connected */ + isConnected: boolean; + /** The current terminal session ID, or null if no session is active */ + sessionId: string | null; + /** Create a new PTY session */ + createSession: (options?: CreateSessionOptions) => void; + /** Send input data to the terminal */ + sendInput: (data: string) => void; + /** Resize the terminal PTY */ + resize: (cols: number, rows: number) => void; + /** Close the current PTY session */ + closeSession: () => void; + /** Connection error message, if any */ + connectionError: string | null; +} + +// ========================================== +// Payload shapes matching terminal.dto.ts +// ========================================== + +interface TerminalCreatedPayload { + sessionId: string; + name: string; + cols: number; + rows: number; +} + +interface TerminalOutputPayload { + sessionId: string; + data: string; +} + +interface TerminalExitPayload { + sessionId: string; + exitCode: number; + signal?: string; +} + +interface TerminalErrorPayload { + message: string; +} + +// ========================================== +// Security validation +// ========================================== + +function validateWebSocketSecurity(url: string): void { + const isProduction = process.env.NODE_ENV === "production"; + const isSecure = url.startsWith("https://") || url.startsWith("wss://"); + + if (isProduction && !isSecure) { + console.warn( + "[Security Warning] Terminal WebSocket using insecure protocol (ws://). " + + "Authentication tokens may be exposed. Use wss:// in production." + ); + } +} + +// ========================================== +// Hook +// ========================================== + +/** + * Hook for managing a real PTY terminal session over WebSocket. + * + * @param options - Configuration including auth token and event callbacks + * @returns Terminal state and control functions + */ +export function useTerminal(options: UseTerminalOptions): UseTerminalReturn { + const { token, onOutput, onExit, onError } = options; + + const [isConnected, setIsConnected] = useState(false); + const [sessionId, setSessionId] = useState(null); + const [connectionError, setConnectionError] = useState(null); + + const socketRef = useRef(null); + const sessionIdRef = useRef(null); + + // Keep callbacks in refs to avoid stale closures without causing reconnects + const onOutputRef = useRef(onOutput); + const onExitRef = useRef(onExit); + const onErrorRef = useRef(onError); + + useEffect(() => { + onOutputRef.current = onOutput; + }, [onOutput]); + + useEffect(() => { + onExitRef.current = onExit; + }, [onExit]); + + useEffect(() => { + onErrorRef.current = onError; + }, [onError]); + + // Connect to the /terminal namespace + useEffect(() => { + if (!token) { + return; + } + + const wsUrl = API_BASE_URL; + validateWebSocketSecurity(wsUrl); + + setConnectionError(null); + + const socket = io(`${wsUrl}/terminal`, { + auth: { token }, + transports: ["websocket", "polling"], + }); + + socketRef.current = socket; + + const handleConnect = (): void => { + setIsConnected(true); + setConnectionError(null); + }; + + const handleDisconnect = (): void => { + setIsConnected(false); + setSessionId(null); + sessionIdRef.current = null; + }; + + const handleConnectError = (error: Error): void => { + setConnectionError(error.message || "Terminal connection failed"); + setIsConnected(false); + }; + + const handleTerminalCreated = (payload: TerminalCreatedPayload): void => { + setSessionId(payload.sessionId); + sessionIdRef.current = payload.sessionId; + }; + + const handleTerminalOutput = (payload: TerminalOutputPayload): void => { + onOutputRef.current?.(payload.sessionId, payload.data); + }; + + const handleTerminalExit = (payload: TerminalExitPayload): void => { + onExitRef.current?.(payload); + setSessionId(null); + sessionIdRef.current = null; + }; + + const handleTerminalError = (payload: TerminalErrorPayload): void => { + onErrorRef.current?.(payload.message); + }; + + socket.on("connect", handleConnect); + socket.on("disconnect", handleDisconnect); + socket.on("connect_error", handleConnectError); + socket.on("terminal:created", handleTerminalCreated); + socket.on("terminal:output", handleTerminalOutput); + socket.on("terminal:exit", handleTerminalExit); + socket.on("terminal:error", handleTerminalError); + + return (): void => { + socket.off("connect", handleConnect); + socket.off("disconnect", handleDisconnect); + socket.off("connect_error", handleConnectError); + socket.off("terminal:created", handleTerminalCreated); + socket.off("terminal:output", handleTerminalOutput); + socket.off("terminal:exit", handleTerminalExit); + socket.off("terminal:error", handleTerminalError); + + // Close active session before disconnecting + const currentSessionId = sessionIdRef.current; + if (currentSessionId) { + socket.emit("terminal:close", { sessionId: currentSessionId }); + } + + socket.disconnect(); + socketRef.current = null; + }; + }, [token]); + + const createSession = useCallback((createOptions: CreateSessionOptions = {}): void => { + const socket = socketRef.current; + if (!socket?.connected) { + return; + } + + const payload: Record = {}; + if (createOptions.name !== undefined) payload.name = createOptions.name; + if (createOptions.cols !== undefined) payload.cols = createOptions.cols; + if (createOptions.rows !== undefined) payload.rows = createOptions.rows; + if (createOptions.cwd !== undefined) payload.cwd = createOptions.cwd; + + socket.emit("terminal:create", payload); + }, []); + + const sendInput = useCallback((data: string): void => { + const socket = socketRef.current; + const currentSessionId = sessionIdRef.current; + + if (!socket?.connected || !currentSessionId) { + return; + } + + socket.emit("terminal:input", { sessionId: currentSessionId, data }); + }, []); + + const resize = useCallback((cols: number, rows: number): void => { + const socket = socketRef.current; + const currentSessionId = sessionIdRef.current; + + if (!socket?.connected || !currentSessionId) { + return; + } + + socket.emit("terminal:resize", { sessionId: currentSessionId, cols, rows }); + }, []); + + const closeSession = useCallback((): void => { + const socket = socketRef.current; + const currentSessionId = sessionIdRef.current; + + if (!socket?.connected || !currentSessionId) { + return; + } + + socket.emit("terminal:close", { sessionId: currentSessionId }); + setSessionId(null); + sessionIdRef.current = null; + }, []); + + return { + isConnected, + sessionId, + createSession, + sendInput, + resize, + closeSession, + connectionError, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55c8c6f..177b0a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -190,6 +190,9 @@ importers: matrix-bot-sdk: specifier: ^0.8.0 version: 0.8.0 + node-pty: + specifier: ^1.0.0 + version: 1.1.0 ollama: specifier: ^0.6.3 version: 0.6.3 @@ -432,6 +435,15 @@ importers: '@types/dompurify': specifier: ^3.2.0 version: 3.2.0 + '@xterm/addon-fit': + specifier: ^0.11.0 + version: 0.11.0 + '@xterm/addon-web-links': + specifier: ^0.12.0 + version: 0.12.0 + '@xterm/xterm': + specifier: ^6.0.0 + version: 6.0.0 '@xyflow/react': specifier: ^12.5.3 version: 12.10.0(@types/react@19.2.10)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -1584,7 +1596,6 @@ packages: '@mosaicstack/telemetry-client@0.1.1': resolution: {integrity: sha512-1udg6p4cs8rhQgQ2pKCfi7EpRlJieRRhA5CIqthRQ6HQZLgQ0wH+632jEulov3rlHSM1iplIQ+AAe5DWrvSkEA==, tarball: https://git.mosaicstack.dev/api/packages/mosaic/npm/%40mosaicstack%2Ftelemetry-client/-/0.1.1/telemetry-client-0.1.1.tgz} - engines: {node: '>=18'} '@mrleebo/prisma-ast@0.13.1': resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==} @@ -3514,6 +3525,15 @@ packages: '@webassemblyjs/wast-printer@1.14.1': resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + '@xterm/addon-fit@0.11.0': + resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} + + '@xterm/addon-web-links@0.12.0': + resolution: {integrity: sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==} + + '@xterm/xterm@6.0.0': + resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} + '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -5874,6 +5894,9 @@ packages: node-abort-controller@3.1.1: resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-downloader-helper@2.1.10: resolution: {integrity: sha512-8LdieUd4Bqw/CzfZLf30h+1xSAq3riWSDfWKsPJYz8EULoWxjS1vw6BGLYFZDxQgXjDR7UmC9UpQ0oV93U98Fg==} engines: {node: '>=14.18'} @@ -5898,6 +5921,9 @@ packages: resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} hasBin: true + node-pty@1.1.0: + resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==} + node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -7939,7 +7965,7 @@ snapshots: chalk: 5.6.2 commander: 12.1.0 dotenv: 17.2.4 - drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)) + drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)) open: 10.2.0 pg: 8.17.2 prettier: 3.8.1 @@ -11004,6 +11030,12 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@xtuc/long': 4.2.2 + '@xterm/addon-fit@0.11.0': {} + + '@xterm/addon-web-links@0.12.0': {} + + '@xterm/xterm@6.0.0': {} + '@xtuc/ieee754@1.2.0': {} '@xtuc/long@4.2.2': {} @@ -11259,7 +11291,7 @@ snapshots: optionalDependencies: '@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)) better-sqlite3: 12.6.2 - drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)) + drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)) next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) pg: 8.17.2 prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3) @@ -11284,7 +11316,7 @@ snapshots: optionalDependencies: '@prisma/client': 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3) better-sqlite3: 12.6.2 - drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)) + drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)) next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) pg: 8.17.2 prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3) @@ -12103,6 +12135,17 @@ snapshots: dotenv@17.2.4: {} + drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)): + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)) + '@types/pg': 8.16.0 + better-sqlite3: 12.6.2 + kysely: 0.28.10 + pg: 8.17.2 + postgres: 3.4.8 + prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3) + drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)): optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -12113,6 +12156,7 @@ snapshots: pg: 8.17.2 postgres: 3.4.8 prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3) + optional: true dunder-proto@1.0.1: dependencies: @@ -13453,6 +13497,8 @@ snapshots: node-abort-controller@3.1.1: {} + node-addon-api@7.1.1: {} + node-downloader-helper@2.1.10: {} node-emoji@1.11.0: @@ -13470,6 +13516,10 @@ snapshots: detect-libc: 2.1.2 optional: true + node-pty@1.1.0: + dependencies: + node-addon-api: 7.1.1 + node-releases@2.0.27: {} normalize-path@3.0.0: {}