UNPKG

@stencila/basha

Version:

Bash interpreter for executable documents

291 lines (290 loc) 10.6 kB
#!/usr/bin/env node "use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Basha = void 0; const executa_1 = require("@stencila/executa"); const async_lock_1 = __importDefault(require("async-lock")); const pty = __importStar(require("node-pty")); const perf_hooks_1 = require("perf_hooks"); const log = executa_1.logga.getLogger('basha'); class Basha extends executa_1.Listener { constructor(servers = [ new executa_1.StdioServer({ command: 'node', args: [__filename, 'start'] }), ]) { super('ba', servers); /** * Programming language names supported by this * interpreter. */ this.programmingLanguages = ['bash', 'sh']; /** * The prompt used to identify when the pseudo- * terminal has finished executing and is ready * for more input. */ this.prompt = '🔨 BASHA > '; /** * Is Bash ready for more input? */ this.isReady = false; /** * A lock to prevent async event loops from attempting to enter * code into the terminal at the same time. */ this.lock = new async_lock_1.default(); /** * Flag to mute log errors when this interpreter * is explicitly `stop()`ed */ this.isStopping = false; } /** * @override Override of `Executor.capabilities` to * define this interpreter's capabilities. */ capabilities() { const params = { required: ['node'], properties: { node: { required: ['type', 'programmingLanguage', 'text'], properties: { type: { enum: ['CodeChunk', 'CodeExpression'], }, programmingLanguage: { enum: this.programmingLanguages, }, text: { type: 'string', }, }, }, }, }; return Promise.resolve({ manifest: true, compile: params, execute: params, cancel: true, }); } /** * @override Override of `Executor.execute` that executes Bash code. * * Calculates the duration of the execution to the nearest microsecond. */ async execute(node, session, claims, job) { if (executa_1.schema.isA('CodeChunk', node) || executa_1.schema.isA('CodeExpression', node)) { const { programmingLanguage = '', text } = node; if (typeof text === 'string' && this.programmingLanguages.includes(programmingLanguage)) { this.job = job; let output; let errors; let duration; try { const before = perf_hooks_1.performance.now(); output = await this.executeCode(text); duration = Math.round((perf_hooks_1.performance.now() - before) * 1e3) / 1e6; } catch (error) { const message = error instanceof Error ? error.message : error; errors = [ executa_1.schema.codeError({ errorType: 'RuntimeError', errorMessage: message, }), ]; } this.job = undefined; let executed; if (executa_1.schema.isA('CodeChunk', node)) { const outputs = output !== undefined ? [output] : undefined; executed = { ...node, outputs, errors, duration }; } else { executed = { ...node, output, errors }; } return executed; } } throw new executa_1.CapabilityError(undefined, executa_1.Method.execute, { node }); } /** * @override Override of `Executor.cancel` that cancels the * current job only. */ cancel(job) { if (this.terminal !== undefined && job !== undefined && job === this.job && !this.isReady) { // Send the equivalent of Ctrl+C keypress to the terminal // It this is pressed while there is no command running then Bash // itself will exit. log.debug('Interrupting the current process'); if (this.terminal !== undefined) this.terminal.write('\x03'); return Promise.resolve(true); } return Promise.resolve(false); } /** * Start a Bash shell process. * * Creates a pseudo-terminal and registers * event handles on it to capture output and * handle unexpected exit. */ startBash() { log.debug(`Starting bash`); const terminal = (this.terminal = pty.spawn('bash', // Use `--norc` to prevent loading of resource file which // may overwrite `PS1` and cause this to fail ['--norc'], { env: { // Use custom prompt to be able to detect readiness PS1: this.prompt, }, })); terminal.onData((data) => { if (this.output === undefined) this.output = data; else this.output += data; if (this.output.endsWith(this.prompt)) { this.isReady = true; if (this.whenReady !== undefined) this.whenReady(); } }); terminal.onExit(() => { if (!this.isStopping) log.warn(`Bash exited prematurely`); if (this.whenReady !== undefined) this.whenReady(); this.terminal = undefined; }); return terminal; } /** * Enter Bash code into the terminal and set up handler to * process output. * * @param code Code to enter. * @returns A promise resolving to the output. */ async enterCode(code) { return this.lock.acquire('terminal', () => { const terminal = this.terminal === undefined ? this.startBash() : this.terminal; const input = code + '\r'; const enter = (resolve) => { this.whenReady = () => { let output = this.output; // No terminal output so do not resolve a value if (output === undefined) return resolve(); // Remove the echoed input from start (including return) if (output.startsWith(input)) output = output.slice(input.length + 1); // ...and the trailing prompt if (output.endsWith(this.prompt)) output = output.slice(0, -this.prompt.length); // If no output between input and next prompt // do not resolve a value if (output.length === 0) return resolve(); // Remove any carriage returns output = output.replace(/\r/g, ''); // Remove the newline from end if (output.endsWith('\n')) output = output.slice(0, -1); resolve(output); }; terminal.write(input); this.output = undefined; this.isReady = false; }; return this.isReady ? new Promise((resolve) => enter(resolve)) : new Promise((resolve) => (this.whenReady = () => enter(resolve))); }); } /** * Execute Bash code. * * This method enters the code, parses the output and * checks the exit code. If the exit code is non-zero * it throws an error with the output. * * @param code Code to execute * @returns A promise resolving to the output from the command. */ async executeCode(code) { const output = await this.enterCode(code); const result = output !== undefined ? this.parseOutput(output) : undefined; const exitCode = await this.enterCode('echo $?'); if (exitCode === '0') return result; else throw new Error(`${result}`); } /** * Parse output from a command. * * Attempts to parse the output as JSON. * In the future, more advanced parsing * such as parsing of fixed-width tables * may be done. * * @param output Output string to parse */ parseOutput(output) { try { return JSON.parse(output); } catch (_a) { return output; } } /** * @override Override of `Listener.stop` to * stop the pseudo-terminal as well as servers. */ async stop() { await super.stop(); log.debug(`Stopping bash`); if (this.terminal !== undefined) { this.isStopping = true; this.terminal.kill(); this.terminal = undefined; } } } exports.Basha = Basha; // istanbul ignore next if (require.main === module) executa_1.cli.main(new Basha()).catch((error) => log.error(error));