UNPKG

@jupyterlite/terminal

Version:
373 lines (342 loc) 11.9 kB
// Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. import type { JupyterFrontEnd, JupyterFrontEndPlugin } from '@jupyterlab/application'; import type { IShell } from '@jupyterlite/cockle'; import type { CommandRegistry } from '@lumino/commands'; import { ILiteTerminalAPIClient } from './tokens'; /** * Default timeout (in milliseconds) for execute-shell commands. */ const DEFAULT_TIMEOUT_MS = 30000; type ShellExecutionStatus = 'ok' | 'error' | 'timeout'; interface IExecuteShellResult { success: boolean; status: ShellExecutionStatus; output: string; exitCode: number | null; shellName: string; duration: number; message: string; } interface IShellListItem { name: string; } const COMMAND_IDS = { executeShell: '@jupyterlite/terminal:execute-shell', startShell: '@jupyterlite/terminal:start-shell', shutdownShell: '@jupyterlite/terminal:shutdown-shell', listShells: '@jupyterlite/terminal:list-shells' } as const; interface IHeadlessSession { shell: IShell; output: string; busy: boolean; timedOut: boolean; } /** * A pool of headless cockle shells used by the exec commands. These shells * are independent of the JupyterLab terminal service, so they do not appear * in the terminal widget list or in `LiteTerminalAPIClient.listRunning()`. */ class HeadlessShellPool { constructor(client: ILiteTerminalAPIClient) { this._client = client; } async create(options: { cwd?: string }): Promise<IHeadlessSession> { const name = this._nextAvailableName(); let output = ''; // Empty PS1 so the shell prints no prompt, keeping the captured output clean. const shell = await this._client.createHeadlessShell({ shellId: name, cwd: options.cwd, environment: { PS1: '' }, outputCallback: text => { output += text; } }); const session: IHeadlessSession = { shell, get output() { return output; }, busy: false, timedOut: false }; this._sessions.set(name, session); shell.disposed.connect(() => { // Keep the pool in sync if the shell disposes itself (e.g. on init failure). this._sessions.delete(name); }); return session; } get(name: string): IHeadlessSession | undefined { return this._sessions.get(name); } names(): string[] { return Array.from(this._sessions.keys()); } async shutdown(name: string): Promise<void> { const session = this._sessions.get(name); if (!session) { throw new Error(`No headless shell found with name '${name}'`); } this._sessions.delete(name); session.shell.dispose(); } private _nextAvailableName(): string { return `headless-${this._nextId++}`; } private _client: ILiteTerminalAPIClient; private _sessions = new Map<string, IHeadlessSession>(); private _nextId = 1; } /** * Normalize the raw shell output for callers that expect pipe-like text. The * captured buffer echoes back the submitted command and uses `\r\n` line * endings, so strip the echoed command line and convert `\r\n` to `\n`. */ function cleanCapturedOutput(captured: string, code: string): string { const normalized = captured.replace(/\r\n/g, '\n'); const expected = code + '\n'; if (normalized.startsWith(expected)) { return normalized.slice(expected.length); } return normalized; } async function runOnSession( session: IHeadlessSession, code: string, timeout: number ): Promise<IExecuteShellResult> { const shellId = session.shell.shellId; // Fold any CR/CRLF inside the command to `\n` so it runs as a single command // rather than one line at a time when submitted with a trailing `\r`. const command = code.trim().replace(/\r\n?/g, '\n'); if (command.length === 0) { return { success: false, status: 'error', output: '', exitCode: null, shellName: shellId, duration: 0, message: 'No command to run' }; } // A timed-out command keeps running, so the session is left in an unknown // state; refuse to reuse it. Also refuse overlapping commands, whose input // and captured output would otherwise interleave. if (session.timedOut) { throw new Error(`Headless shell '${shellId}' is no longer usable after a command timed out`); } if (session.busy) { throw new Error(`Headless shell '${shellId}' is already running a command`); } session.busy = true; const startTime = Date.now(); const startLen = session.output.length; // `input()` resolves only after the command has finished, so it doubles as // the "command finished" signal. A running command cannot be interrupted, so // race it against a timer to avoid hanging on one that blocks (e.g. on stdin). const inputDone = session.shell.input(command + '\r').then( () => false, () => false // a late rejection (e.g. disposed shell) counts as finished ); let timer: ReturnType<typeof setTimeout> | undefined; const timedOut = await Promise.race([ inputDone, new Promise<boolean>(resolve => { timer = setTimeout(() => resolve(true), timeout); }) ]); if (timer !== undefined) { clearTimeout(timer); } session.busy = false; if (timedOut) { session.timedOut = true; } const output = cleanCapturedOutput(session.output.slice(startLen), command); const duration = Date.now() - startTime; if (timedOut) { return { success: false, status: 'timeout', output, exitCode: null, shellName: shellId, duration, message: `Command timed out after ${timeout}ms` }; } const exitCode = await session.shell.exitCode(); return { success: exitCode === 0, status: exitCode === 0 ? 'ok' : 'error', output, exitCode, shellName: shellId, duration, message: exitCode === 0 ? 'Command executed successfully' : `Command failed with exit code ${exitCode}` }; } function registerCommands(commands: CommandRegistry, pool: HeadlessShellPool): void { commands.addCommand(COMMAND_IDS.executeShell, { label: 'Execute Shell', caption: 'Execute a command in a headless cockle shell and capture the output', describedBy: { args: { type: 'object', properties: { code: { type: 'string', description: 'The shell command to execute. Runs as a single pipeline: pipes (|), sequential separators (;), and file redirections (>, >>, 2>, <) are supported. Not supported (cockle limitations): chaining with && or ||, command substitution ($(...) and backticks), environment variable expansion ($VAR), and file-descriptor duplication (2>&1). Run `cockle-config command` to list the commands available in the shell.' }, shellName: { type: 'string', description: 'Name of an existing headless shell to reuse. If not provided, a new shell is created and shut down after execution.' }, cwd: { type: 'string', description: 'Working directory for a newly created headless shell. Ignored when reusing an existing session via shellName.' }, timeout: { type: 'number', description: `Maximum time in milliseconds to wait for the command before returning a timeout result (default: ${DEFAULT_TIMEOUT_MS}). Cockle cannot interrupt a running command, so it keeps running after a timeout.` } }, required: ['code'] } }, execute: async (args: any): Promise<IExecuteShellResult> => { const code = args?.code; const shellName = args?.shellName; const cwd = args?.cwd; const timeout = args?.timeout ?? DEFAULT_TIMEOUT_MS; if (typeof code !== 'string' || code.length === 0) { throw new Error('code is required and must be a non-empty string'); } if (shellName !== undefined && typeof shellName !== 'string') { throw new Error('shellName must be a string'); } if (cwd !== undefined && typeof cwd !== 'string') { throw new Error('cwd must be a string'); } if (typeof timeout !== 'number' || !Number.isFinite(timeout) || timeout <= 0) { throw new Error('timeout must be a positive number'); } let session: IHeadlessSession; let disposeAfter = false; if (shellName) { const existing = pool.get(shellName); if (!existing) { throw new Error(`No headless shell found with name '${shellName}'`); } session = existing; } else { session = await pool.create({ cwd }); disposeAfter = true; } try { return await runOnSession(session, code, timeout); } finally { if (disposeAfter) { try { await pool.shutdown(session.shell.shellId); } catch { // Best-effort: do not mask the real result with a teardown error. } } } } }); commands.addCommand(COMMAND_IDS.startShell, { label: 'Start Headless Shell', caption: 'Start a new headless cockle shell that can be reused via execute-shell', describedBy: { args: { type: 'object', properties: { cwd: { type: 'string', description: 'Working directory for the new headless shell (optional).' } } } }, execute: async (args: any) => { const cwd = args?.cwd; if (cwd !== undefined && typeof cwd !== 'string') { throw new Error('cwd must be a string'); } const session = await pool.create({ cwd }); return { success: true, message: `Headless shell '${session.shell.shellId}' started successfully`, shellName: session.shell.shellId }; } }); commands.addCommand(COMMAND_IDS.shutdownShell, { label: 'Shutdown Headless Shell', caption: 'Shut down a running headless cockle shell by name', describedBy: { args: { type: 'object', properties: { shellName: { type: 'string', description: 'The name of the headless shell to shut down.' } }, required: ['shellName'] } }, execute: async (args: any) => { const shellName = args?.shellName; if (typeof shellName !== 'string' || shellName.length === 0) { throw new Error('shellName is required and must be a non-empty string'); } await pool.shutdown(shellName); return { success: true, message: `Headless shell '${shellName}' shut down successfully`, shellName }; } }); commands.addCommand(COMMAND_IDS.listShells, { label: 'List Headless Shells', caption: 'List running headless cockle shells (does not include user-opened terminals)', describedBy: { args: { type: 'object', properties: {} } }, execute: async () => { const names = pool.names(); const shells: IShellListItem[] = names.map(name => ({ name })); return { success: true, shells, count: shells.length, available: true }; } }); } /** * Plugin that registers headless shell exec commands backed by cockle. * These run commands in a headless shell and capture their output and exit * code, without opening a terminal widget in the UI. */ export const terminalExecPlugin: JupyterFrontEndPlugin<void> = { id: '@jupyterlite/terminal:exec', description: 'Headless shell exec commands backed by cockle', autoStart: true, requires: [ILiteTerminalAPIClient], activate: (app: JupyterFrontEnd, liteTerminalAPIClient: ILiteTerminalAPIClient): void => { const pool = new HeadlessShellPool(liteTerminalAPIClient); registerCommands(app.commands, pool); } };