feat(web): integrate xterm.js with WebSocket terminal backend (#518)
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #518.
This commit is contained in:
2026-02-26 02:55:53 +00:00
committed by jason.woltje
parent 8128eb7fbe
commit 417c6ab49c
9 changed files with 1694 additions and 100 deletions

View File

@@ -33,6 +33,9 @@
"@tiptap/react": "^3.20.0", "@tiptap/react": "^3.20.0",
"@tiptap/starter-kit": "^3.20.0", "@tiptap/starter-kit": "^3.20.0",
"@types/dompurify": "^3.2.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", "@xyflow/react": "^12.5.3",
"better-auth": "^1.4.17", "better-auth": "^1.4.17",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",

View File

@@ -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 }) => (
<div
data-testid="mock-xterminal"
data-token={token}
data-visible={isVisible ? "true" : "false"}
/>
)),
}));
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((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByRole("region", { name: "Terminal panel" })).toBeInTheDocument();
});
it("renders with height 280 when open", () => {
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) 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(
(<TerminalPanel open={false} onClose={onClose} token="test-token" />) 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((<TerminalPanel open={true} onClose={onClose} token="test-token" />) 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(
(<TerminalPanel open={false} onClose={onClose} token="test-token" />) 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(
(<TerminalPanel open={true} onClose={onClose} token="my-auth-token" />) 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((<TerminalPanel open={true} onClose={onClose} token="test-token" />) 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(
(
<TerminalPanel open={true} onClose={onClose} tabs={tabs} token="test-token" />
) 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(
(
<TerminalPanel
open={true}
onClose={onClose}
tabs={tabs}
activeTab="tab2"
token="test-token"
/>
) 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(
(
<TerminalPanel
open={true}
onClose={onClose}
tabs={tabs}
onTabChange={onTabChange}
token="test-token"
/>
) 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((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByRole("button", { name: "Close terminal" })).toBeInTheDocument();
});
it("calls onClose when close button is clicked", () => {
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) 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(
(<TerminalPanel open={false} onClose={onClose} token="test-token" />) 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((<TerminalPanel open={true} onClose={onClose} token="test-token" />) 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((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
expect(screen.getByRole("tablist")).toBeInTheDocument();
});
});
});

View File

@@ -1,9 +1,18 @@
import type { ReactElement, CSSProperties } from "react"; "use client";
export interface TerminalLine { /**
type: "prompt" | "command" | "output" | "error" | "warning" | "success"; * TerminalPanel
content: string; *
} * 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 { export interface TerminalTab {
id: string; id: string;
@@ -16,51 +25,16 @@ export interface TerminalPanelProps {
tabs?: TerminalTab[]; tabs?: TerminalTab[];
activeTab?: string; activeTab?: string;
onTabChange?: (id: string) => void; onTabChange?: (id: string) => void;
lines?: TerminalLine[]; /** Authentication token for the WebSocket connection */
token?: string;
className?: string; className?: string;
} }
const defaultTabs: TerminalTab[] = [ const defaultTabs: TerminalTab[] = [{ id: "main", label: "main" }];
{ id: "main", label: "main" },
{ id: "build", label: "build" },
{ id: "logs", label: "logs" },
];
const blinkKeyframes = ` // ==========================================
@keyframes ms-terminal-blink { // Component
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)";
}
}
export function TerminalPanel({ export function TerminalPanel({
open, open,
@@ -68,11 +42,9 @@ export function TerminalPanel({
tabs, tabs,
activeTab, activeTab,
onTabChange, onTabChange,
lines = [], token = "",
className = "", className = "",
}: TerminalPanelProps): ReactElement { }: TerminalPanelProps): ReactElement {
ensureBlinkStyle();
const resolvedTabs = tabs ?? defaultTabs; const resolvedTabs = tabs ?? defaultTabs;
const resolvedActiveTab = activeTab ?? resolvedTabs[0]?.id ?? ""; const resolvedActiveTab = activeTab ?? resolvedTabs[0]?.id ?? "";
@@ -109,21 +81,10 @@ export function TerminalPanel({
const bodyStyle: CSSProperties = { const bodyStyle: CSSProperties = {
flex: 1, flex: 1,
overflowY: "auto", overflow: "hidden",
padding: "10px 16px", display: "flex",
fontFamily: "var(--mono)", flexDirection: "column",
fontSize: "0.78rem", minHeight: 0,
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",
}; };
return ( return (
@@ -208,7 +169,7 @@ export function TerminalPanel({
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)"; (e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
}} }}
> >
{/* Close icon — simple X using SVG */} {/* Close icon */}
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true"> <svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path <path
d="M1 1L11 11M11 1L1 11" d="M1 1L11 11M11 1L1 11"
@@ -221,36 +182,9 @@ export function TerminalPanel({
</div> </div>
</div> </div>
{/* Body */} {/* Terminal body */}
<div style={bodyStyle} role="log" aria-live="polite" aria-label="Terminal output"> <div style={bodyStyle}>
{lines.map((line, index) => { <XTerminal token={token} isVisible={open} style={{ flex: 1, minHeight: 0 }} />
const isLast = index === lines.length - 1;
const lineStyle: CSSProperties = {
display: "flex",
gap: 8,
};
const contentStyle: CSSProperties = {
color: getLineColor(line.type),
};
return (
<div key={index} style={lineStyle}>
<span style={contentStyle}>
{line.content}
{isLast && <span aria-hidden="true" style={cursorStyle} />}
</span>
</div>
);
})}
{/* Show cursor even when no lines */}
{lines.length === 0 && (
<div style={{ display: "flex", gap: 8 }}>
<span style={{ color: "var(--success)" }}>
<span aria-hidden="true" style={cursorStyle} />
</span>
</div>
)}
</div> </div>
</div> </div>
); );

View File

@@ -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<string, unknown>,
_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<string, unknown>) {
this.fit = mockFitAddonFit;
});
const MockWebLinksAddon = vi.fn(function MockWebLinksAddonConstructor(
this: Record<string, unknown>
) {
// 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<string, unknown>, _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<string, unknown>, _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((<XTerminal token="test-token" />) as ReactElement);
expect(screen.getByTestId("xterminal-container")).toBeInTheDocument();
});
it("renders the xterm viewport div", () => {
render((<XTerminal token="test-token" />) as ReactElement);
expect(screen.getByTestId("xterm-viewport")).toBeInTheDocument();
});
it("applies the className prop to the container", () => {
render((<XTerminal token="test-token" className="custom-class" />) as ReactElement);
expect(screen.getByTestId("xterminal-container")).toHaveClass("custom-class");
});
it("shows connecting message when not connected", () => {
mockIsConnected = false;
render((<XTerminal token="test-token" />) 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((<XTerminal token="test-token" />) 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((<XTerminal token="my-auth-token" />) 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((<XTerminal token="test-token" />) 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((<XTerminal token="test-token" />) as ReactElement);
expect(screen.getByRole("region", { name: "Terminal" })).toBeInTheDocument();
});
});
});

View File

@@ -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<string, string> {
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<HTMLDivElement>(null);
const terminalRef = useRef<XTerm | null>(null);
const fitAddonRef = useRef<XFitAddon | null>(null);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const isTerminalMountedRef = useRef(false);
const [hasExited, setHasExited] = useState(false);
const [exitCode, setExitCode] = useState<number | null>(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<void> => {
// 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 (
<div
className={className}
style={containerStyle}
role="region"
aria-label="Terminal"
data-testid="xterminal-container"
>
{/* Status bar */}
{!isConnected && !hasExited && (
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
padding: "4px 12px",
fontSize: "0.75rem",
fontFamily: "var(--mono)",
color: "var(--warn)",
backgroundColor: "var(--bg-deep)",
zIndex: 10,
borderBottom: "1px solid var(--border)",
}}
>
Connecting to terminal...
</div>
)}
{/* Exit overlay */}
{hasExited && (
<div
style={{
position: "absolute",
bottom: 8,
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
zIndex: 10,
}}
>
<button
style={{
padding: "4px 12px",
borderRadius: "4px",
fontSize: "0.75rem",
fontFamily: "var(--mono)",
color: "var(--text)",
backgroundColor: "var(--surface)",
border: "1px solid var(--border)",
cursor: "pointer",
}}
onClick={(): void => {
setHasExited(false);
setExitCode(null);
if (isConnected) {
const terminal = terminalRef.current;
const fitAddon = fitAddonRef.current;
let cols = 80;
let rows = 24;
if (terminal && fitAddon) {
try {
cols = terminal.cols;
rows = terminal.rows;
} catch {
// Use defaults
}
}
terminal?.clear();
createSession({ cols, rows });
}
}}
>
Restart terminal {exitCode !== null ? `(exit ${exitCode.toString()})` : ""}
</button>
</div>
)}
{/* xterm.js render target */}
<div
ref={containerRef}
style={{
width: "100%",
height: "100%",
padding: "4px 8px",
boxSizing: "border-box",
}}
data-testid="xterm-viewport"
/>
</div>
);
}

View File

@@ -1,2 +1,4 @@
export type { TerminalLine, TerminalTab, TerminalPanelProps } from "./TerminalPanel"; export type { TerminalTab, TerminalPanelProps } from "./TerminalPanel";
export { TerminalPanel } from "./TerminalPanel"; export { TerminalPanel } from "./TerminalPanel";
export type { XTerminalProps } from "./XTerminal";
export { XTerminal } from "./XTerminal";

View File

@@ -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<typeof vi.fn>;
off: ReturnType<typeof vi.fn>;
emit: ReturnType<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn>;
connected: boolean;
}
describe("useTerminal", () => {
let mockSocket: MockSocket;
let socketEventHandlers: Record<string, (data: unknown) => void>;
let mockIo: ReturnType<typeof vi.fn>;
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",
});
});
});
});

View File

@@ -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<string | null>(null);
const [connectionError, setConnectionError] = useState<string | null>(null);
const socketRef = useRef<Socket | null>(null);
const sessionIdRef = useRef<string | null>(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<string, unknown> = {};
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,
};
}

58
pnpm-lock.yaml generated
View File

@@ -190,6 +190,9 @@ importers:
matrix-bot-sdk: matrix-bot-sdk:
specifier: ^0.8.0 specifier: ^0.8.0
version: 0.8.0 version: 0.8.0
node-pty:
specifier: ^1.0.0
version: 1.1.0
ollama: ollama:
specifier: ^0.6.3 specifier: ^0.6.3
version: 0.6.3 version: 0.6.3
@@ -432,6 +435,15 @@ importers:
'@types/dompurify': '@types/dompurify':
specifier: ^3.2.0 specifier: ^3.2.0
version: 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': '@xyflow/react':
specifier: ^12.5.3 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) 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': '@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} 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': '@mrleebo/prisma-ast@0.13.1':
resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==} resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==}
@@ -3514,6 +3525,15 @@ packages:
'@webassemblyjs/wast-printer@1.14.1': '@webassemblyjs/wast-printer@1.14.1':
resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} 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': '@xtuc/ieee754@1.2.0':
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
@@ -5874,6 +5894,9 @@ packages:
node-abort-controller@3.1.1: node-abort-controller@3.1.1:
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-downloader-helper@2.1.10: node-downloader-helper@2.1.10:
resolution: {integrity: sha512-8LdieUd4Bqw/CzfZLf30h+1xSAq3riWSDfWKsPJYz8EULoWxjS1vw6BGLYFZDxQgXjDR7UmC9UpQ0oV93U98Fg==} resolution: {integrity: sha512-8LdieUd4Bqw/CzfZLf30h+1xSAq3riWSDfWKsPJYz8EULoWxjS1vw6BGLYFZDxQgXjDR7UmC9UpQ0oV93U98Fg==}
engines: {node: '>=14.18'} engines: {node: '>=14.18'}
@@ -5898,6 +5921,9 @@ packages:
resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
hasBin: true hasBin: true
node-pty@1.1.0:
resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==}
node-releases@2.0.27: node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
@@ -7939,7 +7965,7 @@ snapshots:
chalk: 5.6.2 chalk: 5.6.2
commander: 12.1.0 commander: 12.1.0
dotenv: 17.2.4 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 open: 10.2.0
pg: 8.17.2 pg: 8.17.2
prettier: 3.8.1 prettier: 3.8.1
@@ -11004,6 +11030,12 @@ snapshots:
'@webassemblyjs/ast': 1.14.1 '@webassemblyjs/ast': 1.14.1
'@xtuc/long': 4.2.2 '@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/ieee754@1.2.0': {}
'@xtuc/long@4.2.2': {} '@xtuc/long@4.2.2': {}
@@ -11259,7 +11291,7 @@ snapshots:
optionalDependencies: optionalDependencies:
'@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)) '@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
better-sqlite3: 12.6.2 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) 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 pg: 8.17.2
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3) prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
@@ -11284,7 +11316,7 @@ snapshots:
optionalDependencies: optionalDependencies:
'@prisma/client': 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3) '@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 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) 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 pg: 8.17.2
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3) prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
@@ -12103,6 +12135,17 @@ snapshots:
dotenv@17.2.4: {} 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)): 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: optionalDependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
@@ -12113,6 +12156,7 @@ snapshots:
pg: 8.17.2 pg: 8.17.2
postgres: 3.4.8 postgres: 3.4.8
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3) prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
optional: true
dunder-proto@1.0.1: dunder-proto@1.0.1:
dependencies: dependencies:
@@ -13453,6 +13497,8 @@ snapshots:
node-abort-controller@3.1.1: {} node-abort-controller@3.1.1: {}
node-addon-api@7.1.1: {}
node-downloader-helper@2.1.10: {} node-downloader-helper@2.1.10: {}
node-emoji@1.11.0: node-emoji@1.11.0:
@@ -13470,6 +13516,10 @@ snapshots:
detect-libc: 2.1.2 detect-libc: 2.1.2
optional: true optional: true
node-pty@1.1.0:
dependencies:
node-addon-api: 7.1.1
node-releases@2.0.27: {} node-releases@2.0.27: {}
normalize-path@3.0.0: {} normalize-path@3.0.0: {}