@jupyterlite/terminal
Version:
A terminal for JupyterLite
373 lines (342 loc) • 11.9 kB
text/typescript
// 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);
}
};