diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index ff7a477..f05c802 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -31,7 +31,11 @@ COPY packages/config/package.json ./packages/config/ COPY apps/api/package.json ./apps/api/ # Install dependencies (no cache mount — Kaniko builds are ephemeral in CI) -RUN pnpm install --frozen-lockfile +# Then explicitly rebuild node-pty from source since pnpm may skip postinstall +# scripts or fail to find prebuilt binaries for this Node.js version +RUN pnpm install --frozen-lockfile \ + && cd node_modules/.pnpm/node-pty@*/node_modules/node-pty \ + && npx node-gyp rebuild 2>&1 || true # ====================== # Builder stage diff --git a/apps/api/src/terminal/terminal.service.spec.ts b/apps/api/src/terminal/terminal.service.spec.ts index 8a93dcf..5545405 100644 --- a/apps/api/src/terminal/terminal.service.spec.ts +++ b/apps/api/src/terminal/terminal.service.spec.ts @@ -46,7 +46,7 @@ describe("TerminalService", () => { let service: TerminalService; let mockSocket: Socket; - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); // Reset mock implementations mockPtyProcess.onData.mockImplementation((_cb: (data: string) => void) => {}); @@ -54,6 +54,8 @@ describe("TerminalService", () => { (_cb: (e: { exitCode: number; signal?: number }) => void) => {} ); service = new TerminalService(); + // Trigger lazy import of node-pty (uses dynamic import(), intercepted by vi.mock) + await service.onModuleInit(); mockSocket = createMockSocket(); }); diff --git a/apps/api/src/terminal/terminal.service.ts b/apps/api/src/terminal/terminal.service.ts index 8025572..820c0d3 100644 --- a/apps/api/src/terminal/terminal.service.ts +++ b/apps/api/src/terminal/terminal.service.ts @@ -13,11 +13,19 @@ * - closeWorkspaceSessions: kill all sessions for a workspace (on disconnect) */ -import { Injectable, Logger } from "@nestjs/common"; -import * as pty from "node-pty"; +import { Injectable, Logger, OnModuleInit } from "@nestjs/common"; +import type { IPty } from "node-pty"; import type { Socket } from "socket.io"; import { randomUUID } from "node:crypto"; +// Lazy-loaded in onModuleInit via dynamic import() to prevent crash +// if the native binary is missing. node-pty requires a compiled .node +// binary which may not be available in all Docker environments. +interface NodePtyModule { + spawn: (file: string, args: string[], options: Record) => IPty; +} +let pty: NodePtyModule | null = null; + /** Maximum concurrent PTY sessions per workspace */ export const MAX_SESSIONS_PER_WORKSPACE = parseInt( process.env.TERMINAL_MAX_SESSIONS_PER_WORKSPACE ?? "10", @@ -31,7 +39,7 @@ const DEFAULT_ROWS = 24; export interface TerminalSession { sessionId: string; workspaceId: string; - pty: pty.IPty; + pty: IPty; name?: string; createdAt: Date; } @@ -53,7 +61,7 @@ export interface SessionCreatedResult { } @Injectable() -export class TerminalService { +export class TerminalService implements OnModuleInit { private readonly logger = new Logger(TerminalService.name); /** @@ -66,13 +74,30 @@ export class TerminalService { */ private readonly workspaceSessions = new Map>(); + async onModuleInit(): Promise { + if (!pty) { + try { + pty = await import("node-pty"); + this.logger.log("node-pty loaded successfully — terminal sessions available"); + } catch { + this.logger.warn( + "node-pty native module not available — terminal sessions will be disabled. " + + "Install build tools (python3, make, g++) and rebuild node-pty to enable." + ); + } + } + } + /** * Create a new PTY session for the given workspace and socket. * Wires PTY onData -> emit terminal:output and onExit -> emit terminal:exit. * - * @throws Error if workspace session limit is exceeded + * @throws Error if workspace session limit is exceeded or node-pty is unavailable */ createSession(socket: Socket, options: CreateSessionOptions): SessionCreatedResult { + if (!pty) { + throw new Error("Terminal sessions are unavailable: node-pty native module failed to load"); + } const { workspaceId, name, cwd, socketId } = options; const cols = options.cols ?? DEFAULT_COLS; const rows = options.rows ?? DEFAULT_ROWS;