@stencila/basha
Version:
Bash interpreter for executable documents
291 lines (290 loc) • 10.6 kB
JavaScript
"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));